@jonathan0823/pi-sonarqube 0.2.0 → 0.3.1

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
@@ -8,12 +8,41 @@ import type {
8
8
  SonarIssueFetchOptions,
9
9
  SonarDuplicationMeasures,
10
10
  IssueSeverityCounts,
11
+ IssueQualityCounts,
11
12
  FileDuplication,
12
13
  DuplicationBlockGroup,
13
14
  } from "./types.js";
14
- import { SONAR_SEVERITIES, SONAR_STATUSES, SONAR_TYPES } from "./types.js";
15
+ import { SONAR_SEVERITIES, SONAR_STATUSES, SONAR_TYPES, SONAR_SOFTWARE_QUALITIES, SONAR_IMPACT_SEVERITIES } from "./types.js";
15
16
  import { parseProperties } from "./config.js";
16
17
 
18
+ // ── Clean code mode detection ────────────────────────────────────────────────
19
+
20
+ const cleanCodeModeCache = new Map<string, "STANDARD" | "MQR">();
21
+
22
+ export async function fetchCleanCodeMode(
23
+ serverUrl: string,
24
+ token?: string,
25
+ signal?: AbortSignal,
26
+ ): Promise<"STANDARD" | "MQR" | undefined> {
27
+ const cached = cleanCodeModeCache.get(serverUrl);
28
+ if (cached) return cached;
29
+ try {
30
+ const result = await fetchJson<{ mode: string }>(
31
+ `${serverUrl}/api/v2/clean-code-policy/mode`,
32
+ token,
33
+ signal,
34
+ );
35
+ const mode = result.mode?.toUpperCase();
36
+ if (mode === "MQR" || mode === "STANDARD") {
37
+ cleanCodeModeCache.set(serverUrl, mode);
38
+ return mode;
39
+ }
40
+ } catch {
41
+ // ponytail: fall through to legacy behavior when mode lookup is unavailable.
42
+ }
43
+ return undefined;
44
+ }
45
+
17
46
  // ── Auth / HTTP helpers ─────────────────────────────────────────────────────
18
47
 
19
48
  export function authHeader(token?: string): string | undefined {
@@ -37,7 +66,8 @@ export async function fetchJson<T>(url: string, token?: string, signal?: AbortSi
37
66
  return (await response.json()) as T;
38
67
  }
39
68
 
40
- export function toIssuePath(component: string, projectKey: string): string {
69
+ export function toIssuePath(component: string | undefined | null, projectKey: string): string {
70
+ if (typeof component !== "string" || component.length === 0) return projectKey;
41
71
  const prefix = `${projectKey}:`;
42
72
  if (component.startsWith(prefix)) return component.slice(prefix.length);
43
73
  const colon = component.indexOf(":");
@@ -61,8 +91,25 @@ export function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIs
61
91
  statuses: normalizeIssueList(filters?.statuses, true),
62
92
  types: normalizeIssueList(filters?.types, true),
63
93
  rules: normalizeIssueList(filters?.rules),
94
+ softwareQualities: normalizeIssueList(filters?.softwareQualities, true),
95
+ impactSeverities: normalizeIssueList(filters?.impactSeverities, true),
64
96
  };
65
- return normalized.severities || normalized.statuses || normalized.types || normalized.rules ? normalized : undefined;
97
+ return normalized.severities || normalized.statuses || normalized.types || normalized.rules || normalized.softwareQualities || normalized.impactSeverities
98
+ ? normalized
99
+ : undefined;
100
+ }
101
+
102
+ export function assertFiltersNotAmbiguous(filters?: SonarIssueFetchOptions): void {
103
+ if (!filters) return;
104
+ if (
105
+ (filters.severities?.length || filters.types?.length) &&
106
+ (filters.softwareQualities?.length || filters.impactSeverities?.length)
107
+ ) {
108
+ throw new Error(
109
+ "Ambiguous filter combination: mixing legacy filters (severity/type) with MQR filters (quality/impactSeverity) is not allowed. " +
110
+ "Use one filter family per command.",
111
+ );
112
+ }
66
113
  }
67
114
 
68
115
  const EXPLICIT_FILTER_RE = /^([^:=]+)[:=](.+)$/;
@@ -77,17 +124,38 @@ export function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOpt
77
124
  const values = explicit[2].split(",").map((p) => p.trim()).filter(Boolean);
78
125
  if (values.length === 0) return undefined;
79
126
 
80
- if (key === "severity" || key === "severities") return { severities: values.map((v) => v.toUpperCase()) };
81
- if (key === "status" || key === "statuses") return { statuses: values.map((v) => v.toUpperCase()) };
82
- if (key === "type" || key === "types") return { types: values.map((v) => v.toUpperCase()) };
83
- if (key === "rule" || key === "rules") return { rules: values };
84
- return undefined;
127
+ switch (key) {
128
+ case "severity":
129
+ case "severities":
130
+ return { severities: values.map((v) => v.toUpperCase()) };
131
+ case "status":
132
+ case "statuses":
133
+ return { statuses: values.map((v) => v.toUpperCase()) };
134
+ case "type":
135
+ case "types":
136
+ return { types: values.map((v) => v.toUpperCase()) };
137
+ case "rule":
138
+ case "rules":
139
+ return { rules: values };
140
+ case "quality":
141
+ case "qualities":
142
+ case "softwarequality":
143
+ case "softwarequalities":
144
+ return { softwareQualities: values.map((v) => v.toUpperCase()) };
145
+ case "impactseverity":
146
+ case "impactseverities":
147
+ return { impactSeverities: values.map((v) => v.toUpperCase()) };
148
+ default:
149
+ return undefined;
150
+ }
85
151
  }
