From 9f4c2cb7f40a8d689c88539e607156232dd57389 Mon Sep 17 00:00:00 2001 From: tomshafir-sonarsource Date: Mon, 6 Jul 2026 13:47:02 +0200 Subject: [PATCH 1/6] CLI-694 Remove legacy state write on Claude integration --- src/cli/commands/analyze/sqaa-auth.ts | 40 ++- src/cli/commands/integrate/_common/state.ts | 126 -------- src/cli/commands/integrate/claude/index.ts | 4 - src/cli/commands/integrate/claude/state.ts | 96 ------ src/lib/state-manager.ts | 16 +- .../specs/integrate/claude.test.ts | 134 ++------- .../cli/commands/analyze/analyze-sqaa.test.ts | 77 ++--- .../commands/integrate/_common/state.test.ts | 275 ------------------ .../integrate/claude/integrate.test.ts | 72 +---- tests/unit/lib/state-manager.test.ts | 74 ----- 10 files changed, 103 insertions(+), 811 deletions(-) delete mode 100644 src/cli/commands/integrate/_common/state.ts delete mode 100644 src/cli/commands/integrate/claude/state.ts delete mode 100644 tests/unit/cli/commands/integrate/_common/state.test.ts diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts index 8975c7a3d..ba6e74a66 100644 --- a/src/cli/commands/analyze/sqaa-auth.ts +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -26,10 +26,10 @@ import type { ResolvedAuth } from '../../../lib/auth-resolver'; import logger from '../../../lib/logger'; import { spawnProcess } from '../../../lib/process'; import { loadState } from '../../../lib/repository/state-repository'; -import type { HookExtension } from '../../../lib/state'; -import { findExtensionsByProject } from '../../../lib/state-manager'; +import { canonicalProjectRoot } from '../../../lib/state-manager'; import { blank, confirmPrompt, text, warn } from '../../../ui'; import { CommandFailedError } from '../_common/error.js'; +import { CLAUDE_INTEGRATION_ID } from '../integrate/claude/declaration'; const LARGE_CHANGESET_HINT = 'For faster feedback, try targeting your changes:\n' + @@ -106,34 +106,42 @@ export function resolveCloudAuth( } /** - * Look up the project key for the current project from the agentExtensions registry. + * Look up the project key for the current project from the declarative + * integration state (`integrations.installed`). * - * 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". + * The SQAA hook feature is recorded per install target root (the directory + * passed to `sonar integrate claude`), so when the user runs SQAA from a + * subdirectory we resolve the git repository top-level first — otherwise + * `process.cwd()` is a non-match against the recorded root and we incorrectly + * skip with "no project configured". * * Falls back to `process.cwd()` when not inside a git repository so the * single-file path still works outside git. */ export async function resolveSqaaProjectKey(projectRoot?: string): Promise { try { - const root = projectRoot ?? (await tryResolveRepoRoot(process.cwd())); + const root = canonicalProjectRoot(projectRoot ?? (await tryResolveRepoRoot(process.cwd()))); const state = loadState(); - const extensions = findExtensionsByProject(state, 'claude-code', root); - const sqaaExt = extensions.find( - (e): e is HookExtension => e.kind === 'hook' && e.name === 'sonar-sqaa', + + const claude = state.integrations.installed.find( + (integration) => integration.integrationId === CLAUDE_INTEGRATION_ID, + ); + const sqaaFeature = claude?.features.find( + (feature) => + feature.featureId === 'sonar-sqaa-hook' && + feature.scope === 'project' && + canonicalProjectRoot(feature.targetRoot) === root, ); - if (!sqaaExt?.projectKey) { - logger.debug('Vortex agentic analysis skipped: no project key found in extensions registry'); + const projectKey = sqaaFeature?.attrs?.projectKey; + if (typeof projectKey !== 'string' || projectKey.length === 0) { + logger.debug('SonarQube Agentic Analysis skipped: no project key found in integration state'); return null; } - return sqaaExt.projectKey; + return projectKey; } catch { - logger.debug('Vortex agentic analysis skipped: failed to resolve extensions'); + logger.debug('SonarQube Agentic Analysis skipped: failed to resolve integration state'); return null; } } diff --git a/src/cli/commands/integrate/_common/state.ts b/src/cli/commands/integrate/_common/state.ts deleted file mode 100644 index 3e05bd203..000000000 --- a/src/cli/commands/integrate/_common/state.ts +++ /dev/null @@ -1,126 +0,0 @@ -/* - * 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. - */ - -// Shared state-mutation helpers for integrate commands. -// Centralizes the load → mark-configured → mutate → save → warn-on-failure -// pattern, and the per-extension upsert that both Claude and Copilot perform. - -import { randomUUID } from 'node:crypto'; -import { homedir } from 'node:os'; - -import { version as VERSION } from '../../../../../package.json'; -import logger from '../../../../lib/logger'; -import { loadState, saveState } from '../../../../lib/repository/state-repository'; -import type { BaseAgentExtension, CliState, HookType } from '../../../../lib/state'; -import { markAgentConfigured, upsertAgentExtension } from '../../../../lib/state-manager'; -import { warn } from '../../../../ui'; - -type ExtensionAttrs = Partial>; - -export interface HookExtension { - kind: 'hook'; - hookType: HookType; - name: string; - /** Override the projectRoot derived from (projectRoot, isGlobal). Used for SQAA, which is always project-scoped. */ - projectRoot?: string; - /** Override the global flag for the same reason. */ - global?: boolean; - attrs?: ExtensionAttrs; -} - -export interface InstructionExtension { - kind: 'instructions'; - name: string; - /** Override the projectRoot derived from (projectRoot, isGlobal). Used for SQAA, which is always project-scoped. */ - projectRoot?: string; - /** Override the global flag for the same reason. */ - global?: boolean; - attrs?: ExtensionAttrs; -} - -export type AgentExtension = HookExtension | InstructionExtension; - -/** - * Upsert a list of agent extensions in `state`. The (projectRoot, isGlobal) - * pair sets the default scope; individual extensions may override it (SQAA is - * always project-scoped even for a global Claude install). - */ -export function recordAgentExtensions( - state: CliState, - agentId: string, - projectRoot: string, - isGlobal: boolean, - extensions: AgentExtension[], -): void { - const effectiveRoot = isGlobal ? homedir() : projectRoot; - const now = new Date().toISOString(); - for (const extension of extensions) { - const base: BaseAgentExtension = { - id: randomUUID(), - agentId, - projectRoot: extension.projectRoot ?? effectiveRoot, - global: extension.global ?? isGlobal, - updatedByCliVersion: VERSION, - updatedAt: now, - ...extension.attrs, - }; - if (extension.kind === 'hook') { - upsertAgentExtension(state, { - ...base, - kind: 'hook', - name: extension.name, - hookType: extension.hookType, - }); - } else { - upsertAgentExtension(state, { - ...base, - kind: 'instructions', - name: extension.name, - }); - } - } -} - -/** - * Run a state mutation with the standard load/save/error envelope used by - * every `integrate ` command: - * - * - load the CLI state - * - mark `agentId` as configured at the current CLI version - * - run `mutate(state)` - * - persist - * - on any failure, surface a warning to the user and log it; never throw - * (a state-write failure must not undo the on-disk install). - */ -export async function withAgentState( - agentId: string, - mutate: (state: CliState) => void | Promise, -): Promise { - try { - const state = loadState(); - markAgentConfigured(state, agentId, VERSION); - await mutate(state); - saveState(state); - } catch (err) { - const msg = (err as Error).message; - warn(`Failed to update configuration state: ${msg}`); - logger.warn(`Failed to update configuration state: ${msg}`); - } -} diff --git a/src/cli/commands/integrate/claude/index.ts b/src/cli/commands/integrate/claude/index.ts index cdcc40491..98f194f8a 100644 --- a/src/cli/commands/integrate/claude/index.ts +++ b/src/cli/commands/integrate/claude/index.ts @@ -43,7 +43,6 @@ import type { IntegrateAgentOptions } from '../_common/types'; import { supportedIntegrations } from '../index.js'; import { CLAUDE_INTEGRATION_ID, type ClaudeIntegrationOptions } from './declaration'; import { detectGlobalSecretsHook } from './hooks'; -import { updateStateAfterConfiguration } from './state'; export interface ConfigurationData { serverURL: string; @@ -127,9 +126,6 @@ export async function integrateClaude( installError = error instanceof Error ? error : new Error(String(error)); } await removeObsoleteHookArtifacts(ctx.project.rootDir, OBSOLETE_A3S_MARKER); - await updateStateAfterConfiguration(config, ctx.project.rootDir, ctx.isGlobal, sqaaEnabled, { - skipSecretsHooks, - }); if (installError) { throw installError; } diff --git a/src/cli/commands/integrate/claude/state.ts b/src/cli/commands/integrate/claude/state.ts deleted file mode 100644 index d2c8170c4..000000000 --- a/src/cli/commands/integrate/claude/state.ts +++ /dev/null @@ -1,96 +0,0 @@ -/* - * 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 { cloudRegionFromUrl, isSonarQubeCloud } from '../../../../lib/auth-resolver'; -import { deleteStaleTokens } from '../../../../lib/keychain'; -import { addInstalledHook, addOrUpdateConnection } from '../../../../lib/state-manager'; -import { type AgentExtension, recordAgentExtensions, withAgentState } from '../_common/state'; -import type { ConfigurationData } from './index'; - -const CLAUDE_AGENT_ID = 'claude-code'; - -export interface UpdateStateOptions { - /** - * When true, do not register project-level sonar-secrets entries in state. - * Use when a global sonar-secrets hook already owns that scope and the - * project-level installation was intentionally skipped. - */ - skipSecretsHooks?: boolean; -} - -/** - * Update state after successful configuration - */ -export async function updateStateAfterConfiguration( - config: ConfigurationData, - projectRoot: string, - isGlobal: boolean, - sqaaEnabled: boolean, - options: UpdateStateOptions = {}, -): Promise { - const { skipSecretsHooks = false } = options; - - await withAgentState(CLAUDE_AGENT_ID, async (state) => { - // Track installed hooks (legacy format for backward compat). - // Skip secrets entries when a pre-existing global hook owns that scope. - if (!skipSecretsHooks) { - addInstalledHook(state, CLAUDE_AGENT_ID, 'sonar-secrets', 'PreToolUse'); - addInstalledHook(state, CLAUDE_AGENT_ID, 'sonar-secrets', 'UserPromptSubmit'); - } - if (sqaaEnabled) { - addInstalledHook(state, CLAUDE_AGENT_ID, 'sonar-sqaa', 'PostToolUse'); - } - - const attrs = { - projectKey: config.projectKey, - orgKey: config.organization, - serverUrl: config.serverURL, - }; - - const extensions: AgentExtension[] = []; - if (!skipSecretsHooks) { - extensions.push( - { kind: 'hook', name: 'sonar-secrets', hookType: 'PreToolUse', attrs }, - { kind: 'hook', name: 'sonar-secrets', hookType: 'UserPromptSubmit', attrs }, - ); - } - // SQAA is always project-scoped, even on a global Claude install. - if (sqaaEnabled) { - extensions.push({ - kind: 'hook', - name: 'sonar-sqaa', - hookType: 'PostToolUse', - projectRoot, - global: false, - attrs, - }); - } - recordAgentExtensions(state, CLAUDE_AGENT_ID, projectRoot, isGlobal, extensions); - - // Save connection so `sonar auth status` reports the active connection - const isCloud = isSonarQubeCloud(config.serverURL); - const type = isCloud ? 'cloud' : 'on-premise'; - await deleteStaleTokens(state.auth.connections, config.serverURL, config.organization); - addOrUpdateConnection(state, config.serverURL, type, { - orgKey: config.organization, - region: cloudRegionFromUrl(config.serverURL), - }); - }); -} diff --git a/src/lib/state-manager.ts b/src/lib/state-manager.ts index 430c59158..7e1cb53e7 100644 --- a/src/lib/state-manager.ts +++ b/src/lib/state-manager.ts @@ -54,7 +54,7 @@ export function getActiveConnection(state: CliState): AuthConnection | undefined return state.auth.connections.find((c) => c.id === state.auth.activeConnectionId); } -function canonicalProjectRoot(projectRoot: string): string { +export function canonicalProjectRoot(projectRoot: string): string { let canonical: string; try { canonical = realpathSync.native(projectRoot); @@ -64,20 +64,6 @@ function canonicalProjectRoot(projectRoot: string): string { return process.platform === 'win32' ? canonical.toLowerCase() : canonical; } -/** - * Find all extensions registered for a specific agent + project root combination. - */ -export function findExtensionsByProject( - state: CliState, - agentId: string, - projectRoot: string, -): AgentExtension[] { - const target = canonicalProjectRoot(projectRoot); - return state.agentExtensions.filter( - (e) => e.agentId === agentId && canonicalProjectRoot(e.projectRoot) === target, - ); -} - /** * Generate connection ID from serverUrl and optional orgKey */ diff --git a/tests/integration/specs/integrate/claude.test.ts b/tests/integration/specs/integrate/claude.test.ts index 3449f27a4..7eb1b5672 100644 --- a/tests/integration/specs/integrate/claude.test.ts +++ b/tests/integration/specs/integrate/claude.test.ts @@ -639,7 +639,7 @@ describe('integrate claude — SQAA entitlement guard', () => { ); it( - 'records sonar-sqaa in agents.claude-code.hooks.installed after fresh SQAA install', + 'records the project key on the declarative sonar-sqaa-hook feature after a fresh SQAA install', async () => { const server = await harness .newFakeServer() @@ -660,15 +660,9 @@ describe('integrate claude — SQAA entitlement guard', () => { expect(result.exitCode).toBe(0); - const state = harness.stateJsonFile.asJson(); - const installedHooks = (state.agents?.['claude-code']?.hooks?.installed ?? []) as Array<{ - name: string; - type: string; - }>; - - expect(installedHooks.some((h) => h.name === 'sonar-sqaa' && h.type === 'PostToolUse')).toBe( - true, - ); + const sqaaFeature = findClaudeFeature(harness, 'sonar-sqaa-hook', 'project'); + expect(sqaaFeature).toBeDefined(); + expect(sqaaFeature?.attrs?.projectKey).toBe('my-project'); }, { timeout: 30000 }, ); @@ -742,19 +736,7 @@ describe('integrate claude — SQAA entitlement guard', () => { expect(result.exitCode).toBe(0); expect(`${result.stdout}\n${result.stderr}`).toContain('not supported with --global'); - const state = harness.stateJsonFile.asJson(); - const sqaaExt = (state.agentExtensions as Array<{ name: string; global: boolean }>).find( - (e) => e.name === 'sonar-sqaa', - ); - expect(sqaaExt).toBeUndefined(); - - const claudeIntegration = state.integrations.installed.find( - (integration: { integrationId: string }) => integration.integrationId === 'claude-code', - ); - const sqaaFeature = claudeIntegration?.features.find( - (feature: { featureId: string }) => feature.featureId === 'sonar-sqaa-hook', - ); - expect(sqaaFeature).toBeUndefined(); + expect(findClaudeFeature(harness, 'sonar-sqaa-hook')).toBeUndefined(); }, { timeout: 30000 }, ); @@ -1284,104 +1266,42 @@ describe('integrate claude — file placement (local vs global)', () => { ); it( - 'keeps existing project-level agentExtensions and adds global ones when -g is passed (CLI-148)', + 'keeps an existing project-scoped install and adds a global one when -g is passed (CLI-148)', async () => { const server = await harness .newFakeServer() .withAuthToken('tok') .withProject('proj') .start(); + harness.withAuth(server.baseUrl(), 'tok'); harness.cwd.writeFile( 'sonar-project.properties', [`sonar.host.url=${server.baseUrl()}`, 'sonar.projectKey=proj'].join('\n'), ); - // Simulate state from a previous project-level integration: agentExtensions with global: false + // Simulate a previous project-level integration recorded in declarative state. const projectRoot = realpathSync(harness.cwd.path); - harness.state().withRawState( - JSON.stringify({ - version: 1, - config: { cliVersion: CURRENT_VERSION }, - auth: { - isAuthenticated: true, - connections: [ - { - id: 'conn-1', - type: 'on-premise', - serverUrl: server.baseUrl(), - authenticatedAt: new Date().toISOString(), - }, - ], - activeConnectionId: 'conn-1', - }, - agents: { - 'claude-code': { - configured: true, - configuredByCliVersion: CURRENT_VERSION, - hooks: { - installed: [ - { name: 'sonar-secrets', type: 'PreToolUse' }, - { name: 'sonar-secrets', type: 'UserPromptSubmit' }, - ], - }, - }, - }, - tools: { installed: [] }, - telemetry: { enabled: false }, - agentExtensions: [ - { - id: randomUUID(), - agentId: 'claude-code', - projectRoot, - global: false, - serverUrl: server.baseUrl(), - updatedByCliVersion: CURRENT_VERSION, - updatedAt: new Date().toISOString(), - kind: 'hook', - name: 'sonar-secrets', - hookType: 'PreToolUse', - }, - { - id: randomUUID(), - agentId: 'claude-code', - projectRoot, - global: false, - serverUrl: server.baseUrl(), - updatedByCliVersion: CURRENT_VERSION, - updatedAt: new Date().toISOString(), - kind: 'hook', - name: 'sonar-secrets', - hookType: 'UserPromptSubmit', - }, - ], - }), - ); - harness.state().withKeychainToken(server.baseUrl(), 'tok'); + harness + .state() + .withInstalledIntegrationFeature( + claudeIntegration, + 'sonar-secrets-hooks', + 'project', + projectRoot, + ); const result = await harness.run('integrate claude -g --non-interactive'); expect(result.exitCode).toBe(0); - const state = harness.stateJsonFile.asJson(); - const extensions = state.agentExtensions as Array<{ - name: string; - hookType: string; - global: boolean; - }>; - - // Project-level sonar-secrets hooks must still be present (not overwritten by -g run) - const projectSecretsHooks = extensions.filter( - (e) => e.name === 'sonar-secrets' && !e.global, - ); - expect(projectSecretsHooks.length).toBe(2); + // The pre-existing project-scoped feature must survive a -g run + expect(findClaudeFeature(harness, 'sonar-secrets-hooks', 'project')).toBeDefined(); - // Global sonar-secrets hooks must also be added - const globalSecretsHooks = extensions.filter((e) => e.name === 'sonar-secrets' && e.global); - expect(globalSecretsHooks.length).toBeGreaterThan(0); + // The global secrets-hooks feature is also recorded. + expect(findClaudeFeature(harness, 'sonar-secrets-hooks', 'global')).toBeDefined(); // sonar-sqaa is never installed on a -g install (it is project-scoped only) - const sqaaHooks = extensions.filter((e) => e.name === 'sonar-sqaa'); - expect(sqaaHooks).toHaveLength(0); + expect(findClaudeFeature(harness, 'sonar-sqaa-hook')).toBeUndefined(); }, { timeout: 30000 }, ); @@ -1941,12 +1861,12 @@ describe('integrate claude — hook migration scenarios', () => { expect(settings.hooks?.PreToolUse).toHaveLength(1); expect(settings.hooks?.UserPromptSubmit).toHaveLength(1); - // agentExtensions must not accumulate duplicates across re-runs - const state = harness.stateJsonFile.asJson() as { - agentExtensions: Array<{ name: string; hookType: string }>; - }; - const secretsExts = state.agentExtensions.filter((e) => e.name === 'sonar-secrets'); - expect(secretsExts).toHaveLength(2); // PreToolUse + UserPromptSubmit only + // Declarative state must not accumulate duplicate feature entries across re-runs. + const secretsFeatures = + getInstalledIntegration(harness, 'claude-code')?.features.filter( + (feature) => feature.featureId === 'sonar-secrets-hooks', + ) ?? []; + expect(secretsFeatures).toHaveLength(1); }, { timeout: 30000 }, ); diff --git a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts index 1a49cdd6c..eef2cec11 100644 --- a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts +++ b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts @@ -20,6 +20,7 @@ // Unit tests for analyzeSqaa command +import { randomUUID } from 'node:crypto'; import * as fs from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; @@ -32,7 +33,7 @@ import { analyzeSqaa, buildSqaaJsonReport } from '../../../../../src/cli/command import * as changesetModule from '../../../../../src/cli/commands/analyze/sqaa-changeset'; 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 { CliState, getDefaultState } from '../../../../../src/lib/state.js'; import * as stateManager from '../../../../../src/lib/state-manager.js'; import { SonarQubeClient } from '../../../../../src/sonarqube/client.js'; import { clearMockUiCalls, getMockUiCalls, setMockUi } from '../../../../../src/ui'; @@ -59,26 +60,44 @@ let readFileSpy: ReturnType; let createAnalysisSpy: ReturnType; let spawnProcessSpy: ReturnType; -/** Cloud state WITH a sonar-sqaa extension entry for the current project root */ +/** + * Seed a declarative Claude Code integration whose project-scoped + * `sonar-sqaa-hook` feature carries the given `projectKey` attr (or none). + */ +function seedClaudeSqaaFeature(state: CliState, projectKey: string | undefined) { + const now = new Date().toISOString(); + state.integrations.installed.push({ + id: randomUUID(), + integrationId: 'claude-code', + installedByCliVersion: '1.0.0', + installedAt: now, + updatedByCliVersion: '1.0.0', + updatedAt: now, + features: [ + { + featureId: 'sonar-sqaa-hook', + scope: 'project', + targetRoot: process.cwd(), + installedByCliVersion: '1.0.0', + installedAt: now, + updatedByCliVersion: '1.0.0', + updatedAt: now, + dependencies: [], + resources: [], + operations: [], + attrs: projectKey === undefined ? {} : { projectKey }, + }, + ], + }); +} + +/** Cloud state WITH a sonar-sqaa hook feature for the current project root */ function makeCloudState() { const state = getDefaultState('test'); stateManager.addOrUpdateConnection(state, SONARCLOUD_URL, 'cloud', { orgKey: TEST_ORG, }); - stateManager.upsertAgentExtension(state, { - id: 'test-ext', - agentId: 'claude-code', - projectRoot: process.cwd(), - global: false, - projectKey: TEST_PROJECT, - orgKey: TEST_ORG, - serverUrl: SONARCLOUD_URL, - updatedByCliVersion: '1.0.0', - updatedAt: new Date().toISOString(), - kind: 'hook', - name: 'sonar-sqaa', - hookType: 'PostToolUse', - }); + seedClaudeSqaaFeature(state, TEST_PROJECT); return state; } @@ -135,31 +154,19 @@ describe('analyzeSqaa: input validation', () => { }); }); -function stateWithExtensionMissingProjectKey() { +function stateWithSqaaFeatureMissingProjectKey() { const state = getDefaultState('test'); stateManager.addOrUpdateConnection(state, SONARCLOUD_URL, 'cloud', { orgKey: TEST_ORG, }); - // Extension exists but projectKey is undefined - stateManager.upsertAgentExtension(state, { - id: 'ext-no-key', - agentId: 'claude-code', - projectRoot: process.cwd(), - global: false, - orgKey: TEST_ORG, - serverUrl: SONARCLOUD_URL, - updatedByCliVersion: '1.0.0', - updatedAt: new Date().toISOString(), - kind: 'hook', - name: 'sonar-sqaa', - hookType: 'PostToolUse', - }); + // Feature exists but projectKey attr is absent + seedClaudeSqaaFeature(state, undefined); return state; } describe('analyzeSqaa: auth resolution', () => { - it('throws CommandFailedError when extension has no projectKey (explicit agentic)', async () => { - loadStateSpy.mockReturnValue(stateWithExtensionMissingProjectKey()); + it('throws CommandFailedError when the SQAA feature has no projectKey (explicit agentic)', async () => { + loadStateSpy.mockReturnValue(stateWithSqaaFeatureMissingProjectKey()); let thrown: unknown; await analyzeSqaa({ file: ['src/index.ts'] }, FAKE_AUTH).catch((err: unknown) => { @@ -173,8 +180,8 @@ describe('analyzeSqaa: auth resolution', () => { expect(createAnalysisSpy).not.toHaveBeenCalled(); }); - it('skips SQAA and warns when extension has no projectKey and requireProject is false (bare analyze)', async () => { - loadStateSpy.mockReturnValue(stateWithExtensionMissingProjectKey()); + it('skips SQAA and warns when the SQAA feature has no projectKey and requireProject is false (bare analyze)', async () => { + loadStateSpy.mockReturnValue(stateWithSqaaFeatureMissingProjectKey()); await analyzeSqaa({ file: ['src/index.ts'] }, FAKE_AUTH, { requireProject: false }); diff --git a/tests/unit/cli/commands/integrate/_common/state.test.ts b/tests/unit/cli/commands/integrate/_common/state.test.ts deleted file mode 100644 index d2a85c4be..000000000 --- a/tests/unit/cli/commands/integrate/_common/state.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -/* - * 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 { homedir } from 'node:os'; - -import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; - -import { version as VERSION } from '../../../../../../package.json'; -import { - recordAgentExtensions, - withAgentState, -} from '../../../../../../src/cli/commands/integrate/_common/state'; -import logger from '../../../../../../src/lib/logger'; -import * as stateRepository from '../../../../../../src/lib/repository/state-repository'; -import { - type AgentExtension, - type CliState, - getDefaultState, - type HookExtension, - type InstructionsExtension, -} from '../../../../../../src/lib/state'; -import { clearMockUiCalls, getMockUiCalls, setMockUi } from '../../../../../../src/ui'; - -const AGENT_ID = 'test-agent'; -const PROJECT_ROOT = '/project/root'; - -function isHook(ext: AgentExtension): ext is HookExtension { - return ext.kind === 'hook'; -} - -function isInstructions(ext: AgentExtension): ext is InstructionsExtension { - return ext.kind === 'instructions'; -} - -describe('withAgentState', () => { - let loadStateSpy: ReturnType; - let saveStateSpy: ReturnType; - let loggerWarnSpy: ReturnType; - - beforeEach(() => { - setMockUi(true); - loggerWarnSpy = spyOn(logger, 'warn').mockImplementation(() => {}); - }); - - afterEach(() => { - clearMockUiCalls(); - setMockUi(false); - loadStateSpy?.mockRestore(); - saveStateSpy?.mockRestore(); - loggerWarnSpy.mockRestore(); - }); - - it('loads state, marks the agent configured at the current CLI version, runs the mutator, then saves', async () => { - const baseState = getDefaultState('test'); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(baseState); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => {}); - let mutatorState: CliState | undefined; - - await withAgentState(AGENT_ID, (state) => { - mutatorState = state; - }); - - expect(loadStateSpy).toHaveBeenCalledTimes(1); - expect(mutatorState).toBe(baseState); - expect(baseState.agents[AGENT_ID]?.configured).toBe(true); - expect(baseState.agents[AGENT_ID]?.configuredAt).toBeDefined(); - expect(VERSION).toBeDefined(); - expect(saveStateSpy).toHaveBeenCalledTimes(1); - expect(saveStateSpy).toHaveBeenCalledWith(baseState); - }); - - it('awaits async mutators before saving', async () => { - const baseState = getDefaultState('test'); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(baseState); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => {}); - let mutatorRan = false; - - await withAgentState(AGENT_ID, async () => { - await new Promise((resolve) => setTimeout(resolve, 5)); - mutatorRan = true; - }); - - expect(mutatorRan).toBe(true); - expect(saveStateSpy).toHaveBeenCalledTimes(1); - }); - - it('does not throw when loadState fails — emits a UI warning and a logger.warn', async () => { - loadStateSpy = spyOn(stateRepository, 'loadState').mockImplementation(() => { - throw new Error('disk error'); - }); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => {}); - - await withAgentState(AGENT_ID, () => { - throw new Error('mutator should not run'); - }); - - const warnCall = getMockUiCalls().find( - (c) => - c.method === 'warn' && - (c.args[0] as string).includes('Failed to update configuration state') && - (c.args[0] as string).includes('disk error'), - ); - expect(warnCall).toBeDefined(); - expect(loggerWarnSpy).toHaveBeenCalledTimes(1); - }); - - it('does not throw when saveState fails — emits a UI warning and a logger.warn', async () => { - const baseState = getDefaultState('test'); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(baseState); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => { - throw new Error('write failed'); - }); - - await withAgentState(AGENT_ID, () => {}); - - const warnCall = getMockUiCalls().find( - (c) => - c.method === 'warn' && - (c.args[0] as string).includes('Failed to update configuration state') && - (c.args[0] as string).includes('write failed'), - ); - expect(warnCall).toBeDefined(); - expect(loggerWarnSpy).toHaveBeenCalledTimes(1); - }); - - it('does not throw when the mutator itself throws', async () => { - const baseState = getDefaultState('test'); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(baseState); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => {}); - - await withAgentState(AGENT_ID, () => { - throw new Error('boom'); - }); - - const warnCall = getMockUiCalls().find( - (c) => c.method === 'warn' && (c.args[0] as string).includes('boom'), - ); - expect(warnCall).toBeDefined(); - // Mutator threw before saveState was reached. - expect(saveStateSpy).not.toHaveBeenCalled(); - }); -}); - -describe('recordAgentExtensions', () => { - let state: CliState; - - beforeEach(() => { - state = getDefaultState('test'); - }); - - it('upserts a HookExtension with the expected shape', () => { - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, false, [ - { - kind: 'hook', - name: 'sonar-secrets', - hookType: 'PreToolUse', - attrs: { projectKey: 'p', orgKey: 'o', serverUrl: 'https://example.com' }, - }, - ]); - - const hooks = state.agentExtensions.filter(isHook); - expect(hooks).toHaveLength(1); - const ext = hooks[0]; - expect(ext.agentId).toBe(AGENT_ID); - expect(ext.projectRoot).toBe(PROJECT_ROOT); - expect(ext.global).toBe(false); - expect(ext.name).toBe('sonar-secrets'); - expect(ext.hookType).toBe('PreToolUse'); - expect(ext.projectKey).toBe('p'); - expect(ext.orgKey).toBe('o'); - expect(ext.serverUrl).toBe('https://example.com'); - expect(ext.updatedByCliVersion).toBe(VERSION); - expect(ext.updatedAt).toBeDefined(); - }); - - it('upserts an InstructionsExtension with the expected shape', () => { - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, false, [ - { kind: 'instructions', name: 'sonar-prompt-secrets' }, - ]); - - const instructions = state.agentExtensions.filter(isInstructions); - expect(instructions).toHaveLength(1); - const ext = instructions[0]; - expect(ext.agentId).toBe(AGENT_ID); - expect(ext.projectRoot).toBe(PROJECT_ROOT); - expect(ext.global).toBe(false); - expect(ext.name).toBe('sonar-prompt-secrets'); - }); - - it('defaults projectRoot to homedir() and global=true when isGlobal is true', () => { - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, true, [ - { kind: 'hook', name: 'sonar-secrets', hookType: 'PreToolUse' }, - ]); - - const hooks = state.agentExtensions.filter(isHook); - expect(hooks).toHaveLength(1); - expect(hooks[0].projectRoot).toBe(homedir()); - expect(hooks[0].global).toBe(true); - }); - - it('respects spec-level projectRoot/global override (the SQAA case)', () => { - // Run-level isGlobal=true, but the spec forces project scope (sonar-sqaa - // is always project-scoped even on a global Claude install). - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, true, [ - { - kind: 'hook', - name: 'sonar-sqaa', - hookType: 'PostToolUse', - projectRoot: PROJECT_ROOT, - global: false, - }, - ]); - - const hooks = state.agentExtensions.filter(isHook); - expect(hooks).toHaveLength(1); - expect(hooks[0].name).toBe('sonar-sqaa'); - expect(hooks[0].projectRoot).toBe(PROJECT_ROOT); - expect(hooks[0].global).toBe(false); - }); - - it('is idempotent — re-recording the same hook spec yields a single entry', () => { - const spec = { - kind: 'hook' as const, - name: 'sonar-secrets', - hookType: 'PreToolUse' as const, - }; - - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, false, [spec]); - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, false, [spec]); - - const hooks = state.agentExtensions.filter( - (e) => isHook(e) && e.name === 'sonar-secrets' && e.hookType === 'PreToolUse', - ); - expect(hooks).toHaveLength(1); - }); - - it('is idempotent for the instructions kind — guards agentExtensionEquals on the new union variant', () => { - const spec = { kind: 'instructions' as const, name: 'sonar-prompt-secrets' }; - - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, false, [spec]); - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, false, [spec]); - - const instructions = state.agentExtensions.filter( - (e) => isInstructions(e) && e.name === 'sonar-prompt-secrets', - ); - expect(instructions).toHaveLength(1); - }); - - it('records multiple specs in a single call', () => { - recordAgentExtensions(state, AGENT_ID, PROJECT_ROOT, false, [ - { kind: 'hook', name: 'sonar-secrets', hookType: 'PreToolUse' }, - { kind: 'instructions', name: 'sonar-prompt-secrets' }, - ]); - - expect(state.agentExtensions.filter(isHook)).toHaveLength(1); - expect(state.agentExtensions.filter(isInstructions)).toHaveLength(1); - }); -}); diff --git a/tests/unit/cli/commands/integrate/claude/integrate.test.ts b/tests/unit/cli/commands/integrate/claude/integrate.test.ts index 8f1c59e00..5765e7ec7 100644 --- a/tests/unit/cli/commands/integrate/claude/integrate.test.ts +++ b/tests/unit/cli/commands/integrate/claude/integrate.test.ts @@ -28,7 +28,6 @@ import * as contextAugmentation from '../../../../../../src/cli/commands/integra import * as registry from '../../../../../../src/cli/commands/integrate/_common/registry'; import { integrateClaude } from '../../../../../../src/cli/commands/integrate/claude'; import * as hooks from '../../../../../../src/cli/commands/integrate/claude/hooks'; -import * as state from '../../../../../../src/cli/commands/integrate/claude/state'; import type { ResolvedAuth } from '../../../../../../src/lib/auth-resolver'; import * as migration from '../../../../../../src/lib/migration'; import type { DiscoveredProject } from '../../../../../../src/lib/project-workspace'; @@ -85,9 +84,6 @@ describe('integrateCommand', () => { Extract<(typeof hooks)['detectGlobalSecretsHook'], (...args: any[]) => any> >; let runMigrationsSpy: Mock any>>; - let updateStateAfterConfigurationSpy: Mock< - Extract<(typeof state)['updateStateAfterConfiguration'], (...args: any[]) => any> - >; let resolveContextAugmentationSetupSpy: Mock< Extract< (typeof contextAugmentation)['resolveContextAugmentationSetup'], @@ -121,7 +117,6 @@ describe('integrateCommand', () => { undefined, ); runMigrationsSpy = spyOn(migration, 'runMigrations'); - updateStateAfterConfigurationSpy = spyOn(state, 'updateStateAfterConfiguration'); mockDiscoveredProject({}); }); @@ -140,7 +135,6 @@ describe('integrateCommand', () => { installIntegrationSpy.mockRestore(); detectGlobalSecretsHookSpy.mockRestore(); runMigrationsSpy.mockRestore(); - updateStateAfterConfigurationSpy.mockRestore(); resolveContextAugmentationSetupSpy.mockRestore(); }); @@ -316,7 +310,7 @@ describe('integrateCommand', () => { ); }); - it('rethrows CAG installation failures after updating Claude state', async () => { + it('rethrows CAG installation failures', async () => { mockDiscoveredProject({ rootDir: '/project/root', projectKey: 'a-project' }); resolveContextAugmentationSetupSpy.mockResolvedValue({ scaEnabled: false }); installIntegrationSpy.mockRejectedValueOnce(new Error('print failed')); @@ -334,25 +328,18 @@ describe('integrateCommand', () => { expect(thrown.message).toBe('print failed'); expect(installIntegrationSpy).toHaveBeenCalledTimes(1); - expect(updateStateAfterConfigurationSpy).toHaveBeenCalledTimes(1); }); - it('runs migration, installs hooks and updates state when setup summary succeeds', async () => { + it('runs migration and installs hooks when setup summary succeeds', async () => { mockDiscoveredProject({ rootDir: '/project/root', projectKey: 'a-project' }); mockSqaaEntitlement(true); await integrateClaude({}, CLOUD_AUTH); - assertMigrationHookInstallationAndStateUpdateRan( - 'a-project', - '/project/root', - undefined, - false, - true, - ); + assertMigrationAndHookInstallationRan('a-project', '/project/root', undefined, false, true); }); - it('runs migration, installs hooks and updates state when global option is set', async () => { + it('runs migration and installs hooks when global option is set', async () => { mockDiscoveredProject({ rootDir: '/project/root', projectKey: 'a-project' }); mockSqaaEntitlement(true); @@ -360,13 +347,7 @@ describe('integrateCommand', () => { // SQAA is project-scoped, so a global install never enables it even when the // org is entitled — sqaaEnabled flows through as false. - assertMigrationHookInstallationAndStateUpdateRan( - 'a-project', - '/project/root', - homedir(), - true, - false, - ); + assertMigrationAndHookInstallationRan('a-project', '/project/root', homedir(), true, false); }); it('still installs when organization access check fails in the summary', async () => { @@ -376,28 +357,16 @@ describe('integrateCommand', () => { await integrateClaude({}, CLOUD_AUTH); - assertMigrationHookInstallationAndStateUpdateRan( - 'a-project', - '/project/root', - undefined, - false, - true, - ); + assertMigrationAndHookInstallationRan('a-project', '/project/root', undefined, false, true); }); - it('runs migration, installs hooks and updates state when project key is missing', async () => { + it('runs migration and installs hooks when project key is missing', async () => { mockDiscoveredProject({ rootDir: '/projectB/root' }); mockSqaaEntitlement(false); await integrateClaude({}, CLOUD_AUTH); - assertMigrationHookInstallationAndStateUpdateRan( - undefined, - '/projectB/root', - undefined, - false, - false, - ); + assertMigrationAndHookInstallationRan(undefined, '/projectB/root', undefined, false, false); }); it('aborts integration when sonar-secrets installation fails', async () => { @@ -443,13 +412,6 @@ describe('integrateCommand', () => { 'a-project', { skipSecretsHooks: true }, ); - expect(updateStateAfterConfigurationSpy).toHaveBeenCalledWith( - expect.anything(), - '/project/root', - false, - false, - { skipSecretsHooks: true }, - ); }); it('still installs the project-scoped sonar-sqaa hook when SQAA is entitled', async () => { @@ -538,13 +500,6 @@ describe('integrateCommand', () => { skipSecretsHooks: false, }, ); - expect(updateStateAfterConfigurationSpy).toHaveBeenLastCalledWith( - expect.anything(), - '/project/root', - true, - false, - { skipSecretsHooks: false }, - ); const warnNotice = getMockUiCalls().find( (c) => c.method === 'warn' && String(c.args[0]).includes('not supported with --global'), @@ -568,7 +523,7 @@ describe('integrateCommand', () => { hasSqaaEntitlementSpy.mockResolvedValue(hasEntitlement ? 'enabled' : 'not_enabled'); } - function assertMigrationHookInstallationAndStateUpdateRan( + function assertMigrationAndHookInstallationRan( projectKey: string | undefined, projectRootDir: string, globalDir: string | undefined, @@ -599,15 +554,6 @@ describe('integrateCommand', () => { installSqaaHook: sqaaEnabled && projectKey !== undefined, installSqaaInstructions: sqaaEnabled && projectKey !== undefined, }); - - expect(updateStateAfterConfigurationSpy).toHaveBeenCalledTimes(1); - expect(updateStateAfterConfigurationSpy).toHaveBeenCalledWith( - expect.anything(), - projectRootDir, - isGlobal, - sqaaEnabled, - expectedOptions, - ); } function expectClaudeInstallCall({ diff --git a/tests/unit/lib/state-manager.test.ts b/tests/unit/lib/state-manager.test.ts index 458fef665..e4651722a 100644 --- a/tests/unit/lib/state-manager.test.ts +++ b/tests/unit/lib/state-manager.test.ts @@ -39,7 +39,6 @@ import { getDefaultState } from '../../../src/lib/state.js'; import { addInstalledHook, addOrUpdateConnection, - findExtensionsByProject, generateConnectionId, markAgentConfigured, removeConnection, @@ -398,79 +397,6 @@ describe('stateFileExists', () => { }); }); -// ============================================================================= -// findExtensionsByProject -// ============================================================================= - -describe('findExtensionsByProject', () => { - it('returns extensions matching agentId and projectRoot', () => { - const state = getDefaultState('1.0.0'); - const ext: HookExtension = { - id: 'ext-1', - agentId: 'claude-code', - projectRoot: '/my/project', - global: false, - kind: 'hook', - name: 'sonar-secrets', - hookType: 'PreToolUse', - updatedByCliVersion: '1.0.0', - updatedAt: '2026-01-01T00:00:00.000Z', - }; - upsertAgentExtension(state, ext); - - const result = findExtensionsByProject(state, 'claude-code', '/my/project'); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe('ext-1'); - }); - - it('returns empty array when no extensions match', () => { - const state = getDefaultState('1.0.0'); - - const result = findExtensionsByProject(state, 'claude-code', '/my/project'); - - expect(result).toHaveLength(0); - }); - - it('does not return extensions for a different agentId', () => { - const state = getDefaultState('1.0.0'); - upsertAgentExtension(state, { - id: 'ext-1', - agentId: 'cursor', - projectRoot: '/my/project', - global: false, - kind: 'hook', - name: 'sonar-secrets', - hookType: 'PreToolUse', - updatedByCliVersion: '1.0.0', - updatedAt: '2026-01-01T00:00:00.000Z', - }); - - const result = findExtensionsByProject(state, 'claude-code', '/my/project'); - - expect(result).toHaveLength(0); - }); - - it('does not return extensions for a different projectRoot', () => { - const state = getDefaultState('1.0.0'); - upsertAgentExtension(state, { - id: 'ext-1', - agentId: 'claude-code', - projectRoot: '/other/project', - global: false, - kind: 'hook', - name: 'sonar-secrets', - hookType: 'PreToolUse', - updatedByCliVersion: '1.0.0', - updatedAt: '2026-01-01T00:00:00.000Z', - }); - - const result = findExtensionsByProject(state, 'claude-code', '/my/project'); - - expect(result).toHaveLength(0); - }); -}); - // ============================================================================= // saveState — filesystem I/O // ============================================================================= From bb022bcbad311f3bd4b71c4cf9570aa359e55396 Mon Sep 17 00:00:00 2001 From: tomshafir-sonarsource Date: Mon, 6 Jul 2026 14:05:26 +0200 Subject: [PATCH 2/6] CLI-694 Post-rebase fix --- src/cli/commands/analyze/sqaa-auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts index ba6e74a66..9267bee38 100644 --- a/src/cli/commands/analyze/sqaa-auth.ts +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -135,13 +135,13 @@ export async function resolveSqaaProjectKey(projectRoot?: string): Promise Date: Mon, 6 Jul 2026 15:28:05 +0200 Subject: [PATCH 3/6] CLI-694 Update withSqaaExtension helper --- build-scripts/ux-report/generate-ux-report.ts | 6 +- .../harness/environment-builder.ts | 90 +++++++++----- .../specs/analyze/analyze-sqaa.test.ts | 110 +++++++++--------- tests/integration/specs/system/reset.test.ts | 2 +- 4 files changed, 119 insertions(+), 89 deletions(-) diff --git a/build-scripts/ux-report/generate-ux-report.ts b/build-scripts/ux-report/generate-ux-report.ts index 77aa37881..d04a56def 100644 --- a/build-scripts/ux-report/generate-ux-report.ts +++ b/build-scripts/ux-report/generate-ux-report.ts @@ -443,7 +443,7 @@ uxDescribe('SonarQube Cloud — Happy Path', () => { harness .state() .withAuth(cloudUrl, TOKEN, ORG) - .withSqaaExtension(harness.cwd.path, PROJECT, ORG, cloudUrl); + .withSqaaFeature(harness.cwd.path, PROJECT, ORG, cloudUrl); }); afterEach(async () => { await harness.dispose(); @@ -665,7 +665,7 @@ uxDescribe('SonarQube Cloud — Error Paths', () => { .start(); h.state() .withAuth(server.baseUrl(), TOKEN, ORG) - .withSqaaExtension(h.cwd.path, PROJECT, ORG, server.baseUrl()); + .withSqaaFeature(h.cwd.path, PROJECT, ORG, server.baseUrl()); h.cwd.writeFile('src/index.ts', 'const x = 1;'); return h.run('analyze agentic --file src/index.ts'); }), @@ -680,7 +680,7 @@ uxDescribe('SonarQube Cloud — Error Paths', () => { .start(); h.state() .withAuth(server.baseUrl(), TOKEN, ORG) - .withSqaaExtension(h.cwd.path, PROJECT, ORG, server.baseUrl()); + .withSqaaFeature(h.cwd.path, PROJECT, ORG, server.baseUrl()); h.cwd.writeFile('src/index.ts', 'const x = 1;'); return h.run('analyze agentic --file src/index.ts'); }), diff --git a/tests/integration/harness/environment-builder.ts b/tests/integration/harness/environment-builder.ts index 8d3d6e64a..e8c286ae2 100644 --- a/tests/integration/harness/environment-builder.ts +++ b/tests/integration/harness/environment-builder.ts @@ -51,6 +51,7 @@ import { SONAR_CONTEXT_AUGMENTATION_VERSION } from '../../../src/lib/signatures. import { buildDownloadUrl } from '../../../src/lib/sonarsource-releases.js'; import type { CliState, + InstalledIntegration, InstalledIntegrationDependency, InstalledTool, IntegrationScope, @@ -65,7 +66,7 @@ function resolveBinaryFixturePath(fixture: BinarySpec): string { return join(DEPENDENCY_ARTIFACTS_DIR, filename); } -interface SqaaExtensionConfig { +interface SqaaFeatureConfig { projectRoot: string; projectKey: string; orgKey?: string; @@ -80,16 +81,7 @@ interface ContextAugmentationSkillConfig { scaEnabled?: boolean; } -function recordContextAugmentationFeature( - state: CliState, - args: { - projectRoot: string; - projectKey: string; - orgKey?: string; - serverUrl?: string; - scaEnabled: boolean; - }, -): void { +function getOrCreateClaudeIntegration(state: CliState): InstalledIntegration { const timestamp = new Date().toISOString(); let integration = state.integrations.installed.find( (entry) => entry.integrationId === CLAUDE_INTEGRATION_ID, @@ -106,6 +98,21 @@ function recordContextAugmentationFeature( }; state.integrations.installed.push(integration); } + return integration; +} + +function recordContextAugmentationFeature( + state: CliState, + args: { + projectRoot: string; + projectKey: string; + orgKey?: string; + serverUrl?: string; + scaEnabled: boolean; + }, +): void { + const integration = getOrCreateClaudeIntegration(state); + const timestamp = integration.installedAt; integration.features.push({ featureId: CONTEXT_AUGMENTATION_FEATURE_ID, @@ -127,6 +134,37 @@ function recordContextAugmentationFeature( }); } +function recordSqaaHookFeature( + state: CliState, + args: { + projectRoot: string; + projectKey: string; + orgKey?: string; + serverUrl?: string; + }, +): void { + const integration = getOrCreateClaudeIntegration(state); + const timestamp = integration.installedAt; + + integration.features.push({ + featureId: 'sonar-sqaa-hook', + scope: 'project', + targetRoot: args.projectRoot, + installedByCliVersion: 'integration-test', + installedAt: timestamp, + updatedByCliVersion: 'integration-test', + updatedAt: timestamp, + dependencies: [], + resources: [], + operations: [], + attrs: { + orgKey: args.orgKey ?? null, + projectKey: args.projectKey, + serverUrl: args.serverUrl ?? null, + }, + }); +} + export class EnvironmentBuilder { private activeConnectionUrl?: string; private activeConnectionType: 'cloud' | 'on-premise' = 'on-premise'; @@ -148,7 +186,7 @@ export class EnvironmentBuilder { private _dockerMockRunning?: boolean; private _dockerMockBinDir?: string; private readonly keychainTokens: Array<{ serverURL: string; token: string; org?: string }> = []; - private readonly sqaaExtensions: SqaaExtensionConfig[] = []; + private readonly sqaaFeatures: SqaaFeatureConfig[] = []; private readonly contextAugmentationSkills: ContextAugmentationSkillConfig[] = []; private readonly installedFeatureSeeds: Array<(state: CliState) => void> = []; @@ -327,16 +365,16 @@ export class EnvironmentBuilder { } /** - * Registers a sonar-sqaa PostToolUse extension for a project. + * Registers a declaratively tracked SQAA hook feature for a project. * Required for `analyze agentic` and `analyze` (full pipeline) to run Agentic Analysis. */ - withSqaaExtension( + withSqaaFeature( projectRoot: string, projectKey: string, orgKey?: string, serverUrl?: string, ): this { - this.sqaaExtensions.push({ projectRoot, projectKey, orgKey, serverUrl }); + this.sqaaFeatures.push({ projectRoot, projectKey, orgKey, serverUrl }); return this; } @@ -472,28 +510,20 @@ export class EnvironmentBuilder { state.dependencies = { installed: installedDependencies }; } - for (const ext of this.sqaaExtensions) { + for (const feature of this.sqaaFeatures) { // Resolve symlinks so the stored path matches process.cwd() in the CLI subprocess // (e.g. /var/folders/... → /private/var/folders/... on macOS) let resolvedRoot: string; try { - resolvedRoot = realpathSync(ext.projectRoot); + resolvedRoot = realpathSync(feature.projectRoot); } catch { - resolvedRoot = ext.projectRoot; + resolvedRoot = feature.projectRoot; } - state.agentExtensions.push({ - id: randomUUID(), - agentId: 'claude-code', + recordSqaaHookFeature(state, { projectRoot: resolvedRoot, - global: false, - projectKey: ext.projectKey, - orgKey: ext.orgKey ?? this.activeConnectionOrgKey, - serverUrl: ext.serverUrl ?? this.activeConnectionUrl, - updatedByCliVersion: 'integration-test', - updatedAt: new Date().toISOString(), - kind: 'hook', - name: 'sonar-sqaa', - hookType: 'PostToolUse', + projectKey: feature.projectKey, + orgKey: feature.orgKey ?? this.activeConnectionOrgKey, + serverUrl: feature.serverUrl ?? this.activeConnectionUrl, }); } diff --git a/tests/integration/specs/analyze/analyze-sqaa.test.ts b/tests/integration/specs/analyze/analyze-sqaa.test.ts index 3a54c3454..99461a47d 100644 --- a/tests/integration/specs/analyze/analyze-sqaa.test.ts +++ b/tests/integration/specs/analyze/analyze-sqaa.test.ts @@ -89,7 +89,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); harness.cwd.writeFile('new.ts', 'const x = 1;'); @@ -121,7 +121,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); harness.cwd.writeFile('new.ts', 'const x = 1;'); @@ -156,7 +156,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); harness.cwd.writeFile('leaked.ts', `const token = "${GITHUB_TEST_TOKEN}";`); @@ -189,7 +189,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('target.ts', 'const x = 1;'); @@ -222,7 +222,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); harness.cwd.writeFile('leaked.ts', `const token = "${GITHUB_TEST_TOKEN}";`); @@ -254,7 +254,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('target.ts', 'const x = 1;'); @@ -392,7 +392,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); const result = await harness.run('analyze', { extraEnv: { SONAR_SECRETS_ALLOW_UNSECURE_HTTP: 'true' }, @@ -413,7 +413,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); const result = await harness.run('analyze --format json', { extraEnv: { SONAR_SECRETS_ALLOW_UNSECURE_HTTP: 'true' }, @@ -440,7 +440,7 @@ describe('analyze (no subcommand)', () => { .state() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); // Binary file only — NUL byte triggers binary detection, excluded from change set. @@ -475,7 +475,7 @@ describe('analyze (no subcommand)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); harness.cwd.writeFile('new.ts', 'const x = 1;'); @@ -591,7 +591,7 @@ describe('analyze agentic', () => { .withSqaaResponse({ issues: [] }) .start(); - // Connection exists but no withSqaaExtension() → no projectKey in registry → error + // Connection exists but no withSqaaFeature() → no projectKey in state → error harness.withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -674,7 +674,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -704,7 +704,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('a.ts', 'const a = 1;'); harness.cwd.writeFile('b.ts', 'const b = 2;'); @@ -738,7 +738,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); initGitRepo(harness.cwd.path); commitFile(harness.cwd.path, 'README.md', 'hello'); @@ -771,7 +771,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('a.ts', 'const a = 1;'); harness.cwd.writeFile('b.ts', 'const b = 2;'); @@ -803,7 +803,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -834,7 +834,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -857,7 +857,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); const result = await harness.run(`analyze agentic --project ${TEST_PROJECT} --depth QUICK`); @@ -884,7 +884,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('main.py', 'def foo():\n pass\n'); @@ -915,7 +915,7 @@ describe('analyze agentic', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -973,7 +973,7 @@ describe('analyze agentic — analysis telemetry', () => { .state() .withTelemetryEnabled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -1015,7 +1015,7 @@ describe('analyze agentic — analysis telemetry', () => { .state() .withTelemetryEnabled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;\nconst y = 2;\n'); @@ -1089,7 +1089,7 @@ describe('sonar analyze — analysis telemetry', () => { .withTelemetryEnabled() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -1123,7 +1123,7 @@ describe('sonar analyze — analysis telemetry', () => { .withTelemetryEnabled() .withSecretsBinaryInstalled() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -1168,7 +1168,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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'); @@ -1197,7 +1197,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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` @@ -1227,7 +1227,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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 @@ -1257,7 +1257,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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'); @@ -1301,7 +1301,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); for (let i = 1; i <= 51; i++) { @@ -1333,7 +1333,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); for (let i = 1; i <= 51; i++) { @@ -1365,7 +1365,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); const largeContent = 'x'.repeat(4 * 1024 * 1024); @@ -1401,7 +1401,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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;'); @@ -1432,7 +1432,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); @@ -1460,7 +1460,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); // Establish a base commit on master commitFile(harness.cwd.path, 'base.ts', 'const base = 1;'); @@ -1494,7 +1494,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); harness.cwd.writeFile('dirty.ts', 'const x = 1;'); @@ -1598,7 +1598,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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 @@ -1629,7 +1629,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); // Write a file slightly over 10 MB @@ -1663,7 +1663,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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;'); @@ -1690,7 +1690,7 @@ describe('analyze agentic — change-set mode (no --file)', () => { bareHarness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(bareHarness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(bareHarness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); bareHarness.cwd.writeFile('app.ts', 'const a = 1;'); @@ -1731,7 +1731,7 @@ describe('verify — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); harness.cwd.writeFile('new.ts', 'const x = 1;'); @@ -1762,7 +1762,7 @@ describe('verify — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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;'); @@ -1792,7 +1792,7 @@ describe('verify — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); for (let i = 1; i <= 51; i++) { @@ -1824,7 +1824,7 @@ describe('verify — change-set mode (no --file)', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); for (let i = 1; i <= 51; i++) { @@ -1868,7 +1868,7 @@ describe('analyze agentic — API error codes', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -1893,7 +1893,7 @@ describe('analyze agentic — API error codes', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -1925,7 +1925,7 @@ describe('analyze agentic — API error codes', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); initGitRepo(harness.cwd.path); commitFile(harness.cwd.path, '.gitignore', '.claude/\n'); @@ -1958,7 +1958,7 @@ describe('analyze agentic — API error codes', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', '// TODO: fix\nconst x = 1;'); @@ -1999,7 +1999,7 @@ describe('analyze agentic — --format json', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -2037,7 +2037,7 @@ describe('analyze agentic — --format json', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', '// TODO: fix\nconst x = 1;'); @@ -2067,7 +2067,7 @@ describe('analyze agentic — --format json', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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;'); @@ -2105,7 +2105,7 @@ describe('analyze agentic — --format json', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); commitFile(harness.cwd.path, 'README.md', 'hello'); for (let i = 1; i <= 51; i++) { @@ -2143,7 +2143,7 @@ describe('analyze agentic — --format json', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('src/index.ts', 'const x = 1;'); @@ -2177,7 +2177,7 @@ describe('analyze agentic — --format json', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); harness.cwd.writeFile('large.ts', 'x'.repeat(256)); @@ -2222,7 +2222,7 @@ describe('analyze agentic — running from a subdirectory', () => { .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()); + .withSqaaFeature(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 @@ -2265,7 +2265,7 @@ describe('analyze agentic — running from a subdirectory', () => { harness .state() .withAuth(server.baseUrl(), VALID_TOKEN, TEST_ORG) - .withSqaaExtension(harness.cwd.path, TEST_PROJECT, TEST_ORG, server.baseUrl()); + .withSqaaFeature(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. diff --git a/tests/integration/specs/system/reset.test.ts b/tests/integration/specs/system/reset.test.ts index 4300275bc..4d53bd4b4 100644 --- a/tests/integration/specs/system/reset.test.ts +++ b/tests/integration/specs/system/reset.test.ts @@ -478,7 +478,7 @@ describe('system reset --force', () => { harness .state() .withAuth(server.baseUrl(), 'tok', 'org-key') - .withSqaaExtension(harness.cwd.path, 'my-project'); + .withSqaaFeature(harness.cwd.path, 'my-project'); const result = await harness.run('system reset --force'); From df2d80e1593c7f4cb5f096b0cee47bd602fa16a5 Mon Sep 17 00:00:00 2001 From: tomshafir-sonarsource Date: Mon, 6 Jul 2026 15:58:42 +0200 Subject: [PATCH 4/6] CLI-694 PR review --- src/cli/commands/analyze/sqaa-auth.ts | 3 ++- .../integrate/_common/sqaa-entitlement.ts | 2 ++ .../commands/integrate/claude/declaration.ts | 3 ++- .../commands/integrate/codex/declaration.ts | 3 ++- .../harness/environment-builder.ts | 3 ++- tests/integration/specs/system/reset.test.ts | 26 +++++++++++++++---- .../cli/commands/analyze/analyze-sqaa.test.ts | 6 +++-- 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/cli/commands/analyze/sqaa-auth.ts b/src/cli/commands/analyze/sqaa-auth.ts index 9267bee38..0bc0fd54c 100644 --- a/src/cli/commands/analyze/sqaa-auth.ts +++ b/src/cli/commands/analyze/sqaa-auth.ts @@ -29,6 +29,7 @@ import { loadState } from '../../../lib/repository/state-repository'; import { canonicalProjectRoot } from '../../../lib/state-manager'; import { blank, confirmPrompt, text, warn } from '../../../ui'; import { CommandFailedError } from '../_common/error.js'; +import { SQAA_HOOK_FEATURE_ID } from '../integrate/_common/sqaa-entitlement'; import { CLAUDE_INTEGRATION_ID } from '../integrate/claude/declaration'; const LARGE_CHANGESET_HINT = @@ -128,7 +129,7 @@ export async function resolveSqaaProjectKey(projectRoot?: string): Promise - feature.featureId === 'sonar-sqaa-hook' && + feature.featureId === SQAA_HOOK_FEATURE_ID && feature.scope === 'project' && canonicalProjectRoot(feature.targetRoot) === root, ); diff --git a/src/cli/commands/integrate/_common/sqaa-entitlement.ts b/src/cli/commands/integrate/_common/sqaa-entitlement.ts index f0b131788..53d50e58d 100644 --- a/src/cli/commands/integrate/_common/sqaa-entitlement.ts +++ b/src/cli/commands/integrate/_common/sqaa-entitlement.ts @@ -22,6 +22,8 @@ import { AGENTIC_ANALYSIS_DOCS_URL } from '../../../../lib/config-constants'; import { SonarQubeClient, type SqaaEntitlementStatus } from '../../../../sonarqube/client'; import { info, warn } from '../../../../ui'; +export const SQAA_HOOK_FEATURE_ID = 'sonar-sqaa-hook'; + /** * Consistent notice shown across all agent integrations when SonarQube Agentic * Analysis is skipped because the integration is global. diff --git a/src/cli/commands/integrate/claude/declaration.ts b/src/cli/commands/integrate/claude/declaration.ts index f4864814f..b7cd7cad1 100644 --- a/src/cli/commands/integrate/claude/declaration.ts +++ b/src/cli/commands/integrate/claude/declaration.ts @@ -49,6 +49,7 @@ import { import { removeJsonMcpServer, upsertJsonMcpServer } from '../_common/mcp-config'; import type { IntegrationContext, IntegrationDeclaration } from '../_common/registry'; import { askUser, jsonPatch, skip, textSnippet, wholeFile } from '../_common/registry'; +import { SQAA_HOOK_FEATURE_ID } from '../_common/sqaa-entitlement'; import type { IntegrateAgentOptions } from '../_common/types'; import { getSecretPreToolTemplateUnix, @@ -124,7 +125,7 @@ export const claudeIntegration: IntegrationDeclaration ], }), { - id: 'sonar-sqaa-hook', + id: SQAA_HOOK_FEATURE_ID, displayName: 'Vortex agentic analysis hook', benefitDescription: AGENTIC_ANALYSIS_FEATURE_BENEFIT, previewDescription: AGENTIC_ANALYSIS_FEATURE_PREVIEW, diff --git a/src/cli/commands/integrate/codex/declaration.ts b/src/cli/commands/integrate/codex/declaration.ts index 18b853abb..0da14ee5c 100644 --- a/src/cli/commands/integrate/codex/declaration.ts +++ b/src/cli/commands/integrate/codex/declaration.ts @@ -53,6 +53,7 @@ import { tomlPatch, wholeFile, } from '../_common/registry'; +import { SQAA_HOOK_FEATURE_ID } from '../_common/sqaa-entitlement'; import type { IntegrateAgentOptions } from '../_common/types'; import { getSecretPromptTemplateUnix, @@ -110,7 +111,7 @@ export const codexIntegration: IntegrationDeclaration = ], }), { - id: 'sonar-sqaa-hook', + id: SQAA_HOOK_FEATURE_ID, displayName: 'Vortex agentic analysis hook', benefitDescription: AGENTIC_ANALYSIS_FEATURE_BENEFIT, previewDescription: AGENTIC_ANALYSIS_FEATURE_PREVIEW, diff --git a/tests/integration/harness/environment-builder.ts b/tests/integration/harness/environment-builder.ts index e8c286ae2..dd3b7d883 100644 --- a/tests/integration/harness/environment-builder.ts +++ b/tests/integration/harness/environment-builder.ts @@ -43,6 +43,7 @@ import { SECRETS_SPEC } from '../../../src/cli/commands/_common/install/secrets' import { CONTEXT_AUGMENTATION_FEATURE_ID } from '../../../src/cli/commands/integrate/_common/features/context-augmentation-feature'; import { recordInstalledFeature } from '../../../src/cli/commands/integrate/_common/registry/installation-recorder'; import type { IntegrationDeclaration } from '../../../src/cli/commands/integrate/_common/registry/types'; +import { SQAA_HOOK_FEATURE_ID } from '../../../src/cli/commands/integrate/_common/sqaa-entitlement'; import { CLAUDE_INTEGRATION_ID } from '../../../src/cli/commands/integrate/claude/declaration'; import { CONTEXT_AUGMENTATION_BINARY_NAME } from '../../../src/lib/install-types.js'; import { generateKeychainAccount } from '../../../src/lib/keychain'; @@ -147,7 +148,7 @@ function recordSqaaHookFeature( const timestamp = integration.installedAt; integration.features.push({ - featureId: 'sonar-sqaa-hook', + featureId: SQAA_HOOK_FEATURE_ID, scope: 'project', targetRoot: args.projectRoot, installedByCliVersion: 'integration-test', diff --git a/tests/integration/specs/system/reset.test.ts b/tests/integration/specs/system/reset.test.ts index 4d53bd4b4..cd8d30df6 100644 --- a/tests/integration/specs/system/reset.test.ts +++ b/tests/integration/specs/system/reset.test.ts @@ -20,6 +20,7 @@ // Integration tests for `sonar system reset` +import { randomUUID } from 'node:crypto'; import { chmodSync, existsSync, @@ -146,6 +147,21 @@ function buildRawState(overrides: { }); } +function legacySqaaHookExtension(projectRoot: string, projectKey: string): unknown { + return { + id: randomUUID(), + agentId: 'claude-code', + projectRoot, + global: false, + projectKey, + updatedByCliVersion: 'integration-test', + updatedAt: new Date().toISOString(), + kind: 'hook', + name: 'sonar-sqaa', + hookType: 'PostToolUse', + }; +} + function gitEnv(userHome: string): NodeJS.ProcessEnv { return { ...process.env, ...buildHomeEnv(userHome) }; } @@ -453,7 +469,6 @@ describe('system reset --force', () => { it( 'clears agentExtensions registry entries without legacy disk cleanup', async () => { - const server = await harness.newFakeServer().start(); const settingsPath = join(harness.cwd.path, '.claude', 'settings.json'); mkdirSync(join(harness.cwd.path, '.claude'), { recursive: true }); writeFileSync( @@ -475,10 +490,11 @@ describe('system reset --force', () => { 'utf-8', ); - harness - .state() - .withAuth(server.baseUrl(), 'tok', 'org-key') - .withSqaaFeature(harness.cwd.path, 'my-project'); + harness.state().withRawState( + buildRawState({ + agentExtensions: [legacySqaaHookExtension(harness.cwd.path, 'my-project')], + }), + ); const result = await harness.run('system reset --force'); diff --git a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts index eef2cec11..f43be42a8 100644 --- a/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts +++ b/tests/unit/cli/commands/analyze/analyze-sqaa.test.ts @@ -31,6 +31,8 @@ import { } from '../../../../../src/cli/commands/_common/error.js'; import { analyzeSqaa, buildSqaaJsonReport } from '../../../../../src/cli/commands/analyze/sqaa'; import * as changesetModule from '../../../../../src/cli/commands/analyze/sqaa-changeset'; +import { SQAA_HOOK_FEATURE_ID } from '../../../../../src/cli/commands/integrate/_common/sqaa-entitlement'; +import { CLAUDE_INTEGRATION_ID } from '../../../../../src/cli/commands/integrate/claude/declaration'; import * as processLib from '../../../../../src/lib/process.js'; import * as stateRepository from '../../../../../src/lib/repository/state-repository.js'; import { CliState, getDefaultState } from '../../../../../src/lib/state.js'; @@ -68,14 +70,14 @@ function seedClaudeSqaaFeature(state: CliState, projectKey: string | undefined) const now = new Date().toISOString(); state.integrations.installed.push({ id: randomUUID(), - integrationId: 'claude-code', + integrationId: CLAUDE_INTEGRATION_ID, installedByCliVersion: '1.0.0', installedAt: now, updatedByCliVersion: '1.0.0', updatedAt: now, features: [ { - featureId: 'sonar-sqaa-hook', + featureId: SQAA_HOOK_FEATURE_ID, scope: 'project', targetRoot: process.cwd(), installedByCliVersion: '1.0.0', From a0e5971293c961005fe63b0a00fd0063e9a9aec2 Mon Sep 17 00:00:00 2001 From: tomshafir-sonarsource Date: Tue, 7 Jul 2026 12:03:58 +0200 Subject: [PATCH 5/6] CLI-694 Remove legacy Claude migrations --- src/cli/commands/integrate/claude/index.ts | 11 +- src/lib/migration.ts | 178 +---- src/lib/repository/state-repository.ts | 2 +- src/lib/state-manager.ts | 97 +-- src/lib/state.ts | 7 - .../integrate/claude/integrate.test.ts | 32 +- .../commands/self-update/migration.test.ts | 618 ++++-------------- tests/unit/lib/state-manager.test.ts | 121 ---- 8 files changed, 126 insertions(+), 940 deletions(-) diff --git a/src/cli/commands/integrate/claude/index.ts b/src/cli/commands/integrate/claude/index.ts index 98f194f8a..97f7282b7 100644 --- a/src/cli/commands/integrate/claude/index.ts +++ b/src/cli/commands/integrate/claude/index.ts @@ -23,11 +23,7 @@ import { homedir } from 'node:os'; import type { ResolvedAuth } from '../../../../lib/auth-resolver'; -import { - OBSOLETE_A3S_MARKER, - removeObsoleteHookArtifacts, - runMigrations, -} from '../../../../lib/migration'; +import { OBSOLETE_A3S_MARKER, removeObsoleteHookArtifacts } from '../../../../lib/migration'; import type { IntegrationStateAttribute } from '../../../../lib/state'; import { displayAgentIntegratePrelude, @@ -67,7 +63,6 @@ export async function integrateClaude( ? undefined : await detectGlobalSecretsHook(homedir()); const skipSecretsHooks = !!existingGlobalHookPath; - const globalDir = ctx.isGlobal ? homedir() : undefined; const sqaaEnabled = await resolveSqaaSetup({ serverURL: config.serverURL, @@ -76,10 +71,6 @@ export async function integrateClaude( isGlobal: ctx.isGlobal, }); - await runMigrations(ctx.project.rootDir, globalDir, sqaaEnabled, config.projectKey, { - skipSecretsHooks, - }); - const contextAugmentation = options.skipContext ? null : await resolveContextAugmentationSetup({ diff --git a/src/lib/migration.ts b/src/lib/migration.ts index 262d070f6..5876309b0 100644 --- a/src/lib/migration.ts +++ b/src/lib/migration.ts @@ -18,28 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// Auto-migration for Claude Code hooks configuration. -// This migration logic is invoked explicitly from the integrate command. -// It should eventually become part of a dedicated post-update mechanism that -// runs automatically after CLI upgrades, to be implemented in a future iteration. +// Cleanup helpers for obsolete Claude Code hook artifacts. +// Consumed by the post-update mechanism that runs automatically after CLI upgrades. -import { randomUUID } from 'node:crypto'; import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { version as CURRENT_VERSION } from '../../package.json'; -import { installHooks } from '../cli/commands/integrate/claude/hooks'; import logger from './logger'; -import { loadState, saveState } from './repository/state-repository'; -import type { CliState, HookExtension } from './state'; -import { addInstalledHook, getActiveConnection, upsertAgentExtension } from './state-manager'; - -// Version that introduced the new hook architecture (separate secrets/SQAA hooks) -const NEW_HOOK_ARCH_VERSION = CURRENT_VERSION; - -// Version known to have the CLI-105 state deduplication bug -const CLI_105_AFFECTED_VERSION = '0.5.1'; +import type { CliState } from './state'; export const OBSOLETE_A3S_MARKER = 'sonar-a3s'; const CLAUDE_CONFIG_DIR = '.claude'; @@ -130,165 +117,6 @@ export function cleanObsoleteFromState(state: CliState, marker: string): void { state.agentExtensions = state.agentExtensions.filter((e) => e.name !== marker); } -export interface RunMigrationsOptions { - /** - * When true, skip migrating/rewriting/installing project-level sonar-secrets hooks. - * Mirrors the same-named flag on {@link installHooks}; used when a pre-existing - * global sonar-secrets hook should take precedence over the project-level one. - */ - skipSecretsHooks?: boolean; -} - -/** - * Run all pending config migrations for Claude Code agent. - * Called during sonar claude setup. Non-blocking — logs and continues on error. - */ -export async function runMigrations( - projectRoot: string, - globalDir?: string, - installSqaa = false, - projectKey?: string, - options: RunMigrationsOptions = {}, -): Promise { - try { - const state = loadState(); - const agentConfig = state.agents['claude-code']; - - if (!agentConfig.configured) { - return; - } - - const installedVersion = agentConfig.configuredByCliVersion; - if (!installedVersion) { - return; - } - - if (installedVersion === NEW_HOOK_ARCH_VERSION) { - return; - } - - logger.debug( - `Migrating Claude Code hooks from v${installedVersion} to v${NEW_HOOK_ARCH_VERSION}`, - ); - - // CLI-105 patch: v0.5.1 only registered UserPromptSubmit due to dedup bug. - // If exactly one sonar-secrets hook is registered, add the missing PreToolUse entry. - if (installedVersion === CLI_105_AFFECTED_VERSION) { - const hooks = agentConfig.hooks.installed; - const secretsHooks = hooks.filter((h) => h.name === 'sonar-secrets'); - if (secretsHooks.length === 1 && secretsHooks[0].type === 'UserPromptSubmit') { - logger.debug('CLI-105 patch: adding missing PreToolUse entry to state'); - addInstalledHook(state, 'claude-code', 'sonar-secrets', 'PreToolUse'); - } - } - - const { skipSecretsHooks = false } = options; - - // Migrate hook scripts on disk: rewrite with new commands. - // When skipSecretsHooks is set, a global hook already exists — leave the - // project-level scripts alone rather than re-materializing them. - if (!skipSecretsHooks) { - migrateHookScripts(projectRoot, globalDir); - } - - // Install new PostToolUse hook (and refresh secrets hooks unless skipped) - await installHooks(projectRoot, globalDir, installSqaa, projectKey, { skipSecretsHooks }); - - // Clean up obsolete sonar-a3s artifacts (settings.json entries + hook dir on disk) - await removeObsoleteHookArtifacts(projectRoot, OBSOLETE_A3S_MARKER); - - // Register PostToolUse hook in state (legacy format for backward compat). - // Only for cloud connections: on-premise servers have no SQAA entitlement. - if (installSqaa) { - addInstalledHook(state, 'claude-code', 'sonar-sqaa', 'PostToolUse'); - } - - // Populate agentExtensions registry from old hooks.installed (if not yet migrated) - migrateToExtensionsRegistry(state, projectRoot, globalDir); - - // Remove obsolete sonar-a3s entries from the in-memory state object before saving. - // Must happen after migrateToExtensionsRegistry to avoid re-migrating stale entries. - cleanObsoleteFromState(state, OBSOLETE_A3S_MARKER); - - // Mark migration complete - state.agents['claude-code'].configuredByCliVersion = CURRENT_VERSION; - state.agents['claude-code'].migratedAt = new Date().toISOString(); - - saveState(state); - logger.debug('Hook migration completed successfully'); - } catch (err) { - logger.warn(`Hook migration failed (non-blocking): ${(err as Error).message}`); - } -} - -/** - * Convert old hooks.installed entries to the new agentExtensions registry. - * Also registers the sonar-sqaa PostToolUse hook if the active connection is cloud. - * Idempotent: skips if extensions for this agent+project already exist. - */ -function migrateToExtensionsRegistry( - state: ReturnType, - projectRoot: string, - globalDir: string | undefined, -): void { - const isGlobal = globalDir !== undefined; - // For global installs, use globalDir as projectRoot so it doesn't collide with project-level entries. - const effectiveProjectRoot = globalDir ?? projectRoot; - const existingExtensions = state.agentExtensions.filter( - (e) => e.agentId === 'claude-code' && e.projectRoot === effectiveProjectRoot, - ); - - const connection = getActiveConnection(state); - const now = new Date().toISOString(); - - const baseExt = { - agentId: 'claude-code', - projectRoot: effectiveProjectRoot, - global: isGlobal, - orgKey: connection?.orgKey, - serverUrl: connection?.serverUrl, - updatedByCliVersion: CURRENT_VERSION, - updatedAt: now, - }; - - // Migrate entries from old hooks.installed that don't yet have a registry entry. - // sonar-sqaa is always project-level (never global), regardless of the -g flag. - const oldHooks = state.agents['claude-code'].hooks.installed; - for (const hook of oldHooks) { - const alreadyMigrated = existingExtensions.some( - (e): e is HookExtension => - e.kind === 'hook' && e.name === hook.name && e.hookType === hook.type, - ); - if (!alreadyMigrated) { - const isSqaa = hook.name === 'sonar-sqaa'; - upsertAgentExtension(state, { - ...baseExt, - projectRoot: isSqaa ? projectRoot : effectiveProjectRoot, - global: isSqaa ? false : isGlobal, - id: randomUUID(), - kind: 'hook', - name: hook.name, - hookType: hook.type, - }); - } - } - - // Add the new sonar-sqaa PostToolUse extension for cloud connections. - // SQAA is always project-level (never global), regardless of the -g flag. - const isCloud = connection?.type === 'cloud'; - if (isCloud) { - upsertAgentExtension(state, { - ...baseExt, - projectRoot, - global: false, - id: randomUUID(), - kind: 'hook', - name: 'sonar-sqaa', - hookType: 'PostToolUse', - }); - } -} - /** * Rewrite old hook scripts that called `sonar analyze --file` to use specific subcommands. * Also called from post-update.ts for automatic migration after CLI upgrades. diff --git a/src/lib/repository/state-repository.ts b/src/lib/repository/state-repository.ts index b4aec527e..ac3e8e9d3 100644 --- a/src/lib/repository/state-repository.ts +++ b/src/lib/repository/state-repository.ts @@ -20,7 +20,7 @@ /** * Filesystem I/O for state.json — reading, writing, and in-place migration. - * Business logic (addOrUpdateConnection, upsertAgentExtension, etc.) lives in state-manager.ts. + * Business logic (addOrUpdateConnection, removeConnection, etc.) lives in state-manager.ts. */ import { randomUUID } from 'node:crypto'; diff --git a/src/lib/state-manager.ts b/src/lib/state-manager.ts index 7e1cb53e7..4a532458a 100644 --- a/src/lib/state-manager.ts +++ b/src/lib/state-manager.ts @@ -34,15 +34,7 @@ import { loadState, saveState } from './repository/state-repository.js'; export { loadState, saveState }; -import { - type AgentExtension, - agentExtensionEquals, - type AuthConnection, - type CliState, - type CloudRegion, - type HookType, - type SkillExtension, -} from './state.js'; +import { type AuthConnection, type CliState, type CloudRegion } from './state.js'; /** * Get the currently active authentication connection, or undefined if none. @@ -131,68 +123,6 @@ export function removeConnection(state: CliState, connectionId: string): void { } } -/** - * Mark agent as configured - */ -export function markAgentConfigured(state: CliState, agentName: string, cliVersion: string): void { - if (!Object.hasOwn(state.agents, agentName)) { - state.agents[agentName] = { - configured: false, - hooks: { installed: [] }, - skills: { installed: [] }, - }; - } - - state.agents[agentName].configured = true; - state.agents[agentName].configuredAt = new Date().toISOString(); - state.agents[agentName].configuredByCliVersion = cliVersion; -} - -/** - * Add installed hook for agent (legacy — kept for migration compatibility) - */ -export function addInstalledHook( - state: CliState, - agentName: string, - hookName: string, - hookType: HookType, -): void { - if (!Object.hasOwn(state.agents, agentName)) { - state.agents[agentName] = { - configured: false, - hooks: { installed: [] }, - skills: { installed: [] }, - }; - } - - // Remove duplicate if exists (match by both name and type) - state.agents[agentName].hooks.installed = state.agents[agentName].hooks.installed.filter( - (h) => !(h.name === hookName && h.type === hookType), - ); - - state.agents[agentName].hooks.installed.push({ - name: hookName, - type: hookType, - installedAt: new Date().toISOString(), - }); -} - -/** - * Upsert an agent extension in the registry. - * Matches by agentId + projectRoot + kind + name + (hookType for hooks). - * For global installs, projectRoot is set to homedir() so it naturally differs from project-level. - */ -export function upsertAgentExtension(state: CliState, extension: AgentExtension): void { - const idx = state.agentExtensions.findIndex((e) => agentExtensionEquals(e, extension)); - - if (idx >= 0) { - // Preserve the original id — callers pass randomUUID() on every call - state.agentExtensions[idx] = { ...extension, id: state.agentExtensions[idx].id }; - } else { - state.agentExtensions.push(extension); - } -} - /** * Record an installed binary in state.json under `tools.installed[]`. Failures * are logged but do not propagate — state writes must not fail an install. @@ -215,28 +145,3 @@ export function recordInstallationInState(name: string, version: string, path: s logger.warn(`Failed to update state: ${(err as Error).message}`); } } - -type SkillExtensionStateInput = Omit & { - updatedAt?: string; -}; - -/** - * Persist a skill extension entry in the registry. Failures are logged but do - * not propagate because extension state writes must not fail integration setup. - */ -export function recordSkillExtensionInState(extension: SkillExtensionStateInput): void { - try { - const { updatedAt = new Date().toISOString(), ...rest } = extension; - const state = loadState(); - upsertAgentExtension(state, { - id: crypto.randomUUID(), - kind: 'skill', - updatedAt, - ...rest, - }); - saveState(state); - } catch (err) { - warn(`Failed to record ${extension.name} skill in state: ${(err as Error).message}`); - logger.warn(`Failed to record ${extension.name} skill in state: ${(err as Error).message}`); - } -} diff --git a/src/lib/state.ts b/src/lib/state.ts index db8091240..d5382749e 100644 --- a/src/lib/state.ts +++ b/src/lib/state.ts @@ -178,13 +178,6 @@ export interface InstructionsExtension extends BaseAgentExtension { */ export type AgentExtension = HookExtension | SkillExtension | InstructionsExtension; -export function agentExtensionEquals(a: AgentExtension, b: AgentExtension): boolean { - if (a.agentId !== b.agentId || a.projectRoot !== b.projectRoot) return false; - if (a.kind !== b.kind || a.name !== b.name) return false; - if (a.kind === 'hook' && b.kind === 'hook') return a.hookType === b.hookType; - return true; -} - /** * Agent hooks configuration */ diff --git a/tests/unit/cli/commands/integrate/claude/integrate.test.ts b/tests/unit/cli/commands/integrate/claude/integrate.test.ts index 5765e7ec7..7606406ff 100644 --- a/tests/unit/cli/commands/integrate/claude/integrate.test.ts +++ b/tests/unit/cli/commands/integrate/claude/integrate.test.ts @@ -29,7 +29,6 @@ import * as registry from '../../../../../../src/cli/commands/integrate/_common/ import { integrateClaude } from '../../../../../../src/cli/commands/integrate/claude'; import * as hooks from '../../../../../../src/cli/commands/integrate/claude/hooks'; import type { ResolvedAuth } from '../../../../../../src/lib/auth-resolver'; -import * as migration from '../../../../../../src/lib/migration'; import type { DiscoveredProject } from '../../../../../../src/lib/project-workspace'; import * as discovery from '../../../../../../src/lib/project-workspace'; import * as stateRepository from '../../../../../../src/lib/repository/state-repository'; @@ -83,7 +82,6 @@ describe('integrateCommand', () => { let detectGlobalSecretsHookSpy: Mock< Extract<(typeof hooks)['detectGlobalSecretsHook'], (...args: any[]) => any> >; - let runMigrationsSpy: Mock any>>; let resolveContextAugmentationSetupSpy: Mock< Extract< (typeof contextAugmentation)['resolveContextAugmentationSetup'], @@ -116,7 +114,6 @@ describe('integrateCommand', () => { detectGlobalSecretsHookSpy = spyOn(hooks, 'detectGlobalSecretsHook').mockResolvedValue( undefined, ); - runMigrationsSpy = spyOn(migration, 'runMigrations'); mockDiscoveredProject({}); }); @@ -134,7 +131,6 @@ describe('integrateCommand', () => { discoverProjectSpy.mockRestore(); installIntegrationSpy.mockRestore(); detectGlobalSecretsHookSpy.mockRestore(); - runMigrationsSpy.mockRestore(); resolveContextAugmentationSetupSpy.mockRestore(); }); @@ -405,13 +401,6 @@ describe('integrateCommand', () => { installSqaaHook: false, installSqaaInstructions: false, }); - expect(runMigrationsSpy).toHaveBeenCalledWith( - '/project/root', - undefined, - false, - 'a-project', - { skipSecretsHooks: true }, - ); }); it('still installs the project-scoped sonar-sqaa hook when SQAA is entitled', async () => { @@ -479,7 +468,7 @@ describe('integrateCommand', () => { await integrateClaude({ global: true }, CLOUD_AUTH); - // SQAA is never installed on a global run, and the install/migration/state + // SQAA is never installed on a global run, and the install/state // paths all see sqaaEnabled = false. expectClaudeInstallCall({ targetRoot: homedir(), @@ -491,15 +480,6 @@ describe('integrateCommand', () => { installSqaaHook: false, installSqaaInstructions: false, }); - expect(runMigrationsSpy).toHaveBeenLastCalledWith( - '/project/root', - homedir(), - false, - 'a-project', - { - skipSecretsHooks: false, - }, - ); const warnNotice = getMockUiCalls().find( (c) => c.method === 'warn' && String(c.args[0]).includes('not supported with --global'), @@ -532,16 +512,6 @@ describe('integrateCommand', () => { auth: ResolvedAuth = CLOUD_AUTH, skipSecretsHooks = false, ): void { - const expectedOptions = { skipSecretsHooks: false }; - expect(runMigrationsSpy).toHaveBeenCalledTimes(1); - expect(runMigrationsSpy).toHaveBeenCalledWith( - projectRootDir, - globalDir, - sqaaEnabled, - projectKey, - expectedOptions, - ); - const mainTargetRoot = globalDir ?? projectRootDir; const mainScope = isGlobal ? 'global' : 'project'; expectClaudeInstallCall({ diff --git a/tests/unit/cli/commands/self-update/migration.test.ts b/tests/unit/cli/commands/self-update/migration.test.ts index e12d8ffa6..219ff9fd5 100644 --- a/tests/unit/cli/commands/self-update/migration.test.ts +++ b/tests/unit/cli/commands/self-update/migration.test.ts @@ -18,409 +18,52 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -// Unit tests for auto-migration logic (src/bootstrap/migration.ts) - -import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { homedir, tmpdir } from 'node:os'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { version as CURRENT_VERSION } from '../../../../../package.json'; -import * as hooks from '../../../../../src/cli/commands/integrate/claude/hooks'; -import { runMigrations } from '../../../../../src/lib/migration'; -import * as stateRepository from '../../../../../src/lib/repository/state-repository.js'; -import type { HookExtension } from '../../../../../src/lib/state.js'; +import { + cleanObsoleteFromState, + migrateHookScripts, + OBSOLETE_A3S_MARKER, + removeObsoleteHookArtifacts, +} from '../../../../../src/lib/migration'; +import type { AgentExtension, CliState, HookExtension } from '../../../../../src/lib/state.js'; import { getDefaultState } from '../../../../../src/lib/state.js'; -import * as stateManager from '../../../../../src/lib/state-manager.js'; import { setMockUi } from '../../../../../src/ui'; const OLD_VERSION = '0.4.0'; -const CLI_105_VERSION = '0.5.1'; - -describe('runMigrations — skip conditions', () => { - let loadStateSpy: ReturnType; - let saveStateSpy: ReturnType; - let addInstalledHookSpy: ReturnType; - let installHooksSpy: ReturnType; - - beforeEach(() => { - setMockUi(true); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(getDefaultState('test')); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => undefined); - addInstalledHookSpy = spyOn(stateManager, 'addInstalledHook').mockImplementation( - () => undefined, - ); - installHooksSpy = spyOn(hooks, 'installHooks').mockResolvedValue(undefined); - }); - - afterEach(() => { - setMockUi(false); - loadStateSpy.mockRestore(); - saveStateSpy.mockRestore(); - addInstalledHookSpy.mockRestore(); - installHooksSpy.mockRestore(); - }); - - it('skips when agent is not configured', async () => { - // Default state has configured: false - await runMigrations('/some/project'); - - expect(installHooksSpy).not.toHaveBeenCalled(); - expect(saveStateSpy).not.toHaveBeenCalled(); - }); - - it('skips when configuredByCliVersion is missing', async () => { - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = undefined; - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - expect(installHooksSpy).not.toHaveBeenCalled(); - expect(saveStateSpy).not.toHaveBeenCalled(); - }); - - it('skips when installed version matches current version', async () => { - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = CURRENT_VERSION; - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - expect(installHooksSpy).not.toHaveBeenCalled(); - expect(saveStateSpy).not.toHaveBeenCalled(); - }); -}); -function makeConfiguredState(version: string) { - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = version; - return state; +function seedAgentExtension(state: CliState, extension: AgentExtension): void { + const idx = state.agentExtensions.findIndex( + (e) => + e.agentId === extension.agentId && + e.projectRoot === extension.projectRoot && + e.kind === extension.kind && + e.name === extension.name && + (e.kind !== 'hook' || extension.kind !== 'hook' || e.hookType === extension.hookType), + ); + if (idx >= 0) { + state.agentExtensions[idx] = { ...extension, id: state.agentExtensions[idx].id }; + } else { + state.agentExtensions.push(extension); + } } -describe('runMigrations — migration execution', () => { - let loadStateSpy: ReturnType; - let saveStateSpy: ReturnType; - let addInstalledHookSpy: ReturnType; - let installHooksSpy: ReturnType; - - beforeEach(() => { - setMockUi(true); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(getDefaultState('test')); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => undefined); - addInstalledHookSpy = spyOn(stateManager, 'addInstalledHook').mockImplementation( - () => undefined, - ); - installHooksSpy = spyOn(hooks, 'installHooks').mockResolvedValue(undefined); - }); - - afterEach(() => { - setMockUi(false); - loadStateSpy.mockRestore(); - saveStateSpy.mockRestore(); - addInstalledHookSpy.mockRestore(); - installHooksSpy.mockRestore(); - }); - - it('calls installHooks when version differs from current', async () => { - loadStateSpy.mockReturnValue(makeConfiguredState(OLD_VERSION)); - - await runMigrations('/some/project'); - - expect(installHooksSpy).toHaveBeenCalledWith('/some/project', undefined, false, undefined, { - skipSecretsHooks: false, - }); - }); - - it('passes globalDir to installHooks when provided', async () => { - loadStateSpy.mockReturnValue(makeConfiguredState(OLD_VERSION)); - - await runMigrations('/some/project', '/global/dir'); - - expect(installHooksSpy).toHaveBeenCalledWith('/some/project', '/global/dir', false, undefined, { - skipSecretsHooks: false, - }); - }); - - it('forwards skipSecretsHooks: true to installHooks when a global secrets hook pre-exists', async () => { - loadStateSpy.mockReturnValue(makeConfiguredState(OLD_VERSION)); - - await runMigrations('/some/project', undefined, false, undefined, { skipSecretsHooks: true }); - - expect(installHooksSpy).toHaveBeenCalledWith('/some/project', undefined, false, undefined, { - skipSecretsHooks: true, - }); - }); - - it('registers sonar-sqaa PostToolUse hook in state when installSqaa is true', async () => { - loadStateSpy.mockReturnValue(makeConfiguredState(OLD_VERSION)); - - await runMigrations('/some/project', undefined, true); - - expect(addInstalledHookSpy).toHaveBeenCalledWith( - expect.anything(), - 'claude-code', - 'sonar-sqaa', - 'PostToolUse', - ); - }); - - it('does not register sonar-sqaa hook in state when installSqaa is false', async () => { - loadStateSpy.mockReturnValue(makeConfiguredState(OLD_VERSION)); - - await runMigrations('/some/project', undefined, false); - - expect(addInstalledHookSpy).not.toHaveBeenCalledWith( - expect.anything(), - 'claude-code', - 'sonar-sqaa', - 'PostToolUse', - ); - }); - - it('updates configuredByCliVersion to current version after migration', async () => { - const state = makeConfiguredState(OLD_VERSION); - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - expect(state.agents['claude-code'].configuredByCliVersion).toBe(CURRENT_VERSION); - }); - - it('sets migratedAt timestamp after migration', async () => { - const state = makeConfiguredState(OLD_VERSION); - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - expect(state.agents['claude-code'].migratedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it('saves state after migration', async () => { - loadStateSpy.mockReturnValue(makeConfiguredState(OLD_VERSION)); - - await runMigrations('/some/project'); - - expect(saveStateSpy).toHaveBeenCalled(); - }); - - it('is non-blocking: resolves without throwing when installHooks fails', async () => { - loadStateSpy.mockReturnValue(makeConfiguredState(OLD_VERSION)); - installHooksSpy.mockRejectedValue(new Error('Hook install failed')); - - await runMigrations('/some/project'); - }); - - it('populates agentExtensions registry with sonar-sqaa for cloud connections', async () => { - const state = makeConfiguredState(OLD_VERSION); - // Directly populate hooks.installed to simulate pre-registry state - // (addInstalledHook is mocked in beforeEach so we must mutate directly) - state.agents['claude-code'].hooks.installed.push( - { name: 'sonar-secrets', type: 'PreToolUse', installedAt: new Date().toISOString() }, - { name: 'sonar-secrets', type: 'UserPromptSubmit', installedAt: new Date().toISOString() }, - ); - stateManager.addOrUpdateConnection(state, 'https://sonarcloud.io', 'cloud', { - orgKey: 'my-org', - }); - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - const sqaaExts = state.agentExtensions.filter( - (e): e is HookExtension => - e.kind === 'hook' && e.name === 'sonar-sqaa' && e.hookType === 'PostToolUse', - ); - expect(sqaaExts.length).toBeGreaterThan(0); - expect(sqaaExts[0].projectRoot).toBe('/some/project'); - }); - - it('migrates old hooks.installed entries to agentExtensions', async () => { - const state = makeConfiguredState(OLD_VERSION); - // Directly populate hooks.installed (addInstalledHook is mocked in beforeEach) - state.agents['claude-code'].hooks.installed.push({ - name: 'sonar-secrets', - type: 'PreToolUse', - installedAt: new Date().toISOString(), - }); - stateManager.addOrUpdateConnection(state, 'https://sonarcloud.io', 'cloud', { - orgKey: 'my-org', - }); - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - const secretsExts = state.agentExtensions.filter( - (e): e is HookExtension => - e.kind === 'hook' && e.name === 'sonar-secrets' && e.hookType === 'PreToolUse', - ); - expect(secretsExts).toHaveLength(1); - expect(secretsExts[0].projectRoot).toBe('/some/project'); - }); - - // CLI-148: existing global agentExtensions must not be counted as "already migrated" - // when running a project-level migration for the same hook name - it('creates project-level agentExtensions even when global entries for same hook exist', async () => { - const state = makeConfiguredState(OLD_VERSION); - state.agents['claude-code'].hooks.installed.push({ - name: 'sonar-secrets', - type: 'PreToolUse', - installedAt: new Date().toISOString(), - }); - stateManager.addOrUpdateConnection(state, 'https://sonarcloud.io', 'cloud', { - orgKey: 'my-org', - }); - // Pre-populate a global entry for the same hook (as if integrate -g already ran). - // Global entries use homedir() as projectRoot — that's the new invariant after CLI-148. - stateManager.upsertAgentExtension(state, { - id: 'pre-existing', - agentId: 'claude-code', - projectRoot: homedir(), - global: true, - kind: 'hook', - name: 'sonar-secrets', - hookType: 'PreToolUse', - serverUrl: 'https://sonarcloud.io', - updatedByCliVersion: '0.0.0', - updatedAt: new Date().toISOString(), - }); - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - const projectExts = state.agentExtensions.filter( - (e): e is HookExtension => - e.kind === 'hook' && - e.name === 'sonar-secrets' && - e.hookType === 'PreToolUse' && - e.projectRoot === '/some/project', - ); - - expect(projectExts).toHaveLength(1); - }); -}); - -describe('runMigrations — CLI-105 patch', () => { - let loadStateSpy: ReturnType; - let saveStateSpy: ReturnType; - let addInstalledHookSpy: ReturnType; - let installHooksSpy: ReturnType; - - beforeEach(() => { - setMockUi(true); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(getDefaultState('test')); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => undefined); - addInstalledHookSpy = spyOn(stateManager, 'addInstalledHook').mockImplementation( - () => undefined, - ); - installHooksSpy = spyOn(hooks, 'installHooks').mockResolvedValue(undefined); - }); - - afterEach(() => { - setMockUi(false); - loadStateSpy.mockRestore(); - saveStateSpy.mockRestore(); - addInstalledHookSpy.mockRestore(); - installHooksSpy.mockRestore(); - }); - - it('adds missing PreToolUse when v0.5.1 has only UserPromptSubmit hook', async () => { - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = CLI_105_VERSION; - state.agents['claude-code'].hooks.installed = [ - { - name: 'sonar-secrets', - type: 'UserPromptSubmit', - installedAt: new Date().toISOString(), - }, - ]; - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - expect(addInstalledHookSpy).toHaveBeenCalledWith( - expect.anything(), - 'claude-code', - 'sonar-secrets', - 'PreToolUse', - ); - }); - - it('does not patch when v0.5.1 already has both sonar-secrets hooks', async () => { - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = CLI_105_VERSION; - state.agents['claude-code'].hooks.installed = [ - { name: 'sonar-secrets', type: 'UserPromptSubmit', installedAt: new Date().toISOString() }, - { name: 'sonar-secrets', type: 'PreToolUse', installedAt: new Date().toISOString() }, - ]; - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - expect(addInstalledHookSpy).not.toHaveBeenCalledWith( - expect.anything(), - 'claude-code', - 'sonar-secrets', - 'PreToolUse', - ); - }); - - it('does not patch when v0.5.1 has a PreToolUse hook already (non-UserPromptSubmit only)', async () => { - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = CLI_105_VERSION; - state.agents['claude-code'].hooks.installed = [ - { name: 'sonar-secrets', type: 'PreToolUse', installedAt: new Date().toISOString() }, - ]; - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); - - expect(addInstalledHookSpy).not.toHaveBeenCalledWith( - expect.anything(), - 'claude-code', - 'sonar-secrets', - 'PreToolUse', - ); - }); -}); - -describe('runMigrations — hook script rewriting', () => { +describe('migrateHookScripts', () => { let testDir: string; - let loadStateSpy: ReturnType; - let saveStateSpy: ReturnType; - let addInstalledHookSpy: ReturnType; - let installHooksSpy: ReturnType; beforeEach(() => { setMockUi(true); testDir = join(tmpdir(), `sonar-cli-migration-test-${Date.now()}`); mkdirSync(testDir, { recursive: true }); - - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = OLD_VERSION; - - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(state); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => undefined); - addInstalledHookSpy = spyOn(stateManager, 'addInstalledHook').mockImplementation( - () => undefined, - ); - installHooksSpy = spyOn(hooks, 'installHooks').mockResolvedValue(undefined); }); afterEach(() => { setMockUi(false); rmSync(testDir, { recursive: true, force: true }); - loadStateSpy.mockRestore(); - saveStateSpy.mockRestore(); - addInstalledHookSpy.mockRestore(); - installHooksSpy.mockRestore(); }); function writeOldScript(filename: string, content: string): string { @@ -431,20 +74,20 @@ describe('runMigrations — hook script rewriting', () => { return path; } - it('rewrites sonar analyze to sonar analyze secrets in Unix scripts', async () => { + it('rewrites sonar analyze to sonar analyze secrets in Unix scripts', () => { const scriptPath = writeOldScript( 'pretool-secrets.sh', '#!/bin/bash\nsonar analyze --file "$filePath" > /dev/null 2>&1\n', ); - await runMigrations(testDir); + migrateHookScripts(testDir); const content = readFileSync(scriptPath, 'utf-8'); expect(content).toContain('sonar analyze secrets'); expect(content).not.toContain('sonar analyze --file'); }); - it('rewrites all four hook script variants', async () => { + it('rewrites all four hook script variants', () => { const scripts = { 'pretool-secrets.sh': '#!/bin/bash\nsonar analyze --file "$f"\n', 'prompt-secrets.sh': '#!/bin/bash\nsonar analyze --file "$f"\n', @@ -457,7 +100,7 @@ describe('runMigrations — hook script rewriting', () => { paths[name] = writeOldScript(name, content); } - await runMigrations(testDir); + migrateHookScripts(testDir); for (const [, path] of Object.entries(paths)) { const content = readFileSync(path, 'utf-8'); @@ -466,193 +109,168 @@ describe('runMigrations — hook script rewriting', () => { } }); - it('leaves already-migrated scripts unchanged', async () => { + it('uses globalDir over projectRoot when provided', () => { + const globalDir = join(testDir, 'global'); + const dir = join(globalDir, '.claude', 'hooks', 'sonar-secrets', 'build-scripts'); + mkdirSync(dir, { recursive: true }); + const scriptPath = join(dir, 'pretool-secrets.sh'); + writeFileSync(scriptPath, '#!/bin/bash\nsonar analyze --file "$f"\n', 'utf-8'); + + migrateHookScripts(testDir, globalDir); + + const content = readFileSync(scriptPath, 'utf-8'); + expect(content).toContain('sonar analyze secrets'); + expect(content).not.toContain('sonar analyze --file'); + }); + + it('leaves already-migrated scripts unchanged', () => { const migrated = '#!/bin/bash\nsonar analyze secrets "$file_path"\n'; const scriptPath = writeOldScript('pretool-secrets.sh', migrated); - await runMigrations(testDir); + migrateHookScripts(testDir); const content = readFileSync(scriptPath, 'utf-8'); expect(content).toBe(migrated); }); - it('completes without error when hook scripts do not exist on disk', async () => { + it('completes without error when hook scripts do not exist on disk', () => { // testDir has no hook scripts - await runMigrations(testDir); + expect(() => migrateHookScripts(testDir)).not.toThrow(); }); it('logs debug and continues when a hook script cannot be read (read error)', () => { - // Create a directory where the script path exists but is itself a directory + // Create a directory where the script path is expected to be a file // (causes readFileSync to throw EISDIR), exercising the catch branch const secretsDir = join(testDir, '.claude', 'hooks', 'sonar-secrets', 'build-scripts'); mkdirSync(secretsDir, { recursive: true }); - // Create a subdirectory with the same name as a script — readFileSync will throw mkdirSync(join(secretsDir, 'pretool-secrets.sh'), { recursive: true }); - // Should complete without throwing despite the read error - expect(runMigrations(testDir)).resolves.toBeUndefined(); - }); - - it('deletes the obsolete sonar-a3s hook directory from projectRoot', async () => { - const a3sDir = join(testDir, '.claude', 'hooks', 'sonar-a3s', 'build-scripts'); - mkdirSync(a3sDir, { recursive: true }); - - await runMigrations(testDir); - - const { existsSync } = await import('node:fs'); - expect(existsSync(join(testDir, '.claude', 'hooks', 'sonar-a3s'))).toBe(false); + expect(() => migrateHookScripts(testDir)).not.toThrow(); }); }); -describe('runMigrations — already-migrated extensions not duplicated', () => { - let loadStateSpy: ReturnType; - let saveStateSpy: ReturnType; - let addInstalledHookSpy: ReturnType; - let installHooksSpy: ReturnType; +describe('removeObsoleteHookArtifacts', () => { + let testDir: string; beforeEach(() => { setMockUi(true); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(getDefaultState('test')); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => undefined); - addInstalledHookSpy = spyOn(stateManager, 'addInstalledHook').mockImplementation( - () => undefined, - ); - installHooksSpy = spyOn(hooks, 'installHooks').mockResolvedValue(undefined); + testDir = join(tmpdir(), `sonar-cli-migration-test-${Date.now()}`); + mkdirSync(testDir, { recursive: true }); }); afterEach(() => { setMockUi(false); - loadStateSpy.mockRestore(); - saveStateSpy.mockRestore(); - addInstalledHookSpy.mockRestore(); - installHooksSpy.mockRestore(); + rmSync(testDir, { recursive: true, force: true }); }); - it('does not add a duplicate registry entry when extension already exists for project', async () => { - const state = getDefaultState('test'); - state.agents['claude-code'].configured = true; - state.agents['claude-code'].configuredByCliVersion = OLD_VERSION; - - // Pre-populate agentExtensions with the sonar-sqaa entry for this project - stateManager.addOrUpdateConnection(state, 'https://sonarcloud.io', 'cloud', { - orgKey: 'my-org', - }); - stateManager.upsertAgentExtension(state, { - id: 'existing-ext', - agentId: 'claude-code', - projectRoot: '/some/project', - global: false, - orgKey: 'my-org', - serverUrl: 'https://sonarcloud.io', - updatedByCliVersion: OLD_VERSION, - updatedAt: new Date().toISOString(), - kind: 'hook', - name: 'sonar-sqaa', - hookType: 'PostToolUse', - }); - - loadStateSpy.mockReturnValue(state); - - await runMigrations('/some/project'); + it('deletes the obsolete sonar-a3s hook directory', async () => { + const a3sDir = join(testDir, '.claude', 'hooks', OBSOLETE_A3S_MARKER, 'build-scripts'); + mkdirSync(a3sDir, { recursive: true }); - // The sonar-sqaa PostToolUse entry should still be present exactly once - const sqaaExts = state.agentExtensions.filter( - (e): e is import('../../../../../src/lib/state.js').HookExtension => - e.kind === 'hook' && e.name === 'sonar-sqaa' && e.hookType === 'PostToolUse', + await removeObsoleteHookArtifacts(testDir, OBSOLETE_A3S_MARKER); + + expect(existsSync(join(testDir, '.claude', 'hooks', OBSOLETE_A3S_MARKER))).toBe(false); + }); + + it('removes settings.json entries whose command references the marker and keeps others', async () => { + const claudeDir = join(testDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + const settingsPath = join(claudeDir, 'settings.json'); + writeFileSync( + settingsPath, + JSON.stringify( + { + hooks: { + PostToolUse: [ + { hooks: [{ command: `sonar hook ${OBSOLETE_A3S_MARKER} run` }] }, + { hooks: [{ command: 'sonar hook sonar-secrets run' }] }, + ], + }, + }, + null, + 2, + ), + 'utf-8', ); - expect(sqaaExts).toHaveLength(1); - }); -}); -describe('runMigrations — sonar-a3s state cleanup', () => { - let loadStateSpy: ReturnType; - let saveStateSpy: ReturnType; - let addInstalledHookSpy: ReturnType; - let installHooksSpy: ReturnType; + await removeObsoleteHookArtifacts(testDir, OBSOLETE_A3S_MARKER); - beforeEach(() => { - setMockUi(true); - loadStateSpy = spyOn(stateRepository, 'loadState').mockReturnValue(getDefaultState('test')); - saveStateSpy = spyOn(stateRepository, 'saveState').mockImplementation(() => undefined); - addInstalledHookSpy = spyOn(stateManager, 'addInstalledHook').mockImplementation( - () => undefined, - ); - installHooksSpy = spyOn(hooks, 'installHooks').mockResolvedValue(undefined); + const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as { + hooks: { PostToolUse: { hooks: { command: string }[] }[] }; + }; + const commands = settings.hooks.PostToolUse.flatMap((e) => e.hooks.map((h) => h.command)); + expect(commands).toEqual(['sonar hook sonar-secrets run']); }); - afterEach(() => { - setMockUi(false); - loadStateSpy.mockRestore(); - saveStateSpy.mockRestore(); - addInstalledHookSpy.mockRestore(); - installHooksSpy.mockRestore(); + it('does not throw when the settings file does not exist', async () => { + const result = await removeObsoleteHookArtifacts(testDir, OBSOLETE_A3S_MARKER); + expect(result).toBeUndefined(); }); +}); - it('removes sonar-a3s from legacy hooks.installed', async () => { - const state = makeConfiguredState(OLD_VERSION); +describe('cleanObsoleteFromState', () => { + it('removes sonar-a3s from legacy hooks.installed', () => { + const state = getDefaultState('test'); state.agents['claude-code'].hooks.installed.push({ - name: 'sonar-a3s', + name: OBSOLETE_A3S_MARKER, type: 'PostToolUse', installedAt: new Date().toISOString(), }); - loadStateSpy.mockReturnValue(state); - await runMigrations('/some/project'); + cleanObsoleteFromState(state, OBSOLETE_A3S_MARKER); - expect(state.agents['claude-code'].hooks.installed.some((h) => h.name === 'sonar-a3s')).toBe( - false, - ); + expect( + state.agents['claude-code'].hooks.installed.some((h) => h.name === OBSOLETE_A3S_MARKER), + ).toBe(false); }); - it('removes sonar-a3s from agentExtensions', async () => { - const state = makeConfiguredState(OLD_VERSION); - stateManager.upsertAgentExtension(state, { + it('removes sonar-a3s from agentExtensions', () => { + const state = getDefaultState('test'); + seedAgentExtension(state, { id: 'a3s-ext', agentId: 'claude-code', projectRoot: '/some/project', global: false, kind: 'hook', - name: 'sonar-a3s', + name: OBSOLETE_A3S_MARKER, hookType: 'PostToolUse', updatedByCliVersion: OLD_VERSION, updatedAt: new Date().toISOString(), }); - loadStateSpy.mockReturnValue(state); - await runMigrations('/some/project'); + cleanObsoleteFromState(state, OBSOLETE_A3S_MARKER); - expect(state.agentExtensions.some((e) => e.name === 'sonar-a3s')).toBe(false); + expect(state.agentExtensions.some((e) => e.name === OBSOLETE_A3S_MARKER)).toBe(false); }); - it('does not remove unrelated entries from legacy hooks.installed', async () => { - const state = makeConfiguredState(OLD_VERSION); + it('does not remove unrelated entries from legacy hooks.installed', () => { + const state = getDefaultState('test'); state.agents['claude-code'].hooks.installed.push( - { name: 'sonar-a3s', type: 'PostToolUse', installedAt: new Date().toISOString() }, + { name: OBSOLETE_A3S_MARKER, type: 'PostToolUse', installedAt: new Date().toISOString() }, { name: 'sonar-secrets', type: 'PreToolUse', installedAt: new Date().toISOString() }, ); - loadStateSpy.mockReturnValue(state); - await runMigrations('/some/project'); + cleanObsoleteFromState(state, OBSOLETE_A3S_MARKER); expect( state.agents['claude-code'].hooks.installed.some((h) => h.name === 'sonar-secrets'), ).toBe(true); }); - it('does not remove unrelated entries from agentExtensions', async () => { - const state = makeConfiguredState(OLD_VERSION); - stateManager.upsertAgentExtension(state, { + it('does not remove unrelated entries from agentExtensions', () => { + const state = getDefaultState('test'); + seedAgentExtension(state, { id: 'a3s-ext', agentId: 'claude-code', projectRoot: '/some/project', global: false, kind: 'hook', - name: 'sonar-a3s', + name: OBSOLETE_A3S_MARKER, hookType: 'PostToolUse', updatedByCliVersion: OLD_VERSION, updatedAt: new Date().toISOString(), }); - stateManager.upsertAgentExtension(state, { + seedAgentExtension(state, { id: 'secrets-ext', agentId: 'claude-code', projectRoot: '/some/project', @@ -663,10 +281,12 @@ describe('runMigrations — sonar-a3s state cleanup', () => { updatedByCliVersion: OLD_VERSION, updatedAt: new Date().toISOString(), }); - loadStateSpy.mockReturnValue(state); - await runMigrations('/some/project'); + cleanObsoleteFromState(state, OBSOLETE_A3S_MARKER); - expect(state.agentExtensions.some((e) => e.name === 'sonar-secrets')).toBe(true); + const survivors = state.agentExtensions.filter( + (e): e is HookExtension => e.kind === 'hook' && e.name === 'sonar-secrets', + ); + expect(survivors).toHaveLength(1); }); }); diff --git a/tests/unit/lib/state-manager.test.ts b/tests/unit/lib/state-manager.test.ts index e4651722a..84ef17bfe 100644 --- a/tests/unit/lib/state-manager.test.ts +++ b/tests/unit/lib/state-manager.test.ts @@ -34,15 +34,11 @@ import { saveState, stateFileExists, } from '../../../src/lib/repository/state-repository.js'; -import type { HookExtension, SkillExtension } from '../../../src/lib/state.js'; import { getDefaultState } from '../../../src/lib/state.js'; import { - addInstalledHook, addOrUpdateConnection, generateConnectionId, - markAgentConfigured, removeConnection, - upsertAgentExtension, } from '../../../src/lib/state-manager.js'; const testSonarUserHome = join(tmpdir(), `sonar-cli-state-test-${Date.now()}`); @@ -165,94 +161,6 @@ describe('State Manager', () => { expect(state.auth.isAuthenticated).toBe(true); }); }); - - describe('upsertAgentExtension: id preservation', () => { - it('preserves original id when caller passes a different id on second upsert', () => { - const state = getDefaultState('test'); - - const firstEntry: HookExtension = { - id: 'original-uuid-111', - agentId: 'claude-code', - projectRoot: '/project', - global: false, - updatedByCliVersion: '1.0.0', - updatedAt: new Date().toISOString(), - kind: 'hook', - name: 'sonar-secrets', - hookType: 'PreToolUse', - }; - - upsertAgentExtension(state, firstEntry); - - // Simulate what callers do: pass a new randomUUID() on every upsert - const secondEntry: HookExtension = { - ...firstEntry, - id: 'replacement-uuid-222', - updatedByCliVersion: '1.1.0', - }; - - upsertAgentExtension(state, secondEntry); - - expect(state.agentExtensions).toHaveLength(1); - expect(state.agentExtensions[0].id).toBe('original-uuid-111'); - }); - }); - - describe('upsertAgentExtension: non-hook (skill) extension', () => { - it('matches non-hook extension by agentId + projectRoot + kind + name', () => { - const state = getDefaultState('test'); - - const ext: SkillExtension = { - id: 'ext-1', - agentId: 'claude-code', - projectRoot: '/project', - global: false, - updatedByCliVersion: '1.0.0', - updatedAt: new Date().toISOString(), - kind: 'skill', - name: 'sonarqube-cli-redeploy', - }; - - upsertAgentExtension(state, ext); - expect(state.agentExtensions).toHaveLength(1); - - // Upserting the same non-hook extension should replace, not append - const updated: SkillExtension = { ...ext, updatedAt: new Date().toISOString() }; - upsertAgentExtension(state, updated); - expect(state.agentExtensions).toHaveLength(1); - expect(state.agentExtensions[0].id).toBe('ext-1'); - }); - - it('adds a second skill extension when name differs', () => { - const state = getDefaultState('test'); - - const ext1: SkillExtension = { - id: 'ext-1', - agentId: 'claude-code', - projectRoot: '/project', - global: false, - updatedByCliVersion: '1.0.0', - updatedAt: new Date().toISOString(), - kind: 'skill', - name: 'skill-a', - }; - - const ext2: SkillExtension = { - id: 'ext-2', - agentId: 'claude-code', - projectRoot: '/project', - global: false, - updatedByCliVersion: '1.0.0', - updatedAt: new Date().toISOString(), - kind: 'skill', - name: 'skill-b', - }; - - upsertAgentExtension(state, ext1); - upsertAgentExtension(state, ext2); - expect(state.agentExtensions).toHaveLength(2); - }); - }); }); // ============================================================================= @@ -452,32 +360,3 @@ describe('removeConnection', () => { expect(state.auth.isAuthenticated).toBe(true); }); }); - -// ============================================================================= -// markAgentConfigured / addInstalledHook — new-agent initialisation branch -// ============================================================================= - -describe('markAgentConfigured', () => { - it('initialises missing agent entry before marking as configured', () => { - const state = getDefaultState('1.0.0'); - - markAgentConfigured(state, 'new-agent', '1.0.0'); - - expect(state.agents['new-agent'].configured).toBe(true); - expect(state.agents['new-agent'].configuredByCliVersion).toBe('1.0.0'); - expect(state.agents['new-agent'].hooks.installed).toEqual([]); - expect(state.agents['new-agent'].skills.installed).toEqual([]); - }); -}); - -describe('addInstalledHook', () => { - it('initialises missing agent entry before adding hook', () => { - const state = getDefaultState('1.0.0'); - - addInstalledHook(state, 'new-agent', 'sonar-secrets', 'PreToolUse'); - - expect(state.agents['new-agent'].hooks.installed).toHaveLength(1); - expect(state.agents['new-agent'].hooks.installed[0].name).toBe('sonar-secrets'); - expect(state.agents['new-agent'].hooks.installed[0].type).toBe('PreToolUse'); - }); -}); From aabb7b3659683a053817c443638058d704ce87b0 Mon Sep 17 00:00:00 2001 From: tomshafir-sonarsource Date: Tue, 7 Jul 2026 13:16:13 +0200 Subject: [PATCH 6/6] CLI-694 Fix failing tests --- .../specs/integrate/claude.test.ts | 293 +++++------------- 1 file changed, 82 insertions(+), 211 deletions(-) diff --git a/tests/integration/specs/integrate/claude.test.ts b/tests/integration/specs/integrate/claude.test.ts index 7eb1b5672..ce48f6645 100644 --- a/tests/integration/specs/integrate/claude.test.ts +++ b/tests/integration/specs/integrate/claude.test.ts @@ -20,13 +20,11 @@ // Integration tests for `sonar integrate claude` -import { randomUUID } from 'node:crypto'; import { realpathSync } from 'node:fs'; import { isAbsolute } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { version as CURRENT_VERSION } from '../../../../package.json'; import { buildLocalBinaryName } from '../../../../src/cli/commands/_common/install/secrets.js'; import { claudeIntegration } from '../../../../src/cli/commands/integrate/claude/declaration.js'; import { detectPlatform } from '../../../../src/lib/platform-detector.js'; @@ -808,100 +806,6 @@ describe('integrate claude — SQAA entitlement guard', () => { }, { timeout: 30000 }, ); - - it( - 'removes sonar-a3s entries from state.json when SQAA hooks are installed via migration', - async () => { - const server = await harness - .newFakeServer() - .withAuthToken('cloud-token') - .withOrganizations([{ key: 'my-org', name: 'My Org' }]) - .withSqaaEntitlement('my-org', 'test-uuid-1234') - .withProject('my-project') - .start(); - const serverUrl = server.baseUrl(); - - // Simulate an old install: sonar-a3s is the PostToolUse hook, no sonar-sqaa yet - const staleState = { - version: '1.0', - lastUpdated: new Date().toISOString(), - auth: { - isAuthenticated: true, - connections: [ - { - id: 'test-conn', - type: 'cloud', - serverUrl, - orgKey: 'my-org', - authenticatedAt: new Date().toISOString(), - }, - ], - activeConnectionId: 'test-conn', - }, - agents: { - 'claude-code': { - configured: true, - configuredByCliVersion: '0.5.0', - hooks: { - installed: [ - { name: 'sonar-a3s', type: 'PostToolUse', installedAt: new Date().toISOString() }, - { - name: 'sonar-secrets', - type: 'PreToolUse', - installedAt: new Date().toISOString(), - }, - ], - }, - skills: { installed: [] }, - }, - }, - config: { cliVersion: CURRENT_VERSION }, - telemetry: { enabled: false, firstUseDate: new Date().toISOString(), events: [] }, - agentExtensions: [ - { - id: randomUUID(), - agentId: 'claude-code', - projectRoot: harness.cwd.path, - global: false, - kind: 'hook', - name: 'sonar-a3s', - hookType: 'PostToolUse', - updatedByCliVersion: '0.5.0', - updatedAt: new Date().toISOString(), - }, - ], - }; - - harness - .state() - .withRawState(JSON.stringify(staleState)) - .withKeychainToken(serverUrl, 'cloud-token', 'my-org'); - harness.cwd.writeFile( - 'sonar-project.properties', - [`sonar.host.url=${serverUrl}`, 'sonar.projectKey=my-project'].join('\n'), - ); - - const result = await harness.run(`integrate claude --project my-project --non-interactive`, { - extraEnv: { - SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, - SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, - }, - }); - - expect(result.exitCode).toBe(0); - - const state = harness.stateJsonFile.asJson(); - const extensions = state.agentExtensions as Array<{ name: string }>; - const hooks = (state.agents?.['claude-code']?.hooks?.installed ?? []) as Array<{ - name: string; - }>; - - expect(extensions.some((e) => e.name === 'sonar-a3s')).toBe(false); - expect(hooks.some((h) => h.name === 'sonar-a3s')).toBe(false); - expect(extensions.some((e) => e.name === 'sonar-sqaa')).toBe(true); - }, - { timeout: 30000 }, - ); }); // ─── Local vs Global file placement ────────────────────────────────────────── @@ -1325,7 +1229,7 @@ describe.skipIf(IS_WINDOWS)('integrate claude — legacy state without agentExte }); it( - 'migrates old hook scripts and populates agentExtensions when upgrading from pre-registry state', + 'migrates old hook scripts and normalizes settings.json when upgrading from pre-registry state', async () => { const server = await harness .newFakeServer() @@ -1437,121 +1341,9 @@ describe.skipIf(IS_WINDOWS)('integrate claude — legacy state without agentExte ); }); -// ─── CLI-137: global install over old project-level state ───────────────────── - -describe.skipIf(IS_WINDOWS)( - 'integrate claude — global install over pre-registry project-level state (CLI-137)', - () => { - let harness: TestHarness; - - beforeEach(async () => { - harness = await TestHarness.create(); - harness.state().withSecretsBinaryInstalled(); - }); - - afterEach(async () => { - await harness.dispose(); - }); - - it( - 'populates agentExtensions with global entries when upgrading from old project-level install with -g', - async () => { - const server = await harness - .newFakeServer() - .withAuthToken('test-token') - .withProject('my-project') - .start(); - - const serverUrl = server.baseUrl(); - - // Old state: project-level install at v0.4.0, hooks in hooks.installed, no agentExtensions - harness.state().withRawState( - JSON.stringify({ - version: 1, - config: { cliVersion: '0.4.0' }, - auth: { - isAuthenticated: true, - connections: [ - { - id: 'conn-1', - type: 'on-premise', - serverUrl, - authenticatedAt: new Date().toISOString(), - }, - ], - activeConnectionId: 'conn-1', - }, - agents: { - 'claude-code': { - configured: true, - configuredByCliVersion: '0.4.0', - hooks: { - installed: [ - { - name: 'sonar-secrets', - type: 'PreToolUse', - installedAt: new Date().toISOString(), - }, - { - name: 'sonar-secrets', - type: 'UserPromptSubmit', - installedAt: new Date().toISOString(), - }, - ], - }, - skills: { installed: [] }, - }, - }, - tools: { installed: [] }, - telemetry: { enabled: false, firstUseDate: new Date().toISOString(), events: [] }, - }), - ); - harness.state().withKeychainToken(serverUrl, 'test-token'); - - // Old hook scripts at project level (pre-registry location) - const oldScript = `#!/bin/bash\noutput=$(sonar analyze --file "$file_path" 2>/dev/null)\n`; - harness.cwd.writeFile( - '.claude/hooks/sonar-secrets/build-scripts/pretool-secrets.sh', - oldScript, - ); - harness.cwd.writeFile( - '.claude/hooks/sonar-secrets/build-scripts/prompt-secrets.sh', - oldScript, - ); - - harness.cwd.writeFile( - 'sonar-project.properties', - [`sonar.host.url=${serverUrl}`, 'sonar.projectKey=my-project'].join('\n'), - ); - - const result = await harness.run('integrate claude -g --non-interactive'); - - expect(result.exitCode).toBe(0); - - const state = harness.stateJsonFile.asJson(); - const extensions = state.agentExtensions as Array<{ - name: string; - hookType: string; - global: boolean; - projectRoot: string; - }>; - - // Global sonar-secrets hooks MUST be in agentExtensions - const globalSecretsHooks = extensions.filter( - (e) => e.name === 'sonar-secrets' && e.global === true, - ); - expect(globalSecretsHooks.length).toBeGreaterThan(0); - expect(globalSecretsHooks.some((h) => h.hookType === 'PreToolUse')).toBe(true); - expect(globalSecretsHooks.some((h) => h.hookType === 'UserPromptSubmit')).toBe(true); - }, - { timeout: 30000 }, - ); - }, -); - // ─── Post-update migration ───────────────────────────────────────────────────── -describe.skipIf(IS_WINDOWS)('post-update migration — hook script rewrite on CLI upgrade', () => { +describe.skipIf(IS_WINDOWS)('post-update migration on CLI upgrade', () => { let harness: TestHarness; beforeEach(async () => { @@ -1648,6 +1440,85 @@ describe.skipIf(IS_WINDOWS)('post-update migration — hook script rewrite on CL }, { timeout: 30000 }, ); + + it( + 'purges obsolete sonar-a3s entries from state.json on first run after CLI upgrade', + async () => { + const now = new Date().toISOString(); + // Old state: configured by v0.4.0 with sonar-a3s recorded in both the legacy + // hooks.installed list and the agentExtensions registry, alongside unrelated + // sonar-secrets entries that must survive the cleanup. + harness.state().withRawState( + JSON.stringify( + { + version: 1, + config: { cliVersion: '0.4.0' }, + auth: { isAuthenticated: false, connections: [], activeConnectionId: null }, + agents: { + 'claude-code': { + configured: true, + configuredByCliVersion: '0.4.0', + hooks: { + installed: [ + { name: 'sonar-a3s', type: 'PostToolUse', installedAt: now }, + { name: 'sonar-secrets', type: 'PreToolUse', installedAt: now }, + ], + }, + skills: { installed: [] }, + }, + }, + agentExtensions: [ + { + id: 'a3s-ext', + agentId: 'claude-code', + projectRoot: harness.cwd.path, + global: false, + kind: 'hook', + name: 'sonar-a3s', + hookType: 'PostToolUse', + updatedByCliVersion: '0.4.0', + updatedAt: now, + }, + { + id: 'secrets-ext', + agentId: 'claude-code', + projectRoot: harness.cwd.path, + global: false, + kind: 'hook', + name: 'sonar-secrets', + hookType: 'PreToolUse', + updatedByCliVersion: '0.4.0', + updatedAt: now, + }, + ], + tools: { installed: [] }, + telemetry: { enabled: false }, + }, + null, + 2, + ), + ); + + // Run any CLI command — post-update fires automatically when cliVersion < current + const result = await harness.run('--version'); + + expect(result.exitCode).toBe(0); + + const state = harness.stateJsonFile.asJson(); + const extensions = (state.agentExtensions ?? []) as Array<{ name: string }>; + const hooks = (state.agents?.['claude-code']?.hooks?.installed ?? []) as Array<{ + name: string; + }>; + + // sonar-a3s is purged from both the legacy list and the registry... + expect(hooks.some((h) => h.name === 'sonar-a3s')).toBe(false); + expect(extensions.some((e) => e.name === 'sonar-a3s')).toBe(false); + // ...while unrelated entries survive. + expect(hooks.some((h) => h.name === 'sonar-secrets')).toBe(true); + expect(extensions.some((e) => e.name === 'sonar-secrets')).toBe(true); + }, + { timeout: 30000 }, + ); }); describe('integrate — argument validation', () => { @@ -1848,7 +1719,7 @@ describe('integrate claude — hook migration scenarios', () => { ); it( - 'scenario C: running integrate twice is idempotent — no duplicate hook entries or agentExtensions', + 'scenario C: running integrate twice is idempotent — no duplicate hook entries or declarative features', async () => { const server = await harness.newFakeServer().withAuthToken('tok').withProject('p').start();