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 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')), }; } 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]]'; +} 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