From 91f155a42198863f17aaafbfd4f0a9244f5ad63b Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Fri, 11 Sep 2026 17:07:39 +0000 Subject: [PATCH 1/3] feat(shared): instantiate items in calm generate An answer that selects an items member now reaches the architecture. Narrowing promotes the selected members into prefixItems and deletes items, so the instantiator only ever reads positions and needs no knowledge of items. Fixes three faults reachable without items. A pattern whose array has no prefixItems produced {} where an array belongs. Passing an empty choice list threw on flatMap. A pattern with no relationships property threw on prefixItems. flattenCalmItems is renamed selectDeclarations. Its "items" meant CALM elements, which reads as JSON Schema items now that both are in play. --- PATTERN-DECISIONS.md | 14 +++--- .../generate/components/instantiate.spec.ts | 18 +++++++ .../generate/components/instantiate.ts | 13 ++--- .../generate/components/options.spec.ts | 49 ++++++++++++++++++- .../commands/generate/components/options.ts | 35 ++++++++++--- 5 files changed, 108 insertions(+), 21 deletions(-) diff --git a/PATTERN-DECISIONS.md b/PATTERN-DECISIONS.md index a77821d146..20fcbe9c1f 100644 --- a/PATTERN-DECISIONS.md +++ b/PATTERN-DECISIONS.md @@ -3,9 +3,6 @@ A CALM pattern can offer a choice. This document records what each tool guarantees about that choice. It describes behaviour only. It does not describe how a tool is built. -Each section names the tests that hold its guarantees. A guarantee below with no test is a -gap. - ## Terms | Term | Meaning | @@ -123,6 +120,11 @@ branches that declare the same property discard one of the two declarations. ## What generation guarantees -`calm generate` does not read `items`. It builds one node per `prefixItems` entry and -nothing else, so a node declared in `items` never reaches the architecture. `calm validate` -accepts the pattern, and the missing node is silent. +`calm generate` builds one element per `prefixItems` entry, in order. An answer that selects +an `items` member appends that member to the same list, so a selected member reaches the +architecture and an unselected one does not. + +An `items` block that declares no `oneOf` or `anyOf` is left alone. Nothing selects from it, +so nothing is promoted out of it. + +An array that ends with no entries becomes `[]`. diff --git a/shared/src/commands/generate/components/instantiate.spec.ts b/shared/src/commands/generate/components/instantiate.spec.ts index dff2621e97..885988a0b8 100644 --- a/shared/src/commands/generate/components/instantiate.spec.ts +++ b/shared/src/commands/generate/components/instantiate.spec.ts @@ -294,4 +294,22 @@ describe('instantiate', () => { 'nested-placeholder': '[[ NESTED_PLACEHOLDER ]]' }); }); + it('emits an empty array for an array with no prefixItems', async () => { + const patternWithUnselectedItems = { + $schema: 'schema#', + $id: 'test-pattern-items-only', + properties: { + nodes: { type: 'array', items: { oneOf: [{ properties: { 'unique-id': { const: 'cache' } } }] } }, + relationships: { type: 'array', prefixItems: [] } + } + }; + + (fs.readFileSync as Mock).mockImplementation(function () { return JSON.stringify(patternWithUnselectedItems); }); + + const pattern = JSON.parse(fs.readFileSync(patternPath, { encoding: 'utf-8' })); + const result = await instantiate(pattern, true, new SchemaDirectory(null as unknown as DocumentLoader)) as TestInstantiatedPattern; + + expect(result.nodes).toEqual([]); + expect(result.relationships).toEqual([]); + }); }); diff --git a/shared/src/commands/generate/components/instantiate.ts b/shared/src/commands/generate/components/instantiate.ts index 2d6170c659..ceeda19a87 100644 --- a/shared/src/commands/generate/components/instantiate.ts +++ b/shared/src/commands/generate/components/instantiate.ts @@ -99,9 +99,11 @@ async function instantiateFromProperties( for (const [key, def] of Object.entries(properties)) { const resolvedDef = await resolveSchema(def as JsonSchema, schemaDir); - if (resolvedDef.type === 'array' && resolvedDef.prefixItems) { + if (resolvedDef.const !== undefined) { + output[key] = resolvedDef.const; + } else if (resolvedDef.type === 'array') { output[key] = await Promise.all( - resolvedDef.prefixItems.map(async (itemDef, idx) => { + (resolvedDef.prefixItems ?? []).map(async (itemDef, idx) => { const resolvedItem = await resolveSchema(itemDef, schemaDir); if (resolvedItem.const !== undefined) { return resolvedItem.const; @@ -110,12 +112,7 @@ async function instantiateFromProperties( }) ); } else { - // Check for const values at the top level - if (resolvedDef.const !== undefined) { - output[key] = resolvedDef.const; - } else { - output[key] = await instantiateObject(resolvedDef, schemaDir, [key]); - } + output[key] = await instantiateObject(resolvedDef, schemaDir, [key]); } } diff --git a/shared/src/commands/generate/components/options.spec.ts b/shared/src/commands/generate/components/options.spec.ts index 3c6f6a3528..e141d03472 100644 --- a/shared/src/commands/generate/components/options.spec.ts +++ b/shared/src/commands/generate/components/options.spec.ts @@ -394,4 +394,51 @@ describe('Pattern Options', () => { expect(extractOptions(allOfPattern)).toEqual(expectedOptions); }); }); -}); \ No newline at end of file + describe('items', () => { + const node = (id: string) => ({ type: 'object', properties: { 'unique-id': { const: id } } }); + const add = (id: string): CalmChoice => ({ description: `Add ${id}`, nodes: [id], relationships: [] }); + const pattern = (nodes: object) => ({ properties: { nodes, relationships: { type: 'array', prefixItems: [] } } }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nodesOf = (result: object) => (result as any).properties.nodes; + + const withItems = () => pattern({ + type: 'array', + prefixItems: [node('webapp')], + items: { oneOf: [node('cache'), node('queue')] } + }); + + it('should promote a chosen items member into prefixItems', () => { + const result = nodesOf(selectChoices(withItems(), [add('cache')])); + expect(result.prefixItems).toEqual([node('webapp'), node('cache')]); + }); + + it('should remove items once its members have been promoted', () => { + expect(nodesOf(selectChoices(withItems(), [add('cache')]))).not.toHaveProperty('items'); + }); + + it('should promote every chosen items member', () => { + const result = nodesOf(selectChoices(withItems(), [add('cache'), add('queue')])); + expect(result.prefixItems).toEqual([node('webapp'), node('cache'), node('queue')]); + }); + + it('should promote nothing when no items member is chosen', () => { + expect(nodesOf(selectChoices(withItems(), [])).prefixItems).toEqual([node('webapp')]); + }); + + it('should leave a plain items schema untouched', () => { + const plain = pattern({ type: 'array', prefixItems: [node('webapp')], items: { $ref: 'core.json#/defs/node' } }); + expect(nodesOf(selectChoices(plain, [add('webapp')])).items).toEqual({ $ref: 'core.json#/defs/node' }); + }); + + it('should build prefixItems for a pattern that declares only items', () => { + const itemsOnly = pattern({ type: 'array', items: { oneOf: [node('cache')] } }); + expect(nodesOf(selectChoices(itemsOnly, [add('cache')])).prefixItems).toEqual([node('cache')]); + }); + + it('should accept a pattern with no relationships property', () => { + const noRelationships = { properties: { nodes: { type: 'array', prefixItems: [node('webapp')] } } }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((selectChoices(noRelationships, [add('webapp')]) as any).properties.nodes.prefixItems).toEqual([node('webapp')]); + }); + }); +}); diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index d740f29896..bd2197770a 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -119,12 +119,30 @@ function flattenOneOfAndAnyOf(item: Item, selectionPredicate: (item: SchemaNode) .filter((x: SchemaNode) => selectionPredicate(x)); } -function flattenCalmItems(pattern: SchemaNode, calmType: 'nodes' | 'relationships', ids: string[]): void { - const calmItems = pattern['properties'][calmType]['prefixItems']; +function isChoice(items: Item | undefined): items is Item { + return Boolean(items?.oneOf || items?.anyOf); +} + +/** + * Selected `items` members are promoted into `prefixItems`, so the instantiator only ever + * reads positions and needs no knowledge of `items`. + */ +function selectDeclarations(pattern: SchemaNode, calmType: 'nodes' | 'relationships', ids: string[]): void { + const declarations = pattern['properties'][calmType]; + if (!declarations) { + return; + } const selectionPredicate = (x: SchemaNode) => ids.includes(x['properties']['unique-id']['const']); - pattern['properties'][calmType]['prefixItems'] = calmItems + const chosen = (declarations['prefixItems'] ?? []) .flatMap((item: Item) => flattenOneOfAndAnyOf(item, selectionPredicate)); + + if (isChoice(declarations['items'])) { + chosen.push(...flattenOneOfAndAnyOf(declarations['items'], selectionPredicate)); + delete declarations['items']; + } + + declarations['prefixItems'] = chosen; } function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoice[]): SchemaNode { @@ -141,7 +159,12 @@ function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoic } function flattenOptionsRelationships(pattern: SchemaNode, choices: CalmChoice[]): void { - pattern['properties']['relationships']['prefixItems'] = pattern['properties']['relationships']['prefixItems'] + const relationships = pattern['properties']['relationships']; + if (!relationships?.['prefixItems']) { + return; + } + + relationships['prefixItems'] = relationships['prefixItems'] .map((rel: SchemaNode) => flattenOptionsRelationship(rel, choices)); } @@ -160,8 +183,8 @@ export function selectChoices(inputPattern: object, choices: CalmChoice[], debug const nodeIds: string[] = choices.flatMap(choice => choice.nodes); const relationshipIds: string[] = choices.flatMap(choice => choice.relationships); - flattenCalmItems(pattern, 'nodes', nodeIds); - flattenCalmItems(pattern, 'relationships', relationshipIds); + selectDeclarations(pattern, 'nodes', nodeIds); + selectDeclarations(pattern, 'relationships', relationshipIds); flattenOptionsRelationships(pattern, choices); From ae17640a27bdebdeb8dbbd61402b5f0d3125312f Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 15 Sep 2026 21:58:03 +0000 Subject: [PATCH 2/3] refactor(shared): share the decision predicate with validation generate and the decision rule asked the same question of the same shape, one with optional chaining and one with a JSONPath query. --- shared/src/commands/generate/components/options.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index bd2197770a..8b2ca10aa1 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -1,4 +1,5 @@ import { initLogger } from '../../../logger'; +import { declaresOptions } from '../../../spectral/functions/pattern/declaration-paths'; /** * A node within a CALM pattern's JSON schema. The pattern is unvalidated JSON @@ -21,10 +22,6 @@ export interface CalmOption { choices: CalmChoice[], } -function isOptionsRelationship(relationship: SchemaNode): boolean { - return relationship['properties']?.['relationship-type']?.['properties']?.['options'] !== undefined; -} - function getItemsInOptionsRelationship(optionsRelationship: SchemaNode): SchemaNode[] { return optionsRelationship['properties']['relationship-type']['properties']['options']['prefixItems']; } @@ -84,7 +81,7 @@ export function extractOptions(pattern: object, debug: boolean = false): CalmOpt } const options: CalmOption[] = calmItems - .filter((rel: SchemaNode) => isOptionsRelationship(rel)) + .filter((rel: SchemaNode) => declaresOptions(rel)) .flatMap((optionsRel: SchemaNode) => [ ...extractOptionsFromBlock(optionsRel, 'oneOf'), ...extractOptionsFromBlock(optionsRel, 'anyOf') @@ -146,7 +143,7 @@ function selectDeclarations(pattern: SchemaNode, calmType: 'nodes' | 'relationsh } function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoice[]): SchemaNode { - if (!isOptionsRelationship(relationship)) { + if (!declaresOptions(relationship)) { return relationship; } From 30561ce18bc247e5e5f0f8c603408fc847b83d57 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Thu, 17 Sep 2026 16:08:48 +0000 Subject: [PATCH 3/3] fix(shared): keep the items catalogue when narrowing a pattern selectChoices is shared by generation and by validation, which has called it since before items support existed. Promoting a chosen items member into prefixItems also deleted the items block, so the pattern that validation compiles lost the only thing constraining what an architecture may add beyond its fixed positions. calm validate accepted architectures it should reject. Generation never read items, so deleting it bought nothing. The block now stays. A promoted member sits at a fixed position and items governs the positions after it, so the two do not collide. instantiateFromProperties checked const before the array branch, which would return a stale const for a resolved schema carrying both and discard the prefixItems content. The original precedence is restored, keeping the empty-array fallback beside it. alternativesOf replaces the duplicated oneOf/anyOf test and reads the keyword list from declaration-paths, so the two cannot drift. Raised in review on #3103. --- PATTERN-DECISIONS.md | 9 ++++ .../generate/components/instantiate.ts | 10 +++-- .../generate/components/options.spec.ts | 4 +- .../commands/generate/components/options.ts | 42 +++++++------------ .../commands/validate/validate.e2e.spec.ts | 41 ++++++++++++++++++ .../functions/pattern/declaration-paths.ts | 2 +- 6 files changed, 73 insertions(+), 35 deletions(-) diff --git a/PATTERN-DECISIONS.md b/PATTERN-DECISIONS.md index 4e1f91108b..c44042482f 100644 --- a/PATTERN-DECISIONS.md +++ b/PATTERN-DECISIONS.md @@ -129,6 +129,15 @@ branches that declare the same property discard one of the two declarations. an `items` member appends that member to the same list, so a selected member reaches the architecture and an unselected one does not. +An answer comes from a decision. A decision names an `items` member in its `nodes` or +`relationships` list, and `calm generate` then offers it. A catalogue that no decision names +is never offered, so `calm generate` builds none of its members. That is the correct result +for a catalogue written to constrain what an architecture may add. Declare a decision beside +the catalogue to offer its members instead. + +`calm validate` keeps reading the `items` block whichever way it is written. Selecting an +answer does not relax what a pattern permits. + An `items` block that declares no `oneOf` or `anyOf` is left alone. Nothing selects from it, so nothing is promoted out of it. diff --git a/shared/src/commands/generate/components/instantiate.ts b/shared/src/commands/generate/components/instantiate.ts index ceeda19a87..45960d8d31 100644 --- a/shared/src/commands/generate/components/instantiate.ts +++ b/shared/src/commands/generate/components/instantiate.ts @@ -99,11 +99,9 @@ async function instantiateFromProperties( for (const [key, def] of Object.entries(properties)) { const resolvedDef = await resolveSchema(def as JsonSchema, schemaDir); - if (resolvedDef.const !== undefined) { - output[key] = resolvedDef.const; - } else if (resolvedDef.type === 'array') { + if (resolvedDef.type === 'array' && resolvedDef.prefixItems) { output[key] = await Promise.all( - (resolvedDef.prefixItems ?? []).map(async (itemDef, idx) => { + resolvedDef.prefixItems.map(async (itemDef, idx) => { const resolvedItem = await resolveSchema(itemDef, schemaDir); if (resolvedItem.const !== undefined) { return resolvedItem.const; @@ -111,6 +109,10 @@ async function instantiateFromProperties( return await instantiateObject(resolvedItem, schemaDir, [key, `${idx}`]); }) ); + } else if (resolvedDef.const !== undefined) { + output[key] = resolvedDef.const; + } else if (resolvedDef.type === 'array') { + output[key] = []; } else { output[key] = await instantiateObject(resolvedDef, schemaDir, [key]); } diff --git a/shared/src/commands/generate/components/options.spec.ts b/shared/src/commands/generate/components/options.spec.ts index e141d03472..228677278e 100644 --- a/shared/src/commands/generate/components/options.spec.ts +++ b/shared/src/commands/generate/components/options.spec.ts @@ -412,8 +412,8 @@ describe('Pattern Options', () => { expect(result.prefixItems).toEqual([node('webapp'), node('cache')]); }); - it('should remove items once its members have been promoted', () => { - expect(nodesOf(selectChoices(withItems(), [add('cache')]))).not.toHaveProperty('items'); + it('should keep items once its members have been promoted, because validation still needs it', () => { + expect(nodesOf(selectChoices(withItems(), [add('cache')])).items).toEqual({ oneOf: [node('cache'), node('queue')] }); }); it('should promote every chosen items member', () => { diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index 8b2ca10aa1..da7fd5ec7e 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -1,5 +1,5 @@ import { initLogger } from '../../../logger'; -import { declaresOptions } from '../../../spectral/functions/pattern/declaration-paths'; +import { ALTERNATIVE_KEYWORDS, declaresOptions } from '../../../spectral/functions/pattern/declaration-paths'; /** * A node within a CALM pattern's JSON schema. The pattern is unvalidated JSON @@ -96,33 +96,24 @@ type Item = { anyOf?: object[], } -/** - * This function flattens oneOf and anyOf blocks into their constituent items if they match the selection predicate. - * If the passed item is not a oneOf or anyOf block, it returns the item as is in a list. - * @param item - The item to flatten - * @param selectionPredicate - A function that takes an item and returns true if it should be included in the flattened result - * @returns A list of items that match the selection predicate, or the item itself if it is not a oneOf or anyOf block - */ +function alternativesOf(item: Item | undefined): object[] | undefined { + const keyword = ALTERNATIVE_KEYWORDS.find(name => item?.[name]); + return keyword ? item?.[keyword] : undefined; +} + function flattenOneOfAndAnyOf(item: Item, selectionPredicate: (item: SchemaNode) => boolean): object[] { - if (!(item.oneOf || item.anyOf)) { - // If it isn't a oneOf or anyOf block, there isn't anything to flatten so return the item + const alternatives = alternativesOf(item); + if (!alternatives) { return [item]; } - const items: object[] = item.oneOf ?? item.anyOf ?? []; - - return items - .flatMap((x: object) => x) - .filter((x: SchemaNode) => selectionPredicate(x)); -} - -function isChoice(items: Item | undefined): items is Item { - return Boolean(items?.oneOf || items?.anyOf); + return alternatives.filter((x: SchemaNode) => selectionPredicate(x)); } /** * Selected `items` members are promoted into `prefixItems`, so the instantiator only ever - * reads positions and needs no knowledge of `items`. + * reads positions. The `items` block itself stays, because validation compiles this same + * pattern and needs it to keep constraining whatever the architecture adds. */ function selectDeclarations(pattern: SchemaNode, calmType: 'nodes' | 'relationships', ids: string[]): void { const declarations = pattern['properties'][calmType]; @@ -131,15 +122,10 @@ function selectDeclarations(pattern: SchemaNode, calmType: 'nodes' | 'relationsh } const selectionPredicate = (x: SchemaNode) => ids.includes(x['properties']['unique-id']['const']); - const chosen = (declarations['prefixItems'] ?? []) - .flatMap((item: Item) => flattenOneOfAndAnyOf(item, selectionPredicate)); + const catalogue: Item[] = alternativesOf(declarations['items']) ? [declarations['items']] : []; - if (isChoice(declarations['items'])) { - chosen.push(...flattenOneOfAndAnyOf(declarations['items'], selectionPredicate)); - delete declarations['items']; - } - - declarations['prefixItems'] = chosen; + declarations['prefixItems'] = [...(declarations['prefixItems'] ?? []), ...catalogue] + .flatMap((item: Item) => flattenOneOfAndAnyOf(item, selectionPredicate)); } function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoice[]): SchemaNode { diff --git a/shared/src/commands/validate/validate.e2e.spec.ts b/shared/src/commands/validate/validate.e2e.spec.ts index 5e5c4585a2..80f0f2c9fc 100644 --- a/shared/src/commands/validate/validate.e2e.spec.ts +++ b/shared/src/commands/validate/validate.e2e.spec.ts @@ -164,6 +164,47 @@ describe('validate E2E', () => { const newPattern = applyArchitectureOptionsToPattern(architecture, pattern, false); expect(newPattern).toStrictEqual(expectedResult); }); + + it('keeps the items catalogue, so validation still constrains what an architecture adds', () => { + const node = (id: string) => ({ properties: { 'unique-id': { const: id } } }); + const pattern = { + properties: { + nodes: { prefixItems: [node('gateway')], items: { oneOf: [node('cache'), node('queue')] } }, + relationships: { + prefixItems: [{ + properties: { + 'unique-id': { const: 'db-choice' }, + 'relationship-type': { + properties: { + options: { + prefixItems: [{ + oneOf: [{ + properties: { + description: { const: 'Use postgres' }, + nodes: { const: ['postgres'] }, + relationships: { const: [] } + } + }] + }] + } + } + } + } + }] + } + } + }; + const architecture = { + nodes: [], + relationships: [{ + 'unique-id': 'db-choice', + 'relationship-type': { options: [{ description: 'Use postgres', nodes: ['postgres'], relationships: [] }] } + }] + }; + + const resolved = applyArchitectureOptionsToPattern(architecture, pattern, false) as typeof pattern; + expect(resolved.properties.nodes.items).toBeDefined(); + }); }); describe('schema specific validations', () => { diff --git a/shared/src/spectral/functions/pattern/declaration-paths.ts b/shared/src/spectral/functions/pattern/declaration-paths.ts index 17a161d042..61392a0ba5 100644 --- a/shared/src/spectral/functions/pattern/declaration-paths.ts +++ b/shared/src/spectral/functions/pattern/declaration-paths.ts @@ -2,7 +2,7 @@ import { get } from 'lodash'; export type CalmType = 'nodes' | 'relationships'; -const ALTERNATIVE_KEYWORDS = ['oneOf', 'anyOf']; +export const ALTERNATIVE_KEYWORDS = ['oneOf', 'anyOf'] as const; const ID = 'properties.unique-id.const'; const OPTIONS = 'properties.relationship-type.properties.options'; const INTERFACES = 'properties.interfaces.prefixItems[*]';