From 83089e73f6b0bcf6701165e3ebf5306dd2423897 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 15 Sep 2026 18:39:34 +0000 Subject: [PATCH 1/2] feat(vscode): instantiate items catalogues in the pattern picker --- .../src/webview/panels/PatternPicker.tsx | 58 +-------------- .../panels/pattern-instantiation.test.ts | 74 +++++++++++++++++++ .../webview/panels/pattern-instantiation.ts | 66 +++++++++++++++++ 3 files changed, 141 insertions(+), 57 deletions(-) create mode 100644 calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts create mode 100644 calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts 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..170aa183ef --- /dev/null +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts @@ -0,0 +1,74 @@ +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 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('instantiates a node from an items catalogue, after the positional entries', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: { oneOf: [node('cache'), node('queue')] } } }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway', 'cache']); + }); + + it('instantiates a node from an anyOf catalogue', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { items: { anyOf: [node('cache')] } } }, + }); + + expect(ids(arch.nodes)).toEqual(['cache']); + }); + + it('instantiates a relationship from an items catalogue', () => { + const arch = instantiateFromPattern({ + properties: { relationships: { items: { oneOf: [relationship('gateway-connects-cache')] } } }, + }); + + expect(ids(arch.relationships)).toEqual(['gateway-connects-cache']); + }); + + it('ignores 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']); + }); + + it('ignores an items schema that only references another schema', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: { $ref: 'core.json#/defs/node' } } }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway']); + }); + + it('returns empty arrays for a pattern that declares nothing', () => { + expect(instantiateFromPattern({})).toEqual({ nodes: [], relationships: [] }); + }); +}); 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..6ff81f36da --- /dev/null +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts @@ -0,0 +1,66 @@ +export function instantiateFromPattern(schema: unknown): any { + const p = schema as any; + const nodeSchemas = declarations(p?.properties?.nodes); + const relSchemas = declarations(p?.properties?.relationships); + + 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 }; +} + +/** + * A catalogue only counts when it offers alternatives. `items` applies one schema to every + * position after the entries, so a member declared directly there is not a choice. + */ +function declarations(field: any): any[] { + const catalogue = field?.items; + const offersChoice = catalogue?.oneOf?.length || catalogue?.anyOf?.length; + return [...(field?.prefixItems ?? []), ...(offersChoice ? [catalogue] : [])]; +} + +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]]'; +} From 87665457610569a83f65b46703881649f5ac3f00 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Thu, 1 Oct 2026 21:17:13 +0000 Subject: [PATCH 2/2] fix(vscode): build relationships that name the nodes the picker built The picker read the node list and the relationship list separately and took the first alternative of each, so it could build a relationship naming a node it had not built. The canvas cannot draw that link, and Apply Pattern merges it into the architecture the author already has open. It now takes the relationship alternative whose endpoints it built, falling back to the first when none fit. It also skips an alternative it cannot build, such as a bare $ref, which previously left the position empty while the relationships naming it were still built. Reading items catalogues is dropped. PATTERN-DECISIONS.md records a catalogue as what an architecture may add, and calm generate builds no member a decision does not name. --- .../panels/pattern-instantiation.test.ts | 147 +++++++++++++++--- .../webview/panels/pattern-instantiation.ts | 75 +++++---- 2 files changed, 169 insertions(+), 53 deletions(-) diff --git a/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts index 170aa183ef..0501ddf779 100644 --- a/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts @@ -11,6 +11,40 @@ const relationship = (id: string) => ({ 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', () => { @@ -28,47 +62,112 @@ describe('instantiateFromPattern', () => { expect(ids(arch.nodes)).toEqual(['postgres']); }); - it('instantiates a node from an items catalogue, after the positional entries', () => { + 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: [node('gateway')], items: { oneOf: [node('cache'), node('queue')] } } }, + properties: { nodes: { prefixItems: [{ oneOf: [{ $ref: 'core.json#/defs/node' }, node('cache')] }] } }, }); - expect(ids(arch.nodes)).toEqual(['gateway', 'cache']); + expect(ids(arch.nodes)).toEqual(['cache']); }); - it('instantiates a node from an anyOf catalogue', () => { - const arch = instantiateFromPattern({ - properties: { nodes: { items: { anyOf: [node('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']); }); - expect(ids(arch.nodes)).toEqual(['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('instantiates a relationship from an items catalogue', () => { - const arch = instantiateFromPattern({ - properties: { relationships: { items: { oneOf: [relationship('gateway-connects-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']); }); - expect(ids(arch.relationships)).toEqual(['gateway-connects-cache']); + it('leaves a relationship that names no node alone', () => { + const arch = instantiateFromPattern({ + properties: { relationships: { prefixItems: [relationship('standalone')] } }, + }); + + expect(ids(arch.relationships)).toEqual(['standalone']); + }); }); - it('ignores an items schema that declares a node directly', () => { - const arch = instantiateFromPattern({ - properties: { nodes: { prefixItems: [node('gateway')], items: node('cache') } }, + 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']); }); - expect(ids(arch.nodes)).toEqual(['gateway']); - }); + it('builds nothing from a relationship catalogue', () => { + const arch = instantiateFromPattern({ + properties: { relationships: { items: { oneOf: [relationship('gateway-connects-cache')] } } }, + }); - it('ignores an items schema that only references another schema', () => { - const arch = instantiateFromPattern({ - properties: { nodes: { prefixItems: [node('gateway')], items: { $ref: 'core.json#/defs/node' } } }, + expect(arch.relationships).toEqual([]); }); - expect(ids(arch.nodes)).toEqual(['gateway']); - }); + it('builds nothing from an items schema that declares a node directly', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: node('cache') } }, + }); - it('returns empty arrays for a pattern that declares nothing', () => { - expect(instantiateFromPattern({})).toEqual({ nodes: [], relationships: [] }); + 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 index 6ff81f36da..d41c446f2e 100644 --- a/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts @@ -1,46 +1,63 @@ export function instantiateFromPattern(schema: unknown): any { const p = schema as any; - const nodeSchemas = declarations(p?.properties?.nodes); - const relSchemas = declarations(p?.properties?.relationships); - 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 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 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; - }); + 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 }; } /** - * A catalogue only counts when it offers alternatives. `items` applies one schema to every - * position after the entries, so a member declared directly there is not a choice. + * 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 declarations(field: any): any[] { - const catalogue = field?.items; - const offersChoice = catalogue?.oneOf?.length || catalogue?.anyOf?.length; - return [...(field?.prefixItems ?? []), ...(offersChoice ? [catalogue] : [])]; +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; } -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); +/** 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 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 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 {