86
152
 
87
153
  const upper = trimmed.toUpperCase();
88
154
  if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number])) return { severities: [upper] };
89
155
  if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number])) return { statuses: [upper] };
90
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] };
91
159
  return undefined;
92
160
  }
93
161
 
@@ -130,18 +198,21 @@ function mergeFilters(
130
198
  if (filter.statuses?.length) merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
131
199
  if (filter.types?.length) merged.types = [...(merged.types ?? []), ...filter.types];
132
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];
133
203
  }
134
204
  return normalizeIssueFilters(merged);
135
205
  }
136
206
 
137
207
  export function issueFilterLabel(filters?: SonarIssueFetchOptions): string {
138
208
  if (!filters) return "";
139
- const parts = [
140
- filters.severities?.length ? `severities=${filters.severities.join(",")}` : "",
141
- filters.statuses?.length ? `statuses=${filters.statuses.join(",")}` : "",
142
- filters.types?.length ? `types=${filters.types.join(",")}` : "",
143
- filters.rules?.length ? `rules=${filters.rules.join(",")}` : "",
144
- ].filter(Boolean);
209
+ 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(",")}`);
212
+ if (filters.types?.length) parts.push(`types=${filters.types.join(",")}`);
213
+ 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(",")}`);
145
216
  return parts.join(" • ");
146
217
  }
147
218
 
@@ -328,6 +399,8 @@ function buildIssueSearchUrl(
328
399
  if (filters?.statuses?.length) url.searchParams.set("statuses", filters.statuses.join(","));
329
400
  if (filters?.types?.length) url.searchParams.set("types", filters.types.join(","));
330
401
  if (filters?.rules?.length) url.searchParams.set("rules", filters.rules.join(","));
402
+ if (filters?.softwareQualities?.length) url.searchParams.set("impactSoftwareQualities", filters.softwareQualities.join(","));
403
+ if (filters?.impactSeverities?.length) url.searchParams.set("impactSeverities", filters.impactSeverities.join(","));
331
404
  return url.toString();
332
405
  }
333
406
 
