@jonathan0823/pi-sonarqube 0.3.2 → 0.5.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,4 +1,9 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ Theme,
6
+ } from "@earendil-works/pi-coding-agent";
2
7
  import { Text } from "@earendil-works/pi-tui";
3
8
  import { stat } from "node:fs/promises";
4
9
  import { basename } from "node:path";
@@ -68,22 +73,48 @@ import {
68
73
  } from "./ui.js";
69
74
 
70
75
  const SonarToolParams = Type.Object({
71
- action: StringEnum(["analyze", "issues", "open", "metrics", "duplications"] as const),
72
- path: Type.Optional(Type.String({ description: "Target alias or project directory to analyze or inspect" })),
73
- issueIndex: Type.Optional(Type.Number({ description: "1-based issue index to open" })),
76
+ action: StringEnum([
77
+ "analyze",
78
+ "issues",
79
+ "open",
80
+ "metrics",
81
+ "duplications",
82
+ ] as const),
83
+ path: Type.Optional(
84
+ Type.String({
85
+ description: "Target alias or project directory to analyze or inspect",
86
+ }),
87
+ ),
88
+ issueIndex: Type.Optional(
89
+ Type.Number({ description: "1-based issue index to open" }),
90
+ ),
74
91
  severities: Type.Optional(
75
- Type.Array(Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" })),
92
+ Type.Array(
93
+ Type.String({ description: "Issue severity to fetch (e.g. CRITICAL)" }),
94
+ ),
76
95
  ),
77
96
  statuses: Type.Optional(
78
- Type.Array(Type.String({ description: "Issue status to fetch (e.g. OPEN)" })),
97
+ Type.Array(
98
+ Type.String({ description: "Issue status to fetch (e.g. OPEN)" }),
99
+ ),
100
+ ),
101
+ types: Type.Optional(
102
+ Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" })),
103
+ ),
104
+ rules: Type.Optional(
105
+ Type.Array(Type.String({ description: "Rule keys to fetch" })),
79
106
  ),
80
- types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
81
- rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
82
107
  softwareQualities: Type.Optional(
83
- Type.Array(Type.String({ description: "Software quality to fetch (e.g. MAINTAINABILITY)" })),
108
+ Type.Array(
109
+ Type.String({
110
+ description: "Software quality to fetch (e.g. MAINTAINABILITY)",
111
+ }),
112
+ ),
84
113
  ),
85
114
  impactSeverities: Type.Optional(
86
- Type.Array(Type.String({ description: "Impact severity to fetch (e.g. HIGH)" })),
115
+ Type.Array(
116
+ Type.String({ description: "Impact severity to fetch (e.g. HIGH)" }),
117
+ ),
87
118
  ),
88
119
  });
89
120
 
@@ -97,7 +128,11 @@ async function restoreState(
97
128
  statesByBaseDir.clear();
98
129
  let latest: SonarAnalysisState | undefined;
99
130
  for (const entry of ctx.sessionManager.getBranch()) {
100
- if (entry.type === "custom" && entry.customType === STATE_TYPE && entry.data) {
131
+ if (
132
+ entry.type === "custom" &&
133
+ entry.customType === STATE_TYPE &&
134
+ entry.data
135
+ ) {
101
136
  latest = entry.data as SonarAnalysisState;
102
137
  statesByBaseDir.set(latest.baseDir, latest);
103
138
  }
@@ -132,7 +167,12 @@ async function analyzeProject(
132
167
  const dashboardUrl = report["dashboardUrl"];
133
168
  const analysisId = ceTaskUrl
134
169
  ? (analysisUi.setPhase("Waiting for SonarQube analysis..."),
135
- await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
170
+ await waitForAnalysis(
171
+ config.serverUrl,
172
+ config.token,
173
+ ceTaskUrl,
174
+ ctx.signal,
175
+ ))
136
176
  : undefined;
137
177
 
138
178
  analysisUi.setPhase("Fetching issues...");
@@ -154,7 +194,11 @@ async function analyzeProject(
154
194
  );
155
195
 
156
196
  analysisUi.setPhase("Detecting mode...");
157
- const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
197
+ const cleanCodeMode = await fetchCleanCodeMode(
198
+ config.serverUrl,
199
+ config.token,
200
+ ctx.signal,
201
+ );
158
202
 
159
203
  const state = createAnalysisState(config, issues, {
160
204
  dashboardUrl,
@@ -200,26 +244,50 @@ async function collectInitInputs(
200
244
  ctx: ExtensionCommandContext,
201
245
  existing: SonarInitConfig | undefined,
202
246
  baseDir: string,
203
- ): Promise<{ serverUrl: string; projectKey: string; token: string | undefined } | undefined> {
204
- const serverUrlInput = await ctx.ui.editor("SonarQube server URL", existing?.serverUrl ?? "http://localhost:9000");
247
+ ): Promise<
248
+ | { serverUrl: string; projectKey: string; token: string | undefined }
249
+ | undefined
250
+ > {
251
+ const serverUrlInput = await ctx.ui.editor(
252
+ "SonarQube server URL",
253
+ existing?.serverUrl ?? "http://localhost:9000",
254
+ );
205
255
  if (serverUrlInput === undefined) return undefined;
206
- const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
256
+ const serverUrl = normalizeServerUrl(
257
+ serverUrlInput || existing?.serverUrl || "http://localhost:9000",
258
+ );
207
259
 
208
- const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
260
+ const projectKeyInput = await ctx.ui.input(
261
+ "SonarQube project key",
262
+ existing?.projectKey ?? basename(baseDir),
263
+ );
209
264
  if (projectKeyInput === undefined) return undefined;
210
- const projectKey = projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
265
+ const projectKey =
266
+ projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
211
267
 
212
- const tokenInput = await ctx.ui.input("SonarQube token (optional, press Enter to skip)", existing?.token ?? "");
268
+ const tokenInput = await ctx.ui.input(
269
+ "SonarQube token (optional, press Enter to skip)",
270
+ existing?.token ?? "",
271
+ );
213
272
  if (tokenInput === undefined) return undefined;
214
273
 
215
274
  return { serverUrl, projectKey, token: tokenInput.trim() || undefined };
216
275
  }
217
276
 
218
- async function initCommandHandler(ctx: ExtensionCommandContext, options: InitCommandOptions = {}): Promise<void> {
219
- const { baseDir, repoRoot } = await resolveInitTarget(ctx, options.alias, options.targetInput);
277
+ async function initCommandHandler(
278
+ ctx: ExtensionCommandContext,
279
+ options: InitCommandOptions = {},
280
+ ): Promise<void> {
281
+ const { baseDir, repoRoot } = await resolveInitTarget(
282
+ ctx,
283
+ options.alias,
284
+ options.targetInput,
285
+ );
220
286
  const existing = await loadProjectConfig(baseDir);
221
287
 
222
- const confirmed = existing ? await confirmOverwrite(ctx, baseDir, existing) : true;
288
+ const confirmed = existing
289
+ ? await confirmOverwrite(ctx, baseDir, existing)
290
+ : true;
223
291
  if (!confirmed) return;
224
292
 
225
293
  const inputs = await collectInitInputs(ctx, existing, baseDir);
@@ -250,7 +318,10 @@ function notifyInitResult(
250
318
  } else if (propertiesState === "updated") {
251
319
  note = ` and merged ${sonarProjectPropertiesPath(baseDir)}`;
252
320
  }
253
- ctx.ui.notify(`SonarQube config saved${label} to ${projectConfigPath(baseDir)}${note}`, "info");
321
+ ctx.ui.notify(
322
+ `SonarQube config saved${label} to ${projectConfigPath(baseDir)}${note}`,
323
+ "info",
324
+ );
254
325
  }
255
326
 
256
327
  // ── Tool result rendering helpers (reduces renderResult complexity) ─────────
@@ -289,9 +360,10 @@ function renderIssueListResult(
289
360
  lines.push(theme.fg("accent", summary));
290
361
  if (state.measures) {
291
362
  const density = state.measures.duplicatedLinesDensity.toFixed(1);
292
- const detail = state.measures.duplicatedBlocks > 0
293
- ? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
294
- : `Duplication: ${density}% (no duplications detected)`;
363
+ const detail =
364
+ state.measures.duplicatedBlocks > 0
365
+ ? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
366
+ : `Duplication: ${density}% (no duplications detected)`;
295
367
  lines.push(theme.fg("dim", detail));
296
368
  }
297
369
 
@@ -304,7 +376,9 @@ function renderIssueListResult(
304
376
  lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
305
377
  }
306
378
  if (!expanded && state.issues.length > visible.length) {
307
- lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
379
+ lines.push(
380
+ theme.fg("dim", `... ${state.issues.length - visible.length} more`),
381
+ );
308
382
  }
309
383
  return new Text(lines.join("\n"), 0, 0);
310
384
  }
@@ -333,22 +407,55 @@ export default function sonarqube(pi: ExtensionAPI) {
333
407
  const line = lines[cursorLine] ?? "";
334
408
  const beforeCursor = line.slice(0, cursorCol);
335
409
  const match = /^\/sonarqube(?:\s+(.*))?$/.exec(beforeCursor);
336
- if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
410
+ if (!match)
411
+ return current.getSuggestions(
412
+ lines,
413
+ cursorLine,
414
+ cursorCol,
415
+ options,
416
+ );
337
417
 
338
418
  const argumentText = match[1] ?? "";
339
- const { current: currentToken } = splitSonarArgumentContext(argumentText);
340
- const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues, latestState?.cleanCodeMode) ?? [];
341
- const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
342
- const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
419
+ const { current: currentToken } =
420
+ splitSonarArgumentContext(argumentText);
421
+ const extraItems =
422
+ sonarArgumentCompletions(
423
+ argumentText,
424
+ latestState?.issues,
425
+ latestState?.cleanCodeMode,
426
+ ) ?? [];
427
+ const currentSuggestions = await current.getSuggestions(
428
+ lines,
429
+ cursorLine,
430
+ cursorCol,
431
+ options,
432
+ );
433
+ const merged = mergeAutocompleteSuggestions(
434
+ currentToken,
435
+ currentSuggestions,
436
+ extraItems,
437
+ );
343
438
  return merged ?? currentSuggestions;
344
439
  },
345
440
 
346
441
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
347
- return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
442
+ return current.applyCompletion(
443
+ lines,
444
+ cursorLine,
445
+ cursorCol,
446
+ item,
447
+ prefix,
448
+ );
348
449
  },
349
450
 
350
451
  shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
351
- return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
452
+ return (
453
+ current.shouldTriggerFileCompletion?.(
454
+ lines,
455
+ cursorLine,
456
+ cursorCol,
457
+ ) ?? true
458
+ );
352
459
  },
353
460
  }));
354
461
  }
@@ -384,24 +491,43 @@ export default function sonarqube(pi: ExtensionAPI) {
384
491
  if (params.action === "analyze") {
385
492
  const state = await analyzeProject(pi, ctx, params.path, filters);
386
493
  rememberState(state);
387
- return { content: [{ type: "text", text: formatReport(state) }], details: state };
494
+ return {
495
+ content: [{ type: "text", text: formatReport(state) }],
496
+ details: state,
497
+ };
388
498
  }
389
499
 
390
- if (params.action === "duplications") return toolDuplications(ctx, params.path);
500
+ if (params.action === "duplications")
501
+ return toolDuplications(ctx, params.path);
391
502
  if (params.action === "metrics") return toolMetrics(ctx, params.path);
392
503
 
393
- const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
504
+ const targetState = await resolveTargetState(
505
+ ctx,
506
+ statesByBaseDir,
507
+ params.path,
508
+ filters,
509
+ );
394
510
  if (!targetState) {
395
511
  return {
396
- content: [{ type: "text", text: "No SonarQube analysis has been run for this target yet." }],
397
- details: { error: "No SonarQube analysis has been run for this target yet." },
512
+ content: [
513
+ {
514
+ type: "text",
515
+ text: "No SonarQube analysis has been run for this target yet.",
516
+ },
517
+ ],
518
+ details: {
519
+ error: "No SonarQube analysis has been run for this target yet.",
520
+ },
398
521
  };
399
522
  }
400
523
 
401
524
  rememberState(targetState);
402
525
 
403
526
  if (params.action === "issues") {
404
- return { content: [{ type: "text", text: formatReport(targetState) }], details: targetState };
527
+ return {
528
+ content: [{ type: "text", text: formatReport(targetState) }],
529
+ details: targetState,
530
+ };
405
531
  }
406
532
 
407
533
  const index = params.issueIndex ?? 1;
@@ -415,12 +541,20 @@ export default function sonarqube(pi: ExtensionAPI) {
415
541
 
416
542
  const preview = await buildIssuePreview(targetState.baseDir, issue);
417
543
  return {
418
- content: [{ type: "text", text: `${formatIssue(issue, index)}\n\n${preview}` }],
544
+ content: [
545
+ {
546
+ type: "text",
547
+ text: `${formatIssue(issue, index)}\n\n${preview}`,
548
+ },
549
+ ],
419
550
  details: { ...targetState, selectedIssue: issue },
420
551
  };
421
552
  } catch (error) {
422
553
  const msg = sonarErrorMessage(error);
423
- return { content: [{ type: "text", text: msg }], details: { error: msg } };
554
+ return {
555
+ content: [{ type: "text", text: msg }],
556
+ details: { error: msg },
557
+ };
424
558
  }
425
559
  },
426
560
 
@@ -433,7 +567,9 @@ export default function sonarqube(pi: ExtensionAPI) {
433
567
  softwareQualities: args.softwareQualities,
434
568
  impactSeverities: args.impactSeverities,
435
569
  });
