@jonathan0823/pi-sonarqube 0.2.0 → 0.3.2
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/README.md +25 -5
- package/dist/api.d.ts +7 -4
- package/dist/api.d.ts.map +1 -1
- package/dist/api.js +153 -54
- package/dist/api.js.map +1 -1
- package/dist/commands.d.ts +4 -2
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +57 -22
- package/dist/commands.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +71 -28
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +13 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -1
- package/dist/ui.d.ts +2 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +133 -93
- package/dist/ui.js.map +1 -1
- package/package.json +7 -1
- package/src/api.ts +171 -59
- package/src/commands.ts +67 -21
- package/src/index.ts +80 -29
- package/src/types.ts +14 -0
- package/src/ui.ts +172 -130
package/src/index.ts
CHANGED
|
@@ -33,10 +33,13 @@ import {
|
|
|
33
33
|
fetchIssues,
|
|
34
34
|
fetchDuplicationMeasures,
|
|
35
35
|
fetchIssueSeverityCounts,
|
|
36
|
+
fetchIssueQualityCounts,
|
|
36
37
|
fetchFileDuplications,
|
|
37
38
|
fetchFileDuplicationBlocks,
|
|
38
39
|
createAnalysisState,
|
|
39
40
|
issueFilterLabel,
|
|
41
|
+
fetchCleanCodeMode,
|
|
42
|
+
assertFiltersNotAmbiguous,
|
|
40
43
|
} from "./api.js";
|
|
41
44
|
import {
|
|
42
45
|
STATE_TYPE,
|
|
@@ -48,7 +51,6 @@ import {
|
|
|
48
51
|
formatSummary,
|
|
49
52
|
formatMetricsOutput,
|
|
50
53
|
formatDuplicationsList,
|
|
51
|
-
formatDuplicationBlockDetail,
|
|
52
54
|
formatReport,
|
|
53
55
|
helpText,
|
|
54
56
|
targetLabel,
|
|
@@ -59,6 +61,7 @@ import {
|
|
|
59
61
|
startAnalysisUi,
|
|
60
62
|
showIssueBrowser,
|
|
61
63
|
buildIssuePreview,
|
|
64
|
+
buildDuplicationPreview,
|
|
62
65
|
openIssuePreview,
|
|
63
66
|
resolveTargetState,
|
|
64
67
|
showDuplicationBrowser,
|
|
@@ -76,6 +79,12 @@ const SonarToolParams = Type.Object({
|
|
|
76
79
|
),
|
|
77
80
|
types: Type.Optional(Type.Array(Type.String({ description: "Issue type to fetch (e.g. BUG)" }))),
|
|
78
81
|
rules: Type.Optional(Type.Array(Type.String({ description: "Rule keys to fetch" }))),
|
|
82
|
+
softwareQualities: Type.Optional(
|
|
83
|
+
Type.Array(Type.String({ description: "Software quality to fetch (e.g. MAINTAINABILITY)" })),
|
|
84
|
+
),
|
|
85
|
+
impactSeverities: Type.Optional(
|
|
86
|
+
Type.Array(Type.String({ description: "Impact severity to fetch (e.g. HIGH)" })),
|
|
87
|
+
),
|
|
79
88
|
});
|
|
80
89
|
|
|
81
90
|
// ── State restoration ───────────────────────────────────────────────────────
|
|
@@ -144,12 +153,16 @@ async function analyzeProject(
|
|
|
144
153
|
ctx.signal,
|
|
145
154
|
);
|
|
146
155
|
|
|
156
|
+
analysisUi.setPhase("Detecting mode...");
|
|
157
|
+
const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
|
|
158
|
+
|
|
147
159
|
const state = createAnalysisState(config, issues, {
|
|
148
160
|
dashboardUrl,
|
|
149
161
|
ceTaskUrl,
|
|
150
162
|
analysisId,
|
|
151
163
|
filters: normalizedFilters,
|
|
152
164
|
measures,
|
|
165
|
+
cleanCodeMode,
|
|
153
166
|
});
|
|
154
167
|
|
|
155
168
|
pi.appendEntry(STATE_TYPE, state);
|
|
@@ -324,7 +337,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
324
337
|
|
|
325
338
|
const argumentText = match[1] ?? "";
|
|
326
339
|
const { current: currentToken } = splitSonarArgumentContext(argumentText);
|
|
327
|
-
const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues) ?? [];
|
|
340
|
+
const extraItems = sonarArgumentCompletions(argumentText, latestState?.issues, latestState?.cleanCodeMode) ?? [];
|
|
328
341
|
const currentSuggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
329
342
|
const merged = mergeAutocompleteSuggestions(currentToken, currentSuggestions, extraItems);
|
|
330
343
|
return merged ?? currentSuggestions;
|
|
@@ -352,7 +365,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
352
365
|
promptSnippet: "Run local SonarQube analysis or inspect issue results",
|
|
353
366
|
promptGuidelines: [
|
|
354
367
|
"Use sonarqube when the user asks to run a local SonarQube scan, inspect issues, or open an issue's source location.",
|
|
355
|
-
"Use the optional severity, status, type, and
|
|
368
|
+
"Use the optional severity, status, type, rule, quality, and impact severity filters to fetch only the most relevant issues.",
|
|
356
369
|
],
|
|
357
370
|
parameters: SonarToolParams,
|
|
358
371
|
|
|
@@ -363,7 +376,10 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
363
376
|
statuses: params.statuses,
|
|
364
377
|
types: params.types,
|
|
365
378
|
rules: params.rules,
|
|
379
|
+
softwareQualities: params.softwareQualities,
|
|
380
|
+
impactSeverities: params.impactSeverities,
|
|
366
381
|
});
|
|
382
|
+
assertFiltersNotAmbiguous(filters);
|
|
367
383
|
|
|
368
384
|
if (params.action === "analyze") {
|
|
369
385
|
const state = await analyzeProject(pi, ctx, params.path, filters);
|
|
@@ -414,6 +430,8 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
414
430
|
statuses: args.statuses,
|
|
415
431
|
types: args.types,
|
|
416
432
|
rules: args.rules,
|
|
433
|
+
softwareQualities: args.softwareQualities,
|
|
434
|
+
impactSeverities: args.impactSeverities,
|
|
417
435
|
});
|
|
418
436
|
const filterText = filters ? ` ${theme.fg("muted", issueFilterLabel(filters))}` : "";
|
|
419
437
|
return new Text(
|
|
@@ -457,7 +475,7 @@ export default function sonarqube(pi: ExtensionAPI) {
|
|
|
457
475
|
|
|
458
476
|
pi.registerCommand("sonarqube", {
|
|
459
477
|
description: "Run a local SonarQube analysis, browse the latest issues, or init project config",
|
|
460
|
-
getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues),
|
|
478
|
+
getArgumentCompletions: (argumentPrefix) => sonarArgumentCompletions(argumentPrefix, latestState?.issues, latestState?.cleanCodeMode),
|
|
461
479
|
handler: async (args, ctx) => {
|
|
462
480
|
try {
|
|
463
481
|
const parsed = parseCommandArgs(args);
|
|
@@ -515,17 +533,19 @@ async function commandMetrics(
|
|
|
515
533
|
targetInput?: string,
|
|
516
534
|
): Promise<void> {
|
|
517
535
|
const config = await resolveConfig(ctx, targetInput);
|
|
518
|
-
const
|
|
536
|
+
const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
|
|
537
|
+
const [measures, issueCounts, qualityCounts] = await Promise.all([
|
|
519
538
|
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
520
|
-
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
539
|
+
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
|
|
540
|
+
fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
521
541
|
]);
|
|
522
|
-
if (!measures && !issueCounts) {
|
|
542
|
+
if (!measures && !issueCounts && !qualityCounts) {
|
|
523
543
|
if (ctx.hasUI) {
|
|
524
544
|
ctx.ui.notify(`Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.`, "warning");
|
|
525
545
|
}
|
|
526
546
|
return;
|
|
527
547
|
}
|
|
528
|
-
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
|
|
548
|
+
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
|
|
529
549
|
if (ctx.hasUI) {
|
|
530
550
|
ctx.ui.notify(text, "info");
|
|
531
551
|
}
|
|
@@ -552,18 +572,7 @@ async function commandDuplications(
|
|
|
552
572
|
throw error;
|
|
553
573
|
}
|
|
554
574
|
if (files.length === 0) {
|
|
555
|
-
|
|
556
|
-
const projectMeasures = await fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal).catch(() => undefined);
|
|
557
|
-
if (projectMeasures && projectMeasures.duplicatedFiles > 0) {
|
|
558
|
-
if (ctx.hasUI) {
|
|
559
|
-
ctx.ui.notify(
|
|
560
|
-
`Cannot list individual files — your SonarQube token lacks "See Source Code" permission. Grant it in SonarQube project permissions, then run /sonarqube init to update the token.`,
|
|
561
|
-
"warning",
|
|
562
|
-
);
|
|
563
|
-
}
|
|
564
|
-
} else if (ctx.hasUI) {
|
|
565
|
-
ctx.ui.notify("No duplications found.", "info");
|
|
566
|
-
}
|
|
575
|
+
if (ctx.hasUI) ctx.ui.notify("No duplications found.", "info");
|
|
567
576
|
return;
|
|
568
577
|
}
|
|
569
578
|
|
|
@@ -586,7 +595,7 @@ async function showDuplicationDrillDown(
|
|
|
586
595
|
return;
|
|
587
596
|
}
|
|
588
597
|
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
589
|
-
const text =
|
|
598
|
+
const text = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
|
|
590
599
|
if (ctx.mode === "tui") {
|
|
591
600
|
await ctx.ui.editor(`Duplications in ${file.filePath}`, text);
|
|
592
601
|
} else if (ctx.hasUI) {
|
|
@@ -607,7 +616,7 @@ async function showDuplicationListOrBrowser(
|
|
|
607
616
|
if (choice == null) return;
|
|
608
617
|
const file = files[choice];
|
|
609
618
|
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
610
|
-
await ctx.ui.editor(`Duplications in ${file.filePath}`,
|
|
619
|
+
await ctx.ui.editor(`Duplications in ${file.filePath}`, await buildDuplicationPreview(config.baseDir, file.filePath, groups));
|
|
611
620
|
}
|
|
612
621
|
|
|
613
622
|
async function commandIssuesOrOpen(
|
|
@@ -693,8 +702,47 @@ async function toolDuplications(ctx: any, path: string | undefined): Promise<{ c
|
|
|
693
702
|
details: { error: `No duplications found for ${config.projectKey}.` },
|
|
694
703
|
};
|
|
695
704
|
}
|
|
696
|
-
|
|
697
|
-
|
|
705
|
+
|
|
706
|
+
const topFiles = files.slice(0, 5);
|
|
707
|
+
const topContexts = await Promise.all(
|
|
708
|
+
topFiles.map(async (file) => {
|
|
709
|
+
try {
|
|
710
|
+
const groups = await fetchFileDuplicationBlocks(config.serverUrl, config.token, file.fileKey, config.projectKey, ctx.signal);
|
|
711
|
+
const preview = await buildDuplicationPreview(config.baseDir, file.filePath, groups);
|
|
712
|
+
return {
|
|
713
|
+
filePath: file.filePath,
|
|
714
|
+
duplicatedBlocks: file.duplicatedBlocks,
|
|
715
|
+
duplicatedLines: file.duplicatedLines,
|
|
716
|
+
preview,
|
|
717
|
+
};
|
|
718
|
+
} catch (error) {
|
|
719
|
+
return {
|
|
720
|
+
filePath: file.filePath,
|
|
721
|
+
duplicatedBlocks: file.duplicatedBlocks,
|
|
722
|
+
duplicatedLines: file.duplicatedLines,
|
|
723
|
+
preview: `Duplications in ${file.filePath}\n\n<failed to load duplicated lines: ${sonarErrorMessage(error)}>`,
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
}),
|
|
727
|
+
);
|
|
728
|
+
const remaining = files.length - topContexts.length;
|
|
729
|
+
const content: Array<{ type: "text"; text: string }> = [
|
|
730
|
+
{ type: "text", text: `Duplicated files for ${config.projectKey}: ${files.length}. Showing top ${topContexts.length}.` },
|
|
731
|
+
...topContexts.map((item, index) => ({
|
|
732
|
+
type: "text" as const,
|
|
733
|
+
text: `#${index + 1} ${item.filePath} blocks=${item.duplicatedBlocks} lines=${item.duplicatedLines}\n${item.preview}`,
|
|
734
|
+
})),
|
|
735
|
+
...(remaining > 0 ? [{ type: "text" as const, text: `+ ${remaining} more file(s) not shown` }] : []),
|
|
736
|
+
];
|
|
737
|
+
return {
|
|
738
|
+
content,
|
|
739
|
+
details: {
|
|
740
|
+
projectKey: config.projectKey,
|
|
741
|
+
totalFiles: files.length,
|
|
742
|
+
topFiles: topContexts,
|
|
743
|
+
moreFiles: remaining,
|
|
744
|
+
},
|
|
745
|
+
};
|
|
698
746
|
}
|
|
699
747
|
|
|
700
748
|
async function toolMetrics(ctx: any, path: string | undefined): Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> }> {
|
|
@@ -707,18 +755,20 @@ async function toolMetrics(ctx: any, path: string | undefined): Promise<{ conten
|
|
|
707
755
|
details: { error: "Project not configured." },
|
|
708
756
|
};
|
|
709
757
|
}
|
|
710
|
-
const
|
|
758
|
+
const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
|
|
759
|
+
const [measures, issueCounts, qualityCounts] = await Promise.all([
|
|
711
760
|
fetchDuplicationMeasures(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
712
|
-
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
761
|
+
fetchIssueSeverityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal, cleanCodeMode),
|
|
762
|
+
fetchIssueQualityCounts(config.serverUrl, config.token, config.projectKey, ctx.signal),
|
|
713
763
|
]);
|
|
714
|
-
if (!measures && !issueCounts) {
|
|
764
|
+
if (!measures && !issueCounts && !qualityCounts) {
|
|
715
765
|
return {
|
|
716
766
|
content: [{ type: "text", text: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` }],
|
|
717
767
|
details: { error: `Project "${config.projectKey}" has not been analyzed yet. Run /sonarqube analyze first.` },
|
|
718
768
|
};
|
|
719
769
|
}
|
|
720
|
-
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts });
|
|
721
|
-
return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts } };
|
|
770
|
+
const text = formatMetricsOutput({ projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode });
|
|
771
|
+
return { content: [{ type: "text", text }], details: { projectKey: config.projectKey, measures, issueCounts, issueQualityCounts: qualityCounts, cleanCodeMode } };
|
|
722
772
|
}
|
|
723
773
|
|
|
724
774
|
// ── Re-exports for public API ───────────────────────────────────────────────
|
|
@@ -730,6 +780,7 @@ export type {
|
|
|
730
780
|
SonarProjectConfig,
|
|
731
781
|
SonarIssueFetchOptions,
|
|
732
782
|
SonarInitConfig,
|
|
783
|
+
IssueQualityCounts,
|
|
733
784
|
} from "./types.js";
|
|
734
785
|
|
|
735
786
|
export { projectConfigPath, loadProjectConfig, saveProjectConfig } from "./config.js";
|
package/src/types.ts
CHANGED
|
@@ -18,6 +18,9 @@ export interface SonarDuplicationMeasures {
|
|
|
18
18
|
duplicatedLines: number;
|
|
19
19
|
duplicatedBlocks: number;
|
|
20
20
|
duplicatedFiles: number;
|
|
21
|
+
coverage?: number;
|
|
22
|
+
linesToCover?: number;
|
|
23
|
+
uncoveredLines?: number;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export interface IssueSeverityCounts {
|
|
@@ -28,6 +31,12 @@ export interface IssueSeverityCounts {
|
|
|
28
31
|
info: number;
|
|
29
32
|
}
|
|
30
33
|
|
|
34
|
+
export interface IssueQualityCounts {
|
|
35
|
+
maintainability: number;
|
|
36
|
+
reliability: number;
|
|
37
|
+
security: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
export interface FileDuplication {
|
|
32
41
|
filePath: string;
|
|
33
42
|
fileKey: string;
|
|
@@ -58,6 +67,7 @@ export interface SonarAnalysisState {
|
|
|
58
67
|
measures?: SonarDuplicationMeasures;
|
|
59
68
|
totalIssues: number;
|
|
60
69
|
issues: SonarIssue[];
|
|
70
|
+
cleanCodeMode?: "STANDARD" | "MQR";
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export interface SonarProjectConfig {
|
|
@@ -73,6 +83,8 @@ export interface SonarIssueFetchOptions {
|
|
|
73
83
|
statuses?: string[];
|
|
74
84
|
types?: string[];
|
|
75
85
|
rules?: string[];
|
|
86
|
+
softwareQualities?: string[];
|
|
87
|
+
impactSeverities?: string[];
|
|
76
88
|
}
|
|
77
89
|
|
|
78
90
|
export interface SonarInitConfig {
|
|
@@ -110,3 +122,5 @@ export interface InitCommandOptions {
|
|
|
110
122
|
export const SONAR_SEVERITIES = ["BLOCKER", "CRITICAL", "MAJOR", "MINOR", "INFO"] as const;
|
|
111
123
|
export const SONAR_STATUSES = ["OPEN", "CONFIRMED", "REOPENED", "RESOLVED", "CLOSED"] as const;
|
|
112
124
|
export const SONAR_TYPES = ["BUG", "VULNERABILITY", "CODE_SMELL"] as const;
|
|
125
|
+
export const SONAR_SOFTWARE_QUALITIES = ["MAINTAINABILITY", "RELIABILITY", "SECURITY"] as const;
|
|
126
|
+
export const SONAR_IMPACT_SEVERITIES = ["BLOCKER", "HIGH", "MEDIUM", "LOW", "INFO"] as const;
|
package/src/ui.ts
CHANGED
|
@@ -2,8 +2,8 @@ import type { Theme, ExtensionContext, ExtensionCommandContext } from "@earendil
|
|
|
2
2
|
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
|
-
import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication } from "./types.js";
|
|
6
|
-
import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters } from "./api.js";
|
|
5
|
+
import type { SonarIssue, SonarAnalysisState, SonarIssueFetchOptions, FileDuplication, DuplicationBlockGroup } from "./types.js";
|
|
6
|
+
import { issueFilterLabel, fetchIssues, createAnalysisState, normalizeIssueFilters, fetchCleanCodeMode } from "./api.js";
|
|
7
7
|
import { resolveConfig, resolveTarget } from "./config.js";
|
|
8
8
|
|
|
9
9
|
// ── Issue preview ───────────────────────────────────────────────────────────
|
|
@@ -26,6 +26,54 @@ export async function buildIssuePreview(baseDir: string, issue: SonarIssue, radi
|
|
|
26
26
|
return rendered.join("\n");
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
export async function buildDuplicationPreview(
|
|
30
|
+
baseDir: string,
|
|
31
|
+
filePath: string,
|
|
32
|
+
groups: DuplicationBlockGroup[],
|
|
33
|
+
): Promise<string> {
|
|
34
|
+
const cache = new Map<string, string>();
|
|
35
|
+
const loadLines = async (relativePath: string): Promise<string[]> => {
|
|
36
|
+
const absolutePath = resolve(baseDir, relativePath);
|
|
37
|
+
const cached = cache.get(absolutePath);
|
|
38
|
+
if (cached !== undefined) return cached.split(/\r?\n/);
|
|
39
|
+
const content = await readFile(absolutePath, "utf8");
|
|
40
|
+
cache.set(absolutePath, content);
|
|
41
|
+
return content.split(/\r?\n/);
|
|
42
|
+
};
|
|
43
|
+
const renderRange = (lines: string[], from: number, size: number): string[] => {
|
|
44
|
+
const start = Math.max(1, from);
|
|
45
|
+
const end = Math.min(lines.length, from + size - 1);
|
|
46
|
+
const width = String(end).length;
|
|
47
|
+
const rendered: string[] = [];
|
|
48
|
+
for (let line = start; line <= end; line++) {
|
|
49
|
+
const num = String(line).padStart(width, " ");
|
|
50
|
+
rendered.push(`${num} | ${lines[line - 1] ?? ""}`);
|
|
51
|
+
}
|
|
52
|
+
return rendered;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const rendered: string[] = [`Duplications in ${filePath}`];
|
|
56
|
+
for (const [groupIndex, group] of groups.entries()) {
|
|
57
|
+
rendered.push("", `Block ${groupIndex + 1}:`);
|
|
58
|
+
for (const block of group.blocks) {
|
|
59
|
+
try {
|
|
60
|
+
const lines = await loadLines(block.filePath);
|
|
61
|
+
const end = block.from + block.size - 1;
|
|
62
|
+
rendered.push(` ${block.filePath}:${block.from}-${end}`);
|
|
63
|
+
for (const line of renderRange(lines, block.from, block.size)) {
|
|
64
|
+
rendered.push(` ${line}`);
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
rendered.push(
|
|
68
|
+
` ${block.filePath}:${block.from}-${block.from + block.size - 1}`,
|
|
69
|
+
" <unable to read duplicated lines>",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return rendered.join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
29
77
|
// ── Analysis UI ─────────────────────────────────────────────────────────────
|
|
30
78
|
|
|
31
79
|
const ANALYSIS_SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
@@ -81,95 +129,35 @@ export class IssueBrowser {
|
|
|
81
129
|
invalidate(): void {} // NOSONAR - required by TUI interface
|
|
82
130
|
|
|
83
131
|
handleInput(data: string): void {
|
|
84
|
-
|
|
85
|
-
this.done(null);
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
if (matchesKey(data, Key.up)) {
|
|
89
|
-
this.selected = Math.max(0, this.selected - 1);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
if (matchesKey(data, Key.down)) {
|
|
93
|
-
this.selected = Math.min(this.state.issues.length - 1, this.selected + 1);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
if (matchesKey(data, Key.enter)) {
|
|
97
|
-
this.done(this.selected);
|
|
98
|
-
}
|
|
132
|
+
this.selected = handleListBrowserInput(data, this.selected, this.state.issues.length, this.done);
|
|
99
133
|
}
|
|
100
134
|
|
|
101
135
|
render(width: number): string[] {
|
|
102
|
-
const lines: string[] = [];
|
|
103
|
-
const title = this.theme.fg("accent", this.theme.bold(" SonarQube Issues "));
|
|
104
136
|
const filterSuffix = this.state.filters ? ` • ${issueFilterLabel(this.state.filters)}` : "";
|
|
105
|
-
|
|
106
|
-
"
|
|
107
|
-
`${this.state.projectKey} • ${this.state.totalIssues} issue(s)${filterSuffix}`,
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"",
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
),
|
|
130
|
-
);
|
|
131
|
-
return lines;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (start > 0) {
|
|
135
|
-
lines.push(truncateToWidth(this.theme.fg("dim", `... ${start} more above`), width));
|
|
136
|
-
}
|
|
137
|
-
for (const [offset, issue] of visibleIssues.entries()) {
|
|
138
|
-
const issueIndex = start + offset;
|
|
139
|
-
const isSelected = issueIndex === this.selected;
|
|
140
|
-
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
141
|
-
const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
142
|
-
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
143
|
-
const severity = severityColor(this.theme, issue.severity);
|
|
144
|
-
const summary = [
|
|
145
|
-
marker,
|
|
146
|
-
String(issueIndex + 1).padStart(2, " "),
|
|
147
|
-
".",
|
|
148
|
-
severity,
|
|
149
|
-
this.theme.fg("accent", location),
|
|
150
|
-
this.theme.fg("muted", rule),
|
|
151
|
-
this.theme.fg("text", `— ${issue.message}`),
|
|
152
|
-
].join(" ");
|
|
153
|
-
lines.push(truncateToWidth(summary, width));
|
|
154
|
-
}
|
|
155
|
-
if (end < this.state.issues.length) {
|
|
156
|
-
lines.push(
|
|
157
|
-
truncateToWidth(
|
|
158
|
-
this.theme.fg("dim", `... ${this.state.issues.length - end} more below`),
|
|
159
|
-
width,
|
|
160
|
-
),
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
lines.push(
|
|
165
|
-
"",
|
|
166
|
-
truncateToWidth(
|
|
167
|
-
this.theme.fg("dim", "Up/Down to move, Enter to preview, Esc to close"),
|
|
168
|
-
width,
|
|
169
|
-
),
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
return lines;
|
|
137
|
+
return renderListBrowser(this.theme, width, {
|
|
138
|
+
title: "SonarQube Issues",
|
|
139
|
+
subtitle: `${this.state.projectKey} • ${this.state.totalIssues} issue(s)${filterSuffix}`,
|
|
140
|
+
items: this.state.issues,
|
|
141
|
+
selected: this.selected,
|
|
142
|
+
pageSize: 20,
|
|
143
|
+
emptyMessage: "No open issues found.",
|
|
144
|
+
footer: "Up/Down to move, Enter to preview, Esc to close",
|
|
145
|
+
renderItem: (issue, issueIndex, isSelected) => {
|
|
146
|
+
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
147
|
+
const location = issue.line ? `${issue.filePath}:${issue.line}` : issue.filePath;
|
|
148
|
+
const rule = issue.ruleName ? `${issue.rule} (${issue.ruleName})` : issue.rule;
|
|
149
|
+
const severity = severityColor(this.theme, issue.severity);
|
|
150
|
+
return [
|
|
151
|
+
marker,
|
|
152
|
+
String(issueIndex + 1).padStart(2, " "),
|
|
153
|
+
".",
|
|
154
|
+
severity,
|
|
155
|
+
this.theme.fg("accent", location),
|
|
156
|
+
this.theme.fg("muted", rule),
|
|
157
|
+
this.theme.fg("text", `— ${issue.message}`),
|
|
158
|
+
].join(" ");
|
|
159
|
+
},
|
|
160
|
+
});
|
|
173
161
|
}
|
|
174
162
|
}
|
|
175
163
|
|
|
@@ -189,6 +177,86 @@ function severityColor(theme: Theme, severity: string): string {
|
|
|
189
177
|
}
|
|
190
178
|
}
|
|
191
179
|
|
|
180
|
+
function handleListBrowserInput(
|
|
181
|
+
data: string,
|
|
182
|
+
selected: number,
|
|
183
|
+
count: number,
|
|
184
|
+
done: (result: number | null) => void,
|
|
185
|
+
): number {
|
|
186
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, "ctrl+c")) {
|
|
187
|
+
done(null);
|
|
188
|
+
return selected;
|
|
189
|
+
}
|
|
190
|
+
if (matchesKey(data, Key.up)) {
|
|
191
|
+
return Math.max(0, selected - 1);
|
|
192
|
+
}
|
|
193
|
+
if (matchesKey(data, Key.down)) {
|
|
194
|
+
return Math.min(Math.max(0, count - 1), selected + 1);
|
|
195
|
+
}
|
|
196
|
+
if (matchesKey(data, Key.enter)) {
|
|
197
|
+
done(selected);
|
|
198
|
+
}
|
|
199
|
+
return selected;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function getBrowserWindow(selected: number, total: number, pageSize: number): { start: number; end: number } {
|
|
203
|
+
const halfWindow = Math.floor(pageSize / 2);
|
|
204
|
+
const maxStart = Math.max(0, total - pageSize);
|
|
205
|
+
const start = Math.max(0, Math.min(selected - halfWindow, maxStart));
|
|
206
|
+
const end = Math.min(total, start + pageSize);
|
|
207
|
+
return { start, end };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface ListBrowserRenderConfig<T> {
|
|
211
|
+
title: string;
|
|
212
|
+
subtitle: string;
|
|
213
|
+
items: readonly T[];
|
|
214
|
+
selected: number;
|
|
215
|
+
pageSize: number;
|
|
216
|
+
emptyMessage: string;
|
|
217
|
+
footer: string;
|
|
218
|
+
renderItem: (item: T, index: number, isSelected: boolean) => string;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function renderListBrowser<T>(
|
|
222
|
+
theme: Theme,
|
|
223
|
+
width: number,
|
|
224
|
+
config: ListBrowserRenderConfig<T>,
|
|
225
|
+
): string[] {
|
|
226
|
+
const lines: string[] = [
|
|
227
|
+
truncateToWidth(theme.fg("accent", theme.bold(` ${config.title} `)), width),
|
|
228
|
+
truncateToWidth(theme.fg("dim", config.subtitle), width),
|
|
229
|
+
"",
|
|
230
|
+
];
|
|
231
|
+
|
|
232
|
+
const pageSize = Math.min(config.pageSize, config.items.length);
|
|
233
|
+
const { start, end } = getBrowserWindow(config.selected, config.items.length, pageSize);
|
|
234
|
+
const visible = config.items.slice(start, end);
|
|
235
|
+
|
|
236
|
+
if (visible.length === 0) {
|
|
237
|
+
lines.push(
|
|
238
|
+
truncateToWidth(theme.fg("success", config.emptyMessage), width),
|
|
239
|
+
"",
|
|
240
|
+
truncateToWidth(theme.fg("dim", config.footer), width),
|
|
241
|
+
);
|
|
242
|
+
return lines;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (start > 0) {
|
|
246
|
+
lines.push(truncateToWidth(theme.fg("dim", `... ${start} more above`), width));
|
|
247
|
+
}
|
|
248
|
+
for (const [offset, item] of visible.entries()) {
|
|
249
|
+
const index = start + offset;
|
|
250
|
+
lines.push(truncateToWidth(config.renderItem(item, index, index === config.selected), width));
|
|
251
|
+
}
|
|
252
|
+
if (end < config.items.length) {
|
|
253
|
+
lines.push(truncateToWidth(theme.fg("dim", `... ${config.items.length - end} more below`), width));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
lines.push("", truncateToWidth(theme.fg("dim", config.footer), width));
|
|
257
|
+
return lines;
|
|
258
|
+
}
|
|
259
|
+
|
|
192
260
|
// ── Duplication browser (used by index.ts) ─────────────────────────────────
|
|
193
261
|
|
|
194
262
|
export class DuplicationBrowser {
|
|
@@ -203,51 +271,24 @@ export class DuplicationBrowser {
|
|
|
203
271
|
invalidate(): void {} // NOSONAR - required by TUI interface
|
|
204
272
|
|
|
205
273
|
handleInput(data: string): void {
|
|
206
|
-
|
|
207
|
-
this.done(null);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
if (matchesKey(data, Key.up)) {
|
|
211
|
-
this.selected = Math.max(0, this.selected - 1);
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (matchesKey(data, Key.down)) {
|
|
215
|
-
this.selected = Math.min(this.files.length - 1, this.selected + 1);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (matchesKey(data, Key.enter)) {
|
|
219
|
-
this.done(this.selected);
|
|
220
|
-
}
|
|
274
|
+
this.selected = handleListBrowserInput(data, this.selected, this.files.length, this.done);
|
|
221
275
|
}
|
|
222
276
|
|
|
223
277
|
render(width: number): string[] {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const index = start + offset;
|
|
239
|
-
const isSelected = index === this.selected;
|
|
240
|
-
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
241
|
-
const detail = `blocks=${file.duplicatedBlocks} lines=${file.duplicatedLines}`;
|
|
242
|
-
const line = `${marker} ${String(index + 1).padStart(2, " ")}. ${this.theme.fg("accent", file.filePath)} ${this.theme.fg("dim", detail)}`;
|
|
243
|
-
lines.push(truncateToWidth(line, width));
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
lines.push(
|
|
247
|
-
"",
|
|
248
|
-
this.theme.fg("dim", "Up/Down to move, Enter for details, Esc to close"),
|
|
249
|
-
);
|
|
250
|
-
return lines;
|
|
278
|
+
return renderListBrowser(this.theme, width, {
|
|
279
|
+
title: "SonarQube Duplications",
|
|
280
|
+
subtitle: `${this.files.length} file(s) with duplicate code`,
|
|
281
|
+
items: this.files,
|
|
282
|
+
selected: this.selected,
|
|
283
|
+
pageSize: 15,
|
|
284
|
+
emptyMessage: "No duplicate code found.",
|
|
285
|
+
footer: "Up/Down to move, Enter for details, Esc to close",
|
|
286
|
+
renderItem: (file, index, isSelected) => {
|
|
287
|
+
const marker = isSelected ? this.theme.fg("accent", ">") : this.theme.fg("dim", " ");
|
|
288
|
+
const detail = `blocks=${file.duplicatedBlocks} lines=${file.duplicatedLines}`;
|
|
289
|
+
return `${marker} ${String(index + 1).padStart(2, " ")}. ${this.theme.fg("accent", file.filePath)} ${this.theme.fg("dim", detail)}`;
|
|
290
|
+
},
|
|
291
|
+
});
|
|
251
292
|
}
|
|
252
293
|
}
|
|
253
294
|
|
|
@@ -300,7 +341,8 @@ export async function loadProjectIssuesFromApi(
|
|
|
300
341
|
ctx.signal,
|
|
301
342
|
normalizedFilters,
|
|
302
343
|
);
|
|
303
|
-
|
|
344
|
+
const cleanCodeMode = await fetchCleanCodeMode(config.serverUrl, config.token, ctx.signal);
|
|
345
|
+
return createAnalysisState(config, issues, { filters: normalizedFilters, cleanCodeMode });
|
|
304
346
|
}
|
|
305
347
|
|
|
306
348
|
export async function resolveTargetState(
|