From 01977522f35292792dfa9f5781ba885a47e7c497 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 14 Sep 2026 23:04:38 +0000 Subject: [PATCH 1/2] 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/2] feat(vscode): instantiate items catalogues in the pattern picker --- .../src/webview/panels/PatternPicker.tsx | 58 +-------------- .../panels/pattern-instantiation.test.ts | 74 +++++++++++++++++++ .../webview/panels/pattern-instantiation.ts | 66 +++++++++++++++++ 3 files changed, 141 insertions(+), 57 deletions(-) create mode 100644 calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts create mode 100644 calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts diff --git a/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx b/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx index 58f3f4bc94..39aac5444b 100644 --- a/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx +++ b/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { instantiateFromPattern } from './pattern-instantiation'; interface PatternEntry { id: string; name: string; description: string; category: string; schema: unknown } @@ -61,63 +62,6 @@ export function PatternPicker({ visible, mode, patterns, onApply, onClose }: Pat ); } -function instantiateFromPattern(schema: unknown): any { - const p = schema as any; - const nodeSchemas = p?.properties?.nodes?.prefixItems ?? []; - const relSchemas = p?.properties?.relationships?.prefixItems ?? []; - - const nodes = nodeSchemas.map((s: any) => instantiateNode(s)).filter(Boolean).map((n: any) => { - if (!n['unique-id']) n['unique-id'] = '[[PLACEHOLDER]]'; - if (!n['node-type']) n['node-type'] = 'system'; - if (!n['name']) n['name'] = '[[PLACEHOLDER]]'; - return n; - }); - - const relationships = relSchemas.map((s: any) => instantiateRel(s)).filter(Boolean).map((r: any) => { - if (!r['unique-id']) r['unique-id'] = '[[PLACEHOLDER]]'; - if (!r['relationship-type']) r['relationship-type'] = {}; - return r; - }); - - return { nodes, relationships }; -} - -function instantiateNode(schema: any): any { - if (schema.oneOf?.length) return instantiateNode(schema.oneOf[0]); - if (schema.anyOf?.length) return instantiateNode(schema.anyOf[0]); - if (!schema.properties) return null; - return instantiateObject(schema.properties); -} - -function instantiateRel(schema: any): any { - if (schema.oneOf?.length) return instantiateRel(schema.oneOf[0]); - if (schema.anyOf?.length) return instantiateRel(schema.anyOf[0]); - if (!schema.properties) return null; - return instantiateObject(schema.properties); -} - -function instantiateObject(properties: Record): Record { - const result: Record = {}; - for (const [key, schema] of Object.entries(properties)) { - if (key.startsWith('$') || key === 'type') continue; - result[key] = extractValue(schema); - } - return result; -} - -function extractValue(schema: any): unknown { - if (schema.const !== undefined) return schema.const; - if (schema.default !== undefined) return schema.default; - if (schema.properties) return instantiateObject(schema.properties); - if (schema.prefixItems) return schema.prefixItems.map(extractValue); - if (schema.type === 'string') return '[[PLACEHOLDER]]'; - if (schema.type === 'integer' || schema.type === 'number') return -1; - if (schema.type === 'boolean') return false; - if (schema.type === 'array') return []; - if (schema.type === 'object') return {}; - return '[[PLACEHOLDER]]'; -} - const overlayStyle: React.CSSProperties = { position: 'fixed', inset: 0, background: 'var(--calm-overlay)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }; const modalStyle: React.CSSProperties = { width: '600px', maxWidth: '90vw', maxHeight: '80vh', background: 'var(--calm-bg)', borderRadius: '12px', border: '1px solid var(--calm-border-heavy)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }; const headerStyle: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid var(--calm-border)' }; diff --git a/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts new file mode 100644 index 0000000000..170aa183ef --- /dev/null +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { instantiateFromPattern } from './pattern-instantiation'; + +const node = (id: string) => ({ + type: 'object', + properties: { 'unique-id': { const: id }, 'node-type': { const: 'service' }, name: { const: id } }, +}); + +const relationship = (id: string) => ({ + type: 'object', + properties: { 'unique-id': { const: id } }, +}); + +const ids = (elements: any[]) => elements.map((element) => element['unique-id']); + +describe('instantiateFromPattern', () => { + it('instantiates a node declared in a prefixItems entry', () => { + const arch = instantiateFromPattern({ properties: { nodes: { prefixItems: [node('gateway')] } } }); + + expect(arch.nodes).toEqual([{ 'unique-id': 'gateway', 'node-type': 'service', name: 'gateway' }]); + }); + + it('takes the first alternative of a prefixItems entry', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [{ oneOf: [node('postgres'), node('mysql')] }] } }, + }); + + expect(ids(arch.nodes)).toEqual(['postgres']); + }); + + it('instantiates a node from an items catalogue, after the positional entries', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: { oneOf: [node('cache'), node('queue')] } } }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway', 'cache']); + }); + + it('instantiates a node from an anyOf catalogue', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { items: { anyOf: [node('cache')] } } }, + }); + + expect(ids(arch.nodes)).toEqual(['cache']); + }); + + it('instantiates a relationship from an items catalogue', () => { + const arch = instantiateFromPattern({ + properties: { relationships: { items: { oneOf: [relationship('gateway-connects-cache')] } } }, + }); + + expect(ids(arch.relationships)).toEqual(['gateway-connects-cache']); + }); + + it('ignores an items schema that declares a node directly', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: node('cache') } }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway']); + }); + + it('ignores an items schema that only references another schema', () => { + const arch = instantiateFromPattern({ + properties: { nodes: { prefixItems: [node('gateway')], items: { $ref: 'core.json#/defs/node' } } }, + }); + + expect(ids(arch.nodes)).toEqual(['gateway']); + }); + + it('returns empty arrays for a pattern that declares nothing', () => { + expect(instantiateFromPattern({})).toEqual({ nodes: [], relationships: [] }); + }); +}); diff --git a/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts new file mode 100644 index 0000000000..6ff81f36da --- /dev/null +++ b/calm-plugins/vscode/src/webview/panels/pattern-instantiation.ts @@ -0,0 +1,66 @@ +export function instantiateFromPattern(schema: unknown): any { + const p = schema as any; + const nodeSchemas = declarations(p?.properties?.nodes); + const relSchemas = declarations(p?.properties?.relationships); + + const nodes = nodeSchemas.map((s: any) => instantiateNode(s)).filter(Boolean).map((n: any) => { + if (!n['unique-id']) n['unique-id'] = '[[PLACEHOLDER]]'; + if (!n['node-type']) n['node-type'] = 'system'; + if (!n['name']) n['name'] = '[[PLACEHOLDER]]'; + return n; + }); + + const relationships = relSchemas.map((s: any) => instantiateRel(s)).filter(Boolean).map((r: any) => { + if (!r['unique-id']) r['unique-id'] = '[[PLACEHOLDER]]'; + if (!r['relationship-type']) r['relationship-type'] = {}; + return r; + }); + + return { nodes, relationships }; +} + +/** + * A catalogue only counts when it offers alternatives. `items` applies one schema to every + * position after the entries, so a member declared directly there is not a choice. + */ +function declarations(field: any): any[] { + const catalogue = field?.items; + const offersChoice = catalogue?.oneOf?.length || catalogue?.anyOf?.length; + return [...(field?.prefixItems ?? []), ...(offersChoice ? [catalogue] : [])]; +} + +function instantiateNode(schema: any): any { + if (schema.oneOf?.length) return instantiateNode(schema.oneOf[0]); + if (schema.anyOf?.length) return instantiateNode(schema.anyOf[0]); + if (!schema.properties) return null; + return instantiateObject(schema.properties); +} + +function instantiateRel(schema: any): any { + if (schema.oneOf?.length) return instantiateRel(schema.oneOf[0]); + if (schema.anyOf?.length) return instantiateRel(schema.anyOf[0]); + if (!schema.properties) return null; + return instantiateObject(schema.properties); +} + +function instantiateObject(properties: Record): Record { + const result: Record = {}; + for (const [key, schema] of Object.entries(properties)) { + if (key.startsWith('$') || key === 'type') continue; + result[key] = extractValue(schema); + } + return result; +} + +function extractValue(schema: any): unknown { + if (schema.const !== undefined) return schema.const; + if (schema.default !== undefined) return schema.default; + if (schema.properties) return instantiateObject(schema.properties); + if (schema.prefixItems) return schema.prefixItems.map(extractValue); + if (schema.type === 'string') return '[[PLACEHOLDER]]'; + if (schema.type === 'integer' || schema.type === 'number') return -1; + if (schema.type === 'boolean') return false; + if (schema.type === 'array') return []; + if (schema.type === 'object') return {}; + return '[[PLACEHOLDER]]'; +}