436
- const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
570
+ const filterText = filters
571
+ ? ` ${theme.fg("muted", issueFilterLabel(filters))}`
572
+ : "";
437
573
  return new Text(
438
574
  `${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${
439
575
  args.path ? ` ${theme.fg("accent", args.path)}` : ""
@@ -460,7 +596,10 @@ export default function sonarqube(pi: ExtensionAPI) {
460
596
 
461
597
  if ("selectedIssue" in state && state.selectedIssue) {
462
598
  const issue = state.selectedIssue;
463
- const contentText = result.content[0]?.type === "text" ? result.content[0].text : undefined;
599
+ const contentText =
600
+ result.content[0]?.type === "text"
601
+ ? result.content[0].text
602
+ : undefined;
464
603
  return renderIssueResult(issue, contentText, expanded, theme);
465
604
  }
466
605
 
@@ -474,8 +613,14 @@ export default function sonarqube(pi: ExtensionAPI) {
474
613
  });
475
614
 
476
615
  pi.registerCommand("sonarqube", {
477
- description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
478
- getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues, latestState?.cleanCodeMode),
616
+ description:
617
+ "Run a local SonarQube analysis, browse the latest issues, or init project config",
618
+ getArgumentCompletions: (argumentPrefix) =>
619
+ sonarArgumentCompletions(
620
+ argumentPrefix,
621
+ latestState?.issues,
622
+ latestState?.cleanCodeMode,
623
+ ),
479
624
  handler: async (args, ctx) => {
480
625
  try {
481
626
  const parsed = parseCommandArgs(args);
@@ -484,20 +629,40 @@ export default function sonarqube(pi: ExtensionAPI) {
484
629
  if (ctx.hasUI) ctx.ui.notify(helpText(), "info");
485
630
  return;
486
631
  case "init":
487
- await initCommandHandler(ctx, { alias: parsed.alias, targetInput: parsed.targetInput });
632
+ await initCommandHandler(ctx, {
633
+ alias: parsed.alias,
634
+ targetInput: parsed.targetInput,
635
+ });
488
636
  return;
489
637
  case "analyze": {
490
- await commandAnalyze(pi, ctx, parsed.targetInput, parsed.filters, rememberState);
638
+ await commandAnalyze(
639
+ pi,
640
+ ctx,
641
+ parsed.targetInput,
642
+ parsed.filters,
643
+ rememberState,
644
+ );
491
645
  return;
492
646
  }
493
647
  case "metrics":
494
648
  await commandMetrics(ctx, parsed.targetInput);
495
649
  return;
496
650
  case "duplications":
497
- await commandDuplications(pi, ctx, parsed.targetInput, parsed.issueIndex);
651
+ await commandDuplications(
652
+ pi,
653
+ ctx,
654
+ parsed.targetInput,
655
+ parsed.issueIndex,
656
+ );
498
657
  return;
499
658
  default:
500
- await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
659
+ await commandIssuesOrOpen(
660
+ pi,
661
+ ctx,
662
+ statesByBaseDir,
663
+ parsed,
664
+ rememberState,
665
+ );
501
666
  }
502
667
  } catch (error) {
503
668
  if (ctx.hasUI) {
@@ -533,19 +698,48 @@ async function commandMetrics(
533
698
  targetInput?: string,
534
699
  ): Promise<void> {
535
700
  const config = await resolveConfig(ctx, targetInput);
536
- const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
701
+ const cleanCodeMode = await fetchCleanCodeMode(
702
+ config.serverUrl,
703
+ config.token,
704
+ ctx.signal,
705
+ );
537
706
  const [measures, issueCounts, qualityCounts] = await Promise.all([
538
- fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
539
- fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
540
- fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
707
+ fetchDuplicationMeasures(
708
+ config.serverUrl,
709
+ config.token,
710
+ config.projectKey,
711
+ ctx.signal,
712
+ ),
713
+ fetchIssueSeverityCounts(
714
+ config.serverUrl,
715
+ config.token,
716
+ config.projectKey,
717
+ ctx.signal,
718
+ cleanCodeMode,
719
+ ),
720
+ fetchIssueQualityCounts(
721
+ config.serverUrl,
722
+ config.token,
723
+ config.projectKey,
724
+ ctx.signal,
725
+ ),
541
726
  ]);
542
727
  if (!measures && !issueCounts && !qualityCounts) {
543
728
  if (ctx.hasUI) {
544
- ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
729
+ ctx.ui.notify(
730
+ `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
731
+ "warning",
732
+ );
545
733
  }
