@jonathan0823/pi-sonarqube 0.4.0 → 0.6.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,54 @@ 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
+ ),
118
+ ),
119
+ pathScope: Type.Optional(
120
+ Type.String({
121
+ description:
122
+ "Scope issues to a specific file or directory path (e.g. src/api.ts or src/). Use this instead of the `path` parameter for file-level filtering.",
123
+ }),
87
124
  ),
88
125
  });
89
126
 
@@ -97,7 +134,11 @@ async function restoreState(
97
134
  statesByBaseDir.clear();
98
135
  let latest: SonarAnalysisState | undefined;
99
136
  for (const entry of ctx.sessionManager.getBranch()) {
100
- if (entry.type === "custom" && entry.customType === STATE_TYPE && entry.data) {
137
+ if (
138
+ entry.type === "custom" &&
139
+ entry.customType === STATE_TYPE &&
140
+ entry.data
141
+ ) {
101
142
  latest = entry.data as SonarAnalysisState;
102
143
  statesByBaseDir.set(latest.baseDir, latest);
103
144
  }
@@ -115,6 +156,7 @@ async function analyzeProject(
115
156
  filters?: SonarIssueFetchOptions,
116
157
  ): Promise<SonarAnalysisState> {
117
158
  const config = await resolveConfig(ctx, inputPath);
159
+ _cachedConfig = { serverUrl: config.serverUrl, projectKey: config.projectKey, token: config.token };
118
160
  const baseDirStat = await stat(config.baseDir).catch(() => undefined);
119
161
  if (!baseDirStat?.isDirectory()) {
120
162
  throw new Error(`Project directory not found: ${config.baseDir}`);
@@ -132,7 +174,12 @@ async function analyzeProject(
132
174
  const dashboardUrl = report["dashboardUrl"];
133
175
  const analysisId = ceTaskUrl
134
176
  ? (analysisUi.setPhase("Waiting for SonarQube analysis..."),
135
- await waitForAnalysis(config.serverUrl, config.token, ceTaskUrl, ctx.signal))
177
+ await waitForAnalysis(
178
+ config.serverUrl,
179
+ config.token,
180
+ ceTaskUrl,
181
+ ctx.signal,
182
+ ))
136
183
  : undefined;
137
184
 
138
185
  analysisUi.setPhase("Fetching issues...");
@@ -143,6 +190,7 @@ async function analyzeProject(
143
190
  config.projectKey,
144
191
  ctx.signal,
145
192
  normalizedFilters,
193
+ config.baseDir,
146
194
  );
147
195
 
148
196
  analysisUi.setPhase("Fetching metrics...");
@@ -154,7 +202,11 @@ async function analyzeProject(
154
202
  );
155
203
 
156
204
  analysisUi.setPhase("Detecting mode...");
157
- const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
205
+ const cleanCodeMode = await fetchCleanCodeMode(
206
+ config.serverUrl,
207
+ config.token,
208
+ ctx.signal,
209
+ );
158
210
 
159
211
  const state = createAnalysisState(config, issues, {
160
212
  dashboardUrl,
@@ -200,26 +252,50 @@ async function collectInitInputs(
200
252
  ctx: ExtensionCommandContext,
201
253
  existing: SonarInitConfig | undefined,
202
254
  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");
255
+ ): Promise<
256
+ | { serverUrl: string; projectKey: string; token: string | undefined }
257
+ | undefined
258
+ > {
259
+ const serverUrlInput = await ctx.ui.editor(
260
+ "SonarQube server URL",
261
+ existing?.serverUrl ?? "http://localhost:9000",
262
+ );
205
263
  if (serverUrlInput === undefined) return undefined;
206
- const serverUrl = normalizeServerUrl(serverUrlInput || existing?.serverUrl || "http://localhost:9000");
264
+ const serverUrl = normalizeServerUrl(
265
+ serverUrlInput || existing?.serverUrl || "http://localhost:9000",
266
+ );
207
267
 
208
- const projectKeyInput = await ctx.ui.input("SonarQube project key", existing?.projectKey ?? basename(baseDir));
268
+ const projectKeyInput = await ctx.ui.input(
269
+ "SonarQube project key",
270
+ existing?.projectKey ?? basename(baseDir),
271
+ );
209
272
  if (projectKeyInput === undefined) return undefined;
210
- const projectKey = projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
273
+ const projectKey =
274
+ projectKeyInput || existing?.projectKey || slugify(basename(baseDir));
211
275
 
212
- const tokenInput = await ctx.ui.input("SonarQube token (optional, press Enter to skip)", existing?.token ?? "");
276
+ const tokenInput = await ctx.ui.input(
277
+ "SonarQube token (optional, press Enter to skip)",
278
+ existing?.token ?? "",
279
+ );
213
280
  if (tokenInput === undefined) return undefined;
214
281
 
215
282
  return { serverUrl, projectKey, token: tokenInput.trim() || undefined };
216
283
  }
217
284
 
218
- async function initCommandHandler(ctx: ExtensionCommandContext, options: InitCommandOptions = {}): Promise<void> {
219
- const { baseDir, repoRoot } = await resolveInitTarget(ctx, options.alias, options.targetInput);
285
+ async function initCommandHandler(
286
+ ctx: ExtensionCommandContext,
287
+ options: InitCommandOptions = {},
288
+ ): Promise<void> {
289
+ const { baseDir, repoRoot } = await resolveInitTarget(
290
+ ctx,
291
+ options.alias,
292
+ options.targetInput,
293
+ );
220
294
  const existing = await loadProjectConfig(baseDir);
221
295
 
222
- const confirmed = existing ? await confirmOverwrite(ctx, baseDir, existing) : true;
296
+ const confirmed = existing
297
+ ? await confirmOverwrite(ctx, baseDir, existing)
298
+ : true;
223
299
  if (!confirmed) return;
224
300
 
225
301
  const inputs = await collectInitInputs(ctx, existing, baseDir);
@@ -250,7 +326,10 @@ function notifyInitResult(
250
326
  } else if (propertiesState === "updated") {
251
327
  note = ` and merged ${sonarProjectPropertiesPath(baseDir)}`;
252
328
  }
253
- ctx.ui.notify(`SonarQube config saved${label} to ${projectConfigPath(baseDir)}${note}`, "info");
329
+ ctx.ui.notify(
330
+ `SonarQube config saved${label} to ${projectConfigPath(baseDir)}${note}`,
331
+ "info",
332
+ );
254
333
  }
255
334
 
256
335
  // ── Tool result rendering helpers (reduces renderResult complexity) ─────────
@@ -289,9 +368,10 @@ function renderIssueListResult(
289
368
  lines.push(theme.fg("accent", summary));
290
369
  if (state.measures) {
291
370
  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)`;
371
+ const detail =
372
+ state.measures.duplicatedBlocks > 0
373
+ ? `Duplication: ${density}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
374
+ : `Duplication: ${density}% (no duplications detected)`;
295
375
  lines.push(theme.fg("dim", detail));
296
376
  }
297
377
 
@@ -304,13 +384,30 @@ function renderIssueListResult(
304
384
  lines.push(theme.fg("muted", formatIssue(issue, i + 1)));
305
385
  }
306
386
  if (!expanded && state.issues.length > visible.length) {
307
- lines.push(theme.fg("dim", `... ${state.issues.length - visible.length} more`));
387
+ lines.push(
388
+ theme.fg("dim", `... ${state.issues.length - visible.length} more`),
389
+ );
308
390
  }
309
391
  return new Text(lines.join("\n"), 0, 0);
310
392
  }
311
393
 
312
394
  // ── Extension entrypoint ────────────────────────────────────────────────────
313
395
 
396
+ // ponytail: lazy config cache so rule autocomplete can fetch from the server
397
+ let _cachedConfig: { serverUrl: string; projectKey: string; token?: string } | undefined;
398
+
399
+ async function cachedServerConfig(cwd: string): Promise<typeof _cachedConfig> {
400
+ if (!_cachedConfig) {
401
+ try {
402
+ const cfg = await resolveConfig({ cwd });
403
+ _cachedConfig = { serverUrl: cfg.serverUrl, projectKey: cfg.projectKey, token: cfg.token };
404
+ } catch {
405
+ // fall through
406
+ }
407
+ }
408
+ return _cachedConfig;
409
+ }
410
+
314
411
  export default function sonarqube(pi: ExtensionAPI) {
315
412
  const statesByBaseDir = new Map<string, SonarAnalysisState>();
316
413
  let latestState: SonarAnalysisState | undefined;
@@ -333,22 +430,60 @@ export default function sonarqube(pi: ExtensionAPI) {
333
430
  const line = lines[cursorLine] ?? "";
334
431
  const beforeCursor = line.slice(0, cursorCol);
335
432
  const match = /^\/sonarqube(?:\s+(.*))?$/.exec(beforeCursor);
336
- if (!match) return current.getSuggestions(lines, cursorLine, cursorCol, options);
433
+ if (!match)
434
+ return current.getSuggestions(
435
+ lines,
436
+ cursorLine,
437
+ cursorCol,
438
+ options,
439
+ );
337
440
 
338
441
  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);
442
+ const { current: currentToken } =
443
+ splitSonarArgumentContext(argumentText);
444
+ const cfg = await cachedServerConfig(ctx.cwd);
445
+ const extraItems =
446
+ (await sonarArgumentCompletions(
447
+ argumentText,
448
+ latestState?.issues,
449
+ latestState?.cleanCodeMode,
450
+ undefined,
451
+ cfg?.serverUrl,
452
+ cfg?.token,
453
+ cfg?.projectKey,
454
+ )) ?? [];
455
+ const currentSuggestions = await current.getSuggestions(
456
+ lines,
457
+ cursorLine,
458
+ cursorCol,
459
+ options,
460
+ );
461
+ const merged = mergeAutocompleteSuggestions(
462
+ currentToken,
463
+ currentSuggestions,
464
+ extraItems,
465
+ );
343
466
  return merged ?? currentSuggestions;
344
467
  },
345
468
 
346
469
  applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
347
- return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
470
+ return current.applyCompletion(
471
+ lines,
472
+ cursorLine,
473
+ cursorCol,
474
+ item,
475
+ prefix,
476
+ );
348
477
  },
349
478
 
350
479
  shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
351
- return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
480
+ return (
481
+ current.shouldTriggerFileCompletion?.(
482
+ lines,
483
+ cursorLine,
484
+ cursorCol,
485
+ ) ?? true
486
+ );
352
487
  },
353
488
  }));
354
489
  }
@@ -366,6 +501,7 @@ export default function sonarqube(pi: ExtensionAPI) {
366
501
  promptGuidelines: [
367
502
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
368
503
  "Use the optional severity, status, type, rule, quality, and impact severity filters to fetch only the most relevant issues.",
504
+ "To scope issues to a specific file or directory, use the `pathScope` parameter (e.g. pathScope: 'src/api.ts') — do not use the `path` parameter for file filtering.",
369
505
  ],
370
506
  parameters: SonarToolParams,
371
507
 
@@ -378,30 +514,50 @@ export default function sonarqube(pi: ExtensionAPI) {
378
514
  rules: params.rules,
379
515
  softwareQualities: params.softwareQualities,
380
516
  impactSeverities: params.impactSeverities,
517
+ pathScope: params.pathScope,
381
518
  });
382
519
  assertFiltersNotAmbiguous(filters);
383
520
 
384
521
  if (params.action === "analyze") {
385
522
  const state = await analyzeProject(pi, ctx, params.path, filters);
386
523
  rememberState(state);
387
- return { content: [{ type: "text", text: formatReport(state) }], details: state };
524
+ return {
525
+ content: [{ type: "text", text: formatReport(state) }],
526
+ details: state,
527
+ };
388
528
  }
389
529
 
390
- if (params.action === "duplications") return toolDuplications(ctx, params.path);
530
+ if (params.action === "duplications")
531
+ return toolDuplications(ctx, params.path);
391
532
  if (params.action === "metrics") return toolMetrics(ctx, params.path);
392
533
 
393
- const targetState = await resolveTargetState(ctx, statesByBaseDir, params.path, filters);
534
+ const targetState = await resolveTargetState(
535
+ ctx,
536
+ statesByBaseDir,
537
+ params.path,
538
+ filters,
539
+ );
394
540
  if (!targetState) {
395
541
  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." },
542
+ content: [
543
+ {
544
+ type: "text",
545
+ text: "No SonarQube analysis has been run for this target yet.",
546
+ },
547
+ ],
548
+ details: {
549
+ error: "No SonarQube analysis has been run for this target yet.",
550
+ },
398
551
  };
399
552
  }
