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 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')), }; } From 600cc5adef29f3026996d618d110793c802a363d Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Fri, 18 Sep 2026 11:10:51 +0000 Subject: [PATCH 2/2] feat(calm-models): fix comment location --- calm-models/src/diff/pattern-diff.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/calm-models/src/diff/pattern-diff.ts b/calm-models/src/diff/pattern-diff.ts index e74bc0bb0..a11bb2c9d 100644 --- a/calm-models/src/diff/pattern-diff.ts +++ b/calm-models/src/diff/pattern-diff.ts @@ -135,6 +135,11 @@ interface PatternPartition { undiffable: unknown[]; } + +function declarationsFor(pattern: SchemaObject, key: string): SchemaObject[] { + return [...getPrefixItems(pattern, key), ...getCatalogue(pattern, key)]; +} + /** * Collapses each (already alternative-expanded) prefix item and sorts it into: * `pinned` (has a `const` `unique-id` → diff by id), `content` (no pinned id but @@ -142,10 +147,6 @@ interface PatternPartition { * node/relationship but pins nothing comparable). Unconstrained decision/options * constructs that don't declare a `unique-id` are skipped entirely. */ -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[] = [];