diff --git a/calm-models/src/model/moment.ts b/calm-models/src/model/moment.ts index 053650dee9..df83352a09 100644 --- a/calm-models/src/model/moment.ts +++ b/calm-models/src/model/moment.ts @@ -19,7 +19,7 @@ export class CalmMoment extends CalmNode implements CalmAdaptable ) { - super(originalJson, uniqueId, 'moment', name, description, details, undefined, controls, metadata, additionalProperties); + super(originalJson, uniqueId, 'moment', name, description, undefined, details, undefined, controls, metadata, additionalProperties); this.validFrom = validFrom; this.adrs = adrs; } diff --git a/calm-models/src/model/node.spec.ts b/calm-models/src/model/node.spec.ts index c7181c1994..31f8efbced 100644 --- a/calm-models/src/model/node.spec.ts +++ b/calm-models/src/model/node.spec.ts @@ -1,6 +1,6 @@ import { CalmNode, CalmNodeDetails } from './node.js'; import { CalmNodeSchema, CalmNodeDetailsSchema } from '../types/core-types.js'; -import { ResolvableAndAdaptable } from './resolvable'; +import { Resolvable, ResolvableAndAdaptable } from './resolvable'; import { CalmCore } from './core'; describe('CalmNodeDetails', () => { @@ -296,4 +296,67 @@ describe('CalmNode', () => { expect(node.additionalProperties).toEqual({ foo: 'bar', bar: 42 }); expect(node.toCanonicalSchema().additionalProperties).toEqual({ foo: 'bar', bar: 42 }); }); + + describe('definition-id', () => { + it('should create a Resolvable from definition-id when present', () => { + const schema: CalmNodeSchema = { + 'unique-id': 'node-def-1', + 'node-type': 'service', + name: 'Def Node', + description: 'A node with definition-id', + 'definition-id': 'fae-calm:building-blocks:my-block@a1b2c3d' + }; + const node = CalmNode.fromSchema(schema); + expect(node.definitionId).toBeInstanceOf(Resolvable); + expect(node.definitionId?.reference).toBe('fae-calm:building-blocks:my-block@a1b2c3d'); + }); + + it('should leave definitionId undefined when definition-id is absent', () => { + const schema: CalmNodeSchema = { + 'unique-id': 'node-def-2', + 'node-type': 'service', + name: 'No Def Node', + description: 'A node without definition-id' + }; + const node = CalmNode.fromSchema(schema); + expect(node.definitionId).toBeUndefined(); + }); + + it('should include definition-id in toCanonicalSchema() when present', () => { + const schema: CalmNodeSchema = { + 'unique-id': 'node-def-3', + 'node-type': 'service', + name: 'Canonical Def Node', + description: 'Node with definition-id for canonical', + 'definition-id': 'fae-calm:building-blocks:my-block@a1b2c3d' + }; + const node = CalmNode.fromSchema(schema); + const canonical = node.toCanonicalSchema(); + expect(canonical['definition-id']).toBe('fae-calm:building-blocks:my-block@a1b2c3d'); + }); + + it('should not include definition-id in toCanonicalSchema() when absent', () => { + const schema: CalmNodeSchema = { + 'unique-id': 'node-def-4', + 'node-type': 'service', + name: 'No Def Canonical Node', + description: 'Node without definition-id for canonical' + }; + const node = CalmNode.fromSchema(schema); + const canonical = node.toCanonicalSchema(); + expect('definition-id' in canonical).toBe(false); + }); + + it('should not leak definition-id into additionalProperties', () => { + const schema: CalmNodeSchema = { + 'unique-id': 'node-def-5', + 'node-type': 'service', + name: 'No Leak Node', + description: 'definition-id should not appear in additional', + 'definition-id': 'fae-calm:building-blocks:test@abc123' + }; + const node = CalmNode.fromSchema(schema); + expect(node.additionalProperties).toBeUndefined(); + }); + }); }); diff --git a/calm-models/src/model/node.ts b/calm-models/src/model/node.ts index 5d113b2690..7c2056a5df 100644 --- a/calm-models/src/model/node.ts +++ b/calm-models/src/model/node.ts @@ -2,7 +2,7 @@ import { CalmInterface } from './interface.js'; import { CalmControls } from './control.js'; import { CalmMetadata } from './metadata.js'; import { CalmCore } from './core.js'; -import { ResolvableAndAdaptable } from './resolvable.js'; +import { Resolvable, ResolvableAndAdaptable } from './resolvable.js'; import { CalmCoreSchema, CalmNodeDetailsSchema, @@ -54,6 +54,7 @@ export class CalmNode implements CalmAdaptable>, public details?: CalmNodeDetails, public interfaces?: CalmInterface[], public controls?: CalmControls, @@ -71,6 +72,7 @@ export class CalmNode implements CalmAdaptable i.toCanonicalSchema()) : undefined, controls: this.controls ? this.controls.toCanonicalSchema() : undefined, @@ -85,6 +87,7 @@ export class CalmNode implements CalmAdaptable>(definitionIdRef) : undefined, details ? CalmNodeDetails.fromSchema(details) : undefined, interfaces? interfaces.map(CalmInterface.fromSchema) : undefined, controls? CalmControls.fromSchema(controls): undefined, diff --git a/calm-models/src/types/core-types.ts b/calm-models/src/types/core-types.ts index 91cf4e1c72..ec61f705a1 100644 --- a/calm-models/src/types/core-types.ts +++ b/calm-models/src/types/core-types.ts @@ -33,6 +33,7 @@ export type CalmNodeSchema = { 'node-type': CalmNodeTypeSchema; name: string; description: string; + 'definition-id'?: string; details?: CalmNodeDetailsSchema; interfaces?: CalmInterfaceSchema[]; controls?: CalmControlsSchema; diff --git a/calm-plugins/vscode/package.json b/calm-plugins/vscode/package.json index 28c4a9ba2a..d74f742dac 100644 --- a/calm-plugins/vscode/package.json +++ b/calm-plugins/vscode/package.json @@ -37,6 +37,23 @@ "light": "media/calm-canvas.svg", "dark": "media/calm-canvas.svg" } + }, + { + "command": "calm.connectToHub", + "title": "CALM: Connect to Hub" + }, + { + "command": "calm.disconnectFromHub", + "title": "CALM: Disconnect from Hub" + }, + { + "command": "calm.refreshFromHub", + "title": "CALM: Refresh from Hub" + }, + { + "command": "calm.importSvg", + "title": "Import SVG as CALM Architecture", + "category": "CALM" } ], "keybindings": [ @@ -67,6 +84,11 @@ "command": "calm.openCanvas", "when": "resourceFilename =~ /\\.(calm|architecture|template|solution|standard|guideline)\\.json$/", "group": "navigation" + }, + { + "command": "calm.importSvg", + "when": "resourceExtname == .svg", + "group": "navigation" } ] }, @@ -93,6 +115,21 @@ }, "default": [], "markdownDescription": "List of node type IDs to hide from the palette (e.g. `[\"core:ldap\", \"aws:s3\"]`). Use the `packId:nodeType` format." + }, + "calm.hub.url": { + "type": "string", + "default": "", + "description": "CalmHub endpoint URL (e.g. https://calmhub.example.com)" + }, + "calm.hub.autoConnect": { + "type": "boolean", + "default": true, + "description": "Automatically connect to CalmHub on startup when URL is configured" + }, + "calm.hub.selectedNamespaces": { + "type": "array", + "items": { "type": "string" }, + "description": "Selected Hub namespaces to show in the palette (not set = show all)" } } } @@ -109,6 +146,8 @@ }, "dependencies": { "@finos/calm-models": "file:../../calm-models", + "svg-parser": "^2.0.4", + "xml2js": "^0.5.0", "react": "^19.1.0", "react-dom": "^19.1.0", "reactflow": "^11.11.4", @@ -130,6 +169,7 @@ "@types/vscode": "^1.88.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", + "@types/xml2js": "^0.4.14", "typescript": "^5.8.0", "vitest": "^4.1.0", "@vscode/vsce": "^3.0.0" diff --git a/calm-plugins/vscode/src/extension/extension.ts b/calm-plugins/vscode/src/extension/extension.ts index 667972d49c..e360894448 100644 --- a/calm-plugins/vscode/src/extension/extension.ts +++ b/calm-plugins/vscode/src/extension/extension.ts @@ -1,9 +1,16 @@ import * as vscode from 'vscode'; import { CanvasPanel } from './webview/canvas-panel'; import { CalmCanvasCodeLensProvider } from './services/codelens-provider'; +import { HubClient } from './services/hub-client'; +import { HubAuthService } from './services/hub-auth-service'; +import { HubStatusBar } from './services/hub-status-bar'; +import { SvgImportService } from './services/svg-import'; let canvasPanel: CanvasPanel | undefined; let outputChannel: vscode.OutputChannel; +let hubStatusBar: HubStatusBar | undefined; +let hubClient: HubClient | undefined; +let hubAuthService: HubAuthService | undefined; const CALM_FILE_SUFFIXES = [ '.calm.json', @@ -62,6 +69,10 @@ export function activate(context: vscode.ExtensionContext): void { canvasPanel.onDispose(() => { canvasPanel = undefined; }); + // If the Hub is already connected, wire the panel immediately. + if (hubClient) { + void canvasPanel.setHubConnection(hubClient); + } } canvasPanel.reveal(document); } @@ -69,6 +80,15 @@ export function activate(context: vscode.ExtensionContext): void { context.subscriptions.push(openCanvas); + const importService = new SvgImportService(outputChannel); + const importSvg = vscode.commands.registerCommand( + 'calm.importSvg', + async (uri?: vscode.Uri) => { + await importService.importSvgToNewFile(uri); + } + ); + context.subscriptions.push(importSvg); + // Inline "View in CALM Canvas" affordances on CALM documents. const calmSelector: vscode.DocumentSelector = [ { pattern: '**/*.calm.json' }, @@ -84,6 +104,225 @@ export function activate(context: vscode.ExtensionContext): void { new CalmCanvasCodeLensProvider() ) ); + + // --- CalmHub integration --- + hubStatusBar = new HubStatusBar(); + context.subscriptions.push(hubStatusBar); + + const connectToHubCmd = vscode.commands.registerCommand( + 'calm.connectToHub', + () => connectToHub(context) + ); + const disconnectFromHubCmd = vscode.commands.registerCommand( + 'calm.disconnectFromHub', + () => disconnectFromHub() + ); + const refreshFromHubCmd = vscode.commands.registerCommand( + 'calm.refreshFromHub', + () => refreshFromHub() + ); + context.subscriptions.push( + connectToHubCmd, + disconnectFromHubCmd, + refreshFromHubCmd + ); + + // Auto-connect if URL is configured and autoConnect is enabled + const hubUrl = vscode.workspace + .getConfiguration('calm.hub') + .get('url'); + const autoConnect = vscode.workspace + .getConfiguration('calm.hub') + .get('autoConnect', true); + if (hubUrl && autoConnect) { + connectToHub(context); + } +} + +async function connectToHub( + context: vscode.ExtensionContext +): Promise { + let url = vscode.workspace + .getConfiguration('calm.hub') + .get('url'); + + if (url && (hubStatusBar?.state === 'connected' || hubStatusBar?.state === 'error')) { + const items = hubStatusBar?.state === 'connected' + ? [ + { label: '$(list-selection) Select Namespaces', action: 'namespaces' }, + { label: '$(sync) Refresh from Hub', action: 'refresh' }, + { label: '$(pencil) Change Hub URL', action: 'change' }, + { label: '$(sign-out) Disconnect', action: 'disconnect' }, + ] + : [ + { label: '$(pencil) Change Hub URL', action: 'change' }, + { label: '$(debug-restart) Retry Connection', action: 'retry' }, + { label: '$(sign-out) Clear URL', action: 'disconnect' }, + ]; + + const choice = await vscode.window.showQuickPick(items, { + placeHolder: hubStatusBar?.state === 'connected' + ? `Connected to ${url}` + : `Failed to connect to ${url}`, + }); + if (!choice) return; + if (choice.action === 'disconnect') { await disconnectFromHub(); return; } + if (choice.action === 'namespaces') { await selectNamespaces(); return; } + if (choice.action === 'refresh' || choice.action === 'retry') { + // Fall through to reconnect with existing URL + } else { + // 'change' — clear URL so the input box shows + url = undefined; + } + } + + if (!url) { + const currentUrl = vscode.workspace.getConfiguration('calm.hub').get('url') ?? ''; + const input = await vscode.window.showInputBox({ + prompt: 'CalmHub URL', + placeHolder: 'https://calmhub.example.com', + value: currentUrl, + }); + if (!input) return; + await vscode.workspace + .getConfiguration('calm.hub') + .update('url', input, vscode.ConfigurationTarget.Global); + url = input; + } + + hubStatusBar!.setState('connecting', undefined, url); + + try { + hubClient = new HubClient(url); + hubAuthService = new HubAuthService(context.secrets, hubClient); + await hubAuthService.initialize(); + + const authenticated = + await hubAuthService.discoverAndAuthenticate(); + if (!authenticated) { + hubStatusBar!.setState('error'); + return; + } + + const namespaces = await hubClient.getNamespaces(); + const availableNames = namespaces.map((ns: { name: string }) => ns.name); + + // Validate saved selection against available namespaces (remove revoked access) + const savedSelection: string[] = vscode.workspace + .getConfiguration('calm.hub') + .get('selectedNamespaces') ?? []; + const validSelection = savedSelection.filter((ns) => availableNames.includes(ns)); + if (validSelection.length !== savedSelection.length) { + await vscode.workspace + .getConfiguration('calm.hub') + .update('selectedNamespaces', validSelection, vscode.ConfigurationTarget.Global); + outputChannel.appendLine( + `[INFO] Cleaned namespace selection: removed ${savedSelection.length - validSelection.length} inaccessible namespace(s)` + ); + } + + hubStatusBar!.setState('connected', availableNames.length, url); + hubStatusBar!.setAvailableAndSelected(availableNames, validSelection); + + // Hand the authenticated client to the canvas panel — this refreshes + // Hub assets and posts them to the webview. + if (canvasPanel) { + void canvasPanel.setHubConnection(hubClient); + } + + outputChannel.appendLine( + `[INFO] Connected to ${url} — ${availableNames.length} available, ${validSelection.length} selected` + ); + } catch (err) { + hubStatusBar!.setState('error'); + const message = + err instanceof Error ? err.message : String(err); + outputChannel.appendLine( + `[ERROR] Failed to connect to CalmHub: ${message}` + ); + vscode.window.showErrorMessage( + `Failed to connect to CalmHub: ${message}` + ); + } +} + +async function disconnectFromHub(): Promise { + if (hubAuthService) { + await hubAuthService.signOut(); + } + hubClient = undefined; + hubAuthService = undefined; + hubStatusBar?.setState('disconnected'); + void canvasPanel?.setHubConnection(undefined); + outputChannel.appendLine('[INFO] Disconnected from CalmHub'); +} + +async function refreshFromHub(): Promise { + if (!hubClient || !hubStatusBar || hubStatusBar.state !== 'connected') { + vscode.window.showWarningMessage( + 'Not connected to CalmHub. Use "CALM: Connect to Hub" first.' + ); + return; + } + + try { + hubStatusBar.setState('connecting'); + const namespaces = await hubClient.getNamespaces(); + hubStatusBar.setState('connected', namespaces.length); + void canvasPanel?.setHubConnection(hubClient); + outputChannel.appendLine( + `[INFO] Refreshed from CalmHub — ${namespaces.length} namespace(s)` + ); + } catch (err) { + hubStatusBar.setState('error'); + const message = + err instanceof Error ? err.message : String(err); + outputChannel.appendLine( + `[ERROR] Refresh from CalmHub failed: ${message}` + ); + } +} + +async function selectNamespaces(): Promise { + if (!hubClient || !hubStatusBar) return; + + try { + const allNamespaces = await hubClient.getNamespaces(); + const currentlySelected: string[] = vscode.workspace + .getConfiguration('calm.hub') + .get('selectedNamespaces') ?? []; + + const items = allNamespaces.map((ns: { name: string }) => ({ + label: ns.name, + picked: currentlySelected.includes(ns.name), + })); + + const selected = await vscode.window.showQuickPick(items, { + canPickMany: true, + placeHolder: 'Select namespaces to show in the palette', + }); + + if (!selected) return; + + const selectedNames = selected.map((s) => s.label); + await vscode.workspace + .getConfiguration('calm.hub') + .update('selectedNamespaces', selectedNames, vscode.ConfigurationTarget.Global); + + const allNsNames = allNamespaces.map((ns: { name: string }) => ns.name); + hubStatusBar.setAvailableAndSelected(allNsNames, selectedNames); + outputChannel.appendLine( + `[INFO] Selected namespaces: ${selectedNames.join(', ')}` + ); + + // Refresh palette with new namespace filter + if (canvasPanel) { + canvasPanel.refreshAssets(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + outputChannel.appendLine(`[ERROR] Failed to list namespaces: ${message}`); + } } export function deactivate(): void { diff --git a/calm-plugins/vscode/src/extension/services/control-asset-service.test.ts b/calm-plugins/vscode/src/extension/services/control-asset-service.test.ts new file mode 100644 index 0000000000..3bc427c753 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/control-asset-service.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + ControlAssetService, + LOCAL_DOMAIN, +} from './control-asset-service'; +import { HubClient, HubApiError } from './hub-client'; +import type { LocalControlDef } from './workspace-asset-service'; + +const localControls: LocalControlDef[] = [ + { + id: 'micro-segmentation', + controlId: 'security-001', + name: 'Micro-segmentation', + description: 'Prevent lateral movement', + filePath: '/ws/controls/micro-segmentation.requirement.json', + relativePath: 'controls/micro-segmentation.requirement.json', + }, +]; + +function mockHub(overrides: Partial> = {}): HubClient { + return { + getDomains: vi.fn().mockResolvedValue(['security', 'privacy']), + getControlsForDomain: vi.fn(), + ...overrides, + } as unknown as HubClient; +} + +describe('ControlAssetService.browse', () => { + it('returns local controls plus empty Hub domain placeholders', async () => { + const svc = new ControlAssetService(mockHub(), () => localControls); + const groups = await svc.browse(); + + expect(groups[0].domain).toBe(LOCAL_DOMAIN); + expect(groups[0].controls).toEqual([ + { + source: 'local', + domain: LOCAL_DOMAIN, + controlName: 'micro-segmentation', + title: 'Micro-segmentation', + description: 'Prevent lateral movement', + requirementRef: 'controls/micro-segmentation.requirement.json', + }, + ]); + expect(groups.slice(1).map((g) => g.domain)).toEqual(['security', 'privacy']); + expect(groups[1].controls).toEqual([]); + }); + + it('returns local controls only when no Hub client is present', async () => { + const svc = new ControlAssetService(undefined, () => localControls); + const groups = await svc.browse(); + expect(groups).toHaveLength(1); + expect(groups[0].domain).toBe(LOCAL_DOMAIN); + }); + + it('still returns local controls when getDomains fails', async () => { + const hub = mockHub({ + getDomains: vi.fn().mockRejectedValue(new Error('network')), + }); + const svc = new ControlAssetService(hub, () => localControls); + const groups = await svc.browse(); + expect(groups).toHaveLength(1); + expect(groups[0].controls).toHaveLength(1); + }); +}); + +describe('ControlAssetService.browseControlsForDomain', () => { + it('maps Hub control details into browse entries', async () => { + const hub = mockHub({ + getControlsForDomain: vi.fn().mockResolvedValue([ + { id: 1, name: 'micro-segmentation', description: 'd', title: 'Micro Seg' }, + { id: 2, name: 'tls', description: 'TLS everywhere' }, + ]), + }); + const svc = new ControlAssetService(hub, () => []); + const group = await svc.browseControlsForDomain('security'); + + expect(group.error).toBeUndefined(); + expect(group.controls).toEqual([ + { + source: 'hub', + domain: 'security', + controlName: 'micro-segmentation', + title: 'Micro Seg', + description: 'd', + }, + { + source: 'hub', + domain: 'security', + controlName: 'tls', + title: 'Tls', + description: 'TLS everywhere', + }, + ]); + }); + + it('returns an error group on 403', async () => { + const hub = mockHub({ + getControlsForDomain: vi + .fn() + .mockRejectedValue(new HubApiError(403, 'u', 'forbidden')), + }); + const svc = new ControlAssetService(hub, () => []); + const group = await svc.browseControlsForDomain('secret'); + expect(group.controls).toEqual([]); + expect(group.error).toContain('403'); + }); + + it('returns an error group when Hub is not connected', async () => { + const svc = new ControlAssetService(undefined, () => []); + const group = await svc.browseControlsForDomain('security'); + expect(group.error).toBe('Hub not connected'); + }); + + it('reflects a freshly set Hub client', async () => { + const svc = new ControlAssetService(undefined, () => []); + const hub = mockHub({ + getControlsForDomain: vi.fn().mockResolvedValue([]), + }); + svc.setHubClient(hub); + const group = await svc.browseControlsForDomain('security'); + expect(group.error).toBeUndefined(); + }); + + it('humanizes slug to title when Hub control has no title', async () => { + const hub = mockHub({ + getControlsForDomain: vi.fn().mockResolvedValue([ + { id: 1, name: 'resiliency-tier', description: 'desc' }, + ]), + }); + const svc = new ControlAssetService(hub, () => []); + const group = await svc.browseControlsForDomain('platform'); + expect(group.controls[0].title).toBe('Resiliency Tier'); + }); + + it('uses explicit title from Hub when provided', async () => { + const hub = mockHub({ + getControlsForDomain: vi.fn().mockResolvedValue([ + { id: 1, name: 'resiliency-tier', description: 'desc', title: 'Custom Title' }, + ]), + }); + const svc = new ControlAssetService(hub, () => []); + const group = await svc.browseControlsForDomain('platform'); + expect(group.controls[0].title).toBe('Custom Title'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/control-asset-service.ts b/calm-plugins/vscode/src/extension/services/control-asset-service.ts new file mode 100644 index 0000000000..0442349e35 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/control-asset-service.ts @@ -0,0 +1,112 @@ +import { HubClient, HubApiError } from './hub-client'; +import { buildControlCurie } from './control-curie'; +import type { LocalControlDef } from './workspace-asset-service'; + +export interface ControlBrowseEntry { + source: 'hub' | 'local'; + /** Hub domain name, or "Local" for locally-authored controls. */ + domain: string; + /** Hub slug or local file stem. */ + controlName: string; + title: string; + description: string; + /** Only set for local controls (their relative path). Hub refs are built after version selection. */ + requirementRef?: string; +} + +export interface ControlBrowseGroup { + domain: string; + controls: ControlBrowseEntry[]; + /** Set when the domain fetch failed (e.g. 403 access denied). */ + error?: string; +} + +/** Domain name used for the locally-authored controls group. */ +export const LOCAL_DOMAIN = 'Local'; + +/** + * Unified browse over local + Hub controls. Local controls are returned eagerly + * (they are already scanned); Hub controls are fetched lazily per domain so an + * inaccessible domain never blocks the rest of the picker. + */ +export class ControlAssetService { + constructor( + private hubClient: HubClient | undefined, + private readonly getLocalControls: () => LocalControlDef[] + ) {} + + setHubClient(client: HubClient | undefined): void { + this.hubClient = client; + } + + /** + * The local controls group (fully populated) followed by one empty + * placeholder group per available Hub domain. Hub controls are filled in + * later via {@link browseControlsForDomain}. + */ + async browse(): Promise { + const local = this.getLocalControls(); + const groups: ControlBrowseGroup[] = [ + { + domain: LOCAL_DOMAIN, + controls: local.map((c) => ({ + source: 'local', + domain: LOCAL_DOMAIN, + controlName: c.id, + title: c.name, + description: c.description, + requirementRef: c.domain + ? buildControlCurie(c.domain, c.id) + : c.relativePath, + })), + }, + ]; + + if (this.hubClient) { + try { + const domains = await this.hubClient.getDomains(); + for (const domain of domains) { + groups.push({ domain, controls: [] }); + } + } catch { + /* Hub domains unavailable — local browse still works */ + } + } + + return groups; + } + + /** Fetch the controls for a single Hub domain. Returns an `error` group on 403/failure. */ + async browseControlsForDomain(domain: string): Promise { + if (!this.hubClient) { + return { domain, controls: [], error: 'Hub not connected' }; + } + try { + const controls = await this.hubClient.getControlsForDomain(domain); + return { + domain, + controls: controls.map((c) => ({ + source: 'hub', + domain, + controlName: c.name, + title: c.title && c.title !== c.name ? c.title : humanize(c.name), + description: c.description ?? '', + })), + }; + } catch (err) { + return { domain, controls: [], error: describeError(err) }; + } + } +} + +function humanize(slug: string): string { + return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); +} + +function describeError(err: unknown): string { + if (err instanceof HubApiError) { + if (err.status === 403) return 'Access denied (403)'; + return `Failed to load controls (${err.status})`; + } + return err instanceof Error ? err.message : String(err); +} diff --git a/calm-plugins/vscode/src/extension/services/control-curie.test.ts b/calm-plugins/vscode/src/extension/services/control-curie.test.ts new file mode 100644 index 0000000000..e3ca478e23 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/control-curie.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { + isCanonicalControlUrl, + isControlCurie, + isLocalControlPath, + isControlRef, + parseControlCurie, + parseCanonicalControlUrl, + makeControlMapKey, + buildControlCurie, + stripControlCurieVersion, +} from './control-curie'; + +const CANONICAL = + 'https://hub.example.com/calm/domains/security/controls/micro-segmentation/requirement/versions/1.0.0'; + +describe('classification', () => { + it('isCanonicalControlUrl detects https URLs targeting /calm/domains/', () => { + expect(isCanonicalControlUrl(CANONICAL)).toBe(true); + expect(isCanonicalControlUrl('https://example.com/foo')).toBe(false); + expect(isCanonicalControlUrl('security:controls:x@1.0.0')).toBe(false); + }); + + it('isControlCurie requires :controls: (version optional)', () => { + expect(isControlCurie('security:controls:micro-segmentation@1.0.0')).toBe(true); + expect(isControlCurie('security:controls:micro-segmentation')).toBe(true); + expect(isControlCurie('security:standards:x@sha')).toBe(false); + expect(isControlCurie('https://h/calm/domains/x/controls/y/requirement/versions/1.0.0')).toBe(false); + }); + + it('isLocalControlPath accepts any relative .json path', () => { + expect(isLocalControlPath('controls/micro-segmentation.requirement.json')).toBe(true); + expect(isLocalControlPath('controls/networking/tls.requirement.json')).toBe(true); + expect(isLocalControlPath('controls/x.json')).toBe(true); + expect(isLocalControlPath('/abs/controls/x.requirement.json')).toBe(false); + expect(isLocalControlPath('../x.requirement.json')).toBe(false); + expect(isLocalControlPath('C:\\controls\\x.requirement.json')).toBe(false); + expect(isLocalControlPath(CANONICAL)).toBe(false); + expect(isLocalControlPath('standards/policy.md')).toBe(false); + }); + + it('isControlRef covers all three formats', () => { + expect(isControlRef(CANONICAL)).toBe(true); + expect(isControlRef('security:controls:x@1.0.0')).toBe(true); + expect(isControlRef('controls/x.requirement.json')).toBe(true); + expect(isControlRef('controls/x.json')).toBe(true); + expect(isControlRef('not-a-ref')).toBe(false); + }); +}); + +describe('parseControlCurie', () => { + it('splits a valid CURIE into parts', () => { + expect(parseControlCurie('security:controls:micro-segmentation@1.0.0')).toEqual({ + domain: 'security', + controlName: 'micro-segmentation', + version: '1.0.0', + }); + }); + + it('accepts dash-separated versions', () => { + expect(parseControlCurie('security:controls:x@1-0-0')?.version).toBe('1-0-0'); + }); + + it('parses an unversioned CURIE (building-block form)', () => { + expect(parseControlCurie('security:controls:micro-segmentation')).toEqual({ + domain: 'security', + controlName: 'micro-segmentation', + version: undefined, + }); + }); + + it('returns null for a non-control CURIE', () => { + expect(parseControlCurie('security:standards:x@1.0.0')).toBeNull(); + }); + + it('returns null for an invalid slug', () => { + expect(parseControlCurie('security:controls:Bad_Slug@1.0.0')).toBeNull(); + }); +}); + +describe('parseCanonicalControlUrl', () => { + it('extracts domain, control, and version', () => { + expect(parseCanonicalControlUrl(CANONICAL)).toEqual({ + domain: 'security', + controlName: 'micro-segmentation', + version: '1.0.0', + }); + }); + + it('rejects URLs with query or fragment', () => { + expect(parseCanonicalControlUrl(CANONICAL + '?x=1')).toBeNull(); + expect(parseCanonicalControlUrl(CANONICAL + '#frag')).toBeNull(); + }); + + it('rejects a malformed path', () => { + expect( + parseCanonicalControlUrl('https://hub.example.com/calm/domains/security/foo') + ).toBeNull(); + }); +}); + +describe('makeControlMapKey', () => { + it('joins domain and control for a Hub CURIE', () => { + expect(makeControlMapKey('security:controls:micro-segmentation@1.0.0')).toBe( + 'security--micro-segmentation' + ); + }); + + it('joins domain and control for a canonical URL', () => { + expect(makeControlMapKey(CANONICAL)).toBe('security--micro-segmentation'); + }); + + it('uses the stem for a top-level local path', () => { + expect(makeControlMapKey('controls/micro-segmentation.requirement.json')).toBe( + 'micro-segmentation' + ); + }); + + it('uses the stem for a plain .json local path (no .requirement)', () => { + expect(makeControlMapKey('controls/tls.json')).toBe('tls'); + expect(makeControlMapKey('controls/networking/tls.json')).toBe('networking--tls'); + }); + + it('joins subdirectory segments for a nested local path', () => { + expect(makeControlMapKey('controls/networking/tls.requirement.json')).toBe( + 'networking--tls' + ); + }); + + it('produces distinct keys for the same slug in different domains', () => { + expect(makeControlMapKey('security:controls:tls@1.0.0')).not.toBe( + makeControlMapKey('privacy:controls:tls@1.0.0') + ); + }); + + it('produces distinct keys for the same stem in different subdirectories', () => { + expect(makeControlMapKey('controls/a/tls.requirement.json')).toBe('a--tls'); + expect(makeControlMapKey('controls/b/tls.requirement.json')).toBe('b--tls'); + }); + + it('only emits key characters allowed by the CALM control-map pattern', () => { + const key = makeControlMapKey('controls/net work/tls.requirement.json'); + expect(key).toMatch(/^[a-zA-Z0-9-]+$/); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/control-curie.ts b/calm-plugins/vscode/src/extension/services/control-curie.ts new file mode 100644 index 0000000000..0576fdd2d8 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/control-curie.ts @@ -0,0 +1,145 @@ +// Classification and parsing for the three control-reference formats plus the +// deterministic controls-map key derivation. Pure module — no VS Code or Node +// APIs. The three formats are, in classification order: +// 1. Canonical URL https://{base}/calm/domains/{domain}/controls/{name}/requirement/versions/{version} +// 2. Hub CURIE {domain}:controls:{name}[@{version}] (unversioned in building blocks) +// 3. Local path controls/....json (relative; `.requirement.json` is the convention) + +export interface ControlCurieResult { + domain: string; + controlName: string; + /** Undefined for an unversioned CURIE (the form used in building blocks). */ + version?: string; +} + +// Hub version scheme — three numeric parts separated by dot or dash (VERSION_REGEX). +const VERSION_RE = /^(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)[-.]?(0|[1-9][0-9]*)$/; +// Control slug — lowercase kebab (CUSTOM_ID_REGEX). +const SLUG_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; +// Domain — mixed-case alphanumeric with hyphens (DOMAIN_REGEX). +const DOMAIN_RE = /^[A-Za-z0-9-]+$/; + +/** A canonical Hub control URL: absolute http(s) URL that targets `/calm/domains/`. */ +export function isCanonicalControlUrl(ref: string): boolean { + return /^https?:\/\//i.test(ref) && ref.includes('/calm/domains/'); +} + +/** A Hub control CURIE: contains `:controls:`. The `@version` suffix is optional (unversioned in building blocks). */ +export function isControlCurie(ref: string): boolean { + return ref.includes(':controls:') && !ref.includes('://'); +} + +/** + * A local requirement path: any relative `*.json` with no scheme, no absolute + * prefix, and no parent traversal. `.requirement.json` is the convention but any + * `.json` is accepted. + */ +export function isLocalControlPath(ref: string): boolean { + if (ref.includes('://')) return false; + if (ref.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(ref)) return false; + if (ref.includes('..')) return false; + return /\.json$/.test(ref); +} + +/** Parse a Hub CURIE `{domain}:controls:{name}[@{version}]`; `null` if not one. Version is optional. */ +export function parseControlCurie(ref: string): ControlCurieResult | null { + if (!isControlCurie(ref)) return null; + const atIdx = ref.lastIndexOf('@'); + const version = atIdx === -1 ? undefined : ref.slice(atIdx + 1); + const head = atIdx === -1 ? ref : ref.slice(0, atIdx); + const parts = head.split(':'); + if (parts.length !== 3 || parts[1] !== 'controls') return null; + const [domain, , controlName] = parts; + if (!DOMAIN_RE.test(domain)) return null; + if (!SLUG_RE.test(controlName)) return null; + if (version !== undefined && !VERSION_RE.test(version)) return null; + return { domain, controlName, version }; +} + +/** Build a control CURIE from parts. Omits the version when not given (building-block form). */ +export function buildControlCurie( + domain: string, + controlName: string, + version?: string +): string { + return version + ? `${domain}:controls:${controlName}@${version}` + : `${domain}:controls:${controlName}`; +} + +/** Return a control CURIE without its `@version` suffix. Non-CURIEs are returned unchanged. */ +export function stripControlCurieVersion(ref: string): string { + const parsed = parseControlCurie(ref); + if (!parsed) return ref; + return buildControlCurie(parsed.domain, parsed.controlName); +} + +/** + * Parse a canonical control URL into domain / control / version. Returns `null` + * if the path does not match the expected control-requirement shape. + */ +export function parseCanonicalControlUrl(ref: string): ControlCurieResult | null { + if (!isCanonicalControlUrl(ref)) return null; + let url: URL; + try { + url = new URL(ref); + } catch { + return null; + } + if (url.search || url.hash) return null; + const segs = url.pathname.split('/').filter((s) => s.length > 0); + // .../calm/domains/{domain}/controls/{name}/requirement/versions/{version} + const dIdx = segs.indexOf('domains'); + if (dIdx === -1) return null; + const domain = decodeURIComponent(segs[dIdx + 1] ?? ''); + const controlsKw = segs[dIdx + 2]; + const controlName = decodeURIComponent(segs[dIdx + 3] ?? ''); + const requirementKw = segs[dIdx + 4]; + const versionsKw = segs[dIdx + 5]; + const version = decodeURIComponent(segs[dIdx + 6] ?? ''); + if ( + controlsKw !== 'controls' || + requirementKw !== 'requirement' || + versionsKw !== 'versions' + ) { + return null; + } + if (!DOMAIN_RE.test(domain) || !SLUG_RE.test(controlName)) return null; + if (!VERSION_RE.test(version)) return null; + return { domain, controlName, version }; +} + +/** True if `ref` is any of the three recognized control reference formats. */ +export function isControlRef(ref: string): boolean { + return ( + isCanonicalControlUrl(ref) || + isControlCurie(ref) || + isLocalControlPath(ref) + ); +} + +/** + * Derive a deterministic CALM controls-map key from a control reference. Keys + * must match `^[a-zA-Z0-9-]+$` (no slashes), so path segments are joined with a + * `--` separator. Hub slugs use single hyphens only, so `--` is unambiguous. + * + * - Hub CURIE / canonical URL → `{domain}--{controlName}` + * - Local path → subdirectory segments (below `controls/`) + stem, joined `--` + */ +export function makeControlMapKey(ref: string): string { + const hub = parseCanonicalControlUrl(ref) ?? parseControlCurie(ref); + if (hub) return `${hub.domain}--${hub.controlName}`; + + // Local path — build the key from the full relative path to avoid collisions + // between identical stems in different subdirectories. + const normalized = ref.replace(/\\/g, '/'); + const stem = normalized + .replace(/^.*\//, '') + .replace(/(\.requirement)?\.json$/, ''); + const segments = normalized.split('/').filter((s) => s.length > 0); + // Drop the leading `controls/` prefix and the filename itself. + const start = segments[0] === 'controls' ? 1 : 0; + const dirs = segments.slice(start, -1); + const key = [...dirs, stem].join('--'); + return key.replace(/[^a-zA-Z0-9-]/g, '-'); +} diff --git a/calm-plugins/vscode/src/extension/services/hub-asset-service.test.ts b/calm-plugins/vscode/src/extension/services/hub-asset-service.test.ts new file mode 100644 index 0000000000..123b6040a6 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-asset-service.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { HubAssetService } from './hub-asset-service'; +import { HubClient } from './hub-client'; + +function createMockClient(): HubClient { + return { + getNamespaces: vi.fn(), + getResources: vi.fn(), + getVersions: vi.fn(), + getResourceAtVersion: vi.fn(), + getAdrs: vi.fn().mockResolvedValue([]), + } as unknown as HubClient; +} + +describe('HubAssetService', () => { + let service: HubAssetService; + let mockClient: HubClient; + + beforeEach(() => { + mockClient = createMockClient(); + service = new HubAssetService(mockClient); + }); + + describe('refresh', () => { + it('fetches building blocks for each namespace', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'finos' }, + { name: 'acme' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (ns: string, type: string) => { + if (ns === 'finos' && type === 'building-blocks') { + return Promise.resolve([ + { uniqueId: 'microservice', name: 'Microservice', numericId: 1 }, + ]); + } + if (ns === 'acme' && type === 'building-blocks') { + return Promise.resolve([ + { uniqueId: 'api-gateway', name: 'API Gateway', numericId: 3 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockImplementation( + (_ns: string, _type: string, _name: string) => { + return Promise.resolve(['sha-1', 'sha-2', 'sha-latest']); + } + ); + + const namespaces = await service.refresh(); + + expect(namespaces).toHaveLength(2); + expect(namespaces[0].name).toBe('finos'); + expect(namespaces[0].buildingBlocks).toHaveLength(1); + expect(namespaces[0].buildingBlocks[0]).toEqual( + expect.objectContaining({ + id: 'microservice', + name: 'Microservice', + behaviour: 'create-node', + namespace: 'finos', + sha: 'sha-latest', + nodeType: 'service', + }) + ); + expect(namespaces[1].name).toBe('acme'); + expect(namespaces[1].buildingBlocks).toHaveLength(1); + }); + + it('handles namespace with no building-blocks gracefully', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'empty-ns' }, + ]); + (mockClient.getResources as ReturnType).mockRejectedValue( + new Error('Not found') + ); + + const namespaces = await service.refresh(); + + expect(namespaces).toHaveLength(1); + expect(namespaces[0].buildingBlocks).toHaveLength(0); + expect(namespaces[0].patterns).toHaveLength(0); + }); + + it('uses latest version (last element) as SHA', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'ns1' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (_ns: string, type: string) => { + if (type === 'building-blocks') { + return Promise.resolve([ + { uniqueId: 'svc', name: 'Service', numericId: 1 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockResolvedValue([ + 'v1', + 'v2', + 'v3-latest', + ]); + + const namespaces = await service.refresh(); + + expect(namespaces[0].buildingBlocks[0].sha).toBe('v3-latest'); + }); + + it('sets sha to undefined when no versions exist', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'ns1' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (_ns: string, type: string) => { + if (type === 'building-blocks') { + return Promise.resolve([ + { uniqueId: 'svc', name: 'Service', numericId: 1 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockResolvedValue([]); + + const namespaces = await service.refresh(); + + expect(namespaces[0].buildingBlocks[0].sha).toBeUndefined(); + }); + }); + + describe('getNamespaces', () => { + it('returns empty array before refresh', () => { + expect(service.getNamespaces()).toEqual([]); + }); + + it('returns cached namespaces after refresh', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'ns1' }, + ]); + (mockClient.getResources as ReturnType).mockResolvedValue([]); + + await service.refresh(); + + expect(service.getNamespaces()).toHaveLength(1); + expect(service.getNamespaces()[0].name).toBe('ns1'); + }); + }); + + describe('getAllBuildingBlocks', () => { + it('returns flattened blocks from all namespaces', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'ns1' }, + { name: 'ns2' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (ns: string, type: string) => { + if (type === 'building-blocks') { + return Promise.resolve([ + { uniqueId: `${ns}-block`, name: `${ns} Block`, numericId: 1 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockResolvedValue(['v1']); + + await service.refresh(); + + const blocks = service.getAllBuildingBlocks(["ns1", "ns2"]); + expect(blocks).toHaveLength(2); + expect(blocks[0].namespace).toBe('ns1'); + expect(blocks[1].namespace).toBe('ns2'); + }); + }); + + describe('getAllPatterns', () => { + it('returns flattened patterns from selected namespaces', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'ns1' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (_ns: string, type: string) => { + if (type === 'patterns') { + return Promise.resolve([ + { uniqueId: 'api-pattern', name: 'API Pattern', numericId: 1 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockResolvedValue(['sha-1']); + (mockClient.getResourceAtVersion as ReturnType).mockResolvedValue({ + title: 'API Gateway Pattern', + description: 'A standard API gateway topology', + category: 'networking', + properties: { nodes: { prefixItems: [] }, relationships: { prefixItems: [] } }, + }); + + await service.refresh(); + + const patterns = service.getAllPatterns(['ns1']); + expect(patterns).toHaveLength(1); + expect(patterns[0]).toEqual(expect.objectContaining({ + id: 'api-pattern', + name: 'API Gateway Pattern', + description: 'A standard API gateway topology', + category: 'networking', + })); + expect(patterns[0].schema).toBeDefined(); + }); + + it('skips patterns with no versions', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'ns1' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (_ns: string, type: string) => { + if (type === 'patterns') { + return Promise.resolve([ + { uniqueId: 'no-version', name: 'No Version', numericId: 1 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockResolvedValue([]); + + await service.refresh(); + + expect(service.getAllPatterns(['ns1'])).toHaveLength(0); + }); + + it('handles namespace with no patterns gracefully', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'ns1' }, + ]); + (mockClient.getResources as ReturnType).mockRejectedValue( + new Error('Not found') + ); + + const namespaces = await service.refresh(); + + expect(namespaces[0].patterns).toHaveLength(0); + }); + + it('uses namespace name as category fallback', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'finos' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (_ns: string, type: string) => { + if (type === 'patterns') { + return Promise.resolve([ + { uniqueId: 'minimal', name: 'Minimal', numericId: 1 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockResolvedValue(['v1']); + (mockClient.getResourceAtVersion as ReturnType).mockResolvedValue({ + title: 'Minimal Pattern', + properties: {}, + }); + + await service.refresh(); + + const patterns = service.getAllPatterns(['finos']); + expect(patterns[0].category).toBe('finos'); + }); + }); + + describe('ADR fetching', () => { + it('flattens and normalizes ADR summaries per namespace', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'finos' }, + ]); + (mockClient.getResources as ReturnType).mockResolvedValue([]); + (mockClient.getAdrs as ReturnType).mockResolvedValue([ + { id: 1, title: 'Use Event Sourcing', status: 'Accepted' }, + { id: 2, status: 'proposed' }, + ]); + + await service.refresh(); + + const adrs = service.getAllAdrs(['finos']); + expect(adrs).toEqual([ + { namespace: 'finos', id: 1, title: 'Use Event Sourcing', status: 'accepted' }, + { namespace: 'finos', id: 2, title: '', status: 'proposed' }, + ]); + }); + + it('filters out summaries with a null id', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'finos' }, + ]); + (mockClient.getResources as ReturnType).mockResolvedValue([]); + (mockClient.getAdrs as ReturnType).mockResolvedValue([ + { id: null, title: 'Malformed' }, + { id: 5, title: 'Good', status: 'draft' }, + ]); + + await service.refresh(); + + const adrs = service.getAllAdrs(['finos']); + expect(adrs).toHaveLength(1); + expect(adrs[0].id).toBe(5); + }); + + it('isolates ADR fetch failures from other assets', async () => { + (mockClient.getNamespaces as ReturnType).mockResolvedValue([ + { name: 'finos' }, + ]); + (mockClient.getResources as ReturnType).mockImplementation( + (_ns: string, type: string) => { + if (type === 'building-blocks') { + return Promise.resolve([ + { uniqueId: 'svc', name: 'Service', numericId: 1 }, + ]); + } + return Promise.resolve([]); + } + ); + (mockClient.getVersions as ReturnType).mockResolvedValue(['v1']); + (mockClient.getAdrs as ReturnType).mockRejectedValue( + new Error('403 forbidden') + ); + + const namespaces = await service.refresh(); + + expect(namespaces[0].adrs).toHaveLength(0); + expect(namespaces[0].buildingBlocks).toHaveLength(1); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/hub-asset-service.ts b/calm-plugins/vscode/src/extension/services/hub-asset-service.ts new file mode 100644 index 0000000000..0f95082a9d --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-asset-service.ts @@ -0,0 +1,166 @@ +import { HubClient, type NamespaceSummary } from './hub-client'; +import type { BuildingBlockDef, PatternEntry } from './workspace-asset-service'; +import type { AdrEntry } from '../types/messages'; + +export interface HubNamespace { + name: string; + buildingBlocks: BuildingBlockDef[]; + patterns: PatternEntry[]; + adrs: AdrEntry[]; +} + +export class HubAssetService { + private namespaces: HubNamespace[] = []; + private client: HubClient; + + constructor(client: HubClient) { + this.client = client; + } + + async refresh(): Promise { + const nsList: NamespaceSummary[] = await this.client.getNamespaces(); + this.namespaces = []; + + for (const ns of nsList) { + const namespace: HubNamespace = { + name: ns.name, + buildingBlocks: [], + patterns: [], + adrs: [], + }; + + try { + const blocks = await this.client.getResources( + ns.name, + 'building-blocks' + ); + const versionResults = await Promise.all( + blocks.map((block) => + this.client + .getVersions(ns.name, 'building-blocks', block.uniqueId) + .then((versions) => ({ block, versions })) + .catch(() => ({ block, versions: [] as string[] })) + ) + ); + for (const { block, versions } of versionResults) { + const latestSha = + versions.length > 0 + ? versions[versions.length - 1] + : undefined; + // Fetch content to get the actual node-type and display name + let nodeType = 'service'; + let displayName = block.name || humanize(block.uniqueId); + if (latestSha) { + try { + const content = await this.client.getResourceAtVersion( + ns.name, 'building-blocks', block.uniqueId, latestSha + ) as Record; + const nodes = content?.nodes as Array> | undefined; + if (nodes?.[0]?.['node-type']) { + nodeType = nodes[0]['node-type'] as string; + } + if (nodes?.[0]?.name) { + displayName = nodes[0].name as string; + } else if (content?.title) { + displayName = content.title as string; + } + } catch { /* fallback */ } + } + namespace.buildingBlocks.push({ + id: block.uniqueId, + name: displayName, + behaviour: 'create-node', + controls: {}, + nodeType, + namespace: ns.name, + sha: latestSha, + }); + } + } catch { + /* namespace may not have building-blocks */ + } + + try { + const patterns = await this.client.getResources( + ns.name, + 'patterns' + ); + const versionResults = await Promise.all( + patterns.map((pat) => + this.client + .getVersions(ns.name, 'patterns', pat.uniqueId) + .then((versions) => ({ pat, versions })) + .catch(() => ({ pat, versions: [] as string[] })) + ) + ); + for (const { pat, versions } of versionResults) { + const latestSha = + versions.length > 0 + ? versions[versions.length - 1] + : undefined; + if (!latestSha) continue; + try { + const content = await this.client.getResourceAtVersion( + ns.name, 'patterns', pat.uniqueId, latestSha + ) as Record; + namespace.patterns.push({ + id: pat.uniqueId, + name: (content.title as string) || pat.name || humanize(pat.uniqueId), + description: (content.description as string) || '', + category: (content.category as string) || (content['x-category'] as string) || ns.name, + schema: content, + }); + } catch { /* skip unreadable pattern */ } + } + } catch { + /* namespace may not have patterns */ + } + + // ADR fetch is isolated in its own try/catch so a failure here never + // blocks building-block / standard / pattern loading for this namespace. + try { + const adrSummaries = await this.client.getAdrs(ns.name); + for (const adr of adrSummaries) { + if (adr.id === null || adr.id === undefined) continue; + namespace.adrs.push({ + namespace: ns.name, + id: adr.id, + title: adr.title ?? '', + status: (adr.status ?? '').toLowerCase(), + }); + } + } catch { + /* namespace may not expose ADRs, or the caller lacks access */ + } + + this.namespaces.push(namespace); + } + + return this.namespaces; + } + + getNamespaces(): HubNamespace[] { + return this.namespaces; + } + + getAllBuildingBlocks(selectedNamespaces: string[]): BuildingBlockDef[] { + const filtered = this.namespaces.filter((ns) => selectedNamespaces.includes(ns.name)); + return filtered.flatMap((ns) => ns.buildingBlocks); + } + + getAllPatterns(selectedNamespaces: string[]): PatternEntry[] { + const filtered = this.namespaces.filter((ns) => selectedNamespaces.includes(ns.name)); + return filtered.flatMap((ns) => ns.patterns); + } + + getAllAdrs(selectedNamespaces: string[]): AdrEntry[] { + const filtered = this.namespaces.filter((ns) => selectedNamespaces.includes(ns.name)); + return filtered.flatMap((ns) => ns.adrs); + } +} + +function humanize(slug: string): string { + return slug + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, (c) => c.toUpperCase()); +} diff --git a/calm-plugins/vscode/src/extension/services/hub-auth-service.test.ts b/calm-plugins/vscode/src/extension/services/hub-auth-service.test.ts new file mode 100644 index 0000000000..3fb542c39b --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-auth-service.test.ts @@ -0,0 +1,170 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { HubAuthService } from './hub-auth-service'; +import { HubClient } from './hub-client'; + +// Mock fetch globally for HubClient +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => data, + } as unknown as Response; +} + +function createMockSecrets(): vscode.SecretStorage { + const store = new Map(); + return { + get: vi.fn(async (key: string) => store.get(key)), + store: vi.fn(async (key: string, value: string) => { + store.set(key, value); + }), + delete: vi.fn(async (key: string) => { + store.delete(key); + }), + onDidChange: vi.fn() as unknown, + } as unknown as vscode.SecretStorage; +} + +describe('HubAuthService', () => { + let client: HubClient; + let secrets: vscode.SecretStorage; + let authService: HubAuthService; + + beforeEach(() => { + mockFetch.mockReset(); + client = new HubClient('https://hub.example.com'); + secrets = createMockSecrets(); + authService = new HubAuthService(secrets, client); + }); + + describe('initialize', () => { + it('loads stored token and sets auth headers', async () => { + // Pre-store a token in the mock secret storage + await secrets.store('calm.hub.token', 'stored-token'); + + await authService.initialize(); + + // The client should have the auth header set; verify by + // checking that the next authenticated call uses it + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: [] }) + ); + await client.getNamespaces(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer stored-token', + }), + }) + ); + }); + + it('does nothing when no token is stored', async () => { + await authService.initialize(); + + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: [] }) + ); + await client.getNamespaces(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.any(String), + }), + }) + ); + }); + }); + + describe('discoverAndAuthenticate', () => { + it('succeeds without token when auth is disabled', async () => { + // getAuthConfig call + mockFetch.mockResolvedValueOnce( + jsonResponse({ authEnabled: false }) + ); + + const result = await authService.discoverAndAuthenticate(); + expect(result).toBe(true); + expect(authService.isAuthenticated).toBe(true); + }); + + it('uses stored token when auth is enabled and token is valid', async () => { + // Pre-store a token + await secrets.store('calm.hub.token', 'valid-token'); + await authService.initialize(); + + // getAuthConfig + mockFetch.mockResolvedValueOnce( + jsonResponse({ authEnabled: true }) + ); + // getNamespaces validation call (token works) + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: [{ name: 'ns1' }] }) + ); + + const result = await authService.discoverAndAuthenticate(); + expect(result).toBe(true); + expect(authService.isAuthenticated).toBe(true); + }); + + it('delegates to Hub login when auth is enabled and no stored token', () => { + // Hub-delegated login (loginViaHub) opens a browser and waits for a localhost callback. + // This is inherently an integration test — verifying the flow requires a running Hub + browser. + // Unit-testable paths: auth-disabled, stored-token-valid, signOut, and network-error. + expect(true).toBe(true); + }); + + it('returns false when getAuthConfig throws', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const result = await authService.discoverAndAuthenticate(); + expect(result).toBe(false); + }); + }); + + describe('signOut', () => { + it('clears token and auth headers', async () => { + await secrets.store('calm.hub.token', 'my-token'); + await authService.initialize(); + + await authService.signOut(); + + expect(secrets.delete).toHaveBeenCalledWith('calm.hub.token'); + expect(authService.isAuthenticated).toBe(false); + + // Verify auth headers are cleared + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: [] }) + ); + await client.getNamespaces(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.not.objectContaining({ + Authorization: expect.any(String), + }), + }) + ); + }); + }); + + describe('isAuthenticated', () => { + it('is false initially', () => { + expect(authService.isAuthenticated).toBe(false); + }); + + it('is true after successful auth with disabled auth', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ authEnabled: false }) + ); + await authService.discoverAndAuthenticate(); + expect(authService.isAuthenticated).toBe(true); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/hub-auth-service.ts b/calm-plugins/vscode/src/extension/services/hub-auth-service.ts new file mode 100644 index 0000000000..70ecf9b230 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-auth-service.ts @@ -0,0 +1,155 @@ +import * as vscode from 'vscode'; +import * as http from 'http'; +import * as crypto from 'crypto'; +import { HubClient, HubAuthConfig } from './hub-client'; + +/** + * Authentication service that delegates the OIDC flow to CalmHub. + * The plugin opens the Hub's /api/calm/auth/plugin-login in a browser, + * the Hub handles OIDC, then redirects back to the plugin's localhost + * callback with the token. + */ +export class HubAuthService { + private token: string | undefined; + private authConfig: HubAuthConfig | undefined; + + constructor( + private secrets: vscode.SecretStorage, + private client: HubClient + ) {} + + async initialize(): Promise { + this.token = (await this.secrets.get('calm.hub.token')) ?? undefined; + if (this.token) { + this.client.setAuthHeaders({ Authorization: `Bearer ${this.token}` }); + } + } + + async discoverAndAuthenticate(): Promise { + try { + this.authConfig = await this.client.getAuthConfig(); + + if (!this.authConfig.authEnabled) { + this.client.setAuthHeaders({}); + return true; + } + + // If we have a stored token, try it + if (this.token) { + this.client.setAuthHeaders({ + Authorization: `Bearer ${this.token}`, + }); + try { + await this.client.getNamespaces(); + return true; + } catch { + // Token expired, need re-auth + } + } + + // Delegate authentication entirely to the Hub + const token = await this.loginViaHub(); + if (token) { + this.token = token; + await this.secrets.store('calm.hub.token', token); + this.client.setAuthHeaders({ Authorization: `Bearer ${token}` }); + return true; + } + + return false; + } catch { + return false; + } + } + + async signOut(): Promise { + this.token = undefined; + this.authConfig = undefined; + await this.secrets.delete('calm.hub.token'); + this.client.setAuthHeaders({}); + } + + get isAuthenticated(): boolean { + return ( + this.authConfig !== undefined && + (!this.authConfig.authEnabled || !!this.token) + ); + } + + private async loginViaHub(): Promise { + const port = await this.findFreePort(); + const nonce = crypto.randomBytes(16).toString('hex'); + + const hubBaseUrl = this.client.getBaseUrl(); + const loginUrl = `${hubBaseUrl}/api/calm/auth/plugin-login?port=${port}&nonce=${nonce}`; + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + server.close(); + resolve(undefined); + }, 120_000); + + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? '', `http://localhost:${port}`); + + if (url.pathname === '/callback') { + const receivedToken = url.searchParams.get('token'); + const receivedNonce = url.searchParams.get('nonce'); + const error = url.searchParams.get('error'); + + res.writeHead(200, { 'Content-Type': 'text/html' }); + + if (error) { + res.end('

Authentication failed

An error occurred during authentication.

'); + clearTimeout(timeout); + server.close(); + resolve(undefined); + return; + } + + if (receivedNonce !== nonce) { + res.end('

Authentication failed

Invalid nonce — possible CSRF attack.

'); + clearTimeout(timeout); + server.close(); + resolve(undefined); + return; + } + + if (receivedToken) { + res.end('

Authentication successful!

You can close this tab and return to VS Code.

'); + clearTimeout(timeout); + server.close(); + resolve(receivedToken); + } else { + res.end('

Authentication failed

No token received.

'); + clearTimeout(timeout); + server.close(); + resolve(undefined); + } + return; + } + }); + + server.listen(port, () => { + vscode.env.openExternal(vscode.Uri.parse(loginUrl)); + }); + + server.on('error', () => { + clearTimeout(timeout); + resolve(undefined); + }); + }); + } + + private findFreePort(): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer(); + server.listen(0, () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + server.close(() => resolve(port)); + }); + server.on('error', reject); + }); + } +} diff --git a/calm-plugins/vscode/src/extension/services/hub-client.test.ts b/calm-plugins/vscode/src/extension/services/hub-client.test.ts new file mode 100644 index 0000000000..631b6bd8fa --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-client.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { HubClient, HubApiError } from './hub-client'; + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function jsonResponse(data: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + json: async () => data, + } as unknown as Response; +} + +describe('HubClient', () => { + let client: HubClient; + + beforeEach(() => { + mockFetch.mockReset(); + client = new HubClient('https://hub.example.com/'); + }); + + it('strips trailing slash from base URL', () => { + // Verify by calling getAuthConfig and checking the URL passed to fetch + mockFetch.mockResolvedValueOnce( + jsonResponse({ authEnabled: false }) + ); + client.getAuthConfig(); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/api/calm/auth/config' + ); + }); + + describe('getAuthConfig', () => { + it('returns auth config on success', async () => { + const rawResponse = { + oidc: { + enabled: true, + authority: 'https://auth.example.com', + clientId: 'calm-vscode', + scopes: ['openid', 'profile'], + }, + }; + mockFetch.mockResolvedValueOnce(jsonResponse(rawResponse)); + + const result = await client.getAuthConfig(); + expect(result).toEqual({ + authEnabled: true, + oidcAuthority: 'https://auth.example.com', + oidcClientId: 'calm-vscode', + oidcScopes: 'openid profile', + }); + }); + + it('throws on non-OK response', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, 500)); + + await expect(client.getAuthConfig()).rejects.toThrow( + 'Auth config failed: 500' + ); + }); + }); + + describe('getNamespaces', () => { + it('returns namespaces from values wrapper', async () => { + const namespaces = [{ name: 'finos' }, { name: 'internal' }]; + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: namespaces }) + ); + + const result = await client.getNamespaces(); + expect(result).toEqual(namespaces); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/api/calm/namespaces', + expect.objectContaining({ + headers: expect.objectContaining({ + Accept: 'application/json', + }), + }) + ); + }); + + it('returns namespaces from bare array', async () => { + const namespaces = [{ name: 'default' }]; + mockFetch.mockResolvedValueOnce(jsonResponse(namespaces)); + + const result = await client.getNamespaces(); + expect(result).toEqual(namespaces); + }); + + it('includes auth headers when set', async () => { + client.setAuthHeaders({ + Authorization: 'Bearer my-token', + }); + mockFetch.mockResolvedValueOnce(jsonResponse({ values: [] })); + + await client.getNamespaces(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer my-token', + Accept: 'application/json', + }), + }) + ); + }); + + it('throws on non-OK response', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, 401)); + + await expect(client.getNamespaces()).rejects.toThrow( + 'Hub request failed: 401' + ); + }); + }); + + describe('getResources', () => { + it('constructs correct URL', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: [] }) + ); + + await client.getResources('finos', 'architectures'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/calm/namespaces/finos/architectures', + expect.any(Object) + ); + }); + }); + + describe('getVersions', () => { + it('constructs correct URL', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: ['1.0', '2.0'] }) + ); + + const result = await client.getVersions( + 'finos', + 'architectures', + 'my-arch' + ); + expect(result).toEqual(['1.0', '2.0']); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/calm/namespaces/finos/architectures/my-arch/versions', + expect.any(Object) + ); + }); + }); + + describe('getResourceAtVersion', () => { + it('returns parsed JSON body', async () => { + const resource = { nodes: [], relationships: [] }; + mockFetch.mockResolvedValueOnce(jsonResponse(resource)); + + const result = await client.getResourceAtVersion( + 'finos', + 'architectures', + 'my-arch', + '1.0' + ); + expect(result).toEqual(resource); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/calm/namespaces/finos/architectures/my-arch/versions/1.0', + expect.any(Object) + ); + }); + }); + + describe('HubApiError', () => { + it('throws a typed error carrying the status and url on 403', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, 403)); + + const err = await client.getDomains().catch((e) => e); + expect(err).toBeInstanceOf(HubApiError); + expect(err.status).toBe(403); + expect(err.url).toBe('https://hub.example.com/calm/domains'); + }); + + it('carries a 404 status', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, 404)); + + const err = await client + .getControlsForDomain('security') + .catch((e) => e); + expect(err).toBeInstanceOf(HubApiError); + expect(err.status).toBe(404); + }); + }); + + describe('getDomains', () => { + it('unwraps the values wrapper into a string array', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: ['security', 'privacy'] }) + ); + + const result = await client.getDomains(); + expect(result).toEqual(['security', 'privacy']); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/calm/domains', + expect.any(Object) + ); + }); + }); + + describe('getControlsForDomain', () => { + it('returns control details and encodes the domain', async () => { + const controls = [ + { id: 1, name: 'micro-segmentation', description: 'd', title: 't' }, + ]; + mockFetch.mockResolvedValueOnce(jsonResponse({ values: controls })); + + const result = await client.getControlsForDomain('security'); + expect(result).toEqual(controls); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/calm/domains/security/controls', + expect.any(Object) + ); + }); + }); + + describe('resolveControlId', () => { + it('resolves a control name to its numeric ID and domain', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: [{ id: 42, name: 'micro-segmentation', description: 'd' }] }) + ); + const result = await client.resolveControlId('security', 'micro-segmentation'); + expect(result).toEqual({ id: 42, domain: 'security' }); + }); + + it('falls back to searching other domains when primary domain 404s', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, 404)); + mockFetch.mockResolvedValueOnce(jsonResponse({ values: ['platform', 'network'] })); + mockFetch.mockResolvedValueOnce(jsonResponse({ values: [{ id: 99, name: 'seg', description: '' }] })); + const result = await client.resolveControlId('bad-domain', 'seg'); + expect(result).toEqual({ id: 99, domain: 'platform' }); + }); + + it('throws when not found in any domain', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({}, 404)); + mockFetch.mockResolvedValueOnce(jsonResponse({ values: ['platform'] })); + mockFetch.mockResolvedValueOnce(jsonResponse({ values: [{ id: 1, name: 'other', description: '' }] })); + await expect(client.resolveControlId('bad', 'missing')).rejects.toThrow('not found'); + }); + }); + + describe('getRequirementVersions', () => { + it('constructs the numeric-ID requirement versions URL', async () => { + mockFetch.mockResolvedValueOnce( + jsonResponse({ values: ['1.0.0', '1.1.0'] }) + ); + + const result = await client.getRequirementVersions('security', 42); + expect(result).toEqual(['1.0.0', '1.1.0']); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/api/calm/domains/security/controls/42/requirement/versions', + expect.any(Object) + ); + }); + }); + + describe('getRequirementAtVersion', () => { + it('returns the raw requirement JSON (no values wrapper)', async () => { + const schema = { $id: 'x', properties: {} }; + mockFetch.mockResolvedValueOnce(jsonResponse(schema)); + + const result = await client.getRequirementAtVersion('security', 42, '1.0.0'); + expect(result).toEqual(schema); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/api/calm/domains/security/controls/42/requirement/versions/1.0.0', + expect.any(Object) + ); + }); + }); + + describe('ADR methods', () => { + it('getAdrs unwraps the summary list', async () => { + const adrs = [{ id: 1, title: 'Use X', status: 'accepted' }]; + mockFetch.mockResolvedValueOnce(jsonResponse({ values: adrs })); + + const result = await client.getAdrs('finos'); + expect(result).toEqual(adrs); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/api/calm/namespaces/finos/adrs', + expect.any(Object) + ); + }); + + it('getAdr returns the AdrMeta wrapper with nested adr content', async () => { + const meta = { + namespace: 'finos', + id: 3, + revision: 2, + adr: { title: 'Use X', status: 'accepted' }, + }; + mockFetch.mockResolvedValueOnce(jsonResponse(meta)); + + const result = await client.getAdr('finos', 3); + expect(result).toEqual(meta); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/api/calm/namespaces/finos/adrs/3', + expect.any(Object) + ); + }); + + it('getAdrRevisions unwraps numeric revisions', async () => { + mockFetch.mockResolvedValueOnce(jsonResponse({ values: [1, 2, 3] })); + + const result = await client.getAdrRevisions('finos', 3); + expect(result).toEqual([1, 2, 3]); + expect(mockFetch).toHaveBeenCalledWith( + 'https://hub.example.com/api/calm/namespaces/finos/adrs/3/revisions', + expect.any(Object) + ); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/hub-client.ts b/calm-plugins/vscode/src/extension/services/hub-client.ts new file mode 100644 index 0000000000..97eb96e8f9 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-client.ts @@ -0,0 +1,268 @@ +export interface HubAuthConfig { + authEnabled: boolean; + oidcAuthority?: string; + oidcClientId?: string; + oidcScopes?: string; +} + +interface RawAuthConfigResponse { + oidc?: { + enabled?: boolean; + authority?: string; + clientId?: string; + scopes?: string[]; + provider?: string; + }; + github?: { + enabled?: boolean; + oauthClientId?: string; + }; + databaseMode?: string; +} + +export interface NamespaceSummary { + name: string; +} + +export interface ResourceSummary { + name: string; + uniqueId: string; + numericId: number; + versionCount?: number; + customId?: string; +} + +/** A control listed under a Hub domain. `name` is the kebab-case slug used in API paths. */ +export interface ControlDetail { + id: number; + name: string; + description: string; + title?: string; +} + +/** Summary entry from `GET /adrs` — flat, top-level fields. `id` may be null for malformed rows. */ +export interface AdrSummary { + id: number | null; + title?: string; + status?: string; +} + +/** Flattened ADR content (from the `adr` member of the `AdrMeta` wrapper). */ +export interface AdrContent { + title: string; + status: string; + contextAndProblemStatement?: string; + decisionDrivers?: string[]; + consideredOptions?: unknown[]; + decisionOutcome?: unknown; + links?: unknown[]; +} + +/** Wrapper returned by `GET /adrs/{id}` — ADR content is nested under `adr`. */ +export interface AdrMeta { + namespace: string; + id: number; + revision: number; + adr: AdrContent; +} + +/** Typed HTTP error so callers can branch on `status` (e.g. 403 domain access denied). */ +export class HubApiError extends Error { + constructor( + public readonly status: number, + public readonly url: string, + message: string + ) { + super(message); + this.name = 'HubApiError'; + } +} + +export class HubClient { + private baseUrl: string; + private authHeaders: Record = {}; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + } + + getBaseUrl(): string { + return this.baseUrl; + } + + setAuthHeaders(headers: Record): void { + this.authHeaders = headers; + } + + async getAuthConfig(): Promise { + const res = await fetch(`${this.baseUrl}/api/calm/auth/config`); + if (!res.ok) throw new Error(`Auth config failed: ${res.status}`); + const raw: RawAuthConfigResponse = await res.json(); + return { + authEnabled: raw.oidc?.enabled ?? false, + oidcAuthority: raw.oidc?.authority, + oidcClientId: raw.oidc?.clientId, + oidcScopes: raw.oidc?.scopes?.join(' '), + }; + } + + async getNamespaces(): Promise { + const res = await this.authenticatedFetch('/api/calm/namespaces'); + const data = await res.json(); + return this.unwrapValues(data); + } + + async getResources( + namespace: string, + type: string + ): Promise { + const res = await this.authenticatedFetch( + `/calm/namespaces/${namespace}/${type}` + ); + const data = await res.json(); + const raw = this.unwrapValues>(data); + return raw.map((item) => ({ + name: (item.name as string) || (item.customId as string) || '', + uniqueId: (item.uniqueId as string) || (item.customId as string) || '', + numericId: (item.numericId as number) || 0, + versionCount: item.versionCount as number | undefined, + })); + } + + async getVersions( + namespace: string, + type: string, + name: string + ): Promise { + const res = await this.authenticatedFetch( + `/calm/namespaces/${namespace}/${type}/${name}/versions` + ); + const data = await res.json(); + return this.unwrapValues(data); + } + + async getResourceAtVersion( + namespace: string, + type: string, + name: string, + version: string + ): Promise { + const res = await this.authenticatedFetch( + `/calm/namespaces/${namespace}/${type}/${name}/versions/${version}` + ); + return res.json(); + } + + // --- Domains & controls (name-mapped `/calm/domains` API) --- + + async getDomains(): Promise { + const res = await this.authenticatedFetch('/calm/domains'); + const data = await res.json(); + return this.unwrapValues(data); + } + + async getControlsForDomain(domain: string): Promise { + const res = await this.authenticatedFetch( + `/calm/domains/${encodeURIComponent(domain)}/controls` + ); + const data = await res.json(); + return this.unwrapValues(data); + } + + async resolveControlId(domain: string, controlName: string): Promise<{ id: number; domain: string }> { + try { + const controls = await this.getControlsForDomain(domain); + const match = controls.find((c) => c.name === controlName); + if (match) return { id: match.id, domain }; + } catch { + // Domain may not exist (e.g. CURIE uses a namespace prefix, not a Hub domain) + } + const domains = await this.getDomains(); + for (const d of domains) { + if (d === domain) continue; + try { + const controls = await this.getControlsForDomain(d); + const match = controls.find((c) => c.name === controlName); + if (match) return { id: match.id, domain: d }; + } catch { continue; } + } + throw new HubApiError(404, '', `Control "${controlName}" not found in domain "${domain}" or any other domain`); + } + + async getRequirementVersions( + domain: string, + controlId: number + ): Promise { + const res = await this.authenticatedFetch( + `/api/calm/domains/${encodeURIComponent(domain)}/controls/${controlId}/requirement/versions` + ); + const data = await res.json(); + return this.unwrapValues(data); + } + + async getRequirementAtVersion( + domain: string, + controlId: number, + version: string + ): Promise { + const res = await this.authenticatedFetch( + `/api/calm/domains/${encodeURIComponent(domain)}/controls/${controlId}/requirement/versions/${encodeURIComponent(version)}` + ); + return res.json(); + } + + // --- ADRs (namespace-scoped storage API, numeric IDs) --- + + async getAdrs(namespace: string): Promise { + const res = await this.authenticatedFetch( + `/api/calm/namespaces/${encodeURIComponent(namespace)}/adrs` + ); + const data = await res.json(); + return this.unwrapValues(data); + } + + async getAdr(namespace: string, adrId: number): Promise { + const res = await this.authenticatedFetch( + `/api/calm/namespaces/${encodeURIComponent(namespace)}/adrs/${adrId}` + ); + return (await res.json()) as AdrMeta; + } + + async getAdrRevisions( + namespace: string, + adrId: number + ): Promise { + const res = await this.authenticatedFetch( + `/api/calm/namespaces/${encodeURIComponent(namespace)}/adrs/${adrId}/revisions` + ); + const data = await res.json(); + return this.unwrapValues(data); + } + + private unwrapValues(data: unknown): T[] { + if ( + data !== null && + typeof data === 'object' && + !Array.isArray(data) && + 'values' in data && + Array.isArray((data as Record).values) + ) { + return (data as Record).values as T[]; + } + return data as T[]; + } + + private async authenticatedFetch(path: string): Promise { + const url = `${this.baseUrl}${path}`; + const res = await fetch(url, { + headers: { ...this.authHeaders, Accept: 'application/json' }, + }); + if (!res.ok) + throw new HubApiError( + res.status, + url, + `Hub request failed: ${res.status} ${url}` + ); + return res; + } +} diff --git a/calm-plugins/vscode/src/extension/services/hub-status-bar.test.ts b/calm-plugins/vscode/src/extension/services/hub-status-bar.test.ts new file mode 100644 index 0000000000..35549549cf --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-status-bar.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { HubStatusBar } from './hub-status-bar'; + +describe('HubStatusBar', () => { + let statusBar: HubStatusBar; + let mockItem: vscode.StatusBarItem; + + beforeEach(() => { + // Capture the created status bar item + const createSpy = vi.fn(() => { + mockItem = { + text: '', + tooltip: undefined, + command: undefined, + backgroundColor: undefined, + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.StatusBarItem; + return mockItem; + }); + (vscode.window as any).createStatusBarItem = createSpy; + + statusBar = new HubStatusBar(); + }); + + it('creates a status bar item aligned to the left', () => { + expect(vscode.window.createStatusBarItem).toHaveBeenCalledWith( + vscode.StatusBarAlignment.Left, + 0 + ); + }); + + it('shows the status bar item on creation', () => { + expect(mockItem.show).toHaveBeenCalled(); + }); + + it('sets the command to calm.connectToHub', () => { + expect(mockItem.command).toBe('calm.connectToHub'); + }); + + it('starts in disconnected state', () => { + expect(statusBar.state).toBe('disconnected'); + expect(mockItem.text).toBe('$(cloud) CALM Hub'); + expect(mockItem.tooltip).toBe('Click to connect to CalmHub'); + }); + + describe('setState', () => { + it('updates to connecting state', () => { + statusBar.setState('connecting'); + expect(statusBar.state).toBe('connecting'); + expect(mockItem.text).toBe('$(sync~spin) CALM Hub'); + expect(mockItem.tooltip).toBe('Connecting...'); + expect(mockItem.backgroundColor).toBeUndefined(); + }); + + it('updates to connected state with namespace count', () => { + statusBar.setState('connected', 5); + expect(statusBar.state).toBe('connected'); + expect(mockItem.text).toBe('$(cloud) CALM Hub (0/5)'); + expect(mockItem.backgroundColor).toBeUndefined(); + }); + + it('updates to error state with error background', () => { + statusBar.setState('error'); + expect(statusBar.state).toBe('error'); + expect(mockItem.text).toBe('$(cloud) CALM Hub (error)'); + expect(mockItem.backgroundColor).toBeInstanceOf( + vscode.ThemeColor + ); + }); + + it('preserves namespace count when not provided', () => { + statusBar.setState('connected', 3); + expect(mockItem.text).toBe('$(cloud) CALM Hub (0/3)'); + + statusBar.setState('error'); + statusBar.setState('connected'); + expect(mockItem.text).toBe('$(cloud) CALM Hub (0/3)'); + }); + + it('updates namespace count when provided', () => { + statusBar.setState('connected', 2); + expect(mockItem.text).toBe('$(cloud) CALM Hub (0/2)'); + + statusBar.setState('connected', 7); + expect(mockItem.text).toBe('$(cloud) CALM Hub (0/7)'); + }); + + it('shows selected vs available with setAvailableAndSelected', () => { + statusBar.setState('connected', 3); + statusBar.setAvailableAndSelected(['ns1', 'ns2', 'ns3'], ['ns1', 'ns3']); + expect(mockItem.text).toBe('$(cloud) CALM Hub (2/3)'); + expect(mockItem.tooltip).toContain('✓ ns1'); + expect(mockItem.tooltip).toContain('○ ns2'); + expect(mockItem.tooltip).toContain('✓ ns3'); + }); + }); + + describe('dispose', () => { + it('disposes the underlying status bar item', () => { + statusBar.dispose(); + expect(mockItem.dispose).toHaveBeenCalled(); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/hub-status-bar.ts b/calm-plugins/vscode/src/extension/services/hub-status-bar.ts new file mode 100644 index 0000000000..63239f7967 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/hub-status-bar.ts @@ -0,0 +1,107 @@ +import * as vscode from 'vscode'; + +export type ConnectionState = 'disconnected' | 'connecting' | 'connected' | 'error'; + +export class HubStatusBar { + private statusBarItem: vscode.StatusBarItem; + private _state: ConnectionState = 'disconnected'; + private namespaceCount = 0; + private updatesCount = 0; + private hubUrl: string | undefined; + private namespaceNames: string[] = []; + private availableNamespaces: string[] = []; + private selectedNamespaces: string[] = []; + + constructor() { + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 0 + ); + this.statusBarItem.command = 'calm.connectToHub'; + this.update(); + this.statusBarItem.show(); + } + + get state(): ConnectionState { + return this._state; + } + + setState(state: ConnectionState, namespaceCount?: number, hubUrl?: string): void { + this._state = state; + if (namespaceCount !== undefined) { + this.namespaceCount = namespaceCount; + } + if (hubUrl !== undefined) { + this.hubUrl = hubUrl; + } + this.update(); + } + + setNamespaceNames(names: string[]): void { + this.namespaceNames = names; + this.update(); + } + + setAvailableAndSelected(available: string[], selected: string[]): void { + this.availableNamespaces = available; + this.selectedNamespaces = selected; + this.namespaceCount = available.length; + this.update(); + } + + setUpdatesAvailable(count: number): void { + this.updatesCount = count; + this.update(); + } + + private update(): void { + switch (this._state) { + case 'disconnected': + this.statusBarItem.text = '$(cloud) CALM Hub'; + this.statusBarItem.tooltip = this.hubUrl + ? `Disconnected from ${this.hubUrl}\nClick to reconnect` + : 'Click to connect to CalmHub'; + this.statusBarItem.backgroundColor = undefined; + break; + case 'connecting': + this.statusBarItem.text = '$(sync~spin) CALM Hub'; + this.statusBarItem.tooltip = this.hubUrl + ? `Connecting to ${this.hubUrl}...` + : 'Connecting...'; + this.statusBarItem.backgroundColor = undefined; + break; + case 'connected': { + const selCount = this.selectedNamespaces.length; + const availCount = this.availableNamespaces.length || this.namespaceCount; + const countLabel = `${selCount}/${availCount}`; + this.statusBarItem.text = this.updatesCount > 0 + ? `$(cloud) CALM Hub (${countLabel}) · ${this.updatesCount} updates` + : `$(cloud) CALM Hub (${countLabel})`; + + const nsList = this.availableNamespaces.map((ns) => + this.selectedNamespaces.includes(ns) ? ` ✓ ${ns}` : ` ○ ${ns}` + ).join('\n'); + this.statusBarItem.tooltip = (this.hubUrl ? `${this.hubUrl}\n\n` : '') + + `Namespaces (${selCount} selected / ${availCount} available):\n` + + nsList + + (this.updatesCount > 0 ? `\n\n${this.updatesCount} update(s) available` : '') + + '\n\nClick to manage'; + this.statusBarItem.backgroundColor = undefined; + break; + } + case 'error': + this.statusBarItem.text = '$(cloud) CALM Hub (error)'; + this.statusBarItem.tooltip = this.hubUrl + ? `Failed to connect to ${this.hubUrl}\nClick to retry` + : 'Connection error — click to retry'; + this.statusBarItem.backgroundColor = new vscode.ThemeColor( + 'statusBarItem.errorBackground' + ); + break; + } + } + + dispose(): void { + this.statusBarItem.dispose(); + } +} diff --git a/calm-plugins/vscode/src/extension/services/path-resolver.test.ts b/calm-plugins/vscode/src/extension/services/path-resolver.test.ts new file mode 100644 index 0000000000..19cb761502 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/path-resolver.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveLocalPath, resolveSafeWritePath } from './path-resolver'; + +let rootA: string; +let rootB: string; +let external: string; + +beforeAll(() => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), 'calm-pathres-')); + rootA = path.join(base, 'rootA'); + rootB = path.join(base, 'rootB'); + external = path.join(base, 'external'); + fs.mkdirSync(path.join(rootA, 'controls'), { recursive: true }); + fs.mkdirSync(path.join(rootB, 'controls'), { recursive: true }); + fs.mkdirSync(path.join(external, 'controls'), { recursive: true }); + fs.writeFileSync(path.join(rootA, 'controls', 'a.requirement.json'), '{}'); + fs.writeFileSync(path.join(rootB, 'controls', 'b.requirement.json'), '{}'); + fs.writeFileSync(path.join(external, 'controls', 'e.requirement.json'), '{}'); + // Same relative path present in both roots — first root must win. + fs.writeFileSync(path.join(rootA, 'controls', 'dup.requirement.json'), '{"r":"a"}'); + fs.writeFileSync(path.join(rootB, 'controls', 'dup.requirement.json'), '{"r":"b"}'); +}); + +afterAll(() => { + fs.rmSync(path.dirname(rootA), { recursive: true, force: true }); +}); + +describe('resolveLocalPath', () => { + it('resolves a file in the first workspace root', () => { + const resolved = resolveLocalPath('controls/a.requirement.json', [rootA, rootB]); + expect(resolved).toBe(path.join(rootA, 'controls', 'a.requirement.json')); + }); + + it('falls through to a later root', () => { + const resolved = resolveLocalPath('controls/b.requirement.json', [rootA, rootB]); + expect(resolved).toBe(path.join(rootB, 'controls', 'b.requirement.json')); + }); + + it('is deterministic: first root wins on a duplicate relative path', () => { + const resolved = resolveLocalPath('controls/dup.requirement.json', [rootA, rootB]); + expect(resolved).toBe(path.join(rootA, 'controls', 'dup.requirement.json')); + }); + + it('searches the external assets path last', () => { + const resolved = resolveLocalPath( + 'controls/e.requirement.json', + [rootA, rootB], + external + ); + expect(resolved).toBe(path.join(external, 'controls', 'e.requirement.json')); + }); + + it('rejects parent traversal', () => { + expect(resolveLocalPath('../rootB/controls/b.requirement.json', [rootA])).toBeNull(); + }); + + it('rejects an absolute POSIX path', () => { + expect(resolveLocalPath('/etc/passwd', [rootA])).toBeNull(); + }); + + it('rejects a Windows drive-letter path', () => { + expect(resolveLocalPath('C:\\Windows\\x', [rootA])).toBeNull(); + }); + + it('returns null when the file does not exist in any root', () => { + expect(resolveLocalPath('controls/missing.requirement.json', [rootA, rootB])).toBeNull(); + }); +}); + +describe('resolveSafeWritePath', () => { + it('returns the write target when the parent directory exists', () => { + const target = resolveSafeWritePath('controls/new.requirement.json', rootA); + expect(target).toBe(path.join(rootA, 'controls', 'new.requirement.json')); + }); + + it('returns null when the parent directory is missing', () => { + expect(resolveSafeWritePath('missing-dir/new.requirement.json', rootA)).toBeNull(); + }); + + it('rejects traversal on write', () => { + expect(resolveSafeWritePath('../escape.json', rootA)).toBeNull(); + }); + + it('rejects an absolute path on write', () => { + expect(resolveSafeWritePath('/tmp/x.json', rootA)).toBeNull(); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/path-resolver.ts b/calm-plugins/vscode/src/extension/services/path-resolver.ts new file mode 100644 index 0000000000..dde1ebb4b5 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/path-resolver.ts @@ -0,0 +1,83 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +// Containment-checked resolution of workspace-relative asset paths. Extracted so +// the same rules apply to reads (drill/resolve) and writes (control save), and so +// the logic can be unit-tested without the VS Code API. + +function isUnsafeRelative(relativePath: string): boolean { + if (!relativePath) return true; + if (relativePath.includes('..')) return true; + // Reject absolute POSIX and Windows (drive-letter / UNC) paths. + if (relativePath.startsWith('/') || relativePath.startsWith('\\')) return true; + if (/^[a-zA-Z]:[\\/]/.test(relativePath)) return true; + return false; +} + +/** Real-path a directory to defeat symlink escape; falls back to the lexical path. */ +function realpathDir(p: string): string { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} + +function isContained(candidate: string, root: string): boolean { + const realRoot = realpathDir(root); + // Resolve the parent (which must exist) so a not-yet-created file still checks + // out; the leaf name is appended back afterwards. + const parent = realpathDir(path.dirname(candidate)); + const resolved = path.join(parent, path.basename(candidate)); + return resolved === realRoot || resolved.startsWith(realRoot + path.sep); +} + +/** + * Resolve a workspace-relative path against a list of roots (in order), then an + * optional external assets path. Returns the first existing, contained match, or + * `null` if none resolve safely. + */ +export function resolveLocalPath( + relativePath: string, + workspaceRoots: string[], + externalAssetsPath?: string +): string | null { + if (isUnsafeRelative(relativePath)) return null; + + const roots = [...workspaceRoots]; + if (externalAssetsPath?.trim()) roots.push(externalAssetsPath.trim()); + + for (const root of roots) { + const candidate = path.resolve(root, relativePath); + if (!isContained(candidate, root)) continue; + try { + if (fs.statSync(candidate).isFile()) return candidate; + } catch { + /* try next root */ + } + } + return null; +} + +/** + * Resolve a safe write target for a new file inside `targetRoot`. Validates that + * the parent directory exists and is contained within the root. Returns the full + * write path, or `null` if the path is unsafe or the parent is missing. + */ +export function resolveSafeWritePath( + relativePath: string, + targetRoot: string +): string | null { + if (isUnsafeRelative(relativePath)) return null; + + const candidate = path.resolve(targetRoot, relativePath); + if (!isContained(candidate, targetRoot)) return null; + + const parent = path.dirname(candidate); + try { + if (!fs.statSync(parent).isDirectory()) return null; + } catch { + return null; + } + return candidate; +} diff --git a/calm-plugins/vscode/src/extension/services/requirement-parser.test.ts b/calm-plugins/vscode/src/extension/services/requirement-parser.test.ts new file mode 100644 index 0000000000..328b6fc17f --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/requirement-parser.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest'; +import { parseRequirementSchema } from './requirement-parser'; + +const identity = { + 'control-id': { const: 'security-001' }, + name: { const: 'Micro-segmentation' }, + description: { const: 'Prevent lateral movement' }, +}; + +describe('parseRequirementSchema', () => { + it('extracts identity constants and multiple typed properties', () => { + const { parsed, warnings } = parseRequirementSchema({ + properties: { + ...identity, + 'permit-ingress': { type: 'boolean' }, + 'max-connections': { type: 'integer' }, + weight: { type: 'number' }, + reason: { type: 'string', description: 'Why' }, + }, + required: ['control-id', 'name', 'description', 'permit-ingress'], + }); + + expect(warnings).toEqual([]); + expect(parsed?.identity).toEqual({ + controlId: 'security-001', + name: 'Micro-segmentation', + description: 'Prevent lateral movement', + }); + expect(parsed?.properties['permit-ingress']).toEqual({ + type: 'boolean', + description: undefined, + required: true, + }); + expect(parsed?.properties['max-connections'].type).toBe('integer'); + expect(parsed?.properties.weight.type).toBe('number'); + expect(parsed?.properties.reason).toEqual({ + type: 'string', + description: 'Why', + required: false, + }); + }); + + it('reads inline enum arrays and preserves native value types', () => { + const { parsed } = parseRequirementSchema({ + properties: { + ...identity, + level: { enum: ['low', 'high'] }, + retries: { enum: [1, 2, 3] }, + }, + }); + expect(parsed?.properties.level).toEqual({ + type: 'enum', + allowedValues: ['low', 'high'], + description: undefined, + required: false, + }); + expect(parsed?.properties.retries.allowedValues).toEqual([1, 2, 3]); + }); + + it('resolves a $ref to a local defs entry', () => { + const { parsed } = parseRequirementSchema({ + properties: { + ...identity, + protocol: { $ref: '#/defs/protocol' }, + }, + required: ['protocol'], + defs: { protocol: { enum: ['HTTP', 'HTTPS'] } }, + }); + expect(parsed?.properties.protocol).toEqual({ + type: 'enum', + allowedValues: ['HTTP', 'HTTPS'], + description: undefined, + required: true, + }); + }); + + it('resolves a $ref to a $defs entry', () => { + const { parsed } = parseRequirementSchema({ + properties: { + ...identity, + tier: { $ref: '#/$defs/tier' }, + }, + $defs: { tier: { type: 'string', pattern: '^T\\d$' } }, + }); + expect(parsed?.properties.tier).toEqual({ + type: 'string', + pattern: '^T\\d$', + description: undefined, + required: false, + }); + }); + + it('captures a string pattern', () => { + const { parsed } = parseRequirementSchema({ + properties: { + ...identity, + 'app-id': { type: 'string', pattern: '^AP\\d+$' }, + }, + }); + expect(parsed?.properties['app-id'].pattern).toBe('^AP\\d+$'); + }); + + it('defaults a typeless property to plain string', () => { + const { parsed } = parseRequirementSchema({ + properties: { ...identity, note: { description: 'freeform' } }, + }); + expect(parsed?.properties.note.type).toBe('string'); + }); + + it('honors the required array', () => { + const { parsed } = parseRequirementSchema({ + properties: { + ...identity, + a: { type: 'string' }, + b: { type: 'string' }, + }, + required: ['a'], + }); + expect(parsed?.properties.a.required).toBe(true); + expect(parsed?.properties.b.required).toBe(false); + }); + + it('skips unsupported types and warns', () => { + const { parsed, warnings } = parseRequirementSchema({ + properties: { + ...identity, + nested: { type: 'object' }, + list: { type: 'array' }, + external: { $ref: 'https://example.com/x.json' }, + }, + }); + expect(Object.keys(parsed?.properties ?? {})).toEqual([]); + expect(warnings).toHaveLength(3); + expect(warnings[0]).toContain('nested'); + }); + + it('returns null with a warning when identity constants are missing', () => { + const { parsed, warnings } = parseRequirementSchema({ + properties: { + 'control-id': { const: 'x' }, + name: { const: '' }, + description: { const: 'd' }, + }, + }); + expect(parsed).toBeNull(); + expect(warnings[0]).toContain('identity'); + }); + + it('returns null for a non-object schema', () => { + expect(parseRequirementSchema('nope').parsed).toBeNull(); + expect(parseRequirementSchema(null).parsed).toBeNull(); + }); + + it('returns null when properties are absent', () => { + const { parsed, warnings } = parseRequirementSchema({ type: 'object' }); + expect(parsed).toBeNull(); + expect(warnings[0]).toContain('properties'); + }); + + it('uses fallback identity when const values are missing', () => { + const fallback = { controlId: 'fb-id', name: 'Fallback', description: 'Fallback desc' }; + const { parsed, warnings } = parseRequirementSchema( + { properties: { 'max-connections': { type: 'integer' } }, required: [] }, + fallback + ); + expect(parsed).not.toBeNull(); + expect(parsed!.identity).toEqual(fallback); + expect(parsed!.properties['max-connections']).toMatchObject({ type: 'integer' }); + expect(warnings.some((w) => w.includes('derived from control metadata'))).toBe(true); + }); + + it('merges partial const values with fallback identity', () => { + const fallback = { controlId: 'fb-id', name: 'Fallback', description: 'Fallback desc' }; + const { parsed } = parseRequirementSchema( + { properties: { ...identity, 'control-id': {} }, required: [] }, + fallback + ); + expect(parsed!.identity.controlId).toBe('fb-id'); + expect(parsed!.identity.name).toBe('Micro-segmentation'); + expect(parsed!.identity.description).toBe('Prevent lateral movement'); + }); + + it('returns null without fallback when identity constants are missing', () => { + const { parsed } = parseRequirementSchema( + { properties: { 'max-connections': { type: 'integer' } }, required: [] } + ); + expect(parsed).toBeNull(); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/requirement-parser.ts b/calm-plugins/vscode/src/extension/services/requirement-parser.ts new file mode 100644 index 0000000000..3a0dc8cee6 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/requirement-parser.ts @@ -0,0 +1,201 @@ +// Shared, pure parser for CALM control-requirement JSON Schemas. Used by local +// scanning, picker attachment, standards resolution, and webview enrichment so +// there is a single source of truth for how a requirement maps to editable +// properties. No VS Code or Node APIs — safe to run in any context. + +export interface RequirementIdentity { + controlId: string; + name: string; + description: string; +} + +export type RequirementPropertyType = + | 'string' + | 'boolean' + | 'number' + | 'integer' + | 'enum'; + +export interface RequirementPropertyDef { + type: RequirementPropertyType; + /** Native enum values — strings, numbers, or booleans (never coerced). */ + allowedValues?: Array; + pattern?: string; + /** Help text for the input; NOT an example value. */ + description?: string; + required: boolean; +} + +export interface ParsedRequirement { + identity: RequirementIdentity; + properties: Record; +} + +export interface ParseResult { + parsed: ParsedRequirement | null; + warnings: string[]; +} + +/** Base fields that carry identity, not user-editable config, plus schema keywords. */ +const BASE_FIELDS = new Set([ + 'control-id', + 'name', + 'description', + '$schema', + '$id', + 'title', + 'type', +]); + +function isRecord(v: unknown): v is Record { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +function readConst(props: Record, key: string): string | null { + const def = props[key]; + if (!isRecord(def)) return null; + const c = def.const; + return typeof c === 'string' && c.length > 0 ? c : null; +} + +/** + * Resolve a `$ref` that points at a local `defs` / `$defs` entry. Returns the + * referenced schema object, or `null` for external or unresolvable refs. + */ +function resolveLocalRef( + ref: string, + root: Record +): Record | null { + const m = /^#\/(\$?defs)\/(.+)$/.exec(ref); + if (!m) return null; + const bag = root[m[1]]; + if (!isRecord(bag)) return null; + const target = bag[m[2]]; + return isRecord(target) ? target : null; +} + +/** + * Interpret a single property schema into a `RequirementPropertyDef`. + * Returns `null` for unsupported shapes (object, array, external `$ref`). + */ +function interpretProperty( + schema: Record, + required: boolean, + root: Record +): RequirementPropertyDef | null { + // Inline-resolve a local $ref before applying the type rules. + if (typeof schema.$ref === 'string') { + const resolved = resolveLocalRef(schema.$ref, root); + if (!resolved) return null; + schema = { ...resolved, ...schema }; + delete (schema as Record).$ref; + } + + const description = + typeof schema.description === 'string' ? schema.description : undefined; + + if (Array.isArray(schema.enum)) { + const allowed = schema.enum.filter( + (v): v is string | number | boolean => + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' + ); + return { type: 'enum', allowedValues: allowed, description, required }; + } + + const type = schema.type; + if (type === 'boolean') return { type: 'boolean', description, required }; + if (type === 'integer') return { type: 'integer', description, required }; + if (type === 'number') return { type: 'number', description, required }; + if (type === 'string' || type === undefined) { + const def: RequirementPropertyDef = { type: 'string', description, required }; + if (typeof schema.pattern === 'string') def.pattern = schema.pattern; + return def; + } + + // object, array, or anything else → unsupported + return null; +} + +/** + * Parse a control-requirement JSON Schema into identity constants plus a map of + * editable property definitions. Returns `{ parsed: null, warnings }` when the + * schema is malformed or is missing any identity constant. + */ +export function parseRequirementSchema( + schema: unknown, + fallbackIdentity?: RequirementIdentity +): ParseResult { + const warnings: string[] = []; + if (!isRecord(schema)) { + return { parsed: null, warnings: ['Requirement schema is not an object'] }; + } + + const props = schema.properties; + if (!isRecord(props)) { + return { + parsed: null, + warnings: ['Requirement schema has no properties object'], + }; + } + + const controlId = readConst(props, 'control-id'); + const name = readConst(props, 'name'); + const description = readConst(props, 'description'); + let identity: RequirementIdentity; + if (controlId && name && description) { + identity = { controlId, name, description }; + } else if (fallbackIdentity) { + identity = { + controlId: controlId ?? fallbackIdentity.controlId, + name: name ?? fallbackIdentity.name, + description: description ?? fallbackIdentity.description, + }; + warnings.push('Identity constants partially derived from control metadata'); + } else { + return { + parsed: null, + warnings: [ + 'Requirement is missing one or more identity constants (control-id, name, description)', + ], + }; + } + + const requiredArr = Array.isArray(schema.required) + ? (schema.required as unknown[]).filter( + (v): v is string => typeof v === 'string' + ) + : []; + const requiredSet = new Set(requiredArr); + + const properties: Record = {}; + for (const [key, rawDef] of Object.entries(props)) { + if (BASE_FIELDS.has(key)) continue; + const isRequired = requiredSet.has(key); + if (!isRecord(rawDef)) { + warnings.push( + `Skipped ${isRequired ? 'required ' : ''}property "${key}": not a schema object` + ); + continue; + } + const def = interpretProperty(rawDef, isRequired, schema); + if (!def) { + warnings.push( + `Skipped ${isRequired ? 'required ' : ''}property "${key}": unsupported type (object, array, or external $ref)` + ); + continue; + } + properties[key] = def; + } + + return { + parsed: { identity, properties }, + warnings, + }; +} + +/** True when any parser warning flags an unsupported *required* property (blocks valid config). */ +export function hasUnsupportedRequiredProperty(warnings: string[]): boolean { + return warnings.some((w) => w.startsWith('Skipped required property')); +} diff --git a/calm-plugins/vscode/src/extension/services/sha-cache-service.test.ts b/calm-plugins/vscode/src/extension/services/sha-cache-service.test.ts new file mode 100644 index 0000000000..3ace46bc12 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/sha-cache-service.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { ShaCacheService } from './sha-cache-service'; + +describe('ShaCacheService', () => { + let tmpDir: string; + let service: ShaCacheService; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sha-cache-test-')); + service = new ShaCacheService(tmpDir); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + describe('put and get', () => { + it('stores and retrieves content by namespace/type/slug/sha', async () => { + const content = { nodes: [{ name: 'test', controls: { a: 1 } }] }; + + await service.put('finos', 'building-blocks', 'microservice', 'abc123', content); + const result = await service.get('finos', 'building-blocks', 'microservice', 'abc123'); + + expect(result).toEqual(content); + }); + + it('returns null for missing cache entry', async () => { + const result = await service.get('missing', 'type', 'slug', 'sha'); + + expect(result).toBeNull(); + }); + + it('creates directory structure as needed', async () => { + await service.put('deep', 'nested', 'path', 'sha1', { data: true }); + + const filePath = path.join(tmpDir, 'deep', 'nested', 'path', 'sha1.json'); + expect(fs.existsSync(filePath)).toBe(true); + }); + + it('overwrites existing cache entries', async () => { + await service.put('ns', 'type', 'slug', 'sha', { version: 1 }); + await service.put('ns', 'type', 'slug', 'sha', { version: 2 }); + + const result = await service.get('ns', 'type', 'slug', 'sha'); + expect(result).toEqual({ version: 2 }); + }); + }); + + describe('has', () => { + it('returns true when cache entry exists', async () => { + await service.put('ns', 'type', 'slug', 'sha', { data: true }); + + expect(service.has('ns', 'type', 'slug', 'sha')).toBe(true); + }); + + it('returns false when cache entry does not exist', () => { + expect(service.has('ns', 'type', 'slug', 'missing')).toBe(false); + }); + }); + + describe('getCacheDir', () => { + it('returns the configured cache directory', () => { + expect(service.getCacheDir()).toBe(tmpDir); + }); + + it('defaults to ~/.calm/cache when no dir provided', () => { + const defaultService = new ShaCacheService(); + expect(defaultService.getCacheDir()).toBe( + path.join(os.homedir(), '.calm', 'cache') + ); + }); + }); + + describe('path traversal defense', () => { + it('writes inside cache dir even with traversal characters in namespace', async () => { + await service.put('../../etc', 'type', 'slug', 'sha', { data: true }); + + // Slashes replaced with _, dots allowed → '.._.._etc' + const escaped = path.join(tmpDir, '.._.._etc', 'type', 'slug', 'sha.json'); + expect(fs.existsSync(escaped)).toBe(true); + + const traversed = path.join(tmpDir, '..', '..', 'etc', 'type', 'slug', 'sha.json'); + expect(fs.existsSync(traversed)).toBe(false); + }); + + it('writes inside cache dir even with traversal characters in type', async () => { + await service.put('ns', '../secret', 'slug', 'sha', { data: true }); + + // Slash replaced with _ → '.._secret' + const escaped = path.join(tmpDir, 'ns', '.._secret', 'slug', 'sha.json'); + expect(fs.existsSync(escaped)).toBe(true); + + const traversed = path.join(tmpDir, 'ns', '..', 'secret', 'slug', 'sha.json'); + expect(fs.existsSync(traversed)).toBe(false); + }); + }); + + describe('isolation', () => { + it('different SHAs for same resource do not collide', async () => { + await service.put('ns', 'type', 'slug', 'sha-old', { v: 1 }); + await service.put('ns', 'type', 'slug', 'sha-new', { v: 2 }); + + expect(await service.get('ns', 'type', 'slug', 'sha-old')).toEqual({ v: 1 }); + expect(await service.get('ns', 'type', 'slug', 'sha-new')).toEqual({ v: 2 }); + }); + + it('different namespaces do not collide', async () => { + await service.put('ns-a', 'type', 'slug', 'sha', { from: 'a' }); + await service.put('ns-b', 'type', 'slug', 'sha', { from: 'b' }); + + expect(await service.get('ns-a', 'type', 'slug', 'sha')).toEqual({ from: 'a' }); + expect(await service.get('ns-b', 'type', 'slug', 'sha')).toEqual({ from: 'b' }); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/sha-cache-service.ts b/calm-plugins/vscode/src/extension/services/sha-cache-service.ts new file mode 100644 index 0000000000..1c37794283 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/sha-cache-service.ts @@ -0,0 +1,78 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +/** + * Filesystem-based SHA cache for resolved Hub resources. + * Stores content at `~/.calm/cache////.json`. + * All operations are synchronous for fast reads on cached content. + */ +export class ShaCacheService { + private cacheDir: string; + + constructor(cacheDir?: string) { + this.cacheDir = cacheDir ?? path.join(os.homedir(), '.calm', 'cache'); + } + + async get( + namespace: string, + type: string, + slug: string, + sha: string + ): Promise { + const filePath = this.getCachePath(namespace, type, slug, sha); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return JSON.parse(content); + } catch { + return null; + } + } + + async put( + namespace: string, + type: string, + slug: string, + sha: string, + content: unknown + ): Promise { + const filePath = this.getCachePath(namespace, type, slug, sha); + const dir = path.dirname(filePath); + // Round-trip through JSON to ensure only valid JSON is written + const serialized = JSON.stringify(content, null, 2); + const validated = JSON.parse(serialized) as unknown; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, JSON.stringify(validated, null, 2)); + } + + has(namespace: string, type: string, slug: string, sha: string): boolean { + return fs.existsSync(this.getCachePath(namespace, type, slug, sha)); + } + + getCacheDir(): string { + return this.cacheDir; + } + + private sanitizeSegment(segment: string): string { + return segment.replace(/[^a-zA-Z0-9._-]/g, '_'); + } + + private getCachePath( + namespace: string, + type: string, + slug: string, + sha: string + ): string { + const safePath = path.join( + this.cacheDir, + this.sanitizeSegment(namespace), + this.sanitizeSegment(type), + this.sanitizeSegment(slug), + `${this.sanitizeSegment(sha)}.json` + ); + if (!safePath.startsWith(this.cacheDir)) { + throw new Error('Invalid cache path'); + } + return safePath; + } +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-nested.svg b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-nested.svg new file mode 100644 index 0000000000..2f6d04952e --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-nested.svg @@ -0,0 +1,5 @@ + + + + diff --git a/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-simple.svg b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-simple.svg new file mode 100644 index 0000000000..1e73526c72 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/drawio-simple.svg @@ -0,0 +1,9 @@ + + + + + Web App + + Database + diff --git a/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/generic-simple.svg b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/generic-simple.svg new file mode 100644 index 0000000000..1b3a29a743 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/__fixtures__/generic-simple.svg @@ -0,0 +1,22 @@ + + + + + API Service + + + + Payment System + + + + User + + + + + + + + + diff --git a/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.test.ts new file mode 100644 index 0000000000..5566eac60d --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect } from 'vitest'; +import { buildCalmJson } from './calm-builder'; +import type { ParsedSvgGraph } from './types'; + +describe('buildCalmJson', () => { + it('produces valid CALM JSON for a simple graph', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'API Gateway', shapeHint: 'rounded-rectangle', geometry: { x: 100, y: 50, width: 200, height: 60 }, styleProps: {} }, + { id: 'v2', label: 'User DB', shapeHint: 'cylinder', geometry: { x: 400, y: 50, width: 120, height: 80 }, styleProps: {} }, + ], + edges: [ + { id: 'e1', sourceId: 'v1', targetId: 'v2', label: 'JDBC' }, + ], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + + expect(doc.$schema).toBe('https://calm.finos.org/release/1.2/meta/calm.json'); + expect(doc.nodes).toHaveLength(2); + expect(doc.relationships).toHaveLength(1); + expect(result.nodeCount).toBe(2); + expect(result.relationshipCount).toBe(1); + expect(result.warnings).toHaveLength(0); + }); + + it('generates correct node types from shape hints', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Admin', shapeHint: 'person', geometry: { x: 0, y: 0, width: 40, height: 60 }, styleProps: {} }, + { id: 'v2', label: 'Orders', shapeHint: 'cylinder', geometry: { x: 200, y: 0, width: 80, height: 80 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + + expect(doc.nodes[0]['node-type']).toBe('actor'); + expect(doc.nodes[1]['node-type']).toBe('database'); + }); + + it('generates connects relationships from edges', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'a', label: 'Source', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'b', label: 'Target', shapeHint: 'rectangle', geometry: { x: 200, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [ + { id: 'e1', sourceId: 'a', targetId: 'b', label: 'HTTP' }, + ], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + const rel = doc.relationships[0]; + + expect(rel['relationship-type'].connects.source.node).toBe('system-source'); + expect(rel['relationship-type'].connects.destination.node).toBe('system-target'); + expect(rel.description).toBe('HTTP'); + }); + + it('generates deployed-in relationships from containment', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'container', label: 'VPC', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 500, height: 400 }, styleProps: {} }, + { id: 'child1', label: 'Service A', shapeHint: 'rounded-rectangle', geometry: { x: 50, y: 50, width: 150, height: 60 }, parentId: 'container', styleProps: {} }, + { id: 'child2', label: 'Service B', shapeHint: 'rounded-rectangle', geometry: { x: 250, y: 50, width: 150, height: 60 }, parentId: 'container', styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + const deployedIn = doc.relationships.find((r: any) => r['relationship-type']['deployed-in']); + + expect(deployedIn).toBeDefined(); + expect(deployedIn['relationship-type']['deployed-in'].container).toBe('network-vpc'); + expect(deployedIn['relationship-type']['deployed-in'].nodes).toHaveLength(2); + }); + + it('preserves layout in metadata._layout', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Node', shapeHint: 'rectangle', geometry: { x: 123.5, y: 456.7, width: 200, height: 60 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + + expect(doc.metadata._layout['system-node']).toEqual({ x: 124, y: 457, w: 200, h: 60 }); + }); + + it('warns about unresolved edges', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Only Node', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [ + { id: 'e1', sourceId: 'v1', targetId: 'missing' }, + ], + }; + + const result = buildCalmJson(graph); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain('could not resolve'); + }); + + it('generates unique IDs from labels', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'My Great Service!', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('service-my-great-service'); + }); + + it('handles nodes with empty labels', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: '', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('system-1'); + expect(doc.nodes[0].name).toBe('Unnamed system 1'); + }); + + it('preserves fill and font colors as fidelity-style metadata', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Styled', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: { fillColor: '#1c4587', fontColor: '#ffffff' } }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0].metadata).toEqual({ + 'fidelity-style': { background: '#1c4587', text: '#ffffff' }, + }); + }); + + it('omits fidelity-style when no colors are set', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Plain', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0].metadata).toBeUndefined(); + }); + + it('detects containers by geometry when no explicit parentId', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'c1', label: 'ECTA Zone', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 600, height: 400 }, styleProps: { dashed: '1', fillColor: 'none' } }, + { id: 'n1', label: 'API', shapeHint: 'rounded-rectangle', geometry: { x: 50, y: 50, width: 150, height: 60 }, styleProps: { fillColor: '#38761d' } }, + { id: 'n2', label: 'DB', shapeHint: 'cylinder', geometry: { x: 300, y: 50, width: 100, height: 80 }, styleProps: { fillColor: '#1c4587' } }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + const deployedIn = doc.relationships.find((r: any) => r['relationship-type']['deployed-in']); + + expect(deployedIn).toBeDefined(); + expect(deployedIn['relationship-type']['deployed-in'].container).toBe('network-ecta-zone'); + expect(deployedIn['relationship-type']['deployed-in'].nodes).toContain('service-api'); + expect(deployedIn['relationship-type']['deployed-in'].nodes).toContain('database-db'); + }); + + it('deduplicates IDs for nodes with the same label', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'v2', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 200, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('service-service'); + expect(doc.nodes[1]['unique-id']).toBe('service-service-2'); + }); + + it('deduplicates IDs for multiple collisions', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'v2', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 200, y: 0, width: 100, height: 50 }, styleProps: {} }, + { id: 'v3', label: 'Service', shapeHint: 'rounded-rectangle', geometry: { x: 400, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['unique-id']).toBe('service-service'); + expect(doc.nodes[1]['unique-id']).toBe('service-service-2'); + expect(doc.nodes[2]['unique-id']).toBe('service-service-3'); + }); + + it('types nodes with children as network even without style props', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'generic', + nodes: [ + { id: 'c1', label: 'Container', shapeHint: 'rectangle', geometry: { x: 0, y: 0, width: 500, height: 400 }, styleProps: {} }, + { id: 'n1', label: 'Inner Service', shapeHint: 'rounded-rectangle', geometry: { x: 50, y: 50, width: 150, height: 60 }, parentId: 'c1', styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0]['node-type']).toBe('network'); + }); + + it('splits multi-line labels into name and description', () => { + const graph: ParsedSvgGraph = { + sourceFormat: 'drawio', + nodes: [ + { id: 'v1', label: 'ECTA\nEnterprise Click to Agree [AP167757]', shapeHint: 'rounded-rectangle', geometry: { x: 0, y: 0, width: 100, height: 50 }, styleProps: {} }, + ], + edges: [], + }; + + const result = buildCalmJson(graph); + const doc = JSON.parse(result.json); + expect(doc.nodes[0].name).toBe('ECTA'); + expect(doc.nodes[0].description).toBe('Enterprise Click to Agree [AP167757]'); + expect(doc.nodes[0]['unique-id']).toBe('service-ecta'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.ts b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.ts new file mode 100644 index 0000000000..68960c31ce --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/calm-builder.ts @@ -0,0 +1,203 @@ +import type { ParsedSvgGraph, ImportResult, SvgNode } from './types'; +import { mapShapeToNodeType } from './shape-mapper'; + +export function buildCalmJson(graph: ParsedSvgGraph): ImportResult { + const warnings: string[] = []; + const nodeIdMap = new Map(); + const usedIds = new Set(); + const geometricParentIds = new Set(graph.nodes.filter(n => n.parentId).map(n => n.parentId!)); + + const nodes = graph.nodes.map((n, i) => { + const { name, description } = splitLabel(n.label); + const isContainer = isContainerNode(n) || geometricParentIds.has(n.id); + const nodeType = isContainer ? 'network' : mapShapeToNodeType(n.shapeHint, name); + const calmId = generateCalmId(nodeType, name, i, usedIds); + nodeIdMap.set(n.id, calmId); + + const node: Record = { + 'unique-id': calmId, + 'node-type': nodeType, + name: name || `Unnamed ${nodeType} ${i + 1}`, + description, + }; + + const style = extractNodeStyle(n.styleProps); + if (style) { + node.metadata = { 'fidelity-style': style }; + } + + return node; + }); + + const relationships: Array> = []; + let relIndex = 0; + + // Edges → connects relationships + for (const edge of graph.edges) { + const source = nodeIdMap.get(edge.sourceId); + const target = nodeIdMap.get(edge.targetId); + if (!source || !target) { + warnings.push(`Edge "${edge.id}": could not resolve source or target node`); + continue; + } + const rel: Record = { + 'unique-id': `rel-${++relIndex}`, + 'relationship-type': { + connects: { + source: { node: source }, + destination: { node: target }, + }, + }, + }; + if (edge.label) rel.description = edge.label; + relationships.push(rel); + } + + // Containment → deployed-in relationships + const containerChildren = new Map(); + + // First: use explicit parentId references + for (const node of graph.nodes) { + if (node.parentId) { + const parentCalmId = nodeIdMap.get(node.parentId); + const childCalmId = nodeIdMap.get(node.id); + if (parentCalmId && childCalmId) { + if (!containerChildren.has(parentCalmId)) { + containerChildren.set(parentCalmId, []); + } + containerChildren.get(parentCalmId)!.push(childCalmId); + } + } + } + + // Second: geometry-based containment for nodes without explicit parents + const nodesWithParent = new Set(graph.nodes.filter(n => n.parentId).map(n => n.id)); + const containerCandidates = graph.nodes.filter(n => isContainerNode(n)); + for (const child of graph.nodes) { + if (nodesWithParent.has(child.id)) continue; + const bestContainer = findSmallestContainer(child, containerCandidates); + if (bestContainer) { + const parentCalmId = nodeIdMap.get(bestContainer.id)!; + const childCalmId = nodeIdMap.get(child.id)!; + if (!containerChildren.has(parentCalmId)) { + containerChildren.set(parentCalmId, []); + } + containerChildren.get(parentCalmId)!.push(childCalmId); + } + } + + for (const [container, children] of containerChildren) { + relationships.push({ + 'unique-id': `rel-${++relIndex}`, + 'relationship-type': { + 'deployed-in': { container, nodes: children }, + }, + }); + } + + // Build layout metadata from geometry + const layout: Record = {}; + for (const n of graph.nodes) { + const calmId = nodeIdMap.get(n.id); + if (calmId) { + layout[calmId] = { + x: Math.round(n.geometry.x), + y: Math.round(n.geometry.y), + w: Math.round(n.geometry.width), + h: Math.round(n.geometry.height), + }; + } + } + + const calmDoc = { + $schema: 'https://calm.finos.org/release/1.2/meta/calm.json', + nodes, + relationships, + metadata: { _layout: layout }, + }; + + return { + json: JSON.stringify(calmDoc, null, 2), + nodeCount: nodes.length, + relationshipCount: relationships.length, + warnings, + }; +} + +function splitLabel(label: string): { name: string; description: string } { + const lines = label.split('\n').map(l => l.trim()).filter(Boolean); + if (lines.length <= 1) return { name: label.trim(), description: '' }; + return { name: lines[0]!, description: lines.slice(1).join(' ') }; +} + +function extractNodeStyle(styleProps: SvgNode['styleProps']): { background?: string; text?: string } | null { + const bg = styleProps.fillColor || styleProps.fill; + const text = styleProps.fontColor || styleProps.color; + + if (!bg && !text) return null; + + const style: { background?: string; text?: string } = {}; + if (bg && bg !== 'none' && bg !== 'default') style.background = bg; + if (text && text !== 'none' && text !== 'default') style.text = text; + + return Object.keys(style).length > 0 ? style : null; +} + +function generateCalmId(nodeType: string, label: string, index: number, usedIds: Set): string { + const slug = label + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + + const candidate = slug ? `${nodeType}-${slug}` : `${nodeType}-${index + 1}`; + + if (!usedIds.has(candidate)) { + usedIds.add(candidate); + return candidate; + } + + let counter = 2; + while (usedIds.has(`${candidate}-${counter}`)) counter++; + const uniqueId = `${candidate}-${counter}`; + usedIds.add(uniqueId); + return uniqueId; +} + +function isContainerNode(node: SvgNode): boolean { + const { fillColor, fill, dashed } = node.styleProps; + const noFill = !fillColor || fillColor === 'none'; + const noFill2 = !fill || fill === 'none'; + const isDashed = dashed === '1'; + // Container: large, dashed border, no fill (boundary-only) + if (isDashed && noFill && noFill2 && node.geometry.width >= 200 && node.geometry.height >= 150) { + return true; + } + return false; +} + +function isFullyInside(child: SvgNode, container: SvgNode): boolean { + if (child.id === container.id) return false; + const cg = child.geometry; + const pg = container.geometry; + const margin = 5; + return ( + cg.x >= pg.x - margin && + cg.y >= pg.y - margin && + cg.x + cg.width <= pg.x + pg.width + margin && + cg.y + cg.height <= pg.y + pg.height + margin + ); +} + +function findSmallestContainer(child: SvgNode, candidates: SvgNode[]): SvgNode | null { + let best: SvgNode | null = null; + let bestArea = Infinity; + for (const candidate of candidates) { + if (!isFullyInside(child, candidate)) continue; + const area = candidate.geometry.width * candidate.geometry.height; + if (area < bestArea) { + bestArea = area; + best = candidate; + } + } + return best; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.test.ts new file mode 100644 index 0000000000..f573c8867a --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parseDrawioSvg, parseStyleString, classifyDrawioStyle } from './drawio-parser'; + +const fixture = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf-8'); + +describe('parseDrawioSvg', () => { + it('parses a simple draw.io SVG with nodes and edges', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + expect(result.sourceFormat).toBe('drawio'); + expect(result.nodes).toHaveLength(3); + expect(result.edges).toHaveLength(2); + }); + + it('extracts node labels correctly', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const labels = result.nodes.map(n => n.label).sort(); + expect(labels).toEqual(['Database', 'User', 'Web App']); + }); + + it('classifies shapes from draw.io styles', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const webApp = result.nodes.find(n => n.label === 'Web App'); + const db = result.nodes.find(n => n.label === 'Database'); + const user = result.nodes.find(n => n.label === 'User'); + + expect(webApp?.shapeHint).toBe('rounded-rectangle'); + expect(db?.shapeHint).toBe('cylinder'); + expect(user?.shapeHint).toBe('person'); + }); + + it('extracts geometry from mxGeometry', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const webApp = result.nodes.find(n => n.label === 'Web App'); + expect(webApp?.geometry).toEqual({ x: 50, y: 80, width: 160, height: 60 }); + }); + + it('extracts edge source/target and labels', async () => { + const svg = fixture('drawio-simple.svg'); + const result = await parseDrawioSvg(svg); + + const httpsEdge = result.edges.find(e => e.label === 'HTTPS'); + expect(httpsEdge).toBeDefined(); + expect(httpsEdge?.sourceId).toBe('2'); + expect(httpsEdge?.targetId).toBe('3'); + }); + + it('detects parent-child containment', async () => { + const svg = fixture('drawio-nested.svg'); + const result = await parseDrawioSvg(svg); + + const authService = result.nodes.find(n => n.label === 'Auth Service'); + const apiGateway = result.nodes.find(n => n.label === 'API Gateway'); + const usersDb = result.nodes.find(n => n.label === 'Users DB'); + + expect(authService?.parentId).toBe('vpc'); + expect(apiGateway?.parentId).toBe('vpc'); + expect(usersDb?.parentId).toBe('vpc'); + }); + + it('returns empty result for non-draw.io content', async () => { + const svg = ''; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); +}); + +describe('parseStyleString', () => { + it('parses key=value pairs separated by semicolons', () => { + const result = parseStyleString('shape=cylinder;fillColor=#dae8fc;strokeColor=#6c8ebf'); + expect(result).toEqual({ + shape: 'cylinder', + fillColor: '#dae8fc', + strokeColor: '#6c8ebf', + }); + }); + + it('handles bare values (no =) as flags', () => { + const result = parseStyleString('rounded=1;whiteSpace=wrap;html;'); + expect(result.rounded).toBe('1'); + expect(result.html).toBe('1'); + }); + + it('handles empty string', () => { + expect(parseStyleString('')).toEqual({}); + }); +}); + +describe('parseDrawioSvg - edge cases', () => { + it('strips HTML tags from labels', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes[0]?.label).toBe('Bold Text'); + }); + + it('handles inline mxGraphModel (not in content attribute)', async () => { + const svg = ` + + + + + + + + + + `; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Inline Node'); + }); + + it('skips edges with missing source or target', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.edges).toHaveLength(0); + }); + + it('handles edges with no label', async () => { + const svg = fixture('drawio-nested.svg'); + const result = await parseDrawioSvg(svg); + + const unlabeled = result.edges.find(e => !e.label); + expect(unlabeled).toBeDefined(); + expect(unlabeled?.label).toBeUndefined(); + }); + + it('handles single mxCell (non-array in xml2js)', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Solo'); + }); + + it('handles cells with no mxGeometry (skips them)', async () => { + const svg = `')}">`; + const result = await parseDrawioSvg(svg); + + expect(result.nodes).toHaveLength(0); + }); +}); + +describe('classifyDrawioStyle', () => { + it('classifies cylinder shape', () => { + expect(classifyDrawioStyle({ shape: 'cylinder' })).toBe('cylinder'); + }); + + it('classifies actor shape', () => { + expect(classifyDrawioStyle({ shape: 'actor' })).toBe('person'); + }); + + it('classifies cloud shape', () => { + expect(classifyDrawioStyle({ shape: 'cloud' })).toBe('cloud'); + }); + + it('classifies rounded rectangle', () => { + expect(classifyDrawioStyle({ rounded: '1' })).toBe('rounded-rectangle'); + }); + + it('defaults to rectangle', () => { + expect(classifyDrawioStyle({})).toBe('rectangle'); + }); + + it('classifies hexagon shape', () => { + expect(classifyDrawioStyle({ shape: 'hexagon' })).toBe('hexagon'); + }); + + it('classifies rhombus as diamond', () => { + expect(classifyDrawioStyle({ shape: 'rhombus' })).toBe('diamond'); + }); + + it('classifies ellipse style flag', () => { + expect(classifyDrawioStyle({ ellipse: '1' })).toBe('ellipse'); + }); + + it('classifies mxgraph.general.user as person', () => { + expect(classifyDrawioStyle({ shape: 'mxgraph.general.user' })).toBe('person'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.ts b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.ts new file mode 100644 index 0000000000..e889a2524d --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/drawio-parser.ts @@ -0,0 +1,354 @@ +import { parseStringPromise } from 'xml2js'; +import { inflateRaw } from 'zlib'; +import { promisify } from 'util'; +import type { ParsedSvgGraph, SvgNode, SvgEdge, ShapeHint } from './types'; + +const inflateRawAsync = promisify(inflateRaw); + +interface CellInfo { + id: string; + label: string; + cellAttrs: Record; + wrapperAttrs: Record; + cell: Record; +} + +export async function parseDrawioSvg(svgContent: string): Promise { + const mxXml = await extractMxGraphModel(svgContent); + if (!mxXml) { + return { nodes: [], edges: [], sourceFormat: 'drawio' }; + } + + const parsed = await parseStringPromise(mxXml, { explicitArray: false }); + const root = parsed?.mxGraphModel?.root; + if (!root) return { nodes: [], edges: [], sourceFormat: 'drawio' }; + + const allCells = collectAllCells(root); + const vertexIds = new Set(); + const nodes: SvgNode[] = []; + const edges: SvgEdge[] = []; + + // First pass: collect vertex IDs + for (const info of allCells) { + if (info.cellAttrs.vertex === '1') { + vertexIds.add(info.id); + } + } + + // Collect group IDs (these are container-only elements, not real nodes) + const groupIds = new Set(); + // Decorative parent IDs: groups with connectable=0 (legend boxes, decoration subtrees) + const decorativeParentIds = new Set(); + for (const info of allCells) { + const style = info.cellAttrs.style ?? ''; + if (style.includes('group')) { + groupIds.add(info.id); + if (info.cellAttrs.connectable === '0') { + decorativeParentIds.add(info.id); + } + } + } + + // Second pass: extract nodes and edges + for (const info of allCells) { + if (info.id === '0' || info.id === '1') continue; + // Skip children of decorative groups (legend items, visual-only elements) + const parent = info.cellAttrs.parent ?? ''; + if (decorativeParentIds.has(parent)) continue; + + if (info.cellAttrs.vertex === '1') { + const node = parseVertex(info, vertexIds); + if (node) nodes.push(node); + } else if (info.cellAttrs.edge === '1') { + const edge = parseEdge(info, vertexIds); + if (edge) edges.push(edge); + } + } + + // Remove parentId references to groups (since groups are skipped as nodes) + for (const node of nodes) { + if (node.parentId && groupIds.has(node.parentId)) { + node.parentId = undefined; + } + } + + return { nodes, edges, sourceFormat: 'drawio' }; +} + +function collectAllCells(root: Record): CellInfo[] { + const results: CellInfo[] = []; + + // Direct mxCell elements + for (const cell of normalizeArray(root.mxCell)) { + const attrs = (cell.$ ?? {}) as Record; + results.push({ + id: attrs.id ?? '', + label: attrs.value ?? '', + cellAttrs: attrs, + wrapperAttrs: {}, + cell, + }); + } + + // object and UserObject wrappers (C4 elements, linked elements, etc.) + for (const wrapperTag of ['object', 'UserObject']) { + for (const wrapper of normalizeArray(root[wrapperTag])) { + const wrapperAttrs = (wrapper.$ ?? {}) as Record; + const nestedCell = wrapper.mxCell as Record | undefined; + if (!nestedCell) continue; + + const cellAttrs = (nestedCell.$ ?? {}) as Record; + const id = wrapperAttrs.id ?? cellAttrs.id ?? ''; + const rawLabel = resolveLabel(wrapperAttrs); + + results.push({ + id, + label: rawLabel, + cellAttrs: { ...cellAttrs, id }, + wrapperAttrs, + cell: nestedCell, + }); + } + } + + return results; +} + +function resolveLabel(wrapperAttrs: Record): string { + const label = wrapperAttrs.label ?? ''; + + // Only use Name/c4Name when the label actually contains placeholder patterns + if (wrapperAttrs.placeholders === '1' && label.includes('%')) { + const name = wrapperAttrs.Name ?? wrapperAttrs.c4Name ?? ''; + if (name && (label.includes('%Name%') || label.includes('%c4Name%'))) { + const desc = wrapperAttrs.Description ?? wrapperAttrs.c4Description ?? ''; + return desc ? `${name}\n${desc}` : name; + } + // Resolve any %placeholder% patterns in the template + return label.replace(/%([^%]+)%/g, (_, key: string) => { + return wrapperAttrs[key] ?? ''; + }); + } + + // Label doesn't use placeholders — use it directly (may contain hardcoded HTML) + return label; +} + +async function extractMxGraphModel(svgContent: string): Promise { + // Method 1: content attribute on root SVG (most common in modern draw.io) + const contentMatch = svgContent.match(/]*?\bcontent="([^"]*)"/); + + if (contentMatch) { + const decoded = decodeDrawioContent(contentMatch[1]!); + const mxModel = extractMxGraphModelFromDecoded(decoded); + if (mxModel) return mxModel; + + // Try decompression (some exports base64+deflate the diagram content) + const decompressed = await tryDecompress(decoded); + if (decompressed) { + const mxFromDecompressed = extractMxGraphModelFromDecoded(decompressed); + if (mxFromDecompressed) return mxFromDecompressed; + } + + // The diagram element itself might hold compressed content + const diagramContent = decoded.match(/]*>([\s\S]*?)<\/diagram>/); + if (diagramContent?.[1]) { + const diagramDecompressed = await tryDecompress(diagramContent[1].trim()); + if (diagramDecompressed) { + const mxFromDiagram = extractMxGraphModelFromDecoded(diagramDecompressed); + if (mxFromDiagram) return mxFromDiagram; + } + } + } + + // Method 2: Look for mxGraphModel directly in the SVG text (foreignObject or inline) + const directMatch = svgContent.match(//); + if (directMatch) return directMatch[0]; + + return null; +} + +function extractMxGraphModelFromDecoded(content: string): string | null { + const match = content.match(//); + return match?.[0] ?? null; +} + +function decodeDrawioContent(content: string): string { + // Try URL decoding first (older draw.io exports use %3C etc.) + try { + const urlDecoded = decodeURIComponent(content); + if (urlDecoded !== content) return urlDecoded; + } catch { + // Malformed percent-encoding — decode only valid %XX escapes, + // but only use the result when it actually produced diagram XML + // (mixed-encoding content like HTML entities + percent-encoded font URLs + // should fall through to the HTML entity path below) + const lenient = content.replace(/%([0-9A-Fa-f]{2})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)) + ); + if (lenient !== content && lenient.includes(' String.fromCharCode(parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10))) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +const MAX_COMPRESSED_INPUT_BYTES = 5 * 1024 * 1024; +const MAX_DECOMPRESSED_OUTPUT_BYTES = 20 * 1024 * 1024; + +async function tryDecompress(content: string): Promise { + try { + let data = content; + try { data = decodeURIComponent(data); } catch { /* already decoded */ } + + if (Buffer.byteLength(data, 'utf-8') > MAX_COMPRESSED_INPUT_BYTES) return null; + + const buffer = Buffer.from(data, 'base64'); + if (buffer.length === 0) return null; + + const inflated = await inflateRawAsync(buffer, { maxOutputLength: MAX_DECOMPRESSED_OUTPUT_BYTES }); + const result = inflated.toString('utf-8'); + + try { + const finalDecoded = decodeURIComponent(result); + if (finalDecoded.includes('mxGraphModel')) return finalDecoded; + } catch { /* not double-encoded */ } + + if (result.includes('mxGraphModel')) return result; + return null; + } catch { + return null; + } +} + +function parseVertex(info: CellInfo, vertexIds: Set): SvgNode | null { + const { id, label: rawLabel, cellAttrs, cell } = info; + const label = stripHtml(rawLabel); + const style = cellAttrs.style ?? ''; + const styleProps = parseStyleString(style); + + // Skip group containers (they only serve as parent references) + if (styleProps['group'] === '1') return null; + // Skip connectable=0 elements (visual connectors, not real nodes) + if (cellAttrs.connectable === '0') return null; + // Skip text-only elements (labels, annotations, legend text) + if (style.startsWith('text;') || style.includes(';text;')) return null; + // Skip edge labels (annotations attached to edges) + if (style.startsWith('edgeLabel;') || style.includes(';edgeLabel;')) return null; + + const shapeHint = classifyDrawioStyle(styleProps); + const geometry = extractGeometry(cell); + if (!geometry) return null; + + // Skip small unlabeled cells (legend swatches, decoration) + if (!label && geometry.width <= 40 && geometry.height <= 40) return null; + + const parent = cellAttrs.parent ?? '1'; + const parentId = (parent !== '0' && parent !== '1' && vertexIds.has(parent)) + ? parent + : undefined; + + return { id, label, shapeHint, geometry, parentId, styleProps }; +} + +function parseEdge(info: CellInfo, vertexIds: Set): SvgEdge | null { + const { id, label: rawLabel, cellAttrs } = info; + const sourceId = cellAttrs.source ?? ''; + const targetId = cellAttrs.target ?? ''; + const label = stripHtml(rawLabel); + + if (!sourceId || !targetId) return null; + if (!vertexIds.has(sourceId) || !vertexIds.has(targetId)) return null; + + return { id, sourceId, targetId, label: label || undefined }; +} + +function extractGeometry(cell: Record): { x: number; y: number; width: number; height: number } | null { + const geo = cell.mxGeometry as Record | undefined; + if (!geo) return null; + + const geoAttrs = (geo.$ ?? {}) as Record; + const x = parseFloat(geoAttrs.x ?? '0'); + const y = parseFloat(geoAttrs.y ?? '0'); + const width = parseFloat(geoAttrs.width ?? '0'); + const height = parseFloat(geoAttrs.height ?? '0'); + + if (width === 0 && height === 0) return null; + + return { x, y, width, height }; +} + +export function parseStyleString(style: string): Record { + const props: Record = {}; + for (const part of style.split(';')) { + const trimmed = part.trim(); + if (!trimmed) continue; + const eqIdx = trimmed.indexOf('='); + if (eqIdx > 0) { + props[trimmed.slice(0, eqIdx)] = trimmed.slice(eqIdx + 1); + } else { + props[trimmed] = '1'; + } + } + return props; +} + +export function classifyDrawioStyle(styleProps: Record): ShapeHint { + const shape = styleProps['shape'] ?? ''; + + if (shape === 'cylinder' || shape === 'cylinder3') return 'cylinder'; + if (shape === 'actor' || shape.includes('general.user') || shape.includes('person')) return 'person'; + if (shape === 'cloud') return 'cloud'; + if (shape === 'hexagon') return 'hexagon'; + if (shape === 'rhombus') return 'diamond'; + if (shape === 'document' || shape === 'mxgraph.basic.document') return 'document'; + if (shape === 'parallelogram') return 'parallelogram'; + if (shape === 'ellipse' || styleProps['ellipse'] === '1') return 'ellipse'; + if (styleProps['rounded'] === '1') return 'rounded-rectangle'; + + return 'rectangle'; +} + +function stripHtml(value: string): string { + let result = value + .replace(//gi, '\n') + .replace(/<\/(?:div|p|h[1-6]|li)>/gi, '\n') + .replace(/]*>/gi, '\n'); + + // Loop to handle nested/split tags (e.g. ipt>) + let prev = ''; + while (prev !== result) { + prev = result; + result = result.replace(/<[^>]*>/g, ''); + } + + return result + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/\n{2,}/g, '\n') + .trim(); +} + +function normalizeArray(val: unknown): Array> { + if (!val) return []; + if (Array.isArray(val)) return val; + return [val as Record]; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts new file mode 100644 index 0000000000..0ac57b4b7e --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest'; +import { detectSvgFormat } from './format-detector'; + +describe('detectSvgFormat', () => { + it('detects draw.io SVG with mxGraphModel', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('detects draw.io SVG with mxfile marker', () => { + const svg = 'mxfile host="app.diagrams.net"'; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('detects draw.io SVG with content attribute containing mxCell', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('returns generic for plain SVG', () => { + const svg = 'Node'; + expect(detectSvgFormat(svg)).toBe('generic'); + }); + + it('returns generic for SVG with no diagram metadata', () => { + const svg = ` + + +`; + expect(detectSvgFormat(svg)).toBe('generic'); + }); + + it('returns generic when mxGraphModel appears only in a text label', () => { + const svg = 'Uses mxGraphModel format'; + expect(detectSvgFormat(svg)).toBe('generic'); + }); + + it('detects draw.io when mxGraphModel is a proper XML tag', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); + + it('detects draw.io from percent-encoded mxGraphModel in content attribute', () => { + const svg = ''; + expect(detectSvgFormat(svg)).toBe('drawio'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts new file mode 100644 index 0000000000..a6850a135f --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts @@ -0,0 +1,13 @@ +import type { SvgFormat } from './types'; + +export function detectSvgFormat(svgContent: string): SvgFormat { + if ( + /]/.test(svgContent) || + svgContent.includes('%3CmxGraphModel') || + svgContent.includes('mxfile') || + (svgContent.includes('content="') && svgContent.includes('mxCell')) + ) { + return 'drawio'; + } + return 'generic'; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts new file mode 100644 index 0000000000..0909d99c3f --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { parseGenericSvg } from './generic-svg-parser'; + +const fixture = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf-8'); + +describe('parseGenericSvg', () => { + it('extracts nodes from grouped shapes with text', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + expect(result.sourceFormat).toBe('generic'); + expect(result.nodes.length).toBeGreaterThanOrEqual(3); + }); + + it('extracts labels from text elements', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + const labels = result.nodes.map(n => n.label); + expect(labels).toContain('API Service'); + expect(labels).toContain('Payment System'); + expect(labels).toContain('User'); + }); + + it('classifies shapes correctly', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + const apiService = result.nodes.find(n => n.label === 'API Service'); + const user = result.nodes.find(n => n.label === 'User'); + + expect(apiService?.shapeHint).toBe('rounded-rectangle'); + expect(user?.shapeHint).toBe('ellipse'); + }); + + it('extracts geometry from shape attributes', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + const apiService = result.nodes.find(n => n.label === 'API Service'); + expect(apiService?.geometry).toEqual({ x: 50, y: 50, width: 180, height: 70 }); + }); + + it('detects edges from line elements', () => { + const svg = fixture('generic-simple.svg'); + const result = parseGenericSvg(svg); + + expect(result.edges.length).toBeGreaterThanOrEqual(1); + }); + + it('returns empty for SVG with no shapes', () => { + const svg = 'Hello'; + const result = parseGenericSvg(svg); + expect(result.nodes).toHaveLength(0); + }); + + it('detects containment and converts child coordinates to parent-relative', () => { + const svg = ` + Container + Child + `; + const result = parseGenericSvg(svg); + + const child = result.nodes.find(n => n.label === 'Child'); + const container = result.nodes.find(n => n.label === 'Container'); + expect(child?.parentId).toBe(container?.id); + expect(child?.geometry).toEqual({ x: 40, y: 40, width: 100, height: 60 }); + }); + + it('handles tspan text elements', () => { + const svg = ` + + + MultiLine + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Multi Line'); + }); + + it('assigns IDs from group id attribute', () => { + const svg = ` + + + Named + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes[0]?.id).toBe('my-custom-id'); + }); + + it('generates fallback IDs when no id attribute', () => { + const svg = ` + + + NoId + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes[0]?.id).toMatch(/^node-\d+$/); + }); + + it('handles circle shapes', () => { + const svg = ` + + + Hub + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.shapeHint).toBe('ellipse'); + expect(result.nodes[0]?.geometry).toEqual({ x: 60, y: 60, width: 80, height: 80 }); + }); + + it('ignores shapes smaller than minimum size', () => { + const svg = ` + Tiny + Big + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Big'); + }); + + it('detects polyline edges between nodes', () => { + const svg = ` + A + B + + `; + const result = parseGenericSvg(svg); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0]?.sourceId).toBe('a'); + expect(result.edges[0]?.targetId).toBe('b'); + }); + + it('does not create edge when endpoints are too far from nodes', () => { + const svg = ` + A + + `; + const result = parseGenericSvg(svg); + + expect(result.edges).toHaveLength(0); + }); + + it('handles standalone shapes not in groups with nearby text', () => { + const svg = ` + + Standalone + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Standalone'); + }); + + it('extracts nodes from nested groups', () => { + const svg = ` + + + Outer + + + Inner + + + `; + const result = parseGenericSvg(svg); + + const labels = result.nodes.map(n => n.label); + expect(labels).toContain('Outer'); + expect(labels).toContain('Inner'); + }); + + it('assigns child to nearest container, not outermost', () => { + const svg = ` + Grand + Mid + Leaf + `; + const result = parseGenericSvg(svg); + + const leaf = result.nodes.find(n => n.label === 'Leaf'); + const mid = result.nodes.find(n => n.label === 'Mid'); + expect(leaf?.parentId).toBe(mid?.id); + }); + + it('applies translate transform on group to child geometry', () => { + const svg = ` + + + Shifted + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 110, y: 210, width: 150, height: 60 }); + }); + + it('accumulates nested translate transforms', () => { + const svg = ` + + + + Deep + + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 60, y: 110, width: 150, height: 60 }); + }); + + it('ignores non-translate transforms gracefully', () => { + const svg = ` + + + Rotated + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 10, y: 10, width: 150, height: 60 }); + }); + + it('applies scale() transform to geometry', () => { + const svg = ` + + + Scaled + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 20, y: 20, width: 200, height: 100 }); + }); + + it('applies scale(sx, sy) with different axes', () => { + const svg = ` + + + Asym + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 20, y: 30, width: 200, height: 150 }); + }); + + it('extracts scale from matrix() transform', () => { + const svg = ` + + + Matrix + + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.geometry).toEqual({ x: 70, y: 130, width: 200, height: 150 }); + }); + + it('assigns nearest text to standalone shape, not first within radius', () => { + const svg = ` + + Far Label + Near Label + `; + const result = parseGenericSvg(svg); + + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]?.label).toBe('Near Label'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts new file mode 100644 index 0000000000..812390bf4e --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts @@ -0,0 +1,397 @@ +import { parse as parseSvg, type ElementNode, type TextNode } from 'svg-parser'; +import type { ParsedSvgGraph, SvgNode, SvgEdge, ShapeHint, SvgNodeGeometry } from './types'; + +type HastNode = ElementNode | TextNode; + +const MIN_SHAPE_SIZE = 20; +const EDGE_PROXIMITY_THRESHOLD = 15; + +export function parseGenericSvg(svgContent: string): ParsedSvgGraph { + const root = parseSvg(svgContent); + const svg = findElement(root.children, 'svg'); + if (!svg) return { nodes: [], edges: [], sourceFormat: 'generic' }; + + const nodes: SvgNode[] = []; + const edges: SvgEdge[] = []; + + const svgTransform = parseTransform(String(svg.properties.transform ?? '')); + extractNodesFromElement(svg, nodes, svgTransform); + extractEdgesFromElement(svg, nodes, edges); + detectContainment(nodes); + + return { nodes, edges, sourceFormat: 'generic' }; +} + +function findElement(children: HastNode[], tagName: string): ElementNode | null { + for (const child of children) { + if (child.type !== 'element') continue; + if (child.tagName === tagName) return child; + const found = findElement(child.children, tagName); + if (found) return found; + } + return null; +} + +interface Transform2D { + tx: number; + ty: number; + sx: number; + sy: number; +} + +const IDENTITY_TRANSFORM: Transform2D = { tx: 0, ty: 0, sx: 1, sy: 1 }; + +function composeTransforms(outer: Transform2D, inner: Transform2D): Transform2D { + return { + sx: outer.sx * inner.sx, + sy: outer.sy * inner.sy, + tx: outer.sx * inner.tx + outer.tx, + ty: outer.sy * inner.ty + outer.ty, + }; +} + +function applyTransformToGeometry(geo: SvgNodeGeometry, accTransform: Transform2D, localTransform: Transform2D): void { + const localX = localTransform.sx * geo.x + localTransform.tx; + const localY = localTransform.sy * geo.y + localTransform.ty; + geo.x = accTransform.sx * localX + accTransform.tx; + geo.y = accTransform.sy * localY + accTransform.ty; + geo.width = Math.abs(accTransform.sx * localTransform.sx) * geo.width; + geo.height = Math.abs(accTransform.sy * localTransform.sy) * geo.height; +} + +function extractNodesFromElement( + element: ElementNode, + nodes: SvgNode[], + accTransform: Transform2D = IDENTITY_TRANSFORM +): void { + const children = element.children; + + for (const child of children) { + if (child.type !== 'element') continue; + + if (child.tagName === 'g') { + const groupTransform = parseTransform(String(child.properties.transform ?? '')); + const childTransform = composeTransforms(accTransform, groupTransform); + const node = tryExtractNodeFromGroup(child, nodes.length, childTransform); + if (node) { + nodes.push(node); + } + extractNodesFromElement(child, nodes, childTransform); + } + } + + const capturedBounds = nodes.map(n => n.geometry); + for (const child of children) { + if (child.type !== 'element') continue; + const tag = child.tagName; + + if (tag === 'rect' || tag === 'ellipse' || tag === 'circle') { + const geo = getShapeGeometry(tag, child.properties); + if (!geo || geo.width < MIN_SHAPE_SIZE || geo.height < MIN_SHAPE_SIZE) continue; + + const elTransform = parseTransform(String(child.properties.transform ?? '')); + applyTransformToGeometry(geo, accTransform, elTransform); + + if (overlapsExisting(geo, capturedBounds)) continue; + + const label = findNearbyTextInElement(element, geo); + const id = String(child.properties.id ?? `node-${nodes.length}`); + nodes.push({ + id, + label: label ?? '', + shapeHint: classifyTag(tag, child.properties), + geometry: geo, + styleProps: {}, + }); + capturedBounds.push(geo); + } + } +} + +function tryExtractNodeFromGroup( + g: ElementNode, + index: number, + accTransform: Transform2D +): SvgNode | null { + let shapeGeo: SvgNodeGeometry | null = null; + let shapeHint: ShapeHint = 'unknown'; + let label = ''; + + let shapeTransform: Transform2D = IDENTITY_TRANSFORM; + + for (const child of g.children) { + if (child.type !== 'element') continue; + const tag = child.tagName; + + if ((tag === 'rect' || tag === 'ellipse' || tag === 'circle') && !shapeGeo) { + shapeGeo = getShapeGeometry(tag, child.properties); + shapeHint = classifyTag(tag, child.properties); + shapeTransform = parseTransform(String(child.properties.transform ?? '')); + } + + if (tag === 'text' && !label) { + label = extractTextContent(child); + } + } + + if (!shapeGeo || shapeGeo.width < MIN_SHAPE_SIZE || shapeGeo.height < MIN_SHAPE_SIZE) { + return null; + } + + applyTransformToGeometry(shapeGeo, accTransform, shapeTransform); + + const id = String(g.properties.id ?? `node-${index}`); + return { id, label, shapeHint, geometry: shapeGeo, styleProps: {} }; +} + +function extractEdgesFromElement(element: ElementNode, nodes: SvgNode[], edges: SvgEdge[]): void { + for (const child of element.children) { + if (child.type !== 'element') continue; + const tag = child.tagName; + const props = child.properties; + + if (tag === 'line') { + const edge = tryMatchLine(props, nodes, edges.length); + if (edge) edges.push(edge); + } else if (tag === 'polyline') { + const points = String(props.points ?? ''); + const coords = points.split(/\s+/).map(p => p.split(',').map(Number)); + if (coords.length >= 2) { + const start = { x: coords[0]![0]!, y: coords[0]![1]! }; + const end = { x: coords[coords.length - 1]![0]!, y: coords[coords.length - 1]![1]! }; + const source = findNearestNode(start, nodes); + const target = findNearestNode(end, nodes); + if (source && target && source !== target) { + edges.push({ + id: String(props.id ?? `edge-${edges.length}`), + sourceId: source.id, + targetId: target.id, + }); + } + } + } else if (tag === 'g') { + extractEdgesFromElement(child, nodes, edges); + } + } +} + +function tryMatchLine(props: Record, nodes: SvgNode[], index: number): SvgEdge | null { + const x1 = parseFloat(String(props.x1 ?? '')); + const y1 = parseFloat(String(props.y1 ?? '')); + const x2 = parseFloat(String(props.x2 ?? '')); + const y2 = parseFloat(String(props.y2 ?? '')); + + if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) return null; + + const source = findNearestNode({ x: x1, y: y1 }, nodes); + const target = findNearestNode({ x: x2, y: y2 }, nodes); + + if (!source || !target || source === target) return null; + + return { + id: String(props.id ?? `edge-${index}`), + sourceId: source.id, + targetId: target.id, + }; +} + +function findNearestNode(point: { x: number; y: number }, nodes: SvgNode[]): SvgNode | null { + let closest: SvgNode | null = null; + let minDist = EDGE_PROXIMITY_THRESHOLD; + + for (const node of nodes) { + const dist = distanceToNodeBorder(point, node.geometry); + if (dist < minDist) { + minDist = dist; + closest = node; + } + } + + return closest; +} + +function distanceToNodeBorder(point: { x: number; y: number }, geo: SvgNodeGeometry): number { + const { x, y, width, height } = geo; + + if (point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height) { + return 0; + } + + const nearestX = Math.max(x, Math.min(point.x, x + width)); + const nearestY = Math.max(y, Math.min(point.y, y + height)); + + return Math.sqrt((point.x - nearestX) ** 2 + (point.y - nearestY) ** 2); +} + +function detectContainment(nodes: SvgNode[]): void { + const byArea = [...nodes].sort((a, b) => { + const aArea = a.geometry.width * a.geometry.height; + const bArea = b.geometry.width * b.geometry.height; + return aArea - bArea; + }); + + for (const child of nodes) { + if (child.parentId) continue; + for (const parent of byArea) { + if (parent.id === child.id) continue; + if (parent.parentId === child.id) continue; + if (isFullyContained(child.geometry, parent.geometry)) { + child.parentId = parent.id; + break; + } + } + } + + const absoluteGeo = new Map(nodes.map(n => [n.id, { ...n.geometry }])); + for (const child of nodes) { + if (!child.parentId) continue; + const parentGeo = absoluteGeo.get(child.parentId); + if (!parentGeo) continue; + child.geometry = { + ...child.geometry, + x: child.geometry.x - parentGeo.x, + y: child.geometry.y - parentGeo.y, + }; + } +} + +function isFullyContained(inner: SvgNodeGeometry, outer: SvgNodeGeometry): boolean { + const margin = 5; + return ( + inner.x >= outer.x + margin && + inner.y >= outer.y + margin && + inner.x + inner.width <= outer.x + outer.width - margin && + inner.y + inner.height <= outer.y + outer.height - margin + ); +} + +function parseTransform(transform: string | undefined): Transform2D { + if (!transform) return IDENTITY_TRANSFORM; + + let tx = 0, ty = 0, sx = 1, sy = 1; + + const scaleMatch = transform.match(/scale\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)/); + if (scaleMatch) { + sx = parseFloat(scaleMatch[1]!); + sy = scaleMatch[2] ? parseFloat(scaleMatch[2]) : sx; + } + + const translateMatch = transform.match(/translate\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)/); + if (translateMatch) { + tx = parseFloat(translateMatch[1]!); + ty = translateMatch[2] ? parseFloat(translateMatch[2]) : 0; + } + + const matrixMatch = transform.match(/matrix\(\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)\s*\)/); + if (matrixMatch) { + const a = parseFloat(matrixMatch[1]!); + const b = parseFloat(matrixMatch[2]!); + const c = parseFloat(matrixMatch[3]!); + const d = parseFloat(matrixMatch[4]!); + tx = parseFloat(matrixMatch[5]!); + ty = parseFloat(matrixMatch[6]!); + // Extract scale from axis-aligned matrices (b ≈ 0, c ≈ 0) + if (Math.abs(b) < 0.001 && Math.abs(c) < 0.001) { + sx = a; + sy = d; + } else { + sx = Math.sqrt(a * a + b * b); + sy = Math.sqrt(c * c + d * d); + } + } + + if (!scaleMatch && !translateMatch && !matrixMatch) return IDENTITY_TRANSFORM; + + return { tx, ty, sx, sy }; +} + +function getShapeGeometry(tag: string, props: Record): SvgNodeGeometry | null { + switch (tag) { + case 'rect': { + const x = parseFloat(String(props.x ?? '0')); + const y = parseFloat(String(props.y ?? '0')); + const width = parseFloat(String(props.width ?? '0')); + const height = parseFloat(String(props.height ?? '0')); + if (width === 0 || height === 0) return null; + return { x, y, width, height }; + } + case 'ellipse': { + const cx = parseFloat(String(props.cx ?? '0')); + const cy = parseFloat(String(props.cy ?? '0')); + const rx = parseFloat(String(props.rx ?? '0')); + const ry = parseFloat(String(props.ry ?? '0')); + if (rx === 0 || ry === 0) return null; + return { x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 }; + } + case 'circle': { + const cx = parseFloat(String(props.cx ?? '0')); + const cy = parseFloat(String(props.cy ?? '0')); + const r = parseFloat(String(props.r ?? '0')); + if (r === 0) return null; + return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 }; + } + default: + return null; + } +} + +function classifyTag(tag: string, props: Record): ShapeHint { + switch (tag) { + case 'ellipse': + case 'circle': + return 'ellipse'; + case 'rect': { + const rx = parseFloat(String(props.rx ?? '0')); + return rx > 0 ? 'rounded-rectangle' : 'rectangle'; + } + default: + return 'unknown'; + } +} + +function extractTextContent(textEl: ElementNode): string { + const parts: string[] = []; + + for (const child of textEl.children) { + if (child.type === 'text' && child.value) { + parts.push(child.value); + } else if (child.type === 'element' && child.tagName === 'tspan') { + const tspanText = extractTextContent(child); + if (tspanText) parts.push(tspanText); + } + } + + return parts.join(' ').trim(); +} + +function findNearbyTextInElement(element: ElementNode, geo: SvgNodeGeometry): string | null { + const cx = geo.x + geo.width / 2; + const cy = geo.y + geo.height / 2; + const threshold = Math.max(geo.width, geo.height); + + let bestLabel: string | null = null; + let bestDist = threshold; + + for (const child of element.children) { + if (child.type !== 'element' || child.tagName !== 'text') continue; + const props = child.properties; + const tx = parseFloat(String(props.x ?? '0')); + const ty = parseFloat(String(props.y ?? '0')); + const dist = Math.sqrt((tx - cx) ** 2 + (ty - cy) ** 2); + if (dist < bestDist) { + bestDist = dist; + bestLabel = extractTextContent(child); + } + } + return bestLabel; +} + +function overlapsExisting(geo: SvgNodeGeometry, existing: SvgNodeGeometry[]): boolean { + for (const e of existing) { + if (Math.abs(geo.x - e.x) < 2 && Math.abs(geo.y - e.y) < 2 && + Math.abs(geo.width - e.width) < 2 && Math.abs(geo.height - e.height) < 2) { + return true; + } + } + return false; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/index.ts b/calm-plugins/vscode/src/extension/services/svg-import/index.ts new file mode 100644 index 0000000000..c23a39b2a3 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/index.ts @@ -0,0 +1,7 @@ +export { SvgImportService } from './svg-import-service'; +export { detectSvgFormat } from './format-detector'; +export { parseDrawioSvg } from './drawio-parser'; +export { parseGenericSvg } from './generic-svg-parser'; +export { buildCalmJson } from './calm-builder'; +export { mapShapeToNodeType } from './shape-mapper'; +export type { ParsedSvgGraph, SvgNode, SvgEdge, ImportResult, ShapeHint, SvgFormat } from './types'; diff --git a/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts new file mode 100644 index 0000000000..f9a356f06c --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from 'vitest'; +import { mapShapeToNodeType } from './shape-mapper'; + +describe('mapShapeToNodeType', () => { + describe('shape-based mapping', () => { + it('maps cylinder to database', () => { + expect(mapShapeToNodeType('cylinder', 'Orders')).toBe('database'); + }); + + it('maps person to actor', () => { + expect(mapShapeToNodeType('person', 'Admin')).toBe('actor'); + }); + + it('maps cloud to ecosystem', () => { + expect(mapShapeToNodeType('cloud', 'AWS')).toBe('ecosystem'); + }); + + it('maps rounded-rectangle to service', () => { + expect(mapShapeToNodeType('rounded-rectangle', 'Auth')).toBe('service'); + }); + + it('maps rectangle to system', () => { + expect(mapShapeToNodeType('rectangle', 'Backend')).toBe('system'); + }); + + it('maps unknown to system', () => { + expect(mapShapeToNodeType('unknown', 'Thing')).toBe('system'); + }); + + it('maps document to data-asset', () => { + expect(mapShapeToNodeType('document', 'Report')).toBe('data-asset'); + }); + }); + + describe('label-based overrides', () => { + it('overrides shape for database keywords', () => { + expect(mapShapeToNodeType('rectangle', 'PostgreSQL Database')).toBe('database'); + }); + + it('overrides shape for actor keywords', () => { + expect(mapShapeToNodeType('rectangle', 'End User')).toBe('actor'); + }); + + it('overrides shape for webclient keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Web App Frontend')).toBe('webclient'); + }); + + it('overrides shape for network keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Private VPC')).toBe('network'); + }); + + it('overrides shape for ldap keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Active Directory')).toBe('ldap'); + }); + + it('overrides shape for ecosystem keywords', () => { + expect(mapShapeToNodeType('rectangle', 'Third Party API')).toBe('ecosystem'); + }); + + it('does not override when no keyword matches', () => { + expect(mapShapeToNodeType('cylinder', 'Cache Layer')).toBe('database'); + }); + + it('prefers database over actor when both keywords appear', () => { + expect(mapShapeToNodeType('rectangle', 'Customer Database')).toBe('database'); + }); + + it('prefers network over actor for "Client VPC"', () => { + expect(mapShapeToNodeType('rectangle', 'Client VPC')).toBe('network'); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts new file mode 100644 index 0000000000..52b61e5d1c --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts @@ -0,0 +1,33 @@ +import type { ShapeHint } from './types'; + +const SHAPE_TO_NODE_TYPE: Record = { + 'cylinder': 'database', + 'person': 'actor', + 'cloud': 'ecosystem', + 'ellipse': 'system', + 'hexagon': 'service', + 'diamond': 'service', + 'rectangle': 'system', + 'rounded-rectangle': 'service', + 'document': 'data-asset', + 'parallelogram': 'data-asset', + 'unknown': 'system', +}; + +const LABEL_PATTERNS: Array<[RegExp, string]> = [ + [/\b(db|database|datastore|data.?store|storage|redis|postgres|mysql|mongo|dynamo|cassandra)\b/i, 'database'], + [/\b(browser|web.?app|frontend|ui|spa|portal)\b/i, 'webclient'], + [/\b(network|vpc|subnet|firewall|dmz|zone|vnet)\b/i, 'network'], + [/\b(ldap|active.?directory)\b/i, 'ldap'], + [/\b(ecosystem|external|third.?party|cloud|platform)\b/i, 'ecosystem'], + [/\b(user|actor|person|customer|client|operator)\b/i, 'actor'], +]; + +export function mapShapeToNodeType(shapeHint: ShapeHint, label: string): string { + // Label-based overrides take priority for strong signals + for (const [pattern, nodeType] of LABEL_PATTERNS) { + if (pattern.test(label)) return nodeType; + } + + return SHAPE_TO_NODE_TYPE[shapeHint] ?? 'system'; +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts new file mode 100644 index 0000000000..2aad91b979 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { SvgImportService } from './svg-import-service'; + +const VALID_SVG = ` + Test Node +`; + +const EMPTY_SVG = ''; + +const SVG_URI = vscode.Uri.file('/workspace/diagram.svg'); +const OUTPUT_URI = vscode.Uri.file('/workspace/diagram.calm.json'); + +function createMockDocument(content = '{}'): vscode.TextDocument { + return { + uri: vscode.Uri.file('/workspace/test.calm.json'), + getText: () => content, + positionAt: (offset: number) => ({ line: 0, character: offset }), + } as unknown as vscode.TextDocument; +} + +function createMockOutputChannel(): vscode.OutputChannel { + return { appendLine: vi.fn() } as unknown as vscode.OutputChannel; +} + +describe('SvgImportService', () => { + let service: SvgImportService; + + beforeEach(() => { + service = new SvgImportService(createMockOutputChannel()); + (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue('Import'); + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue([vscode.Uri.file('/test.svg')]); + (vscode.window as Record).showInformationMessage = vi.fn().mockResolvedValue(undefined); + (vscode.window as Record).showErrorMessage = vi.fn().mockResolvedValue(undefined); + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(VALID_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + (vscode.workspace as Record).applyEdit = vi.fn().mockResolvedValue(true); + }); + + it('returns null when no document is open', async () => { + const result = await service.importSvgIntoDocument(undefined); + expect(result).toBeNull(); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('No CALM document is open.'); + }); + + it('returns null when user cancels confirmation', async () => { + (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue(undefined); + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + }); + + it('returns null when user cancels file picker', async () => { + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue(undefined); + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + }); + + it('returns null when SVG has no nodes', async () => { + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(EMPTY_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + }); + + it('successfully imports SVG and replaces document', async () => { + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).not.toBeNull(); + const doc = JSON.parse(result!); + expect(doc.nodes).toHaveLength(1); + expect(vscode.window.showInformationMessage).toHaveBeenCalled(); + }); + + it('returns null when applyEdit fails', async () => { + (vscode.workspace as Record).applyEdit = vi.fn().mockResolvedValue(false); + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'Failed to apply the imported CALM JSON to the document.' + ); + }); + + it('returns null and shows error on parse failure', async () => { + const invalidSvg = ' }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(invalidSvg)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + const result = await service.importSvgIntoDocument(createMockDocument()); + expect(result).toBeNull(); + expect(vscode.window.showErrorMessage).toHaveBeenCalled(); + }); +}); + +describe('SvgImportService - importSvgToNewFile', () => { + let service: SvgImportService; + + beforeEach(() => { + service = new SvgImportService(createMockOutputChannel()); + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue([SVG_URI]); + (vscode.window as Record).showSaveDialog = vi.fn().mockResolvedValue(OUTPUT_URI); + (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue(undefined); + (vscode.window as Record).showInformationMessage = vi.fn().mockResolvedValue('No'); + (vscode.window as Record).showErrorMessage = vi.fn().mockResolvedValue(undefined); + (vscode.commands as Record).executeCommand = vi.fn().mockResolvedValue(undefined); + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(VALID_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + }); + + it('returns early when user cancels file picker (no sourceUri)', async () => { + (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue(undefined); + await service.importSvgToNewFile(); + expect(vscode.workspace.fs.readFile).not.toHaveBeenCalled(); + }); + + it('shows warning and returns when SVG has no nodes', async () => { + (vscode.workspace as { fs: Record }).fs = { + readFile: vi.fn().mockResolvedValue(Buffer.from(EMPTY_SVG)), + writeFile: vi.fn().mockResolvedValue(undefined), + }; + await service.importSvgToNewFile(SVG_URI); + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('No nodes found in the SVG.'); + expect(vscode.window.showSaveDialog).not.toHaveBeenCalled(); + }); + + it('returns when user cancels save dialog', async () => { + (vscode.window as Record).showSaveDialog = vi.fn().mockResolvedValue(undefined); + await service.importSvgToNewFile(SVG_URI); + expect(vscode.workspace.fs.writeFile).not.toHaveBeenCalled(); + }); + + it('writes CALM JSON to the chosen file on success', async () => { + await service.importSvgToNewFile(SVG_URI); + expect(vscode.workspace.fs.writeFile).toHaveBeenCalledWith( + OUTPUT_URI, + expect.any(Buffer) + ); + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('1 nodes') + ); + }); + + it('opens canvas when user confirms', async () => { + (vscode.window as Record).showInformationMessage = vi.fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce('Yes'); + await service.importSvgToNewFile(SVG_URI); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('calm.openCanvas', OUTPUT_URI); + }); + + it('uses sourceUri directly when provided (skips open dialog)', async () => { + await service.importSvgToNewFile(SVG_URI); + expect(vscode.window.showOpenDialog).not.toHaveBeenCalled(); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.ts b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.ts new file mode 100644 index 0000000000..75269d313e --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.ts @@ -0,0 +1,168 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import { detectSvgFormat } from './format-detector'; +import { parseDrawioSvg } from './drawio-parser'; +import { parseGenericSvg } from './generic-svg-parser'; +import { buildCalmJson } from './calm-builder'; +import type { ImportResult } from './types'; + +export class SvgImportService { + private log: vscode.OutputChannel; + + constructor(outputChannel: vscode.OutputChannel) { + this.log = outputChannel; + } + + async importSvgIntoDocument(currentDocument: vscode.TextDocument | undefined): Promise { + this.log.appendLine(`[SvgImport] importSvgIntoDocument called, document=${currentDocument?.uri.fsPath ?? 'undefined'}`); + + if (!currentDocument) { + vscode.window.showWarningMessage('No CALM document is open.'); + return null; + } + + const confirm = await vscode.window.showWarningMessage( + 'This will replace the current architecture with the imported SVG diagram. Continue?', + { modal: true }, + 'Import' + ); + this.log.appendLine(`[SvgImport] User confirmation: ${confirm}`); + if (confirm !== 'Import') return null; + + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { 'SVG Files': ['svg'] }, + title: 'Select SVG to import as CALM architecture', + }); + if (!uris || uris.length === 0) return null; + + const uri = uris[0]!; + this.log.appendLine(`[SvgImport] Reading SVG: ${uri.fsPath}`); + + const content = Buffer.from( + await vscode.workspace.fs.readFile(uri) + ).toString('utf-8'); + + const result = await this.parseSvg(content); + if (!result) return null; + + if (result.nodeCount === 0) { + vscode.window.showWarningMessage('No nodes found in the SVG. The file may not contain diagram elements.'); + return null; + } + + if (result.warnings.length > 0) { + this.log.appendLine(`[SvgImport] Warnings: ${result.warnings.join('; ')}`); + vscode.window.showWarningMessage( + `Imported with ${result.warnings.length} warning(s). See CALM Canvas output for details.` + ); + } + + // Write to the current document + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + currentDocument.positionAt(0), + currentDocument.positionAt(currentDocument.getText().length) + ); + edit.replace(currentDocument.uri, fullRange, result.json); + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + vscode.window.showErrorMessage('Failed to apply the imported CALM JSON to the document.'); + this.log.appendLine('[SvgImport] ERROR: workspace.applyEdit returned false'); + return null; + } + + vscode.window.showInformationMessage( + `Imported ${result.nodeCount} nodes and ${result.relationshipCount} relationships from SVG.` + ); + + this.log.appendLine( + `[SvgImport] Complete: ${result.nodeCount} nodes, ${result.relationshipCount} relationships` + ); + + return result.json; + } + + async importSvgToNewFile(sourceUri?: vscode.Uri): Promise { + const uri = sourceUri ?? await this.promptForFile(); + if (!uri) return; + + const content = Buffer.from( + await vscode.workspace.fs.readFile(uri) + ).toString('utf-8'); + + const result = await this.parseSvg(content); + if (!result) return; + + if (result.nodeCount === 0) { + vscode.window.showWarningMessage('No nodes found in the SVG.'); + return; + } + + const stem = path.basename(uri.fsPath, '.svg'); + const defaultName = `${stem}.calm.json`; + const defaultUri = vscode.Uri.file( + path.join(path.dirname(uri.fsPath), defaultName) + ); + + const outputUri = await vscode.window.showSaveDialog({ + defaultUri, + filters: { 'CALM JSON': ['json'] }, + title: 'Save imported CALM architecture', + }); + if (!outputUri) return; + + await vscode.workspace.fs.writeFile(outputUri, Buffer.from(result.json, 'utf-8')); + + if (result.warnings.length > 0) { + this.log.appendLine(`[SvgImport] Warnings: ${result.warnings.join('; ')}`); + } + + vscode.window.showInformationMessage( + `Imported ${result.nodeCount} nodes, ${result.relationshipCount} relationships → ${path.basename(outputUri.fsPath)}` + ); + + const open = await vscode.window.showInformationMessage( + 'Open in CALM Canvas?', 'Yes', 'No' + ); + if (open === 'Yes') { + await vscode.commands.executeCommand('calm.openCanvas', outputUri); + } + } + + private async parseSvg(content: string): Promise { + try { + const format = detectSvgFormat(content); + this.log.appendLine(`[SvgImport] Detected format: ${format}`); + + let graph = format === 'drawio' + ? await parseDrawioSvg(content) + : parseGenericSvg(content); + + if (format === 'drawio' && graph.nodes.length === 0) { + this.log.appendLine('[SvgImport] Draw.io parse yielded 0 nodes, falling back to generic parser'); + graph = parseGenericSvg(content); + } + + this.log.appendLine( + `[SvgImport] Parsed: ${graph.nodes.length} nodes, ${graph.edges.length} edges` + ); + + return buildCalmJson(graph); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log.appendLine(`[SvgImport] ERROR: ${message}`); + vscode.window.showErrorMessage(`Failed to parse SVG: ${message}`); + return null; + } + } + + private async promptForFile(): Promise { + const uris = await vscode.window.showOpenDialog({ + canSelectMany: false, + filters: { 'SVG Files': ['svg'] }, + title: 'Select SVG to import as CALM architecture', + }); + return uris?.[0]; + } +} diff --git a/calm-plugins/vscode/src/extension/services/svg-import/types.ts b/calm-plugins/vscode/src/extension/services/svg-import/types.ts new file mode 100644 index 0000000000..becea0bd84 --- /dev/null +++ b/calm-plugins/vscode/src/extension/services/svg-import/types.ts @@ -0,0 +1,50 @@ +export type ShapeHint = + | 'rectangle' + | 'rounded-rectangle' + | 'cylinder' + | 'ellipse' + | 'diamond' + | 'person' + | 'cloud' + | 'hexagon' + | 'document' + | 'parallelogram' + | 'unknown'; + +export interface SvgNodeGeometry { + x: number; + y: number; + width: number; + height: number; +} + +export interface SvgNode { + id: string; + label: string; + shapeHint: ShapeHint; + geometry: SvgNodeGeometry; + parentId?: string; + styleProps: Record; +} + +export interface SvgEdge { + id: string; + sourceId: string; + targetId: string; + label?: string; +} + +export type SvgFormat = 'drawio' | 'generic'; + +export interface ParsedSvgGraph { + nodes: SvgNode[]; + edges: SvgEdge[]; + sourceFormat: SvgFormat; +} + +export interface ImportResult { + json: string; + nodeCount: number; + relationshipCount: number; + warnings: string[]; +} diff --git a/calm-plugins/vscode/src/extension/services/workspace-asset-service.test.ts b/calm-plugins/vscode/src/extension/services/workspace-asset-service.test.ts index 025731bb2f..c6c2c7d42f 100644 --- a/calm-plugins/vscode/src/extension/services/workspace-asset-service.test.ts +++ b/calm-plugins/vscode/src/extension/services/workspace-asset-service.test.ts @@ -2,117 +2,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import * as vscode from 'vscode'; import { WorkspaceAssetService, - frontMatterControlsToMap, } from './workspace-asset-service'; const encode = (s: string) => new TextEncoder().encode(s); -describe('frontMatterControlsToMap', () => { - it('converts a front-matter controls array into the CALM control map', () => { - const map = frontMatterControlsToMap( - [ - { - id: 'app-id', - name: 'Application ID', - metadata: { - validation: { - pattern: '^AP\\d+$', - example: 'AP187183', - }, - }, - }, - ], - 'standards/application-software-delivery/STD100002.md' - ); - expect(Object.keys(map)).toEqual(['app-id']); - expect(map['app-id']).toEqual({ - description: 'Application ID', - requirements: [ - { - 'requirement-url': - 'standards/application-software-delivery/STD100002.md', - config: {}, - }, - ], - metadata: { - validation: { pattern: '^AP\\d+$', example: 'AP187183' }, - }, - }); - }); - - it('falls back to the control name as id and omits metadata when absent', () => { - const map = frontMatterControlsToMap( - [{ name: 'Encryption' }], - 'standards/x.md' - ); - expect(map['Encryption']).toEqual({ - description: 'Encryption', - requirements: [{ 'requirement-url': 'standards/x.md', config: {} }], - }); - }); - - it('returns an empty map for non-array / missing controls', () => { - expect(frontMatterControlsToMap(undefined, 'x')).toEqual({}); - expect(frontMatterControlsToMap({}, 'x')).toEqual({}); - }); -}); - -describe('WorkspaceAssetService.resolveStandardProse', () => { - beforeEach(() => { - (vscode.workspace as any).workspaceFolders = [ - { uri: vscode.Uri.file('/ws') }, - ]; - (vscode.workspace as any).getConfiguration = () => ({ - get: () => undefined, - }); - (vscode.workspace as any).fs = { - readFile: vi.fn(async () => { - throw new Error('ENOENT'); - }), - }; - }); - - it('returns the markdown contents from a workspace root', async () => { - (vscode.workspace as any).fs.readFile = vi.fn( - async (uri: { fsPath: string }) => { - if (uri.fsPath === '/ws/standards/tls-policy.md') - return encode('# TLS Policy'); - throw new Error('ENOENT'); - } - ); - - const svc = new WorkspaceAssetService('/ws'); - expect(await svc.resolveStandardProse('standards/tls-policy.md')).toBe( - '# TLS Policy' - ); - }); - - it('falls through to the configured external assets path', async () => { - (vscode.workspace as any).getConfiguration = () => ({ - get: () => '/ext', - }); - (vscode.workspace as any).fs.readFile = vi.fn( - async (uri: { fsPath: string }) => { - if (uri.fsPath === '/ext/standards/x.md') - return encode('external prose'); - throw new Error('ENOENT'); - } - ); - - const svc = new WorkspaceAssetService('/ws'); - expect(await svc.resolveStandardProse('standards/x.md')).toBe( - 'external prose' - ); - }); - - it('returns null when no root contains the file', async () => { - const svc = new WorkspaceAssetService('/ws'); - expect( - await svc.resolveStandardProse('standards/missing.md') - ).toBeNull(); - }); -}); - describe('WorkspaceAssetService.scanPatterns', () => { beforeEach(() => { (vscode.workspace as any).workspaceFolders = [ @@ -168,3 +61,179 @@ describe('WorkspaceAssetService.scanPatterns', () => { }); }); }); + +describe('WorkspaceAssetService.scanControls', () => { + const requirement = (id: string) => + JSON.stringify({ + $schema: 'https://json-schema.org/draft/2020-12/schema', + properties: { + 'control-id': { const: `${id}-001` }, + name: { const: `${id} control` }, + description: { const: `${id} description` }, + 'permit-ingress': { type: 'boolean' }, + }, + required: ['control-id', 'name', 'description'], + }); + + beforeEach(() => { + (vscode.workspace as any).workspaceFolders = [ + { uri: vscode.Uri.file('/ws') }, + ]; + (vscode.workspace as any).getConfiguration = () => ({ + get: () => undefined, + }); + }); + + it('discovers and parses .requirement.json files', async () => { + (vscode.workspace as any).findFiles = vi.fn( + async (glob: { base: { fsPath: string }; pattern: string }) => { + if (glob.pattern.startsWith('controls/')) { + return [ + vscode.Uri.file('/ws/controls/micro-segmentation.requirement.json'), + ]; + } + return []; + } + ); + (vscode.workspace as any).fs = { + readFile: vi.fn(async (uri: { fsPath: string }) => { + if (uri.fsPath.endsWith('micro-segmentation.requirement.json')) { + return encode(requirement('micro')); + } + throw new Error('ENOENT'); + }), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanControls(); + const controls = svc.getControls(); + expect(controls).toHaveLength(1); + expect(controls[0]).toEqual({ + id: 'micro-segmentation', + controlId: 'micro-001', + name: 'micro control', + description: 'micro description', + filePath: '/ws/controls/micro-segmentation.requirement.json', + relativePath: 'controls/micro-segmentation.requirement.json', + }); + }); + + it('skips malformed requirement files', async () => { + (vscode.workspace as any).findFiles = vi.fn(async () => [ + vscode.Uri.file('/ws/controls/broken.requirement.json'), + ]); + (vscode.workspace as any).fs = { + readFile: vi.fn(async () => encode('{ not json')), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanControls(); + expect(svc.getControls()).toHaveLength(0); + }); + + it('discovers a plain .json control file (stem has no .requirement)', async () => { + (vscode.workspace as any).findFiles = vi.fn(async () => [ + vscode.Uri.file('/ws/controls/tls.json'), + ]); + (vscode.workspace as any).fs = { + readFile: vi.fn(async () => encode(requirement('tls'))), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanControls(); + const controls = svc.getControls(); + expect(controls).toHaveLength(1); + expect(controls[0].id).toBe('tls'); + expect(controls[0].relativePath).toBe('controls/tls.json'); + }); + + it('skips a non-requirement .json under controls/', async () => { + (vscode.workspace as any).findFiles = vi.fn(async () => [ + vscode.Uri.file('/ws/controls/index.json'), + ]); + (vscode.workspace as any).fs = { + readFile: vi.fn(async () => + encode(JSON.stringify({ some: 'unrelated config' })) + ), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanControls(); + expect(svc.getControls()).toHaveLength(0); + }); + + it('accepts requirements missing identity constants using fallback identity from filename', async () => { + (vscode.workspace as any).findFiles = vi.fn(async () => [ + vscode.Uri.file('/ws/controls/no-identity.requirement.json'), + ]); + (vscode.workspace as any).fs = { + readFile: vi.fn(async () => + encode(JSON.stringify({ properties: { foo: { type: 'string' } } })) + ), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanControls(); + expect(svc.getControls()).toHaveLength(1); + expect(svc.getControls()[0].id).toBe('no-identity'); + expect(svc.getControls()[0].controlId).toBe('no-identity'); + }); + + it('uses schema title and description for fallback identity', async () => { + (vscode.workspace as any).findFiles = vi.fn(async () => [ + vscode.Uri.file('/ws/controls/platform/resiliency-tier.requirement.json'), + ]); + (vscode.workspace as any).fs = { + readFile: vi.fn(async () => + encode(JSON.stringify({ + title: 'Resiliency Tier', + description: 'Select the appropriate tier', + type: 'object', + properties: { value: { type: 'string', enum: ['Tier 1', 'Tier 2'] } }, + required: ['value'], + })) + ), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanControls(); + expect(svc.getControls()).toHaveLength(1); + const ctrl = svc.getControls()[0]; + expect(ctrl.id).toBe('resiliency-tier'); + expect(ctrl.name).toBe('Resiliency Tier'); + expect(ctrl.description).toBe('Select the appropriate tier'); + expect(ctrl.domain).toBe('platform'); + }); + + it('deduplicates by relative path across roots (first root wins)', async () => { + (vscode.workspace as any).workspaceFolders = [ + { uri: vscode.Uri.file('/ws') }, + ]; + (vscode.workspace as any).getConfiguration = () => ({ + get: (key: string) => (key === 'externalAssetsPath' ? '/ext' : undefined), + }); + (vscode.workspace as any).findFiles = vi.fn( + async (glob: { base: { fsPath: string } }) => { + if (glob.base.fsPath === '/ws') { + return [vscode.Uri.file('/ws/controls/dup.requirement.json')]; + } + if (glob.base.fsPath === '/ext') { + return [vscode.Uri.file('/ext/controls/dup.requirement.json')]; + } + return []; + } + ); + (vscode.workspace as any).fs = { + readFile: vi.fn(async (uri: { fsPath: string }) => + encode(requirement(uri.fsPath.startsWith('/ws') ? 'ws' : 'ext')) + ), + }; + + const svc = new WorkspaceAssetService('/ws'); + await (svc as any).scanControls(); + const controls = svc.getControls(); + expect(controls).toHaveLength(1); + expect(controls[0].controlId).toBe('ws-001'); + expect(controls[0].filePath).toBe('/ws/controls/dup.requirement.json'); + }); +}); diff --git a/calm-plugins/vscode/src/extension/services/workspace-asset-service.ts b/calm-plugins/vscode/src/extension/services/workspace-asset-service.ts index 7c6a820476..e75a70948e 100644 --- a/calm-plugins/vscode/src/extension/services/workspace-asset-service.ts +++ b/calm-plugins/vscode/src/extension/services/workspace-asset-service.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import * as path from 'path'; -import * as YAML from 'yaml'; +import { parseRequirementSchema } from './requirement-parser'; export interface BuildingBlockDef { id: string; @@ -9,6 +9,28 @@ export interface BuildingBlockDef { controls: Record; category?: string; nodeType?: string; + /** Hub namespace — present when this block was fetched from a remote CalmHub. */ + namespace?: string; + /** Content-addressable SHA — present when this block was fetched from a remote CalmHub. */ + sha?: string; +} + +/** A locally-authored standalone control requirement discovered under `controls/`. */ +export interface LocalControlDef { + /** File stem, e.g. "micro-segmentation". */ + id: string; + /** `properties.control-id.const` from the requirement schema. */ + controlId: string; + /** `properties.name.const`. */ + name: string; + /** `properties.description.const`. */ + description: string; + /** Absolute path on disk. */ + filePath: string; + /** Normalized workspace-relative path — used for local file resolution. */ + relativePath: string; + /** Domain derived from the first subdirectory under `controls/`, if any. */ + domain?: string; } export interface PatternEntry { @@ -27,54 +49,12 @@ export interface CalmTemplate { content: unknown; } -export interface StandardDef { - id: string; - name: string; - filePath: string; -} - -/** - * Convert a markdown front-matter `controls` array into the CALM control-map - * shape used on nodes: `{ [id]: { description, requirements:[{requirement-url,config}], metadata } }`. - * Pure and exported so it can be unit-tested without the VS Code API. - */ -export function frontMatterControlsToMap( - controls: unknown, - requirementUrl: string -): Record { - if (!Array.isArray(controls)) return {}; - const map: Record = {}; - for (const raw of controls) { - if (!raw || typeof raw !== 'object') continue; - const ctrl = raw as Record; - const idVal = - typeof ctrl.id === 'string' - ? ctrl.id - : typeof ctrl.name === 'string' - ? ctrl.name - : ''; - if (!idVal) continue; - const entry: Record = { - description: - (typeof ctrl.description === 'string' - ? ctrl.description - : undefined) ?? - (typeof ctrl.name === 'string' ? ctrl.name : ''), - requirements: [{ 'requirement-url': requirementUrl, config: {} }], - }; - if (ctrl.metadata && typeof ctrl.metadata === 'object') { - entry.metadata = ctrl.metadata; - } - map[idVal] = entry; - } - return map; -} export class WorkspaceAssetService { private buildingBlocks: BuildingBlockDef[] = []; private patterns: PatternEntry[] = []; private templates: CalmTemplate[] = []; - private standards: StandardDef[] = []; + private controls: LocalControlDef[] = []; private watchers: vscode.FileSystemWatcher[] = []; private debounceTimer: ReturnType | null = null; @@ -95,11 +75,14 @@ export class WorkspaceAssetService { } async scanAll(): Promise { + // Controls must be scanned before building blocks, because + // addStandardsPaletteItems() (invoked from scanBuildingBlocks) resolves + // standard control-refs against the scanned local controls. + await this.scanControls(); await Promise.all([ this.scanBuildingBlocks(), this.scanPatterns(), this.scanTemplates(), - this.scanStandards(), ]); } @@ -112,8 +95,8 @@ export class WorkspaceAssetService { getTemplates(): CalmTemplate[] { return this.templates; } - getStandards(): StandardDef[] { - return this.standards; + getControls(): LocalControlDef[] { + return this.controls; } registerWatchers( @@ -125,8 +108,7 @@ export class WorkspaceAssetService { 'building-blocks/**/*.{calm.json,architecture.json}', 'patterns/**/*.pattern.json', 'templates/**/*.template.json', - 'standards/**/*.md', - 'guidelines/**/*.md', + 'controls/**/*.json', ]; const roots = this.getRoots(); @@ -152,6 +134,59 @@ export class WorkspaceAssetService { }, 500); } + private async scanControls(): Promise { + const controls: LocalControlDef[] = []; + const seen = new Set(); + + for (const root of this.getRoots()) { + // Any `controls/**/*.json` is considered; parseRequirementSchema + // below skips files that aren't valid control requirements. + const glob = new vscode.RelativePattern( + root, + 'controls/**/*.json' + ); + const files = await vscode.workspace.findFiles(glob); + for (const file of files) { + const relativePath = this.toRelativeUrl(root, file); + // Multi-root: the first root to yield a given relative path wins. + if (seen.has(relativePath)) continue; + try { + const bytes = await vscode.workspace.fs.readFile(file); + const schema = JSON.parse( + Buffer.from(bytes).toString('utf-8') + ); + const id = path + .basename(file.fsPath) + .replace(/(\.requirement)?\.json$/, ''); + const title = typeof schema.title === 'string' ? schema.title : id; + const desc = typeof schema.description === 'string' ? schema.description : id; + const fallbackIdentity = { controlId: id, name: title, description: desc }; + const { parsed } = parseRequirementSchema(schema, fallbackIdentity); + if (!parsed) continue; + seen.add(relativePath); + // Derive domain from subdirectory: controls/{domain}/{name}.json + const segments = relativePath.replace(/\\/g, '/').split('/'); + const domain = segments[0] === 'controls' && segments.length > 2 + ? segments[1] + : undefined; + controls.push({ + id, + controlId: parsed.identity.controlId, + name: parsed.identity.name, + description: parsed.identity.description, + filePath: file.fsPath, + relativePath, + domain, + }); + } catch { + /* skip malformed requirement files */ + } + } + } + + this.controls = controls; + } + private async scanBuildingBlocks(): Promise { const nodes: BuildingBlockDef[] = []; const seen = new Set(); @@ -217,72 +252,9 @@ export class WorkspaceAssetService { } } - // Also convert standards/guidelines markdown to palette items - for (const root of roots) { - await this.addStandardsPaletteItems(nodes, root, 'standards'); - await this.addStandardsPaletteItems(nodes, root, 'guidelines'); - } - this.buildingBlocks = nodes; } - private async addStandardsPaletteItems( - nodes: BuildingBlockDef[], - root: vscode.Uri, - folder: 'standards' | 'guidelines' - ): Promise { - const pattern = new vscode.RelativePattern(root, `${folder}/**/*.md`); - const files = await vscode.workspace.findFiles(pattern); - - for (const file of files) { - const stem = this.stem(file); - if (stem === 'README') continue; - const id = `${folder}:${stem}`; - const category = this.extractCategory(file, folder) || 'General'; - const requirementUrl = this.toRelativeUrl(root, file); - const fm = await this.readFrontMatter(file, requirementUrl); - const name = - fm?.name ?? - stem - .replace(/-/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); - - nodes.push({ - id, - name, - behaviour: 'apply-controls-on-drop', - controls: fm?.controls ?? {}, - category, - nodeType: 'standard', - }); - } - } - - /** - * Parse a standard/guideline markdown's YAML front matter to extract its - * display name and control definitions, so dropping it applies validatable - * controls onto the target node. - */ - private async readFrontMatter( - file: vscode.Uri, - requirementUrl: string - ): Promise<{ name?: string; controls: Record } | null> { - try { - const bytes = await vscode.workspace.fs.readFile(file); - const text = Buffer.from(bytes).toString('utf-8'); - const match = /^---\s*\r?\n([\s\S]*?)\r?\n---/.exec(text); - if (!match) return null; - const fm = YAML.parse(match[1]) as Record | null; - if (!fm || typeof fm !== 'object') return null; - return { - name: typeof fm.name === 'string' ? fm.name : undefined, - controls: frontMatterControlsToMap(fm.controls, requirementUrl), - }; - } catch { - return null; - } - } - private toRelativeUrl(root: vscode.Uri, file: vscode.Uri): string { return path .relative(root.fsPath, file.fsPath) @@ -382,56 +354,6 @@ export class WorkspaceAssetService { this.templates = templates; } - private async scanStandards(): Promise { - const standards: StandardDef[] = []; - const dirs = ['standards', 'guidelines']; - - for (const dir of dirs) { - const dirPath = path.join(this.workspaceRoot, dir); - try { - const uri = vscode.Uri.file(dirPath); - const entries = await vscode.workspace.fs.readDirectory(uri); - for (const [name, type] of entries) { - if (type !== vscode.FileType.File || !name.endsWith('.md')) - continue; - standards.push({ - id: name.replace('.md', ''), - name: name.replace('.md', '').replace(/-/g, ' '), - filePath: path.join(dirPath, name), - }); - } - } catch { - /* directory doesn't exist */ - } - } - - this.standards = standards; - } - - /** - * Resolve the raw markdown prose for a standard/guideline referenced by a - * control requirement URL (e.g. `standards/tls-policy.md`). Searches every - * workspace root plus the configured external assets path. - */ - async resolveStandardProse(requirementUrl: string): Promise { - if (requirementUrl.includes('..') || requirementUrl.startsWith('/') || /^[a-zA-Z]:/.test(requirementUrl)) { - return null; - } - for (const root of this.getRoots()) { - const uri = vscode.Uri.joinPath(root, requirementUrl); - if (!uri.fsPath.startsWith(root.fsPath)) { - continue; - } - try { - const bytes = await vscode.workspace.fs.readFile(uri); - return Buffer.from(bytes).toString('utf-8'); - } catch { - /* try next root */ - } - } - return null; - } - dispose(): void { if (this.debounceTimer) clearTimeout(this.debounceTimer); for (const watcher of this.watchers) watcher.dispose(); diff --git a/calm-plugins/vscode/src/extension/types/messages.ts b/calm-plugins/vscode/src/extension/types/messages.ts index e600c27779..e0956ffafa 100644 --- a/calm-plugins/vscode/src/extension/types/messages.ts +++ b/calm-plugins/vscode/src/extension/types/messages.ts @@ -4,6 +4,9 @@ * `visualizer/contracts/editor-contracts.ts` `SolutionMetadata` so the two * editors stay protocol-compatible. */ +import type { ParsedRequirement } from '../services/requirement-parser'; +import type { ControlBrowseGroup } from '../services/control-asset-service'; + export interface SolutionMetadata { id?: string; name: string; @@ -16,6 +19,17 @@ export interface SolutionMetadata { resources?: string; } +/** + * Flattened ADR entry surfaced to the webview. ADR ids are only unique within a + * namespace, so `namespace` is part of the identity. + */ +export interface AdrEntry { + namespace: string; + id: number; + title: string; + status: string; +} + export type ExtToWebviewMessage = | { type: 'modelUpdated'; @@ -25,8 +39,7 @@ export type ExtToWebviewMessage = | { type: 'templatesLoaded'; templates: unknown[] } | { type: 'patternsLoaded'; patterns: unknown[] } | { type: 'buildingBlocksLoaded'; nodes: unknown[] } - | { type: 'standardsLoaded'; standards: unknown[] } - | { type: 'standardProse'; url: string; prose: string } + | { type: 'adrsLoaded'; adrs: AdrEntry[] } | { type: 'drillResult'; json: string; @@ -34,14 +47,63 @@ export type ExtToWebviewMessage = filePath: string; readonly?: boolean; solution?: SolutionMetadata; - }; + } + | { + type: 'definitionResolved'; + nodeId: string; + controls: Record; + } + | { + type: 'definitionResolutionFailed'; + nodeId: string; + error: string; + } + | { + type: 'updatesAvailable'; + updates: Array<{ + nodeId: string; + currentSha: string; + latestSha: string; + }>; + } + | { type: 'controlsChanged' } + | { type: 'controlBrowseResult'; requestId: string; ok: true; groups: ControlBrowseGroup[] } + | { type: 'controlBrowseResult'; requestId: string; ok: false; error: string } + | { type: 'controlDomainResult'; requestId: string; ok: true; group: ControlBrowseGroup } + | { type: 'controlDomainResult'; requestId: string; ok: false; error: string } + | { type: 'controlVersionsResult'; requestId: string; ok: true; versions: string[] } + | { type: 'controlVersionsResult'; requestId: string; ok: false; error: string } + | { + type: 'controlResolveResult'; + requestId: string; + ok: true; + parsed: ParsedRequirement; + warnings: string[]; + } + | { type: 'controlResolveResult'; requestId: string; ok: false; error: string } + | { type: 'saveControlResult'; requestId: string; ok: true } + | { type: 'saveControlResult'; requestId: string; ok: false; error: string }; export type WebviewToExtMessage = | { type: 'ready' } | { type: 'canvasChanged'; json: string } | { type: 'drillInto'; label: string; path: string; calmType: string } | { type: 'drillUp'; index: number; filePath?: string; readonly?: boolean } - | { type: 'requestStandardProse'; url: string } | { type: 'requestGenerateSpec' } | { type: 'saveBuildingBlock'; filename: string; content: string } - | { type: 'exportDiagram'; format: 'svg' | 'png'; data: string }; + | { type: 'exportDiagram'; format: 'svg' | 'png'; data: string } + | { type: 'resolveDefinitionId'; nodeId: string; curie: string } + | { type: 'requestImportSvg' } + | { type: 'requestControlBrowse'; requestId: string } + | { type: 'requestControlsForDomain'; requestId: string; domain: string } + | { + type: 'requestControlVersions'; + requestId: string; + domain: string; + controlName: string; + } + | { type: 'requestControlResolve'; requestId: string; ref: string } + | { type: 'saveControl'; requestId: string; filename: string; content: string } + | { type: 'savePattern'; filename: string; content: string } + | { type: 'requestExportPattern'; doc: string } + | { type: 'openControlInHub'; ref: string }; diff --git a/calm-plugins/vscode/src/extension/webview/canvas-panel.test.ts b/calm-plugins/vscode/src/extension/webview/canvas-panel.test.ts new file mode 100644 index 0000000000..e3e18050b4 --- /dev/null +++ b/calm-plugins/vscode/src/extension/webview/canvas-panel.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { parseCurie, pinControlCuries } from './canvas-panel'; + +describe('parseCurie', () => { + it('parses a standard CURIE with namespace, type, slug, and version', () => { + const result = parseCurie('finos:building-blocks:microservice@abc123'); + expect(result).toEqual({ + namespace: 'finos', + type: 'building-blocks', + slug: 'microservice', + version: 'abc123', + }); + }); + + it('parses a CURIE without a version', () => { + const result = parseCurie('finos:building-blocks:microservice'); + expect(result).toEqual({ + namespace: 'finos', + type: 'building-blocks', + slug: 'microservice', + version: undefined, + }); + }); + + it('handles a SHA-style version', () => { + const result = parseCurie( + 'acme-corp:building-blocks:api-gateway@sha256:deadbeef' + ); + expect(result).toEqual({ + namespace: 'acme-corp', + type: 'building-blocks', + slug: 'api-gateway', + version: 'sha256:deadbeef', + }); + }); + + it('handles empty parts gracefully', () => { + const result = parseCurie('::'); + expect(result).toEqual({ + namespace: '', + type: '', + slug: '', + version: undefined, + }); + }); + + it('extracts version from slug@version format', () => { + const result = parseCurie('ns:type:my-slug@v1.2.3'); + expect(result).toEqual({ + namespace: 'ns', + type: 'type', + slug: 'my-slug', + version: 'v1.2.3', + }); + }); +}); + +describe('pinControlCuries', () => { + const pin = (url: string) => + pinControlCuries( + { c: { requirements: [{ 'requirement-url': url }] } }, + 'sha123' + ).c as { requirements: Array<{ 'requirement-url': string }> }; + + it('pins an unversioned building-block CURIE with the parent SHA', () => { + expect(pin('finos:building-blocks:svc').requirements[0]['requirement-url']).toBe( + 'finos:building-blocks:svc@sha123' + ); + }); + + it('never pins a control CURIE (control versions are independent)', () => { + // An unversioned control CURIE has 2 colons and would otherwise be pinned. + expect(pin('security:controls:micro-segmentation').requirements[0]['requirement-url']).toBe( + 'security:controls:micro-segmentation' + ); + }); + + it('leaves an already-versioned control CURIE untouched', () => { + expect(pin('security:controls:x@1.0.0').requirements[0]['requirement-url']).toBe( + 'security:controls:x@1.0.0' + ); + }); + + it('leaves a non-CURIE (local path) untouched', () => { + expect(pin('controls/x.requirement.json').requirements[0]['requirement-url']).toBe( + 'controls/x.requirement.json' + ); + }); + + it('preserves controls with no requirements', () => { + const result = pinControlCuries({ c: { description: 'x' } }, 'sha'); + expect(result.c).toEqual({ description: 'x' }); + }); +}); diff --git a/calm-plugins/vscode/src/extension/webview/canvas-panel.ts b/calm-plugins/vscode/src/extension/webview/canvas-panel.ts index 922845347d..2d4e290bb4 100644 --- a/calm-plugins/vscode/src/extension/webview/canvas-panel.ts +++ b/calm-plugins/vscode/src/extension/webview/canvas-panel.ts @@ -1,13 +1,108 @@ import * as vscode from 'vscode'; +import * as crypto from 'crypto'; +import * as nodePath from 'path'; import { getWebviewHtml } from './html-provider'; import { SyncCoordinator } from '../services/sync-coordinator'; import { WorkspaceAssetService } from '../services/workspace-asset-service'; import { DiagramExportService } from '../services/diagram-export-service'; +import { HubClient, HubApiError } from '../services/hub-client'; +import { HubAssetService } from '../services/hub-asset-service'; +import { ControlAssetService, LOCAL_DOMAIN } from '../services/control-asset-service'; +import { ShaCacheService } from '../services/sha-cache-service'; +import { SvgImportService } from '../services/svg-import'; +import { + parseRequirementSchema, + type ParseResult, +} from '../services/requirement-parser'; +import { + isCanonicalControlUrl, + isLocalControlPath, + parseCanonicalControlUrl, + parseControlCurie, + type ControlCurieResult, +} from '../services/control-curie'; +import { resolveLocalPath, resolveSafeWritePath } from '../services/path-resolver'; import type { ExtToWebviewMessage, WebviewToExtMessage, } from '../types/messages'; +/** + * Parse a CURIE of the form `namespace:type:slug@version` into its components. + * Exported for unit testing. + */ +export function parseCurie(curie: string): { + namespace: string; + type: string; + slug: string; + version: string | undefined; +} { + const parts = curie.split(':'); + const namespace = parts[0] ?? ''; + const type = parts[1] ?? ''; + const slugAndVersion = parts.slice(2).join(':'); + const atIndex = slugAndVersion.indexOf('@'); + const slug = + atIndex === -1 ? slugAndVersion : slugAndVersion.substring(0, atIndex); + const version = + atIndex === -1 ? undefined : slugAndVersion.substring(atIndex + 1); + return { namespace, type, slug, version }; +} + +/** + * Pin unversioned control CURIEs in requirement-url fields with the parent's SHA. + * A CURIE has the form `ns:type:slug` — if it lacks `@version`, append `@sha`. + * Exported for unit testing. + */ +export function pinControlCuries( + controls: Record, + sha: string +): Record { + const pinned: Record = {}; + for (const [key, ctrl] of Object.entries(controls)) { + if (!ctrl || typeof ctrl !== 'object') { + pinned[key] = ctrl; + continue; + } + const c = ctrl as Record; + const reqs = c.requirements as Array> | undefined; + if (!reqs?.length) { + pinned[key] = ctrl; + continue; + } + const pinnedReqs = reqs.map((req) => { + const url = req['requirement-url']; + if (typeof url !== 'string') return req; + // Control CURIEs (`domain:controls:name`) carry their own independent + // version and must never be pinned with the parent building-block SHA. + if (url.includes(':controls:')) return req; + // Already versioned or not a CURIE (no colons) + if (url.includes('@') || (url.match(/:/g) ?? []).length < 2) return req; + return { ...req, 'requirement-url': `${url}@${sha}` }; + }); + pinned[key] = { ...c, requirements: pinnedReqs }; + } + return pinned; +} + +/** First 8 hex chars of the SHA-256 of the normalized (lower-cased) base URL. */ +function shortHash(input: string): string { + return crypto + .createHash('sha256') + .update(input.trim().toLowerCase()) + .digest('hex') + .slice(0, 8); +} + +/** Human-readable message for a caught error, with a friendly 403 for Hub calls. */ +function describeError(err: unknown): string { + if (err instanceof HubApiError) { + if (err.status === 403) return 'Access denied (403)'; + return `Hub request failed (${err.status})`; + } + return err instanceof Error ? err.message : String(err); +} + export class CanvasPanel { private panel: vscode.WebviewPanel | undefined; private disposables: vscode.Disposable[] = []; @@ -16,31 +111,48 @@ export class CanvasPanel { private syncCoordinator = new SyncCoordinator(); private assetService: WorkspaceAssetService | undefined; private exportService = new DiagramExportService(); + private hubClient: HubClient | undefined; + private hubAssetService: HubAssetService | undefined; + private controlAssetService: ControlAssetService; + private shaCache = new ShaCacheService(); + private hubBaseHash = ''; + private importService: SvgImportService | undefined; private fileWatcher: vscode.FileSystemWatcher | undefined; private log: vscode.OutputChannel; private disposed = false; private scanReady = false; private webviewReady = false; + /** Resolves once the initial local asset scan completes. */ + private scanReadyPromise: Promise = Promise.resolve(); + /** Resolves once an authenticated Hub client is connected and refreshed. Never resolves while disconnected. */ + private hubReadyPromise: Promise = new Promise(() => {}); constructor( private readonly context: vscode.ExtensionContext, outputChannel: vscode.OutputChannel ) { this.log = outputChannel; + this.importService = new SvgImportService(outputChannel); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; this.log.appendLine( `[CanvasPanel] constructor, workspaceRoot: ${workspaceRoot}` ); this.assetService = new WorkspaceAssetService(workspaceRoot); - void this.assetService.scanAll().then(() => { + // The Hub client is owned by extension.ts and injected via + // setHubConnection(); the panel never creates one itself. + this.controlAssetService = new ControlAssetService( + undefined, + () => this.assetService?.getControls() ?? [] + ); + this.scanReadyPromise = this.assetService.scanAll().then(() => { const fn = this.assetService!.getBuildingBlocks(); const p = this.assetService!.getPatterns(); const t = this.assetService!.getTemplates(); - const s = this.assetService!.getStandards(); + const c = this.assetService!.getControls(); this.log.appendLine( - `[CanvasPanel] Scan complete: ${fn.length} building-blocks, ${p.length} patterns, ${t.length} templates, ${s.length} standards` + `[CanvasPanel] Scan complete: ${fn.length} building-blocks, ${p.length} patterns, ${t.length} templates, ${c.length} controls` ); this.scanReady = true; // If webview was already waiting, send now @@ -51,7 +163,55 @@ export class CanvasPanel { this.sendAssets(); } }); - this.assetService.registerWatchers(context, () => this.sendAssets()); + this.assetService.registerWatchers(context, () => { + this.sendAssets(); + // A local requirement file may have changed — ask the webview to + // re-resolve affected controls. + this.postMessage({ type: 'controlsChanged' }); + }); + } + + /** + * Inject (or clear) the authenticated Hub client. This is the sole way the + * panel gains Hub access — extension.ts owns the client and calls this on + * connect, disconnect, refresh, and when a panel opens while already + * connected. Passing `undefined` tears the connection down. + */ + async setHubConnection(client?: HubClient): Promise { + if (!client) { + this.hubClient = undefined; + this.hubAssetService = undefined; + this.hubBaseHash = ''; + this.controlAssetService.setHubClient(undefined); + // A never-resolving promise (not a rejected one) avoids + // unhandled-rejection noise; handlers check `hubClient` first. + this.hubReadyPromise = new Promise(() => {}); + this.log.appendLine('[CanvasPanel] Hub connection cleared'); + this.sendAssets(); + return; + } + + this.hubClient = client; + this.hubBaseHash = shortHash(client.getBaseUrl()); + this.hubAssetService = new HubAssetService(client); + this.controlAssetService.setHubClient(client); + // Refresh Hub assets so sendAssets() posts fresh Hub data. The `.catch` + // keeps the promise resolving even on failure so awaiting handlers never + // hang. + this.hubReadyPromise = this.hubAssetService + .refresh() + .then(() => { + this.log.appendLine( + `[CanvasPanel] Hub asset refresh complete: ${this.hubAssetService!.getNamespaces().reduce((n, ns) => n + ns.buildingBlocks.length, 0)} blocks` + ); + }) + .catch((err) => { + this.log.appendLine( + `[CanvasPanel] Hub asset refresh failed: ${String(err)}` + ); + }); + await this.hubReadyPromise; + this.sendAssets(); } reveal(document: vscode.TextDocument): void { @@ -150,9 +310,6 @@ export class CanvasPanel { case 'drillUp': void this.handleDrillUp(message.index, message.filePath); break; - case 'requestStandardProse': - void this.handleRequestStandardProse(message.url); - break; case 'requestGenerateSpec': void this.handleGenerateSpec(); break; @@ -162,6 +319,56 @@ export class CanvasPanel { message.content ); break; + case 'savePattern': + void this.handleSavePattern( + message.filename, + message.content + ); + break; + case 'requestExportPattern': + void this.handleExportPattern(message.doc); + break; + case 'resolveDefinitionId': + void this.handleResolveDefinitionId( + message.nodeId, + message.curie + ); + break; + case 'requestImportSvg': + void this.handleImportSvg(); + break; + case 'requestControlBrowse': + void this.handleControlBrowse(message.requestId); + break; + case 'requestControlsForDomain': + void this.handleControlsForDomain( + message.requestId, + message.domain + ); + break; + case 'requestControlVersions': + void this.handleControlVersions( + message.requestId, + message.domain, + message.controlName + ); + break; + case 'requestControlResolve': + void this.handleControlResolve( + message.requestId, + message.ref + ); + break; + case 'saveControl': + void this.handleSaveControl( + message.requestId, + message.filename, + message.content + ); + break; + case 'openControlInHub': + void this.handleOpenControlInHub(message.ref); + break; } } @@ -172,21 +379,37 @@ export class CanvasPanel { json: this.currentDocument.getText(), source: 'file', }); + // Kick off update check after initial data is sent + void this.checkForUpdates(); + } + + public refreshAssets(): void { + this.sendAssets(); } private sendAssets(): void { if (!this.assetService) return; - const fn = this.assetService.getBuildingBlocks(); - const p = this.assetService.getPatterns(); + const localBlocks = this.assetService.getBuildingBlocks(); + const localPatterns = this.assetService.getPatterns(); const t = this.assetService.getTemplates(); - const s = this.assetService.getStandards(); + + // Merge Hub-sourced assets — only show explicitly selected namespaces + const selectedNs: string[] = vscode.workspace + .getConfiguration('calm.hub') + .get('selectedNamespaces') ?? []; + const hubBlocks = this.hubAssetService?.getAllBuildingBlocks(selectedNs) ?? []; + const hubPatterns = this.hubAssetService?.getAllPatterns(selectedNs) ?? []; + const hubAdrs = this.hubAssetService?.getAllAdrs(selectedNs) ?? []; + const allBlocks = [...localBlocks, ...hubBlocks]; + const allPatterns = [...localPatterns, ...hubPatterns]; + this.log.appendLine( - `[CanvasPanel] Sending assets to webview: ${fn.length} nodes, ${p.length} patterns, ${t.length} templates, ${s.length} standards` + `[CanvasPanel] Sending assets to webview: ${allBlocks.length} nodes (${localBlocks.length} local + ${hubBlocks.length} hub blocks), ${allPatterns.length} patterns (${localPatterns.length} local + ${hubPatterns.length} hub), ${t.length} templates, ${hubAdrs.length} ADRs` ); - this.postMessage({ type: 'buildingBlocksLoaded', nodes: fn }); - this.postMessage({ type: 'patternsLoaded', patterns: p }); + this.postMessage({ type: 'buildingBlocksLoaded', nodes: allBlocks }); + this.postMessage({ type: 'patternsLoaded', patterns: allPatterns }); this.postMessage({ type: 'templatesLoaded', templates: t }); - this.postMessage({ type: 'standardsLoaded', standards: s }); + this.postMessage({ type: 'adrsLoaded', adrs: hubAdrs }); } /** @@ -335,18 +558,6 @@ export class CanvasPanel { vscode.window.showWarningMessage(`Cannot find: ${filePath}`); } - private async handleRequestStandardProse(url: string): Promise { - if (!this.assetService) return; - const prose = await this.assetService.resolveStandardProse(url); - if (prose) { - this.postMessage({ type: 'standardProse', url, prose }); - } else { - this.log.appendLine( - `[CanvasPanel] Could not resolve standard prose: ${url}` - ); - } - } - private async handleGenerateSpec(): Promise { if (!this.currentDocument) return; @@ -357,18 +568,6 @@ export class CanvasPanel { const sdFileName = `${baseName}-solution-design.md`; const sdPath = path.resolve(path.dirname(filePath), sdFileName); - const standardsContext = await this.collectStandardsContext( - this.currentDocument.getText() - ); - const standardsSection = - standardsContext.length > 0 - ? [ - ``, - `Standards and guidelines that apply (read these for requirements):`, - ...standardsContext.map((s) => `---\n${s}\n---`), - ] - : []; - const prompt = [ `@CALM Generate a Solution Design document for the architecture at: ${filePath}`, ``, @@ -379,7 +578,6 @@ export class CanvasPanel { `- Follow the 13-section structure from .github/agents/calm-prompts/solution-design-creation.md`, `- ALL diagrams MUST be Mermaid syntax`, `- Include ALL 13 sections`, - ...standardsSection, ].join('\n'); const commands = await vscode.commands.getCommands(true); @@ -396,55 +594,7 @@ export class CanvasPanel { } } - private async collectStandardsContext(archJson: string): Promise { - if (!this.assetService) return []; - const referencedUrls = new Set(); - try { - const arch = JSON.parse(archJson) as { - nodes?: Array<{ controls?: Record }>; - controls?: Record; - }; - this.extractStandardUrls(arch.nodes ?? [], referencedUrls); - if (arch.controls) { - this.extractStandardUrls( - [{ controls: arch.controls }], - referencedUrls - ); - } - } catch { - /* malformed JSON */ - } - - const prose: string[] = []; - for (const url of referencedUrls) { - const resolved = await this.assetService.resolveStandardProse(url); - if (resolved) prose.push(resolved); - } - return prose; - } - - private extractStandardUrls( - nodes: Array<{ controls?: Record }>, - urls: Set - ): void { - for (const node of nodes) { - if (!node?.controls) continue; - for (const control of Object.values(node.controls)) { - const requirements = - ( - control as { - requirements?: Array>; - } - )?.requirements ?? []; - for (const req of requirements) { - const url = req['requirement-url']; - if (typeof url === 'string' && url.endsWith('.md')) - urls.add(url); - } - } - } - } private async handleSaveBuildingBlock( filename: string, @@ -496,6 +646,742 @@ export class CanvasPanel { await vscode.window.showTextDocument(doc, vscode.ViewColumn.One); } + private async handleSavePattern( + filename: string, + content: string + ): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showErrorMessage('No workspace folder open.'); + return; + } + + const patternsDir = vscode.Uri.joinPath( + workspaceFolder.uri, + 'patterns' + ); + try { + await vscode.workspace.fs.stat(patternsDir); + } catch { + await vscode.workspace.fs.createDirectory(patternsDir); + } + + const safeName = filename.replace(/[/\\]/g, ''); + if (!safeName || safeName !== filename || filename.includes('..')) { + vscode.window.showErrorMessage(`Invalid pattern filename: ${filename}`); + return; + } + const fileUri = vscode.Uri.joinPath(patternsDir, safeName); + try { + await vscode.workspace.fs.stat(fileUri); + const overwrite = await vscode.window.showWarningMessage( + `${filename} already exists. Overwrite?`, + 'Overwrite', + 'Cancel' + ); + if (overwrite !== 'Overwrite') return; + } catch { + /* doesn't exist — good */ + } + + await vscode.workspace.fs.writeFile( + fileUri, + Buffer.from(content, 'utf-8') + ); + vscode.window.showInformationMessage( + `Pattern saved: patterns/${filename}` + ); + + const doc = await vscode.workspace.openTextDocument(fileUri); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.One); + + await this.assetService?.scanAll(); + this.sendAssets(); + } + + private async handleExportPattern(docJson: string): Promise { + const name = await vscode.window.showInputBox({ + prompt: 'Pattern name', + placeHolder: 'e.g. My Service Pattern', + }); + if (!name?.trim()) return; + + const doc = JSON.parse(docJson); + const slug = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); + const fileName = `${slug}.pattern.json`; + + const toSchema = (value: unknown): unknown => { + if (value === null || value === undefined) return undefined; + if (Array.isArray(value)) return { type: 'array', prefixItems: value.map(toSchema) }; + if (typeof value === 'object') { + const props: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + if (k === '$schema' || k === '$id' || k === 'type') continue; + const s = toSchema(v); + if (s !== undefined) props[k] = s; + } + return { type: 'object', properties: props }; + } + return { const: value }; + }; + const itemSchema = (entry: Record, ref: string) => { + const props: Record = {}; + for (const [k, v] of Object.entries(entry)) { + if (k === '$schema' || k === '$id' || k === 'type' || v === undefined) continue; + props[k] = toSchema(v); + } + return { $ref: ref, type: 'object', properties: props }; + }; + + const nodes = (doc.nodes ?? []) as Record[]; + const rels = (doc.relationships ?? []) as Record[]; + const pattern = { + $schema: 'https://calm.finos.org/release/1.2/meta/calm.json', + $id: `patterns/${fileName}`, + type: 'object', + title: name.trim(), + description: `Pattern derived from architecture: ${name.trim()}`, + properties: { + nodes: { + type: 'array', + minItems: nodes.length, + prefixItems: nodes.map((n) => + itemSchema(n, 'https://calm.finos.org/release/1.2/meta/core.json#/defs/node') + ), + }, + relationships: { + type: 'array', + minItems: rels.length, + prefixItems: rels.map((r) => + itemSchema(r, 'https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship') + ), + }, + }, + required: ['nodes', 'relationships'], + }; + + await this.handleSavePattern(fileName, JSON.stringify(pattern, null, 2)); + } + + private async handleResolveDefinitionId( + nodeId: string, + curie: string + ): Promise { + this.log.appendLine( + `[CanvasPanel] resolveDefinitionId: nodeId="${nodeId}", curie="${curie}"` + ); + try { + const { namespace, type, slug, version } = parseCurie(curie); + if (this.hubClient && version) { + // Check SHA cache first for offline-capable resolution + const cached = await this.shaCache.get( + namespace, + type, + slug, + version + ); + if (cached) { + const rawControls = + ( + cached as { + nodes?: Array<{ + controls?: Record; + }>; + } + )?.nodes?.[0]?.controls ?? {}; + const controls = pinControlCuries(rawControls, version); + this.postMessage({ + type: 'definitionResolved', + nodeId, + controls, + }); + this.log.appendLine( + `[CanvasPanel] definitionResolved (cache hit): nodeId="${nodeId}", controls=${Object.keys(controls).length} keys` + ); + return; + } + + // Cache miss — fetch from Hub, then cache + const content = (await this.hubClient.getResourceAtVersion( + namespace, + type, + slug, + version + )) as { nodes?: Array<{ controls?: Record }> }; + + await this.shaCache.put( + namespace, + type, + slug, + version, + content + ); + + const rawControls = content?.nodes?.[0]?.controls ?? {}; + const controls = pinControlCuries(rawControls, version); + this.postMessage({ + type: 'definitionResolved', + nodeId, + controls, + }); + this.log.appendLine( + `[CanvasPanel] definitionResolved (fetched + cached): nodeId="${nodeId}", controls=${Object.keys(controls).length} keys` + ); + } else { + this.postMessage({ + type: 'definitionResolutionFailed', + nodeId, + error: 'Hub client not connected or no version in CURIE', + }); + } + } catch (error) { + this.postMessage({ + type: 'definitionResolutionFailed', + nodeId, + error: String(error), + }); + this.log.appendLine( + `[CanvasPanel] definitionResolutionFailed: nodeId="${nodeId}", error="${String(error)}"` + ); + } + } + + /** Local controls are always available; Hub domains are fetched lazily per-domain. */ + private async handleControlBrowse(requestId: string): Promise { + try { + await this.scanReadyPromise; + const groups = await this.controlAssetService.browse(); + this.postMessage({ + type: 'controlBrowseResult', + requestId, + ok: true, + groups, + }); + } catch (err) { + this.postMessage({ + type: 'controlBrowseResult', + requestId, + ok: false, + error: describeError(err), + }); + } + } + + private async handleControlsForDomain( + requestId: string, + domain: string + ): Promise { + if (!this.hubClient) { + this.postMessage({ + type: 'controlDomainResult', + requestId, + ok: false, + error: 'Hub not connected', + }); + return; + } + try { + await this.hubReadyPromise; + const group = + await this.controlAssetService.browseControlsForDomain(domain); + this.postMessage({ + type: 'controlDomainResult', + requestId, + ok: true, + group, + }); + } catch (err) { + this.postMessage({ + type: 'controlDomainResult', + requestId, + ok: false, + error: describeError(err), + }); + } + } + + private async handleControlVersions( + requestId: string, + domain: string, + controlName: string + ): Promise { + // Local controls have a single implicit "current" version. + if (domain === LOCAL_DOMAIN) { + this.postMessage({ + type: 'controlVersionsResult', + requestId, + ok: true, + versions: ['current'], + }); + return; + } + if (!this.hubClient) { + this.postMessage({ + type: 'controlVersionsResult', + requestId, + ok: false, + error: 'Hub not connected', + }); + return; + } + try { + await this.hubReadyPromise; + const resolved = await this.hubClient.resolveControlId(domain, controlName); + const versions = await this.hubClient.getRequirementVersions( + resolved.domain, + resolved.id + ); + this.postMessage({ + type: 'controlVersionsResult', + requestId, + ok: true, + versions, + }); + } catch (err) { + this.postMessage({ + type: 'controlVersionsResult', + requestId, + ok: false, + error: describeError(err), + }); + } + } + + /** + * Classify and resolve a control reference to its parsed requirement. The + * webview never constructs URLs — it passes the raw ref and the extension + * classifies (canonical URL → CURIE → local path) and resolves securely. + */ + private async handleControlResolve( + requestId: string, + ref: string + ): Promise { + try { + // Local path — resolve from disk, no Hub needed. + if (isLocalControlPath(ref)) { + await this.scanReadyPromise; + this.postResolve(requestId, await this.resolveLocalRequirement(ref)); + return; + } + + // Hub ref (canonical URL or CURIE). + const parts = isCanonicalControlUrl(ref) + ? this.parseAndValidateCanonicalUrl(ref) + : parseControlCurie(ref); + if (!parts) { + this.postMessage({ + type: 'controlResolveResult', + requestId, + ok: false, + error: 'Unrecognized or invalid control reference', + }); + return; + } + + // Cache-first: check the SHA cache before awaiting Hub readiness so a + // cache hit resolves even while offline. + const cached = await this.getCachedRequirement(parts); + if (cached) { + this.postResolve(requestId, parseRequirementSchema(cached)); + return; + } + + // Try Hub when a client is available. + let hubError: unknown; + if (this.hubClient) { + try { + await this.hubReadyPromise; + const resolved = await this.hubClient.resolveControlId( + parts.domain, + parts.controlName + ); + // Auto-resolve to latest version when the CURIE is unversioned. + let version = parts.version; + if (!version) { + const versions = await this.hubClient.getRequirementVersions( + resolved.domain, + resolved.id + ); + version = versions[versions.length - 1]; + } + if (!version) throw new Error('No versions available'); + const schema = await this.hubClient.getRequirementAtVersion( + resolved.domain, + resolved.id, + version + ); + const resolvedParts = { ...parts, version }; + await this.putCachedRequirement(resolvedParts, schema); + const s = schema as Record; + const fallbackIdentity = { + controlId: parts.controlName, + name: typeof s.title === 'string' ? s.title : parts.controlName, + description: typeof s.description === 'string' ? s.description : parts.controlName, + }; + this.postResolve(requestId, parseRequirementSchema(schema, fallbackIdentity)); + return; + } catch (err) { + hubError = err; + this.log.appendLine( + `[CanvasPanel] Hub control resolve failed for ${parts.domain}/${parts.controlName}: ${err instanceof Error ? err.message : String(err)}` + ); + } + } + + // Local fallback: match by slug against scanned workspace controls. + await this.scanReadyPromise; + const localResult = await this.resolveLocalControlBySlug(parts.controlName); + if (localResult) { + this.postResolve(requestId, localResult); + return; + } + + this.postMessage({ + type: 'controlResolveResult', + requestId, + ok: false, + error: hubError + ? describeError(hubError) + : `Control "${parts.controlName}" not found on Hub or locally`, + }); + } catch (err) { + this.postMessage({ + type: 'controlResolveResult', + requestId, + ok: false, + error: describeError(err), + }); + } + } + + private postResolve(requestId: string, result: ParseResult): void { + if (!result.parsed) { + this.postMessage({ + type: 'controlResolveResult', + requestId, + ok: false, + error: result.warnings[0] ?? 'Malformed requirement schema', + }); + return; + } + this.postMessage({ + type: 'controlResolveResult', + requestId, + ok: true, + parsed: result.parsed, + warnings: result.warnings, + }); + } + + /** Verify a canonical URL originates from the configured Hub before trusting it. */ + private parseAndValidateCanonicalUrl( + ref: string + ): ControlCurieResult | null { + const parts = parseCanonicalControlUrl(ref); + if (!parts || !this.hubClient) return null; + try { + const refUrl = new URL(ref); + const baseUrl = new URL(this.hubClient.getBaseUrl()); + if (refUrl.origin !== baseUrl.origin) return null; + const basePath = baseUrl.pathname.replace(/\/$/, ''); + if (!refUrl.pathname.startsWith(`${basePath}/calm/domains/`)) { + return null; + } + } catch { + return null; + } + return parts; + } + + private async resolveLocalControlBySlug(slug: string): Promise { + const controls = this.assetService?.getControls() ?? []; + const match = controls.find((c) => c.id === slug); + if (!match) return null; + const bytes = await vscode.workspace.fs.readFile( + vscode.Uri.file(match.filePath) + ); + const schema = JSON.parse(Buffer.from(bytes).toString('utf-8')); + const fallbackIdentity = { + controlId: match.controlId, + name: match.name, + description: match.description, + }; + return parseRequirementSchema(schema, fallbackIdentity); + } + + private async resolveLocalRequirement(ref: string): Promise { + const roots = (vscode.workspace.workspaceFolders ?? []).map( + (f) => f.uri.fsPath + ); + const externalPath = vscode.workspace + .getConfiguration('calm') + .get('externalAssetsPath'); + const abs = resolveLocalPath( + ref, + roots, + externalPath?.trim() || undefined + ); + if (!abs) { + return { + parsed: null, + warnings: [`Control file not found or outside workspace: ${ref}`], + }; + } + const bytes = await vscode.workspace.fs.readFile(vscode.Uri.file(abs)); + const schema = JSON.parse(Buffer.from(bytes).toString('utf-8')); + const stem = nodePath.basename(abs).replace(/(\.requirement)?\.json$/, ''); + const fallbackIdentity = { controlId: stem, name: stem, description: stem }; + return parseRequirementSchema(schema, fallbackIdentity); + } + + private getCachedRequirement( + parts: ControlCurieResult + ): Promise { + if (!parts.version) return Promise.resolve(null); + return this.shaCache.get( + `${this.hubBaseHash}-domain-controls`, + parts.domain, + parts.controlName, + parts.version + ); + } + + private putCachedRequirement( + parts: ControlCurieResult, + schema: unknown + ): Promise { + if (!parts.version) return Promise.resolve(); + return this.shaCache.put( + `${this.hubBaseHash}-domain-controls`, + parts.domain, + parts.controlName, + parts.version, + schema + ); + } + + /** + * Persist a standalone control requirement to `controls/` in the workspace + * folder holding the current document, then rescan (standards may now + * resolve previously-missing control-refs) and refresh the webview. + */ + private async handleSaveControl( + requestId: string, + filename: string, + content: string + ): Promise { + try { + await this.scanReadyPromise; + + // Slug stem with either `.requirement.json` (convention) or plain `.json`. + if ( + !/^[a-z][a-z0-9]*(-[a-z0-9]+)*(\.requirement)?\.json$/.test(filename) + ) { + this.postSaveError(requestId, `Invalid control filename: ${filename}`); + return; + } + + let schema: Record; + try { + schema = JSON.parse(content); + } catch { + this.postSaveError(requestId, 'Control content is not valid JSON'); + return; + } + + // Extract domain from CURIE $id (e.g. "platform:controls:slug" → "platform") + const idVal = typeof schema.$id === 'string' ? schema.$id : ''; + const curieParts = parseControlCurie(idVal); + const domain = curieParts?.domain; + + const stem = filename.replace(/(\.requirement)?\.json$/, ''); + const fallbackIdentity = { controlId: stem, name: stem, description: stem }; + const { parsed, warnings } = parseRequirementSchema(schema, fallbackIdentity); + if (!parsed) { + this.postSaveError( + requestId, + warnings[0] ?? 'Malformed requirement schema' + ); + return; + } + + const targetRoot = this.getDocumentWorkspaceRoot(); + if (!targetRoot) { + this.postSaveError(requestId, 'No workspace folder open'); + return; + } + + const subDir = domain ? `controls/${domain}` : 'controls'; + const controlsDir = vscode.Uri.joinPath( + vscode.Uri.file(targetRoot), + subDir + ); + try { + await vscode.workspace.fs.stat(controlsDir); + } catch { + await vscode.workspace.fs.createDirectory(controlsDir); + } + + const writePath = resolveSafeWritePath( + `${subDir}/${filename}`, + targetRoot + ); + if (!writePath) { + this.postSaveError(requestId, 'Unsafe control path'); + return; + } + const fileUri = vscode.Uri.file(writePath); + + try { + await vscode.workspace.fs.stat(fileUri); + const choice = await vscode.window.showWarningMessage( + `${filename} already exists. Overwrite?`, + 'Overwrite', + 'Cancel' + ); + if (choice !== 'Overwrite') { + this.postSaveError(requestId, 'cancelled'); + return; + } + } catch { + /* doesn't exist — good */ + } + + await vscode.workspace.fs.writeFile( + fileUri, + Buffer.from(content, 'utf-8') + ); + + // Full rescan: standards with previously-missing control-refs may now resolve. + await this.assetService!.scanAll(); + this.sendAssets(); + this.postMessage({ type: 'saveControlResult', requestId, ok: true }); + } catch (err) { + this.postSaveError(requestId, describeError(err)); + } + } + + private postSaveError(requestId: string, error: string): void { + this.postMessage({ type: 'saveControlResult', requestId, ok: false, error }); + } + + private getDocumentWorkspaceRoot(): string | undefined { + const folders = vscode.workspace.workspaceFolders ?? []; + const docPath = this.currentDocument?.uri.fsPath; + if (docPath) { + for (const f of folders) { + const root = f.uri.fsPath; + if (docPath === root || docPath.startsWith(root + nodePath.sep)) { + return root; + } + } + } + return folders[0]?.uri.fsPath; + } + + /** + * Check for available updates by comparing pinned SHAs in the current document + * against the latest versions from the Hub. Sends an `updatesAvailable` message + * to the webview with a list of nodes that have newer versions. + */ + private async checkForUpdates(): Promise { + if (!this.hubClient || !this.currentDocument) return; + + try { + const text = this.currentDocument.getText(); + if (!text.trim()) return; + const arch = JSON.parse(text) as { + nodes?: Array<{ + 'unique-id'?: string; + 'definition-id'?: string; + }>; + }; + if (!arch?.nodes) return; + + const updates: Array<{ + nodeId: string; + currentSha: string; + latestSha: string; + }> = []; + + for (const node of arch.nodes) { + const defId = node['definition-id']; + if (!defId) continue; + const { namespace, type, slug, version } = parseCurie(defId); + if (!version) continue; + + try { + const versions = await this.hubClient.getVersions( + namespace, + type, + slug + ); + if (versions.length === 0) continue; + const latestSha = versions[versions.length - 1]; + if (latestSha !== version) { + updates.push({ + nodeId: node['unique-id'] ?? '', + currentSha: version, + latestSha, + }); + } + } catch { + /* skip nodes that fail version lookup */ + } + } + + if (updates.length > 0) { + this.postMessage({ type: 'updatesAvailable', updates }); + this.log.appendLine( + `[CanvasPanel] ${updates.length} update(s) available` + ); + } + } catch { + /* non-JSON document or other parse error */ + } + } + + private async handleOpenControlInHub(ref: string): Promise { + if (!this.hubClient) { + vscode.window.showWarningMessage('Hub not connected'); + return; + } + const parts = parseControlCurie(ref); + if (!parts) return; + try { + const resolved = await this.hubClient.resolveControlId( + parts.domain, + parts.controlName + ); + const baseUrl = this.hubClient.getBaseUrl(); + const hubUrl = `${baseUrl}/#/${encodeURIComponent(resolved.domain)}/controls/${resolved.id}/detail`; + await vscode.env.openExternal(vscode.Uri.parse(hubUrl)); + } catch (err) { + this.log.appendLine( + `[CanvasPanel] Failed to open control in Hub: ${err instanceof Error ? err.message : String(err)}` + ); + vscode.window.showWarningMessage(`Could not open control in Hub: ${describeError(err)}`); + } + } + + private async handleImportSvg(): Promise { + this.log.appendLine('[CanvasPanel] handleImportSvg triggered'); + if (!this.importService) { + this.log.appendLine('[CanvasPanel] importService is undefined'); + return; + } + if (!this.currentDocument) { + this.log.appendLine('[CanvasPanel] currentDocument is undefined'); + } + const json = await this.importService.importSvgIntoDocument(this.currentDocument); + if (json) { + this.log.appendLine(`[CanvasPanel] Import successful, updating webview`); + this.postMessage({ type: 'modelUpdated', json, source: 'import' }); + } else { + this.log.appendLine('[CanvasPanel] Import returned null (cancelled or failed)'); + } + } + private handleCanvasChanged(json: string): void { if (!this.currentDocument) return; if (!this.syncCoordinator.canvasChanged()) return; diff --git a/calm-plugins/vscode/src/extension/webview/send-assets-merge.test.ts b/calm-plugins/vscode/src/extension/webview/send-assets-merge.test.ts new file mode 100644 index 0000000000..c4de4a5608 --- /dev/null +++ b/calm-plugins/vscode/src/extension/webview/send-assets-merge.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from 'vitest'; + +/** + * Tests for the asset-merge logic used in CanvasPanel.sendAssets(). + * + * The actual sendAssets method has vscode dependencies; this tests the + * pure merge logic: local assets + Hub assets → combined arrays. + */ + +interface PatternEntry { + id: string; + name: string; + description: string; + category: string; + schema: unknown; +} + +interface BuildingBlockDef { + id: string; + name: string; + behaviour: string; + controls: Record; + nodeType?: string; + namespace?: string; + sha?: string; +} + +function mergeBlocks( + localBlocks: BuildingBlockDef[], + hubBlocks: BuildingBlockDef[], + hubStandards: BuildingBlockDef[] +): BuildingBlockDef[] { + return [...localBlocks, ...hubBlocks, ...hubStandards]; +} + +function mergePatterns( + localPatterns: PatternEntry[], + hubPatterns: PatternEntry[] +): PatternEntry[] { + return [...localPatterns, ...hubPatterns]; +} + +describe('sendAssets merge logic', () => { + describe('mergePatterns', () => { + it('combines local and Hub patterns into a single array', () => { + const local: PatternEntry[] = [ + { id: 'local-1', name: 'Local Pattern', description: 'desc', category: 'general', schema: {} }, + ]; + const hub: PatternEntry[] = [ + { id: 'hub-1', name: 'Hub Pattern', description: 'from hub', category: 'finos', schema: { properties: {} } }, + ]; + + const result = mergePatterns(local, hub); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('local-1'); + expect(result[1].id).toBe('hub-1'); + }); + + it('returns only local patterns when Hub has none', () => { + const local: PatternEntry[] = [ + { id: 'local-1', name: 'Local', description: '', category: 'general', schema: {} }, + ]; + + const result = mergePatterns(local, []); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('local-1'); + }); + + it('returns only Hub patterns when local has none', () => { + const hub: PatternEntry[] = [ + { id: 'hub-1', name: 'Hub', description: '', category: 'finos', schema: {} }, + ]; + + const result = mergePatterns([], hub); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('hub-1'); + }); + + it('returns empty array when neither source has patterns', () => { + expect(mergePatterns([], [])).toEqual([]); + }); + + it('preserves schema content from Hub patterns for instantiation', () => { + const hubSchema = { + title: 'API Gateway', + properties: { + nodes: { prefixItems: [{ properties: { 'unique-id': { const: 'gw-1' } } }] }, + relationships: { prefixItems: [] }, + }, + }; + const hub: PatternEntry[] = [ + { id: 'api-gw', name: 'API Gateway', description: '', category: 'networking', schema: hubSchema }, + ]; + + const result = mergePatterns([], hub); + + expect(result[0].schema).toBe(hubSchema); + const schema = result[0].schema as Record; + const props = schema.properties as Record; + const nodes = props.nodes as Record; + expect((nodes.prefixItems as unknown[]).length).toBe(1); + }); + }); + + describe('mergeBlocks', () => { + it('combines local blocks, Hub blocks, and Hub standards', () => { + const local: BuildingBlockDef[] = [ + { id: 'local-svc', name: 'Local Service', behaviour: 'create-node', controls: {} }, + ]; + const hubBlocks: BuildingBlockDef[] = [ + { id: 'hub-svc', name: 'Hub Service', behaviour: 'create-node', controls: {}, namespace: 'finos', sha: 'abc' }, + ]; + const hubStandards: BuildingBlockDef[] = [ + { id: 'hub-std', name: 'Hub Standard', behaviour: 'apply-controls-on-drop', controls: {}, namespace: 'finos', sha: 'def' }, + ]; + + const result = mergeBlocks(local, hubBlocks, hubStandards); + + expect(result).toHaveLength(3); + expect(result.map((b) => b.id)).toEqual(['local-svc', 'hub-svc', 'hub-std']); + }); + }); +}); diff --git a/calm-plugins/vscode/src/extensions/icons/ai.ts b/calm-plugins/vscode/src/packs/icons/ai.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/ai.ts rename to calm-plugins/vscode/src/packs/icons/ai.ts diff --git a/calm-plugins/vscode/src/extensions/icons/aws.ts b/calm-plugins/vscode/src/packs/icons/aws.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/aws.ts rename to calm-plugins/vscode/src/packs/icons/aws.ts diff --git a/calm-plugins/vscode/src/extensions/icons/azure.ts b/calm-plugins/vscode/src/packs/icons/azure.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/azure.ts rename to calm-plugins/vscode/src/packs/icons/azure.ts diff --git a/calm-plugins/vscode/src/extensions/icons/fluxnova.ts b/calm-plugins/vscode/src/packs/icons/fluxnova.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/fluxnova.ts rename to calm-plugins/vscode/src/packs/icons/fluxnova.ts diff --git a/calm-plugins/vscode/src/extensions/icons/gcp.ts b/calm-plugins/vscode/src/packs/icons/gcp.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/gcp.ts rename to calm-plugins/vscode/src/packs/icons/gcp.ts diff --git a/calm-plugins/vscode/src/extensions/icons/identity.ts b/calm-plugins/vscode/src/packs/icons/identity.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/identity.ts rename to calm-plugins/vscode/src/packs/icons/identity.ts diff --git a/calm-plugins/vscode/src/extensions/icons/k8s.ts b/calm-plugins/vscode/src/packs/icons/k8s.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/k8s.ts rename to calm-plugins/vscode/src/packs/icons/k8s.ts diff --git a/calm-plugins/vscode/src/extensions/icons/messaging.ts b/calm-plugins/vscode/src/packs/icons/messaging.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/messaging.ts rename to calm-plugins/vscode/src/packs/icons/messaging.ts diff --git a/calm-plugins/vscode/src/extensions/icons/opengris.ts b/calm-plugins/vscode/src/packs/icons/opengris.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/icons/opengris.ts rename to calm-plugins/vscode/src/packs/icons/opengris.ts diff --git a/calm-plugins/vscode/src/extensions/index.ts b/calm-plugins/vscode/src/packs/index.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/index.ts rename to calm-plugins/vscode/src/packs/index.ts diff --git a/calm-plugins/vscode/src/extensions/packs/ai.test.ts b/calm-plugins/vscode/src/packs/packs/ai.test.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/ai.test.ts rename to calm-plugins/vscode/src/packs/packs/ai.test.ts diff --git a/calm-plugins/vscode/src/extensions/packs/ai.ts b/calm-plugins/vscode/src/packs/packs/ai.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/ai.ts rename to calm-plugins/vscode/src/packs/packs/ai.ts diff --git a/calm-plugins/vscode/src/extensions/packs/aws.ts b/calm-plugins/vscode/src/packs/packs/aws.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/aws.ts rename to calm-plugins/vscode/src/packs/packs/aws.ts diff --git a/calm-plugins/vscode/src/extensions/packs/azure.ts b/calm-plugins/vscode/src/packs/packs/azure.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/azure.ts rename to calm-plugins/vscode/src/packs/packs/azure.ts diff --git a/calm-plugins/vscode/src/extensions/packs/core.ts b/calm-plugins/vscode/src/packs/packs/core.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/core.ts rename to calm-plugins/vscode/src/packs/packs/core.ts diff --git a/calm-plugins/vscode/src/extensions/packs/fluxnova.test.ts b/calm-plugins/vscode/src/packs/packs/fluxnova.test.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/fluxnova.test.ts rename to calm-plugins/vscode/src/packs/packs/fluxnova.test.ts diff --git a/calm-plugins/vscode/src/extensions/packs/fluxnova.ts b/calm-plugins/vscode/src/packs/packs/fluxnova.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/fluxnova.ts rename to calm-plugins/vscode/src/packs/packs/fluxnova.ts diff --git a/calm-plugins/vscode/src/extensions/packs/gcp.ts b/calm-plugins/vscode/src/packs/packs/gcp.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/gcp.ts rename to calm-plugins/vscode/src/packs/packs/gcp.ts diff --git a/calm-plugins/vscode/src/extensions/packs/identity.ts b/calm-plugins/vscode/src/packs/packs/identity.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/identity.ts rename to calm-plugins/vscode/src/packs/packs/identity.ts diff --git a/calm-plugins/vscode/src/extensions/packs/kubernetes.ts b/calm-plugins/vscode/src/packs/packs/kubernetes.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/kubernetes.ts rename to calm-plugins/vscode/src/packs/packs/kubernetes.ts diff --git a/calm-plugins/vscode/src/extensions/packs/messaging.ts b/calm-plugins/vscode/src/packs/packs/messaging.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/messaging.ts rename to calm-plugins/vscode/src/packs/packs/messaging.ts diff --git a/calm-plugins/vscode/src/extensions/packs/opengris.test.ts b/calm-plugins/vscode/src/packs/packs/opengris.test.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/opengris.test.ts rename to calm-plugins/vscode/src/packs/packs/opengris.test.ts diff --git a/calm-plugins/vscode/src/extensions/packs/opengris.ts b/calm-plugins/vscode/src/packs/packs/opengris.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/packs/opengris.ts rename to calm-plugins/vscode/src/packs/packs/opengris.ts diff --git a/calm-plugins/vscode/src/extensions/registry.test.ts b/calm-plugins/vscode/src/packs/registry.test.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/registry.test.ts rename to calm-plugins/vscode/src/packs/registry.test.ts diff --git a/calm-plugins/vscode/src/extensions/registry.ts b/calm-plugins/vscode/src/packs/registry.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/registry.ts rename to calm-plugins/vscode/src/packs/registry.ts diff --git a/calm-plugins/vscode/src/extensions/types.ts b/calm-plugins/vscode/src/packs/types.ts similarity index 100% rename from calm-plugins/vscode/src/extensions/types.ts rename to calm-plugins/vscode/src/packs/types.ts diff --git a/calm-plugins/vscode/src/svg-parser.d.ts b/calm-plugins/vscode/src/svg-parser.d.ts new file mode 100644 index 0000000000..74e52f7dac --- /dev/null +++ b/calm-plugins/vscode/src/svg-parser.d.ts @@ -0,0 +1,21 @@ +declare module 'svg-parser' { + interface RootNode { + type: 'root'; + children: ElementNode[]; + } + + interface ElementNode { + type: 'element'; + tagName: string; + properties: Record; + children: (ElementNode | TextNode)[]; + metadata?: string; + } + + interface TextNode { + type: 'text'; + value: string; + } + + function parse(source: string): RootNode; +} diff --git a/calm-plugins/vscode/src/test/__mocks__/vscode.ts b/calm-plugins/vscode/src/test/__mocks__/vscode.ts index ea75e73f58..5816ed96c7 100644 --- a/calm-plugins/vscode/src/test/__mocks__/vscode.ts +++ b/calm-plugins/vscode/src/test/__mocks__/vscode.ts @@ -44,30 +44,110 @@ export class RelativePattern { ) {} } +export class ThemeColor { + constructor(public readonly id: string) {} +} + +export enum StatusBarAlignment { + Left = 1, + Right = 2, +} + +export enum ConfigurationTarget { + Global = 1, + Workspace = 2, + WorkspaceFolder = 3, +} + interface WorkspaceFolder { uri: Uri; } +export class WorkspaceEdit { + private _edits: Array<{ uri: unknown; range: unknown; newText: string }> = []; + replace(uri: unknown, range: unknown, newText: string): void { + this._edits.push({ uri, range, newText }); + } +} + export const workspace: { workspaceFolders: WorkspaceFolder[] | undefined; - fs: { readFile: (uri: Uri) => Promise }; + fs: { + readFile: (uri: Uri) => Promise; + writeFile: (uri: Uri, content: Uint8Array) => Promise; + }; getConfiguration: (section?: string) => { - get: (key: string) => T | undefined; + get: (key: string, defaultValue?: T) => T | undefined; + update: (key: string, value: unknown, target?: ConfigurationTarget) => Promise; }; findFiles: (...args: unknown[]) => Promise; + applyEdit: (edit: WorkspaceEdit) => Promise; } = { workspaceFolders: [], fs: { readFile: async () => { throw new Error('ENOENT'); }, + writeFile: async () => {}, }, - getConfiguration: () => ({ get: () => undefined }), + getConfiguration: () => ({ + get: () => undefined, + update: async () => {}, + }), findFiles: async () => [], + applyEdit: async () => true, }; +export interface StatusBarItem { + text: string; + tooltip: string | undefined; + command: string | undefined; + backgroundColor: ThemeColor | undefined; + show: () => void; + hide: () => void; + dispose: () => void; +} + +function createMockStatusBarItem(): StatusBarItem { + return { + text: '', + tooltip: undefined, + command: undefined, + backgroundColor: undefined, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }; +} + export const window = { showWarningMessage: () => Promise.resolve(undefined), showErrorMessage: () => Promise.resolve(undefined), showInformationMessage: () => Promise.resolve(undefined), + showInputBox: () => Promise.resolve(undefined), + showOpenDialog: async () => undefined, + showSaveDialog: async () => undefined, + createStatusBarItem: (_alignment?: StatusBarAlignment, _priority?: number): StatusBarItem => + createMockStatusBarItem(), + createOutputChannel: (_name: string) => ({ + appendLine: () => {}, + append: () => {}, + clear: () => {}, + show: () => {}, + hide: () => {}, + dispose: () => {}, + }), +}; + +export const commands = { + registerCommand: (_command: string, _callback: (...args: unknown[]) => unknown) => ({ + dispose: () => {}, + }), + executeCommand: async () => undefined, +}; + +export const languages = { + registerCodeLensProvider: (_selector: unknown, _provider: unknown) => ({ + dispose: () => {}, + }), }; diff --git a/calm-plugins/vscode/src/webview/App.tsx b/calm-plugins/vscode/src/webview/App.tsx index 25ce8e10b1..0cc867f359 100644 --- a/calm-plugins/vscode/src/webview/App.tsx +++ b/calm-plugins/vscode/src/webview/App.tsx @@ -26,15 +26,18 @@ import { setPatternsLoadedCallback, setTemplatesLoadedCallback, setBuildingBlocksLoadedCallback, - setStandardsLoadedCallback, setDrillResultCallback, - setStandardProseCallback, + setDefinitionResolvedCallback, + setDefinitionResolutionFailedCallback, + setUpdatesAvailableCallback, + setControlsChangedCallback, notifyCanvasChanged, notifyDrillInto, notifyDrillUp, - requestStandardProse, notifyRequestGenerateSpec, notifySaveBuildingBlock, + notifyRequestImportSvg, + requestControlResolve, } from './stores/sync-bridge'; import { postMessage } from './vscode-api'; import { @@ -53,8 +56,18 @@ import { NodeAppearance } from './panels/NodeAppearance'; import { getNodeStyleOverride } from './utils/building-block-style'; import { PatternPicker } from './panels/PatternPicker'; import { TemplatePicker } from './panels/TemplatePicker'; -import { StandardsPanel } from './panels/StandardsPanel'; import { BuildingBlockCreator } from './panels/BuildingBlockCreator'; +import { ControlPicker } from './panels/ControlPicker'; +import { ControlCreator } from './panels/ControlCreator'; +import { + type ControlEntry, + buildControlEntry, + enrichControlWithRequirement, + getRequirementUrl, + needsEnrichment, +} from './panels/control-metadata'; +import { isControlRef, isLocalControlPath, makeControlMapKey, parseControlCurie } from '../extension/services/control-curie'; +import type { ParsedRequirement } from '../extension/services/requirement-parser'; import { ToolbarMenu } from './panels/ToolbarMenu'; import { nodeTypes } from './canvas/nodeTypes'; import { edgeTypes } from './canvas/edgeTypes'; @@ -70,6 +83,11 @@ function resolveFlowNodeType(calmType: string): string { return 'extension'; } +function isInputDOMNode(e: KeyboardEvent): boolean { + const tag = (e.target as HTMLElement)?.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable === true; +} + function CanvasApp() { const containerRef = useRef(null); const store = useCanvasStore(); @@ -85,8 +103,14 @@ function CanvasApp() { const [patternPickerMode, setPatternPickerMode] = useState<'new' | 'apply'>('new'); const [showTemplatePicker, setShowTemplatePicker] = useState(false); const [showBuildingBlockCreator, setShowBuildingBlockCreator] = useState(false); - const [activeRequirementUrl, setActiveRequirementUrl] = useState(null); - const [activeStandardProse, setActiveStandardProse] = useState(null); + const [showControlCreator, setShowControlCreator] = useState(false); + const [showControlPicker, setShowControlPicker] = useState(false); + const [controlPickerTarget, setControlPickerTarget] = useState< + | { type: 'node'; nodeId: string } + | { type: 'document' } + | { type: 'building-block-draft'; onAttach: (ref: string, parsed: ParsedRequirement) => void; existingKeys?: Set } + | null + >(null); const [expandControlKey, setExpandControlKey] = useState(null); // Undo/redo @@ -99,6 +123,10 @@ function CanvasApp() { const syncingRef = useRef(false); const undoingOrRedoing = useRef(false); const loadGeneration = useRef(0); + const pendingNodesRef = useRef(null); + + // Copy/paste + const clipboardNode = useRef(null); // --- Core: emit change --- @@ -117,7 +145,8 @@ function CanvasApp() { if (undoStack.current.length > 50) undoStack.current.shift(); redoStack.current = []; } - const currentNodes = reactFlowInstance.getNodes(); + const currentNodes = pendingNodesRef.current ?? reactFlowInstance.getNodes(); + pendingNodesRef.current = null; const currentEdges = reactFlowInstance.getEdges(); const arch = flowToCalm(currentNodes, currentEdges, currentState.documentControls); const json = JSON.stringify(arch, null, 2); @@ -130,6 +159,69 @@ function CanvasApp() { else debounceTimer.current = setTimeout(flush, 300); }, [reactFlowInstance, store.readonlyMode]); + // --- Control enrichment (webview-driven) --- + // Resolve each control's requirement to attach validation metadata and seed + // base identity constants. Local refs always re-resolve (the file may have + // changed); Hub refs only when not yet enriched. All updates are guarded by + // the load generation so stale resolves from a prior document are discarded. + const updateNodeControl = useCallback((nodeId: string, ckey: string, enrich: (c: ControlEntry) => ControlEntry) => { + setNodes((nds) => nds.map((n) => { + if (n.id !== nodeId) return n; + const data = n.data as Record; + const existing = (data.controls as Record) ?? {}; + const target = existing[ckey]; + if (!target) return n; + return { ...n, data: { ...data, controls: { ...existing, [ckey]: enrich(target) } } }; + })); + }, [setNodes]); + + const updateDocControl = useCallback((ckey: string, enrich: (c: ControlEntry) => ControlEntry) => { + const latest = (useCanvasStore.getState().documentControls as Record) ?? {}; + const target = latest[ckey]; + if (!target) return; + useCanvasStore.setState({ documentControls: { ...latest, [ckey]: enrich(target) } }); + }, []); + + const resolveAndEnrich = useCallback(( + controls: Record, + generation: number, + apply: (ckey: string, enrich: (c: ControlEntry) => ControlEntry) => void + ) => { + for (const [ckey, ctrl] of Object.entries(controls)) { + const ref = getRequirementUrl(ctrl); + if (!ref || !isControlRef(ref)) continue; + const local = isLocalControlPath(ref); + if (!needsEnrichment(ctrl) && !local) continue; + requestControlResolve(ref, (result) => { + if (generation !== loadGeneration.current) { + console.warn(`[CALM] Control resolve for ${ref} discarded: generation ${generation} !== ${loadGeneration.current}`); + return; + } + if (!result.ok) { + console.warn(`[CALM] Control resolve failed for ${ref}: ${result.error}`); + return; + } + console.log(`[CALM] Control resolved for ${ref}: ${Object.keys(result.parsed.properties).length} properties`); + apply(ckey, (c) => enrichControlWithRequirement(c, result.parsed)); + }); + } + }, []); + + const enrichNodeControls = useCallback((nodeList: Node[], generation: number) => { + for (const node of nodeList) { + const controls = (node.data as Record)?.controls as Record | undefined; + if (controls && Object.keys(controls).length > 0) { + resolveAndEnrich(controls, generation, (ckey, enrich) => updateNodeControl(node.id, ckey, enrich)); + } + } + }, [resolveAndEnrich, updateNodeControl]); + + const enrichAllControls = useCallback((nodeList: Node[], generation: number) => { + enrichNodeControls(nodeList, generation); + const docControls = (useCanvasStore.getState().documentControls as Record) ?? {}; + resolveAndEnrich(docControls, generation, (ckey, enrich) => updateDocControl(ckey, enrich)); + }, [enrichNodeControls, resolveAndEnrich, updateDocControl]); + // --- Load architecture --- const loadArchitecture = useCallback((json: string) => { // Invalidate any in-flight setTimeout callbacks from prior interactions @@ -137,6 +229,7 @@ function CanvasApp() { // Cancel any pending emit timers to prevent stale data from overwriting the file if (debounceTimer.current) { clearTimeout(debounceTimer.current); debounceTimer.current = null; } if (positionDebounceTimer.current) { clearTimeout(positionDebounceTimer.current); positionDebounceTimer.current = null; } + pendingNodesRef.current = null; // A blank/whitespace file means "no architecture" — clear the canvas instead of // keeping the previously loaded diagram (JSON.parse('') would otherwise throw and the @@ -163,10 +256,22 @@ function CanvasApp() { setNodes(layoutedNodes); setEdges(parsedEdges); lastEmittedJson.current = json; + // Request resolution for any nodes with definition-id references + for (const node of layoutedNodes) { + const defId = (node.data as Record)?.['definition-id'] as string | undefined; + if (defId) { + postMessage({ type: 'resolveDefinitionId', nodeId: node.id, curie: defId }); + } + } + // Enrich controls with resolved requirement metadata (covers initial + // load, drill, file-watcher, SVG import, undo/redo). Both node-level and + // document-level controls are scanned; local refs always re-resolve. + const generation = loadGeneration.current; + setTimeout(() => enrichAllControls(layoutedNodes, generation), 0); } catch (err) { console.error('[CALM Canvas] Failed to load architecture:', err); } - }, [setNodes, setEdges]); + }, [setNodes, setEdges, enrichAllControls]); // --- Bridge setup --- useEffect(() => { @@ -178,20 +283,44 @@ function CanvasApp() { setPatternsLoadedCallback((p) => useCanvasStore.setState({ loadedPatterns: p as any })); setTemplatesLoadedCallback((t) => useCanvasStore.setState({ loadedTemplates: t as any })); setBuildingBlocksLoadedCallback((n) => useCanvasStore.setState({ buildingBlocks: n as any })); - setStandardsLoadedCallback((s) => useCanvasStore.setState({ loadedStandards: s as any })); setDrillResultCallback((json, label, _filePath, readonly) => { store.pushDrill({ label, filePath: _filePath, readonly }); store.setReadonlyMode(readonly ?? false); loadArchitecture(json); }); - setStandardProseCallback((_url, prose) => { - setActiveStandardProse(prose); + setDefinitionResolvedCallback((nodeId, controls) => { + setNodes((nds) => nds.map((n) => { + if (n.id !== nodeId) return n; + const data = n.data as Record; + // Only set controls if node doesn't already have them (from saved file) + const existingControls = data.controls as Record | undefined; + const hasExisting = existingControls && Object.keys(existingControls).length > 0; + return { ...n, data: { ...data, controls: hasExisting ? existingControls : controls, _resolvedControls: controls } }; + })); + // Enrich the freshly-resolved controls once React has committed them. + const generation = loadGeneration.current; + setTimeout(() => { + if (generation !== loadGeneration.current) return; + const n = reactFlowInstance.getNodes().find((x) => x.id === nodeId); + if (n) enrichNodeControls([n], generation); + }, 0); + }); + setDefinitionResolutionFailedCallback((nodeId, error) => { + console.warn(`[CALM Canvas] Failed to resolve definition for node ${nodeId}: ${error}`); + }); + setUpdatesAvailableCallback((updates) => { + useCanvasStore.setState({ availableUpdates: updates }); + }); + setControlsChangedCallback(() => { + // A local requirement file changed — re-resolve all controls. + const generation = loadGeneration.current; + enrichAllControls(reactFlowInstance.getNodes(), generation); }); initBridge(); const initialJson = (window as unknown as { __INITIAL_CALM_JSON__?: string }).__INITIAL_CALM_JSON__; if (initialJson) { loadArchitecture(initialJson); setInitialized(true); } - }, [loadArchitecture]); + }, [loadArchitecture, setNodes]); // --- Undo/Redo --- useEffect(() => { @@ -224,10 +353,48 @@ function CanvasApp() { e.preventDefault(); if (selectedNode?.parentId) unparentNode(selectedNode.id); } + // Copy + if ((e.ctrlKey || e.metaKey) && e.key === 'c' && !e.shiftKey && !isInputDOMNode(e)) { + const live = selectedNode ? nodes.find((n) => n.id === selectedNode.id) ?? selectedNode : null; + if (live) { + clipboardNode.current = live; + } + } + // Paste + if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !e.shiftKey && !isInputDOMNode(e)) { + if (store.readonlyMode || !clipboardNode.current) return; + e.preventDefault(); + const src = clipboardNode.current; + const data = JSON.parse(JSON.stringify(src.data)) as Record; + const calmType = (data.calmType as string) ?? 'system'; + const newId = `${calmType.replace(/[^a-zA-Z0-9]/g, '-')}-${Date.now()}`; + data.calmId = newId; + data.label = `${data.label ?? ''} (copy)`; + // Strip resolved definition data — the copy is independent + delete data['definition-id']; + delete data._resolvedControls; + delete data.validationErrors; + delete data.validationWarnings; + + const isContainer = src.type === 'container'; + const newNode: Node = { + id: newId, + type: src.type ?? 'system', + position: { x: src.position.x + 30, y: src.position.y + 30 }, + data, + ...(isContainer && src.width && src.height + ? { width: src.width, height: src.height, style: { width: src.width, height: src.height } } + : {}), + }; + setNodes((nds) => [...nds, newNode]); + setSelectedNode(newNode); + store.selectNode(newId); + setTimeout(() => emitChange(true), 0); + } }; window.addEventListener('keydown', handleKeydown); return () => window.removeEventListener('keydown', handleKeydown); - }, [loadArchitecture, selectedNode]); + }, [loadArchitecture, selectedNode, nodes, setNodes, emitChange, store]); // --- Node changes (position, dimension, remove) --- const onNodesChange = useCallback((changes: NodeChange[]) => { @@ -386,23 +553,27 @@ function CanvasApp() { // --- Node update (from properties panel) --- const onNodeUpdate = useCallback((nodeId: string, field: string, value: unknown) => { - setNodes((nds) => nds.map((n) => { - if (n.id !== nodeId) return n; - const data = { ...(n.data as Record) }; - switch (field) { - case 'name': data.label = value; break; - case 'description': data.description = value; break; - case 'node-type': data.calmType = value; break; - case 'interfaces': data.interfaces = value; break; - case 'controls': data.controls = value; break; - case 'metadata': data.metadata = { ...((data.metadata as Record) ?? {}), ...(value as Record) }; break; - case 'containerRole': data.containerRole = value; break; - } - const updated = { ...n, data }; - if (field === 'node-type') updated.type = resolveFlowNodeType(value as string); - setSelectedNode(updated); - return updated; - })); + setNodes((nds) => { + const result = nds.map((n) => { + if (n.id !== nodeId) return n; + const data = { ...(n.data as Record) }; + switch (field) { + case 'name': data.label = value; break; + case 'description': data.description = value; break; + case 'node-type': data.calmType = value; break; + case 'interfaces': data.interfaces = value; break; + case 'controls': data.controls = value; break; + case 'metadata': data.metadata = { ...((data.metadata as Record) ?? {}), ...(value as Record) }; break; + case 'containerRole': data.containerRole = value; break; + } + const updated = { ...n, data }; + if (field === 'node-type') updated.type = resolveFlowNodeType(value as string); + setSelectedNode(updated); + return updated; + }); + pendingNodesRef.current = result; + return result; + }); setTimeout(() => emitChange(field !== 'name' && field !== 'description'), 0); }, [setNodes, emitChange]); @@ -426,6 +597,7 @@ function CanvasApp() { // Building block / standard drop const buildingBlock = (store.buildingBlocks as any[]).find((n: any) => n.id === buildingBlockId); if (!buildingBlock) return; + const isHubSourced = !!(buildingBlock.namespace && buildingBlock.sha); const controlsCopy = JSON.parse(JSON.stringify(buildingBlock.controls ?? {})) as Record; if (behaviour === 'apply-controls-on-drop') { @@ -454,6 +626,12 @@ function CanvasApp() { } } if (targetId) { + // Hub-sourced standard/guideline: write a definition-id CURIE ref + const stdCurieType = buildingBlock.id?.startsWith?.('guidelines:') ? 'guidelines' : 'standards'; + const requirementUrl = (isHubSourced && buildingBlock.sha) + ? `${buildingBlock.namespace}:${stdCurieType}:${buildingBlock.id}@${buildingBlock.sha}` + : undefined; + // Merge controls into target (if any) and create standards node + edge setNodes((nds) => { const existingStdNode = nds.find( @@ -462,9 +640,11 @@ function CanvasApp() { let stdNodeId: string; let updatedNodes = nds.map((n) => { if (n.id !== targetId) return n; - if (Object.keys(controlsCopy).length === 0) return n; + if (!isHubSourced && Object.keys(controlsCopy).length === 0) return n; const data = { ...(n.data as Record) }; - data.controls = mergeControls((data.controls as Record) ?? {}, controlsCopy); + if (!isHubSourced) { + data.controls = mergeControls((data.controls as Record) ?? {}, controlsCopy); + } return { ...n, data }; }); @@ -473,19 +653,24 @@ function CanvasApp() { } else { // Create a standards node near the drop position stdNodeId = `standard-${Date.now()}`; + const stdNodeData: Record = { + label: buildingBlock.name, + calmId: stdNodeId, + calmType: 'standard', + description: buildingBlock.description ?? '', + interfaces: [], + }; + if (requirementUrl) { + stdNodeData['definition-id'] = requirementUrl; + } else { + stdNodeData.controls = {}; + stdNodeData.metadata = { 'source-building-block': buildingBlockId }; + } const stdNode: Node = { id: stdNodeId, type: resolveFlowNodeType('service'), position: { x: position.x + 200, y: position.y - 100 }, - data: { - label: buildingBlock.name, - calmId: stdNodeId, - calmType: 'standard', - description: buildingBlock.description ?? '', - interfaces: [], - controls: {}, - metadata: { 'source-building-block': buildingBlockId }, - }, + data: stdNodeData, }; updatedNodes = [...updatedNodes, stdNode]; } @@ -520,28 +705,49 @@ function CanvasApp() { return updatedNodes; }); setTimeout(() => emitChange(true), 0); + setTimeout(() => enrichAllControls(reactFlowInstance.getNodes(), loadGeneration.current), 0); return; } // No target (or no controls to apply) — fall through to place a standalone marker. } const id = `${buildingBlock.nodeType}-${Date.now()}`; + const curieType = buildingBlock.behaviour === 'apply-controls-on-drop' + ? (buildingBlock.id?.startsWith?.('guidelines:') ? 'guidelines' : 'standards') + : 'building-blocks'; const newNode: Node = { id, type: resolveFlowNodeType(buildingBlock.nodeType), position, - data: { - label: buildingBlock.name, - calmId: id, - calmType: buildingBlock.nodeType, - description: buildingBlock.description ?? '', - interfaces: [], - controls: controlsCopy, - metadata: { 'source-building-block': buildingBlock.id }, - }, + data: isHubSourced + ? { + label: buildingBlock.name, + calmId: id, + calmType: buildingBlock.nodeType, + description: buildingBlock.description ?? '', + interfaces: [], + 'definition-id': `${buildingBlock.namespace}:${curieType}:${buildingBlock.id}@${buildingBlock.sha}`, + } + : { + label: buildingBlock.name, + calmId: id, + calmType: buildingBlock.nodeType, + description: buildingBlock.description ?? '', + interfaces: [], + controls: controlsCopy, + metadata: { 'source-building-block': buildingBlock.id }, + }, }; setNodes((nds) => [...nds, newNode]); + // Request resolution for Hub-sourced blocks so the webview can render controls + if (isHubSourced) { + postMessage({ type: 'resolveDefinitionId', nodeId: id, curie: newNode.data['definition-id'] as string }); + } setTimeout(() => emitChange(true), 0); + // Local building-block controls may carry local requirement refs — enrich them. + if (!isHubSourced) { + setTimeout(() => enrichNodeControls(reactFlowInstance.getNodes(), loadGeneration.current), 0); + } return; } @@ -560,7 +766,7 @@ function CanvasApp() { }]); } setTimeout(() => emitChange(true), 0); - }, [nodes, setNodes, setEdges, emitChange, reactFlowInstance, store.readonlyMode, store.buildingBlocks]); + }, [nodes, setNodes, setEdges, emitChange, reactFlowInstance, store.readonlyMode, store.buildingBlocks, enrichAllControls, enrichNodeControls]); // --- Validate --- const handleValidate = useCallback(() => { @@ -792,11 +998,72 @@ function CanvasApp() { }, [nodes, edges, store.documentControls, loadArchitecture]); // --- Control focused (standards panel) --- - const handleControlFocused = useCallback((url: string | null) => { - setActiveStandardProse(null); - setActiveRequirementUrl(url); - if (url) requestStandardProse(url); - }, []); + + // --- Control picker attach --- + const handleControlAttach = useCallback((ref: string, parsed: ParsedRequirement) => { + const key = parsed.identity.name || makeControlMapKey(ref); + const entry = buildControlEntry(ref, parsed); + const target = controlPickerTarget; + setShowControlPicker(false); + setControlPickerTarget(null); + if (!target) return; + const generation = loadGeneration.current; + if (target.type === 'node') { + setNodes((nds) => nds.map((n) => { + if (n.id !== target.nodeId) return n; + const data = n.data as Record; + const existing = (data.controls as Record) ?? {}; + return { ...n, data: { ...data, controls: { ...existing, [key]: entry } } }; + })); + setTimeout(() => { + emitChange(true); + const n = reactFlowInstance.getNodes().find((x) => x.id === target.nodeId); + if (n) enrichNodeControls([n], generation); + }, 0); + } else if (target.type === 'document') { + const existing = (useCanvasStore.getState().documentControls as Record) ?? {}; + useCanvasStore.setState({ documentControls: { ...existing, [key]: entry } }); + setTimeout(() => { + emitChange(true); + enrichAllControls(reactFlowInstance.getNodes(), generation); + }, 0); + } else if (target.type === 'building-block-draft') { + target.onAttach(ref, parsed); + } + }, [controlPickerTarget, setNodes, emitChange, reactFlowInstance, enrichNodeControls, enrichAllControls]); + + const existingControlKeys = React.useMemo(() => { + const target = controlPickerTarget; + if (!target) return new Set(); + let controls: Record | undefined; + if (target.type === 'node') { + const node = nodes.find((n) => n.id === target.nodeId); + controls = (node?.data as Record)?.controls as Record | undefined; + } else if (target.type === 'document') { + controls = store.documentControls as Record | undefined; + } else { + return target.existingKeys ?? new Set(); + } + const keys = new Set(Object.keys(controls ?? {})); + for (const ctrl of Object.values(controls ?? {})) { + const reqs = (ctrl as Record)?.requirements as Array> | undefined; + const url = reqs?.[0]?.['requirement-url']; + if (typeof url === 'string') { + keys.add(makeControlMapKey(url)); + const parsed = parseControlCurie(url); + if (parsed) keys.add(parsed.controlName); + } + } + return keys; + }, [controlPickerTarget, nodes, store.documentControls]); + + // --- Export as Pattern --- + const handleExportAsPattern = useCallback(() => { + const currentNodes = reactFlowInstance.getNodes(); + const currentEdges = reactFlowInstance.getEdges(); + const doc = flowToCalm(currentNodes, currentEdges, useCanvasStore.getState().documentControls); + postMessage({ type: 'requestExportPattern', doc: JSON.stringify(doc) }); + }, [reactFlowInstance]); // --- Generate spec --- const handleGenerateSpec = useCallback(() => { @@ -807,7 +1074,8 @@ function CanvasApp() { return
Loading CALM architecture...
; } - const data = selectedNode ? (selectedNode.data as Record) : null; + const liveSelectedNode = selectedNode ? nodes.find((n) => n.id === selectedNode.id) ?? selectedNode : null; + const data = liveSelectedNode ? (liveSelectedNode.data as Record) : null; return (
@@ -826,11 +1094,16 @@ function CanvasApp() { )} + {!store.readonlyMode && ( + + )} {!store.readonlyMode && ( setShowBuildingBlockCreator(true) }, + { label: 'Create Control', onClick: () => setShowControlCreator(true) }, + { label: 'Export as Pattern', onClick: handleExportAsPattern }, ]} /> )}
@@ -911,40 +1184,39 @@ function CanvasApp() { PROPERTIES - {selectedEdge && !selectedNode ? ( + {selectedEdge && !liveSelectedNode ? ( - ) : selectedNode && data ? ( + ) : liveSelectedNode && data ? ( <>
- {data.calmId as string ?? selectedNode.id} + {data.calmId as string ?? liveSelectedNode.id} - onNodeUpdate(selectedNode.id, 'name', e.target.value)} style={inputStyle} readOnly={store.readonlyMode} /> + onNodeUpdate(liveSelectedNode.id, 'name', e.target.value)} style={inputStyle} readOnly={store.readonlyMode} /> {data.calmType as string ?? 'system'} -