546
734
  return;
547
735
  }
548
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
736
+ const text = formatMetricsOutput({
737
+ projectKey: config.projectKey,
738
+ measures,
739
+ issueCounts,
740
+ issueQualityCounts: qualityCounts,
741
+ cleanCodeMode,
742
+ });
549
743
  if (ctx.hasUI) {
550
744
  ctx.ui.notify(text, "info");
551
745
  }
@@ -560,12 +754,20 @@ async function commandDuplications(
560
754
  const config = await resolveConfig(ctx, targetInput);
561
755
  let files: FileDuplication[];
562
756
  try {
563
- files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
757
+ files = await fetchFileDuplications(
758
+ config.serverUrl,
759
+ config.token,
760
+ config.projectKey,
761
+ ctx.signal,
762
+ );
564
763
  } catch (error) {
565
764
  const msg = sonarErrorMessage(error);
566
765
  if (msg.includes("403") || msg.includes("Insufficient privileges")) {
567
766
  if (ctx.hasUI) {
568
- 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");
767
+ ctx.ui.notify(
768
+ "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.",
769
+ "warning",
770
+ );
569
771
  }
570
772
  return;
571
773
  }
@@ -594,8 +796,18 @@ async function showDuplicationDrillDown(
594
796
  if (ctx.hasUI) ctx.ui.notify(`File #${fileIndex} not found.`, "error");
595
797
  return;
596
798
  }
597
- const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
598
- const text = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
799
+ const groups = await fetchFileDuplicationBlocks(
800
+ config.serverUrl,
801
+ config.token,
802
+ file.fileKey,
803
+ config.projectKey,
804
+ ctx.signal,
805
+ );
806
+ const text = await buildDuplicationPreview(
807
+ config.baseDir,
808
+ file.filePath,
809
+ groups,
810
+ );
599
811
  if (ctx.mode === "tui") {
600
812
  await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
601
813
  } else if (ctx.hasUI) {
@@ -615,18 +827,43 @@ async function showDuplicationListOrBrowser(
615
827
  const choice = await showDuplicationBrowser(ctx, files);
616
828
  if (choice == null) return;
617
829
  const file = files[choice];
618
- const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
619
- await ctx.ui.editor(`Duplications in ${file.filePath}`, await buildDuplicationPreview(config.baseDir, file.filePath, groups));
830
+ const groups = await fetchFileDuplicationBlocks(
831
+ config.serverUrl,
832
+ config.token,
833
+ file.fileKey,
834
+ config.projectKey,
835
+ ctx.signal,
836
+ );
837
+ const lines = [`Duplications in ${file.filePath}`];
838
+ for (const [i, group] of groups.entries()) {
839
+ lines.push("", `Block ${i + 1}:`);
840
+ for (const block of group.blocks) {
841
+ const end = block.from + block.size - 1;
842
+ lines.push(` ${block.filePath}:${block.from}-${end}`);
843
+ }
844
+ }
845
+ ctx.ui.setEditorText(lines.join("\n"));
846
+ ctx.ui.notify("Duplication loaded into editor — press Enter to send", "info");
620
847
  }
621
848
 
622
849
  async function commandIssuesOrOpen(
623
850
  pi: ExtensionAPI,
624
851
  ctx: ExtensionCommandContext,
625
852
  statesByBaseDir: Map<string, SonarAnalysisState>,
626
- parsed: { targetInput?: string; filters?: SonarIssueFetchOptions; action: string; issueIndex?: number },
853
+ parsed: {
854
+ targetInput?: string;
855
+ filters?: SonarIssueFetchOptions;
856
+ action: string;
857
+ issueIndex?: number;
858
+ },
627
859
  rememberState: (s: SonarAnalysisState) => void,
628
860
  ): Promise<void> {
629
- const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
861
+ const targetState = await resolveTargetState(
862
+ ctx,
863
+ statesByBaseDir,
864
+ parsed.targetInput,
865
+ parsed.filters,
866
+ );
630
867
  if (!targetState) {
631
868
  if (ctx.hasUI) {
632
869
  ctx.ui.notify(
@@ -664,30 +901,60 @@ async function commandShowIssues(
664
901
  const choice = await showIssueBrowser(ctx, targetState);
665
902
  if (choice != null) {
666
903
  const issue = targetState.issues[choice];
667
- if (issue) await openIssuePreview(ctx, targetState, issue);
904
+ if (issue) {
905
+ const line = issue.line ? `:${issue.line}` : "";
906
+ const rule = issue.ruleName
907
+ ? `${issue.rule} (${issue.ruleName})`
908
+ : issue.rule;
909
+ ctx.ui.setEditorText(
910
+ `${issue.severity} ${issue.filePath}${line} — ${rule} — ${issue.message}`,
911
+ );
912
+ ctx.ui.notify("Issue loaded into editor — press Enter to send", "info");
913
+ }
668
914
  }
669
915
  }
670
916
 
671
917
  // ── Tool helper functions ────────────────────────────────────────────────────
672
918
 
673
- async function toolDuplications(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
919
+ async function toolDuplications(
920
+ ctx: any,
921
+ path: string | undefined,
922
+ ): Promise<{
923
+ content: Array<{ type: "text"; text: string }>;
924
+ details: Record<string, unknown>;
925
+ }> {
674
926
  let config;
675
927
  try {
676
928
  config = await resolveConfig(ctx, path);
677
929
  } catch {
678
930
  return {
679
- content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
931
+ content: [
932
+ {
933
+ type: "text",
934
+ text: "Project not configured. Run /sonarqube init first.",
935
+ },
936
+ ],
680
937
  details: { error: "Project not configured." },
681
938
  };
682
939
  }
683
940
  let files: FileDuplication[];
684
941
  try {
685
- files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
942
+ files = await fetchFileDuplications(
943
+ config.serverUrl,
944
+ config.token,
945
+ config.projectKey,
946
+ ctx.signal,
947
+ );
686
948
  } catch (error) {
687
949
  const msg = sonarErrorMessage(error);
688
950
  if (msg.includes("403") || msg.includes("Insufficient privileges")) {
689
951
  return {
690
- 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." }],
952
+ content: [
953
+ {
954
+ type: "text",
955
+ 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.",
956
+ },
957
+ ],
691
958
  details: { error: "Token missing 'See Source Code' permission." },
692
959
  };
693
960
  }
@@ -698,7 +965,12 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
698
965
  }
699
966
  if (files.length === 0) {
700
967
  return {
701
- content: [{ type: "text", text: `No duplications found for ${config.projectKey}.` }],
968
+ content: [
969
+ {
970
+ type: "text",
971
+ text: `No duplications found for ${config.projectKey}.`,
972
+ },
973
+ ],
702
974
  details: { error: `No duplications found for ${config.projectKey}.` },
703
975
  };
704
976
  }
@@ -707,12 +979,23 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
707
979
  const topContexts = await Promise.all(
708
980
  topFiles.map(async (file) => {
709
981
  try {
710
- const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
711
- const preview = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
982
+ const groups = await fetchFileDuplicationBlocks(
983
+ config.serverUrl,
984
+ config.token,
985
+ file.fileKey,
986
+ config.projectKey,
987
+ ctx.signal,
988
+ );
989
+ const preview = await buildDuplicationPreview(
990
+ config.baseDir,
991
+ file.filePath,
992
+ groups,
993
+ );
712
994
  return {
713
995
  filePath: file.filePath,
714
996
  duplicatedBlocks: file.duplicatedBlocks,
715
997
  duplicatedLines: file.duplicatedLines,
998
+ duplicatedLinesDensity: file.duplicatedLinesDensity,
716
999
  preview,
717
1000
  };
718
1001
  } catch (error) {
@@ -720,6 +1003,7 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
720
1003
  filePath: file.filePath,
721
1004
  duplicatedBlocks: file.duplicatedBlocks,
722
1005
  duplicatedLines: file.duplicatedLines,
1006
+ duplicatedLinesDensity: file.duplicatedLinesDensity,
723
1007
  preview: `Duplications in ${file.filePath}\n\n<failed to load duplicated lines: ${sonarErrorMessage(error)}>`,
724
1008
  };
725
1009
  }
@@ -727,12 +1011,22 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
727
1011
  );
728
1012
  const remaining = files.length - topContexts.length;
729
1013
  const content: Array<{ type: "text"; text: string }> = [
730
- { type: "text", text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.` },
1014
+ {
1015
+ type: "text",
1016
+ text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.`,
1017
+ },
731
1018
  ...topContexts.map((item, index) => ({
732
1019
  type: "text" as const,
733
- text: `#${index + 1} ${item.filePath} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
1020
+ text: `#${index + 1} ${item.filePath} dup%=${item.duplicatedLinesDensity.toFixed(1)} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
734
1021
  })),
735
- ...(remaining > 0 ? [{ type: "text" as const, text: `+ ${remaining} more file(s) not shown` }] : []),
1022
+ ...(remaining > 0
1023
+ ? [
1024
+ {
1025
+ type: "text" as const,
1026
+ text: `+ ${remaining} more file(s) not shown`,
1027
+ },
1028
+ ]
1029
+ : []),
736
1030
  ];
737
1031
  return {
738
1032
  content,
@@ -745,30 +1039,83 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
745
1039
  };
746
1040
  }
747
1041
 
748
- async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
1042
+ async function toolMetrics(
1043
+ ctx: any,
1044
+ path: string | undefined,
1045
+ ): Promise<{
1046
+ content: Array<{ type: "text"; text: string }>;
1047
+ details: Record<string, unknown>;
1048
+ }> {
749
1049
  let config;
750
1050
  try {
751
1051
  config = await resolveConfig(ctx, path);
752
1052
  } catch {
753
1053
  return {
754
- content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
1054
+ content: [
1055
+ {
1056
+ type: "text",
1057
+ text: "Project not configured. Run /sonarqube init first.",
1058
+ },
1059
+ ],
755
1060
  details: { error: "Project not configured." },
756
1061
  };
757
1062
  }
758
- const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
1063
+ const cleanCodeMode = await fetchCleanCodeMode(
1064
+ config.serverUrl,
1065
+ config.token,
1066
+ ctx.signal,
1067
+ );
759
1068
  const [measures, issueCounts, qualityCounts] = await Promise.all([
760
- fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
761
- fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
762
- fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
1069
+ fetchDuplicationMeasures(
1070
+ config.serverUrl,
1071
+ config.token,
1072
+ config.projectKey,
1073
+ ctx.signal,
1074
+ ),
1075
+ fetchIssueSeverityCounts(
1076
+ config.serverUrl,
1077
+ config.token,
1078
+ config.projectKey,
1079
+ ctx.signal,
1080
+ cleanCodeMode,
1081
+ ),
1082
+ fetchIssueQualityCounts(
1083
+ config.serverUrl,
1084
+ config.token,
1085
+ config.projectKey,
1086
+ ctx.signal,
1087
+ ),
763
1088
  ]);
764
1089
  if (!measures && !issueCounts && !qualityCounts) {
765
1090
  return {
766
- content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
767
- details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
1091
+ content: [
1092
+ {
1093
+ type: "text",
1094
+ text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
1095
+ },
1096
+ ],
1097
+ details: {
1098
+ error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
1099
+ },
768
1100
  };
769
1101
  }
770
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
771
- return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode } };
1102
+ const text = formatMetricsOutput({
1103
+ projectKey: config.projectKey,
1104
+ measures,
1105
+ issueCounts,
1106
+ issueQualityCounts: qualityCounts,
1107
+ cleanCodeMode,
1108
+ });
1109
+ return {
1110
+ content: [{ type: "text", text }],
1111
+ details: {
1112
+ projectKey: config.projectKey,
1113
+ measures,
1114
+ issueCounts,
1115
+ issueQualityCounts: qualityCounts,
1116
+ cleanCodeMode,
1117
+ },
1118
+ };
772
1119
  }
773
1120
 
774
1121
  // ── Re-exports for public API ───────────────────────────────────────────────
@@ -783,4 +1130,8 @@ export type {
783
1130
  IssueQualityCounts,
784
1131
  } from "./types.js";
785
1132
 
786
- export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";
1133
+ export {
1134
+ projectConfigPath,
1135
+ loadProjectConfig,
1136
+ saveProjectConfig,
1137
+ } from "./config.js";