@jonathan0823/pi-sonarqube 0.1.4 → 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
@@ -6,10 +6,43 @@ import type {
6
6
  SonarAnalysisState,
7
7
  SonarProjectConfig,
8
8
  SonarIssueFetchOptions,
9
+ SonarDuplicationMeasures,
10
+ IssueSeverityCounts,
11
+ IssueQualityCounts,
12
+ FileDuplication,
13
+ DuplicationBlockGroup,
9
14
  } from "./types.js";
10
- 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";
11
16
  import { parseProperties } from "./config.js";
12
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
+
13
46
  // ── Auth / HTTP helpers ─────────────────────────────────────────────────────
14
47
 
15
48
  export function authHeader(token?: string): string | undefined {
@@ -33,7 +66,8 @@ export async function fetchJson<T>(url: string, token?: string, signal?: AbortSi
33
66
  return (await response.json()) as T;
34
67
  }
35
68
 
36
- 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;
37
71
  const prefix = `${projectKey}:`;
38
72
  if (component.startsWith(prefix)) return component.slice(prefix.length);
39
73
  const colon = component.indexOf(":");
@@ -57,26 +91,25 @@ export function normalizeIssueFilters(filters?: SonarIssueFetchOptions): SonarIs
57
91
  statuses: normalizeIssueList(filters?.statuses, true),
58
92
  types: normalizeIssueList(filters?.types, true),
59
93
  rules: normalizeIssueList(filters?.rules),
94
+ softwareQualities: normalizeIssueList(filters?.softwareQualities, true),
95
+ impactSeverities: normalizeIssueList(filters?.impactSeverities, true),
60
96
  };
61
- 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;
62
100
  }
63
101
 
64
- export function mergeIssueFilters(
65
- ...filters: Array<Partial<SonarIssueFetchOptions> | undefined>
66
- ): SonarIssueFetchOptions | undefined {
67
- const merged: SonarIssueFetchOptions = {};
68
- for (const filter of filters) {
69
- if (!filter) continue;
70
- if (filter.severities?.length) merged.severities = [...(merged.severities ?? []), ...filter.severities];
71
- if (filter.statuses?.length) merged.statuses = [...(merged.statuses ?? []), ...filter.statuses];
72
- if (filter.types?.length) merged.types = [...(merged.types ?? []), ...filter.types];
73
- if (filter.rules?.length) merged.rules = [...(merged.rules ?? []), ...filter.rules];
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
+ );
74
112
  }
75
- return normalizeIssueFilters(merged);
76
- }
77
-
78
- export function splitFilterValues(value: string): string[] {
79
- return value.split(",").map((p) => p.trim()).filter(Boolean);
80
113
  }
81
114
 
82
115
  const EXPLICIT_FILTER_RE = /^([^:=]+)[:=](.+)$/;
@@ -88,20 +121,41 @@ export function parseIssueFilterToken(token: string): Partial<SonarIssueFetchOpt
88
121
  const explicit = EXPLICIT_FILTER_RE.exec(trimmed);
89
122
  if (explicit) {
90
123
  const key = explicit[1].trim().toLowerCase();
91
- const values = splitFilterValues(explicit[2]);
124
+ const values = explicit[2].split(",").map((p) => p.trim()).filter(Boolean);
92
125
  if (values.length === 0) return undefined;
93
126
 
94
- if (key === "severity" || key === "severities") return { severities: values.map((v) => v.toUpperCase()) };
95
- if (key === "status" || key === "statuses") return { statuses: values.map((v) => v.toUpperCase()) };
96
- if (key === "type" || key === "types") return { types: values.map((v) => v.toUpperCase()) };
97
- if (key === "rule" || key === "rules") return { rules: values };
98
- 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
+ }
99
151
  }
100
152
 
101
153
  const upper = trimmed.toUpperCase();
102
154
  if (SONAR_SEVERITIES.includes(upper as (typeof SONAR_SEVERITIES)[number])) return { severities: [upper] };
103
155
  if (SONAR_STATUSES.includes(upper as (typeof SONAR_STATUSES)[number])) return { statuses: [upper] };
104
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] };
105
159
  return undefined;
