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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/cli/command-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ import { apiCommand, apiExtraHelpText, type ApiCommandOptions } from './commands
import { GENERIC_HTTP_METHODS } from '../sonarqube/client';
import { claudePreToolUse } from './commands/hook/claude-pre-tool-use';
import { agentPromptSubmit } from './commands/hook/agent-prompt-submit';
import {
agentPostToolUse,
type AgentPostToolUseOptions,
} from './commands/hook/agent-post-tool-use';

const DEFAULT_PAGE_SIZE = MAX_PAGE_SIZE;

Expand Down Expand Up @@ -264,11 +268,9 @@ hookCommand

hookCommand
.command('claude-post-tool-use')
.option('-p, --project <project>', 'SonarCloud project key')
.description('PostToolUse handler: run SQAA analysis on modified files')
.anonymousAction(() => {
return;
});
.description('PostToolUse handler: run SQAA analysis after agent edits or writes a file')
.requiredOption('--project <key>', 'SonarQube Cloud project key')
.anonymousAction((options: AgentPostToolUseOptions) => agentPostToolUse(options));

// Hidden flush command — only registered when running as a telemetry worker.
if (process.env[TELEMETRY_FLUSH_MODE_ENV]) {
Expand Down
106 changes: 106 additions & 0 deletions src/cli/commands/hook/agent-post-tool-use.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* 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.
*/

// PostToolUse callback handler — runs SQAA analysis after the agent edits or writes a file.
// Replaces the bash/PowerShell logic that was previously embedded in the hook script.

import { existsSync, readFileSync } from 'node:fs';
import { relative } from 'node:path';
import { resolveAuth } from '../../../lib/auth-resolver';
import logger from '../../../lib/logger';
import { SonarQubeClient } from '../../../sonarqube/client';
import type { SqaaIssue } from '../../../sonarqube/client';
import { readStdinJson } from './stdin';

interface PostToolUsePayload {
tool_name?: string;
tool_input?: { file_path?: string };
}

export interface AgentPostToolUseOptions {
project?: string;
}

export async function agentPostToolUse(options: AgentPostToolUseOptions): Promise<void> {
let payload: PostToolUsePayload;
try {
payload = await readStdinJson<PostToolUsePayload>();
} catch {
return; // unparseable stdin — non-blocking
}

const toolName = payload.tool_name;
if (toolName !== 'Edit' && toolName !== 'Write') return;

const filePath = payload.tool_input?.file_path;
if (!filePath || !existsSync(filePath)) return;

const auth = await resolveAuth().catch(() => null);
if (auth?.connectionType !== 'cloud' || !auth.orgKey) return;

const projectKey = options.project;
if (!projectKey) return;

try {
const fileContent = readFileSync(filePath, 'utf-8');
const filePath_ = relative(process.cwd(), filePath);
const client = new SonarQubeClient(auth.serverUrl, auth.token);

const response = await client.analyzeFile({
organizationKey: auth.orgKey,
projectKey,
filePath: filePath_,
fileContent,
});

const text = formatSqaaResult(response.issues, response.errors);
process.stdout.write(
JSON.stringify({
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: text },
}) + '\n',
);
} catch (err) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic duplication: formatSqaaResult reimplements the same logic as displaySqaaResults in src/cli/commands/analyze/sqaa.ts (line 162). Both iterate over issues with the same [idx+1] message (line startLine) structure and handle the errors array the same way. They've already diverged: displaySqaaResults puts the rule on a separate Rule: X line, while this version inlines it as [rule].

If the output format needs to change (e.g. adding severity, effort, or a new field), both functions must be updated. Extract shared formatting logic — for example a buildSqaaIssueLines(issues, errors): string[] helper in a shared module — and have each caller apply its own output target on top.

  • Mark as noise

logger.debug(`PostToolUse SQAA analysis failed: ${(err as Error).message}`);
}
}

function formatSqaaResult(
issues: SqaaIssue[],
errors?: Array<{ code: string; message: string }> | null,
): string {
const lines: string[] = [];

if (issues.length === 0) {
lines.push('SQAA analysis completed — no issues found.');
} else {
lines.push(`SQAA analysis found ${issues.length} issue${issues.length === 1 ? '' : 's'}:`);
issues.forEach((issue, idx) => {
const location = issue.textRange ? ` (line ${issue.textRange.startLine})` : '';
lines.push(` [${idx + 1}] ${issue.message}${location} [${issue.rule}]`);
});
}

if (errors && errors.length > 0) {
lines.push('SQAA errors:');
errors.forEach((e) => lines.push(` [${e.code}] ${e.message}`));
}

return lines.join('\n');
}
26 changes: 26 additions & 0 deletions tests/integration/specs/analyze/analyze-sqaa.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,32 @@ describe('analyze sqaa', () => {
{ timeout: 15000 },
);

it(
'exits with code 0 and silently 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);
// --branch is ignored, no API call is made
const sqaaCalls = server
.getRecordedRequests()
.filter((r) => r.path === '/a3s-analysis/analyses');
expect(sqaaCalls).toHaveLength(0);
},
{ timeout: 15000 },
);

it(
'calls SQAA API and reports no issues found for clean file',
async () => {
Expand Down
85 changes: 85 additions & 0 deletions tests/integration/specs/hook/hook-agent-post-tool-use.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* SonarQube CLI
* Copyright (C) 2026 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.
*/

// Integration tests for `sonar hook claude-post-tool-use`.

import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { join } from 'node:path';
import { TestHarness } from '../../harness';

const VALID_TOKEN = 'integration-test-token';
const TEST_ORG = 'my-org';
const TEST_PROJECT = 'my-project';

function postToolUseStdin(filePath: string, toolName = 'Edit'): string {
return JSON.stringify({ tool_name: toolName, tool_input: { file_path: filePath } });
}

describe('sonar hook claude-post-tool-use', () => {
let harness: TestHarness;

beforeEach(async () => {
harness = await TestHarness.create();
});

afterEach(async () => {
await harness.dispose();
});

it(
'exits 0 and outputs SQAA JSON when analysis returns no issues',
async () => {
const server = await harness
.newFakeServer()
.withAuthToken(VALID_TOKEN)
.withSqaaResponse({ issues: [] })
.start();
harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG);
harness.cwd.writeFile('src/main.ts', 'const x = 1;');
const filePath = join(harness.cwd.path, 'src/main.ts');

const result = await harness.run(`hook claude-post-tool-use --project ${TEST_PROJECT}`, {
stdin: postToolUseStdin(filePath),
});

expect(result.exitCode).toBe(0);
const output = JSON.parse(result.stdout.trim());
expect(output.hookSpecificOutput.hookEventName).toBe('PostToolUse');
expect(output.hookSpecificOutput.additionalContext).toContain('no issues');
},
{ timeout: 15000 },
);

it(
'exits 0 and outputs no hook response when not authenticated',
async () => {
harness.cwd.writeFile('src/main.ts', 'const x = 1;');
const filePath = join(harness.cwd.path, 'src/main.ts');

const result = await harness.run(`hook claude-post-tool-use --project ${TEST_PROJECT}`, {
stdin: postToolUseStdin(filePath),
});

expect(result.exitCode).toBe(0);
expect(result.stdout.trim()).toBe('');
},
{ timeout: 15000 },
);
});
Loading
Loading