diff --git a/calm-models/src/diff/fixtures/diff-test-patterns.json b/calm-models/src/diff/fixtures/diff-test-patterns.json index 8a25df1f4..7e59c2e41 100644 --- a/calm-models/src/diff/fixtures/diff-test-patterns.json +++ b/calm-models/src/diff/fixtures/diff-test-patterns.json @@ -10,25 +10,43 @@ { "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", "properties": { - "unique-id": { "const": "payment-service" }, - "name": { "const": "Payment Service" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "payment-service" + }, + "name": { + "const": "Payment Service" + }, + "node-type": { + "const": "service" + } } }, { "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", "properties": { - "unique-id": { "const": "user-db" }, - "name": { "const": "User Database" }, - "node-type": { "const": "database" } + "unique-id": { + "const": "user-db" + }, + "name": { + "const": "User Database" + }, + "node-type": { + "const": "database" + } } } ] @@ -39,20 +57,46 @@ { "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/relationship", "properties": { - "unique-id": { "const": "gateway-to-payment" }, - "description": { "const": "Gateway routes to payment service" }, + "unique-id": { + "const": "gateway-to-payment" + }, + "description": { + "const": "Gateway routes to payment service" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "api-gateway" }, "destination": { "node": "payment-service" } } } + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "payment-service" + } + } + } } } }, { "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/relationship", "properties": { - "unique-id": { "const": "payment-to-db" }, - "description": { "const": "Payment persists to database" }, + "unique-id": { + "const": "payment-to-db" + }, + "description": { + "const": "Payment persists to database" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "payment-service" }, "destination": { "node": "user-db" } } } + "const": { + "connects": { + "source": { + "node": "payment-service" + }, + "destination": { + "node": "user-db" + } + } + } } } } @@ -70,30 +114,54 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "payment-service" }, - "name": { "const": "Payment Service" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "payment-service" + }, + "name": { + "const": "Payment Service" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "user-db" }, - "name": { "const": "User Database" }, - "node-type": { "const": "database" } + "unique-id": { + "const": "user-db" + }, + "name": { + "const": "User Database" + }, + "node-type": { + "const": "database" + } } }, { "properties": { - "unique-id": { "const": "audit-service" }, - "name": { "const": "Audit Service" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "audit-service" + }, + "name": { + "const": "Audit Service" + }, + "node-type": { + "const": "service" + } } } ] @@ -103,28 +171,67 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "gateway-to-payment" }, - "description": { "const": "Gateway routes to payment service" }, + "unique-id": { + "const": "gateway-to-payment" + }, + "description": { + "const": "Gateway routes to payment service" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "api-gateway" }, "destination": { "node": "payment-service" } } } + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "payment-service" + } + } + } } } }, { "properties": { - "unique-id": { "const": "payment-to-db" }, - "description": { "const": "Payment persists to database" }, + "unique-id": { + "const": "payment-to-db" + }, + "description": { + "const": "Payment persists to database" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "payment-service" }, "destination": { "node": "user-db" } } } + "const": { + "connects": { + "source": { + "node": "payment-service" + }, + "destination": { + "node": "user-db" + } + } + } } } }, { "properties": { - "unique-id": { "const": "payment-to-audit" }, - "description": { "const": "Payment emits audit events" }, + "unique-id": { + "const": "payment-to-audit" + }, + "description": { + "const": "Payment emits audit events" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "payment-service" }, "destination": { "node": "audit-service" } } } + "const": { + "connects": { + "source": { + "node": "payment-service" + }, + "destination": { + "node": "audit-service" + } + } + } } } } @@ -142,16 +249,28 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "payment-service" }, - "name": { "const": "Payment Service" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "payment-service" + }, + "name": { + "const": "Payment Service" + }, + "node-type": { + "const": "service" + } } } ] @@ -161,10 +280,23 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "gateway-to-payment" }, - "description": { "const": "Gateway routes to payment service" }, + "unique-id": { + "const": "gateway-to-payment" + }, + "description": { + "const": "Gateway routes to payment service" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "api-gateway" }, "destination": { "node": "payment-service" } } } + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "payment-service" + } + } + } } } } @@ -182,23 +314,41 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway v2" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway v2" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "payment-service" }, - "name": { "const": "Payment Service" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "payment-service" + }, + "name": { + "const": "Payment Service" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "user-db" }, - "name": { "const": "User Database" }, - "node-type": { "const": "database" } + "unique-id": { + "const": "user-db" + }, + "name": { + "const": "User Database" + }, + "node-type": { + "const": "database" + } } } ] @@ -208,19 +358,45 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "gateway-to-payment" }, - "description": { "const": "Gateway routes to payment service" }, + "unique-id": { + "const": "gateway-to-payment" + }, + "description": { + "const": "Gateway routes to payment service" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "api-gateway" }, "destination": { "node": "payment-service" } } } + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "payment-service" + } + } + } } } }, { "properties": { - "unique-id": { "const": "payment-to-db" }, - "description": { "const": "Payment persists to database" }, + "unique-id": { + "const": "payment-to-db" + }, + "description": { + "const": "Payment persists to database" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "payment-service" }, "destination": { "node": "user-db" } } } + "const": { + "connects": { + "source": { + "node": "payment-service" + }, + "destination": { + "node": "user-db" + } + } + } } } } @@ -238,23 +414,41 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "payment-processor" }, - "name": { "const": "Payment Service" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "payment-processor" + }, + "name": { + "const": "Payment Service" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "user-db" }, - "name": { "const": "User Database" }, - "node-type": { "const": "database" } + "unique-id": { + "const": "user-db" + }, + "name": { + "const": "User Database" + }, + "node-type": { + "const": "database" + } } } ] @@ -264,19 +458,45 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "gateway-to-payment" }, - "description": { "const": "Gateway routes to payment service" }, + "unique-id": { + "const": "gateway-to-payment" + }, + "description": { + "const": "Gateway routes to payment service" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "api-gateway" }, "destination": { "node": "payment-service" } } } + "const": { + "connects": { + "source": { + "node": "api-gateway" + }, + "destination": { + "node": "payment-service" + } + } + } } } }, { "properties": { - "unique-id": { "const": "payment-to-db" }, - "description": { "const": "Payment persists to database" }, + "unique-id": { + "const": "payment-to-db" + }, + "description": { + "const": "Payment persists to database" + }, "relationship-type": { - "const": { "connects": { "source": { "node": "payment-service" }, "destination": { "node": "user-db" } } } + "const": { + "connects": { + "source": { + "node": "payment-service" + }, + "destination": { + "node": "user-db" + } + } + } } } } @@ -296,9 +516,15 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } } ] @@ -317,21 +543,36 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "type": "string" }, - "name": { "const": "Worker" }, - "node-type": { "const": "service" } + "unique-id": { + "type": "string" + }, + "name": { + "const": "Worker" + }, + "node-type": { + "const": "service" + } } } ] }, - "relationships": { "type": "array", "prefixItems": [] } + "relationships": { + "type": "array", + "prefixItems": [] + } } }, "contentReorderedPattern": { @@ -344,21 +585,36 @@ "prefixItems": [ { "properties": { - "unique-id": { "type": "string" }, - "name": { "const": "Worker" }, - "node-type": { "const": "service" } + "unique-id": { + "type": "string" + }, + "name": { + "const": "Worker" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } } ] }, - "relationships": { "type": "array", "prefixItems": [] } + "relationships": { + "type": "array", + "prefixItems": [] + } } }, "contentModifiedPattern": { @@ -371,21 +627,36 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "type": "string" }, - "name": { "const": "Worker v2" }, - "node-type": { "const": "service" } + "unique-id": { + "type": "string" + }, + "name": { + "const": "Worker v2" + }, + "node-type": { + "const": "service" + } } } ] }, - "relationships": { "type": "array", "prefixItems": [] } + "relationships": { + "type": "array", + "prefixItems": [] + } } }, "contentDuplicatePattern": { @@ -398,28 +669,49 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "type": "string" }, - "name": { "const": "Worker" }, - "node-type": { "const": "service" } + "unique-id": { + "type": "string" + }, + "name": { + "const": "Worker" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "type": "string" }, - "name": { "const": "Worker" }, - "node-type": { "const": "service" } + "unique-id": { + "type": "string" + }, + "name": { + "const": "Worker" + }, + "node-type": { + "const": "service" + } } } ] }, - "relationships": { "type": "array", "prefixItems": [] } + "relationships": { + "type": "array", + "prefixItems": [] + } } }, "undiffablePattern": { @@ -432,14 +724,22 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "properties": { - "unique-id": { "type": "string" } + "unique-id": { + "type": "string" + } } }, { @@ -447,7 +747,10 @@ } ] }, - "relationships": { "type": "array", "prefixItems": [] } + "relationships": { + "type": "array", + "prefixItems": [] + } } }, "decisionPattern": { @@ -460,25 +763,43 @@ "prefixItems": [ { "properties": { - "unique-id": { "const": "api-gateway" }, - "name": { "const": "API Gateway" }, - "node-type": { "const": "service" } + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } } }, { "oneOf": [ { "properties": { - "unique-id": { "const": "postgres-store" }, - "name": { "const": "Postgres Store" }, - "node-type": { "const": "database" } + "unique-id": { + "const": "postgres-store" + }, + "name": { + "const": "Postgres Store" + }, + "node-type": { + "const": "database" + } } }, { "properties": { - "unique-id": { "const": "dynamo-store" }, - "name": { "const": "DynamoDB Store" }, - "node-type": { "const": "database" } + "unique-id": { + "const": "dynamo-store" + }, + "name": { + "const": "DynamoDB Store" + }, + "node-type": { + "const": "database" + } } } ] @@ -490,5 +811,193 @@ "prefixItems": [] } } + }, + "cataloguePattern": { + "$schema": "https://calm.finos.org/release/1.0-rc2/meta/calm.json", + "type": "object", + "title": "Catalogue Pattern", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } + } + } + ], + "items": { + "oneOf": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "cache" + }, + "name": { + "const": "Cache" + }, + "node-type": { + "const": "service" + } + } + } + ] + } + }, + "relationships": { + "type": "array", + "prefixItems": [] + } + } + }, + "catalogueGrownPattern": { + "$schema": "https://calm.finos.org/release/1.0-rc2/meta/calm.json", + "type": "object", + "title": "Catalogue Grown Pattern", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } + } + } + ], + "items": { + "oneOf": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "cache" + }, + "name": { + "const": "Cache" + }, + "node-type": { + "const": "service" + } + } + }, + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "queue" + }, + "name": { + "const": "Queue" + }, + "node-type": { + "const": "service" + } + } + } + ] + } + }, + "relationships": { + "type": "array", + "prefixItems": [] + } + } + }, + "catalogueRenamedMemberPattern": { + "$schema": "https://calm.finos.org/release/1.0-rc2/meta/calm.json", + "type": "object", + "title": "Catalogue Renamed Member Pattern", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } + } + } + ], + "items": { + "oneOf": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "cache" + }, + "name": { + "const": "Redis" + }, + "node-type": { + "const": "service" + } + } + } + ] + } + }, + "relationships": { + "type": "array", + "prefixItems": [] + } + } + }, + "noCataloguePattern": { + "$schema": "https://calm.finos.org/release/1.0-rc2/meta/calm.json", + "type": "object", + "title": "No Catalogue Pattern", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.0-rc2/meta/core.json#/defs/node", + "properties": { + "unique-id": { + "const": "api-gateway" + }, + "name": { + "const": "API Gateway" + }, + "node-type": { + "const": "service" + } + } + } + ] + }, + "relationships": { + "type": "array", + "prefixItems": [] + } + } } } diff --git a/calm-models/src/diff/pattern-diff.spec.ts b/calm-models/src/diff/pattern-diff.spec.ts index 8f6f50936..77b2d4a69 100644 --- a/calm-models/src/diff/pattern-diff.spec.ts +++ b/calm-models/src/diff/pattern-diff.spec.ts @@ -149,3 +149,56 @@ describe('diffPatterns', () => { expect(result.undiffableItems).toBeUndefined(); }); }); +describe('items catalogues', () => { + it('reduces a catalogue member to an instance-shaped node', () => { + const { nodes } = normalisePatternToInstance(testPatterns.cataloguePattern); + expect(nodes.map((n) => n['unique-id'])).toEqual(['api-gateway', 'cache']); + }); + + it('reports a member added to a catalogue', () => { + const result = diffPatterns(testPatterns.cataloguePattern, testPatterns.catalogueGrownPattern); + expect(result.nodesAdded.map((n) => n['unique-id'])).toEqual(['queue']); + expect(result.nodesRemoved).toHaveLength(0); + }); + + it('reports a member removed from a catalogue', () => { + const result = diffPatterns(testPatterns.catalogueGrownPattern, testPatterns.cataloguePattern); + expect(result.nodesRemoved.map((n) => n['unique-id'])).toEqual(['queue']); + expect(result.nodesAdded).toHaveLength(0); + }); + + it('reports a changed member as modified', () => { + const result = diffPatterns(testPatterns.cataloguePattern, testPatterns.catalogueRenamedMemberPattern); + expect(result.nodesModified).toHaveLength(1); + expect(result.nodesModified[0].original['unique-id']).toBe('cache'); + }); + + it('reports an unchanged member as same', () => { + const result = diffPatterns(testPatterns.cataloguePattern, testPatterns.cataloguePattern); + expect(result.nodesSame.map((n) => n['unique-id'])).toEqual(['api-gateway', 'cache']); + }); + + it('reports a pattern that gains a catalogue', () => { + const result = diffPatterns(testPatterns.noCataloguePattern, testPatterns.cataloguePattern); + expect(result.nodesAdded.map((n) => n['unique-id'])).toEqual(['cache']); + }); + + it('reports a pattern that loses its catalogue', () => { + const result = diffPatterns(testPatterns.cataloguePattern, testPatterns.noCataloguePattern); + expect(result.nodesRemoved.map((n) => n['unique-id'])).toEqual(['cache']); + }); + + it('ignores an items schema that declares a node directly', () => { + const plain = { + properties: { + nodes: { + type: 'array', + prefixItems: [], + items: { properties: { 'unique-id': { const: 'ghost' } } }, + }, + relationships: { type: 'array', prefixItems: [] }, + }, + }; + expect(normalisePatternToInstance(plain).nodes).toHaveLength(0); + }); +}); diff --git a/calm-models/src/diff/pattern-diff.ts b/calm-models/src/diff/pattern-diff.ts index 520701606..e74bc0bb0 100644 --- a/calm-models/src/diff/pattern-diff.ts +++ b/calm-models/src/diff/pattern-diff.ts @@ -39,29 +39,54 @@ function collapseSchema(schema: unknown): unknown { return undefined; } +function alternativesOf(schema: unknown): SchemaObject[] | undefined { + if (!isObject(schema)) return undefined; + if (Array.isArray(schema['oneOf'])) return schema['oneOf'] as SchemaObject[]; + if (Array.isArray(schema['anyOf'])) return schema['anyOf'] as SchemaObject[]; + return undefined; +} + /** - * Reads the `prefixItems` for a top-level pattern field (e.g. `nodes`, - * `relationships`), handling both direct `properties` and `allOf` wrapping. + * A pattern may declare a field directly or inside an `allOf` branch, so the + * first branch carrying what the caller wants is the one that counts. */ -function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { +function findField( + pattern: SchemaObject, + key: string, + carries: (field: SchemaObject) => boolean, +): SchemaObject | undefined { const direct = isObject(pattern['properties']) ? pattern['properties'][key] : undefined; - if (isObject(direct) && Array.isArray(direct['prefixItems'])) { - return direct['prefixItems'] as SchemaObject[]; + if (isObject(direct) && carries(direct)) { + return direct; } if (Array.isArray(pattern['allOf'])) { for (const sub of pattern['allOf']) { if (!isObject(sub) || !isObject(sub['properties'])) continue; const field = sub['properties'][key]; - if (isObject(field) && Array.isArray(field['prefixItems'])) { - return field['prefixItems'] as SchemaObject[]; + if (isObject(field) && carries(field)) { + return field; } } } - return []; + return undefined; +} + +function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { + const field = findField(pattern, key, (f) => Array.isArray(f['prefixItems'])); + return (field?.['prefixItems'] as SchemaObject[]) ?? []; +} + +/** + * A catalogue declares its members under `items`. Returned as one more choice + * block so expandAlternatives flattens it like any other. + */ +function getCatalogue(pattern: SchemaObject, key: string): SchemaObject[] { + const field = findField(pattern, key, (f) => alternativesOf(f['items']) !== undefined); + return field ? [field['items'] as SchemaObject] : []; } /** @@ -71,11 +96,7 @@ function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { function expandAlternatives(prefixItems: SchemaObject[]): SchemaObject[] { const expanded: SchemaObject[] = []; for (const item of prefixItems) { - const alternatives = Array.isArray(item['oneOf']) - ? item['oneOf'] - : Array.isArray(item['anyOf']) - ? item['anyOf'] - : null; + const alternatives = alternativesOf(item); if (alternatives) { for (const alt of alternatives) { if (isObject(alt)) expanded.push(alt); @@ -121,11 +142,15 @@ interface PatternPartition { * node/relationship but pins nothing comparable). Unconstrained decision/options * constructs that don't declare a `unique-id` are skipped entirely. */ -function partitionPrefixItems(prefixItems: SchemaObject[]): PatternPartition { +function declarationsFor(pattern: SchemaObject, key: string): SchemaObject[] { + return [...getPrefixItems(pattern, key), ...getCatalogue(pattern, key)]; +} + +function partitionDeclarations(declarations: SchemaObject[]): PatternPartition { const pinned: Record[] = []; const content: Record[] = []; const undiffable: unknown[] = []; - for (const item of expandAlternatives(prefixItems)) { + for (const item of expandAlternatives(declarations)) { const collapsed = collapseSchema(item); if (hasUniqueId(collapsed)) { pinned.push(collapsed); @@ -146,8 +171,8 @@ function partitionPattern(pattern: unknown): { nodes: PatternPartition; relation }; } return { - nodes: partitionPrefixItems(getPrefixItems(pattern, 'nodes')), - relationships: partitionPrefixItems(getPrefixItems(pattern, 'relationships')), + nodes: partitionDeclarations(declarationsFor(pattern, 'nodes')), + relationships: partitionDeclarations(declarationsFor(pattern, 'relationships')), }; } diff --git a/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx b/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx index 58f3f4bc9..39aac5444 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 000000000..170aa183e --- /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 000000000..6ff81f36d --- /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]]'; +}