diff --git a/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx b/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx index 58f3f4bc94..39aac5444b 100644 --- a/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx +++ b/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { instantiateFromPattern } from './pattern-instantiation'; interface PatternEntry { id: string; name: string; description: string; category: string; schema: unknown } @@ -61,63 +62,6 @@ export function PatternPicker({ visible, mode, patterns, onApply, onClose }: Pat ); } -function instantiateFromPattern(schema: unknown): any { - const p = schema as any; - const nodeSchemas = p?.properties?.nodes?.prefixItems ?? []; - const relSchemas = p?.properties?.relationships?.prefixItems ?? []; - - const nodes = nodeSchemas.map((s: any) => instantiateNode(s)).filter(Boolean).map((n: any) => { - if (!n['unique-id']) n['unique-id'] = '[[PLACEHOLDER]]'; - if (!n['node-type']) n['node-type'] = 'system'; - if (!n['name']) n['name'] = '[[PLACEHOLDER]]'; - return n; - }); - - const relationships = relSchemas.map((s: any) => instantiateRel(s)).filter(Boolean).map((r: any) => { - if (!r['unique-id']) r['unique-id'] = '[[PLACEHOLDER]]'; - if (!r['relationship-type']) r['relationship-type'] = {}; - return r; - }); - - return { nodes, relationships }; -} - -function instantiateNode(schema: any): any { - if (schema.oneOf?.length) return instantiateNode(schema.oneOf[0]); - if (schema.anyOf?.length) return instantiateNode(schema.anyOf[0]); - if (!schema.properties) return null; - return instantiateObject(schema.properties); -} - -function instantiateRel(schema: any): any { - if (schema.oneOf?.length) return instantiateRel(schema.oneOf[0]); - if (schema.anyOf?.length) return instantiateRel(schema.anyOf[0]); - if (!schema.properties) return null; - return instantiateObject(schema.properties); -} - -function instantiateObject(properties: Record): Record { - const result: Record = {}; - for (const [key, schema] of Object.entries(properties)) { - if (key.startsWith('$') || key === 'type') continue; - result[key] = extractValue(schema); - } - return result; -} - -function extractValue(schema: any): unknown { - if (schema.const !== undefined) return schema.const; - if (schema.default !== undefined) return schema.default; - if (schema.properties) return instantiateObject(schema.properties); - if (schema.prefixItems) return schema.prefixItems.map(extractValue); - if (schema.type === 'string') return '[[PLACEHOLDER]]'; - if (schema.type === 'integer' || schema.type === 'number') return -1; - if (schema.type === 'boolean') return false; - if (schema.type === 'array') return []; - if (schema.type === 'object') return {}; - return '[[PLACEHOLDER]]'; -} - const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'var(--calm-overlay)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }; const modalStyle: React.CSSProperties = { width: '600px', maxWidth: '90vw', maxHeight: '80vh', background: 'var(--calm-bg)', borderRadius: '12px', border: '1px solid var(--calm-border-heavy)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }; const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--calm-border)' }; diff --git a/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts new file mode 100644 index 0000000000..0501ddf779 --- /dev/null +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { instantiateFromPattern } from './pattern-instantiation'; + +const node = (id: string) => ({ + type: 'object', + properties: { 'unique-id': { const: id }, 'node-type': { const: 'service' }, name: { const: id } }, +}); + +const relationship = (id: string) => ({ + type: 'object', + properties: { 'unique-id': { const: id } }, +}); + +const connects = (id: string, source: string, destination: string) => ({ + type: 'object', + properties: { + 'unique-id': { const: id }, + 'relationship-type': { + properties: { + connects: { + properties: { + source: { properties: { node: { const: source } } }, + destination: { properties: { node: { const: destination } } }, + }, + }, + }, + }, + }, +}); + +const deployedIn = (id: string, container: string, nodes: string[]) => ({ + type: 'object', + properties: { + 'unique-id': { const: id }, + 'relationship-type': { + properties: { + 'deployed-in': { + properties: { + container: { const: container }, + nodes: { prefixItems: nodes.map((n) => ({ const: n })) }, + }, + }, + }, + }, + }, +}); + +const ids = (elements: any[]) => elements.map((element) => element['unique-id']); + +describe('instantiateFromPattern', () => { + it('instantiates a node declared in a prefixItems entry', () => { + const arch = instantiateFromPattern({ properties: { nodes: { prefixItems: [node('gateway')] } } }); + + expect(arch.nodes).toEqual([{ 'unique-id': 'gateway', 'node-type': 'service', name: 'gateway' }]); + }); + + it('takes the first alternative of a prefixItems entry', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [{ oneOf: [node('postgres'), node('mysql')] }] } }, + }); + + expect(ids(arch.nodes)).toEqual(['postgres']); + }); + + it('skips an alternative it cannot build and takes the next one', () => { + // A bare $ref pins nothing, so taking it literally left the position empty while the + // relationships that name it were still built. + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [{ oneOf: [{ $ref: 'core.json#/defs/node' }, node('cache')] }] } }, + }); + + expect(ids(arch.nodes)).toEqual(['cache']); + }); + + it('returns empty arrays for a pattern that declares nothing', () => { + expect(instantiateFromPattern({})).toEqual({ nodes: [], relationships: [] }); + }); + + describe('choosing a relationship that fits the nodes it built', () => { + it('takes the alternative whose endpoints were built, not the first one', () => { + // The node catalogue offers cache or queue and the picker builds cache. Taking the + // first relationship names queue, which is not there, and the canvas cannot draw it. + const arch = instantiateFromPattern({ + properties: { + nodes: { prefixItems: [node('gateway'), { oneOf: [node('cache'), node('queue')] }] }, + relationships: { + prefixItems: [{ + oneOf: [ + connects('gateway-connects-queue', 'gateway', 'queue'), + connects('gateway-connects-cache', 'gateway', 'cache'), + ], + }], + }, + }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway', 'cache']); + expect(ids(arch.relationships)).toEqual(['gateway-connects-cache']); + }); + + it('reads the endpoints of a deployed-in relationship too', () => { + const arch = instantiateFromPattern({ + properties: { + nodes: { prefixItems: [node('k8s'), { oneOf: [node('cache'), node('queue')] }] }, + relationships: { + prefixItems: [{ + oneOf: [deployedIn('in-k8s', 'k8s', ['queue']), deployedIn('in-k8s', 'k8s', ['cache'])], + }], + }, + }, + }); + + expect(arch.relationships[0]['relationship-type']['deployed-in'].nodes).toEqual(['cache']); + }); + + it('falls back to the first alternative when none of them fit', () => { + // A pattern that offers no coherent combination. The position still holds a + // relationship, so the author sees what the pattern asked for rather than nothing. + const arch = instantiateFromPattern({ + properties: { + nodes: { prefixItems: [node('gateway')] }, + relationships: { + prefixItems: [{ + oneOf: [ + connects('gateway-connects-queue', 'gateway', 'queue'), + connects('gateway-connects-cache', 'gateway', 'cache'), + ], + }], + }, + }, + }); + + expect(ids(arch.relationships)).toEqual(['gateway-connects-queue']); + }); + + it('leaves a relationship that names no node alone', () => { + const arch = instantiateFromPattern({ + properties: { relationships: { prefixItems: [relationship('standalone')] } }, + }); + + expect(ids(arch.relationships)).toEqual(['standalone']); + }); + }); + + describe('items catalogues', () => { + // A catalogue is what an architecture MAY add, none of it required — see + // PATTERN-DECISIONS.md. `calm generate` builds no member that a decision does not name, + // and the picker holds the same line rather than choosing one on the author's behalf. + it('builds nothing from a node catalogue', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: { oneOf: [node('cache'), node('queue')] } } }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway']); + }); + + it('builds nothing from a relationship catalogue', () => { + const arch = instantiateFromPattern({ + properties: { relationships: { items: { oneOf: [relationship('gateway-connects-cache')] } } }, + }); + + expect(arch.relationships).toEqual([]); + }); + + it('builds nothing from an items schema that declares a node directly', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: node('cache') } }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway']); + }); + }); +}); diff --git a/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts new file mode 100644 index 0000000000..d41c446f2e --- /dev/null +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts @@ -0,0 +1,83 @@ +export function instantiateFromPattern(schema: unknown): any { + const p = schema as any; + + const nodes = (p?.properties?.nodes?.prefixItems ?? []) + .map((entry: any) => firstBuildable(entry)) + .filter(Boolean) + .map((n: any) => { + if (!n['unique-id']) n['unique-id'] = '[[PLACEHOLDER]]'; + if (!n['node-type']) n['node-type'] = 'system'; + if (!n['name']) n['name'] = '[[PLACEHOLDER]]'; + return n; + }); + + const built = new Set(nodes.map((n: any) => n['unique-id'])); + const relationships = (p?.properties?.relationships?.prefixItems ?? []) + .map((entry: any) => firstBuildable(entry, (r) => endpointsOf(r).every((id) => built.has(id)))) + .filter(Boolean) + .map((r: any) => { + if (!r['unique-id']) r['unique-id'] = '[[PLACEHOLDER]]'; + if (!r['relationship-type']) r['relationship-type'] = {}; + return r; + }); + + return { nodes, relationships }; +} + +/** + * The declaration a position is built from. Where it offers alternatives the first one that + * builds wins, except that a relationship prefers an alternative naming only nodes already + * built — the two catalogues are read separately, so the first of each can disagree and leave + * a relationship pointing at a node that is not there. + */ +function firstBuildable(entry: any, prefer?: (built: any) => boolean): any { + const candidates = alternativesOf(entry).map(instantiate).filter(Boolean); + return (prefer && candidates.find(prefer)) || candidates[0] || null; +} + +/** Every leaf declaration an entry offers, flattening nested `oneOf`/`anyOf`. */ +function alternativesOf(schema: any): any[] { + const alternatives = schema?.oneOf?.length ? schema.oneOf : schema?.anyOf?.length ? schema.anyOf : null; + return alternatives ? alternatives.flatMap(alternativesOf) : [schema]; +} + +function instantiate(schema: any): any { + return schema?.properties ? instantiateObject(schema.properties) : null; +} + +/** The nodes a relationship names, across the four relationship types that name any. */ +function endpointsOf(relationship: any): string[] { + const type = relationship?.['relationship-type'] ?? {}; + const connects = type['connects']; + const endpoints: unknown[] = [connects?.source?.node, connects?.destination?.node]; + + for (const key of ['interacts', 'deployed-in', 'composed-of']) { + const body = type[key]; + if (!body) continue; + endpoints.push(body.actor, body.container, ...(Array.isArray(body.nodes) ? body.nodes : [])); + } + + return endpoints.filter((id): id is string => typeof id === 'string'); +} + +function instantiateObject(properties: Record): Record { + const result: Record = {}; + for (const [key, schema] of Object.entries(properties)) { + if (key.startsWith('$') || key === 'type') continue; + result[key] = extractValue(schema); + } + return result; +} + +function extractValue(schema: any): unknown { + if (schema.const !== undefined) return schema.const; + if (schema.default !== undefined) return schema.default; + if (schema.properties) return instantiateObject(schema.properties); + if (schema.prefixItems) return schema.prefixItems.map(extractValue); + if (schema.type === 'string') return '[[PLACEHOLDER]]'; + if (schema.type === 'integer' || schema.type === 'number') return -1; + if (schema.type === 'boolean') return false; + if (schema.type === 'array') return []; + if (schema.type === 'object') return {}; + return '[[PLACEHOLDER]]'; +}