diff --git a/CLAUDE.md b/CLAUDE.md index 6cb8f716c..29c554317 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,6 +79,8 @@ SQAA is **project-scoped and opt-in**: integrate orchestrators call `resolveSqaa `sonar context [action] [args...]` is a passthrough to the locally-installed `sonar-context-augmentation` binary (CAG). It forwards args verbatim, propagates the child exit code, and injects context through `SONAR_CONTEXT_ORGANIZATION`, `SONAR_CONTEXT_PROJECT`, `SONAR_CONTEXT_TOKEN`, and `SONAR_CONTEXT_URL` env vars. Every CAG spawn (including the `--help` path and the integrate/post-update flows) also carries `SONAR_CONTEXT_INVOCATION_ID` — the per-CLI-process correlation id from `src/core/telemetry/invocation-id.ts`, shared with telemetry's `invocation_id` and forwarded by CAG to its daemon as the `x-sonar-invocation-id` header. The passthrough resolves project context from the recorded declarative CAG feature state for the current project rather than running full project auto-discovery. This lookup is git-worktree-aware so you can `sonar integrate` once and use `sonar context` from any worktree (including ones created later, and after the integrate-time worktree is removed): integrate records a stable `attrs.repoRoot` = the repository's **main working tree** (resolved via `resolveRecordedRepoRoot` in `src/core/host/git-worktree.ts`, which reads `git worktree list --porcelain`) while keeping `targetRoot` = the physical install dir for teardown; the passthrough matches the current directory (mapped to its main-worktree equivalent via `resolveWorktreeEquivalentPaths`) against `attrs.repoRoot ?? targetRoot`, falling back to `targetRoot` for state written by older CLI versions. When a recorded integration matches, the passthrough sets `SONAR_CONTEXT_WORKSPACE_ROOT` via `resolveContextWorkspaceRoot`: the **git working-tree root** containing the invocation when inside a repository (climbing up from subdirectories, including linked worktrees), or the feature's physical `targetRoot` when not in a git repo, so CAG can locate or lazily create a per-workspace daemon folder; org/project/server metadata still come from the recorded integration; it is left unset (and any inherited value dropped) when no integration matches. Implementation in `src/commands/context/`. The binary is downloaded by `sonar integrate claude` / `sonar integrate copilot` / `sonar integrate codex` / `sonar integrate antigravity` / `sonar integrate cursor` (skip with `--skip-context`); `sonar context` itself never auto-installs and emits a clear "not installed" error pointing the user back to integrate. `--global` integrations also skip CAG setup; install it by re-running `sonar integrate ` from a project directory. The `--global` skip notice (`Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.`) is only emitted when the org is actually entitled to CAG — unentitled orgs skip silently. The CLI owns the agent skill file declaratively as a `wholeFile` resource inside a single CAG feature and renders it by calling `sonar-context-augmentation tool print-skill --invocation-prefix "sonar context" --sca-enabled=`, forwarding the recorded organization as `SONAR_CONTEXT_ORGANIZATION` (from the feature's `attrs.orgKey`, best-effort — omitted when unrecorded) so CAG's org-gated internal dogfooding tools are rendered into the skill for allowlisted organizations; project/URL/token are not passed to `print-skill`. The rendered file is written to `.claude/skills/sonar-context-augmentation/SKILL.md`, `.github/skills/sonar-context-augmentation/SKILL.md`, or `.agents/skills/sonar-context-augmentation/SKILL.md` depending on the agent. **Cursor** also writes to `.agents/skills/sonar-context-augmentation/SKILL.md` — the shared cross-tool skills directory it reads alongside Codex and Antigravity — rather than a Cursor-private `.cursor/skills` copy, so the three tools share one skill instead of duplicating it. Cursor loads skills on demand (distinct from Cursor's SQAA delivery, which is an always-applied `.cursor/rules/*.mdc` rule because that protocol must run every turn). That same feature also declares a `tool integrate --invocation-prefix "sonar context"` operation, but the operation is install-only: the framework passes `executionMode=install|update` into feature contexts, and CAG uses `shouldApply` so `tool integrate` runs during `sonar integrate ` but not during post-update refreshes. Post-update still reinstalls the shared dependency, best-effort runs `sonar-context-augmentation tool stop --all` against the previously-installed binary before replacing it, and refreshes the skill file. Replay failures are debug-logged so they do not abort CLI startup. +For Claude Code project installs, that same CAG feature also writes a generated launcher under `.claude/hooks/sonar-context-augmentation/build-scripts/` and patches `.claude/settings.json` with `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` entries using matcher `Bash|PowerShell|Monitor|Read`. The launcher delegates to `sonar context __hook Claude`, uses `sonar-context-augmentation` as the managed marker for idempotency/removal, coexists with the SQAA `PostToolUse` entry, and is refreshed on post-update like the skill file. CAG distinguishes the concrete Claude hook event from the stdin payload. + `--help`, `-h`, and bare `sonar context` (no action) are forwarded to CAG. Before installing, `sonar integrate claude|copilot|codex|antigravity` pre-flights the CAG entitlement check: `SonarQubeClient.hasCagEntitlement(orgKey)` resolves the org UUID via `/organizations/organizations` then calls `GET /cag/cag-entitlement/{uuid}` (SonarQube Cloud only). The CLI gates on `hasEntitlement` (pure addon entitlement, independent of consumption); CAG setup proceeds for entitled orgs even when the consumption limit is reached — only tool invocations are blocked at runtime by the CAG daemon itself. If `hasEntitlement` is absent or false, CAG setup is skipped with a warning. Any error in the check is treated as "not entitled". The `sonar context` passthrough is not gated — CAG itself enforces entitlement per-request. diff --git a/src/commands/integrate/_common/features/context-augmentation-feature.ts b/src/commands/integrate/_common/features/context-augmentation-feature.ts index f5469974b..01c7817d6 100644 --- a/src/commands/integrate/_common/features/context-augmentation-feature.ts +++ b/src/commands/integrate/_common/features/context-augmentation-feature.ts @@ -29,7 +29,7 @@ import { CONTEXT_AUGMENTATION_FEATURE_PREVIEW, } from '../feature-constants.ts'; import { contextAugmentationBinaryDependency } from '../registry/dependencies'; -import { wholeFile } from '../registry/resources'; +import { type ResourceDeclaration, wholeFile } from '../registry/resources'; import { askUser, skip } from '../registry/selection.ts'; import type { FeatureDeclaration, IntegrationContext } from '../registry/types.ts'; @@ -40,6 +40,7 @@ export const CONTEXT_AUGMENTATION_TOOL_INTEGRATION_OPERATION_ID = export interface ContextAugmentationSkillFeatureOptions { targetPath: (context: IntegrationContext) => string; + resources?: ResourceDeclaration[]; } export function createContextAugmentationFeature< @@ -67,6 +68,7 @@ export function createContextAugmentationFeature< orgKey: getOptionalStringAttr(context, 'orgKey'), }), }), + ...(options.resources ?? []), ], operations: [ { diff --git a/src/commands/integrate/claude/declaration.ts b/src/commands/integrate/claude/declaration.ts index 1970b6915..82f359d78 100644 --- a/src/commands/integrate/claude/declaration.ts +++ b/src/commands/integrate/claude/declaration.ts @@ -53,6 +53,8 @@ import { askUser, jsonPatch, skip, textSnippet, wholeFile } from '../_common/reg import { SQAA_HOOK_FEATURE_ID } from '../_common/sqaa-entitlement.ts'; import type { IntegrateAgentOptions } from '../_common/types.ts'; import { + getContextAugmentationHookTemplateUnix, + getContextAugmentationHookTemplateWindows, getSecretPreToolTemplateUnix, getSecretPreToolTemplateWindows, getSecretPromptTemplateUnix, @@ -66,6 +68,9 @@ const SETTINGS_FILE = 'settings.json'; const CLAUDE_MD_FILE = 'CLAUDE.md'; const PRETOOL_SCRIPT_REL = 'sonar-secrets/build-scripts/pretool-secrets'; const PROMPT_SCRIPT_REL = 'sonar-secrets/build-scripts/prompt-secrets'; +const CONTEXT_HOOK_SCRIPT_REL = + 'sonar-context-augmentation/build-scripts/context-augmentation-hook'; +const CONTEXT_TOOL_MATCHER = 'Bash|PowerShell|Monitor|Read'; export const CLAUDE_INTEGRATION_ID = 'claude-code'; @@ -222,6 +227,53 @@ export const claudeIntegration: IntegrationDeclaration }, createContextAugmentationFeature({ targetPath: resolveClaudeSkillPath, + resources: [ + wholeFile({ + id: 'context-augmentation-hook-script', + displayName: 'Claude Context Augmentation hook script', + targetPath: (context) => + resolveAgentHookScriptPath(context, CLAUDE_CONFIG_DIR, CONTEXT_HOOK_SCRIPT_REL), + content: { + unix: getContextAugmentationHookTemplateUnix(), + windows: getContextAugmentationHookTemplateWindows(), + }, + executable: true, + }), + jsonPatch({ + id: 'claude-settings-context-augmentation-hook', + displayName: 'Claude Context Augmentation hook configuration', + targetPath: resolveClaudeSettingsPath, + defaultValue: { hooks: {} }, + patch: (document, context) => + upsertAgentHooks(document, [ + createAgentHookEntry( + context, + CLAUDE_CONFIG_DIR, + 'PreToolUse', + CONTEXT_TOOL_MATCHER, + 'sonar-context-augmentation', + CONTEXT_HOOK_SCRIPT_REL, + ), + createAgentHookEntry( + context, + CLAUDE_CONFIG_DIR, + 'PostToolUse', + CONTEXT_TOOL_MATCHER, + 'sonar-context-augmentation', + CONTEXT_HOOK_SCRIPT_REL, + ), + createAgentHookEntry( + context, + CLAUDE_CONFIG_DIR, + 'PostToolUseFailure', + CONTEXT_TOOL_MATCHER, + 'sonar-context-augmentation', + CONTEXT_HOOK_SCRIPT_REL, + ), + ]), + removePatch: (document) => removeAgentHooks(document, ['sonar-context-augmentation']), + }), + ], }), ], }; diff --git a/src/commands/integrate/claude/hook-templates.ts b/src/commands/integrate/claude/hook-templates.ts index 839fa09f2..f60e08911 100644 --- a/src/commands/integrate/claude/hook-templates.ts +++ b/src/commands/integrate/claude/hook-templates.ts @@ -43,6 +43,14 @@ export function getSecretPromptTemplateWindows(): string { return windowsTemplate('sonar hook claude-prompt-submit'); } +export function getContextAugmentationHookTemplateUnix(): string { + return unixTemplate('sonar context __hook Claude'); +} + +export function getContextAugmentationHookTemplateWindows(): string { + return windowsTemplate('sonar context __hook Claude'); +} + export function getSqaaPostToolTemplateUnix(projectKey: string): string { return unixTemplate(formatSqaaPostToolHookCommandUnix('claude-post-tool-use', projectKey)); } diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 336c7dd67..51a0a5419 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -36,7 +36,7 @@ import { SONAR_CONTEXT_AUGMENTATION_VERSION } from '@/core/host/signatures.ts'; import { pathComparisonKey } from '@/lib/fs-utils.js'; import type { CliState, InstalledIntegrationFeature } from '@/lib/state.js'; -import { TestHarness } from '../../harness'; +import { hookScriptName, TestHarness } from '../../harness'; import { type CagInvocation, readCagInvocations as readInvocations, @@ -135,6 +135,85 @@ function expectSkillFile(harness: TestHarness, relativePath: string, scaEnabled: ); } +interface ClaudeHookCommand { + type?: string; + command?: string; + timeout?: number; +} + +interface ClaudeHookEntry { + matcher?: string; + hooks?: ClaudeHookCommand[]; +} + +interface ClaudeSettings { + hooks?: { + PreToolUse?: ClaudeHookEntry[]; + PostToolUse?: ClaudeHookEntry[]; + PostToolUseFailure?: ClaudeHookEntry[]; + }; +} + +type HarnessRoot = 'cwd' | 'userHome'; + +function readClaudeSettings(harness: TestHarness, root: HarnessRoot): ClaudeSettings | undefined { + const file = harness[root].file('.claude', 'settings.json'); + if (!file.exists()) { + return undefined; + } + return file.asJson() as ClaudeSettings; +} + +function claudeCagHookEntries( + settings: ClaudeSettings | undefined, + eventType: keyof NonNullable, +): ClaudeHookEntry[] { + return (settings?.hooks?.[eventType] ?? []).filter((entry) => + entry.hooks?.some((hook) => hook.command?.includes(CLAUDE_CAG_HOOK_MARKER)), + ); +} + +function claudeCagPostToolEntries(settings: ClaudeSettings | undefined): ClaudeHookEntry[] { + return claudeCagHookEntries(settings, 'PostToolUse'); +} + +function claudeSqaaPostToolEntries(settings: ClaudeSettings | undefined): ClaudeHookEntry[] { + return (settings?.hooks?.PostToolUse ?? []).filter((entry) => + entry.hooks?.some((hook) => hook.command?.includes('sonar-sqaa')), + ); +} + +function expectClaudeCagHookInstalled(harness: TestHarness): void { + const script = harness.cwd.file(CLAUDE_CAG_HOOK_SCRIPT_PATH); + expect(script.exists()).toBe(true); + expect(script.asText()).toContain('sonar context __hook Claude'); + expect(script.asText()).not.toContain('ClaudePostToolUse'); + + const settings = readClaudeSettings(harness, 'cwd'); + for (const eventType of CLAUDE_CAG_EVENT_TYPES) { + const entries = claudeCagHookEntries(settings, eventType); + expect(entries).toHaveLength(1); + expect(entries[0]?.matcher).toBe(CLAUDE_CAG_HOOK_MATCHER); + expect(entries[0]?.hooks?.[0]?.type).toBe('command'); + expect(entries[0]?.hooks?.[0]?.command).toContain(CLAUDE_CAG_HOOK_MARKER); + expect(entries[0]?.hooks?.[0]?.timeout).toBe(60); + } +} + +function expectClaudeCagHookAbsent(harness: TestHarness): void { + for (const root of ['cwd', 'userHome'] as const) { + expect(harness[root].file(CLAUDE_CAG_HOOK_SCRIPT_PATH).exists()).toBe(false); + const settings = readClaudeSettings(harness, root); + for (const eventType of CLAUDE_CAG_EVENT_TYPES) { + expect(claudeCagHookEntries(settings, eventType)).toHaveLength(0); + } + } +} + +function markHarnessCwdAsGitRoot(harness: TestHarness): void { + harness.cwd.writeFile('.git/HEAD', 'ref: refs/heads/main\n'); +} + const PROJECT_KEY = 'my-project'; const ORG_KEY = 'my-org'; const TOKEN = 'cloud-token'; @@ -147,6 +226,10 @@ const DOGFOODING_SKILL_MARKER = '## Dogfooding Tools'; const CLAUDE_SKILL_PATH = '.claude/skills/sonar-context-augmentation/SKILL.md'; const COPILOT_SKILL_PATH = '.github/skills/sonar-context-augmentation/SKILL.md'; const CODEX_SKILL_PATH = '.agents/skills/sonar-context-augmentation/SKILL.md'; +const CLAUDE_CAG_HOOK_MARKER = 'sonar-context-augmentation'; +const CLAUDE_CAG_HOOK_MATCHER = 'Bash|PowerShell|Monitor|Read'; +const CLAUDE_CAG_HOOK_SCRIPT_PATH = `.claude/hooks/sonar-context-augmentation/build-scripts/${hookScriptName('context-augmentation-hook')}`; +const CLAUDE_CAG_EVENT_TYPES = ['PreToolUse', 'PostToolUse', 'PostToolUseFailure'] as const; const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; describe('integrate claude — Context Augmentation', () => { @@ -154,6 +237,7 @@ describe('integrate claude — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); await harness.newFakeBinariesServer().start(); }); @@ -217,6 +301,7 @@ describe('integrate claude — Context Augmentation', () => { `✓ sonar-context-augmentation ${SONAR_CONTEXT_AUGMENTATION_VERSION}`, ); expectSkillFile(harness, CLAUDE_SKILL_PATH, true); + expectClaudeCagHookInstalled(harness); // A non-allowlisted org yields no dogfooding tools section in the rendered skill. expect(harness.cwd.file(CLAUDE_SKILL_PATH).asText()).not.toContain(DOGFOODING_SKILL_MARKER); @@ -232,6 +317,100 @@ describe('integrate claude — Context Augmentation', () => { { timeout: 30000 }, ); + it( + 'does not duplicate the Claude Context Augmentation hook and preserves unrelated PostToolUse entries', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(TOKEN) + .withProject(PROJECT_KEY) + .withCagEntitlement(ORG_KEY) + .withScaEnabled(true) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, TOKEN, ORG_KEY); + harness.state().withContextAugmentationBinaryInstalled(); + harness.cwd.writeFile( + 'sonar-project.properties', + [ + `sonar.host.url=${serverUrl}`, + `sonar.projectKey=${PROJECT_KEY}`, + `sonar.organization=${ORG_KEY}`, + ].join('\n'), + ); + harness.cwd.writeFile( + '.claude/settings.json', + JSON.stringify({ + hooks: { + PostToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo user-hook', timeout: 15 }], + }, + ], + }, + }), + ); + + const env = { + SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, + SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, + }; + expect( + (await harness.run('integrate claude --non-interactive', { extraEnv: env })).exitCode, + ).toBe(0); + expect( + (await harness.run('integrate claude --non-interactive', { extraEnv: env })).exitCode, + ).toBe(0); + + const settings = readClaudeSettings(harness, 'cwd'); + expectClaudeCagHookInstalled(harness); + expect(claudeCagHookEntries(settings, 'PreToolUse')).toHaveLength(1); + expect(claudeCagPostToolEntries(settings)).toHaveLength(1); + expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(1); + expect(settings?.hooks?.PostToolUse?.some((entry) => entry.matcher === 'Bash')).toBe(true); + }, + { timeout: 30000 }, + ); + + it( + 'coexists with the Claude SQAA PostToolUse hook', + async () => { + const entitlementUuid = 'test-uuid-1234'; + const server = await harness + .newFakeServer() + .withAuthToken(TOKEN) + .withProject(PROJECT_KEY) + .withCagEntitlement(ORG_KEY, { uuid: entitlementUuid }) + .withSqaaEntitlement(ORG_KEY, entitlementUuid) + .withScaEnabled(true) + .start(); + const serverUrl = server.baseUrl(); + harness.withAuth(serverUrl, TOKEN, ORG_KEY); + harness.state().withContextAugmentationBinaryInstalled(); + + const result = await harness.run( + `integrate claude --project ${PROJECT_KEY} --non-interactive`, + { + extraEnv: { + SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, + SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, + }, + }, + ); + + expect(result.exitCode).toBe(0); + const settings = readClaudeSettings(harness, 'cwd'); + expectClaudeCagHookInstalled(harness); + expect(claudeSqaaPostToolEntries(settings)).toHaveLength(1); + expect(claudeSqaaPostToolEntries(settings)[0]?.matcher).toBe('Edit|Write'); + expect(claudeCagHookEntries(settings, 'PreToolUse')).toHaveLength(1); + expect(claudeCagPostToolEntries(settings)).toHaveLength(1); + expect(claudeCagHookEntries(settings, 'PostToolUseFailure')).toHaveLength(1); + }, + { timeout: 30000 }, + ); + it( 'renders internal dogfooding tools in the skill for an allowlisted organization', async () => { @@ -365,6 +544,7 @@ describe('integrate claude — Context Augmentation', () => { expect(integrate.argv).toEqual(['tool', 'integrate', '--invocation-prefix', 'sonar context']); expect(result.stderr).toContain('Could not verify SCA availability'); expectSkillFile(harness, CLAUDE_SKILL_PATH, false); + expectClaudeCagHookInstalled(harness); const state = loadState(harness); expectRecordedCagFeature(state, { integrationId: CLAUDE_INTEGRATION_ID, @@ -410,6 +590,7 @@ describe('integrate claude — Context Augmentation', () => { 'sonar-context-augmentation tool print-skill produced empty output', ); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); }, { timeout: 30000 }, ); @@ -441,6 +622,7 @@ describe('integrate claude — Context Augmentation', () => { const nonProbe = invocations.filter((i) => i.argv[0] !== '--version'); expect(nonProbe).toEqual([]); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); }, @@ -481,6 +663,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain('not available for your organization'); }, { timeout: 30000 }, @@ -557,6 +740,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain('could not verify entitlement'); }, { timeout: 30000 }, @@ -717,6 +901,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expectSkillFile(harness, CLAUDE_SKILL_PATH, false); + expectClaudeCagHookInstalled(harness); expect(result.stderr).toContain('Vortex context augmentation tool integration failed.'); }, { timeout: 30000 }, @@ -748,6 +933,7 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain('a project key and organization are required'); }, { timeout: 30000 }, @@ -777,6 +963,7 @@ describe('integrate claude — Context Augmentation', () => { const nonProbe = readInvocations(harness).filter((i) => i.argv[0] !== '--version'); expect(nonProbe).toEqual([]); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); // "not available on SonarQube Server" info line must appear, not the // misleading "organization required" warning expect(result.stdout + result.stderr).toContain('not available on SonarQube Server'); @@ -791,6 +978,7 @@ describe('integrate copilot — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); harness.state().withSecretsBinaryInstalled(); }); @@ -843,6 +1031,7 @@ describe('integrate copilot — Context Augmentation', () => { expectContextEnv(integrate, serverUrl); expect(result.stdout).not.toContain('Running: sonar-context-augmentation'); expectSkillFile(harness, COPILOT_SKILL_PATH, false); + expectClaudeCagHookAbsent(harness); // State records the declarative feature under the Copilot integration. const state = loadState(harness); @@ -862,6 +1051,7 @@ describe('integrate codex — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); harness.state().withSecretsBinaryInstalled(); }); @@ -914,6 +1104,7 @@ describe('integrate codex — Context Augmentation', () => { expectContextEnv(integrate, serverUrl); expect(result.stdout).not.toContain('Running: sonar-context-augmentation'); expectSkillFile(harness, CODEX_SKILL_PATH, false); + expectClaudeCagHookAbsent(harness); const state = loadState(harness); expectRecordedCagFeature(state, { @@ -1036,6 +1227,7 @@ describe('integrate --global — Context Augmentation', () => { beforeEach(async () => { harness = await TestHarness.create(); + markHarnessCwdAsGitRoot(harness); harness.state().withSecretsBinaryInstalled(); }); @@ -1073,6 +1265,7 @@ describe('integrate --global — Context Augmentation', () => { expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); expect(harness.cwd.file(COPILOT_SKILL_PATH).exists()).toBe(false); expect(harness.cwd.file(CODEX_SKILL_PATH).exists()).toBe(false); + expectClaudeCagHookAbsent(harness); expect(result.stderr).toContain( 'Skipping Vortex context augmentation: not supported with --global', ); @@ -1104,6 +1297,7 @@ describe('integrate --global — Context Augmentation', () => { expect(nonProbe).toEqual([]); const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); + expectClaudeCagHookAbsent(harness); expect(`${result.stdout}\n${result.stderr}`).not.toContain( 'Skipping Vortex context augmentation: not supported with --global', ); diff --git a/tests/unit/commands/integrate/claude/hook-templates.test.ts b/tests/unit/commands/integrate/claude/hook-templates.test.ts index 4a222a406..109118a5c 100644 --- a/tests/unit/commands/integrate/claude/hook-templates.test.ts +++ b/tests/unit/commands/integrate/claude/hook-templates.test.ts @@ -27,6 +27,8 @@ import { WINDOWS_SONAR_COMMAND_GUARD, } from '../../../../../src/commands/integrate/_common/hooks.ts'; import { + getContextAugmentationHookTemplateUnix, + getContextAugmentationHookTemplateWindows, getSecretPreToolTemplateUnix, getSecretPreToolTemplateWindows, getSecretPromptTemplateUnix, @@ -82,6 +84,25 @@ describe('Secret Scanning Hook Templates', () => { }); }); +describe('Context Augmentation Hook Templates', () => { + it('Unix hook: bash shebang, delegates to sonar context hook', () => { + const template = getContextAugmentationHookTemplateUnix(); + + expect(template.startsWith('#!/bin/bash')).toBe(true); + expect(template.includes('sonar context __hook Claude')).toBe(true); + expect(template.includes('ClaudePostToolUse')).toBe(false); + expect(template.includes(UNIX_SONAR_COMMAND_GUARD)).toBe(true); + }); + + it('Windows hook: delegates to sonar context hook', () => { + const template = getContextAugmentationHookTemplateWindows(); + + expect(template.includes('sonar context __hook Claude')).toBe(true); + expect(template.includes('ClaudePostToolUse')).toBe(false); + expect(template.includes(WINDOWS_SONAR_COMMAND_GUARD)).toBe(true); + }); +}); + describe('SQAA PostToolUse Hook Templates', () => { it('PostTool Unix hook: bash shebang, delegates to sonar hook with project key', () => { const template = getSqaaPostToolTemplateUnix('my-project'); @@ -117,12 +138,14 @@ describe('SQAA PostToolUse Hook Templates', () => { }); describe('Template Integrity', () => { - it('All 6 templates are valid non-empty strings with distinct content', () => { + it('All 8 templates are valid non-empty strings with distinct content', () => { const templates = [ getSecretPreToolTemplateUnix(), getSecretPreToolTemplateWindows(), getSecretPromptTemplateUnix(), getSecretPromptTemplateWindows(), + getContextAugmentationHookTemplateUnix(), + getContextAugmentationHookTemplateWindows(), getSqaaPostToolTemplateUnix('proj'), getSqaaPostToolTemplateWindows('proj'), ]; @@ -144,6 +167,8 @@ describe('Template Integrity', () => { getSecretPreToolTemplateWindows(), getSecretPromptTemplateUnix(), getSecretPromptTemplateWindows(), + getContextAugmentationHookTemplateUnix(), + getContextAugmentationHookTemplateWindows(), getSqaaPostToolTemplateUnix('proj'), getSqaaPostToolTemplateWindows('proj'), ]; @@ -159,5 +184,13 @@ describe('Template Integrity', () => { expect(getSecretPreToolTemplateUnix().includes('claude-post-tool-use')).toBe(false); expect(getSecretPromptTemplateUnix().includes('claude-post-tool-use')).toBe(false); + expect(getContextAugmentationHookTemplateUnix().includes('claude-post-tool-use')).toBe(false); + }); + + it('Context Augmentation template routes to the double-underscore CAG hook', () => { + expect(getContextAugmentationHookTemplateUnix().includes('sonar context __hook Claude')).toBe( + true, + ); + expect(getContextAugmentationHookTemplateUnix().includes('sonar context _hook')).toBe(false); }); });