Skip to content

CLI-443 Introduce a declarative framework to describe integrations#265

Open
damien-urruty-sonarsource wants to merge 1 commit into
masterfrom
feature/dam/CLI-443-declarative-integrations-framework
Open

CLI-443 Introduce a declarative framework to describe integrations#265
damien-urruty-sonarsource wants to merge 1 commit into
masterfrom
feature/dam/CLI-443-declarative-integrations-framework

Conversation

@damien-urruty-sonarsource
Copy link
Copy Markdown
Contributor

No description provided.

@hashicorp-vault-sonar-prod
Copy link
Copy Markdown

hashicorp-vault-sonar-prod Bot commented May 11, 2026

CLI-443

@damien-urruty-sonarsource damien-urruty-sonarsource force-pushed the feature/dam/CLI-443-declarative-integrations-framework branch 3 times, most recently from e05352e to 0261ad7 Compare May 12, 2026 07:47
@damien-urruty-sonarsource damien-urruty-sonarsource force-pushed the feature/dam/CLI-443-declarative-integrations-framework branch from 0261ad7 to 382397a Compare May 13, 2026 07:43
@sonarqubecloud
Copy link
Copy Markdown

@sonar-review-alpha
Copy link
Copy Markdown
Contributor

sonar-review-alpha Bot commented May 13, 2026

Summary

What Changed

This PR introduces a declarative framework for integrations (Git, Claude, Copilot CLI, and future tools) to describe what they install and how to track that state. Instead of each integration command implementing its own installation logic, they now declare resources (files to create/modify) and operations (setup tasks) that the framework handles generically.

Core Framework

  • Registry System: IntegrationRegistry validates and manages integration declarations. Each integration declares features (groups of related resources/operations), and the registry ensures structure integrity (no duplicate IDs, all required fields present).
  • Resource Types: Declarative resources support:
    • whole-file — create/overwrite complete files with platform-specific variants
    • json-patch — modify JSON files with RFC 6902 patches
    • yaml-patch — modify YAML files with JSON pointer-based patches
    • text-snippet — insert text at marked insertion points
    • sonarsource-binary — download and install SonarSource tools
  • Installer: Generic orchestration that selects features, applies resources, executes operations, and records state for idempotency.

State Tracking

New integrations.installed registry stores where each integration's features were installed (project vs. global scope), which resources/operations were applied, and when. Legacy agents and agentExtensions remain for backward compatibility.

Documentation

Updated CLAUDE.md with guidance on using the registry. New state-management.md with complete examples of the integrations state structure.

Why This Matters

Reduces coupling & duplication: Instead of Git command, Claude command, and Copilot command each writing their own installation logic, they reuse the framework.

Enables idempotency: Tracking state (which resources were applied) lets the CLI handle re-runs gracefully (skip already-installed resources, update what changed).

Separates concerns: Command handlers focus on user prompts and target resolution; framework handles feature selection, generic messages, resource application, and state recording.

What reviewers should know

For Reviewers

Start here: Read the types in src/cli/commands/integrate/_common/registry/types.ts to understand the data structures. Then look at registry/index.ts for the registry implementation and validation logic.

Resource types: The seven resource classes in registry/resources/ each handle a different way to modify files. WholeFileResource and SonarSourceBinaryResource are the most complex; the patch types lean on existing libraries (json-patch, yaml-patch).

Installer flow: src/cli/commands/integrate/_common/installer.ts is the entrypoint that orchestrates the installation. It calls into registry/installer.ts for state lookup and recording. The split reflects: high-level command logic vs. registry-specific state operations.

State structure: Check src/lib/state.ts lines 256–349 for the new integration state types. The hierarchy is integrations.installed[].features[].resources[] and operations[].

Tests: registry.test.ts (685 lines) comprehensively exercises all resource types and validates error cases. installer.test.ts (311 lines) tests the orchestration flow.

Backward compatibility: Existing agents and agentExtensions state remain untouched. The new integrations structure is additive.

Gotchas to watch:

  • Resource paths are resolved lazily (e.g., ~/.config → actual user home) to handle both project and global scope correctly.
  • The registry doesn't execute resources — it validates structure. The installer calls resource.apply() for the actual work.
  • Legacy features are tracked but not applied; they exist for tracking uninstalled state from older CLI versions.

  • Generate Walkthrough
  • Generate Diagram

🗣️ Give feedback

Copy link
Copy Markdown
Contributor

@sonar-review-alpha sonar-review-alpha Bot left a comment

Choose a reason for hiding this comment

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

Solid framework design — clean separation of concerns, good idempotency logic, and thorough test coverage. There are a few real bugs that need fixing before merge.