106
160
  }
107
161
 
@@ -131,17 +185,34 @@ export function parseSonarIssueArgs(
131
185
  }
132
186
  }
133
187
 
134
- return { targetInput, issueIndex, filters: mergeIssueFilters(...filters) };
188
+ return { targetInput, issueIndex, filters: mergeFilters(...filters) };
189
+ }
190
+
191
+ function mergeFilters(
192
+ ...filters: Array<Partial<SonarIssueFetchOptions> | undefined>
193
+ ): SonarIssueFetchOptions | undefined {
194
+ const merged: SonarIssueFetchOptions = {};
195
+ for (const filter of filters) {
196
+ 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];
203
+ }
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">> = {},
517
+ extras: Partial<Pick<SonarAnalysisState, "dashboardUrl" | "ceTaskUrl" | "analysisId" | "filters" | "measures" | "cleanCodeMode">> = {},
444
518
  ): SonarAnalysisState {
445
519
  return {
446
520
  version: 1,
@@ -453,24 +527,156 @@ export function createAnalysisState(
453
527
  analysisId: extras.analysisId,
454
528
  filters: extras.filters,
455
529
  totalIssues: issues.length,
530
+ measures: extras.measures,
531
+ cleanCodeMode: extras.cleanCodeMode,
456
532
  issues,
457
533
  };
458
534
  }
459
535
 
460
- export async function hasProjectAnalyses(
536
+ // ── Measures ──────────────────────────────────────────────────────────────────
537
+
538
+ export async function fetchDuplicationMeasures(
461
539
  serverUrl: string,
462
540
  token: string | undefined,
463
541
  projectKey: string,
464
542
  signal?: AbortSignal,
465
- ): Promise<boolean> {
543
+ ): Promise<SonarDuplicationMeasures | undefined> {
466
544
  try {
467
- const result = await fetchJson<{ analyses?: Array<{ key?: string }> }>(
468
- `${serverUrl}/api/project_analyses/search?project=${encodeURIComponent(projectKey)}&ps=1`,
469
- token,
470
- signal,
471
- );
472
- return (result.analyses?.length ?? 0) > 0;
545
+ const url = `${serverUrl}/api/measures/component?component=${encodeURIComponent(projectKey)}&metricKeys=duplicated_lines_density,duplicated_lines,duplicated_blocks,duplicated_files`;
546
+ const result = await fetchJson<{
547
+ component: { measures: Array<{ metric: string; value: string }> };
548
+ }>(url, token, signal);
549
+ const measures = result.component.measures;
550
+ const getValue = (key: string): number => {
551
+ const m = measures.find((m) => m.metric === key);
552
+ return m ? Number.parseFloat(m.value) : 0;
553
+ };
554
+ return {
555
+ duplicatedLinesDensity: getValue("duplicated_lines_density"),
556
+ duplicatedLines: getValue("duplicated_lines"),
557
+ duplicatedBlocks: getValue("duplicated_blocks"),
558
+ duplicatedFiles: getValue("duplicated_files"),
559
+ };
473
560
  } catch {
474
- return false;
561
+ return undefined;
475
562
  }
476
563
  }
564
+
565
+ export async function fetchIssueSeverityCounts(
566
+ serverUrl: string,
567
+ token: string | undefined,
568
+ projectKey: string,
569
+ signal?: AbortSignal,
570
+ ): Promise<IssueSeverityCounts | undefined> {
571
+ try {
572
+ const url = `${serverUrl}/api/issues/search?projects=${encodeURIComponent(projectKey)}&resolved=false&ps=1&facets=severities`;
573
+ const result = await fetchJson<{
574
+ facets: Array<{ property: string; values: Array<{ val: string; count: number }> }>;
575
+ }>(url, token, signal);
576
+ const facet = result.facets?.find((f) => f.property === "severities");
577
+ if (!facet) return undefined;
578
+ const getCount = (severity: string): number => {
579
+ const entry = facet.values.find((v) => v.val === severity);
580
+ return entry?.count ?? 0;
581
+ };
582
+ return {
583
+ blocker: getCount("BLOCKER"),
584
+ critical: getCount("CRITICAL"),
585
+ major: getCount("MAJOR"),
586
+ minor: getCount("MINOR"),
587
+ info: getCount("INFO"),
588
+ };
589
+ } catch {
590
+ return undefined;
591
+ }
592
+ }
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
+
621
+ // ── File duplications ─────────────────────────────────────────────────────────
622
+
623
+ export async function fetchFileDuplications(
624
+ serverUrl: string,
625
+ token: string | undefined,
626
+ projectKey: string,
627
+ signal?: AbortSignal,
628
+ ): Promise<FileDuplication[]> {
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;
658
+ }
659
+
660
+ export async function fetchFileDuplicationBlocks(
661
+ serverUrl: string,
662
+ token: string | undefined,
663
+ fileKey: string,
664
+ projectKey: string,
665
+ signal?: AbortSignal,
666
+ ): Promise<DuplicationBlockGroup[]> {
667
+ const url = `${serverUrl}/api/duplications/show?key=${encodeURIComponent(fileKey)}`;
668
+ const result = await fetchJson<{
669
+ duplications: Array<{
670
+ blocks: Array<{ from: number; size: number; component: string }>;
671
+ }>;
672
+ }>(url, token, signal);
673
+ return result.duplications.map((d) => ({
674
+ blocks: d.blocks.map((b) => ({
675
+ from: b.from,
676
+ size: b.size,
677
+ filePath: toIssuePath(b.component, projectKey),
678
+ })),
679
+ }));
680
+ }
681
+
682
+
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 } 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
 
@@ -13,14 +13,12 @@ const SONAR_COMMANDS = [
13
13
  { value: "issues", label: "issues", description: "browse the latest issues" },
14
14
  { value: "open", label: "open", description: "preview a specific issue" },
15
15
  { value: "init", label: "init", description: "configure a project target" },
16
+ { value: "metrics", label: "metrics", description: "show project metrics (duplication, issue counts)" },
17
+ { value: "duplications", label: "duplications", description: "browse duplicated files and blocks" },
16
18
  ] as const;
17
19
 
18
20
  // ── Autocomplete helpers ────────────────────────────────────────────────────
19
21
 
20
- function createAutocompleteItem(value: string, description?: string): AutocompleteItem {
21
- return description ? { value, label: value, description } : { value, label: value };
22
- }
23
-
24
22
  export function filterAutocompleteItems(
25
23
  items: readonly { value: string; label: string; description?: string }[],
26
24
  prefix: string,
@@ -71,23 +69,34 @@ export function splitSonarArgumentContext(
71
69
  };
72
70
  }
73
71
 
74
- function createFilterCompletionList(): AutocompleteItem[] {
75
- const entries: AutocompleteItem[] = [];
76
- const add = (value: string, desc: string) => {
77
- entries.push(
78
- createAutocompleteItem(`severity:${value}`, desc),
79
- createAutocompleteItem(value, 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
+ ]),
80
81
  );
81
- };
82
- for (const value of SONAR_SEVERITIES) add(value, "severity");
83
- for (const value of SONAR_STATUSES) add(value, "status");
84
- for (const value of SONAR_TYPES) add(value, "type");
85
- 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)];
86
94
  }
