@jonathan0823/pi-sonarqube 0.1.3 → 0.2.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.
package/src/index.ts CHANGED
@@ -1,801 +1,84 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { stat } from "node:fs/promises";
4
+ import { basename } from "node:path";
1
5
  import { StringEnum } from "@earendil-works/pi-ai";
2
- import type {
3
- ExtensionAPI,
4
- ExtensionCommandContext,
5
- ExtensionContext,
6
- Theme,
7
- } from "@earendil-works/pi-coding-agent";
8
- import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
9
- import { Key, matchesKey, Text, truncateToWidth } from "@earendil-works/pi-tui";
10
- import type { AutocompleteItem, AutocompleteSuggestions } from "@earendil-works/pi-tui";
11
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
12
- import { basename, dirname, relative, resolve } from "node:path";
13
6
  import { Type } from "typebox";
14
-
15
- // ── Public types ──────────────────────────────────────────────────────────────
16
-
17
- export type SonarAction = "analyze" | "issues" | "open" | "init";
18
-
19
- export interface SonarIssue {
20
- key: string;
21
- rule: string;
22
- ruleName?: string;
23
- severity: string;
24
- message: string;
25
- filePath: string;
26
- line: number | null;
27
- status?: string;
28
- }
29
-
30
- export interface SonarAnalysisState {
31
- version: 1;
32
- analyzedAt: string;
33
- baseDir: string;
34
- serverUrl: string;
35
- projectKey: string;
36
- dashboardUrl?: string;
37
- ceTaskUrl?: string;
38
- analysisId?: string;
39
- filters?: SonarIssueFetchOptions;
40
- totalIssues: number;
41
- issues: SonarIssue[];
42
- }
43
-
44
- export interface SonarProjectConfig {
45
- baseDir: string;
46
- serverUrl: string;
47
- projectKey: string;
48
- token?: string;
49
- hasProperties: boolean;
50
- }
51
-
52
- export interface SonarIssueFetchOptions {
53
- severities?: string[];
54
- statuses?: string[];
55
- types?: string[];
56
- rules?: string[];
57
- }
58
-
59
- export interface SonarInitConfig {
60
- serverUrl: string;
61
- projectKey: string;
62
- token?: string;
63
- }
64
-
65
- // ── Internal types / state keys ───────────────────────────────────────────────
66
-
67
- const STATE_TYPE = "sonarqube-analysis-state";
7
+ import type {
8
+ SonarIssue,
9
+ SonarAnalysisState,
10
+ SonarIssueFetchOptions,
11
+ SonarInitConfig,
12
+ InitCommandOptions,
13
+ FileDuplication,
14
+ SonarProjectConfig,
15
+ } from "./types.js";
16
+ import {
17
+ slugify,
18
+ normalizeServerUrl,
19
+ sonarProjectPropertiesPath,
20
+ projectConfigPath,
21
+ resolveInitTarget,
22
+ loadProjectConfig,
23
+ saveProjectConfig,
24
+ ensureDefaultSonarProjectProperties,
25
+ saveWorkspaceRegistry,
26
+ resolveConfig,
27
+ } from "./config.js";
28
+ import {
29
+ runScanner,
30
+ readReportTask,
31
+ waitForAnalysis,
32
+ normalizeIssueFilters,
33
+ fetchIssues,
34
+ fetchDuplicationMeasures,
35
+ fetchIssueSeverityCounts,
36
+ fetchFileDuplications,
37
+ fetchFileDuplicationBlocks,
38
+ createAnalysisState,
39
+ issueFilterLabel,
40
+ } from "./api.js";
41
+ import {
42
+ STATE_TYPE,
43
+ sonarArgumentCompletions,
44
+ parseCommandArgs,
45
+ splitSonarArgumentContext,
46
+ mergeAutocompleteSuggestions,
47
+ formatIssue,
48
+ formatSummary,
49
+ formatMetricsOutput,
50
+ formatDuplicationsList,
51
+ formatDuplicationBlockDetail,
52
+ formatReport,
53
+ helpText,
54
+ targetLabel,
55
+ sonarErrorMessage,
56
+ } from "./commands.js";
57
+
58
+ import {
59
+ startAnalysisUi,
60
+ showIssueBrowser,
61
+ buildIssuePreview,
62
+ openIssuePreview,
63
+ resolveTargetState,
64
+ showDuplicationBrowser,
65
+ } from "./ui.js";
68
66
 
