@jonathan0823/pi-sonarqube 0.4.0 → 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,7 +827,13 @@ 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);
830
+ const groups = await fetchFileDuplicationBlocks(
831
+ config.serverUrl,
832
+ config.token,
833
+ file.fileKey,
834
+ config.projectKey,
835
+ ctx.signal,
836
+ );
619
837
  const lines = [`Duplications in ${file.filePath}`];
620
838
  for (const [i, group] of groups.entries()) {
621
839
  lines.push("", `Block ${i + 1}:`);
@@ -632,10 +850,20 @@ async function commandIssuesOrOpen(
632
850
  pi: ExtensionAPI,
633
851
  ctx: ExtensionCommandContext,
634
852
  statesByBaseDir: Map<string, SonarAnalysisState>,
635
- parsed: { targetInput?: string; filters?: SonarIssueFetchOptions; action: string; issueIndex?: number },
853
+ parsed: {
854
+ targetInput?: string;
855
+ filters?: SonarIssueFetchOptions;
856
+ action: string;
857
+ issueIndex?: number;
858
+ },
636
859
  rememberState: (s: SonarAnalysisState) => void,
637
860
  ): Promise<void> {
638
- 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
+ );
639
867
  if (!targetState) {
640
868
  if (ctx.hasUI) {
641
869
  ctx.ui.notify(
@@ -675,8 +903,12 @@ async function commandShowIssues(
675
903
  const issue = targetState.issues[choice];
676
904
  if (issue) {
677
905
  const line = issue.line ? `:${issue.line}` : "";
678
- const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
679
- ctx.ui.setEditorText(`${issue.severity} ${issue.filePath}${line} — ${rule} — ${issue.message}`);
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
+ );
680
912
  ctx.ui.notify("Issue loaded into editor — press Enter to send", "info");
681
913
  }
682
914
  }
@@ -684,24 +916,45 @@ async function commandShowIssues(
684
916
 
685
917
  // ── Tool helper functions ────────────────────────────────────────────────────
686
918
 
687
- 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
+ }> {
688
926
  let config;
689
927
  try {
690
928
  config = await resolveConfig(ctx, path);
691
929
  } catch {
692
930
  return {
693
- 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
+ ],
694
937
  details: { error: "Project not configured." },
695
938
  };
696
939
  }
697
940
  let files: FileDuplication[];
698
941
  try {
699
- 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
+ );
700
948
  } catch (error) {
701
949
  const msg = sonarErrorMessage(error);
702
950
  if (msg.includes("403") || msg.includes("Insufficient privileges")) {
703
951
  return {
704
- 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
+ ],
705
958
  details: { error: "Token missing 'See Source Code' permission." },
706
959
  };
707
960
  }
@@ -712,7 +965,12 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
712
965
  }
713
966
  if (files.length === 0) {
714
967
  return {
715
- 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
+ ],
716
974
  details: { error: `No duplications found for ${config.projectKey}.` },
717
975
  };
718
976
  }
@@ -721,12 +979,23 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
721
979
  const topContexts = await Promise.all(
722
980
  topFiles.map(async (file) => {
723
981
  try {
724
- const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
725
- 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
+ );
726
994
  return {
727
995
  filePath: file.filePath,
728
996
  duplicatedBlocks: file.duplicatedBlocks,
729
997
  duplicatedLines: file.duplicatedLines,
998
+ duplicatedLinesDensity: file.duplicatedLinesDensity,
730
999
  preview,
731
1000
  };
732
1001
  } catch (error) {
@@ -734,6 +1003,7 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
734
1003
  filePath: file.filePath,
735
1004
  duplicatedBlocks: file.duplicatedBlocks,
736
1005
  duplicatedLines: file.duplicatedLines,
1006
+ duplicatedLinesDensity: file.duplicatedLinesDensity,
737
1007
  preview: `Duplications in ${file.filePath}\n\n<failed to load duplicated lines: ${sonarErrorMessage(error)}>`,
738
1008
  };
739
1009
  }
@@ -741,12 +1011,22 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
741
1011
  );
742
1012
  const remaining = files.length - topContexts.length;
743
1013
  const content: Array<{ type: "text"; text: string }> = [
744
- { 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
+ },
745
1018
  ...topContexts.map((item, index) => ({
746
1019
  type: "text" as const,
747
- 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}`,
748
1021
  })),
749
- ...(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
+ : []),
750
1030
  ];
751
1031
  return {
752
1032
  content,
@@ -759,30 +1039,83 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
759
1039
  };
760
1040
  }
761
1041
 
762
- 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
+ }> {
763
1049
  let config;
764
1050
  try {
765
1051
  config = await resolveConfig(ctx, path);
766
1052
  } catch {
767
1053
  return {
768
- 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
+ ],
769
1060
  details: { error: "Project not configured." },
770
1061
  };
771
1062
  }
772
- 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
+ );
773
1068
  const [measures, issueCounts, qualityCounts] = await Promise.all([
774
- fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
775
- fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
776
- 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
+ ),
777
1088
  ]);
778
1089
  if (!measures && !issueCounts && !qualityCounts) {
779
1090
  return {
780
- content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
781
- 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
+ },
782
1100
  };
783
1101
  }
784
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
785
- 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
+ };
786
1119
  }
787
1120
 
788
1121
  // ── Re-exports for public API ───────────────────────────────────────────────
@@ -797,4 +1130,8 @@ export type {
797
1130
  IssueQualityCounts,
798
1131
  } from "./types.js";
799
1132
 
800
- export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";
1133
+ export {
1134
+ projectConfigPath,
1135
+ loadProjectConfig,
1136
+ saveProjectConfig,
1137
+ } from "./config.js";