@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/commands.ts CHANGED
@@ -1,20 +1,57 @@
1
- import type { AutocompleteItem, AutocompleteSuggestions } from "@earendil-works/pi-tui";
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";
1
+ import type {
2
+ AutocompleteItem,
3
+ AutocompleteSuggestions,
4
+ } from "@earendil-works/pi-tui";
5
+ import type {
6
+ SonarIssue,
7
+ SonarIssueFetchOptions,
8
+ SonarDuplicationMeasures,
9
+ IssueSeverityCounts,
10
+ IssueQualityCounts,
11
+ FileDuplication,
12
+ DuplicationBlockGroup,
13
+ } from "./types.js";
14
+ import {
15
+ SONAR_SEVERITIES,
16
+ SONAR_STATUSES,
17
+ SONAR_TYPES,
18
+ SONAR_SOFTWARE_QUALITIES,
19
+ SONAR_IMPACT_SEVERITIES,
20
+ } from "./types.js";
21
+ import { execFileSync } from "node:child_process";
22
+ import { readdirSync } from "node:fs";
23
+ import { basename, dirname, relative, resolve } from "node:path";
4
24
  import { looksLikePath } from "./config.js";
5
- import { parseSonarIssueArgs, issueFilterLabel } from "./api.js";
25
+ import {
26
+ parseSonarIssueArgs,
27
+ issueFilterLabel,
28
+ fetchProjectProfiles,
29
+ fetchRuleSearch,
30
+ } from "./api.js";
6
31
 
7
32
  // ── Constants ───────────────────────────────────────────────────────────────
8
33
 
9
34
  export const STATE_TYPE = "sonarqube-analysis-state";
10
35
 