69
67
  const SonarToolParams = Type.Object({
70
- action: StringEnum(["analyze", "issues", "open"] as const),
68
+ action: StringEnum(["analyze", "issues", "open", "metrics", "duplications"] as const),
71
69
  path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
72
70
  issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
73
- severities: Type.Optional(Type.Array(Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" }))),
74
- statuses: Type.Optional(Type.Array(Type.String({ description: "Issue status to fetch (e.g. OPEN)" }))),
71
+ severities: Type.Optional(
72
+ Type.Array(Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" })),
73
+ ),
74
+ statuses: Type.Optional(
75
+ Type.Array(Type.String({ description: "Issue status to fetch (e.g. OPEN)" })),
76
+ ),
75
77
  types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
76
78
  rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
77
79
  });
78
80
 
79
- const SONAR_COMMANDS = [
80
- { value: "analyze", label: "analyze", description: "run analysis and fetch issues" },
81
- { value: "issues", label: "issues", description: "browse the latest issues" },
82
- { value: "open", label: "open", description: "preview a specific issue" },
83
- { value: "init", label: "init", description: "configure a project target" },
84
- ] as const;
85
-
86
- // ── Helpers ───────────────────────────────────────────────────────────────────
87
-
88
- function nowIso(): string {
89
- return new Date().toISOString();
90
- }
91
-
92
- function slugify(input: string): string {
93
- return (
94
- input
95
- .toLowerCase()
96
- .replace(/[^a-z0-9]+/g, "-")
97
- .replace(/^-+|-+$/g, "")
98
- .slice(0, 80) || "sonarqube"
99
- );
100
- }
101
-
102
- function normalizePath(input: string, cwd: string): string {
103
- const trimmed = input.trim().replace(/^@/, "");
104
- return resolve(cwd, trimmed);
105
- }
106
-
107
- function parseProperties(text: string): Record<string, string> {
108
- const props: Record<string, string> = {};
109
- for (const rawLine of text.split(/\r?\n/)) {
110
- const line = rawLine.trim();
111
- if (!line || line.startsWith("#") || line.startsWith("!")) continue;
112
- const sep = line.indexOf("=");
113
- if (sep < 0) continue;
114
- const key = line.slice(0, sep).trim();
115
- const value = line.slice(sep + 1).trim();
116
- if (key) props[key] = value;
117
- }
118
- return props;
119
- }
120
-
121
- function normalizeServerUrl(url: string | undefined): string {
122
- const fallback = "http://localhost:9000";
123
- if (!url?.trim()) return fallback;
124
- return url.trim().replace(/\/+$/, "");
125
- }
126
-
127
- const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
128
- const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
129
- const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
130
-
131
- function createAutocompleteItem(value: string, description?: string): AutocompleteItem {
132
- return description ? { value, label: value, description } : { value, label: value };
133
- }
134
-
135
- function filterAutocompleteItems(
136
- items: readonly { value: string; label: string; description?: string }[],
137
- prefix: string,
138
- ): AutocompleteItem[] {
139
- const query = prefix.trim().toLowerCase();
140
- const filtered = query
141
- ? items.filter((item) => item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().startsWith(query))
142
- : [...items];
143
- return filtered.map((item) => ({ value: item.value, label: item.label, description: item.description }));
144
- }
145
-
146
- function mergeAutocompleteSuggestions(
147
- prefix: string,
148
- current: AutocompleteSuggestions | null,
149
- extraItems: AutocompleteItem[],
150
- ): AutocompleteSuggestions | null {
151
- const merged = new Map<string, AutocompleteItem>();
152
- for (const item of current?.items ?? []) merged.set(item.value, item);
153
- for (const item of extraItems) merged.set(item.value, item);
154
- const items = [...merged.values()];
155
- return items.length > 0 ? { prefix, items } : current;
156
- }
157
-
158
- function splitSonarArgumentContext(argumentText: string): { command: string; current: string; tokens: string[] } {
159
- const trimmedLeft = argumentText.replace(/^\s+/, "");
160
- if (!trimmedLeft) return { command: "", current: "", tokens: [] };
161
-
162
- const tokens = trimmedLeft.split(/\s+/);
163
- const current = /\s$/.test(argumentText) ? "" : tokens.pop() ?? "";
164
- return { command: tokens[0] ?? current, current, tokens: tokens.length > 0 ? tokens : current ? [current] : [] };
165
- }
166
-
167
- function createFilterCompletionList(): AutocompleteItem[] {
168
- return [
169
- ...SONAR_SEVERITIES.flatMap((value) => [
170
- createAutocompleteItem(`severity:${value}`, "severity"),
171
- createAutocompleteItem(value, "severity"),
172
- ]),
173
- ...SONAR_STATUSES.flatMap((value) => [
174
- createAutocompleteItem(`status:${value}`, "status"),
175
- createAutocompleteItem(value, "status"),
176
- ]),
177
- ...SONAR_TYPES.flatMap((value) => [
178
- createAutocompleteItem(`type:${value}`, "type"),
179
- createAutocompleteItem(value, "type"),
180
- ]),
181
- ];
182
- }
183
-
184
- function sonarArgumentCompletions(argumentText: string, issues?: SonarIssue[]): AutocompleteItem[] | null {
185
- const { command, current, tokens } = splitSonarArgumentContext(argumentText);
186
- const lowerCommand = command.toLowerCase();
187
- const commandMatches = SONAR_COMMANDS.filter((item) => item.value.startsWith(lowerCommand));
188
-
189
- if (!command || commandMatches.length > 1 || !SONAR_COMMANDS.some((item) => item.value === lowerCommand)) {
190
- return commandMatches.length > 0 ? commandMatches.map((item) => createAutocompleteItem(item.value, item.description)) : null;
191
- }
192
-
193
- if (lowerCommand === "open") {
194
- const issueItems = (issues ?? []).slice(0, 10).map((issue, index) =>
195
- createAutocompleteItem(
196
- String(index + 1),
197
- `${issue.severity} ${issue.filePath}${issue.line ? `:${issue.line}` : ""}`,
198
- ),
199
- );
200
- const suggestions = [...issueItems, ...createFilterCompletionList()];
201
- const filtered = filterAutocompleteItems(suggestions, current);
202
- return filtered.length > 0 ? filtered : null;
203
- }
204
-
205
- if (lowerCommand === "analyze" || lowerCommand === "issues") {
206
- const suggestions = createFilterCompletionList();
207
- const filtered = filterAutocompleteItems(suggestions, current);
208
- return filtered.length > 0 ? filtered : null;
209
- }
210
-
211
- if (lowerCommand === "init") {
212
- return null;
213
- }
214
-
215
- return tokens.length === 0 ? filterAutocompleteItems(SONAR_COMMANDS, current) : null;
216
- }
217
-
218
- // ── Helpers ───────────────────────────────────────────────────────────────────
219
-
220
- function resolveProjectKey(baseDir: string, props: Record<string, string>): string {
221
- const fromProps = props["sonar.projectKey"]?.trim();
222
- if (fromProps) return fromProps;
223
- return slugify(basename(baseDir));
224
- }
225
-
226
- async function readOptionalText(path: string): Promise<string | undefined> {
227
- try {
228
- return await readFile(path, "utf8");
229
- } catch {
230
- return undefined;
231
- }
232
- }
233
-
234
- async function readOptionalJson<T>(path: string): Promise<T | undefined> {
235
- try {
236
- const text = await readFile(path, "utf8");
237
- return JSON.parse(text) as T;
238
- } catch {
239
- return undefined;
240
- }
241
- }
242
-
243
- function projectConfigPath(baseDir: string): string {
244
- return resolve(baseDir, CONFIG_DIR_NAME, "sonarqube.json");
245
- }
246
-
247
- function sonarqubeConfigDir(baseDir: string): string {
248
- return resolve(baseDir, CONFIG_DIR_NAME);
249
- }
250
-
251
- /**
252
- * Load project-local config from `.pi/sonarqube.json`.
253
- * Returns undefined when the file doesn't exist or is invalid.
254
- */
255
- async function loadProjectConfig(baseDir: string): Promise<SonarInitConfig | undefined> {
256
- return readOptionalJson<SonarInitConfig>(projectConfigPath(baseDir));
257
- }
258
-
259
- /**
260
- * Save project-local config to `.pi/sonarqube.json`.
261
- */
262
- async function saveProjectConfig(baseDir: string, config: SonarInitConfig): Promise<void> {
263
- const dir = sonarqubeConfigDir(baseDir);
264
- await mkdir(dir, { recursive: true });
265
- await writeFile(projectConfigPath(baseDir), JSON.stringify(config, null, 2) + "\n", "utf8");
266
- }
267
-
268
- interface SonarWorkspaceRegistry {
269
- version: 1;
270
- workspaces: Record<string, string>;
271
- }
272
-
273
- interface ResolvedTarget {
274
- baseDir: string;
275
- repoRoot: string;
276
- alias?: string;
277
- }
278
-
279
- const WORKSPACE_REGISTRY_FILE = "sonarqube.workspaces.json";
280
-
281
- function workspaceRegistryPath(repoRoot: string): string {
282
- return resolve(repoRoot, CONFIG_DIR_NAME, WORKSPACE_REGISTRY_FILE);
283
- }
284
-
285
- async function hasGitRootMarker(dir: string): Promise<boolean> {
286
- try {
287
- const entry = await stat(resolve(dir, ".git"));
288
- return entry.isDirectory() || entry.isFile();
289
- } catch {
290
- return false;
291
- }
292
- }
293
-
294
- async function findRepoRoot(startDir: string): Promise<string> {
295
- let dir = resolve(startDir);
296
- for (;;) {
297
- if (await hasGitRootMarker(dir)) return dir;
298
- const parent = dirname(dir);
299
- if (parent === dir) return resolve(startDir);
300
- dir = parent;
301
- }
302
- }
303
-
304
- async function loadWorkspaceRegistry(startDir: string): Promise<{ repoRoot: string; registry: SonarWorkspaceRegistry }> {
305
- const repoRoot = await findRepoRoot(startDir);
306
- const raw = await readOptionalJson<Partial<SonarWorkspaceRegistry>>(workspaceRegistryPath(repoRoot));
307
- const workspaces = raw?.workspaces && typeof raw.workspaces === "object" ? { ...raw.workspaces } : {};
308
- return { repoRoot, registry: { version: 1, workspaces: workspaces as Record<string, string> } };
309
- }
310
-
311
- async function saveWorkspaceRegistry(startDir: string, alias: string, targetDir: string): Promise<void> {
312
- const { repoRoot, registry } = await loadWorkspaceRegistry(startDir);
313
- registry.workspaces[alias] = relative(repoRoot, targetDir) || ".";
314
- await mkdir(sonarqubeConfigDir(repoRoot), { recursive: true });
315
- await writeFile(workspaceRegistryPath(repoRoot), JSON.stringify(registry, null, 2) + "\n", "utf8");
316
- }
317
-
318
- function looksLikePath(token: string): boolean {
319
- return (
320
- token === "." ||
321
- token === ".." ||
322
- token.startsWith("./") ||
323
- token.startsWith("../") ||
324
- token.startsWith("/") ||
325
- token.startsWith("~") ||
326
- token.includes("/") ||
327
- token.includes("\\")
328
- );
329
- }
330
-
331
- function knownTargets(registry: SonarWorkspaceRegistry): string[] {
332
- return Object.keys(registry.workspaces).sort();
333
- }
334
-
335
- async function resolveTarget(ctx: ExtensionContext, targetInput?: string): Promise<ResolvedTarget> {
336
- const { repoRoot, registry } = await loadWorkspaceRegistry(ctx.cwd);
337
- if (!targetInput) {
338
- return { baseDir: ctx.cwd, repoRoot };
339
- }
340
-
341
- const aliasTarget = registry.workspaces[targetInput];
342
- if (aliasTarget) {
343
- const baseDir = resolve(repoRoot, aliasTarget);
344
- const baseDirStat = await stat(baseDir).catch(() => undefined);
345
- if (!baseDirStat?.isDirectory()) {
346
- throw new Error(`SonarQube target "${targetInput}" points to a missing directory: ${baseDir}`);
347
- }
348
- return { baseDir, repoRoot, alias: targetInput };
349
- }
350
-
351
- if (looksLikePath(targetInput)) {
352
- const baseDir = normalizePath(targetInput, ctx.cwd);
353
- const baseDirStat = await stat(baseDir).catch(() => undefined);
354
- if (!baseDirStat?.isDirectory()) {
355
- throw new Error(`SonarQube target path not found: ${baseDir}`);
356
- }
357
- return { baseDir, repoRoot };
358
- }
359
-
360
- const known = knownTargets(registry);
361
- if (known.length > 0) {
362
- throw new Error(
363
- `Unknown SonarQube target "${targetInput}". Known targets: ${known.join(", ")}. Use /sonarqube init <alias> <path> to add one.`,
364
- );
365
- }
366
-
367
- throw new Error(`Unknown SonarQube target "${targetInput}". Use /sonarqube init <alias> <path> to add one.`);
368
- }
369
-
370
- async function resolveInitTarget(
371
- ctx: ExtensionContext,
372
- alias?: string,
373
- targetInput?: string,
374
- ): Promise<ResolvedTarget> {
375
- if (targetInput) {
376
- const resolved = await resolveTarget(ctx, targetInput);
377
- return { ...resolved, alias };
378
- }
379
-
380
- const { repoRoot } = await loadWorkspaceRegistry(ctx.cwd);
381
- return { baseDir: ctx.cwd, repoRoot, alias };
382
- }
383
-
384
- export { projectConfigPath, loadProjectConfig, saveProjectConfig, sonarqubeConfigDir };
385
-
386
- // ── Config resolution (env > .pi/sonarqube.json > sonar-project.properties) ────
387
-
388
- async function resolveConfig(ctx: ExtensionContext, inputPath?: string): Promise<SonarProjectConfig> {
389
- const { baseDir } = await resolveTarget(ctx, inputPath);
390
- const propertiesPath = resolve(baseDir, "sonar-project.properties");
391
- const propertiesText = await readOptionalText(propertiesPath);
392
- const props = propertiesText ? parseProperties(propertiesText) : {};
393
- const projectCfg = await loadProjectConfig(baseDir);
394
-
395
- const serverUrl = normalizeServerUrl(
396
- process.env.SONARQUBE_URL ??
397
- process.env.SONAR_HOST_URL ??
398
- projectCfg?.serverUrl?.trim() ??
399
- props["sonar.host.url"],
400
- );
401
- const projectKey =
402
- process.env.SONAR_PROJECT_KEY?.trim() ||
403
- projectCfg?.projectKey?.trim() ||
404
- props["sonar.projectKey"]?.trim() ||
405
- resolveProjectKey(baseDir, props);
406
- const token =
407
- process.env.SONARQUBE_TOKEN?.trim() ||
408
- process.env.SONAR_TOKEN?.trim() ||
409
- projectCfg?.token?.trim() ||
410
- props["sonar.token"]?.trim();
411
-
412
- return {
413
- baseDir,
414
- serverUrl,
415
- projectKey,
416
- token: token || undefined,
417
- hasProperties: Boolean(propertiesText),
418
- };
419
- }
420
-
421
- // ── Auth / API helpers ────────────────────────────────────────────────────────
422
-
423
- function authHeader(token?: string): string | undefined {
424
- if (!token) return undefined;
425
- return `Basic ${Buffer.from(`${token}:`).toString("base64")}`;
426
- }
427
-
428
- async function fetchJson<T>(url: string, token?: string, signal?: AbortSignal): Promise<T> {
429
- const headers: Record<string, string> = { Accept: "application/json" };
430
- const auth = authHeader(token);
431
- if (auth) headers.Authorization = auth;
432
-
433
- const response = await fetch(url, { headers, signal });
434
- if (!response.ok) {
435
- const body = await response.text().catch(() => "");
436
- const suffix = body.trim() ? `: ${body.trim().slice(0, 240)}` : "";
437
- throw new Error(`SonarQube API request failed (${response.status} ${response.statusText}) for ${url}${suffix}`);
438
- }
439
-
440
- return (await response.json()) as T;
441
- }
442
-
443
- function toIssuePath(component: string, projectKey: string): string {
444
- const prefix = `${projectKey}:`;
445
- if (component.startsWith(prefix)) return component.slice(prefix.length);
446
- const colon = component.indexOf(":");
447
- return colon >= 0 ? component.slice(colon + 1) : component;
448
- }
449
-
450
- function normalizeIssueList(values?: string[], uppercase = false): string[] | undefined {
451
- const cleaned = values
452
- ?.map((value) => value.trim())
453
- .filter(Boolean)
454
- .map((value) => (uppercase ? value.toUpperCase() : value));
455
- if (!cleaned?.length) return undefined;
456
- return [...new Set(cleaned)];
457
- }
458
-
459
- function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIssueFetchOptions | undefined {
460
- const normalized = {
461
- severities: normalizeIssueList(filters?.severities, true),
462
- statuses: normalizeIssueList(filters?.statuses, true),
463
- types: normalizeIssueList(filters?.types, true),
464
- rules: normalizeIssueList(filters?.rules),
465
- };
466
- return normalized.severities || normalized.statuses || normalized.types || normalized.rules ? normalized : undefined;
467
- }
468
-
469
- function mergeIssueFilters(...filters: Array<Partial<SonarIssueFetchOptions> | undefined>): SonarIssueFetchOptions | undefined {
470
- const merged: SonarIssueFetchOptions = {};
471
- for (const filter of filters) {
472
- if (!filter) continue;
473
- if (filter.severities?.length) merged.severities = [...(merged.severities ?? []), ...filter.severities];
474
- if (filter.statuses?.length) merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
475
- if (filter.types?.length) merged.types = [...(merged.types ?? []), ...filter.types];
476
- if (filter.rules?.length) merged.rules = [...(merged.rules ?? []), ...filter.rules];
477
- }
478
- return normalizeIssueFilters(merged);
479
- }
480
-
481
- function splitFilterValues(value: string): string[] {
482
- return value.split(",").map((part) => part.trim()).filter(Boolean);
483
- }
484
-
485
- function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOptions> | undefined {
486
- const trimmed = token.trim();
487
- if (!trimmed) return undefined;
488
-
489
- const explicit = trimmed.match(/^([^:=]+)[:=](.+)$/);
490
- if (explicit) {
491
- const key = explicit[1].trim().toLowerCase();
492
- const values = splitFilterValues(explicit[2]);
493
- if (values.length === 0) return undefined;
494
-
495
- if (key === "severity" || key === "severities") return { severities: values.map((value) => value.toUpperCase()) };
496
- if (key === "status" || key === "statuses") return { statuses: values.map((value) => value.toUpperCase()) };
497
- if (key === "type" || key === "types") return { types: values.map((value) => value.toUpperCase()) };
498
- if (key === "rule" || key === "rules") return { rules: values };
499
- return undefined;
500
- }
501
-
502
- const upper = trimmed.toUpperCase();
503
- if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number])) return { severities: [upper] };
504
- if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number])) return { statuses: [upper] };
505
- if (SONAR_TYPES.includes(upper as (typeof SONAR_TYPES)[number])) return { types: [upper] };
506
- return undefined;
507
- }
508
-
509
- interface ParsedSonarIssueArgs {
510
- targetInput?: string;
511
- issueIndex?: number;
512
- filters?: SonarIssueFetchOptions;
513
- }
514
-
515
- function parseSonarIssueArgs(tokens: string[], allowIssueIndex = false): ParsedSonarIssueArgs {
516
- const filters: Array<Partial<SonarIssueFetchOptions> | undefined> = [];
517
- let targetInput: string | undefined;
518
- let issueIndex: number | undefined;
519
-
520
- for (const token of tokens) {
521
- if (!token) continue;
522
- if (allowIssueIndex && issueIndex === undefined && /^\d+$/.test(token)) {
523
- issueIndex = Number(token);
524
- continue;
525
- }
526
-
527
- const parsedFilter = parseIssueFilterToken(token);
528
- if (parsedFilter) {
529
- filters.push(parsedFilter);
530
- continue;
531
- }
532
-
533
- if (!targetInput) {
534
- targetInput = token;
535
- }
536
- }
537
-
538
- return {
539
- targetInput,
540
- issueIndex,
541
- filters: mergeIssueFilters(...filters),
542
- };
543
- }
544
-
545
- function issueFilterLabel(filters?: SonarIssueFetchOptions): string {
546
- if (!filters) return "";
547
- const parts = [
548
- filters.severities?.length ? `severities=${filters.severities.join(",")}` : "",
549
- filters.statuses?.length ? `statuses=${filters.statuses.join(",")}` : "",
550
- filters.types?.length ? `types=${filters.types.join(",")}` : "",
551
- filters.rules?.length ? `rules=${filters.rules.join(",")}` : "",
552
- ].filter(Boolean);
553
- return parts.join(" • ");
554
- }
555
-
556
- function severitySortRank(severity: string): number {
557
- switch (severity.toUpperCase()) {
558
- case "BLOCKER":
559
- return 0;
560
- case "CRITICAL":
561
- return 1;
562
- case "MAJOR":
563
- return 2;
564
- case "MINOR":
565
- return 3;
566
- case "INFO":
567
- return 4;
568
- default:
569
- return 5;
570
- }
571
- }
572
-
573
- function compareIssuesForContext(left: SonarIssue, right: SonarIssue): number {
574
- const severityDiff = severitySortRank(left.severity) - severitySortRank(right.severity);
575
- if (severityDiff !== 0) return severityDiff;
576
- const fileDiff = left.filePath.localeCompare(right.filePath);
577
- if (fileDiff !== 0) return fileDiff;
578
- const leftLine = left.line ?? Number.POSITIVE_INFINITY;
579
- const rightLine = right.line ?? Number.POSITIVE_INFINITY;
580
- if (leftLine !== rightLine) return leftLine - rightLine;
581
- return left.message.localeCompare(right.message);
582
- }
583
-
584
- // ── Issue helpers ─────────────────────────────────────────────────────────────
585
-
586
- function buildIssuePreview(baseDir: string, issue: SonarIssue, radius = 3): Promise<string> {
587
- return (async () => {
588
- const filePath = resolve(baseDir, issue.filePath);
589
- const content = await readFile(filePath, "utf8");
590
- const lines = content.split(/\r?\n/);
591
- const target = issue.line ?? 1;
592
- const start = Math.max(1, target - radius);
593
- const end = Math.min(lines.length, target + radius);
594
- const width = String(end).length;
595
-
596
- const rendered: string[] = [];
597
- for (let line = start; line <= end; line++) {
598
- const marker = line === target ? ">" : " ";
599
- const num = String(line).padStart(width, " ");
600
- rendered.push(`${marker} ${num} | ${lines[line - 1] ?? ""}`);
601
- }
602
-
603
- return rendered.join("\n");
604
- })();
605
- }
606
-
607
- function formatIssue(issue: SonarIssue, index?: number): string {
608
- const loc = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
609
- const prefix = index !== undefined ? `${String(index).padStart(2, "0")}. ` : "";
610
- const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
611
- return `${prefix}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
612
- }
613
-
614
- function formatSummary(state: SonarAnalysisState): string {
615
- const issueCount = state.totalIssues;
616
- const noIssues = issueCount === 0;
617
- const filterLabel = state.filters ? ` (${issueFilterLabel(state.filters)})` : "";
618
- return noIssues
619
- ? `SonarQube: no issues found for ${state.projectKey}${filterLabel}`
620
- : `SonarQube: ${issueCount} issue${issueCount === 1 ? "" : "s"} found for ${state.projectKey}${filterLabel}`;
621
- }
622
-
623
- function formatReport(state: SonarAnalysisState): string {
624
- const lines = [
625
- `Project: ${state.projectKey}`,
626
- `Server: ${state.serverUrl}`,
627
- `Base dir: ${state.baseDir}`,
628
- `Issues: ${state.totalIssues}`,
629
- ];
630
-
631
- if (state.filters) {
632
- lines.push(`Filters: ${issueFilterLabel(state.filters)}`);
633
- }
634
-
635
- if (state.issues.length === 0) {
636
- lines.push("No open issues found.");
637
- return lines.join("\n");
638
- }
639
-
640
- lines.push("");
641
- for (const [index, issue] of state.issues.slice(0, 10).entries()) {
642
- lines.push(formatIssue(issue, index + 1));
643
- }
644
- if (state.issues.length > 10) {
645
- lines.push(`... ${state.issues.length - 10} more`);
646
- }
647
- return lines.join("\n");
648
- }
649
-
650
-
651
-
652
- function severityColor(theme: Theme, severity: string): string {
653
- switch (severity.toLowerCase()) {
654
- case "blocker":
655
- case "critical":
656
- return theme.fg("error", severity);
657
- case "major":
658
- return theme.fg("warning", severity);
659
- case "minor":
660
- return theme.fg("accent", severity);
661
- default:
662
- return theme.fg("muted", severity);
663
- }
664
- }
665
-
666
- const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
667
- const ANALYSIS_WIDGET_KEY = "sonarqube-analysis";
668
-
669
- interface AnalysisUiHandle {
670
- setPhase(phase: string): void;
671
- stop(): void;
672
- }
673
-
674
- function startAnalysisUi(ctx: ExtensionContext, projectKey: string): AnalysisUiHandle {
675
- let frameIndex = 0;
676
- let phase = "Starting...";
677
- const render = () => {
678
- const frame = ANALYSIS_SPINNER[frameIndex];
679
- ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, [`${frame} SonarQube ${projectKey}`, phase]);
680
- };
681
-
682
- ctx.ui.setWorkingMessage(`Analyzing ${projectKey}...`);
683
- ctx.ui.setWorkingIndicator({ frames: ANALYSIS_SPINNER as unknown as string[], intervalMs: 80 });
684
- render();
685
-
686
- const timer = setInterval(() => {
687
- frameIndex = (frameIndex + 1) % ANALYSIS_SPINNER.length;
688
- render();
689
- }, 80);
690
-
691
- return {
692
- setPhase(nextPhase: string) {
693
- phase = nextPhase;
694
- render();
695
- },
696
- stop() {
697
- clearInterval(timer);
698
- ctx.ui.setWidget(ANALYSIS_WIDGET_KEY, undefined);
699
- ctx.ui.setWorkingMessage();
700
- ctx.ui.setWorkingIndicator();
701
- },
702
- };
703
- }
704
-
705
- // ── Issue Browser UI component ────────────────────────────────────────────────
706
-
707
- class IssueBrowser {
708
- private selected = 0;
709
- private cachedWidth: number | undefined;
710
- private cachedLines: string[] | undefined;
711
-
712
- constructor(
713
- private readonly state: SonarAnalysisState,
714
- private readonly theme: Theme,
715
- private readonly done: (result: number | null) => void,
716
- ) {}
717
-
718
- invalidate(): void {
719
- this.cachedWidth = undefined;
720
- this.cachedLines = undefined;
721
- }
722
-
723
- handleInput(data: string): void {
724
- if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
725
- this.done(null);
726
- return;
727
- }
728
- if (matchesKey(data, Key.up)) {
729
- this.selected = Math.max(0, this.selected - 1);
730
- this.invalidate();
731
- return;
732
- }
733
- if (matchesKey(data, Key.down)) {
734
- this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
735
- this.invalidate();
736
- return;
737
- }
738
- if (matchesKey(data, Key.enter)) {
739
- this.done(this.selected);
740
- }
741
- }
742
-
743
- render(width: number): string[] {
744
- if (this.cachedLines && this.cachedWidth === width) {
745
- return this.cachedLines;
746
- }
747
-
748
- const lines: string[] = [];
749
- const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
750
- const subtitleText = this.state.filters
751
- ? `${this.state.projectKey} • ${this.state.totalIssues} issue(s) • ${issueFilterLabel(this.state.filters)}`
752
- : `${this.state.projectKey} • ${this.state.totalIssues} issue(s)`;
753
- const subtitle = this.theme.fg("dim", subtitleText);
754
- lines.push(truncateToWidth(title, width));
755
- lines.push(truncateToWidth(subtitle, width));
756
- lines.push("");
757
-
758
- const pageSize = Math.min(20, this.state.issues.length);
759
- const halfWindow = Math.floor(pageSize / 2);
760
- const maxStart = Math.max(0, this.state.issues.length - pageSize);
761
- const start = Math.max(0, Math.min(this.selected - halfWindow, maxStart));
762
- const end = Math.min(this.state.issues.length, start + pageSize);
763
- const visibleIssues = this.state.issues.slice(start, end);
764
-
765
- if (visibleIssues.length === 0) {
766
- lines.push(truncateToWidth(this.theme.fg("success", "No open issues found."), width));
767
- } else {
768
- if (start > 0) {
769
- lines.push(truncateToWidth(this.theme.fg("dim", `... ${start} more above`), width));
770
- }
771
- visibleIssues.forEach((issue, index) => {
772
- const issueIndex = start + index;
773
- const selected = issueIndex === this.selected;
774
- const marker = selected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
775
- const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
776
- const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
777
- const severity = severityColor(this.theme, issue.severity);
778
- const summary = `${marker} ${String(issueIndex + 1).padStart(2, "0")}. ${severity} ${this.theme.fg(
779
- "accent",
780
- location,
781
- )} ${this.theme.fg("muted", rule)} ${this.theme.fg("text", `— ${issue.message}`)}`;
782
- lines.push(truncateToWidth(summary, width));
783
- });
784
- if (end < this.state.issues.length) {
785
- lines.push(truncateToWidth(this.theme.fg("dim", `... ${this.state.issues.length - end} more below`), width));
786
- }
787
- }
788
-
789
- lines.push("");
790
- lines.push(truncateToWidth(this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"), width));
791
-
792
- this.cachedWidth = width;
793
- this.cachedLines = lines;
794
- return lines;
795
- }
796
- }
797
-
798
- // ── State restoration ─────────────────────────────────────────────────────────
81
+ // ── State restoration ───────────────────────────────────────────────────────
799
82
 
800
83
  async function restoreState(
801
84
  pi: ExtensionAPI,
@@ -810,275 +93,11 @@ async function restoreState(
810
93
  statesByBaseDir.set(latest.baseDir, latest);
811
94
  }
812
95
  }
813
-
814
96
  ctx.ui.setStatus("sonarqube", undefined);
815
97
  return latest;
816
98
  }
817
99
 
818
- // ── Error hints ───────────────────────────────────────────────────────────────
819
-
820
- function extractScannerHint(output: string, config: SonarProjectConfig): string {
821
- const lines = output.toLowerCase();
822
-
823
- if (/status code 401|status 401|unauthorized/i.test(lines)) {
824
- const defaultHint = config.serverUrl === "http://localhost:9000"
825
- ? " (default: http://localhost:9000)"
826
- : "";
827
- return [
828
- `Authentication failed for ${config.serverUrl}${defaultHint}.`,
829
- "Run `/sonarqube init` to set up your server URL and token, then retry.",
830
- "Or set SONARQUBE_TOKEN in your environment.",
831
- ].join(" ");
832
- }
833
-
834
- if (/status code 403|status 403|forbidden/i.test(lines)) {
835
- return [
836
- `Access denied by ${config.serverUrl}.`,
837
- 'Your token may not have the required permissions ("Execute Analysis").',
838
- "Run `/sonarqube init` to update the token or check server permissions.",
839
- ].join(" ");
840
- }
841
-
842
- if (/connect econnrefused|connect refused|connect timeout|enotfound|econnreset/i.test(lines)) {
843
- return [
844
- `Cannot reach SonarQube server at ${config.serverUrl}.`,
845
- "Is the server running? Run `/sonarqube init` to check the server URL.",
846
- "Default: http://localhost:9000",
847
- ].join(" ");
848
- }
849
-
850
- if (/not found|unknown url/i.test(lines) && (config.serverUrl === "http://localhost:9000" || /localhost/i.test(config.serverUrl))) {
851
- return [
852
- `SonarQube API at ${config.serverUrl} returned "not found".`,
853
- "The default server URL is http://localhost:9000. Run `/sonarqube init` to configure.",
854
- ].join(" ");
855
- }
856
-
857
- return "";
858
- }
859
-
860
- // ── SonarScanner interaction ─────────────────────────────────────────────────
861
-
862
- async function runScanner(pi: ExtensionAPI, config: SonarProjectConfig, signal?: AbortSignal): Promise<string> {
863
- const args = [
864
- `-Dsonar.projectKey=${config.projectKey}`,
865
- `-Dsonar.projectBaseDir=${config.baseDir}`,
866
- `-Dsonar.host.url=${config.serverUrl}`,
867
- ...(config.hasProperties ? [] : [`-Dsonar.sources=.`]),
868
- `-Dsonar.scm.disabled=true`,
869
- ];
870
- if (config.token) {
871
- args.push(`-Dsonar.token=${config.token}`);
872
- }
873
-
874
- const result = await pi.exec("sonar-scanner", args, {
875
- cwd: config.baseDir,
876
- signal,
877
- timeout: 30 * 60 * 1000,
878
- });
879
- if (result.code !== 0) {
880
- const output = [result.stdout, result.stderr]
881
- .filter(Boolean)
882
- .join("\n")
883
- .trim();
884
- const hint = extractScannerHint(output, config);
885
- const detail = output ? `\n\n${output.slice(-4000)}` : "";
886
- const message = hint
887
- ? `SonarQube scan failed for ${config.projectKey}. ${hint}`
888
- : `SonarQube scan failed for ${config.projectKey}${detail}`;
889
- throw new Error(message);
890
- }
891
-
892
- return result.stdout || result.stderr;
893
- }
894
-
895
- async function readReportTask(baseDir: string): Promise<Record<string, string>> {
896
- const path = resolve(baseDir, ".scannerwork", "report-task.txt");
897
- const text = await readFile(path, "utf8");
898
- return parseProperties(text);
899
- }
900
-
901
- async function waitForAnalysis(
902
- serverUrl: string,
903
- token: string | undefined,
904
- ceTaskUrl: string,
905
- signal?: AbortSignal,
906
- ): Promise<string | undefined> {
907
- for (let attempt = 0; attempt < 120; attempt++) {
908
- const result = await fetchJson<{
909
- task: { status: string; analysisId?: string; errorMessage?: string };
910
- }>(ceTaskUrl, token, signal);
911
- const status = result.task.status.toUpperCase();
912
- if (status === "SUCCESS") return result.task.analysisId;
913
- if (status === "FAILED") {
914
- throw new Error(`SonarQube analysis failed: ${result.task.errorMessage || "unknown error"}`);
915
- }
916
- if (status === "CANCELED") {
917
- throw new Error("SonarQube analysis was canceled by the server");
918
- }
919
- await new Promise((resolveWait) => setTimeout(resolveWait, 2000));
920
- }
921
-
922
- throw new Error(`Timed out waiting for SonarQube analysis at ${serverUrl}`);
923
- }
924
-
925
- async function fetchRuleName(
926
- serverUrl: string,
927
- token: string | undefined,
928
- ruleKey: string,
929
- signal?: AbortSignal,
930
- ): Promise<string | undefined> {
931
- try {
932
- const result = await fetchJson<{ rule: { name?: string } }>(
933
- `${serverUrl}/api/rules/show?key=${encodeURIComponent(ruleKey)}`,
934
- token,
935
- signal,
936
- );
937
- return result.rule.name;
938
- } catch {
939
- return undefined;
940
- }
941
- }
942
-
943
- async function fetchIssues(
944
- serverUrl: string,
945
- token: string | undefined,
946
- projectKey: string,
947
- signal?: AbortSignal,
948
- filters?: SonarIssueFetchOptions,
949
- ): Promise<SonarIssue[]> {
950
- const normalizedFilters = normalizeIssueFilters(filters);
951
- const issues: SonarIssue[] = [];
952
- const ruleNames = new Map<string, string | undefined>();
953
- let page = 1;
954
- let total = 0;
955
-
956
- for (;;) {
957
- const url = new URL("/api/issues/search", serverUrl);
958
- url.searchParams.set("projects", projectKey);
959
- url.searchParams.set("resolved", "false");
960
- url.searchParams.set("ps", "100");
961
- url.searchParams.set("p", String(page));
962
- if (normalizedFilters?.severities?.length) {
963
- url.searchParams.set("severities", normalizedFilters.severities.join(","));
964
- }
965
- if (normalizedFilters?.statuses?.length) {
966
- url.searchParams.set("statuses", normalizedFilters.statuses.join(","));
967
- }
968
- if (normalizedFilters?.types?.length) {
969
- url.searchParams.set("types", normalizedFilters.types.join(","));
970
- }
971
- if (normalizedFilters?.rules?.length) {
972
- url.searchParams.set("rules", normalizedFilters.rules.join(","));
973
- }
974
-
975
- const result = await fetchJson<{
976
- total: number;
977
- issues: Array<{
978
- key: string;
979
- rule: string;
980
- severity: string;
981
- message: string;
982
- component: string;
983
- line?: number;
984
- status?: string;
985
- }>;
986
- }>(url.toString(), token, signal);
987
-
988
- total = result.total;
989
- for (const raw of result.issues) {
990
- if (!ruleNames.has(raw.rule)) {
991
- ruleNames.set(raw.rule, await fetchRuleName(serverUrl, token, raw.rule, signal));
992
- }
993
- issues.push({
994
- key: raw.key,
995
- rule: raw.rule,
996
- ruleName: ruleNames.get(raw.rule),
997
- severity: raw.severity,
998
- message: raw.message,
999
- filePath: toIssuePath(raw.component, projectKey),
1000
- line: typeof raw.line === "number" ? raw.line : null,
1001
- status: raw.status,
1002
- });
1003
- }
1004
-
1005
- if (issues.length >= total || result.issues.length === 0) break;
1006
- page += 1;
1007
- }
1008
-
1009
- if (normalizedFilters && issues.length > 1) {
1010
- issues.sort(compareIssuesForContext);
1011
- }
1012
-
1013
- return issues;
1014
- }
1015
-
1016
- function createAnalysisState(
1017
- config: Pick<SonarProjectConfig, "baseDir" | "serverUrl" | "projectKey">,
1018
- issues: SonarIssue[],
1019
- extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters">> = {},
1020
- ): SonarAnalysisState {
1021
- return {
1022
- version: 1,
1023
- analyzedAt: nowIso(),
1024
- baseDir: config.baseDir,
1025
- serverUrl: config.serverUrl,
1026
- projectKey: config.projectKey,
1027
- dashboardUrl: extras.dashboardUrl,
1028
- ceTaskUrl: extras.ceTaskUrl,
1029
- analysisId: extras.analysisId,
1030
- filters: extras.filters,
1031
- totalIssues: issues.length,
1032
- issues,
1033
- };
1034
- }
1035
-
1036
- async function hasProjectAnalyses(
1037
- serverUrl: string,
1038
- token: string | undefined,
1039
- projectKey: string,
1040
- signal?: AbortSignal,
1041
- ): Promise<boolean> {
1042
- try {
1043
- const result = await fetchJson<{ analyses?: Array<{ key?: string }> }>(
1044
- `${serverUrl}/api/project_analyses/search?project=${encodeURIComponent(projectKey)}&ps=1`,
1045
- token,
1046
- signal,
1047
- );
1048
- return (result.analyses?.length ?? 0) > 0;
1049
- } catch {
1050
- return false;
1051
- }
1052
- }
1053
-
1054
- async function loadProjectIssuesFromApi(
1055
- ctx: ExtensionContext,
1056
- inputPath?: string,
1057
- filters?: SonarIssueFetchOptions,
1058
- ): Promise<SonarAnalysisState> {
1059
- const config = await resolveConfig(ctx, inputPath);
1060
- const normalizedFilters = normalizeIssueFilters(filters);
1061
- const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal, normalizedFilters);
1062
- return createAnalysisState(config, issues, { filters: normalizedFilters });
1063
- }
1064
-
1065
- async function resolveTargetState(
1066
- ctx: ExtensionContext,
1067
- statesByBaseDir: Map<string, SonarAnalysisState>,
1068
- targetInput?: string,
1069
- filters?: SonarIssueFetchOptions,
1070
- ): Promise<SonarAnalysisState | undefined> {
1071
- const resolvedTarget = await resolveTarget(ctx, targetInput);
1072
- try {
1073
- return await loadProjectIssuesFromApi(ctx, targetInput, filters);
1074
- } catch (error) {
1075
- const cached = statesByBaseDir.get(resolvedTarget.baseDir);
1076
- if (cached) return cached;
1077
- throw error;
1078
- }
1079
- }
1080
-
1081
- // ── Analysis orchestration ────────────────────────────────────────────────────
100
+ // ── Analysis orchestration ──────────────────────────────────────────────────
1082
101
 
1083
102
  async function analyzeProject(
1084
103
  pi: ExtensionAPI,
@@ -1103,18 +122,34 @@ async function analyzeProject(
1103
122
  const ceTaskUrl = report["ceTaskUrl"];
1104
123
  const dashboardUrl = report["dashboardUrl"];
1105
124
  const analysisId = ceTaskUrl
1106
- ? (analysisUi.setPhase("Waiting for SonarQube analysis..."), await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
125
+ ? (analysisUi.setPhase("Waiting for SonarQube analysis..."),
126
+ await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
1107
127
  : undefined;
1108
128
 
1109
129
  analysisUi.setPhase("Fetching issues...");
1110
130
  const normalizedFilters = normalizeIssueFilters(filters);
1111
- const issues = await fetchIssues(config.serverUrl, config.token, config.projectKey, ctx.signal, normalizedFilters);
131
+ const issues = await fetchIssues(
132
+ config.serverUrl,
133
+ config.token,
134
+ config.projectKey,
135
+ ctx.signal,
136
+ normalizedFilters,
137
+ );
138
+
139
+ analysisUi.setPhase("Fetching metrics...");
140
+ const measures = await fetchDuplicationMeasures(
141
+ config.serverUrl,
142
+ config.token,
143
+ config.projectKey,
144
+ ctx.signal,
145
+ );
1112
146
 
1113
147
  const state = createAnalysisState(config, issues, {
1114
148
  dashboardUrl,
1115
149
  ceTaskUrl,
1116
150
  analysisId,
1117
151
  filters: normalizedFilters,
152
+ measures,
1118
153
  });
1119
154
 
1120
155
  pi.appendEntry(STATE_TYPE, state);
@@ -1126,150 +161,142 @@ async function analyzeProject(
1126
161
  }
1127
162
  }
1128
163
 
1129
- // ── UI helpers ────────────────────────────────────────────────────────────────
164
+ // ── Init command helpers (extracted to reduce cognitive complexity) ─────────
1130
165
 
1131
- async function showIssueBrowser(
166
+ async function confirmOverwrite(
1132
167
  ctx: ExtensionCommandContext,
1133
- state: SonarAnalysisState,
1134
- ): Promise<number | null> {
1135
- if (ctx.mode !== "tui") return null;
1136
- if (state.issues.length === 0) return null;
1137
-
1138
- return await ctx.ui.custom<number | null>((_tui, theme, _kb, done) => new IssueBrowser(state, theme, done));
168
+ baseDir: string,
169
+ existing: SonarInitConfig,
170
+ ): Promise<boolean> {
171
+ const ok = await ctx.ui.confirm(
172
+ "Overwrite?",
173
+ [
174
+ `SonarQube config already exists at ${baseDir}:`,
175
+ ` Server: ${existing.serverUrl}`,
176
+ ` Project: ${existing.projectKey}`,
177
+ "",
178
+ "Overwrite?",
179
+ ].join("\n"),
180
+ );
181
+ if (ok) return true;
182
+ if (ctx.hasUI) ctx.ui.notify("Init cancelled.", "info");
183
+ return false;
1139
184
  }
1140
185
 
1141
- async function openIssuePreview(
186
+ async function collectInitInputs(
1142
187
  ctx: ExtensionCommandContext,
1143
- state: SonarAnalysisState,
1144
- issue: SonarIssue,
1145
- ): Promise<void> {
1146
- const preview = await buildIssuePreview(state.baseDir, issue);
1147
- const title = `${issue.filePath}${issue.line ? `:${issue.line}` : ""}`;
1148
- if (ctx.mode === "tui") {
1149
- await ctx.ui.editor(title, preview);
1150
- return;
1151
- }
1152
- ctx.ui.notify(`${title}\n${preview}`, "info");
1153
- }
188
+ existing: SonarInitConfig | undefined,
189
+ baseDir: string,
190
+ ): Promise<{ serverUrl: string; projectKey: string; token: string | undefined } | undefined> {
191
+ const serverUrlInput = await ctx.ui.editor("SonarQube server URL", existing?.serverUrl ?? "http://localhost:9000");
192
+ if (serverUrlInput === undefined) return undefined;
193
+ const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
194
+
195
+ const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
196
+ if (projectKeyInput === undefined) return undefined;
197
+ const projectKey = projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
1154
198
 
1155
- // ── Init command ──────────────────────────────────────────────────────────────
199
+ const tokenInput = await ctx.ui.input("SonarQube token (optional, press Enter to skip)", existing?.token ?? "");
200
+ if (tokenInput === undefined) return undefined;
1156
201
 
1157
- interface InitCommandOptions {
1158
- alias?: string;
1159
- targetInput?: string;
202
+ return { serverUrl, projectKey, token: tokenInput.trim() || undefined };
1160
203
  }
1161
204
 
1162
205
  async function initCommandHandler(ctx: ExtensionCommandContext, options: InitCommandOptions = {}): Promise<void> {
1163
206
  const { baseDir, repoRoot } = await resolveInitTarget(ctx, options.alias, options.targetInput);
1164
207
  const existing = await loadProjectConfig(baseDir);
1165
208
 
1166
- if (existing) {
1167
- const ok = await ctx.ui.confirm(
1168
- "Overwrite?",
1169
- `SonarQube config already exists at ${baseDir}:\n Server: ${existing.serverUrl}\n Project: ${existing.projectKey}\n\nOverwrite?`,
1170
- );
1171
- if (!ok) {
1172
- if (ctx.hasUI) ctx.ui.notify("Init cancelled.", "info");
1173
- return;
1174
- }
1175
- }
209
+ const confirmed = existing ? await confirmOverwrite(ctx, baseDir, existing) : true;
210
+ if (!confirmed) return;
1176
211
 
1177
- const serverUrlInput = await ctx.ui.editor("SonarQube server URL", existing?.serverUrl ?? "http://localhost:9000");
1178
- if (serverUrlInput === undefined) return;
1179
- const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
212
+ const inputs = await collectInitInputs(ctx, existing, baseDir);
213
+ if (!inputs) return;
1180
214
 
1181
- const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
1182
- if (projectKeyInput === undefined) return;
1183
- const projectKey = projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
1184
-
1185
- const tokenInput = await ctx.ui.input("SonarQube token (optional, press Enter to skip)", existing?.token ?? "");
1186
- if (tokenInput === undefined) return;
1187
- const token = tokenInput.trim() || undefined;
1188
-
1189
- await saveProjectConfig(baseDir, {
1190
- serverUrl,
1191
- projectKey,
1192
- token,
1193
- });
215
+ await saveProjectConfig(baseDir, inputs);
216
+ const propertiesState = await ensureDefaultSonarProjectProperties(baseDir);
1194
217
 
1195
218
  if (options.alias) {
1196
219
  await saveWorkspaceRegistry(repoRoot, options.alias, baseDir);
1197
220
  }
1198
221
 
1199
222
  if (ctx.hasUI) {
1200
- const targetLabel = options.alias ? ` (${options.alias})` : "";
1201
- ctx.ui.notify(`SonarQube config saved${targetLabel} to ${projectConfigPath(baseDir)}`, "info");
223
+ notifyInitResult(ctx, baseDir, propertiesState, options.alias);
1202
224
  }
1203
225
  }
1204
226
 
1205
- function helpText(): string {
1206
- return [
1207
- "SonarQube commands:",
1208
- "",
1209
- " /sonarqube init [alias] [path] configure a project target",
1210
- " /sonarqube analyze [target] run analysis for a target or path",
1211
- " /sonarqube issues [target] browse issues for a target or path",
1212
- " /sonarqube open [target] <n> preview issue #n for a target or path",
1213
- " /sonarqube show this help",
1214
- "",
1215
- "Filters:",
1216
- " /sonarqube issues be CRITICAL",
1217
- " /sonarqube issues be severity:CRITICAL status:OPEN",
1218
- "",
1219
- "Autocomplete:",
1220
- " type /sonarqube and press Tab to complete the subcommand or filters",
1221
- "",
1222
- "Defaults:",
1223
- " config is stored in .pi/sonarqube.json",
1224
- " use project paths directly (no alias needed)",
1225
- ].join("\n");
227
+ function notifyInitResult(
228
+ ctx: ExtensionCommandContext,
229
+ baseDir: string,
230
+ propertiesState: "created" | "updated" | "unchanged",
231
+ alias?: string,
232
+ ): void {
233
+ const label = alias ? ` (${alias})` : "";
234
+ let note = "";
235
+ if (propertiesState === "created") {
236
+ note = ` and created ${sonarProjectPropertiesPath(baseDir)}`;
237
+ } else if (propertiesState === "updated") {
238
+ note = ` and merged ${sonarProjectPropertiesPath(baseDir)}`;
239
+ }
240
+ ctx.ui.notify(`SonarQube config saved${label} to ${projectConfigPath(baseDir)}${note}`, "info");
1226
241
  }
1227
242
 
1228
- function targetLabel(targetInput?: string): string {
1229
- return targetInput ? ` ${targetInput}` : "";
243
+ // ── Tool result rendering helpers (reduces renderResult complexity) ─────────
244
+
245
+ function renderErrorResult(errorMsg: string, theme: Theme): Text {
246
+ return new Text(theme.fg("error", errorMsg), 0, 0);
1230
247
  }
1231
248
 
1232
- function sonarErrorMessage(error: unknown): string {
1233
- return error instanceof Error ? error.message : String(error);
249
+ function renderEmptyResult(summary: string, theme: Theme): Text {
250
+ return new Text(theme.fg("success", summary), 0, 0);
1234
251
  }
1235
252
 
1236
- // ── Command arg parsing ───────────────────────────────────────────────────────
1237
-
1238
- type ParsedSonarCommand =
1239
- | { action: "help" }
1240
- | { action: "init"; alias?: string; targetInput?: string }
1241
- | { action: "analyze"; targetInput?: string; filters?: SonarIssueFetchOptions }
1242
- | { action: "issues"; targetInput?: string; filters?: SonarIssueFetchOptions }
1243
- | { action: "open"; targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions };
1244
-
1245
- function parseCommandArgs(args: string): ParsedSonarCommand {
1246
- const tokens = args.trim().split(/\s+/).filter(Boolean);
1247
- if (tokens.length === 0) return { action: "help" };
1248
-
1249
- const head = tokens[0].toLowerCase();
1250
- if (head === "init") {
1251
- if (tokens.length === 1) return { action: "init" };
1252
- if (tokens.length === 2) {
1253
- return looksLikePath(tokens[1])
1254
- ? { action: "init", targetInput: tokens[1] }
1255
- : { action: "init", alias: tokens[1] };
1256
- }
1257
- return { action: "init", alias: tokens[1], targetInput: tokens[2] };
1258
- }
1259
- if (head === "issues" || head === "view") {
1260
- return { action: "issues", ...parseSonarIssueArgs(tokens.slice(1)) };
253
+ function renderIssueResult(
254
+ issue: SonarIssue,
255
+ contentText: string | undefined,
256
+ expanded: boolean,
257
+ theme: Theme,
258
+ ): Text {
259
+ const text = contentText ?? formatIssue(issue);
260
+ const preview = expanded ? text.split("\n").slice(1).join("\n") : "";
261
+ const label = theme.fg("accent", formatIssue(issue));
262
+ return new Text(preview ? `${label}\n${preview}` : label, 0, 0);
263
+ }
264
+
265
+ function renderIssueListResult(
266
+ state: SonarAnalysisState,
267
+ expanded: boolean,
268
+ theme: Theme,
269
+ ): Text {
270
+ const summary = formatSummary(state);
271
+ if (state.issues.length === 0 && !state.measures) {
272
+ return new Text(theme.fg("success", summary), 0, 0);
1261
273
  }
1262
- if (head === "open") {
1263
- return { action: "open", ...parseSonarIssueArgs(tokens.slice(1), true) };
274
+
275
+ const lines: string[] = [];
276
+ lines.push(theme.fg("accent", summary));
277
+ if (state.measures) {
278
+ const density = state.measures.duplicatedLinesDensity.toFixed(1);
279
+ const detail = state.measures.duplicatedBlocks > 0
280
+ ? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
281
+ : `Duplication: ${density}% (no duplications detected)`;
282
+ lines.push(theme.fg("dim", detail));
1264
283
  }
1265
- if (head === "analyze" || head === "run") {
1266
- return { action: "analyze", ...parseSonarIssueArgs(tokens.slice(1)) };
284
+
285
+ if (state.issues.length === 0) {
286
+ return new Text(lines.join("\n"), 0, 0);
1267
287
  }
1268
288
 
1269
- return { action: "analyze", ...parseSonarIssueArgs(tokens) };
289
+ const visible = expanded ? state.issues : state.issues.slice(0, 5);
290
+ for (const [i, issue] of visible.entries()) {
291
+ lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
292
+ }
293
+ if (!expanded && state.issues.length > visible.length) {
294
+ lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
295
+ }
296
+ return new Text(lines.join("\n"), 0, 0);
1270
297
  }
1271
298
 
1272
- // ── Extension entrypoint ──────────────────────────────────────────────────────
299
+ // ── Extension entrypoint ────────────────────────────────────────────────────
1273
300
 
1274
301
  export default function sonarqube(pi: ExtensionAPI) {
1275
302
  const statesByBaseDir = new Map<string, SonarAnalysisState>();
@@ -1292,10 +319,8 @@ export default function sonarqube(pi: ExtensionAPI) {
1292
319
  async getSuggestions(lines, cursorLine, cursorCol, options) {
1293
320
  const line = lines[cursorLine] ?? "";
1294
321
  const beforeCursor = line.slice(0, cursorCol);
1295
- const match = beforeCursor.match(/^\/sonarqube(?:\s+(.*))?$/);
1296
- if (!match) {
1297
- return current.getSuggestions(lines, cursorLine, cursorCol, options);
1298
- }
322
+ const match = /^\/sonarqube(?:\s+(.*))?$/.exec(beforeCursor);
323
+ if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
1299
324
 
1300
325
  const argumentText = match[1] ?? "";
1301
326
  const { current: currentToken } = splitSonarArgumentContext(argumentText);
@@ -1315,6 +340,7 @@ export default function sonarqube(pi: ExtensionAPI) {
1315
340
  }));
1316
341
  }
1317
342
  });
343
+
1318
344
  pi.on("session_tree", async (_event, ctx) => {
1319
345
  await restore(ctx);
1320
346
  });
@@ -1342,12 +368,12 @@ export default function sonarqube(pi: ExtensionAPI) {
1342
368
  if (params.action === "analyze") {
1343
369
  const state = await analyzeProject(pi, ctx, params.path, filters);
1344
370
  rememberState(state);
1345
- return {
1346
- content: [{ type: "text", text: formatReport(state) }],
1347
- details: state,
1348
- };
371
+ return { content: [{ type: "text", text: formatReport(state) }], details: state };
1349
372
  }
1350
373
 
374
+ if (params.action === "duplications") return toolDuplications(ctx, params.path);
375
+ if (params.action === "metrics") return toolMetrics(ctx, params.path);
376
+
1351
377
  const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
1352
378
  if (!targetState) {
1353
379
  return {
@@ -1359,10 +385,7 @@ export default function sonarqube(pi: ExtensionAPI) {
1359
385
  rememberState(targetState);
1360
386
 
1361
387
  if (params.action === "issues") {
1362
- return {
1363
- content: [{ type: "text", text: formatReport(targetState) }],
1364
- details: targetState,
1365
- };
388
+ return { content: [{ type: "text", text: formatReport(targetState) }], details: targetState };
1366
389
  }
1367
390
 
1368
391
  const index = params.issueIndex ?? 1;
@@ -1380,8 +403,8 @@ export default function sonarqube(pi: ExtensionAPI) {
1380
403
  details: { ...targetState, selectedIssue: issue },
1381
404
  };
1382
405
  } catch (error) {
1383
- const message = sonarErrorMessage(error);
1384
- return { content: [{ type: "text", text: message }], details: { error: message } };
406
+ const msg = sonarErrorMessage(error);
407
+ return { content: [{ type: "text", text: msg }], details: { error: msg } };
1385
408
  }
1386
409
  },
1387
410
 
@@ -1403,37 +426,28 @@ export default function sonarqube(pi: ExtensionAPI) {
1403
426
  },
1404
427
 
1405
428
  renderResult(result, { expanded }, theme, _context) {
1406
- const state =
1407
- result.details as
1408
- | SonarAnalysisState
1409
- | { error?: string; selectedIssue?: SonarIssue }
1410
- | undefined;
1411
- if (state && "error" in state && state.error) {
1412
- return new Text(theme.fg("error", state.error), 0, 0);
429
+ const state = result.details as
430
+ | SonarAnalysisState
431
+ | { error?: string; selectedIssue?: SonarIssue }
432
+ | undefined;
433
+
434
+ if (!state) {
435
+ const text = result.content[0];
436
+ return new Text(text?.type === "text" ? text.text : "", 0, 0);
1413
437
  }
1414
438
 
1415
- if (state && "selectedIssue" in state && state.selectedIssue) {
1416
- const issue = state.selectedIssue;
1417
- const text = result.content[0]?.type === "text" ? result.content[0].text : formatIssue(issue);
1418
- const preview = expanded ? text.split("\n").slice(1).join("\n") : "";
1419
- return new Text(`${theme.fg("accent", formatIssue(issue))}${preview ? `\n${preview}` : ""}`, 0, 0);
439
+ if ("error" in state && state.error) {
440
+ return renderErrorResult(state.error, theme);
1420
441
  }
1421
442
 
1422
- if (state && "issues" in state) {
1423
- const summary = formatSummary(state);
1424
- if (state.issues.length === 0) {
1425
- return new Text(theme.fg("success", summary), 0, 0);
1426
- }
443
+ if ("selectedIssue" in state && state.selectedIssue) {
444
+ const issue = state.selectedIssue;
445
+ const contentText = result.content[0]?.type === "text" ? result.content[0].text : undefined;
446
+ return renderIssueResult(issue, contentText, expanded, theme);
447
+ }
1427
448
 
1428
- const visible = expanded ? state.issues : state.issues.slice(0, 5);
1429
- const lines = [theme.fg("accent", summary)];
1430
- visible.forEach((issue, index) => {
1431
- lines.push(theme.fg("muted", formatIssue(issue, index + 1)));
1432
- });
1433
- if (!expanded && state.issues.length > visible.length) {
1434
- lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
1435
- }
1436
- return new Text(lines.join("\n"), 0, 0);
449
+ if ("issues" in state) {
450
+ return renderIssueListResult(state, expanded, theme);
1437
451
  }
1438
452
 
1439
453
  const text = result.content[0];
@@ -1442,72 +456,31 @@ export default function sonarqube(pi: ExtensionAPI) {
1442
456
  });
1443
457
 
1444
458
  pi.registerCommand("sonarqube", {
1445
- description:
1446
- "Run a local SonarQube analysis, browse the latest issues, or init project config",
459
+ description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
1447
460
  getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
1448
461
  handler: async (args, ctx) => {
1449
462
  try {
1450
463
  const parsed = parseCommandArgs(args);
1451
-
1452
- // --- Help ---
1453
- if (parsed.action === "help") {
1454
- if (ctx.hasUI) {
1455
- ctx.ui.notify(helpText(), "info");
1456
- }
1457
- return;
1458
- }
1459
-
1460
- // --- Init ---
1461
- if (parsed.action === "init") {
1462
- await initCommandHandler(ctx, { alias: parsed.alias, targetInput: parsed.targetInput });
1463
- return;
1464
- }
1465
-
1466
- // --- Analyze ---
1467
- if (parsed.action === "analyze") {
1468
- const state = await analyzeProject(pi, ctx, parsed.targetInput, parsed.filters);
1469
- rememberState(state);
1470
- if (ctx.hasUI) {
1471
- const scope = targetLabel(parsed.targetInput);
1472
- ctx.ui.notify(`${formatSummary(state)}. Use /sonarqube issues${scope} to browse.`, state.issues.length === 0 ? "info" : "warning");
1473
- }
1474
- return;
1475
- }
1476
-
1477
- const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
1478
- if (!targetState) {
1479
- if (ctx.hasUI) {
1480
- ctx.ui.notify(
1481
- `No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`,
1482
- "warning",
1483
- );
1484
- }
1485
- return;
1486
- }
1487
- rememberState(targetState);
1488
-
1489
- // --- Issues ---
1490
- if (parsed.action === "issues") {
1491
- if (targetState.issues.length === 0) {
1492
- if (ctx.hasUI) ctx.ui.notify(formatSummary(targetState), "info");
464
+ switch (parsed.action) {
465
+ case "help":
466
+ if (ctx.hasUI) ctx.ui.notify(helpText(), "info");
467
+ return;
468
+ case "init":
469
+ await initCommandHandler(ctx, { alias: parsed.alias, targetInput: parsed.targetInput });
470
+ return;
471
+ case "analyze": {
472
+ await commandAnalyze(pi, ctx, parsed.targetInput, parsed.filters, rememberState);
1493
473
  return;
1494
474
  }
1495
- const choice = await showIssueBrowser(ctx, targetState);
1496
- if (choice !== null && choice !== undefined) {
1497
- const issue = targetState.issues[choice];
1498
- if (issue) await openIssuePreview(ctx, targetState, issue);
1499
- }
1500
- return;
1501
- }
1502
-
1503
- // --- Open ---
1504
- const index = parsed.issueIndex ?? 1;
1505
- const issue = targetState.issues[index - 1];
1506
- if (!issue) {
1507
- if (ctx.hasUI) ctx.ui.notify(`Issue #${index} was not found.`, "error");
1508
- return;
475
+ case "metrics":
476
+ await commandMetrics(ctx, parsed.targetInput);
477
+ return;
478
+ case "duplications":
479
+ await commandDuplications(pi, ctx, parsed.targetInput, parsed.issueIndex);
480
+ return;
481
+ default:
482
+ await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
1509
483
  }
