@jonathan0823/pi-sonarqube 0.5.0 → 0.7.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
@@ -17,9 +17,22 @@ import {
17
17
  SONAR_TYPES,
18
18
  SONAR_SOFTWARE_QUALITIES,
19
19
  SONAR_IMPACT_SEVERITIES,
20
+ CleanCodeMode,
20
21
  } from "./types.js";
21
- import { looksLikePath } from "./config.js";
22
- import { parseSonarIssueArgs, issueFilterLabel } from "./api.js";
22
+ import { execFileSync } from "node:child_process";
23
+ import { readdirSync } from "node:fs";
24
+ import { basename, dirname, relative, resolve } from "node:path";
25
+ import {
26
+ looksLikePath,
27
+ loadWorkspaceRegistry,
28
+ knownTargets,
29
+ } from "./config.js";
30
+ import {
31
+ parseSonarIssueArgs,
32
+ issueFilterLabel,
33
+ fetchProjectProfiles,
34
+ fetchRuleSearch,
35
+ } from "./api.js";
23
36
 
24
37
  // ── Constants ───────────────────────────────────────────────────────────────
25
38
 
@@ -110,9 +123,7 @@ export function splitSonarArgumentContext(argumentText: string): {
110
123
  };
111
124
  }
112
125
 
113
- function createFilterCompletionList(
114
- mode?: "STANDARD" | "MQR",
115
- ): AutocompleteItem[] {
126
+ function createFilterCompletionList(mode?: CleanCodeMode): AutocompleteItem[] {
116
127
  const buildItems = (
117
128
  groups: ReadonlyArray<
118
129
  readonly [string, readonly string[], string, boolean]
@@ -138,17 +149,332 @@ function createFilterCompletionList(
138
149
  ["quality", SONAR_SOFTWARE_QUALITIES, "software quality (MQR)", true],
139
150
  ["impactSeverity", SONAR_IMPACT_SEVERITIES, "impact severity (MQR)", false],
140
151
  ] as const;
152
+ const ruleItems = [
153
+ { value: "rule:", label: "rule:", description: "rule key" },
154
+ { value: "rules:", label: "rules:", description: "rule key" },
155
+ ];
156
+ const scopeItems = [
157
+ {
158
+ value: "in:",
159
+ label: "in:",
160
+ description: "path or dir scope (e.g. in:src/api.ts or in:src/)",
161
+ },
162
+ ];
141
163
 
142
164
  return mode === "MQR"
143
- ? [...buildItems(mqrGroups), ...buildItems(legacyGroups)]
144
- : [...buildItems(legacyGroups), ...buildItems(mqrGroups)];
165
+ ? [
166
+ ...scopeItems,
167
+ ...ruleItems,
168
+ ...buildItems(mqrGroups),
169
+ ...buildItems(legacyGroups),
170
+ ]
171
+ : [
172
+ ...scopeItems,
173
+ ...ruleItems,
174
+ ...buildItems(legacyGroups),
175
+ ...buildItems(mqrGroups),
176
+ ];
177
+ }
178
+
179
+ function listDirCompletions(
180
+ dirPath: string,
181
+ baseDir: string,
182
+ ): AutocompleteItem[] | null {
183
+ try {
184
+ const entries = readdirSync(dirPath, { withFileTypes: true });
185
+ const items: AutocompleteItem[] = [];
186
+ const relativePrefix =
187
+ dirPath === baseDir ? "" : relative(baseDir, dirPath) + "/";
188
+
189
+ for (const entry of entries) {
190
+ if (entry.name.startsWith(".")) continue;
191
+ const isDir = entry.isDirectory();
192
+ const name = isDir ? entry.name + "/" : entry.name;
193
+ const fullPath = relativePrefix + name;
194
+ items.push({
195
+ value: `in:${fullPath}`,
196
+ label: name,
197
+ description: relativePrefix.replace(/\/$/, ""),
198
+ });
199
+ }
200
+
201
+ items.sort((a, b) => {
202
+ const aDir = a.description === "" ? 0 : 1;
203
+ const bDir = b.description === "" ? 0 : 1;
204
+ if (aDir !== bDir) return aDir - bDir;
205
+ // Within same depth, dirs first, then by label
206
+ const aD = a.label.endsWith("/") ? 0 : 1;
207
+ const bD = b.label.endsWith("/") ? 0 : 1;
208
+ return aD - bD || a.label.localeCompare(b.label);
209
+ });
210
+
211
+ return items.length > 0 ? items.slice(0, 50) : null;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ function fuzzyWithCmd(
218
+ cmd: string,
219
+ args: string[],
220
+ prefix: string,
221
+ baseDir: string,
222
+ ): AutocompleteItem[] | null {
223
+ const out = execFileSync(cmd, args, {
224
+ encoding: "utf8",
225
+ timeout: 3000,
226
+ maxBuffer: 5 * 1024 * 1024,
227
+ windowsHide: true,
228
+ });
229
+ const lines = out.trim().split("\n").filter(Boolean);
230
+ if (lines.length === 0) return null;
231
+
232
+ const lowerPrefix = prefix.toLowerCase();
233
+ const matched: AutocompleteItem[] = [];
234
+ const seen = new Set<string>();
235
+
236
+ for (const line of lines) {
237
+ if (matched.length >= 50) break;
238
+ // Normalize: fd outputs dirs with trailing /, rg doesn't
239
+ const isDir = line.endsWith("/");
240
+ const entry = isDir ? line.slice(0, -1) : line;
241
+ if (!entry.toLowerCase().includes(lowerPrefix) || seen.has(entry)) continue;
242
+ seen.add(entry);
243
+
244
+ const dir = dirname(entry);
245
+ const name = basename(entry) + (isDir ? "/" : "");
246
+ matched.push({
247
+ value: `in:${line}`,
248
+ label: name,
249
+ description: dir === "." ? "" : dir + "/",
250
+ });
251
+
252
+ // For rg (no dirs output), extract ancestor dirs that match
253
+ if (!isDir) {
254
+ addAncestorDirMatches(entry, lowerPrefix, matched, seen);
255
+ }
256
+ }
257
+
258
+ matched.sort((a, b) => {
259
+ const aDir = a.label.endsWith("/") ? 0 : 1;
260
+ const bDir = b.label.endsWith("/") ? 0 : 1;
261
+ if (aDir !== bDir) return aDir - bDir;
262
+ return a.label.localeCompare(b.label);
263
+ });
264
+
265
+ return matched;
266
+ }
267
+
268
+ /**
269
+ * For non-directory entries, extract ancestor directories that match the prefix
270
+ * and add them as autocomplete suggestions.
271
+ */
272
+ function addAncestorDirMatches(
273
+ entry: string,
274
+ lowerPrefix: string,
275
+ matched: AutocompleteItem[],
276
+ seen: Set<string>,
277
+ ): void {
278
+ const segs = entry.split("/");
279
+ for (let i = 0; i < segs.length - 1; i++) {
280
+ if (matched.length >= 50) break;
281
+ const dirPath = segs.slice(0, i + 1).join("/") + "/";
282
+ if (seen.has(dirPath)) continue;
283
+ seen.add(dirPath);
284
+ if (segs[i].toLowerCase().includes(lowerPrefix)) {
285
+ const parentDir = segs.slice(0, i).join("/");
286
+ matched.push({
287
+ value: `in:${dirPath}`,
288
+ label: segs[i] + "/",
289
+ description: parentDir || "",
290
+ });
291
+ }
292
+ }
293
+ }
294
+
295
+ async function fetchRuleAutocompleteCompletions(
296
+ serverUrl: string,
297
+ token: string | undefined,
298
+ projectKey: string,
299
+ query: string,
300
+ ): Promise<AutocompleteItem[] | null> {
301
+ try {
302
+ const profiles = await fetchProjectProfiles(serverUrl, token, projectKey);
303
+ const languages = [
304
+ ...new Set(profiles.map((p) => p.language).filter(Boolean)),
305
+ ];
306
+ const rules = await fetchRuleSearch(serverUrl, token, query, languages);
307
+ if (rules.length === 0) return null;
308
+ return rules.map((r) => ({
309
+ value: `rule:${r.key}`,
310
+ label: r.key,
311
+ description: r.name ?? "",
312
+ }));
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ function getInPathCompletions(
319
+ prefix: string,
320
+ baseDir: string,
321
+ ): AutocompleteItem[] | null {
322
+ // Directory browsing: show contents when prefix ends with /
323
+ if (prefix.endsWith("/")) {
324
+ return listDirCompletions(resolve(baseDir, prefix), baseDir);
325
+ }
326
+
327
+ // Empty prefix: show root directory contents
328
+ if (!prefix) {
329
+ return listDirCompletions(baseDir, baseDir);
330
+ }
331
+
332
+ // Try fd first (fast, respects .gitignore, matches Pi TUI behavior)
333
+ try {
334
+ return fuzzyWithCmd(
335
+ "fd",
336
+ [
337
+ "--base-directory",
338
+ baseDir,
339
+ "--type",
340
+ "f",
341
+ "--type",
342
+ "d",
343
+ "--hidden",
344
+ "--exclude",
345
+ ".git",
346
+ "--max-results",
347
+ "50",
348
+ "--full-path",
349
+ prefix,
350
+ ],
351
+ prefix,
352
+ baseDir,
353
+ );
354
+ } catch {
355
+ // fd not available
356
+ }
357
+
358
+ // Fallback: rg --files (slower but available on most systems)
359
+ try {
360
+ return fuzzyWithCmd(
361
+ "rg",
362
+ ["--files", "--color", "never", "--no-ignore-parent"],
363
+ prefix,
364
+ baseDir,
365
+ );
366
+ } catch {
367
+ // rg not available
368
+ }
369
+
370
+ // Last resort: list current directory
371
+ return listDirCompletions(baseDir, baseDir);
372
+ }
373
+
374
+ function getOpenCompletions(
375
+ issues: SonarIssue[] | undefined,
376
+ mode: CleanCodeMode | undefined,
377
+ current: string,
378
+ ): AutocompleteItem[] | null {
379
+ const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
380
+ const lineSuffix = issue.line ? `:${issue.line}` : "";
381
+ return {
382
+ value: String(index + 1),
383
+ label: String(index + 1),
384
+ description: `${issue.severity} ${issue.filePath}${lineSuffix}`,
385
+ };
386
+ });
387
+ const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
388
+ const filtered = filterAutocompleteItems(suggestions, current);
389
+ return filtered.length > 0 ? filtered : null;
390
+ }
391
+
392
+ async function getFilterCompletions(
393
+ current: string,
394
+ mode: CleanCodeMode | undefined,
395
+ cwd: string | undefined,
396
+ serverUrl: string | undefined,
397
+ token: string | undefined,
398
+ projectKey: string | undefined,
399
+ ): Promise<AutocompleteItem[] | null> {
400
+ if (current.startsWith("in:") && cwd) {
401
+ return getInPathCompletions(current.slice(3), cwd);
402
+ }
403
+ if (
404
+ (current.startsWith("rule:") || current.startsWith("rules:")) &&
405
+ serverUrl &&
406
+ projectKey
407
+ ) {
408
+ const query = current.split(":").slice(1).join(":");
409
+ const ruleItems = await fetchRuleAutocompleteCompletions(
410
+ serverUrl,
411
+ token,
412
+ projectKey,
413
+ query,
414
+ );
415
+ const staticSuggestions = createFilterCompletionList(mode);
416
+ const filtered = filterAutocompleteItems(staticSuggestions, current);
417
+ const combined = [
418
+ ...(filtered.length > 0 ? filtered : []),
419
+ ...(ruleItems ?? []),
420
+ ];
421
+ return combined.length > 0 ? combined : null;
422
+ }
423
+ const suggestions = createFilterCompletionList(mode);
424
+ const filtered = filterAutocompleteItems(suggestions, current);
425
+ return filtered.length > 0 ? filtered : null;
426
+ }
427
+
428
+ async function getAnalyzeCompletions(
429
+ current: string,
430
+ mode: CleanCodeMode | undefined,
431
+ cwd: string | undefined,
432
+ serverUrl: string | undefined,
433
+ token: string | undefined,
434
+ projectKey: string | undefined,
435
+ ): Promise<AutocompleteItem[] | null> {
436
+ const [filterItems, aliasItems] = await Promise.all([
437
+ getFilterCompletions(current, mode, cwd, serverUrl, token, projectKey),
438
+ cwd ? getWorkspaceAliases(cwd, current) : Promise.resolve(null),
439
+ ]);
440
+ if (!filterItems && !aliasItems) return null;
441
+ const combined: AutocompleteItem[] = [
442
+ ...(aliasItems ?? []),
443
+ ...(filterItems ?? []),
444
+ ];
445
+ return combined.length > 0 ? combined : null;
446
+ }
447
+
448
+ async function getWorkspaceAliases(
449
+ cwd: string,
450
+ current: string,
451
+ ): Promise<AutocompleteItem[] | null> {
452
+ try {
453
+ const { registry } = await loadWorkspaceRegistry(cwd);
454
+ const aliases = knownTargets(registry);
455
+ if (aliases.length === 0) return null;
456
+ return filterAutocompleteItems(
457
+ aliases.map((alias) => ({
458
+ value: alias,
459
+ label: alias,
460
+ description: registry.workspaces[alias],
461
+ })),
462
+ current,
463
+ );
464
+ } catch {
465
+ return null;
466
+ }
145
467
  }
146
468
 
147
- export function sonarArgumentCompletions(
469
+ export async function sonarArgumentCompletions(
148
470
  argumentText: string,
149
471
  issues?: SonarIssue[],
150
- mode?: "STANDARD" | "MQR",
151
- ): AutocompleteItem[] | null {
472
+ mode?: CleanCodeMode,
473
+ cwd?: string,
474
+ serverUrl?: string,
475
+ token?: string,
476
+ projectKey?: string,
477
+ ): Promise<AutocompleteItem[] | null> {
152
478
  const { command, current, tokens } = splitSonarArgumentContext(argumentText);
153
479
  const lowerCommand = command.toLowerCase();
154
480
  const commandMatches = SONAR_COMMANDS.filter((item) =>
@@ -168,23 +494,29 @@ export function sonarArgumentCompletions(
168
494
  }
169
495
 
170
496
  if (lowerCommand === "open") {
171
- const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
172
- const lineSuffix = issue.line ? `:${issue.line}` : "";
173
- return {
174
- value: String(index + 1),
175
- label: String(index + 1),
176
- description: `${issue.severity} ${issue.filePath}${lineSuffix}`,
177
- };
178
- });
179
- const suggestions = [...issueItems, ...createFilterCompletionList(mode)];
180
- const filtered = filterAutocompleteItems(suggestions, current);
181
- return filtered.length > 0 ? filtered : null;
497
+ return getOpenCompletions(issues, mode, current);
182
498
  }
183
499
 
184
- if (lowerCommand === "analyze" || lowerCommand === "issues") {
185
- const suggestions = createFilterCompletionList(mode);
186
- const filtered = filterAutocompleteItems(suggestions, current);
187
- return filtered.length > 0 ? filtered : null;
500
+ if (lowerCommand === "analyze") {
501
+ return getAnalyzeCompletions(
502
+ current,
503
+ mode,
504
+ cwd,
505
+ serverUrl,
506
+ token,
507
+ projectKey,
508
+ );
509
+ }
510
+
511
+ if (lowerCommand === "issues") {
512
+ return getFilterCompletions(
513
+ current,
514
+ mode,
515
+ cwd,
516
+ serverUrl,
517
+ token,
518
+ projectKey,
519
+ );
188
520
  }
189
521
 
190
522
  if (
@@ -218,7 +550,12 @@ export type ParsedSonarCommand =
218
550
  filters?: SonarIssueFetchOptions;
219
551
  }
220
552
  | { action: "metrics"; targetInput?: string }
221
- | { action: "duplications"; targetInput?: string; issueIndex?: number };
553
+ | {
554
+ action: "duplications";
555
+ targetInput?: string;
556
+ issueIndex?: number;
557
+ filters?: SonarIssueFetchOptions;
558
+ };
222
559
 
223
560
  export function parseCommandArgs(args: string): ParsedSonarCommand {
224
561
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -273,6 +610,9 @@ export function helpText(): string {
273
610
  "Filters:",
274
611
  " /sonarqube issues be CRITICAL",
275
612
  " /sonarqube issues be severity:CRITICAL status:OPEN",
613
+ " /sonarqube issues be rule:S1192",
614
+ " /sonarqube issues in:src/api.ts",
615
+ " /sonarqube issues in:src/",
276
616
  " /sonarqube issues be quality:RELIABILITY",
277
617
  " /sonarqube issues be quality:SECURITY impactSeverity:HIGH",
278
618
  "",
@@ -286,6 +626,7 @@ export function helpText(): string {
286
626
  "Defaults:",
287
627
  " config is stored in .pi/sonarqube.json",
288
628
  " use project paths directly (no alias needed)",
629
+ " issue and duplication browsers search file, rule, severity, status, message, and stats",
289
630
  ].join("\n");
290
631
  }
291
632
 
@@ -314,7 +655,7 @@ export function formatMetricsOutput(metrics: {
314
655
  measures?: SonarDuplicationMeasures;
315
656
  issueCounts?: IssueSeverityCounts;
316
657
  issueQualityCounts?: IssueQualityCounts;
317
- cleanCodeMode?: "STANDARD" | "MQR";
658
+ cleanCodeMode?: CleanCodeMode;
318
659
  }): string {
319
660
  const lines: string[] = [`Metrics for ${metrics.projectKey}`];
320
661