@jonathan0823/pi-sonarqube 0.8.2 → 0.9.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/dist/types.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,iFAAiF;AA4HjF,+EAA+E;AAE/E,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,SAAS;IACT,UAAU;IACV,OAAO;IACP,OAAO;IACP,MAAM;CACE,CAAC;AACX,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,MAAM;IACN,WAAW;IACX,UAAU;IACV,UAAU;IACV,QAAQ;CACA,CAAC;AACX,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,eAAe,EAAE,YAAY,CAAU,CAAC;AAC3E,MAAM,CAAC,MAAM,wBAAwB,GAAG;IACtC,iBAAiB;IACjB,aAAa;IACb,UAAU;CACF,CAAC;AACX,MAAM,CAAC,MAAM,uBAAuB,GAAG;IACrC,SAAS;IACT,MAAM;IACN,QAAQ;IACR,KAAK;IACL,MAAM;CACE,CAAC"}
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,iFAAiF;AA8HjF,+EAA+E;AAE/E,MAAM,CAAC,MAAM,gBAAgB,GAAG;IAC9B,SAAS;IACT,UAAU;IACV,OAAO;IACP,OAAO;IACP,MAAM;CACE,CAAC;AACX,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,MAAM;IACN,WAAW;IACX,UAAU;IACV,UAAU;IACV,QAAQ;CACA,CAAC;AACX,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAK,EAAE,eAAe,EAAE,YAAY,CAAU,CAAC;AAC3E,MAAM,CAAC,MAAM,wBAAwB,GAAG;IACtC,iBAAiB;IACjB,aAAa;IACb,UAAU;CACF,CAAC;AACX,MAAM,CAAC,MAAM,uBAAuB,GAAG;IACrC,SAAS;IACT,MAAM;IACN,QAAQ;IACR,KAAK;IACL,MAAM;CACE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jonathan0823/pi-sonarqube",
3
- "version": "0.8.2",
3
+ "version": "0.9.1",
4
4
  "description": "SonarQube extension for Pi — run local analysis and browse issues.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -44,11 +44,11 @@
44
44
  "type": "git",
45
45
  "url": "git+https://github.com/Jonathan0823/pi-sonarqube.git"
46
46
  },
47
- "dependencies": {
48
- "@earendil-works/pi-ai": "^0.79.6",
49
- "@earendil-works/pi-coding-agent": "^0.79.6",
50
- "@earendil-works/pi-tui": "^0.79.6",
51
- "typebox": "^1.1.38"
47
+ "peerDependencies": {
48
+ "@earendil-works/pi-ai": "*",
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "@earendil-works/pi-tui": "*",
51
+ "typebox": "*"
52
52
  },
53
53
  "devDependencies": {
54
54
  "c8": "^11.0.0",
package/src/api.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  SONAR_IMPACT_SEVERITIES,
21
21
  CleanCodeMode,
22
22
  } from "./types.js";
23
- import { parseProperties } from "./config.js";
23
+ import { parseProperties, trimTrailing } from "./config.js";
24
24
 
25
25
  // ── Clean code mode detection ────────────────────────────────────────────────
26
26
 
@@ -115,6 +115,7 @@ export function normalizeIssueFilters(
115
115
  rules: normalizeIssueList(filters?.rules),
116
116
  softwareQualities: normalizeIssueList(filters?.softwareQualities, true),
117
117
  impactSeverities: normalizeIssueList(filters?.impactSeverities, true),
118
+ issueKeys: normalizeIssueList(filters?.issueKeys),
118
119
  pathScope: filters?.pathScope,
119
120
  componentKeys: filters?.componentKeys,
120
121
  };
@@ -124,6 +125,7 @@ export function normalizeIssueFilters(
124
125
  normalized.rules ||
125
126
  normalized.softwareQualities ||
126
127
  normalized.impactSeverities ||
128
+ normalized.issueKeys ||
127
129
  normalized.pathScope ||
128
130
  normalized.componentKeys?.length
129
131
  ? normalized
@@ -213,15 +215,34 @@ export function parseIssueFilterToken(
213
215
  return undefined;
214
216
  }
215
217
 
218
+ function parseIssueKeyToken(
219
+ token: string,
220
+ allowIssueKeys: boolean,
221
+ ): string[] | undefined {
222
+ if (!allowIssueKeys || !token.toLowerCase().startsWith("issue:")) {
223
+ return undefined;
224
+ }
225
+ const keys = token
226
+ .slice(token.indexOf(":") + 1)
227
+ .split(",")
228
+ .map((key) => key.trim())
229
+ .filter(Boolean);
230
+ if (keys.length === 0) throw new Error("issue: requires an issue key");
231
+ return keys;
232
+ }
233
+
216
234
  export function parseSonarIssueArgs(
217
235
  tokens: string[],
218
236
  allowIssueIndex = false,
237
+ allowIssueKeys = false,
219
238
  ): {
220
239
  targetInput?: string;
221
240
  issueIndex?: number;
241
+ issueKeys?: string[];
222
242
  filters?: SonarIssueFetchOptions;
223
243
  } {
224
244
  const filters: Array<Partial<SonarIssueFetchOptions> | undefined> = [];
245
+ const issueKeys: string[] = [];
225
246
  let targetInput: string | undefined;
226
247
  let issueIndex: number | undefined;
227
248
 
@@ -231,6 +252,11 @@ export function parseSonarIssueArgs(
231
252
  issueIndex = Number(token);
232
253
  continue;
233
254
  }
255
+ const parsedIssueKeys = parseIssueKeyToken(token, allowIssueKeys);
256
+ if (parsedIssueKeys) {
257
+ issueKeys.push(...parsedIssueKeys);
258
+ continue;
259
+ }
234
260
 
235
261
  const parsedFilter = parseIssueFilterToken(token);
236
262
  if (parsedFilter) {
@@ -243,7 +269,12 @@ export function parseSonarIssueArgs(
243
269
  }
244
270
  }
245
271
 
246
- return { targetInput, issueIndex, filters: mergeFilters(...filters) };
272
+ return {
273
+ targetInput,
274
+ issueIndex,
275
+ issueKeys: normalizeIssueList(issueKeys),
276
+ filters: mergeFilters(...filters),
277
+ };
247
278
  }
248
279
 
249
280
  function mergeFilterField<T>(
@@ -289,6 +320,8 @@ export function issueFilterLabel(filters?: SonarIssueFetchOptions): string {
289
320
  parts.push(`qualities=${filters.softwareQualities.join(",")}`);
290
321
  if (filters.impactSeverities?.length)
291
322
  parts.push(`impactSeverities=${filters.impactSeverities.join(",")}`);
323
+ if (filters.issueKeys?.length)
324
+ parts.push(`issues=${filters.issueKeys.join(",")}`);
292
325
  if (filters.pathScope) parts.push(`in:${filters.pathScope}`);
293
326
  return parts.join(" • ");
294
327
  }
@@ -302,7 +335,7 @@ export function resolvePathScope(
302
335
  const absPath = resolve(baseDir, raw);
303
336
  const relPath = relative(baseDir, absPath);
304
337
  if (relPath === "" || relPath === ".") return projectKey;
305
- const cleanRel = relPath.replace(/[\\/]+$/, "");
338
+ const cleanRel = trimTrailing(relPath, "/\\");
306
339
  return `${projectKey}:${cleanRel}`;
307
340
  }
308
341
 
@@ -508,7 +541,7 @@ function buildIssueSearchUrl(
508
541
  ): string {
509
542
  const url = new URL("/api/issues/search", serverUrl);
510
543
  url.searchParams.set("projects", projectKey);
511
- url.searchParams.set("resolved", "false");
544
+ if (!filters?.issueKeys?.length) url.searchParams.set("resolved", "false");
512
545
  url.searchParams.set("ps", "100");
513
546
  url.searchParams.set("p", String(page));
514
547
  if (filters?.severities?.length)
@@ -529,6 +562,8 @@ function buildIssueSearchUrl(
529
562
  "impactSeverities",
530
563
  filters.impactSeverities.join(","),
531
564
  );
565
+ if (filters?.issueKeys?.length)
566
+ url.searchParams.set("issues", filters.issueKeys.join(","));
532
567
  if (filters?.componentKeys?.length)
533
568
  url.searchParams.set("componentKeys", filters.componentKeys.join(","));
534
569
  return url.toString();
package/src/commands.ts CHANGED
@@ -157,7 +157,8 @@ function createFilterCompletionList(mode?: CleanCodeMode): AutocompleteItem[] {
157
157
  {
158
158
  value: "in:",
159
159
  label: "in:",
160
- description: "path or dir scope (e.g. in:src/api.ts or in:src/)",
160
+ description:
161
+ "file or directory inside the selected project (e.g. in:src/api.ts or in:src/)",
161
162
  },
162
163
  ];
163
164
 
@@ -372,19 +373,10 @@ function getInPathCompletions(
372
373
  }
373
374
 
374
375
  function getOpenCompletions(
375
- issues: SonarIssue[] | undefined,
376
376
  mode: CleanCodeMode | undefined,
377
377
  current: string,
378
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)];
379
+ const suggestions = createFilterCompletionList(mode);
388
380
  const filtered = filterAutocompleteItems(suggestions, current);
389
381
  return filtered.length > 0 ? filtered : null;
390
382
  }
@@ -494,7 +486,7 @@ export async function sonarArgumentCompletions(
494
486
  }
495
487
 
496
488
  if (lowerCommand === "open") {
497
- return getOpenCompletions(issues, mode, current);
489
+ return getOpenCompletions(mode, current);
498
490
  }
499
491
 
500
492
  if (lowerCommand === "analyze") {
@@ -547,6 +539,7 @@ export type ParsedSonarCommand =
547
539
  action: "open";
548
540
  targetInput?: string;
549
541
  issueIndex?: number;
542
+ issueKeys?: string[];
550
543
  filters?: SonarIssueFetchOptions;
551
544
  }
552
545
  | { action: "metrics"; targetInput?: string }
@@ -575,7 +568,10 @@ export function parseCommandArgs(args: string): ParsedSonarCommand {
575
568
  return { action: "issues", ...parseSonarIssueArgs(tokens.slice(1)) };
576
569
  }
577
570
  if (head === "open") {
578
- return { action: "open", ...parseSonarIssueArgs(tokens.slice(1), true) };
571
+ return {
572
+ action: "open",
573
+ ...parseSonarIssueArgs(tokens.slice(1), true, true),
574
+ };
579
575
  }
580
576
  if (head === "analyze" || head === "run") {
581
577
  return { action: "analyze", ...parseSonarIssueArgs(tokens.slice(1)) };
@@ -600,9 +596,10 @@ export function helpText(): string {
600
596
  "SonarQube commands:",
601
597
  "",
602
598
  " /sonarqube init [alias] [path] configure a project target",
603
- " /sonarqube analyze [target] run analysis for a target or path",
604
- " /sonarqube issues [target] browse issues for a target or path",
605
- " /sonarqube open [target] <n> preview issue #n for a target or path",
599
+ " /sonarqube analyze [target] run analysis for a project target",
600
+ " /sonarqube issues [target] browse issues for a project target",
601
+ " /sonarqube open [target] <n> preview positional issue #n",
602
+ " /sonarqube open [target] issue:<key>[,<key>] preview stable issue keys",
606
603
  " /sonarqube metrics [target] show project metrics (no scanner)",
607
604
  " /sonarqube duplications [target] browse duplicated files and blocks",
608
605
  " /sonarqube show this help",
@@ -620,6 +617,8 @@ export function helpText(): string {
620
617
  " type /sonarqube and press Tab to complete the subcommand or filters",
621
618
  "",
622
619
  "Notes:",
620
+ " [target] is a configured alias or SonarQube project root.",
621
+ " Use in:<path> for a file or directory relative to that project root.",
623
622
  " Legacy filters (severity, type) and MQR filters (quality, impactSeverity)",
624
623
  " cannot be combined in the same command. Use one filter family per query.",
625
624
  "",
@@ -638,16 +637,33 @@ export function sonarErrorMessage(error: unknown): string {
638
637
  return error instanceof Error ? error.message : String(error);
639
638
  }
640
639
 
640
+ export function assertIssueSelection(
641
+ issueIndex?: number,
642
+ issueKeys?: string[],
643
+ ): void {
644
+ if (issueIndex !== undefined && issueKeys?.length) {
645
+ throw new Error("Use either issueIndex or issueKeys, not both.");
646
+ }
647
+ if (issueKeys && issueKeys.length > 10) {
648
+ throw new Error("At most 10 issue keys can be opened at once.");
649
+ }
650
+ }
651
+
641
652
  // ── Issue formatting ────────────────────────────────────────────────────────
642
653
 
643
- export function formatIssue(issue: SonarIssue, index?: number): string {
654
+ export function formatIssue(
655
+ issue: SonarIssue,
656
+ index?: number,
657
+ includeKey = true,
658
+ ): string {
644
659
  const loc = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
645
660
  const prefix =
646
661
  typeof index === "number" ? `${String(index).padStart(2, "0")}. ` : "";
647
662
  const rule = issue.ruleName
648
663
  ? `${issue.rule} (${issue.ruleName})`
649
664
  : issue.rule;
650
- return `${prefix}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
665
+ const key = includeKey ? `[${issue.key}] ` : "";
666
+ return `${prefix}${key}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
651
667
  }
652
668
 
653
669
  export function formatMetricsOutput(metrics: {
package/src/config.ts CHANGED
@@ -11,13 +11,28 @@ import type {
11
11
 
12
12
  // ── Generic helpers ─────────────────────────────────────────────────────────
13
13
 
14
+ /** Remove trailing characters in `chars` from `value`. */
15
+ export function trimTrailing(value: string, chars: string): string {
16
+ let end = value.length;
17
+ while (end > 0 && chars.includes(value[end - 1])) end--;
18
+ return value.slice(0, end);
19
+ }
20
+
21
+ /** Remove leading and trailing characters in `chars` from `value`. */
22
+ export function trimChars(value: string, chars: string): string {
23
+ let start = 0;
24
+ let end = value.length;
25
+ while (start < end && chars.includes(value[start])) start++;
26
+ while (end > start && chars.includes(value[end - 1])) end--;
27
+ return value.slice(start, end);
28
+ }
29
+
14
30
  export function slugify(input: string): string {
15
31
  return (
16
- input
17
- .toLowerCase()
18
- .replace(/[^a-z0-9]+/g, "-")
19
- .replace(/^-+|-+$/g, "")
20
- .slice(0, 80) || "sonarqube"
32
+ trimChars(input.toLowerCase().replace(/[^a-z0-9]+/g, "-"), "-").slice(
33
+ 0,
34
+ 80,
35
+ ) || "sonarqube"
21
36
  );
22
37
  }
23
38
 
@@ -43,7 +58,7 @@ export function parseProperties(text: string): Record<string, string> {
43
58
  export function normalizeServerUrl(url: string | undefined): string {
44
59
  const fallback = "http://localhost:9000";
45
60
  if (!url?.trim()) return fallback;
46
- return url.trim().replace(/\/+$/, "");
61
+ return trimTrailing(url.trim(), "/");
47
62
  }
48
63
 
49
64
  export function resolveProjectKey(
@@ -131,7 +146,17 @@ export function mergeCommaSeparatedValues(
131
146
  return [...merged].join(",");
132
147
  }
133
148
 
134
- const EXCLUSION_RE = /^(\s*)(sonar\.(?:sources|exclusions))\s*=\s*(.*)$/;
149
+ function matchSonarProperty(
150
+ line: string,
151
+ ): { indent: string; key: string; value: string } | undefined {
152
+ const trimmedStart = line.trimStart();
153
+ const eq = trimmedStart.indexOf("=");
154
+ if (eq < 0) return undefined;
155
+ const key = trimmedStart.slice(0, eq).trimEnd();
156
+ if (key !== "sonar.sources" && key !== "sonar.exclusions") return undefined;
157
+ const indent = line.slice(0, line.length - trimmedStart.length);
158
+ return { indent, key, value: trimmedStart.slice(eq + 1).trim() };
159
+ }
135
160
 
136
161
  export async function ensureDefaultSonarProjectProperties(
137
162
  baseDir: string,
@@ -149,12 +174,10 @@ export async function ensureDefaultSonarProjectProperties(
149
174
  let changed = false;
150
175
 
151
176
  const updatedLines = lines.map((line) => {
152
- const match = EXCLUSION_RE.exec(line);
153
- if (!match) return line;
177
+ const property = matchSonarProperty(line);
178
+ if (!property) return line;
154
179
 
155
- const indent = match[1] ?? "";
156
- const key = match[2];
157
- const value = match[3] ?? "";
180
+ const { indent, key, value } = property;
158
181
 
159
182
  if (key === "sonar.sources") {
160
183
  hasSources = true;
@@ -188,7 +211,7 @@ export async function ensureDefaultSonarProjectProperties(
188
211
  return "unchanged";
189
212
  }
190
213
 
191
- const normalized = updatedLines.join("\n").replace(/\n*$/, "\n");
214
+ const normalized = trimTrailing(updatedLines.join("\n"), "\n") + "\n";
192
215
  await writeFile(path, normalized, "utf8");
193
216
  return existing.includes("sonar.sources") ||
194
217
  existing.includes("sonar.exclusions")