1510
- await openIssuePreview(ctx, targetState, issue);
1511
484
  } catch (error) {
1512
485
  if (ctx.hasUI) {
1513
486
  ctx.ui.notify(sonarErrorMessage(error), "error");
@@ -1518,3 +491,245 @@ export default function sonarqube(pi: ExtensionAPI) {
1518
491
  },
1519
492
  });
1520
493
  }
494
+
495
+ async function commandAnalyze(
496
+ pi: ExtensionAPI,
497
+ ctx: ExtensionContext,
498
+ targetInput: string | undefined,
499
+ filters: SonarIssueFetchOptions | undefined,
500
+ rememberState: (s: SonarAnalysisState) => void,
501
+ ): Promise<void> {
502
+ const state = await analyzeProject(pi, ctx, targetInput, filters);
503
+ rememberState(state);
504
+ if (ctx.hasUI) {
505
+ const scope = targetLabel(targetInput);
506
+ ctx.ui.notify(
507
+ `${formatSummary(state)}. Use /sonarqube issues${scope} to browse.`,
508
+ state.issues.length === 0 ? "info" : "warning",
509
+ );
510
+ }
511
+ }
512
+
513
+ async function commandMetrics(
514
+ ctx: ExtensionCommandContext,
515
+ targetInput?: string,
516
+ ): Promise<void> {
517
+ const config = await resolveConfig(ctx, targetInput);
518
+ const [measures, issueCounts] = await Promise.all([
519
+ fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
520
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
521
+ ]);
522
+ if (!measures && !issueCounts) {
523
+ if (ctx.hasUI) {
524
+ ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
525
+ }
526
+ return;
527
+ }
528
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
529
+ if (ctx.hasUI) {
530
+ ctx.ui.notify(text, "info");
531
+ }
532
+ }
533
+
534
+ async function commandDuplications(
535
+ pi: ExtensionAPI,
536
+ ctx: ExtensionCommandContext,
537
+ targetInput?: string,
538
+ fileIndex?: number,
539
+ ): Promise<void> {
540
+ const config = await resolveConfig(ctx, targetInput);
541
+ let files: FileDuplication[];
542
+ try {
543
+ files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
544
+ } catch (error) {
545
+ const msg = sonarErrorMessage(error);
546
+ if (msg.includes("403") || msg.includes("Insufficient privileges")) {
547
+ if (ctx.hasUI) {
548
+ ctx.ui.notify("SonarQube token needs 'See Source Code' permission to list duplicated files. Update the token permissions in SonarQube and run /sonarqube init to update the token.", "warning");
549
+ }
550
+ return;
551
+ }
552
+ throw error;
553
+ }
554
+ if (files.length === 0) {
555
+ // Fallback: check project-level metrics for confirmation
556
+ const projectMeasures = await fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal).catch(() => undefined);
557
+ if (projectMeasures && projectMeasures.duplicatedFiles > 0) {
558
+ if (ctx.hasUI) {
559
+ ctx.ui.notify(
560
+ `Cannot list individual files — your SonarQube token lacks "See Source Code" permission. Grant it in SonarQube project permissions, then run /sonarqube init to update the token.`,
561
+ "warning",
562
+ );
563
+ }
564
+ } else if (ctx.hasUI) {
565
+ ctx.ui.notify("No duplications found.", "info");
566
+ }
567
+ return;
568
+ }
569
+
570
+ if (fileIndex !== undefined) {
571
+ await showDuplicationDrillDown(ctx, config, files, fileIndex);
572
+ return;
573
+ }
574
+ await showDuplicationListOrBrowser(ctx, config, files);
575
+ }
576
+
577
+ async function showDuplicationDrillDown(
578
+ ctx: ExtensionCommandContext,
579
+ config: SonarProjectConfig,
580
+ files: FileDuplication[],
581
+ fileIndex: number,
582
+ ): Promise<void> {
583
+ const file = files[fileIndex - 1];
584
+ if (!file) {
585
+ if (ctx.hasUI) ctx.ui.notify(`File #${fileIndex} not found.`, "error");
586
+ return;
587
+ }
588
+ const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
589
+ const text = formatDuplicationBlockDetail(file.filePath, groups);
590
+ if (ctx.mode === "tui") {
591
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
592
+ } else if (ctx.hasUI) {
593
+ ctx.ui.notify(text, "info");
594
+ }
595
+ }
596
+
597
+ async function showDuplicationListOrBrowser(
598
+ ctx: ExtensionCommandContext,
599
+ config: SonarProjectConfig,
600
+ files: FileDuplication[],
601
+ ): Promise<void> {
602
+ if (ctx.mode !== "tui") {
603
+ ctx.ui.notify(formatDuplicationsList(files), "info");
604
+ return;
605
+ }
606
+ const choice = await showDuplicationBrowser(ctx, files);
607
+ if (choice == null) return;
608
+ const file = files[choice];
609
+ const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
610
+ await ctx.ui.editor(`Duplications in ${file.filePath}`, formatDuplicationBlockDetail(file.filePath, groups));
611
+ }
612
+
613
+ async function commandIssuesOrOpen(
614
+ pi: ExtensionAPI,
615
+ ctx: ExtensionCommandContext,
616
+ statesByBaseDir: Map<string, SonarAnalysisState>,
617
+ parsed: { targetInput?: string; filters?: SonarIssueFetchOptions; action: string; issueIndex?: number },
618
+ rememberState: (s: SonarAnalysisState) => void,
619
+ ): Promise<void> {
620
+ const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
621
+ if (!targetState) {
622
+ if (ctx.hasUI) {
623
+ ctx.ui.notify(
624
+ `No SonarQube analysis found for this target. Run /sonarqube analyze${targetLabel(parsed.targetInput)} first.`,
625
+ "warning",
626
+ );
627
+ }
628
+ return;
629
+ }
630
+ rememberState(targetState);
631
+
632
+ if (parsed.action === "issues") {
633
+ await commandShowIssues(ctx, targetState);
634
+ return;
635
+ }
636
+
637
+ // "open"
638
+ const index = parsed.issueIndex ?? 1;
639
+ const issue = targetState.issues[index - 1];
640
+ if (!issue) {
641
+ if (ctx.hasUI) ctx.ui.notify(`Issue #${index} was not found.`, "error");
642
+ return;
643
+ }
644
+ await openIssuePreview(ctx, targetState, issue);
645
+ }
646
+
647
+ async function commandShowIssues(
648
+ ctx: ExtensionCommandContext,
649
+ targetState: SonarAnalysisState,
650
+ ): Promise<void> {
651
+ if (targetState.issues.length === 0) {
652
+ if (ctx.hasUI) ctx.ui.notify(formatSummary(targetState), "info");
653
+ return;
654
+ }
655
+ const choice = await showIssueBrowser(ctx, targetState);
656
+ if (choice != null) {
657
+ const issue = targetState.issues[choice];
658
+ if (issue) await openIssuePreview(ctx, targetState, issue);
659
+ }
660
+ }
661
+
662
+ // ── Tool helper functions ────────────────────────────────────────────────────
663
+
664
+ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
665
+ let config;
666
+ try {
667
+ config = await resolveConfig(ctx, path);
668
+ } catch {
669
+ return {
670
+ content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
671
+ details: { error: "Project not configured." },
672
+ };
673
+ }
674
+ let files: FileDuplication[];
675
+ try {
676
+ files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
677
+ } catch (error) {
678
+ const msg = sonarErrorMessage(error);
679
+ if (msg.includes("403") || msg.includes("Insufficient privileges")) {
680
+ return {
681
+ content: [{ type: "text", text: "SonarQube token needs 'See Source Code' permission to list duplicated files. Grant it in SonarQube project permissions, then update the token via /sonarqube init." }],
682
+ details: { error: "Token missing 'See Source Code' permission." },
683
+ };
684
+ }
685
+ return {
686
+ content: [{ type: "text", text: msg }],
687
+ details: { error: msg },
688
+ };
689
+ }
690
+ if (files.length === 0) {
691
+ return {
692
+ content: [{ type: "text", text: `No duplications found for ${config.projectKey}.` }],
693
+ details: { error: `No duplications found for ${config.projectKey}.` },
694
+ };
695
+ }
696
+ const text = formatDuplicationsList(files);
697
+ return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, files } };
698
+ }
699
+
700
+ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
701
+ let config;
702
+ try {
703
+ config = await resolveConfig(ctx, path);
704
+ } catch {
705
+ return {
706
+ content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
707
+ details: { error: "Project not configured." },
708
+ };
709
+ }
710
+ const [measures, issueCounts] = await Promise.all([
711
+ fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
712
+ fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
713
+ ]);
714
+ if (!measures && !issueCounts) {
715
+ return {
716
+ content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
717
+ details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
718
+ };
719
+ }
720
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
721
+ return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts } };
722
+ }
723
+
724
+ // ── Re-exports for public API ───────────────────────────────────────────────
725
+
726
+ export type {
727
+ SonarAction,
728
+ SonarIssue,
729
+ SonarAnalysisState,
730
+ SonarProjectConfig,
731
+ SonarIssueFetchOptions,
732
+ SonarInitConfig,
733
+ } from "./types.js";
734
+
735
+ export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";