87
95
 
88
96
  export function sonarArgumentCompletions(
89
97
  argumentText: string,
90
98
  issues?: SonarIssue[],
99
+ mode?: "STANDARD" | "MQR",
91
100
  ): AutocompleteItem[] | null {
92
101
  const { command, current, tokens } = splitSonarArgumentContext(argumentText);
93
102
  const lowerCommand = command.toLowerCase();
@@ -96,29 +105,26 @@ export function sonarArgumentCompletions(
96
105
 
97
106
  if (!command || commandMatches.length > 1 || !isFullMatch) {
98
107
  if (commandMatches.length === 0) return null;
99
- return commandMatches.map((item) => createAutocompleteItem(item.value, item.description));
108
+ return commandMatches.map((item) => ({ value: item.value, label: item.value, description: item.description }));
100
109
  }
101
110
 
102
111
  if (lowerCommand === "open") {
103
112
  const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
104
113
  const lineSuffix = issue.line ? `:${issue.line}` : "";
105
- return createAutocompleteItem(
106
- String(index + 1),
107
- `${issue.severity} ${issue.filePath}${lineSuffix}`,
108
- );
114
+ return { value: String(index + 1), label: String(index + 1), description: `${issue.severity} ${issue.filePath}${lineSuffix}` };
109
115
  });
110
- const suggestions = [...issueItems, ...createFilterCompletionList()];
116
+ const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
111
117
  const filtered = filterAutocompleteItems(suggestions, current);
112
118
  return filtered.length > 0 ? filtered : null;
113
119
  }
