Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ By default, new commands should register a `authenticatedAction()`, only technic

Please use the exception types defined in `src/cli/commands/_common/error.ts` for production code. If you need to throw an error from a mock in test code, it's fine to use the generic `Error` type.

Error subclasses extend the abstract `CliError` and carry their own `exitCode`, which `SonarCommand.runCommand()` forwards to `process.exitCode`:

- `InvalidOptionError` → exit code `2` (conflicting or invalid CLI options).
- `CommandFailedError` → exit code `1` by default, or whatever is passed to the constructor.
- Any other `Error` caught by `runCommand` → exit code `1`.

## State and auth

- Persistent state (server URL, org, project) is managed via `src/lib/state-manager.ts`.
Expand Down
62 changes: 38 additions & 24 deletions src/cli/command-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ import {
VALID_FORMATS as DEPENDENCY_RISKS_FORMATS,
} from './commands/analyze/dependency-risks';
import { analyzeSecrets, type AnalyzeSecretsOptions } from './commands/analyze/secrets';
import { analyzeSqaa, type AnalyzeSqaaOptions } from './commands/analyze/sqaa';
import {
analyzeSqaa,
type AnalyzeSqaaOptions,
VALID_FORMATS as SQAA_FORMATS,
} from './commands/analyze/sqaa';
import { apiCommand, type ApiCommandOptions, apiExtraHelpText } from './commands/api/api';
import { authLogin, type AuthLoginOptions } from './commands/auth/login';
import { authLogout } from './commands/auth/logout';
Expand Down Expand Up @@ -256,18 +260,27 @@ analyze
analyzeSecrets({ paths: Array.isArray(paths) ? paths : [], stdin: options.stdin }, auth),
);

analyze
.command('sqaa')
.description('Run server-side SonarQube Agentic Analysis on a file (SonarQube Cloud only)')
.requiredOption('--file <file>', 'File path to analyze')
.option('--branch <branch>', 'Branch name for analysis context')
.option(
'-p, --project <project>',
'SonarQube Cloud project key (overrides auto-detected project)',
)
.authenticatedAction((auth, options: AnalyzeSqaaOptions, cmd: Command) =>
analyzeSqaa(options, auth, cmd),
);
// Shared option set for `analyze sqaa` and its `verify` alias.
const sqaaFormatOption = new Option('--format <format>', 'Output format')
.choices(SQAA_FORMATS)
.default('text');

function applySqaaOptions(cmd: SonarCommand): SonarCommand {
return cmd
.option('--file <file>', 'Analyze a single file (skips change set detection)')
.option('--staged', 'Analyze staged files only (git diff --cached)')
.option('--base <ref>', 'Analyze files changed vs a branch or ref (e.g. main)')
.option('--branch <branch>', 'Branch name for analysis context')
.option(
'-p, --project <project>',
'SonarQube Cloud project key (overrides auto-detected project)',
)
.option('--force', 'Skip the large change set confirmation prompt')
.addOption(sqaaFormatOption)
.authenticatedAction((auth, options: AnalyzeSqaaOptions, innerCmd: Command) =>
analyzeSqaa(options, auth, innerCmd),
);
}

const dependencyRisksFormatOption = new Option('--format <format>', 'Output format')
.choices(DEPENDENCY_RISKS_FORMATS)
Expand All @@ -282,17 +295,18 @@ analyze
analyzeDependencyRisks(options, auth),
);

COMMAND_TREE.command('verify')
.description('Analyze a file for issues')
.requiredOption('--file <file>', 'File path to analyze')
.option('--branch <branch>', 'Branch name for analysis context')
.option(
'-p, --project <project>',
'SonarQube Cloud project key (overrides auto-detected project)',
)
.authenticatedAction((auth, options: AnalyzeSqaaOptions, cmd: Command) =>
analyzeSqaa(options, auth, cmd),
);
applySqaaOptions(
analyze
.command('sqaa')
.description('Run server-side SonarQube Agentic Analysis (SonarQube Cloud only)'),
);

// `verify` is a user-facing alias for `analyze sqaa` that fits CI/pipeline vocabulary.
applySqaaOptions(
COMMAND_TREE.command('verify').description(
'Run server-side SonarQube Agentic Analysis on the local change set (alias of `analyze sqaa`, SonarQube Cloud only)',
),
);

// Configure things related to the CLI
const configure = COMMAND_TREE.command('config').description('Configure CLI settings');
Expand Down
16 changes: 13 additions & 3 deletions src/cli/commands/_common/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,20 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

/**
* Base class for all CLI errors that carry an exit code.
* runCommand reads exitCode from any subclass — no instanceof checks needed per type.
*/
export abstract class CliError extends Error {
abstract readonly exitCode: number;
}

