@jonathan0823/pi-sonarqube 0.1.3 → 0.2.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.
@@ -0,0 +1,314 @@
1
+ import type { AutocompleteItem, AutocompleteSuggestions } from "@earendil-works/pi-tui";
2
+ import type { SonarIssue, SonarIssueFetchOptions, SonarDuplicationMeasures, IssueSeverityCounts, FileDuplication, DuplicationBlockGroup } from "./types.js";
3
+ import { SONAR_SEVERITIES, SONAR_STATUSES, SONAR_TYPES } from "./types.js";
4
+ import { looksLikePath } from "./config.js";
5
+ import { parseSonarIssueArgs, issueFilterLabel } from "./api.js";
6
+
7
+ // ── Constants ───────────────────────────────────────────────────────────────
8
+
9
+ export const STATE_TYPE = "sonarqube-analysis-state";
10
+
11
+ const SONAR_COMMANDS = [
12
+ { value: "analyze", label: "analyze", description: "run analysis and fetch issues" },
13
+ { value: "issues", label: "issues", description: "browse the latest issues" },
14
+ { value: "open", label: "open", description: "preview a specific issue" },
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" },
18
+ ] as const;
19
+
20
+ // ── Autocomplete helpers ────────────────────────────────────────────────────
21
+
22
+ export function filterAutocompleteItems(
23
+ items: readonly { value: string; label: string; description?: string }[],
24
+ prefix: string,
25
+ ): AutocompleteItem[] {
26
+ const query = prefix.trim().toLowerCase();
27
+ if (!query) return items.map((item) => ({ value: item.value, label: item.label, description: item.description }));
28
+ return items
29
+ .filter(
30
+ (item) =>
31
+ item.value.toLowerCase().startsWith(query) || item.label.toLowerCase().startsWith(query),
32
+ )
33
+ .map((item) => ({ value: item.value, label: item.label, description: item.description }));
34
+ }
35
+
36
+ export function mergeAutocompleteSuggestions(
37
+ prefix: string,
38
+ current: AutocompleteSuggestions | null,
39
+ extraItems: AutocompleteItem[],
40
+ ): AutocompleteSuggestions | null {
41
+ const merged = new Map<string, AutocompleteItem>();
42
+ for (const item of current?.items ?? []) merged.set(item.value, item);
43
+ for (const item of extraItems) merged.set(item.value, item);
44
+ const items = [...merged.values()];
45
+ return items.length > 0 ? { prefix, items } : current;
46
+ }
47
+
48
+ export function splitSonarArgumentContext(
49
+ argumentText: string,
50
+ ): { command: string; current: string; tokens: string[] } {
51
+ const trimmedLeft = argumentText.replace(/^\s+/, "");
52
+ if (!trimmedLeft) return { command: "", current: "", tokens: [] };
53
+
54
+ const tokens = trimmedLeft.split(/\s+/);
55
+ const current = /\s$/.test(argumentText) ? "" : tokens.pop() ?? "";
56
+ const command = tokens[0] ?? current;
57
+ let tokensOut: string[];
58
+ if (tokens.length > 0) {
59
+ tokensOut = tokens;
60
+ } else if (current) {
61
+ tokensOut = [current];
62
+ } else {
63
+ tokensOut = [];
64
+ }
65
+ return {
66
+ command,
67
+ current,
68
+ tokens: tokensOut,
69
+ };
70
+ }
71
+
72
+ function createFilterCompletionList(): AutocompleteItem[] {
73
+ const entries: AutocompleteItem[] = [];
74
+ const add = (value: string, desc: string) => {
75
+ entries.push(
76
+ { value: `severity:${value}`, label: `severity:${value}`, description: desc },
77
+ { value, label: value, description: desc },
78
+ );
79
+ };
80
+ for (const value of SONAR_SEVERITIES) add(value, "severity");
81
+ for (const value of SONAR_STATUSES) add(value, "status");
82
+ for (const value of SONAR_TYPES) add(value, "type");
83
+ return entries;
84
+ }
85
+
86
+ export function sonarArgumentCompletions(
87
+ argumentText: string,
88
+ issues?: SonarIssue[],
89
+ ): AutocompleteItem[] | null {
90
+ const { command, current, tokens } = splitSonarArgumentContext(argumentText);
91
+ const lowerCommand = command.toLowerCase();
92
+ const commandMatches = SONAR_COMMANDS.filter((item) => item.value.startsWith(lowerCommand));
93
+ const isFullMatch = SONAR_COMMANDS.some((item) => item.value === lowerCommand);
94
+
95
+ if (!command || commandMatches.length > 1 || !isFullMatch) {
96
+ if (commandMatches.length === 0) return null;
97
+ return commandMatches.map((item) => ({ value: item.value, label: item.value, description: item.description }));
98
+ }
99
+
100
+ if (lowerCommand === "open") {
101
+ const issueItems = (issues ?? []).slice(0, 10).map((issue, index) => {
102
+ const lineSuffix = issue.line ? `:${issue.line}` : "";
103
+ return { value: String(index + 1), label: String(index + 1), description: `${issue.severity} ${issue.filePath}${lineSuffix}` };
104
+ });
105
+ const suggestions = [...issueItems, ...createFilterCompletionList()];
106
+ const filtered = filterAutocompleteItems(suggestions, current);
107
+ return filtered.length > 0 ? filtered : null;
108
+ }
109
+
110
+ if (lowerCommand === "analyze" || lowerCommand === "issues") {
111
+ const suggestions = createFilterCompletionList();
112
+ const filtered = filterAutocompleteItems(suggestions, current);
113
+ return filtered.length > 0 ? filtered : null;
114
+ }
115
+
116
+ if (lowerCommand === "init" || lowerCommand === "metrics" || lowerCommand === "duplications") {
117
+ return null;
118
+ }
119
+
120
+ return tokens.length === 0 ? filterAutocompleteItems(SONAR_COMMANDS, current) : null;
121
+ }
122
+
123
+ // ── Command arg parsing ─────────────────────────────────────────────────────
124
+
125
+ export type ParsedSonarCommand =
126
+ | { action: "help" }
127
+ | { action: "init"; alias?: string; targetInput?: string }
128
+ | { action: "analyze"; targetInput?: string; filters?: SonarIssueFetchOptions }
129
+ | { action: "issues"; targetInput?: string; filters?: SonarIssueFetchOptions }
130
+ | { action: "open"; targetInput?: string; issueIndex?: number; filters?: SonarIssueFetchOptions }
131
+ | { action: "metrics"; targetInput?: string }
132
+ | { action: "duplications"; targetInput?: string; issueIndex?: number };
133
+
134
+
135
+ export function parseCommandArgs(args: string): ParsedSonarCommand {
136
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
137
+ if (tokens.length === 0) return { action: "help" };
138
+
139
+ const head = tokens[0].toLowerCase();
140
+ if (head === "init") {
141
+ if (tokens.length === 1) return { action: "init" };
142
+ if (tokens.length === 2) {
143
+ return looksLikePath(tokens[1])
144
+ ? { action: "init", targetInput: tokens[1] }
145
+ : { action: "init", alias: tokens[1] };
146
+ }
147
+ return { action: "init", alias: tokens[1], targetInput: tokens[2] };
148
+ }
149
+ if (head === "issues" || head === "view") {
150
+ return { action: "issues", ...parseSonarIssueArgs(tokens.slice(1)) };
151
+ }
152
+ if (head === "open") {
153
+ return { action: "open", ...parseSonarIssueArgs(tokens.slice(1), true) };
154
+ }
155
+ if (head === "analyze" || head === "run") {
156
+ return { action: "analyze", ...parseSonarIssueArgs(tokens.slice(1)) };
157
+ }
158
+ if (head === "metrics") {
159
+ return { action: "metrics", ...parseSonarIssueArgs(tokens.slice(1)) };
160
+ }
161
+ if (head === "duplications") {
162
+ return { action: "duplications", ...parseSonarIssueArgs(tokens.slice(1), true) };
163
+ }
164
+
165
+ return { action: "analyze", ...parseSonarIssueArgs(tokens) };
166
+ }
167
+
168
+ // ── Help text / messages ────────────────────────────────────────────────────
169
+
170
+ export function helpText(): string {
171
+ return [
172
+ "SonarQube commands:",
173
+ "",
174
+ " /sonarqube init [alias] [path] configure a project target",
175
+ " /sonarqube analyze [target] run analysis for a target or path",
176
+ " /sonarqube issues [target] browse issues for a target or path",
177
+ " /sonarqube open [target] <n> preview issue #n for a target or path",
178
+ " /sonarqube metrics [target] show project metrics (no scanner)",
179
+ " /sonarqube duplications [target] browse duplicated files and blocks",
180
+ " /sonarqube show this help",
181
+ "",
182
+ "Filters:",
183
+ " /sonarqube issues be CRITICAL",
184
+ " /sonarqube issues be severity:CRITICAL status:OPEN",
185
+ "",
186
+ "Autocomplete:",
187
+ " type /sonarqube and press Tab to complete the subcommand or filters",
188
+ "",
189
+ "Defaults:",
190
+ " config is stored in .pi/sonarqube.json",
191
+ " use project paths directly (no alias needed)",
192
+ ].join("\n");
193
+ }
194
+
195
+ export function targetLabel(targetInput?: string): string {
196
+ return targetInput ? ` ${targetInput}` : "";
197
+ }
198
+
199
+ export function sonarErrorMessage(error: unknown): string {
200
+ return error instanceof Error ? error.message : String(error);
201
+ }
202
+
203
+ // ── Issue formatting ────────────────────────────────────────────────────────
204
+
205
+ export function formatIssue(issue: SonarIssue, index?: number): string {
206
+ const loc = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
207
+ const prefix = typeof index === "number" ? `${String(index).padStart(2, "0")}. ` : "";
208
+ const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
209
+ return `${prefix}${issue.severity} ${loc} — ${rule} — ${issue.message}`;
210
+ }
211
+
212
+ export function formatMetricsOutput(metrics: {
213
+ projectKey: string;
214
+ measures?: SonarDuplicationMeasures;
215
+ issueCounts?: IssueSeverityCounts;
216
+ }): string {
217
+ const lines: string[] = [`Metrics for ${metrics.projectKey}`];
218
+
219
+ if (metrics.measures) {
220
+ const density = metrics.measures.duplicatedLinesDensity.toFixed(1);
221
+ lines.push(
222
+ metrics.measures.duplicatedBlocks > 0
223
+ ? `Duplication: ${density}% lines=${metrics.measures.duplicatedLines} blocks=${metrics.measures.duplicatedBlocks} files=${metrics.measures.duplicatedFiles}`
224
+ : `Duplication: ${density}% (no duplications detected)`,
225
+ );
226
+ }
227
+
228
+ if (metrics.issueCounts) {
229
+ const counts = [
230
+ `BLOCKER ${metrics.issueCounts.blocker}`,
231
+ `CRITICAL ${metrics.issueCounts.critical}`,
232
+ `MAJOR ${metrics.issueCounts.major}`,
233
+ `MINOR ${metrics.issueCounts.minor}`,
234
+ `INFO ${metrics.issueCounts.info}`,
235
+ ];
236
+ lines.push(`Issues: ${counts.join(" ")}`);
237
+ }
238
+
239
+ return lines.join("\n");
240
+ }
241
+
242
+ export function formatDuplicationsList(files: FileDuplication[]): string {
243
+ if (files.length === 0) return "No duplicated files found.";
244
+ const lines: string[] = [`Duplicated files (${files.length})`];
245
+ for (const [i, f] of files.entries()) {
246
+ lines.push(` ${String(i + 1).padStart(2, " ")}. ${f.filePath} blocks=${f.duplicatedBlocks} lines=${f.duplicatedLines}`);
247
+ }
248
+ return lines.join("\n");
249
+ }
250
+
251
+ export function formatDuplicationBlockDetail(filePath: string, groups: DuplicationBlockGroup[]): string {
252
+ if (groups.length === 0) return `No duplications found in ${filePath}.`;
253
+ const lines: string[] = [`Duplications in ${filePath}`, ""];
254
+ for (const [i, group] of groups.entries()) {
255
+ lines.push(`Block ${i + 1}:`);
256
+ for (const block of group.blocks) {
257
+ const end = block.from + block.size - 1;
258
+ lines.push(` ${block.filePath}:${block.from}-${end}`);
259
+ }
260
+ lines.push("");
261
+ }
262
+ return lines.join("\n");
263
+ }
264
+
265
+ export function formatSummary(state: { totalIssues: number; projectKey: string; filters?: SonarIssueFetchOptions }): string {
266
+ const issueCount = state.totalIssues;
267
+ const filterLabel = state.filters ? ` (${issueFilterLabel(state.filters)})` : "";
268
+ if (issueCount === 0) {
269
+ return `SonarQube: no issues found for ${state.projectKey}${filterLabel}`;
270
+ }
271
+ const plural = issueCount === 1 ? "" : "s";
272
+ return `SonarQube: ${issueCount} issue${plural} found for ${state.projectKey}${filterLabel}`;
273
+ }
274
+
275
+ export function formatReport(state: {
276
+ projectKey: string;
277
+ serverUrl: string;
278
+ baseDir: string;
279
+ totalIssues: number;
280
+ filters?: SonarIssueFetchOptions;
281
+ measures?: SonarDuplicationMeasures;
282
+ issues: SonarIssue[];
283
+ }): string {
284
+ const lines = [
285
+ `Project: ${state.projectKey}`,
286
+ `Server: ${state.serverUrl}`,
287
+ `Base dir: ${state.baseDir}`,
288
+ `Issues: ${state.totalIssues}`,
289
+ ];
290
+
291
+ if (state.filters) {
292
+ lines.push(`Filters: ${issueFilterLabel(state.filters)}`);
293
+ }
294
+
295
+ if (state.measures && state.issues.length > 0) {
296
+ lines.push("", state.measures.duplicatedBlocks > 0
297
+ ? `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% lines=${state.measures.duplicatedLines} blocks=${state.measures.duplicatedBlocks} files=${state.measures.duplicatedFiles}`
298
+ : `Duplication: ${state.measures.duplicatedLinesDensity.toFixed(1)}% (no duplications detected)`);
299
+ }
300
+
301
+ if (state.issues.length === 0) {
302
+ if (!state.measures) lines.push("No open issues found.");
303
+ return lines.join("\n");
304
+ }
305
+
306
+ lines.push("");
307
+ for (const [i, issue] of state.issues.slice(0, 10).entries()) {
308
+ lines.push(formatIssue(issue, i + 1));
309
+ }
310
+ if (state.issues.length > 10) {
311
+ lines.push(`... ${state.issues.length - 10} more`);
312
+ }
313
+ return lines.join("\n");
314
+ }
package/src/config.ts ADDED
@@ -0,0 +1,336 @@
1
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
2
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, relative, resolve } from "node:path";
4
+ import type { SonarInitConfig, SonarProjectConfig, SonarWorkspaceRegistry, ResolvedTarget } from "./types.js";
5
+
6
+ // ── Generic helpers ─────────────────────────────────────────────────────────
7
+
8
+ export function slugify(input: string): string {
9
+ return (
10
+ input
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .replace(/^-+|-+$/g, "")
14
+ .slice(0, 80) || "sonarqube"
15
+ );
16
+ }
17
+
18
+ export function normalizePath(input: string, cwd: string): string {
19
+ const trimmed = input.trim().replace(/^@/, "");
20
+ return resolve(cwd, trimmed);
21
+ }
22
+
23
+ export function parseProperties(text: string): Record<string, string> {
24
+ const props: Record<string, string> = {};
25
+ for (const rawLine of text.split(/\r?\n/)) {
26
+ const line = rawLine.trim();
27
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
28
+ const sep = line.indexOf("=");
29
+ if (sep < 0) continue;
30
+ const key = line.slice(0, sep).trim();
31
+ const value = line.slice(sep + 1).trim();
32
+ if (key) props[key] = value;
33
+ }
34
+ return props;
35
+ }
36
+
37
+ export function normalizeServerUrl(url: string | undefined): string {
38
+ const fallback = "http://localhost:9000";
39
+ if (!url?.trim()) return fallback;
40
+ return url.trim().replace(/\/+$/, "");
41
+ }
42
+
43
+ export function resolveProjectKey(baseDir: string, props: Record<string, string>): string {
44
+ const fromProps = props["sonar.projectKey"]?.trim();
45
+ if (fromProps) return fromProps;
46
+ return slugify(basename(baseDir));
47
+ }
48
+
49
+ // ── File helpers ────────────────────────────────────────────────────────────
50
+
51
+ export async function readOptionalText(path: string): Promise<string | undefined> {
52
+ try {
53
+ return await readFile(path, "utf8");
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ export async function readOptionalJson<T>(path: string): Promise<T | undefined> {
60
+ try {
61
+ const text = await readFile(path, "utf8");
62
+ return JSON.parse(text) as T;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ // ── Config file paths ───────────────────────────────────────────────────────
69
+
70
+ export function projectConfigPath(baseDir: string): string {
71
+ return resolve(baseDir, CONFIG_DIR_NAME, "sonarqube.json");
72
+ }
73
+
74
+ export function sonarqubeConfigDir(baseDir: string): string {
75
+ return resolve(baseDir, CONFIG_DIR_NAME);
76
+ }
77
+
78
+ export function sonarProjectPropertiesPath(baseDir: string): string {
79
+ return resolve(baseDir, "sonar-project.properties");
80
+ }
81
+
82
+ // ── Default sonar-project.properties ────────────────────────────────────────
83
+
84
+ export const DEFAULT_SONAR_EXCLUSIONS = [
85
+ "**/node_modules/**",
86
+ "dist/**",
87
+ "coverage/**",
88
+ ".scannerwork/**",
89
+ "**/.env*",
90
+ ];
91
+
92
+ const DEFAULT_SONAR_PROJECT_PROPERTIES = [
93
+ "sonar.sources=.",
94
+ `sonar.exclusions=${DEFAULT_SONAR_EXCLUSIONS.join(",")}`,
95
+ ].join("\n") + "\n";
96
+
97
+ export function mergeCommaSeparatedValues(existingValue: string | undefined, additions: string[]): string {
98
+ const merged = new Set(
99
+ (existingValue ?? "")
100
+ .split(",")
101
+ .map((v) => v.trim())
102
+ .filter(Boolean),
103
+ );
104
+ for (const addition of additions) {
105
+ const trimmed = addition.trim();
106
+ if (trimmed) merged.add(trimmed);
107
+ }
108
+ return [...merged].join(",");
109
+ }
110
+
111
+ const EXCLUSION_RE = /^(\s*)(sonar\.(?:sources|exclusions))\s*=\s*(.*)$/;
112
+
113
+ export async function ensureDefaultSonarProjectProperties(baseDir: string): Promise<"created" | "updated" | "unchanged"> {
114
+ const path = sonarProjectPropertiesPath(baseDir);
115
+ const existing = await readOptionalText(path);
116
+ if (!existing?.trim()) {
117
+ await writeFile(path, DEFAULT_SONAR_PROJECT_PROPERTIES, "utf8");
118
+ return "created";
119
+ }
120
+
121
+ const lines = existing.split(/\r?\n/);
122
+ let hasSources = false;
123
+ let hasExclusions = false;
124
+ let changed = false;
125
+
126
+ const updatedLines = lines.map((line) => {
127
+ const match = EXCLUSION_RE.exec(line);
128
+ if (!match) return line;
129
+
130
+ const indent = match[1] ?? "";
131
+ const key = match[2];
132
+ const value = match[3] ?? "";
133
+
134
+ if (key === "sonar.sources") {
135
+ hasSources = true;
136
+ const trimmedValue = value.trim();
137
+ if (trimmedValue) return line;
138
+ changed = true;
139
+ return `${indent}sonar.sources=.`;
140
+ }
141
+
142
+ if (key === "sonar.exclusions") {
143
+ hasExclusions = true;
144
+ const merged = mergeCommaSeparatedValues(value, DEFAULT_SONAR_EXCLUSIONS);
145
+ if (merged === value.trim()) return line;
146
+ changed = true;
147
+ return `${indent}sonar.exclusions=${merged}`;
148
+ }
149
+
150
+ return line;
151
+ });
152
+
153
+ if (!hasSources) {
154
+ updatedLines.push("sonar.sources=.");
155
+ changed = true;
156
+ }
157
+ if (!hasExclusions) {
158
+ updatedLines.push(`sonar.exclusions=${DEFAULT_SONAR_EXCLUSIONS.join(",")}`);
159
+ changed = true;
160
+ }
161
+
162
+ if (!changed) {
163
+ return "unchanged";
164
+ }
165
+
166
+ const normalized = updatedLines.join("\n").replace(/\n*$/, "\n");
167
+ await writeFile(path, normalized, "utf8");
168
+ return existing.includes("sonar.sources") || existing.includes("sonar.exclusions") ? "updated" : "created";
169
+ }
170
+
171
+ // ── Config load/save ────────────────────────────────────────────────────────
172
+
173
+ export async function loadProjectConfig(baseDir: string): Promise<SonarInitConfig | undefined> {
174
+ return readOptionalJson<SonarInitConfig>(projectConfigPath(baseDir));
175
+ }
176
+
177
+ export async function saveProjectConfig(baseDir: string, config: SonarInitConfig): Promise<void> {
178
+ const dir = sonarqubeConfigDir(baseDir);
179
+ await mkdir(dir, { recursive: true });
180
+ await writeFile(projectConfigPath(baseDir), JSON.stringify(config, null, 2) + "\n", "utf8");
181
+ }
182
+
183
+ // ── Workspace registry ──────────────────────────────────────────────────────
184
+
185
+ const WORKSPACE_REGISTRY_FILE = "sonarqube.workspaces.json";
186
+
187
+ export function workspaceRegistryPath(repoRoot: string): string {
188
+ return resolve(repoRoot, CONFIG_DIR_NAME, WORKSPACE_REGISTRY_FILE);
189
+ }
190
+
191
+ async function hasGitRootMarker(dir: string): Promise<boolean> {
192
+ try {
193
+ const entry = await stat(resolve(dir, ".git"));
194
+ return entry.isDirectory() || entry.isFile();
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ export async function findRepoRoot(startDir: string): Promise<string> {
201
+ let dir = resolve(startDir);
202
+ for (;;) {
203
+ if (await hasGitRootMarker(dir)) return dir;
204
+ const parent = dirname(dir);
205
+ if (parent === dir) return resolve(startDir);
206
+ dir = parent;
207
+ }
208
+ }
209
+
210
+ export async function loadWorkspaceRegistry(
211
+ startDir: string,
212
+ ): Promise<{ repoRoot: string; registry: SonarWorkspaceRegistry }> {
213
+ const repoRoot = await findRepoRoot(startDir);
214
+ const raw = await readOptionalJson<Partial<SonarWorkspaceRegistry>>(workspaceRegistryPath(repoRoot));
215
+ const workspaces = raw?.workspaces && typeof raw.workspaces === "object" ? { ...raw.workspaces } : {};
216
+ return { repoRoot, registry: { version: 1, workspaces } };
217
+ }
218
+
219
+ export async function saveWorkspaceRegistry(startDir: string, alias: string, targetDir: string): Promise<void> {
220
+ const { repoRoot, registry } = await loadWorkspaceRegistry(startDir);
221
+ registry.workspaces[alias] = relative(repoRoot, targetDir) || ".";
222
+ await mkdir(sonarqubeConfigDir(repoRoot), { recursive: true });
223
+ await writeFile(workspaceRegistryPath(repoRoot), JSON.stringify(registry, null, 2) + "\n", "utf8");
224
+ }
225
+
226
+ export function looksLikePath(token: string): boolean {
227
+ return (
228
+ token === "." ||
229
+ token === ".." ||
230
+ token.startsWith("./") ||
231
+ token.startsWith("../") ||
232
+ token.startsWith("/") ||
233
+ token.startsWith("~") ||
234
+ token.includes("/") ||
235
+ token.includes("\\")
236
+ );
237
+ }
238
+
239
+ export function knownTargets(registry: SonarWorkspaceRegistry): string[] {
240
+ return Object.keys(registry.workspaces).sort((left, right) => left.localeCompare(right));
241
+ }
242
+
243
+ export async function resolveTarget(ctx: { cwd: string }, targetInput?: string): Promise<ResolvedTarget> {
244
+ const { repoRoot, registry } = await loadWorkspaceRegistry(ctx.cwd);
245
+ if (!targetInput) {
246
+ return { baseDir: ctx.cwd, repoRoot };
247
+ }
248
+
249
+ const aliasTarget = registry.workspaces[targetInput];
250
+ if (aliasTarget) {
251
+ const baseDir = resolve(repoRoot, aliasTarget);
252
+ const baseDirStat = await stat(baseDir).catch(() => undefined);
253
+ if (!baseDirStat?.isDirectory()) {
254
+ throw new Error(`SonarQube target "${targetInput}" points to a missing directory: ${baseDir}`);
255
+ }
256
+ return { baseDir, repoRoot, alias: targetInput };
257
+ }
258
+
259
+ if (looksLikePath(targetInput)) {
260
+ const baseDir = normalizePath(targetInput, ctx.cwd);
261
+ const baseDirStat = await stat(baseDir).catch(() => undefined);
262
+ if (!baseDirStat?.isDirectory()) {
263
+ throw new Error(`SonarQube target path not found: ${baseDir}`);
264
+ }
265
+ return { baseDir, repoRoot };
266
+ }
267
+
268
+ const known = knownTargets(registry);
269
+ if (known.length > 0) {
270
+ throw new Error(
271
+ `Unknown SonarQube target "${targetInput}". Known targets: ${known.join(", ")}. Use /sonarqube init <alias> <path> to add one.`,
272
+ );
273
+ }
274
+ throw new Error(`Unknown SonarQube target "${targetInput}". Use /sonarqube init <alias> <path> to add one.`);
275
+ }
276
+
277
+ export async function resolveInitTarget(
278
+ ctx: { cwd: string },
279
+ alias?: string,
280
+ targetInput?: string,
281
+ ): Promise<ResolvedTarget> {
282
+ if (targetInput) {
283
+ const resolved = await resolveTarget(ctx, targetInput);
284
+ return { ...resolved, alias };
285
+ }
286
+
287
+ if (alias) {
288
+ const { repoRoot, registry } = await loadWorkspaceRegistry(ctx.cwd);
289
+ const aliasTarget = registry.workspaces[alias];
290
+ if (aliasTarget) {
291
+ return { baseDir: resolve(repoRoot, aliasTarget), repoRoot, alias };
292
+ }
293
+ }
294
+
295
+ return { baseDir: ctx.cwd, repoRoot: await findRepoRoot(ctx.cwd) };
296
+ }
297
+
298
+ // ── Config resolution ───────────────────────────────────────────────────────
299
+
300
+ interface ResolveCtx {
301
+ cwd: string;
302
+ signal?: AbortSignal;
303
+ }
304
+
305
+ export async function resolveConfig(ctx: ResolveCtx, inputPath?: string): Promise<SonarProjectConfig> {
306
+ const { baseDir } = await resolveTarget(ctx, inputPath);
307
+ const propertiesPath = resolve(baseDir, "sonar-project.properties");
308
+ const propertiesText = await readOptionalText(propertiesPath);
309
+ const props = propertiesText ? parseProperties(propertiesText) : {};
310
+ const projectCfg = await loadProjectConfig(baseDir);
311
+
312
+ const serverUrl = normalizeServerUrl(
313
+ process.env.SONARQUBE_URL ??
314
+ process.env.SONAR_HOST_URL ??
315
+ projectCfg?.serverUrl?.trim() ??
316
+ props["sonar.host.url"],
317
+ );
318
+ const projectKey =
319
+ process.env.SONAR_PROJECT_KEY?.trim() ||
320
+ projectCfg?.projectKey?.trim() ||
321
+ props["sonar.projectKey"]?.trim() ||
322
+ resolveProjectKey(baseDir, props);
323
+ const token =
324
+ process.env.SONARQUBE_TOKEN?.trim() ||
325
+ process.env.SONAR_TOKEN?.trim() ||
326
+ projectCfg?.token?.trim() ||
327
+ props["sonar.token"]?.trim();
328
+
329
+ return {
330
+ baseDir,
331
+ serverUrl,
332
+ projectKey,
333
+ token: token || undefined,
334
+ hasProperties: Boolean(propertiesText),
335
+ };
336
+ }