@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/api.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
3
+ import { relative, resolve } from "node:path";
4
4
  import type {
5
5
  SonarIssue,
6
6
  SonarAnalysisState,
@@ -12,7 +12,13 @@ import type {
12
12
  FileDuplication,
13
13
  DuplicationBlockGroup,
14
14
  } from "./types.js";
15
- import { SONAR_SEVERITIES, SONAR_STATUSES, SONAR_TYPES, SONAR_SOFTWARE_QUALITIES, SONAR_IMPACT_SEVERITIES } from "./types.js";
15
+ import {
16
+ SONAR_SEVERITIES,
17
+ SONAR_STATUSES,
18
+ SONAR_TYPES,
19
+ SONAR_SOFTWARE_QUALITIES,
20
+ SONAR_IMPACT_SEVERITIES,
21
+ } from "./types.js";
16
22
  import { parseProperties } from "./config.js";
17
23
 
18
24
  // ── Clean code mode detection ────────────────────────────────────────────────
@@ -51,7 +57,11 @@ export function authHeader(token?: string): string | undefined {
51
57
  return `Basic ${Buffer.from(credentials).toString("base64")}`;
52
58
  }
53
59
 
54
- export async function fetchJson<T>(url: string, token?: string, signal?: AbortSignal): Promise<T> {
60
+ export async function fetchJson<T>(
61
+ url: string,
62
+ token?: string,
63
+ signal?: AbortSignal,
64
+ ): Promise<T> {
55
65
  const headers: Record<string, string> = { Accept: "application/json" };
56
66
  const auth = authHeader(token);
57
67
  if (auth) headers.Authorization = auth;
@@ -60,14 +70,20 @@ export async function fetchJson<T>(url: string, token?: string, signal?: AbortSi
60
70
  if (!response.ok) {
61
71
  const body = await response.text().catch(() => "");
62
72
  const suffix = body.trim() ? `: ${body.trim().slice(0, 240)}` : "";
63
- throw new Error(`SonarQube API request failed (${response.status} ${response.statusText}) for ${url}${suffix}`);
73
+ throw new Error(
74
+ `SonarQube API request failed (${response.status} ${response.statusText}) for ${url}${suffix}`,
75
+ );
64
76
  }
65
77
 
66
78
  return (await response.json()) as T;
67
79
  }
68
80
 
69
- export function toIssuePath(component: string | undefined | null, projectKey: string): string {
70
- if (typeof component !== "string" || component.length === 0) return projectKey;
81
+ export function toIssuePath(
82
+ component: string | undefined | null,
83
+ projectKey: string,
84
+ ): string {
85
+ if (typeof component !== "string" || component.length === 0)
86
+ return projectKey;
71
87
  const prefix = `${projectKey}:`;
72
88
  if (component.startsWith(prefix)) return component.slice(prefix.length);
73
89
  const colon = component.indexOf(":");
@@ -76,7 +92,10 @@ export function toIssuePath(component: string | undefined | null, projectKey: st
76
92
 
77
93
  // ── Issue filter helpers ────────────────────────────────────────────────────
78
94
 
79
- export function normalizeIssueList(values?: string[], uppercase = false): string[] | undefined {
95
+ export function normalizeIssueList(
96
+ values?: string[],
97
+ uppercase = false,
98
+ ): string[] | undefined {
80
99
  const cleaned = values
81
100
  ?.map((v) => v.trim())
82
101
  .filter(Boolean)
@@ -85,7 +104,9 @@ export function normalizeIssueList(values?: string[], uppercase = false): string
85
104
  return [...new Set(cleaned)];
86
105
  }
87
106
 
88
- export function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIssueFetchOptions | undefined {
107
+ export function normalizeIssueFilters(
108
+ filters?: SonarIssueFetchOptions,
109
+ ): SonarIssueFetchOptions | undefined {
89
110
  const normalized = {
90
111
  severities: normalizeIssueList(filters?.severities, true),
91
112
  statuses: normalizeIssueList(filters?.statuses, true),
@@ -93,13 +114,24 @@ export function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIs
93
114
  rules: normalizeIssueList(filters?.rules),
94
115
  softwareQualities: normalizeIssueList(filters?.softwareQualities, true),
95
116
  impactSeverities: normalizeIssueList(filters?.impactSeverities, true),
117
+ pathScope: filters?.pathScope,
118
+ componentKeys: filters?.componentKeys,
96
119
  };
97
- return normalized.severities || normalized.statuses || normalized.types || normalized.rules || normalized.softwareQualities || normalized.impactSeverities
120
+ return normalized.severities ||
121
+ normalized.statuses ||
122
+ normalized.types ||
123
+ normalized.rules ||
124
+ normalized.softwareQualities ||
125
+ normalized.impactSeverities ||
126
+ normalized.pathScope ||
127
+ normalized.componentKeys?.length
98
128
  ? normalized
99
129
  : undefined;
100
130
  }
101
131
 
102
- export function assertFiltersNotAmbiguous(filters?: SonarIssueFetchOptions): void {
132
+ export function assertFiltersNotAmbiguous(
133
+ filters?: SonarIssueFetchOptions,
134
+ ): void {
103
135
  if (!filters) return;
104
136
  if (
105
137
  (filters.severities?.length || filters.types?.length) &&
@@ -107,21 +139,26 @@ export function assertFiltersNotAmbiguous(filters?: SonarIssueFetchOptions): voi
107
139
  ) {
108
140
  throw new Error(
109
141
  "Ambiguous filter combination: mixing legacy filters (severity/type) with MQR filters (quality/impactSeverity) is not allowed. " +
110
- "Use one filter family per command.",
142
+ "Use one filter family per command.",
111
143
  );
112
144
  }
113
145
  }
114
146
 
115
147
  const EXPLICIT_FILTER_RE = /^([^:=]+)[:=](.+)$/;
116
148
 
117
- export function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOptions> | undefined {
149
+ export function parseIssueFilterToken(
150
+ token: string,
151
+ ): Partial<SonarIssueFetchOptions> | undefined {
118
152
  const trimmed = token.trim();
119
153
  if (!trimmed) return undefined;
120
154
 
121
155
  const explicit = EXPLICIT_FILTER_RE.exec(trimmed);
122
156
  if (explicit) {
123
157
  const key = explicit[1].trim().toLowerCase();
124
- const values = explicit[2].split(",").map((p) => p.trim()).filter(Boolean);
158
+ const values = explicit[2]
159
+ .split(",")
160
+ .map((p) => p.trim())
161
+ .filter(Boolean);
125
162
  if (values.length === 0) return undefined;
126
163
 
127
164
  switch (key) {
@@ -145,24 +182,44 @@ export function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOpt
145
182
  case "impactseverity":
146
183
  case "impactseverities":
147
184
  return { impactSeverities: values.map((v) => v.toUpperCase()) };
185
+ case "in":
186
+ case "path":
187
+ return { pathScope: values[0] };
148
188
  default:
149
189
  return undefined;
150
190
  }
151
191
  }
152
192
 
153
193
  const upper = trimmed.toUpperCase();
154
- if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number])) return { severities: [upper] };
155
- if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number])) return { statuses: [upper] };
156
- if (SONAR_TYPES.includes(upper as (typeof SONAR_TYPES)[number])) return { types: [upper] };
157
- if (SONAR_SOFTWARE_QUALITIES.includes(upper as (typeof SONAR_SOFTWARE_QUALITIES)[number])) return { softwareQualities: [upper] };
158
- if (SONAR_IMPACT_SEVERITIES.includes(upper as (typeof SONAR_IMPACT_SEVERITIES)[number])) return { impactSeverities: [upper] };
194
+ if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number]))
195
+ return { severities: [upper] };
196
+ if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number]))
197
+ return { statuses: [upper] };
198
+ if (SONAR_TYPES.includes(upper as (typeof SONAR_TYPES)[number]))
199
+ return { types: [upper] };
200
+ if (
201
+ SONAR_SOFTWARE_QUALITIES.includes(
202
+ upper as (typeof SONAR_SOFTWARE_QUALITIES)[number],
203
+ )
204
+ )
205
+ return { softwareQualities: [upper] };
206
+ if (
207
+ SONAR_IMPACT_SEVERITIES.includes(
208
+ upper as (typeof SONAR_IMPACT_SEVERITIES)[number],
209
+ )
210
+ )
211
+ return { impactSeverities: [upper] };
159
212
  return undefined;
