diff --git a/CLAUDE.md b/CLAUDE.md index d37635111..6390a16ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,7 @@ Like the hook-based agents, SQAA is **project-scoped and opt-in**: integrate orc `--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 /a3s-analysis/cag-org-config/{uuid}` (SonarQube Cloud only). If `eligible && enabled` is false, CAG setup is skipped with a warning (cloud) or a plain info line (SonarQube Server). Any error in the check is treated as "not entitled". The `sonar context` passthrough is not gated — CAG itself enforces entitlement per-request. +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 /a3s-analysis/cag-entitlement/{uuid}` (SonarQube Cloud only). If `allowed` is false, CAG setup is skipped with a warning (cloud) or a plain info line (SonarQube Server). Any error in the check is treated as "not entitled". The `sonar context` passthrough is not gated — CAG itself enforces entitlement per-request. After the CAG entitlement check passes, the integrate flow also queries SCA availability via `SonarQubeClient.getScaEnablement(connectionType, orgKey)` (`/sca/feature-enabled` on cloud, `/api/v2/sca/feature-enabled` on SonarQube Server). The resolved boolean is passed to `sonar-context-augmentation tool print-skill` as `--sca-enabled=true|false`, and is persisted on declarative feature attrs together with the recorded `serverUrl`, `orgKey`, and `projectKey` so `sonar context` and post-update can reuse the same connection metadata without re-querying the server. A check failure (network/404) emits a warn line and proceeds with `--sca-enabled=false`. diff --git a/src/cli/commands/integrate/_common/context-augmentation.ts b/src/cli/commands/integrate/_common/context-augmentation.ts index 09307d221..0d1ee6278 100644 --- a/src/cli/commands/integrate/_common/context-augmentation.ts +++ b/src/cli/commands/integrate/_common/context-augmentation.ts @@ -107,7 +107,7 @@ export async function resolveContextAugmentationSetup( // that could not use CAG anyway). if (p.auth.orgKey) { const client = new SonarQubeClient(p.auth.serverUrl, p.auth.token); - if ((await client.hasCagEntitlement(p.auth.orgKey)) === 'enabled') { + if ((await client.hasCagEntitlement(p.auth.orgKey)) === 'allowed') { warn( 'Skipping Context Augmentation: not supported with --global. Re-run without --global from a project directory to install it there.', ); @@ -131,9 +131,9 @@ export async function resolveContextAugmentationSetup( ); return null; } - if (entitlement === 'not_enabled') { + if (entitlement === 'not_allowed') { warn( - 'Skipping Context Augmentation: not enabled for your organization. Enable it in your SonarQube Cloud organization settings.', + 'Skipping Context Augmentation: not available for your organization. Access requires an eligible SonarQube Cloud plan.', ); return null; } diff --git a/src/sonarqube/client.ts b/src/sonarqube/client.ts index 3f45c79dd..9a20f1f9f 100644 --- a/src/sonarqube/client.ts +++ b/src/sonarqube/client.ts @@ -50,7 +50,7 @@ export const GENERIC_HTTP_METHODS = ['GET', 'POST', 'PATCH', 'PUT', 'DELETE'] as export const METHODS_WITH_BODY = new Set(['POST', 'PATCH', 'PUT']); export type HttpMethod = (typeof GENERIC_HTTP_METHODS)[number]; -export type CagEntitlementStatus = 'enabled' | 'not_enabled' | 'check_failed'; +export type CagEntitlementStatus = 'allowed' | 'not_allowed' | 'check_failed'; export type SqaaEntitlementStatus = 'enabled' | 'not_enabled' | 'check_failed'; @@ -400,13 +400,13 @@ export class SonarQubeClient { async checkCagEntitlement(organizationUuid: string): Promise { try { - const endpoint = `/a3s-analysis/cag-org-config/${organizationUuid}`; - const result = await this.get<{ id: string; enabled: boolean; eligible: boolean }>( + const endpoint = `/a3s-analysis/cag-entitlement/${organizationUuid}`; + const result = await this.get<{ allowed: boolean; consumption?: object }>( endpoint, undefined, resolveFromEndpoint(this.serverURL, endpoint), ); - return result.eligible && result.enabled ? 'enabled' : 'not_enabled'; + return result.allowed ? 'allowed' : 'not_allowed'; } catch { return 'check_failed'; } @@ -414,7 +414,7 @@ export class SonarQubeClient { async hasCagEntitlement(organizationKey?: string): Promise { if (!organizationKey || !isSonarQubeCloud(this.serverURL)) { - return 'not_enabled'; + return 'not_allowed'; } const uuid = await this.getOrganizationId(organizationKey); if (!uuid) { diff --git a/tests/e2e/context/cag-integrate.test.ts b/tests/e2e/context/cag-integrate.test.ts index 348526aed..315340e70 100644 --- a/tests/e2e/context/cag-integrate.test.ts +++ b/tests/e2e/context/cag-integrate.test.ts @@ -24,7 +24,7 @@ * the real CAG binary at all on connections where CAG must be skipped: * * - SonarQube Server (non-Cloud) connections. - * - Cloud connections where the org's CAG entitlement is disabled. + * - Cloud connections where the org is not allowed to use CAG. * * Both paths must exit before `installContextAugmentationBinary()` runs, so * we assert no binary lands under `/bin/` and no declarative CAG @@ -103,12 +103,12 @@ describe('sonar integrate — CAG pre-flight skip paths (real CLI, fake expect(findRecordedCagFeature(harness.stateJsonFile.asJson() as CliState)).toBeUndefined(); }); - it('skips CAG when entitlement is not enabled on the Cloud org', async () => { + it('skips CAG when the Cloud org is not allowed to use it', async () => { const server = await harness .newFakeServer() .withAuthToken(TOKEN) .withProject(PROJECT_KEY) - .withCagEntitlement(ORG_KEY, { enabled: false }) + .withCagEntitlement(ORG_KEY, { allowed: false }) .start(); const serverUrl = server.baseUrl(); harness.withAuth(serverUrl, TOKEN, ORG_KEY); @@ -132,9 +132,9 @@ describe('sonar integrate — CAG pre-flight skip paths (real CLI, fake expect(result.exitCode, result.stderr).toBe(0); expect(result.stderr).toContain( - 'Skipping Context Augmentation: not enabled for your organization. Enable it in your SonarQube Cloud organization settings.', + 'Skipping Context Augmentation: not available for your organization. Access requires an eligible SonarQube Cloud plan.', ); - expect(existsSync(cagBinaryPath), 'no CAG download when entitlement is disabled').toBe(false); + expect(existsSync(cagBinaryPath), 'no CAG download when access is denied').toBe(false); expect(findRecordedCagFeature(harness.stateJsonFile.asJson() as CliState)).toBeUndefined(); }); diff --git a/tests/integration/harness/fake-sonarqube-server.test.ts b/tests/integration/harness/fake-sonarqube-server.test.ts index 68ed0f63f..2401869de 100644 --- a/tests/integration/harness/fake-sonarqube-server.test.ts +++ b/tests/integration/harness/fake-sonarqube-server.test.ts @@ -32,19 +32,7 @@ describe('FakeSonarQubeServer — UUID consistency', () => { await server?.stop(); }); - it('returns cag-org-config 200 when org has both SQAA and CAG entitlement with a custom UUID', async () => { - // This test demonstrates the UUID mismatch bug: - // - // withSqaaEntitlement stores an explicit UUID ('test-uuid-1234'). - // withCagEntitlement stores no UUID — the cag-org-config lookup derives it - // as `${orgKey}-uuid-v4`. - // - // When CAG calls /organizations/organizations (no param), the no-param - // branch synthesises org entries taking the UUID from SQAA ('test-uuid-1234'). - // CAG then calls /a3s-analysis/cag-org-config/test-uuid-1234, but the handler - // looks for an org where `${orgKey}-uuid-v4 === 'test-uuid-1234'`, which never - // matches — so it returns 404 and CAG treats itself as not entitled. - + it('returns cag-entitlement 200 when org has both SQAA and CAG entitlement', async () => { server = await new FakeSonarQubeServerBuilder() .withSqaaEntitlement('my-org', 'test-uuid-1234') .withCagEntitlement('my-org') @@ -61,17 +49,12 @@ describe('FakeSonarQubeServer — UUID consistency', () => { const uuid = orgs[0].uuidV4; - // Step 2: fetch cag-org-config using the UUID CAG received from step 1. - const configResp = await fetch(`${base}/a3s-analysis/cag-org-config/${uuid}`); + // Step 2: fetch CAG entitlement using the UUID CAG received from step 1. + const configResp = await fetch(`${base}/a3s-analysis/cag-entitlement/${uuid}`); - // Expected: 200 with eligible/enabled from withCagEntitlement. - // Actual (bug): 404 because the handler derives UUID as `${orgKey}-uuid-v4` - // but the UUID returned by /organizations/organizations is 'test-uuid-1234' - // (taken from SQAA), not 'my-org-uuid-v4'. expect(configResp.status).toBe(HTTP_OK); - const config = (await configResp.json()) as { eligible: boolean; enabled: boolean }; - expect(config.eligible).toBe(true); - expect(config.enabled).toBe(true); + const config = (await configResp.json()) as { allowed: boolean }; + expect(config.allowed).toBe(true); }); }); diff --git a/tests/integration/harness/fake-sonarqube-server.ts b/tests/integration/harness/fake-sonarqube-server.ts index aa9b973ba..a5e5d3ebc 100644 --- a/tests/integration/harness/fake-sonarqube-server.ts +++ b/tests/integration/harness/fake-sonarqube-server.ts @@ -121,10 +121,7 @@ export class FakeSonarQubeServerBuilder { string, { uuid: string; eligible: boolean; enabled: boolean } > = new Map(); - private readonly cagEntitlementOrgs: Map< - string, - { uuid: string; eligible: boolean; enabled: boolean } - > = new Map(); + private readonly cagEntitlementOrgs: Map = new Map(); private validToken?: string; private systemStatusCode = 200; private systemVersion = '9.9.0.00001'; @@ -137,6 +134,8 @@ export class FakeSonarQubeServerBuilder { private sqaaStatusBody?: string; private sqaaPayloadLimit?: { maxRequestSize?: number; maxFiles?: number }; private scaEnabled?: boolean; + private cagEntitlementStatusCode?: number; + private cagEntitlementStatusBody?: string; private readonly projectSettings: Map = new Map(); private agentJobErrorCode?: number; private agentJobErrorMessage?: string; @@ -254,18 +253,24 @@ export class FakeSonarQubeServerBuilder { return this; } - withCagEntitlement( - orgKey: string, - options: { uuid?: string; eligible?: boolean; enabled?: boolean } = {}, - ): this { + withCagEntitlement(orgKey: string, options: { uuid?: string; allowed?: boolean } = {}): this { this.cagEntitlementOrgs.set(orgKey, { uuid: options.uuid ?? `${orgKey}-uuid-v4`, - eligible: options.eligible ?? true, - enabled: options.enabled ?? true, + allowed: options.allowed ?? true, }); return this; } + /** + * Force GET /a3s-analysis/cag-entitlement/{uuid} to return a specific HTTP + * status code. Useful for testing entitlement check failure paths. + */ + withCagEntitlementStatusCode(status: number, body?: string): this { + this.cagEntitlementStatusCode = status; + this.cagEntitlementStatusBody = body; + return this; + } + /** * Configure the response of the SCA availability endpoints * (`/sca/feature-enabled` for cloud, `/api/v2/sca/feature-enabled` for on-premise). @@ -305,6 +310,8 @@ export class FakeSonarQubeServerBuilder { sqaaEntitlementOrgs, cagEntitlementOrgs, scaEnabled, + cagEntitlementStatusCode, + cagEntitlementStatusBody, projectSettings, agentJobErrorCode, agentJobErrorMessage, @@ -557,11 +564,13 @@ export class FakeSonarQubeServerBuilder { }); } const orgKey = query.organizationKey; - const entitlement = orgKey ? sqaaEntitlementOrgs.get(orgKey) : undefined; - if (entitlement) { + const sqaaEntitlement = orgKey ? sqaaEntitlementOrgs.get(orgKey) : undefined; + const cagEntitlement = orgKey ? cagEntitlementOrgs.get(orgKey) : undefined; + const entitlementUuid = cagEntitlement?.uuid ?? sqaaEntitlement?.uuid; + if (orgKey && entitlementUuid) { return new Response( JSON.stringify([ - { id: `id-${orgKey}`, uuidV4: entitlement.uuid, key: orgKey, name: orgKey }, + { id: `id-${orgKey}`, uuidV4: entitlementUuid, key: orgKey, name: orgKey }, ]), { headers: { 'Content-Type': 'application/json' } }, ); @@ -577,9 +586,9 @@ export class FakeSonarQubeServerBuilder { { headers: { 'Content-Type': 'application/json' } }, ); } - // No organizationKey query param: sonar-context-augmentation calls - // /organizations/organizations with no params during open-beta - // entitlement resolution and expects a flat list of accessible orgs. + // No organizationKey query param: sonar-context-augmentation can call + // /organizations/organizations with no params during entitlement + // resolution and expects a flat list of accessible orgs. // Synthesize entries from the orgs that have CAG/SQAA entitlement // registered so the daemon can find a matching org by key. const knownOrgs = new Set([ @@ -653,9 +662,18 @@ export class FakeSonarQubeServerBuilder { ); } - const cagOrgConfigMatch = /^\/a3s-analysis\/cag-org-config\/(.+)$/.exec(path); - if (cagOrgConfigMatch) { - const uuid = cagOrgConfigMatch[1]; + const cagEntitlementMatch = /^\/a3s-analysis\/cag-entitlement\/(.+)$/.exec(path); + if (cagEntitlementMatch) { + if (cagEntitlementStatusCode !== undefined) { + return new Response( + cagEntitlementStatusBody ?? JSON.stringify({ errors: [{ msg: 'CAG failed' }] }), + { + status: cagEntitlementStatusCode, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + const uuid = cagEntitlementMatch[1]; const entitlement = [...cagEntitlementOrgs.values()].find((e) => e.uuid === uuid); if (!entitlement) { return new Response(JSON.stringify({ errors: [{ msg: 'Not found' }] }), { @@ -665,9 +683,7 @@ export class FakeSonarQubeServerBuilder { } return new Response( JSON.stringify({ - id: uuid, - eligible: entitlement.eligible, - enabled: entitlement.enabled, + allowed: entitlement.allowed, }), { headers: { 'Content-Type': 'application/json' } }, ); diff --git a/tests/integration/specs/integrate/context-augmentation.test.ts b/tests/integration/specs/integrate/context-augmentation.test.ts index 4d499fc5c..470bc3884 100644 --- a/tests/integration/specs/integrate/context-augmentation.test.ts +++ b/tests/integration/specs/integrate/context-augmentation.test.ts @@ -333,13 +333,13 @@ describe('integrate claude — Context Augmentation', () => { ); it( - 'skips CAG with a warning when the org does not have it enabled', + 'skips CAG with a warning when the org is not allowed to use it', async () => { const server = await harness .newFakeServer() .withAuthToken(TOKEN) .withProject(PROJECT_KEY) - .withCagEntitlement(ORG_KEY, { enabled: false }) + .withCagEntitlement(ORG_KEY, { allowed: false }) .start(); const serverUrl = server.baseUrl(); harness.withAuth(serverUrl, TOKEN, ORG_KEY); @@ -366,7 +366,46 @@ describe('integrate claude — Context Augmentation', () => { const state = loadState(harness); expect(findRecordedCagFeature(state)).toBeUndefined(); expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); - expect(result.stderr).toContain('not enabled for your organization'); + expect(result.stderr).toContain('not available for your organization'); + }, + { timeout: 30000 }, + ); + + it( + 'skips CAG with a warning when the entitlement check fails', + async () => { + const server = await harness + .newFakeServer() + .withAuthToken(TOKEN) + .withProject(PROJECT_KEY) + .withCagEntitlementStatusCode(500) + .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'), + ); + + const result = await harness.run('integrate claude --non-interactive', { + extraEnv: { + SONARQUBE_CLI_SONARCLOUD_URL: serverUrl, + SONARQUBE_CLI_SONARCLOUD_API_URL: serverUrl, + }, + }); + + expect(result.exitCode).toBe(0); + const nonProbe = readInvocations(harness).filter((i) => i.argv[0] !== '--version'); + expect(nonProbe).toEqual([]); + const state = loadState(harness); + expect(findRecordedCagFeature(state)).toBeUndefined(); + expect(harness.cwd.file(CLAUDE_SKILL_PATH).exists()).toBe(false); + expect(result.stderr).toContain('could not verify entitlement'); }, { timeout: 30000 }, ); @@ -774,13 +813,13 @@ describe('integrate codex — Context Augmentation', () => { ); it( - 'skips CAG with a warning when the org does not have it enabled', + 'skips CAG with a warning when the org is not allowed to use it', async () => { const server = await harness .newFakeServer() .withAuthToken(TOKEN) .withProject(PROJECT_KEY) - .withCagEntitlement(ORG_KEY, { enabled: false }) + .withCagEntitlement(ORG_KEY, { allowed: false }) .start(); const serverUrl = server.baseUrl(); harness.withAuth(serverUrl, TOKEN, ORG_KEY); @@ -805,7 +844,7 @@ describe('integrate codex — Context Augmentation', () => { const nonProbe = readInvocations(harness).filter((i) => i.argv[0] !== '--version'); expect(nonProbe).toEqual([]); expect(harness.cwd.file(CODEX_SKILL_PATH).exists()).toBe(false); - expect(result.stderr).toContain('not enabled for your organization'); + expect(result.stderr).toContain('not available for your organization'); }, { timeout: 30000 }, ); diff --git a/tests/unit/cli/commands/integrate/claude/integrate.test.ts b/tests/unit/cli/commands/integrate/claude/integrate.test.ts index c79c719aa..687825e1f 100644 --- a/tests/unit/cli/commands/integrate/claude/integrate.test.ts +++ b/tests/unit/cli/commands/integrate/claude/integrate.test.ts @@ -101,7 +101,7 @@ describe('integrateCommand', () => { hasSqaaEntitlementSpy = spyOn(SonarQubeClient.prototype, 'hasSqaaEntitlement'); hasSqaaEntitlementSpy.mockResolvedValue('not_enabled'); hasCagEntitlementSpy = spyOn(SonarQubeClient.prototype, 'hasCagEntitlement'); - hasCagEntitlementSpy.mockResolvedValue('enabled'); + hasCagEntitlementSpy.mockResolvedValue('allowed'); resolveContextAugmentationSetupSpy = spyOn( contextAugmentation, 'resolveContextAugmentationSetup', diff --git a/tests/unit/sonarqube/client.test.ts b/tests/unit/sonarqube/client.test.ts index cab2e9273..4f6c74107 100644 --- a/tests/unit/sonarqube/client.test.ts +++ b/tests/unit/sonarqube/client.test.ts @@ -817,22 +817,22 @@ describe('SonarQubeClient', () => { cloudClient = new SonarQubeClient(SONARCLOUD_URL, TOKEN); }); - it('returns not_enabled when organizationKey is not provided', async () => { + it('returns not_allowed when organizationKey is not provided', async () => { fetchSpy = mockFetch({}); - expect(await cloudClient.hasCagEntitlement(undefined)).toBe('not_enabled'); + expect(await cloudClient.hasCagEntitlement(undefined)).toBe('not_allowed'); expect(fetchSpy).not.toHaveBeenCalled(); }); - it('returns not_enabled when organizationKey is empty string', async () => { + it('returns not_allowed when organizationKey is empty string', async () => { fetchSpy = mockFetch({}); - expect(await cloudClient.hasCagEntitlement('')).toBe('not_enabled'); + expect(await cloudClient.hasCagEntitlement('')).toBe('not_allowed'); expect(fetchSpy).not.toHaveBeenCalled(); }); - it('returns not_enabled when server is not SonarQube Cloud', async () => { + it('returns not_allowed when server is not SonarQube Cloud', async () => { const serverClient = new SonarQubeClient(SERVER_URL, TOKEN); fetchSpy = mockFetch({}); - expect(await serverClient.hasCagEntitlement('my-org')).toBe('not_enabled'); + expect(await serverClient.hasCagEntitlement('my-org')).toBe('not_allowed'); expect(fetchSpy).not.toHaveBeenCalled(); }); @@ -841,7 +841,7 @@ describe('SonarQubeClient', () => { expect(await cloudClient.hasCagEntitlement('unknown-org')).toBe('check_failed'); }); - it('returns enabled when org UUID is resolved and entitlement is eligible and enabled', async () => { + it('returns allowed when org UUID is resolved and entitlement is allowed', async () => { fetchSpy = spyOn(globalThis, 'fetch') .mockResolvedValueOnce({ ok: true, @@ -851,13 +851,13 @@ describe('SonarQubeClient', () => { .mockResolvedValueOnce({ ok: true, status: 200, - json: () => Promise.resolve({ id: 'org-uuid', eligible: true, enabled: true }), + json: () => Promise.resolve({ allowed: true }), } as Response); - expect(await cloudClient.hasCagEntitlement('my-org')).toBe('enabled'); + expect(await cloudClient.hasCagEntitlement('my-org')).toBe('allowed'); }); - it('returns not_enabled when eligible but not enabled', async () => { + it('returns not_allowed when the organization is not allowed', async () => { fetchSpy = spyOn(globalThis, 'fetch') .mockResolvedValueOnce({ ok: true, @@ -867,13 +867,13 @@ describe('SonarQubeClient', () => { .mockResolvedValueOnce({ ok: true, status: 200, - json: () => Promise.resolve({ id: 'org-uuid', eligible: true, enabled: false }), + json: () => Promise.resolve({ allowed: false }), } as Response); - expect(await cloudClient.hasCagEntitlement('my-org')).toBe('not_enabled'); + expect(await cloudClient.hasCagEntitlement('my-org')).toBe('not_allowed'); }); - it('returns not_enabled when enabled but not eligible', async () => { + it('ignores consumption data when mapping an allowed response', async () => { fetchSpy = spyOn(globalThis, 'fetch') .mockResolvedValueOnce({ ok: true, @@ -883,10 +883,10 @@ describe('SonarQubeClient', () => { .mockResolvedValueOnce({ ok: true, status: 200, - json: () => Promise.resolve({ id: 'org-uuid', eligible: false, enabled: true }), + json: () => Promise.resolve({ allowed: true, consumption: { consumed: 10, limit: 100 } }), } as Response); - expect(await cloudClient.hasCagEntitlement('my-org')).toBe('not_enabled'); + expect(await cloudClient.hasCagEntitlement('my-org')).toBe('allowed'); }); it('returns check_failed when the entitlement check fails with an API error', async () => { @@ -906,7 +906,7 @@ describe('SonarQubeClient', () => { expect(await cloudClient.hasCagEntitlement('my-org')).toBe('check_failed'); }); - it('hits the correct CAG org config endpoint with the resolved UUID', async () => { + it('hits the correct CAG entitlement endpoint with the resolved UUID', async () => { const targetUuid = 'cag-specific-uuid'; fetchSpy = spyOn(globalThis, 'fetch') .mockResolvedValueOnce({ @@ -917,13 +917,13 @@ describe('SonarQubeClient', () => { .mockResolvedValueOnce({ ok: true, status: 200, - json: () => Promise.resolve({ id: targetUuid, eligible: true, enabled: true }), + json: () => Promise.resolve({ allowed: true }), } as Response); await cloudClient.hasCagEntitlement('my-org'); const entitlementUrl = new URL((fetchSpy.mock.calls[1][0] as URL).toString()); - expect(entitlementUrl.pathname).toBe(`/a3s-analysis/cag-org-config/${targetUuid}`); + expect(entitlementUrl.pathname).toBe(`/a3s-analysis/cag-entitlement/${targetUuid}`); }); });