Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 1 addition & 57 deletions calm-plugins/vscode/src/webview/panels/PatternPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 }

Expand Down Expand Up @@ -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<string, any>): Record<string, unknown> {
const result: Record<string, unknown> = {};
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)' };
Expand Down
173 changes: 173 additions & 0 deletions calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
});
83 changes: 83 additions & 0 deletions calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts
Original file line number Diff line number Diff line change
@@ -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<string>(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<string, any>): Record<string, unknown> {
const result: Record<string, unknown> = {};
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]]';
}
Loading