400
553
 
401
554
  rememberState(targetState);
402
555
 
403
556
  if (params.action === "issues") {
404
- return { content: [{ type: "text", text: formatReport(targetState) }], details: targetState };
557
+ return {
558
+ content: [{ type: "text", text: formatReport(targetState) }],
559
+ details: targetState,
560
+ };
405
561
  }
406
562
 
407
563
  const index = params.issueIndex ?? 1;
@@ -415,12 +571,20 @@ export default function sonarqube(pi: ExtensionAPI) {
415
571
 
416
572
  const preview = await buildIssuePreview(targetState.baseDir, issue);
417
573
  return {
418
- content: [{ type: "text", text: `${formatIssue(issue, index)}\n\n${preview}` }],
574
+ content: [
575
+ {
576
+ type: "text",
577
+ text: `${formatIssue(issue, index)}\n\n${preview}`,
578
+ },
579
+ ],
419
580
  details: { ...targetState, selectedIssue: issue },
420
581
  };
421
582
  } catch (error) {
422
583
  const msg = sonarErrorMessage(error);
423
- return { content: [{ type: "text", text: msg }], details: { error: msg } };
584
+ return {
585
+ content: [{ type: "text", text: msg }],
586
+ details: { error: msg },
587
+ };
424
588
  }