/**
* Thrown when the user provides invalid or conflicting command options.
* Always exits with code 2.
*/
export class InvalidOptionError extends Error {
export class InvalidOptionError extends CliError {
readonly exitCode = 2;
constructor(reason: string) {
super(reason);
this.name = 'InvalidOptionError';
Expand All @@ -30,9 +40,9 @@ export class InvalidOptionError extends Error {

/**
* Thrown when the command (and options if any defined) are valid, but it failed to execute.
* An optional exitCode overrides the default exit code of 1 set by runCommand.
* Defaults to exit code 1; pass a custom code when needed.
*/
export class CommandFailedError extends Error {
export class CommandFailedError extends CliError {
readonly exitCode: number;
constructor(message: string, exitCode = 1) {
super(message);
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/_common/sonar-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type { ResolvedAuth } from '../../../lib/auth-resolver.js';
import { resolveAuth } from '../../../lib/auth-resolver.js';
import logger from '../../../lib/logger.js';
import { blank, error } from '../../../ui';
import { CommandFailedError } from './error.js';
import { CliError, CommandFailedError } from './error.js';

/**
* Commander Command subclass for the Sonar CLI.
Expand Down Expand Up @@ -104,7 +104,7 @@ export class SonarCommand extends Command {
blank();
error((err as Error).message);
logger.error((err as Error).message);
process.exitCode = err instanceof CommandFailedError ? err.exitCode : 1;
process.exitCode = err instanceof CliError ? err.exitCode : 1;
}
}
}
157 changes: 157 additions & 0 deletions src/cli/commands/analyze/sqaa-analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
/*
* SonarQube CLI
* Copyright (C) SonarSource Sàrl
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/

// Concurrent execution engine for SQAA change-set analysis

import type { SqaaIssue } from '../../../sonarqube/client';
import type { SqaaProgress } from '../../../ui/components/sqaa-progress.js';
import {
fetchWithRetry,
MAX_503_RETRIES,
readSqaaFileContent,
RETRY_503_BASE_DELAY_MS,
} from './sqaa-api';
import type { CloudAuth } from './sqaa-auth';

/** Maximum number of files analyzed concurrently. */
export const SQAA_CONCURRENCY = 20;

export type FileSuccess = {
file: string;
filePath: string;
issues: SqaaIssue[];
errors?: Array<{ code: string; message: string }> | null;
};
export type FileFailure = { file: string; filePath: string; failure: Error };
export type FileResult = FileSuccess | FileFailure;

export interface RunContext {
files: string[];
allPaths: string[];
cloudAuth: CloudAuth;
projectKey: string;
branch: string | undefined;
progress: SqaaProgress;
/** Directory used as the base for SQAA-side file paths (typically the git repo root). */
pathBase: string;
}

export interface RunTally {
allResults: FileResult[];
totalIssues: number;
totalErrors: number;
totalFailures: number;
}

/**
* Run analyses through a worker pool of `SQAA_CONCURRENCY`. Returns the merged
* tally once every spawned worker has joined.
*
* Fail-fast contract: if any file fails, no worker will pick up a *new* file
* after that point. Workers already mid-flight finish their current file.
*/
export async function runAnalyses(ctx: RunContext): Promise<RunTally> {
const tally: RunTally = { allResults: [], totalIssues: 0, totalErrors: 0, totalFailures: 0 };
if (ctx.files.length === 0) return tally;

// Shared cursor and fail-fast flag.
// `next` is the count of indices claimed so far (each worker does `idx = next++`).
// `hadFailure` is the fail-fast signal: set when any file fails.
let next = 0;
let hadFailure = false;

const worker = async (): Promise<void> => {
while (!hadFailure) {
const idx = next++;
if (idx >= ctx.files.length) return;
const result = await processFile(ctx, idx);
tally.allResults.push(result);
tallyResults([result], tally);
if ('failure' in result) {
hadFailure = true;
}
}
};

const workerCount = Math.min(SQAA_CONCURRENCY, ctx.files.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));

// `next` equals the total number of indices claimed across all workers (including
// any that went past the end of the array). Cap it to get the first unclaimed index.
const firstUnpicked = Math.min(next, ctx.files.length);
if (firstUnpicked < ctx.files.length) {
ctx.progress.skipRemaining(firstUnpicked);
}

// Workers complete in arbitrary order. Restore original file ordering so that
// downstream consumers (JSON report, text display) see a stable, predictable sequence.
const fileIndexMap = new Map(ctx.files.map((f, i) => [f, i]));
tally.allResults.sort(
(a, b) => (fileIndexMap.get(a.file) ?? 0) - (fileIndexMap.get(b.file) ?? 0),
);

return tally;
}

/**
* Process a single file end-to-end: read content, call the API with 503 retry,
* and emit progress transitions. Errors (including retry exhaustion) are caught and converted into a `FileFailure`.
*/
async function processFile(ctx: RunContext, idx: number): Promise<FileResult> {
const file = ctx.files[idx];
const filePath = ctx.allPaths[idx];
ctx.progress.update(idx, 'analyzing');
try {
const fileContent = readSqaaFileContent(file);
const response = await fetchWithRetry(
ctx.cloudAuth,
ctx.projectKey,
file,
fileContent,
ctx.branch,
async (attempt) => {
await ctx.progress.retrying(
idx,
attempt,
MAX_503_RETRIES,
RETRY_503_BASE_DELAY_MS * 2 ** (attempt - 1),
);
// retrying() already resets status to 'analyzing' when the countdown ends.
},
ctx.pathBase,
);
ctx.progress.update(idx, 'done');
return { file, filePath, issues: response.issues, errors: response.errors };
} catch (err) {
ctx.progress.update(idx, 'failed');
return { file, filePath, failure: err as Error };
}
}

export function tallyResults(results: FileResult[], tally: RunTally): void {
for (const r of results) {
if ('failure' in r) {
tally.totalFailures += 1;
} else {
tally.totalIssues += r.issues.length;
tally.totalErrors += r.errors?.length ?? 0;
}
}
}
Loading
Loading