@@ -387,6 +460,7 @@ export async function fetchIssues(
387
460
  signal?: AbortSignal,
388
461
  filters?: SonarIssueFetchOptions,
389
462
  ): Promise<SonarIssue[]> {
463
+ assertFiltersNotAmbiguous(filters);
390
464
  const normalizedFilters = normalizeIssueFilters(filters);
391
465
  const issues: SonarIssue[] = [];
392
466
  const ruleNames = new Map<string, string | undefined>();
@@ -440,7 +514,7 @@ export async function fetchRuleName(
440
514
  export function createAnalysisState(
441
515
  config: Pick<SonarProjectConfig, "baseDir" | "serverUrl" | "projectKey">,
442
516
  issues: SonarIssue[],
443
- extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters" | "measures">> = {},
517
+ extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters" | "measures" | "cleanCodeMode">> = {},
444
518
  ): SonarAnalysisState {
445
519
  return {
446
520
  version: 1,
@@ -454,6 +528,7 @@ export function createAnalysisState(
454
528
  filters: extras.filters,
455
529
  totalIssues: issues.length,
456
530
  measures: extras.measures,
531
+ cleanCodeMode: extras.cleanCodeMode,
457
532
  issues,
458
533
  };
459
534
  }
@@ -516,6 +591,33 @@ export async function fetchIssueSeverityCounts(
516
591
  }
517
592
  }
518
593
 
594
+ export async function fetchIssueQualityCounts(
595
+ serverUrl: string,
596
+ token: string | undefined,
597
+ projectKey: string,
598
+ signal?: AbortSignal,
599
+ ): Promise<IssueQualityCounts | undefined> {
600
+ try {
601
+ const url = `${serverUrl}/api/issues/search?projects=${encodeURIComponent(projectKey)}&resolved=false&ps=1&facets=impactSoftwareQualities`;
602
+ const result = await fetchJson<{
603
+ facets: Array<{ property: string; values: Array<{ val: string; count: number }> }>;
604
+ }>(url, token, signal);
605
+ const facet = result.facets?.find((f) => f.property === "impactSoftwareQualities");
606
+ if (!facet) return undefined;
607
+ const getCount = (q: string): number => {
608
+ const entry = facet.values.find((v) => v.val === q);
609
+ return entry?.count ?? 0;
610
+ };
611
+ return {
612
+ maintainability: getCount("MAINTAINABILITY"),
613
+ reliability: getCount("RELIABILITY"),
614
+ security: getCount("SECURITY"),
615
+ };
616
+ } catch {
617
+ return undefined;
618
+ }
619
+ }
620
+
519
621
  // ── File duplications ─────────────────────────────────────────────────────────
520
622
 
521
623
  export async function fetchFileDuplications(
@@ -524,42 +626,35 @@ export async function fetchFileDuplications(
524
626
  projectKey: string,
525
627
  signal?: AbortSignal,
526
628
  ): Promise<FileDuplication[]> {
527
- // Get file keys from issues search facets
528
- const facetUrl = `${serverUrl}/api/issues/search?projects=${encodeURIComponent(projectKey)}&ps=1&facets=files`;
529
- const facetResult = await fetchJson<{
530
- facets: Array<{ property: string; values: Array<{ val: string; count: number }> }>;
531
- }>(facetUrl, token, signal);
532
-
533
- const fileFacet = facetResult.facets?.find((f) => f.property === "files");
534
- if (!fileFacet?.values?.length) return [];
535
-
536
- // Query per-file duplication measures. CATCH errors per file (404/403 = inaccessible)
537
- const results: Array<FileDuplication | null> = await Promise.all(
538
- fileFacet.values.map(async (entry) => {
539
- try {
540
- const fileKey = `${projectKey}:${entry.val}`;
541
- const url = `${serverUrl}/api/measures/component?component=${encodeURIComponent(fileKey)}&metricKeys=duplicated_lines,duplicated_blocks`;
542
- const res = await fetchJson<{
543
- component: { measures?: Array<{ metric: string; value: string }> };
544
- }>(url, token, signal);
545
- const measures = res.component.measures ?? [];
546
- const getValue = (metric: string): number => {
547
- const m = measures.find((m) => m.metric === metric);
548
- return m ? Number.parseInt(m.value, 10) : 0;
549
- };
550
- return {
551
- filePath: entry.val,
552
- fileKey: `${projectKey}:${entry.val}`,
553
- duplicatedLines: getValue("duplicated_lines"),
554
- duplicatedBlocks: getValue("duplicated_blocks"),
555
- };
556
- } catch {
557
- return null;
558
- }
559
- }),
560
- );
561
-
562
- return results.filter((f): f is FileDuplication => f !== null && f.duplicatedBlocks > 0);
629
+ const treeUrl = `${serverUrl}/api/measures/component_tree?component=${encodeURIComponent(projectKey)}&qualifiers=FIL&metricKeys=duplicated_lines,duplicated_blocks&ps=500`;
630
+ const treeResult = await fetchJson<{
631
+ components: Array<{
632
+ key: string;
633
+ path?: string;
634
+ name?: string;
635
+ measures?: Array<{ metric: string; value: string }>;
636
+ }>;
637
+ }>(treeUrl, token, signal);
638
+
639
+ const results = (treeResult.components ?? [])
640
+ .map((component) => {
641
+ const measures = component.measures ?? [];
642
+ const getValue = (metric: string): number => {
643
+ const m = measures.find((item) => item.metric === metric);
644
+ return m ? Number.parseInt(m.value, 10) : 0;
645
+ };
646
+ const duplicatedBlocks = getValue("duplicated_blocks");
647
+ if (duplicatedBlocks <= 0) return null;
648
+ return {
649
+ filePath: component.path ?? toIssuePath(component.key, projectKey) ?? component.name ?? component.key,
650
+ fileKey: component.key,
651
+ duplicatedLines: getValue("duplicated_lines"),
652
+ duplicatedBlocks,
653
+ };
654
+ })
655
+ .filter((f): f is FileDuplication => f !== null);
656
+
657
+ return results;
563
658
  }
564
659
 
565
660
  export async function fetchFileDuplicationBlocks(
package/src/commands.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { AutocompleteItem, AutocompleteSuggestions } from "@earendil-works/pi-tui";
2
- import type { SonarIssue, SonarIssueFetchOptions, SonarDuplicationMeasures, IssueSeverityCounts, FileDuplication, DuplicationBlockGroup } from "./types.js";
3
- import { SONAR_SEVERITIES, SONAR_STATUSES, SONAR_TYPES } from "./types.js";
2
+ import type { SonarIssue, SonarIssueFetchOptions, SonarDuplicationMeasures, IssueSeverityCounts, IssueQualityCounts, FileDuplication, DuplicationBlockGroup } from "./types.js";
3
+ import { SONAR_SEVERITIES, SONAR_STATUSES, SONAR_TYPES, SONAR_SOFTWARE_QUALITIES, SONAR_IMPACT_SEVERITIES } from "./types.js";
4
4
  import { looksLikePath } from "./config.js";
5
5
  import { parseSonarIssueArgs, issueFilterLabel } from "./api.js";
6
6
 
@@ -69,23 +69,34 @@ export function splitSonarArgumentContext(
69
69
  };
70
70
  }
71
71
 
72
- function createFilterCompletionList(): AutocompleteItem[] {
73
- const entries: AutocompleteItem[] = [];
74
- const add = (value: string, desc: string) => {
75
- entries.push(
76
- { value: `severity:${value}`, label: `severity:${value}`, description: desc },
77
- { value, label: value, description: desc },
72
+ function createFilterCompletionList(mode?: "STANDARD" | "MQR"): AutocompleteItem[] {
73
+ const buildItems = (
74
+ groups: ReadonlyArray<readonly [string, readonly string[], string, boolean]>,
75
+ ): AutocompleteItem[] =>
76
+ groups.flatMap(([prefix, values, description, includeBare]) =>
77
+ values.flatMap((value) => [
78
+ { value: `${prefix}:${value}`, label: `${prefix}:${value}`, description },
79
+ ...(includeBare ? [{ value, label: value, description }] : []),
80
+ ]),
78
81
  );
79
- };
80
- for (const value of SONAR_SEVERITIES) add(value, "severity");
81
- for (const value of SONAR_STATUSES) add(value, "status");
82
- for (const value of SONAR_TYPES) add(value, "type");
83
- return entries;
82
+
83
+ const legacyGroups = [
84
+ ["severity", SONAR_SEVERITIES, "severity", true],
85
+ ["status", SONAR_STATUSES, "status", true],
86
+ ["type", SONAR_TYPES, "type", true],
87
+ ] as const;
88
+ const mqrGroups = [
89
+ ["quality", SONAR_SOFTWARE_QUALITIES, "software quality (MQR)", true],
90
+ ["impactSeverity", SONAR_IMPACT_SEVERITIES, "impact severity (MQR)", false],
91
+ ] as const;
92
+
93
+ return mode === "MQR" ? [...buildItems(mqrGroups), ...buildItems(legacyGroups)] : [...buildItems(legacyGroups), ...buildItems(mqrGroups)];
84
94
  }
85
95
 
86
96
  export function sonarArgumentCompletions(
87
97
  argumentText: string,
88
98
  issues?: SonarIssue[],
99
+ mode?: "STANDARD" | "MQR",
89
100
  ): AutocompleteItem[] | null {
90
101
  const { command, current, tokens } = splitSonarArgumentContext(argumentText);
91
102
  const lowerCommand = command.toLowerCase();
@@ -102,13 +113,13 @@ export function sonarArgumentCompletions(
102
113
  const lineSuffix = issue.line ? `:${issue.line}` : "";
103
114
  return { value: String(index + 1), label: String(index + 1), description: `${issue.severity} ${issue.filePath}${lineSuffix}` };
104
115
  });
105
- const suggestions = [...issueItems, ...createFilterCompletionList()];
116
+ const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
106
117
  const filtered = filterAutocompleteItems(suggestions, current);
107
118
  return filtered.length > 0 ? filtered : null;
108
119
  }
109
120
 
110
121
  if (lowerCommand === "analyze" || lowerCommand === "issues") {
111
- const suggestions = createFilterCompletionList();
122
+ const suggestions = createFilterCompletionList(mode);
112
123
  const filtered = filterAutocompleteItems(suggestions, current);
113
124
  return filtered.length > 0 ? filtered : null;
114
125
  }
@@ -182,10 +193,16 @@ export function helpText(): string {
182
193
  "Filters:",
183
194
  " /sonarqube issues be CRITICAL",
184
195
  " /sonarqube issues be severity:CRITICAL status:OPEN",
196
+ " /sonarqube issues be quality:RELIABILITY",
197
+ " /sonarqube issues be quality:SECURITY impactSeverity:HIGH",
185
198
  "",
186
199
  "Autocomplete:",
187
200
  " type /sonarqube and press Tab to complete the subcommand or filters",
188
201
  "",
202
+ "Notes:",
203
+ " Legacy filters (severity, type) and MQR filters (quality, impactSeverity)",
204
+ " cannot be combined in the same command. Use one filter family per query.",
205
+ "",
189
206
  "Defaults:",
190
207
  " config is stored in .pi/sonarqube.json",
191
208
  " use project paths directly (no alias needed)",
@@ -213,6 +230,7 @@ export function formatMetricsOutput(metrics: {
213
230
  projectKey: string;
214
231
  measures?: SonarDuplicationMeasures;
215
232
  issueCounts?: IssueSeverityCounts;
233
+ issueQualityCounts?: IssueQualityCounts;
216
234
  }): string {
217
235
  const lines: string[] = [`Metrics for ${metrics.projectKey}`];
218
236
 
@@ -236,6 +254,15 @@ export function formatMetricsOutput(metrics: {
236
254
  lines.push(`Issues: ${counts.join(" ")}`);
237
255
  }
238
256
 
257
+ if (metrics.issueQualityCounts) {
258
+ const counts = [
259
+ `MAINTAINABILITY ${metrics.issueQualityCounts.maintainability}`,
260
+ `RELIABILITY ${metrics.issueQualityCounts.reliability}`,
261
+ `SECURITY ${metrics.issueQualityCounts.security}`,
262
+ ];
263
+ lines.push(`Quality: ${counts.join(" ")}`);
264
+ }
265
+
239
266
  return lines.join("\n");
240
267
  }
241
268
 
package/src/index.ts CHANGED
@@ -33,10 +33,13 @@ import {
33
33
  fetchIssues,
34
34
  fetchDuplicationMeasures,
35
35
  fetchIssueSeverityCounts,
36
+ fetchIssueQualityCounts,
36
37
  fetchFileDuplications,
37
38
  fetchFileDuplicationBlocks,
38
39
  createAnalysisState,
39
40
  issueFilterLabel,
41
+ fetchCleanCodeMode,
42
+ assertFiltersNotAmbiguous,
40
43
  } from "./api.js";
41
44
  import {
42
45
  STATE_TYPE,
@@ -76,6 +79,12 @@ const SonarToolParams = Type.Object({
76
79
  ),
77
80
  types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
78
81
  rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
82
+ softwareQualities: Type.Optional(
83
+ Type.Array(Type.String({ description: "Software quality to fetch (e.g. MAINTAINABILITY)" })),
84
+ ),
85
+ impactSeverities: Type.Optional(
86
+ Type.Array(Type.String({ description: "Impact severity to fetch (e.g. HIGH)" })),
87
+ ),
79
88
  });
80
89
 
81
90
  // ── State restoration ───────────────────────────────────────────────────────
@@ -144,12 +153,16 @@ async function analyzeProject(
144
153
  ctx.signal,
145
154
  );
146
155
 
156
+ analysisUi.setPhase("Detecting mode...");
157
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
158
+
147
159
  const state = createAnalysisState(config, issues, {
148
160
  dashboardUrl,
149
161
  ceTaskUrl,
150
162
  analysisId,
151
163
  filters: normalizedFilters,
152
164
  measures,
165
+ cleanCodeMode,
153
166
  });
154
167
 
155
168
  pi.appendEntry(STATE_TYPE, state);
@@ -324,7 +337,7 @@ export default function sonarqube(pi: ExtensionAPI) {
324
337
 
325
338
  const argumentText = match[1] ?? "";
326
339
  const { current: currentToken } = splitSonarArgumentContext(argumentText);
327
- const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues) ?? [];
340
+ const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues, latestState?.cleanCodeMode) ?? [];
328
341
  const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
329
342
  const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
330
343
  return merged ?? currentSuggestions;
@@ -352,7 +365,7 @@ export default function sonarqube(pi: ExtensionAPI) {
352
365
  promptSnippet: "Run local SonarQube analysis or inspect issue results",
353
366
  promptGuidelines: [
354
367
  "Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
355
- "Use the optional severity, status, type, and rule filters to fetch only the most relevant issues.",
368
+ "Use the optional severity, status, type, rule, quality, and impact severity filters to fetch only the most relevant issues.",
356
369
  ],
357
370
  parameters: SonarToolParams,
358
371
 
@@ -363,7 +376,10 @@ export default function sonarqube(pi: ExtensionAPI) {
363
376
  statuses: params.statuses,
364
377
  types: params.types,
365
378
  rules: params.rules,
379
+ softwareQualities: params.softwareQualities,
380
+ impactSeverities: params.impactSeverities,
366
381
  });
382
+ assertFiltersNotAmbiguous(filters);
367
383
 
368
384
  if (params.action === "analyze") {
369
385
  const state = await analyzeProject(pi, ctx, params.path, filters);
@@ -414,6 +430,8 @@ export default function sonarqube(pi: ExtensionAPI) {
414
430
  statuses: args.statuses,
415
431
  types: args.types,
416
432
  rules: args.rules,
433
+ softwareQualities: args.softwareQualities,
434
+ impactSeverities: args.impactSeverities,
417
435
  });
418
436
  const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
419
437
  return new Text(
@@ -457,7 +475,7 @@ export default function sonarqube(pi: ExtensionAPI) {
457
475
 
458
476
  pi.registerCommand("sonarqube", {
459
477
  description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
460
- getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
478
+ getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues, latestState?.cleanCodeMode),
461
479
  handler: async (args, ctx) => {
462
480
  try {
463
481
  const parsed = parseCommandArgs(args);
@@ -515,17 +533,18 @@ async function commandMetrics(
515
533
  targetInput?: string,
516
534
  ): Promise<void> {
517
535
  const config = await resolveConfig(ctx, targetInput);
518
- const [measures, issueCounts] = await Promise.all([
536
+ const [measures, issueCounts, qualityCounts] = await Promise.all([
519
537
  fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
520
538
  fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
539
+ fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
521
540
  ]);
522
- if (!measures && !issueCounts) {
541
+ if (!measures && !issueCounts && !qualityCounts) {
523
542
  if (ctx.hasUI) {
524
543
  ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
525
544
  }
526
545
  return;
527
546
  }
528
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
547
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts });
529
548
  if (ctx.hasUI) {
530
549
  ctx.ui.notify(text, "info");
531
550
  }
@@ -552,18 +571,7 @@ async function commandDuplications(
552
571
  throw error;
553
572
  }
554
573
  if (files.length === 0) {
555
- // Fallback: check project-level metrics for confirmation
556
- const projectMeasures = await fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal).catch(() => undefined);
557
- if (projectMeasures && projectMeasures.duplicatedFiles > 0) {
558
- if (ctx.hasUI) {
559
- ctx.ui.notify(
560
- `Cannot list individual files — your SonarQube token lacks "See Source Code" permission. Grant it in SonarQube project permissions, then run /sonarqube init to update the token.`,
561
- "warning",
562
- );
563
- }
564
- } else if (ctx.hasUI) {
565
- ctx.ui.notify("No duplications found.", "info");
566
- }
574
+ if (ctx.hasUI) ctx.ui.notify("No duplications found.", "info");
567
575
  return;
568
576
  }
569
577
 
@@ -707,18 +715,19 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
707
715
  details: { error: "Project not configured." },
708
716
  };
709
717
  }
710
- const [measures, issueCounts] = await Promise.all([
718
+ const [measures, issueCounts, qualityCounts] = await Promise.all([
711
719
  fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
712
720
  fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
721
+ fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
713
722
  ]);
714
- if (!measures && !issueCounts) {
723
+ if (!measures && !issueCounts && !qualityCounts) {
715
724
  return {
716
725
  content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
717
726
  details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
718
727
  };
719
728
  }
720
- const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
721
- return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts } };
729
+ const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts });
730
+ return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts } };
722
731
  }
723
732
 
724
733
  // ── Re-exports for public API ───────────────────────────────────────────────
@@ -730,6 +739,7 @@ export type {
730
739
  SonarProjectConfig,
731
740
  SonarIssueFetchOptions,
732
741
  SonarInitConfig,
742
+ IssueQualityCounts,
733
743
  } from "./types.js";
734
744
 
735
745
  export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";
package/src/types.ts CHANGED
@@ -28,6 +28,12 @@ export interface IssueSeverityCounts {
28
28
  info: number;
29
29
  }
30
30
 
31
+ export interface IssueQualityCounts {
32
+ maintainability: number;
33
+ reliability: number;
34
+ security: number;
35
+ }
36
+
31
37
  export interface FileDuplication {
32
38
  filePath: string;
33
39
  fileKey: string;
@@ -58,6 +64,7 @@ export interface SonarAnalysisState {
58
64
  measures?: SonarDuplicationMeasures;
59
65
  totalIssues: number;
60
66
  issues: SonarIssue[];
67
+ cleanCodeMode?: "STANDARD" | "MQR";
61
68
  }
62
69
 
63
70
  export interface SonarProjectConfig {
@@ -73,6 +80,8 @@ export interface SonarIssueFetchOptions {
73
80
  statuses?: string[];
74
81
  types?: string[];
75
82
  rules?: string[];
83
+ softwareQualities?: string[];
84
+ impactSeverities?: string[];
76
85
  }
77
86
 
78
87
  export interface SonarInitConfig {
@@ -110,3 +119,5 @@ export interface InitCommandOptions {
110
119
  export const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
111
120
  export const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
112
121
  export const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
122
+ export const SONAR_SOFTWARE_QUALITIES = ["MAINTAINABILITY", "RELIABILITY", "SECURITY"] as const;
123
+ export const SONAR_IMPACT_SEVERITIES = ["BLOCKER", "HIGH", "MEDIUM", "LOW", "INFO"] as const;
package/src/ui.ts CHANGED
@@ -3,7 +3,7 @@ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication } from "./types.js";
6
- import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters } from "./api.js";
6
+ import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters, fetchCleanCodeMode } from "./api.js";
7
7
  import { resolveConfig, resolveTarget } from "./config.js";
8
8
 
9
9
  // ── Issue preview ───────────────────────────────────────────────────────────
@@ -300,7 +300,8 @@ export async function loadProjectIssuesFromApi(
300
300
  ctx.signal,
301
301
  normalizedFilters,
302
302
  );
303
- return createAnalysisState(config, issues, { filters: normalizedFilters });
303
+ const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
304
+ return createAnalysisState(config, issues, { filters: normalizedFilters, cleanCodeMode });
304
305
  }
305
306
 
306
307
  export async function resolveTargetState(