425
589
  },
426
590
 
@@ -433,7 +597,9 @@ export default function sonarqube(pi: ExtensionAPI) {
433
597
  softwareQualities: args.softwareQualities,
434
598
  impactSeverities: args.impactSeverities,
435
599
  });
436
- const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
600
+ const filterText = filters
601
+ ? ` ${theme.fg("muted", issueFilterLabel(filters))}`
602
+ : "";
437
603
  return new Text(
438
604
  `${theme.fg("toolTitle", theme.bold("sonarqube"))} ${theme.fg("muted", args.action)}${
439
605
  args.path ? ` ${theme.fg("accent", args.path)}` : ""
@@ -460,7 +626,10 @@ export default function sonarqube(pi: ExtensionAPI) {
460
626
 
461
627
  if ("selectedIssue" in state && state.selectedIssue) {
462
628
  const issue = state.selectedIssue;
463
- const contentText = result.content[0]?.type === "text" ? result.content[0].text : undefined;
629
+ const contentText =
630
+ result.content[0]?.type === "text"
631
+ ? result.content[0].text
632
+ : undefined;
464
633
  return renderIssueResult(issue, contentText, expanded, theme);
465
634
  }
466
635
 
@@ -474,8 +643,20 @@ export default function sonarqube(pi: ExtensionAPI) {
474
643
  });
475
644
 
476
645
  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),
646
+ description:
647
+ "Run a local SonarQube analysis, browse the latest issues, or init project config",
648
+ getArgumentCompletions: async (argumentPrefix) => {
649
+ const cfg = await cachedServerConfig(process.cwd());
650
+ return sonarArgumentCompletions(
651
+ argumentPrefix,
652
+ latestState?.issues,
653
+ latestState?.cleanCodeMode,
654
+ process.cwd(),
655
+ cfg?.serverUrl,
656
+ cfg?.token,
657
+ cfg?.projectKey,
658
+ );
659
+ },
479
660
  handler: async (args, ctx) => {
480
661
  try {
481
662
  const parsed = parseCommandArgs(args);
@@ -484,20 +665,41 @@ export default function sonarqube(pi: ExtensionAPI) {
484
665
  if (ctx.hasUI) ctx.ui.notify(helpText(), "info");
485
666
  return;
486
667
  case "init":
487
- await initCommandHandler(ctx, { alias: parsed.alias, targetInput: parsed.targetInput });
668
+ await initCommandHandler(ctx, {
669
+ alias: parsed.alias,
670
+ targetInput: parsed.targetInput,
671
+ });
488
672
  return;
489
673
  case "analyze": {
490
- await commandAnalyze(pi, ctx, parsed.targetInput, parsed.filters, rememberState);
674
+ await commandAnalyze(
675
+ pi,
676
+ ctx,
677
+ parsed.targetInput,
678
+ parsed.filters,
679
+ rememberState,
680
+ );
491
681
  return;
492
682
  }
493
683
  case "metrics":
494
684
  await commandMetrics(ctx, parsed.targetInput);
495
685
  return;
496
686
  case "duplications":
497
- await commandDuplications(pi, ctx, parsed.targetInput, parsed.issueIndex);
687
+ await commandDuplications(
688
+ pi,
689
+ ctx,
690
+ parsed.targetInput,
691
+ parsed.issueIndex,
692
+ parsed.filters,
693
+ );
498
694
  return;
499
695
  default:
500
- await commandIssuesOrOpen(pi, ctx, statesByBaseDir, parsed, rememberState);
696
+ await commandIssuesOrOpen(
697
+ pi,
698
+ ctx,
699
+ statesByBaseDir,
700
+ parsed,
701
+ rememberState,
702
+ );
501
703
  }
502
704
  } catch (error) {
503
705
  if (ctx.hasUI) {
@@ -528,24 +730,67 @@ async function commandAnalyze(
528
730
  }
529
731
  }
530
732
 
733
+ async function loadMetricsData(
734
+ config: SonarProjectConfig,
735
+ signal?: AbortSignal,
736
+ ): Promise<{
737
+ cleanCodeMode: Awaited<ReturnType<typeof fetchCleanCodeMode>>;
738
+ measures: Awaited<ReturnType<typeof fetchDuplicationMeasures>>;
739
+ issueCounts: Awaited<ReturnType<typeof fetchIssueSeverityCounts>>;
740
+ qualityCounts: Awaited<ReturnType<typeof fetchIssueQualityCounts>>;
741
+ } | null> {
742
+ const cleanCodeMode = await fetchCleanCodeMode(
743
+ config.serverUrl,
744
+ config.token,
745
+ signal,
746
+ );
747
+ const [measures, issueCounts, qualityCounts] = await Promise.all([
748
+ fetchDuplicationMeasures(
749
+ config.serverUrl,
750
+ config.token,
751
+ config.projectKey,
752
+ signal,
753
+ ),
754
+ fetchIssueSeverityCounts(
755
+ config.serverUrl,
756
+ config.token,
757
+ config.projectKey,
758
+ signal,
759
+ cleanCodeMode,
760
+ ),
761
+ fetchIssueQualityCounts(
762
+ config.serverUrl,
763
+ config.token,
764
+ config.projectKey,
765
+ signal,
766
+ ),
767
+ ]);
768
+ if (!measures && !issueCounts && !qualityCounts) return null;
769
+ return { cleanCodeMode, measures, issueCounts, qualityCounts };
770
+ }
771
+
531
772
  async function commandMetrics(
532
773
  ctx: ExtensionCommandContext,
533
774
  targetInput?: string,
534
775
  ): Promise<void> {
535
776
  const config = await resolveConfig(ctx, targetInput);
536
- const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
537
- 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),
541
- ]);
542
- if (!measures && !issueCounts && !qualityCounts) {
777
+ const metrics = await loadMetricsData(config, ctx.signal);
778
+ if (!metrics) {
543
779
  if (ctx.hasUI) {
544
- ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
780
+ ctx.ui.notify(
781
+ `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
782
+ "warning",
783
+ );
545
784
  }