11
36
  const SONAR_COMMANDS = [
12
- { value: "analyze", label: "analyze", description: "run analysis and fetch issues" },
37
+ {
38
+ value: "analyze",
39
+ label: "analyze",
40
+ description: "run analysis and fetch issues",
41
+ },
13
42
  { value: "issues", label: "issues", description: "browse the latest issues" },
14
43
  { value: "open", label: "open", description: "preview a specific issue" },
15
44
  { 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" },
45
+ {
46
+ value: "metrics",
47
+ label: "metrics",
48
+ description: "show project metrics (duplication, issue counts)",
49
+ },
50
+ {
51
+ value: "duplications",
52
+ label: "duplications",
53
+ description: "browse duplicated files and blocks",
54
+ },
18
55
  ] as const;
19
56
 
20
57
  // ── Autocomplete helpers ────────────────────────────────────────────────────
@@ -24,13 +61,23 @@ export function filterAutocompleteItems(
24
61
  prefix: string,
25
62
  ): AutocompleteItem[] {
26
63
  const query = prefix.trim().toLowerCase();
27
- if (!query) return items.map((item) => ({ value: item.value, label: item.label, description: item.description }));
64
+ if (!query)
65
+ return items.map((item) => ({
66
+ value: item.value,
67
+ label: item.label,
68
+ description: item.description,
69
+ }));
28
70
  return items
29
71
  .filter(
30
72
  (item) =>
31
- item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().startsWith(query),
73
+ item.value.toLowerCase().startsWith(query) ||
74
+ item.label.toLowerCase().startsWith(query),
32
75
  )
33
- .map((item) => ({ value: item.value, label: item.label, description: item.description }));
76
+ .map((item) => ({
77
+ value: item.value,
78
+ label: item.label,
79
+ description: item.description,
80
+ }));
34
81
  }
35
82
 
36
83
  export function mergeAutocompleteSuggestions(
@@ -45,14 +92,16 @@ export function mergeAutocompleteSuggestions(
45
92
  return items.length > 0 ? { prefix, items } : current;
46
93
  }
47
94
 
48
- export function splitSonarArgumentContext(
49
- argumentText: string,
50
- ): { command: string; current: string; tokens: string[] } {
95
+ export function splitSonarArgumentContext(argumentText: string): {
96
+ command: string;
97
+ current: string;
98
+ tokens: string[];
99
+ } {
51
100
  const trimmedLeft = argumentText.replace(/^\s+/, "");
52
101
  if (!trimmedLeft) return { command: "", current: "", tokens: [] };
53
102
 
54
103
  const tokens = trimmedLeft.split(/\s+/);
55
- const current = /\s$/.test(argumentText) ? "" : tokens.pop() ?? "";
104
+ const current = /\s$/.test(argumentText) ? "" : (tokens.pop() ?? "");
56
105
  const command = tokens[0] ?? current;
57
106
  let tokensOut: string[];
58
107
  if (tokens.length > 0) {
@@ -69,13 +118,21 @@ export function splitSonarArgumentContext(
69
118
  };
70
119
  }
71
120
 
72
- function createFilterCompletionList(mode?: "STANDARD" | "MQR"): AutocompleteItem[] {
121
+ function createFilterCompletionList(
122
+ mode?: "STANDARD" | "MQR",
123
+ ): AutocompleteItem[] {
73
124
  const buildItems = (
74
- groups: ReadonlyArray<readonly [string, readonly string[], string, boolean]>,
125
+ groups: ReadonlyArray<
126
+ readonly [string, readonly string[], string, boolean]
127
+ >,
75
128
  ): AutocompleteItem[] =>
76
129
  groups.flatMap(([prefix, values, description, includeBare]) =>
77
130
  values.flatMap((value) => [
78
- { value: `${prefix}:${value}`, label: `${prefix}:${value}`, description },
131
+ {
132
+ value: `${prefix}:${value}`,
133
+ label: `${prefix}:${value}`,
134
+ description,
135
+ },
79
136
  ...(includeBare ? [{ value, label: value, description }] : []),
80
137
  ]),
81
138
  );
@@ -89,46 +146,335 @@ function createFilterCompletionList(mode?: "STANDARD" | "MQR"): AutocompleteItem
89
146
  ["quality", SONAR_SOFTWARE_QUALITIES, "software quality (MQR)", true],
90
147
  ["impactSeverity", SONAR_IMPACT_SEVERITIES, "impact severity (MQR)", false],
91
148
  ] as const;
149
+ const ruleItems = [
150
+ { value: "rule:", label: "rule:", description: "rule key" },
151
+ { value: "rules:", label: "rules:", description: "rule key" },
152
+ ];
153
+ const scopeItems = [
154
+ {
155
+ value: "in:",
156
+ label: "in:",
157
+ description: "path or dir scope (e.g. in:src/api.ts or in:src/)",
158
+ },
159
+ ];
160
+
161
+ return mode === "MQR"
162
+ ? [
163
+ ...scopeItems,
164
+ ...ruleItems,
165
+ ...buildItems(mqrGroups),
166
+ ...buildItems(legacyGroups),
167
+ ]
168
+ : [
169
+ ...scopeItems,
170
+ ...ruleItems,
171
+ ...buildItems(legacyGroups),
172
+ ...buildItems(mqrGroups),
173
+ ];
174
+ }
175
+
176
+ function listDirCompletions(
177
+ dirPath: string,
178
+ baseDir: string,
179
+ ): AutocompleteItem[] | null {
180
+ try {
181
+ const entries = readdirSync(dirPath, { withFileTypes: true });
182
+ const items: AutocompleteItem[] = [];
183
+ const relativePrefix =
184
+ dirPath === baseDir ? "" : relative(baseDir, dirPath) + "/";
185
+
186
+ for (const entry of entries) {
187
+ if (entry.name.startsWith(".")) continue;
188
+ const isDir = entry.isDirectory();
189
+ const name = isDir ? entry.name + "/" : entry.name;
190
+ const fullPath = relativePrefix + name;
191
+ items.push({
192
+ value: `in:${fullPath}`,
193
+ label: name,
194
+ description: relativePrefix.replace(/\/$/, ""),
195
+ });
196
+ }
197
+
198
+ items.sort((a, b) => {
199
+ const aDir = a.description === "" ? 0 : 1;
200
+ const bDir = b.description === "" ? 0 : 1;
201
+ if (aDir !== bDir) return aDir - bDir;
202
+ // Within same depth, dirs first, then by label
203
+ const aD = a.label.endsWith("/") ? 0 : 1;
204
+ const bD = b.label.endsWith("/") ? 0 : 1;
205
+ return aD - bD || a.label.localeCompare(b.label);
206
+ });
207
+
208
+ return items.length > 0 ? items.slice(0, 50) : null;
209
+ } catch {
210
+ return null;
211
+ }
212
+ }
213
+
214
+ function fuzzyWithCmd(
215
+ cmd: string,
216
+ args: string[],
217
+ prefix: string,
218
+ baseDir: string,
219
+ ): AutocompleteItem[] | null {
220
+ const out = execFileSync(cmd, args, {
221
+ encoding: "utf8",
222
+ timeout: 3000,
223
+ maxBuffer: 5 * 1024 * 1024,
224
+ windowsHide: true,
225
+ });
226
+ const lines = out.trim().split("\n").filter(Boolean);
227
+ if (lines.length === 0) return null;
228
+
229
+ const lowerPrefix = prefix.toLowerCase();
230
+ const matched: AutocompleteItem[] = [];
231
+ const seen = new Set<string>();
232
+
233
+ for (const line of lines) {
234
+ if (matched.length >= 50) break;
235
+ // Normalize: fd outputs dirs with trailing /, rg doesn't
236
+ const isDir = line.endsWith("/");
237
+ const entry = isDir ? line.slice(0, -1) : line;
238
+ if (!entry.toLowerCase().includes(lowerPrefix) || seen.has(entry)) continue;
239
+ seen.add(entry);
240
+
241
+ const dir = dirname(entry);
242
+ const name = basename(entry) + (isDir ? "/" : "");
243
+ matched.push({
244
+ value: `in:${line}`,
245
+ label: name,
246
+ description: dir === "." ? "" : dir + "/",
247
+ });
248
+
249
+ // For rg (no dirs output), extract ancestor dirs that match
250
+ if (!isDir) {
251
+ addAncestorDirMatches(entry, lowerPrefix, matched, seen);
252
+ }
253
+ }
254
+
255
+ matched.sort((a, b) => {
256
+ const aDir = a.label.endsWith("/") ? 0 : 1;
257
+ const bDir = b.label.endsWith("/") ? 0 : 1;
258
+ if (aDir !== bDir) return aDir - bDir;
259
+ return a.label.localeCompare(b.label);
260
+ });
261
+
262
+ return matched;
263
+ }
264
+
265
+ /**
266
+ * For non-directory entries, extract ancestor directories that match the prefix
267
+ * and add them as autocomplete suggestions.
268
+ */
269
+ function addAncestorDirMatches(
270
+ entry: string,
271
+ lowerPrefix: string,
272
+ matched: AutocompleteItem[],
273
+ seen: Set<string>,
274
+ ): void {
275
+ const segs = entry.split("/");
276
+ for (let i = 0; i < segs.length - 1; i++) {
277
+ if (matched.length >= 50) break;
278
+ const dirPath = segs.slice(0, i + 1).join("/") + "/";
279
+ if (seen.has(dirPath)) continue;
280
+ seen.add(dirPath);
281
+ if (segs[i].toLowerCase().includes(lowerPrefix)) {
282
+ const parentDir = segs.slice(0, i).join("/");
283
+ matched.push({
284
+ value: `in:${dirPath}`,
285
+ label: segs[i] + "/",
286
+ description: parentDir || "",
287
+ });
288
+ }
289
+ }
290
+ }
291
+
292
+ async function fetchRuleAutocompleteCompletions(
293
+ serverUrl: string,
294
+ token: string | undefined,
295
+ projectKey: string,
296
+ query: string,
297
+ ): Promise<AutocompleteItem[] | null> {
298
+ try {
299
+ const profiles = await fetchProjectProfiles(serverUrl, token, projectKey);
300
+ const languages = [
301
+ ...new Set(profiles.map((p) => p.language).filter(Boolean)),
302
+ ];
303
+ const rules = await fetchRuleSearch(serverUrl, token, query, languages);
304
+ if (rules.length === 0) return null;
305
+ return rules.map((r) => ({
306
+ value: `rule:${r.key}`,
307
+ label: r.key,
308
+ description: r.name ?? "",
309
+ }));
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
92
314
 
93
- return mode === "MQR" ? [...buildItems(mqrGroups), ...buildItems(legacyGroups)] : [...buildItems(legacyGroups), ...buildItems(mqrGroups)];
315
+ function getInPathCompletions(
316
+ prefix: string,
317
+ baseDir: string,
318
+ ): AutocompleteItem[] | null {
319
+ // Directory browsing: show contents when prefix ends with /
320
+ if (prefix.endsWith("/")) {
321
+ return listDirCompletions(resolve(baseDir, prefix), baseDir);
322
+ }
323
+
324
+ // Empty prefix: show root directory contents
325
+ if (!prefix) {
326
+ return listDirCompletions(baseDir, baseDir);
327
+ }
328
+
329
+ // Try fd first (fast, respects .gitignore, matches Pi TUI behavior)
330
+ try {
331
+ return fuzzyWithCmd(
332
+ "fd",
333
+ [
334
+ "--base-directory",
335
+ baseDir,
336
+ "--type",
337
+ "f",
338
+ "--type",
339
+ "d",
340
+ "--hidden",
341
+ "--exclude",
342
+ ".git",
343
+ "--max-results",
344
+ "50",
345
+ "--full-path",
346
+ prefix,
347
+ ],
348
+ prefix,
349
+ baseDir,
350
+ );
351
+ } catch {
352
+ // fd not available
353
+ }
354
+
355
+ // Fallback: rg --files (slower but available on most systems)
356
+ try {
357
+ return fuzzyWithCmd(
358
+ "rg",
359
+ ["--files", "--color", "never", "--no-ignore-parent"],
360
+ prefix,
361
+ baseDir,
362
+ );
363
+ } catch {
364
+ // rg not available
365
+ }
366
+
367
+ // Last resort: list current directory
368
+ return listDirCompletions(baseDir, baseDir);
94
369
  }
95
370
 
96
- export function sonarArgumentCompletions(
371
+ function getOpenCompletions(
372
+ issues: SonarIssue[] | undefined,
373
+ mode: "STANDARD" | "MQR" | undefined,
374
+ current: string,
375
+ ): AutocompleteItem[] | null {
376
+ const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
377
+ const lineSuffix = issue.line ? `:${issue.line}` : "";
378
+ return {
379
+ value: String(index + 1),
380
+ label: String(index + 1),
381
+ description: `${issue.severity} ${issue.filePath}${lineSuffix}`,
382
+ };
383
+ });
384
+ const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
385
+ const filtered = filterAutocompleteItems(suggestions, current);
386
+ return filtered.length > 0 ? filtered : null;
387
+ }
388
+
389
+ async function getFilterCompletions(
390
+ current: string,
391
+ mode: "STANDARD" | "MQR" | undefined,
392
+ cwd: string | undefined,
393
+ serverUrl: string | undefined,
394
+ token: string | undefined,
395
+ projectKey: string | undefined,
396
+ ): Promise<AutocompleteItem[] | null> {
397
+ if (current.startsWith("in:") && cwd) {
398
+ return getInPathCompletions(current.slice(3), cwd);
399
+ }
400
+ if (
401
+ (current.startsWith("rule:") || current.startsWith("rules:")) &&
402
+ serverUrl &&
403
+ projectKey
404
+ ) {
405
+ const query = current.split(":").slice(1).join(":");
406
+ const ruleItems = await fetchRuleAutocompleteCompletions(
407
+ serverUrl,
408
+ token,
409
+ projectKey,
410
+ query,
411
+ );
412
+ const staticSuggestions = createFilterCompletionList(mode);
413
+ const filtered = filterAutocompleteItems(staticSuggestions, current);
414
+ const combined = [
415
+ ...(filtered.length > 0 ? filtered : []),
416
+ ...(ruleItems ?? []),
417
+ ];
418
+ return combined.length > 0 ? combined : null;
419
+ }
420
+ const suggestions = createFilterCompletionList(mode);
421
+ const filtered = filterAutocompleteItems(suggestions, current);
422
+ return filtered.length > 0 ? filtered : null;
423
+ }
424
+
425
+ export async function sonarArgumentCompletions(
97
426
  argumentText: string,
98
427
  issues?: SonarIssue[],
99
428
  mode?: "STANDARD" | "MQR",
100
- ): AutocompleteItem[] | null {
429
+ cwd?: string,
430
+ serverUrl?: string,
431
+ token?: string,
432
+ projectKey?: string,
433
+ ): Promise<AutocompleteItem[] | null> {
101
434
  const { command, current, tokens } = splitSonarArgumentContext(argumentText);
102
435
  const lowerCommand = command.toLowerCase();
103
- const commandMatches = SONAR_COMMANDS.filter((item) => item.value.startsWith(lowerCommand));
104
- const isFullMatch = SONAR_COMMANDS.some((item) => item.value === lowerCommand);
436
+ const commandMatches = SONAR_COMMANDS.filter((item) =>
437
+ item.value.startsWith(lowerCommand),
438
+ );
439
+ const isFullMatch = SONAR_COMMANDS.some(
440
+ (item) => item.value === lowerCommand,
441
+ );
105
442
 
106
443
  if (!command || commandMatches.length > 1 || !isFullMatch) {
107
444
  if (commandMatches.length === 0) return null;
108
- return commandMatches.map((item) => ({ value: item.value, label: item.value, description: item.description }));
445
+ return commandMatches.map((item) => ({
446
+ value: item.value,
447
+ label: item.value,
448
+ description: item.description,
449
+ }));
109
450
  }
110
451
 
111
452
  if (lowerCommand === "open") {
112
- const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
113
- const lineSuffix = issue.line ? `:${issue.line}` : "";
114
- return { value: String(index + 1), label: String(index + 1), description: `${issue.severity} ${issue.filePath}${lineSuffix}` };
115
- });
116
- const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
117
- const filtered = filterAutocompleteItems(suggestions, current);
118
- return filtered.length > 0 ? filtered : null;
453
+ return getOpenCompletions(issues, mode, current);
119
454
  }
120
455
 
121
456
  if (lowerCommand === "analyze" || lowerCommand === "issues") {
122
- const suggestions = createFilterCompletionList(mode);
123
- const filtered = filterAutocompleteItems(suggestions, current);
124
- return filtered.length > 0 ? filtered : null;
457
+ return getFilterCompletions(
458
+ current,
459
+ mode,
460
+ cwd,
461
+ serverUrl,
462
+ token,
463
+ projectKey,
464
+ );
125
465
  }
126
466
 
127
- if (lowerCommand === "init" || lowerCommand === "metrics" || lowerCommand === "duplications") {
467
+ if (
468
+ lowerCommand === "init" ||
469
+ lowerCommand === "metrics" ||
470
+ lowerCommand === "duplications"
471
+ ) {
128
472
  return null;
129
473
  }
130
474
 
131
- return tokens.length === 0 ? filterAutocompleteItems(SONAR_COMMANDS, current) : null;
475
+ return tokens.length === 0
476
+ ? filterAutocompleteItems(SONAR_COMMANDS, current)
477
+ : null;
132
478
  }
133
479
 
134
480
  // ── Command arg parsing ─────────────────────────────────────────────────────
@@ -136,12 +482,25 @@ export function sonarArgumentCompletions(
136
482
  export type ParsedSonarCommand =
137
483
  | { action: "help" }
138
484
  | { action: "init"; alias?: string; targetInput?: string }
139
- | { action: "analyze"; targetInput?: string; filters?: SonarIssueFetchOptions }
485
+ | {
486
+ action: "analyze";
487
+ targetInput?: string;
488
+ filters?: SonarIssueFetchOptions;
489
+ }
140
490
  | { action: "issues"; targetInput?: string; filters?: SonarIssueFetchOptions }
141
- | { action: "open"; targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions }
491
+ | {
492
+ action: "open";
493
+ targetInput?: string;
494
+ issueIndex?: number;
495
+ filters?: SonarIssueFetchOptions;
496
+ }
142
497
  | { action: "metrics"; targetInput?: string }
143
- | { action: "duplications"; targetInput?: string; issueIndex?: number };
144
-
498
+ | {
499
+ action: "duplications";
500
+ targetInput?: string;
501
+ issueIndex?: number;
502
+ filters?: SonarIssueFetchOptions;
503
+ };
145
504
 
146
505
  export function parseCommandArgs(args: string): ParsedSonarCommand {
147
506
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -170,7 +529,10 @@ export function parseCommandArgs(args: string): ParsedSonarCommand {
170
529
  return { action: "metrics", ...parseSonarIssueArgs(tokens.slice(1)) };
171
530
  }
172
531
  if (head === "duplications") {
173
- return { action: "duplications", ...parseSonarIssueArgs(tokens.slice(1), true) };
532
+ return {
533
+ action: "duplications",
534
+ ...parseSonarIssueArgs(tokens.slice(1), true),
535
+ };
174
536
  }
175
537
 
176
538
  return { action: "analyze", ...parseSonarIssueArgs(tokens) };
@@ -193,6 +555,9 @@ export function helpText(): string {
193
555
  "Filters:",
194
556
  " /sonarqube issues be CRITICAL",
195
557
  " /sonarqube issues be severity:CRITICAL status:OPEN",
558
+ " /sonarqube issues be rule:S1192",
559
+ " /sonarqube issues in:src/api.ts",
560
+ " /sonarqube issues in:src/",
196
561
  " /sonarqube issues be quality:RELIABILITY",
197
562
  " /sonarqube issues be quality:SECURITY impactSeverity:HIGH",
198
563
  "",
@@ -206,6 +571,7 @@ export function helpText(): string {
206
571
  "Defaults:",
207
572
  " config is stored in .pi/sonarqube.json",
208
573
  " use project paths directly (no alias needed)",
574
+ " issue and duplication browsers search file, rule, severity, status, message, and stats",
209
575
  ].join("\n");
210
576
  }
211
577
 
@@ -221,8 +587,11 @@ export function sonarErrorMessage(error: unknown): string {
221
587
 
222
588
  export function formatIssue(issue: SonarIssue, index?: number): string {
223
589
  const loc = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
224
- const prefix = typeof index === "number" ? `${String(index).padStart(2, "0")}. ` : "";
225
- const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
590
+ const prefix =
591
+ typeof index === "number" ? `${String(index).padStart(2, "0")}. ` : "";
592
+ const rule = issue.ruleName
593
+ ? `${issue.rule} (${issue.ruleName})`
594
+ : issue.rule;
226
595
  return `${prefix}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
227
596
  }
228
597
 
@@ -240,10 +609,14 @@ export function formatMetricsOutput(metrics: {
240
609
  lines.push("Coverage: n/a");
241
610
  } else {
242
611
  const pct = metrics.measures.coverage.toFixed(1);
243
- const covered = (metrics.measures.linesToCover ?? 0) - (metrics.measures.uncoveredLines ?? 0);
612
+ const covered =
613
+ (metrics.measures.linesToCover ?? 0) -
614
+ (metrics.measures.uncoveredLines ?? 0);
244
615
  const uncovered = metrics.measures.uncoveredLines ?? 0;
245
616
  const total = metrics.measures.linesToCover ?? 0;
246
- lines.push(`Coverage: ${pct}% covered=${covered} uncovered=${uncovered} lines=${total}`);
617
+ lines.push(
618
+ `Coverage: ${pct}% covered=${covered} uncovered=${uncovered} lines=${total}`,
619
+ );
247
620
  }
248
621
 
249
622
  const density = metrics.measures.duplicatedLinesDensity.toFixed(1);
@@ -255,21 +628,22 @@ export function formatMetricsOutput(metrics: {
255
628
  }
256
629
 
257
630
  if (metrics.issueCounts) {
258
- const counts = metrics.cleanCodeMode === "MQR"
259
- ? [
260
- `BLOCKER ${metrics.issueCounts.blocker}`,
261
- `HIGH ${metrics.issueCounts.critical}`,
262
- `MEDIUM ${metrics.issueCounts.major}`,
263
- `LOW ${metrics.issueCounts.minor}`,
264
- `INFO ${metrics.issueCounts.info}`,
265
- ]
266
- : [
267
- `BLOCKER ${metrics.issueCounts.blocker}`,
268
- `CRITICAL ${metrics.issueCounts.critical}`,
269
- `MAJOR ${metrics.issueCounts.major}`,
270
- `MINOR ${metrics.issueCounts.minor}`,
271
- `INFO ${metrics.issueCounts.info}`,
272
- ];
631
+ const counts =
632
+ metrics.cleanCodeMode === "MQR"
633
+ ? [
634
+ `BLOCKER ${metrics.issueCounts.blocker}`,
635
+ `HIGH ${metrics.issueCounts.critical}`,
636
+ `MEDIUM ${metrics.issueCounts.major}`,
637
+ `LOW ${metrics.issueCounts.minor}`,
638
+ `INFO ${metrics.issueCounts.info}`,
639
+ ]
640
+ : [
641
+ `BLOCKER ${metrics.issueCounts.blocker}`,
642
+ `CRITICAL ${metrics.issueCounts.critical}`,
643
+ `MAJOR ${metrics.issueCounts.major}`,
644
+ `MINOR ${metrics.issueCounts.minor}`,
645
+ `INFO ${metrics.issueCounts.info}`,
646
+ ];
273
647
  lines.push(`Issues: ${counts.join(" ")}`);
274
648
  }
275
649
 
@@ -289,12 +663,17 @@ export function formatDuplicationsList(files: FileDuplication[]): string {
289
663
  if (files.length === 0) return "No duplicated files found.";
290
664
  const lines: string[] = [`Duplicated files (${files.length})`];
291
665
  for (const [i, f] of files.entries()) {
292
- lines.push(` ${String(i + 1).padStart(2, " ")}. ${f.filePath} blocks=${f.duplicatedBlocks} lines=${f.duplicatedLines}`);
666
+ lines.push(
667
+ ` ${String(i + 1).padStart(2, " ")}. ${f.filePath} dup%=${f.duplicatedLinesDensity.toFixed(1)} blocks=${f.duplicatedBlocks} lines=${f.duplicatedLines}`,
668
+ );
293
669
  }
294
670
  return lines.join("\n");
295
671
  }
296
672
 
297
- export function formatDuplicationBlockDetail(filePath: string, groups: DuplicationBlockGroup[]): string {
673
+ export function formatDuplicationBlockDetail(
674
+ filePath: string,
675
+ groups: DuplicationBlockGroup[],
676
+ ): string {
298
677
  if (groups.length === 0) return `No duplications found in ${filePath}.`;
299
678
  const lines: string[] = [`Duplications in ${filePath}`, ""];
300
679
  for (const [i, group] of groups.entries()) {
@@ -308,9 +687,15 @@ export function formatDuplicationBlockDetail(filePath: string, groups: Duplicati
308
687
  return lines.join("\n");
309
688
  }
310
689
 
311
- export function formatSummary(state: { totalIssues: number; projectKey: string; filters?: SonarIssueFetchOptions }): string {
690
+ export function formatSummary(state: {
691
+ totalIssues: number;
692
+ projectKey: string;
693
+ filters?: SonarIssueFetchOptions;
694
+ }): string {
312
695
  const issueCount = state.totalIssues;
313
- const filterLabel = state.filters ? ` (${issueFilterLabel(state.filters)})` : "";
696
+ const filterLabel = state.filters
697
+ ? ` (${issueFilterLabel(state.filters)})`
698
+ : "";
314
699
  if (issueCount === 0) {
315
700
  return `SonarQube: no issues found for ${state.projectKey}${filterLabel}`;
316
701
  }
@@ -339,9 +724,12 @@ export function formatReport(state: {
339
724
  }
340
725
 
341
726
  if (state.measures && state.issues.length > 0) {
342
- lines.push("", state.measures.duplicatedBlocks > 0
343
- ? `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
344
- : `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% (no duplications detected)`);
727
+ lines.push(
728
+ "",
729
+ state.measures.duplicatedBlocks > 0
730
+ ? `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
731
+ : `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% (no duplications detected)`,
732
+ );
345
733
  }
346
734
 
347
735
  if (state.issues.length === 0) {