From 26b18c9dae1d11ebee24e9f6d8805cba2447904a Mon Sep 17 00:00:00 2001 From: Nicolas QUINQUENEL Date: Wed, 6 May 2026 22:54:23 +0200 Subject: [PATCH 01/10] CLI-359 Analyze local change set with SQAA --- CLAUDE.md | 6 + src/cli/command-tree.ts | 51 +- src/cli/commands/_common/error.ts | 16 +- src/cli/commands/_common/sonar-command.ts | 4 +- src/cli/commands/analyze/sqaa-changeset.ts | 139 +++ src/cli/commands/analyze/sqaa.ts | 460 +++++++++- src/sonarqube/client.ts | 38 +- src/sonarqube/errors.ts | 35 + src/ui/components/sqaa-progress.ts | 300 +++++++ .../harness/fake-sonarqube-server.ts | 25 +- .../specs/analyze/analyze-secrets.test.ts | 12 +- .../specs/analyze/analyze-sqaa.test.ts | 789 ++++++++++++++++-- tests/integration/specs/api/api.test.ts | 20 +- tests/integration/specs/auth/auth.test.ts | 4 +- .../specs/config/config-telemetry.test.ts | 4 +- .../specs/integrate/copilot.test.ts | 4 +- .../specs/list/list-issues.test.ts | 16 +- .../specs/list/list-projects.test.ts | 8 +- .../commands/_common/sonar-command.test.ts | 13 +- tests/unit/ui/sqaa-progress.test.ts | 152 ++++ 20 files changed, 1916 insertions(+), 180 deletions(-) create mode 100644 src/cli/commands/analyze/sqaa-changeset.ts create mode 100644 src/sonarqube/errors.ts create mode 100644 src/ui/components/sqaa-progress.ts create mode 100644 tests/unit/ui/sqaa-progress.test.ts 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..6c9f68f4 100644 --- a/src/cli/command-tree.ts +++ b/src/cli/command-tree.ts @@ -256,18 +256,22 @@ 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. +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') + .authenticatedAction((auth, options: AnalyzeSqaaOptions, innerCmd: Command) => + analyzeSqaa(options, auth, innerCmd), + ); +} const dependencyRisksFormatOption = new Option('--format ', 'Output format') .choices(DEPENDENCY_RISKS_FORMATS) @@ -282,17 +286,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-changeset.ts b/src/cli/commands/analyze/sqaa-changeset.ts new file mode 100644 index 00000000..94f0aceb --- /dev/null +++ b/src/cli/commands/analyze/sqaa-changeset.ts @@ -0,0 +1,139 @@ +/* + * 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 } 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; +} + +/** + * 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. + * + * 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 diffFiles = await getDiffFiles(cwd, { staged, base }); + + const untrackedFiles = staged ? [] : await getUntrackedNonIgnoredFiles(cwd); + + const absolute = [...diffFiles, ...untrackedFiles].map((f) => join(cwd, f)); + const filtered = filterNonBinary(absolute); + + return enforceMaxSize(filtered); +} + +async function getDiffFiles( + cwd: string, + opts: { staged?: boolean; base?: string }, +): Promise { + const args: string[] = ['diff', '--name-only', '--diff-filter=ACMR']; + + 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 parseLines(result); +} + +async function getUntrackedNonIgnoredFiles(cwd: string): Promise { + const result = await runGit(['ls-files', '--others', '--exclude-standard'], cwd); + return parseLines(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 parseLines(output: string): string[] { + return output + .split('\n') + .map((l) => l.trim()) + .filter((l) => l.length > 0); +} + +/** + * Removes binary files from the list. + * We use a simple heuristic: read the first 8 KB; if it contains a NUL byte, it's binary. + */ +function filterNonBinary(files: string[]): string[] { + return files.filter((f) => { + try { + const buf = Buffer.alloc(8192); + const fd = openSync(f, 'r'); + try { + const bytesRead = readSync(fd, buf, 0, buf.length, 0); + return !buf.subarray(0, bytesRead).includes(0x00); + } finally { + closeSync(fd); + } + } catch { + return false; + } + }); +} + +/** Excludes files that exceed the per-file size limit. */ +function enforceMaxSize(files: string[]): string[] { + return files.filter((f) => { + try { + return statSync(f).size <= SQAA_MAX_FILE_BYTES; + } catch { + return false; + } + }); +} diff --git a/src/cli/commands/analyze/sqaa.ts b/src/cli/commands/analyze/sqaa.ts index 71f23d8b..65c23b27 100644 --- a/src/cli/commands/analyze/sqaa.ts +++ b/src/cli/commands/analyze/sqaa.ts @@ -30,13 +30,44 @@ 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 { ServiceUnavailableError } from '../../../sonarqube/errors.js'; +import { blank, confirmPrompt, error, success, text, warn } from '../../../ui'; +import { SqaaProgress } from '../../../ui/components/sqaa-progress.js'; import { CommandFailedError, InvalidOptionError } from '../_common/error.js'; +import { resolveChangeSet } from './sqaa-changeset'; + +/** Exit code when analysis succeeds and issues are found. */ +const EXIT_CODE_ISSUES_FOUND = 51; + +/** Change-set size above which the user is prompted to confirm before proceeding. */ +const SQAA_LARGE_CHANGESET_THRESHOLD = 20; + +/** Number of files analyzed concurrently within a batch. */ +const SQAA_BATCH_SIZE = 3; + +/** Maximum number of retries on 503 responses. */ +const MAX_503_RETRIES = 3; + +/** Base delay for 503 retry backoff in milliseconds. Attempt N waits BASE * 2^(N-1): 2s, 4s, 8s. */ +const RETRY_503_BASE_DELAY_MS = 2000; + +/** Interval for the live countdown tick in milliseconds. */ +const COUNTDOWN_TICK_MS = 1000; + +/** Cloud authentication context required for SQAA API calls. */ +interface CloudAuth { + serverUrl: string; + token: string; + orgKey: string; +} export interface AnalyzeSqaaOptions { - file: string; + file?: string; + staged?: boolean; + base?: string; branch?: string; project?: string; + force?: boolean; } export async function analyzeSqaa( @@ -44,40 +75,327 @@ export async function analyzeSqaa( auth: ResolvedAuth, command?: Command, ): Promise { - const { file, branch, project } = options; + const { file, staged, base, branch, project, force } = options; + + if (staged && base !== undefined) { + throw new InvalidOptionError('--staged and --base cannot be used together'); + } - if (!existsSync(file)) { - throw new InvalidOptionError(`File not found: ${file}`); + if (file !== undefined) { + if (!existsSync(file)) { + throw new InvalidOptionError(`File not found: ${file}`); + } + await runSqaaAnalysis(file, auth, branch, project, command); + return; } - await runSqaaAnalysis(file, auth, branch, project, command); + // Change-set mode: resolve files from Git. + const files = await resolveChangeSet(process.cwd(), { staged, base }); + + if (files.length === 0) { + blank(); + text('SonarQube Agentic Analysis: no files in the change set to analyze.'); + return; + } + + if (!force && files.length > SQAA_LARGE_CHANGESET_THRESHOLD) { + const confirmed = await confirmLargeChangeset(files.length); + if (!confirmed) return; + } + + await runSqaaAnalysisOnFiles(files, auth, branch, project, command); } -export async function runSqaaAnalysis( +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)', + const resolved = resolveCloudAuthAndProject(auth, explicitProject, command); + if (!resolved) return; + + const { cloudAuth, projectKey } = resolved; + const fileContent = readSqaaFileContent(file); + const issueCount = await callSqaaApiAndDisplay(cloudAuth, projectKey, file, fileContent, branch); + if (issueCount > 0) { + process.exitCode = EXIT_CODE_ISSUES_FOUND; + } +} + +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'; + +/** + * Warn about a large change set and ask the user to confirm. + * In non-TTY (agent/CI) mode, prints a warning and auto-proceeds. + * Returns false only when the user explicitly declines in an interactive terminal. + */ +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.stdout.isTTY) { + return true; + } + + blank(); + const confirmed = await confirmPrompt('Do you wish to proceed?'); + if (!confirmed) { + blank(); + text('Analysis cancelled. Use --force to skip this prompt.'); + return false; + } + return true; +} + +type FileSuccess = { + file: string; + filePath: string; + issues: SqaaIssue[]; + errors?: Array<{ code: string; message: string }> | null; +}; +type FileFailure = { file: string; filePath: string; failure: Error }; +type FileResult = FileSuccess | FileFailure; + +interface BatchContext { + files: string[]; + allPaths: string[]; + cloudAuth: CloudAuth; + projectKey: string; + branch: string | undefined; + progress: SqaaProgress; +} + +interface BatchTally { + allResults: FileResult[]; + totalIssues: number; + totalErrors: number; + totalFailures: number; +} + +async function runSqaaAnalysisOnFiles( + files: string[], + auth: ResolvedAuth, + branch?: string, + explicitProject?: string, + command?: Command, +): Promise { + const resolved = resolveCloudAuthAndProject(auth, explicitProject, command); + if (!resolved) return; + + const { cloudAuth, projectKey } = resolved; + const allPaths = files.map(toRelativePosixPath); + const progress = new SqaaProgress({ files: allPaths }); + const ctx: BatchContext = { files, allPaths, cloudAuth, projectKey, branch, progress }; + const tally = await runBatches(ctx); + + progress.finish(tally.allResults.length); + printFileDetails(tally.allResults); + printSummary(tally.totalIssues, tally.totalErrors, tally.totalFailures); +} + +async function runBatches(ctx: BatchContext): Promise { + const batches = chunkArray(ctx.files, SQAA_BATCH_SIZE); + const tally: BatchTally = { allResults: [], totalIssues: 0, totalErrors: 0, totalFailures: 0 }; + + for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { + const batch = batches[batchIdx]; + const batchOffset = batchIdx * SQAA_BATCH_SIZE; + const batchPaths = ctx.allPaths.slice(batchOffset, batchOffset + batch.length); + + ctx.progress.startBatch(batchOffset, batch.length); + const batchResponses = await executeBatch(batch, batchOffset, ctx); + ctx.progress.commitBatch(batchOffset, batch.length); + + const { results, hadFailure } = collectBatchResults(batch, batchPaths, batchResponses); + tally.allResults.push(...results); + tallyResults(results, tally); + + if (hadFailure) { + ctx.progress.skipRemaining(batchOffset + batch.length); + break; + } + } + + return tally; +} + +async function executeBatch( + batch: string[], + batchOffset: number, + ctx: BatchContext, +): Promise< + PromiseSettledResult<{ + issues: SqaaIssue[]; + errors?: Array<{ code: string; message: string }> | null; + }>[] +> { + const responses = await Promise.allSettled( + batch.map(async (file, i) => { + const globalIdx = batchOffset + i; + ctx.progress.update(globalIdx, 'analyzing'); + const fileContent = readSqaaFileContent(file); + const response = await fetchWithRetry( + ctx.cloudAuth, + ctx.projectKey, + file, + fileContent, + ctx.branch, + async (attempt) => { + await ctx.progress.retrying( + globalIdx, + attempt, + MAX_503_RETRIES, + RETRY_503_BASE_DELAY_MS * 2 ** (attempt - 1), + ); + // retrying() already resets status to 'analyzing' when the countdown ends. + }, + ); + ctx.progress.update(globalIdx, 'done'); + return response; + }), + ); + + for (let i = 0; i < responses.length; i++) { + if (responses[i].status === 'rejected') { + ctx.progress.update(batchOffset + i, 'failed'); + } + } + + return responses; +} + +function tallyResults(results: FileResult[], tally: BatchTally): 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; + } + } +} + +function collectBatchResults( + batch: string[], + batchPaths: string[], + batchResponses: PromiseSettledResult<{ + issues: SqaaIssue[]; + errors?: Array<{ code: string; message: string }> | null; + }>[], +): { results: FileResult[]; hadFailure: boolean } { + const results: FileResult[] = []; + let hadFailure = false; + + for (let i = 0; i < batchResponses.length; i++) { + const resp = batchResponses[i]; + const file = batch[i]; + const filePath = batchPaths[i]; + if (resp.status === 'fulfilled') { + results.push({ file, filePath, issues: resp.value.issues, errors: resp.value.errors }); + } else { + results.push({ file, filePath, failure: resp.reason as Error }); + hadFailure = true; + } + } + + return { results, hadFailure }; +} + +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); + } + } +} + +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(); + } +} + +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'}.`, ); - return; + 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. +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** Split an array into chunks of at most `size` elements. */ +function chunkArray(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); } + return chunks; +} + +/** + * Combines cloud-auth validation and project-key resolution. + * Returns null (with a warning already printed) when SQAA should be skipped. + */ +function resolveCloudAuthAndProject( + auth: ResolvedAuth, + explicitProject: string | undefined, + command: Command | undefined, +): { cloudAuth: CloudAuth; projectKey: string } | null { + const cloudAuth = resolveCloudAuth(auth, explicitProject); + if (!cloudAuth) return null; 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; + return null; } - const fileContent = readSqaaFileContent(file); - await callSqaaApiAndDisplay(cloudAuth, projectKey, file, fileContent, branch); + return { cloudAuth, projectKey }; } /** @@ -88,14 +406,16 @@ export async function runSqaaAnalysis( function resolveCloudAuth( auth: ResolvedAuth, explicitProject: string | undefined, -): { serverUrl: string; token: string; orgKey: string } | null { +): 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', ); } - logger.debug('SonarQube Agentic Analysis skipped: missing orgKey or on-premise server'); + warn( + 'SonarQube Agentic Analysis skipped: a SonarQube Cloud connection is required. Run: sonar auth login (ensure you connect to SonarQube Cloud)', + ); return null; } @@ -158,45 +478,121 @@ function toRelativePosixPath(file: string): string { } /** - * Call the SQAA API and display the results. - * Throws CommandFailedError on API failure. + * Fetch the SQAA API response for a single file. Does not print anything. + * Throws ServiceUnavailableError on 503 (caller handles retry), CommandFailedError on other failures. */ -async function callSqaaApiAndDisplay( - auth: { serverUrl: string; token: string; orgKey: string }, +async function fetchSqaaResponse( + auth: CloudAuth, projectKey: string, file: string, fileContent: string, branch: string | undefined, -): Promise { +): Promise<{ issues: SqaaIssue[]; errors?: Array<{ code: string; message: string }> | null }> { const filePath = toRelativePosixPath(file); const client = new SonarQubeClient(auth.serverUrl, auth.token); - - blank(); - text('Running SonarQube Agentic Analysis...'); - try { - const response = await client.analyzeFile({ + return await client.analyzeFile({ organizationKey: auth.orgKey, projectKey, ...(branch ? { branchName: branch } : {}), filePath, fileContent, }); - - displaySqaaResults(response.issues, response.errors); } catch (err) { + if (err instanceof ServiceUnavailableError) throw err; throw new CommandFailedError(`SonarQube Agentic Analysis failed.\n ${(err as Error).message}`); } } +/** + * Call the SQAA API and display the results. + * Returns the number of issues found. + * Throws CommandFailedError on API failure. + */ +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); +} + +/** + * Calls fetchSqaaResponse with a 503-retry loop. + */ +async function fetchWithRetry( + auth: CloudAuth, + projectKey: string, + file: string, + fileContent: string, + branch: string | undefined, + onRetry?: (attempt: number) => Promise, +): 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); + } 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.'); +} + +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. + */ +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'); +} + function displaySqaaResults( issues: SqaaIssue[], errors?: Array<{ code: string; message: string }> | null, -): void { + inChangeSetMode = false, +): number { blank(); if (issues.length === 0) { - success('SonarQube Agentic Analysis completed — no issues found.'); + if (!inChangeSetMode) { + success('SonarQube Agentic Analysis completed — no issues found.'); + } } else { error( `SonarQube Agentic Analysis found ${issues.length} issue${issues.length === 1 ? '' : 's'}:`, @@ -218,4 +614,6 @@ function displaySqaaResults( } blank(); + + return issues.length; } 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..9874a447 --- /dev/null +++ b/src/ui/components/sqaa-progress.ts @@ -0,0 +1,300 @@ +/* + * 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 SQAA batch analysis. + +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'; + +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]'); + } +} + +function statusIcon(status: FileStatus): string { + if (status === 'waiting') return dim('○'); + if (status === 'skipped') return dim('⊘'); + return '●'; +} + +/** Icon used in non-TTY per-file result lines after a batch commits or in finish(). */ +function fileStatusIcon(status: FileStatus): string { + if (status === 'done') return green('✓'); + if (status === 'failed') return red('✗'); + if (status === 'skipped') 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 multi-batch SQAA run. + * + * Initialized with all file paths upfront; all files are visible throughout the run. + * Batching is purely a concurrency concern — the display always shows the full list, + * with per-file statuses updated as each batch progresses. + * + * TTY: maintains one block on screen — erases and rewrites it in-place on every update. + * Non-TTY: prints a static header per batch and per-file result lines on commitBatch. + */ +export class SqaaProgress { + private readonly allFiles: string[]; + private readonly statuses: FileStatus[]; + private readonly isTTY: boolean; + /** 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[]; isTTY?: boolean }) { + this.allFiles = opts.files; + this.statuses = opts.files.map(() => 'waiting'); + this.isTTY = opts.isTTY ?? process.stdout.isTTY; + this.colWidth = Math.max(...opts.files.map((f) => f.length), 0) + 2; + } + + /** + * Signal the start of a new batch (identified by global offset into allFiles). + * TTY: redraws the full block. + * Non-TTY: prints a static "Analyzing files N–M of total..." header. + */ + startBatch(batchOffset: number, batchSize: number): void { + if (isMockActive()) { + recordCall('sqaaProgress.startBatch', batchOffset, batchSize); + return; + } + if (this.isTTY) { + this.eraseTTY(); + this.renderTTY(); + } else { + const from = batchOffset + 1; + const to = Math.min(batchOffset + batchSize, this.allFiles.length); + process.stdout.write(`\nAnalyzing files ${from}–${to} of ${this.allFiles.length}...\n`); + } + } + + /** + * Update a file's status by its global index across all files. + * TTY: redraws the full block. + * Non-TTY: no-op (deferred to commitBatch). + */ + update(globalIndex: number, status: FileStatus): void { + if (isMockActive()) { + recordCall('sqaaProgress.update', globalIndex, status); + return; + } + this.statuses[globalIndex] = status; + if (this.isTTY) { + this.eraseTTY(); + this.renderTTY(); + } + } + + /** + * 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 (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'; + } + + /** + * Commit the current batch's final state. + * TTY: no-op (block stays on screen; next startBatch or finish will update it). + * Non-TTY: prints per-file ✓/✗ result lines for the batch slice. + */ + commitBatch(batchOffset: number, batchSize: number): void { + if (isMockActive()) { + recordCall('sqaaProgress.commitBatch', batchOffset, batchSize); + return; + } + if (!this.isTTY) { + const end = Math.min(batchOffset + batchSize, this.allFiles.length); + for (let i = batchOffset; i < end; i++) { + process.stdout.write(` ${fileStatusIcon(this.statuses[i])} ${this.allFiles[i]}\n`); + } + process.stdout.write('\n'); + } + } + + /** + * Mark all files from fromIndex onwards as skipped. + * Call before finish() when fail-fast stops processing early. + */ + skipRemaining(fromIndex: number): void { + 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'; + } + } + } + + /** + * Called once after all batches are done (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 committed (fail-fast path). + */ + finish(processedTotal: number): void { + if (isMockActive()) { + recordCall('sqaaProgress.finish', processedTotal); + return; + } + if (this.isTTY) { + this.eraseTTY(); + const bar = renderBar(processedTotal, this.allFiles.length); + process.stdout.write(`${bar} ${processedTotal}/${this.allFiles.length} 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 were never reached by any batch's commitBatch. + 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.allFiles.length); + const lines: string[] = [ + bold('SonarQube Agentic Analysis in progress...'), + `${bar} ${done}/${this.allFiles.length} 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..c0f92d96 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,39 +282,282 @@ 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 <= 21; 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 (21)'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(21); + }, + { 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 <= 21; 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).toContain('no issues found'); + 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(21); + }, + { 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'); @@ -370,4 +565,458 @@ describe('verify', () => { }, { 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 excluded → change set is empty + 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( + '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 excluded → change set is empty + 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( + '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 diff HEAD fails with non-zero exit outside a git repo → CommandFailedError → exit 1 + expect(result.exitCode).toBe(1); + expect(result.stdout + result.stderr).toContain('git diff failed'); + }, + { 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 <= 21; 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 (21)'); + const sqaaCalls = server + .getRecordedRequests() + .filter((r) => r.path === '/a3s-analysis/analyses'); + expect(sqaaCalls).toHaveLength(21); + }, + { 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 <= 21; 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(21); + }, + { 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 }, + ); }); 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/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/ui/sqaa-progress.test.ts b/tests/unit/ui/sqaa-progress.test.ts new file mode 100644 index 00000000..1ca19298 --- /dev/null +++ b/tests/unit/ui/sqaa-progress.test.ts @@ -0,0 +1,152 @@ +/* + * 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 batch header, file results, and skipped files on fail-fast', () => { + const progress = new SqaaProgress({ files: FILES, isTTY: false }); + + const header = captureStdout(() => progress.startBatch(0, 2)); + expect(header).toContain('Analyzing files 1–2 of 3'); + + progress.update(0, 'done'); + progress.update(1, 'failed'); + const commit = captureStdout(() => progress.commitBatch(0, 2)); + expect(commit).toContain('src/a.ts'); + expect(commit).toContain('src/b.ts'); + expect(commit.endsWith('\n\n')).toBe(true); + + captureStdout(() => progress.skipRemaining(2)); + const finish = captureStdout(() => progress.finish(2)); + expect(finish).toContain('src/c.ts'); // skipped file printed in finish + }); + + 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.startBatch(0, 3)); + 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.startBatch(0, 3)); + // 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...]'); + }); +}); + +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.startBatch(0, 3); + progress.update(0, 'done'); + progress.commitBatch(0, 3); + 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.startBatch'); + expect(methods).toContain('sqaaProgress.update'); + expect(methods).toContain('sqaaProgress.commitBatch'); + expect(methods).toContain('sqaaProgress.skipRemaining'); + expect(methods).toContain('sqaaProgress.finish'); + expect(methods).toContain('sqaaProgress.retrying'); + }); +}); From e5afb3408c4ed50b94920a5957e156422d659a31 Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Wed, 6 May 2026 23:12:48 +0200 Subject: [PATCH 02/10] CLI-313 De-duplicate SQAA analysis unit tests --- .../cli/commands/analyze/analyze-sqaa.test.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts index a34c29d8..a623b678 100644 --- a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts +++ b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts @@ -176,30 +176,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 +206,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, From c7d2b8e9c65c359de599d121129d9ed4c54ce22a Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 11:00:47 +0200 Subject: [PATCH 03/10] PR review --- src/cli/command-tree.ts | 11 +- src/cli/commands/analyze/sqaa-analysis.ts | 176 ++++++ src/cli/commands/analyze/sqaa-api.ts | 176 ++++++ src/cli/commands/analyze/sqaa-auth.ts | 145 +++++ src/cli/commands/analyze/sqaa-changeset.ts | 62 +- src/cli/commands/analyze/sqaa-display.ts | 161 +++++ src/cli/commands/analyze/sqaa.ts | 590 +++--------------- src/ui/components/sqaa-progress.ts | 26 +- .../specs/analyze/analyze-sqaa.test.ts | 128 +++- 9 files changed, 938 insertions(+), 537 deletions(-) create mode 100644 src/cli/commands/analyze/sqaa-analysis.ts create mode 100644 src/cli/commands/analyze/sqaa-api.ts create mode 100644 src/cli/commands/analyze/sqaa-auth.ts create mode 100644 src/cli/commands/analyze/sqaa-display.ts diff --git a/src/cli/command-tree.ts b/src/cli/command-tree.ts index 6c9f68f4..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'; @@ -257,6 +261,10 @@ analyze ); // 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)') @@ -268,6 +276,7 @@ function applySqaaOptions(cmd: SonarCommand): SonarCommand { '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), ); diff --git a/src/cli/commands/analyze/sqaa-analysis.ts b/src/cli/commands/analyze/sqaa-analysis.ts new file mode 100644 index 00000000..cbedd898 --- /dev/null +++ b/src/cli/commands/analyze/sqaa-analysis.ts @@ -0,0 +1,176 @@ +/* + * 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. + */ + +// Batch 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'; + +/** Number of files analyzed concurrently within a batch. */ +export const SQAA_BATCH_SIZE = 3; + +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 BatchContext { + files: string[]; + allPaths: string[]; + cloudAuth: CloudAuth; + projectKey: string; + branch: string | undefined; + progress: SqaaProgress; +} + +export interface BatchTally { + allResults: FileResult[]; + totalIssues: number; + totalErrors: number; + totalFailures: number; +} + +export async function runBatches(ctx: BatchContext): Promise { + const batches = chunkArray(ctx.files, SQAA_BATCH_SIZE); + const tally: BatchTally = { allResults: [], totalIssues: 0, totalErrors: 0, totalFailures: 0 }; + + for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { + const batch = batches[batchIdx]; + const batchOffset = batchIdx * SQAA_BATCH_SIZE; + const batchPaths = ctx.allPaths.slice(batchOffset, batchOffset + batch.length); + + ctx.progress.startBatch(batchOffset, batch.length); + const batchResponses = await executeBatch(batch, batchOffset, ctx); + ctx.progress.commitBatch(batchOffset, batch.length); + + const { results, hadFailure } = collectBatchResults(batch, batchPaths, batchResponses); + tally.allResults.push(...results); + tallyResults(results, tally); + + if (hadFailure) { + ctx.progress.skipRemaining(batchOffset + batch.length); + break; + } + } + + return tally; +} + +async function executeBatch( + batch: string[], + batchOffset: number, + ctx: BatchContext, +): Promise< + PromiseSettledResult<{ + issues: SqaaIssue[]; + errors?: Array<{ code: string; message: string }> | null; + }>[] +> { + const responses = await Promise.allSettled( + batch.map(async (file, i) => { + const globalIdx = batchOffset + i; + ctx.progress.update(globalIdx, 'analyzing'); + const fileContent = readSqaaFileContent(file); + const response = await fetchWithRetry( + ctx.cloudAuth, + ctx.projectKey, + file, + fileContent, + ctx.branch, + async (attempt) => { + await ctx.progress.retrying( + globalIdx, + attempt, + MAX_503_RETRIES, + RETRY_503_BASE_DELAY_MS * 2 ** (attempt - 1), + ); + // retrying() already resets status to 'analyzing' when the countdown ends. + }, + ); + ctx.progress.update(globalIdx, 'done'); + return response; + }), + ); + + for (let i = 0; i < responses.length; i++) { + if (responses[i].status === 'rejected') { + ctx.progress.update(batchOffset + i, 'failed'); + } + } + + return responses; +} + +export function tallyResults(results: FileResult[], tally: BatchTally): 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; + } + } +} + +export function collectBatchResults( + batch: string[], + batchPaths: string[], + batchResponses: PromiseSettledResult<{ + issues: SqaaIssue[]; + errors?: Array<{ code: string; message: string }> | null; + }>[], +): { results: FileResult[]; hadFailure: boolean } { + const results: FileResult[] = []; + let hadFailure = false; + + for (let i = 0; i < batchResponses.length; i++) { + const resp = batchResponses[i]; + const file = batch[i]; + const filePath = batchPaths[i]; + if (resp.status === 'fulfilled') { + results.push({ file, filePath, issues: resp.value.issues, errors: resp.value.errors }); + } else { + results.push({ file, filePath, failure: resp.reason as Error }); + hadFailure = true; + } + } + + return { results, hadFailure }; +} + +/** Split an array into chunks of at most `size` elements. */ +export function chunkArray(arr: T[], size: number): T[][] { + const chunks: T[][] = []; + for (let i = 0; i < arr.length; i += size) { + chunks.push(arr.slice(i, i + size)); + } + return chunks; +} diff --git a/src/cli/commands/analyze/sqaa-api.ts b/src/cli/commands/analyze/sqaa-api.ts new file mode 100644 index 00000000..71a879b2 --- /dev/null +++ b/src/cli/commands/analyze/sqaa-api.ts @@ -0,0 +1,176 @@ +/* + * 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 POSIX-style relative path under the current working directory. + * Throws when the file is outside cwd (traversal) or on a different drive. + */ +export 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}`); + } + + 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. + */ +export async function fetchSqaaResponse( + auth: CloudAuth, + projectKey: string, + file: string, + fileContent: string, + branch: string | undefined, +): Promise<{ issues: SqaaIssue[]; errors?: Array<{ code: string; message: string }> | null }> { + const filePath = toRelativePosixPath(file); + 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, +): 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); + } 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..0febc7ab --- /dev/null +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -0,0 +1,145 @@ +/* + * 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 type { Command } from 'commander'; + +import type { ResolvedAuth } from '../../../lib/auth-resolver'; +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 { 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 function resolveCloudAuthAndProject( + auth: ResolvedAuth, + explicitProject: string | undefined, + command: Command | undefined, +): { cloudAuth: CloudAuth; projectKey: string } | null { + const cloudAuth = resolveCloudAuth(auth, explicitProject); + if (!cloudAuth) return null; + + 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 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 directory from the agentExtensions registry. + * Returns null when SQAA should be skipped. + */ +export 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 (!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; + } +} + +/** + * Warn about a large change set and ask the user to confirm. + * In non-TTY (agent/CI) mode, 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.stdout.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 index 94f0aceb..8b1834ad 100644 --- a/src/cli/commands/analyze/sqaa-changeset.ts +++ b/src/cli/commands/analyze/sqaa-changeset.ts @@ -36,6 +36,18 @@ export interface ChangeSetOptions { 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[]; +} + /** * 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. @@ -48,17 +60,17 @@ export interface ChangeSetOptions { export async function resolveChangeSet( cwd: string, options: ChangeSetOptions = {}, -): Promise { +): Promise { const { staged, base } = options; const diffFiles = await getDiffFiles(cwd, { staged, base }); - const untrackedFiles = staged ? [] : await getUntrackedNonIgnoredFiles(cwd); - const absolute = [...diffFiles, ...untrackedFiles].map((f) => join(cwd, f)); - const filtered = filterNonBinary(absolute); - return enforceMaxSize(filtered); + const { files: nonBinary, ignored: binaryIgnored } = partitionBinary(absolute); + const { files, ignored: oversizedIgnored } = partitionBySize(nonBinary); + + return { files, ignored: [...binaryIgnored, ...oversizedIgnored] }; } async function getDiffFiles( @@ -107,33 +119,47 @@ function parseLines(output: string): string[] { } /** - * Removes binary files from the list. - * We use a simple heuristic: read the first 8 KB; if it contains a NUL byte, it's binary. + * Separates binary files from text files. + * Heuristic: reads the first 8 KB; a NUL byte indicates binary content. */ -function filterNonBinary(files: string[]): string[] { - return files.filter((f) => { +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); - return !buf.subarray(0, bytesRead).includes(0x00); + if (buf.subarray(0, bytesRead).includes(0x00)) { + ignored.push({ path: f, reason: 'binary' }); + } else { + kept.push(f); + } } finally { closeSync(fd); } } catch { - return false; + // Unreadable files are silently skipped (e.g. permission errors, deleted between stat and read). } - }); + } + return { files: kept, ignored }; } -/** Excludes files that exceed the per-file size limit. */ -function enforceMaxSize(files: string[]): string[] { - return files.filter((f) => { +/** 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 { - return statSync(f).size <= SQAA_MAX_FILE_BYTES; + if (statSync(f).size <= SQAA_MAX_FILE_BYTES) { + kept.push(f); + } else { + ignored.push({ path: f, reason: 'oversized' }); + } } catch { - return false; + // 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..6823b7db --- /dev/null +++ b/src/cli/commands/analyze/sqaa-display.ts @@ -0,0 +1,161 @@ +/* + * 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 { BatchTally, FileFailure, FileResult, FileSuccess } from './sqaa-analysis'; +import { toRelativePosixPath } from './sqaa-api'; +import type { IgnoredFile } from './sqaa-changeset'; + +/** Exit code when analysis succeeds and issues are found. */ +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 }>; + summary: { totalIssues: number; totalFailures: number }; +} + +export function printJsonReport(tally: BatchTally, ignored: IgnoredFile[]): 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 report: SqaaJsonReport = { + files, + ignored: ignored.map((f) => ({ path: toRelativePosixPath(f.path), reason: f.reason })), + failures, + summary: { totalIssues: tally.totalIssues, totalFailures: tally.totalFailures }, + }; + + 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 65c23b27..569df773 100644 --- a/src/cli/commands/analyze/sqaa.ts +++ b/src/cli/commands/analyze/sqaa.ts @@ -17,24 +17,26 @@ * 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 { ServiceUnavailableError } from '../../../sonarqube/errors.js'; -import { blank, confirmPrompt, error, success, text, warn } from '../../../ui'; +import { blank, print, setMockUi, text } from '../../../ui'; import { SqaaProgress } from '../../../ui/components/sqaa-progress.js'; -import { CommandFailedError, InvalidOptionError } from '../_common/error.js'; +import { InvalidOptionError } from '../_common/error.js'; +import type { BatchContext, BatchTally } from './sqaa-analysis'; +import { runBatches } from './sqaa-analysis'; +import { + callSqaaApiAndDisplay, + fetchWithRetry, + readSqaaFileContent, + toRelativePosixPath, +} from './sqaa-api'; +import { confirmLargeChangeset, resolveCloudAuthAndProject } from './sqaa-auth'; +import type { IgnoredFile } from './sqaa-changeset'; import { resolveChangeSet } from './sqaa-changeset'; +import { applyExitCode, printFileDetails, printJsonReport, printSummary } from './sqaa-display'; /** Exit code when analysis succeeds and issues are found. */ const EXIT_CODE_ISSUES_FOUND = 51; @@ -42,24 +44,8 @@ const EXIT_CODE_ISSUES_FOUND = 51; /** Change-set size above which the user is prompted to confirm before proceeding. */ const SQAA_LARGE_CHANGESET_THRESHOLD = 20; -/** Number of files analyzed concurrently within a batch. */ -const SQAA_BATCH_SIZE = 3; - -/** Maximum number of retries on 503 responses. */ -const MAX_503_RETRIES = 3; - -/** Base delay for 503 retry backoff in milliseconds. Attempt N waits BASE * 2^(N-1): 2s, 4s, 8s. */ -const RETRY_503_BASE_DELAY_MS = 2000; - -/** Interval for the live countdown tick in milliseconds. */ -const COUNTDOWN_TICK_MS = 1000; - -/** Cloud authentication context required for SQAA API calls. */ -interface CloudAuth { - serverUrl: string; - token: string; - orgKey: string; -} +export const VALID_FORMATS = ['text', 'json'] as const; +export type OutputFormat = (typeof VALID_FORMATS)[number]; export interface AnalyzeSqaaOptions { file?: string; @@ -68,6 +54,7 @@ export interface AnalyzeSqaaOptions { branch?: string; project?: string; force?: boolean; + format?: OutputFormat; } export async function analyzeSqaa( @@ -75,7 +62,7 @@ export async function analyzeSqaa( auth: ResolvedAuth, command?: Command, ): Promise { - const { file, staged, base, branch, project, force } = options; + const { file, staged, base, branch, project, force, format = 'text' } = options; if (staged && base !== undefined) { throw new InvalidOptionError('--staged and --base cannot be used together'); @@ -85,25 +72,33 @@ export async function analyzeSqaa( if (!existsSync(file)) { throw new InvalidOptionError(`File not found: ${file}`); } - await runSqaaAnalysis(file, auth, branch, project, command); + await runSqaaAnalysis(file, auth, branch, project, command, format); return; } // Change-set mode: resolve files from Git. - const files = await resolveChangeSet(process.cwd(), { staged, base }); + const { files, ignored } = await resolveChangeSet(process.cwd(), { staged, base }); - if (files.length === 0) { + if (files.length === 0 && ignored.length === 0) { blank(); text('SonarQube Agentic Analysis: no files in the change set to analyze.'); return; } + if (files.length === 0) { + blank(); + text( + 'SonarQube Agentic Analysis: no files to analyze — all change set files were excluded (binary or oversized).', + ); + return; + } + if (!force && files.length > SQAA_LARGE_CHANGESET_THRESHOLD) { const confirmed = await confirmLargeChangeset(files.length); if (!confirmed) return; } - await runSqaaAnalysisOnFiles(files, auth, branch, project, command); + await runSqaaAnalysisOnFiles(files, ignored, auth, branch, project, command, format); } async function runSqaaAnalysis( @@ -112,508 +107,89 @@ async function runSqaaAnalysis( branch?: string, explicitProject?: string, command?: Command, + format: OutputFormat = 'text', ): Promise { const resolved = resolveCloudAuthAndProject(auth, explicitProject, command); if (!resolved) return; const { cloudAuth, projectKey } = resolved; const fileContent = readSqaaFileContent(file); - const issueCount = await callSqaaApiAndDisplay(cloudAuth, projectKey, file, fileContent, branch); - if (issueCount > 0) { - process.exitCode = EXIT_CODE_ISSUES_FOUND; - } -} - -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'; - -/** - * Warn about a large change set and ask the user to confirm. - * In non-TTY (agent/CI) mode, prints a warning and auto-proceeds. - * Returns false only when the user explicitly declines in an interactive terminal. - */ -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.stdout.isTTY) { - return true; + 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: [], + summary: { totalIssues: response.issues.length, totalFailures: 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 }], + summary: { totalIssues: 0, totalFailures: 1 }, + }; + print(JSON.stringify(report, null, 2)); + process.exitCode = 1; + } + return; } - blank(); - const confirmed = await confirmPrompt('Do you wish to proceed?'); - if (!confirmed) { - blank(); - text('Analysis cancelled. Use --force to skip this prompt.'); - return false; + const issueCount = await callSqaaApiAndDisplay(cloudAuth, projectKey, file, fileContent, branch); + if (issueCount > 0) { + process.exitCode = EXIT_CODE_ISSUES_FOUND; } - return true; -} - -type FileSuccess = { - file: string; - filePath: string; - issues: SqaaIssue[]; - errors?: Array<{ code: string; message: string }> | null; -}; -type FileFailure = { file: string; filePath: string; failure: Error }; -type FileResult = FileSuccess | FileFailure; - -interface BatchContext { - files: string[]; - allPaths: string[]; - cloudAuth: CloudAuth; - projectKey: string; - branch: string | undefined; - progress: SqaaProgress; -} - -interface BatchTally { - allResults: FileResult[]; - totalIssues: number; - totalErrors: number; - totalFailures: number; } async function runSqaaAnalysisOnFiles( files: string[], + ignored: IgnoredFile[], auth: ResolvedAuth, branch?: string, explicitProject?: string, command?: Command, + format: OutputFormat = 'text', ): Promise { const resolved = resolveCloudAuthAndProject(auth, explicitProject, command); if (!resolved) return; const { cloudAuth, projectKey } = resolved; const allPaths = files.map(toRelativePosixPath); - const progress = new SqaaProgress({ files: allPaths }); - const ctx: BatchContext = { files, allPaths, cloudAuth, projectKey, branch, progress }; - const tally = await runBatches(ctx); - - progress.finish(tally.allResults.length); - printFileDetails(tally.allResults); - printSummary(tally.totalIssues, tally.totalErrors, tally.totalFailures); -} -async function runBatches(ctx: BatchContext): Promise { - const batches = chunkArray(ctx.files, SQAA_BATCH_SIZE); - const tally: BatchTally = { allResults: [], totalIssues: 0, totalErrors: 0, totalFailures: 0 }; - - for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { - const batch = batches[batchIdx]; - const batchOffset = batchIdx * SQAA_BATCH_SIZE; - const batchPaths = ctx.allPaths.slice(batchOffset, batchOffset + batch.length); - - ctx.progress.startBatch(batchOffset, batch.length); - const batchResponses = await executeBatch(batch, batchOffset, ctx); - ctx.progress.commitBatch(batchOffset, batch.length); - - const { results, hadFailure } = collectBatchResults(batch, batchPaths, batchResponses); - tally.allResults.push(...results); - tallyResults(results, tally); - - if (hadFailure) { - ctx.progress.skipRemaining(batchOffset + batch.length); - break; - } - } - - return tally; -} - -async function executeBatch( - batch: string[], - batchOffset: number, - ctx: BatchContext, -): Promise< - PromiseSettledResult<{ - issues: SqaaIssue[]; - errors?: Array<{ code: string; message: string }> | null; - }>[] -> { - const responses = await Promise.allSettled( - batch.map(async (file, i) => { - const globalIdx = batchOffset + i; - ctx.progress.update(globalIdx, 'analyzing'); - const fileContent = readSqaaFileContent(file); - const response = await fetchWithRetry( - ctx.cloudAuth, - ctx.projectKey, - file, - fileContent, - ctx.branch, - async (attempt) => { - await ctx.progress.retrying( - globalIdx, - attempt, - MAX_503_RETRIES, - RETRY_503_BASE_DELAY_MS * 2 ** (attempt - 1), - ); - // retrying() already resets status to 'analyzing' when the countdown ends. - }, - ); - ctx.progress.update(globalIdx, 'done'); - return response; - }), - ); - - for (let i = 0; i < responses.length; i++) { - if (responses[i].status === 'rejected') { - ctx.progress.update(batchOffset + i, 'failed'); - } - } - - return responses; -} - -function tallyResults(results: FileResult[], tally: BatchTally): 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; - } - } -} - -function collectBatchResults( - batch: string[], - batchPaths: string[], - batchResponses: PromiseSettledResult<{ - issues: SqaaIssue[]; - errors?: Array<{ code: string; message: string }> | null; - }>[], -): { results: FileResult[]; hadFailure: boolean } { - const results: FileResult[] = []; - let hadFailure = false; - - for (let i = 0; i < batchResponses.length; i++) { - const resp = batchResponses[i]; - const file = batch[i]; - const filePath = batchPaths[i]; - if (resp.status === 'fulfilled') { - results.push({ file, filePath, issues: resp.value.issues, errors: resp.value.errors }); - } else { - results.push({ file, filePath, failure: resp.reason as Error }); - hadFailure = true; - } - } - - return { results, hadFailure }; -} - -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); - } - } -} - -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(); - } -} - -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. -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -/** Split an array into chunks of at most `size` elements. */ -function chunkArray(arr: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - chunks.push(arr.slice(i, i + size)); - } - return chunks; -} - -/** - * Combines cloud-auth validation and project-key resolution. - * Returns null (with a warning already printed) when SQAA should be skipped. - */ -function resolveCloudAuthAndProject( - auth: ResolvedAuth, - explicitProject: string | undefined, - command: Command | undefined, -): { cloudAuth: CloudAuth; projectKey: string } | null { - const cloudAuth = resolveCloudAuth(auth, explicitProject); - if (!cloudAuth) return null; - - 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 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. - */ -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 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 (!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; - } -} - -/** - * 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}`); - } -} - -/** - * 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}`); - } - - 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. - */ -async function fetchSqaaResponse( - auth: CloudAuth, - projectKey: string, - file: string, - fileContent: string, - branch: string | undefined, -): Promise<{ issues: SqaaIssue[]; errors?: Array<{ code: string; message: string }> | null }> { - const filePath = toRelativePosixPath(file); - const client = new SonarQubeClient(auth.serverUrl, auth.token); - try { - return await client.analyzeFile({ - organizationKey: auth.orgKey, + if (format === 'json') { + // Run batches with all UI suppressed, then emit structured JSON. + const silentProgress = new SqaaProgress({ files: allPaths }); + const ctx: BatchContext = { + files, + allPaths, + cloudAuth, 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}`); - } -} - -/** - * Call the SQAA API and display the results. - * Returns the number of issues found. - * Throws CommandFailedError on API failure. - */ -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); -} - -/** - * Calls fetchSqaaResponse with a 503-retry loop. - */ -async function fetchWithRetry( - auth: CloudAuth, - projectKey: string, - file: string, - fileContent: string, - branch: string | undefined, - onRetry?: (attempt: number) => Promise, -): Promise<{ issues: SqaaIssue[]; errors?: Array<{ code: string; message: string }> | null }> { - for (let attempt = 1; attempt <= MAX_503_RETRIES + 1; attempt++) { + branch, + progress: silentProgress, + }; + setMockUi(true); + let tally: BatchTally; try { - return await fetchSqaaResponse(auth, projectKey, file, fileContent, branch); - } catch (err) { - const shouldRetry = err instanceof ServiceUnavailableError && attempt <= MAX_503_RETRIES; - if (!shouldRetry) throw err; - await waitBeforeRetry(attempt, onRetry); + tally = await runBatches(ctx); + } finally { + setMockUi(false); } - } - throw new CommandFailedError('SonarQube Agentic Analysis failed: unexpected retry exhaustion.'); -} - -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. - */ -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); + printJsonReport(tally, ignored); + applyExitCode(tally.totalIssues, tally.totalFailures); 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'); -} -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(); + const ignoredPaths = ignored.map((f) => toRelativePosixPath(f.path)); + const progress = new SqaaProgress({ files: allPaths, ignoredFiles: ignoredPaths }); + const ctx: BatchContext = { files, allPaths, cloudAuth, projectKey, branch, progress }; + const tally = await runBatches(ctx); - return issues.length; + progress.finish(tally.allResults.length); + printFileDetails(tally.allResults); + printSummary(tally.totalIssues, tally.totalErrors, tally.totalFailures); } diff --git a/src/ui/components/sqaa-progress.ts b/src/ui/components/sqaa-progress.ts index 9874a447..1de65876 100644 --- a/src/ui/components/sqaa-progress.ts +++ b/src/ui/components/sqaa-progress.ts @@ -25,7 +25,14 @@ 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'; +export type FileStatus = + | 'waiting' + | 'analyzing' + | 'done' + | 'failed' + | 'retrying' + | 'skipped' + | 'ignored'; const BAR_WIDTH = 12; const FILLED = '⣿'; @@ -56,12 +63,14 @@ function statusLabel(status: FileStatus, retryLabel?: string): string { 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') return dim('⊘'); + if (status === 'skipped' || status === 'ignored') return dim('⊘'); return '●'; } @@ -69,7 +78,7 @@ function statusIcon(status: FileStatus): string { function fileStatusIcon(status: FileStatus): string { if (status === 'done') return green('✓'); if (status === 'failed') return red('✗'); - if (status === 'skipped') return dim('⊘'); + if (status === 'skipped' || status === 'ignored') return dim('⊘'); return dim('○'); } @@ -100,11 +109,14 @@ export class SqaaProgress { /** Number of lines currently written to stdout (TTY mode only). */ private linesRendered = 0; - constructor(opts: { files: string[]; isTTY?: boolean }) { - this.allFiles = opts.files; - this.statuses = opts.files.map(() => 'waiting'); + constructor(opts: { files: string[]; ignoredFiles?: string[]; isTTY?: 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.colWidth = Math.max(...opts.files.map((f) => f.length), 0) + 2; + this.colWidth = Math.max(...this.allFiles.map((f) => f.length), 0) + 2; } /** diff --git a/tests/integration/specs/analyze/analyze-sqaa.test.ts b/tests/integration/specs/analyze/analyze-sqaa.test.ts index c0f92d96..93260e5f 100644 --- a/tests/integration/specs/analyze/analyze-sqaa.test.ts +++ b/tests/integration/specs/analyze/analyze-sqaa.test.ts @@ -663,8 +663,8 @@ describe('analyze sqaa — change-set mode (no --file)', () => { const result = await harness.run('analyze sqaa'); expect(result.exitCode).toBe(0); - // Binary file excluded → change set is empty - expect(result.stdout + result.stderr).toContain('no files in the change set'); + // 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'); @@ -694,8 +694,8 @@ describe('analyze sqaa — change-set mode (no --file)', () => { const result = await harness.run('analyze sqaa'); expect(result.exitCode).toBe(0); - // Oversized file excluded → change set is empty - expect(result.stdout + result.stderr).toContain('no files in the change set'); + // 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'); @@ -1020,3 +1020,123 @@ describe('analyze sqaa — API error codes', () => { { 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 }, + ); +}); From e8a5790ad2b81f99d129342caf569f0572f7b24b Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 14:29:28 +0200 Subject: [PATCH 04/10] Worker pool --- src/cli/commands/analyze/sqaa-analysis.ts | 183 ++++++++---------- src/cli/commands/analyze/sqaa-api.ts | 20 +- src/cli/commands/analyze/sqaa-auth.ts | 50 ++++- src/cli/commands/analyze/sqaa-changeset.ts | 42 ++-- src/cli/commands/analyze/sqaa-display.ts | 30 ++- src/cli/commands/analyze/sqaa.ts | 91 +++++---- src/ui/components/sqaa-progress.ts | 104 ++++++---- .../specs/analyze/analyze-sqaa.test.ts | 137 ++++++++++++- .../cli/commands/analyze/analyze-sqaa.test.ts | 13 ++ tests/unit/ui/sqaa-progress.test.ts | 86 ++++++-- 10 files changed, 523 insertions(+), 233 deletions(-) diff --git a/src/cli/commands/analyze/sqaa-analysis.ts b/src/cli/commands/analyze/sqaa-analysis.ts index cbedd898..709a5f0b 100644 --- a/src/cli/commands/analyze/sqaa-analysis.ts +++ b/src/cli/commands/analyze/sqaa-analysis.ts @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// Batch execution engine for SQAA change-set analysis. +// Concurrent execution engine for SQAA change-set analysis import type { SqaaIssue } from '../../../sonarqube/client'; import type { SqaaProgress } from '../../../ui/components/sqaa-progress.js'; @@ -30,8 +30,8 @@ import { } from './sqaa-api'; import type { CloudAuth } from './sqaa-auth'; -/** Number of files analyzed concurrently within a batch. */ -export const SQAA_BATCH_SIZE = 3; +/** Maximum number of files analyzed concurrently. */ +export const SQAA_CONCURRENCY = 3; export type FileSuccess = { file: string; @@ -42,94 +42,110 @@ export type FileSuccess = { export type FileFailure = { file: string; filePath: string; failure: Error }; export type FileResult = FileSuccess | FileFailure; -export interface BatchContext { +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 BatchTally { +export interface RunTally { allResults: FileResult[]; totalIssues: number; totalErrors: number; totalFailures: number; } -export async function runBatches(ctx: BatchContext): Promise { - const batches = chunkArray(ctx.files, SQAA_BATCH_SIZE); - const tally: BatchTally = { allResults: [], totalIssues: 0, totalErrors: 0, totalFailures: 0 }; - - for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) { - const batch = batches[batchIdx]; - const batchOffset = batchIdx * SQAA_BATCH_SIZE; - const batchPaths = ctx.allPaths.slice(batchOffset, batchOffset + batch.length); +/** + * 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; - ctx.progress.startBatch(batchOffset, batch.length); - const batchResponses = await executeBatch(batch, batchOffset, ctx); - ctx.progress.commitBatch(batchOffset, batch.length); + 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 { results, hadFailure } = collectBatchResults(batch, batchPaths, batchResponses); - tally.allResults.push(...results); - tallyResults(results, tally); + const workerCount = Math.min(SQAA_CONCURRENCY, ctx.files.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); - if (hadFailure) { - ctx.progress.skipRemaining(batchOffset + batch.length); - break; - } + // `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; } -async function executeBatch( - batch: string[], - batchOffset: number, - ctx: BatchContext, -): Promise< - PromiseSettledResult<{ - issues: SqaaIssue[]; - errors?: Array<{ code: string; message: string }> | null; - }>[] -> { - const responses = await Promise.allSettled( - batch.map(async (file, i) => { - const globalIdx = batchOffset + i; - ctx.progress.update(globalIdx, 'analyzing'); - const fileContent = readSqaaFileContent(file); - const response = await fetchWithRetry( - ctx.cloudAuth, - ctx.projectKey, - file, - fileContent, - ctx.branch, - async (attempt) => { - await ctx.progress.retrying( - globalIdx, - attempt, - MAX_503_RETRIES, - RETRY_503_BASE_DELAY_MS * 2 ** (attempt - 1), - ); - // retrying() already resets status to 'analyzing' when the countdown ends. - }, - ); - ctx.progress.update(globalIdx, 'done'); - return response; - }), - ); - - for (let i = 0; i < responses.length; i++) { - if (responses[i].status === 'rejected') { - ctx.progress.update(batchOffset + i, 'failed'); - } +/** + * 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 }; } - - return responses; } -export function tallyResults(results: FileResult[], tally: BatchTally): void { +export function tallyResults(results: FileResult[], tally: RunTally): void { for (const r of results) { if ('failure' in r) { tally.totalFailures += 1; @@ -139,38 +155,3 @@ export function tallyResults(results: FileResult[], tally: BatchTally): void { } } } - -export function collectBatchResults( - batch: string[], - batchPaths: string[], - batchResponses: PromiseSettledResult<{ - issues: SqaaIssue[]; - errors?: Array<{ code: string; message: string }> | null; - }>[], -): { results: FileResult[]; hadFailure: boolean } { - const results: FileResult[] = []; - let hadFailure = false; - - for (let i = 0; i < batchResponses.length; i++) { - const resp = batchResponses[i]; - const file = batch[i]; - const filePath = batchPaths[i]; - if (resp.status === 'fulfilled') { - results.push({ file, filePath, issues: resp.value.issues, errors: resp.value.errors }); - } else { - results.push({ file, filePath, failure: resp.reason as Error }); - hadFailure = true; - } - } - - return { results, hadFailure }; -} - -/** Split an array into chunks of at most `size` elements. */ -export function chunkArray(arr: T[], size: number): T[][] { - const chunks: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - chunks.push(arr.slice(i, i + size)); - } - return chunks; -} diff --git a/src/cli/commands/analyze/sqaa-api.ts b/src/cli/commands/analyze/sqaa-api.ts index 71a879b2..bcb8292f 100644 --- a/src/cli/commands/analyze/sqaa-api.ts +++ b/src/cli/commands/analyze/sqaa-api.ts @@ -58,14 +58,14 @@ export function readSqaaFileContent(file: string): string { } /** - * Compute a POSIX-style relative path under the current working directory. - * Throws when the file is outside cwd (traversal) or on a different drive. + * 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): string { - const rel = normalizePath(relative(process.cwd(), file)); +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 the current working directory: ${file}`); + throw new InvalidOptionError(`File must be inside ${base}: ${file}`); } return rel; @@ -74,6 +74,10 @@ export function toRelativePosixPath(file: string): string { /** * 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, @@ -81,8 +85,9 @@ export async function fetchSqaaResponse( file: string, fileContent: string, branch: string | undefined, + pathBase?: string, ): Promise<{ issues: SqaaIssue[]; errors?: Array<{ code: string; message: string }> | null }> { - const filePath = toRelativePosixPath(file); + const filePath = toRelativePosixPath(file, pathBase); const client = new SonarQubeClient(auth.serverUrl, auth.token); try { return await client.analyzeFile({ @@ -108,10 +113,11 @@ export async function fetchWithRetry( 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); + return await fetchSqaaResponse(auth, projectKey, file, fileContent, branch, pathBase); } catch (err) { const shouldRetry = err instanceof ServiceUnavailableError && attempt <= MAX_503_RETRIES; if (!shouldRetry) throw err; diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts index 0febc7ab..a2488e9b 100644 --- a/src/cli/commands/analyze/sqaa-auth.ts +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -24,6 +24,7 @@ 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'; @@ -47,15 +48,16 @@ export interface CloudAuth { * Combines cloud-auth validation and project-key resolution. * Returns null (with a warning already printed) when SQAA should be skipped. */ -export function resolveCloudAuthAndProject( +export async function resolveCloudAuthAndProject( auth: ResolvedAuth, explicitProject: string | undefined, command: Command | undefined, -): { cloudAuth: CloudAuth; projectKey: string } | null { + projectRoot?: string, +): Promise<{ cloudAuth: CloudAuth; projectKey: string } | null> { const cloudAuth = resolveCloudAuth(auth, explicitProject); if (!cloudAuth) return null; - const projectKey = explicitProject ?? resolveSqaaProjectKey(command); + 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', @@ -91,13 +93,25 @@ export function resolveCloudAuth( } /** - * Look up the project key for the current directory from the agentExtensions registry. - * Returns null when SQAA should be skipped. + * 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 function resolveSqaaProjectKey(command?: Command): string | null { +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', process.cwd()); + const extensions = findExtensionsByProject(state, 'claude-code', root); const sqaaExt = extensions.find( (e): e is HookExtension => e.kind === 'hook' && e.name === 'sonar-sqaa', ); @@ -119,10 +133,26 @@ export function resolveSqaaProjectKey(command?: Command): string | 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 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-TTY (agent/CI) mode, prints a warning and auto-proceeds. - * Returns false only when the user explicitly declines in an interactive terminal. + * 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(); @@ -130,7 +160,7 @@ export async function confirmLargeChangeset(fileCount: number): Promise `You are about to analyze a large number of files (${fileCount}). This may take longer to process.\n${LARGE_CHANGESET_HINT}`, ); - if (!process.stdout.isTTY) { + if (!process.stdin.isTTY) { return true; } diff --git a/src/cli/commands/analyze/sqaa-changeset.ts b/src/cli/commands/analyze/sqaa-changeset.ts index 8b1834ad..35ec68f2 100644 --- a/src/cli/commands/analyze/sqaa-changeset.ts +++ b/src/cli/commands/analyze/sqaa-changeset.ts @@ -46,12 +46,18 @@ export interface IgnoredFile { 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) @@ -63,21 +69,34 @@ export async function resolveChangeSet( ): Promise { const { staged, base } = options; - const diffFiles = await getDiffFiles(cwd, { staged, base }); - const untrackedFiles = staged ? [] : await getUntrackedNonIgnoredFiles(cwd); - const absolute = [...diffFiles, ...untrackedFiles].map((f) => join(cwd, f)); + 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] }; + 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 out.trim(); } async function getDiffFiles( cwd: string, opts: { staged?: boolean; base?: string }, ): Promise { - const args: string[] = ['diff', '--name-only', '--diff-filter=ACMR']; + // -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'); @@ -88,12 +107,12 @@ async function getDiffFiles( } const result = await runGit(args, cwd); - return parseLines(result); + return parseNulSeparated(result); } async function getUntrackedNonIgnoredFiles(cwd: string): Promise { - const result = await runGit(['ls-files', '--others', '--exclude-standard'], cwd); - return parseLines(result); + const result = await runGit(['ls-files', '-z', '--others', '--exclude-standard'], cwd); + return parseNulSeparated(result); } async function runGit(args: string[], cwd: string): Promise { @@ -111,11 +130,8 @@ async function runGit(args: string[], cwd: string): Promise { return result.stdout; } -function parseLines(output: string): string[] { - return output - .split('\n') - .map((l) => l.trim()) - .filter((l) => l.length > 0); +function parseNulSeparated(output: string): string[] { + return output.split('\0').filter((p) => p.length > 0); } /** diff --git a/src/cli/commands/analyze/sqaa-display.ts b/src/cli/commands/analyze/sqaa-display.ts index 6823b7db..d4b250ff 100644 --- a/src/cli/commands/analyze/sqaa-display.ts +++ b/src/cli/commands/analyze/sqaa-display.ts @@ -22,12 +22,12 @@ import type { SqaaIssue } from '../../../sonarqube/client'; import { blank, error, print, success, text } from '../../../ui'; -import type { BatchTally, FileFailure, FileResult, FileSuccess } from './sqaa-analysis'; +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. */ -const EXIT_CODE_ISSUES_FOUND = 51; +export const EXIT_CODE_ISSUES_FOUND = 51; export interface SqaaJsonReport { files: Array<{ @@ -37,10 +37,17 @@ export interface SqaaJsonReport { }>; ignored: Array<{ path: string; reason: 'binary' | 'oversized' }>; failures: Array<{ path: string; message: string }>; - summary: { totalIssues: number; totalFailures: number }; + /** 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: BatchTally, ignored: IgnoredFile[]): void { +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 })); @@ -49,11 +56,22 @@ export function printJsonReport(tally: BatchTally, ignored: IgnoredFile[]): void .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), reason: f.reason })), + ignored: ignored.map((f) => ({ + path: toRelativePosixPath(f.path, pathBase), + reason: f.reason, + })), failures, - summary: { totalIssues: tally.totalIssues, totalFailures: tally.totalFailures }, + skipped, + summary: { + totalIssues: tally.totalIssues, + totalFailures: tally.totalFailures, + totalSkipped: skipped.length, + }, }; print(JSON.stringify(report, null, 2)); diff --git a/src/cli/commands/analyze/sqaa.ts b/src/cli/commands/analyze/sqaa.ts index 569df773..c88a3e1d 100644 --- a/src/cli/commands/analyze/sqaa.ts +++ b/src/cli/commands/analyze/sqaa.ts @@ -22,24 +22,28 @@ import { existsSync } from 'node:fs'; import type { Command } from 'commander'; import type { ResolvedAuth } from '../../../lib/auth-resolver'; -import { blank, print, setMockUi, text } from '../../../ui'; +import { blank, print, text } from '../../../ui'; import { SqaaProgress } from '../../../ui/components/sqaa-progress.js'; import { InvalidOptionError } from '../_common/error.js'; -import type { BatchContext, BatchTally } from './sqaa-analysis'; -import { runBatches } from './sqaa-analysis'; +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 { IgnoredFile } from './sqaa-changeset'; +import type { ChangeSetResult } from './sqaa-changeset'; import { resolveChangeSet } from './sqaa-changeset'; -import { applyExitCode, printFileDetails, printJsonReport, printSummary } from './sqaa-display'; - -/** Exit code when analysis succeeds and issues are found. */ -const EXIT_CODE_ISSUES_FOUND = 51; +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 = 20; @@ -77,15 +81,15 @@ export async function analyzeSqaa( } // Change-set mode: resolve files from Git. - const { files, ignored } = await resolveChangeSet(process.cwd(), { staged, base }); + const changeSet = await resolveChangeSet(process.cwd(), { staged, base }); - if (files.length === 0 && ignored.length === 0) { + if (changeSet.files.length === 0 && changeSet.ignored.length === 0) { blank(); text('SonarQube Agentic Analysis: no files in the change set to analyze.'); return; } - if (files.length === 0) { + if (changeSet.files.length === 0) { blank(); text( 'SonarQube Agentic Analysis: no files to analyze — all change set files were excluded (binary or oversized).', @@ -93,12 +97,18 @@ export async function analyzeSqaa( return; } - if (!force && files.length > SQAA_LARGE_CHANGESET_THRESHOLD) { - const confirmed = await confirmLargeChangeset(files.length); + // 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; + + // 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; } - await runSqaaAnalysisOnFiles(files, ignored, auth, branch, project, command, format); + await runSqaaAnalysisOnFiles(changeSet, resolved, branch, format); } async function runSqaaAnalysis( @@ -109,7 +119,7 @@ async function runSqaaAnalysis( command?: Command, format: OutputFormat = 'text', ): Promise { - const resolved = resolveCloudAuthAndProject(auth, explicitProject, command); + const resolved = await resolveCloudAuthAndProject(auth, explicitProject, command); if (!resolved) return; const { cloudAuth, projectKey } = resolved; @@ -123,7 +133,8 @@ async function runSqaaAnalysis( files: [{ path: filePath, issues: response.issues, errors: response.errors }], ignored: [], failures: [], - summary: { totalIssues: response.issues.length, totalFailures: 0 }, + 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; @@ -132,7 +143,8 @@ async function runSqaaAnalysis( files: [], ignored: [], failures: [{ path: filePath, message: (err as Error).message }], - summary: { totalIssues: 0, totalFailures: 1 }, + skipped: [], + summary: { totalIssues: 0, totalFailures: 1, totalSkipped: 0 }, }; print(JSON.stringify(report, null, 2)); process.exitCode = 1; @@ -147,47 +159,46 @@ async function runSqaaAnalysis( } async function runSqaaAnalysisOnFiles( - files: string[], - ignored: IgnoredFile[], - auth: ResolvedAuth, + changeSet: ChangeSetResult, + resolved: { cloudAuth: CloudAuth; projectKey: string }, branch?: string, - explicitProject?: string, - command?: Command, format: OutputFormat = 'text', ): Promise { - const resolved = resolveCloudAuthAndProject(auth, explicitProject, command); - if (!resolved) return; - + const { files, ignored, repoRoot } = changeSet; const { cloudAuth, projectKey } = resolved; - const allPaths = files.map(toRelativePosixPath); + const allPaths = files.map((f) => toRelativePosixPath(f, repoRoot)); if (format === 'json') { - // Run batches with all UI suppressed, then emit structured JSON. - const silentProgress = new SqaaProgress({ files: allPaths }); - const ctx: BatchContext = { + // 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, }; - setMockUi(true); - let tally: BatchTally; - try { - tally = await runBatches(ctx); - } finally { - setMockUi(false); - } - printJsonReport(tally, ignored); + const tally = await runAnalyses(ctx); + printJsonReport(tally, ignored, allPaths, repoRoot); applyExitCode(tally.totalIssues, tally.totalFailures); return; } - const ignoredPaths = ignored.map((f) => toRelativePosixPath(f.path)); + const ignoredPaths = ignored.map((f) => toRelativePosixPath(f.path, repoRoot)); const progress = new SqaaProgress({ files: allPaths, ignoredFiles: ignoredPaths }); - const ctx: BatchContext = { files, allPaths, cloudAuth, projectKey, branch, progress }; - const tally = await runBatches(ctx); + 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); diff --git a/src/ui/components/sqaa-progress.ts b/src/ui/components/sqaa-progress.ts index 1de65876..29b39324 100644 --- a/src/ui/components/sqaa-progress.ts +++ b/src/ui/components/sqaa-progress.ts @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// Live progress display for SQAA batch analysis. +// Live progress display for the SQAA worker-pool run. import * as readline from 'node:readline'; @@ -74,7 +74,7 @@ function statusIcon(status: FileStatus): string { return '●'; } -/** Icon used in non-TTY per-file result lines after a batch commits or in finish(). */ +/** 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('✗'); @@ -89,19 +89,21 @@ function formatFileLine(path: string, icon: string, label: string, colWidth: num } /** - * Single progress renderer for an entire multi-batch SQAA run. + * Single progress renderer for an entire SQAA worker-pool run. * * Initialized with all file paths upfront; all files are visible throughout the run. - * Batching is purely a concurrency concern — the display always shows the full list, - * with per-file statuses updated as each batch progresses. * * TTY: maintains one block on screen — erases and rewrites it in-place on every update. - * Non-TTY: prints a static header per batch and per-file result lines on commitBatch. + * 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; /** Width of the path column — longest path length + 1 space minimum. */ private readonly colWidth: number; /** Per-file dynamic label override (used for retry countdown). */ @@ -109,42 +111,56 @@ export class SqaaProgress { /** Number of lines currently written to stdout (TTY mode only). */ private linesRendered = 0; - constructor(opts: { files: string[]; ignoredFiles?: string[]; isTTY?: boolean }) { + 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.colWidth = Math.max(...this.allFiles.map((f) => f.length), 0) + 2; } /** - * Signal the start of a new batch (identified by global offset into allFiles). - * TTY: redraws the full block. - * Non-TTY: prints a static "Analyzing files N–M of total..." header. + * 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. */ - startBatch(batchOffset: number, batchSize: number): void { + start(): void { + if (this.silent) return; if (isMockActive()) { - recordCall('sqaaProgress.startBatch', batchOffset, batchSize); + recordCall('sqaaProgress.start'); return; } if (this.isTTY) { this.eraseTTY(); this.renderTTY(); } else { - const from = batchOffset + 1; - const to = Math.min(batchOffset + batchSize, this.allFiles.length); - process.stdout.write(`\nAnalyzing files ${from}–${to} of ${this.allFiles.length}...\n`); + // Files counted are the ones the pool will process — exclude files already + // marked as ignored (binary/oversized) at construction time. + const toProcess = this.statuses.filter((s) => s === 'waiting').length; + process.stdout.write(`\nAnalyzing ${toProcess} files...\n`); } } /** * Update a file's status by its global index across all files. * TTY: redraws the full block. - * Non-TTY: no-op (deferred to commitBatch). + * 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; @@ -153,6 +169,10 @@ export class SqaaProgress { if (this.isTTY) { this.eraseTTY(); this.renderTTY(); + return; + } + if (status === 'done' || status === 'failed') { + process.stdout.write(` ${fileStatusIcon(status)} ${this.allFiles[globalIndex]}\n`); } } @@ -169,6 +189,13 @@ export class SqaaProgress { 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; @@ -196,30 +223,20 @@ export class SqaaProgress { this.statuses[globalIndex] = 'analyzing'; } - /** - * Commit the current batch's final state. - * TTY: no-op (block stays on screen; next startBatch or finish will update it). - * Non-TTY: prints per-file ✓/✗ result lines for the batch slice. - */ - commitBatch(batchOffset: number, batchSize: number): void { - if (isMockActive()) { - recordCall('sqaaProgress.commitBatch', batchOffset, batchSize); - return; - } - if (!this.isTTY) { - const end = Math.min(batchOffset + batchSize, this.allFiles.length); - for (let i = batchOffset; i < end; i++) { - process.stdout.write(` ${fileStatusIcon(this.statuses[i])} ${this.allFiles[i]}\n`); - } - process.stdout.write('\n'); - } - } - /** * 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; @@ -232,12 +249,22 @@ export class SqaaProgress { } /** - * Called once after all batches are done (or fail-fast). + * 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 committed (fail-fast path). + * 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; @@ -259,7 +286,8 @@ export class SqaaProgress { process.stdout.write('\n'); this.linesRendered = 0; } else { - // Print skipped files that were never reached by any batch's commitBatch. + // 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`); diff --git a/tests/integration/specs/analyze/analyze-sqaa.test.ts b/tests/integration/specs/analyze/analyze-sqaa.test.ts index 93260e5f..06b7a617 100644 --- a/tests/integration/specs/analyze/analyze-sqaa.test.ts +++ b/tests/integration/specs/analyze/analyze-sqaa.test.ts @@ -754,9 +754,9 @@ describe('analyze sqaa — change-set mode (no --file)', () => { await bareHarness.dispose(); - // git diff HEAD fails with non-zero exit outside a git repo → CommandFailedError → exit 1 + // git rev-parse --show-toplevel fails outside a git repo → CommandFailedError → exit 1 expect(result.exitCode).toBe(1); - expect(result.stdout + result.stderr).toContain('git diff failed'); + expect(result.stdout + result.stderr).toContain('not a git repository'); }, { timeout: 15000 }, ); @@ -1139,4 +1139,137 @@ describe('analyze sqaa — --format json', () => { }, { 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'); + // 21 files: > concurrency (3) so fail-fast leaves later files skipped, + // and > large-changeset threshold (20) so we also exercise the no-prompt path. + for (let i = 1; i <= 21; 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(21); + }, + { 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); + 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/unit/cli/commands/analyze/analyze-sqaa.test.ts b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts index a623b678..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 ───────────────────────────────────────────────────────────── diff --git a/tests/unit/ui/sqaa-progress.test.ts b/tests/unit/ui/sqaa-progress.test.ts index 1ca19298..c9705130 100644 --- a/tests/unit/ui/sqaa-progress.test.ts +++ b/tests/unit/ui/sqaa-progress.test.ts @@ -56,22 +56,36 @@ async function captureStdoutAsync(fn: () => Promise): Promise { } describe('SqaaProgress — non-TTY mode', () => { - it('prints batch header, file results, and skipped files on fail-fast', () => { + 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.startBatch(0, 2)); - expect(header).toContain('Analyzing files 1–2 of 3'); + const header = captureStdout(() => progress.start()); + expect(header).toContain('Analyzing 3 files'); - progress.update(0, 'done'); - progress.update(1, 'failed'); - const commit = captureStdout(() => progress.commitBatch(0, 2)); - expect(commit).toContain('src/a.ts'); - expect(commit).toContain('src/b.ts'); - expect(commit.endsWith('\n\n')).toBe(true); + // 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'); // skipped file printed in finish + 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 () => { @@ -86,7 +100,7 @@ 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.startBatch(0, 3)); + const start = captureStdout(() => progress.start()); expect(start).toContain('SonarQube Agentic Analysis in progress'); expect(start).toContain('0/3 files analyzed'); expect(start).toContain('[WAITING]'); @@ -111,7 +125,7 @@ describe('SqaaProgress — TTY mode', () => { it('retrying shows live countdown label and resets to analyzing', async () => { const progress = new SqaaProgress({ files: FILES, isTTY: true }); - captureStdout(() => progress.startBatch(0, 3)); + 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'); @@ -132,9 +146,8 @@ describe('SqaaProgress — mock mode', () => { const progress = new SqaaProgress({ files: FILES }); const output = captureStdout(() => { - progress.startBatch(0, 3); + progress.start(); progress.update(0, 'done'); - progress.commitBatch(0, 3); progress.skipRemaining(1); progress.finish(3); }); @@ -142,11 +155,52 @@ describe('SqaaProgress — mock mode', () => { expect(output).toBe(''); const methods = getMockUiCalls().map((c) => c.method); - expect(methods).toContain('sqaaProgress.startBatch'); + expect(methods).toContain('sqaaProgress.start'); expect(methods).toContain('sqaaProgress.update'); - expect(methods).toContain('sqaaProgress.commitBatch'); 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'); + }); +}); From e5f0f225ba799a63c314fbde91688e63972abeb7 Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 14:56:23 +0200 Subject: [PATCH 05/10] Fix --- tests/integration/specs/remediate/remediate.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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'); From 8fcc39883830ae5fe4e39afb90a663479c9ab8bb Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 14:59:44 +0200 Subject: [PATCH 06/10] Fix review --- src/ui/components/sqaa-progress.ts | 19 +++++++++++++------ tests/unit/ui/sqaa-progress.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/ui/components/sqaa-progress.ts b/src/ui/components/sqaa-progress.ts index 29b39324..98a16996 100644 --- a/src/ui/components/sqaa-progress.ts +++ b/src/ui/components/sqaa-progress.ts @@ -104,6 +104,13 @@ export class SqaaProgress { 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). */ @@ -124,6 +131,7 @@ export class SqaaProgress { 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; } @@ -145,8 +153,7 @@ export class SqaaProgress { } else { // Files counted are the ones the pool will process — exclude files already // marked as ignored (binary/oversized) at construction time. - const toProcess = this.statuses.filter((s) => s === 'waiting').length; - process.stdout.write(`\nAnalyzing ${toProcess} files...\n`); + process.stdout.write(`\nAnalyzing ${this.processableTotal} files...\n`); } } @@ -271,8 +278,8 @@ export class SqaaProgress { } if (this.isTTY) { this.eraseTTY(); - const bar = renderBar(processedTotal, this.allFiles.length); - process.stdout.write(`${bar} ${processedTotal}/${this.allFiles.length} files analyzed\n\n`); + 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( @@ -298,10 +305,10 @@ export class SqaaProgress { private buildLines(): string[] { const done = this.statuses.filter((s) => s === 'done' || s === 'failed').length; - const bar = renderBar(done, this.allFiles.length); + const bar = renderBar(done, this.processableTotal); const lines: string[] = [ bold('SonarQube Agentic Analysis in progress...'), - `${bar} ${done}/${this.allFiles.length} files analyzed`, + `${bar} ${done}/${this.processableTotal} files analyzed`, '', ]; for (let i = 0; i < this.allFiles.length; i++) { diff --git a/tests/unit/ui/sqaa-progress.test.ts b/tests/unit/ui/sqaa-progress.test.ts index c9705130..d671f7d8 100644 --- a/tests/unit/ui/sqaa-progress.test.ts +++ b/tests/unit/ui/sqaa-progress.test.ts @@ -133,6 +133,31 @@ describe('SqaaProgress — TTY mode', () => { 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', () => { From 3dc860f1e2ecb58d52b5cbffad0f97827ab79014 Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 15:07:48 +0200 Subject: [PATCH 07/10] Improve --- src/cli/commands/analyze/sqaa-analysis.ts | 2 +- src/cli/commands/analyze/sqaa.ts | 2 +- .../specs/analyze/analyze-sqaa.test.ts | 28 +++++++++---------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/cli/commands/analyze/sqaa-analysis.ts b/src/cli/commands/analyze/sqaa-analysis.ts index 709a5f0b..19279e9a 100644 --- a/src/cli/commands/analyze/sqaa-analysis.ts +++ b/src/cli/commands/analyze/sqaa-analysis.ts @@ -31,7 +31,7 @@ import { import type { CloudAuth } from './sqaa-auth'; /** Maximum number of files analyzed concurrently. */ -export const SQAA_CONCURRENCY = 3; +export const SQAA_CONCURRENCY = 20; export type FileSuccess = { file: string; diff --git a/src/cli/commands/analyze/sqaa.ts b/src/cli/commands/analyze/sqaa.ts index c88a3e1d..51aeffa2 100644 --- a/src/cli/commands/analyze/sqaa.ts +++ b/src/cli/commands/analyze/sqaa.ts @@ -46,7 +46,7 @@ import { } from './sqaa-display'; /** Change-set size above which the user is prompted to confirm before proceeding. */ -const SQAA_LARGE_CHANGESET_THRESHOLD = 20; +const SQAA_LARGE_CHANGESET_THRESHOLD = 50; export const VALID_FORMATS = ['text', 'json'] as const; export type OutputFormat = (typeof VALID_FORMATS)[number]; diff --git a/tests/integration/specs/analyze/analyze-sqaa.test.ts b/tests/integration/specs/analyze/analyze-sqaa.test.ts index 06b7a617..ca27f1fe 100644 --- a/tests/integration/specs/analyze/analyze-sqaa.test.ts +++ b/tests/integration/specs/analyze/analyze-sqaa.test.ts @@ -429,18 +429,18 @@ describe('analyze sqaa — change-set mode (no --file)', () => { .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); - for (let i = 1; i <= 21; i++) { + 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 (21)'); + 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(21); + expect(sqaaCalls).toHaveLength(51); }, { timeout: 30000 }, ); @@ -459,7 +459,7 @@ describe('analyze sqaa — change-set mode (no --file)', () => { .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); - for (let i = 1; i <= 21; i++) { + for (let i = 1; i <= 51; i++) { harness.cwd.writeFile(`file${i}.ts`, `const x${i} = ${i};`); } @@ -470,7 +470,7 @@ describe('analyze sqaa — change-set mode (no --file)', () => { const sqaaCalls = server .getRecordedRequests() .filter((r) => r.path === '/a3s-analysis/analyses'); - expect(sqaaCalls).toHaveLength(21); + expect(sqaaCalls).toHaveLength(51); }, { timeout: 30000 }, ); @@ -849,18 +849,18 @@ describe('verify — change-set mode (no --file)', () => { .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); - for (let i = 1; i <= 21; i++) { + 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 (21)'); + 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(21); + expect(sqaaCalls).toHaveLength(51); }, { timeout: 30000 }, ); @@ -879,7 +879,7 @@ describe('verify — change-set mode (no --file)', () => { .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); - for (let i = 1; i <= 21; i++) { + for (let i = 1; i <= 51; i++) { harness.cwd.writeFile(`file${i}.ts`, `const x${i} = ${i};`); } @@ -890,7 +890,7 @@ describe('verify — change-set mode (no --file)', () => { const sqaaCalls = server .getRecordedRequests() .filter((r) => r.path === '/a3s-analysis/analyses'); - expect(sqaaCalls).toHaveLength(21); + expect(sqaaCalls).toHaveLength(51); }, { timeout: 30000 }, ); @@ -1157,9 +1157,9 @@ describe('analyze sqaa — --format json', () => { .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); - // 21 files: > concurrency (3) so fail-fast leaves later files skipped, - // and > large-changeset threshold (20) so we also exercise the no-prompt path. - for (let i = 1; i <= 21; i++) { + // 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};`); } @@ -1179,7 +1179,7 @@ describe('analyze sqaa — --format json', () => { 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(21); + expect(report.files.length + report.failures.length + report.skipped.length).toBe(51); }, { timeout: 30000 }, ); From a478870d7288b9ff5605cc120223401cca4d46f2 Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 15:19:20 +0200 Subject: [PATCH 08/10] Fix --- src/cli/commands/analyze/sqaa-auth.ts | 4 +++- src/cli/commands/analyze/sqaa-changeset.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts index a2488e9b..6cb2e46e 100644 --- a/src/cli/commands/analyze/sqaa-auth.ts +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -20,6 +20,8 @@ // 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'; @@ -141,7 +143,7 @@ async function tryResolveRepoRoot(cwd: string): Promise { try { const result = await spawnProcess('git', ['rev-parse', '--show-toplevel'], { cwd }); if (result.exitCode === 0) { - return result.stdout.trim(); + return resolve(result.stdout.trim()); } } catch { // git not installed or otherwise unavailable — fall through to cwd. diff --git a/src/cli/commands/analyze/sqaa-changeset.ts b/src/cli/commands/analyze/sqaa-changeset.ts index 35ec68f2..af7d7159 100644 --- a/src/cli/commands/analyze/sqaa-changeset.ts +++ b/src/cli/commands/analyze/sqaa-changeset.ts @@ -21,7 +21,7 @@ // Resolves the set of local files to analyze from Git, honouring .gitignore. import { closeSync, openSync, readSync, statSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, resolve } from 'node:path'; import { spawnProcess } from '../../../lib/process'; import { CommandFailedError } from '../_common/error'; @@ -87,7 +87,7 @@ export async function resolveChangeSet( */ async function resolveRepoRoot(cwd: string): Promise { const out = await runGit(['rev-parse', '--show-toplevel'], cwd); - return out.trim(); + return resolve(out.trim()); } async function getDiffFiles( From 6e724b2a88b39b48dfa157f1b06c93ca17237928 Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 15:31:06 +0200 Subject: [PATCH 09/10] Fix --- src/lib/state-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/state-manager.ts b/src/lib/state-manager.ts index f7b15d8b..21a24a59 100644 --- a/src/lib/state-manager.ts +++ b/src/lib/state-manager.ts @@ -24,6 +24,7 @@ */ import crypto from 'node:crypto'; +import { relative } from 'node:path'; export { loadState, saveState } from './repository/state-repository.js'; @@ -55,7 +56,7 @@ export function findExtensionsByProject( projectRoot: string, ): AgentExtension[] { return state.agentExtensions.filter( - (e) => e.agentId === agentId && e.projectRoot === projectRoot, + (e) => e.agentId === agentId && relative(e.projectRoot, projectRoot) === '', ); } From ded6ac5cbb76bed90a890d1e1e973138c3cf9cbf Mon Sep 17 00:00:00 2001 From: Nicolas Quinquenel Date: Fri, 8 May 2026 15:45:12 +0200 Subject: [PATCH 10/10] Test --- src/lib/state-manager.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/lib/state-manager.ts b/src/lib/state-manager.ts index 21a24a59..8e308420 100644 --- a/src/lib/state-manager.ts +++ b/src/lib/state-manager.ts @@ -24,7 +24,8 @@ */ import crypto from 'node:crypto'; -import { relative } from 'node:path'; +import { realpathSync } from 'node:fs'; +import { resolve } from 'node:path'; export { loadState, saveState } from './repository/state-repository.js'; @@ -47,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. */ @@ -55,8 +66,9 @@ export function findExtensionsByProject( agentId: string, projectRoot: string, ): AgentExtension[] { + const target = canonicalProjectRoot(projectRoot); return state.agentExtensions.filter( - (e) => e.agentId === agentId && relative(e.projectRoot, projectRoot) === '', + (e) => e.agentId === agentId && canonicalProjectRoot(e.projectRoot) === target, ); }