diff --git a/CLAUDE.md b/CLAUDE.md index 6d2265ac..e0d03629 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/src/cli/command-tree.ts b/src/cli/command-tree.ts index 9b48299c..ddc7fa39 100644 --- a/src/cli/command-tree.ts +++ b/src/cli/command-tree.ts @@ -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'; @@ -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 path to analyze') - .option('--branch ', 'Branch name for analysis context') - .option( - '-p, --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 ', 'Output format') + .choices(SQAA_FORMATS) + .default('text'); + +function applySqaaOptions(cmd: SonarCommand): SonarCommand { + return cmd + .option('--file ', 'Analyze a single file (skips change set detection)') + .option('--staged', 'Analyze staged files only (git diff --cached)') + .option('--base ', 'Analyze files changed vs a branch or ref (e.g. main)') + .option('--branch ', 'Branch name for analysis context') + .option( + '-p, --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 ', 'Output format') .choices(DEPENDENCY_RISKS_FORMATS) @@ -282,17 +295,18 @@ analyze analyzeDependencyRisks(options, auth), ); -COMMAND_TREE.command('verify') - .description('Analyze a file for issues') - .requiredOption('--file ', 'File path to analyze') - .option('--branch ', 'Branch name for analysis context') - .option( - '-p, --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'); diff --git a/src/cli/commands/_common/error.ts b/src/cli/commands/_common/error.ts index 2fa1ea0c..23ea7818 100644 --- a/src/cli/commands/_common/error.ts +++ b/src/cli/commands/_common/error.ts @@ -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'; @@ -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); diff --git a/src/cli/commands/_common/sonar-command.ts b/src/cli/commands/_common/sonar-command.ts index fa36ac1a..d6fd9eb9 100644 --- a/src/cli/commands/_common/sonar-command.ts +++ b/src/cli/commands/_common/sonar-command.ts @@ -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. @@ -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; } } } diff --git a/src/cli/commands/analyze/sqaa-analysis.ts b/src/cli/commands/analyze/sqaa-analysis.ts new file mode 100644 index 00000000..19279e9a --- /dev/null +++ b/src/cli/commands/analyze/sqaa-analysis.ts @@ -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 { + 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 => { + 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 { + 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; + } + } +} diff --git a/src/cli/commands/analyze/sqaa-api.ts b/src/cli/commands/analyze/sqaa-api.ts new file mode 100644 index 00000000..bcb8292f --- /dev/null +++ b/src/cli/commands/analyze/sqaa-api.ts @@ -0,0 +1,182 @@ +/* + * 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. + */ + +// HTTP API layer for SQAA: fetch, retry, and single-file display. + +import { readFileSync } from 'node:fs'; +import { isAbsolute, relative } from 'node:path'; + +import { normalizePath } from '../../../lib/fs-utils'; +import type { SqaaIssue } from '../../../sonarqube/client'; +import { SonarQubeClient } from '../../../sonarqube/client'; +import { ServiceUnavailableError } from '../../../sonarqube/errors.js'; +import { blank, text } from '../../../ui'; +import { CommandFailedError, InvalidOptionError } from '../_common/error.js'; +import type { CloudAuth } from './sqaa-auth'; +import { displaySqaaResults } from './sqaa-display'; + +/** Maximum number of retries on 503 responses. */ +export const MAX_503_RETRIES = 3; + +/** Base delay for 503 retry backoff in milliseconds. Attempt N waits BASE * 2^(N-1): 2s, 4s, 8s. */ +export const RETRY_503_BASE_DELAY_MS = 2000; + +/** Interval for the live countdown tick in milliseconds. */ +const COUNTDOWN_TICK_MS = 1000; + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Read file content for SQAA analysis. + * Throws CommandFailedError when the file cannot be read. + */ +export function readSqaaFileContent(file: string): string { + try { + return readFileSync(file, 'utf-8'); + } catch (err) { + throw new CommandFailedError(`Failed to read file: ${(err as Error).message}`); + } +} + +/** + * Compute a path of `file` relative to `base` (defaults to the + * current working directory). Throws when the file is outside `base` (traversal) or on a different drive. + */ +export function toRelativePosixPath(file: string, base: string = process.cwd()): string { + const rel = normalizePath(relative(base, file)); + + if (isAbsolute(rel) || rel.split('/').includes('..')) { + throw new InvalidOptionError(`File must be inside ${base}: ${file}`); + } + + return rel; +} + +/** + * Fetch the SQAA API response for a single file. Does not print anything. + * Throws ServiceUnavailableError on 503 (caller handles retry), CommandFailedError on other failures. + * + * `pathBase` is the directory the SQAA-side file path is computed relative to. + * Defaults to `process.cwd()` for the single-file path; change-set callers + * pass the repository root so paths are stable regardless of where the user runs. + */ +export async function fetchSqaaResponse( + auth: CloudAuth, + projectKey: string, + file: string, + fileContent: string, + branch: string | undefined, + pathBase?: string, +): Promise<{ issues: SqaaIssue[]; errors?: Array<{ code: string; message: string }> | null }> { + const filePath = toRelativePosixPath(file, pathBase); + const client = new SonarQubeClient(auth.serverUrl, auth.token); + try { + return await client.analyzeFile({ + organizationKey: auth.orgKey, + projectKey, + ...(branch ? { branchName: branch } : {}), + filePath, + fileContent, + }); + } catch (err) { + if (err instanceof ServiceUnavailableError) throw err; + throw new CommandFailedError(`SonarQube Agentic Analysis failed.\n ${(err as Error).message}`); + } +} + +/** + * Calls fetchSqaaResponse with a 503-retry loop. + */ +export async function fetchWithRetry( + auth: CloudAuth, + projectKey: string, + file: string, + fileContent: string, + branch: string | undefined, + onRetry?: (attempt: number) => Promise, + pathBase?: string, +): Promise<{ issues: SqaaIssue[]; errors?: Array<{ code: string; message: string }> | null }> { + for (let attempt = 1; attempt <= MAX_503_RETRIES + 1; attempt++) { + try { + return await fetchSqaaResponse(auth, projectKey, file, fileContent, branch, pathBase); + } catch (err) { + const shouldRetry = err instanceof ServiceUnavailableError && attempt <= MAX_503_RETRIES; + if (!shouldRetry) throw err; + await waitBeforeRetry(attempt, onRetry); + } + } + throw new CommandFailedError('SonarQube Agentic Analysis failed: unexpected retry exhaustion.'); +} + +export async function waitBeforeRetry( + attempt: number, + onRetry?: (attempt: number) => Promise, +): Promise { + const delayMs = RETRY_503_BASE_DELAY_MS * 2 ** (attempt - 1); + if (onRetry) { + await onRetry(attempt); + } else { + await defaultRetryCountdown(attempt, MAX_503_RETRIES, delayMs); + } +} + +/** + * Countdown used for the single-file path (no SqaaProgress block on screen). Writes to stdout directly. + */ +export async function defaultRetryCountdown( + attempt: number, + maxRetries: number, + delayMs: number, +): Promise { + const totalSeconds = Math.round(delayMs / 1000); + if (!process.stdout.isTTY) { + process.stdout.write( + `⚠️ Server busy (503). Retrying in ${totalSeconds}s... [Attempt ${attempt}/${maxRetries}]\n`, + ); + await sleep(delayMs); + return; + } + for (let remaining = totalSeconds; remaining > 0; remaining--) { + process.stdout.write( + `\r⚠️ Server busy (503). Retrying in ${remaining}s... [Attempt ${attempt}/${maxRetries}] `, + ); + await sleep(COUNTDOWN_TICK_MS); + } + process.stdout.write('\r\x1b[K'); +} + +/** + * Call the SQAA API and display the results for the single-file path. + * Returns the number of issues found. Throws CommandFailedError on API failure. + */ +export async function callSqaaApiAndDisplay( + auth: CloudAuth, + projectKey: string, + file: string, + fileContent: string, + branch: string | undefined, +): Promise { + blank(); + text('Running SonarQube Agentic Analysis...'); + const response = await fetchWithRetry(auth, projectKey, file, fileContent, branch); + return displaySqaaResults(response.issues, response.errors); +} diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts new file mode 100644 index 00000000..6cb2e46e --- /dev/null +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -0,0 +1,177 @@ +/* + * 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. + */ + +// Auth and project-key resolution for SQAA commands. + +import { resolve } from 'node:path'; + +import type { Command } from 'commander'; + +import type { ResolvedAuth } from '../../../lib/auth-resolver'; +import logger from '../../../lib/logger'; +import { spawnProcess } from '../../../lib/process'; +import { loadState } from '../../../lib/repository/state-repository'; +import type { HookExtension } from '../../../lib/state'; +import { findExtensionsByProject } from '../../../lib/state-manager'; +import { blank, confirmPrompt, text, warn } from '../../../ui'; +import { CommandFailedError } from '../_common/error.js'; + +const LARGE_CHANGESET_HINT = + 'For faster feedback, try targeting your changes:\n' + + ' --staged analyze only staged files\n' + + ' --base analyze files changed vs a branch (e.g. --base main)\n' + + ' --file analyze a single specific file'; + +/** Cloud authentication context required for SQAA API calls. */ +export interface CloudAuth { + serverUrl: string; + token: string; + orgKey: string; +} + +/** + * Combines cloud-auth validation and project-key resolution. + * Returns null (with a warning already printed) when SQAA should be skipped. + */ +export async function resolveCloudAuthAndProject( + auth: ResolvedAuth, + explicitProject: string | undefined, + command: Command | undefined, + projectRoot?: string, +): Promise<{ cloudAuth: CloudAuth; projectKey: string } | null> { + const cloudAuth = resolveCloudAuth(auth, explicitProject); + if (!cloudAuth) return null; + + const projectKey = explicitProject ?? (await resolveSqaaProjectKey(command, projectRoot)); + if (!projectKey) { + warn( + 'SonarQube Agentic Analysis skipped: no project configured. Specify one with --project or run: sonar integrate claude', + ); + return null; + } + + return { cloudAuth, projectKey }; +} + +/** + * Validate that the resolved auth is for SonarQube Cloud. + * Returns null when the connection is not Cloud and --project is not set. + * Throws CommandFailedError when --project is set but the connection is not Cloud. + */ +export function resolveCloudAuth( + auth: ResolvedAuth, + explicitProject: string | undefined, +): CloudAuth | null { + if (auth.connectionType != 'cloud' || auth.orgKey == null) { + if (explicitProject) { + throw new CommandFailedError( + 'SonarQube Agentic Analysis requires a SonarQube Cloud connection. Run: sonar auth login', + ); + } + warn( + 'SonarQube Agentic Analysis skipped: a SonarQube Cloud connection is required. Run: sonar auth login (ensure you connect to SonarQube Cloud)', + ); + return null; + } + + return { serverUrl: auth.serverUrl, token: auth.token, orgKey: auth.orgKey }; +} + +/** + * Look up the project key for the current project from the agentExtensions registry. + * + * The registry keys extensions by project root (the directory passed to + * `sonar integrate claude`), so when the user runs SQAA from a subdirectory we + * have to resolve the git repository top-level first — otherwise `process.cwd()` + * is a non-match against the registered root and we incorrectly skip with + * "no project configured". + * + * Falls back to `process.cwd()` when not inside a git repository so the + * single-file path still works outside git. + */ +export async function resolveSqaaProjectKey( + command?: Command, + projectRoot?: string, +): Promise { + try { + const root = projectRoot ?? (await tryResolveRepoRoot(process.cwd())); + const state = loadState(); + const extensions = findExtensionsByProject(state, 'claude-code', root); + const sqaaExt = extensions.find( + (e): e is HookExtension => e.kind === 'hook' && e.name === 'sonar-sqaa', + ); + + if (!sqaaExt?.projectKey) { + logger.debug( + 'SonarQube Agentic Analysis skipped: no project key found in extensions registry', + ); + if (process.stdin.isTTY) { + command?.outputHelp(); + } + return null; + } + + return sqaaExt.projectKey; + } catch { + logger.debug('SonarQube Agentic Analysis skipped: failed to resolve extensions'); + return null; + } +} + +/** + * Resolve the git repository top-level for `cwd`, falling back to `cwd` itself + * when not inside a git repository (so non-git workflows still work). + */ +async function tryResolveRepoRoot(cwd: string): Promise { + try { + const result = await spawnProcess('git', ['rev-parse', '--show-toplevel'], { cwd }); + if (result.exitCode === 0) { + return resolve(result.stdout.trim()); + } + } catch { + // git not installed or otherwise unavailable — fall through to cwd. + } + return cwd; +} + +/** + * Warn about a large change set and ask the user to confirm. + * In non-interactive contexts (no stdin TTY — e.g. CI/agent runs), prints a + * warning and auto-proceeds. Returns false only when the user explicitly declines in an interactive terminal. + */ +export async function confirmLargeChangeset(fileCount: number): Promise { + blank(); + warn( + `You are about to analyze a large number of files (${fileCount}). This may take longer to process.\n${LARGE_CHANGESET_HINT}`, + ); + + if (!process.stdin.isTTY) { + return true; + } + + blank(); + const confirmed = await confirmPrompt('Do you wish to proceed?'); + if (!confirmed) { + blank(); + text('Analysis cancelled. Use --force to bypass the file count check.'); + return false; + } + return true; +} diff --git a/src/cli/commands/analyze/sqaa-changeset.ts b/src/cli/commands/analyze/sqaa-changeset.ts new file mode 100644 index 00000000..af7d7159 --- /dev/null +++ b/src/cli/commands/analyze/sqaa-changeset.ts @@ -0,0 +1,181 @@ +/* + * 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. + */ + +// Resolves the set of local files to analyze from Git, honouring .gitignore. + +import { closeSync, openSync, readSync, statSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import { spawnProcess } from '../../../lib/process'; +import { CommandFailedError } from '../_common/error'; + +/** Maximum byte size per file sent to SQAA. Files exceeding this are skipped. */ +export const SQAA_MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB + +export interface ChangeSetOptions { + /** Staged files only (`--staged`). */ + staged?: boolean; + /** Diff against this branch/ref (`--base `). */ + base?: string; +} + +/** A file excluded from analysis with the reason it was skipped. */ +export interface IgnoredFile { + path: string; + reason: 'binary' | 'oversized'; +} + +/** Result of resolving a change set: files to analyze and files silently ignored. */ +export interface ChangeSetResult { + files: string[]; + ignored: IgnoredFile[]; + /** Repository root used to resolve git output. Useful for computing relative paths. */ + repoRoot: string; +} + +/** + * Resolves the list of absolute file paths that belong to the local change set, + * filtering out git-ignored paths and binary files, capped at SQAA_MAX_FILE_BYTES per file. + * + * All git commands are run from the repository top-level (resolved via + * `git rev-parse --show-toplevel`), so behavior is identical whether the user + * runs from the repo root or a subdirectory. Returned paths are absolute. + * + * Modes: + * - Default (no options): `git diff HEAD` (staged + unstaged) + untracked non-ignored files + * - staged=true: `git diff --cached` (staged only) + * - base=: `git diff ` + untracked non-ignored files + */ +export async function resolveChangeSet( + cwd: string, + options: ChangeSetOptions = {}, +): Promise { + const { staged, base } = options; + + const repoRoot = await resolveRepoRoot(cwd); + + const diffFiles = await getDiffFiles(repoRoot, { staged, base }); + const untrackedFiles = staged ? [] : await getUntrackedNonIgnoredFiles(repoRoot); + const absolute = [...diffFiles, ...untrackedFiles].map((f) => join(repoRoot, f)); + + const { files: nonBinary, ignored: binaryIgnored } = partitionBinary(absolute); + const { files, ignored: oversizedIgnored } = partitionBySize(nonBinary); + + return { files, ignored: [...binaryIgnored, ...oversizedIgnored], repoRoot }; +} + +/** + * Resolve the absolute path of the repository top-level for `cwd`. + * Throws CommandFailedError when `cwd` is not inside a Git repository. + */ +async function resolveRepoRoot(cwd: string): Promise { + const out = await runGit(['rev-parse', '--show-toplevel'], cwd); + return resolve(out.trim()); +} + +async function getDiffFiles( + cwd: string, + opts: { staged?: boolean; base?: string }, +): Promise { + // -z: NUL-separated output, no path quoting — robust against unusual filenames + // (leading/trailing whitespace, newlines, non-ASCII bytes with core.quotePath=true). + const args: string[] = ['diff', '--name-only', '--diff-filter=ACMR', '-z']; + + if (opts.staged) { + args.push('--cached'); + } else if (opts.base) { + args.push(opts.base); + } else { + args.push('HEAD'); + } + + const result = await runGit(args, cwd); + return parseNulSeparated(result); +} + +async function getUntrackedNonIgnoredFiles(cwd: string): Promise { + const result = await runGit(['ls-files', '-z', '--others', '--exclude-standard'], cwd); + return parseNulSeparated(result); +} + +async function runGit(args: string[], cwd: string): Promise { + let result; + try { + result = await spawnProcess('git', args, { cwd }); + } catch (err) { + throw new CommandFailedError(`Failed to run git: ${(err as Error).message}`); + } + if (result.exitCode !== 0) { + throw new CommandFailedError( + `git ${args[0]} failed (exit ${result.exitCode}): ${result.stderr}`, + ); + } + return result.stdout; +} + +function parseNulSeparated(output: string): string[] { + return output.split('\0').filter((p) => p.length > 0); +} + +/** + * Separates binary files from text files. + * Heuristic: reads the first 8 KB; a NUL byte indicates binary content. + */ +function partitionBinary(files: string[]): { files: string[]; ignored: IgnoredFile[] } { + const kept: string[] = []; + const ignored: IgnoredFile[] = []; + for (const f of files) { + try { + const buf = Buffer.alloc(8192); + const fd = openSync(f, 'r'); + try { + const bytesRead = readSync(fd, buf, 0, buf.length, 0); + if (buf.subarray(0, bytesRead).includes(0x00)) { + ignored.push({ path: f, reason: 'binary' }); + } else { + kept.push(f); + } + } finally { + closeSync(fd); + } + } catch { + // Unreadable files are silently skipped (e.g. permission errors, deleted between stat and read). + } + } + return { files: kept, ignored }; +} + +/** Separates files that exceed the per-file size limit. */ +function partitionBySize(files: string[]): { files: string[]; ignored: IgnoredFile[] } { + const kept: string[] = []; + const ignored: IgnoredFile[] = []; + for (const f of files) { + try { + if (statSync(f).size <= SQAA_MAX_FILE_BYTES) { + kept.push(f); + } else { + ignored.push({ path: f, reason: 'oversized' }); + } + } catch { + // Silently skip files that can't be stated. + } + } + return { files: kept, ignored }; +} diff --git a/src/cli/commands/analyze/sqaa-display.ts b/src/cli/commands/analyze/sqaa-display.ts new file mode 100644 index 00000000..d4b250ff --- /dev/null +++ b/src/cli/commands/analyze/sqaa-display.ts @@ -0,0 +1,179 @@ +/* + * 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. + */ + +// Display layer for SQAA results: text and JSON output. + +import type { SqaaIssue } from '../../../sonarqube/client'; +import { blank, error, print, success, text } from '../../../ui'; +import type { FileFailure, FileResult, FileSuccess, RunTally } from './sqaa-analysis'; +import { toRelativePosixPath } from './sqaa-api'; +import type { IgnoredFile } from './sqaa-changeset'; + +/** Exit code when analysis succeeds and issues are found. */ +export const EXIT_CODE_ISSUES_FOUND = 51; + +export interface SqaaJsonReport { + files: Array<{ + path: string; + issues: SqaaIssue[]; + errors?: Array<{ code: string; message: string }> | null; + }>; + ignored: Array<{ path: string; reason: 'binary' | 'oversized' }>; + failures: Array<{ path: string; message: string }>; + /** Files in the change set that were never sent to the API (fail-fast skipped them). */ + skipped: string[]; + summary: { totalIssues: number; totalFailures: number; totalSkipped: number }; +} + +export function printJsonReport( + tally: RunTally, + ignored: IgnoredFile[], + allPaths: string[], + pathBase?: string, +): void { + const files = tally.allResults + .filter((r): r is FileSuccess => !('failure' in r)) + .map((r) => ({ path: r.filePath, issues: r.issues, errors: r.errors })); + + const failures = tally.allResults + .filter((r): r is FileFailure => 'failure' in r) + .map((r) => ({ path: r.filePath, message: r.failure.message })); + + const processedPaths = new Set(tally.allResults.map((r) => r.filePath)); + const skipped = allPaths.filter((p) => !processedPaths.has(p)); + + const report: SqaaJsonReport = { + files, + ignored: ignored.map((f) => ({ + path: toRelativePosixPath(f.path, pathBase), + reason: f.reason, + })), + failures, + skipped, + summary: { + totalIssues: tally.totalIssues, + totalFailures: tally.totalFailures, + totalSkipped: skipped.length, + }, + }; + + print(JSON.stringify(report, null, 2)); +} + +export function applyExitCode(totalIssues: number, totalFailures: number): void { + if (totalFailures > 0) { + process.exitCode = 1; + } else if (totalIssues > 0) { + process.exitCode = EXIT_CODE_ISSUES_FOUND; + } +} + +export function printFileDetails(allResults: FileResult[]): void { + blank(); + for (const result of allResults) { + if ('failure' in result) { + text(`── ${result.filePath}`); + text(` Failed to analyze: ${result.failure.message}`); + blank(); + } else if (result.issues.length > 0 || (result.errors && result.errors.length > 0)) { + text(`── ${result.filePath}`); + printIssuesAndErrors(result.issues, result.errors); + } + } +} + +export function printIssuesAndErrors( + issues: SqaaIssue[], + errors?: Array<{ code: string; message: string }> | null, +): void { + if (issues.length > 0) { + text(` Found ${issues.length} issue${issues.length === 1 ? '' : 's'}:`); + blank(); + issues.forEach((issue, idx) => { + const location = issue.textRange ? ` (line ${issue.textRange.startLine})` : ''; + text(` [${idx + 1}] ${issue.message}${location}`); + text(` Rule: ${issue.rule}`); + }); + blank(); + } + if (errors && errors.length > 0) { + text(' Analysis errors:'); + errors.forEach((e) => { + text(` [${e.code}] ${e.message}`); + }); + blank(); + } +} + +export function printSummary( + totalIssues: number, + totalErrors: number, + totalFailures: number, +): void { + if (totalFailures > 0) { + // Failures take precedence: the run was incomplete regardless of issues found so far. + error( + `SonarQube Agentic Analysis completed with ${totalFailures} failure${totalFailures === 1 ? '' : 's'}.`, + ); + process.exitCode = 1; + } else if (totalIssues > 0) { + process.exitCode = EXIT_CODE_ISSUES_FOUND; + } else if (totalErrors === 0) { + success('SonarQube Agentic Analysis completed — change set is clean.'); + } + // else: no issues, no failures, but API-level errors were printed per file — stay silent on the + // summary line (matches single-file behavior) and leave the exit code untouched. +} + +export function displaySqaaResults( + issues: SqaaIssue[], + errors?: Array<{ code: string; message: string }> | null, + inChangeSetMode = false, +): number { + blank(); + + if (issues.length === 0) { + if (!inChangeSetMode) { + success('SonarQube Agentic Analysis completed — no issues found.'); + } + } else { + error( + `SonarQube Agentic Analysis found ${issues.length} issue${issues.length === 1 ? '' : 's'}:`, + ); + blank(); + issues.forEach((issue, idx) => { + const location = issue.textRange ? ` (line ${issue.textRange.startLine})` : ''; + text(` [${idx + 1}] ${issue.message}${location}`); + text(` Rule: ${issue.rule}`); + }); + } + + if (errors && errors.length > 0) { + blank(); + error('SonarQube Agentic Analysis returned errors:'); + errors.forEach((e) => { + text(` [${e.code}] ${e.message}`); + }); + } + + blank(); + + return issues.length; +} diff --git a/src/cli/commands/analyze/sqaa.ts b/src/cli/commands/analyze/sqaa.ts index 71f23d8b..51aeffa2 100644 --- a/src/cli/commands/analyze/sqaa.ts +++ b/src/cli/commands/analyze/sqaa.ts @@ -17,26 +17,48 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { existsSync, readFileSync } from 'node:fs'; -import { isAbsolute, relative } from 'node:path'; +import { existsSync } from 'node:fs'; import type { Command } from 'commander'; import type { ResolvedAuth } from '../../../lib/auth-resolver'; -import { normalizePath } from '../../../lib/fs-utils'; -import logger from '../../../lib/logger'; -import { loadState } from '../../../lib/repository/state-repository'; -import type { HookExtension } from '../../../lib/state'; -import { findExtensionsByProject } from '../../../lib/state-manager'; -import type { SqaaIssue } from '../../../sonarqube/client'; -import { SonarQubeClient } from '../../../sonarqube/client'; -import { blank, error, success, text, warn } from '../../../ui'; -import { CommandFailedError, InvalidOptionError } from '../_common/error.js'; +import { blank, print, text } from '../../../ui'; +import { SqaaProgress } from '../../../ui/components/sqaa-progress.js'; +import { InvalidOptionError } from '../_common/error.js'; +import type { RunContext } from './sqaa-analysis'; +import { runAnalyses } from './sqaa-analysis'; +import { + callSqaaApiAndDisplay, + fetchWithRetry, + readSqaaFileContent, + toRelativePosixPath, +} from './sqaa-api'; +import type { CloudAuth } from './sqaa-auth'; +import { confirmLargeChangeset, resolveCloudAuthAndProject } from './sqaa-auth'; +import type { ChangeSetResult } from './sqaa-changeset'; +import { resolveChangeSet } from './sqaa-changeset'; +import { + applyExitCode, + EXIT_CODE_ISSUES_FOUND, + printFileDetails, + printJsonReport, + printSummary, +} from './sqaa-display'; + +/** Change-set size above which the user is prompted to confirm before proceeding. */ +const SQAA_LARGE_CHANGESET_THRESHOLD = 50; + +export const VALID_FORMATS = ['text', 'json'] as const; +export type OutputFormat = (typeof VALID_FORMATS)[number]; export interface AnalyzeSqaaOptions { - file: string; + file?: string; + staged?: boolean; + base?: string; branch?: string; project?: string; + force?: boolean; + format?: OutputFormat; } export async function analyzeSqaa( @@ -44,178 +66,141 @@ export async function analyzeSqaa( auth: ResolvedAuth, command?: Command, ): Promise { - const { file, branch, project } = options; + const { file, staged, base, branch, project, force, format = 'text' } = options; - if (!existsSync(file)) { - throw new InvalidOptionError(`File not found: ${file}`); + if (staged && base !== undefined) { + throw new InvalidOptionError('--staged and --base cannot be used together'); } - await runSqaaAnalysis(file, auth, branch, project, command); -} - -export async function runSqaaAnalysis( - file: string, - auth: ResolvedAuth, - branch?: string, - explicitProject?: string, - command?: Command, -): Promise { - const cloudAuth = resolveCloudAuth(auth, explicitProject); - if (!cloudAuth) { - warn( - 'SonarQube Agentic Analysis skipped: a SonarQube Cloud connection is required. Run: sonar auth login (ensure you connect to SonarQube Cloud)', - ); + if (file !== undefined) { + if (!existsSync(file)) { + throw new InvalidOptionError(`File not found: ${file}`); + } + await runSqaaAnalysis(file, auth, branch, project, command, format); return; } - const projectKey = explicitProject ?? resolveSqaaProjectKey(command); - if (!projectKey) { - warn( - 'SonarQube Agentic Analysis skipped: no project configured. Specify one with --project or run: sonar integrate claude', - ); - return; - } + // Change-set mode: resolve files from Git. + const changeSet = await resolveChangeSet(process.cwd(), { staged, base }); - const fileContent = readSqaaFileContent(file); - await callSqaaApiAndDisplay(cloudAuth, projectKey, file, fileContent, branch); -} - -/** - * Validate that the resolved auth is for SonarQube Cloud. - * Returns null when the connection is not Cloud and --project is not set. - * Throws CommandFailedError when --project is set but the connection is not Cloud. - */ -function resolveCloudAuth( - auth: ResolvedAuth, - explicitProject: string | undefined, -): { serverUrl: string; token: string; orgKey: string } | null { - if (auth.connectionType != 'cloud' || auth.orgKey == null) { - if (explicitProject) { - throw new CommandFailedError( - 'SonarQube Agentic Analysis requires a SonarQube Cloud connection. Run: sonar auth login', - ); - } - logger.debug('SonarQube Agentic Analysis skipped: missing orgKey or on-premise server'); - return null; + if (changeSet.files.length === 0 && changeSet.ignored.length === 0) { + blank(); + text('SonarQube Agentic Analysis: no files in the change set to analyze.'); + return; } - return { serverUrl: auth.serverUrl, token: auth.token, orgKey: auth.orgKey }; -} - -/** - * Look up the project key for the current directory from the agentExtensions registry. - * Returns null when SQAA should be skipped. - */ -function resolveSqaaProjectKey(command?: Command): string | null { - try { - const state = loadState(); - const extensions = findExtensionsByProject(state, 'claude-code', process.cwd()); - const sqaaExt = extensions.find( - (e): e is HookExtension => e.kind === 'hook' && e.name === 'sonar-sqaa', + if (changeSet.files.length === 0) { + blank(); + text( + 'SonarQube Agentic Analysis: no files to analyze — all change set files were excluded (binary or oversized).', ); - - if (!sqaaExt?.projectKey) { - logger.debug( - 'SonarQube Agentic Analysis skipped: no project key found in extensions registry', - ); - if (process.stdin.isTTY) { - command?.outputHelp(); - } - return null; - } - - return sqaaExt.projectKey; - } catch { - logger.debug('SonarQube Agentic Analysis skipped: failed to resolve extensions'); - return null; + return; } -} -/** - * Read file content for SQAA analysis. - * Throws CommandFailedError when the file cannot be read. - */ -function readSqaaFileContent(file: string): string { - try { - return readFileSync(file, 'utf-8'); - } catch (err) { - throw new CommandFailedError(`Failed to read file: ${(err as Error).message}`); - } -} + // Resolve cloud auth + project key BEFORE prompting. + // Pass repoRoot so we reuse the already-resolved root instead of spawning git again. + const resolved = await resolveCloudAuthAndProject(auth, project, command, changeSet.repoRoot); + if (!resolved) return; -/** - * Compute a POSIX-style relative path under the current working directory. - * Throws when the file is outside cwd (traversal) or on a different drive. - */ -function toRelativePosixPath(file: string): string { - const rel = normalizePath(relative(process.cwd(), file)); - - if (isAbsolute(rel) || rel.split('/').includes('..')) { - throw new InvalidOptionError(`File must be inside the current working directory: ${file}`); + // JSON mode is consumed by scripts/CI: never block on an interactive prompt + if (!force && format !== 'json' && changeSet.files.length > SQAA_LARGE_CHANGESET_THRESHOLD) { + const confirmed = await confirmLargeChangeset(changeSet.files.length); + if (!confirmed) return; } - return rel; + await runSqaaAnalysisOnFiles(changeSet, resolved, branch, format); } -/** - * Call the SQAA API and display the results. - * Throws CommandFailedError on API failure. - */ -async function callSqaaApiAndDisplay( - auth: { serverUrl: string; token: string; orgKey: string }, - projectKey: string, +async function runSqaaAnalysis( file: string, - fileContent: string, - branch: string | undefined, + auth: ResolvedAuth, + branch?: string, + explicitProject?: string, + command?: Command, + format: OutputFormat = 'text', ): Promise { - const filePath = toRelativePosixPath(file); - const client = new SonarQubeClient(auth.serverUrl, auth.token); + const resolved = await resolveCloudAuthAndProject(auth, explicitProject, command); + if (!resolved) return; - blank(); - text('Running SonarQube Agentic Analysis...'); + const { cloudAuth, projectKey } = resolved; + const fileContent = readSqaaFileContent(file); - try { - const response = await client.analyzeFile({ - organizationKey: auth.orgKey, - projectKey, - ...(branch ? { branchName: branch } : {}), - filePath, - fileContent, - }); - - displaySqaaResults(response.issues, response.errors); - } catch (err) { - throw new CommandFailedError(`SonarQube Agentic Analysis failed.\n ${(err as Error).message}`); + if (format === 'json') { + const filePath = toRelativePosixPath(file); + try { + const response = await fetchWithRetry(cloudAuth, projectKey, file, fileContent, branch); + const report = { + files: [{ path: filePath, issues: response.issues, errors: response.errors }], + ignored: [], + failures: [], + skipped: [], + summary: { totalIssues: response.issues.length, totalFailures: 0, totalSkipped: 0 }, + }; + print(JSON.stringify(report, null, 2)); + if (response.issues.length > 0) process.exitCode = EXIT_CODE_ISSUES_FOUND; + } catch (err) { + const report = { + files: [], + ignored: [], + failures: [{ path: filePath, message: (err as Error).message }], + skipped: [], + summary: { totalIssues: 0, totalFailures: 1, totalSkipped: 0 }, + }; + print(JSON.stringify(report, null, 2)); + process.exitCode = 1; + } + return; } -} -function displaySqaaResults( - issues: SqaaIssue[], - errors?: Array<{ code: string; message: string }> | null, -): void { - blank(); - - if (issues.length === 0) { - success('SonarQube Agentic Analysis completed — no issues found.'); - } else { - error( - `SonarQube Agentic Analysis found ${issues.length} issue${issues.length === 1 ? '' : 's'}:`, - ); - blank(); - issues.forEach((issue, idx) => { - const location = issue.textRange ? ` (line ${issue.textRange.startLine})` : ''; - text(` [${idx + 1}] ${issue.message}${location}`); - text(` Rule: ${issue.rule}`); - }); + const issueCount = await callSqaaApiAndDisplay(cloudAuth, projectKey, file, fileContent, branch); + if (issueCount > 0) { + process.exitCode = EXIT_CODE_ISSUES_FOUND; } +} - if (errors && errors.length > 0) { - blank(); - error('SonarQube Agentic Analysis returned errors:'); - errors.forEach((e) => { - text(` [${e.code}] ${e.message}`); - }); +async function runSqaaAnalysisOnFiles( + changeSet: ChangeSetResult, + resolved: { cloudAuth: CloudAuth; projectKey: string }, + branch?: string, + format: OutputFormat = 'text', +): Promise { + const { files, ignored, repoRoot } = changeSet; + const { cloudAuth, projectKey } = resolved; + const allPaths = files.map((f) => toRelativePosixPath(f, repoRoot)); + + if (format === 'json') { + // Suppress all UI rendering at the component level (no global mock state). + const silentProgress = new SqaaProgress({ files: allPaths, silent: true }); + const ctx: RunContext = { + files, + allPaths, + cloudAuth, + projectKey, + branch, + progress: silentProgress, + pathBase: repoRoot, + }; + const tally = await runAnalyses(ctx); + printJsonReport(tally, ignored, allPaths, repoRoot); + applyExitCode(tally.totalIssues, tally.totalFailures); + return; } - blank(); + const ignoredPaths = ignored.map((f) => toRelativePosixPath(f.path, repoRoot)); + const progress = new SqaaProgress({ files: allPaths, ignoredFiles: ignoredPaths }); + const ctx: RunContext = { + files, + allPaths, + cloudAuth, + projectKey, + branch, + progress, + pathBase: repoRoot, + }; + progress.start(); + const tally = await runAnalyses(ctx); + + progress.finish(tally.allResults.length); + printFileDetails(tally.allResults); + printSummary(tally.totalIssues, tally.totalErrors, tally.totalFailures); } diff --git a/src/lib/state-manager.ts b/src/lib/state-manager.ts index f7b15d8b..8e308420 100644 --- a/src/lib/state-manager.ts +++ b/src/lib/state-manager.ts @@ -24,6 +24,8 @@ */ import crypto from 'node:crypto'; +import { realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; export { loadState, saveState } from './repository/state-repository.js'; @@ -46,6 +48,16 @@ export function getActiveConnection(state: CliState): AuthConnection | undefined return state.auth.connections.find((c) => c.id === state.auth.activeConnectionId); } +function canonicalProjectRoot(projectRoot: string): string { + let canonical: string; + try { + canonical = realpathSync.native(projectRoot); + } catch { + canonical = resolve(projectRoot); + } + return process.platform === 'win32' ? canonical.toLowerCase() : canonical; +} + /** * Find all extensions registered for a specific agent + project root combination. */ @@ -54,8 +66,9 @@ export function findExtensionsByProject( agentId: string, projectRoot: string, ): AgentExtension[] { + const target = canonicalProjectRoot(projectRoot); return state.agentExtensions.filter( - (e) => e.agentId === agentId && e.projectRoot === projectRoot, + (e) => e.agentId === agentId && canonicalProjectRoot(e.projectRoot) === target, ); } diff --git a/src/sonarqube/client.ts b/src/sonarqube/client.ts index 8d082cb1..b96b5588 100644 --- a/src/sonarqube/client.ts +++ b/src/sonarqube/client.ts @@ -24,12 +24,15 @@ import { version as VERSION } from '../../package.json'; import { isSonarQubeCloud, resolveFromEndpoint } from '../lib/auth-resolver'; import logger from '../lib/logger'; import { print } from '../ui'; +import { RateLimitError, ServiceUnavailableError } from './errors'; import type { SettingsValue } from './settings-value'; const GET_REQUEST_TIMEOUT_MS = 30000; // 30 seconds const POST_REQUEST_TIMEOUT_MS = 60000; // 60 seconds for analysis const HTTP_STATUS_FORBIDDEN = 403; const HTTP_STATUS_NOT_FOUND = 404; +const HTTP_STATUS_TOO_MANY_REQUESTS = 429; +const HTTP_STATUS_SERVICE_UNAVAILABLE = 503; export const GENERIC_HTTP_METHODS = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'] as const; export const METHODS_WITH_BODY = new Set(['POST', 'PATCH', 'PUT']); @@ -63,24 +66,29 @@ export class SonarQubeClient { } private async raiseForStatus(response: Response, method: HttpMethod) { + if (response.ok) return; + + // Status-specific typed errors apply regardless of HTTP method. + if (response.status === HTTP_STATUS_TOO_MANY_REQUESTS) { + throw new RateLimitError(); + } + if (response.status === HTTP_STATUS_SERVICE_UNAVAILABLE) { + throw new ServiceUnavailableError(); + } + if (method === 'GET') { - if (!response.ok) { - if ( - response.status === HTTP_STATUS_FORBIDDEN || - response.status === HTTP_STATUS_NOT_FOUND - ) { - throw new Error( - `Access denied (HTTP ${response.status}). Check that the supplied token and organization are valid.`, - ); - } - throw new Error(`SonarQube API error: ${response.status} ${response.statusText}`); + if (response.status === HTTP_STATUS_FORBIDDEN || response.status === HTTP_STATUS_NOT_FOUND) { + throw new Error( + `Access denied (HTTP ${response.status}). Check that the supplied token and organization are valid.`, + ); } - } else if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `SonarQube API error: ${response.status} ${response.statusText} - ${errorText}`, - ); + throw new Error(`SonarQube API error: ${response.status} ${response.statusText}`); } + + const errorText = await response.text(); + throw new Error( + `SonarQube API error: ${response.status} ${response.statusText} - ${errorText}`, + ); } /** diff --git a/src/sonarqube/errors.ts b/src/sonarqube/errors.ts new file mode 100644 index 00000000..ffe10825 --- /dev/null +++ b/src/sonarqube/errors.ts @@ -0,0 +1,35 @@ +/* + * 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. + */ + +/** Thrown by the API client on HTTP 429 (Too Many Requests). */ +export class RateLimitError extends Error { + constructor() { + super('Rate limit reached (429). Wait a moment and try again.'); + this.name = 'RateLimitError'; + } +} + +/** Thrown by the API client on HTTP 503 (Service Unavailable). */ +export class ServiceUnavailableError extends Error { + constructor() { + super('Server busy (503). The service is temporarily unavailable.'); + this.name = 'ServiceUnavailableError'; + } +} diff --git a/src/ui/components/sqaa-progress.ts b/src/ui/components/sqaa-progress.ts new file mode 100644 index 00000000..98a16996 --- /dev/null +++ b/src/ui/components/sqaa-progress.ts @@ -0,0 +1,347 @@ +/* + * 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. + */ + +// Live progress display for the SQAA worker-pool run. + +import * as readline from 'node:readline'; + +import { bold, cyan, dim, green, red, yellow } from '../colors.js'; +import { isMockActive, recordCall } from '../mock.js'; + +export type FileStatus = + | 'waiting' + | 'analyzing' + | 'done' + | 'failed' + | 'retrying' + | 'skipped' + | 'ignored'; + +const BAR_WIDTH = 12; +const FILLED = '⣿'; +const EMPTY = '⣀'; +/** Interval for the live countdown tick in milliseconds. */ +const COUNTDOWN_TICK_MS = 1000; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function renderBar(done: number, total: number): string { + const filled = total === 0 ? BAR_WIDTH : Math.round((done / total) * BAR_WIDTH); + return '[' + FILLED.repeat(filled) + EMPTY.repeat(BAR_WIDTH - filled) + ']'; +} + +function statusLabel(status: FileStatus, retryLabel?: string): string { + switch (status) { + case 'waiting': + return dim('[WAITING]'); + case 'analyzing': + return cyan('[ANALYZING...]'); + case 'done': + return green('[DONE]'); + case 'failed': + return red('[FAILED]'); + case 'retrying': + return yellow(retryLabel ?? '[RETRYING...]'); + case 'skipped': + return dim('[SKIPPED]'); + case 'ignored': + return dim('[IGNORED]'); + } +} + +function statusIcon(status: FileStatus): string { + if (status === 'waiting') return dim('○'); + if (status === 'skipped' || status === 'ignored') return dim('⊘'); + return '●'; +} + +/** Icon used in non-TTY per-file result lines (rolling output and finish()). */ +function fileStatusIcon(status: FileStatus): string { + if (status === 'done') return green('✓'); + if (status === 'failed') return red('✗'); + if (status === 'skipped' || status === 'ignored') return dim('⊘'); + return dim('○'); +} + +/** Render a single file row with padding to align the status label column. */ +function formatFileLine(path: string, icon: string, label: string, colWidth: number): string { + const padding = ' '.repeat(Math.max(1, colWidth - path.length)); + return ` ${icon} ${path}${padding}${label}`; +} + +/** + * Single progress renderer for an entire SQAA worker-pool run. + * + * Initialized with all file paths upfront; all files are visible throughout the run. + * + * TTY: maintains one block on screen — erases and rewrites it in-place on every update. + * Non-TTY: prints a one-time `Analyzing files...` header on `start()`, then a + * per-file result line every time a file reaches a terminal status + * (`done`/`failed`) — i.e. rolling output in completion order. + */ +export class SqaaProgress { + private readonly allFiles: string[]; + private readonly statuses: FileStatus[]; + private readonly isTTY: boolean; + /** When true, all rendering methods are no-ops (used by --format json). */ + private readonly silent: boolean; + /** + * Number of files the worker pool will actually analyze — i.e. `allFiles` + * minus pre-ignored entries. Used as the denominator of the progress bar and + * the `X/Y files analyzed` summary so 100% is reachable when ignored files + * are present. + */ + private readonly processableTotal: number; + /** Width of the path column — longest path length + 1 space minimum. */ + private readonly colWidth: number; + /** Per-file dynamic label override (used for retry countdown). */ + private readonly retryLabels = new Map(); + /** Number of lines currently written to stdout (TTY mode only). */ + private linesRendered = 0; + + constructor(opts: { + files: string[]; + ignoredFiles?: string[]; + isTTY?: boolean; + silent?: boolean; + }) { + const ignored = opts.ignoredFiles ?? []; + this.allFiles = [...opts.files, ...ignored]; + const waiting: FileStatus = 'waiting'; + const ignoredStatus: FileStatus = 'ignored'; + this.statuses = [...opts.files.map(() => waiting), ...ignored.map(() => ignoredStatus)]; + this.isTTY = opts.isTTY ?? process.stdout.isTTY; + this.silent = opts.silent ?? false; + this.processableTotal = opts.files.length; + this.colWidth = Math.max(...this.allFiles.map((f) => f.length), 0) + 2; + } + + /** + * Render the initial state of the progress block. Call this once before the + * worker pool spawns; subsequent transitions arrive via `update()`. + * TTY: draws the full block. + * Non-TTY: prints a one-time `Analyzing files...` header. + */ + start(): void { + if (this.silent) return; + if (isMockActive()) { + recordCall('sqaaProgress.start'); + return; + } + if (this.isTTY) { + this.eraseTTY(); + this.renderTTY(); + } else { + // Files counted are the ones the pool will process — exclude files already + // marked as ignored (binary/oversized) at construction time. + process.stdout.write(`\nAnalyzing ${this.processableTotal} files...\n`); + } + } + + /** + * Update a file's status by its global index across all files. + * TTY: redraws the full block. + * Non-TTY: prints a per-file result line on terminal transitions + * (`done`/`failed`); other transitions are absorbed silently. + */ + update(globalIndex: number, status: FileStatus): void { + if (this.silent) { + this.statuses[globalIndex] = status; + return; + } + if (isMockActive()) { + recordCall('sqaaProgress.update', globalIndex, status); + return; + } + this.statuses[globalIndex] = status; + if (this.isTTY) { + this.eraseTTY(); + this.renderTTY(); + return; + } + if (status === 'done' || status === 'failed') { + process.stdout.write(` ${fileStatusIcon(status)} ${this.allFiles[globalIndex]}\n`); + } + } + + /** + * Show a retry countdown for a file, waiting delayMs before resolving. + * Resets the file's status back to 'analyzing' when done so the caller + * can transition it without a stale [RETRYING...] flash. + * TTY: updates the file's label in the progress block each second. + * Non-TTY: prints a single static line then waits. + */ + async retrying( + globalIndex: number, + attempt: number, + maxRetries: number, + delayMs: number, + ): Promise { + if (this.silent) { + // Still wait so retry semantics are preserved without rendering. + this.statuses[globalIndex] = 'retrying'; + await sleep(delayMs); + this.statuses[globalIndex] = 'analyzing'; + return; + } + if (isMockActive()) { + recordCall('sqaaProgress.retrying', globalIndex, attempt, maxRetries, delayMs); + return; + } + const totalSeconds = Math.round(delayMs / COUNTDOWN_TICK_MS); + this.statuses[globalIndex] = 'retrying'; + + if (!this.isTTY) { + process.stdout.write( + `⚠️ Server busy (503). Retrying in ${totalSeconds}s... [Attempt ${attempt}/${maxRetries}]\n`, + ); + await sleep(delayMs); + this.statuses[globalIndex] = 'analyzing'; + return; + } + + for (let remaining = totalSeconds; remaining > 0; remaining--) { + this.retryLabels.set(globalIndex, `[RETRYING in ${remaining}s... ${attempt}/${maxRetries}]`); + this.eraseTTY(); + this.renderTTY(); + await sleep(COUNTDOWN_TICK_MS); + } + this.retryLabels.delete(globalIndex); + // Reset status here so no intervening redraw shows the stale [RETRYING...] fallback. + this.statuses[globalIndex] = 'analyzing'; + } + + /** + * Mark all files from fromIndex onwards as skipped. + * Call before finish() when fail-fast stops processing early. + */ + skipRemaining(fromIndex: number): void { + if (this.silent) { + // Still record skips in internal state so callers can read them. + for (let i = fromIndex; i < this.allFiles.length; i++) { + if (this.statuses[i] === 'waiting') { + this.statuses[i] = 'skipped'; + } + } + return; + } + if (isMockActive()) { + recordCall('sqaaProgress.skipRemaining', fromIndex); + return; + } + for (let i = fromIndex; i < this.allFiles.length; i++) { + if (this.statuses[i] === 'waiting') { + this.statuses[i] = 'skipped'; + } + } + } + + /** + * Read-only access to file statuses by global index. + * Exposed for testing and introspection (e.g. verifying silent-mode state transitions). + */ + getStatuses(): readonly FileStatus[] { + return this.statuses; + } + + /** + * Called once after the worker pool has joined (success or fail-fast). + * TTY: erases the live block, reprints the summary bar first, then the file list with + * final statuses — so the bar stays at the top and the list persists on screen. + * Non-TTY: prints any skipped files that were never reached (fail-fast path); + * non-skipped files were already printed in rolling order by `update()`. + */ + finish(processedTotal: number): void { + if (this.silent) return; + if (isMockActive()) { + recordCall('sqaaProgress.finish', processedTotal); + return; + } + if (this.isTTY) { + this.eraseTTY(); + const bar = renderBar(processedTotal, this.processableTotal); + process.stdout.write(`${bar} ${processedTotal}/${this.processableTotal} files analyzed\n\n`); + for (let i = 0; i < this.allFiles.length; i++) { + process.stdout.write( + formatFileLine( + this.allFiles[i], + statusIcon(this.statuses[i]), + statusLabel(this.statuses[i]), + this.colWidth, + ) + '\n', + ); + } + process.stdout.write('\n'); + this.linesRendered = 0; + } else { + // Print skipped files that the rolling per-file output never reached + // (fail-fast path — these files were never picked up by a worker). + for (let i = 0; i < this.allFiles.length; i++) { + if (this.statuses[i] === 'skipped') { + process.stdout.write(` ${fileStatusIcon('skipped')} ${this.allFiles[i]}\n`); + } + } + } + } + + private buildLines(): string[] { + const done = this.statuses.filter((s) => s === 'done' || s === 'failed').length; + const bar = renderBar(done, this.processableTotal); + const lines: string[] = [ + bold('SonarQube Agentic Analysis in progress...'), + `${bar} ${done}/${this.processableTotal} files analyzed`, + '', + ]; + for (let i = 0; i < this.allFiles.length; i++) { + lines.push( + formatFileLine( + this.allFiles[i], + statusIcon(this.statuses[i]), + statusLabel(this.statuses[i], this.retryLabels.get(i)), + this.colWidth, + ), + ); + } + return lines; + } + + private renderTTY(): void { + const lines = this.buildLines(); + for (const line of lines) { + process.stdout.write(line + '\n'); + } + this.linesRendered = lines.length; + } + + private eraseTTY(): void { + // Cap lines to erase at the current terminal height to avoid moving the cursor + // past the top of the viewport if the block scrolled out of view. + // Cap to terminal height when known, to avoid moving the cursor above the viewport. + const rows: number | undefined = process.stdout.rows; + const linesToErase = rows ? Math.min(this.linesRendered, rows - 1) : this.linesRendered; + for (let i = 0; i < linesToErase; i++) { + readline.moveCursor(process.stdout, 0, -1); + readline.clearLine(process.stdout, 0); + } + this.linesRendered = 0; + } +} diff --git a/tests/integration/harness/fake-sonarqube-server.ts b/tests/integration/harness/fake-sonarqube-server.ts index 1f5a9b2b..d57fb3f9 100644 --- a/tests/integration/harness/fake-sonarqube-server.ts +++ b/tests/integration/harness/fake-sonarqube-server.ts @@ -24,6 +24,8 @@ import type { SonarQubeIssue } from '../../../src/lib/types.js'; import type { SettingsValue } from '../../../src/sonarqube/settings-value.js'; import type { RecordedRequest } from './types.js'; +const HTTP_BAD_REQUEST = 400; + export interface IssueConfig { key?: string; ruleKey: string; @@ -122,6 +124,8 @@ export class FakeSonarQubeServerBuilder { private revokeTokenStatusCode = 204; private revokeTokenResponseBody = ''; private sqaaResponse?: SqaaResponseConfig; + private sqaaStatusCode?: number; + private sqaaStatusBody?: string; private scaEnabled?: boolean; private readonly projectSettings: Map = new Map(); private agentJobErrorCode?: number; @@ -202,6 +206,16 @@ export class FakeSonarQubeServerBuilder { return this; } + /** + * Force POST /a3s-analysis/analyses to return a specific HTTP status code. + * Takes precedence over withSqaaResponse. Useful for testing 429, 503, etc. + */ + withSqaaStatusCode(status: number, body?: string): this { + this.sqaaStatusCode = status; + this.sqaaStatusBody = body; + return this; + } + withSqaaEntitlement( orgKey: string, uuid: string, @@ -248,6 +262,8 @@ export class FakeSonarQubeServerBuilder { revokeTokenStatusCode, revokeTokenResponseBody, sqaaResponse, + sqaaStatusCode, + sqaaStatusBody, sqaaEntitlementOrgs, scaEnabled, projectSettings, @@ -324,7 +340,7 @@ export class FakeSonarQubeServerBuilder { } if (path === '/api/user_tokens/revoke' && req.method === 'POST') { - if (revokeTokenStatusCode >= 400) { + if (revokeTokenStatusCode >= HTTP_BAD_REQUEST) { return new Response(revokeTokenResponseBody, { status: revokeTokenStatusCode }); } @@ -562,6 +578,13 @@ export class FakeSonarQubeServerBuilder { } if (path === '/a3s-analysis/analyses' && req.method === 'POST') { + if (sqaaStatusCode !== undefined) { + return new Response(JSON.stringify({ message: sqaaStatusBody ?? 'simulated error' }), { + status: sqaaStatusCode, + headers: { 'Content-Type': 'application/json' }, + }); + } + if (!sqaaResponse) { return new Response( JSON.stringify({ errors: [{ msg: 'SQAA endpoint not configured' }] }), diff --git a/tests/integration/specs/analyze/analyze-secrets.test.ts b/tests/integration/specs/analyze/analyze-secrets.test.ts index abfc7fae..3dee8564 100644 --- a/tests/integration/specs/analyze/analyze-secrets.test.ts +++ b/tests/integration/specs/analyze/analyze-secrets.test.ts @@ -171,14 +171,14 @@ describe('analyze secrets', () => { ); it( - 'exits with code 1 when neither paths nor --stdin is provided', + 'exits with code 2 when neither paths nor --stdin is provided', async () => { harness.state().withSecretsBinaryInstalled(); harness.withAuth(FAKE_SERVER, 'fake-token'); const result = await harness.run('analyze secrets'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain( 'Either provide file/directory paths or --stdin', ); @@ -187,14 +187,14 @@ describe('analyze secrets', () => { ); it( - 'exits with code 1 for non-existent file path', + 'exits with code 2 for non-existent file path', async () => { harness.state().withSecretsBinaryInstalled(); harness.withAuth(FAKE_SERVER, 'fake-token'); const result = await harness.run('analyze secrets /nonexistent/path/file.txt'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('Path not found'); }, { timeout: 15000 }, @@ -226,13 +226,13 @@ describe('analyze secrets', () => { ); it( - 'exits with code 1 when both paths and --stdin are provided', + 'exits with code 2 when both paths and --stdin are provided', async () => { harness.withAuth(FAKE_SERVER, 'fake-token'); const result = await harness.run('analyze secrets somefile.js --stdin'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('Cannot use both paths and --stdin'); }, { timeout: 15000 }, diff --git a/tests/integration/specs/analyze/analyze-sqaa.test.ts b/tests/integration/specs/analyze/analyze-sqaa.test.ts index 42b773a5..ca27f1fe 100644 --- a/tests/integration/specs/analyze/analyze-sqaa.test.ts +++ b/tests/integration/specs/analyze/analyze-sqaa.test.ts @@ -20,9 +20,13 @@ // Integration tests for `analyze sqaa` and `verify` commands. +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { TestHarness } from '../../harness'; +import { commitFile, git, initGitRepo, stageFile } from '../hook/git-test-helpers'; const VALID_TOKEN = 'integration-test-token'; const TEST_ORG = 'my-org'; @@ -65,14 +69,14 @@ describe('analyze sqaa', () => { }); it( - 'exits with code 1 when file does not exist', + 'exits with code 2 when file does not exist', async () => { const server = await harness.newFakeServer().withAuthToken(VALID_TOKEN).start(); harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); const result = await harness.run('analyze sqaa --file nonexistent.ts'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('File not found'); }, { timeout: 15000 }, @@ -144,63 +148,7 @@ describe('analyze sqaa', () => { ); it( - 'exits with code 0, warns, and skips SQAA when --branch is provided but no project is registered', - async () => { - const server = await harness - .newFakeServer() - .withAuthToken(VALID_TOKEN) - .withSqaaResponse({ issues: [] }) - .start(); - - // Cloud connection with orgKey but no extension registered → no projectKey → skip - harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); - - harness.cwd.writeFile('src/index.ts', 'const x = 1;'); - - const result = await harness.run('analyze sqaa --file src/index.ts --branch main'); - - expect(result.exitCode).toBe(0); - expect(result.stdout + result.stderr).toContain( - 'SonarQube Agentic Analysis skipped: no project configured. Specify one with --project or run: sonar integrate claude', - ); - const sqaaCalls = server - .getRecordedRequests() - .filter((r) => r.path === '/a3s-analysis/analyses'); - expect(sqaaCalls).toHaveLength(0); - }, - { timeout: 15000 }, - ); - - it( - 'calls SQAA API when --project is provided without extension registry entry', - async () => { - const server = await harness - .newFakeServer() - .withAuthToken(VALID_TOKEN) - .withSqaaResponse({ issues: [] }) - .start(); - - // Cloud auth only — no extension registered; --project bypasses registry lookup - harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); - - harness.cwd.writeFile('src/index.ts', 'const x = 1;'); - - const result = await harness.run( - `analyze sqaa --file src/index.ts --project ${TEST_PROJECT}`, - ); - - expect(result.exitCode).toBe(0); - expect(result.stdout + result.stderr).toContain('no issues found'); - const sqaaCalls = server - .getRecordedRequests() - .filter((r) => r.path === '/a3s-analysis/analyses'); - expect(sqaaCalls).toHaveLength(1); - }, - { timeout: 15000 }, - ); - - it( - 'calls SQAA API when both --project and --branch are provided', + 'calls SQAA API when --project and --branch are provided (bypasses extension registry)', async () => { const server = await harness .newFakeServer() @@ -208,7 +156,7 @@ describe('analyze sqaa', () => { .withSqaaResponse({ issues: [] }) .start(); - // Cloud auth only — --project + --branch both provided + // Cloud auth only — no extension registered; --project + --branch bypass registry lookup harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -278,7 +226,7 @@ describe('analyze sqaa', () => { const result = await harness.run('analyze sqaa --file main.py'); - expect(result.exitCode).toBe(0); + expect(result.exitCode).toBe(51); const output = result.stdout + result.stderr; expect(output).toContain('2 issues'); expect(output).toContain('Refactor this method'); @@ -318,11 +266,15 @@ describe('analyze sqaa', () => { ); }); -describe('verify', () => { +describe('analyze sqaa — change-set mode (no --file)', () => { let harness: TestHarness; beforeEach(async () => { harness = await TestHarness.create(); + // All change-set tests need a real git repo in cwd. + initGitRepo(harness.cwd.path); + // Ignore harness-internal files the CLI binary may create in cwd (e.g. .claude/). + commitFile(harness.cwd.path, '.gitignore', '.claude/\n'); }); afterEach(async () => { @@ -330,43 +282,993 @@ describe('verify', () => { }); it( - 'exits with code 1 when file does not exist', + 'exits with code 0 and reports no files when change set is empty', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + // Empty repo: first commit with no changes after it. + commitFile(harness.cwd.path, 'README.md', 'hello'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('no files in the change set'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(0); + }, + { timeout: 15000 }, + ); + + it( + 'default mode: analyzes unstaged modified files vs HEAD', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'app.ts', 'const a = 1;'); + // Modify without staging — should appear in `git diff HEAD` + harness.cwd.writeFile('app.ts', 'const a = 2;'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('change set is clean'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(1); + }, + { timeout: 15000 }, + ); + + it( + 'default mode: includes untracked non-ignored files', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + // New untracked file — not in any commit, not ignored + harness.cwd.writeFile('new-feature.ts', 'export const x = 1;'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('change set is clean'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(1); + }, + { timeout: 15000 }, + ); + + it( + 'default mode: excludes git-ignored files', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + // Append dist/ to the existing .gitignore (already committed in beforeEach) + commitFile(harness.cwd.path, '.gitignore', '.claude/\ndist/\n'); + harness.cwd.writeFile('dist/bundle.js', 'console.log("built");'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + // No files to analyze (ignored file excluded, nothing else changed) + expect(result.stdout + result.stderr).toContain('no files in the change set'); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 2 when --staged and --base are combined', async () => { const server = await harness.newFakeServer().withAuthToken(VALID_TOKEN).start(); harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); - const result = await harness.run('verify --file nonexistent.ts'); + commitFile(harness.cwd.path, 'README.md', 'hello'); - expect(result.exitCode).toBe(1); - expect(result.stdout + result.stderr).toContain('File not found'); + const result = await harness.run('analyze sqaa --staged --base main'); + + expect(result.exitCode).toBe(2); + expect(result.stdout + result.stderr).toContain( + '--staged and --base cannot be used together', + ); }, { timeout: 15000 }, ); it( - 'calls SQAA API and reports no issues found for clean file', + 'warns but auto-proceeds in non-TTY when change set exceeds the large-set threshold', async () => { const server = await harness .newFakeServer() .withAuthToken(VALID_TOKEN) .withSqaaResponse({ issues: [] }) .start(); + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + for (let i = 1; i <= 51; i++) { + harness.cwd.writeFile(`file${i}.ts`, `const x${i} = ${i};`); + } + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('large number of files (51)'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(51); + }, + { timeout: 30000 }, + ); + it( + 'skips the large change set warning with --force', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); - harness.cwd.writeFile('src/index.ts', 'const x = 1;'); + commitFile(harness.cwd.path, 'README.md', 'hello'); + for (let i = 1; i <= 51; i++) { + harness.cwd.writeFile(`file${i}.ts`, `const x${i} = ${i};`); + } - const result = await harness.run('verify --file src/index.ts'); + const result = await harness.run('analyze sqaa --force'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).not.toContain('large number of files'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(51); + }, + { timeout: 30000 }, + ); + + it( + '--staged: analyzes only staged files', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + stageFile(harness.cwd.path, 'staged.ts', 'const s = 1;'); + // Unstaged modification — should not be included + harness.cwd.writeFile('unstaged.ts', 'const u = 1;'); + + const result = await harness.run('analyze sqaa --staged'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('change set is clean'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(1); + }, + { timeout: 15000 }, + ); + + it( + '--staged: exits with code 0 and no API call when nothing is staged', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + + const result = await harness.run('analyze sqaa --staged'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('no files in the change set'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(0); + }, + { timeout: 15000 }, + ); + + it( + '--base : analyzes files changed vs base branch', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + // Establish a base commit on master + commitFile(harness.cwd.path, 'base.ts', 'const base = 1;'); + // Create a feature branch and add a new file + git(['checkout', '-b', 'feature'], harness.cwd.path); + commitFile(harness.cwd.path, 'feature.ts', 'const f = 1;'); + + const result = await harness.run('analyze sqaa --base master'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('change set is clean'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(1); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 51 when issues are found in change-set', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ + issues: [{ rule: 'ts:S1234', message: 'Fix this', startLine: 1 }], + }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + harness.cwd.writeFile('dirty.ts', 'const x = 1;'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(51); + expect(result.stdout + result.stderr).toContain('Fix this'); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 0 and skips SQAA for on-premise server in change-set mode', + async () => { + const server = await harness.newFakeServer().withAuthToken(VALID_TOKEN).start(); + harness.withAuth(server.baseUrl(), VALID_TOKEN); // no orgKey → on-premise + + commitFile(harness.cwd.path, 'README.md', 'hello'); + harness.cwd.writeFile('app.ts', 'const a = 1;'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(0); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 0 and warns when no project is configured in change-set mode', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + // Cloud auth but no extension registered → no projectKey in registry → skip + harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + harness.cwd.writeFile('app.ts', 'const a = 1;'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain( + 'SonarQube Agentic Analysis skipped: no project configured', + ); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(0); + }, + { timeout: 15000 }, + ); + + it( + 'excludes binary files from the change set', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + // Write a file with a NUL byte — detected as binary and excluded + writeFileSync(join(harness.cwd.path, 'image.bin'), Buffer.from([0x89, 0x50, 0x00, 0x4e])); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + // Binary file shown as IGNORED — no files to analyze + expect(result.stdout + result.stderr).toContain('all change set files were excluded'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(0); + }, + { timeout: 15000 }, + ); + + it( + 'excludes files that exceed the 10 MB size limit', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + // Write a file slightly over 10 MB + writeFileSync(join(harness.cwd.path, 'huge.ts'), Buffer.alloc(10 * 1024 * 1024 + 1, 'a')); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(0); + // Oversized file shown as IGNORED — no files to analyze + expect(result.stdout + result.stderr).toContain('all change set files were excluded'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(0); + }, + { timeout: 15000 }, + ); + + it( + 'does not report "change set is clean" when the API returned errors for every file', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ + issues: [], + errors: [{ code: 'NOT_ENTITLED', message: 'Organization is not entitled to SQAA' }], + }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + stageFile(harness.cwd.path, 'staged.ts', 'const s = 1;'); + + const result = await harness.run('analyze sqaa --staged'); + + const output = result.stdout + result.stderr; + expect(output).toContain('NOT_ENTITLED'); + // No issues were reported, so exit code must not be 51. + expect(result.exitCode).not.toBe(51); + // When the server returned errors for every file, don't mislead the user with "clean". + expect(output).not.toContain('change set is clean'); + }, + { timeout: 15000 }, + ); + + it( + 'exits with code 1 when the cwd is not a git repository', + async () => { + // Create a second harness whose cwd is not a git repo (no initGitRepo called). + const bareHarness = await TestHarness.create(); + const server = await bareHarness.newFakeServer().withAuthToken(VALID_TOKEN).start(); + + bareHarness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(bareHarness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + bareHarness.cwd.writeFile('app.ts', 'const a = 1;'); + + const result = await bareHarness.run('analyze sqaa'); + + await bareHarness.dispose(); + + // git rev-parse --show-toplevel fails outside a git repo → CommandFailedError → exit 1 + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('not a git repository'); + }, + { timeout: 15000 }, + ); +}); + +describe('verify — change-set mode (no --file)', () => { + let harness: TestHarness; + + beforeEach(async () => { + harness = await TestHarness.create(); + initGitRepo(harness.cwd.path); + commitFile(harness.cwd.path, '.gitignore', '.claude/\n'); + }); + + afterEach(async () => { + await harness.dispose(); + }); + + it( + 'default mode: analyzes untracked files and reports no issues', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + harness.cwd.writeFile('new.ts', 'const x = 1;'); + + const result = await harness.run('verify'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('change set is clean'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(1); + }, + { timeout: 15000 }, + ); + + it( + '--staged: analyzes only staged files', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + stageFile(harness.cwd.path, 'staged.ts', 'const s = 1;'); + harness.cwd.writeFile('unstaged.ts', 'const u = 1;'); + + const result = await harness.run('verify --staged'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('change set is clean'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + // Only staged.ts is sent — unstaged.ts is excluded + expect(sqaaCalls).toHaveLength(1); + }, + { timeout: 15000 }, + ); + + it( + 'warns but auto-proceeds in non-TTY when change set exceeds the large-set threshold', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + for (let i = 1; i <= 51; i++) { + harness.cwd.writeFile(`file${i}.ts`, `const x${i} = ${i};`); + } + + const result = await harness.run('verify'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).toContain('large number of files (51)'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(51); + }, + { timeout: 30000 }, + ); + + it( + 'skips the large change set warning with --force', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + for (let i = 1; i <= 51; i++) { + harness.cwd.writeFile(`file${i}.ts`, `const x${i} = ${i};`); + } + + const result = await harness.run('verify --force'); + + expect(result.exitCode).toBe(0); + expect(result.stdout + result.stderr).not.toContain('large number of files'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(51); + }, + { timeout: 30000 }, + ); +}); + +describe('analyze sqaa — API error codes', () => { + let harness: TestHarness; + + beforeEach(async () => { + harness = await TestHarness.create(); + }); + + afterEach(async () => { + await harness.dispose(); + }); + + it( + 'exits with code 1 and shows rate-limit message on 429', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaStatusCode(429) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + harness.cwd.writeFile('src/index.ts', 'const x = 1;'); + + const result = await harness.run('analyze sqaa --file src/index.ts'); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('Rate limit reached'); + }, + { timeout: 15000 }, + ); + + it( + 'retries 3 times on 503 then exits with code 1', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaStatusCode(503) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + harness.cwd.writeFile('src/index.ts', 'const x = 1;'); + + const result = await harness.run('analyze sqaa --file src/index.ts'); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('Server busy'); + // 4 total attempts: 1 initial + 3 retries + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(4); + }, + { timeout: 30000 }, + ); + + it( + 'retries 503 for multiple files concurrently in change-set mode', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaStatusCode(503) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + initGitRepo(harness.cwd.path); + commitFile(harness.cwd.path, '.gitignore', '.claude/\n'); + // Two files in the same batch will both hit 503 concurrently. + harness.cwd.writeFile('a.ts', 'const a = 1;'); + harness.cwd.writeFile('b.ts', 'const b = 2;'); + + const result = await harness.run('analyze sqaa'); + + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('Server busy'); + // Each file gets 1 initial + 3 retries = 4 attempts; 2 files = 8 total. + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(8); + }, + { timeout: 60000 }, + ); + + it( + 'outputs errors to stderr and results to stdout', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [{ rule: 'ts:S1135', message: 'TODO', startLine: 1 }] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + harness.cwd.writeFile('src/index.ts', '// TODO: fix\nconst x = 1;'); + + const result = await harness.run('analyze sqaa --file src/index.ts'); + + expect(result.exitCode).toBe(51); + // Issue details are on stdout. + expect(result.stdout).toContain('TODO'); + // No Sonar error text should appear on stdout. + expect(result.stdout).not.toContain('❌ SonarQube Agentic Analysis failed'); + }, + { timeout: 15000 }, + ); +}); + +describe('analyze sqaa — --format json', () => { + let harness: TestHarness; + + beforeEach(async () => { + harness = await TestHarness.create(); + initGitRepo(harness.cwd.path); + commitFile(harness.cwd.path, '.gitignore', '.claude/\n'); + }); + + afterEach(async () => { + await harness.dispose(); + }); + + it( + 'outputs valid JSON report for a clean single file', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + harness.cwd.writeFile('src/index.ts', 'const x = 1;'); + + const result = await harness.run('analyze sqaa --file src/index.ts --format json'); + + expect(result.exitCode).toBe(0); + const report = JSON.parse(result.stdout) as { + files: { path: string; issues: unknown[] }[]; + ignored: unknown[]; + failures: unknown[]; + summary: { totalIssues: number; totalFailures: number }; + }; + expect(report.files).toHaveLength(1); + expect(report.files[0].path).toBe('src/index.ts'); + expect(report.files[0].issues).toHaveLength(0); + expect(report.ignored).toHaveLength(0); + expect(report.failures).toHaveLength(0); + expect(report.summary.totalIssues).toBe(0); + expect(report.summary.totalFailures).toBe(0); + }, + { timeout: 15000 }, + ); + + it( + 'outputs valid JSON report with issues for single file', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ + issues: [{ rule: 'ts:S1135', message: 'Fix this TODO', startLine: 1 }], + }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + harness.cwd.writeFile('src/index.ts', '// TODO: fix\nconst x = 1;'); + + const result = await harness.run('analyze sqaa --file src/index.ts --format json'); + + expect(result.exitCode).toBe(51); + const report = JSON.parse(result.stdout) as { + files: { path: string; issues: { rule: string; message: string }[] }[]; + summary: { totalIssues: number }; + }; + expect(report.files[0].issues).toHaveLength(1); + expect(report.files[0].issues[0].rule).toBe('ts:S1135'); + expect(report.summary.totalIssues).toBe(1); + }, + { timeout: 15000 }, + ); + + it( + 'outputs valid JSON report for change-set mode with ignored files', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + harness.cwd.writeFile('src/index.ts', 'const x = 1;'); + // Binary file — should appear in ignored + writeFileSync(join(harness.cwd.path, 'image.bin'), Buffer.from([0x89, 0x50, 0x00, 0x4e])); + + const result = await harness.run('analyze sqaa --format json'); + + expect(result.exitCode).toBe(0); + const report = JSON.parse(result.stdout) as { + files: { path: string }[]; + ignored: { path: string; reason: string }[]; + failures: unknown[]; + summary: { totalIssues: number; totalFailures: number }; + }; + expect(report.files).toHaveLength(1); + expect(report.files[0].path).toBe('src/index.ts'); + expect(report.ignored).toHaveLength(1); + expect(report.ignored[0].reason).toBe('binary'); + expect(report.failures).toHaveLength(0); + expect(report.summary.totalIssues).toBe(0); + }, + { timeout: 15000 }, + ); + + it( + 'JSON report surfaces files skipped on fail-fast and skips the large-changeset prompt', + async () => { + // 429 fails immediately (no retry), so the first worker to fail triggers + // fail-fast and later files are never picked up — without the long 503 backoff. + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaStatusCode(429) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + // 51 files: > concurrency (20) so fail-fast leaves later files skipped, + // and > large-changeset threshold (50) so we also exercise the no-prompt path. + for (let i = 1; i <= 51; i++) { + harness.cwd.writeFile(`file${i}.ts`, `const x${i} = ${i};`); + } + + const result = await harness.run('analyze sqaa --format json'); + + expect(result.exitCode).toBe(1); + // JSON consumers should never see the interactive prompt warning. + expect(result.stderr).not.toContain('large number of files'); + + const report = JSON.parse(result.stdout) as { + files: unknown[]; + failures: { path: string; message: string }[]; + skipped: string[]; + summary: { totalIssues: number; totalFailures: number; totalSkipped: number }; + }; + expect(report.failures.length).toBeGreaterThan(0); + expect(report.skipped.length).toBeGreaterThan(0); + expect(report.summary.totalSkipped).toBe(report.skipped.length); + // Every staged file ends up in exactly one bucket (succeeded/failed/skipped). + expect(report.files.length + report.failures.length + report.skipped.length).toBe(51); + }, + { timeout: 30000 }, + ); +}); + +describe('analyze sqaa — running from a subdirectory', () => { + let harness: TestHarness; + + beforeEach(async () => { + harness = await TestHarness.create(); + initGitRepo(harness.cwd.path); + commitFile(harness.cwd.path, '.gitignore', '.claude/\n'); + }); + + afterEach(async () => { + await harness.dispose(); + }); + + it( + 'resolves repo-root project key and full change set when invoked from a subdirectory', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + // Extension is registered against the repo root, just like `sonar integrate claude` does. + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + // One change above and one below the subdirectory, so we cover both + // sides of the previous join(cwd, repoRelativePath) bug. + harness.cwd.writeFile('top-level.ts', 'export const a = 1;'); + harness.cwd.writeFile('src/ui/inside.ts', 'export const b = 2;'); + + const subdir = join(harness.cwd.path, 'src', 'ui'); + const result = await harness.run('analyze sqaa', { cwd: subdir }); + + expect(result.exitCode).toBe(0); + const output = result.stdout + result.stderr; + // Project must still be found — no fallthrough to the "no project configured" warning. + expect(output).not.toContain('no project configured'); + expect(output).toContain('change set is clean'); + + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(2); + + // Paths sent to SQAA are relative to the repo root regardless of cwd. + const filePaths = sqaaCalls + .map((c) => (JSON.parse(c.body ?? '{}') as { filePath?: string }).filePath) + .sort(); + expect(filePaths).toEqual(['src/ui/inside.ts', 'top-level.ts']); + }, + { timeout: 15000 }, + ); + + it( + 'handles paths containing whitespace via -z parsing', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(VALID_TOKEN) + .withSqaaResponse({ issues: [] }) + .start(); + + harness + .state() + .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) + .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + + commitFile(harness.cwd.path, 'README.md', 'hello'); + // Filename with a space — would be corrupted by the previous `.trim()`-based parser. + harness.cwd.writeFile('with space.ts', 'export const x = 1;'); + + const result = await harness.run('analyze sqaa'); expect(result.exitCode).toBe(0); - expect(result.stdout + result.stderr).toContain('no issues found'); const sqaaCalls = server .getRecordedRequests() .filter((r) => r.path === '/a3s-analysis/analyses'); expect(sqaaCalls).toHaveLength(1); + const sentPath = (JSON.parse(sqaaCalls[0].body ?? '{}') as { filePath?: string }).filePath; + expect(sentPath).toBe('with space.ts'); }, { timeout: 15000 }, ); diff --git a/tests/integration/specs/api/api.test.ts b/tests/integration/specs/api/api.test.ts index 6641bb6a..91c8b1fd 100644 --- a/tests/integration/specs/api/api.test.ts +++ b/tests/integration/specs/api/api.test.ts @@ -47,65 +47,65 @@ describe('api', () => { ); it( - 'exits with code 1 for an invalid HTTP method', + 'exits with code 2 for an invalid HTTP method', async () => { harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('api trace /api/system/status'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain("Invalid HTTP method 'trace'"); }, { timeout: 15000 }, ); it( - 'exits with code 1 when endpoint does not start with /', + 'exits with code 2 when endpoint does not start with /', async () => { harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('api get api/system/status'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain("Endpoint must start with '/'"); }, { timeout: 15000 }, ); it( - 'exits with code 1 when --data is used with GET', + 'exits with code 2 when --data is used with GET', async () => { harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run(`api get /api/system/status --data '{"k":"v"}'`); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('--data is only valid for'); }, { timeout: 15000 }, ); it( - 'exits with code 1 when --data is used with DELETE', + 'exits with code 2 when --data is used with DELETE', async () => { harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run(`api delete /api/system/status --data '{"k":"v"}'`); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('--data is only valid for'); }, { timeout: 15000 }, ); it( - 'exits with code 1 when --data is not valid JSON', + 'exits with code 2 when --data is not valid JSON', async () => { harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('api post /api/system/status --data not-json'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('--data must be valid JSON'); }, { timeout: 15000 }, diff --git a/tests/integration/specs/auth/auth.test.ts b/tests/integration/specs/auth/auth.test.ts index 5273b649..35d6a8a3 100644 --- a/tests/integration/specs/auth/auth.test.ts +++ b/tests/integration/specs/auth/auth.test.ts @@ -52,11 +52,11 @@ describe('auth login', () => { }); it( - 'exits with code 1 when --server is not a valid URL', + 'exits with code 2 when --server is not a valid URL', async () => { const result = await harness.run('auth login --server not-a-url --with-token mytoken'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('Invalid server URL'); }, { timeout: 15000 }, diff --git a/tests/integration/specs/config/config-telemetry.test.ts b/tests/integration/specs/config/config-telemetry.test.ts index 983edfc4..cad86957 100644 --- a/tests/integration/specs/config/config-telemetry.test.ts +++ b/tests/integration/specs/config/config-telemetry.test.ts @@ -36,11 +36,11 @@ describe('config telemetry', () => { }); it( - 'exits with code 1 when both --enabled and --disabled are provided', + 'exits with code 2 when both --enabled and --disabled are provided', async () => { const result = await harness.run('config telemetry --enabled --disabled'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('Cannot use both --enabled and --disabled'); }, { timeout: 15000 }, diff --git a/tests/integration/specs/integrate/copilot.test.ts b/tests/integration/specs/integrate/copilot.test.ts index 566258ab..ba5a777f 100644 --- a/tests/integration/specs/integrate/copilot.test.ts +++ b/tests/integration/specs/integrate/copilot.test.ts @@ -587,11 +587,11 @@ describe('integrate copilot', () => { describe('option validation', () => { it( - 'exits with code 1 when both --global and --project are provided', + 'exits with code 2 when both --global and --project are provided', async () => { const result = await harness.run('integrate copilot --global --project foo'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain( '--global and --project are mutually exclusive', ); diff --git a/tests/integration/specs/list/list-issues.test.ts b/tests/integration/specs/list/list-issues.test.ts index 9d139e03..0a976d7c 100644 --- a/tests/integration/specs/list/list-issues.test.ts +++ b/tests/integration/specs/list/list-issues.test.ts @@ -233,14 +233,14 @@ describe('list issues — argument validation', () => { ); it( - 'exits with code 1 when --page-size is less than 1', + 'exits with code 2 when --page-size is less than 1', async () => { // Validation runs inside the handler — auth must pass first harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('list issues --project my-project --page-size 0'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain( "Invalid --page-size option: '0'. Must be an integer between 1 and 500", ); @@ -249,14 +249,14 @@ describe('list issues — argument validation', () => { ); it( - 'exits with code 1 when --page-size is greater than 500', + 'exits with code 2 when --page-size is greater than 500', async () => { // Validation runs inside the handler — auth must pass first harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('list issues --project my-project --page-size 501'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('Invalid --page-size'); }, { timeout: 15000 }, @@ -279,14 +279,14 @@ describe('list issues — argument validation', () => { ); it( - 'exits with code 1 when --severities is not a recognised value', + 'exits with code 2 when --severities is not a recognised value', async () => { // Validation runs inside the handler — auth must pass first harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('list issues --project my-project --severities UNKNOWN'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('Invalid severity'); }, { timeout: 15000 }, @@ -344,14 +344,14 @@ describe('list issues — argument validation', () => { ); it( - 'exits with code 1 when --statuses is not a recognised value', + 'exits with code 2 when --statuses is not a recognised value', async () => { // Validation runs inside the handler — auth must pass first harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('list issues --project my-project --statuses UNKNOWN'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain('Invalid status(es)'); }, { timeout: 15000 }, diff --git a/tests/integration/specs/list/list-projects.test.ts b/tests/integration/specs/list/list-projects.test.ts index 795ceb37..ea52e504 100644 --- a/tests/integration/specs/list/list-projects.test.ts +++ b/tests/integration/specs/list/list-projects.test.ts @@ -125,14 +125,14 @@ describe('list projects', () => { ); it( - 'exits with code 1 when --page-size is 0', + 'exits with code 2 when --page-size is 0', async () => { // Validation runs inside the handler — auth must pass first harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('list projects --page-size 0'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain( "Invalid --page-size option: '0'. Must be an integer between 1 and 500", ); @@ -141,14 +141,14 @@ describe('list projects', () => { ); it( - 'exits with code 1 when --page-size exceeds 500', + 'exits with code 2 when --page-size exceeds 500', async () => { // Validation runs inside the handler — auth must pass first harness.withAuth('http://localhost:19999', 'fake-token'); const result = await harness.run('list projects --page-size 501'); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); expect(result.stdout + result.stderr).toContain( "Invalid --page-size option: '501'. Must be an integer between 1 and 500", ); diff --git a/tests/integration/specs/remediate/remediate.test.ts b/tests/integration/specs/remediate/remediate.test.ts index 90b03832..10a9f78c 100644 --- a/tests/integration/specs/remediate/remediate.test.ts +++ b/tests/integration/specs/remediate/remediate.test.ts @@ -640,7 +640,7 @@ describe('sonar remediate', () => { ); it( - 'exits with code 1 when more than 20 issue keys are supplied', + 'exits with code 2 when more than 20 issue keys are supplied', async () => { const server = await harness .newFakeServer() @@ -652,7 +652,7 @@ describe('sonar remediate', () => { const tooMany = Array.from({ length: 21 }, (_, i) => `k${i + 1}`).join(','); const result = await harness.run(`remediate --project ${TEST_PROJECT} --issues ${tooMany}`); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); const output = result.stdout + result.stderr; expect(output).toContain('--issues accepts at most 20 issue keys'); expect(output).toContain('got 21'); @@ -668,7 +668,7 @@ describe('sonar remediate', () => { ); it( - 'exits with code 1 when --issues contains empty entries', + 'exits with code 2 when --issues contains empty entries', async () => { const server = await harness .newFakeServer() @@ -679,7 +679,7 @@ describe('sonar remediate', () => { const result = await harness.run(`remediate --project ${TEST_PROJECT} --issues "k1,,k2"`); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); const output = result.stdout + result.stderr; expect(output).toContain('Empty entries are not allowed'); @@ -802,7 +802,7 @@ describe('sonar remediate', () => { const tooMany = Array.from({ length: 21 }, (_, i) => `k${i + 1}`).join(','); const result = await harness.run(`remediate --project ${TEST_PROJECT} --issues ${tooMany}`); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); const output = result.stdout + result.stderr; expect(output).toContain('--issues accepts at most 20 issue keys'); expect(output).not.toContain('requires SonarQube Cloud'); @@ -879,7 +879,7 @@ describe('sonar remediate', () => { extraEnv: { SONARQUBE_CLI_MOCK_TTY: '' }, }); - expect(result.exitCode).toBe(1); + expect(result.exitCode).toBe(2); const output = result.stdout + result.stderr; expect(output).toContain('Empty entries are not allowed'); expect(output).not.toContain('Non-interactive mode requires'); diff --git a/tests/unit/cli/commands/_common/sonar-command.test.ts b/tests/unit/cli/commands/_common/sonar-command.test.ts index 39066c06..f64c1dfe 100644 --- a/tests/unit/cli/commands/_common/sonar-command.test.ts +++ b/tests/unit/cli/commands/_common/sonar-command.test.ts @@ -22,7 +22,10 @@ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; -import { CommandFailedError } from '../../../../../src/cli/commands/_common/error'; +import { + CommandFailedError, + InvalidOptionError, +} from '../../../../../src/cli/commands/_common/error'; import { SonarCommand } from '../../../../../src/cli/commands/_common/sonar-command'; import type { ResolvedAuth } from '../../../../../src/lib/auth-resolver'; import * as authResolver from '../../../../../src/lib/auth-resolver'; @@ -83,6 +86,14 @@ describe('SonarCommand', () => { expect(process.exitCode).toBe(1); }); + it('sets process.exitCode to 2 on InvalidOptionError', async () => { + const cmd = new SonarCommand(); + await cmd.runCommand(() => { + throw new InvalidOptionError('bad flag'); + }); + expect(process.exitCode).toBe(2); + }); + it('uses the exit code from CommandFailedError', async () => { const cmd = new SonarCommand(); await cmd.runCommand(() => { diff --git a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts index a34c29d8..ecd6464b 100644 --- a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts +++ b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts @@ -29,6 +29,7 @@ import { InvalidOptionError, } from '../../../../../src/cli/commands/_common/error.js'; import { analyzeSqaa } from '../../../../../src/cli/commands/analyze/sqaa'; +import * as processLib from '../../../../../src/lib/process.js'; import * as stateRepository from '../../../../../src/lib/repository/state-repository.js'; import { getDefaultState } from '../../../../../src/lib/state.js'; import * as stateManager from '../../../../../src/lib/state-manager.js'; @@ -54,6 +55,7 @@ let saveStateSpy: ReturnType; let existsSpy: ReturnType; let readFileSpy: ReturnType; let analyzeFileSpy: ReturnType; +let spawnProcessSpy: ReturnType; /** Cloud state WITH a sonar-sqaa extension entry for the current project root */ function makeCloudState() { @@ -93,6 +95,16 @@ beforeEach(() => { issues: [], errors: null, }); + + // Mock the git-based repo-root resolver so unit tests don't shell out to git. + // Without this, parallel Bun test workers each spawn `git rev-parse --show-toplevel` + // and the OS-level contention causes intermittent flakes. We return process.cwd() + // so the registered extension's projectRoot still matches the lookup key. + spawnProcessSpy = spyOn(processLib, 'spawnProcess').mockResolvedValue({ + exitCode: 0, + stdout: process.cwd() + '\n', + stderr: '', + }); }); afterEach(() => { @@ -102,6 +114,7 @@ afterEach(() => { existsSpy.mockRestore(); readFileSpy.mockRestore(); analyzeFileSpy.mockRestore(); + spawnProcessSpy.mockRestore(); }); // ─── analyzeSqaa ───────────────────────────────────────────────────────────── @@ -176,30 +189,6 @@ describe('analyzeSqaa: API call and result display', () => { expect(request.branchName).toBe('feature/my-branch'); }); - it('displays both issues and errors when response contains both', async () => { - analyzeFileSpy.mockResolvedValue({ - id: 'a1', - issues: [ - { - rule: 'cpp:S1186', - message: 'Add a nested comment explaining why this method is empty.', - textRange: { startLine: 2, endLine: 2, startOffset: 28, endOffset: 30 }, - }, - ], - errors: [{ code: 'PARSE_ERROR', message: "'NonExistentHeader.h' file not found" }], - }); - - await analyzeSqaa({ file: 'src/index.ts' }, FAKE_AUTH); - - const output = getMockUiCalls() - .map((c) => String(c.args[0])) - .join('\n'); - expect(output).toContain('cpp:S1186'); - expect(output).toContain('Add a nested comment'); - expect(output).toContain('PARSE_ERROR'); - expect(output).toContain('NonExistentHeader.h'); - }); - it('throws CommandFailedError when SQAA API call fails', () => { analyzeFileSpy.mockRejectedValue(new Error('Network error')); @@ -230,13 +219,6 @@ describe('analyzeSqaa: path normalization', () => { // ─── analyzeSqaa: explicit --project option ────────────────────────────────── describe('analyzeSqaa: explicit --project option', () => { - it('uses provided project key even when extension has a different project key', async () => { - await analyzeSqaa({ file: 'src/index.ts', project: 'override-project' }, FAKE_AUTH); - - expect(analyzeFileSpy).toHaveBeenCalledTimes(1); - expect(analyzeFileSpy.mock.calls[0][0].projectKey).toBe('override-project'); - }); - it('throws CommandFailedError when --project given but on-premise server', () => { const onPremiseAuth = { token: TEST_TOKEN, diff --git a/tests/unit/ui/sqaa-progress.test.ts b/tests/unit/ui/sqaa-progress.test.ts new file mode 100644 index 00000000..d671f7d8 --- /dev/null +++ b/tests/unit/ui/sqaa-progress.test.ts @@ -0,0 +1,231 @@ +/* + * 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. + */ + +// Unit tests for SqaaProgress — TTY and non-TTY rendering paths. + +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; + +import { clearMockUiCalls, getMockUiCalls, setMockUi } from '../../../src/ui'; +import { SqaaProgress } from '../../../src/ui/components/sqaa-progress.js'; + +const FILES = ['src/a.ts', 'src/b.ts', 'src/c.ts']; + +function captureStdout(fn: () => void): string { + const chunks: string[] = []; + const spy = spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }); + try { + fn(); + } finally { + spy.mockRestore(); + } + return chunks.join(''); +} + +async function captureStdoutAsync(fn: () => Promise): Promise { + const chunks: string[] = []; + const spy = spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { + chunks.push(String(chunk)); + return true; + }); + try { + await fn(); + } finally { + spy.mockRestore(); + } + return chunks.join(''); +} + +describe('SqaaProgress — non-TTY mode', () => { + it('prints a one-time header, rolling per-file result lines, and skipped files on fail-fast', () => { + const progress = new SqaaProgress({ files: FILES, isTTY: false }); + + const header = captureStdout(() => progress.start()); + expect(header).toContain('Analyzing 3 files'); + + // Result lines arrive in completion order (rolling), one per terminal transition. + const aLine = captureStdout(() => progress.update(0, 'done')); + expect(aLine).toContain('src/a.ts'); + // Intermediate transitions don't print anything in non-TTY mode. + expect(captureStdout(() => progress.update(1, 'analyzing'))).toBe(''); + const bLine = captureStdout(() => progress.update(1, 'failed')); + expect(bLine).toContain('src/b.ts'); + + // Fail-fast: c.ts is never picked up, finish() flushes it as skipped. + captureStdout(() => progress.skipRemaining(2)); + const finish = captureStdout(() => progress.finish(2)); + expect(finish).toContain('src/c.ts'); + }); + + it('header counts only files the pool will process (excludes pre-ignored files)', () => { + const progress = new SqaaProgress({ + files: FILES, + ignoredFiles: ['build/output.bin'], + isTTY: false, + }); + + const header = captureStdout(() => progress.start()); + // Only the 3 waiting files count; the binary one is already accounted for elsewhere. + expect(header).toContain('Analyzing 3 files'); + }); + + it('retrying prints a countdown line and resets status to analyzing', async () => { + const progress = new SqaaProgress({ files: FILES, isTTY: false }); + const output = await captureStdoutAsync(() => progress.retrying(0, 1, 3, 1)); + expect(output).toContain('Server busy (503)'); + expect(output).toContain('Attempt 1/3'); + }); +}); + +describe('SqaaProgress — TTY mode', () => { + it('renders full block with all statuses through a complete lifecycle', () => { + const progress = new SqaaProgress({ files: FILES, isTTY: true }); + + const start = captureStdout(() => progress.start()); + expect(start).toContain('SonarQube Agentic Analysis in progress'); + expect(start).toContain('0/3 files analyzed'); + expect(start).toContain('[WAITING]'); + + const analyzing = captureStdout(() => progress.update(0, 'analyzing')); + expect(analyzing).toContain('[ANALYZING...]'); + + const done = captureStdout(() => progress.update(0, 'done')); + expect(done).toContain('[DONE]'); + expect(done).toContain('1/3 files analyzed'); + + const failed = captureStdout(() => progress.update(1, 'failed')); + expect(failed).toContain('[FAILED]'); + + captureStdout(() => progress.skipRemaining(2)); + const finish = captureStdout(() => progress.finish(2)); + expect(finish).toContain('2/3 files analyzed'); + expect(finish).toContain('[DONE]'); + expect(finish).toContain('[FAILED]'); + expect(finish).toContain('[SKIPPED]'); + }); + + it('retrying shows live countdown label and resets to analyzing', async () => { + const progress = new SqaaProgress({ files: FILES, isTTY: true }); + captureStdout(() => progress.start()); + // 500ms rounds to 1s so the countdown loop body executes once. + const output = await captureStdoutAsync(() => progress.retrying(0, 1, 3, 500)); + expect(output).toContain('RETRYING'); + + const after = captureStdout(() => progress.update(0, 'done')); + expect(after).not.toContain('[RETRYING...]'); + }); + + it('progress bar denominator excludes pre-ignored files', () => { + // 3 analyzable + 1 ignored. Bar total must be 3 (so 100% is reachable), + // not 4. Ignored files still appear in the listing below the bar. + const progress = new SqaaProgress({ + files: FILES, + ignoredFiles: ['build/output.bin'], + isTTY: true, + }); + + const start = captureStdout(() => progress.start()); + expect(start).toContain('0/3 files analyzed'); + expect(start).not.toContain('0/4 files analyzed'); + + const afterDone = captureStdout(() => progress.update(0, 'done')); + expect(afterDone).toContain('1/3 files analyzed'); + + progress.update(1, 'done'); + progress.update(2, 'done'); + const finish = captureStdout(() => progress.finish(3)); + expect(finish).toContain('3/3 files analyzed'); + // Ignored entry is still listed below the summary bar. + expect(finish).toContain('build/output.bin'); + expect(finish).toContain('[IGNORED]'); + }); +}); + +describe('SqaaProgress — mock mode', () => { + beforeEach(() => { + setMockUi(true); + clearMockUiCalls(); + }); + afterEach(() => setMockUi(false)); + + it('records all method calls and writes nothing to stdout', async () => { + const progress = new SqaaProgress({ files: FILES }); + + const output = captureStdout(() => { + progress.start(); + progress.update(0, 'done'); + progress.skipRemaining(1); + progress.finish(3); + }); + await progress.retrying(0, 1, 3, 1); + + expect(output).toBe(''); + const methods = getMockUiCalls().map((c) => c.method); + expect(methods).toContain('sqaaProgress.start'); + expect(methods).toContain('sqaaProgress.update'); + expect(methods).toContain('sqaaProgress.skipRemaining'); + expect(methods).toContain('sqaaProgress.finish'); + expect(methods).toContain('sqaaProgress.retrying'); + }); +}); + +describe('SqaaProgress — silent flag (used by --format json)', () => { + // Silent mode is the production replacement for setMockUi(true) in JSON mode: + // it must not write to stdout, must not record calls into the global mock + // buffer, and must still update internal status so consumers can read it. + + beforeEach(() => { + setMockUi(true); + clearMockUiCalls(); + }); + afterEach(() => setMockUi(false)); + + it('writes nothing to stdout and records no mock calls even when mock is active', async () => { + const progress = new SqaaProgress({ files: FILES, silent: true }); + + const output = await captureStdoutAsync(async () => { + progress.start(); + progress.update(0, 'done'); + progress.skipRemaining(1); + progress.finish(3); + await progress.retrying(2, 1, 3, 1); + }); + + expect(output).toBe(''); + // Crucially: no entries pushed into the global mock buffer (the original + // setMockUi(true) approach grew this array unboundedly during JSON runs). + expect(getMockUiCalls()).toHaveLength(0); + }); + + it('still updates internal status for skipRemaining and retrying', async () => { + const progress = new SqaaProgress({ files: FILES, silent: true }); + + progress.update(0, 'done'); + progress.skipRemaining(1); + expect(progress.getStatuses()).toEqual(['done', 'skipped', 'skipped']); + + // retrying() preserves the wait so retry semantics are unchanged, and + // resets the status back to 'analyzing' on completion. + await progress.retrying(0, 1, 3, 1); + expect(progress.getStatuses()[0]).toBe('analyzing'); + }); +});