114
120
 
115
121
  if (lowerCommand === "analyze" || lowerCommand === "issues") {
116
- const suggestions = createFilterCompletionList();
122
+ const suggestions = createFilterCompletionList(mode);
117
123
  const filtered = filterAutocompleteItems(suggestions, current);
118
124
  return filtered.length > 0 ? filtered : null;
119
125
  }
120
126
 
121
- if (lowerCommand === "init") {
127
+ if (lowerCommand === "init" || lowerCommand === "metrics" || lowerCommand === "duplications") {
122
128
  return null;
123
129
  }
124
130
 
@@ -132,7 +138,10 @@ export type ParsedSonarCommand =
132
138
  | { action: "init"; alias?: string; targetInput?: string }
133
139
  | { action: "analyze"; targetInput?: string; filters?: SonarIssueFetchOptions }
134
140
  | { action: "issues"; targetInput?: string; filters?: SonarIssueFetchOptions }
135
- | { action: "open"; targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions };
141
+ | { action: "open"; targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions }
142
+ | { action: "metrics"; targetInput?: string }
143
+ | { action: "duplications"; targetInput?: string; issueIndex?: number };
144
+
136
145
 
137
146
  export function parseCommandArgs(args: string): ParsedSonarCommand {
138
147
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -157,6 +166,12 @@ export function parseCommandArgs(args: string): ParsedSonarCommand {
157
166
  if (head === "analyze" || head === "run") {
158
167
  return { action: "analyze", ...parseSonarIssueArgs(tokens.slice(1)) };
159
168
  }
169
+ if (head === "metrics") {
170
+ return { action: "metrics", ...parseSonarIssueArgs(tokens.slice(1)) };
171
+ }
172
+ if (head === "duplications") {
173
+ return { action: "duplications", ...parseSonarIssueArgs(tokens.slice(1), true) };
174
+ }
160
175
 
161
176
  return { action: "analyze", ...parseSonarIssueArgs(tokens) };
162
177
  }
@@ -171,15 +186,23 @@ export function helpText(): string {
171
186
  " /sonarqube analyze [target] run analysis for a target or path",
172
187
  " /sonarqube issues [target] browse issues for a target or path",
173
188
  " /sonarqube open [target] <n> preview issue #n for a target or path",
189
+ " /sonarqube metrics [target] show project metrics (no scanner)",
190
+ " /sonarqube duplications [target] browse duplicated files and blocks",
174
191
  " /sonarqube show this help",
175
192
  "",
176
193
  "Filters:",
177
194
  " /sonarqube issues be CRITICAL",
178
195
  " /sonarqube issues be severity:CRITICAL status:OPEN",
196
+ " /sonarqube issues be quality:RELIABILITY",
197
+ " /sonarqube issues be quality:SECURITY impactSeverity:HIGH",
179
198
  "",
180
199
  "Autocomplete:",
181
200
  " type /sonarqube and press Tab to complete the subcommand or filters",
182
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
+ "",
183
206
  "Defaults:",
184
207
  " config is stored in .pi/sonarqube.json",
185
208
  " use project paths directly (no alias needed)",
@@ -203,6 +226,69 @@ export function formatIssue(issue: SonarIssue, index?: number): string {
203
226
  return `${prefix}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
204
227
  }
205
228
 
229
+ export function formatMetricsOutput(metrics: {
230
+ projectKey: string;
231
+ measures?: SonarDuplicationMeasures;
232
+ issueCounts?: IssueSeverityCounts;
233
+ issueQualityCounts?: IssueQualityCounts;
234
+ }): string {
235
+ const lines: string[] = [`Metrics for ${metrics.projectKey}`];
236
+
237
+ if (metrics.measures) {
238
+ const density = metrics.measures.duplicatedLinesDensity.toFixed(1);
239
+ lines.push(
240
+ metrics.measures.duplicatedBlocks > 0
241
+ ? `Duplication: ${density}% lines=${metrics.measures.duplicatedLines} blocks=${metrics.measures.duplicatedBlocks} files=${metrics.measures.duplicatedFiles}`
242
+ : `Duplication: ${density}% (no duplications detected)`,
243
+ );
244
+ }
245
+
246
+ if (metrics.issueCounts) {
247
+ const counts = [
248
+ `BLOCKER ${metrics.issueCounts.blocker}`,
249
+ `CRITICAL ${metrics.issueCounts.critical}`,
250
+ `MAJOR ${metrics.issueCounts.major}`,
251
+ `MINOR ${metrics.issueCounts.minor}`,
252
+ `INFO ${metrics.issueCounts.info}`,
253
+ ];
254
+ lines.push(`Issues: ${counts.join(" ")}`);
255
+ }
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
+
266
+ return lines.join("\n");
267
+ }
268
+
269
+ export function formatDuplicationsList(files: FileDuplication[]): string {
270
+ if (files.length === 0) return "No duplicated files found.";
271
+ const lines: string[] = [`Duplicated files (${files.length})`];
272
+ for (const [i, f] of files.entries()) {
273
+ lines.push(` ${String(i + 1).padStart(2, " ")}. ${f.filePath} blocks=${f.duplicatedBlocks} lines=${f.duplicatedLines}`);
274
+ }
275
+ return lines.join("\n");
276
+ }
277
+
278
+ export function formatDuplicationBlockDetail(filePath: string, groups: DuplicationBlockGroup[]): string {
279
+ if (groups.length === 0) return `No duplications found in ${filePath}.`;
280
+ const lines: string[] = [`Duplications in ${filePath}`, ""];
281
+ for (const [i, group] of groups.entries()) {
282
+ lines.push(`Block ${i + 1}:`);
283
+ for (const block of group.blocks) {
284
+ const end = block.from + block.size - 1;
285
+ lines.push(` ${block.filePath}:${block.from}-${end}`);
286
+ }
287
+ lines.push("");
288
+ }
289
+ return lines.join("\n");
290
+ }
291
+
206
292
  export function formatSummary(state: { totalIssues: number; projectKey: string; filters?: SonarIssueFetchOptions }): string {
207
293
  const issueCount = state.totalIssues;
208
294
  const filterLabel = state.filters ? ` (${issueFilterLabel(state.filters)})` : "";
@@ -219,6 +305,7 @@ export function formatReport(state: {
219
305
  baseDir: string;
220
306
  totalIssues: number;
221
307
  filters?: SonarIssueFetchOptions;
308
+ measures?: SonarDuplicationMeasures;
222
309
  issues: SonarIssue[];
223
310
  }): string {
224
311
  const lines = [
@@ -232,8 +319,14 @@ export function formatReport(state: {
232
319
  lines.push(`Filters: ${issueFilterLabel(state.filters)}`);
233
320
  }
234
321
 
322
+ if (state.measures && state.issues.length > 0) {
323
+ lines.push("", state.measures.duplicatedBlocks > 0
324
+ ? `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
325
+ : `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% (no duplications detected)`);
326
+ }
327
+
235
328
  if (state.issues.length === 0) {
236
- lines.push("No open issues found.");
329
+ if (!state.measures) lines.push("No open issues found.");
237
330
  return lines.join("\n");
238
331
  }
239
332
 
package/src/config.ts CHANGED
@@ -292,10 +292,6 @@ export async function resolveInitTarget(
292
292
  }
293
293
  }
294
294
 
295
- if (targetInput) {
296
- return resolveTarget(ctx, targetInput);
297
- }
298
-
299
295
  return { baseDir: ctx.cwd, repoRoot: await findRepoRoot(ctx.cwd) };
300
296
  }
301
297