@jonathan0823/pi-sonarqube 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/api.ts CHANGED
@@ -18,18 +18,19 @@ import {
18
18
  SONAR_TYPES,
19
19
  SONAR_SOFTWARE_QUALITIES,
20
20
  SONAR_IMPACT_SEVERITIES,
21
+ CleanCodeMode,
21
22
  } from "./types.js";
22
23
  import { parseProperties } from "./config.js";
23
24
 
24
25
  // ── Clean code mode detection ────────────────────────────────────────────────
25
26
 
26
- const cleanCodeModeCache = new Map<string, "STANDARD" | "MQR">();
27
+ const cleanCodeModeCache = new Map<string, CleanCodeMode>();
27
28
 
28
29
  export async function fetchCleanCodeMode(
29
30
  serverUrl: string,
30
31
  token?: string,
31
32
  signal?: AbortSignal,
32
- ): Promise<"STANDARD" | "MQR" | undefined> {
33
+ ): Promise<CleanCodeMode | undefined> {
33
34
  const cached = cleanCodeModeCache.get(serverUrl);
34
35
  if (cached) return cached;
35
36
  try {
@@ -262,8 +263,14 @@ function mergeFilters(
262
263
  merged.statuses = mergeFilterField(merged.statuses, filter.statuses);
263
264
  merged.types = mergeFilterField(merged.types, filter.types);
264
265
  merged.rules = mergeFilterField(merged.rules, filter.rules);
265
- merged.softwareQualities = mergeFilterField(merged.softwareQualities, filter.softwareQualities);
266
- merged.impactSeverities = mergeFilterField(merged.impactSeverities, filter.impactSeverities);
266
+ merged.softwareQualities = mergeFilterField(
267
+ merged.softwareQualities,
268
+ filter.softwareQualities,
269
+ );
270
+ merged.impactSeverities = mergeFilterField(
271
+ merged.impactSeverities,
272
+ filter.impactSeverities,
273
+ );
267
274
  if (filter.pathScope) merged.pathScope = filter.pathScope;
268
275
  }
269
276
  return normalizeIssueFilters(merged);
@@ -523,10 +530,7 @@ function buildIssueSearchUrl(
523
530
  filters.impactSeverities.join(","),
524
531
  );
525
532
  if (filters?.componentKeys?.length)
526
- url.searchParams.set(
527
- "componentKeys",
528
- filters.componentKeys.join(","),
529
- );
533
+ url.searchParams.set("componentKeys", filters.componentKeys.join(","));
530
534
  return url.toString();
531
535
  }
532
536
 
@@ -653,19 +657,19 @@ export async function fetchProjectProfiles(
653
657
  token: string | undefined,
654
658
  projectKey: string,
655
659
  signal?: AbortSignal,
656
- ): Promise<Array<{key: string; language: string; name: string}>> {
660
+ ): Promise<Array<{ key: string; language: string; name: string }>> {
657
661
  try {
658
662
  const result = await fetchJson<{
659
- profiles: Array<{key?: string; language?: string; name?: string}>;
663
+ profiles: Array<{ key?: string; language?: string; name?: string }>;
660
664
  }>(
661
665
  `${serverUrl}/api/qualityprofiles/search?project=${encodeURIComponent(projectKey)}`,
662
666
  token,
663
667
  signal,
664
668
  );
665
- return (result.profiles ?? []).map(p => ({
666
- key: p.key ?? '',
667
- language: p.language ?? '',
668
- name: p.name ?? '',
669
+ return (result.profiles ?? []).map((p) => ({
670
+ key: p.key ?? "",
671
+ language: p.language ?? "",
672
+ name: p.name ?? "",
669
673
  }));
670
674
  } catch {
671
675
  return [];
@@ -678,7 +682,7 @@ export async function fetchRuleSearch(
678
682
  query: string,
679
683
  languages?: string[],
680
684
  signal?: AbortSignal,
681
- ): Promise<Array<{key: string; name?: string}>> {
685
+ ): Promise<Array<{ key: string; name?: string }>> {
682
686
  try {
683
687
  const params = new URLSearchParams();
684
688
  if (query) params.set("q", query);
@@ -687,14 +691,10 @@ export async function fetchRuleSearch(
687
691
  if (languages?.length) params.set("languages", languages.join(","));
688
692
 
689
693
  const result = await fetchJson<{
690
- rules: Array<{key?: string; name?: string}>;
691
- }>(
692
- `${serverUrl}/api/rules/search?${params.toString()}`,
693
- token,
694
- signal,
695
- );
696
- return (result.rules ?? []).map(r => ({
697
- key: r.key ?? '',
694
+ rules: Array<{ key?: string; name?: string }>;
695
+ }>(`${serverUrl}/api/rules/search?${params.toString()}`, token, signal);
696
+ return (result.rules ?? []).map((r) => ({
697
+ key: r.key ?? "",
698
698
  name: r.name,
699
699
  }));
700
700
  } catch {
@@ -797,7 +797,7 @@ export async function fetchIssueSeverityCounts(
797
797
  token: string | undefined,
798
798
  projectKey: string,
799
799
  signal?: AbortSignal,
800
- mode?: "STANDARD" | "MQR",
800
+ mode?: CleanCodeMode,
801
801
  ): Promise<IssueSeverityCounts | undefined> {
802
802
  try {
803
803
  const facetKey = mode === "MQR" ? "impactSeverities" : "severities";
package/src/commands.ts CHANGED
@@ -17,11 +17,16 @@ import {
17
17
  SONAR_TYPES,
18
18
  SONAR_SOFTWARE_QUALITIES,
19
19
  SONAR_IMPACT_SEVERITIES,
20
+ CleanCodeMode,
20
21
  } from "./types.js";
21
22
  import { execFileSync } from "node:child_process";
22
23
  import { readdirSync } from "node:fs";
23
24
  import { basename, dirname, relative, resolve } from "node:path";
24
- import { looksLikePath } from "./config.js";
25
+ import {
26
+ looksLikePath,
27
+ loadWorkspaceRegistry,
28
+ knownTargets,
29
+ } from "./config.js";
25
30
  import {
26
31
  parseSonarIssueArgs,
27
32
  issueFilterLabel,
@@ -118,9 +123,7 @@ export function splitSonarArgumentContext(argumentText: string): {
118
123
  };
119
124
  }
120
125
 
121
- function createFilterCompletionList(
122
- mode?: "STANDARD" | "MQR",
123
- ): AutocompleteItem[] {
126
+ function createFilterCompletionList(mode?: CleanCodeMode): AutocompleteItem[] {
124
127
  const buildItems = (
125
128
  groups: ReadonlyArray<
126
129
  readonly [string, readonly string[], string, boolean]
@@ -370,7 +373,7 @@ function getInPathCompletions(
370
373
 
371
374
  function getOpenCompletions(
372
375
  issues: SonarIssue[] | undefined,
373
- mode: "STANDARD" | "MQR" | undefined,
376
+ mode: CleanCodeMode | undefined,
374
377
  current: string,
375
378
  ): AutocompleteItem[] | null {
376
379
  const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
@@ -388,7 +391,7 @@ function getOpenCompletions(
388
391
 
389
392
  async function getFilterCompletions(
390
393
  current: string,
391
- mode: "STANDARD" | "MQR" | undefined,
394
+ mode: CleanCodeMode | undefined,
392
395
  cwd: string | undefined,
393
396
  serverUrl: string | undefined,
394
397
  token: string | undefined,
@@ -422,10 +425,51 @@ async function getFilterCompletions(
422
425
  return filtered.length > 0 ? filtered : null;
423
426
  }
424
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
+ }
467
+ }
468
+
425
469
  export async function sonarArgumentCompletions(
426
470
  argumentText: string,
427
471
  issues?: SonarIssue[],
428
- mode?: "STANDARD" | "MQR",
472
+ mode?: CleanCodeMode,
429
473
  cwd?: string,
430
474
  serverUrl?: string,
431
475
  token?: string,
@@ -453,7 +497,18 @@ export async function sonarArgumentCompletions(
453
497
  return getOpenCompletions(issues, mode, current);
454
498
  }
455
499
 
456
- if (lowerCommand === "analyze" || lowerCommand === "issues") {
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") {
457
512
  return getFilterCompletions(
458
513
  current,
459
514
  mode,
@@ -600,7 +655,7 @@ export function formatMetricsOutput(metrics: {
600
655
  measures?: SonarDuplicationMeasures;
601
656
  issueCounts?: IssueSeverityCounts;
602
657
  issueQualityCounts?: IssueQualityCounts;
603
- cleanCodeMode?: "STANDARD" | "MQR";
658
+ cleanCodeMode?: CleanCodeMode;
604
659
  }): string {
605
660
  const lines: string[] = [`Metrics for ${metrics.projectKey}`];
606
661
 
package/src/config.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
2
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
4
  import { basename, dirname, relative, resolve } from "node:path";
4
5
  import type {
5
6
  SonarInitConfig,
@@ -291,6 +292,155 @@ export function knownTargets(registry: SonarWorkspaceRegistry): string[] {
291
292
  );
292
293
  }
293
294
 
295
+ export interface DiscoveredTarget {
296
+ dir: string;
297
+ alias?: string;
298
+ }
299
+
300
+ const SONARQUBE_CONFIG_FILE = ".pi/sonarqube.json";
301
+
302
+ // ── Target discovery ──────────────────────────────────────────────────────
303
+
304
+ async function execFd(repoRoot: string): Promise<string> {
305
+ return execFileSync(
306
+ "fd",
307
+ [
308
+ "--hidden",
309
+ "-g",
310
+ "sonarqube.json",
311
+ "--base-directory",
312
+ repoRoot,
313
+ "--max-depth",
314
+ "8",
315
+ "--exclude",
316
+ ".git",
317
+ "--exclude",
318
+ "node_modules",
319
+ ],
320
+ {
321
+ encoding: "utf8",
322
+ timeout: 5000,
323
+ maxBuffer: 1024 * 1024,
324
+ windowsHide: true,
325
+ },
326
+ );
327
+ }
328
+
329
+ export async function discoverSonarQubeTargets(
330
+ repoRoot: string,
331
+ strategy?: "auto" | "fd" | "walk",
332
+ ): Promise<DiscoveredTarget[]> {
333
+ if (strategy === "walk") {
334
+ return walkForSonarqubeConfigs(repoRoot);
335
+ }
336
+
337
+ // Try fd first (auto default) or fd-only
338
+ try {
339
+ const output = await execFd(repoRoot);
340
+ const parsed = parseDiscoveredPaths(output, repoRoot);
341
+ if (parsed.length > 0 || strategy === "fd") return parsed;
342
+ } catch (_e) {
343
+ if (strategy === "fd") throw _e;
344
+ }
345
+
346
+ return walkForSonarqubeConfigs(repoRoot);
347
+ }
348
+
349
+ function parseDiscoveredPaths(
350
+ output: string,
351
+ repoRoot: string,
352
+ ): DiscoveredTarget[] {
353
+ const lines = output.trim().split("\n").filter(Boolean);
354
+ const seen = new Set<string>();
355
+ const targets: DiscoveredTarget[] = [];
356
+ for (const line of lines) {
357
+ const dir = resolve(repoRoot, dirname(dirname(line)));
358
+ if (dir === repoRoot) continue;
359
+ const key = relative(repoRoot, dir);
360
+ if (seen.has(key)) continue;
361
+ seen.add(key);
362
+ targets.push({ dir, alias: undefined });
363
+ }
364
+ return targets;
365
+ }
366
+
367
+ async function walkForSonarqubeConfigs(
368
+ repoRoot: string,
369
+ ): Promise<DiscoveredTarget[]> {
370
+ const configDirs: string[] = [];
371
+
372
+ async function walk(dir: string, depth: number): Promise<void> {
373
+ if (depth > 6) return;
374
+ try {
375
+ const entries = await readdir(dir, { withFileTypes: true });
376
+ for (const entry of entries) {
377
+ if (entry.name === "node_modules" || entry.name === ".git") continue;
378
+ if (entry.name.startsWith(".") && entry.name !== ".pi") continue;
379
+ const fullPath = resolve(dir, entry.name);
380
+ if (entry.isDirectory()) {
381
+ await walk(fullPath, depth + 1);
382
+ }
383
+ }
384
+ // Check if this dir has .pi/sonarqube.json
385
+ try {
386
+ await stat(resolve(dir, SONARQUBE_CONFIG_FILE));
387
+ if (dir !== repoRoot) configDirs.push(dir);
388
+ } catch {
389
+ // no config here
390
+ }
391
+ } catch {
392
+ // skip unreadable dirs
393
+ }
394
+ }
395
+
396
+ await walk(repoRoot, 0);
397
+
398
+ const seen = new Set<string>();
399
+ const targets: DiscoveredTarget[] = [];
400
+ for (const dir of configDirs) {
401
+ const key = relative(repoRoot, dir);
402
+ if (seen.has(key)) continue;
403
+ seen.add(key);
404
+ targets.push({ dir, alias: undefined });
405
+ }
406
+ return targets;
407
+ }
408
+
409
+ // ── Registered + discovered target merger ───────────────────────────────────
410
+
411
+ export interface MappedTarget {
412
+ alias: string;
413
+ path: string;
414
+ }
415
+
416
+ /**
417
+ * Gather all available SonarQube targets from the workspace registry and from
418
+ * disk discovery (`.pi/sonarqube.json`). Registry entries take precedence.
419
+ */
420
+ export async function gatherTargets(
421
+ startDir: string,
422
+ ): Promise<{ repoRoot: string; targets: MappedTarget[] }> {
423
+ const { repoRoot, registry } = await loadWorkspaceRegistry(startDir);
424
+ const mapped = new Map<string, MappedTarget>();
425
+
426
+ for (const [alias, relPath] of Object.entries(registry.workspaces)) {
427
+ mapped.set(relPath, { alias, path: relPath });
428
+ }
429
+
430
+ const discovered = await discoverSonarQubeTargets(repoRoot);
431
+ for (const d of discovered) {
432
+ const relPath = relative(repoRoot, d.dir);
433
+ if (!mapped.has(relPath)) {
434
+ mapped.set(relPath, { alias: basename(d.dir), path: relPath });
435
+ }
436
+ }
437
+
438
+ return {
439
+ repoRoot,
440
+ targets: [...mapped.values()],
441
+ };
442
+ }
443
+
294
444
  export async function resolveTarget(
295
445
  ctx: { cwd: string },
296
446
  targetInput?: string,
package/src/index.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  openIssuePreview,
71
71
  resolveTargetState,
72
72
  showDuplicationBrowser,
73
+ pickOrResolveTarget,
73
74
  } from "./ui.js";
74
75
 
75
76
  const SonarToolParams = Type.Object({
@@ -156,7 +157,11 @@ async function analyzeProject(
156
157
  filters?: SonarIssueFetchOptions,
157
158
  ): Promise<SonarAnalysisState> {
158
159
  const config = await resolveConfig(ctx, inputPath);
159
- _cachedConfig = { serverUrl: config.serverUrl, projectKey: config.projectKey, token: config.token };
160
+ _cachedConfig = {
161
+ serverUrl: config.serverUrl,
162
+ projectKey: config.projectKey,
163
+ token: config.token,
164
+ };
160
165
  const baseDirStat = await stat(config.baseDir).catch(() => undefined);
161
166
  if (!baseDirStat?.isDirectory()) {
162
167
  throw new Error(`Project directory not found: ${config.baseDir}`);
@@ -286,10 +291,16 @@ async function initCommandHandler(
286
291
  ctx: ExtensionCommandContext,
287
292
  options: InitCommandOptions = {},
288
293
  ): Promise<void> {
294
+ let { alias, targetInput } = options;
295
+ if (!alias && !targetInput) {
296
+ const pickResult = await pickOrResolveTarget(ctx);
297
+ if (pickResult === null) return;
298
+ if (pickResult !== undefined) targetInput = pickResult;
299
+ }
289
300
  const { baseDir, repoRoot } = await resolveInitTarget(
290
301
  ctx,
291
- options.alias,
292
- options.targetInput,
302
+ alias,
303
+ targetInput,
293
304
  );
294
305
  const existing = await loadProjectConfig(baseDir);
295
306
 
@@ -394,13 +405,18 @@ function renderIssueListResult(
394
405
  // ── Extension entrypoint ────────────────────────────────────────────────────
395
406
 
396
407
  // ponytail: lazy config cache so rule autocomplete can fetch from the server
397
- let _cachedConfig: { serverUrl: string; projectKey: string; token?: string } | undefined;
408
+ let _cachedConfig:
409
+ { serverUrl: string; projectKey: string; token?: string } | undefined;
398
410
 
399
411
  async function cachedServerConfig(cwd: string): Promise<typeof _cachedConfig> {
400
412
  if (!_cachedConfig) {
401
413
  try {
402
414
  const cfg = await resolveConfig({ cwd });
403
- _cachedConfig = { serverUrl: cfg.serverUrl, projectKey: cfg.projectKey, token: cfg.token };
415
+ _cachedConfig = {
416
+ serverUrl: cfg.serverUrl,
417
+ projectKey: cfg.projectKey,
418
+ token: cfg.token,
419
+ };
404
420
  } catch {
405
421
  // fall through
406
422
  }
@@ -719,6 +735,11 @@ async function commandAnalyze(
719
735
  filters: SonarIssueFetchOptions | undefined,
720
736
  rememberState: (s: SonarAnalysisState) => void,
721
737
  ): Promise<void> {
738
+ if (!targetInput) {
739
+ const pickResult = await pickOrResolveTarget(ctx);
740
+ if (pickResult === null) return; // user cancelled
741
+ targetInput = pickResult;
742
+ }
722
743
  const state = await analyzeProject(pi, ctx, targetInput, filters);
723
744
  rememberState(state);
724
745
  if (ctx.hasUI) {
@@ -773,6 +794,11 @@ async function commandMetrics(
773
794
  ctx: ExtensionCommandContext,
774
795
  targetInput?: string,
775
796
  ): Promise<void> {
797
+ if (!targetInput) {
798
+ const pickResult = await pickOrResolveTarget(ctx);
799
+ if (pickResult === null) return;
800
+ targetInput = pickResult;
801
+ }
776
802
  const config = await resolveConfig(ctx, targetInput);
777
803
  const metrics = await loadMetricsData(config, ctx.signal);
778
804
  if (!metrics) {
@@ -803,6 +829,11 @@ async function commandDuplications(
803
829
  fileIndex?: number,
804
830
  filters?: SonarIssueFetchOptions,
805
831
  ): Promise<void> {
832
+ if (!targetInput) {
833
+ const pickResult = await pickOrResolveTarget(ctx);
834
+ if (pickResult === null) return;
835
+ targetInput = pickResult;
836
+ }
806
837
  const config = await resolveConfig(ctx, targetInput);
807
838
  let files: FileDuplication[];
808
839
  try {
@@ -914,10 +945,16 @@ async function commandIssuesOrOpen(
914
945
  },
915
946
  rememberState: (s: SonarAnalysisState) => void,
916
947
  ): Promise<void> {
948
+ let { targetInput } = parsed;
949
+ if (!targetInput) {
950
+ const pickResult = await pickOrResolveTarget(ctx);
951
+ if (pickResult === null) return;
952
+ targetInput = pickResult;
953
+ }
917
954
  const targetState = await resolveTargetState(
918
955
  ctx,
919
956
  statesByBaseDir,
920
- parsed.targetInput,
957
+ targetInput,
921
958
  parsed.filters,
922
959
  );
923
960
  if (!targetState) {
package/src/types.ts CHANGED
@@ -68,7 +68,7 @@ export interface SonarAnalysisState {
68
68
  measures?: SonarDuplicationMeasures;
69
69
  totalIssues: number;
70
70
  issues: SonarIssue[];
71
- cleanCodeMode?: "STANDARD" | "MQR";
71
+ cleanCodeMode?: CleanCodeMode;
72
72
  }
73
73
 
74
74
  export interface SonarProjectConfig {
@@ -98,6 +98,8 @@ export interface SonarInitConfig {
98
98
 
99
99
  // ── Internal types shared by adapter and extension ──────────────────────────
100
100
 
101
+ export type CleanCodeMode = "STANDARD" | "MQR";
102
+
101
103
  export interface SonarWorkspaceRegistry {
102
104
  version: 1;
103
105
  workspaces: Record<string, string>;