160
213
  }
161
214
 
162
215
  export function parseSonarIssueArgs(
163
216
  tokens: string[],
164
217
  allowIssueIndex = false,
165
- ): { targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions } {
218
+ ): {
219
+ targetInput?: string;
220
+ issueIndex?: number;
221
+ filters?: SonarIssueFetchOptions;
222
+ } {
166
223
  const filters: Array<Partial<SonarIssueFetchOptions> | undefined> = [];
167
224
  let targetInput: string | undefined;
168
225
  let issueIndex: number | undefined;
@@ -188,18 +245,26 @@ export function parseSonarIssueArgs(
188
245
  return { targetInput, issueIndex, filters: mergeFilters(...filters) };
189
246
  }
190
247
 
248
+ function mergeFilterField<T>(
249
+ merged: T[] | undefined,
250
+ incoming?: T[],
251
+ ): T[] | undefined {
252
+ return incoming?.length ? [...(merged ?? []), ...incoming] : merged;
253
+ }
254
+
191
255
  function mergeFilters(
192
256
  ...filters: Array<Partial<SonarIssueFetchOptions> | undefined>
193
257
  ): SonarIssueFetchOptions | undefined {
194
258
  const merged: SonarIssueFetchOptions = {};
195
259
  for (const filter of filters) {
196
260
  if (!filter) continue;
197
- if (filter.severities?.length) merged.severities = [...(merged.severities ?? []), ...filter.severities];
198
- if (filter.statuses?.length) merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
199
- if (filter.types?.length) merged.types = [...(merged.types ?? []), ...filter.types];
200
- if (filter.rules?.length) merged.rules = [...(merged.rules ?? []), ...filter.rules];
201
- if (filter.softwareQualities?.length) merged.softwareQualities = [...(merged.softwareQualities ?? []), ...filter.softwareQualities];
202
- if (filter.impactSeverities?.length) merged.impactSeverities = [...(merged.impactSeverities ?? []), ...filter.impactSeverities];
261
+ merged.severities = mergeFilterField(merged.severities, filter.severities);
262
+ merged.statuses = mergeFilterField(merged.statuses, filter.statuses);
263
+ merged.types = mergeFilterField(merged.types, filter.types);
264
+ merged.rules = mergeFilterField(merged.rules, filter.rules);
265
+ merged.softwareQualities = mergeFilterField(merged.softwareQualities, filter.softwareQualities);
266
+ merged.impactSeverities = mergeFilterField(merged.impactSeverities, filter.impactSeverities);
267
+ if (filter.pathScope) merged.pathScope = filter.pathScope;
203
268
  }
204
269
  return normalizeIssueFilters(merged);
205
270
  }
@@ -207,15 +272,33 @@ function mergeFilters(
207
272
  export function issueFilterLabel(filters?: SonarIssueFetchOptions): string {
208
273
  if (!filters) return "";
209
274
  const parts: string[] = [];
210
- if (filters.severities?.length) parts.push(`severities=${filters.severities.join(",")}`);
211
- if (filters.statuses?.length) parts.push(`statuses=${filters.statuses.join(",")}`);
275
+ if (filters.severities?.length)
276
+ parts.push(`severities=${filters.severities.join(",")}`);
277
+ if (filters.statuses?.length)
278
+ parts.push(`statuses=${filters.statuses.join(",")}`);
212
279
  if (filters.types?.length) parts.push(`types=${filters.types.join(",")}`);
213
280
  if (filters.rules?.length) parts.push(`rules=${filters.rules.join(",")}`);
214
- if (filters.softwareQualities?.length) parts.push(`qualities=${filters.softwareQualities.join(",")}`);
215
- if (filters.impactSeverities?.length) parts.push(`impactSeverities=${filters.impactSeverities.join(",")}`);
281
+ if (filters.softwareQualities?.length)
282
+ parts.push(`qualities=${filters.softwareQualities.join(",")}`);
283
+ if (filters.impactSeverities?.length)
284
+ parts.push(`impactSeverities=${filters.impactSeverities.join(",")}`);
285
+ if (filters.pathScope) parts.push(`in:${filters.pathScope}`);
216
286
  return parts.join(" • ");
217
287
  }
218
288
 
289
+ export function resolvePathScope(
290
+ baseDir: string,
291
+ projectKey: string,
292
+ pathScope: string,
293
+ ): string {
294
+ const raw = pathScope.replace(/^@/, "");
295
+ const absPath = resolve(baseDir, raw);
296
+ const relPath = relative(baseDir, absPath);
297
+ if (relPath === "" || relPath === ".") return projectKey;
298
+ const cleanRel = relPath.replace(/[\\/]+$/, "");
299
+ return `${projectKey}:${cleanRel}`;
300
+ }
301
+
219
302
  function severitySortRank(severity: string): number {
220
303
  switch (severity.toUpperCase()) {
221
304
  case "BLOCKER":
@@ -237,7 +320,8 @@ function severitySortRank(severity: string): number {
237
320
  }
238
321
 
239
322
  function compareIssuesForContext(left: SonarIssue, right: SonarIssue): number {
240
- const severityDiff = severitySortRank(left.severity) - severitySortRank(right.severity);
323
+ const severityDiff =
324
+ severitySortRank(left.severity) - severitySortRank(right.severity);
241
325
  if (severityDiff !== 0) return severityDiff;
242
326
  const fileDiff = left.filePath.localeCompare(right.filePath);
243
327
  if (fileDiff !== 0) return fileDiff;
@@ -256,7 +340,11 @@ export function sonarScannerInstallHint(): string {
256
340
  ].join(" ");
257
341
  }
258
342
 
259
- export function extractScannerHint(output: string, config: SonarProjectConfig, exitCode?: number): string {
343
+ export function extractScannerHint(
344
+ output: string,
345
+ config: SonarProjectConfig,
346
+ exitCode?: number,
347
+ ): string {
260
348
  const lines = output.toLowerCase();
261
349
 
262
350
  const isNotFoundOnPath =
@@ -268,7 +356,10 @@ export function extractScannerHint(output: string, config: SonarProjectConfig, e
268
356
 
269
357
  const isUnauthorized = /status code 401|status 401|unauthorized/i.test(lines);
270
358
  if (isUnauthorized) {
271
- const defaultHint = config.serverUrl === "http://localhost:9000" ? " (default: http://localhost:9000)" : "";
359
+ const defaultHint =
360
+ config.serverUrl === "http://localhost:9000"
361
+ ? " (default: http://localhost:9000)"
362
+ : "";
272
363
  return [
273
364
  `Authentication failed for ${config.serverUrl}${defaultHint}.`,
274
365
  "Run `/sonarqube init` to set up your server URL and token, then retry.",
@@ -285,7 +376,10 @@ export function extractScannerHint(output: string, config: SonarProjectConfig, e
285
376
  ].join(" ");
286
377
  }
287
378
 
288
- const isConnectionError = /connect econnrefused|connect refused|connect timeout|enotfound|econnreset/i.test(lines);
379
+ const isConnectionError =
380
+ /connect econnrefused|connect refused|connect timeout|enotfound|econnreset/i.test(
381
+ lines,
382
+ );
289
383
  if (isConnectionError) {
290
384
  return [
291
385
  `Cannot reach SonarQube server at ${config.serverUrl}.`,
@@ -294,7 +388,11 @@ export function extractScannerHint(output: string, config: SonarProjectConfig, e
294
388
  ].join(" ");
295
389
  }
296
390
 
297
- if (/not found|unknown url/i.test(lines) && (config.serverUrl === "http://localhost:9000" || /localhost/i.test(config.serverUrl))) {
391
+ if (
392
+ /not found|unknown url/i.test(lines) &&
393
+ (config.serverUrl === "http://localhost:9000" ||
394
+ /localhost/i.test(config.serverUrl))
395
+ ) {
298
396
  return [
299
397
  `SonarQube API at ${config.serverUrl} returned "not found".`,
300
398
  "The default server URL is http://localhost:9000. Run `/sonarqube init` to configure.",
@@ -306,7 +404,11 @@ export function extractScannerHint(output: string, config: SonarProjectConfig, e
306
404
 
307
405
  // ── Scanner interaction ─────────────────────────────────────────────────────
308
406
 
309
- export async function runScanner(pi: ExtensionAPI, config: SonarProjectConfig, signal?: AbortSignal): Promise<string> {
407
+ export async function runScanner(
408
+ pi: ExtensionAPI,
409
+ config: SonarProjectConfig,
410
+ signal?: AbortSignal,
411
+ ): Promise<string> {
310
412
  const scannerCheck =
311
413
  process.platform === "win32"
312
414
  ? await pi.exec("cmd", ["/d", "/s", "/c", "where", "sonar-scanner"], {
@@ -356,7 +458,9 @@ export async function runScanner(pi: ExtensionAPI, config: SonarProjectConfig, s
356
458
  return result.stdout || result.stderr;
357
459
  }
358
460
 
359
- export async function readReportTask(baseDir: string): Promise<Record<string, string>> {
461
+ export async function readReportTask(
462
+ baseDir: string,
463
+ ): Promise<Record<string, string>> {
360
464
  const path = resolve(baseDir, ".scannerwork", "report-task.txt");
361
465
  const text = await readFile(path, "utf8");
362
466
  return parseProperties(text);
@@ -375,7 +479,9 @@ export async function waitForAnalysis(
375
479
  const status = result.task.status.toUpperCase();
376
480
  if (status === "SUCCESS") return result.task.analysisId;
377
481
  if (status === "FAILED") {
378
- throw new Error(`SonarQube analysis failed: ${result.task.errorMessage || "unknown error"}`);
482
+ throw new Error(
483
+ `SonarQube analysis failed: ${result.task.errorMessage || "unknown error"}`,
484
+ );
379
485
  }
380
486
  if (status === "CANCELED") {
381
487
  throw new Error("SonarQube analysis was canceled by the server");
@@ -398,12 +504,29 @@ function buildIssueSearchUrl(
398
504
  url.searchParams.set("resolved", "false");
399
505
  url.searchParams.set("ps", "100");
400
506
  url.searchParams.set("p", String(page));
401
- if (filters?.severities?.length) url.searchParams.set("severities", filters.severities.join(","));
402
- if (filters?.statuses?.length) url.searchParams.set("statuses", filters.statuses.join(","));
403
- if (filters?.types?.length) url.searchParams.set("types", filters.types.join(","));
404
- if (filters?.rules?.length) url.searchParams.set("rules", filters.rules.join(","));
405
- if (filters?.softwareQualities?.length) url.searchParams.set("impactSoftwareQualities", filters.softwareQualities.join(","));
406
- if (filters?.impactSeverities?.length) url.searchParams.set("impactSeverities", filters.impactSeverities.join(","));
507
+ if (filters?.severities?.length)
508
+ url.searchParams.set("severities", filters.severities.join(","));
509
+ if (filters?.statuses?.length)
510
+ url.searchParams.set("statuses", filters.statuses.join(","));
511
+ if (filters?.types?.length)
512
+ url.searchParams.set("types", filters.types.join(","));
513
+ if (filters?.rules?.length)
514
+ url.searchParams.set("rules", filters.rules.join(","));
515
+ if (filters?.softwareQualities?.length)
516
+ url.searchParams.set(
517
+ "impactSoftwareQualities",
518
+ filters.softwareQualities.join(","),
519
+ );
520
+ if (filters?.impactSeverities?.length)
521
+ url.searchParams.set(
522
+ "impactSeverities",
523
+ filters.impactSeverities.join(","),
524
+ );
525
+ if (filters?.componentKeys?.length)
526
+ url.searchParams.set(
527
+ "componentKeys",
528
+ filters.componentKeys.join(","),
529
+ );
407
530
  return url.toString();
408
531
  }
409
532
 
@@ -439,7 +562,18 @@ export async function fetchIssuePage(
439
562
  page: number,
440
563
  signal?: AbortSignal,
441
564
  filters?: SonarIssueFetchOptions,
442
- ): Promise<{ total: number; issueRows: Array<{ key: string; rule: string; severity: string; message: string; component: string; line?: number; status?: string }> }> {
565
+ ): Promise<{
566
+ total: number;
567
+ issueRows: Array<{
568
+ key: string;
569
+ rule: string;
570
+ severity: string;
571
+ message: string;
572
+ component: string;
573
+ line?: number;
574
+ status?: string;
575
+ }>;
576
+ }> {
443
577
  const url = buildIssueSearchUrl(serverUrl, projectKey, page, filters);
444
578
  const result = await fetchJson<{
445
579
  total: number;
@@ -462,9 +596,19 @@ export async function fetchIssues(
462
596
  projectKey: string,
463
597
  signal?: AbortSignal,
464
598
  filters?: SonarIssueFetchOptions,
599
+ baseDir?: string,
465
600
  ): Promise<SonarIssue[]> {
466
601
  assertFiltersNotAmbiguous(filters);
467
602
  const normalizedFilters = normalizeIssueFilters(filters);
603
+
604
+ if (normalizedFilters?.pathScope && baseDir) {
605
+ const componentKey = resolvePathScope(
606
+ baseDir,
607
+ projectKey,
608
+ normalizedFilters.pathScope,
609
+ );
610
+ normalizedFilters.componentKeys = [componentKey];
611
+ }
468
612
  const issues: SonarIssue[] = [];
469
613
  const ruleNames = new Map<string, string | undefined>();
470
614
  let page = 1;
@@ -472,13 +616,21 @@ export async function fetchIssues(
472
616
 
473
617
  for (;;) {
474
618
  const { total: pageTotal, issueRows } = await fetchIssuePage(
475
- serverUrl, token, projectKey, page, signal, normalizedFilters,
619
+ serverUrl,
620
+ token,
621
+ projectKey,
622
+ page,
623
+ signal,
624
+ normalizedFilters,
476
625
  );
477
626
  total = pageTotal;
478
627
 
479
628
  for (const raw of issueRows) {
480
629
  if (!ruleNames.has(raw.rule)) {
481
- ruleNames.set(raw.rule, await fetchRuleName(serverUrl, token, raw.rule, signal));
630
+ ruleNames.set(
631
+ raw.rule,
632
+ await fetchRuleName(serverUrl, token, raw.rule, signal),
633
+ );
482
634
  }
483
635
  issues.push(mapRawIssue(raw, projectKey, ruleNames));
484
636
  }
@@ -494,6 +646,62 @@ export async function fetchIssues(
494
646
  return issues;
495
647
  }
496
648
 
649
+ // ── Rule autocomplete helpers ───────────────────────────────────────────
650
+
651
+ export async function fetchProjectProfiles(
652
+ serverUrl: string,
653
+ token: string | undefined,
654
+ projectKey: string,
655
+ signal?: AbortSignal,
656
+ ): Promise<Array<{key: string; language: string; name: string}>> {
657
+ try {
658
+ const result = await fetchJson<{
659
+ profiles: Array<{key?: string; language?: string; name?: string}>;
660
+ }>(
661
+ `${serverUrl}/api/qualityprofiles/search?project=${encodeURIComponent(projectKey)}`,
662
+ token,
663
+ signal,
664
+ );
665
+ return (result.profiles ?? []).map(p => ({
666
+ key: p.key ?? '',
667
+ language: p.language ?? '',
668
+ name: p.name ?? '',
669
+ }));
670
+ } catch {
671
+ return [];
672
+ }
673
+ }
674
+
675
+ export async function fetchRuleSearch(
676
+ serverUrl: string,
677
+ token: string | undefined,
678
+ query: string,
679
+ languages?: string[],
680
+ signal?: AbortSignal,
681
+ ): Promise<Array<{key: string; name?: string}>> {
682
+ try {
683
+ const params = new URLSearchParams();
684
+ if (query) params.set("q", query);
685
+ params.set("ps", "20");
686
+ params.set("p", "1");
687
+ if (languages?.length) params.set("languages", languages.join(","));
688
+
689
+ const result = await fetchJson<{
690
+ rules: Array<{key?: string; name?: string}>;
691
+ }>(
692
+ `${serverUrl}/api/rules/search?${params.toString()}`,
693
+ token,
694
+ signal,
695
+ );
696
+ return (result.rules ?? []).map(r => ({
697
+ key: r.key ?? '',
698
+ name: r.name,
699
+ }));
700
+ } catch {
701
+ return [];
702
+ }
703
+ }
704
+
497
705
  export async function fetchRuleName(
498
706
  serverUrl: string,
499
707
  token: string | undefined,
@@ -517,7 +725,17 @@ export async function fetchRuleName(
517
725
  export function createAnalysisState(
518
726
  config: Pick<SonarProjectConfig, "baseDir" | "serverUrl" | "projectKey">,
519
727
  issues: SonarIssue[],
520
- extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters" | "measures" | "cleanCodeMode">> = {},
728
+ extras: Partial<
729
+ Pick<
730
+ SonarAnalysisState,
731
+ | "dashboardUrl"
732
+ | "ceTaskUrl"
733
+ | "analysisId"
734
+ | "filters"
735
+ | "measures"
736
+ | "cleanCodeMode"
737
+ >
738
+ > = {},
521
739
  ): SonarAnalysisState {
522
740
  return {
523
741
  version: 1,
@@ -554,15 +772,20 @@ export async function fetchDuplicationMeasures(
554
772
  const m = measures.find((m) => m.metric === key);
555
773
  return m ? Number.parseFloat(m.value) : 0;
556
774
  };
557
- const hasMetric = (key: string): boolean => measures.some((m) => m.metric === key);
775
+ const hasMetric = (key: string): boolean =>
776
+ measures.some((m) => m.metric === key);
558
777
  return {
559
778
  duplicatedLinesDensity: getValue("duplicated_lines_density"),
560
779
  duplicatedLines: getValue("duplicated_lines"),
561
780
  duplicatedBlocks: getValue("duplicated_blocks"),
562
781
  duplicatedFiles: getValue("duplicated_files"),
563
782
  coverage: hasMetric("coverage") ? getValue("coverage") : undefined,
564
- linesToCover: hasMetric("lines_to_cover") ? getValue("lines_to_cover") : undefined,
565
- uncoveredLines: hasMetric("uncovered_lines") ? getValue("uncovered_lines") : undefined,
783
+ linesToCover: hasMetric("lines_to_cover")
784
+ ? getValue("lines_to_cover")
785
+ : undefined,
786
+ uncoveredLines: hasMetric("uncovered_lines")
787
+ ? getValue("uncovered_lines")
788
+ : undefined,
566
789
  };
567
790
  } catch {
568
791
  return undefined;
@@ -580,7 +803,10 @@ export async function fetchIssueSeverityCounts(
580
803
  const facetKey = mode === "MQR" ? "impactSeverities" : "severities";
581
804
  const url = `${serverUrl}/api/issues/search?projects=${encodeURIComponent(projectKey)}&resolved=false&ps=1&facets=${facetKey}`;
582
805
  const result = await fetchJson<{
583
- facets: Array<{ property: string; values: Array<{ val: string; count: number }> }>;
806
+ facets: Array<{
807
+ property: string;
808
+ values: Array<{ val: string; count: number }>;
809
+ }>;
584
810
  }>(url, token, signal);
585
811
  const facet = result.facets?.find((f) => f.property === facetKey);
586
812
  if (!facet) return undefined;
@@ -617,9 +843,14 @@ export async function fetchIssueQualityCounts(
617
843
  try {
618
844
  const url = `${serverUrl}/api/issues/search?projects=${encodeURIComponent(projectKey)}&resolved=false&ps=1&facets=impactSoftwareQualities`;
619
845
  const result = await fetchJson<{
620
- facets: Array<{ property: string; values: Array<{ val: string; count: number }> }>;
846
+ facets: Array<{
847
+ property: string;
848
+ values: Array<{ val: string; count: number }>;
849
+ }>;
621
850
  }>(url, token, signal);
622
- const facet = result.facets?.find((f) => f.property === "impactSoftwareQualities");
851
+ const facet = result.facets?.find(
852
+ (f) => f.property === "impactSoftwareQualities",
853
+ );
623
854
  if (!facet) return undefined;
624
855
  const getCount = (q: string): number => {
625
856
  const entry = facet.values.find((v) => v.val === q);
@@ -642,8 +873,10 @@ export async function fetchFileDuplications(
642
873
  token: string | undefined,
643
874
  projectKey: string,
644
875
  signal?: AbortSignal,
876
+ pathScope?: string,
877
+ baseDir?: string,
645
878
  ): Promise<FileDuplication[]> {
646
- const treeUrl = `${serverUrl}/api/measures/component_tree?component=${encodeURIComponent(projectKey)}&qualifiers=FIL&metricKeys=duplicated_lines,duplicated_blocks&ps=500`;
879
+ const treeUrl = `${serverUrl}/api/measures/component_tree?component=${encodeURIComponent(projectKey)}&qualifiers=FIL&metricKeys=duplicated_lines,duplicated_lines_density,duplicated_blocks&ps=500`;
647
880
  const treeResult = await fetchJson<{
648
881
  components: Array<{
649
882
  key: string;
@@ -653,24 +886,44 @@ export async function fetchFileDuplications(
653
886
  }>;
654
887
  }>(treeUrl, token, signal);
655
888
 
656
- const results = (treeResult.components ?? [])
889
+ let results = (treeResult.components ?? [])
657
890
  .map((component) => {
658
891
  const measures = component.measures ?? [];
659
892
  const getValue = (metric: string): number => {
660
893
  const m = measures.find((item) => item.metric === metric);
661
- return m ? Number.parseInt(m.value, 10) : 0;
894
+ return m ? Number.parseFloat(m.value) : 0;
662
895
  };
663
896
  const duplicatedBlocks = getValue("duplicated_blocks");
664
897
  if (duplicatedBlocks <= 0) return null;
665
898
  return {
666
- filePath: component.path ?? toIssuePath(component.key, projectKey) ?? component.name ?? component.key,
899
+ filePath:
900
+ component.path ??
901
+ toIssuePath(component.key, projectKey) ??
902
+ component.name ??
903
+ component.key,
667
904
  fileKey: component.key,
668
905
  duplicatedLines: getValue("duplicated_lines"),
906
+ duplicatedLinesDensity: getValue("duplicated_lines_density"),
669
907
  duplicatedBlocks,
670
908
  };
671
909
  })
672
910
  .filter((f): f is FileDuplication => f !== null);
673
911
 
912
+ if (pathScope && baseDir) {
913
+ const absPath = resolve(baseDir, pathScope);
914
+ const relPrefix = relative(baseDir, absPath);
915
+ results = results.filter((f) => f.filePath.startsWith(relPrefix));
916
+ }
917
+
918
+ results.sort((left, right) => {
919
+ const densityDiff =
920
+ right.duplicatedLinesDensity - left.duplicatedLinesDensity;
921
+ if (densityDiff !== 0) return densityDiff;
922
+ const linesDiff = right.duplicatedLines - left.duplicatedLines;
923
+ if (linesDiff !== 0) return linesDiff;
924
+ return left.filePath.localeCompare(right.filePath);
925
+ });
926
+
674
927
  return results;
675
928
  }
676
929
 
@@ -695,5 +948,3 @@ export async function fetchFileDuplicationBlocks(
695
948
  })),
696
949
  }));
697
950
  }
698
-
699
-