546
785
  return;
547
786
  }
548
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
787
+ const text = formatMetricsOutput({
788
+ projectKey: config.projectKey,
789
+ measures: metrics.measures,
790
+ issueCounts: metrics.issueCounts,
791
+ issueQualityCounts: metrics.qualityCounts,
792
+ cleanCodeMode: metrics.cleanCodeMode,
793
+ });
549
794
  if (ctx.hasUI) {
550
795
  ctx.ui.notify(text, "info");
551
796
  }
@@ -556,16 +801,27 @@ async function commandDuplications(
556
801
  ctx: ExtensionCommandContext,
557
802
  targetInput?: string,
558
803
  fileIndex?: number,
804
+ filters?: SonarIssueFetchOptions,
559
805
  ): Promise<void> {
560
806
  const config = await resolveConfig(ctx, targetInput);
561
807
  let files: FileDuplication[];
562
808
  try {
563
- files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
809
+ files = await fetchFileDuplications(
810
+ config.serverUrl,
811
+ config.token,
812
+ config.projectKey,
813
+ ctx.signal,
814
+ filters?.pathScope,
815
+ config.baseDir,
816
+ );
564
817
  } catch (error) {
565
818
  const msg = sonarErrorMessage(error);
566
819
  if (msg.includes("403") || msg.includes("Insufficient privileges")) {
567
820
  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");
821
+ ctx.ui.notify(
822
+ "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.",
823
+ "warning",
824
+ );
569
825
  }
570
826
  return;
571
827
  }
@@ -580,7 +836,7 @@ async function commandDuplications(
580
836
  await showDuplicationDrillDown(ctx, config, files, fileIndex);
581
837
  return;
582
838
  }
583
- await showDuplicationListOrBrowser(ctx, config, files);
839
+ await showDuplicationListOrBrowser(ctx, config, files, filters);
584
840
  }
585
841
 
586
842
  async function showDuplicationDrillDown(
@@ -594,8 +850,18 @@ async function showDuplicationDrillDown(
594
850
  if (ctx.hasUI) ctx.ui.notify(`File #${fileIndex} not found.`, "error");
595
851
  return;
596
852
  }
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);
853
+ const groups = await fetchFileDuplicationBlocks(
854
+ config.serverUrl,
855
+ config.token,
856
+ file.fileKey,
857
+ config.projectKey,
858
+ ctx.signal,
859
+ );
860
+ const text = await buildDuplicationPreview(
861
+ config.baseDir,
862
+ file.filePath,
863
+ groups,
864
+ );
599
865
  if (ctx.mode === "tui") {
600
866
  await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
601
867
  } else if (ctx.hasUI) {
@@ -607,15 +873,23 @@ async function showDuplicationListOrBrowser(
607
873
  ctx: ExtensionCommandContext,
608
874
  config: SonarProjectConfig,
609
875
  files: FileDuplication[],
876
+ filters?: SonarIssueFetchOptions,
610
877
  ): Promise<void> {
611
878
  if (ctx.mode !== "tui") {
612
879
  ctx.ui.notify(formatDuplicationsList(files), "info");
613
880
  return;
614
881
  }
615
- const choice = await showDuplicationBrowser(ctx, files);
882
+ const scopeLabel = filters?.pathScope ? issueFilterLabel(filters) : undefined;
883
+ const choice = await showDuplicationBrowser(ctx, files, scopeLabel);
616
884
  if (choice == null) return;
617
885
  const file = files[choice];
618
- const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
886
+ const groups = await fetchFileDuplicationBlocks(
887
+ config.serverUrl,
888
+ config.token,
889
+ file.fileKey,
890
+ config.projectKey,
891
+ ctx.signal,
892
+ );
619
893
  const lines = [`Duplications in ${file.filePath}`];
620
894
  for (const [i, group] of groups.entries()) {
621
895
  lines.push("", `Block ${i + 1}:`);
@@ -632,10 +906,20 @@ async function commandIssuesOrOpen(
632
906
  pi: ExtensionAPI,
633
907
  ctx: ExtensionCommandContext,
634
908
  statesByBaseDir: Map<string, SonarAnalysisState>,
635
- parsed: { targetInput?: string; filters?: SonarIssueFetchOptions; action: string; issueIndex?: number },
909
+ parsed: {
910
+ targetInput?: string;
911
+ filters?: SonarIssueFetchOptions;
912
+ action: string;
913
+ issueIndex?: number;
914
+ },
636
915
  rememberState: (s: SonarAnalysisState) => void,
637
916
  ): Promise<void> {
638
- const targetState = await resolveTargetState(ctx, statesByBaseDir, parsed.targetInput, parsed.filters);
917
+ const targetState = await resolveTargetState(
918
+ ctx,
919
+ statesByBaseDir,
920
+ parsed.targetInput,
921
+ parsed.filters,
922
+ );
639
923
  if (!targetState) {
640
924
  if (ctx.hasUI) {
641
925
  ctx.ui.notify(
@@ -675,8 +959,12 @@ async function commandShowIssues(
675
959
  const issue = targetState.issues[choice];
676
960
  if (issue) {
677
961
  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}`);
962
+ const rule = issue.ruleName
963
+ ? `${issue.rule} (${issue.ruleName})`
964
+ : issue.rule;
965
+ ctx.ui.setEditorText(
966
+ `${issue.severity} ${issue.filePath}${line} — ${rule} — ${issue.message}`,
967
+ );
680
968
  ctx.ui.notify("Issue loaded into editor — press Enter to send", "info");
681
969
  }
682
970
  }
@@ -684,24 +972,45 @@ async function commandShowIssues(
684
972
 
685
973
  // ── Tool helper functions ────────────────────────────────────────────────────
686
974
 
687
- async function toolDuplications(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
975
+ async function toolDuplications(
976
+ ctx: any,
977
+ path: string | undefined,
978
+ ): Promise<{
979
+ content: Array<{ type: "text"; text: string }>;
980
+ details: Record<string, unknown>;
981
+ }> {
688
982
  let config;
689
983
  try {
690
984
  config = await resolveConfig(ctx, path);
691
985
  } catch {
692
986
  return {
693
- content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
987
+ content: [
988
+ {
989
+ type: "text",
990
+ text: "Project not configured. Run /sonarqube init first.",
991
+ },
992
+ ],
694
993
  details: { error: "Project not configured." },
695
994
  };
696
995
  }
697
996
  let files: FileDuplication[];
698
997
  try {
699
- files = await fetchFileDuplications(config.serverUrl, config.token, config.projectKey, ctx.signal);
998
+ files = await fetchFileDuplications(
999
+ config.serverUrl,
1000
+ config.token,
1001
+ config.projectKey,
1002
+ ctx.signal,
1003
+ );
700
1004
  } catch (error) {
701
1005
  const msg = sonarErrorMessage(error);
702
1006
  if (msg.includes("403") || msg.includes("Insufficient privileges")) {
703
1007
  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." }],
1008
+ content: [
1009
+ {
1010
+ type: "text",
1011
+ 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.",
1012
+ },
1013
+ ],
705
1014
  details: { error: "Token missing 'See Source Code' permission." },
706
1015
  };
707
1016
  }
@@ -712,7 +1021,12 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
712
1021
  }
713
1022
  if (files.length === 0) {
714
1023
  return {
715
- content: [{ type: "text", text: `No duplications found for ${config.projectKey}.` }],
1024
+ content: [
1025
+ {
1026
+ type: "text",
1027
+ text: `No duplications found for ${config.projectKey}.`,
1028
+ },
1029
+ ],
716
1030
  details: { error: `No duplications found for ${config.projectKey}.` },
717
1031
  };
718
1032
  }
@@ -721,12 +1035,23 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
721
1035
  const topContexts = await Promise.all(
722
1036
  topFiles.map(async (file) => {
723
1037
  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);
1038
+ const groups = await fetchFileDuplicationBlocks(
1039
+ config.serverUrl,
1040
+ config.token,
1041
+ file.fileKey,
1042
+ config.projectKey,
1043
+ ctx.signal,
1044
+ );
1045
+ const preview = await buildDuplicationPreview(
1046
+ config.baseDir,
1047
+ file.filePath,
1048
+ groups,
1049
+ );
726
1050
  return {
727
1051
  filePath: file.filePath,
728
1052
  duplicatedBlocks: file.duplicatedBlocks,
729
1053
  duplicatedLines: file.duplicatedLines,
1054
+ duplicatedLinesDensity: file.duplicatedLinesDensity,
730
1055
  preview,
731
1056
  };
732
1057
  } catch (error) {
@@ -734,6 +1059,7 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
734
1059
  filePath: file.filePath,
735
1060
  duplicatedBlocks: file.duplicatedBlocks,
736
1061
  duplicatedLines: file.duplicatedLines,
1062
+ duplicatedLinesDensity: file.duplicatedLinesDensity,
737
1063
  preview: `Duplications in ${file.filePath}\n\n<failed to load duplicated lines: ${sonarErrorMessage(error)}>`,
738
1064
  };
739
1065
  }
@@ -741,12 +1067,22 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
741
1067
  );
742
1068
  const remaining = files.length - topContexts.length;
743
1069
  const content: Array<{ type: "text"; text: string }> = [
744
- { type: "text", text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.` },
1070
+ {
1071
+ type: "text",
1072
+ text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.`,
1073
+ },
745
1074
  ...topContexts.map((item, index) => ({
746
1075
  type: "text" as const,
747
- text: `#${index + 1} ${item.filePath} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
1076
+ text: `#${index + 1} ${item.filePath} dup%=${item.duplicatedLinesDensity.toFixed(1)} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
748
1077
  })),
749
- ...(remaining > 0 ? [{ type: "text" as const, text: `+ ${remaining} more file(s) not shown` }] : []),
1078
+ ...(remaining > 0
1079
+ ? [
1080
+ {
1081
+ type: "text" as const,
1082
+ text: `+ ${remaining} more file(s) not shown`,
1083
+ },
1084
+ ]
1085
+ : []),
750
1086
  ];
751
1087
  return {
752
1088
  content,
@@ -759,30 +1095,58 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
759
1095
  };
760
1096
  }
761
1097
 
762
- async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
1098
+ async function toolMetrics(
1099
+ ctx: any,
1100
+ path: string | undefined,
1101
+ ): Promise<{
1102
+ content: Array<{ type: "text"; text: string }>;
1103
+ details: Record<string, unknown>;
1104
+ }> {
763
1105
  let config;
764
1106
  try {
765
1107
  config = await resolveConfig(ctx, path);
766
1108
  } catch {
767
1109
  return {
768
- content: [{ type: "text", text: "Project not configured. Run /sonarqube init first." }],
1110
+ content: [
1111
+ {
1112
+ type: "text",
1113
+ text: "Project not configured. Run /sonarqube init first.",
1114
+ },
1115
+ ],
769
1116
  details: { error: "Project not configured." },
770
1117
  };
771
1118
  }
772
- const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
773
- 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),
777
- ]);
778
- if (!measures && !issueCounts && !qualityCounts) {
1119
+ const metrics = await loadMetricsData(config, ctx.signal);
1120
+ if (!metrics) {
779
1121
  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.` },
1122
+ content: [
1123
+ {
1124
+ type: "text",
1125
+ text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
1126
+ },
1127
+ ],
1128
+ details: {
1129
+ error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`,
1130
+ },
782
1131
  };
783
1132
  }
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 } };
1133
+ const text = formatMetricsOutput({
1134
+ projectKey: config.projectKey,
1135
+ measures: metrics.measures,
1136
+ issueCounts: metrics.issueCounts,
1137
+ issueQualityCounts: metrics.qualityCounts,
1138
+ cleanCodeMode: metrics.cleanCodeMode,
1139
+ });
1140
+ return {
1141
+ content: [{ type: "text", text }],
1142
+ details: {
1143
+ projectKey: config.projectKey,
1144
+ measures: metrics.measures,
1145
+ issueCounts: metrics.issueCounts,
1146
+ issueQualityCounts: metrics.qualityCounts,
1147
+ cleanCodeMode: metrics.cleanCodeMode,
1148
+ },
1149
+ };
786
1150
  }
787
1151
 
788
1152
  // ── Re-exports for public API ───────────────────────────────────────────────
@@ -797,4 +1161,8 @@ export type {
797
1161
  IssueQualityCounts,
798
1162
  } from "./types.js";
799
1163
 
800
- export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";
1164
+ export {
1165
+ projectConfigPath,
1166
+ loadProjectConfig,
1167
+ saveProjectConfig,
1168
+ } from "./config.js";