From 01977522f35292792dfa9f5781ba885a47e7c497 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 14 Sep 2026 23:04:38 +0000 Subject: [PATCH 1/4] feat(calm-models): compare items catalogues in the pattern differ A catalogue member was invisible to calm diff. Adding or removing one reported no change at all, and hasChanges stayed false, so calm diff --exit-code passed. An unchanged member appeared in no bucket, so the Hub painted it with no diff status. The catalogue is a choice block, so it is read as one more declaration source and expandAlternatives flattens it into the existing partition. Members land in the buckets that already exist. No new buckets, no change to NodeChange or hasChanges. An items schema that declares a node directly is not read, matching validation and the pattern renderer. The allOf traversal was written for prefixItems only, so it becomes findField, which takes what the caller is after. Reporting a change in obligation is out of scope and filed as #3111. It reproduces on main with no items involved. --- .../src/diff/fixtures/diff-test-patterns.json | 755 +++++++++++++++--- calm-models/src/diff/pattern-diff.spec.ts | 53 ++ calm-models/src/diff/pattern-diff.ts | 59 +- 3 files changed, 727 insertions(+), 140 deletions(-) diff --git a/calm-models/src/diff/fixtures/diff-test-patterns.json b/calm-models/src/diff/fixtures/diff-test-patterns.json index 8a25df1f4b..7e59c2e410 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 8f6f509368..77b2d4a695 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 5207016067..e74bc0bb03 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')), }; } From 83089e73f6b0bcf6701165e3ebf5306dd2423897 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 15 Sep 2026 18:39:34 +0000 Subject: [PATCH 2/4] 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 5be24e6594603d20c0d59388791e0af41b88c18d Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 15 Sep 2026 22:19:06 +0000 Subject: [PATCH 3/4] docs(calm-ai): document items catalogues in the pattern authoring guide The guide described prefixItems in sixteen places and never mentioned items, so an agent following it could only write patterns where every declared element is built. Adds a worked catalogue example, a table choosing between the two sites by obligation, the four rules items carries, and the requirement that a decision stays in relationships prefixItems. --- calm-ai/tools/pattern-creation.md | 83 +++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/calm-ai/tools/pattern-creation.md b/calm-ai/tools/pattern-creation.md index b7bb5184ce..95ea3f65ba 100644 --- a/calm-ai/tools/pattern-creation.md +++ b/calm-ai/tools/pattern-creation.md @@ -168,6 +168,74 @@ Patterns use JSON schema constructs to provide choices and options: } ``` +### Optional Candidates with items + +`prefixItems` fixes positions, and every entry it declares is built. Declare candidates an architecture *may* add — none of them required — under `items`, inside `oneOf` or `anyOf`: + +```json +{ + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "unique-id": { "const": "api-gateway" }, + "node-type": { "const": "service" }, + "name": { "const": "API Gateway" }, + "description": { "type": "string" } + }, + "required": ["unique-id", "node-type", "name", "description"] + } + ], + "items": { + "oneOf": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "unique-id": { "const": "redis-cache" }, + "node-type": { "const": "database" }, + "name": { "const": "Redis Cache" }, + "description": { "type": "string" } + }, + "required": ["unique-id", "node-type", "name", "description"] + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "properties": { + "unique-id": { "const": "message-queue" }, + "node-type": { "const": "service" }, + "name": { "const": "Message Queue" }, + "description": { "type": "string" } + }, + "required": ["unique-id", "node-type", "name", "description"] + } + ] + }, + "minItems": 1, + "maxItems": 3 + } + } +} +``` + +Choose the site by obligation, not by how many candidates there are: + +| The architecture must | Declare the candidates in | +|---|---| +| contain exactly one of them | a `prefixItems` entry holding `oneOf`/`anyOf` | +| be free to add any number of them, or none | `items` holding `oneOf`/`anyOf` | + +Four rules apply to `items`: + +- A node or relationship declared there must sit inside `oneOf` or `anyOf`. `items` applies one schema to every position after the entries, so a member declared directly would force every added element to be that same one. +- `maxItems` counts the whole array and the `prefixItems` entries fill it from the front. Leave room for the members, or none can ever be built. +- Two members may both be built, so they must not share a `unique-id`, and two nodes declared there must not share an interface id. Two alternatives of one `prefixItems` entry may share both, because only one of them is ever built. +- `items` cannot limit how many times one member is used. Two positions may match the same member, and the architecture then holds one `unique-id` twice. `calm validate` reports that against the architecture, not against the pattern. + +Declare a decision itself as a plain entry in `relationships.prefixItems`, never under `items`. An architecture contains every relationship declared at a fixed position, so the decision is always asked. A decision declared under `items` can be left out, and an answer never gets to decline it. `calm validate` reports this. + ## Complete Pattern Example **Conference Signup Pattern (Based on Real Example):** @@ -619,7 +687,8 @@ Always use specific interface schema references: ### Array Handling - Use `prefixItems` to define specific array positions -- Use `minItems`/`maxItems` to constrain array sizes +- Use `items` with `oneOf`/`anyOf` for candidates an architecture may add, none of them required +- Use `minItems`/`maxItems` to constrain array sizes, leaving room for any `items` members - Each array item should reference base schema + add constraints ## Using Patterns with calm generate @@ -643,7 +712,8 @@ The CLI will prompt for choices when encountering `anyOf`/`oneOf` options, or yo - `const` - Fixed values that cannot be changed - `enum` - List of allowed values - `minItems`/`maxItems` - Array size constraints -- `prefixItems` - Define specific array items +- `prefixItems` - Define specific array positions, each one always built +- `items` - Constrain every position after the entries, each member optional ### Option Constructs @@ -665,14 +735,19 @@ The CLI will prompt for choices when encountering `anyOf`/`oneOf` options, or yo 3. Node definitions must use `$ref` to core node schema 4. Relationship definitions must use `$ref` to core relationship schema 5. Use `const` for fixed values, `anyOf`/`oneOf` for options -6. All constraint properties must be valid JSON schema constructs -7. Pattern should be testable with `calm validate -p ` +6. A node or relationship declared under `items` must sit inside `oneOf` or `anyOf` +7. `maxItems` must leave room for an `items` member beyond the `prefixItems` entries +8. Declare a decision in `relationships.prefixItems`, never under `items` +9. Declare `oneOf` or `anyOf` beside an element, never both +10. All constraint properties must be valid JSON schema constructs +11. Pattern should be testable with `calm validate -p ` ## Best Practices - Create patterns for commonly repeated architecture components - Use meaningful constraint names and descriptions - Provide clear choices in `anyOf`/`oneOf` constructs +- Put candidates in `prefixItems` when exactly one is required, and in `items` when any number may be added - Use `const` values for fixed architectural decisions - Reference external schemas for complex interface definitions - Test patterns thoroughly before publishing From bdb0f0f3f72841c436111bcfa7408958f992d124 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 15 Sep 2026 22:19:06 +0000 Subject: [PATCH 4/4] docs: describe items catalogues in the pattern documentation Both pages taught the positional model only. Neither mentioned that a pattern can list candidates an architecture may add. --- docs/docs/core-concepts/patterns.md | 6 ++++-- docs/docs/tutorials/intermediate/17-patterns.md | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/docs/core-concepts/patterns.md b/docs/docs/core-concepts/patterns.md index 3fd5768384..d4e423a255 100644 --- a/docs/docs/core-concepts/patterns.md +++ b/docs/docs/core-concepts/patterns.md @@ -19,8 +19,9 @@ Patterns describe architecture blueprints. Instead of listing a fixed set of com Because they’re expressed in JSON Schema, patterns use familiar constraints such as: * const to enforce fixed values that identify required elements, -* `prefixItems` and `minItems`/`maxItems` to require specific arrays of elements, and -* `oneOf` / `anyOf` to offer allowable alternatives (e.g., different database options). +* `prefixItems` and `minItems`/`maxItems` to require specific arrays of elements, +* `oneOf` / `anyOf` to offer allowable alternatives (e.g., different database options), and +* `items` to list candidates an architecture may add, none of them required. This schema-based definition makes patterns self-validating, versionable, and compatible with existing tooling. @@ -32,6 +33,7 @@ Patterns are primarily about **structural intent**—what must exist and how it * **User-authored fields (open but required)**: fields like `description` are typically left as `"type": "string"` (and may be required), so generated architectures include placeholders that users should replace. * **Required arrays of components**: `prefixItems` + `minItems`/`maxItems` are commonly used to require a specific set/count of nodes and relationships. * **Choices**: `anyOf`/`oneOf` can model “pick one of these components/topologies” patterns. +* **Optional candidates**: `items` holding `anyOf`/`oneOf` lists elements an architecture *may* add. A `prefixItems` entry is a single position and takes exactly one alternative, whereas `items` describes every position after the entries — so an architecture may build any number of its candidates, or none. Leave room in `maxItems` for them. ## Example pattern template diff --git a/docs/docs/tutorials/intermediate/17-patterns.md b/docs/docs/tutorials/intermediate/17-patterns.md index 8848dd1e2b..75b2cfff47 100644 --- a/docs/docs/tutorials/intermediate/17-patterns.md +++ b/docs/docs/tutorials/intermediate/17-patterns.md @@ -16,7 +16,7 @@ Learn how CALM Patterns enable you to define reusable architecture templates tha By the end of this tutorial, you will: - Understand the dual superpower of Patterns: generation and validation -- Know how Patterns use JSON Schema keywords (`const`, `prefixItems`, `minItems`/`maxItems`, `$ref`) +- Know how Patterns use JSON Schema keywords (`const`, `prefixItems`, `items`, `minItems`/`maxItems`, `$ref`) - Create a Pattern for a 3-tier web application - Generate a new architecture from your Pattern - Validate both a passing and a failing architecture against the Pattern @@ -60,6 +60,7 @@ Patterns use JSON Schema keywords to define requirements: |---------|---------|---------| | `const` | Requires an exact value | `"unique-id": { "const": "api-gateway" }` | | `prefixItems` | Defines exact ordered items in an array | First node must be X, second must be Y | +| `items` | Constrains every position after those items | A cache or a queue may be added, or neither | | `minItems` / `maxItems` | Enforces array length | Exactly 3 nodes | | `$ref` | References other schemas | Point to a node or Standards definition | @@ -198,6 +199,7 @@ Before moving on, use git to capture the state of your work. A descriptive commi |---------|----------|--------| | `const` | Exact value | Nothing else | | `prefixItems` | Specific ordered items | Additional items after them | +| `items` | Every later position matches one of its alternatives | Any number of them, or none | | `minItems` + `maxItems` (equal) | Exact array length | — | | `$ref` | Schema from another file | Properties defined there | @@ -209,7 +211,7 @@ Generated architectures use placeholders as signals: ### Pattern vs Architecture -A Pattern defines the **shape** any matching architecture must have. An architecture that satisfies the Pattern is free to add extra nodes, relationships, interfaces, and metadata — Patterns only constrain what they explicitly specify. +A Pattern defines the **shape** any matching architecture must have. An architecture that satisfies the Pattern is free to add extra nodes, relationships, interfaces, and metadata — Patterns only constrain what they explicitly specify. A Pattern that declares `items` does constrain those extras: each one must match a candidate it lists. ## Resources