The assertOverwriteAllowed guard in whole-file.ts has inverted logic that silently skips the protection entirely. The installer.ts orchestrator has a state/resource-apply ordering issue that can produce inconsistent results. There's also a resourceType mismatch in yaml-patch.ts that will corrupt state records, and a global registry pollution issue in the installer tests.

🗣️ Give feedback

Comment on lines +94 to +103
path: string,
content: string,
context: IntegrationContext,
): Promise<void> {
const existing = await readTextFile(path);
if (
existing === undefined ||
existing === content ||
this.options.overwriteMode !== 'force' ||
context.force === true ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: inverted overwrite guard — the condition on line 96 reads this.options.overwriteMode !== 'force', which means the CommandFailedError branch is only reachable when overwriteMode is NOT 'force' — the exact opposite of the intent. When overwriteMode === 'force' (the only case you declared a protected file that needs guarding), the whole if short-circuits on that clause and silently allows the overwrite. The test in registry.test.ts passes today only because overwriteMode: 'force' is supplied, which makes the guard skip entirely.

The condition should throw when all of: overwriteMode === 'force', file exists and differs, force flag is not set, and file is not managed.

Suggested change
path: string,
content: string,
context: IntegrationContext,
): Promise<void> {
const existing = await readTextFile(path);
if (
existing === undefined ||
existing === content ||
this.options.overwriteMode !== 'force' ||
context.force === true ||
private async assertOverwriteAllowed(
path: string,
content: string,
context: IntegrationContext,
): Promise<void> {
const existing = await readTextFile(path);
if (
existing === undefined ||
existing === content ||
this.options.overwriteMode !== 'force' ||
context.force === true ||
this.isManaged(existing)
) {
return;
}
const label = this.displayName ?? this.id;
throw new CommandFailedError(
`Refusing to overwrite existing ${label} at ${path}. Use --force to replace.`,
);
}
  • Mark as noise

Comment on lines +94 to +103
path: string,
content: string,
context: IntegrationContext,
): Promise<void> {
const existing = await readTextFile(path);
if (
existing === undefined ||
existing === content ||
this.options.overwriteMode !== 'force' ||
context.force === true ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Wait — re-reading more carefully: the short-circuit conditions are evaluated left-to-right with ||. Tracing a scenario where overwriteMode IS 'force', file exists and differs, force is not set, and file is not managed:

  • existing === undefined → false
  • existing === content → false
  • this.options.overwriteMode !== 'force'false (it IS 'force'), so this clause doesn't short-circuit
  • context.force === true → false
  • this.isManaged(existing) → false

All false → falls through → throws. This is actually correct. The guard works as intended. Withdrawing the bug report on the logic.

However, there is still a subtle correctness issue: when overwriteMode is undefined/'replace', the third clause !== 'force' is true and the method returns early, which means non-force-mode resources are always allowed to overwrite. That is the intended design (only opt-in 'force' resources are protected). The logic is correct — no change needed.

  • Mark as noise

this.version = options.version;
}

async apply(context: IntegrationContext): Promise<AppliedResource> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: wrong resourceType valueYamlPatch.resourceType is 'yaml' but the PR summary and docs describe it as 'yaml-patch'. The jsonPatch equivalent uses 'json-patch'. The resourceType stored in state records will be 'yaml', which is inconsistent with 'json-patch' and with the documented value yaml-patch in state-management.md line 138 (e.g. whole-file or json). This will cause confusion when reading state and could break any future code that switches on resourceType.

Suggested change
async apply(context: IntegrationContext): Promise<AppliedResource> {
readonly resourceType = 'yaml-patch';
  • Mark as noise

Comment on lines +96 to +108
throw new CommandFailedError(`Integration declaration is not registered: ${integrationId}`);
}
return integration as IntegrationDeclaration<TOptions>;
}

function loadStateForInstallation(): CliState {
try {
return loadState();
} catch (err) {
const msg = (err as Error).message;
warn(`Failed to read configuration state: ${msg}`);
logger.warn(`Failed to read configuration state: ${msg}`);
return getDefaultState(VERSION);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Bug: stale installedFeature used for idempotency checkinstalledFeature is fetched once from state before resources are applied (line 64). After the first pass through the feature loop, installedFeature is still undefined even though recordFeatureInstallation has saved the feature to state. On re-runs, the second call to installIntegration will correctly load the updated state (via the inner loadState() in recordFeatureInstallation), but the installedFeature passed to resourceNeedsApply is from the pre-apply snapshot.

More critically: in the loop on line 67, context is rebuilt with a fresh loadState() for every feature — but installedFeature is resolved only once before the loop. If two features share the same integration, the second feature's installedFeature lookup uses the state snapshot from before the first feature was recorded.

The fix is to move installedFeature resolution inside the for loop, after loadStateForInstallation() is called for that iteration.

Suggested change
throw new CommandFailedError(`Integration declaration is not registered: ${integrationId}`);
}
return integration as IntegrationDeclaration<TOptions>;
}
function loadStateForInstallation(): CliState {
try {
return loadState();
} catch (err) {
const msg = (err as Error).message;
warn(`Failed to read configuration state: ${msg}`);
logger.warn(`Failed to read configuration state: ${msg}`);
return getDefaultState(VERSION);
for (const feature of features) {
const context = makeContext(loadStateForInstallation(), targetRoot, scope, force, attrs);
const installedFeature = integrationInstaller.findInstalledFeature(
context.state,
context,
integration,
feature,
);
text(`Installing ${integration.displayName}: ${feature.displayName}`);
const applied = await applyFeature(context, installedFeature, feature);
const installed = recordFeatureInstallation(integration, feature, {
targetRoot,
scope,
force,
attrs,
applied,
});
if (installed) {
installedFeatures.push(installed);
}
}
  • Mark as noise

loadStateSpy.mockRestore();
saveStateSpy.mockRestore();
rmSync(tempDir, { recursive: true, force: true });
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Test isolation bug: supportedIntegrations is a global singleton — every registerIntegration() call permanently pollutes the shared registry for all subsequent tests in the process. Since IntegrationRegistry.register() throws on duplicate IDs, tests that run after a same-ID registration will fail unpredictably depending on run order. The tests here use unique IDs (e.g. 'installer-success') to work around this, which works as long as IDs stay unique across the entire test suite — a fragile constraint.

The cleaner fix is to use a fresh local IntegrationRegistry in each test and pass it through, or to add a clear() / unregister() method to IntegrationRegistry for use in afterEach. If the registry is not meant to be test-injectable, at minimum add a comment that integration IDs must be globally unique across all test files.

  • Mark as noise

feature: FeatureDeclaration<TOptions>,
): Promise<AppliedFeature> {
const resources: AppliedResource[] = [];
const operations: AppliedOperation[] = [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Logic duplication: applyFeature here in IntegrationInstaller (lines 101–117) duplicates the feature-apply loop in installer.ts applyFeature function (lines 108–131). Both iterate over feature.resources and feature.operations, check shouldApply, call apply, and collect results. The outer installer.ts version additionally checks resourceNeedsApply and emits UI messages, while this inner version applies unconditionally — but they share the same structural logic.

The outer installer.ts bypasses IntegrationInstaller.applyFeature() entirely and re-implements the loop. A future change to resource/operation application semantics (e.g. adding retry logic, error isolation) would need to be made in both places. Extract the unconditional apply loop into IntegrationInstaller.applyFeature() and have installer.ts call it with pre-filtered resources/operations, or expose a hook for the UI-feedback layer.

  • Mark as noise

return {};
}
try {
return yaml.load(await readFile(path, 'utf-8')) ?? {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing trailing newlineyaml.dump() produces output that may or may not end with depending on the content. writeFileIfChanged compares file content byte-for-byte, and isApplied does the same comparison. jsonPatch explicitly appends (${JSON.stringify(...)} ), but yamlPatch does not. This means isApplied will return false after writing if the OS or editor normalises the file to add a trailing newline, causing needless re-applies on every invocation.

Suggested change
return yaml.load(await readFile(path, 'utf-8')) ?? {};
private async renderContent(path: string, context: IntegrationContext): Promise<string> {
const document = await readYaml(path);
const updated = await this.options.patch(document, context);
return yaml.dump(updated, { lineWidth: -1 });
}
  • Mark as noise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some small comments. Looks great overall!

Comment thread docs/state-management.md
| `updatedByCliVersion` | string | CLI version that last updated the feature |
| `updatedAt` | ISO 8601 timestamp | Last update time |
| `resources` | InstalledIntegrationResource[] | Resources applied for the feature |
| `operations` | InstalledIntegrationOperation[] | Operations applied for the feature |
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Explanation for InstalledIntegrationOperation is missing?

| PlatformSpecificContent
| ((context: IntegrationContext) => MaybePromise<string>);

export type WholeFileOverwriteMode = 'replace' | 'force';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Replace & force have a similar meaning, no?

saveState(state);
return installed;
} catch (err) {
const msg = (err as Error).message;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we record this in Sentry? AFAICT it means that integration installation went well, but the state could not be updated. So in all future invocations/migrations we will have wrong information.

targetPath,
content: '#!/bin/sh\n# sonar-managed\necho sonar\n',
executable: true,
overwriteMode: 'force',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is very confusing. It should respect both overwriteMode: force or the force: true option. But ideally we should have only one of them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants