Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions src/commands/architecture/_common/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ import { HTTP_STATUS_FORBIDDEN, HTTP_STATUS_NOT_FOUND } from '@/core/server/http
const HTTP_SUCCESS_MIN_STATUS = 200;
const HTTP_REDIRECTION_MIN_STATUS = 300;

const PLAN_HINT =
'Architecture needs SonarQube Cloud Team or Enterprise, or the Architecture feature on SonarQube Server.';
const PLAN_HINT = 'Architecture needs SonarQube Cloud Team or Enterprise.';

const UPDATE_HINT =
'Update the CLI with `sonar update`, or check that the connected SonarQube deployment supports the same public Architecture API.';
Expand All @@ -40,7 +39,7 @@ export function incompatibleResponse(message: string, cause?: Error): CommandFai
return new CommandFailedError(message, { cause, remediationHint: UPDATE_HINT });
}

export function collectionFailure(error: unknown, isCloud: boolean): never {
export function collectionFailure(error: unknown, _isCloud: boolean): never {
if (
error instanceof UnexpectedApiError &&
error.status >= HTTP_SUCCESS_MIN_STATUS &&
Expand All @@ -55,11 +54,7 @@ export function collectionFailure(error: unknown, isCloud: boolean): never {
throw new CommandFailedError('The public Architecture API endpoint was not found (HTTP 404).', {
cause: error,
remediationHint:
'Check feature availability, Browse permission and API routing. ' +
UPDATE_HINT +
(isCloud
? ''
: ' On older Server versions, use a Server build that exposes the public Architecture APIs.'),
'Check feature availability, Browse permission and API routing. ' + UPDATE_HINT,
});
}
throw error;
Expand Down
26 changes: 0 additions & 26 deletions src/commands/architecture/_common/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,37 +24,11 @@ import {
assertSingleLineProjectKey,
discoverProject,
} from '@/core/project-discovery/project-info.ts';
import { fetchServerVersion, isAtLeast, isFullYearVersion } from '@/core/server/server-info.ts';

const MIN_ARCHITECTURE_SERVER_VERSION = '2026.6';
const MIN_COMMUNITY_BUILD_YEAR = 20;

export async function resolveProject(
options: { project?: string },
ctx: CommandAuthenticatedInvocationContext,
): Promise<string> {
if (ctx.auth.connectionType !== 'cloud') {
const version = await fetchServerVersion(ctx.auth.serverUrl).catch(() => undefined);
if (
version !== undefined &&
!isFullYearVersion(version) &&
Number(version.split('.')[0]) >= MIN_COMMUNITY_BUILD_YEAR
) {
throw new CommandFailedError(
`SonarQube Community Build ${version} does not include Architecture; SonarQube Server ${MIN_ARCHITECTURE_SERVER_VERSION} or later is required.`,
{ remediationHint: 'Use SonarQube Server or SonarQube Cloud for Architecture commands.' },
);
}
if (
version !== undefined &&
(!isFullYearVersion(version) || !isAtLeast(version, MIN_ARCHITECTURE_SERVER_VERSION))
) {
throw new CommandFailedError(
`SonarQube Server ${version} does not support these Architecture commands; ${MIN_ARCHITECTURE_SERVER_VERSION} or later is required.`,
{ remediationHint: 'Upgrade SonarQube Server, then retry.' },
);
}
}
const projectKey =
options.project ??
(
Expand Down
53 changes: 43 additions & 10 deletions src/commands/architecture/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

import { InvalidArgumentError } from 'commander';

import { CommandFailedError } from '@/core/commands/command-error.ts';
import type { CommandAuthenticatedInvocationContext } from '@/core/commands/invocation-context.ts';
import { parseInteger } from '@/core/commands/params.ts';
import { type SonarCommand, SonarOption, Stage } from '@/core/commands/sonar-command.ts';

Expand Down Expand Up @@ -57,11 +59,23 @@ function singleValue(option: SonarOption): SonarOption {
});
}

function requireCloud(ctx: CommandAuthenticatedInvocationContext): void {
if (ctx.auth.connectionType !== 'cloud') {
throw new CommandFailedError(
'The sonar architecture command requires a SonarQube Cloud connection.',
{
remediationHint: 'Authenticate against SonarQube Cloud with ‘sonar auth login’ and retry.',
},
);
}
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved

export function registerArchitectureCommands(parent: SonarCommand): void {
const architecture = parent
.command('architecture')
.alias('arch')
.description('Read and set the architecture of a project')
.description('Read and set the architecture of a project (SonarQube Cloud only)')
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
Comment thread
gitar-bot[bot] marked this conversation as resolved.
Resolved
.addHelpText('afterAll', '\n(SonarQube Cloud only)')
.rootHelp({ category: 'data', label: 'architecture|arch <command>' });

const currentArchitecture = architecture
Expand Down Expand Up @@ -101,7 +115,10 @@ export function registerArchitectureCommands(parent: SonarCommand): void {
)
.addOption(languageOption())
.addOption(textFormatOption())
.authenticatedAction((ctx, options: CurrentReadOptions) => getCurrent(options, ctx));
.authenticatedAction((ctx, options: CurrentReadOptions) => {
requireCloud(ctx);
return getCurrent(options, ctx);
});
intendedArchitecture
.command('get')
.description(
Expand All @@ -112,7 +129,10 @@ export function registerArchitectureCommands(parent: SonarCommand): void {
.option('--depth <n>', 'Levels of groups below the start group (default: all)', parseInteger)
.addOption(languageOption())
.addOption(textFormatOption())
.authenticatedAction((ctx, options: ArchitectureReadOptions) => getIntended(options, ctx));
.authenticatedAction((ctx, options: ArchitectureReadOptions) => {
requireCloud(ctx);
return getIntended(options, ctx);
});

intendedArchitecture
.command('set')
Expand All @@ -122,7 +142,10 @@ export function registerArchitectureCommands(parent: SonarCommand): void {
.requiredOption('--file <file>', 'Intended architecture JSON file')
.option('--dry-run', 'Validate and show the changes, removals first, without writing')
.option('-p, --project <project>', 'Project key')
.authenticatedAction((ctx, options: SetFileOptions) => setIntended(options, ctx));
.authenticatedAction((ctx, options: SetFileOptions) => {
requireCloud(ctx);
return setIntended(options, ctx);
});

const directives = architecture
.command('directives')
Expand All @@ -135,9 +158,10 @@ export function registerArchitectureCommands(parent: SonarCommand): void {
.addOption(textFormatOption())
.option('--page <n>', 'Page number (default 1)', parseInteger)
.option('--page-size <n>', 'Items per page (default 50, maximum 1000)', parseInteger)
.authenticatedAction((ctx, options: DirectivesReadOptions) =>
getArchitectureDirectives(options, ctx),
);
.authenticatedAction((ctx, options: DirectivesReadOptions) => {
requireCloud(ctx);
return getArchitectureDirectives(options, ctx);
});
directives
.command('set')
.description('Create, update or delete explicit project directives')
Expand All @@ -148,7 +172,10 @@ export function registerArchitectureCommands(parent: SonarCommand): void {
'after',
'\nCreation skips existing directives. Updates retain unspecified fields. Deletion requires an explicit ID; omissions never delete entries.',
)
.authenticatedAction((ctx, options: SetFileOptions) => setProjectDirectives(options, ctx));
.authenticatedAction((ctx, options: SetFileOptions) => {
requireCloud(ctx);
return setProjectDirectives(options, ctx);
});

const findings = architecture
.command('findings')
Expand All @@ -175,13 +202,19 @@ export function registerArchitectureCommands(parent: SonarCommand): void {
'Items per page (default 50, 0 counts only, maximum 1000)',
parseFindingPageSize,
)
.authenticatedAction((ctx, options: FindingsReadOptions) => getFindings(options, ctx));
.authenticatedAction((ctx, options: FindingsReadOptions) => {
requireCloud(ctx);
return getFindings(options, ctx);
});

architecture
.command('install-skill')
.stage(Stage.Beta())
.description('Install the architecture skill for Claude Code and Cursor in this repository')
.option('--global', 'Install in the home directory instead of the repository')
.option('--force', 'Replace a SKILL.md that was not written by this command')
.authenticatedAction((ctx, options: InstallSkillOptions) => installSkill(options, ctx));
.authenticatedAction((ctx, options: InstallSkillOptions) => {
requireCloud(ctx);
return installSkill(options, ctx);
});
}
6 changes: 3 additions & 3 deletions src/commands/architecture/skill-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ never repeat a write or roll back automatically.

## Availability

Intended and directive operations on SonarQube Server require the public project lookup
(/api/v2/components/projects) and public Architecture APIs.
The CLI architecture commands require a SonarQube Cloud connection. Check the installed MCP
tool descriptions for their supported environments.
Architecture coverage is not exposed by these adapters in this iteration. Do not call
unavailable operations, claim their results or substitute other analysis tools.

Expand All @@ -171,7 +171,7 @@ unavailable operations, claim their results or substitute other analysis tools.
Read deviations, flaws and smells from the latest analysis with \`sonar architecture findings get\`,
optionally \`--category deviation|flaw|smell\`, \`--type <type>\` or \`--language <language>\`.
Every row includes its category, type, language, details and files; findings have no IDs or separate detail lookup.
Findings use the analyzed main branch on Cloud and Server and are read-only. Use \`--page-size 0\` only to count findings; do not request further count-only pages.
Findings use the analyzed main branch and are read-only. Use \`--page-size 0\` only to count findings; do not request further count-only pages.
To read findings, use a positive page size. Retain \`pageIndex\`, \`pageSize\` and \`total\` from each response.
Keep the same filters and page size, and increment \`--page\` while \`pageIndex * pageSize < total\`.
A zero total means no findings match the active filters or are visible; it does not establish project access or a clean architecture.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/*
* 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.
*/
import { describe, expect, it } from 'bun:test';

import { TestHarness } from '../../harness';

describe('Architecture requires Cloud', () => {
it.each(['2025.1', '2026.6', '26.11'])('blocks every operation on Server %s', async (version) => {
const h = await TestHarness.create();
try {
const server = await h
.newFakeServer()
.withVersion(version)
.withAuthToken('test-token')
.start();
h.withAuth(server.baseUrl(), 'test-token');
for (const command of [
'arch current get -p shop',
'arch intended get -p shop',
'arch intended set -p shop --file missing.json',
'arch directives get -p shop',
'arch directives set -p shop --file missing.json',
'arch findings get -p shop',
'arch install-skill',
]) {
const result = await h.run(command);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
expect(result.stderr).toContain(
'Authenticate against SonarQube Cloud with ‘sonar auth login’ and retry.',
);
}
expect(
server
.getRecordedRequests()
.filter((r) => r.path.includes('/architecture/') || r.path === '/api/system/status'),
).toHaveLength(0);
} finally {
await h.dispose();
}
});

it('keeps Cloud available and shows the command in help', async () => {
const h = await TestHarness.create();
try {
const server = await h
.newFakeServer()
.asSonarCloud()
.withAuthToken('test-token')
.withProject('shop', (p) => p.withArchitecture(() => undefined))
.start();
h.withAuth(server.recordedAuthServerUrl(), 'test-token', 'my-org');
const help = await h.run('architecture --help');
expect(help.stdout).toContain('SonarQube Cloud only');
const result = await h.run('arch current get -p shop --language java --format json');
expect(result.exitCode).toBe(0);
} finally {
await h.dispose();
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,13 @@ describe('Architecture API compatibility diagnostics', () => {
const result = await harness.run(
`arch ${target} get -p shop --format json${target === 'current' ? ' --language java' : ''}`,
);
if (!cloud) {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
return;
}
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('sonar update');
expect(result.stderr).toContain('Architecture API');
Expand Down Expand Up @@ -180,6 +187,13 @@ describe('Architecture API compatibility diagnostics', () => {
const result = await harness.run(
`arch ${target} get -p shop --format json${target === 'current' ? ' --language java' : ''}`,
);
if (!cloud) {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
return;
}
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(COMPATIBILITY);
expect(result.stderr).toContain('sonar update');
Expand Down Expand Up @@ -223,6 +237,13 @@ describe('Architecture API compatibility diagnostics', () => {
cloud ? 'my-org' : undefined,
);
const result = await harness.run('arch directives get -p shop --format json');
if (!cloud) {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
return;
}
expect(result.exitCode).toBe(1);
if (compatibleShape) {
expect(result.stderr).not.toContain(COMPATIBILITY);
Expand Down Expand Up @@ -250,10 +271,16 @@ describe('Architecture API compatibility diagnostics', () => {
const result = await harness.run(
'arch directives get -p shop --id 11111111-1111-4111-8111-111111111111 --format json',
);
if (!cloud) {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
return;
}
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain('was not found or is not accessible');
if (cloud) expect(result.stderr).not.toContain('Server build');
else expect(result.stderr).toContain('Server build');
expect(result.stderr).not.toContain('Server build');
expect(
server
.getRecordedRequests()
Expand Down Expand Up @@ -281,6 +308,13 @@ describe('Architecture API compatibility diagnostics', () => {
const result = await harness.run(
`arch ${target} get -p shop --format json${target === 'current' ? ' --language java' : ''}`,
);
if (!cloud) {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
return;
}
expect(result.exitCode).toBe(0);
expect(JSON.parse(result.stdout)).toBeDefined();
expect(result.stderr + result.stdout).not.toContain(COMPATIBILITY);
Expand Down Expand Up @@ -326,6 +360,13 @@ describe('Architecture API compatibility diagnostics', () => {
}),
);
const result = await harness.run('arch intended set -p shop --file model.json');
if (!cloud) {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
return;
}
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(COMPATIBILITY);
expect(result.stderr).toContain('sonar update');
Expand Down Expand Up @@ -366,11 +407,17 @@ describe('Architecture API compatibility diagnostics', () => {
cloud ? 'my-org' : undefined,
);
const result = await harness.run('arch current get -p shop --language java --format json');
if (!cloud) {
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain(
'The sonar architecture command requires a SonarQube Cloud connection.',
);
return;
}
expect(result.exitCode).toBe(1);
if (status === 404 || (cloud && status === 403))
expect(result.stderr).toContain(COMPATIBILITY);
if (status === 404 || status === 403) expect(result.stderr).toContain(COMPATIBILITY);
else expect(result.stderr).not.toContain(COMPATIBILITY);
if (cloud && status === 403) {
if (status === 403) {
expect(result.stderr).toContain('sonar update');
expect(result.stderr).toContain('permission');
} else if (status !== 404) expect(result.stderr).not.toContain('sonar update');
Expand Down
Loading
Loading