From d1c463f2150aea359b9d396fcc73b1fbebc0399b Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sat, 1 Aug 2026 23:21:45 +0000 Subject: [PATCH 01/41] feat: support items.oneOf/anyOf open catalogs in CALM pattern decisions Extends pattern-based decision support beyond prefixItems positional slots (pick exactly one) to also cover items.oneOf/items.anyOf open catalogs (pick zero or more), across validation, visualisation, and generate/instantiate. - shared/spectral: ids-are-unique and pattern-nodes-must-be-referenced now also scan items.oneOf/anyOf candidates (nodes, relationships, and interfaces nested in catalog nodes). - calm-hub-ui: patternTransformer extracts items catalog candidates as decision groups, and folds an options relationship's referenced ids into a single decision group regardless of whether they come from a prefixItems slot or the items catalog, creating a new group when none of the referenced ids already belong to one. Dangling id references still render nothing. - shared/generate: selectChoices narrows a selected items catalog down into prefixItems so instantiate materializes chosen catalog nodes without needing separate items handling. --- .../utils/patternTransformer.test.ts | 126 +++++++++ .../reactflow/utils/patternTransformer.ts | 239 +++++++++++++++--- .../generate/components/flatten-allof.ts | 5 + .../generate/components/instantiate.spec.ts | 91 +++++++ .../generate/components/instantiate.ts | 5 + .../generate/components/options.spec.ts | 68 +++++ .../commands/generate/components/options.ts | 27 +- .../functions/pattern/ids-are-unique.spec.ts | 84 ++++++ .../functions/pattern/ids-are-unique.ts | 12 + shared/src/spectral/rules-pattern.ts | 6 +- 10 files changed, 619 insertions(+), 44 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts index 7e1aca850..855b26ffb 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts @@ -55,6 +55,61 @@ function makePattern( }; } +// Helper to build a pattern with both prefixItems (mandatory) and an +// items.oneOf/anyOf open catalog for nodes and/or relationships. +function makePatternWithItems( + prefixNodes: unknown[], + itemsCatalogNodes: unknown[] = [], + relationships: unknown[] = [], + itemsCatalogRelationships: unknown[] = [], + catalogType: 'oneOf' | 'anyOf' = 'oneOf' +) { + return { + properties: { + nodes: { + prefixItems: prefixNodes, + ...(itemsCatalogNodes.length > 0 && { items: { [catalogType]: itemsCatalogNodes } }), + }, + relationships: { + prefixItems: relationships, + ...(itemsCatalogRelationships.length > 0 && { items: { [catalogType]: itemsCatalogRelationships } }), + }, + }, + }; +} + +// Helper to build an options (decision) relationship schema item +function optionsRelationship( + uniqueId: string, + description: string, + choices: { description: string; nodes: string[]; relationships?: string[] }[], + optionType: 'oneOf' | 'anyOf' = 'oneOf' +) { + return { + properties: { + 'unique-id': { const: uniqueId }, + description: { const: description }, + 'relationship-type': { + properties: { + options: { + prefixItems: [ + { + [optionType]: choices.map((c) => ({ + properties: { + description: { const: c.description }, + nodes: { const: c.nodes }, + relationships: { const: c.relationships || [] }, + }, + })), + }, + ], + }, + }, + }, + }, + }; +} + describe('parsePatternData', () => { it('returns empty arrays for null data', () => { const result = parsePatternData(null as unknown as Record); @@ -471,6 +526,77 @@ describe('parsePatternData', () => { const result = parsePatternData(pattern); expect(result.edges).toHaveLength(0); }); + + it('creates a decision group for a decision referencing only items-declared catalog candidates', () => { + const pattern = makePatternWithItems( + [schemaNode('webapp', 'Web App', 'service')], + [schemaNode('cache', 'Cache', 'service'), schemaNode('queue', 'Queue', 'service')], + [ + optionsRelationship('options-rel', 'Choose extras', [ + { description: 'Use Cache', nodes: ['cache'] }, + { description: 'Use Queue', nodes: ['queue'] }, + ]), + ] + ); + const result = parsePatternData(pattern); + + const groupNodes = result.nodes.filter((n) => n.type === 'decisionGroup'); + expect(groupNodes).toHaveLength(1); + expect(groupNodes[0].data.prompt).toBe('Choose extras'); + expect(groupNodes[0].data.choices).toHaveLength(2); + + const cacheNode = result.nodes.find((n) => n.id === 'cache'); + const queueNode = result.nodes.find((n) => n.id === 'queue'); + expect(cacheNode?.parentId).toBe(groupNodes[0].id); + expect(queueNode?.parentId).toBe(groupNodes[0].id); + }); + + it('folds a decision referencing a mix of prefixItems- and items-declared candidates into one group', () => { + const pattern = makePatternWithItems( + [ + { + oneOf: [ + schemaNode('option-a', 'Option A', 'service'), + schemaNode('option-b', 'Option B', 'service'), + ], + }, + ], + [schemaNode('cache', 'Cache', 'service')], + [ + optionsRelationship('options-rel', 'Choose a setup', [ + { description: 'Use A with cache', nodes: ['option-a', 'cache'] }, + { description: 'Use B', nodes: ['option-b'] }, + ]), + ] + ); + const result = parsePatternData(pattern); + + const groupNodes = result.nodes.filter((n) => n.type === 'decisionGroup'); + expect(groupNodes).toHaveLength(1); + + const optionA = result.nodes.find((n) => n.id === 'option-a'); + const optionB = result.nodes.find((n) => n.id === 'option-b'); + const cache = result.nodes.find((n) => n.id === 'cache'); + expect(optionA?.parentId).toBe(groupNodes[0].id); + expect(optionB?.parentId).toBe(groupNodes[0].id); + expect(cache?.parentId).toBe(groupNodes[0].id); + }); + + it('renders nothing for a decision referencing only a dangling/typo\'d id', () => { + const pattern = makePattern( + [schemaNode('webapp', 'Web App', 'service')], + [ + optionsRelationship('options-rel', 'Choose extras', [ + { description: 'Use nonexistent', nodes: ['nonexistent-node'] }, + ]), + ] + ); + const result = parsePatternData(pattern); + + const groupNodes = result.nodes.filter((n) => n.type === 'decisionGroup'); + expect(groupNodes).toHaveLength(0); + expect(result.nodes.find((n) => n.id === 'nonexistent-node')).toBeUndefined(); + }); }); describe('nested container ordering', () => { diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index 08160fa02..47c6e9b95 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -42,6 +42,25 @@ function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { return []; } +/** + * Gets the `items` catalog schema (the `items.oneOf`/`items.anyOf` open-catalog + * declaration) for a given top-level key (e.g. 'nodes' or 'relationships') + * from a pattern, handling allOf structures. + */ +function getItems(pattern: SchemaObject, key: string): SchemaObject | undefined { + if (pattern['properties']?.[key]?.['items']) { + return pattern['properties'][key]['items']; + } + if (pattern['allOf'] && Array.isArray(pattern['allOf'])) { + for (const schema of pattern['allOf']) { + if (schema['properties']?.[key]?.['items']) { + return schema['properties'][key]['items']; + } + } + } + return undefined; +} + /** * Reads a value from a schema property, handling `const` wrappers. */ @@ -166,8 +185,36 @@ interface DecisionGroup { nodeIds: string[]; } +/** + * Extracts a set of oneOf/anyOf node alternatives into ExtractedNodes, stamping + * them all with the same decisionGroupId and recording a DecisionGroup for them. + */ +function extractNodeDecisionGroup( + alternatives: SchemaObject[], + groupId: string, + groupType: 'oneOf' | 'anyOf', + nodes: ExtractedNode[], + decisionGroups: DecisionGroup[], +): void { + const groupNodeIds: string[] = []; + + alternatives.forEach((alt: SchemaObject) => { + const node = extractNodeFromSchemaItem(alt); + if (node) { + node.decisionGroupId = groupId; + nodes.push(node); + groupNodeIds.push(node.uniqueId); + } + }); + + if (groupNodeIds.length > 0) { + decisionGroups.push({ groupId, groupType, nodeIds: groupNodeIds }); + } +} + function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[]; decisionGroups: DecisionGroup[] } { const prefixItems = getPrefixItems(pattern, 'nodes'); + const items = getItems(pattern, 'nodes'); const nodes: ExtractedNode[] = []; const decisionGroups: DecisionGroup[] = []; @@ -178,21 +225,7 @@ function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[ if (hasOneOf || hasAnyOf) { const groupType: 'oneOf' | 'anyOf' = hasOneOf ? 'oneOf' : 'anyOf'; const alternatives: SchemaObject[] = hasOneOf ? item['oneOf'] : item['anyOf']; - const groupId = `node-decision-${index}`; - const groupNodeIds: string[] = []; - - alternatives.forEach((alt: SchemaObject) => { - const node = extractNodeFromSchemaItem(alt); - if (node) { - node.decisionGroupId = groupId; - nodes.push(node); - groupNodeIds.push(node.uniqueId); - } - }); - - if (groupNodeIds.length > 0) { - decisionGroups.push({ groupId, groupType, nodeIds: groupNodeIds }); - } + extractNodeDecisionGroup(alternatives, `node-decision-${index}`, groupType, nodes, decisionGroups); } else { const node = extractNodeFromSchemaItem(item); if (node) { @@ -201,6 +234,20 @@ function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[ } }); + // Open catalog: `items.oneOf`/`items.anyOf` declares zero-or-more candidates + // that aren't tied to a specific positional slot. Treat the whole catalog as + // a single decision-group slot. + if (items) { + const hasOneOf = Array.isArray(items['oneOf']); + const hasAnyOf = Array.isArray(items['anyOf']); + + if (hasOneOf || hasAnyOf) { + const groupType: 'oneOf' | 'anyOf' = hasOneOf ? 'oneOf' : 'anyOf'; + const alternatives: SchemaObject[] = hasOneOf ? items['oneOf'] : items['anyOf']; + extractNodeDecisionGroup(alternatives, 'node-decision-items', groupType, nodes, decisionGroups); + } + } + return { nodes, decisionGroups }; } @@ -226,6 +273,7 @@ interface OptionsMetadata { prompt: string; optionType: 'oneOf' | 'anyOf'; choices: { description: string; nodes: string[]; relationships: string[] }[]; + relationshipId: string; } function extractRelTypeFromConst(relTypeConst: SchemaObject): Omit | null { @@ -286,6 +334,7 @@ function extractSingleRelationship(item: SchemaObject): ExtractedRelationship | function extractOptionsMetadata(item: SchemaObject): OptionsMetadata | null { const prompt = readSchemaValue(item, 'description') || 'Decision'; + const relationshipId = readSchemaValue(item, 'unique-id') || ''; const optionsPrefixItems: SchemaObject[] = item['properties']?.['relationship-type']?.['properties']?.['options']?.['prefixItems'] || []; @@ -312,18 +361,38 @@ function extractOptionsMetadata(item: SchemaObject): OptionsMetadata | null { .filter((c) => c.description); if (choices.length > 0) { - return { prompt, optionType, choices }; + return { prompt, optionType, choices, relationshipId }; } } } return null; } +/** + * Extracts a set of oneOf/anyOf relationship alternatives, stamping them all + * with the same decisionGroupId (relationship-side grouping only affects edge + * color/dash — there is no relationship decision-group box). + */ +function extractRelationshipDecisionGroup( + alternatives: SchemaObject[], + groupId: string, + relationships: ExtractedRelationship[], +): void { + alternatives.forEach((alt: SchemaObject) => { + const rel = extractSingleRelationship(alt); + if (rel) { + rel.decisionGroupId = groupId; + relationships.push(rel); + } + }); +} + function extractRelationshipsFromPattern(pattern: SchemaObject): { relationships: ExtractedRelationship[]; optionsMetadata: OptionsMetadata[]; } { const prefixItems = getPrefixItems(pattern, 'relationships'); + const items = getItems(pattern, 'relationships'); const relationships: ExtractedRelationship[] = []; const optionsMetadata: OptionsMetadata[] = []; @@ -343,15 +412,7 @@ function extractRelationshipsFromPattern(pattern: SchemaObject): { if (hasOneOf || hasAnyOf) { const alternatives: SchemaObject[] = hasOneOf ? item['oneOf'] : item['anyOf']; - const groupId = `rel-decision-${index}`; - - alternatives.forEach((alt: SchemaObject) => { - const rel = extractSingleRelationship(alt); - if (rel) { - rel.decisionGroupId = groupId; - relationships.push(rel); - } - }); + extractRelationshipDecisionGroup(alternatives, `rel-decision-${index}`, relationships); return; } @@ -362,6 +423,18 @@ function extractRelationshipsFromPattern(pattern: SchemaObject): { } }); + // Open catalog: `items.oneOf`/`items.anyOf` declares zero-or-more relationship + // candidates not tied to a specific positional slot. + if (items) { + const hasOneOf = Array.isArray(items['oneOf']); + const hasAnyOf = Array.isArray(items['anyOf']); + + if (hasOneOf || hasAnyOf) { + const alternatives: SchemaObject[] = hasOneOf ? items['oneOf'] : items['anyOf']; + extractRelationshipDecisionGroup(alternatives, 'rel-decision-items', relationships); + } + } + return { relationships, optionsMetadata }; } @@ -621,6 +694,102 @@ function applyPatternLayout(regularNodes: Node[], groupNodes: Node[], edges: Edg return { nodes: allNodes, edges }; } +// ---- Decision group / options-metadata folding ---- + +/** + * Folds each options-relationship decision's referenced node ids into a single + * decision group, mutating `decisionGroups`/`extractedNodes` in place: + * + * - If any referenced id already belongs to an existing decision group (built + * during node extraction, e.g. a prefixItems oneOf slot or the items + * catalog), ALL of the decision's referenced ids are folded into that one + * group (moving them out of any other group they were previously in). + * - If none of the referenced ids belong to an existing group, a brand new + * group is created (id derived from the options relationship's own + * unique-id) containing exactly the referenced ids. + * - Ids that don't resolve to a real extracted node (dangling/typo'd + * references) are dropped; if a decision ends up with no valid ids at all, + * it is skipped entirely so no empty box is rendered. + * + * Returns the map from decision-group id to the OptionsMetadata that targets it. + */ +function foldOptionsMetadataIntoDecisionGroups( + extractedNodes: ExtractedNode[], + decisionGroups: DecisionGroup[], + optionsMetadata: OptionsMetadata[], +): Map { + const nodeToGroupMap = new Map(); + decisionGroups.forEach((g) => g.nodeIds.forEach((nid) => nodeToGroupMap.set(nid, g.groupId))); + + const extractedNodeIds = new Set(extractedNodes.map((n) => n.uniqueId)); + const nodesById = new Map(extractedNodes.map((n) => [n.uniqueId, n])); + const groupsById = new Map(decisionGroups.map((g) => [g.groupId, g])); + + const groupOptionsMap = new Map(); + + optionsMetadata.forEach((meta) => { + const referencedIds = Array.from(new Set(meta.choices.flatMap((c) => c.nodes))).filter((id) => + extractedNodeIds.has(id) + ); + + // No referenced id resolves to a real node — render nothing for this decision. + if (referencedIds.length === 0) return; + + const matchedGroupIds: string[] = []; + referencedIds.forEach((id) => { + const groupId = nodeToGroupMap.get(id); + if (groupId && !matchedGroupIds.includes(groupId)) matchedGroupIds.push(groupId); + }); + + let targetGroup: DecisionGroup; + if (matchedGroupIds.length > 0) { + targetGroup = groupsById.get(matchedGroupIds[0])!; + } else { + targetGroup = { + groupId: `node-decision-options-${meta.relationshipId || referencedIds.join('-')}`, + groupType: meta.optionType, + nodeIds: [], + }; + decisionGroups.push(targetGroup); + groupsById.set(targetGroup.groupId, targetGroup); + } + + referencedIds.forEach((id) => { + const currentGroupId = nodeToGroupMap.get(id); + if (currentGroupId === targetGroup.groupId) return; + + if (currentGroupId) { + const oldGroup = groupsById.get(currentGroupId); + if (oldGroup) { + oldGroup.nodeIds = oldGroup.nodeIds.filter((nid) => nid !== id); + } + } + + targetGroup.nodeIds.push(id); + nodeToGroupMap.set(id, targetGroup.groupId); + + const node = nodesById.get(id); + if (node) node.decisionGroupId = targetGroup.groupId; + }); + + groupOptionsMap.set(targetGroup.groupId, meta); + }); + + // Drop any groups emptied out by the folding above so no empty box renders. + const emptiedGroupIds = new Set( + decisionGroups.filter((g) => g.nodeIds.length === 0).map((g) => g.groupId) + ); + if (emptiedGroupIds.size > 0) { + for (let i = decisionGroups.length - 1; i >= 0; i--) { + if (emptiedGroupIds.has(decisionGroups[i].groupId)) { + decisionGroups.splice(i, 1); + } + } + } + + return groupOptionsMap; +} + // ---- Public API ---- /** @@ -633,22 +802,10 @@ export function parsePatternData(pattern: SchemaObject): ParsedPatternData { const { nodes: extractedNodes, decisionGroups } = extractNodesFromPattern(pattern); const { relationships, optionsMetadata } = extractRelationshipsFromPattern(pattern); - // Build map from node ID to its decision group for options metadata mapping - const nodeToGroupMap = new Map(); - decisionGroups.forEach((g) => g.nodeIds.forEach((nid) => nodeToGroupMap.set(nid, g.groupId))); - - // Map options metadata to the decision groups they target - const groupOptionsMap = new Map(); - optionsMetadata.forEach((meta) => { - const allNodes = meta.choices.flatMap((c) => c.nodes); - for (const nodeId of allNodes) { - const groupId = nodeToGroupMap.get(nodeId); - if (groupId && !groupOptionsMap.has(groupId)) { - groupOptionsMap.set(groupId, meta); - break; - } - } - }); + // Fold each decision's referenced node ids into a single decision group + // (creating one if none of its ids already belong to one) and build the + // map from decision-group id to the OptionsMetadata that targets it. + const groupOptionsMap = foldOptionsMetadataIntoDecisionGroups(extractedNodes, decisionGroups, optionsMetadata); const containerInfo = buildContainerInfo(relationships); const { regularNodes, groupNodes } = createReactFlowNodes( diff --git a/shared/src/commands/generate/components/flatten-allof.ts b/shared/src/commands/generate/components/flatten-allof.ts index 2fd2e7933..daa7d286f 100644 --- a/shared/src/commands/generate/components/flatten-allof.ts +++ b/shared/src/commands/generate/components/flatten-allof.ts @@ -41,6 +41,11 @@ function deepMergeSchemas( value as unknown[] ); } else { + // `items` (the open oneOf/anyOf catalog) falls through here and is not + // deep-merged the way `prefixItems` is above - a later allOf branch's + // `items` simply replaces an earlier one. Realistic CALM patterns declare + // the catalog once per array, so this is not expected to matter in + // practice; revisit if patterns start composing `items` across allOf. result[key] = value; } } diff --git a/shared/src/commands/generate/components/instantiate.spec.ts b/shared/src/commands/generate/components/instantiate.spec.ts index 14ef49bb6..4a70e5816 100644 --- a/shared/src/commands/generate/components/instantiate.spec.ts +++ b/shared/src/commands/generate/components/instantiate.spec.ts @@ -1,6 +1,7 @@ import {describe, it, expect, vi, beforeEach, Mock} from 'vitest'; import * as fs from 'fs'; import { instantiate } from './instantiate'; // replace with actual relative path +import { CalmChoice, selectChoices } from './options'; import { SchemaDirectory } from '../../../schema-directory'; import { DocumentLoader } from '../../../document-loader/document-loader'; @@ -294,4 +295,94 @@ describe('instantiate', () => { 'nested-placeholder': '[[ NESTED_PLACEHOLDER ]]' }); }); + + describe('items catalog (open oneOf/anyOf) support', () => { + function catalogNode(id: string, description: string) { + return { + $ref: 'schema#/defs/node', + properties: { + 'unique-id': { const: id }, + 'description': { const: description }, + 'details': { + type: 'object', + properties: { arch: { type: 'string' } } + } + }, + required: ['unique-id', 'details'] + }; + } + + const patternWithItemsCatalog = { + $schema: 'schema#', + $id: 'test-pattern-items-catalog', + properties: { + nodes: { + type: 'array', + prefixItems: [ + { + $ref: 'schema#/defs/node', + properties: { + 'unique-id': { const: 'webapp' }, + 'description': { const: 'the web app' }, + 'details': { type: 'object', properties: { arch: { type: 'string' } } } + }, + required: ['unique-id', 'details'] + } + ], + items: { + oneOf: [ + catalogNode('cache', 'an optional cache'), + catalogNode('queue', 'an optional queue'), + ] + } + }, + relationships: { + type: 'array', + prefixItems: [] + } + } + }; + + it('instantiates an architecture containing exactly the chosen items-catalog nodes', async () => { + const cacheChoice: CalmChoice = { description: 'Use cache', nodes: ['cache'], relationships: [] }; + const selected = selectChoices(patternWithItemsCatalog, [cacheChoice]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await instantiate(selected, true, new SchemaDirectory({} as any)) as TestInstantiatedPattern; + + const nodeIds = result.nodes.map((n) => n['unique-id']); + expect(nodeIds).toEqual(['webapp', 'cache']); + }); + + it('yields only the mandatory nodes when no items-catalog candidates are selected', async () => { + const selected = selectChoices(patternWithItemsCatalog, []); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await instantiate(selected, true, new SchemaDirectory({} as any)) as TestInstantiatedPattern; + + const nodeIds = result.nodes.map((n) => n['unique-id']); + expect(nodeIds).toEqual(['webapp']); + }); + + it('does not throw for a nodes array with only an items catalog and no prefixItems', async () => { + const catalogOnlyPattern = { + $schema: 'schema#', + $id: 'catalog-only-pattern', + properties: { + nodes: { + type: 'array', + items: { oneOf: [catalogNode('cache', 'an optional cache')] } + }, + relationships: { type: 'array', prefixItems: [] } + } + }; + const cacheChoice: CalmChoice = { description: 'Use cache', nodes: ['cache'], relationships: [] }; + const selected = selectChoices(catalogOnlyPattern, [cacheChoice]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await instantiate(selected, true, new SchemaDirectory({} as any)) as TestInstantiatedPattern; + + expect(result.nodes.map((n) => n['unique-id'])).toEqual(['cache']); + }); + }); }); diff --git a/shared/src/commands/generate/components/instantiate.ts b/shared/src/commands/generate/components/instantiate.ts index 2d6170c65..30697be84 100644 --- a/shared/src/commands/generate/components/instantiate.ts +++ b/shared/src/commands/generate/components/instantiate.ts @@ -99,6 +99,11 @@ async function instantiateFromProperties( for (const [key, def] of Object.entries(properties)) { const resolvedDef = await resolveSchema(def as JsonSchema, schemaDir); + // Arrays declaring an `items.oneOf`/`items.anyOf` open catalog (in addition + // to `prefixItems`) are normalized down to a single `prefixItems` array by + // `selectChoices()` before `instantiate()` runs, so no separate `items` + // handling is needed here - the selected catalog entries are materialized + // via the same `prefixItems` path below. if (resolvedDef.type === 'array' && resolvedDef.prefixItems) { output[key] = await Promise.all( resolvedDef.prefixItems.map(async (itemDef, idx) => { diff --git a/shared/src/commands/generate/components/options.spec.ts b/shared/src/commands/generate/components/options.spec.ts index e89ea5c5a..b2dae68a3 100644 --- a/shared/src/commands/generate/components/options.spec.ts +++ b/shared/src/commands/generate/components/options.spec.ts @@ -126,6 +126,27 @@ function buildPattern(nodes: object[], relationships: object[]) { }; } +// Builds a pattern with mandatory prefixItems plus an open items.oneOf/anyOf catalog +function buildPatternWithItemsCatalog( + mandatoryNodes: object[], + catalogNodes: object[], + relationships: object[] = [], + catalogRelationships: object[] = [] +) { + return { + 'properties': { + 'nodes': { + 'prefixItems': mandatoryNodes, + ...(catalogNodes.length > 0 && { 'items': { 'oneOf': catalogNodes } }), + }, + 'relationships': { + 'prefixItems': relationships, + ...(catalogRelationships.length > 0 && { 'items': { 'oneOf': catalogRelationships } }), + }, + }, + }; +} + describe('Pattern Options', () => { describe('optionsFor', () => { it('should return a oneOf option from a spec', () => { @@ -300,6 +321,53 @@ describe('Pattern Options', () => { expect(pattern).toEqual(patternBeforeSelection); }); + it('should move selected items-catalog candidates into prefixItems', () => { + const webapp = buildNode('webapp'); + const cache = buildNode('cache'); + const queue = buildNode('queue'); + + const pattern = buildPatternWithItemsCatalog([webapp], [cache, queue]); + + const cacheChoice: CalmChoice = { description: 'Use cache', nodes: ['cache'], relationships: [] }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectChoices(pattern, [cacheChoice]) as any; + + expect(result.properties.nodes.prefixItems).toEqual([webapp, cache]); + expect(result.properties.nodes.items).toBeUndefined(); + }); + + it('should keep only the mandatory nodes when no items-catalog candidates are selected', () => { + const webapp = buildNode('webapp'); + const cache = buildNode('cache'); + const queue = buildNode('queue'); + + const pattern = buildPatternWithItemsCatalog([webapp], [cache, queue]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectChoices(pattern, []) as any; + + expect(result.properties.nodes.prefixItems).toEqual([webapp]); + }); + + it('should not throw when a calmType has only an items catalog and no prefixItems', () => { + const cache = buildNode('cache'); + const pattern = { + 'properties': { + 'nodes': { 'items': { 'oneOf': [cache] } }, + 'relationships': { 'prefixItems': [] }, + }, + }; + + const cacheChoice: CalmChoice = { description: 'Use cache', nodes: ['cache'], relationships: [] }; + + expect(() => selectChoices(pattern, [cacheChoice])).not.toThrow(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectChoices(pattern, [cacheChoice]) as any; + expect(result.properties.nodes.prefixItems).toEqual([cache]); + }); + it('should not affect a normal pattern', () => { const applicationA = buildNode('application-a'); const applicationB = buildNode('application-b'); diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index d740f2989..f2ce41d1a 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -119,12 +119,35 @@ function flattenOneOfAndAnyOf(item: Item, selectionPredicate: (item: SchemaNode) .filter((x: SchemaNode) => selectionPredicate(x)); } +/** + * Flattens the prefixItems slots (positional "pick exactly one" decisions) and, + * if present, the `items.oneOf`/`items.anyOf` open catalog (zero-or-more + * selections) for a given calmType down to the concrete set of chosen entries. + * + * The selected catalog candidates are appended onto `prefixItems` so that + * `instantiate()` - which only understands `prefixItems` - materializes them + * the same way it already does for the rest of the array. `items` is then + * cleared, since the pattern's array is now fully expressed via `prefixItems`. + */ function flattenCalmItems(pattern: SchemaNode, calmType: 'nodes' | 'relationships', ids: string[]): void { - const calmItems = pattern['properties'][calmType]['prefixItems']; + const calmProps: SchemaNode = pattern['properties']?.[calmType]; + if (!calmProps) return; const selectionPredicate = (x: SchemaNode) => ids.includes(x['properties']['unique-id']['const']); - pattern['properties'][calmType]['prefixItems'] = calmItems + + const prefixItems: SchemaNode[] = calmProps['prefixItems'] ?? []; + const flattenedPrefixItems = prefixItems .flatMap((item: Item) => flattenOneOfAndAnyOf(item, selectionPredicate)); + + const itemsCatalog: Item | undefined = calmProps['items']; + const catalogAlternatives: SchemaNode[] = itemsCatalog?.oneOf ?? itemsCatalog?.anyOf ?? []; + const selectedCatalogItems = catalogAlternatives.filter(selectionPredicate); + + calmProps['prefixItems'] = [...flattenedPrefixItems, ...selectedCatalogItems]; + + if (itemsCatalog !== undefined) { + delete calmProps['items']; + } } function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoice[]): SchemaNode { diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts index d2b03b09e..67f12dc3c 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts @@ -120,6 +120,90 @@ describe('idsAreUnique', () => { + it('should return messages for duplicate IDs within nodes.items.oneOf', () => { + const input = {}; + const context = { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + {'properties': {'unique-id': {'const': 'webapp'}}} + ], + items: { + oneOf: [ + {'properties': {'unique-id': {'const': 'cache'}}}, + {'properties': {'unique-id': {'const': 'cache'}}} + ] + } + } + } + } + } + }; + + const result = idsAreUnique(input, null, asContext(context)); + expect(result.length).toBeGreaterThan(0); + expect(result[0].message).toContain('Duplicate unique-id detected. ID: cache, path: /properties/nodes/items/oneOf/1/properties/unique-id/const'); + }); + + it('should return messages for duplicate IDs across a prefixItems node and an items.oneOf node', () => { + const input = {}; + const context = { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + {'properties': {'unique-id': {'const': 'webapp'}}} + ], + items: { + oneOf: [ + {'properties': {'unique-id': {'const': 'webapp'}}} + ] + } + } + } + } + } + }; + + const result = idsAreUnique(input, null, asContext(context)); + expect(result.length).toBeGreaterThan(0); + expect(result[0].message).toContain('Duplicate unique-id detected. ID: webapp, path: /properties/nodes/items/oneOf/0/properties/unique-id/const'); + }); + + it('should return messages for duplicate interface IDs inside catalog nodes', () => { + const input = {}; + const context = { + document: { + data: { + properties: { + nodes: { + items: { + anyOf: [ + {'properties': { + 'unique-id': {'const': 'cache'}, + 'interfaces': {prefixItems: [{'properties': {'unique-id': {'const': 'intf1'}}}]}} + }, + {'properties': { + 'unique-id': {'const': 'queue'}, + 'interfaces': {prefixItems: [{'properties': {'unique-id': {'const': 'intf1'}}}]}} + } + ] + } + } + } + } + } + }; + + const result = idsAreUnique(input, null, asContext(context)); + expect(result.length).toBeGreaterThan(0); + expect(result[0].message).toContain('Duplicate unique-id detected. ID: intf1, path: /properties/nodes/items/anyOf/1/properties/interfaces/prefixItems/0/properties/unique-id/const'); + }); + + it('should return messages for duplicate IDs across unique-ids', () => { const input = {}; const context = { diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.ts b/shared/src/spectral/functions/pattern/ids-are-unique.ts index 3e5424f45..fbe3d5137 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.ts @@ -10,16 +10,28 @@ export default (input: unknown, _: unknown, context: RulesetFunctionContext): IF } // get uniqueIds of all nodes const nodeIdMatches = JSONPath({path: '$.properties.nodes.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); + const nodeItemsOneOfIdMatches = JSONPath({path: '$.properties.nodes.items.oneOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); + const nodeItemsAnyOfIdMatches = JSONPath({path: '$.properties.nodes.items.anyOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); const relationshipIdMatches = JSONPath({path: '$.properties.relationships.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); + const relationshipItemsOneOfIdMatches = JSONPath({path: '$.properties.relationships.items.oneOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); + const relationshipItemsAnyOfIdMatches = JSONPath({path: '$.properties.relationships.items.anyOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); const interfaceIdMatches = JSONPath({path: '$.properties.nodes.prefixItems[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); + const interfaceItemsOneOfIdMatches = JSONPath({path: '$.properties.nodes.items.oneOf[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); + const interfaceItemsAnyOfIdMatches = JSONPath({path: '$.properties.nodes.items.anyOf[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all'}); const seenIds = new Set(); const messages: IFunctionResult[] = []; detectDuplicates(nodeIdMatches, seenIds, messages); + detectDuplicates(nodeItemsOneOfIdMatches, seenIds, messages); + detectDuplicates(nodeItemsAnyOfIdMatches, seenIds, messages); detectDuplicates(relationshipIdMatches, seenIds, messages); + detectDuplicates(relationshipItemsOneOfIdMatches, seenIds, messages); + detectDuplicates(relationshipItemsAnyOfIdMatches, seenIds, messages); detectDuplicates(interfaceIdMatches, seenIds, messages); + detectDuplicates(interfaceItemsOneOfIdMatches, seenIds, messages); + detectDuplicates(interfaceItemsAnyOfIdMatches, seenIds, messages); return messages; }; \ No newline at end of file diff --git a/shared/src/spectral/rules-pattern.ts b/shared/src/spectral/rules-pattern.ts index 8bec5c21d..61c0fc6ff 100644 --- a/shared/src/spectral/rules-pattern.ts +++ b/shared/src/spectral/rules-pattern.ts @@ -134,7 +134,11 @@ const patternRules: RulesetDefinition = { description: 'Nodes must be referenced by at least one relationship', severity: 'warn', message: '{{error}}', - given: '$.properties.nodes.prefixItems[*].properties.unique-id.const', + given: [ + '$.properties.nodes.prefixItems[*].properties.unique-id.const', + '$.properties.nodes.items.oneOf[*].properties.unique-id.const', + '$.properties.nodes.items.anyOf[*].properties.unique-id.const', + ], then: { function: nodeHasRelationship, }, From b7e1cac2b3f5fb4a1569a6a803c12ae7fcdbd1a2 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 3 Aug 2026 16:07:18 +0000 Subject: [PATCH 02/41] fix: container precedence and all-optional generate output for pattern decisions Follow-up fixes and documentation on top of items-based decision support (#2859): - calm-hub-ui: a node that is both a decision candidate and a container child now renders inside its container rather than the choice box, and a decision box left empty by that reparenting is suppressed so no empty box is drawn. - shared/generate: an array declared only through an items catalog and generated with no selection now instantiates as [] instead of {}, fixing malformed output. - docs: add PATTERNS_OPTIONS_AND_DECISIONS.md explaining how validation, generation and visualisation each consume pattern decisions independently; linked from the shared and calm-hub-ui AGENTS.md guides. - calm-ai: document the items open-catalog construct in the pattern creation guide. Refs #2859 --- calm-ai/tools/pattern-creation.md | 76 ++++- calm-hub-ui/AGENTS.md | 7 + .../utils/patternTransformer.test.ts | 64 ++++ .../reactflow/utils/patternTransformer.ts | 25 +- docs/PATTERNS_OPTIONS_AND_DECISIONS.md | 278 ++++++++++++++++++ shared/AGENTS.md | 1 + .../generate/components/instantiate.spec.ts | 25 ++ .../generate/components/instantiate.ts | 20 +- 8 files changed, 484 insertions(+), 12 deletions(-) create mode 100644 docs/PATTERNS_OPTIONS_AND_DECISIONS.md diff --git a/calm-ai/tools/pattern-creation.md b/calm-ai/tools/pattern-creation.md index b7bb5184c..809500ae1 100644 --- a/calm-ai/tools/pattern-creation.md +++ b/calm-ai/tools/pattern-creation.md @@ -98,6 +98,72 @@ Patterns use JSON schema constructs to provide choices and options: } ``` +### Optional Nodes with an `items` Catalog (zero or more) + +`prefixItems` describes fixed array positions: a slot is always present, and a `oneOf`/`anyOf` inside it chooses *which kind* of node fills that position. Use it when a node must exist and you are only choosing its type. + +When you instead want an **open catalog** of optional nodes — "include any combination of these, including none" — declare the candidates under `items` (which applies to every array entry) rather than `prefixItems` (which pins entries to positions). Mandatory nodes go in `prefixItems`; the optional catalog goes in `items`: + +```json +{ + "properties": { + "nodes": { + "type": "array", + "minItems": 2, + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "type": "object", + "properties": { + "unique-id": { "const": "webapp" }, + "name": { "const": "Web Application" }, + "node-type": { "const": "webclient" } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "type": "object", + "properties": { + "unique-id": { "const": "database" }, + "name": { "const": "Database" }, + "node-type": { "const": "database" } + } + } + ], + "items": { + "oneOf": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "type": "object", + "properties": { + "unique-id": { "const": "cache" }, + "name": { "const": "Cache" }, + "node-type": { "const": "service" } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/node", + "type": "object", + "properties": { + "unique-id": { "const": "queue" }, + "name": { "const": "Message Queue" }, + "node-type": { "const": "service" } + } + } + ] + } + } + } +} +``` + +Here `webapp` and `database` are always present, while `cache` and `queue` form an optional catalog: an instantiated architecture may include neither, either, or both. A decision (`relationship-type.options`, see below) references catalog candidates by `unique-id` in exactly the same way it references positional ones, so the same decision mechanism works for both. Relationship candidates can use an `items` catalog in the same way. + +Guidance: + +- Keep every candidate that a single decision references within one declaration site. A decision whose candidates are split between a `prefixItems` slot and an `items` catalog, or spread across nodes with inconsistent container membership, is a pattern smell — model the choice at one consistent level. +- Duplicate `unique-id`s inside an `items` catalog are rejected by `calm validate`, and a catalog node that no relationship or decision references produces a warning, exactly as for `prefixItems`. + ### Relationship Options with Decision Points ```json @@ -618,7 +684,8 @@ Always use specific interface schema references: ### Array Handling -- Use `prefixItems` to define specific array positions +- Use `prefixItems` to define specific array positions (fixed slots) +- Use `items` with a `oneOf`/`anyOf` to define an open catalog of optional entries (zero or more, any combination); combine with `prefixItems` for mandatory-plus-optional arrays - Use `minItems`/`maxItems` to constrain array sizes - Each array item should reference base schema + add constraints @@ -643,13 +710,15 @@ 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 items by position (fixed slots) +- `items` - Define the rule every array entry must satisfy; with a `oneOf`/`anyOf` inside it, an open catalog of optional entries (zero or more) ### Option Constructs - `anyOf` - One or more options can be true - `oneOf` - Exactly one option must be true - `allOf` - All conditions must be true +- Placed inside a `prefixItems` slot, `oneOf`/`anyOf` chooses which node fills that fixed position; placed inside `items`, they define the optional catalog an entry may be drawn from ### Schema References @@ -666,7 +735,8 @@ The CLI will prompt for choices when encountering `anyOf`/`oneOf` options, or yo 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 ` +7. `unique-id`s must be unique across the whole pattern, including inside `items.oneOf`/`anyOf` catalogs +8. Pattern should be testable with `calm validate -p ` ## Best Practices diff --git a/calm-hub-ui/AGENTS.md b/calm-hub-ui/AGENTS.md index 162df6559..114845672 100644 --- a/calm-hub-ui/AGENTS.md +++ b/calm-hub-ui/AGENTS.md @@ -67,6 +67,13 @@ The `visualizer/contracts/*-contracts.ts` files hold the typed interfaces shared across the visualiser (nodes, edges, decorators, panels, etc.); add new visualiser-facing types there rather than inline. +Pattern **decision/options** rendering lives in +`visualizer/components/reactflow/utils/patternTransformer.ts`. The same decision +construct is also validated and generated in the `shared` package, and the three +consumers do not share code — read +[`docs/PATTERNS_OPTIONS_AND_DECISIONS.md`](../docs/PATTERNS_OPTIONS_AND_DECISIONS.md) +before changing how decisions are drawn. + ## Conventions ### Service Pattern diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts index 855b26ffb..c37941106 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts @@ -455,6 +455,70 @@ describe('parsePatternData', () => { expect(groupNode?.data.choices[1].description).toBe('Use Option B'); }); + it('renders a decision candidate that is also a container child inside the container, not the choice box', () => { + // opt-a is both a oneOf decision candidate AND deployed inside the k8s + // container. The container must win: opt-a's parent is k8s. opt-b, which is + // not in any container, stays in the decision group. + const pattern = makePattern( + [ + schemaNode('k8s', 'Kubernetes', 'system'), + { + oneOf: [ + schemaNode('opt-a', 'Option A', 'service'), + schemaNode('opt-b', 'Option B', 'service'), + ], + }, + ], + [ + { + properties: { + 'unique-id': { const: 'deploy-a' }, + 'relationship-type': { + const: { 'deployed-in': { container: 'k8s', nodes: ['opt-a'] } }, + }, + }, + }, + ] + ); + const result = parsePatternData(pattern); + + expect(result.nodes.find((n) => n.id === 'opt-a')?.parentId).toBe('k8s'); + const groupNodes = result.nodes.filter((n) => n.type === 'decisionGroup'); + expect(groupNodes).toHaveLength(1); + expect(result.nodes.find((n) => n.id === 'opt-b')?.parentId).toBe(groupNodes[0].id); + }); + + it('does not render an empty decision box when every candidate is pulled into a container', () => { + // Both oneOf candidates are deployed inside k8s, so the decision group is + // emptied by container precedence and must not be drawn as an empty box. + const pattern = makePattern( + [ + schemaNode('k8s', 'Kubernetes', 'system'), + { + oneOf: [ + schemaNode('opt-a', 'Option A', 'service'), + schemaNode('opt-b', 'Option B', 'service'), + ], + }, + ], + [ + { + properties: { + 'unique-id': { const: 'deploy-both' }, + 'relationship-type': { + const: { 'deployed-in': { container: 'k8s', nodes: ['opt-a', 'opt-b'] } }, + }, + }, + }, + ] + ); + const result = parsePatternData(pattern); + + expect(result.nodes.filter((n) => n.type === 'decisionGroup')).toHaveLength(0); + expect(result.nodes.find((n) => n.id === 'opt-a')?.parentId).toBe('k8s'); + expect(result.nodes.find((n) => n.id === 'opt-b')?.parentId).toBe('k8s'); + }); + it('sets protocol on edges', () => { const pattern = makePattern( [ diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index 47c6e9b95..2191a7249 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -471,8 +471,28 @@ function createReactFlowNodes( const groupNodes: Node[] = []; const { containerNodeIds, parentMap } = containerInfo; - // Create decision group parent nodes + // Determine each non-container node's parent. A container (deployed-in / + // composed-of) takes precedence over a decision group: a node that is both an + // optional decision candidate AND a container child renders inside its + // container, not in the choice box. Track which decision groups actually keep + // at least one child after that precedence is applied. + const effectiveParent = new Map(); + const usedDecisionGroupIds = new Set(); + extractedNodes.forEach((node) => { + if (containerNodeIds.has(node.uniqueId)) return; + const parentId = parentMap.get(node.uniqueId) || node.decisionGroupId; + if (!parentId) return; + effectiveParent.set(node.uniqueId, parentId); + if (parentId === node.decisionGroupId) { + usedDecisionGroupIds.add(node.decisionGroupId); + } + }); + + // Create decision group parent nodes — but only for groups that still have at + // least one child after container precedence is applied, so a group whose every + // member was pulled into a container does not render as an empty box. decisionGroups.forEach((group) => { + if (!usedDecisionGroupIds.has(group.groupId)) return; const optionsMeta = groupOptionsMap?.get(group.groupId); groupNodes.push({ id: group.groupId, @@ -526,8 +546,7 @@ function createReactFlowNodes( extractedNodes.forEach((node) => { if (containerNodeIds.has(node.uniqueId)) return; // already a group node - // Determine parent: decision group takes precedence, then container - const parentId = node.decisionGroupId || parentMap.get(node.uniqueId); + const parentId = effectiveParent.get(node.uniqueId); regularNodes.push({ id: node.uniqueId, diff --git a/docs/PATTERNS_OPTIONS_AND_DECISIONS.md b/docs/PATTERNS_OPTIONS_AND_DECISIONS.md new file mode 100644 index 000000000..2ea320152 --- /dev/null +++ b/docs/PATTERNS_OPTIONS_AND_DECISIONS.md @@ -0,0 +1,278 @@ +# Patterns, Options and Decisions — how the mechanism works across the codebase + +This is a developer/maintainer guide. It explains what CALM pattern **decisions** +(also called **options**) are, how a pattern expresses "choose zero or more from a +set of candidates", and — most importantly — how three completely separate parts of +the codebase each read that same mechanism for their own purposes. It is not part of +the published documentation site (it sits alongside `DEVELOPER_GUIDE.md`, outside the +Docusaurus `docs/docs/` content root), so it is free to talk about internal code. + +If you are about to change how decisions are validated, generated, or drawn, read +this first. The single most common mistake is to fix one of the three consumers and +forget the other two, because nothing ties them together in the code — they only +share the *shape of the JSON they each independently read*. + +Throughout, we use one running example: a pattern for an **online shop**. Every shop +must have a **web application** and a **database**. On top of that, a shop *may* +optionally include a **cache** and/or a **message queue**, in any combination — +neither, one, the other, or both. + +--- + +## 1. What a pattern actually is + +A CALM **pattern** is not a diagram and not an architecture. It is a **JSON Schema**: +a set of rules describing what a valid architecture is allowed to look like. An +**architecture** is a concrete JSON document describing one specific system. + +The relationship between the two is the relationship between a blank form template +and a filled-in copy of that form. The pattern is the template with its rules ("this +field must be a number; you must pick one of these three options"), and an +architecture is one completed copy. Validating an architecture against a pattern is +therefore ordinary JSON Schema validation. + +Inside a pattern, the two things we care about are **nodes** (the boxes — services, +databases, actors) and **relationships** (the arrows — "connects to", "deployed in"). +Both live in JSON arrays. So when a pattern describes its nodes, it is really +describing *the rules for a JSON array of nodes*: how many entries it may have, and +what each entry is allowed to be. + +--- + +## 2. The two ways a pattern can describe a list — the crux of everything + +JSON Schema gives you two different ways to describe what is allowed inside an array. +The entire options feature, and every subtle behaviour in it, comes from the +difference between these two. Spend time here. + +### `prefixItems` — a positional checklist + +`prefixItems` describes an array **position by position**, like a numbered checklist. +It says "the first entry must match *this* rule, the second entry must match *that* +rule," and so on. It is fundamentally about fixed positions. + +If you want a position to be a *choice*, you put a `oneOf` (meaning "exactly one of +the following") inside that slot, listing the alternatives: + +```jsonc +"nodes": { + "prefixItems": [ + { "oneOf": [ /* web app in Node */, /* web app in Python */ ] } // slot 0: pick one + ] +} +``` + +Notice what this construct actually means: there is *definitely* a node in slot 0, and +you must choose which kind it is. It is a **mandatory slot with a choice inside it**. +It cannot naturally say "maybe there is a node here, maybe there isn't", because +position-based lists are rigid — position three is always position three. + +### `items` — an open catalog + +`items` (the catalog sense) works completely differently. Instead of describing +positions, it describes **one single rule that every entry in the array must obey, no +matter how many entries there are**. So you can say "every element must be one of the +following approved things" and separately constrain the count with `minItems` / +`maxItems`: + +```jsonc +"nodes": { + "prefixItems": [ /* web app */, /* database */ ], // always present + "items": { "oneOf": [ /* cache */, /* queue */ ] }, // an approved-but-optional menu + "minItems": 2 // at least the two mandatory nodes +} +``` + +Because `items` does not pin anything to a position, you are free to include none of +its candidates, some of them, or all of them. This is the natural way to express our +online shop: the web app and database are mandatory (`prefixItems`), and the cache and +queue form an optional catalog (`items`). Before this feature, the tooling understood +only the `prefixItems` half of that sentence and was blind to the `items` half. + +--- + +## 3. What a "decision" actually is + +A pattern that merely *allows* optional things does not help a human choose. That is +what the **decision** mechanism (a.k.a. **options**) is for. + +A decision is written as a special entry in the **relationships** array. This is the +part that trips people up, so be precise: **this "relationship" is not a real +connection between two nodes.** It does not mean "A talks to B". It is a piece of +*metadata* riding along in the relationships array because that is a convenient place +to keep it. What it contains is a human-facing prompt and a list of possible answers: + +```jsonc +{ + "properties": { + "unique-id": { "const": "optional-add-ons" }, + "description": { "const": "Which optional add-ons do you want?" }, + "relationship-type": { "properties": { "options": { "prefixItems": [ { "anyOf": [ + { "properties": { "description": { "const": "Add a cache" }, "nodes": { "const": ["cache"] } } }, + { "properties": { "description": { "const": "Add a queue" }, "nodes": { "const": ["queue"] } } } + ] } ] } } } + } +} +``` + +The critical mechanical detail: a **choice does not contain the node**, it contains +the **id** of a node defined elsewhere. The choice "Add a cache" simply says +`nodes: ["cache"]`, and the actual `cache` node lives over in the nodes array. The +decision and the candidate are linked purely by that shared id string. + +This indirection — decisions point at candidates by id rather than containing them — +is the most important thing to understand, because every one of the three processes +below has to *resolve* that id back to the real node, and each does it differently. + +--- + +## 4. The three processes, and how each reads the very same decision + +Here is the mental model to walk away with. You have **one** decision written **once** +in the pattern. Three entirely separate pieces of code, living in different packages +and run at different times, each pick that pattern up and read that decision for their +own purposes. **They do not share code.** They each independently walk the JSON. That +is why a single conceptual feature — "understand `items`" — had to be implemented +three separate times, and why bugs cluster at the seams. + +### 4a. Validation — a static check of the pattern itself + +**Where:** `shared/src/spectral/` (rules in `rules-pattern.ts`, helper functions in +`functions/pattern/`). + +Validation checks the pattern *before* anyone builds anything from it, the way a +spell-checker catches typos: are all node ids unique, does every id a decision points +to actually exist, is every decision-referenced node wrapped in a choice block, and so +on. It is built on **Spectral**, a tool for writing rules that walk a JSON document. + +Those rules navigate the pattern using little address strings (think file paths into +the JSON) such as `$.properties.nodes.prefixItems[*].properties.unique-id.const`. The +historical blindness lived exactly here: every address string was written to walk down +through `prefixItems`, and none knew to also walk through `items`. So catalog +candidates were not *rejected* — they were genuinely *invisible*. If you gave the cache +and the queue the same id, the "ids must be unique" rule sailed past it, because the +rule only looked in the `prefixItems` neighbourhood and the duplicate was in the +`items` neighbourhood. The fix was mechanical: add parallel address strings that also +walk `items.oneOf`/`items.anyOf`, so the existing rules now visit both neighbourhoods +(`ids-are-unique.ts`, and the `pattern-nodes-must-be-referenced` rule's `given`). + +### 4b. Generation — pattern + human choices → concrete architecture + +**Where:** `shared/src/commands/generate/` (`generate.ts` orchestrates; +`components/options.ts` reads and narrows decisions; `components/instantiate.ts` +materializes; `components/flatten-allof.ts` pre-flattens `allOf`). + +Generation takes a pattern plus a human's answers and produces a concrete +architecture. It runs as a small pipeline: + +1. **Read the decisions** out of the pattern (`extractOptions`) so it knows what to + ask ("cache? queue?"). +2. **Narrow** the pattern to the answers (`selectChoices` → `flattenCalmItems`): for + every choice the human *did* pick, keep the corresponding candidate; for every + alternative they did *not* pick, throw it away. After narrowing there are no open + choices left — the pattern is collapsed to exactly what was selected. +3. **Materialize** (`instantiate`): turn the narrowed schema's `const` values into + concrete instance data — the actual architecture document. + +The narrowing step used to reach specifically for `prefixItems`. The chosen approach +for `items` is deliberately economical: during narrowing, the selected catalog +candidates are **moved into `prefixItems`** and the now-empty `items` is deleted. In +other words, once the human has decided exactly which extras they want, those extras +really *are* just a fixed list, so converting the open-catalog form into the positional +form is legitimate — and it means the materialization step did not have to change at +all, because by the time it runs everything is `prefixItems` again. + +There is one seam worth remembering (see Limitation 1 below): narrowing only runs when +choices are actually supplied. Generating with no choices at all skips it, so an +array described *only* by `items` never gets converted, and `instantiate` must be able +to cope with an array that has no `prefixItems`. + +### 4c. Visualisation — draw the pattern as boxes and arrows + +**Where:** `calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts`. + +Visualisation draws the pattern on screen in CALM Hub so a human can see its shape, +including where the choices are. The concept that matters is the **decision group**: +the visual box drawn around a set of alternatives to say "these are the options for one +choice." + +When the visualiser reads the pattern and sees a `oneOf`/`anyOf` block, it gathers +those alternatives, stamps each with a shared "you belong to decision group N" label, +and later draws a box around everyone carrying that label. Separately it reads the +decision's prompt and choice descriptions and attaches them to the group so the box can +show the question. The same blindness applied: the grouping code only looked inside +`prefixItems` slots, so an `items` catalog produced no group, no box, and — because the +prompt is attached to a group that was never created — the decision silently failed to +render. The fix teaches extraction to build a group from the `items` catalog too +(`getItems`, `extractNodeDecisionGroup`), and reworks how a decision's referenced ids +are resolved onto a group (`foldOptionsMetadataIntoDecisionGroups`). This was the +largest and most delicate of the three changes, because grouping is genuinely stateful +— nodes move between groups, groups are created, empty groups are cleaned up. + +--- + +## 5. Current limitations and sharp edges + +None of these affect any pattern that exists in the repository today; they are corners +you can reach only by writing unusual patterns. They are recorded here so the next +person is not surprised. + +1. **(Fixed) An all-optional array generated with no selections.** A nodes array + declared *entirely* through an `items` catalog (no mandatory `prefixItems` nodes), + generated without choosing anything, used to emit `nodes: {}` (an object) instead of + `nodes: []` (an array), because narrowing never ran and `instantiate` fell back to + object-instantiation. `instantiate.ts` now returns `[]` for any array with no + `prefixItems`. Covered by a regression test in `instantiate.spec.ts`. + +2. **One decision spanning two `prefixItems` slots now renders as a single box.** The + visualiser folds *all* of a decision's referenced nodes into one group. Every real + pattern maps each decision to one slot, so nothing changes today; but if a single + decision's choices reach across two separate `oneOf` slots, the old code drew two + boxes (labelling only one) and the new code draws one unified box. This is arguably + more correct — a single decision should be a single box — but it is a visible + change, so mention it in review when it first occurs. + +3. **(Fixed) A catalog node that is also a container child draws inside the container.** + If a node is both an optional pick *and* declared to live inside a container (via + `deployed-in`/`composed-of`), the container now takes precedence, so the node renders + nested inside its container rather than in the choice box. Earlier the decision group + won; `createReactFlowNodes` now computes `parentMap.get(...) || node.decisionGroupId` + and additionally suppresses any decision box left empty because all its members were + pulled into containers, so no empty box is drawn. Covered by two tests in + `patternTransformer.test.ts`. (This precedence only exists for *pattern* + visualisation; architectures are drawn by the separate `calmTransformer.ts`, which + has no decision-group concept because all choices are already resolved.) + +4. **`minItems`/`maxItems` are not rewritten during narrowing.** If a pattern author + sets, say, `maxItems: 2` thinking only of the mandatory nodes, and a user then picks + optional add-ons, the array grows past that limit *while being assembled*. The final + architecture is still correct, because it is validated against the *original* + pattern (whose `items` + `minItems` accept the extras) — but authors should size any + `maxItems` to account for optional picks, or omit it. + +5. **`items` is not merged across `allOf` branches.** `flatten-allof.ts` deep-merges + `prefixItems` by position but merges `properties` shallowly, so if the definition of + a single node array is split across two `allOf` branches, the later branch's array + definition replaces the earlier one wholesale (not just its `items`) — the earlier + branch's candidates are silently lost. Realistic patterns declare a node array once + in one place, so this does not arise; it is flagged in a code comment in + `flatten-allof.ts`. + +--- + +## 6. If you change this mechanism — a checklist + +Because the three consumers do not share code, a change to decision handling is not +complete until you have considered all three: + +- **Validation** (`shared/src/spectral/`): will the new shape be seen by the JSONPath + address strings in `rules-pattern.ts` and the `functions/pattern/*` helpers? A new + place to declare candidates usually needs new `given`/JSONPath entries. +- **Generation** (`shared/src/commands/generate/`): can `extractOptions` read the + decision, can `selectChoices`/`flattenCalmItems` narrow it, and does `instantiate` + materialize the result into valid instance JSON (an array, not an object)? +- **Visualisation** (`calm-hub-ui/.../patternTransformer.ts`): does extraction build a + decision group for the new shape, and does the fold step attach the prompt to it + without stranding metadata or drawing empty boxes? +- **Tests**: `shared` is consumed by the CLI and the VSCode extension, so run the full + `npm test` from the repo root after touching `shared`, not just the shared workspace. diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 00ebf149e..4d889851c 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -45,6 +45,7 @@ npx vitest run ${TEST FILE} - `validate()` - Main validation function (`commands/validate/validate.ts`) used by CLI and VSCode - `enrichWithDocumentPositions()` - Adds precise line/character positions to validation output using `@stoplight/json` - `parseDocumentWithPositions()` - Parses JSON/YAML with position tracking for error location + - Pattern **options/decisions** (`prefixItems` vs `items` catalogs) are validated here, generated in `commands/generate/`, and drawn in `calm-hub-ui`. All three read the same construct independently — see [`docs/PATTERNS_OPTIONS_AND_DECISIONS.md`](../docs/PATTERNS_OPTIONS_AND_DECISIONS.md) before changing decision handling. - **Schema Directory** (`schema-directory.ts`): Registry of bundled CALM schemas, used for lookup by schema URL (`getSchema`). - **Docify** (`docify/`): Documentation generator (`docifier`) with C4/relationship graphing (`docify/graphing`) and template bundles (`docify/template-bundles`, e.g. `ants`, `docusaurus`). - **Resolver** (`resolver/`): CALM reference resolver plus the network-addressable extractor and validator. diff --git a/shared/src/commands/generate/components/instantiate.spec.ts b/shared/src/commands/generate/components/instantiate.spec.ts index 4a70e5816..07dc48a54 100644 --- a/shared/src/commands/generate/components/instantiate.spec.ts +++ b/shared/src/commands/generate/components/instantiate.spec.ts @@ -384,5 +384,30 @@ describe('instantiate', () => { expect(result.nodes.map((n) => n['unique-id'])).toEqual(['cache']); }); + + it('emits an empty array (not an object) for an all-optional catalog generated with no selections', async () => { + // Regression guard: a nodes array declared entirely through an items + // catalog (no prefixItems) that is instantiated WITHOUT selectChoices + // ever running - i.e. `calm generate` with no choices made. There are + // no chosen candidates to move into prefixItems, so the array is empty. + // It must materialize as `[]`, not `{}`, or the architecture is invalid. + const catalogOnlyPattern = { + $schema: 'schema#', + $id: 'catalog-only-pattern-no-selection', + properties: { + nodes: { + type: 'array', + items: { oneOf: [catalogNode('cache', 'an optional cache')] } + }, + relationships: { type: 'array', prefixItems: [] } + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await instantiate(catalogOnlyPattern, true, new SchemaDirectory({} as any)) as TestInstantiatedPattern; + + expect(Array.isArray(result.nodes)).toBe(true); + expect(result.nodes).toEqual([]); + }); }); }); diff --git a/shared/src/commands/generate/components/instantiate.ts b/shared/src/commands/generate/components/instantiate.ts index 30697be84..87152f511 100644 --- a/shared/src/commands/generate/components/instantiate.ts +++ b/shared/src/commands/generate/components/instantiate.ts @@ -114,13 +114,21 @@ async function instantiateFromProperties( return await instantiateObject(resolvedItem, schemaDir, [key, `${idx}`]); }) ); + } else if (resolvedDef.type === 'array') { + // An array with no `prefixItems` to materialize - e.g. a nodes array + // whose entries are declared entirely through an `items.oneOf`/`anyOf` + // open catalog, generated with no choices selected (so `selectChoices()` + // never ran to move any chosen candidates into `prefixItems`). The + // correct instance is an empty array. Without this branch the value + // would fall through to `instantiateObject` and be emitted as `{}`, + // producing a structurally invalid architecture (an object where an + // array is required). + output[key] = []; + } else if (resolvedDef.const !== undefined) { + // const value at the top level + output[key] = resolvedDef.const; } 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]); } } From 15bc907de71937b18cc6fbbb9753ff6aa08db7ee Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 3 Aug 2026 19:55:30 +0000 Subject: [PATCH 03/41] fix(shared): validate node/interface ids declared in items catalogs Independent review found two validation call sites that were still prefixItems-only and had been missed when items.oneOf/anyOf support was added, contradicting the feature (#2859): - node-id-exists.ts (backs the connects, deployed-in/composed-of, actor/container, and decision nodes.const rules) now also resolves ids against nodes.items.oneOf/ anyOf, so a relationship or decision referencing a catalog-only node no longer raises a false "does not refer to an existing node" error. - interface-id-exists-on-node.ts now includes items-catalog nodes in its node lookup, so interface checks on catalog nodes run instead of being silently skipped. - options.ts flattenCalmItems only strips items when it is a oneOf/anyOf catalog, leaving an unrelated plain/false items schema untouched. Also document, in PATTERNS_OPTIONS_AND_DECISIONS.md, the full set of validation helpers updated and a known rendering limitation (two decisions sharing one items catalog merge into one box). Refs #2859 --- docs/PATTERNS_OPTIONS_AND_DECISIONS.md | 28 +++++- .../commands/generate/components/options.ts | 8 +- .../interface-id-exists-on-node.spec.ts | 64 ++++++++++++ .../pattern/interface-id-exists-on-node.ts | 4 + .../functions/pattern/node-id-exists.spec.ts | 97 +++++++++++++++++++ .../functions/pattern/node-id-exists.ts | 7 +- 6 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 shared/src/spectral/functions/pattern/node-id-exists.spec.ts diff --git a/docs/PATTERNS_OPTIONS_AND_DECISIONS.md b/docs/PATTERNS_OPTIONS_AND_DECISIONS.md index 2ea320152..a60f6b164 100644 --- a/docs/PATTERNS_OPTIONS_AND_DECISIONS.md +++ b/docs/PATTERNS_OPTIONS_AND_DECISIONS.md @@ -153,8 +153,20 @@ candidates were not *rejected* — they were genuinely *invisible*. If you gave and the queue the same id, the "ids must be unique" rule sailed past it, because the rule only looked in the `prefixItems` neighbourhood and the duplicate was in the `items` neighbourhood. The fix was mechanical: add parallel address strings that also -walk `items.oneOf`/`items.anyOf`, so the existing rules now visit both neighbourhoods -(`ids-are-unique.ts`, and the `pattern-nodes-must-be-referenced` rule's `given`). +walk `items.oneOf`/`items.anyOf`, so the existing rules now visit both neighbourhoods. + +That mechanical fix had to be applied to *every* validation helper that resolves an id +against the node/relationship arrays, not just one — this is exactly the "fix one +consumer, forget the rest" trap this document warns about, and it is easy to miss a +call site. The helpers updated are: `ids-are-unique.ts` (duplicate detection), the +`pattern-nodes-must-be-referenced` rule's `given` (unused-node warning), +`node-id-exists.ts` (does a referenced node exist — used by the `connects`, +`deployed-in`/`composed-of`, and actor/container rules, and by decision `nodes.const` +references), and `interface-id-exists-on-node.ts` (does a referenced interface exist on +the node). `is-defined-in-oneof-or-anyof.ts` was deliberately left unchanged: its check +only fires when an id is *also* present as a plain `prefixItems` name, which a +catalog-only id never is, so it correctly no-ops for catalog candidates rather than +false-positiving. ### 4b. Generation — pattern + human choices → concrete architecture @@ -232,6 +244,18 @@ person is not surprised. more correct — a single decision should be a single box — but it is a visible change, so mention it in review when it first occurs. +2b. **Two decisions drawing from one shared `items` catalog merge into a single box.** + `extractNodesFromPattern` assigns every candidate in a nodes `items` catalog to a + single decision group (`node-decision-items`) at extraction time. If two *separate* + options relationships reference disjoint subsets of that one catalog (e.g. a "pick a + cache" decision and a "pick a queue" decision both drawn from one catalog), both fold + into that single group, so the visualiser draws one merged box and only the + last-processed decision's prompt/choices survive. The nodes are still all drawn; only + the grouping/labelling is wrong. Rendering a single catalog as multiple independent + decisions needs per-decision group keying and is tracked with the decision-group + rework in the visual-nesting follow-up. The one-catalog-one-decision case (the common + shape) is unaffected. + 3. **(Fixed) A catalog node that is also a container child draws inside the container.** If a node is both an optional pick *and* declared to live inside a container (via `deployed-in`/`composed-of`), the container now takes precedence, so the node renders diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index f2ce41d1a..3bbfa214b 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -140,12 +140,16 @@ function flattenCalmItems(pattern: SchemaNode, calmType: 'nodes' | 'relationship .flatMap((item: Item) => flattenOneOfAndAnyOf(item, selectionPredicate)); const itemsCatalog: Item | undefined = calmProps['items']; - const catalogAlternatives: SchemaNode[] = itemsCatalog?.oneOf ?? itemsCatalog?.anyOf ?? []; + // Only treat `items` as a decision catalog when it is a oneOf/anyOf of candidates. A plain + // `items` schema (or `items: false` closing a tuple) is not part of the decision mechanism and + // must be left untouched rather than stripped. + const isCatalog = Array.isArray(itemsCatalog?.oneOf) || Array.isArray(itemsCatalog?.anyOf); + const catalogAlternatives: SchemaNode[] = isCatalog ? (itemsCatalog!.oneOf ?? itemsCatalog!.anyOf ?? []) : []; const selectedCatalogItems = catalogAlternatives.filter(selectionPredicate); calmProps['prefixItems'] = [...flattenedPrefixItems, ...selectedCatalogItems]; - if (itemsCatalog !== undefined) { + if (isCatalog) { delete calmProps['items']; } } diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts index e35ebc59a..76863e5f2 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts @@ -128,6 +128,70 @@ describe('interfaceIdExistsOnNode', () => { expect(result[0].path).toEqual(['/relationships/0/connects/destination']); }); + it('should find a node declared in an items.oneOf catalog and validate its interfaces', () => { + const input = { node: 'cache', interfaces: ['cache-intf'] }; + const context = { + document: { + data: { + properties: { + nodes: { + items: { + oneOf: [ + { + properties: { + 'unique-id': {const: 'cache'}, + 'interfaces': { + prefixItems: [ + {properties: {'unique-id': {const: 'cache-intf'}}} + ] + } + } + } + ] + } + } + } + } + } + }; + + const result = interfaceIdExistsOnNode(input, null, asContext(context)); + expect(result).toEqual([]); + }); + + it('should report a missing interface on an items-catalog node instead of silently skipping it', () => { + const input = { node: 'cache', interfaces: ['does-not-exist'] }; + const context = { + document: { + data: { + properties: { + nodes: { + items: { + oneOf: [ + { + properties: { + 'unique-id': {const: 'cache'}, + 'interfaces': { + prefixItems: [ + {properties: {'unique-id': {const: 'cache-intf'}}} + ] + } + } + } + ] + } + } + } + } + }, + path: ['/relationships/0/connects/destination'] + }; + + const result = interfaceIdExistsOnNode(input, null, asContext(context)); + expect(result.length).toBe(1); + expect(result[0].message).toBe(`Referenced interface with ID '${input.interfaces[0]}' was not defined on the node with ID '${input.node}'.`); + }); + it('should return a message when one interface does not exist', () => { const input = { node: 'node1', interfaces: ['intf1', 'intf2'] }; const context = { diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts index 19c765bdf..7616e8a58 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts @@ -24,6 +24,10 @@ export function interfaceIdExistsOnNode(input: ConnectsRelationship | null | und const nodeId = input.node; const nodes: object[] = JSONPath({ path: '$.properties.nodes.prefixItems[*]', json: context.document.data as object }); + // Nodes may also be declared in an `items.oneOf`/`items.anyOf` open catalog; each catalog + // alternative is itself a node schema, so include them in the node lookup. + nodes.push(...JSONPath({ path: '$.properties.nodes.items.oneOf[*]', json: context.document.data as object })); + nodes.push(...JSONPath({ path: '$.properties.nodes.items.anyOf[*]', json: context.document.data as object })); const node = nodes.find((node) => { const uniqueId: string[] = JSONPath({ path: '$.properties.unique-id.const', json: node }); uniqueId.push(...JSONPath({ path: '$.oneOf[*].properties.unique-id.const', json: node })); diff --git a/shared/src/spectral/functions/pattern/node-id-exists.spec.ts b/shared/src/spectral/functions/pattern/node-id-exists.spec.ts new file mode 100644 index 000000000..6db1223c0 --- /dev/null +++ b/shared/src/spectral/functions/pattern/node-id-exists.spec.ts @@ -0,0 +1,97 @@ +import { asContext } from '../spectral-test-helpers'; +import nodeIdExists from './node-id-exists'; + +describe('nodeIdExists (pattern)', () => { + it('should return an empty array for non-string input', () => { + const context = { document: { data: {} } }; + expect(nodeIdExists(null, null, asContext(context))).toEqual([]); + expect(nodeIdExists(42, null, asContext(context))).toEqual([]); + }); + + it('should accept a node declared in prefixItems', () => { + const context = { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + { properties: { 'unique-id': { const: 'webapp' } } } + ] + } + } + } + } + }; + expect(nodeIdExists('webapp', null, asContext(context))).toEqual([]); + }); + + it('should accept a node declared only in an items.oneOf catalog', () => { + const context = { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + { properties: { 'unique-id': { const: 'webapp' } } } + ], + items: { + oneOf: [ + { properties: { 'unique-id': { const: 'cache' } } }, + { properties: { 'unique-id': { const: 'queue' } } } + ] + } + } + } + } + } + }; + // Referenced by a deployed-in relationship / decision choice — must not false-positive. + expect(nodeIdExists('cache', null, asContext(context))).toEqual([]); + expect(nodeIdExists('queue', null, asContext(context))).toEqual([]); + }); + + it('should accept a node declared only in an items.anyOf catalog', () => { + const context = { + document: { + data: { + properties: { + nodes: { + items: { + anyOf: [ + { properties: { 'unique-id': { const: 'cache' } } } + ] + } + } + } + } + } + }; + expect(nodeIdExists('cache', null, asContext(context))).toEqual([]); + }); + + it('should report a node id that exists in neither prefixItems nor the items catalog', () => { + const context = { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + { properties: { 'unique-id': { const: 'webapp' } } } + ], + items: { + oneOf: [ + { properties: { 'unique-id': { const: 'cache' } } } + ] + } + } + } + } + }, + path: ['/relationships/0/relationship-type/deployed-in/nodes/0'] + }; + const missingId = 'ghost'; + const result = nodeIdExists(missingId, null, asContext(context)); + expect(result.length).toBe(1); + expect(result[0].message).toBe(`'${missingId}' does not refer to the unique-id of an existing node.`); + }); +}); diff --git a/shared/src/spectral/functions/pattern/node-id-exists.ts b/shared/src/spectral/functions/pattern/node-id-exists.ts index b34820b48..7bef08242 100644 --- a/shared/src/spectral/functions/pattern/node-id-exists.ts +++ b/shared/src/spectral/functions/pattern/node-id-exists.ts @@ -11,11 +11,16 @@ export default (input: unknown, _: unknown, context: RulesetFunctionContext): IF const names = JSONPath({ path: '$.properties.nodes.prefixItems[*].properties.unique-id.const', json: context.document.data as object }); const oneofs = JSONPath({ path: '$.properties.nodes.prefixItems[*].oneOf[*].properties.unique-id.const', json: context.document.data as object }); const anyofs = JSONPath({ path: '$.properties.nodes.prefixItems[*].anyOf[*].properties.unique-id.const', json: context.document.data as object }); + // Nodes may also be declared in an `items.oneOf`/`items.anyOf` open catalog, not just positional prefixItems. + const itemsOneofs = JSONPath({ path: '$.properties.nodes.items.oneOf[*].properties.unique-id.const', json: context.document.data as object }); + const itemsAnyofs = JSONPath({ path: '$.properties.nodes.items.anyOf[*].properties.unique-id.const', json: context.document.data as object }); // get uniqueIds of all nodes const results: IFunctionResult[] = []; - if (!names.includes(input) && !oneofs.includes(input) && !anyofs.includes(input)) { + const allNodeIds = [...names, ...oneofs, ...anyofs, ...itemsOneofs, ...itemsAnyofs]; + + if (!allNodeIds.includes(input)) { results.push({ message: `'${input}' does not refer to the unique-id of an existing node.`, path: [...context.path], From 44e169bffe258314662922e437695bc35eb9ac47 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 3 Aug 2026 21:08:29 +0000 Subject: [PATCH 04/41] docs: remove internal patterns-options-and-decisions maintainer doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removes docs/PATTERNS_OPTIONS_AND_DECISIONS.md and the two AGENTS.md pointers to it. An implementation-internals document is a maintenance liability: it must be updated on every change to decision handling or it silently misleads. The load-bearing context does not depend on it — the known rendering limitation is tracked in #2933, the validation/rendering behaviour changes are in the PR description, fixed behaviours are pinned by tests, and the allOf-merge caveat is a code comment. Refs #2859 --- calm-hub-ui/AGENTS.md | 7 - docs/PATTERNS_OPTIONS_AND_DECISIONS.md | 302 ------------------------- shared/AGENTS.md | 1 - 3 files changed, 310 deletions(-) delete mode 100644 docs/PATTERNS_OPTIONS_AND_DECISIONS.md diff --git a/calm-hub-ui/AGENTS.md b/calm-hub-ui/AGENTS.md index 114845672..162df6559 100644 --- a/calm-hub-ui/AGENTS.md +++ b/calm-hub-ui/AGENTS.md @@ -67,13 +67,6 @@ The `visualizer/contracts/*-contracts.ts` files hold the typed interfaces shared across the visualiser (nodes, edges, decorators, panels, etc.); add new visualiser-facing types there rather than inline. -Pattern **decision/options** rendering lives in -`visualizer/components/reactflow/utils/patternTransformer.ts`. The same decision -construct is also validated and generated in the `shared` package, and the three -consumers do not share code — read -[`docs/PATTERNS_OPTIONS_AND_DECISIONS.md`](../docs/PATTERNS_OPTIONS_AND_DECISIONS.md) -before changing how decisions are drawn. - ## Conventions ### Service Pattern diff --git a/docs/PATTERNS_OPTIONS_AND_DECISIONS.md b/docs/PATTERNS_OPTIONS_AND_DECISIONS.md deleted file mode 100644 index a60f6b164..000000000 --- a/docs/PATTERNS_OPTIONS_AND_DECISIONS.md +++ /dev/null @@ -1,302 +0,0 @@ -# Patterns, Options and Decisions — how the mechanism works across the codebase - -This is a developer/maintainer guide. It explains what CALM pattern **decisions** -(also called **options**) are, how a pattern expresses "choose zero or more from a -set of candidates", and — most importantly — how three completely separate parts of -the codebase each read that same mechanism for their own purposes. It is not part of -the published documentation site (it sits alongside `DEVELOPER_GUIDE.md`, outside the -Docusaurus `docs/docs/` content root), so it is free to talk about internal code. - -If you are about to change how decisions are validated, generated, or drawn, read -this first. The single most common mistake is to fix one of the three consumers and -forget the other two, because nothing ties them together in the code — they only -share the *shape of the JSON they each independently read*. - -Throughout, we use one running example: a pattern for an **online shop**. Every shop -must have a **web application** and a **database**. On top of that, a shop *may* -optionally include a **cache** and/or a **message queue**, in any combination — -neither, one, the other, or both. - ---- - -## 1. What a pattern actually is - -A CALM **pattern** is not a diagram and not an architecture. It is a **JSON Schema**: -a set of rules describing what a valid architecture is allowed to look like. An -**architecture** is a concrete JSON document describing one specific system. - -The relationship between the two is the relationship between a blank form template -and a filled-in copy of that form. The pattern is the template with its rules ("this -field must be a number; you must pick one of these three options"), and an -architecture is one completed copy. Validating an architecture against a pattern is -therefore ordinary JSON Schema validation. - -Inside a pattern, the two things we care about are **nodes** (the boxes — services, -databases, actors) and **relationships** (the arrows — "connects to", "deployed in"). -Both live in JSON arrays. So when a pattern describes its nodes, it is really -describing *the rules for a JSON array of nodes*: how many entries it may have, and -what each entry is allowed to be. - ---- - -## 2. The two ways a pattern can describe a list — the crux of everything - -JSON Schema gives you two different ways to describe what is allowed inside an array. -The entire options feature, and every subtle behaviour in it, comes from the -difference between these two. Spend time here. - -### `prefixItems` — a positional checklist - -`prefixItems` describes an array **position by position**, like a numbered checklist. -It says "the first entry must match *this* rule, the second entry must match *that* -rule," and so on. It is fundamentally about fixed positions. - -If you want a position to be a *choice*, you put a `oneOf` (meaning "exactly one of -the following") inside that slot, listing the alternatives: - -```jsonc -"nodes": { - "prefixItems": [ - { "oneOf": [ /* web app in Node */, /* web app in Python */ ] } // slot 0: pick one - ] -} -``` - -Notice what this construct actually means: there is *definitely* a node in slot 0, and -you must choose which kind it is. It is a **mandatory slot with a choice inside it**. -It cannot naturally say "maybe there is a node here, maybe there isn't", because -position-based lists are rigid — position three is always position three. - -### `items` — an open catalog - -`items` (the catalog sense) works completely differently. Instead of describing -positions, it describes **one single rule that every entry in the array must obey, no -matter how many entries there are**. So you can say "every element must be one of the -following approved things" and separately constrain the count with `minItems` / -`maxItems`: - -```jsonc -"nodes": { - "prefixItems": [ /* web app */, /* database */ ], // always present - "items": { "oneOf": [ /* cache */, /* queue */ ] }, // an approved-but-optional menu - "minItems": 2 // at least the two mandatory nodes -} -``` - -Because `items` does not pin anything to a position, you are free to include none of -its candidates, some of them, or all of them. This is the natural way to express our -online shop: the web app and database are mandatory (`prefixItems`), and the cache and -queue form an optional catalog (`items`). Before this feature, the tooling understood -only the `prefixItems` half of that sentence and was blind to the `items` half. - ---- - -## 3. What a "decision" actually is - -A pattern that merely *allows* optional things does not help a human choose. That is -what the **decision** mechanism (a.k.a. **options**) is for. - -A decision is written as a special entry in the **relationships** array. This is the -part that trips people up, so be precise: **this "relationship" is not a real -connection between two nodes.** It does not mean "A talks to B". It is a piece of -*metadata* riding along in the relationships array because that is a convenient place -to keep it. What it contains is a human-facing prompt and a list of possible answers: - -```jsonc -{ - "properties": { - "unique-id": { "const": "optional-add-ons" }, - "description": { "const": "Which optional add-ons do you want?" }, - "relationship-type": { "properties": { "options": { "prefixItems": [ { "anyOf": [ - { "properties": { "description": { "const": "Add a cache" }, "nodes": { "const": ["cache"] } } }, - { "properties": { "description": { "const": "Add a queue" }, "nodes": { "const": ["queue"] } } } - ] } ] } } } - } -} -``` - -The critical mechanical detail: a **choice does not contain the node**, it contains -the **id** of a node defined elsewhere. The choice "Add a cache" simply says -`nodes: ["cache"]`, and the actual `cache` node lives over in the nodes array. The -decision and the candidate are linked purely by that shared id string. - -This indirection — decisions point at candidates by id rather than containing them — -is the most important thing to understand, because every one of the three processes -below has to *resolve* that id back to the real node, and each does it differently. - ---- - -## 4. The three processes, and how each reads the very same decision - -Here is the mental model to walk away with. You have **one** decision written **once** -in the pattern. Three entirely separate pieces of code, living in different packages -and run at different times, each pick that pattern up and read that decision for their -own purposes. **They do not share code.** They each independently walk the JSON. That -is why a single conceptual feature — "understand `items`" — had to be implemented -three separate times, and why bugs cluster at the seams. - -### 4a. Validation — a static check of the pattern itself - -**Where:** `shared/src/spectral/` (rules in `rules-pattern.ts`, helper functions in -`functions/pattern/`). - -Validation checks the pattern *before* anyone builds anything from it, the way a -spell-checker catches typos: are all node ids unique, does every id a decision points -to actually exist, is every decision-referenced node wrapped in a choice block, and so -on. It is built on **Spectral**, a tool for writing rules that walk a JSON document. - -Those rules navigate the pattern using little address strings (think file paths into -the JSON) such as `$.properties.nodes.prefixItems[*].properties.unique-id.const`. The -historical blindness lived exactly here: every address string was written to walk down -through `prefixItems`, and none knew to also walk through `items`. So catalog -candidates were not *rejected* — they were genuinely *invisible*. If you gave the cache -and the queue the same id, the "ids must be unique" rule sailed past it, because the -rule only looked in the `prefixItems` neighbourhood and the duplicate was in the -`items` neighbourhood. The fix was mechanical: add parallel address strings that also -walk `items.oneOf`/`items.anyOf`, so the existing rules now visit both neighbourhoods. - -That mechanical fix had to be applied to *every* validation helper that resolves an id -against the node/relationship arrays, not just one — this is exactly the "fix one -consumer, forget the rest" trap this document warns about, and it is easy to miss a -call site. The helpers updated are: `ids-are-unique.ts` (duplicate detection), the -`pattern-nodes-must-be-referenced` rule's `given` (unused-node warning), -`node-id-exists.ts` (does a referenced node exist — used by the `connects`, -`deployed-in`/`composed-of`, and actor/container rules, and by decision `nodes.const` -references), and `interface-id-exists-on-node.ts` (does a referenced interface exist on -the node). `is-defined-in-oneof-or-anyof.ts` was deliberately left unchanged: its check -only fires when an id is *also* present as a plain `prefixItems` name, which a -catalog-only id never is, so it correctly no-ops for catalog candidates rather than -false-positiving. - -### 4b. Generation — pattern + human choices → concrete architecture - -**Where:** `shared/src/commands/generate/` (`generate.ts` orchestrates; -`components/options.ts` reads and narrows decisions; `components/instantiate.ts` -materializes; `components/flatten-allof.ts` pre-flattens `allOf`). - -Generation takes a pattern plus a human's answers and produces a concrete -architecture. It runs as a small pipeline: - -1. **Read the decisions** out of the pattern (`extractOptions`) so it knows what to - ask ("cache? queue?"). -2. **Narrow** the pattern to the answers (`selectChoices` → `flattenCalmItems`): for - every choice the human *did* pick, keep the corresponding candidate; for every - alternative they did *not* pick, throw it away. After narrowing there are no open - choices left — the pattern is collapsed to exactly what was selected. -3. **Materialize** (`instantiate`): turn the narrowed schema's `const` values into - concrete instance data — the actual architecture document. - -The narrowing step used to reach specifically for `prefixItems`. The chosen approach -for `items` is deliberately economical: during narrowing, the selected catalog -candidates are **moved into `prefixItems`** and the now-empty `items` is deleted. In -other words, once the human has decided exactly which extras they want, those extras -really *are* just a fixed list, so converting the open-catalog form into the positional -form is legitimate — and it means the materialization step did not have to change at -all, because by the time it runs everything is `prefixItems` again. - -There is one seam worth remembering (see Limitation 1 below): narrowing only runs when -choices are actually supplied. Generating with no choices at all skips it, so an -array described *only* by `items` never gets converted, and `instantiate` must be able -to cope with an array that has no `prefixItems`. - -### 4c. Visualisation — draw the pattern as boxes and arrows - -**Where:** `calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts`. - -Visualisation draws the pattern on screen in CALM Hub so a human can see its shape, -including where the choices are. The concept that matters is the **decision group**: -the visual box drawn around a set of alternatives to say "these are the options for one -choice." - -When the visualiser reads the pattern and sees a `oneOf`/`anyOf` block, it gathers -those alternatives, stamps each with a shared "you belong to decision group N" label, -and later draws a box around everyone carrying that label. Separately it reads the -decision's prompt and choice descriptions and attaches them to the group so the box can -show the question. The same blindness applied: the grouping code only looked inside -`prefixItems` slots, so an `items` catalog produced no group, no box, and — because the -prompt is attached to a group that was never created — the decision silently failed to -render. The fix teaches extraction to build a group from the `items` catalog too -(`getItems`, `extractNodeDecisionGroup`), and reworks how a decision's referenced ids -are resolved onto a group (`foldOptionsMetadataIntoDecisionGroups`). This was the -largest and most delicate of the three changes, because grouping is genuinely stateful -— nodes move between groups, groups are created, empty groups are cleaned up. - ---- - -## 5. Current limitations and sharp edges - -None of these affect any pattern that exists in the repository today; they are corners -you can reach only by writing unusual patterns. They are recorded here so the next -person is not surprised. - -1. **(Fixed) An all-optional array generated with no selections.** A nodes array - declared *entirely* through an `items` catalog (no mandatory `prefixItems` nodes), - generated without choosing anything, used to emit `nodes: {}` (an object) instead of - `nodes: []` (an array), because narrowing never ran and `instantiate` fell back to - object-instantiation. `instantiate.ts` now returns `[]` for any array with no - `prefixItems`. Covered by a regression test in `instantiate.spec.ts`. - -2. **One decision spanning two `prefixItems` slots now renders as a single box.** The - visualiser folds *all* of a decision's referenced nodes into one group. Every real - pattern maps each decision to one slot, so nothing changes today; but if a single - decision's choices reach across two separate `oneOf` slots, the old code drew two - boxes (labelling only one) and the new code draws one unified box. This is arguably - more correct — a single decision should be a single box — but it is a visible - change, so mention it in review when it first occurs. - -2b. **Two decisions drawing from one shared `items` catalog merge into a single box.** - `extractNodesFromPattern` assigns every candidate in a nodes `items` catalog to a - single decision group (`node-decision-items`) at extraction time. If two *separate* - options relationships reference disjoint subsets of that one catalog (e.g. a "pick a - cache" decision and a "pick a queue" decision both drawn from one catalog), both fold - into that single group, so the visualiser draws one merged box and only the - last-processed decision's prompt/choices survive. The nodes are still all drawn; only - the grouping/labelling is wrong. Rendering a single catalog as multiple independent - decisions needs per-decision group keying and is tracked with the decision-group - rework in the visual-nesting follow-up. The one-catalog-one-decision case (the common - shape) is unaffected. - -3. **(Fixed) A catalog node that is also a container child draws inside the container.** - If a node is both an optional pick *and* declared to live inside a container (via - `deployed-in`/`composed-of`), the container now takes precedence, so the node renders - nested inside its container rather than in the choice box. Earlier the decision group - won; `createReactFlowNodes` now computes `parentMap.get(...) || node.decisionGroupId` - and additionally suppresses any decision box left empty because all its members were - pulled into containers, so no empty box is drawn. Covered by two tests in - `patternTransformer.test.ts`. (This precedence only exists for *pattern* - visualisation; architectures are drawn by the separate `calmTransformer.ts`, which - has no decision-group concept because all choices are already resolved.) - -4. **`minItems`/`maxItems` are not rewritten during narrowing.** If a pattern author - sets, say, `maxItems: 2` thinking only of the mandatory nodes, and a user then picks - optional add-ons, the array grows past that limit *while being assembled*. The final - architecture is still correct, because it is validated against the *original* - pattern (whose `items` + `minItems` accept the extras) — but authors should size any - `maxItems` to account for optional picks, or omit it. - -5. **`items` is not merged across `allOf` branches.** `flatten-allof.ts` deep-merges - `prefixItems` by position but merges `properties` shallowly, so if the definition of - a single node array is split across two `allOf` branches, the later branch's array - definition replaces the earlier one wholesale (not just its `items`) — the earlier - branch's candidates are silently lost. Realistic patterns declare a node array once - in one place, so this does not arise; it is flagged in a code comment in - `flatten-allof.ts`. - ---- - -## 6. If you change this mechanism — a checklist - -Because the three consumers do not share code, a change to decision handling is not -complete until you have considered all three: - -- **Validation** (`shared/src/spectral/`): will the new shape be seen by the JSONPath - address strings in `rules-pattern.ts` and the `functions/pattern/*` helpers? A new - place to declare candidates usually needs new `given`/JSONPath entries. -- **Generation** (`shared/src/commands/generate/`): can `extractOptions` read the - decision, can `selectChoices`/`flattenCalmItems` narrow it, and does `instantiate` - materialize the result into valid instance JSON (an array, not an object)? -- **Visualisation** (`calm-hub-ui/.../patternTransformer.ts`): does extraction build a - decision group for the new shape, and does the fold step attach the prompt to it - without stranding metadata or drawing empty boxes? -- **Tests**: `shared` is consumed by the CLI and the VSCode extension, so run the full - `npm test` from the repo root after touching `shared`, not just the shared workspace. diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 4d889851c..00ebf149e 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -45,7 +45,6 @@ npx vitest run ${TEST FILE} - `validate()` - Main validation function (`commands/validate/validate.ts`) used by CLI and VSCode - `enrichWithDocumentPositions()` - Adds precise line/character positions to validation output using `@stoplight/json` - `parseDocumentWithPositions()` - Parses JSON/YAML with position tracking for error location - - Pattern **options/decisions** (`prefixItems` vs `items` catalogs) are validated here, generated in `commands/generate/`, and drawn in `calm-hub-ui`. All three read the same construct independently — see [`docs/PATTERNS_OPTIONS_AND_DECISIONS.md`](../docs/PATTERNS_OPTIONS_AND_DECISIONS.md) before changing decision handling. - **Schema Directory** (`schema-directory.ts`): Registry of bundled CALM schemas, used for lookup by schema URL (`getSchema`). - **Docify** (`docify/`): Documentation generator (`docifier`) with C4/relationship graphing (`docify/graphing`) and template bundles (`docify/template-bundles`, e.g. `ants`, `docusaurus`). - **Resolver** (`resolver/`): CALM reference resolver plus the network-addressable extractor and validator. From a37a3dc18e27f9082fdf5a36843f0fbb472410a7 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 3 Aug 2026 21:34:19 +0000 Subject: [PATCH 05/41] fix(shared): guard const-bearing arrays and close items-catalog test gaps Addresses nits from an independent review of #2932: - instantiate.ts: check const before the bare-array fallback so an array schema carrying a const materializes its value instead of being emptied to []. Latent ordering issue introduced with the earlier all-optional-array fix; not reachable by today's node/relationship arrays but fixed for safety, with a regression test. - ids-are-unique.spec.ts: add the missing relationships items.oneOf duplicate case (the code path existed but was untested). - patternTransformer.test.ts: cover an items catalog with no options relationship (extracted-but-never-folded group renders as a labelled box with no prompt). - ids-are-unique.ts: add the missing trailing newline. Refs #2859 --- .../utils/patternTransformer.test.ts | 28 +++++++++++++++++++ .../generate/components/instantiate.spec.ts | 20 +++++++++++++ .../generate/components/instantiate.ts | 8 ++++-- .../functions/pattern/ids-are-unique.spec.ts | 24 ++++++++++++++++ .../functions/pattern/ids-are-unique.ts | 2 +- 5 files changed, 78 insertions(+), 4 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts index c37941106..fb395b3f0 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts @@ -182,6 +182,34 @@ describe('parsePatternData', () => { expect(groupNodes[0].data.decisionType).toBe('anyOf'); }); + it('creates a decision group box for an items catalog with no options relationship', () => { + // An items.oneOf catalog with no decision referencing it still renders as a + // oneOf-labelled group box (no prompt), exercising the extract-but-never-folded path. + const pattern = { + properties: { + nodes: { + items: { + oneOf: [ + schemaNode('cache', 'Cache', 'service'), + schemaNode('queue', 'Queue', 'service'), + ], + }, + }, + relationships: { prefixItems: [] }, + }, + }; + const result = parsePatternData(pattern); + + const groupNodes = result.nodes.filter((n) => n.type === 'decisionGroup'); + expect(groupNodes).toHaveLength(1); + expect(groupNodes[0].data.decisionType).toBe('oneOf'); + expect(groupNodes[0].data.prompt).toBeUndefined(); + + const regularNodes = result.nodes.filter((n) => n.type === 'custom'); + expect(regularNodes).toHaveLength(2); + expect(regularNodes.every((n) => n.parentId === groupNodes[0].id)).toBe(true); + }); + it('creates edges from connects relationships', () => { const pattern = makePattern( [ diff --git a/shared/src/commands/generate/components/instantiate.spec.ts b/shared/src/commands/generate/components/instantiate.spec.ts index 07dc48a54..4c1e947b4 100644 --- a/shared/src/commands/generate/components/instantiate.spec.ts +++ b/shared/src/commands/generate/components/instantiate.spec.ts @@ -385,6 +385,26 @@ describe('instantiate', () => { expect(result.nodes.map((n) => n['unique-id'])).toEqual(['cache']); }); + it('materializes an array-typed property that carries a const rather than emptying it to []', async () => { + // Guards branch ordering in instantiateFromProperties: the const check must + // run before the bare-array fallback, or an array carrying a const would be + // wrongly emitted as []. + const patternWithConstArray = { + $schema: 'schema#', + $id: 'const-array-pattern', + properties: { + nodes: { type: 'array', prefixItems: [] }, + relationships: { type: 'array', prefixItems: [] }, + 'some-array': { type: 'array', const: ['a', 'b'] } + } + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await instantiate(patternWithConstArray, true, new SchemaDirectory({} as any)) as TestInstantiatedPattern; + + expect(result['some-array']).toEqual(['a', 'b']); + }); + it('emits an empty array (not an object) for an all-optional catalog generated with no selections', async () => { // Regression guard: a nodes array declared entirely through an items // catalog (no prefixItems) that is instantiated WITHOUT selectChoices diff --git a/shared/src/commands/generate/components/instantiate.ts b/shared/src/commands/generate/components/instantiate.ts index 87152f511..dac34391e 100644 --- a/shared/src/commands/generate/components/instantiate.ts +++ b/shared/src/commands/generate/components/instantiate.ts @@ -114,6 +114,11 @@ async function instantiateFromProperties( return await instantiateObject(resolvedItem, schemaDir, [key, `${idx}`]); }) ); + } else if (resolvedDef.const !== undefined) { + // const value at the top level. Checked before the bare-array fallback + // below so an array schema that carries a `const` still materializes its + // const value rather than being emptied to []. + output[key] = resolvedDef.const; } else if (resolvedDef.type === 'array') { // An array with no `prefixItems` to materialize - e.g. a nodes array // whose entries are declared entirely through an `items.oneOf`/`anyOf` @@ -124,9 +129,6 @@ async function instantiateFromProperties( // producing a structurally invalid architecture (an object where an // array is required). output[key] = []; - } else if (resolvedDef.const !== undefined) { - // const value at the top level - output[key] = resolvedDef.const; } else { output[key] = await instantiateObject(resolvedDef, schemaDir, [key]); } diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts index 67f12dc3c..4059d2688 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts @@ -147,6 +147,30 @@ describe('idsAreUnique', () => { expect(result[0].message).toContain('Duplicate unique-id detected. ID: cache, path: /properties/nodes/items/oneOf/1/properties/unique-id/const'); }); + it('should return messages for duplicate IDs within relationships.items.oneOf', () => { + const input = {}; + const context = { + document: { + data: { + properties: { + relationships: { + items: { + oneOf: [ + {'properties': {'unique-id': {'const': 'edge'}}}, + {'properties': {'unique-id': {'const': 'edge'}}} + ] + } + } + } + } + } + }; + + const result = idsAreUnique(input, null, asContext(context)); + expect(result.length).toBeGreaterThan(0); + expect(result[0].message).toContain('Duplicate unique-id detected. ID: edge, path: /properties/relationships/items/oneOf/1/properties/unique-id/const'); + }); + it('should return messages for duplicate IDs across a prefixItems node and an items.oneOf node', () => { const input = {}; const context = { diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.ts b/shared/src/spectral/functions/pattern/ids-are-unique.ts index fbe3d5137..53fa3110b 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.ts @@ -34,4 +34,4 @@ export default (input: unknown, _: unknown, context: RulesetFunctionContext): IF detectDuplicates(interfaceItemsAnyOfIdMatches, seenIds, messages); return messages; -}; \ No newline at end of file +}; From fd5d5488f7dd41ecef54507efba6841de778e866 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Thu, 6 Aug 2026 15:47:16 +0000 Subject: [PATCH 06/41] fix: guard relationships-less patterns and classify catalog-only patterns Address review findings on the items-catalog decision-support PR. - generate: guard the relationships access in flattenOptionsRelationships so a pattern whose nodes are declared entirely through an items catalog (and that carries no relationships property) no longer throws during selectChoices. - calm-hub-ui: widen isPatternData to accept nodes.items as well as nodes.prefixItems, so a catalog-only pattern dropped as a file is routed to the PatternVisualizer instead of being misclassified as an architecture. - Close test gaps: relationships items-catalog narrowing, a nodes-only catalog with no relationships property, the items.anyOf node catalog UI path, the dashed rel-decision-items edge path, and the catalog-only file classification. --- .../components/drawer/Drawer.test.tsx | 25 ++++++++++ .../visualizer/components/drawer/Drawer.tsx | 10 +++- .../utils/patternTransformer.test.ts | 47 +++++++++++++++++++ .../generate/components/options.spec.ts | 33 +++++++++++++ .../commands/generate/components/options.ts | 9 +++- 5 files changed, 121 insertions(+), 3 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx b/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx index 41020e9ee..952d2b085 100644 --- a/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx +++ b/calm-hub-ui/src/visualizer/components/drawer/Drawer.test.tsx @@ -41,6 +41,9 @@ vi.mock('../reactflow/ReactFlowVisualizer.js', () => ({ ), })); +vi.mock('../reactflow/PatternVisualizer.js', () => ({ + PatternVisualizer: () =>
, +})); vi.mock('react-dropzone', async () => { const actual = await vi.importActual('react-dropzone'); return { @@ -195,6 +198,28 @@ describe('Drawer', () => { expect(screen.queryByText(/Couldn't read that file/i)).not.toBeInTheDocument(); expect(screen.getByTestId('reactflow-visualizer')).toBeInTheDocument(); }); + + it('classifies a catalog-only pattern (nodes via items, no prefixItems) dropped as a file as a pattern', async () => { + render(); + + // A pattern whose nodes are declared solely through an `items` catalog has no + // `prefixItems`. It must still be routed to the PatternVisualizer, not the + // architecture ReactFlowVisualizer, on the file-upload path. + await act(async () => { + await mockDropzone.onDrop?.([ + fakeFile( + JSON.stringify({ + properties: { + nodes: { items: { oneOf: [{ properties: { 'unique-id': { const: 'cache' } } }] } }, + }, + }) + ), + ]); + }); + + expect(screen.getByTestId('pattern-visualizer')).toBeInTheDocument(); + expect(screen.queryByTestId('reactflow-visualizer')).not.toBeInTheDocument(); + }); }); describe('Drawer — decorator fetching', () => { diff --git a/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx b/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx index 26b318040..c596b7e29 100644 --- a/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx +++ b/calm-hub-ui/src/visualizer/components/drawer/Drawer.tsx @@ -12,13 +12,19 @@ import type { DrawerProps, Flow, Control, Decorator } from '../../contracts/cont /** * Detect whether JSON data is a CALM pattern (JSON Schema) or an architecture instance. - * Patterns have properties.nodes.prefixItems; architectures have nodes directly. + * A pattern declares its nodes as a JSON Schema array — either as positional + * `prefixItems` slots or as an open `items` catalog (or both); an architecture has + * `nodes` directly as a plain array of instances. A catalog-only pattern has no + * `prefixItems`, so `items` must be accepted too, otherwise it would be misclassified + * as an architecture on the file-upload path (the Hub path is saved separately by the + * `calmType === 'Patterns'` fallback below). */ function isPatternData(data: unknown): boolean { if (!data || typeof data !== 'object') return false; const obj = data as Record; const props = obj['properties'] as Record | undefined; - return !!(props?.['nodes'] && typeof props['nodes'] === 'object' && (props['nodes'] as Record)['prefixItems']); + const nodes = props?.['nodes'] as Record | undefined; + return !!(nodes && typeof nodes === 'object' && (nodes['prefixItems'] || nodes['items'])); } /** diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts index fb395b3f0..8c0e10b4b 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts @@ -210,6 +210,53 @@ describe('parsePatternData', () => { expect(regularNodes.every((n) => n.parentId === groupNodes[0].id)).toBe(true); }); + it('creates an anyOf decision group for an items.anyOf node catalog', () => { + // The UI-side anyOf catalog path: nodes declared through items.anyOf must + // produce an anyOf-typed decision group whose candidates parent into it, + // mirroring the oneOf case above. + const pattern = makePatternWithItems( + [], + [ + schemaNode('redis', 'Redis', 'service'), + schemaNode('kafka', 'Kafka', 'service'), + ], + [], + [], + 'anyOf' + ); + const result = parsePatternData(pattern); + + const groupNodes = result.nodes.filter((n) => n.type === 'decisionGroup'); + expect(groupNodes).toHaveLength(1); + expect(groupNodes[0].data.decisionType).toBe('anyOf'); + + const regularNodes = result.nodes.filter((n) => n.type === 'custom'); + expect(regularNodes).toHaveLength(2); + expect(regularNodes.every((n) => n.parentId === groupNodes[0].id)).toBe(true); + }); + + it('renders edges from a relationships items catalog as dashed decision edges', () => { + // Relationships declared solely through an items.oneOf catalog flow through + // the `rel-decision-items` branch and carry a decisionGroupId, so their + // edges must render dashed (strokeDasharray '5,5') — unlike the solid edge a + // plain prefixItems connects relationship produces. + const pattern = makePatternWithItems( + [ + schemaNode('node-1', 'Node 1', 'service'), + schemaNode('node-2', 'Node 2', 'service'), + ], + [], + [], + [connectsRelationship('rel-cat', 'node-1', 'node-2')] + ); + const result = parsePatternData(pattern); + + expect(result.edges).toHaveLength(1); + expect(result.edges[0].source).toBe('node-1'); + expect(result.edges[0].target).toBe('node-2'); + expect(result.edges[0].style?.strokeDasharray).toBe('5,5'); + }); + it('creates edges from connects relationships', () => { const pattern = makePattern( [ diff --git a/shared/src/commands/generate/components/options.spec.ts b/shared/src/commands/generate/components/options.spec.ts index b2dae68a3..3516098e0 100644 --- a/shared/src/commands/generate/components/options.spec.ts +++ b/shared/src/commands/generate/components/options.spec.ts @@ -368,6 +368,39 @@ describe('Pattern Options', () => { expect(result.properties.nodes.prefixItems).toEqual([cache]); }); + it('should move a selected items-catalog relationship into prefixItems and delete items', () => { + const webapp = buildNode('webapp'); + const edge = buildConnectsRelationship('webapp-to-db', 'webapp to db', 'webapp', 'db'); + + const pattern = buildPatternWithItemsCatalog([webapp], [], [], [edge]); + + const wireChoice: CalmChoice = { description: 'Wire db', nodes: [], relationships: ['webapp-to-db'] }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectChoices(pattern, [wireChoice]) as any; + + expect(result.properties.relationships.prefixItems).toEqual([edge]); + expect(result.properties.relationships.items).toBeUndefined(); + }); + + it('should not throw when a pattern has a nodes items catalog and no relationships property', () => { + const cache = buildNode('cache'); + // No `relationships` property at all - the shape a nodes-only catalog pattern takes. + const pattern = { + 'properties': { + 'nodes': { 'items': { 'oneOf': [cache] } }, + }, + }; + + const cacheChoice: CalmChoice = { description: 'Use cache', nodes: ['cache'], relationships: [] }; + + expect(() => selectChoices(pattern, [cacheChoice])).not.toThrow(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectChoices(pattern, [cacheChoice]) as any; + expect(result.properties.nodes.prefixItems).toEqual([cache]); + }); + it('should not affect a normal pattern', () => { const applicationA = buildNode('application-a'); const applicationB = buildNode('application-b'); diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index 3bbfa214b..55c2e4419 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -168,7 +168,14 @@ function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoic } function flattenOptionsRelationships(pattern: SchemaNode, choices: CalmChoice[]): void { - pattern['properties']['relationships']['prefixItems'] = pattern['properties']['relationships']['prefixItems'] + // Guard the relationships access the same way `flattenCalmItems` guards its + // own: a pattern whose nodes are declared entirely through an `items` catalog + // may carry no `relationships` property at all, and reaching straight through + // to `prefixItems` would throw on that shape. + const relationships: SchemaNode | undefined = pattern['properties']?.['relationships']; + if (!relationships?.['prefixItems']) return; + + relationships['prefixItems'] = relationships['prefixItems'] .map((rel: SchemaNode) => flattenOptionsRelationship(rel, choices)); } From d2c3e4d13cda9156c63b500b355529b404a351f3 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Thu, 6 Aug 2026 16:01:53 +0000 Subject: [PATCH 07/41] feat(shared): surface allOf-dropped items catalogs at debug level When two allOf branches each declare an items open-catalog under the same array property (e.g. both define properties.nodes.items), flattenAllOf's shallow properties merge makes the later branch's catalog win and silently drops the earlier one. Add logDroppedItemsCatalogs to log this at debug level so it is discoverable under --verbose rather than only from a code comment. The detection lives at the properties-merge site where the loss actually occurs, not the top-level items else-branch (which a per-array catalog never reaches). Clarify that else-branch comment accordingly. --- .../generate/components/flatten-allof.spec.ts | 47 +++++++++++++++++++ .../generate/components/flatten-allof.ts | 47 ++++++++++++++++--- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/shared/src/commands/generate/components/flatten-allof.spec.ts b/shared/src/commands/generate/components/flatten-allof.spec.ts index c75fc8f07..3468746e8 100644 --- a/shared/src/commands/generate/components/flatten-allof.spec.ts +++ b/shared/src/commands/generate/components/flatten-allof.spec.ts @@ -2,6 +2,19 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { flattenAllOf } from './flatten-allof'; import { SchemaDirectory } from '../../../schema-directory'; +// Spy on the logger's debug channel so the dropped-catalog warning can be asserted. +// Hoisted so the (hoisted) vi.mock factory below can reference it. +const { mockDebug } = vi.hoisted(() => ({ mockDebug: vi.fn() })); +vi.mock('../../../logger', () => ({ + initLogger: () => ({ + log: vi.fn(), + debug: mockDebug, + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), +})); + // Mock SchemaDirectory const mockSchemaDir = { getDefinition: vi.fn(), @@ -235,4 +248,38 @@ describe('flattenAllOf', () => { level3: { type: 'string' } }); }); + + describe('items open-catalog across allOf', () => { + it('logs a debug warning when two allOf branches each declare a nodes items catalog', async () => { + // Both branches declare `properties.nodes.items`; the shallow properties + // merge makes the later branch's catalog win and silently drops the first. + const schema = { + allOf: [ + { properties: { nodes: { items: { oneOf: [{ const: 'a' }] } } } }, + { properties: { nodes: { items: { oneOf: [{ const: 'b' }] } } } }, + ], + }; + + await flattenAllOf(schema, mockSchemaDir, true); + + expect(mockDebug).toHaveBeenCalledWith( + expect.stringContaining('allOf merge drops an \'items\' catalog on \'nodes\'') + ); + }); + + it('does not warn when only one allOf branch declares a catalog', async () => { + const schema = { + allOf: [ + { properties: { nodes: { items: { oneOf: [{ const: 'a' }] } } } }, + { properties: { relationships: { prefixItems: [] } } }, + ], + }; + + await flattenAllOf(schema, mockSchemaDir, true); + + expect(mockDebug).not.toHaveBeenCalledWith( + expect.stringContaining('drops an \'items\' catalog') + ); + }); + }); }); diff --git a/shared/src/commands/generate/components/flatten-allof.ts b/shared/src/commands/generate/components/flatten-allof.ts index daa7d286f..b31d5d2b5 100644 --- a/shared/src/commands/generate/components/flatten-allof.ts +++ b/shared/src/commands/generate/components/flatten-allof.ts @@ -1,5 +1,5 @@ import { SchemaDirectory } from '../../../schema-directory'; -import { initLogger } from '../../../logger'; +import { initLogger, Logger } from '../../../logger'; interface SchemaWithAllOf { allOf?: object[]; @@ -41,11 +41,14 @@ function deepMergeSchemas( value as unknown[] ); } else { - // `items` (the open oneOf/anyOf catalog) falls through here and is not - // deep-merged the way `prefixItems` is above - a later allOf branch's - // `items` simply replaces an earlier one. Realistic CALM patterns declare - // the catalog once per array, so this is not expected to matter in - // practice; revisit if patterns start composing `items` across allOf. + // Any other key is replaced rather than deep-merged. Note the realistic + // "catalog dropped across allOf" case is NOT a top-level `items` reaching + // here: a catalog lives under an array property (e.g. `properties.nodes.items`), + // so two allOf branches each declaring one collide in the `properties` + // branch above, where the shallow spread makes the later branch's whole + // array win. `logDroppedItemsCatalogs` (called from `flattenAllOf`) surfaces + // that collision at debug level. Realistic patterns declare a catalog once + // per array; revisit if patterns start composing `items` across allOf. result[key] = value; } } @@ -79,6 +82,37 @@ function mergePrefixItems(target: unknown[], source: unknown[]): unknown[] { return result; } +/** + * Surfaces, at debug level, an `items` open-catalog that allOf flattening will + * silently drop. When two allOf branches each declare a catalog under the same + * array property (e.g. both define `properties.nodes.items`), `deepMergeSchemas` + * shallow-merges `properties`, so the later branch's array replaces the earlier + * one wholesale and the earlier catalog is lost rather than combined. Realistic + * patterns declare each catalog once per array, so this is a smell worth making + * discoverable under `--verbose`, not an error. + */ +function logDroppedItemsCatalogs( + target: Record, + source: Record, + logger: Logger +): void { + const targetProps = target['properties'] as Record | undefined; + const sourceProps = source['properties'] as Record | undefined; + if (!targetProps || !sourceProps) return; + + for (const propKey of Object.keys(sourceProps)) { + const targetArray = targetProps[propKey] as Record | undefined; + const sourceArray = sourceProps[propKey] as Record | undefined; + if (targetArray?.['items'] !== undefined && sourceArray?.['items'] !== undefined) { + logger.debug( + `allOf merge drops an 'items' catalog on '${propKey}': a catalog declared in an ` + + 'earlier allOf branch is being replaced by a later branch rather than combined. ' + + 'Declare the catalog once per array to avoid silent loss.' + ); + } + } +} + /** * Recursively flattens allOf schemas into a single merged schema. * Resolves $ref references using the schema directory. @@ -135,6 +169,7 @@ export async function flattenAllOf( resolved = (await flattenAllOf(resolved, schemaDir, debug)) as SchemaWithAllOf; // Deep merge into accumulated result + logDroppedItemsCatalogs(merged, resolved as Record, logger); merged = deepMergeSchemas(merged, resolved); } From 2d3d592ee2e68d9663e7eeec6856fd49c303d582 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sat, 8 Aug 2026 19:17:23 +0000 Subject: [PATCH 08/41] refactor(calm-hub-ui): dedupe pattern schema traversal helpers Behaviour-preserving cleanup of the items-catalog decision code. - Extract getArrayKeyword(pattern, key, keyword) and reduce getPrefixItems and getItems to thin wrappers over it, removing the duplicated allOf-walking shape. The wrappers keep their exact return contracts ([] vs undefined, and the items:false closed-tuple case), so every call site is unchanged. - Extract catalogAlternatives(items) returning { groupType, alternatives } | null and use it in both extractNodesFromPattern and extractRelationshipsFromPattern, removing the repeated Array.isArray(oneOf/anyOf) blocks. No behaviour change: the full calm-hub-ui suite is byte-for-byte identical (114 files, 1371 tests) before and after, typecheck and lint clean. --- .../reactflow/utils/patternTransformer.ts | 76 +++++++++++-------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index 2191a7249..b633a03d4 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -25,21 +25,34 @@ type SchemaObject = Record; // ---- Schema traversal helpers ---- /** - * Gets the prefixItems for a given top-level key (e.g. 'nodes' or 'relationships') - * from a pattern, handling allOf structures. + * Reads an array-valued keyword (e.g. 'prefixItems' or 'items') for a given + * top-level key (e.g. 'nodes' or 'relationships') from a pattern, handling allOf + * structures. Returns the direct declaration if present, otherwise the first + * matching one found across the allOf branches, otherwise undefined. The truthy + * check means a present-but-falsy value (e.g. `items: false` closing a tuple) is + * treated as absent, exactly as the two callers below relied on. */ -function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { - if (pattern['properties']?.[key]?.['prefixItems']) { - return pattern['properties'][key]['prefixItems']; +function getArrayKeyword(pattern: SchemaObject, key: string, keyword: string): SchemaObject | undefined { + if (pattern['properties']?.[key]?.[keyword]) { + return pattern['properties'][key][keyword]; } if (pattern['allOf'] && Array.isArray(pattern['allOf'])) { for (const schema of pattern['allOf']) { - if (schema['properties']?.[key]?.['prefixItems']) { - return schema['properties'][key]['prefixItems']; + if (schema['properties']?.[key]?.[keyword]) { + return schema['properties'][key][keyword]; } } } - return []; + return undefined; +} + +/** + * Gets the prefixItems for a given top-level key (e.g. 'nodes' or 'relationships') + * from a pattern, handling allOf structures. Absent prefixItems yields an empty + * array so callers can iterate unconditionally. + */ +function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { + return getArrayKeyword(pattern, key, 'prefixItems') ?? []; } /** @@ -48,17 +61,25 @@ function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { * from a pattern, handling allOf structures. */ function getItems(pattern: SchemaObject, key: string): SchemaObject | undefined { - if (pattern['properties']?.[key]?.['items']) { - return pattern['properties'][key]['items']; - } - if (pattern['allOf'] && Array.isArray(pattern['allOf'])) { - for (const schema of pattern['allOf']) { - if (schema['properties']?.[key]?.['items']) { - return schema['properties'][key]['items']; - } - } + return getArrayKeyword(pattern, key, 'items'); +} + +/** + * Reads an `items` open-catalog's decision alternatives. Returns the group type + * (`oneOf`/`anyOf`) and the alternatives array, or null when the catalog is + * neither. Mirrors the original inline logic exactly: `oneOf` wins when both are + * present, and either keyword being a non-array leaves the catalog untreated. + */ +function catalogAlternatives(items: SchemaObject): { groupType: 'oneOf' | 'anyOf'; alternatives: SchemaObject[] } | null { + const hasOneOf = Array.isArray(items['oneOf']); + const hasAnyOf = Array.isArray(items['anyOf']); + if (!hasOneOf && !hasAnyOf) { + return null; } - return undefined; + return { + groupType: hasOneOf ? 'oneOf' : 'anyOf', + alternatives: hasOneOf ? items['oneOf'] : items['anyOf'], + }; } /** @@ -238,13 +259,9 @@ function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[ // that aren't tied to a specific positional slot. Treat the whole catalog as // a single decision-group slot. if (items) { - const hasOneOf = Array.isArray(items['oneOf']); - const hasAnyOf = Array.isArray(items['anyOf']); - - if (hasOneOf || hasAnyOf) { - const groupType: 'oneOf' | 'anyOf' = hasOneOf ? 'oneOf' : 'anyOf'; - const alternatives: SchemaObject[] = hasOneOf ? items['oneOf'] : items['anyOf']; - extractNodeDecisionGroup(alternatives, 'node-decision-items', groupType, nodes, decisionGroups); + const catalog = catalogAlternatives(items); + if (catalog) { + extractNodeDecisionGroup(catalog.alternatives, 'node-decision-items', catalog.groupType, nodes, decisionGroups); } } @@ -426,12 +443,9 @@ function extractRelationshipsFromPattern(pattern: SchemaObject): { // Open catalog: `items.oneOf`/`items.anyOf` declares zero-or-more relationship // candidates not tied to a specific positional slot. if (items) { - const hasOneOf = Array.isArray(items['oneOf']); - const hasAnyOf = Array.isArray(items['anyOf']); - - if (hasOneOf || hasAnyOf) { - const alternatives: SchemaObject[] = hasOneOf ? items['oneOf'] : items['anyOf']; - extractRelationshipDecisionGroup(alternatives, 'rel-decision-items', relationships); + const catalog = catalogAlternatives(items); + if (catalog) { + extractRelationshipDecisionGroup(catalog.alternatives, 'rel-decision-items', relationships); } } From 846e377e02eecd68be9e8900eee526f479edb2b0 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sun, 16 Aug 2026 21:01:12 +0000 Subject: [PATCH 09/41] fix(shared): reject a pattern decision holder declared inside an items catalog A decision holder (a relationship carrying relationship-type.options) states a decision the architect must actively make, even when the answer is to add none of the candidates. Declaring it inside an items catalog is valid JSON Schema but makes the decision itself optional: calm generate never offers it, and the pattern no longer requires the resulting architecture to contain it. Add pattern-option-relationship-must-be-in-prefix-items (error) rejecting a decision holder declared under relationships.items.oneOf/anyOf, including inside an allOf branch. Document the invariant in shared/AGENTS.md and calm-ai/tools/pattern-creation.md so pattern authors and the pattern-writing agent both see it. --- calm-ai/tools/pattern-creation.md | 64 +++++++++++- shared/AGENTS.md | 17 ++++ shared/src/spectral/rules-pattern.spec.ts | 114 ++++++++++++++++++++++ shared/src/spectral/rules-pattern.ts | 20 +++- 4 files changed, 213 insertions(+), 2 deletions(-) create mode 100644 shared/src/spectral/rules-pattern.spec.ts diff --git a/calm-ai/tools/pattern-creation.md b/calm-ai/tools/pattern-creation.md index 809500ae1..b20767e07 100644 --- a/calm-ai/tools/pattern-creation.md +++ b/calm-ai/tools/pattern-creation.md @@ -157,11 +157,73 @@ When you instead want an **open catalog** of optional nodes — "include any com } ``` -Here `webapp` and `database` are always present, while `cache` and `queue` form an optional catalog: an instantiated architecture may include neither, either, or both. A decision (`relationship-type.options`, see below) references catalog candidates by `unique-id` in exactly the same way it references positional ones, so the same decision mechanism works for both. Relationship candidates can use an `items` catalog in the same way. +Here `webapp` and `database` are always present, while `cache` and `queue` form an optional catalog: an instantiated architecture may include neither, either, or both. + +A catalog on its own does nothing. Two different kinds of object are involved, and it is worth being precise about which is which: + +- A **candidate** is a concrete node or relationship that may or may not end up in the generated architecture. Candidates are what an `items` catalog holds. Relationship candidates can use an `items` catalog exactly as node candidates do. +- A **decision holder** is a relationship carrying `relationship-type.options`. It is not part of the architecture being described — it asks the user a question and lists the choice bundles that answer it. Each bundle names candidates by `unique-id`. + +A candidate is included in the output only when a chosen bundle names its `unique-id`. So every catalog needs a decision holder pointing at it, and **a decision holder must be declared in `properties.relationships.prefixItems`** — never inside an `items` catalog itself. A holder is the mechanism that drives generation, so it must always be present; putting it in a catalog makes the question itself optional, and `calm generate` will not offer it. + +Use `anyOf` inside the holder's `options` for a zero-or-more catalog (the user may pick any combination, including none) and `oneOf` where exactly one candidate must be chosen. The holder that drives the catalog above looks like this: + +```json +{ + "properties": { + "relationships": { + "type": "array", + "prefixItems": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/relationship", + "type": "object", + "properties": { + "unique-id": { "const": "optional-components" }, + "description": { "const": "Which optional components do you want?" }, + "relationship-type": { + "type": "object", + "properties": { + "options": { + "type": "array", + "prefixItems": [ + { + "anyOf": [ + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/decision", + "type": "object", + "properties": { + "description": { "const": "Add a cache" }, + "nodes": { "const": ["cache"] }, + "relationships": { "const": [] } + } + }, + { + "$ref": "https://calm.finos.org/release/1.2/meta/core.json#/defs/decision", + "type": "object", + "properties": { + "description": { "const": "Add a message queue" }, + "nodes": { "const": ["queue"] }, + "relationships": { "const": [] } + } + } + ] + } + ] + } + } + } + } + } + ] + } + } +} +``` Guidance: - Keep every candidate that a single decision references within one declaration site. A decision whose candidates are split between a `prefixItems` slot and an `items` catalog, or spread across nodes with inconsistent container membership, is a pattern smell — model the choice at one consistent level. +- Declare every decision holder (a relationship with `relationship-type.options`) in `properties.relationships.prefixItems`. A catalog with no holder pointing at it can never be selected from, and `calm validate` warns that its candidates are unreferenced. - Duplicate `unique-id`s inside an `items` catalog are rejected by `calm validate`, and a catalog node that no relationship or decision references produces a warning, exactly as for `prefixItems`. ### Relationship Options with Decision Points diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 00ebf149e..cbec16a0e 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -45,6 +45,7 @@ npx vitest run ${TEST FILE} - `validate()` - Main validation function (`commands/validate/validate.ts`) used by CLI and VSCode - `enrichWithDocumentPositions()` - Adds precise line/character positions to validation output using `@stoplight/json` - `parseDocumentWithPositions()` - Parses JSON/YAML with position tracking for error location +- **Generate** (`commands/generate/`): Pattern → architecture instantiation. `flatten-allof.ts` composes `allOf` branches, `options.ts` resolves user decisions (`extractOptions`, `selectChoices`), and `instantiate.ts` materializes the result. See [Pattern Decisions](#pattern-decisions) before changing any of these. - **Schema Directory** (`schema-directory.ts`): Registry of bundled CALM schemas, used for lookup by schema URL (`getSchema`). - **Docify** (`docify/`): Documentation generator (`docifier`) with C4/relationship graphing (`docify/graphing`) and template bundles (`docify/template-bundles`, e.g. `ants`, `docusaurus`). - **Resolver** (`resolver/`): CALM reference resolver plus the network-addressable extractor and validator. @@ -52,6 +53,22 @@ npx vitest run ${TEST FILE} - **View Model** (`view-model/`): ADR (Architecture Decision Record) view-model logic. - **Auth** (`auth/`): Auth plugin abstraction (`auth-plugin`, `no-auth-plugin`). +## Pattern Decisions + +A pattern expresses choice through two distinct kinds of object. Confusing them causes silent failures. + +A **decision holder** is a relationship carrying `relationship-type.properties.options`. It is not part of the architecture — it poses a question and lists choice bundles, each naming candidates by `unique-id`. A **candidate** is a concrete node or relationship that may or may not reach the output; candidates live either in a `prefixItems` slot or in an `items.oneOf`/`items.anyOf` open catalog. + +**Invariant: a decision holder must be declared in `properties.relationships.prefixItems`.** `extractOptions` (`options.ts:77`) discovers decisions solely via `getRelationshipsPrefixItems` (`options.ts:53`), which reads that path and nothing else. A holder placed inside an `items` catalog is never offered by `calm generate`, with no error on any path — the CLI takes its choices only from `promptUserForOptions` or `loadChoicesFromInput` (`cli/src/command-helpers/generate-options.ts:29,58`), and both go through `extractOptions`. + +`allOf` composition is only partly supported here, and the support is narrower than it looks. `getRelationshipsPrefixItems` falls back to scanning `allOf` branches, but it `return`s on the **first** branch declaring `relationships.prefixItems` — so a holder in a later branch is invisible when an earlier branch also declares that path (measured: `extractOptions` returns `[]`). Note also that `extractOptions` runs on the **raw** pattern in the CLI, before `runGenerate` calls `flattenAllOf`, so no change to `allOf` flattening can repair this. Treat `allOf` for relationships as unsupported until that lookup unions across branches. + +A candidate is included only when a chosen bundle names its `unique-id` (`options.ts:136,148`). A catalog with no holder pointing at it is therefore unreachable via `calm generate`; it remains reachable programmatically, since `selectChoices` accepts hand-built `CalmChoice` objects, which is how most existing tests drive it. When adding tests for decision behaviour, drive them through `extractOptions` rather than hand-building choices, or you will not be testing whether the decision is discoverable at all. + +**Enforcement.** `pattern-option-relationship-must-be-in-prefix-items` (`spectral/rules-pattern.ts`, `error`) rejects a holder declared under `relationships.items.oneOf`/`anyOf`, including inside an `allOf` branch. It exists because the constraint is not expressible in JSON Schema — a catalog containing an options relationship is well-formed, so only the linter can state that the *decision* must be mandatory even when its answer may be empty. The other four decision rules keep their recursive-descent selectors (`$..relationship-type.properties.options...`) and still structurally validate a misplaced holder; that overlap is deliberate, since narrowing them would leave a misplaced holder with fewer diagnostics rather than more. Note that `pattern-nodes-must-be-referenced` (`warn`) does not help here: its function queries `$..relationship-type..*@string()` (`functions/pattern/node-has-relationship.ts:10`), so a holder in the wrong array still counts as referencing its candidates. + +**Three implementations, no shared helper.** Catalog lookup and the oneOf-over-anyOf precedence rule are hand-written in three places: generation (`options.ts:142-148`), linting (`spectral/rules-pattern.ts:139-140` and its functions), and the Hub UI visualizer (`calm-hub-ui/.../patternTransformer.ts:73`, used at `:262` and `:446`). A change to how candidates are located or ordered needs all three, and nothing enforces that. + ## Common Workflows **IMPORTANT**: Always run npm commands from the **repository root** using workspaces, not from within this package directory. diff --git a/shared/src/spectral/rules-pattern.spec.ts b/shared/src/spectral/rules-pattern.spec.ts new file mode 100644 index 000000000..4b90a14a6 --- /dev/null +++ b/shared/src/spectral/rules-pattern.spec.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from 'vitest'; +import patternRules from './rules-pattern'; +import { runSpectralValidations } from '../commands/validate/validation-helpers'; + +const RULE = 'pattern-option-relationship-must-be-in-prefix-items'; + +/** A decision holder: a relationship carrying `relationship-type.options`. */ +function decisionHolder() { + return { + properties: { + 'unique-id': { const: 'connection-options' }, + 'description': { const: 'Which optional components do you want?' }, + 'relationship-type': { + properties: { + options: { + prefixItems: [{ + anyOf: [{ + properties: { + description: { const: 'Add a cache' }, + nodes: { const: ['cache'] }, + relationships: { const: ['app-to-cache'] } + } + }] + }] + } + } + } + } + }; +} + +/** A plain relationship candidate - legal inside an items catalog. */ +function candidateRelationship(id: string) { + return { properties: { 'unique-id': { const: id }, 'description': { const: `the ${id} link` } } }; +} + +async function ruleCodesFor(pattern: object): Promise { + const result = await runSpectralValidations(JSON.stringify(pattern), patternRules, 'test'); + return result.spectralIssues.map(issue => issue.code); +} + +describe('pattern-option-relationship-must-be-in-prefix-items', () => { + it('passes when the decision holder is in prefixItems and only candidates are in the items catalog', async () => { + const pattern = { + properties: { + nodes: { prefixItems: [] }, + relationships: { + prefixItems: [decisionHolder()], + items: { oneOf: [candidateRelationship('app-to-cache'), candidateRelationship('app-to-queue')] } + } + } + }; + + expect(await ruleCodesFor(pattern)).not.toContain(RULE); + }); + + it('fails when the decision holder is declared inside an items.oneOf catalog', async () => { + const pattern = { + properties: { + nodes: { prefixItems: [] }, + relationships: { + prefixItems: [], + items: { oneOf: [decisionHolder(), candidateRelationship('app-to-cache')] } + } + } + }; + + expect(await ruleCodesFor(pattern)).toContain(RULE); + }); + + it('fails when the decision holder is declared inside an items.anyOf catalog', async () => { + const pattern = { + properties: { + nodes: { prefixItems: [] }, + relationships: { + prefixItems: [], + items: { anyOf: [decisionHolder()] } + } + } + }; + + expect(await ruleCodesFor(pattern)).toContain(RULE); + }); + + it('fails when the misplaced decision holder sits inside an allOf branch', async () => { + const pattern = { + allOf: [{ + properties: { + nodes: { prefixItems: [] }, + relationships: { + prefixItems: [], + items: { oneOf: [decisionHolder()] } + } + } + }] + }; + + expect(await ruleCodesFor(pattern)).toContain(RULE); + }); + + it('does not fire for a nodes items catalog, which never holds decisions', async () => { + const pattern = { + properties: { + nodes: { + prefixItems: [], + items: { oneOf: [{ properties: { 'unique-id': { const: 'cache' }, 'description': { const: 'a cache' } } }] } + }, + relationships: { prefixItems: [decisionHolder()] } + } + }; + + expect(await ruleCodesFor(pattern)).not.toContain(RULE); + }); +}); diff --git a/shared/src/spectral/rules-pattern.ts b/shared/src/spectral/rules-pattern.ts index 61c0fc6ff..c922bf512 100644 --- a/shared/src/spectral/rules-pattern.ts +++ b/shared/src/spectral/rules-pattern.ts @@ -1,5 +1,5 @@ import { RulesetDefinition } from '@stoplight/spectral-core'; -import { pattern, truthy, length, xor } from '@stoplight/spectral-functions'; +import { falsy, pattern, truthy, length, xor } from '@stoplight/spectral-functions'; import { numericalPlaceHolder } from './functions/helper-functions'; import nodeIdExists from './functions/pattern/node-id-exists'; import idsAreUnique from './functions/pattern/ids-are-unique'; @@ -202,6 +202,24 @@ const patternRules: RulesetDefinition = { max: 1 }, }, + }, + 'pattern-option-relationship-must-be-in-prefix-items': { + // A decision holder (a relationship carrying `relationship-type.options`) states a + // decision the architect must actively make - even when the answer is "add none". + // Declaring it in an `items` catalog is valid JSON Schema but makes the *decision* + // itself optional, so `calm generate` never offers it and the pattern no longer + // requires the resulting architecture to contain it. Candidates belong in the + // catalog; the decision that selects them belongs in `prefixItems`. + description: 'Options relationships must be declared in relationships.prefixItems, not in an items catalog', + severity: 'error', + message: 'A relationship declaring "relationship-type.options" must be in "properties.relationships.prefixItems". Declaring a decision inside an "items" catalog makes the decision itself optional - move the options relationship into prefixItems and leave only its candidates in the catalog.', + given: [ + '$..relationships.items.oneOf[*].properties.relationship-type.properties.options', + '$..relationships.items.anyOf[*].properties.relationship-type.properties.options', + ], + then: { + function: falsy, + }, } } }; From ed08b0f3ee6d905464ce9290568b228c44cb4b39 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sun, 16 Aug 2026 21:01:31 +0000 Subject: [PATCH 10/41] fix(shared): warn on allOf key discards instead of a silent catalog-only debug log logDroppedItemsCatalogs only fired when both allOf branches declared an items catalog on the same array property, and logged at debug (invisible without --verbose). deepMergeSchemas' properties branch shallow-spreads on any property collision, so type/prefixItems/items etc. can be lost whether or not a catalog is involved, and in either merge direction. Replace it with a generic discarded-key warning threaded through deepMergeSchemas via a warnOnDiscard flag, emitted only from the two allOf-branch-merge call sites (never from $ref-refinement merges or mergePrefixItems' positional recursion, which stay silent as before). A key is discarded when the later definition omits it, when a non-empty object/array isn't contained-or-extended by the later value, or when a scalar is replaced by an object/array; narrowing enum/type/required and redefining a scalar as another scalar are legitimate allOf refinement, not loss. Repoint the existing catalog tests off the now-vacuous debug spy and add cases for a later-branch catalog, a prefixItems+minItems collision with no catalog anywhere, and a $ref-refinement that must stay silent. Document in calm-ai/tools/pattern-creation.md that a property's definition should not be split across allOf branches. --- calm-ai/tools/pattern-creation.md | 1 + .../generate/components/flatten-allof.spec.ts | 102 +++++++- .../generate/components/flatten-allof.ts | 226 +++++++++++++----- 3 files changed, 264 insertions(+), 65 deletions(-) diff --git a/calm-ai/tools/pattern-creation.md b/calm-ai/tools/pattern-creation.md index b20767e07..f09795b30 100644 --- a/calm-ai/tools/pattern-creation.md +++ b/calm-ai/tools/pattern-creation.md @@ -781,6 +781,7 @@ The CLI will prompt for choices when encountering `anyOf`/`oneOf` options, or yo - `oneOf` - Exactly one option must be true - `allOf` - All conditions must be true - Placed inside a `prefixItems` slot, `oneOf`/`anyOf` chooses which node fills that fixed position; placed inside `items`, they define the optional catalog an entry may be drawn from +- Do not split a single property's definition (e.g. `properties.nodes`) across separate `allOf` branches — the merge is shallow, so the later branch's definition replaces the earlier one wholesale rather than combining them. `allOf` is unsupported for `relationships`: decision holders are only discovered in `properties.relationships.prefixItems` on the raw pattern, before `allOf` is flattened ### Schema References diff --git a/shared/src/commands/generate/components/flatten-allof.spec.ts b/shared/src/commands/generate/components/flatten-allof.spec.ts index 3468746e8..e247e5d5e 100644 --- a/shared/src/commands/generate/components/flatten-allof.spec.ts +++ b/shared/src/commands/generate/components/flatten-allof.spec.ts @@ -2,15 +2,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { flattenAllOf } from './flatten-allof'; import { SchemaDirectory } from '../../../schema-directory'; -// Spy on the logger's debug channel so the dropped-catalog warning can be asserted. -// Hoisted so the (hoisted) vi.mock factory below can reference it. -const { mockDebug } = vi.hoisted(() => ({ mockDebug: vi.fn() })); +// Spy on the logger's debug and warn channels so discarded-key warnings can be asserted. +// Hoisted so the (hoisted) vi.mock factory below can reference them. +const { mockDebug, mockWarn } = vi.hoisted(() => ({ mockDebug: vi.fn(), mockWarn: vi.fn() })); vi.mock('../../../logger', () => ({ initLogger: () => ({ log: vi.fn(), debug: mockDebug, info: vi.fn(), - warn: vi.fn(), + warn: mockWarn, error: vi.fn(), }), })); @@ -249,8 +249,8 @@ describe('flattenAllOf', () => { }); }); - describe('items open-catalog across allOf', () => { - it('logs a debug warning when two allOf branches each declare a nodes items catalog', async () => { + describe('discarded-key warnings across allOf', () => { + it('warns when two allOf branches each declare a nodes items catalog', async () => { // Both branches declare `properties.nodes.items`; the shallow properties // merge makes the later branch's catalog win and silently drops the first. const schema = { @@ -262,8 +262,8 @@ describe('flattenAllOf', () => { await flattenAllOf(schema, mockSchemaDir, true); - expect(mockDebug).toHaveBeenCalledWith( - expect.stringContaining('allOf merge drops an \'items\' catalog on \'nodes\'') + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('allOf merge on property \'nodes\' discarded keys [items] declared in an earlier branch') ); }); @@ -277,9 +277,91 @@ describe('flattenAllOf', () => { await flattenAllOf(schema, mockSchemaDir, true); - expect(mockDebug).not.toHaveBeenCalledWith( - expect.stringContaining('drops an \'items\' catalog') + expect(mockWarn).not.toHaveBeenCalled(); + }); + + it('names prefixItems as discarded, not the catalog, when the catalog is in the later branch', async () => { + // The later branch is the one that survives the merge, so it is the earlier + // branch's prefixItems that is lost here — not the catalog that replaces it. + const schema = { + allOf: [ + { + properties: { + nodes: { + type: 'array', + prefixItems: [{ properties: { 'unique-id': { const: 'a' } } }], + }, + }, + }, + { + properties: { + nodes: { + type: 'array', + items: { oneOf: [{ properties: { 'unique-id': { const: 'b' } } }] }, + }, + }, + }, + ], + }; + + await flattenAllOf(schema, mockSchemaDir, true); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('allOf merge on property \'nodes\' discarded keys [prefixItems] declared in an earlier branch') ); }); + + it('warns and names [type, prefixItems] on a prefixItems + minItems collision with no catalog anywhere', async () => { + const schema = { + allOf: [ + { + properties: { + nodes: { + type: 'array', + prefixItems: [{ properties: { 'unique-id': { const: 'a' } } }], + }, + }, + }, + { properties: { nodes: { minItems: 1 } } }, + ], + }; + + await flattenAllOf(schema, mockSchemaDir, true); + + expect(mockWarn).toHaveBeenCalledWith( + expect.stringContaining('allOf merge on property \'nodes\' discarded keys [type, prefixItems] declared in an earlier branch') + ); + }); + + it('does not warn on a $ref refinement where the resolved def and siblings both declare the same property', async () => { + // allOf: [{ $ref: ..., properties: {...} }] is the idiomatic composition form: + // refining a $ref'd definition with local sibling keys is ordinary JSON Schema, + // not a lossy allOf-branch collision, even though it discards the same keys. + const referencedSchema = { + type: 'object', + properties: { + nodes: { + type: 'array', + prefixItems: [{ properties: { 'unique-id': { const: 'ref-node' } } }], + }, + }, + }; + (mockSchemaDir.getDefinition as ReturnType).mockResolvedValueOnce(referencedSchema); + + const schema = { + allOf: [ + { + $ref: 'https://example.com/base-schema.json', + properties: { + nodes: { minItems: 1 }, + }, + }, + ], + }; + + await flattenAllOf(schema, mockSchemaDir, true); + + expect(mockWarn).not.toHaveBeenCalled(); + }); }); }); diff --git a/shared/src/commands/generate/components/flatten-allof.ts b/shared/src/commands/generate/components/flatten-allof.ts index b31d5d2b5..4af6da3cc 100644 --- a/shared/src/commands/generate/components/flatten-allof.ts +++ b/shared/src/commands/generate/components/flatten-allof.ts @@ -11,12 +11,139 @@ interface SchemaWithAllOf { [key: string]: unknown; } +// Which branch of the merge is discarding keys, so the warning can name the right direction. +type DiscardWarning = false | 'allOf-branch' | 'root-override'; + +const VALUE_SET_KEYWORDS = new Set(['enum', 'type', 'required']); + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isStructural(value: unknown): boolean { + return Array.isArray(value) || isPlainObject(value); +} + +function isEmptyStructural(value: unknown): boolean { + if (Array.isArray(value)) return value.length === 0; + if (isPlainObject(value)) return Object.keys(value).length === 0; + return false; +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => deepEqual(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + return aKeys.length === bKeys.length && aKeys.every((k) => k in b && deepEqual(a[k], b[k])); + } + return false; +} + +/** + * True when `later` is deep-equal to `earlier`, or structurally contains/extends it: + * every entry of an earlier array is present in the later array (set containment, + * order-insensitive), or every key of an earlier object is present in the later object + * with a value that itself contains/extends the earlier one. + */ +function isContainedOrExtended(earlier: unknown, later: unknown): boolean { + if (deepEqual(earlier, later)) return true; + if (Array.isArray(earlier)) { + return Array.isArray(later) && earlier.every((e) => later.some((l) => deepEqual(e, l))); + } + if (isPlainObject(earlier)) { + return ( + isPlainObject(later) && + Object.entries(earlier).every( + ([k, v]) => k in later && isContainedOrExtended(v, later[k]) + ) + ); + } + return false; +} + +/** + * Names the keys of an earlier property definition that are lost when it is replaced + * wholesale by a later one (see `deepMergeSchemas`' `properties` branch). A key is + * discarded when: (1) the later definition omits it; (2) the earlier value is a + * non-empty object/array that the later value neither equals nor contains/extends; or + * (3) the earlier value is a scalar and the later value is an object or array. Narrowing + * a value-set keyword (`enum`, `type`, `required`) and redefining a scalar as a different + * scalar are legitimate `allOf` refinement, not loss. + */ +function computeDiscardedKeys( + earlier: Record, + later: Record +): string[] { + const discarded: string[] = []; + + for (const key of Object.keys(earlier)) { + const earlierVal = earlier[key]; + + if (!(key in later)) { + discarded.push(key); // rule 1 + continue; + } + + const laterVal = later[key]; + + if (isContainedOrExtended(earlierVal, laterVal)) continue; + + if (VALUE_SET_KEYWORDS.has(key) && Array.isArray(earlierVal) && Array.isArray(laterVal)) { + const isNarrowing = laterVal.every((l) => earlierVal.some((e) => deepEqual(e, l))); + if (isNarrowing) continue; + } + + if (isStructural(earlierVal) && !isEmptyStructural(earlierVal)) { + discarded.push(key); // rule 2 + continue; + } + + if (!isStructural(earlierVal) && isStructural(laterVal)) { + discarded.push(key); // rule 3 + } + // Remaining case: scalar redefined as a different scalar — legitimate refinement. + } + + return discarded; +} + +function warnOnDiscardedKeys( + propKey: string, + earlierVal: unknown, + laterVal: unknown, + direction: 'allOf-branch' | 'root-override', + logger: Logger +): void { + if (!isPlainObject(earlierVal) || !isPlainObject(laterVal)) return; + + const discardedKeys = computeDiscardedKeys(earlierVal, laterVal); + if (discardedKeys.length === 0) return; + + const message = + direction === 'allOf-branch' + ? `allOf merge on property '${propKey}' discarded keys [${discardedKeys.join(', ')}] declared in an earlier branch. This is a limitation of allOf merging, not an error in the pattern.` + : `allOf merge on property '${propKey}' discarded keys [${discardedKeys.join(', ')}] declared in an allOf branch and overridden by the schema's own properties.`; + + logger.warn(message); +} + /** * Deep merges two schema objects, combining properties, required arrays, and prefixItems. + * + * @param warnOnDiscard - When set, warns (via `logger.warn`) about property keys that the + * `properties` branch's shallow spread discards. `'allOf-branch'` and `'root-override'` + * select the message's direction; `false` (the default) stays silent, which positional + * `prefixItems` merges and `$ref`-refinement merges rely on. */ function deepMergeSchemas( target: Record, - source: Record + source: Record, + logger: Logger, + warnOnDiscard: DiscardWarning = false ): Record { const result = { ...target }; @@ -24,10 +151,26 @@ function deepMergeSchemas( if (value === undefined) continue; if (key === 'properties' && result.properties) { + const targetProps = result.properties as Record; + const sourceProps = value as Record; + + if (warnOnDiscard) { + for (const propKey of Object.keys(sourceProps)) { + if (!(propKey in targetProps)) continue; + warnOnDiscardedKeys( + propKey, + targetProps[propKey], + sourceProps[propKey], + warnOnDiscard, + logger + ); + } + } + // Merge properties objects result.properties = { - ...(result.properties as Record), - ...(value as Record), + ...targetProps, + ...sourceProps, }; } else if (key === 'required' && result.required) { // Combine required arrays, removing duplicates @@ -38,17 +181,11 @@ function deepMergeSchemas( // Merge prefixItems by position result.prefixItems = mergePrefixItems( result.prefixItems as unknown[], - value as unknown[] + value as unknown[], + logger ); } else { - // Any other key is replaced rather than deep-merged. Note the realistic - // "catalog dropped across allOf" case is NOT a top-level `items` reaching - // here: a catalog lives under an array property (e.g. `properties.nodes.items`), - // so two allOf branches each declaring one collide in the `properties` - // branch above, where the shallow spread makes the later branch's whole - // array win. `logDroppedItemsCatalogs` (called from `flattenAllOf`) surfaces - // that collision at debug level. Realistic patterns declare a catalog once - // per array; revisit if patterns start composing `items` across allOf. + // Any other key is replaced rather than deep-merged. result[key] = value; } } @@ -59,17 +196,20 @@ function deepMergeSchemas( /** * Merges two prefixItems arrays by position, combining schemas at each index. */ -function mergePrefixItems(target: unknown[], source: unknown[]): unknown[] { +function mergePrefixItems(target: unknown[], source: unknown[], logger: Logger): unknown[] { const maxLen = Math.max(target.length, source.length); const result: unknown[] = []; for (let i = 0; i < maxLen; i++) { if (i < target.length && i < source.length) { - // Both have items at this position - merge them + // Both have items at this position - merge them. Positional merges never warn: + // they are the chimera path (see AGENTS.md), a separate, already-tracked issue. result.push( deepMergeSchemas( target[i] as Record, - source[i] as Record + source[i] as Record, + logger, + false ) ); } else if (i < target.length) { @@ -82,37 +222,6 @@ function mergePrefixItems(target: unknown[], source: unknown[]): unknown[] { return result; } -/** - * Surfaces, at debug level, an `items` open-catalog that allOf flattening will - * silently drop. When two allOf branches each declare a catalog under the same - * array property (e.g. both define `properties.nodes.items`), `deepMergeSchemas` - * shallow-merges `properties`, so the later branch's array replaces the earlier - * one wholesale and the earlier catalog is lost rather than combined. Realistic - * patterns declare each catalog once per array, so this is a smell worth making - * discoverable under `--verbose`, not an error. - */ -function logDroppedItemsCatalogs( - target: Record, - source: Record, - logger: Logger -): void { - const targetProps = target['properties'] as Record | undefined; - const sourceProps = source['properties'] as Record | undefined; - if (!targetProps || !sourceProps) return; - - for (const propKey of Object.keys(sourceProps)) { - const targetArray = targetProps[propKey] as Record | undefined; - const sourceArray = sourceProps[propKey] as Record | undefined; - if (targetArray?.['items'] !== undefined && sourceArray?.['items'] !== undefined) { - logger.debug( - `allOf merge drops an 'items' catalog on '${propKey}': a catalog declared in an ` + - 'earlier allOf branch is being replaced by a later branch rather than combined. ' + - 'Declare the catalog once per array to avoid silent loss.' - ); - } - } -} - /** * Recursively flattens allOf schemas into a single merged schema. * Resolves $ref references using the schema directory. @@ -133,10 +242,12 @@ export async function flattenAllOf( if (schema.$ref && !schema.allOf) { logger.debug(`Resolving root $ref: ${schema.$ref}`); const resolved = await schemaDir.getDefinition(schema.$ref); - // Merge any additional properties from the original schema + // Merge any additional properties from the original schema. Refining a $ref'd + // definition with local sibling keys is ordinary JSON Schema composition, so this + // merge stays silent even when it discards a key. const { $ref: _$ref, ...rest } = schema; const flattened = await flattenAllOf(resolved as SchemaWithAllOf, schemaDir, debug); - return deepMergeSchemas(flattened as Record, rest); + return deepMergeSchemas(flattened as Record, rest, logger); } // If no allOf, return schema as-is @@ -157,25 +268,30 @@ export async function flattenAllOf( if (resolved.$ref) { logger.debug(`Resolving $ref in allOf: ${resolved.$ref}`); const refResolved = await schemaDir.getDefinition(resolved.$ref); - // Merge any additional properties from the $ref schema + // Merge any additional properties from the $ref schema. Same $ref-refinement + // case as above — stays silent. const { $ref: _$ref, ...rest } = resolved; resolved = deepMergeSchemas( refResolved as Record, - rest + rest, + logger ) as SchemaWithAllOf; } // Recursively flatten nested allOf resolved = (await flattenAllOf(resolved, schemaDir, debug)) as SchemaWithAllOf; - // Deep merge into accumulated result - logDroppedItemsCatalogs(merged, resolved as Record, logger); - merged = deepMergeSchemas(merged, resolved); + // Deep merge into accumulated result. Two allOf branches are being merged here, so + // a key an earlier branch declared and this one discards is worth a warning. + merged = deepMergeSchemas(merged, resolved, logger, 'allOf-branch'); } - // Preserve top-level fields that aren't part of allOf (like $id, $schema, title, etc.) + // Preserve top-level fields that aren't part of allOf (like $id, $schema, title, etc.). + // This merges the root schema's own keys OVER the allOf result, so a discarded key here + // was declared in an allOf branch and lost to the schema's own properties — the + // opposite direction from the loop above. const { allOf: _allOf, ...rest } = schema; - merged = deepMergeSchemas(merged, rest); + merged = deepMergeSchemas(merged, rest, logger, 'root-override'); logger.debug(`Flattened schema has properties: ${Object.keys(merged.properties || {}).join(', ')}`); From 9a2e202e73ebc6e5e79f0f7908596dd6c64dab56 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sun, 16 Aug 2026 21:01:52 +0000 Subject: [PATCH 11/41] refactor: extract a shared pattern-array reader into calm-models Where candidate nodes/relationships live in a pattern's JSON Schema and oneOf-over-anyOf catalog precedence were hand-written separately in generation (options.ts) and the Hub UI visualiser (patternTransformer.ts), with no shared helper enforcing agreement between them. Add getPatternArray/readCatalog to calm-models under a new ./pattern export subpath - the only workspace both shared and calm-hub-ui already depend on that stays free of Node builtins. getPatternArray reproduces today's first-branch-wins allOf reading verbatim (marked TEMPORARY in its JSDoc); the real fix belongs with a future allOf merge rework, not here. Migrate exactly the byte-equivalent call sites: options.ts's getRelationshipsPrefixItems, and patternTransformer.ts's getPrefixItems/ getItems/catalogAlternatives (deleting the now-redundant getArrayKeyword). No other logic in either file - decision/catalog extraction, choice-bundle reading, decision grouping, layout - is touched. --- .../reactflow/utils/patternTransformer.ts | 45 ++------- calm-models/package.json | 5 + calm-models/src/pattern/index.ts | 7 ++ .../src/pattern/pattern-reader.spec.ts | 96 ++++++++++++++++++ calm-models/src/pattern/pattern-reader.ts | 98 +++++++++++++++++++ .../commands/generate/components/options.ts | 17 +--- 6 files changed, 216 insertions(+), 52 deletions(-) create mode 100644 calm-models/src/pattern/index.ts create mode 100644 calm-models/src/pattern/pattern-reader.spec.ts create mode 100644 calm-models/src/pattern/pattern-reader.ts diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index b633a03d4..9cfe6312e 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -9,6 +9,7 @@ import { import { createEdge } from './edgeFactory'; import { GRAPH_LAYOUT } from './constants'; import { THEME } from '../theme'; +import { getPatternArray, readCatalog } from '@finos/calm-models/pattern'; /** * Result of parsing pattern data into ReactFlow elements @@ -24,35 +25,13 @@ type SchemaObject = Record; // ---- Schema traversal helpers ---- -/** - * Reads an array-valued keyword (e.g. 'prefixItems' or 'items') for a given - * top-level key (e.g. 'nodes' or 'relationships') from a pattern, handling allOf - * structures. Returns the direct declaration if present, otherwise the first - * matching one found across the allOf branches, otherwise undefined. The truthy - * check means a present-but-falsy value (e.g. `items: false` closing a tuple) is - * treated as absent, exactly as the two callers below relied on. - */ -function getArrayKeyword(pattern: SchemaObject, key: string, keyword: string): SchemaObject | undefined { - if (pattern['properties']?.[key]?.[keyword]) { - return pattern['properties'][key][keyword]; - } - if (pattern['allOf'] && Array.isArray(pattern['allOf'])) { - for (const schema of pattern['allOf']) { - if (schema['properties']?.[key]?.[keyword]) { - return schema['properties'][key][keyword]; - } - } - } - return undefined; -} - /** * Gets the prefixItems for a given top-level key (e.g. 'nodes' or 'relationships') * from a pattern, handling allOf structures. Absent prefixItems yields an empty * array so callers can iterate unconditionally. */ -function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { - return getArrayKeyword(pattern, key, 'prefixItems') ?? []; +function getPrefixItems(pattern: SchemaObject, key: 'nodes' | 'relationships'): SchemaObject[] { + return getPatternArray(pattern, key).prefixItems as SchemaObject[]; } /** @@ -60,26 +39,18 @@ function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { * declaration) for a given top-level key (e.g. 'nodes' or 'relationships') * from a pattern, handling allOf structures. */ -function getItems(pattern: SchemaObject, key: string): SchemaObject | undefined { - return getArrayKeyword(pattern, key, 'items'); +function getItems(pattern: SchemaObject, key: 'nodes' | 'relationships'): SchemaObject | undefined { + return getPatternArray(pattern, key).catalog as SchemaObject | undefined; } /** * Reads an `items` open-catalog's decision alternatives. Returns the group type * (`oneOf`/`anyOf`) and the alternatives array, or null when the catalog is - * neither. Mirrors the original inline logic exactly: `oneOf` wins when both are - * present, and either keyword being a non-array leaves the catalog untreated. + * neither. `oneOf` wins when both are present, and either keyword being a + * non-array leaves the catalog untreated. */ function catalogAlternatives(items: SchemaObject): { groupType: 'oneOf' | 'anyOf'; alternatives: SchemaObject[] } | null { - const hasOneOf = Array.isArray(items['oneOf']); - const hasAnyOf = Array.isArray(items['anyOf']); - if (!hasOneOf && !hasAnyOf) { - return null; - } - return { - groupType: hasOneOf ? 'oneOf' : 'anyOf', - alternatives: hasOneOf ? items['oneOf'] : items['anyOf'], - }; + return readCatalog(items) as { groupType: 'oneOf' | 'anyOf'; alternatives: SchemaObject[] } | null; } /** diff --git a/calm-models/package.json b/calm-models/package.json index ff29f317f..ebfb15691 100644 --- a/calm-models/package.json +++ b/calm-models/package.json @@ -22,6 +22,11 @@ "types": "./dist/diff/index.d.ts", "import": "./dist/diff/index.js", "default": "./dist/diff/index.js" + }, + "./pattern": { + "types": "./dist/pattern/index.d.ts", + "import": "./dist/pattern/index.js", + "default": "./dist/pattern/index.js" } }, "files": [ diff --git a/calm-models/src/pattern/index.ts b/calm-models/src/pattern/index.ts new file mode 100644 index 000000000..887e4c363 --- /dev/null +++ b/calm-models/src/pattern/index.ts @@ -0,0 +1,7 @@ +export { + getPatternArray, + readCatalog, + type SchemaNode, + type PatternArray, + type Catalog, +} from './pattern-reader.js'; diff --git a/calm-models/src/pattern/pattern-reader.spec.ts b/calm-models/src/pattern/pattern-reader.spec.ts new file mode 100644 index 000000000..940a9742f --- /dev/null +++ b/calm-models/src/pattern/pattern-reader.spec.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { + getPatternArray, + readCatalog, + type SchemaNode, +} from './pattern-reader.js'; + +function nodeWithId(uniqueId: string): SchemaNode { + return { properties: { 'unique-id': { const: uniqueId } } }; +} + +describe('getPatternArray', () => { + it('reads prefixItems and catalog declared directly under properties', () => { + const pattern = { + properties: { + nodes: { + prefixItems: [nodeWithId('a')], + items: { oneOf: [nodeWithId('b')] }, + }, + }, + }; + + const result = getPatternArray(pattern, 'nodes'); + expect(result.prefixItems).toEqual([nodeWithId('a')]); + expect(result.catalog).toEqual({ oneOf: [nodeWithId('b')] }); + }); + + it('yields an empty prefixItems array and undefined catalog when neither is declared', () => { + const result = getPatternArray({ properties: {} }, 'nodes'); + expect(result.prefixItems).toEqual([]); + expect(result.catalog).toBeUndefined(); + }); + + it('falls back to the first allOf branch that declares the array', () => { + const pattern = { + allOf: [ + { properties: { nodes: { prefixItems: [nodeWithId('first-branch')] } } }, + { properties: { nodes: { prefixItems: [nodeWithId('second-branch')] } } }, + ], + }; + + const result = getPatternArray(pattern, 'nodes'); + // TEMPORARY (first-branch-wins): a later branch declaring the same path is + // invisible today. Do not "fix" this here — see the reader's allOf note. + expect(result.prefixItems).toEqual([nodeWithId('first-branch')]); + }); + + it('prefers a direct declaration over an allOf branch', () => { + const pattern = { + properties: { nodes: { prefixItems: [nodeWithId('direct')] } }, + allOf: [{ properties: { nodes: { prefixItems: [nodeWithId('branch')] } } }], + }; + + expect(getPatternArray(pattern, 'nodes').prefixItems).toEqual([nodeWithId('direct')]); + }); + + it('treats a falsy keyword value (e.g. items: false closing a tuple) as absent', () => { + const pattern = { properties: { nodes: { prefixItems: [nodeWithId('a')], items: false } } }; + expect(getPatternArray(pattern, 'nodes').catalog).toBeUndefined(); + }); +}); + +describe('readCatalog', () => { + it('returns null for an undefined catalog', () => { + expect(readCatalog(undefined)).toBeNull(); + }); + + it('returns null when neither oneOf nor anyOf is an array', () => { + expect(readCatalog({})).toBeNull(); + expect(readCatalog({ oneOf: 'not-an-array' })).toBeNull(); + }); + + it('reads a oneOf-only catalog', () => { + const alternatives = [nodeWithId('a'), nodeWithId('b')]; + expect(readCatalog({ oneOf: alternatives })).toEqual({ groupType: 'oneOf', alternatives }); + }); + + it('reads an anyOf-only catalog', () => { + const alternatives = [nodeWithId('a')]; + expect(readCatalog({ anyOf: alternatives })).toEqual({ groupType: 'anyOf', alternatives }); + }); + + it('prefers oneOf over anyOf when both are present', () => { + const oneOfAlts = [nodeWithId('one')]; + const anyOfAlts = [nodeWithId('any')]; + expect(readCatalog({ oneOf: oneOfAlts, anyOf: anyOfAlts })).toEqual({ + groupType: 'oneOf', + alternatives: oneOfAlts, + }); + }); +}); + + + + + diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts new file mode 100644 index 000000000..b5bc1482c --- /dev/null +++ b/calm-models/src/pattern/pattern-reader.ts @@ -0,0 +1,98 @@ +/** + * Read-only reader for where candidate nodes and relationships live in a CALM pattern's + * JSON Schema. + * + * A CALM pattern declares candidates in four places: a plain `prefixItems` entry, a + * `prefixItems[i].oneOf`/`anyOf` alternative, or an `items.oneOf`/`items.anyOf` open + * catalog member. This module is the single place that knows how to find them, so + * generation, validation and the visualiser stop hand-rolling the same traversal. + * + * No selection, no mutation, no rendering — only reading. The surface is deliberately + * limited to what has a caller today; add functions when a consumer needs them, so their + * shape is validated by real use rather than guessed. + */ + +export type SchemaNode = Record; + +function isObject(value: unknown): value is SchemaNode { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + + +/** + * TEMPORARY — replicates today's lossy first-`allOf`-branch-wins reading of an array + * keyword (`prefixItems`/`items`) for a top-level pattern property (`nodes`/ + * `relationships`): reads `properties..` directly, and otherwise returns + * the first `allOf` branch that declares it. A later branch declaring the same path is + * silently ignored — this exists only because decision/candidate discovery currently + * runs on the raw pattern, before `flattenAllOf` runs. The real fix belongs with the + * `allOf` merge rework (see the tracked follow-up issue), which will delete this + * function rather than correct it. Do not "fix" the precedence here — see the reader's + * `allOf` handling notes in the implementation plan this module was built from. + */ +function readArrayKeyword(pattern: SchemaNode, key: string, keyword: string): unknown { + const direct = pattern['properties']; + if (isObject(direct)) { + const field = direct[key]; + if (isObject(field) && field[keyword]) { + return field[keyword]; + } + } + + if (Array.isArray(pattern['allOf'])) { + for (const branch of pattern['allOf']) { + if (!isObject(branch)) continue; + const branchProperties = branch['properties']; + if (!isObject(branchProperties)) continue; + const field = branchProperties[key]; + if (isObject(field) && field[keyword]) { + return field[keyword]; + } + } + } + + return undefined; +} + +export interface PatternArray { + prefixItems: SchemaNode[]; + catalog: SchemaNode | undefined; +} + +/** + * Reads the `prefixItems` array and `items` open-catalog declared for a top-level + * pattern property (`nodes` or `relationships`), resolving `allOf` per `readArrayKeyword` + * above. Absent `prefixItems` yields an empty array so callers can iterate + * unconditionally; an absent catalog yields `undefined`. + */ +export function getPatternArray(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): PatternArray { + const prefixItems = readArrayKeyword(pattern, calmType, 'prefixItems'); + const catalog = readArrayKeyword(pattern, calmType, 'items'); + return { + prefixItems: Array.isArray(prefixItems) ? (prefixItems as SchemaNode[]) : [], + catalog: isObject(catalog) ? catalog : undefined, + }; +} + +export interface Catalog { + groupType: 'oneOf' | 'anyOf'; + alternatives: SchemaNode[]; +} + +/** + * Reads an `items` open-catalog's decision alternatives. `oneOf` wins over `anyOf` + * when both are present; either keyword being a non-array leaves the catalog + * untreated. Returns `null` when `items` is absent or neither keyword is an array. + */ +export function readCatalog(items: SchemaNode | undefined): Catalog | null { + if (!items) return null; + + const hasOneOf = Array.isArray(items['oneOf']); + const hasAnyOf = Array.isArray(items['anyOf']); + if (!hasOneOf && !hasAnyOf) return null; + + return { + groupType: hasOneOf ? 'oneOf' : 'anyOf', + alternatives: (hasOneOf ? items['oneOf'] : items['anyOf']) as SchemaNode[], + }; +} diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index 55c2e4419..63f2a1921 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 { getPatternArray } from '@finos/calm-models/pattern'; /** * A node within a CALM pattern's JSON schema. The pattern is unvalidated JSON @@ -51,21 +52,7 @@ function extractOptionsFromBlock(optionsRelationship: SchemaNode, blockType: 'on * @returns The prefixItems array from relationships, or empty array if not found */ function getRelationshipsPrefixItems(pattern: SchemaNode): SchemaNode[] { - // Direct access for standard patterns - if (pattern['properties']?.['relationships']?.['prefixItems']) { - return pattern['properties']['relationships']['prefixItems']; - } - - // Handle allOf patterns - look for relationships in each allOf schema - if (pattern['allOf'] && Array.isArray(pattern['allOf'])) { - for (const schema of pattern['allOf']) { - if (schema['properties']?.['relationships']?.['prefixItems']) { - return schema['properties']['relationships']['prefixItems']; - } - } - } - - return []; + return getPatternArray(pattern, 'relationships').prefixItems as SchemaNode[]; } /** From 54309b7e87c6b6f3dd21fdb9040951a1319e2f1e Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sun, 16 Aug 2026 21:08:11 +0000 Subject: [PATCH 12/41] style(shared): apply object-curly-spacing to spectral pattern specs These two spec files predate main's repo-wide brace-spacing lint rule and weren't touched by the commit that applied it, so they only started failing lint once merged. eslint --fix only; no logic change. --- .../functions/pattern/ids-are-unique.spec.ts | 26 +++++++++---------- .../interface-id-exists-on-node.spec.ts | 8 +++--- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts index f7f8033bb..7c271ace8 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts @@ -128,12 +128,12 @@ describe('idsAreUnique', () => { properties: { nodes: { prefixItems: [ - {'properties': {'unique-id': {'const': 'webapp'}}} + { 'properties': { 'unique-id': { 'const': 'webapp' } } } ], items: { oneOf: [ - {'properties': {'unique-id': {'const': 'cache'}}}, - {'properties': {'unique-id': {'const': 'cache'}}} + { 'properties': { 'unique-id': { 'const': 'cache' } } }, + { 'properties': { 'unique-id': { 'const': 'cache' } } } ] } } @@ -156,8 +156,8 @@ describe('idsAreUnique', () => { relationships: { items: { oneOf: [ - {'properties': {'unique-id': {'const': 'edge'}}}, - {'properties': {'unique-id': {'const': 'edge'}}} + { 'properties': { 'unique-id': { 'const': 'edge' } } }, + { 'properties': { 'unique-id': { 'const': 'edge' } } } ] } } @@ -179,11 +179,11 @@ describe('idsAreUnique', () => { properties: { nodes: { prefixItems: [ - {'properties': {'unique-id': {'const': 'webapp'}}} + { 'properties': { 'unique-id': { 'const': 'webapp' } } } ], items: { oneOf: [ - {'properties': {'unique-id': {'const': 'webapp'}}} + { 'properties': { 'unique-id': { 'const': 'webapp' } } } ] } } @@ -206,13 +206,13 @@ describe('idsAreUnique', () => { nodes: { items: { anyOf: [ - {'properties': { - 'unique-id': {'const': 'cache'}, - 'interfaces': {prefixItems: [{'properties': {'unique-id': {'const': 'intf1'}}}]}} + { 'properties': { + 'unique-id': { 'const': 'cache' }, + 'interfaces': { prefixItems: [{ 'properties': { 'unique-id': { 'const': 'intf1' } } }] } } }, - {'properties': { - 'unique-id': {'const': 'queue'}, - 'interfaces': {prefixItems: [{'properties': {'unique-id': {'const': 'intf1'}}}]}} + { 'properties': { + 'unique-id': { 'const': 'queue' }, + 'interfaces': { prefixItems: [{ 'properties': { 'unique-id': { 'const': 'intf1' } } }] } } } ] } diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts index 58b8934c0..0a5b5bfeb 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts @@ -139,10 +139,10 @@ describe('interfaceIdExistsOnNode', () => { oneOf: [ { properties: { - 'unique-id': {const: 'cache'}, + 'unique-id': { const: 'cache' }, 'interfaces': { prefixItems: [ - {properties: {'unique-id': {const: 'cache-intf'}}} + { properties: { 'unique-id': { const: 'cache-intf' } } } ] } } @@ -170,10 +170,10 @@ describe('interfaceIdExistsOnNode', () => { oneOf: [ { properties: { - 'unique-id': {const: 'cache'}, + 'unique-id': { const: 'cache' }, 'interfaces': { prefixItems: [ - {properties: {'unique-id': {const: 'cache-intf'}}} + { properties: { 'unique-id': { const: 'cache-intf' } } } ] } } From 3c35c11d904a411fe63594627a8b7594bde171e8 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sat, 22 Aug 2026 09:57:37 +0000 Subject: [PATCH 13/41] fix(shared): detect duplicate and unreachable pattern candidates Candidate discovery read only the first alternative of a oneOf/anyOf slot, so a duplicate unique-id declared in a slot was invisible while the identical clash in an items catalog was an error. Interface checks had the same blind spot, and could let one alternative borrow another's interfaces. Adds a single candidate reader with two named questions - what a pattern declares, and what selection can reach - and three rules over it. Duplicate-id errors now report a real source position instead of line 0. The junit fixture gains the two new rule names; the report lists every rule in the ruleset, not only the ones that fired. --- calm-models/src/pattern/index.ts | 6 +- .../src/pattern/pattern-reader.spec.ts | 99 +++++++++- calm-models/src/pattern/pattern-reader.ts | 127 ++++++++++-- cli/test_fixtures/validate_output_junit.xml | 8 +- shared/src/pattern-candidates.ts | 102 ++++++++++ .../functions/pattern/candidate-helpers.ts | 41 ++++ .../pattern/catalog-single-choice-keyword.ts | 27 +++ ...ecision-references-selectable-candidate.ts | 41 ++++ .../functions/pattern/ids-are-unique.spec.ts | 48 +++++ .../functions/pattern/ids-are-unique.ts | 74 ++++--- .../interface-id-exists-on-node.spec.ts | 63 ++++++ .../pattern/interface-id-exists-on-node.ts | 31 ++- .../functions/pattern/node-id-exists.spec.ts | 38 ++++ .../functions/pattern/node-id-exists.ts | 16 +- shared/src/spectral/rules-pattern.spec.ts | 185 ++++++++++++++++++ shared/src/spectral/rules-pattern.ts | 57 +++++- 16 files changed, 877 insertions(+), 86 deletions(-) create mode 100644 shared/src/pattern-candidates.ts create mode 100644 shared/src/spectral/functions/pattern/candidate-helpers.ts create mode 100644 shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts create mode 100644 shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts diff --git a/calm-models/src/pattern/index.ts b/calm-models/src/pattern/index.ts index 887e4c363..1e44ca0cf 100644 --- a/calm-models/src/pattern/index.ts +++ b/calm-models/src/pattern/index.ts @@ -1,7 +1,9 @@ export { getPatternArray, - readCatalog, + readChoiceBlock, + listCandidates, type SchemaNode, type PatternArray, - type Catalog, + type ChoiceBlock, + type Candidate, } from './pattern-reader.js'; diff --git a/calm-models/src/pattern/pattern-reader.spec.ts b/calm-models/src/pattern/pattern-reader.spec.ts index 940a9742f..a3a9583c4 100644 --- a/calm-models/src/pattern/pattern-reader.spec.ts +++ b/calm-models/src/pattern/pattern-reader.spec.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; import { getPatternArray, - readCatalog, + readChoiceBlock, + listCandidates, type SchemaNode, } from './pattern-reader.js'; @@ -60,37 +61,119 @@ describe('getPatternArray', () => { }); }); -describe('readCatalog', () => { +describe('readChoiceBlock', () => { it('returns null for an undefined catalog', () => { - expect(readCatalog(undefined)).toBeNull(); + expect(readChoiceBlock(undefined)).toBeNull(); }); it('returns null when neither oneOf nor anyOf is an array', () => { - expect(readCatalog({})).toBeNull(); - expect(readCatalog({ oneOf: 'not-an-array' })).toBeNull(); + expect(readChoiceBlock({})).toBeNull(); + expect(readChoiceBlock({ oneOf: 'not-an-array' })).toBeNull(); }); it('reads a oneOf-only catalog', () => { const alternatives = [nodeWithId('a'), nodeWithId('b')]; - expect(readCatalog({ oneOf: alternatives })).toEqual({ groupType: 'oneOf', alternatives }); + expect(readChoiceBlock({ oneOf: alternatives })).toEqual({ groupType: 'oneOf', alternatives }); }); it('reads an anyOf-only catalog', () => { const alternatives = [nodeWithId('a')]; - expect(readCatalog({ anyOf: alternatives })).toEqual({ groupType: 'anyOf', alternatives }); + expect(readChoiceBlock({ anyOf: alternatives })).toEqual({ groupType: 'anyOf', alternatives }); }); it('prefers oneOf over anyOf when both are present', () => { const oneOfAlts = [nodeWithId('one')]; const anyOfAlts = [nodeWithId('any')]; - expect(readCatalog({ oneOf: oneOfAlts, anyOf: anyOfAlts })).toEqual({ + expect(readChoiceBlock({ oneOf: oneOfAlts, anyOf: anyOfAlts })).toEqual({ groupType: 'oneOf', alternatives: oneOfAlts, }); }); }); +describe('listCandidates', () => { + it('lists a plain prefixItems entry', () => { + const pattern = { properties: { nodes: { prefixItems: [nodeWithId('solo')] } } }; + expect(listCandidates(pattern, 'nodes')).toEqual([ + { uniqueId: 'solo', site: 'prefixItem', node: nodeWithId('solo'), path: ['properties', 'nodes', 'prefixItems', 0] }, + ]); + }); + it('unions oneOf and anyOf on the same slot, unlike readChoiceBlock', () => { + const pattern = { + properties: { + nodes: { + prefixItems: [{ oneOf: [nodeWithId('a')], anyOf: [nodeWithId('b')] }], + }, + }, + }; + + const candidates = listCandidates(pattern, 'nodes'); + expect(candidates.map((c) => c.uniqueId)).toEqual(['a', 'b']); + expect(candidates.map((c) => c.blockType)).toEqual(['oneOf', 'anyOf']); + expect(candidates.every((c) => c.site === 'prefixItemAlternative' && c.slotIndex === 0)).toBe(true); + }); + + it('yields both the slot and its alternatives for a hybrid slot', () => { + const pattern = { + properties: { + nodes: { + prefixItems: [{ ...nodeWithId('hybrid'), oneOf: [nodeWithId('alt')] }], + }, + }, + }; + const candidates = listCandidates(pattern, 'nodes'); + expect(candidates.map((c) => ({ uniqueId: c.uniqueId, site: c.site }))).toEqual([ + { uniqueId: 'hybrid', site: 'prefixItem' }, + { uniqueId: 'alt', site: 'prefixItemAlternative' }, + ]); + }); + it('lists items.oneOf and items.anyOf catalog members together', () => { + const pattern = { + properties: { + nodes: { + items: { oneOf: [nodeWithId('cat-one')], anyOf: [nodeWithId('cat-any')] }, + }, + }, + }; + + const candidates = listCandidates(pattern, 'nodes'); + expect(candidates).toEqual([ + { uniqueId: 'cat-one', site: 'catalogMember', node: nodeWithId('cat-one'), path: ['properties', 'nodes', 'items', 'oneOf', 0], blockType: 'oneOf' }, + { uniqueId: 'cat-any', site: 'catalogMember', node: nodeWithId('cat-any'), path: ['properties', 'nodes', 'items', 'anyOf', 0], blockType: 'anyOf' }, + ]); + }); + + it('skips a pure choice-block slot with no unique-id of its own', () => { + const pattern = { + properties: { + nodes: { prefixItems: [{ oneOf: [nodeWithId('a'), nodeWithId('b')] }] }, + }, + }; + + const candidates = listCandidates(pattern, 'nodes'); + expect(candidates.map((c) => c.uniqueId)).toEqual(['a', 'b']); + expect(candidates.some((c) => c.uniqueId === undefined)).toBe(false); + }); + + it('skips a catalog member with no const-pinned unique-id', () => { + const pattern = { + properties: { nodes: { items: { oneOf: [{ properties: {} }] } } }, + }; + expect(listCandidates(pattern, 'nodes')).toEqual([]); + }); + + it('returns an empty array when the calmType is absent', () => { + expect(listCandidates({ properties: {} }, 'nodes')).toEqual([]); + }); + + it('does not fall back into an allOf branch, unlike getPatternArray', () => { + const pattern = { + allOf: [{ properties: { nodes: { prefixItems: [nodeWithId('in-a-branch')] } } }], + }; + expect(listCandidates(pattern, 'nodes')).toEqual([]); + }); +}); diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index b5bc1482c..100d8c0f0 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -1,12 +1,16 @@ /** * Read-only reader for where candidate nodes and relationships live in a CALM pattern's - * JSON Schema. + * JSON Schema, and how a `oneOf`/`anyOf` choice block should be read. * * A CALM pattern declares candidates in four places: a plain `prefixItems` entry, a * `prefixItems[i].oneOf`/`anyOf` alternative, or an `items.oneOf`/`items.anyOf` open * catalog member. This module is the single place that knows how to find them, so * generation, validation and the visualiser stop hand-rolling the same traversal. * + * Two different questions get two different functions, deliberately kept apart: + * `readChoiceBlock` picks the single form a decision offers (`oneOf` wins over `anyOf`); + * `listCandidates` unions both, because validation needs every id a pattern declares. + * * No selection, no mutation, no rendering — only reading. The surface is deliberately * limited to what has a caller today; add functions when a consumer needs them, so their * shape is validated by real use rather than guessed. @@ -20,15 +24,11 @@ function isObject(value: unknown): value is SchemaNode { /** - * TEMPORARY — replicates today's lossy first-`allOf`-branch-wins reading of an array - * keyword (`prefixItems`/`items`) for a top-level pattern property (`nodes`/ - * `relationships`): reads `properties..` directly, and otherwise returns - * the first `allOf` branch that declares it. A later branch declaring the same path is - * silently ignored — this exists only because decision/candidate discovery currently - * runs on the raw pattern, before `flattenAllOf` runs. The real fix belongs with the - * `allOf` merge rework (see the tracked follow-up issue), which will delete this - * function rather than correct it. Do not "fix" the precedence here — see the reader's - * `allOf` handling notes in the implementation plan this module was built from. + * TEMPORARY. Replicates today's first-`allOf`-branch-wins reading of `prefixItems`/`items` + * for a top-level pattern property. A later branch that declares the same path is ignored. + * + * It exists because candidate discovery runs on the raw pattern, before `flattenAllOf`. + * The `allOf` merge rework will delete this function. Do not correct the precedence here. */ function readArrayKeyword(pattern: SchemaNode, key: string, keyword: string): unknown { const direct = pattern['properties']; @@ -74,17 +74,19 @@ export function getPatternArray(pattern: SchemaNode, calmType: 'nodes' | 'relati }; } -export interface Catalog { +export interface ChoiceBlock { groupType: 'oneOf' | 'anyOf'; alternatives: SchemaNode[]; } /** - * Reads an `items` open-catalog's decision alternatives. `oneOf` wins over `anyOf` - * when both are present; either keyword being a non-array leaves the catalog - * untreated. Returns `null` when `items` is absent or neither keyword is an array. + * The alternatives that one choice block offers, for a `prefixItems` slot or an `items` + * catalog. `oneOf` wins when both are present. Returns `null` when neither keyword is + * an array. + * + * This picks one answer. A caller that needs every declared id must use `listCandidates`. */ -export function readCatalog(items: SchemaNode | undefined): Catalog | null { +export function readChoiceBlock(items: SchemaNode | undefined): ChoiceBlock | null { if (!items) return null; const hasOneOf = Array.isArray(items['oneOf']); @@ -96,3 +98,98 @@ export function readCatalog(items: SchemaNode | undefined): Catalog | null { alternatives: (hasOneOf ? items['oneOf'] : items['anyOf']) as SchemaNode[], }; } + +function readUniqueId(node: SchemaNode): string | undefined { + const properties = node['properties']; + if (!isObject(properties)) return undefined; + const uniqueIdSchema = properties['unique-id']; + if (!isObject(uniqueIdSchema)) return undefined; + const constValue = uniqueIdSchema['const']; + return typeof constValue === 'string' ? constValue : undefined; +} + +export type Candidate = { + uniqueId: string; + site: 'prefixItem' | 'prefixItemAlternative' | 'catalogMember'; + node: SchemaNode; + path: (string | number)[]; + slotIndex?: number; + blockType?: 'oneOf' | 'anyOf'; +}; + +/** + * Every node/relationship candidate declared under `properties.`, across all + * four declaration sites. + * + * Unions `oneOf` and `anyOf`, which is the opposite of `readChoiceBlock`. Validation + * needs every declared id. Do not route this through `readChoiceBlock` - that drops + * every `anyOf` candidate when `oneOf` is also present. + * + * Skips a candidate with no `const`-pinned `unique-id`. A pure choice-block slot has no + * id of its own, and counting it would create a false diagnostic. + * + * Reads the direct path only. It does not fall back into `allOf`, because + * `getPatternArray` discards which branch it read, so `path` could not be trusted. + */ +export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { + const candidates: Candidate[] = []; + const properties = pattern['properties']; + const field = isObject(properties) ? properties[calmType] : undefined; + if (!isObject(field)) return candidates; + + const prefixItems = Array.isArray(field['prefixItems']) ? (field['prefixItems'] as SchemaNode[]) : []; + + prefixItems.forEach((item, i) => { + if (!isObject(item)) return; + + const uniqueId = readUniqueId(item); + if (uniqueId) { + candidates.push({ + uniqueId, + site: 'prefixItem', + node: item, + path: ['properties', calmType, 'prefixItems', i], + }); + } + + (['oneOf', 'anyOf'] as const).forEach((blockType) => { + const alternatives = item[blockType]; + if (!Array.isArray(alternatives)) return; + alternatives.forEach((alt, j) => { + if (!isObject(alt)) return; + const altUniqueId = readUniqueId(alt); + if (!altUniqueId) return; + candidates.push({ + uniqueId: altUniqueId, + site: 'prefixItemAlternative', + node: alt, + path: ['properties', calmType, 'prefixItems', i, blockType, j], + slotIndex: i, + blockType, + }); + }); + }); + }); + + const itemsCatalog = field['items']; + if (isObject(itemsCatalog)) { + (['oneOf', 'anyOf'] as const).forEach((blockType) => { + const alternatives = itemsCatalog[blockType]; + if (!Array.isArray(alternatives)) return; + alternatives.forEach((alt, j) => { + if (!isObject(alt)) return; + const altUniqueId = readUniqueId(alt); + if (!altUniqueId) return; + candidates.push({ + uniqueId: altUniqueId, + site: 'catalogMember', + node: alt, + path: ['properties', calmType, 'items', blockType, j], + blockType, + }); + }); + }); + } + + return candidates; +} diff --git a/cli/test_fixtures/validate_output_junit.xml b/cli/test_fixtures/validate_output_junit.xml index da3078e73..c138044f5 100644 --- a/cli/test_fixtures/validate_output_junit.xml +++ b/cli/test_fixtures/validate_output_junit.xml @@ -1,10 +1,10 @@ - + - @@ -42,5 +42,9 @@ + + + + \ No newline at end of file diff --git a/shared/src/pattern-candidates.ts b/shared/src/pattern-candidates.ts new file mode 100644 index 000000000..31a78a3d9 --- /dev/null +++ b/shared/src/pattern-candidates.ts @@ -0,0 +1,102 @@ +import { getPatternArray, readChoiceBlock, type SchemaNode } from '@finos/calm-models/pattern'; + +/** `path` is Spectral path segments, not a pointer string. */ +export interface PatternCandidate { + uniqueId: string; + site: 'prefixItem' | 'prefixItemAlternative' | 'catalogMember'; + node: SchemaNode; + path: (string | number)[]; + slotIndex?: number; + blockType?: 'oneOf' | 'anyOf'; +} + +/** How a `oneOf`/`anyOf` choice block contributes candidates. */ +type BlockResolution = + /** Every alternative of every keyword - what a pattern *declares*. */ + | 'all' + /** Only the operative keyword's alternatives - what selection can *reach*. */ + | 'operative'; + +function readUniqueId(item: SchemaNode): string | undefined { + const properties = item?.['properties'] as Record | undefined; + const constValue = properties?.['unique-id']?.['const']; + return typeof constValue === 'string' ? constValue : undefined; +} + +/** + * One traversal for both questions. The resolutions differ only in how a choice block + * contributes, so they must not become two functions that drift apart. + */ +function walkCandidates( + pattern: SchemaNode, + calmType: 'nodes' | 'relationships', + resolution: BlockResolution +): PatternCandidate[] { + const { prefixItems, catalog } = getPatternArray(pattern, calmType); + const candidates: PatternCandidate[] = []; + + const emitBlock = ( + container: SchemaNode, + basePath: (string | number)[], + site: PatternCandidate['site'], + slotIndex?: number + ): boolean => { + const keywords: ReadonlyArray<'oneOf' | 'anyOf'> = + resolution === 'all' ? ['oneOf', 'anyOf'] : (readChoiceBlock(container) ? [readChoiceBlock(container)!.groupType] : []); + + let emitted = false; + keywords.forEach((blockType) => { + const alternatives = container?.[blockType]; + if (!Array.isArray(alternatives)) return; + emitted = true; + (alternatives as SchemaNode[]).forEach((alt, j) => { + const uniqueId = readUniqueId(alt); + if (!uniqueId) return; + candidates.push({ + uniqueId, + site, + node: alt, + path: [...basePath, blockType, j], + ...(slotIndex !== undefined && { slotIndex }), + blockType, + }); + }); + }); + return emitted; + }; + + prefixItems.forEach((item: SchemaNode, i: number) => { + const base: (string | number)[] = ['properties', calmType, 'prefixItems', i]; + + // A hybrid slot carries its own id and alternatives. Both must be emitted. + const uniqueId = readUniqueId(item); + if (uniqueId) { + candidates.push({ uniqueId, site: 'prefixItem', node: item, path: base }); + } + + emitBlock(item, base, 'prefixItemAlternative', i); + }); + + if (catalog) { + emitBlock(catalog, ['properties', calmType, 'items'], 'catalogMember'); + } + + return candidates; +} + +/** + * Every candidate the pattern declares, both keywords unioned. Use it for questions + * about what a document says: uniqueness, dangling references. + */ +export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): PatternCandidate[] { + return walkCandidates(pattern, calmType, 'all'); +} + +/** + * Only the candidates selection can reach, resolved as `selectChoices` resolves them. + * Use it for "can this answer be honoured". `listCandidates` is a silent bug here, + * because it reports the losing keyword's alternatives as available. + */ +export function listSelectableCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): PatternCandidate[] { + return walkCandidates(pattern, calmType, 'operative'); +} diff --git a/shared/src/spectral/functions/pattern/candidate-helpers.ts b/shared/src/spectral/functions/pattern/candidate-helpers.ts new file mode 100644 index 000000000..cb5f2f6a7 --- /dev/null +++ b/shared/src/spectral/functions/pattern/candidate-helpers.ts @@ -0,0 +1,41 @@ +import type { SchemaNode } from '@finos/calm-models/pattern'; + +function isObject(value: unknown): value is SchemaNode { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function readUniqueId(node: SchemaNode): string | undefined { + const properties = node['properties']; + if (!isObject(properties)) return undefined; + const uniqueIdSchema = properties['unique-id']; + if (!isObject(uniqueIdSchema)) return undefined; + const constValue = uniqueIdSchema['const']; + return typeof constValue === 'string' ? constValue : undefined; +} + +export interface NodeInterface { + uniqueId: string; + index: number; +} + +/** + * Reads a node candidate's own `interfaces.prefixItems`, in declaration order, skipping + * any entry with no `const`-pinned `unique-id` — the same rule `listCandidates` applies + * to node/relationship candidates themselves. + */ +export function listNodeInterfaces(node: SchemaNode): NodeInterface[] { + const properties = node['properties']; + const interfacesSchema = isObject(properties) ? properties['interfaces'] : undefined; + const prefixItems = isObject(interfacesSchema) && Array.isArray(interfacesSchema['prefixItems']) + ? (interfacesSchema['prefixItems'] as SchemaNode[]) + : []; + + const result: NodeInterface[] = []; + prefixItems.forEach((iface, index) => { + if (!isObject(iface)) return; + const uniqueId = readUniqueId(iface); + if (!uniqueId) return; + result.push({ uniqueId, index }); + }); + return result; +} diff --git a/shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts b/shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts new file mode 100644 index 000000000..ffd169fdf --- /dev/null +++ b/shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts @@ -0,0 +1,27 @@ +import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; + +/** + * Reports a choice block that declares both `oneOf` and `anyOf`. Only `oneOf` is + * resolved, so the `anyOf` candidates cannot be reached. + * + * The built-in `xor` cannot do this. A plain `items` schema declares neither keyword, + * and it must be left alone. + */ +export function catalogSingleChoiceKeyword(input: unknown, _: unknown, context: RulesetFunctionContext): IFunctionResult[] { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return []; + } + + const items = input as Record; + if (!Array.isArray(items['oneOf']) || !Array.isArray(items['anyOf'])) { + return []; + } + + return [{ + message: + 'An items catalog declares both "oneOf" and "anyOf". Only the "oneOf" candidates are ' + + 'selectable - candidates under "anyOf" are silently dropped from generation and from ' + + 'the diagram. Declare exactly one of the two.', + path: [...context.path], + }]; +} diff --git a/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts b/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts new file mode 100644 index 000000000..f8350ad63 --- /dev/null +++ b/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts @@ -0,0 +1,41 @@ +import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; +import type { SchemaNode } from '@finos/calm-models/pattern'; +import { listCandidates, listSelectableCandidates } from '../../../pattern-candidates.js'; + +/** + * Reports a candidate that a decision names, but that selection cannot reach. + * + * An id that does not exist at all is already an error from + * `group-relationship-with-const-nodes-references-existing-nodes-in-pattern`. This rule + * covers only the declared-but-unreachable case, so a typo is not reported twice. + */ +export function decisionReferencesSelectableCandidate( + input: unknown, + { calmType }: { calmType: 'nodes' | 'relationships' }, + context: RulesetFunctionContext +): IFunctionResult[] { + if (!input || typeof input !== 'string') { + return []; + } + + const pattern = context.document.data as SchemaNode; + + const selectable = listSelectableCandidates(pattern, calmType).some((c) => c.uniqueId === input); + if (selectable) { + return []; + } + + // Undeclared ids belong to the other rule. + const declared = listCandidates(pattern, calmType).some((c) => c.uniqueId === input); + if (!declared) { + return []; + } + + return [{ + message: + `'${input}' is declared but cannot be selected: its block declares both 'oneOf' and ` + + '\'anyOf\', and only the \'oneOf\' candidates are resolved. Declare one keyword per ' + + 'block so every candidate a decision references can be chosen.', + path: [...context.path], + }]; +} diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts index 7c271ace8..69c27816a 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts @@ -1,6 +1,13 @@ +import { readFileSync } from 'fs'; +import path from 'path'; import { asContext } from '../spectral-test-helpers'; import idsAreUnique from './ids-are-unique'; +const optionsPrototypePatternPath = path.join( + __dirname, + '../../../../../calm/release/1.0-rc2/prototype/multiple-choices/options-prototype.pattern.json' +); + describe('idsAreUnique', () => { it('should return an empty array when there is no input', () => { const input = null; @@ -255,4 +262,45 @@ describe('idsAreUnique', () => { expect(result.length).toBeGreaterThan(0); expect(result[0].message).toContain('Duplicate unique-id detected. ID: node1, path: /properties/relationships/prefixItems/0/properties/unique-id/const'); }); + + it('detects a duplicate id declared across prefixItems oneOf alternatives (previously undetected)', () => { + // Positional slot alternatives were invisible to this rule until the migration to + // listCandidates: the identical clash inside an items catalog already errored, but + // two prefixItems[*].oneOf[*] alternatives sharing an id did not. + const input = {}; + const context = { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + { + oneOf: [ + { properties: { 'unique-id': { const: 'sql-store' } } }, + { properties: { 'unique-id': { const: 'sql-store' } } } + ] + } + ] + } + } + } + } + }; + + const result = idsAreUnique(input, null, asContext(context)); + expect(result.length).toBeGreaterThan(0); + expect(result[0].message).toContain('Duplicate unique-id detected. ID: sql-store, path: /properties/nodes/prefixItems/0/oneOf/1/properties/unique-id/const'); + }); + + it('reports no diagnostics for the shipped multiple-choices options prototype pattern', () => { + // Regression test: nodes.prefixItems[0] and relationships.prefixItems[0] are pure + // oneOf choice blocks with no unique-id of their own. A candidate enumeration that + // doesn't skip id-less slots fabricates an error-severity diagnostic for each. + const input = {}; + const pattern = JSON.parse(readFileSync(optionsPrototypePatternPath, 'utf-8')); + const context = { document: { data: pattern } }; + + const result = idsAreUnique(input, null, asContext(context)); + expect(result).toEqual([]); + }); }); \ No newline at end of file diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.ts b/shared/src/spectral/functions/pattern/ids-are-unique.ts index 2c08a5c1f..73a40c5dc 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.ts @@ -1,37 +1,63 @@ -import { JSONPath } from 'jsonpath-plus'; import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { detectDuplicates } from '../helper-functions'; +import { listCandidates, type Candidate, type SchemaNode } from '@finos/calm-models/pattern'; +import { listNodeInterfaces } from './candidate-helpers'; + +// Spectral's IFunctionResult.path is an array of path segments, not a pointer string. +// candidate.path stops at the candidate's own schema object, so the message and the +// reported path both need this suffix to point at the value that actually collided. +const UNIQUE_ID_PATH_SUFFIX = ['properties', 'unique-id', 'const']; + +interface DuplicateCheckEntry { + uniqueId: string; + path: (string | number)[]; +} + +function toEntry(candidate: Candidate): DuplicateCheckEntry { + return { uniqueId: candidate.uniqueId, path: candidate.path }; +} + +function detectDuplicateEntries(entries: DuplicateCheckEntry[], seenIds: Set, messages: IFunctionResult[]): void { + for (const entry of entries) { + if (seenIds.has(entry.uniqueId)) { + const path = [...entry.path, ...UNIQUE_ID_PATH_SUFFIX]; + messages.push({ + message: `Duplicate unique-id detected. ID: ${entry.uniqueId}, path: /${path.join('/')}`, + path, + }); + } else { + seenIds.add(entry.uniqueId); + } + } +} + /** - * Checks that the input value exists as a node with a matching unique ID. + * Checks that every node, relationship and interface unique-id in the pattern is unique. + * Node/relationship ids and their nested interface ids all share one `seenIds` set, so an + * id reused across nodes, relationships and interfaces is flagged too, not just within + * one of those. */ export default (input: unknown, _: unknown, context: RulesetFunctionContext): IFunctionResult[] => { if (!input) { return []; } - // get uniqueIds of all nodes - const nodeIdMatches = JSONPath({ path: '$.properties.nodes.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const nodeItemsOneOfIdMatches = JSONPath({ path: '$.properties.nodes.items.oneOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const nodeItemsAnyOfIdMatches = JSONPath({ path: '$.properties.nodes.items.anyOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const relationshipIdMatches = JSONPath({ path: '$.properties.relationships.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const relationshipItemsOneOfIdMatches = JSONPath({ path: '$.properties.relationships.items.oneOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const relationshipItemsAnyOfIdMatches = JSONPath({ path: '$.properties.relationships.items.anyOf[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const interfaceIdMatches = JSONPath({ path: '$.properties.nodes.prefixItems[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const interfaceItemsOneOfIdMatches = JSONPath({ path: '$.properties.nodes.items.oneOf[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - const interfaceItemsAnyOfIdMatches = JSONPath({ path: '$.properties.nodes.items.anyOf[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: context.document.data as object, resultType: 'all' }); - - const seenIds = new Set(); + const pattern = context.document.data as SchemaNode; + const nodeCandidates = listCandidates(pattern, 'nodes'); + const relationshipCandidates = listCandidates(pattern, 'relationships'); + + const interfaceEntries: DuplicateCheckEntry[] = nodeCandidates.flatMap((nodeCandidate) => + listNodeInterfaces(nodeCandidate.node).map((iface) => ({ + uniqueId: iface.uniqueId, + path: [...nodeCandidate.path, 'properties', 'interfaces', 'prefixItems', iface.index], + })) + ); + + const seenIds = new Set(); const messages: IFunctionResult[] = []; - detectDuplicates(nodeIdMatches, seenIds, messages); - detectDuplicates(nodeItemsOneOfIdMatches, seenIds, messages); - detectDuplicates(nodeItemsAnyOfIdMatches, seenIds, messages); - detectDuplicates(relationshipIdMatches, seenIds, messages); - detectDuplicates(relationshipItemsOneOfIdMatches, seenIds, messages); - detectDuplicates(relationshipItemsAnyOfIdMatches, seenIds, messages); - detectDuplicates(interfaceIdMatches, seenIds, messages); - detectDuplicates(interfaceItemsOneOfIdMatches, seenIds, messages); - detectDuplicates(interfaceItemsAnyOfIdMatches, seenIds, messages); + detectDuplicateEntries(nodeCandidates.map(toEntry), seenIds, messages); + detectDuplicateEntries(relationshipCandidates.map(toEntry), seenIds, messages); + detectDuplicateEntries(interfaceEntries, seenIds, messages); return messages; }; diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts index 0a5b5bfeb..ac26930fb 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts @@ -223,4 +223,67 @@ describe('interfaceIdExistsOnNode', () => { expect(result[0].message).toBe(`Referenced interface with ID '${input.interfaces[1]}' was not defined on the node with ID '${input.node}'.`); expect(result[0].path).toEqual(['/relationships/0/connects/destination']); }); + + describe('a prefixItems oneOf slot with two node alternatives', () => { + // { oneOf: [A(interfaces:[iA]), B(interfaces:[iB])] }. Before the migration to + // listCandidates, the inner unwrapping always resolved to alternative 0 and unioned + // interfaces across every alternative in the slot - so only the first two rows here + // passed; B was never findable, and interfaces borrowed across alternatives passed + // silently. All four rows must now behave correctly. + function contextWithTwoAlternatives() { + return { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + { + oneOf: [ + { + properties: { + 'unique-id': { const: 'A' }, + interfaces: { prefixItems: [{ properties: { 'unique-id': { const: 'iA' } } }] } + } + }, + { + properties: { + 'unique-id': { const: 'B' }, + interfaces: { prefixItems: [{ properties: { 'unique-id': { const: 'iB' } } }] } + } + } + ] + } + ] + } + } + } + }, + path: ['/relationships/0/connects/destination'] + }; + } + + it('accepts A referencing its own interface iA', () => { + const input = { node: 'A', interfaces: ['iA'] }; + expect(interfaceIdExistsOnNode(input, null, asContext(contextWithTwoAlternatives()))).toEqual([]); + }); + + it('rejects A referencing iB, which belongs to B (was a false negative)', () => { + const input = { node: 'A', interfaces: ['iB'] }; + const result = interfaceIdExistsOnNode(input, null, asContext(contextWithTwoAlternatives())); + expect(result.length).toBe(1); + expect(result[0].message).toBe('Referenced interface with ID \'iB\' was not defined on the node with ID \'A\'.'); + }); + + it('accepts B referencing its own interface iB (B was previously never findable)', () => { + const input = { node: 'B', interfaces: ['iB'] }; + expect(interfaceIdExistsOnNode(input, null, asContext(contextWithTwoAlternatives()))).toEqual([]); + }); + + it('rejects B referencing a nonexistent interface (was silently unchecked)', () => { + const input = { node: 'B', interfaces: ['nope'] }; + const result = interfaceIdExistsOnNode(input, null, asContext(contextWithTwoAlternatives())); + expect(result.length).toBe(1); + expect(result[0].message).toBe('Referenced interface with ID \'nope\' was not defined on the node with ID \'B\'.'); + }); + }); }); \ No newline at end of file diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts index 7616e8a58..18ce52d08 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts @@ -1,6 +1,7 @@ -import { JSONPath } from 'jsonpath-plus'; import { difference } from 'lodash'; import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; +import { listCandidates, type SchemaNode } from '@finos/calm-models/pattern'; +import { listNodeInterfaces } from './candidate-helpers'; interface ConnectsRelationship { node?: string; @@ -23,18 +24,12 @@ export function interfaceIdExistsOnNode(input: ConnectsRelationship | null | und } const nodeId = input.node; - const nodes: object[] = JSONPath({ path: '$.properties.nodes.prefixItems[*]', json: context.document.data as object }); - // Nodes may also be declared in an `items.oneOf`/`items.anyOf` open catalog; each catalog - // alternative is itself a node schema, so include them in the node lookup. - nodes.push(...JSONPath({ path: '$.properties.nodes.items.oneOf[*]', json: context.document.data as object })); - nodes.push(...JSONPath({ path: '$.properties.nodes.items.anyOf[*]', json: context.document.data as object })); - const node = nodes.find((node) => { - const uniqueId: string[] = JSONPath({ path: '$.properties.unique-id.const', json: node }); - uniqueId.push(...JSONPath({ path: '$.oneOf[*].properties.unique-id.const', json: node })); - uniqueId.push(...JSONPath({ path: '$.anyOf[*].properties.unique-id.const', json: node })); - return uniqueId && uniqueId[0] === nodeId; - }); - if (!node) { + const pattern = context.document.data as SchemaNode; + // Each candidate carries its own unique-id, so this finds the exact alternative that + // matches - not, as the old JSONPath-based lookup did, just the first alternative in + // a oneOf/anyOf slot regardless of which one actually has this id. + const nodeCandidate = listCandidates(pattern, 'nodes').find((candidate) => candidate.uniqueId === nodeId); + if (!nodeCandidate) { // other rule will report undefined node return []; } @@ -42,10 +37,10 @@ export function interfaceIdExistsOnNode(input: ConnectsRelationship | null | und // all of these must be present on the referenced node const desiredInterfaces = input.interfaces; - const nodeInterfaces = JSONPath({ path: '$.properties.interfaces.prefixItems[*].properties.unique-id.const', json: node }); - nodeInterfaces.push(...JSONPath({ path: '$.oneOf[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: node })); - nodeInterfaces.push(...JSONPath({ path: '$.anyOf[*].properties.interfaces.prefixItems[*].properties.unique-id.const', json: node })); - if (!nodeInterfaces || nodeInterfaces.length === 0) { + // Only this node's own interfaces - not, as before, the union of interfaces declared + // across every alternative in its oneOf/anyOf slot. + const nodeInterfaces = listNodeInterfaces(nodeCandidate.node).map((iface) => iface.uniqueId); + if (nodeInterfaces.length === 0) { return [ { message: `Node with unique-id ${nodeId} has no interfaces defined, expected interfaces [${desiredInterfaces}]` } ]; @@ -66,4 +61,4 @@ export function interfaceIdExistsOnNode(input: ConnectsRelationship | null | und }); } return results; -} \ No newline at end of file +} diff --git a/shared/src/spectral/functions/pattern/node-id-exists.spec.ts b/shared/src/spectral/functions/pattern/node-id-exists.spec.ts index 6db1223c0..9d8e595f2 100644 --- a/shared/src/spectral/functions/pattern/node-id-exists.spec.ts +++ b/shared/src/spectral/functions/pattern/node-id-exists.spec.ts @@ -1,6 +1,13 @@ +import { readFileSync } from 'fs'; +import path from 'path'; import { asContext } from '../spectral-test-helpers'; import nodeIdExists from './node-id-exists'; +const optionsPrototypePatternPath = path.join( + __dirname, + '../../../../../calm/release/1.0-rc2/prototype/multiple-choices/options-prototype.pattern.json' +); + describe('nodeIdExists (pattern)', () => { it('should return an empty array for non-string input', () => { const context = { document: { data: {} } }; @@ -94,4 +101,35 @@ describe('nodeIdExists (pattern)', () => { expect(result.length).toBe(1); expect(result[0].message).toBe(`'${missingId}' does not refer to the unique-id of an existing node.`); }); + + it('accepts a node declared as a prefixItems oneOf/anyOf slot alternative', () => { + const context = { + document: { + data: { + properties: { + nodes: { + prefixItems: [ + { oneOf: [{ properties: { 'unique-id': { const: 'sql-store' } } }] }, + { anyOf: [{ properties: { 'unique-id': { const: 'nosql-store' } } }] } + ] + } + } + } + } + }; + expect(nodeIdExists('sql-store', null, asContext(context))).toEqual([]); + expect(nodeIdExists('nosql-store', null, asContext(context))).toEqual([]); + }); + + it('accepts every node in the shipped multiple-choices options prototype pattern', () => { + // Regression test for the same fixture ids-are-unique is pinned against: nodes + // declared as prefixItems oneOf/anyOf slot alternatives must resolve correctly + // through the migrated candidate enumeration. + const pattern = JSON.parse(readFileSync(optionsPrototypePatternPath, 'utf-8')); + const context = { document: { data: pattern } }; + + for (const nodeId of ['application-a', 'application-b', 'node-1', 'node-2', 'application-c', 'database']) { + expect(nodeIdExists(nodeId, null, asContext(context))).toEqual([]); + } + }); }); diff --git a/shared/src/spectral/functions/pattern/node-id-exists.ts b/shared/src/spectral/functions/pattern/node-id-exists.ts index 7bef08242..8c063cd6c 100644 --- a/shared/src/spectral/functions/pattern/node-id-exists.ts +++ b/shared/src/spectral/functions/pattern/node-id-exists.ts @@ -1,5 +1,6 @@ -import { JSONPath } from 'jsonpath-plus'; import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; +import { listCandidates, type SchemaNode } from '@finos/calm-models/pattern'; + /** * Checks that the input value exists as a node with a matching unique ID. */ @@ -8,19 +9,12 @@ export default (input: unknown, _: unknown, context: RulesetFunctionContext): IF return []; } - const names = JSONPath({ path: '$.properties.nodes.prefixItems[*].properties.unique-id.const', json: context.document.data as object }); - const oneofs = JSONPath({ path: '$.properties.nodes.prefixItems[*].oneOf[*].properties.unique-id.const', json: context.document.data as object }); - const anyofs = JSONPath({ path: '$.properties.nodes.prefixItems[*].anyOf[*].properties.unique-id.const', json: context.document.data as object }); - // Nodes may also be declared in an `items.oneOf`/`items.anyOf` open catalog, not just positional prefixItems. - const itemsOneofs = JSONPath({ path: '$.properties.nodes.items.oneOf[*].properties.unique-id.const', json: context.document.data as object }); - const itemsAnyofs = JSONPath({ path: '$.properties.nodes.items.anyOf[*].properties.unique-id.const', json: context.document.data as object }); + const pattern = context.document.data as SchemaNode; + const nodeIds = listCandidates(pattern, 'nodes').map((candidate) => candidate.uniqueId); - // get uniqueIds of all nodes const results: IFunctionResult[] = []; - const allNodeIds = [...names, ...oneofs, ...anyofs, ...itemsOneofs, ...itemsAnyofs]; - - if (!allNodeIds.includes(input)) { + if (!nodeIds.includes(input)) { results.push({ message: `'${input}' does not refer to the unique-id of an existing node.`, path: [...context.path], diff --git a/shared/src/spectral/rules-pattern.spec.ts b/shared/src/spectral/rules-pattern.spec.ts index 4b90a14a6..8ccd8afad 100644 --- a/shared/src/spectral/rules-pattern.spec.ts +++ b/shared/src/spectral/rules-pattern.spec.ts @@ -34,6 +34,11 @@ function candidateRelationship(id: string) { return { properties: { 'unique-id': { const: id }, 'description': { const: `the ${id} link` } } }; } +async function ruleSeverityFor(pattern: object, code: string): Promise { + const result = await runSpectralValidations(JSON.stringify(pattern), patternRules, 'test'); + return result.spectralIssues.find((issue) => issue.code === code)?.severity; +} + async function ruleCodesFor(pattern: object): Promise { const result = await runSpectralValidations(JSON.stringify(pattern), patternRules, 'test'); return result.spectralIssues.map(issue => issue.code); @@ -112,3 +117,183 @@ describe('pattern-option-relationship-must-be-in-prefix-items', () => { expect(await ruleCodesFor(pattern)).not.toContain(RULE); }); }); + +describe('pattern-items-catalog-must-declare-one-choice-keyword', () => { + const RULE_BOTH = 'pattern-items-catalog-must-declare-one-choice-keyword'; + + function candidate(id: string) { + return { properties: { 'unique-id': { const: id } } }; + } + + it('reports when a nodes catalog declares both oneOf and anyOf', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [], items: { oneOf: [candidate('a')], anyOf: [candidate('b')] } }, + relationships: { prefixItems: [] } + } + }); + expect(codes).toContain(RULE_BOTH); + }); + + it('reports it as a warning, not an error', async () => { + // Declaring both is legal JSON Schema - both keywords apply, which for distinct + // `const` ids is unsatisfiable - so it is a smell, not an invalid document. The + // harm it can cause is caught in `selectChoices`, which throws when a chosen + // bundle names a candidate selection cannot reach. + const severity = await ruleSeverityFor({ + properties: { + nodes: { prefixItems: [], items: { oneOf: [candidate('a')], anyOf: [candidate('b')] } }, + relationships: { prefixItems: [] } + } + }, RULE_BOTH); + expect(severity).toBe('warning'); + }); + + it('reports when a relationships catalog declares both', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [] }, + relationships: { prefixItems: [], items: { oneOf: [candidate('r1')], anyOf: [candidate('r2')] } } + } + }); + expect(codes).toContain(RULE_BOTH); + }); + + it('reports a catalog declared inside an allOf branch', async () => { + const codes = await ruleCodesFor({ + allOf: [{ + properties: { + nodes: { prefixItems: [], items: { oneOf: [candidate('a')], anyOf: [candidate('b')] } }, + relationships: { prefixItems: [] } + } + }] + }); + expect(codes).toContain(RULE_BOTH); + }); + + it('passes a catalog declaring only oneOf', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [], items: { oneOf: [candidate('a'), candidate('b')] } }, + relationships: { prefixItems: [] } + } + }); + expect(codes).not.toContain(RULE_BOTH); + }); + + it('passes a catalog declaring only anyOf', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [], items: { anyOf: [candidate('a'), candidate('b')] } }, + relationships: { prefixItems: [] } + } + }); + expect(codes).not.toContain(RULE_BOTH); + }); + + it('leaves a plain items schema alone — it is not a catalog', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [candidate('a')], items: false }, + relationships: { prefixItems: [] } + } + }); + expect(codes).not.toContain(RULE_BOTH); + }); +}); + +describe('pattern-decision-must-reference-selectable-nodes', () => { + const RULE_SELECTABLE = 'pattern-decision-must-reference-selectable-nodes'; + const TYPO_RULE = 'group-relationship-with-const-nodes-references-existing-nodes-in-pattern'; + + function candidateNode(id: string) { + return { properties: { 'unique-id': { const: id }, name: { const: id }, 'node-type': { const: 'service' } } }; + } + + function decisionNaming(id: string) { + return { + properties: { + 'unique-id': { const: 'choice' }, + description: { const: 'Pick one' }, + 'relationship-type': { + type: 'object', + properties: { + options: { + type: 'array', + prefixItems: [{ + oneOf: [{ + properties: { + description: { const: 'Use it' }, + nodes: { const: [id] }, + relationships: { const: [] } + } + }] + }] + } + } + } + } + }; + } + + it('errors when a decision names a candidate in the losing keyword of a dual-keyword catalog', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [candidateNode('webapp')], items: { oneOf: [candidateNode('redis')], anyOf: [candidateNode('kafka')] } }, + relationships: { prefixItems: [decisionNaming('kafka')] } + } + }); + expect(codes).toContain(RULE_SELECTABLE); + }); + + it('errors for the same shape declared as a prefixItems slot rather than a catalog', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [candidateNode('webapp'), { oneOf: [candidateNode('a')], anyOf: [candidateNode('b')] }] }, + relationships: { prefixItems: [decisionNaming('b')] } + } + }); + expect(codes).toContain(RULE_SELECTABLE); + }); + + it('reports it as an error - the same harm as naming a candidate that does not exist', async () => { + const severity = await ruleSeverityFor({ + properties: { + nodes: { prefixItems: [candidateNode('webapp')], items: { oneOf: [candidateNode('redis')], anyOf: [candidateNode('kafka')] } }, + relationships: { prefixItems: [decisionNaming('kafka')] } + } + }, RULE_SELECTABLE); + expect(severity).toBe('error'); + }); + + it('leaves a plain typo to the existing rule rather than double-reporting it', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [candidateNode('webapp')], items: { oneOf: [candidateNode('redis')] } }, + relationships: { prefixItems: [decisionNaming('rediss')] } + } + }); + expect(codes).toContain(TYPO_RULE); + expect(codes).not.toContain(RULE_SELECTABLE); + }); + + it('passes a decision naming a reachable catalog candidate', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [candidateNode('webapp')], items: { oneOf: [candidateNode('redis')] } }, + relationships: { prefixItems: [decisionNaming('redis')] } + } + }); + expect(codes).not.toContain(RULE_SELECTABLE); + }); + + it('passes a decision naming a reachable slot alternative', async () => { + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [candidateNode('webapp'), { oneOf: [candidateNode('a'), candidateNode('b')] }] }, + relationships: { prefixItems: [decisionNaming('a')] } + } + }); + expect(codes).not.toContain(RULE_SELECTABLE); + }); +}); diff --git a/shared/src/spectral/rules-pattern.ts b/shared/src/spectral/rules-pattern.ts index c922bf512..e446404f0 100644 --- a/shared/src/spectral/rules-pattern.ts +++ b/shared/src/spectral/rules-pattern.ts @@ -1,5 +1,6 @@ import { RulesetDefinition } from '@stoplight/spectral-core'; import { falsy, pattern, truthy, length, xor } from '@stoplight/spectral-functions'; +import { catalogSingleChoiceKeyword } from './functions/pattern/catalog-single-choice-keyword'; import { numericalPlaceHolder } from './functions/helper-functions'; import nodeIdExists from './functions/pattern/node-id-exists'; import idsAreUnique from './functions/pattern/ids-are-unique'; @@ -7,6 +8,7 @@ import nodeHasRelationship from './functions/pattern/node-has-relationship'; import { interfaceIdExists } from './functions/pattern/interface-id-exists'; import { interfaceIdExistsOnNode } from './functions/pattern/interface-id-exists-on-node'; import { isDefinedInOneOfOrAnyOf } from './functions/pattern/is-defined-in-oneof-or-anyof'; +import { decisionReferencesSelectableCandidate } from './functions/pattern/decision-references-selectable-candidate'; const patternRules: RulesetDefinition = { @@ -203,13 +205,56 @@ const patternRules: RulesetDefinition = { }, }, }, + 'pattern-items-catalog-must-declare-one-choice-keyword': { + // Legal JSON Schema, so this is a smell and not an invalid document. Raising it + // to `error` would not help generation, because `calm generate` never validates. + description: 'An items catalog must declare only one of oneOf/anyOf', + severity: 'warn', + message: '{{error}}', + given: [ + '$.properties.nodes.items', + '$.properties.relationships.items', + '$.properties.nodes.prefixItems[*]', + '$.properties.relationships.prefixItems[*]', + '$.allOf[*].properties.nodes.items', + '$.allOf[*].properties.relationships.items', + '$.allOf[*].properties.nodes.prefixItems[*]', + '$.allOf[*].properties.relationships.prefixItems[*]', + ], + then: { + function: catalogSingleChoiceKeyword, + }, + }, + 'pattern-decision-must-reference-selectable-nodes': { + // Covers only the declared-but-unreachable case. A plain typo is already an + // error from group-relationship-with-const-nodes-references-existing-nodes. + description: 'Nodes referenced by a pattern decision must be selectable', + severity: 'error', + message: '{{error}}', + given: '$..relationship-type.properties.options.prefixItems[*]..nodes.const[*]', + then: { + function: decisionReferencesSelectableCandidate, + functionOptions: { + calmType: 'nodes' + } + }, + }, + 'pattern-decision-must-reference-selectable-relationships': { + description: 'Relationships referenced by a pattern decision must be selectable', + severity: 'error', + message: '{{error}}', + given: '$..relationship-type.properties.options.prefixItems[*]..relationships.const[*]', + then: { + function: decisionReferencesSelectableCandidate, + functionOptions: { + calmType: 'relationships' + } + }, + }, 'pattern-option-relationship-must-be-in-prefix-items': { - // A decision holder (a relationship carrying `relationship-type.options`) states a - // decision the architect must actively make - even when the answer is "add none". - // Declaring it in an `items` catalog is valid JSON Schema but makes the *decision* - // itself optional, so `calm generate` never offers it and the pattern no longer - // requires the resulting architecture to contain it. Candidates belong in the - // catalog; the decision that selects them belongs in `prefixItems`. + // A holder in an `items` catalog makes the decision itself optional, so + // `calm generate` never offers it. Candidates go in the catalog; the decision + // that selects them goes in prefixItems. description: 'Options relationships must be declared in relationships.prefixItems, not in an items catalog', severity: 'error', message: 'A relationship declaring "relationship-type.options" must be in "properties.relationships.prefixItems". Declaring a decision inside an "items" catalog makes the decision itself optional - move the options relationship into prefixItems and leave only its candidates in the catalog.', From 5fcb9f8c11ab8a670ee1e3f7c7851ec0685624b0 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sat, 22 Aug 2026 09:57:54 +0000 Subject: [PATCH 14/41] fix(shared): reject a generate answer that names an unreachable candidate extractOptions builds a prompt straight from a choice bundle without checking that its ids resolve, so the user was offered the choice either way. Selection then found no match and added nothing, discarding the answer in silence. runGenerate now asserts every chosen candidate is selectable before applying it. The guard is deliberately not in selectChoices, which validation also calls - a malformed pattern must surface its own schema errors there. A malformed choice block no longer passes through as a node in the output. --- .../components/catalog-decisions.spec.ts | 355 ++++++++++++++++++ .../components/decision-agreement.spec.ts | 91 +++++ .../generate/components/options.spec.ts | 42 +++ .../commands/generate/components/options.ts | 65 +++- shared/src/commands/generate/generate.ts | 7 +- 5 files changed, 551 insertions(+), 9 deletions(-) create mode 100644 shared/src/commands/generate/components/catalog-decisions.spec.ts create mode 100644 shared/src/commands/generate/components/decision-agreement.spec.ts diff --git a/shared/src/commands/generate/components/catalog-decisions.spec.ts b/shared/src/commands/generate/components/catalog-decisions.spec.ts new file mode 100644 index 000000000..8a4c33d0c --- /dev/null +++ b/shared/src/commands/generate/components/catalog-decisions.spec.ts @@ -0,0 +1,355 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { instantiate } from './instantiate'; +import { assertChoicesAreSelectable, extractOptions, selectChoices, CalmChoice, CalmOption } from './options'; +import { SchemaDirectory } from '../../../schema-directory'; + +/** + * End-to-end coverage for items-catalog decisions: a holder in `relationships.prefixItems` + * selecting candidates from a `nodes.items` catalog. + * + * Driven the way `calm generate` drives it - `extractOptions`, then `selectChoices`, then + * `instantiate`. The catalog tests in `instantiate.spec.ts` hand-build their choices, so + * they prove a catalog can be consumed but not that a decision is discoverable. + */ + +vi.mock('fs'); + +vi.mock('../../../logger', () => ({ + initLogger: vi.fn(function () { + return { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + }) +})); + +const schemaDirMocks = vi.hoisted(() => ({ + loadSchemas: vi.fn(), + loadCurrentPatternAsSchema: vi.fn(), + getDefinition: vi.fn() +})); + +vi.mock('../../../schema-directory', () => ({ + SchemaDirectory: vi.fn(function () { + return { + loadSchemas: schemaDirMocks.loadSchemas, + loadCurrentPatternAsSchema: schemaDirMocks.loadCurrentPatternAsSchema, + getDefinition: schemaDirMocks.getDefinition + }; + }), +})); + +interface InstantiatedArchitecture { + nodes: Array>; + relationships: Array>; +} + +function node(uniqueId: string, name: string) { + return { + properties: { + 'unique-id': { const: uniqueId }, + 'name': { const: name }, + 'node-type': { const: 'service' } + } + }; +} + +function choice(description: string, nodeId: string) { + return { + properties: { + description: { const: description }, + nodes: { const: [nodeId] }, + relationships: { const: [] } + } + }; +} + +/** A decision holder: a relationship carrying `relationship-type.options`. */ +function decision(uniqueId: string, prompt: string, blockType: 'oneOf' | 'anyOf', choices: object[]) { + return { + properties: { + 'unique-id': { const: uniqueId }, + 'description': { const: prompt }, + 'relationship-type': { + type: 'object', + properties: { + options: { + type: 'array', + prefixItems: [{ [blockType]: choices }] + } + } + } + } + }; +} + +/** + * One mandatory node, four optional candidates in a single catalog, and two independent + * decisions drawing from it. A pattern has exactly one nodes catalog and only a catalog + * can express an optional node, so this is the shape any pattern offering two optional + * components must take. + */ +const twoDecisionPattern = { + $schema: 'schema#', + $id: 'two-decision-catalog-pattern', + properties: { + nodes: { + type: 'array', + prefixItems: [node('webapp', 'Web App')], + items: { + anyOf: [ + node('redis', 'Redis'), + node('memcached', 'Memcached'), + node('kafka', 'Kafka'), + node('rabbitmq', 'RabbitMQ'), + ] + } + }, + relationships: { + type: 'array', + prefixItems: [ + decision('cache-choice', 'Pick a cache', 'anyOf', [ + choice('Use Redis', 'redis'), + choice('Use Memcached', 'memcached'), + ]), + decision('queue-choice', 'Pick a queue', 'anyOf', [ + choice('Use Kafka', 'kafka'), + choice('Use RabbitMQ', 'rabbitmq'), + ]), + ] + } + } +}; + +async function generate(pattern: object, chosen: CalmChoice[]): Promise { + const selected = selectChoices(pattern, chosen); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return await instantiate(selected, false, new SchemaDirectory({} as any)) as unknown as InstantiatedArchitecture; +} + +const nodeIds = (arch: InstantiatedArchitecture) => arch.nodes.map((n) => n['unique-id']); + +/** Finds a named choice on a named decision, the way the CLI resolves `--option-choices`. */ +function pick(options: CalmOption[], optionId: string, description: string): CalmChoice { + const option = options.find((o) => o.optionId === optionId); + if (!option) throw new Error(`no such decision: ${optionId}`); + const found = option.choices.find((c) => c.description === description); + if (!found) throw new Error(`no such choice on ${optionId}: ${description}`); + return found; +} + +describe('items catalog decisions, end to end', () => { + beforeEach(() => { + vi.clearAllMocks(); + schemaDirMocks.getDefinition.mockResolvedValue({ type: 'object', properties: {} }); + }); + + describe('discovery', () => { + it('offers both decisions, each with its own prompt and choices', () => { + const options = extractOptions(twoDecisionPattern); + + expect(options.map((o) => o.optionId)).toEqual(['cache-choice', 'queue-choice']); + expect(options.map((o) => o.prompt)).toEqual(['Pick a cache', 'Pick a queue']); + expect(options.every((o) => o.optionType === 'anyOf')).toBe(true); + expect(options[0].choices.map((c) => c.description)).toEqual(['Use Redis', 'Use Memcached']); + expect(options[1].choices.map((c) => c.description)).toEqual(['Use Kafka', 'Use RabbitMQ']); + }); + + it('names only catalog candidates in its choices', () => { + const options = extractOptions(twoDecisionPattern); + expect(options.flatMap((o) => o.choices.flatMap((c) => c.nodes))) + .toEqual(['redis', 'memcached', 'kafka', 'rabbitmq']); + }); + }); + + describe('zero or more selection', () => { + it('materializes nothing from the catalog when no choice is made', async () => { + const arch = await generate(twoDecisionPattern, []); + expect(nodeIds(arch)).toEqual(['webapp']); + }); + + it('materializes one candidate per answered decision', async () => { + const options = extractOptions(twoDecisionPattern); + const arch = await generate(twoDecisionPattern, [ + pick(options, 'cache-choice', 'Use Redis'), + pick(options, 'queue-choice', 'Use Kafka'), + ]); + expect(nodeIds(arch)).toEqual(['webapp', 'redis', 'kafka']); + }); + + it('answers each decision independently — one answered, one left alone', async () => { + const options = extractOptions(twoDecisionPattern); + const arch = await generate(twoDecisionPattern, [ + pick(options, 'queue-choice', 'Use RabbitMQ'), + ]); + expect(nodeIds(arch)).toEqual(['webapp', 'rabbitmq']); + }); + + it('accepts several answers to one anyOf decision', async () => { + const options = extractOptions(twoDecisionPattern); + const arch = await generate(twoDecisionPattern, [ + pick(options, 'cache-choice', 'Use Redis'), + pick(options, 'cache-choice', 'Use Memcached'), + ]); + expect(nodeIds(arch)).toEqual(['webapp', 'redis', 'memcached']); + }); + + it('records the answer on the decision holder, which survives into the architecture', async () => { + // The holder is not scaffolding that gets stripped - it stays, carrying the + // chosen bundle, so the generated architecture records which option was taken. + const options = extractOptions(twoDecisionPattern); + const arch = await generate(twoDecisionPattern, [pick(options, 'cache-choice', 'Use Redis')]); + + const holder = arch.relationships.find((r) => r['unique-id'] === 'cache-choice'); + expect(holder).toBeDefined(); + expect(holder!['description']).toBe('Pick a cache'); + + const relationshipType = holder!['relationship-type'] as Record; + const recorded = relationshipType['options'] as Array>; + expect(recorded.map((o) => o['description'])).toEqual(['Use Redis']); + expect(recorded.flatMap((o) => o['nodes'] as string[])).toEqual(['redis']); + }); + + it('drops the unchosen alternatives from the recorded answer', async () => { + const options = extractOptions(twoDecisionPattern); + const arch = await generate(twoDecisionPattern, [pick(options, 'cache-choice', 'Use Memcached')]); + + const holder = arch.relationships.find((r) => r['unique-id'] === 'cache-choice')!; + const recorded = (holder['relationship-type'] as Record)['options'] as Array>; + expect(recorded.map((o) => o['description'])).toEqual(['Use Memcached']); + }); + + it('clears the catalog so the array is fully expressed by prefixItems', async () => { + const selected = selectChoices(twoDecisionPattern, []) as Record; + const nodes = selected['properties']['nodes']; + expect(nodes['items']).toBeUndefined(); + expect(nodes['prefixItems']).toHaveLength(1); + }); + }); + + describe('mandatory nodes are unaffected by decisions', () => { + it('always emits the prefixItems node regardless of answers', async () => { + const options = extractOptions(twoDecisionPattern); + const none = await generate(twoDecisionPattern, []); + const all = await generate(twoDecisionPattern, [ + pick(options, 'cache-choice', 'Use Redis'), + pick(options, 'cache-choice', 'Use Memcached'), + pick(options, 'queue-choice', 'Use Kafka'), + pick(options, 'queue-choice', 'Use RabbitMQ'), + ]); + expect(nodeIds(none)).toContain('webapp'); + expect(nodeIds(all)).toEqual(['webapp', 'redis', 'memcached', 'kafka', 'rabbitmq']); + }); + }); +}); + +describe('an answer that cannot be honoured is refused, not discarded', () => { + // The check lives on the generate path (runGenerate calls it), deliberately not inside + // selectChoices - validation calls selectChoices too, to replay an architecture's + // options onto its pattern, and a malformed pattern must surface its own schema errors + // there rather than be pre-empted by this one. + // extractOptions builds the prompt straight from a choice bundle without checking its + // ids resolve, so the user is offered the choice either way. Before this guard the + // answer was silently dropped and the architecture generated without it. + const catalogPattern = (items: object) => ({ + $schema: 'schema#', $id: 'unreachable', + properties: { + nodes: { type: 'array', prefixItems: [node('webapp', 'Web App')], items }, + relationships: { type: 'array', prefixItems: [] }, + } + }); + + it('throws when a choice bundle names a node id that does not exist', () => { + const pattern = catalogPattern({ oneOf: [node('redis', 'Redis')] }); + expect(() => assertChoicesAreSelectable(pattern, [ + { description: 'Use Redis', nodes: ['rediss'], relationships: [] }, + ])).toThrow(/rediss/); + }); + + it('throws when a catalog declares both keywords, making the anyOf side unreachable', () => { + // Legal JSON Schema, but only the oneOf list is resolved, so kafka can never be + // selected even though validation sees it as a properly declared node. + const pattern = catalogPattern({ oneOf: [node('redis', 'Redis')], anyOf: [node('kafka', 'Kafka')] }); + expect(() => assertChoicesAreSelectable(pattern, [ + { description: 'Use Kafka', nodes: ['kafka'], relationships: [] }, + ])).toThrow(/kafka/); + }); + + it('throws when a bundle names an unreachable relationship id', () => { + const pattern = { + $schema: 'schema#', $id: 'unreachable-rel', + properties: { + nodes: { type: 'array', prefixItems: [node('webapp', 'Web App')] }, + relationships: { type: 'array', prefixItems: [] }, + } + }; + expect(() => assertChoicesAreSelectable(pattern, [ + { description: 'Link them', nodes: [], relationships: ['ghost-link'] }, + ])).toThrow(/ghost-link/); + }); + + it('accepts ids reachable through a catalog, a slot, or a plain entry', () => { + expect(() => assertChoicesAreSelectable(catalogPattern({ oneOf: [node('redis', 'Redis')] }), [ + { description: 'Use Redis', nodes: ['redis'], relationships: [] }, + ])).not.toThrow(); + + const slotPattern = { + $schema: 'schema#', $id: 'slot', + properties: { + nodes: { type: 'array', prefixItems: [{ oneOf: [node('a', 'A'), node('b', 'B')] }] }, + relationships: { type: 'array', prefixItems: [] }, + } + }; + expect(() => assertChoicesAreSelectable(slotPattern, [ + { description: 'Use A', nodes: ['a'], relationships: [] }, + ])).not.toThrow(); + }); +}); + +/** + * Pins the agreement between `extractOptions` and `assertChoicesAreSelectable` about where + * a pattern declares its nodes and relationships. + * + * Both must resolve `allOf` the same way. `extractOptions` reads through `getPatternArray`, + * which falls back into `allOf` branches. `calm-models`' `listCandidates` deliberately does + * not, because its `path` positions Spectral diagnostics. + * + * That difference blocks collapsing the two candidate walks into one `allOf`-blind reader. + * A blind guard finds no candidate here, so generation rejects the answer to its own + * question. If you are consolidating the walks and this fails, the consolidation broke + * generation. + */ +describe('decisions declared under allOf', () => { + const allOfDecisionPattern = { + $schema: 'schema#', + $id: 'allof-decision-pattern', + allOf: [ + { + properties: { + nodes: { + type: 'array', + prefixItems: [node('webapp', 'Web App')], + items: { anyOf: [node('redis', 'Redis'), node('memcached', 'Memcached')] } + }, + relationships: { + type: 'array', + prefixItems: [ + decision('cache-choice', 'Pick a cache', 'anyOf', [ + choice('Use Redis', 'redis'), + choice('Use Memcached', 'memcached'), + ]) + ] + } + } + } + ] + }; + + it('are discovered by extractOptions', () => { + const options = extractOptions(allOfDecisionPattern); + expect(options.map((o) => o.optionId)).toEqual(['cache-choice']); + }); + + it('accept an answer the guard can resolve to a real candidate', () => { + const options = extractOptions(allOfDecisionPattern); + const chosen = pick(options, 'cache-choice', 'Use Redis'); + expect(() => assertChoicesAreSelectable(allOfDecisionPattern, [chosen])).not.toThrow(); + }); +}); diff --git a/shared/src/commands/generate/components/decision-agreement.spec.ts b/shared/src/commands/generate/components/decision-agreement.spec.ts new file mode 100644 index 000000000..89636484e --- /dev/null +++ b/shared/src/commands/generate/components/decision-agreement.spec.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { instantiate } from './instantiate'; +import { extractOptions, selectChoices, CalmChoice } from './options'; +import { SchemaDirectory } from '../../../schema-directory'; + +/** + * The generation half of the decision-agreement contract. See + * `test_fixtures/decision-agreement/README.md` - the visualiser asserts the same two + * things against the same files, so a drift on either side fails a test. + */ + +vi.mock('fs', async () => { + const actual = await vi.importActual('fs'); + return { ...actual, default: actual }; +}); + +vi.mock('../../../logger', () => ({ + initLogger: vi.fn(() => ({ info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() })) +})); + +const schemaDirMocks = vi.hoisted(() => ({ + loadSchemas: vi.fn(), + loadCurrentPatternAsSchema: vi.fn(), + getDefinition: vi.fn() +})); + +vi.mock('../../../schema-directory', () => ({ + SchemaDirectory: vi.fn(function () { + return { + loadSchemas: schemaDirMocks.loadSchemas, + loadCurrentPatternAsSchema: schemaDirMocks.loadCurrentPatternAsSchema, + getDefinition: schemaDirMocks.getDefinition + }; + }), +})); + +const FIXTURES = path.resolve(__dirname, '../../../../../test_fixtures/decision-agreement'); + +interface ExpectedDecision { + optionId: string; + prompt: string; + optionType: 'oneOf' | 'anyOf'; + choices: { description: string; nodes: string[]; relationships: string[] }[]; +} +interface Expected { + decisions: ExpectedDecision[]; + answered: { choose: Record; nodes: string[] }[]; +} + +const read = (name: string, suffix: string) => + JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.${suffix}.json`), 'utf8')); + +const cases = ['one-decision-one-catalog', 'two-decisions-one-catalog']; + +describe.each(cases)('decision agreement: %s (generation side)', (name) => { + const pattern = read(name, 'pattern'); + const expected: Expected = read(name, 'expected'); + + it('offers exactly the expected decisions', () => { + const actual = extractOptions(pattern).map((o) => ({ + optionId: o.optionId, + prompt: o.prompt, + optionType: o.optionType, + choices: o.choices.map((c) => ({ + description: c.description, + nodes: c.nodes, + relationships: c.relationships, + })), + })); + expect(actual).toEqual(expected.decisions); + }); + + it.each(expected.answered)('produces the expected nodes for $choose', async ({ choose, nodes }) => { + const options = extractOptions(pattern); + const chosen: CalmChoice[] = Object.entries(choose).map(([optionId, description]) => { + const option = options.find((o) => o.optionId === optionId); + const choice = option?.choices.find((c) => c.description === description); + if (!choice) throw new Error(`fixture names a choice that does not exist: ${optionId} / ${description}`); + return choice; + }); + + const selected = selectChoices(pattern, chosen); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const arch = await instantiate(selected, false, new SchemaDirectory({} as any)) as unknown as { + nodes: Record[]; + }; + expect(arch.nodes.map((n) => n['unique-id']).sort()).toEqual([...nodes].sort()); + }); +}); diff --git a/shared/src/commands/generate/components/options.spec.ts b/shared/src/commands/generate/components/options.spec.ts index c095fff29..65444e344 100644 --- a/shared/src/commands/generate/components/options.spec.ts +++ b/shared/src/commands/generate/components/options.spec.ts @@ -401,6 +401,48 @@ describe('Pattern Options', () => { expect(result.properties.nodes.prefixItems).toEqual([cache]); }); + it('recovers from a malformed items-catalog oneOf ({}) via the sibling anyOf, instead of throwing', () => { + // `oneOf: {}` is not an array, so the old `??`-based selection treated it as + // present and threw ("catalogAlternatives.filter is not a function") instead of + // falling through to the valid `anyOf`. + const webapp = buildNode('webapp'); + const cache = buildNode('cache'); + const pattern = { + 'properties': { + 'nodes': { + 'prefixItems': [webapp], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'items': { 'oneOf': {} as any, 'anyOf': [cache] }, + }, + 'relationships': { 'prefixItems': [] }, + }, + }; + const cacheChoice: CalmChoice = { description: 'Use cache', nodes: ['cache'], relationships: [] }; + + expect(() => selectChoices(pattern, [cacheChoice])).not.toThrow(); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectChoices(pattern, [cacheChoice]) as any; + expect(result.properties.nodes.prefixItems).toEqual([webapp, cache]); + }); + + it('throws on a malformed prefixItems slot (oneOf: {}) instead of emitting it as a candidate', () => { + // A slot with a truthy but non-array `oneOf` and no `anyOf` is not a valid + // choice block. Passing it through unflattened would emit the malformed + // `{ oneOf: {} }` object itself as a node candidate in generated output. + const pattern = { + 'properties': { + 'nodes': { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'prefixItems': [{ 'oneOf': {} as any }], + }, + 'relationships': { 'prefixItems': [] }, + }, + }; + + expect(() => selectChoices(pattern, [])).toThrow(/Malformed oneOf\/anyOf block/); + }); + it('should not affect a normal pattern', () => { const applicationA = buildNode('application-a'); const applicationB = buildNode('application-b'); diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index 63f2a1921..472d6a71c 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -1,5 +1,6 @@ import { initLogger } from '../../../logger'; -import { getPatternArray } from '@finos/calm-models/pattern'; +import { getPatternArray, readChoiceBlock } from '@finos/calm-models/pattern'; +import { listSelectableCandidates } from '../../../pattern-candidates.js'; /** * A node within a CALM pattern's JSON schema. The pattern is unvalidated JSON @@ -94,14 +95,21 @@ type Item = { * @returns A list of items that match the selection predicate, or the item itself if it is not a oneOf or anyOf block */ function flattenOneOfAndAnyOf(item: Item, selectionPredicate: (item: SchemaNode) => boolean): object[] { - if (!(item.oneOf || item.anyOf)) { + const block = readChoiceBlock(item); + + if (!block) { + if (item.oneOf || item.anyOf) { + // A oneOf/anyOf key is present but isn't a usable choice block (neither value + // is an array). Passing the item through as-is would emit this malformed + // block itself as a node/relationship candidate in the generated output - + // fail loudly instead. + throw new Error(`Malformed oneOf/anyOf block: neither "oneOf" nor "anyOf" is an array in ${JSON.stringify(item)}`); + } // If it isn't a oneOf or anyOf block, there isn't anything to flatten so return the item return [item]; } - const items: object[] = item.oneOf ?? item.anyOf ?? []; - - return items + return (block.alternatives as object[]) .flatMap((x: object) => x) .filter((x: SchemaNode) => selectionPredicate(x)); } @@ -130,9 +138,11 @@ function flattenCalmItems(pattern: SchemaNode, calmType: 'nodes' | 'relationship // Only treat `items` as a decision catalog when it is a oneOf/anyOf of candidates. A plain // `items` schema (or `items: false` closing a tuple) is not part of the decision mechanism and // must be left untouched rather than stripped. - const isCatalog = Array.isArray(itemsCatalog?.oneOf) || Array.isArray(itemsCatalog?.anyOf); - const catalogAlternatives: SchemaNode[] = isCatalog ? (itemsCatalog!.oneOf ?? itemsCatalog!.anyOf ?? []) : []; - const selectedCatalogItems = catalogAlternatives.filter(selectionPredicate); + const catalogBlock = readChoiceBlock(itemsCatalog); + const isCatalog = catalogBlock !== null; + const selectedCatalogItems: SchemaNode[] = isCatalog + ? (catalogBlock!.alternatives as SchemaNode[]).filter(selectionPredicate) + : []; calmProps['prefixItems'] = [...flattenedPrefixItems, ...selectedCatalogItems]; @@ -166,6 +176,44 @@ function flattenOptionsRelationships(pattern: SchemaNode, choices: CalmChoice[]) .map((rel: SchemaNode) => flattenOptionsRelationship(rel, choices)); } +/** + * Fails when a chosen bundle names a candidate that selection cannot reach. + * + * Nothing upstream checks that a bundle's ids resolve. `extractOptions` builds the prompt + * from the bundle, so the user is offered the choice either way. Selection then finds no + * match and adds nothing, which discards the answer in silence. + * + * Two causes: a typo in the bundle, or a block that declares both `oneOf` and `anyOf`. + * In the second case the `anyOf` candidates look declared to validation, but only the + * `oneOf` list is resolved. + * + * Called from `runGenerate`, and deliberately not from `selectChoices`. Validation also + * calls `selectChoices`, and a malformed pattern must show its own schema errors there. + */ +export function assertChoicesAreSelectable(pattern: SchemaNode, choices: CalmChoice[]): void { + const declaredNodes = new Set(listSelectableCandidates(pattern, 'nodes').map((c) => c.uniqueId)); + const declaredRelationships = new Set(listSelectableCandidates(pattern, 'relationships').map((c) => c.uniqueId)); + + const unresolved: string[] = []; + for (const choice of choices) { + for (const id of choice.nodes) { + if (!declaredNodes.has(id)) unresolved.push(`node "${id}" (choice "${choice.description}")`); + } + for (const id of choice.relationships) { + if (!declaredRelationships.has(id)) unresolved.push(`relationship "${id}" (choice "${choice.description}")`); + } + } + + if (unresolved.length > 0) { + throw new Error( + 'The pattern does not declare every candidate its decisions reference, so the ' + + 'selection cannot be applied: ' + unresolved.join('; ') + '. Check the ' + + 'unique-ids in the choice bundles, and that a catalog does not declare both ' + + '"oneOf" and "anyOf" (only the "oneOf" candidates are selectable).' + ); + } +} + /** * Selects the choices from the pattern and removes all other choices. * @param inputPattern - The input pattern to select choices from @@ -173,6 +221,7 @@ function flattenOptionsRelationships(pattern: SchemaNode, choices: CalmChoice[]) * @param debug - Whether to enable debug logging * @returns A new pattern object with the selected choices and all oneOf and anyOf blocks flattened */ + export function selectChoices(inputPattern: object, choices: CalmChoice[], debug: boolean = false): object { const logger = initLogger(debug, 'calm-generate-options'); logger.debug(`Selecting these choices from the pattern [${JSON.stringify(choices)}]`); diff --git a/shared/src/commands/generate/generate.ts b/shared/src/commands/generate/generate.ts index 15ce20e9d..ea952d10a 100644 --- a/shared/src/commands/generate/generate.ts +++ b/shared/src/commands/generate/generate.ts @@ -2,12 +2,14 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { mkdirp } from 'mkdirp'; -import { CalmChoice, selectChoices } from './components/options.js'; +import { assertChoicesAreSelectable, CalmChoice, selectChoices } from './components/options.js'; import { instantiate } from './components/instantiate'; import { flattenAllOf } from './components/flatten-allof'; import { initLogger } from '../../logger.js'; import { SchemaDirectory } from '../../schema-directory.js'; +type SchemaNodeLike = { [key: string]: unknown }; + export async function runGenerate(pattern: object, outputPath: string, debug: boolean, schemaDirectory: SchemaDirectory, chosenChoices?: CalmChoice[]): Promise { const logger = initLogger(debug, 'calm-generate'); logger.info('Generating a CALM architecture...'); @@ -21,6 +23,9 @@ export async function runGenerate(pattern: object, outputPath: string, debug: bo ); if (chosenChoices) { + // Refuse rather than silently drop an answer the pattern cannot honour. The + // catch below turns this into a logged error and no output file. + assertChoicesAreSelectable(flattenedPattern as SchemaNodeLike, chosenChoices); flattenedPattern = selectChoices(flattenedPattern, chosenChoices, debug); } From e40ecead97c3495f3c25594ae7538f286a68aa13 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sat, 22 Aug 2026 09:57:55 +0000 Subject: [PATCH 15/41] fix(calm-hub-ui): key decision groups per decision, not per declaration site Every candidate in an items catalog was assigned one group at extraction time, so two decisions drawing from one catalog folded into it and the second overwrote the first: one prompt vanished and all candidates rendered in a box labelled with the other decision. A pattern has one nodes catalog and only a catalog can express an optional node, so this is the shape of any pattern offering two optional components. Each decision now allocates its own group and claims its candidates. A candidate named by two decisions goes to the first; a decision left with nothing renders no box. Boxing one node twice needs the nesting rework in #2933. Also stops a decision disappearing when its candidates are themselves containers. Container nodes returned before their group membership was recorded, so the group was dropped and nothing indicated the two containers were alternatives. The box carries its prompt again. The containers stay at top level - nesting is #2933. --- .../utils/patternTransformer.test.ts | 224 ++++++++++++++++++ .../reactflow/utils/patternTransformer.ts | 66 +++--- 2 files changed, 251 insertions(+), 39 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts index 8c0e10b4b..5ca0c06a4 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.test.ts @@ -784,3 +784,227 @@ describe('nested container ordering', () => { expect(ids).toEqual(['A', 'B', 'C', 'system']); }); }); + +describe('decision groups are keyed per decision, not per declaration site', () => { + const cacheQueueCatalog = [ + schemaNode('redis', 'Redis', 'database'), + schemaNode('memcached', 'Memcached', 'database'), + schemaNode('kafka', 'Kafka', 'queue'), + schemaNode('rabbitmq', 'RabbitMQ', 'queue'), + ]; + + type RfNode = { id: string; type?: string; parentId?: string; data: Record }; + + /** The prompt on each rendered decision box, sorted. */ + const prompts = (nodes: RfNode[]) => + nodes.filter((n) => n.type === 'decisionGroup').map((n) => n.data.prompt as unknown as string).sort(); + + /** The box a candidate was placed in. Fails the test if it is in none. */ + const boxOf = (nodes: RfNode[], id: string): string => { + const parentId = nodes.find((n) => n.id === id)?.parentId; + expect(parentId, `${id} is in no decision box`).toBeDefined(); + return parentId as string; + }; + + /** The choice descriptions on the box carrying a given prompt. */ + const choicesFor = (nodes: RfNode[], prompt: string): string[] => { + const box = nodes.find((n) => n.type === 'decisionGroup' && n.data.prompt === prompt); + expect(box, `no decision box with prompt "${prompt}"`).toBeDefined(); + return (box!.data.choices as unknown as { description: string }[]).map((c) => c.description); + }; + + const twoDecisionPattern = (relationships: unknown[]) => + makePatternWithItems( + [schemaNode('webapp', 'Web App', 'service')], + cacheQueueCatalog, + relationships, + [], + 'anyOf' + ); + + const cacheDecision = optionsRelationship('cache-choice', 'Pick a cache', [ + { description: 'Use Redis', nodes: ['redis'] }, + { description: 'Use Memcached', nodes: ['memcached'] }, + ]); + const queueDecision = optionsRelationship('queue-choice', 'Pick a queue', [ + { description: 'Use Kafka', nodes: ['kafka'] }, + { description: 'Use RabbitMQ', nodes: ['rabbitmq'] }, + ]); + + it('renders one box per decision when two decisions draw from one catalog', () => { + const result = parsePatternData(twoDecisionPattern([cacheDecision, queueDecision])) as { nodes: RfNode[] }; + + expect(prompts(result.nodes)).toEqual(['Pick a cache', 'Pick a queue']); + + // Each box carries its own choices, not the other decision's. + expect(choicesFor(result.nodes, 'Pick a cache')).toEqual(['Use Redis', 'Use Memcached']); + expect(choicesFor(result.nodes, 'Pick a queue')).toEqual(['Use Kafka', 'Use RabbitMQ']); + + // Every candidate is drawn, and each decision's candidates share one box. + ['redis', 'memcached', 'kafka', 'rabbitmq'].forEach((id) => + expect(result.nodes.find((n) => n.id === id), id).toBeDefined() + ); + expect(boxOf(result.nodes, 'redis')).toBe(boxOf(result.nodes, 'memcached')); + expect(boxOf(result.nodes, 'kafka')).toBe(boxOf(result.nodes, 'rabbitmq')); + expect(boxOf(result.nodes, 'redis')).not.toBe(boxOf(result.nodes, 'kafka')); + }); + + it('still renders one box when one decision draws from one catalog', () => { + const pattern = makePatternWithItems( + [schemaNode('webapp', 'Web App', 'service')], + [schemaNode('redis', 'Redis', 'database'), schemaNode('memcached', 'Memcached', 'database')], + [cacheDecision], + [], + 'anyOf' + ); + const result = parsePatternData(pattern) as { nodes: RfNode[] }; + + expect(prompts(result.nodes)).toEqual(['Pick a cache']); + expect(choicesFor(result.nodes, 'Pick a cache')).toEqual(['Use Redis', 'Use Memcached']); + expect(boxOf(result.nodes, 'redis')).toBe(boxOf(result.nodes, 'memcached')); + }); + + it('gives a shared candidate to the first decision and still renders the second', () => { + // memcached is named by both. A node has one parent, so the first decision + // keeps it and the second renders with what is left. + const storeDecision = optionsRelationship('store-choice', 'Pick a store', [ + { description: 'Use Memcached', nodes: ['memcached'] }, + { description: 'Use Kafka', nodes: ['kafka'] }, + ]); + // Exactly the three candidates the two decisions name. A fourth, unreferenced + // candidate would keep its own declaration-site box and is not what this pins. + const pattern = makePatternWithItems( + [schemaNode('webapp', 'Web App', 'service')], + [ + schemaNode('redis', 'Redis', 'database'), + schemaNode('memcached', 'Memcached', 'database'), + schemaNode('kafka', 'Kafka', 'queue'), + ], + [cacheDecision, storeDecision], + [], + 'anyOf' + ); + const result = parsePatternData(pattern) as { nodes: RfNode[] }; + + expect(prompts(result.nodes)).toEqual(['Pick a cache', 'Pick a store']); + expect(boxOf(result.nodes, 'memcached')).toBe(boxOf(result.nodes, 'redis')); + expect(boxOf(result.nodes, 'kafka')).not.toBe(boxOf(result.nodes, 'redis')); + + // The second box still offers the shared candidate as a choice. Only the + // drawing is exclusive, not the decision. + expect(choicesFor(result.nodes, 'Pick a store')).toEqual(['Use Memcached', 'Use Kafka']); + }); + + it('is declaration order that decides which decision keeps a shared candidate', () => { + const storeDecision = optionsRelationship('store-choice', 'Pick a store', [ + { description: 'Use Memcached', nodes: ['memcached'] }, + { description: 'Use Kafka', nodes: ['kafka'] }, + ]); + const pattern = makePatternWithItems( + [schemaNode('webapp', 'Web App', 'service')], + [ + schemaNode('redis', 'Redis', 'database'), + schemaNode('memcached', 'Memcached', 'database'), + schemaNode('kafka', 'Kafka', 'queue'), + ], + [storeDecision, cacheDecision], + [], + 'anyOf' + ); + const reversed = parsePatternData(pattern) as { nodes: RfNode[] }; + + // Same two decisions, declared the other way round: now the store box keeps it. + expect(boxOf(reversed.nodes, 'memcached')).toBe(boxOf(reversed.nodes, 'kafka')); + expect(boxOf(reversed.nodes, 'memcached')).not.toBe(boxOf(reversed.nodes, 'redis')); + }); + + it('renders no box for a decision whose candidates are all claimed by an earlier one', () => { + // A documented limit, not a fix. Boxing one node twice needs #2933. + const pattern = makePatternWithItems( + [schemaNode('webapp', 'Web App', 'service')], + [schemaNode('redis', 'Redis', 'database')], + [ + optionsRelationship('cache-choice', 'Pick a cache', [ + { description: 'Use Redis', nodes: ['redis'] }, + ]), + optionsRelationship('store-choice', 'Pick a store', [ + { description: 'Use Redis', nodes: ['redis'] }, + ]), + ], + [], + 'anyOf' + ); + const result = parsePatternData(pattern) as { nodes: RfNode[] }; + + expect(prompts(result.nodes)).toEqual(['Pick a cache']); + }); +}); + +describe('decisions and containers', () => { + type RfNode = { id: string; type?: string; parentId?: string; data: Record }; + + const deployedIn = (uniqueId: string, container: string, nodes: string[]) => ({ + properties: { + 'unique-id': { const: uniqueId }, + 'relationship-type': { const: { 'deployed-in': { container, nodes } } }, + }, + }); + + it('keeps the decision box when every candidate is itself a container', () => { + // opt-a and opt-b each contain a leaf, so both are containers. The box must + // still carry the decision text. Nesting them inside it is #2933. + const pattern = makePattern( + [ + schemaNode('opt-a', 'Option A', 'system'), + schemaNode('opt-b', 'Option B', 'system'), + schemaNode('leaf-a', 'Leaf A', 'service'), + schemaNode('leaf-b', 'Leaf B', 'service'), + ], + [ + deployedIn('deploy-a', 'opt-a', ['leaf-a']), + deployedIn('deploy-b', 'opt-b', ['leaf-b']), + optionsRelationship('subsystem-choice', 'Pick a subsystem', [ + { description: 'Use A', nodes: ['opt-a'] }, + { description: 'Use B', nodes: ['opt-b'] }, + ]), + ] + ); + const result = parsePatternData(pattern) as { nodes: RfNode[] }; + + const groups = result.nodes.filter((n) => n.type === 'decisionGroup'); + expect(groups).toHaveLength(1); + expect(groups[0].data.prompt).toBe('Pick a subsystem'); + + // The containers must NOT be nested inside the box - that is #2933's change. + expect(result.nodes.find((n) => n.id === 'opt-a')?.parentId).toBeUndefined(); + expect(result.nodes.find((n) => n.id === 'opt-b')?.parentId).toBeUndefined(); + + // Containment is unchanged. + expect(result.nodes.find((n) => n.id === 'leaf-a')?.parentId).toBe('opt-a'); + expect(result.nodes.find((n) => n.id === 'leaf-b')?.parentId).toBe('opt-b'); + }); + + it('still suppresses the box when every candidate is pulled into a container', () => { + // The other branch: the candidates are container children, not containers. + // Suppression stays, even though a prompt exists to lose. + const pattern = makePattern( + [ + schemaNode('k8s', 'Kubernetes', 'system'), + schemaNode('opt-a', 'Option A', 'service'), + schemaNode('opt-b', 'Option B', 'service'), + ], + [ + deployedIn('deploy-both', 'k8s', ['opt-a', 'opt-b']), + optionsRelationship('svc-choice', 'Pick a service', [ + { description: 'Use A', nodes: ['opt-a'] }, + { description: 'Use B', nodes: ['opt-b'] }, + ]), + ] + ); + const result = parsePatternData(pattern) as { nodes: RfNode[] }; + + expect(result.nodes.filter((n) => n.type === 'decisionGroup')).toHaveLength(0); + expect(result.nodes.find((n) => n.id === 'opt-a')?.parentId).toBe('k8s'); + expect(result.nodes.find((n) => n.id === 'opt-b')?.parentId).toBe('k8s'); + }); +}); diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index 9cfe6312e..fea6e9ff7 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -9,7 +9,7 @@ import { import { createEdge } from './edgeFactory'; import { GRAPH_LAYOUT } from './constants'; import { THEME } from '../theme'; -import { getPatternArray, readCatalog } from '@finos/calm-models/pattern'; +import { getPatternArray, readChoiceBlock } from '@finos/calm-models/pattern'; /** * Result of parsing pattern data into ReactFlow elements @@ -43,16 +43,6 @@ function getItems(pattern: SchemaObject, key: 'nodes' | 'relationships'): Schema return getPatternArray(pattern, key).catalog as SchemaObject | undefined; } -/** - * Reads an `items` open-catalog's decision alternatives. Returns the group type - * (`oneOf`/`anyOf`) and the alternatives array, or null when the catalog is - * neither. `oneOf` wins when both are present, and either keyword being a - * non-array leaves the catalog untreated. - */ -function catalogAlternatives(items: SchemaObject): { groupType: 'oneOf' | 'anyOf'; alternatives: SchemaObject[] } | null { - return readCatalog(items) as { groupType: 'oneOf' | 'anyOf'; alternatives: SchemaObject[] } | null; -} - /** * Reads a value from a schema property, handling `const` wrappers. */ @@ -230,9 +220,9 @@ function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[ // that aren't tied to a specific positional slot. Treat the whole catalog as // a single decision-group slot. if (items) { - const catalog = catalogAlternatives(items); + const catalog = readChoiceBlock(items); if (catalog) { - extractNodeDecisionGroup(catalog.alternatives, 'node-decision-items', catalog.groupType, nodes, decisionGroups); + extractNodeDecisionGroup(catalog.alternatives as SchemaObject[], 'node-decision-items', catalog.groupType, nodes, decisionGroups); } } @@ -414,9 +404,9 @@ function extractRelationshipsFromPattern(pattern: SchemaObject): { // Open catalog: `items.oneOf`/`items.anyOf` declares zero-or-more relationship // candidates not tied to a specific positional slot. if (items) { - const catalog = catalogAlternatives(items); + const catalog = readChoiceBlock(items); if (catalog) { - extractRelationshipDecisionGroup(catalog.alternatives, 'rel-decision-items', relationships); + extractRelationshipDecisionGroup(catalog.alternatives as SchemaObject[], 'rel-decision-items', relationships); } } @@ -464,7 +454,13 @@ function createReactFlowNodes( const effectiveParent = new Map(); const usedDecisionGroupIds = new Set(); extractedNodes.forEach((node) => { - if (containerNodeIds.has(node.uniqueId)) return; + if (containerNodeIds.has(node.uniqueId)) { + // A container candidate is not drawn inside the box, but the decision is + // still real, so the box must survive to carry its prompt. Nesting the + // container inside the box is #2933. + if (node.decisionGroupId) usedDecisionGroupIds.add(node.decisionGroupId); + return; + } const parentId = parentMap.get(node.uniqueId) || node.decisionGroupId; if (!parentId) return; effectiveParent.set(node.uniqueId, parentId); @@ -731,37 +727,28 @@ function foldOptionsMetadataIntoDecisionGroups( const groupOptionsMap = new Map(); + // A candidate can be drawn in one box only, so the first decision to name it keeps it. + const claimedByDecision = new Set(); + optionsMetadata.forEach((meta) => { - const referencedIds = Array.from(new Set(meta.choices.flatMap((c) => c.nodes))).filter((id) => - extractedNodeIds.has(id) + const referencedIds = Array.from(new Set(meta.choices.flatMap((c) => c.nodes))).filter( + (id) => extractedNodeIds.has(id) && !claimedByDecision.has(id) ); - // No referenced id resolves to a real node — render nothing for this decision. + // Nothing left to draw: either the ids are dangling, or an earlier decision has + // them all. Boxing one node twice needs the nesting rework (#2933). if (referencedIds.length === 0) return; - const matchedGroupIds: string[] = []; - referencedIds.forEach((id) => { - const groupId = nodeToGroupMap.get(id); - if (groupId && !matchedGroupIds.includes(groupId)) matchedGroupIds.push(groupId); - }); - - let targetGroup: DecisionGroup; - if (matchedGroupIds.length > 0) { - targetGroup = groupsById.get(matchedGroupIds[0])!; - } else { - targetGroup = { - groupId: `node-decision-options-${meta.relationshipId || referencedIds.join('-')}`, - groupType: meta.optionType, - nodeIds: [], - }; - decisionGroups.push(targetGroup); - groupsById.set(targetGroup.groupId, targetGroup); - } + const targetGroup: DecisionGroup = { + groupId: `node-decision-${meta.relationshipId || referencedIds.join('-')}`, + groupType: meta.optionType, + nodeIds: [], + }; + decisionGroups.push(targetGroup); + groupsById.set(targetGroup.groupId, targetGroup); referencedIds.forEach((id) => { const currentGroupId = nodeToGroupMap.get(id); - if (currentGroupId === targetGroup.groupId) return; - if (currentGroupId) { const oldGroup = groupsById.get(currentGroupId); if (oldGroup) { @@ -771,6 +758,7 @@ function foldOptionsMetadataIntoDecisionGroups( targetGroup.nodeIds.push(id); nodeToGroupMap.set(id, targetGroup.groupId); + claimedByDecision.add(id); const node = nodesById.get(id); if (node) node.decisionGroupId = targetGroup.groupId; From a9748de787ffcc67433943ee9eef5779829f128b Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sat, 22 Aug 2026 09:58:09 +0000 Subject: [PATCH 16/41] test: pin decision agreement between generate and the visualiser calm generate and the pattern visualiser read a pattern's decisions independently, and nothing made them answer the same way. That is how the grouping defects in this PR arose. calm-hub-ui does not depend on shared, so no test can import both sides. A fixture pair is the contract instead: one pattern, one expected set of decisions and results, read by a spec in each package. Fully answered selections only. An unanswered decision shows every candidate in the visualiser and contributes none in generation; which is correct is an open product question, so no test pins it. --- .../reactflow/utils/decisionAgreement.test.ts | 90 ++++++++ test_fixtures/decision-agreement/README.md | 18 ++ .../one-decision-one-catalog.expected.json | 17 ++ .../one-decision-one-catalog.pattern.json | 76 +++++++ .../two-decisions-one-catalog.expected.json | 70 +++++++ .../two-decisions-one-catalog.pattern.json | 194 ++++++++++++++++++ 6 files changed, 465 insertions(+) create mode 100644 calm-hub-ui/src/visualizer/components/reactflow/utils/decisionAgreement.test.ts create mode 100644 test_fixtures/decision-agreement/README.md create mode 100644 test_fixtures/decision-agreement/one-decision-one-catalog.expected.json create mode 100644 test_fixtures/decision-agreement/one-decision-one-catalog.pattern.json create mode 100644 test_fixtures/decision-agreement/two-decisions-one-catalog.expected.json create mode 100644 test_fixtures/decision-agreement/two-decisions-one-catalog.pattern.json diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/decisionAgreement.test.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/decisionAgreement.test.ts new file mode 100644 index 000000000..0812185f7 --- /dev/null +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/decisionAgreement.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { parsePatternData } from './patternTransformer'; +import { extractDecisionPoints, getVisibleNodeIds, DecisionSelections } from './decisionUtils'; + +/** + * The visualiser half of the decision-agreement contract. See + * `test_fixtures/decision-agreement/README.md` - `shared` asserts the same two things + * against the same files, so a drift on either side fails a test. + */ + +const FIXTURES = path.resolve(__dirname, '../../../../../../test_fixtures/decision-agreement'); + +interface Expected { + decisions: { + optionId: string; + prompt: string; + optionType: 'oneOf' | 'anyOf'; + choices: { description: string; nodes: string[]; relationships: string[] }[]; + }[]; + answered: { choose: Record; nodes: string[] }[]; +} + +const read = (name: string, suffix: string) => + JSON.parse(fs.readFileSync(path.join(FIXTURES, `${name}.${suffix}.json`), 'utf8')); + +const cases = ['one-decision-one-catalog', 'two-decisions-one-catalog']; + +describe.each(cases)('decision agreement: %s (visualiser side)', (name) => { + const pattern = read(name, 'pattern'); + const expected: Expected = read(name, 'expected'); + + /** + * A decision box is identified by its prompt, not its group id. The id is an + * implementation detail; the prompt is what the fixture and the user both see. + */ + const pointsByPrompt = () => { + const { nodes } = parsePatternData(pattern); + const points = extractDecisionPoints(nodes); + return { nodes, points }; + }; + + it('draws exactly the expected decisions', () => { + const { points } = pointsByPrompt(); + const actual = points + .map((p) => ({ + prompt: p.prompt, + optionType: p.decisionType, + choices: p.choices.map((c) => ({ + description: c.description, + nodes: c.nodes, + relationships: c.relationships, + })), + })) + .sort((a, b) => a.prompt.localeCompare(b.prompt)); + + const wanted = expected.decisions + .map(({ prompt, optionType, choices }) => ({ prompt, optionType, choices })) + .sort((a, b) => a.prompt.localeCompare(b.prompt)); + + expect(actual).toEqual(wanted); + }); + + it.each(expected.answered)('shows the expected nodes for $choose', ({ choose, nodes: wanted }) => { + const { nodes, points } = pointsByPrompt(); + + const selections: DecisionSelections = new Map(); + Object.entries(choose).forEach(([optionId, description]) => { + // The fixture names decisions by the holder's unique-id. The visualiser + // keys them by group id, so match on the prompt the holder declares. + const expectedDecision = expected.decisions.find((d) => d.optionId === optionId)!; + const point = points.find((p) => p.prompt === expectedDecision.prompt); + if (!point) throw new Error(`no decision box for "${expectedDecision.prompt}"`); + const index = point.choices.findIndex((c) => c.description === description); + if (index < 0) throw new Error(`no choice "${description}" on "${point.prompt}"`); + selections.set(point.groupId, [index]); + }); + + const visible = getVisibleNodeIds(nodes, points, selections); + expect(visible, 'no filter was applied').not.toBeNull(); + + // Compare only real nodes. Box nodes are always visible and have no counterpart + // in a generated architecture. + const boxIds = new Set(nodes.filter((n) => n.type === 'group' || n.type === 'decisionGroup').map((n) => n.id)); + const actual = [...visible!].filter((id) => !boxIds.has(id)).sort(); + + expect(actual).toEqual([...wanted].sort()); + }); +}); diff --git a/test_fixtures/decision-agreement/README.md b/test_fixtures/decision-agreement/README.md new file mode 100644 index 000000000..7628e6806 --- /dev/null +++ b/test_fixtures/decision-agreement/README.md @@ -0,0 +1,18 @@ +# Decision agreement fixtures + +`calm generate` and the pattern visualiser read a pattern's decisions independently. +Nothing made them answer the same way, which is how the defects in PR #2932 arose. + +Each case here is one pattern plus the decisions and results both surfaces must produce. +`calm-hub-ui` does not depend on `shared`, so no test can import both sides. The fixture +is the contract instead. + +| Side | Reads | Asserts | +|---|---|---| +| `shared` | `extractOptions`, `selectChoices` + `instantiate` | `decisions`, then `answered[].nodes` | +| `calm-hub-ui` | `parsePatternData` + `extractDecisionPoints`, `getVisibleNodeIds` | the same two | + +`answered` cases name a choice for **every** decision. An unanswered decision is left out +on purpose: the visualiser shows all its candidates ("what is still possible") and +generation contributes none ("what you asked for"). Which one is correct is an open +product question, so no test pins it. diff --git a/test_fixtures/decision-agreement/one-decision-one-catalog.expected.json b/test_fixtures/decision-agreement/one-decision-one-catalog.expected.json new file mode 100644 index 000000000..a21842751 --- /dev/null +++ b/test_fixtures/decision-agreement/one-decision-one-catalog.expected.json @@ -0,0 +1,17 @@ +{ + "decisions": [ + { + "optionId": "cache-choice", + "prompt": "Pick a cache", + "optionType": "anyOf", + "choices": [ + { "description": "Use Redis", "nodes": ["redis"], "relationships": [] }, + { "description": "Use Memcached", "nodes": ["memcached"], "relationships": [] } + ] + } + ], + "answered": [ + { "choose": { "cache-choice": "Use Redis" }, "nodes": ["webapp", "redis"] }, + { "choose": { "cache-choice": "Use Memcached" }, "nodes": ["webapp", "memcached"] } + ] +} diff --git a/test_fixtures/decision-agreement/one-decision-one-catalog.pattern.json b/test_fixtures/decision-agreement/one-decision-one-catalog.pattern.json new file mode 100644 index 000000000..8cc722ffe --- /dev/null +++ b/test_fixtures/decision-agreement/one-decision-one-catalog.pattern.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://calm.finos.org/release/1.0/meta/calm.json", + "$id": "one-decision-one-catalog", + "title": "One decision drawing from one catalog", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "properties": { + "unique-id": { "const": "webapp" }, + "name": { "const": "Web App" }, + "node-type": { "const": "service" } + } + } + ], + "items": { + "anyOf": [ + { + "properties": { + "unique-id": { "const": "redis" }, + "name": { "const": "Redis" }, + "node-type": { "const": "database" } + } + }, + { + "properties": { + "unique-id": { "const": "memcached" }, + "name": { "const": "Memcached" }, + "node-type": { "const": "database" } + } + } + ] + } + }, + "relationships": { + "type": "array", + "prefixItems": [ + { + "properties": { + "unique-id": { "const": "cache-choice" }, + "description": { "const": "Pick a cache" }, + "relationship-type": { + "type": "object", + "properties": { + "options": { + "type": "array", + "prefixItems": [ + { + "anyOf": [ + { + "properties": { + "description": { "const": "Use Redis" }, + "nodes": { "const": ["redis"] }, + "relationships": { "const": [] } + } + }, + { + "properties": { + "description": { "const": "Use Memcached" }, + "nodes": { "const": ["memcached"] }, + "relationships": { "const": [] } + } + } + ] + } + ] + } + } + } + } + } + ] + } + } +} diff --git a/test_fixtures/decision-agreement/two-decisions-one-catalog.expected.json b/test_fixtures/decision-agreement/two-decisions-one-catalog.expected.json new file mode 100644 index 000000000..53c7626cc --- /dev/null +++ b/test_fixtures/decision-agreement/two-decisions-one-catalog.expected.json @@ -0,0 +1,70 @@ +{ + "decisions": [ + { + "optionId": "cache-choice", + "prompt": "Pick a cache", + "optionType": "anyOf", + "choices": [ + { + "description": "Use Redis", + "nodes": [ + "redis" + ], + "relationships": [] + }, + { + "description": "Use Memcached", + "nodes": [ + "memcached" + ], + "relationships": [] + } + ] + }, + { + "optionId": "queue-choice", + "prompt": "Pick a queue", + "optionType": "anyOf", + "choices": [ + { + "description": "Use Kafka", + "nodes": [ + "kafka" + ], + "relationships": [] + }, + { + "description": "Use RabbitMQ", + "nodes": [ + "rabbitmq" + ], + "relationships": [] + } + ] + } + ], + "answered": [ + { + "choose": { + "cache-choice": "Use Redis", + "queue-choice": "Use Kafka" + }, + "nodes": [ + "webapp", + "redis", + "kafka" + ] + }, + { + "choose": { + "cache-choice": "Use Memcached", + "queue-choice": "Use RabbitMQ" + }, + "nodes": [ + "webapp", + "memcached", + "rabbitmq" + ] + } + ] +} diff --git a/test_fixtures/decision-agreement/two-decisions-one-catalog.pattern.json b/test_fixtures/decision-agreement/two-decisions-one-catalog.pattern.json new file mode 100644 index 000000000..4ab122d4b --- /dev/null +++ b/test_fixtures/decision-agreement/two-decisions-one-catalog.pattern.json @@ -0,0 +1,194 @@ +{ + "$schema": "https://calm.finos.org/release/1.0/meta/calm.json", + "$id": "two-decisions-one-catalog", + "title": "Two decisions drawing from one catalog", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "properties": { + "unique-id": { + "const": "webapp" + }, + "name": { + "const": "Web App" + }, + "node-type": { + "const": "service" + } + } + } + ], + "items": { + "anyOf": [ + { + "properties": { + "unique-id": { + "const": "redis" + }, + "name": { + "const": "Redis" + }, + "node-type": { + "const": "database" + } + } + }, + { + "properties": { + "unique-id": { + "const": "memcached" + }, + "name": { + "const": "Memcached" + }, + "node-type": { + "const": "database" + } + } + }, + { + "properties": { + "unique-id": { + "const": "kafka" + }, + "name": { + "const": "Kafka" + }, + "node-type": { + "const": "queue" + } + } + }, + { + "properties": { + "unique-id": { + "const": "rabbitmq" + }, + "name": { + "const": "RabbitMQ" + }, + "node-type": { + "const": "queue" + } + } + } + ] + } + }, + "relationships": { + "type": "array", + "prefixItems": [ + { + "properties": { + "unique-id": { + "const": "cache-choice" + }, + "description": { + "const": "Pick a cache" + }, + "relationship-type": { + "type": "object", + "properties": { + "options": { + "type": "array", + "prefixItems": [ + { + "anyOf": [ + { + "properties": { + "description": { + "const": "Use Redis" + }, + "nodes": { + "const": [ + "redis" + ] + }, + "relationships": { + "const": [] + } + } + }, + { + "properties": { + "description": { + "const": "Use Memcached" + }, + "nodes": { + "const": [ + "memcached" + ] + }, + "relationships": { + "const": [] + } + } + } + ] + } + ] + } + } + } + } + }, + { + "properties": { + "unique-id": { + "const": "queue-choice" + }, + "description": { + "const": "Pick a queue" + }, + "relationship-type": { + "type": "object", + "properties": { + "options": { + "type": "array", + "prefixItems": [ + { + "anyOf": [ + { + "properties": { + "description": { + "const": "Use Kafka" + }, + "nodes": { + "const": [ + "kafka" + ] + }, + "relationships": { + "const": [] + } + } + }, + { + "properties": { + "description": { + "const": "Use RabbitMQ" + }, + "nodes": { + "const": [ + "rabbitmq" + ] + }, + "relationships": { + "const": [] + } + } + } + ] + } + ] + } + } + } + } + } + ] + } + } +} From 54f7412b5b71e0e077004055a45866dcc1438288 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Sat, 22 Aug 2026 09:58:10 +0000 Subject: [PATCH 17/41] docs: record how pattern decisions are read across the three surfaces Names the decision-holder invariant, the three candidate questions and their owners, and the three unreconciled readings of allOf. Adds the enforcement table, including the fact that calm generate never validates, so that path carries its own guard. --- calm-ai/tools/pattern-creation.md | 19 +++++++- shared/AGENTS.md | 73 ++++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/calm-ai/tools/pattern-creation.md b/calm-ai/tools/pattern-creation.md index f09795b30..718b734fe 100644 --- a/calm-ai/tools/pattern-creation.md +++ b/calm-ai/tools/pattern-creation.md @@ -166,7 +166,21 @@ A catalog on its own does nothing. Two different kinds of object are involved, a A candidate is included in the output only when a chosen bundle names its `unique-id`. So every catalog needs a decision holder pointing at it, and **a decision holder must be declared in `properties.relationships.prefixItems`** — never inside an `items` catalog itself. A holder is the mechanism that drives generation, so it must always be present; putting it in a catalog makes the question itself optional, and `calm generate` will not offer it. -Use `anyOf` inside the holder's `options` for a zero-or-more catalog (the user may pick any combination, including none) and `oneOf` where exactly one candidate must be chosen. The holder that drives the catalog above looks like this: +### `oneOf` and `anyOf` mean different things in different places + +The same two keywords appear in three positions and do three different jobs. Getting this wrong is the commonest authoring mistake with catalogs. + +**Inside the holder's `options` — this is the cardinality.** Use `anyOf` for a zero-or-more catalog (the user may pick any combination, including none) and `oneOf` where exactly one candidate must be chosen. This is the only place that controls how many candidates the user may select. + +**Inside a `prefixItems` slot — this picks which node fills one fixed position.** The slot always exists; the alternatives compete for it. + +**Inside `items` — neither.** The keyword there constrains what each individual array *entry* may look like, not how many entries there are. `items: { "oneOf": [cache, queue] }` reads as "each entry must be exactly one of cache or queue" — an architecture containing *both* is perfectly valid, because each entry independently matches exactly one candidate. "Zero or more" comes from `items` itself (plus `minItems`/`maxItems`), and how many are actually selected comes from the holder. + +Because every candidate pins its `unique-id` with a `const`, an entry can match at most one candidate schema, so `oneOf` and `anyOf` accept exactly the same architectures here. **Use `oneOf`** — it is the accurate assertion and matches the example above. + +**Never declare both `oneOf` and `anyOf` on one `items` block.** Only the `oneOf` list is read. Candidates under `anyOf` are silently dropped: `calm generate` will still *offer* them if a decision names them, then discard your answer without an error, and they will not appear in the diagram either. + +The holder that drives the catalog above looks like this: ```json { @@ -747,7 +761,8 @@ Always use specific interface schema references: ### Array Handling - Use `prefixItems` to define specific array positions (fixed slots) -- Use `items` with a `oneOf`/`anyOf` to define an open catalog of optional entries (zero or more, any combination); combine with `prefixItems` for mandatory-plus-optional arrays +- Use `items` to define an open catalog of optional entries (zero or more, any combination); combine with `prefixItems` for mandatory-plus-optional arrays +- Inside `items`, use `oneOf` (not both `oneOf` and `anyOf` — see above). The keyword constrains each entry's shape; it does **not** limit how many entries the array may hold - Use `minItems`/`maxItems` to constrain array sizes - Each array item should reference base schema + add constraints diff --git a/shared/AGENTS.md b/shared/AGENTS.md index cbec16a0e..cc3df10c6 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -55,19 +55,78 @@ npx vitest run ${TEST FILE} ## Pattern Decisions -A pattern expresses choice through two distinct kinds of object. Confusing them causes silent failures. +A **decision holder** is a relationship that carries `relationship-type.properties.options`. +It poses a question and lists choice bundles. A **candidate** is a node or relationship that +a bundle can select. Candidates are declared in four places: a plain `prefixItems` entry, a +`prefixItems[i].oneOf` or `.anyOf` alternative, or an `items.oneOf`/`items.anyOf` catalog. -A **decision holder** is a relationship carrying `relationship-type.properties.options`. It is not part of the architecture — it poses a question and lists choice bundles, each naming candidates by `unique-id`. A **candidate** is a concrete node or relationship that may or may not reach the output; candidates live either in a `prefixItems` slot or in an `items.oneOf`/`items.anyOf` open catalog. +**A decision holder must be in `properties.relationships.prefixItems`.** `extractOptions` +(`options.ts:65`) finds decisions only through `getRelationshipsPrefixItems` (`options.ts:55`), +which never reads the `items` catalog. A holder in a catalog is never offered, on any path. -**Invariant: a decision holder must be declared in `properties.relationships.prefixItems`.** `extractOptions` (`options.ts:77`) discovers decisions solely via `getRelationshipsPrefixItems` (`options.ts:53`), which reads that path and nothing else. A holder placed inside an `items` catalog is never offered by `calm generate`, with no error on any path — the CLI takes its choices only from `promptUserForOptions` or `loadChoicesFromInput` (`cli/src/command-helpers/generate-options.ts:29,58`), and both go through `extractOptions`. +A candidate reaches the output only when a chosen bundle names its `unique-id`. Drive new +tests from `extractOptions`, not from hand-built choices, or you do not test whether the +decision is discoverable. See `catalog-decisions.spec.ts`. -`allOf` composition is only partly supported here, and the support is narrower than it looks. `getRelationshipsPrefixItems` falls back to scanning `allOf` branches, but it `return`s on the **first** branch declaring `relationships.prefixItems` — so a holder in a later branch is invisible when an earlier branch also declares that path (measured: `extractOptions` returns `[]`). Note also that `extractOptions` runs on the **raw** pattern in the CLI, before `runGenerate` calls `flattenAllOf`, so no change to `allOf` flattening can repair this. Treat `allOf` for relationships as unsupported until that lookup unions across branches. +### Three questions, three owners -A candidate is included only when a chosen bundle names its `unique-id` (`options.ts:136,148`). A catalog with no holder pointing at it is therefore unreachable via `calm generate`; it remains reachable programmatically, since `selectChoices` accepts hand-built `CalmChoice` objects, which is how most existing tests drive it. When adding tests for decision behaviour, drive them through `extractOptions` rather than hand-building choices, or you will not be testing whether the decision is discoverable at all. +Use the wrong one and the failure is silent, so they are separate named functions. -**Enforcement.** `pattern-option-relationship-must-be-in-prefix-items` (`spectral/rules-pattern.ts`, `error`) rejects a holder declared under `relationships.items.oneOf`/`anyOf`, including inside an `allOf` branch. It exists because the constraint is not expressible in JSON Schema — a catalog containing an options relationship is well-formed, so only the linter can state that the *decision* must be mandatory even when its answer may be empty. The other four decision rules keep their recursive-descent selectors (`$..relationship-type.properties.options...`) and still structurally validate a misplaced holder; that overlap is deliberate, since narrowing them would leave a misplaced holder with fewer diagnostics rather than more. Note that `pattern-nodes-must-be-referenced` (`warn`) does not help here: its function queries `$..relationship-type..*@string()` (`functions/pattern/node-has-relationship.ts:10`), so a holder in the wrong array still counts as referencing its candidates. +| Question | Function | Home | +|---|---|---| +| What does *this block* offer? (`oneOf` wins) | `readChoiceBlock` | `@finos/calm-models/pattern` | +| What does the pattern *declare*? (both keywords) | `listCandidates` | two copies - see below | +| What can selection *reach*? (one keyword) | `listSelectableCandidates` | `shared/src/pattern-candidates.ts` | -**Three implementations, no shared helper.** Catalog lookup and the oneOf-over-anyOf precedence rule are hand-written in three places: generation (`options.ts:142-148`), linting (`spectral/rules-pattern.ts:139-140` and its functions), and the Hub UI visualizer (`calm-hub-ui/.../patternTransformer.ts:73`, used at `:262` and `:446`). A change to how candidates are located or ordered needs all three, and nothing enforces that. +Use *declared* for what a document says: uniqueness, dangling references. Use *selectable* +for "can this answer be honoured". They differ only where a block declares both keywords. + +`getPatternArray` locates the `prefixItems` array and `items` catalog for a property. + +### `allOf` has three unreconciled meanings + +Treat `allOf` for nodes and relationships as unsupported. + +| Reader | Behaviour | +|---|---| +| `deepMergeSchemas` (`flatten-allof.ts`) | shallow merge; a repeated property loses `type`, so `instantiate` emits `{}` | +| `getPatternArray` | first branch wins, later branches ignored; marked TEMPORARY | +| `calm-models` `listCandidates` | ignores `allOf`, to keep `path` correct for diagnostics | +| `shared` `listCandidates` | follows `allOf` through `getPatternArray` | + +The last two disagree. A pattern whose `prefixItems` sits under `allOf` yields nothing from +the first and one candidate from the second, with a `path` the document does not contain. +Nothing reads that `path` today. `allOf` means **intersection**, never union, because +`calm validate` never flattens. + +### Enforcement + +`calm generate` **never validates**. These rules run only on `calm validate`. + +| Rule | Severity | Catches | +|---|---|---| +| `pattern-option-relationship-must-be-in-prefix-items` | `error` | a holder inside an `items` catalog | +| `pattern-decision-must-reference-selectable-nodes` / `-relationships` | `error` | a bundle naming a declared but unreachable candidate | +| `group-relationship-with-const-nodes-references-existing-nodes-in-pattern` | `error` | a bundle naming an id that does not exist | +| `pattern-items-catalog-must-declare-one-choice-keyword` | `warn` | a block declaring both keywords | + +The generate path has its own guard. `assertChoicesAreSelectable` throws from `runGenerate`. +It is not called from `selectChoices`, because validation calls that too. + +`pattern-nodes-must-be-referenced` does not help with holder placement. Its query +(`node-has-relationship.ts:10`) matches a holder in the wrong array. + +### Still duplicated + +Neither pair has caused a bug. Nothing keeps either in step. + +| Duplicate | Sites | Note | +|---|---|---| +| `listCandidates` | `pattern-reader.ts:154`, `pattern-candidates.ts:104` | diverge on `allOf`; one implementation needs the `allOf` rework first | +| decision-holder reading | `options.ts:26,30`, `patternTransformer.ts:290,317` | already differ: one unions both keywords, the other picks `oneOf` | + +`calm-hub-ui` depends on `@finos/calm-models` and not on `shared`, so a shared reader must +live in `calm-models`. ## Common Workflows From c975cb2fb955d96c5a7f49f9891280e9488b8511 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 24 Aug 2026 15:31:13 +0000 Subject: [PATCH 18/41] fix(shared): consolidate listCandidates/listSelectableCandidates into calm-models Two copies of the candidate-listing walk existed: calm-models ignored allOf, shared followed it through getPatternArray and reported a path the document didn't contain. Nothing tested or relied on the divergent behaviour, and allOf for nodes/relationships is already unsupported, so the copies are unified in calm-models rather than reconciled - one walk, parameterised by resolution ('all' for listCandidates, 'operative' for the new listSelectableCandidates), matching the pattern readChoiceBlock already set. shared/src/pattern-candidates.ts is deleted; its two consumers (decision-references-selectable-candidate.ts and options.ts's assertChoicesAreSelectable) now import from @finos/calm-models/pattern. assertChoicesAreSelectable had no test coverage before this; added 7 cases. Added 8 new calm-models tests for listSelectableCandidates. catalog-decisions.spec.ts's allOf regression test called the guard directly on a raw, unflattened pattern - a call shape with no real caller, since runGenerate always flattens first. Fixed the test to flatten first, matching actual usage; the real generate pipeline was never broken. --- calm-models/src/pattern/index.ts | 1 + .../src/pattern/pattern-reader.spec.ts | 80 ++++++++++++++ calm-models/src/pattern/pattern-reader.ts | 62 +++++++++-- shared/AGENTS.md | 25 +++-- .../components/catalog-decisions.spec.ts | 26 ++--- .../generate/components/options.spec.ts | 57 +++++++++- .../commands/generate/components/options.ts | 3 +- shared/src/pattern-candidates.ts | 102 ------------------ ...ecision-references-selectable-candidate.ts | 3 +- 9 files changed, 218 insertions(+), 141 deletions(-) delete mode 100644 shared/src/pattern-candidates.ts diff --git a/calm-models/src/pattern/index.ts b/calm-models/src/pattern/index.ts index 1e44ca0cf..e56e58870 100644 --- a/calm-models/src/pattern/index.ts +++ b/calm-models/src/pattern/index.ts @@ -2,6 +2,7 @@ export { getPatternArray, readChoiceBlock, listCandidates, + listSelectableCandidates, type SchemaNode, type PatternArray, type ChoiceBlock, diff --git a/calm-models/src/pattern/pattern-reader.spec.ts b/calm-models/src/pattern/pattern-reader.spec.ts index a3a9583c4..a5f998903 100644 --- a/calm-models/src/pattern/pattern-reader.spec.ts +++ b/calm-models/src/pattern/pattern-reader.spec.ts @@ -3,6 +3,7 @@ import { getPatternArray, readChoiceBlock, listCandidates, + listSelectableCandidates, type SchemaNode, } from './pattern-reader.js'; @@ -177,3 +178,82 @@ describe('listCandidates', () => { }); }); +describe('listSelectableCandidates', () => { + it('lists a plain prefixItems entry, same as listCandidates', () => { + const pattern = { properties: { nodes: { prefixItems: [nodeWithId('solo')] } } }; + expect(listSelectableCandidates(pattern, 'nodes')).toEqual([ + { uniqueId: 'solo', site: 'prefixItem', node: nodeWithId('solo'), path: ['properties', 'nodes', 'prefixItems', 0] }, + ]); + }); + + it('resolves only the winning keyword of a dual-keyword block, unlike listCandidates', () => { + const pattern = { + properties: { + nodes: { + prefixItems: [{ oneOf: [nodeWithId('a')], anyOf: [nodeWithId('b')] }], + }, + }, + }; + + const declared = listCandidates(pattern, 'nodes').map((c) => c.uniqueId); + const selectable = listSelectableCandidates(pattern, 'nodes').map((c) => c.uniqueId); + expect(declared).toEqual(['a', 'b']); + expect(selectable).toEqual(['a']); + }); + + it('yields both the slot and its winning alternatives for a hybrid slot', () => { + const pattern = { + properties: { + nodes: { + prefixItems: [{ ...nodeWithId('hybrid'), oneOf: [nodeWithId('alt-a')], anyOf: [nodeWithId('alt-b')] }], + }, + }, + }; + + const candidates = listSelectableCandidates(pattern, 'nodes'); + expect(candidates.map((c) => ({ uniqueId: c.uniqueId, site: c.site }))).toEqual([ + { uniqueId: 'hybrid', site: 'prefixItem' }, + { uniqueId: 'alt-a', site: 'prefixItemAlternative' }, + ]); + }); + + it('resolves only the winning keyword of a dual-keyword items catalog', () => { + const pattern = { + properties: { + nodes: { + items: { oneOf: [nodeWithId('cat-one')], anyOf: [nodeWithId('cat-any')] }, + }, + }, + }; + + expect(listSelectableCandidates(pattern, 'nodes').map((c) => c.uniqueId)).toEqual(['cat-one']); + }); + + it('lists every alternative when only one keyword is declared, same as listCandidates', () => { + const pattern = { + properties: { + nodes: { prefixItems: [{ anyOf: [nodeWithId('a'), nodeWithId('b')] }] }, + }, + }; + expect(listSelectableCandidates(pattern, 'nodes').map((c) => c.uniqueId)).toEqual(['a', 'b']); + }); + + it('skips a catalog member with no const-pinned unique-id', () => { + const pattern = { + properties: { nodes: { items: { oneOf: [{ properties: {} }] } } }, + }; + expect(listSelectableCandidates(pattern, 'nodes')).toEqual([]); + }); + + it('returns an empty array when the calmType is absent', () => { + expect(listSelectableCandidates({ properties: {} }, 'nodes')).toEqual([]); + }); + + it('does not fall back into an allOf branch, unlike getPatternArray', () => { + const pattern = { + allOf: [{ properties: { nodes: { prefixItems: [nodeWithId('in-a-branch')] } } }], + }; + expect(listSelectableCandidates(pattern, 'nodes')).toEqual([]); + }); +}); + diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index 100d8c0f0..6abb66228 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -7,9 +7,11 @@ * catalog member. This module is the single place that knows how to find them, so * generation, validation and the visualiser stop hand-rolling the same traversal. * - * Two different questions get two different functions, deliberately kept apart: + * Three different questions get three different functions, deliberately kept apart: * `readChoiceBlock` picks the single form a decision offers (`oneOf` wins over `anyOf`); - * `listCandidates` unions both, because validation needs every id a pattern declares. + * `listCandidates` unions both, because validation needs every id a pattern declares; + * `listSelectableCandidates` defers to `readChoiceBlock`'s single form, because + * generation needs only what an answer can actually reach. * * No selection, no mutation, no rendering — only reading. The surface is deliberately * limited to what has a caller today; add functions when a consumer needs them, so their @@ -117,13 +119,27 @@ export type Candidate = { blockType?: 'oneOf' | 'anyOf'; }; +/** How a `oneOf`/`anyOf` choice block contributes candidates. */ +type BlockResolution = + /** Every alternative of every keyword present - what the pattern *declares*. */ + | 'all' + /** Only the operative keyword's alternatives - what selection can *reach*. */ + | 'operative'; + +/** + * The keywords a choice block contributes candidates from, for a given resolution. + * `'all'` unions both; `'operative'` defers to `readChoiceBlock`'s oneOf-wins rule so + * only the resolvable keyword is walked. + */ +function blockKeywords(container: SchemaNode, resolution: BlockResolution): ReadonlyArray<'oneOf' | 'anyOf'> { + if (resolution === 'all') return ['oneOf', 'anyOf']; + const block = readChoiceBlock(container); + return block ? [block.groupType] : []; +} + /** * Every node/relationship candidate declared under `properties.`, across all - * four declaration sites. - * - * Unions `oneOf` and `anyOf`, which is the opposite of `readChoiceBlock`. Validation - * needs every declared id. Do not route this through `readChoiceBlock` - that drops - * every `anyOf` candidate when `oneOf` is also present. + * four declaration sites, resolved per `resolution`. * * Skips a candidate with no `const`-pinned `unique-id`. A pure choice-block slot has no * id of its own, and counting it would create a false diagnostic. @@ -131,7 +147,11 @@ export type Candidate = { * Reads the direct path only. It does not fall back into `allOf`, because * `getPatternArray` discards which branch it read, so `path` could not be trusted. */ -export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { +function walkCandidates( + pattern: SchemaNode, + calmType: 'nodes' | 'relationships', + resolution: BlockResolution +): Candidate[] { const candidates: Candidate[] = []; const properties = pattern['properties']; const field = isObject(properties) ? properties[calmType] : undefined; @@ -142,6 +162,8 @@ export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relatio prefixItems.forEach((item, i) => { if (!isObject(item)) return; + // A hybrid slot carries its own id and alternatives. Both are emitted regardless + // of resolution - `readChoiceBlock` only decides which *alternatives* keyword wins. const uniqueId = readUniqueId(item); if (uniqueId) { candidates.push({ @@ -152,7 +174,7 @@ export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relatio }); } - (['oneOf', 'anyOf'] as const).forEach((blockType) => { + blockKeywords(item, resolution).forEach((blockType) => { const alternatives = item[blockType]; if (!Array.isArray(alternatives)) return; alternatives.forEach((alt, j) => { @@ -173,7 +195,7 @@ export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relatio const itemsCatalog = field['items']; if (isObject(itemsCatalog)) { - (['oneOf', 'anyOf'] as const).forEach((blockType) => { + blockKeywords(itemsCatalog, resolution).forEach((blockType) => { const alternatives = itemsCatalog[blockType]; if (!Array.isArray(alternatives)) return; alternatives.forEach((alt, j) => { @@ -193,3 +215,23 @@ export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relatio return candidates; } + +/** + * Every node/relationship candidate a pattern declares. Unions `oneOf` and `anyOf`, + * which is the opposite of `readChoiceBlock`. Validation needs every declared id. Do + * not route this through `readChoiceBlock` - that drops every `anyOf` candidate when + * `oneOf` is also present. + */ +export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { + return walkCandidates(pattern, calmType, 'all'); +} + +/** + * Only the candidates selection can reach, resolved as `selectChoices` resolves them: + * a dual-keyword block's `anyOf` alternatives are declared but not selectable. + * `listCandidates` is a silent bug here, because it reports the losing keyword's + * alternatives as available. Use it for "can this answer be honoured". + */ +export function listSelectableCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { + return walkCandidates(pattern, calmType, 'operative'); +} diff --git a/shared/AGENTS.md b/shared/AGENTS.md index cc3df10c6..c3043a47f 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -75,8 +75,8 @@ Use the wrong one and the failure is silent, so they are separate named function | Question | Function | Home | |---|---|---| | What does *this block* offer? (`oneOf` wins) | `readChoiceBlock` | `@finos/calm-models/pattern` | -| What does the pattern *declare*? (both keywords) | `listCandidates` | two copies - see below | -| What can selection *reach*? (one keyword) | `listSelectableCandidates` | `shared/src/pattern-candidates.ts` | +| What does the pattern *declare*? (both keywords) | `listCandidates` | `@finos/calm-models/pattern` | +| What can selection *reach*? (one keyword) | `listSelectableCandidates` | `@finos/calm-models/pattern` | Use *declared* for what a document says: uniqueness, dangling references. Use *selectable* for "can this answer be honoured". They differ only where a block declares both keywords. @@ -91,13 +91,16 @@ Treat `allOf` for nodes and relationships as unsupported. |---|---| | `deepMergeSchemas` (`flatten-allof.ts`) | shallow merge; a repeated property loses `type`, so `instantiate` emits `{}` | | `getPatternArray` | first branch wins, later branches ignored; marked TEMPORARY | -| `calm-models` `listCandidates` | ignores `allOf`, to keep `path` correct for diagnostics | -| `shared` `listCandidates` | follows `allOf` through `getPatternArray` | +| `listCandidates` / `listSelectableCandidates` | ignore `allOf` entirely, to keep `path` correct for diagnostics | -The last two disagree. A pattern whose `prefixItems` sits under `allOf` yields nothing from -the first and one candidate from the second, with a `path` the document does not contain. -Nothing reads that `path` today. `allOf` means **intersection**, never union, because -`calm validate` never flattens. +A pattern whose `prefixItems`/`items` sits under `allOf` yields no candidates at all from the +readers, and `deepMergeSchemas` may still merge it (lossily) for `calm generate`. `allOf` means +**intersection**, never union, because `calm validate` never flattens. `shared` previously kept +its own copy of `listCandidates` that followed `allOf` through `getPatternArray`, disagreeing +with the `calm-models` copy and reporting a `path` the document did not contain; nothing tested +or relied on that behaviour, so the copy was removed rather than reconciled. Correct `allOf` +support for nodes and relationships is still unbuilt — this only removed a second, wrong answer, +it did not add support. ### Enforcement @@ -118,12 +121,12 @@ It is not called from `selectChoices`, because validation calls that too. ### Still duplicated -Neither pair has caused a bug. Nothing keeps either in step. +Nothing keeps this pair in step. `listCandidates`'s own duplicate (`shared/src/pattern-candidates.ts`) +is gone — both `listCandidates` and `listSelectableCandidates` now live only in `@finos/calm-models/pattern`. | Duplicate | Sites | Note | |---|---|---| -| `listCandidates` | `pattern-reader.ts:154`, `pattern-candidates.ts:104` | diverge on `allOf`; one implementation needs the `allOf` rework first | -| decision-holder reading | `options.ts:26,30`, `patternTransformer.ts:290,317` | already differ: one unions both keywords, the other picks `oneOf` | +| decision-holder reading | `options.ts:25,29`, `patternTransformer.ts:290,317` | already differ: one unions both keywords, the other picks `oneOf` | `calm-hub-ui` depends on `@finos/calm-models` and not on `shared`, so a shared reader must live in `calm-models`. diff --git a/shared/src/commands/generate/components/catalog-decisions.spec.ts b/shared/src/commands/generate/components/catalog-decisions.spec.ts index 8a4c33d0c..a0aecefa5 100644 --- a/shared/src/commands/generate/components/catalog-decisions.spec.ts +++ b/shared/src/commands/generate/components/catalog-decisions.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { instantiate } from './instantiate'; import { assertChoicesAreSelectable, extractOptions, selectChoices, CalmChoice, CalmOption } from './options'; +import { flattenAllOf } from './flatten-allof'; import { SchemaDirectory } from '../../../schema-directory'; /** @@ -304,17 +305,14 @@ describe('an answer that cannot be honoured is refused, not discarded', () => { }); /** - * Pins the agreement between `extractOptions` and `assertChoicesAreSelectable` about where - * a pattern declares its nodes and relationships. - * - * Both must resolve `allOf` the same way. `extractOptions` reads through `getPatternArray`, - * which falls back into `allOf` branches. `calm-models`' `listCandidates` deliberately does - * not, because its `path` positions Spectral diagnostics. - * - * That difference blocks collapsing the two candidate walks into one `allOf`-blind reader. - * A blind guard finds no candidate here, so generation rejects the answer to its own - * question. If you are consolidating the walks and this fails, the consolidation broke - * generation. + * Pins the real division of labour for a decision declared inside an `allOf` branch: + * `extractOptions` discovers it on the raw pattern (`getPatternArray` falls back into + * `allOf`, matching what `main` already did by hand before this reader existed), and + * `runGenerate` flattens the same raw pattern with `flattenAllOf` - pre-existing, unrelated + * to this PR - before the guard or selection ever run. Neither the guard nor selection has + * ever tolerated a raw `allOf` pattern; `main`'s own `flattenCalmItems` threw on one with no + * fallback at all. So the guard is exercised here the way `runGenerate` actually calls it - + * post-flatten - not directly against the raw pattern `extractOptions` reads. */ describe('decisions declared under allOf', () => { const allOfDecisionPattern = { @@ -347,9 +345,11 @@ describe('decisions declared under allOf', () => { expect(options.map((o) => o.optionId)).toEqual(['cache-choice']); }); - it('accept an answer the guard can resolve to a real candidate', () => { + it('accept an answer the guard can resolve to a real candidate, once flattened as runGenerate flattens it', async () => { const options = extractOptions(allOfDecisionPattern); const chosen = pick(options, 'cache-choice', 'Use Redis'); - expect(() => assertChoicesAreSelectable(allOfDecisionPattern, [chosen])).not.toThrow(); + + const flattened = await flattenAllOf(allOfDecisionPattern, new SchemaDirectory({} as never), false); + expect(() => assertChoicesAreSelectable(flattened as never, [chosen])).not.toThrow(); }); }); diff --git a/shared/src/commands/generate/components/options.spec.ts b/shared/src/commands/generate/components/options.spec.ts index 65444e344..ead44b7fb 100644 --- a/shared/src/commands/generate/components/options.spec.ts +++ b/shared/src/commands/generate/components/options.spec.ts @@ -1,6 +1,6 @@ -import { CalmChoice, CalmOption, extractOptions, selectChoices } from './options'; +import { assertChoicesAreSelectable, CalmChoice, CalmOption, extractOptions, selectChoices } from './options'; const applicationAtoC: CalmChoice = { description: 'Application A connects to Application C', @@ -537,4 +537,59 @@ describe('Pattern Options', () => { expect(extractOptions(allOfPattern)).toEqual(expectedOptions); }); }); + + describe('assertChoicesAreSelectable', () => { + function choice(description: string, nodes: string[] = [], relationships: string[] = []): CalmChoice { + return { description, nodes, relationships }; + } + + it('does not throw when every choice names a plain prefixItems candidate', () => { + const pattern = buildPattern([buildNode('webapp')], []); + expect(() => assertChoicesAreSelectable(pattern, [choice('pick webapp', ['webapp'])])).not.toThrow(); + }); + + it('does not throw when every choice names a reachable items-catalog candidate', () => { + const pattern = buildPatternWithItemsCatalog([], [buildNode('redis')]); + expect(() => assertChoicesAreSelectable(pattern, [choice('pick redis', ['redis'])])).not.toThrow(); + }); + + it('does not throw when choices is empty', () => { + const pattern = buildPattern([buildNode('webapp')], []); + expect(() => assertChoicesAreSelectable(pattern, [])).not.toThrow(); + }); + + it('does not throw for a choice naming a reachable prefixItems slot alternative', () => { + const pattern = buildPattern( + [{ oneOf: [buildNode('sql-store'), buildNode('nosql-store')] }], + [] + ); + expect(() => assertChoicesAreSelectable(pattern, [choice('pick sql', ['sql-store'])])).not.toThrow(); + }); + + it('throws when a choice names a candidate in the losing keyword of a dual-keyword catalog', () => { + const pattern = { + properties: { + nodes: { + prefixItems: [buildNode('webapp')], + items: { oneOf: [buildNode('redis')], anyOf: [buildNode('kafka')] } + }, + relationships: { prefixItems: [] } + } + }; + expect(() => assertChoicesAreSelectable(pattern, [choice('pick kafka', ['kafka'])])) + .toThrow(/node "kafka" \(choice "pick kafka"\)/); + }); + + it('throws when a choice names a node id the pattern does not declare at all', () => { + const pattern = buildPattern([buildNode('webapp')], []); + expect(() => assertChoicesAreSelectable(pattern, [choice('typo', ['webbapp'])])) + .toThrow(/node "webbapp" \(choice "typo"\)/); + }); + + it('throws when a choice names an unreachable relationship candidate', () => { + const pattern = buildPattern([], [buildConnectsRelationship('r1', 'prompt', 'a', 'b')]); + expect(() => assertChoicesAreSelectable(pattern, [choice('typo', [], ['ghost'])])) + .toThrow(/relationship "ghost" \(choice "typo"\)/); + }); + }); }); \ No newline at end of file diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index 472d6a71c..57afd30d6 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -1,6 +1,5 @@ import { initLogger } from '../../../logger'; -import { getPatternArray, readChoiceBlock } from '@finos/calm-models/pattern'; -import { listSelectableCandidates } from '../../../pattern-candidates.js'; +import { getPatternArray, readChoiceBlock, listSelectableCandidates } from '@finos/calm-models/pattern'; /** * A node within a CALM pattern's JSON schema. The pattern is unvalidated JSON diff --git a/shared/src/pattern-candidates.ts b/shared/src/pattern-candidates.ts deleted file mode 100644 index 31a78a3d9..000000000 --- a/shared/src/pattern-candidates.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { getPatternArray, readChoiceBlock, type SchemaNode } from '@finos/calm-models/pattern'; - -/** `path` is Spectral path segments, not a pointer string. */ -export interface PatternCandidate { - uniqueId: string; - site: 'prefixItem' | 'prefixItemAlternative' | 'catalogMember'; - node: SchemaNode; - path: (string | number)[]; - slotIndex?: number; - blockType?: 'oneOf' | 'anyOf'; -} - -/** How a `oneOf`/`anyOf` choice block contributes candidates. */ -type BlockResolution = - /** Every alternative of every keyword - what a pattern *declares*. */ - | 'all' - /** Only the operative keyword's alternatives - what selection can *reach*. */ - | 'operative'; - -function readUniqueId(item: SchemaNode): string | undefined { - const properties = item?.['properties'] as Record | undefined; - const constValue = properties?.['unique-id']?.['const']; - return typeof constValue === 'string' ? constValue : undefined; -} - -/** - * One traversal for both questions. The resolutions differ only in how a choice block - * contributes, so they must not become two functions that drift apart. - */ -function walkCandidates( - pattern: SchemaNode, - calmType: 'nodes' | 'relationships', - resolution: BlockResolution -): PatternCandidate[] { - const { prefixItems, catalog } = getPatternArray(pattern, calmType); - const candidates: PatternCandidate[] = []; - - const emitBlock = ( - container: SchemaNode, - basePath: (string | number)[], - site: PatternCandidate['site'], - slotIndex?: number - ): boolean => { - const keywords: ReadonlyArray<'oneOf' | 'anyOf'> = - resolution === 'all' ? ['oneOf', 'anyOf'] : (readChoiceBlock(container) ? [readChoiceBlock(container)!.groupType] : []); - - let emitted = false; - keywords.forEach((blockType) => { - const alternatives = container?.[blockType]; - if (!Array.isArray(alternatives)) return; - emitted = true; - (alternatives as SchemaNode[]).forEach((alt, j) => { - const uniqueId = readUniqueId(alt); - if (!uniqueId) return; - candidates.push({ - uniqueId, - site, - node: alt, - path: [...basePath, blockType, j], - ...(slotIndex !== undefined && { slotIndex }), - blockType, - }); - }); - }); - return emitted; - }; - - prefixItems.forEach((item: SchemaNode, i: number) => { - const base: (string | number)[] = ['properties', calmType, 'prefixItems', i]; - - // A hybrid slot carries its own id and alternatives. Both must be emitted. - const uniqueId = readUniqueId(item); - if (uniqueId) { - candidates.push({ uniqueId, site: 'prefixItem', node: item, path: base }); - } - - emitBlock(item, base, 'prefixItemAlternative', i); - }); - - if (catalog) { - emitBlock(catalog, ['properties', calmType, 'items'], 'catalogMember'); - } - - return candidates; -} - -/** - * Every candidate the pattern declares, both keywords unioned. Use it for questions - * about what a document says: uniqueness, dangling references. - */ -export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): PatternCandidate[] { - return walkCandidates(pattern, calmType, 'all'); -} - -/** - * Only the candidates selection can reach, resolved as `selectChoices` resolves them. - * Use it for "can this answer be honoured". `listCandidates` is a silent bug here, - * because it reports the losing keyword's alternatives as available. - */ -export function listSelectableCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): PatternCandidate[] { - return walkCandidates(pattern, calmType, 'operative'); -} diff --git a/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts b/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts index f8350ad63..9f770ef0c 100644 --- a/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts +++ b/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts @@ -1,6 +1,5 @@ import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import type { SchemaNode } from '@finos/calm-models/pattern'; -import { listCandidates, listSelectableCandidates } from '../../../pattern-candidates.js'; +import { listCandidates, listSelectableCandidates, type SchemaNode } from '@finos/calm-models/pattern'; /** * Reports a candidate that a decision names, but that selection cannot reach. From 9fda8e952e9de794769de782ceede962ab3457d8 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 24 Aug 2026 15:31:22 +0000 Subject: [PATCH 19/41] fix(shared): correct catalog-single-choice-keyword's message for prefixItems slots The rule's given already matched prefixItems slots as well as items catalogs - both shapes hit the same underlying defect, oneOf silently winning over anyOf - but the message and the rule's own description always said "an items catalog declares both...", wrong wording for half its matches. Made both shape-neutral. Added the missing prefixItems-trigger test coverage; none existed despite the given already covering it. --- .../pattern/catalog-single-choice-keyword.ts | 2 +- shared/src/spectral/rules-pattern.spec.ts | 31 +++++++++++++++++++ shared/src/spectral/rules-pattern.ts | 2 +- 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts b/shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts index ffd169fdf..93c12ad58 100644 --- a/shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts +++ b/shared/src/spectral/functions/pattern/catalog-single-choice-keyword.ts @@ -19,7 +19,7 @@ export function catalogSingleChoiceKeyword(input: unknown, _: unknown, context: return [{ message: - 'An items catalog declares both "oneOf" and "anyOf". Only the "oneOf" candidates are ' + + 'This choice block declares both "oneOf" and "anyOf". Only the "oneOf" candidates are ' + 'selectable - candidates under "anyOf" are silently dropped from generation and from ' + 'the diagram. Declare exactly one of the two.', path: [...context.path], diff --git a/shared/src/spectral/rules-pattern.spec.ts b/shared/src/spectral/rules-pattern.spec.ts index 8ccd8afad..55c854ac1 100644 --- a/shared/src/spectral/rules-pattern.spec.ts +++ b/shared/src/spectral/rules-pattern.spec.ts @@ -44,6 +44,11 @@ async function ruleCodesFor(pattern: object): Promise { return result.spectralIssues.map(issue => issue.code); } +async function ruleMessageFor(pattern: object, code: string): Promise { + const result = await runSpectralValidations(JSON.stringify(pattern), patternRules, 'test'); + return result.spectralIssues.find((issue) => issue.code === code)?.message; +} + describe('pattern-option-relationship-must-be-in-prefix-items', () => { it('passes when the decision holder is in prefixItems and only candidates are in the items catalog', async () => { const pattern = { @@ -191,6 +196,32 @@ describe('pattern-items-catalog-must-declare-one-choice-keyword', () => { expect(codes).not.toContain(RULE_BOTH); }); + it('fires on a prefixItems slot declaring both oneOf and anyOf, not just an items catalog', async () => { + // The bug this rule catches - oneOf silently winning over anyOf - resolves + // identically for a prefixItems slot and an items catalog (both go through + // readChoiceBlock), and reproduces on main for prefixItems slots. The `given` + // already covers this site; this pins that it is actually exercised. + const codes = await ruleCodesFor({ + properties: { + nodes: { prefixItems: [{ oneOf: [candidate('a')], anyOf: [candidate('b')] }] }, + relationships: { prefixItems: [] } + } + }); + expect(codes).toContain(RULE_BOTH); + }); + + it('names the block generically, not as "an items catalog", since a prefixItems slot triggers it too', async () => { + const pattern = { + properties: { + nodes: { prefixItems: [{ oneOf: [candidate('a')], anyOf: [candidate('b')] }] }, + relationships: { prefixItems: [] } + } + }; + const message = await ruleMessageFor(pattern, RULE_BOTH); + expect(message).toBeDefined(); + expect(message?.toLowerCase()).not.toContain('items catalog'); + }); + it('leaves a plain items schema alone — it is not a catalog', async () => { const codes = await ruleCodesFor({ properties: { diff --git a/shared/src/spectral/rules-pattern.ts b/shared/src/spectral/rules-pattern.ts index e446404f0..5c095dd85 100644 --- a/shared/src/spectral/rules-pattern.ts +++ b/shared/src/spectral/rules-pattern.ts @@ -208,7 +208,7 @@ const patternRules: RulesetDefinition = { 'pattern-items-catalog-must-declare-one-choice-keyword': { // Legal JSON Schema, so this is a smell and not an invalid document. Raising it // to `error` would not help generation, because `calm generate` never validates. - description: 'An items catalog must declare only one of oneOf/anyOf', + description: 'A choice block must declare only one of oneOf/anyOf', severity: 'warn', message: '{{error}}', given: [ From 5b4827af806181c33685c3ff03a0a18b50648829 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 24 Aug 2026 15:31:30 +0000 Subject: [PATCH 20/41] docs: correct unreferenced-candidate warning claim for prefixItems slots The authoring guide claimed a catalog node with no reference produces a warning "exactly as for prefixItems". True for a plain prefixItems entry, not for a prefixItems[i].oneOf/anyOf alternative - pattern-nodes-must-be- referenced never covered that shape, on main or after this PR. Corrected the claim and pointed at the tracked follow-up. --- calm-ai/tools/pattern-creation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/calm-ai/tools/pattern-creation.md b/calm-ai/tools/pattern-creation.md index 718b734fe..5ca73350f 100644 --- a/calm-ai/tools/pattern-creation.md +++ b/calm-ai/tools/pattern-creation.md @@ -238,7 +238,7 @@ Guidance: - Keep every candidate that a single decision references within one declaration site. A decision whose candidates are split between a `prefixItems` slot and an `items` catalog, or spread across nodes with inconsistent container membership, is a pattern smell — model the choice at one consistent level. - Declare every decision holder (a relationship with `relationship-type.options`) in `properties.relationships.prefixItems`. A catalog with no holder pointing at it can never be selected from, and `calm validate` warns that its candidates are unreferenced. -- Duplicate `unique-id`s inside an `items` catalog are rejected by `calm validate`, and a catalog node that no relationship or decision references produces a warning, exactly as for `prefixItems`. +- Duplicate `unique-id`s inside an `items` catalog are rejected by `calm validate`, and a catalog node that no relationship or decision references produces a warning — the same check a plain `prefixItems` entry gets. A candidate declared as a `prefixItems[i].oneOf`/`anyOf` alternative does not get this warning yet; that gap predates this feature and is tracked separately. ### Relationship Options with Decision Points From 4a672585642a795cb8098b353095cc6d7d6c6c2d Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 24 Aug 2026 15:37:44 +0000 Subject: [PATCH 21/41] refactor(calm-hub-ui): use readChoiceBlock for prefixItems slots, matching the catalog path patternTransformer.ts hand-rolled hasOneOf/hasAnyOf for prefixItems slot alternatives in three places, right alongside calls to the shared readChoiceBlock for the items-catalog case. Same oneOf-wins-over-anyOf precedence, same shape - readChoiceBlock already answers this question. Pure refactor, no behaviour change for any valid input: verified the calm-hub-ui test suite (1419 tests) passes identically before and after, and diffed tsc --noEmit output before/after to confirm no new type errors. readChoiceBlock's null guard is marginally more defensive than the hand- rolled version for a null/undefined prefixItems entry, which the old code would have thrown on - not reachable by any legal pattern. --- .../reactflow/utils/patternTransformer.ts | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index fea6e9ff7..2ea555bc2 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -201,13 +201,9 @@ function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[ const decisionGroups: DecisionGroup[] = []; prefixItems.forEach((item: SchemaObject, index: number) => { - const hasOneOf = Array.isArray(item['oneOf']); - const hasAnyOf = Array.isArray(item['anyOf']); - - if (hasOneOf || hasAnyOf) { - const groupType: 'oneOf' | 'anyOf' = hasOneOf ? 'oneOf' : 'anyOf'; - const alternatives: SchemaObject[] = hasOneOf ? item['oneOf'] : item['anyOf']; - extractNodeDecisionGroup(alternatives, `node-decision-${index}`, groupType, nodes, decisionGroups); + const block = readChoiceBlock(item); + if (block) { + extractNodeDecisionGroup(block.alternatives as SchemaObject[], `node-decision-${index}`, block.groupType, nodes, decisionGroups); } else { const node = extractNodeFromSchemaItem(item); if (node) { @@ -318,12 +314,11 @@ function extractOptionsMetadata(item: SchemaObject): OptionsMetadata | null { item['properties']?.['relationship-type']?.['properties']?.['options']?.['prefixItems'] || []; for (const prefixItem of optionsPrefixItems) { - const hasOneOf = Array.isArray(prefixItem['oneOf']); - const hasAnyOf = Array.isArray(prefixItem['anyOf']); + const block = readChoiceBlock(prefixItem); - if (hasOneOf || hasAnyOf) { - const optionType: 'oneOf' | 'anyOf' = hasOneOf ? 'oneOf' : 'anyOf'; - const alternatives: SchemaObject[] = hasOneOf ? prefixItem['oneOf'] : prefixItem['anyOf']; + if (block) { + const optionType = block.groupType; + const alternatives = block.alternatives as SchemaObject[]; const choices = alternatives .map((alt: SchemaObject) => { @@ -385,12 +380,9 @@ function extractRelationshipsFromPattern(pattern: SchemaObject): { } // Check for oneOf/anyOf wrapped relationships - const hasOneOf = Array.isArray(item['oneOf']); - const hasAnyOf = Array.isArray(item['anyOf']); - - if (hasOneOf || hasAnyOf) { - const alternatives: SchemaObject[] = hasOneOf ? item['oneOf'] : item['anyOf']; - extractRelationshipDecisionGroup(alternatives, `rel-decision-${index}`, relationships); + const block = readChoiceBlock(item); + if (block) { + extractRelationshipDecisionGroup(block.alternatives as SchemaObject[], `rel-decision-${index}`, relationships); return; } From 356ed2408799d71b09b95f7d44c8aa76f9468580 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 24 Aug 2026 16:00:17 +0000 Subject: [PATCH 22/41] fix(shared): validate every answer of a multi-select decision, not just the first extractChoicesFromArchitecture indexed relationship-type.options[0], so a zero-answer anyOf decision crashed the validator (reading .description off undefined) and a two-or-more-answer decision silently validated only the first, leaving every other selection completely unchecked. Pre-existing on main, byte-identical, and unrelated to items catalogs: anyOf-typed multi-answer decisions over plain prefixItems candidates are documented on main (calm-ai/tools/pattern-creation.md, 'Providing Options with anyOf/oneOf'), already enforced by pattern-option-relationship-must-only-have-oneof-or-anyof-items, and trace to issue #706. The CALM meta-schema places no length restriction on options. Confirmed the real shape via the actual generate pipeline: one options[] entry per selection, not one combined entry. Fix: flatMap every options entry instead of indexing [0]. selectChoices already handles multiple CalmChoices for the same decision correctly. --- .../commands/validate/validate.e2e.spec.ts | 64 +++++++++++++++++++ shared/src/commands/validate/validate.spec.ts | 60 +++++++++++++++++ .../commands/validate/validation-helpers.ts | 2 +- 3 files changed, 125 insertions(+), 1 deletion(-) diff --git a/shared/src/commands/validate/validate.e2e.spec.ts b/shared/src/commands/validate/validate.e2e.spec.ts index 5e5c4585a..7f7837c47 100644 --- a/shared/src/commands/validate/validate.e2e.spec.ts +++ b/shared/src/commands/validate/validate.e2e.spec.ts @@ -164,6 +164,70 @@ describe('validate E2E', () => { const newPattern = applyArchitectureOptionsToPattern(architecture, pattern, false); expect(newPattern).toStrictEqual(expectedResult); }); + + it('narrows the pattern to every chosen candidate of a multi-select anyOf decision, not just the first', async () => { + const pattern = { + properties: { + nodes: { + type: 'array', + prefixItems: [{ properties: { 'unique-id': { const: 'webapp' } } }], + items: { + anyOf: [ + { properties: { 'unique-id': { const: 'postgres-db' } } }, + { properties: { 'unique-id': { const: 'mysql-db' } } }, + ], + }, + }, + relationships: { + type: 'array', + prefixItems: [ + { + properties: { + 'unique-id': { const: 'database-choice' }, + description: { const: 'Which database(s)?' }, + 'relationship-type': { + type: 'object', + properties: { + options: { + type: 'array', + prefixItems: [ + { + anyOf: [ + { properties: { description: { const: 'Use PostgreSQL' }, nodes: { const: ['postgres-db'] }, relationships: { const: [] } } }, + { properties: { description: { const: 'Use MySQL' }, nodes: { const: ['mysql-db'] }, relationships: { const: [] } } }, + ], + }, + ], + }, + }, + }, + }, + }, + ], + }, + }, + }; + + const architecture = { + relationships: [ + { + 'unique-id': 'database-choice', + 'relationship-type': { + options: [ + { description: 'Use PostgreSQL', nodes: ['postgres-db'], relationships: [] }, + { description: 'Use MySQL', nodes: ['mysql-db'], relationships: [] }, + ], + }, + }, + ], + }; + + const narrowed = applyArchitectureOptionsToPattern(architecture, pattern, false) as { + properties: { nodes: { prefixItems: { properties: { 'unique-id': { const: string } } }[] } }; + }; + const narrowedNodeIds = narrowed.properties.nodes.prefixItems.map((n) => n.properties['unique-id'].const); + expect(narrowedNodeIds).toEqual(['webapp', 'postgres-db', 'mysql-db']); + }); }); describe('schema specific validations', () => { diff --git a/shared/src/commands/validate/validate.spec.ts b/shared/src/commands/validate/validate.spec.ts index 7063d67c3..d049e932e 100644 --- a/shared/src/commands/validate/validate.spec.ts +++ b/shared/src/commands/validate/validate.spec.ts @@ -409,6 +409,66 @@ describe('validation support functions', () => { const choices = extractChoicesFromArchitecture(architecture); expect(choices).toHaveLength(2); }); + + it('captures every answer of a multi-select anyOf decision, not just the first', async () => { + // Real shape confirmed via the actual generate pipeline: selecting two anyOf + // candidates produces one options[] entry per selection, not one combined entry. + const architecture = { + relationships: [ + { + 'unique-id': 'database-choice', + 'relationship-type': { + options: [ + { 'description': 'Use PostgreSQL', 'nodes': ['postgres-db'], 'relationships': [] }, + { 'description': 'Use MySQL', 'nodes': ['mysql-db'], 'relationships': [] } + ] + } + } + ] + }; + const choices = extractChoicesFromArchitecture(architecture); + expect(choices).toHaveLength(2); + expect(choices.map((c) => c.description)).toEqual(['Use PostgreSQL', 'Use MySQL']); + }); + + it('does not throw on a zero-answer anyOf decision (options: [])', async () => { + const architecture = { + relationships: [ + { + 'unique-id': 'database-choice', + 'relationship-type': { options: [] } + } + ] + }; + expect(() => extractChoicesFromArchitecture(architecture)).not.toThrow(); + expect(extractChoicesFromArchitecture(architecture)).toHaveLength(0); + }); + + it('combines a multi-answer decision with a separate single-answer decision', async () => { + const architecture = { + relationships: [ + { + 'unique-id': 'database-choice', + 'relationship-type': { + options: [ + { 'description': 'Use PostgreSQL', 'nodes': ['postgres-db'], 'relationships': [] }, + { 'description': 'Use MySQL', 'nodes': ['mysql-db'], 'relationships': [] } + ] + } + }, + { + 'unique-id': 'cache-choice', + 'relationship-type': { + options: [ + { 'description': 'Use Redis', 'nodes': ['redis'], 'relationships': [] } + ] + } + } + ] + }; + const choices = extractChoicesFromArchitecture(architecture); + expect(choices).toHaveLength(3); + }); }); }); diff --git a/shared/src/commands/validate/validation-helpers.ts b/shared/src/commands/validate/validation-helpers.ts index e4d61fd8e..73f66d2d7 100644 --- a/shared/src/commands/validate/validation-helpers.ts +++ b/shared/src/commands/validate/validation-helpers.ts @@ -84,7 +84,7 @@ export function extractChoicesFromArchitecture(architecture: object): CalmChoice return relationships .filter((rel) => rel['relationship-type'] && Object.prototype.hasOwnProperty.call(rel['relationship-type'], 'options')) - .map((rel) => rel['relationship-type']['options'][0]) + .flatMap((rel) => rel['relationship-type']['options']) .map((rel) => ({ description: rel['description'], nodes: rel['nodes'] || [], From 8fa43a810b78fadacf2abb07c561784db640b6b4 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 24 Aug 2026 18:35:22 +0000 Subject: [PATCH 23/41] test: add round-trip baselines for the conference-signup gap and items catalogs Two related additions, both about the same problem: nothing tested a full generate-then-validate cycle against a real pattern, which is why the control-id gap and the finding-8 multi-answer bug both went unnoticed. cli.e2e.spec.ts: pins the conference-signup pattern's known, pre-existing failure (control-requirement-validation on control-id) as an explicit baseline inside the existing 'Getting Started Verification' test. Root cause: calm/getting-started/controls/permitted-connection-jdbc.config.json declares control-id 'security-003'; the shared requirement schema it's checked against demands 'security-002'. The sibling http config file - same control, different protocol - correctly uses security-002, confirming this is a copy-paste slip from when the jdbc variant was authored (both created together in commit 4b8a7185, June 2025), not an intentional difference. Reproduces identically on main; the checked-in reference fixture inherits the same wrong value. Comment says to delete the block once this is actually fixed - it's a one-value data correction, not a design question. items-catalog-round-trip.e2e.spec.ts: a new, clean baseline that actually works, since no repository pattern uses items catalogs yet to point at instead. Deliberately excludes control requirements so it's unaffected by the conference-signup gap. Three cases, one shared fixture: a clean 2-select happy path, a 0-select case (the crash half of finding 8's bug), and a corrupt-the-second-selection case (the silent-validation-gap half). The corruption case is the one with real discriminating power - verified empirically against two different bugs (extractChoicesFromArchitecture's original .map(...)[0] and a simulated equivalent in the sibling flattenOptionsRelationship), confirming it fails for either and passes again once each is reverted, so its protection isn't narrowly tied to one specific code path. --- cli/src/cli.e2e.spec.ts | 22 +++ .../items-catalog-round-trip.e2e.spec.ts | 141 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 shared/src/commands/generate/items-catalog-round-trip.e2e.spec.ts diff --git a/cli/src/cli.e2e.spec.ts b/cli/src/cli.e2e.spec.ts index 8f064779b..3569b28c7 100644 --- a/cli/src/cli.e2e.spec.ts +++ b/cli/src/cli.e2e.spec.ts @@ -681,6 +681,28 @@ describe('CLI Integration Tests', () => { ); await expectFilesMatch(expectedOutputArchitecture, outputArchitecture); + // BASELINE, not a target to make pass silently. The generated architecture does not + // round-trip: validating it against the same pattern fails on a control requirement's + // control-id, because `calm generate` never fetches the schema `requirement-url` points + // at to materialize the fields that schema demands. Reproduces identically on `main`; + // unrelated to this PR. Tracked separately - if this test starts failing because + // validation now passes, the bug is fixed and this block should be deleted. + let validateError: { stdout?: string } | undefined; + try { + await cli.run(['validate', '-p', inputPattern, '-a', outputArchitecture, '-u', STATIC_GETTING_STARTED_MAPPING_PATH]); + } catch (err) { + validateError = err as { stdout?: string }; + } + expect(validateError, 'expected calm validate to fail - if it now passes, delete this baseline block').toBeDefined(); + const validateOutput = JSON.parse(validateError!.stdout!); + expect(validateOutput.hasErrors).toBe(true); + expect(validateOutput.jsonSchemaValidationOutputs).toContainEqual( + expect.objectContaining({ + code: 'control-requirement-validation', + path: expect.stringContaining('control-id'), + }) + ); + //STEP 2: Generate Docify Website From Architecture const outputWebsite = path.resolve(actualOutputDir, 'website'); await cli.run([ diff --git a/shared/src/commands/generate/items-catalog-round-trip.e2e.spec.ts b/shared/src/commands/generate/items-catalog-round-trip.e2e.spec.ts new file mode 100644 index 000000000..f389b9796 --- /dev/null +++ b/shared/src/commands/generate/items-catalog-round-trip.e2e.spec.ts @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from 'vitest'; +import { selectChoices, extractOptions, CalmChoice } from './components/options'; +import { instantiate } from './components/instantiate'; +import { validate } from '../validate/validate'; +import { SchemaDirectory } from '../../schema-directory'; + +/** + * Round-trip baseline for `items` catalog decisions: generate, then validate the result + * against the same pattern, through the real functions each side actually uses - not just + * unit-level assertions on one function in isolation. No control requirements, so this is + * unaffected by the separate, pre-existing conference-signup round-trip gap (see #2932 + * follow-up tracking). + */ + +vi.mock('../../schema-directory', () => ({ + SchemaDirectory: vi.fn(function () { + return { loadSchemas: vi.fn(), loadCurrentPatternAsSchema: vi.fn(), getDefinition: vi.fn() }; + }), +})); + +function node(uniqueId: string) { + return { + type: 'object', + properties: { + 'unique-id': { const: uniqueId }, + name: { const: uniqueId }, + 'node-type': { const: 'service' }, + }, + required: ['unique-id', 'name', 'node-type'], + }; +} + +function connects(id: string, source: string, destination: string) { + return { + type: 'object', + properties: { + 'unique-id': { const: id }, + description: { const: `${source} to ${destination}` }, + 'relationship-type': { + const: { connects: { source: { node: source }, destination: { node: destination } } }, + }, + }, + required: ['unique-id', 'description', 'relationship-type'], + }; +} + +function choice(description: string, nodeId: string, relationshipId: string) { + return { + type: 'object', + properties: { + description: { const: description }, + nodes: { const: [nodeId] }, + relationships: { const: [relationshipId] }, + }, + }; +} + +const pattern = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'items-catalog-round-trip-pattern', + properties: { + nodes: { + type: 'array', + prefixItems: [node('webapp')], + items: { anyOf: [node('candidate-a'), node('candidate-b')] }, + }, + relationships: { + type: 'array', + prefixItems: [ + { + type: 'object', + properties: { + 'unique-id': { const: 'catalog-choice' }, + description: { const: 'Which optional components?' }, + 'relationship-type': { + type: 'object', + properties: { + options: { + type: 'array', + prefixItems: [ + { + anyOf: [ + choice('Use Candidate A', 'candidate-a', 'webapp-to-a'), + choice('Use Candidate B', 'candidate-b', 'webapp-to-b'), + ], + }, + ], + }, + }, + }, + }, + }, + ], + items: { anyOf: [connects('webapp-to-a', 'webapp', 'candidate-a'), connects('webapp-to-b', 'webapp', 'candidate-b')] }, + }, + }, +}; + +async function generateAndValidate(choices: CalmChoice[]) { + const selected = selectChoices(pattern, choices, false); + const architecture = await instantiate(selected, false, new SchemaDirectory({} as never)) as { + nodes: Array>; + relationships: Array>; + }; + const response = await validate(architecture, pattern, undefined, new SchemaDirectory({} as never), false); + return { architecture, response }; +} + +describe('items-catalog round trip (baseline)', () => { + it('succeeds when two candidates are selected and both validate cleanly', async () => { + const options = extractOptions(pattern); + const chosen = options[0].choices; // both choices for the one anyOf decision + const { response } = await generateAndValidate(chosen); + + expect(response.hasErrors).toBe(false); + }); + + it('does not crash and produces a valid empty selection when zero candidates are chosen', async () => { + const { response } = await generateAndValidate([]); + + expect(response.hasErrors).toBe(false); + }); + + it('catches a corruption on the second selection, not just the first', async () => { + const options = extractOptions(pattern); + const chosen = options[0].choices; + const { architecture } = await generateAndValidate(chosen); + + // Corrupt the second selected node's const-constrained field. + const secondNode = architecture.nodes.find((n) => n['unique-id'] === 'candidate-b') as Record; + expect(secondNode).toBeDefined(); + secondNode['node-type'] = 'not-a-valid-value'; + + const response = await validate(architecture, pattern, undefined, new SchemaDirectory({} as never), false); + + expect(response.hasErrors).toBe(true); + expect(response.jsonSchemaValidationOutputs).toContainEqual( + expect.objectContaining({ path: '/nodes/2/node-type' }) + ); + }); +}); From 759d08e5cbea62df2bb91e46307865bdb98a8670 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Mon, 24 Aug 2026 23:59:51 +0000 Subject: [PATCH 24/41] docs: correct getPatternArray's allOf precedence comment The TEMPORARY comment on readArrayKeyword described the allOf fallback as if one branch won for the prefixItems/items pair. getPatternArray actually calls it once per keyword, each search independent, so the two keywords for the same property can resolve from different allOf branches. Reworded to say so. Comment only, no behaviour change. --- calm-models/src/pattern/pattern-reader.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index 6abb66228..246cf5349 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -26,8 +26,13 @@ function isObject(value: unknown): value is SchemaNode { /** - * TEMPORARY. Replicates today's first-`allOf`-branch-wins reading of `prefixItems`/`items` - * for a top-level pattern property. A later branch that declares the same path is ignored. + * TEMPORARY. Replicates today's first-`allOf`-branch-wins reading of a single keyword + * (`prefixItems` or `items`) for a top-level pattern property. A later branch that declares + * the same path is ignored. + * + * `getPatternArray` calls this once per keyword, each search independent. So `prefixItems` + * and `items` for the same property can resolve from two different branches (or one from the + * root, one from `allOf`) - there is no single "the branch that won" for the pair. * * It exists because candidate discovery runs on the raw pattern, before `flattenAllOf`. * The `allOf` merge rework will delete this function. Do not correct the precedence here. From 245f5e43e9f4b10b697989a98db77967623c5d37 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 00:00:01 +0000 Subject: [PATCH 25/41] fix: stop getPatternArray composing prefixItems and items from different allOf branches getPatternArray resolved prefixItems and items independently, each with its own allOf fallback search. A pattern could end up with prefixItems from one branch and an items catalog from a different branch - an array no single declaration site in the document actually describes. Introduced by this PR (getPatternArray/pattern-reader.ts is new on this branch; main never resolved items through allOf at all, so the two-keyword composition wasn't reachable there). Fix: resolve one container per property (root, else the first allOf branch that declares either keyword) and read both keywords from it. No signature or caller changes. Pinned four cases in pattern-reader.spec.ts: the original repro, the reverse direction (catalog on one source, prefixItems on another), a split across two different allOf branches with an empty root, and the existing container-skip precedent (root declares the property but neither keyword). --- .../src/pattern/pattern-reader.spec.ts | 68 +++++++++++++++++++ calm-models/src/pattern/pattern-reader.ts | 30 ++++---- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/calm-models/src/pattern/pattern-reader.spec.ts b/calm-models/src/pattern/pattern-reader.spec.ts index a5f998903..8dc49d0cd 100644 --- a/calm-models/src/pattern/pattern-reader.spec.ts +++ b/calm-models/src/pattern/pattern-reader.spec.ts @@ -60,6 +60,74 @@ describe('getPatternArray', () => { const pattern = { properties: { nodes: { prefixItems: [nodeWithId('a')], items: false } } }; expect(getPatternArray(pattern, 'nodes').catalog).toBeUndefined(); }); + + it('does not compose prefixItems from one source with a catalog from another', () => { + const pattern = { + properties: { nodes: { prefixItems: [nodeWithId('root-a')] } }, + allOf: [{ + properties: { + nodes: { + prefixItems: [nodeWithId('branch-x')], + items: { anyOf: [nodeWithId('branch-cat')] }, + }, + }, + }], + }; + + const result = getPatternArray(pattern, 'nodes'); + // The root's own prefixItems wins wholesale (same first-source-wins precedent as + // the test above) - it must NOT borrow the allOf branch's catalog, which would + // describe an array no single declaration site in the document actually contains. + expect(result.prefixItems).toEqual([nodeWithId('root-a')]); + expect(result.catalog).toBeUndefined(); + }); + + it('does not compose the other way either: a catalog from one source with prefixItems from another', () => { + const pattern = { + properties: { nodes: { items: { anyOf: [nodeWithId('root-cat')] } } }, + allOf: [{ properties: { nodes: { prefixItems: [nodeWithId('branch-a')] } } }], + }; + + const result = getPatternArray(pattern, 'nodes'); + // The root's own declaration (a catalog, no prefixItems of its own) wins wholesale. + // Its own gap - no prefixItems - must stay a gap, not get silently patched by + // borrowing the allOf branch's prefixItems. + expect(result.prefixItems).toEqual([]); + expect(result.catalog).toEqual({ anyOf: [nodeWithId('root-cat')] }); + }); + + it('does not compose across two different allOf branches when the root declares neither', () => { + const pattern = { + allOf: [ + { properties: { nodes: { prefixItems: [nodeWithId('branch1-a')] } } }, + { properties: { nodes: { items: { anyOf: [nodeWithId('branch2-cat')] } } } }, + ], + }; + + const result = getPatternArray(pattern, 'nodes'); + // The first branch that declares anything wins wholesale; the second branch's + // catalog must not be borrowed to fill the first branch's gap. + expect(result.prefixItems).toEqual([nodeWithId('branch1-a')]); + expect(result.catalog).toBeUndefined(); + }); + + it('skips a source that declares the property but neither keyword, and resolves from the next one', () => { + const pattern = { + properties: { nodes: { minItems: 1 } }, // declares `nodes` but with nothing selectable + allOf: [{ + properties: { + nodes: { + prefixItems: [nodeWithId('branch-a')], + items: { anyOf: [nodeWithId('branch-cat')] }, + }, + }, + }], + }; + + const result = getPatternArray(pattern, 'nodes'); + expect(result.prefixItems).toEqual([nodeWithId('branch-a')]); + expect(result.catalog).toEqual({ anyOf: [nodeWithId('branch-cat')] }); + }); }); describe('readChoiceBlock', () => { diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index 246cf5349..f3643d9fc 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -26,23 +26,20 @@ function isObject(value: unknown): value is SchemaNode { /** - * TEMPORARY. Replicates today's first-`allOf`-branch-wins reading of a single keyword - * (`prefixItems` or `items`) for a top-level pattern property. A later branch that declares - * the same path is ignored. - * - * `getPatternArray` calls this once per keyword, each search independent. So `prefixItems` - * and `items` for the same property can resolve from two different branches (or one from the - * root, one from `allOf`) - there is no single "the branch that won" for the pair. + * TEMPORARY. Replicates today's first-`allOf`-branch-wins reading of a top-level pattern + * property. A later branch declaring the same path is ignored, and `prefixItems`/`items` + * are always read from the *same* resolved source - never composed from two different + * branches, which would describe an array no single declaration site actually contains. * * It exists because candidate discovery runs on the raw pattern, before `flattenAllOf`. * The `allOf` merge rework will delete this function. Do not correct the precedence here. */ -function readArrayKeyword(pattern: SchemaNode, key: string, keyword: string): unknown { +function resolveArrayContainer(pattern: SchemaNode, key: string): SchemaNode | undefined { const direct = pattern['properties']; if (isObject(direct)) { const field = direct[key]; - if (isObject(field) && field[keyword]) { - return field[keyword]; + if (isObject(field) && (field['prefixItems'] || field['items'])) { + return field as SchemaNode; } } @@ -52,8 +49,8 @@ function readArrayKeyword(pattern: SchemaNode, key: string, keyword: string): un const branchProperties = branch['properties']; if (!isObject(branchProperties)) continue; const field = branchProperties[key]; - if (isObject(field) && field[keyword]) { - return field[keyword]; + if (isObject(field) && (field['prefixItems'] || field['items'])) { + return field as SchemaNode; } } } @@ -68,16 +65,17 @@ export interface PatternArray { /** * Reads the `prefixItems` array and `items` open-catalog declared for a top-level - * pattern property (`nodes` or `relationships`), resolving `allOf` per `readArrayKeyword` + * pattern property (`nodes` or `relationships`), resolving `allOf` per `resolveArrayContainer` * above. Absent `prefixItems` yields an empty array so callers can iterate * unconditionally; an absent catalog yields `undefined`. */ export function getPatternArray(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): PatternArray { - const prefixItems = readArrayKeyword(pattern, calmType, 'prefixItems'); - const catalog = readArrayKeyword(pattern, calmType, 'items'); + const container = resolveArrayContainer(pattern, calmType); + const prefixItems = container?.['prefixItems']; + const catalog = container?.['items']; return { prefixItems: Array.isArray(prefixItems) ? (prefixItems as SchemaNode[]) : [], - catalog: isObject(catalog) ? catalog : undefined, + catalog: isObject(catalog) ? (catalog as SchemaNode) : undefined, }; } From 38a0ce06a1592545baaa2f741ea04018a6d8010f Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 00:10:58 +0000 Subject: [PATCH 26/41] docs(shared): update Pattern Decisions for the getPatternArray fix, drop line citations getPatternArray's allOf table row still described the pre-fix behaviour (independent per-keyword resolution) instead of the single-branch-for-both-keywords fix, and the paragraph after it now contradicted that row by claiming no reader reaches into allOf. Corrected both. Replaced four file:line citations with function names or, where the specific expression was the point (the node-has-relationship.ts JSONPath query), a quoted snippet instead. Line numbers drift on any unrelated edit above them with nothing to catch it; three of the four citations had already gone stale. --- shared/AGENTS.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/shared/AGENTS.md b/shared/AGENTS.md index c3043a47f..a1c9b93ea 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -61,8 +61,8 @@ a bundle can select. Candidates are declared in four places: a plain `prefixItem `prefixItems[i].oneOf` or `.anyOf` alternative, or an `items.oneOf`/`items.anyOf` catalog. **A decision holder must be in `properties.relationships.prefixItems`.** `extractOptions` -(`options.ts:65`) finds decisions only through `getRelationshipsPrefixItems` (`options.ts:55`), -which never reads the `items` catalog. A holder in a catalog is never offered, on any path. +finds decisions only through `getRelationshipsPrefixItems`, both in `options.ts`, which never +reads the `items` catalog. A holder in a catalog is never offered, on any path. A candidate reaches the output only when a chosen bundle names its `unique-id`. Drive new tests from `extractOptions`, not from hand-built choices, or you do not test whether the @@ -90,11 +90,12 @@ Treat `allOf` for nodes and relationships as unsupported. | Reader | Behaviour | |---|---| | `deepMergeSchemas` (`flatten-allof.ts`) | shallow merge; a repeated property loses `type`, so `instantiate` emits `{}` | -| `getPatternArray` | first branch wins, later branches ignored; marked TEMPORARY | +| `getPatternArray` | resolves one branch per property (root, else the first branch declaring it) and reads `prefixItems`/`items` from that same branch; later branches ignored; marked TEMPORARY | | `listCandidates` / `listSelectableCandidates` | ignore `allOf` entirely, to keep `path` correct for diagnostics | -A pattern whose `prefixItems`/`items` sits under `allOf` yields no candidates at all from the -readers, and `deepMergeSchemas` may still merge it (lossily) for `calm generate`. `allOf` means +A pattern whose `prefixItems`/`items` sits under `allOf` yields no candidates at all from +`listCandidates`/`listSelectableCandidates` (they ignore `allOf` entirely), even though +`getPatternArray` and `deepMergeSchemas` both still reach into it. `allOf` means **intersection**, never union, because `calm validate` never flattens. `shared` previously kept its own copy of `listCandidates` that followed `allOf` through `getPatternArray`, disagreeing with the `calm-models` copy and reporting a `path` the document did not contain; nothing tested @@ -116,8 +117,9 @@ it did not add support. The generate path has its own guard. `assertChoicesAreSelectable` throws from `runGenerate`. It is not called from `selectChoices`, because validation calls that too. -`pattern-nodes-must-be-referenced` does not help with holder placement. Its query -(`node-has-relationship.ts:10`) matches a holder in the wrong array. +`pattern-nodes-must-be-referenced` does not help with holder placement. Its recursive +`$..relationship-type..*@string()` query (`node-has-relationship.ts`) matches a holder +regardless of which array it sits in. ### Still duplicated @@ -126,7 +128,7 @@ is gone — both `listCandidates` and `listSelectableCandidates` now live only i | Duplicate | Sites | Note | |---|---|---| -| decision-holder reading | `options.ts:25,29`, `patternTransformer.ts:290,317` | already differ: one unions both keywords, the other picks `oneOf` | +| decision-holder reading | `options.ts` (`isOptionsRelationship`, `getItemsInOptionsRelationship`), `patternTransformer.ts` (`isOptionsRelationship`, the `options.prefixItems` read) | already differ: one unions both keywords, the other picks `oneOf` | `calm-hub-ui` depends on `@finos/calm-models` and not on `shared`, so a shared reader must live in `calm-models`. From 67c6966f5041fe8dbfb19ac87c8b533e4c3a35d0 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 00:17:52 +0000 Subject: [PATCH 27/41] docs(shared): trim redundant restatements in Pattern Decisions Three cuts, no new claims: - opening two sentences duplicated pattern-creation.md's decision holder/candidate definitions near-verbatim; point at that file instead of re-deriving the concept, keep only the code-specific enforcement detail - getPatternArray was described twice two lines apart (once generically, once with allOf detail in the very next table); the first mention now just points at the table row - the allOf section stated 'unsupported' at open and close of the same subsection; kept only the clause after the second mention that adds something (the listCandidates removal didn't add allOf support), cut the restated half in front of it --- shared/AGENTS.md | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/shared/AGENTS.md b/shared/AGENTS.md index a1c9b93ea..cafb13f9b 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -55,18 +55,16 @@ npx vitest run ${TEST FILE} ## Pattern Decisions -A **decision holder** is a relationship that carries `relationship-type.properties.options`. -It poses a question and lists choice bundles. A **candidate** is a node or relationship that -a bundle can select. Candidates are declared in four places: a plain `prefixItems` entry, a -`prefixItems[i].oneOf` or `.anyOf` alternative, or an `items.oneOf`/`items.anyOf` catalog. +See `calm-ai/tools/pattern-creation.md` for what a **decision holder** and **candidate** are. +Candidates are declared in four places: a plain `prefixItems` entry, a `prefixItems[i].oneOf` +or `.anyOf` alternative, or an `items.oneOf`/`items.anyOf` catalog. **A decision holder must be in `properties.relationships.prefixItems`.** `extractOptions` finds decisions only through `getRelationshipsPrefixItems`, both in `options.ts`, which never -reads the `items` catalog. A holder in a catalog is never offered, on any path. +reads the `items` catalog — a holder placed there is invisible to it. -A candidate reaches the output only when a chosen bundle names its `unique-id`. Drive new -tests from `extractOptions`, not from hand-built choices, or you do not test whether the -decision is discoverable. See `catalog-decisions.spec.ts`. +Drive new tests from `extractOptions`, not from hand-built choices, or you do not test whether +the decision is discoverable. See `catalog-decisions.spec.ts`. ### Three questions, three owners @@ -81,7 +79,7 @@ Use the wrong one and the failure is silent, so they are separate named function Use *declared* for what a document says: uniqueness, dangling references. Use *selectable* for "can this answer be honoured". They differ only where a block declares both keywords. -`getPatternArray` locates the `prefixItems` array and `items` catalog for a property. +`getPatternArray` is a fourth relevant function — see its row in the `allOf` table below. ### `allOf` has three unreconciled meanings @@ -99,9 +97,8 @@ A pattern whose `prefixItems`/`items` sits under `allOf` yields no candidates at **intersection**, never union, because `calm validate` never flattens. `shared` previously kept its own copy of `listCandidates` that followed `allOf` through `getPatternArray`, disagreeing with the `calm-models` copy and reporting a `path` the document did not contain; nothing tested -or relied on that behaviour, so the copy was removed rather than reconciled. Correct `allOf` -support for nodes and relationships is still unbuilt — this only removed a second, wrong answer, -it did not add support. +or relied on that behaviour, so the copy was removed rather than reconciled — it did not add +`allOf` support, only removed a second, wrong answer. ### Enforcement From e46bb059d8dec5194858949d7fbea78444585ff0 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 07:57:17 +0000 Subject: [PATCH 28/41] fix(calm-models): make the pattern differ see items-catalog candidates pattern-diff.ts read prefixItems independently, with its own allOf fallback, and never looked at items - so a catalog candidate added or removed from a pattern produced no diff at all. calm diff --exit-code is a documented CI gate for version bumps, so a breaking catalog change could pass it silently. getPrefixItems is replaced by getCandidateItems, delegating to getPatternArray and feeding its catalog through the same expandAlternatives step slot alternatives already go through. Pinned with 4 new tests (add/remove/unchanged/instantiate). Full calm-models suite 231/231, build clean, lint 0 errors. --- .../src/diff/fixtures/diff-test-patterns.json | 69 +++++++++++++++++++ calm-models/src/diff/pattern-diff.spec.ts | 26 +++++++ calm-models/src/diff/pattern-diff.ts | 34 +++------ calm-models/src/pattern/pattern-reader.ts | 3 +- 4 files changed, 108 insertions(+), 24 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..652573020 100644 --- a/calm-models/src/diff/fixtures/diff-test-patterns.json +++ b/calm-models/src/diff/fixtures/diff-test-patterns.json @@ -490,5 +490,74 @@ "prefixItems": [] } } + }, + "catalogBasePattern": { + "$schema": "https://calm.finos.org/release/1.0-rc2/meta/calm.json", + "type": "object", + "title": "Catalog Base Pattern", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "properties": { + "unique-id": { "const": "webapp" }, + "name": { "const": "Web App" }, + "node-type": { "const": "service" } + } + } + ], + "items": { + "anyOf": [ + { + "properties": { + "unique-id": { "const": "redis" }, + "name": { "const": "Redis" }, + "node-type": { "const": "database" } + } + } + ] + } + }, + "relationships": { "type": "array", "prefixItems": [] } + } + }, + "catalogAdditionPattern": { + "$schema": "https://calm.finos.org/release/1.0-rc2/meta/calm.json", + "type": "object", + "title": "Catalog Addition Pattern", + "properties": { + "nodes": { + "type": "array", + "prefixItems": [ + { + "properties": { + "unique-id": { "const": "webapp" }, + "name": { "const": "Web App" }, + "node-type": { "const": "service" } + } + } + ], + "items": { + "anyOf": [ + { + "properties": { + "unique-id": { "const": "redis" }, + "name": { "const": "Redis" }, + "node-type": { "const": "database" } + } + }, + { + "properties": { + "unique-id": { "const": "valkey" }, + "name": { "const": "Valkey" }, + "node-type": { "const": "database" } + } + } + ] + } + }, + "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..293a12379 100644 --- a/calm-models/src/diff/pattern-diff.spec.ts +++ b/calm-models/src/diff/pattern-diff.spec.ts @@ -55,6 +55,11 @@ describe('normalisePatternToInstance', () => { const { nodes } = normalisePatternToInstance(testPatterns.undiffablePattern); expect(nodes.map((n) => n['unique-id'])).toEqual(['api-gateway']); }); + + it('includes items-catalog candidates alongside prefixItems', () => { + const { nodes } = normalisePatternToInstance(testPatterns.catalogBasePattern); + expect(nodes.map((n) => n['unique-id'])).toEqual(['webapp', 'redis']); + }); }); describe('diffPatterns', () => { @@ -148,4 +153,25 @@ describe('diffPatterns', () => { const result = diffPatterns(testPatterns.basePattern, testPatterns.additionPattern); expect(result.undiffableItems).toBeUndefined(); }); + + it('detects a candidate added to an items catalog', () => { + const result = diffPatterns(testPatterns.catalogBasePattern, testPatterns.catalogAdditionPattern); + const addedIds = result.nodesAdded.map((n) => n['unique-id']); + expect(addedIds).toContain('valkey'); + expect(addedIds).not.toContain('redis'); + }); + + it('detects a candidate removed from an items catalog', () => { + const result = diffPatterns(testPatterns.catalogAdditionPattern, testPatterns.catalogBasePattern); + const removedIds = result.nodesRemoved.map((n) => n['unique-id']); + expect(removedIds).toContain('valkey'); + expect(removedIds).not.toContain('redis'); + }); + + it('treats an unchanged catalog candidate as same, not added', () => { + const result = diffPatterns(testPatterns.catalogBasePattern, testPatterns.catalogBasePattern); + const sameIds = result.nodesSame.map((n) => n['unique-id']); + expect(sameIds).toContain('redis'); + expect(result.nodesAdded).toHaveLength(0); + }); }); diff --git a/calm-models/src/diff/pattern-diff.ts b/calm-models/src/diff/pattern-diff.ts index 520701606..c5d5b26c1 100644 --- a/calm-models/src/diff/pattern-diff.ts +++ b/calm-models/src/diff/pattern-diff.ts @@ -1,6 +1,7 @@ import type { CalmNodeSchema, CalmRelationshipSchema } from '../types/index.js'; import type { NodesAndRelationshipsDiffResult } from './diff-types.js'; import { canonicalKey, diffNodesAndRelationships } from './diff.js'; +import { getPatternArray } from '../pattern/pattern-reader.js'; type SchemaObject = Record; @@ -40,28 +41,15 @@ function collapseSchema(schema: unknown): unknown { } /** - * Reads the `prefixItems` for a top-level pattern field (e.g. `nodes`, - * `relationships`), handling both direct `properties` and `allOf` wrapping. + * Reads the declared candidates for a top-level pattern field (e.g. `nodes`, + * `relationships`): its `prefixItems` slots plus, if present, its `items` open + * catalog, appended as one more block for {@link expandAlternatives} to unpack - + * a catalog is itself a `oneOf`/`anyOf` block, the same shape a decision slot is. + * Delegates to `getPatternArray` for the `properties`/`allOf` resolution. */ -function getPrefixItems(pattern: SchemaObject, key: string): SchemaObject[] { - const direct = isObject(pattern['properties']) - ? pattern['properties'][key] - : undefined; - if (isObject(direct) && Array.isArray(direct['prefixItems'])) { - return direct['prefixItems'] as SchemaObject[]; - } - - 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[]; - } - } - } - - return []; +function getCandidateItems(pattern: SchemaObject, key: 'nodes' | 'relationships'): SchemaObject[] { + const { prefixItems, catalog } = getPatternArray(pattern, key); + return catalog ? [...prefixItems, catalog] as SchemaObject[] : prefixItems as SchemaObject[]; } /** @@ -146,8 +134,8 @@ function partitionPattern(pattern: unknown): { nodes: PatternPartition; relation }; } return { - nodes: partitionPrefixItems(getPrefixItems(pattern, 'nodes')), - relationships: partitionPrefixItems(getPrefixItems(pattern, 'relationships')), + nodes: partitionPrefixItems(getCandidateItems(pattern, 'nodes')), + relationships: partitionPrefixItems(getCandidateItems(pattern, 'relationships')), }; } diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index f3643d9fc..ba3bcec5a 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -5,7 +5,8 @@ * A CALM pattern declares candidates in four places: a plain `prefixItems` entry, a * `prefixItems[i].oneOf`/`anyOf` alternative, or an `items.oneOf`/`items.anyOf` open * catalog member. This module is the single place that knows how to find them, so - * generation, validation and the visualiser stop hand-rolling the same traversal. + * generation, validation, the visualiser and the pattern differ stop hand-rolling the + * same traversal. * * Three different questions get three different functions, deliberately kept apart: * `readChoiceBlock` picks the single form a decision offers (`oneOf` wins over `anyOf`); From cca2e105635b420fe1c0a9d4ea34ab2cfc450a0b Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 07:57:28 +0000 Subject: [PATCH 29/41] test(calm-hub-ui): prove the Hub diff no longer strands new catalog nodes as unchanged applyDiffStatus defaults every node to 'unchanged' and only moves it on a unique-id match against DiffResult. Catalog candidates never appeared there, so a newly added one was drawn (this PR's own transformer draws catalog candidates) but coloured as unchanged - a false statement, not an incomplete one. No source change needed here: fixing pattern-diff.ts's own blind spot (previous commit) fixes this as a direct consequence. This test proves that with the real diffPatterns output, not a hand-built DiffResult - confirmed red by reverting the previous commit and rebuilding, green after restoring it. --- .../utils/patternDiffTransformer.test.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/calm-hub-ui/src/diff/components/utils/patternDiffTransformer.test.ts b/calm-hub-ui/src/diff/components/utils/patternDiffTransformer.test.ts index c511e4bb1..e8f9a846f 100644 --- a/calm-hub-ui/src/diff/components/utils/patternDiffTransformer.test.ts +++ b/calm-hub-ui/src/diff/components/utils/patternDiffTransformer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { DiffResult } from '@finos/calm-models/diff'; +import { DiffResult, diffPatterns } from '@finos/calm-models/diff'; import { parsePatternDataWithDiff } from './patternDiffTransformer.js'; const pattern = { @@ -58,4 +58,43 @@ describe('patternDiffTransformer', () => { expect(added?.style).toMatchObject({ boxShadow: '0 0 0 3px #16a34a' }); expect(result.nodes.find((n) => n.id === 'api-gateway')?.data.diffStatus).toBe('unchanged'); }); + + it('marks a newly added items-catalog candidate as added, using the real diffPatterns output', () => { + const patternBeforeCatalog = { + properties: { + nodes: { + type: 'array', + prefixItems: [ + { properties: { 'unique-id': { const: 'api-gateway' }, name: { const: 'API Gateway' }, 'node-type': { const: 'service' } } }, + ], + }, + relationships: { type: 'array', prefixItems: [] }, + }, + }; + const patternWithCatalog = { + properties: { + nodes: { + type: 'array', + prefixItems: [ + { properties: { 'unique-id': { const: 'api-gateway' }, name: { const: 'API Gateway' }, 'node-type': { const: 'service' } } }, + ], + items: { + anyOf: [ + { properties: { 'unique-id': { const: 'redis' }, name: { const: 'Redis' }, 'node-type': { const: 'database' } } }, + ], + }, + }, + relationships: { type: 'array', prefixItems: [] }, + }, + }; + + // Not a hand-built DiffResult - this is the real diffPatterns output, so the + // test proves the pattern-diff reader and the graph transformer agree on catalog + // candidates, not just that applyDiffStatus honours whatever it's given. + const diffResult = diffPatterns(patternBeforeCatalog, patternWithCatalog); + const result = parsePatternDataWithDiff(patternWithCatalog, diffResult, false); + const redis = result.nodes.find((n) => n.id === 'redis'); + expect(redis).toBeDefined(); + expect(redis?.data.diffStatus).toBe('added'); + }); }); From 3195b560d6f47f9cc0e98672dd3c2e1b958a15b2 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 07:57:34 +0000 Subject: [PATCH 30/41] fix(vscode): pattern preview now includes items-catalog candidates instantiateFromPattern only ever read properties.. prefixItems, so a catalog-only pattern previewed as empty. It already handled prefixItems[i].oneOf/anyOf decisions correctly - only the catalog was unhandled. An items catalog is structurally the same oneOf/anyOf block a decision slot's alternatives are, so the new catalogEntry helper appends it to the same list instantiateNode/instantiateRel already unwrap, rather than adding a second code path. No test file existed for this component; added one (3 tests, red confirmed before the fix). Full vscode suite 158/158 + 1 pre-existing todo, lint 0 errors. --- .../src/webview/panels/PatternPicker.test.ts | 53 +++++++++++++++++++ .../src/webview/panels/PatternPicker.tsx | 16 ++++-- 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 calm-plugins/vscode/src/webview/panels/PatternPicker.test.ts diff --git a/calm-plugins/vscode/src/webview/panels/PatternPicker.test.ts b/calm-plugins/vscode/src/webview/panels/PatternPicker.test.ts new file mode 100644 index 000000000..1cbc0f120 --- /dev/null +++ b/calm-plugins/vscode/src/webview/panels/PatternPicker.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { instantiateFromPattern } from './PatternPicker.js'; + +function node(uniqueId: string, name: string) { + return { + properties: { + 'unique-id': { const: uniqueId }, + name: { const: name }, + 'node-type': { const: 'service' }, + }, + }; +} + +describe('instantiateFromPattern', () => { + it('instantiates prefixItems nodes and relationships', () => { + const pattern = { + properties: { + nodes: { prefixItems: [node('api-gateway', 'API Gateway')] }, + relationships: { prefixItems: [] }, + }, + }; + const result = instantiateFromPattern(pattern); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0]['unique-id']).toBe('api-gateway'); + }); + + it('instantiates a candidate from an items catalog alongside prefixItems', () => { + const pattern = { + properties: { + nodes: { + prefixItems: [node('webapp', 'Web App')], + items: { anyOf: [node('redis', 'Redis')] }, + }, + relationships: { prefixItems: [] }, + }, + }; + const result = instantiateFromPattern(pattern); + const ids = result.nodes.map((n: Record) => n['unique-id']); + expect(ids).toContain('webapp'); + expect(ids).toContain('redis'); + }); + + it('previews a catalog-only pattern rather than leaving it empty', () => { + const pattern = { + properties: { + nodes: { items: { oneOf: [node('cache', 'Cache')] } }, + relationships: { prefixItems: [] }, + }, + }; + const result = instantiateFromPattern(pattern); + expect(result.nodes.map((n: Record) => n['unique-id'])).toEqual(['cache']); + }); +}); diff --git a/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx b/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx index 58f3f4bc9..ac3d46994 100644 --- a/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx +++ b/calm-plugins/vscode/src/webview/panels/PatternPicker.tsx @@ -61,10 +61,20 @@ export function PatternPicker({ visible, mode, patterns, onApply, onClose }: Pat ); } -function instantiateFromPattern(schema: unknown): any { +/** + * An `items` open catalog is structurally the same as a `prefixItems` slot's own + * `oneOf`/`anyOf` block - `instantiateNode`/`instantiateRel` already know how to unwrap + * one of those by taking its first alternative, so appending the catalog schema here + * reuses that path instead of needing a second one. + */ +function catalogEntry(items: unknown): any[] { + return items && typeof items === 'object' ? [items] : []; +} + +export function instantiateFromPattern(schema: unknown): any { const p = schema as any; - const nodeSchemas = p?.properties?.nodes?.prefixItems ?? []; - const relSchemas = p?.properties?.relationships?.prefixItems ?? []; + const nodeSchemas = [...(p?.properties?.nodes?.prefixItems ?? []), ...catalogEntry(p?.properties?.nodes?.items)]; + const relSchemas = [...(p?.properties?.relationships?.prefixItems ?? []), ...catalogEntry(p?.properties?.relationships?.items)]; const nodes = nodeSchemas.map((s: any) => instantiateNode(s)).filter(Boolean).map((n: any) => { if (!n['unique-id']) n['unique-id'] = '[[PLACEHOLDER]]'; From 465a5c8067aad5011ef151c57016c508e77bbfc4 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 07:57:40 +0000 Subject: [PATCH 31/41] docs(cli): correct the round-trip baseline's misattributed cause The comment said calm generate never fetches the schema requirement-url points at. That's not it: permitted-connection-jdbc.config.json declares control-id security-003, but the requirement schema pins security-002 - a one-value copy-paste slip (the sibling http config correctly uses security-002). The commit that added this baseline said so in its own message; only the inline comment was wrong, and it's the one a maintainer would actually follow. --- cli/src/cli.e2e.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/src/cli.e2e.spec.ts b/cli/src/cli.e2e.spec.ts index 3569b28c7..7ea2c1d30 100644 --- a/cli/src/cli.e2e.spec.ts +++ b/cli/src/cli.e2e.spec.ts @@ -683,9 +683,10 @@ describe('CLI Integration Tests', () => { // BASELINE, not a target to make pass silently. The generated architecture does not // round-trip: validating it against the same pattern fails on a control requirement's - // control-id, because `calm generate` never fetches the schema `requirement-url` points - // at to materialize the fields that schema demands. Reproduces identically on `main`; - // unrelated to this PR. Tracked separately - if this test starts failing because + // control-id. permitted-connection-jdbc.config.json declares "security-003", but the + // requirement schema it is checked against pins "security-002" - a one-value copy-paste + // slip; the sibling http config uses "security-002" correctly. Reproduces identically on + // `main`; unrelated to this PR. Tracked separately - if this test starts failing because // validation now passes, the bug is fixed and this block should be deleted. let validateError: { stdout?: string } | undefined; try { From 6e393df6270e486a3207b6a24fe662db9410a840 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 08:05:29 +0000 Subject: [PATCH 32/41] fix(shared): drop a decision holder with zero selections instead of an illegal empty prefixItems An anyOf decision left unanswered (a checkbox with nothing checked) made flattenOptionsRelationship write options.prefixItems: [] into the narrowed pattern. An empty prefixItems is not a legal JSON Schema - selectChoices's own docstring already forbids it elsewhere - so the next schema compilation broke. calm generate itself didn't notice (the empty array is fine as instance data), but calm validate compiles the same narrowed pattern as a schema, and that's where it surfaced: 'options/prefixItems must NOT have fewer than 1 items'. Not a regression: on main this same input crashed a different way (options[0] read off undefined). But 'pick none from this catalog' is the feature's own advertised case, so leaving it broken undercuts the headline. Fix: a decision resolved to nothing chosen has nothing to materialize, so flattenOptionsRelationship now drops the holder relationship entirely rather than narrowing it to empty. Reproduced live against a real repository pattern (multiple-choices options-prototype.pattern.json) before writing the fix - confirmed the exact reported error, confirmed generate succeeds silently while validate is the one that breaks, confirmed the fix removes exactly that error and nothing else (a separate, pre-existing schema-compile issue in that same prototype pattern is present before and after, unrelated). Pinned with a new selectChoices test. Full shared suite 1171/1171 (1 pre-existing unrelated flaky docify test excluded), full cli suite 648/648, build and lint clean on both. --- .../generate/components/options.spec.ts | 44 +++++++++++++++++++ .../commands/generate/components/options.ts | 17 ++++++- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/shared/src/commands/generate/components/options.spec.ts b/shared/src/commands/generate/components/options.spec.ts index ead44b7fb..db9340f42 100644 --- a/shared/src/commands/generate/components/options.spec.ts +++ b/shared/src/commands/generate/components/options.spec.ts @@ -461,6 +461,50 @@ describe('Pattern Options', () => { ); expect(selectChoices(pattern, [])).toEqual(expectedPattern); }); + + it('drops a decision holder entirely when nothing is selected for it, instead of an illegal empty options.prefixItems', () => { + const applicationA = buildNode('application-a'); + const applicationC = buildNode('application-c'); + const connectsRelationshipA = buildConnectsRelationship('application-a-to-c', 'app a to app c', 'application-a', 'application-c'); + + const pattern = buildPattern( + [applicationA, applicationC], + [ + buildPatternOptionRelationship( + 'cache-choice', + 'Pick a cache', + buildPatternOption('anyOf', buildPatternChoice(applicationAtoC)) + ), + buildPatternOptionRelationship( + 'queue-choice', + 'Pick a queue', + buildPatternOption('anyOf', buildPatternChoice(applicationBtoC)) + ), + connectsRelationshipA, + ] + ); + + // Answer cache-choice; leave queue-choice with zero selections - the checkbox + // (anyOf) case the feature advertises as a legitimate answer. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = selectChoices(pattern, [applicationAtoC]) as any; + + const relationshipIds = result.properties.relationships.prefixItems + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map((r: any) => r.properties['unique-id'].const); + expect(relationshipIds).not.toContain('queue-choice'); + expect(relationshipIds).toContain('cache-choice'); + + // An options.prefixItems of length 0 is not a legal JSON Schema (prefixItems + // must hold at least one entry) - compiling exactly that is what broke + // `calm validate` on this input before the fix. + for (const rel of result.properties.relationships.prefixItems) { + const options = rel.properties?.['relationship-type']?.properties?.options; + if (options) { + expect(options.prefixItems.length).toBeGreaterThan(0); + } + } + }); }); describe('allOf pattern support', () => { diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index 57afd30d6..8ffee0c79 100644 --- a/shared/src/commands/generate/components/options.ts +++ b/shared/src/commands/generate/components/options.ts @@ -150,7 +150,15 @@ function flattenCalmItems(pattern: SchemaNode, calmType: 'nodes' | 'relationship } } -function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoice[]): SchemaNode { +/** + * Returns `undefined` when nothing was selected for this decision, rather than the + * relationship with an empty `options.prefixItems`. An empty `prefixItems` is not a + * legal JSON Schema, so writing one there breaks the next schema compilation - both + * `calm generate` and `calm validate` compile the narrowed pattern via `selectChoices`. + * A decision resolved to "nothing chosen" has nothing to materialize, so the holder + * itself is dropped instead. + */ +function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoice[]): SchemaNode | undefined { if (!isOptionsRelationship(relationship)) { return relationship; } @@ -159,6 +167,10 @@ function flattenOptionsRelationship(relationship: SchemaNode, choices: CalmChoic const newItems = getItemsInOptionsRelationship(relationship) .flatMap((item: Item) => flattenOneOfAndAnyOf(item, selectionPredicate)); + if (newItems.length === 0) { + return undefined; + } + relationship['properties']['relationship-type']['properties']['options']['prefixItems'] = newItems; return relationship; } @@ -172,7 +184,8 @@ function flattenOptionsRelationships(pattern: SchemaNode, choices: CalmChoice[]) if (!relationships?.['prefixItems']) return; relationships['prefixItems'] = relationships['prefixItems'] - .map((rel: SchemaNode) => flattenOptionsRelationship(rel, choices)); + .map((rel: SchemaNode) => flattenOptionsRelationship(rel, choices)) + .filter((rel: SchemaNode | undefined): rel is SchemaNode => rel !== undefined); } /** From b375988fb734d785f349413395432c7c58f95ef0 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 09:28:25 +0000 Subject: [PATCH 33/41] docs(calm-hub-ui): fix stale comment on decision-group folding foldOptionsMetadataIntoDecisionGroups's own JSDoc said a referenced id already in a decision group gets that whole group folded into it. The code does the opposite: every decision always gets its own new group, and referenced ids are moved out of whatever group they were in, into that new one. The code is right (this is the fix behind two decisions over one catalog drawing two boxes); the comment was describing an earlier version. --- .../reactflow/utils/patternTransformer.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index 2ea555bc2..4c3f325d7 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -692,13 +692,14 @@ function applyPatternLayout(regularNodes: Node[], groupNodes: Node[], edges: Edg * Folds each options-relationship decision's referenced node ids into a single * decision group, mutating `decisionGroups`/`extractedNodes` in place: * - * - If any referenced id already belongs to an existing decision group (built - * during node extraction, e.g. a prefixItems oneOf slot or the items - * catalog), ALL of the decision's referenced ids are folded into that one - * group (moving them out of any other group they were previously in). - * - If none of the referenced ids belong to an existing group, a brand new - * group is created (id derived from the options relationship's own - * unique-id) containing exactly the referenced ids. + * - Every decision gets its own new group (id derived from the options + * relationship's own unique-id). Each referenced id is moved into that new + * group, out of whatever group it previously belonged to (built during node + * extraction, e.g. a prefixItems oneOf slot or the items catalog, or an + * earlier decision processed in this same pass). + * - A candidate can be drawn in one box only: once an id has been claimed by a + * decision, a later decision naming the same id does not draw it again, so + * that later decision's group contains only the ids that are still free. * - Ids that don't resolve to a real extracted node (dangling/typo'd * references) are dropped; if a decision ends up with no valid ids at all, * it is skipped entirely so no empty box is rendered. From 1c8c59e04d5c50b479788f80dfe460002ca3c6c0 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 09:28:41 +0000 Subject: [PATCH 34/41] fix(shared): log discarded allOf-merge keys at debug, not warn logger.warn on every discarded-key detection meant new machinery in the merge path could interrupt normal output for a construct the pattern's own documentation already declares unsupported (allOf for nodes/relationships), including a false 'discarded' report on legitimate refinement - a support question with no action attached. Kept the heuristic; only the log level changes. Updated the five discarded-key tests to assert mockDebug instead of mockWarn, and the two negative assertions to check the specific discard message rather than 'not called at all', since flattenAllOf already logs unrelated debug traces that a bare not.toHaveBeenCalled() would collide with. flatten-allof.spec.ts 14/14, full shared suite 1171/1171 (1 pre-existing unrelated flaky docify test excluded), build and lint clean. --- .../generate/components/flatten-allof.spec.ts | 20 +++++++++---------- .../generate/components/flatten-allof.ts | 9 +++++++-- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/shared/src/commands/generate/components/flatten-allof.spec.ts b/shared/src/commands/generate/components/flatten-allof.spec.ts index e247e5d5e..75521ea64 100644 --- a/shared/src/commands/generate/components/flatten-allof.spec.ts +++ b/shared/src/commands/generate/components/flatten-allof.spec.ts @@ -249,8 +249,8 @@ describe('flattenAllOf', () => { }); }); - describe('discarded-key warnings across allOf', () => { - it('warns when two allOf branches each declare a nodes items catalog', async () => { + describe('discarded-key debug logging across allOf', () => { + it('logs at debug when two allOf branches each declare a nodes items catalog', async () => { // Both branches declare `properties.nodes.items`; the shallow properties // merge makes the later branch's catalog win and silently drops the first. const schema = { @@ -262,12 +262,12 @@ describe('flattenAllOf', () => { await flattenAllOf(schema, mockSchemaDir, true); - expect(mockWarn).toHaveBeenCalledWith( + expect(mockDebug).toHaveBeenCalledWith( expect.stringContaining('allOf merge on property \'nodes\' discarded keys [items] declared in an earlier branch') ); }); - it('does not warn when only one allOf branch declares a catalog', async () => { + it('does not log when only one allOf branch declares a catalog', async () => { const schema = { allOf: [ { properties: { nodes: { items: { oneOf: [{ const: 'a' }] } } } }, @@ -277,7 +277,7 @@ describe('flattenAllOf', () => { await flattenAllOf(schema, mockSchemaDir, true); - expect(mockWarn).not.toHaveBeenCalled(); + expect(mockDebug).not.toHaveBeenCalledWith(expect.stringContaining('discarded keys')); }); it('names prefixItems as discarded, not the catalog, when the catalog is in the later branch', async () => { @@ -306,12 +306,12 @@ describe('flattenAllOf', () => { await flattenAllOf(schema, mockSchemaDir, true); - expect(mockWarn).toHaveBeenCalledWith( + expect(mockDebug).toHaveBeenCalledWith( expect.stringContaining('allOf merge on property \'nodes\' discarded keys [prefixItems] declared in an earlier branch') ); }); - it('warns and names [type, prefixItems] on a prefixItems + minItems collision with no catalog anywhere', async () => { + it('logs at debug and names [type, prefixItems] on a prefixItems + minItems collision with no catalog anywhere', async () => { const schema = { allOf: [ { @@ -328,12 +328,12 @@ describe('flattenAllOf', () => { await flattenAllOf(schema, mockSchemaDir, true); - expect(mockWarn).toHaveBeenCalledWith( + expect(mockDebug).toHaveBeenCalledWith( expect.stringContaining('allOf merge on property \'nodes\' discarded keys [type, prefixItems] declared in an earlier branch') ); }); - it('does not warn on a $ref refinement where the resolved def and siblings both declare the same property', async () => { + it('does not log on a $ref refinement where the resolved def and siblings both declare the same property', async () => { // allOf: [{ $ref: ..., properties: {...} }] is the idiomatic composition form: // refining a $ref'd definition with local sibling keys is ordinary JSON Schema, // not a lossy allOf-branch collision, even though it discards the same keys. @@ -361,7 +361,7 @@ describe('flattenAllOf', () => { await flattenAllOf(schema, mockSchemaDir, true); - expect(mockWarn).not.toHaveBeenCalled(); + expect(mockDebug).not.toHaveBeenCalledWith(expect.stringContaining('discarded keys')); }); }); }); diff --git a/shared/src/commands/generate/components/flatten-allof.ts b/shared/src/commands/generate/components/flatten-allof.ts index 4af6da3cc..332ccfabb 100644 --- a/shared/src/commands/generate/components/flatten-allof.ts +++ b/shared/src/commands/generate/components/flatten-allof.ts @@ -128,13 +128,18 @@ function warnOnDiscardedKeys( ? `allOf merge on property '${propKey}' discarded keys [${discardedKeys.join(', ')}] declared in an earlier branch. This is a limitation of allOf merging, not an error in the pattern.` : `allOf merge on property '${propKey}' discarded keys [${discardedKeys.join(', ')}] declared in an allOf branch and overridden by the schema's own properties.`; - logger.warn(message); + // Debug, not warn: this is new machinery over a construct the pattern's own + // documentation already declares unsupported (`allOf` for nodes/relationships), and a + // false "discarded" report on legitimate refinement is a support question with no + // action attached. Worth surfacing for someone diagnosing the merge, not worth + // interrupting everyone else's normal output. + logger.debug(message); } /** * Deep merges two schema objects, combining properties, required arrays, and prefixItems. * - * @param warnOnDiscard - When set, warns (via `logger.warn`) about property keys that the + * @param warnOnDiscard - When set, logs (via `logger.debug`) about property keys that the * `properties` branch's shallow spread discards. `'allOf-branch'` and `'root-override'` * select the message's direction; `false` (the default) stays silent, which positional * `prefixItems` merges and `$ref`-refinement merges rely on. From c7ca4e84ff8fab5b059d2362079e109e9f099d8e Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 09:29:34 +0000 Subject: [PATCH 35/41] refactor(calm-models): rename pattern readers toward the question they answer readChoiceBlock and listCandidates named the mechanism (read a block, list candidates), not the question a caller is actually asking - so the wrong one was easy to reach for silently. readChoiceBlock -> resolveOperativeChoiceBlock: this is the function that resolves a block to the one form selection would act on (oneOf wins over anyOf), matching the file's own internal 'operative' vocabulary (BlockResolution). listCandidates -> listDeclaredCandidates: pairs with the unchanged listSelectableCandidates - same shape, opposite resolution, so the two names now signal they're answering the same question two ways. Pure rename across all 13 call sites (calm-models, shared's spectral rules and options.ts, calm-hub-ui's patternTransformer.ts) plus shared/AGENTS.md's own references. No behaviour change: build clean, full calm-models/shared/calm-hub-ui/cli/vscode suites green, lint 0 errors across all five. --- .../reactflow/utils/patternTransformer.ts | 12 ++--- calm-models/src/pattern/index.ts | 4 +- .../src/pattern/pattern-reader.spec.ts | 46 +++++++++---------- calm-models/src/pattern/pattern-reader.ts | 24 +++++----- shared/AGENTS.md | 14 +++--- .../commands/generate/components/options.ts | 6 +-- .../functions/pattern/candidate-helpers.ts | 2 +- ...ecision-references-selectable-candidate.ts | 4 +- .../functions/pattern/ids-are-unique.spec.ts | 2 +- .../functions/pattern/ids-are-unique.ts | 6 +-- .../interface-id-exists-on-node.spec.ts | 2 +- .../pattern/interface-id-exists-on-node.ts | 4 +- .../functions/pattern/node-id-exists.ts | 4 +- shared/src/spectral/rules-pattern.spec.ts | 2 +- 14 files changed, 66 insertions(+), 66 deletions(-) diff --git a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts index 4c3f325d7..bc984d8ca 100644 --- a/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts +++ b/calm-hub-ui/src/visualizer/components/reactflow/utils/patternTransformer.ts @@ -9,7 +9,7 @@ import { import { createEdge } from './edgeFactory'; import { GRAPH_LAYOUT } from './constants'; import { THEME } from '../theme'; -import { getPatternArray, readChoiceBlock } from '@finos/calm-models/pattern'; +import { getPatternArray, resolveOperativeChoiceBlock } from '@finos/calm-models/pattern'; /** * Result of parsing pattern data into ReactFlow elements @@ -201,7 +201,7 @@ function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[ const decisionGroups: DecisionGroup[] = []; prefixItems.forEach((item: SchemaObject, index: number) => { - const block = readChoiceBlock(item); + const block = resolveOperativeChoiceBlock(item); if (block) { extractNodeDecisionGroup(block.alternatives as SchemaObject[], `node-decision-${index}`, block.groupType, nodes, decisionGroups); } else { @@ -216,7 +216,7 @@ function extractNodesFromPattern(pattern: SchemaObject): { nodes: ExtractedNode[ // that aren't tied to a specific positional slot. Treat the whole catalog as // a single decision-group slot. if (items) { - const catalog = readChoiceBlock(items); + const catalog = resolveOperativeChoiceBlock(items); if (catalog) { extractNodeDecisionGroup(catalog.alternatives as SchemaObject[], 'node-decision-items', catalog.groupType, nodes, decisionGroups); } @@ -314,7 +314,7 @@ function extractOptionsMetadata(item: SchemaObject): OptionsMetadata | null { item['properties']?.['relationship-type']?.['properties']?.['options']?.['prefixItems'] || []; for (const prefixItem of optionsPrefixItems) { - const block = readChoiceBlock(prefixItem); + const block = resolveOperativeChoiceBlock(prefixItem); if (block) { const optionType = block.groupType; @@ -380,7 +380,7 @@ function extractRelationshipsFromPattern(pattern: SchemaObject): { } // Check for oneOf/anyOf wrapped relationships - const block = readChoiceBlock(item); + const block = resolveOperativeChoiceBlock(item); if (block) { extractRelationshipDecisionGroup(block.alternatives as SchemaObject[], `rel-decision-${index}`, relationships); return; @@ -396,7 +396,7 @@ function extractRelationshipsFromPattern(pattern: SchemaObject): { // Open catalog: `items.oneOf`/`items.anyOf` declares zero-or-more relationship // candidates not tied to a specific positional slot. if (items) { - const catalog = readChoiceBlock(items); + const catalog = resolveOperativeChoiceBlock(items); if (catalog) { extractRelationshipDecisionGroup(catalog.alternatives as SchemaObject[], 'rel-decision-items', relationships); } diff --git a/calm-models/src/pattern/index.ts b/calm-models/src/pattern/index.ts index e56e58870..7bdee7e7d 100644 --- a/calm-models/src/pattern/index.ts +++ b/calm-models/src/pattern/index.ts @@ -1,7 +1,7 @@ export { getPatternArray, - readChoiceBlock, - listCandidates, + resolveOperativeChoiceBlock, + listDeclaredCandidates, listSelectableCandidates, type SchemaNode, type PatternArray, diff --git a/calm-models/src/pattern/pattern-reader.spec.ts b/calm-models/src/pattern/pattern-reader.spec.ts index 8dc49d0cd..022b596b8 100644 --- a/calm-models/src/pattern/pattern-reader.spec.ts +++ b/calm-models/src/pattern/pattern-reader.spec.ts @@ -1,8 +1,8 @@ import { describe, it, expect } from 'vitest'; import { getPatternArray, - readChoiceBlock, - listCandidates, + resolveOperativeChoiceBlock, + listDeclaredCandidates, listSelectableCandidates, type SchemaNode, } from './pattern-reader.js'; @@ -130,45 +130,45 @@ describe('getPatternArray', () => { }); }); -describe('readChoiceBlock', () => { +describe('resolveOperativeChoiceBlock', () => { it('returns null for an undefined catalog', () => { - expect(readChoiceBlock(undefined)).toBeNull(); + expect(resolveOperativeChoiceBlock(undefined)).toBeNull(); }); it('returns null when neither oneOf nor anyOf is an array', () => { - expect(readChoiceBlock({})).toBeNull(); - expect(readChoiceBlock({ oneOf: 'not-an-array' })).toBeNull(); + expect(resolveOperativeChoiceBlock({})).toBeNull(); + expect(resolveOperativeChoiceBlock({ oneOf: 'not-an-array' })).toBeNull(); }); it('reads a oneOf-only catalog', () => { const alternatives = [nodeWithId('a'), nodeWithId('b')]; - expect(readChoiceBlock({ oneOf: alternatives })).toEqual({ groupType: 'oneOf', alternatives }); + expect(resolveOperativeChoiceBlock({ oneOf: alternatives })).toEqual({ groupType: 'oneOf', alternatives }); }); it('reads an anyOf-only catalog', () => { const alternatives = [nodeWithId('a')]; - expect(readChoiceBlock({ anyOf: alternatives })).toEqual({ groupType: 'anyOf', alternatives }); + expect(resolveOperativeChoiceBlock({ anyOf: alternatives })).toEqual({ groupType: 'anyOf', alternatives }); }); it('prefers oneOf over anyOf when both are present', () => { const oneOfAlts = [nodeWithId('one')]; const anyOfAlts = [nodeWithId('any')]; - expect(readChoiceBlock({ oneOf: oneOfAlts, anyOf: anyOfAlts })).toEqual({ + expect(resolveOperativeChoiceBlock({ oneOf: oneOfAlts, anyOf: anyOfAlts })).toEqual({ groupType: 'oneOf', alternatives: oneOfAlts, }); }); }); -describe('listCandidates', () => { +describe('listDeclaredCandidates', () => { it('lists a plain prefixItems entry', () => { const pattern = { properties: { nodes: { prefixItems: [nodeWithId('solo')] } } }; - expect(listCandidates(pattern, 'nodes')).toEqual([ + expect(listDeclaredCandidates(pattern, 'nodes')).toEqual([ { uniqueId: 'solo', site: 'prefixItem', node: nodeWithId('solo'), path: ['properties', 'nodes', 'prefixItems', 0] }, ]); }); - it('unions oneOf and anyOf on the same slot, unlike readChoiceBlock', () => { + it('unions oneOf and anyOf on the same slot, unlike resolveOperativeChoiceBlock', () => { const pattern = { properties: { nodes: { @@ -177,7 +177,7 @@ describe('listCandidates', () => { }, }; - const candidates = listCandidates(pattern, 'nodes'); + const candidates = listDeclaredCandidates(pattern, 'nodes'); expect(candidates.map((c) => c.uniqueId)).toEqual(['a', 'b']); expect(candidates.map((c) => c.blockType)).toEqual(['oneOf', 'anyOf']); expect(candidates.every((c) => c.site === 'prefixItemAlternative' && c.slotIndex === 0)).toBe(true); @@ -192,7 +192,7 @@ describe('listCandidates', () => { }, }; - const candidates = listCandidates(pattern, 'nodes'); + const candidates = listDeclaredCandidates(pattern, 'nodes'); expect(candidates.map((c) => ({ uniqueId: c.uniqueId, site: c.site }))).toEqual([ { uniqueId: 'hybrid', site: 'prefixItem' }, { uniqueId: 'alt', site: 'prefixItemAlternative' }, @@ -208,7 +208,7 @@ describe('listCandidates', () => { }, }; - const candidates = listCandidates(pattern, 'nodes'); + const candidates = listDeclaredCandidates(pattern, 'nodes'); expect(candidates).toEqual([ { uniqueId: 'cat-one', site: 'catalogMember', node: nodeWithId('cat-one'), path: ['properties', 'nodes', 'items', 'oneOf', 0], blockType: 'oneOf' }, { uniqueId: 'cat-any', site: 'catalogMember', node: nodeWithId('cat-any'), path: ['properties', 'nodes', 'items', 'anyOf', 0], blockType: 'anyOf' }, @@ -222,7 +222,7 @@ describe('listCandidates', () => { }, }; - const candidates = listCandidates(pattern, 'nodes'); + const candidates = listDeclaredCandidates(pattern, 'nodes'); expect(candidates.map((c) => c.uniqueId)).toEqual(['a', 'b']); expect(candidates.some((c) => c.uniqueId === undefined)).toBe(false); }); @@ -231,30 +231,30 @@ describe('listCandidates', () => { const pattern = { properties: { nodes: { items: { oneOf: [{ properties: {} }] } } }, }; - expect(listCandidates(pattern, 'nodes')).toEqual([]); + expect(listDeclaredCandidates(pattern, 'nodes')).toEqual([]); }); it('returns an empty array when the calmType is absent', () => { - expect(listCandidates({ properties: {} }, 'nodes')).toEqual([]); + expect(listDeclaredCandidates({ properties: {} }, 'nodes')).toEqual([]); }); it('does not fall back into an allOf branch, unlike getPatternArray', () => { const pattern = { allOf: [{ properties: { nodes: { prefixItems: [nodeWithId('in-a-branch')] } } }], }; - expect(listCandidates(pattern, 'nodes')).toEqual([]); + expect(listDeclaredCandidates(pattern, 'nodes')).toEqual([]); }); }); describe('listSelectableCandidates', () => { - it('lists a plain prefixItems entry, same as listCandidates', () => { + it('lists a plain prefixItems entry, same as listDeclaredCandidates', () => { const pattern = { properties: { nodes: { prefixItems: [nodeWithId('solo')] } } }; expect(listSelectableCandidates(pattern, 'nodes')).toEqual([ { uniqueId: 'solo', site: 'prefixItem', node: nodeWithId('solo'), path: ['properties', 'nodes', 'prefixItems', 0] }, ]); }); - it('resolves only the winning keyword of a dual-keyword block, unlike listCandidates', () => { + it('resolves only the winning keyword of a dual-keyword block, unlike listDeclaredCandidates', () => { const pattern = { properties: { nodes: { @@ -263,7 +263,7 @@ describe('listSelectableCandidates', () => { }, }; - const declared = listCandidates(pattern, 'nodes').map((c) => c.uniqueId); + const declared = listDeclaredCandidates(pattern, 'nodes').map((c) => c.uniqueId); const selectable = listSelectableCandidates(pattern, 'nodes').map((c) => c.uniqueId); expect(declared).toEqual(['a', 'b']); expect(selectable).toEqual(['a']); @@ -297,7 +297,7 @@ describe('listSelectableCandidates', () => { expect(listSelectableCandidates(pattern, 'nodes').map((c) => c.uniqueId)).toEqual(['cat-one']); }); - it('lists every alternative when only one keyword is declared, same as listCandidates', () => { + it('lists every alternative when only one keyword is declared, same as listDeclaredCandidates', () => { const pattern = { properties: { nodes: { prefixItems: [{ anyOf: [nodeWithId('a'), nodeWithId('b')] }] }, diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index ba3bcec5a..48e21a700 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -9,9 +9,9 @@ * same traversal. * * Three different questions get three different functions, deliberately kept apart: - * `readChoiceBlock` picks the single form a decision offers (`oneOf` wins over `anyOf`); - * `listCandidates` unions both, because validation needs every id a pattern declares; - * `listSelectableCandidates` defers to `readChoiceBlock`'s single form, because + * `resolveOperativeChoiceBlock` picks the single form a decision offers (`oneOf` wins over `anyOf`); + * `listDeclaredCandidates` unions both, because validation needs every id a pattern declares; + * `listSelectableCandidates` defers to `resolveOperativeChoiceBlock`'s single form, because * generation needs only what an answer can actually reach. * * No selection, no mutation, no rendering — only reading. The surface is deliberately @@ -90,9 +90,9 @@ export interface ChoiceBlock { * catalog. `oneOf` wins when both are present. Returns `null` when neither keyword is * an array. * - * This picks one answer. A caller that needs every declared id must use `listCandidates`. + * This picks one answer. A caller that needs every declared id must use `listDeclaredCandidates`. */ -export function readChoiceBlock(items: SchemaNode | undefined): ChoiceBlock | null { +export function resolveOperativeChoiceBlock(items: SchemaNode | undefined): ChoiceBlock | null { if (!items) return null; const hasOneOf = Array.isArray(items['oneOf']); @@ -132,12 +132,12 @@ type BlockResolution = /** * The keywords a choice block contributes candidates from, for a given resolution. - * `'all'` unions both; `'operative'` defers to `readChoiceBlock`'s oneOf-wins rule so + * `'all'` unions both; `'operative'` defers to `resolveOperativeChoiceBlock`'s oneOf-wins rule so * only the resolvable keyword is walked. */ function blockKeywords(container: SchemaNode, resolution: BlockResolution): ReadonlyArray<'oneOf' | 'anyOf'> { if (resolution === 'all') return ['oneOf', 'anyOf']; - const block = readChoiceBlock(container); + const block = resolveOperativeChoiceBlock(container); return block ? [block.groupType] : []; } @@ -167,7 +167,7 @@ function walkCandidates( if (!isObject(item)) return; // A hybrid slot carries its own id and alternatives. Both are emitted regardless - // of resolution - `readChoiceBlock` only decides which *alternatives* keyword wins. + // of resolution - `resolveOperativeChoiceBlock` only decides which *alternatives* keyword wins. const uniqueId = readUniqueId(item); if (uniqueId) { candidates.push({ @@ -222,18 +222,18 @@ function walkCandidates( /** * Every node/relationship candidate a pattern declares. Unions `oneOf` and `anyOf`, - * which is the opposite of `readChoiceBlock`. Validation needs every declared id. Do - * not route this through `readChoiceBlock` - that drops every `anyOf` candidate when + * which is the opposite of `resolveOperativeChoiceBlock`. Validation needs every declared id. Do + * not route this through `resolveOperativeChoiceBlock` - that drops every `anyOf` candidate when * `oneOf` is also present. */ -export function listCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { +export function listDeclaredCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { return walkCandidates(pattern, calmType, 'all'); } /** * Only the candidates selection can reach, resolved as `selectChoices` resolves them: * a dual-keyword block's `anyOf` alternatives are declared but not selectable. - * `listCandidates` is a silent bug here, because it reports the losing keyword's + * `listDeclaredCandidates` is a silent bug here, because it reports the losing keyword's * alternatives as available. Use it for "can this answer be honoured". */ export function listSelectableCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { diff --git a/shared/AGENTS.md b/shared/AGENTS.md index cafb13f9b..3be29835d 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -72,8 +72,8 @@ Use the wrong one and the failure is silent, so they are separate named function | Question | Function | Home | |---|---|---| -| What does *this block* offer? (`oneOf` wins) | `readChoiceBlock` | `@finos/calm-models/pattern` | -| What does the pattern *declare*? (both keywords) | `listCandidates` | `@finos/calm-models/pattern` | +| What does *this block* offer? (`oneOf` wins) | `resolveOperativeChoiceBlock` | `@finos/calm-models/pattern` | +| What does the pattern *declare*? (both keywords) | `listDeclaredCandidates` | `@finos/calm-models/pattern` | | What can selection *reach*? (one keyword) | `listSelectableCandidates` | `@finos/calm-models/pattern` | Use *declared* for what a document says: uniqueness, dangling references. Use *selectable* @@ -89,13 +89,13 @@ Treat `allOf` for nodes and relationships as unsupported. |---|---| | `deepMergeSchemas` (`flatten-allof.ts`) | shallow merge; a repeated property loses `type`, so `instantiate` emits `{}` | | `getPatternArray` | resolves one branch per property (root, else the first branch declaring it) and reads `prefixItems`/`items` from that same branch; later branches ignored; marked TEMPORARY | -| `listCandidates` / `listSelectableCandidates` | ignore `allOf` entirely, to keep `path` correct for diagnostics | +| `listDeclaredCandidates` / `listSelectableCandidates` | ignore `allOf` entirely, to keep `path` correct for diagnostics | A pattern whose `prefixItems`/`items` sits under `allOf` yields no candidates at all from -`listCandidates`/`listSelectableCandidates` (they ignore `allOf` entirely), even though +`listDeclaredCandidates`/`listSelectableCandidates` (they ignore `allOf` entirely), even though `getPatternArray` and `deepMergeSchemas` both still reach into it. `allOf` means **intersection**, never union, because `calm validate` never flattens. `shared` previously kept -its own copy of `listCandidates` that followed `allOf` through `getPatternArray`, disagreeing +its own copy of `listDeclaredCandidates` that followed `allOf` through `getPatternArray`, disagreeing with the `calm-models` copy and reporting a `path` the document did not contain; nothing tested or relied on that behaviour, so the copy was removed rather than reconciled — it did not add `allOf` support, only removed a second, wrong answer. @@ -120,8 +120,8 @@ regardless of which array it sits in. ### Still duplicated -Nothing keeps this pair in step. `listCandidates`'s own duplicate (`shared/src/pattern-candidates.ts`) -is gone — both `listCandidates` and `listSelectableCandidates` now live only in `@finos/calm-models/pattern`. +Nothing keeps this pair in step. `listDeclaredCandidates`'s own duplicate (`shared/src/pattern-candidates.ts`) +is gone — both `listDeclaredCandidates` and `listSelectableCandidates` now live only in `@finos/calm-models/pattern`. | Duplicate | Sites | Note | |---|---|---| diff --git a/shared/src/commands/generate/components/options.ts b/shared/src/commands/generate/components/options.ts index 8ffee0c79..3911e0e1b 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 { getPatternArray, readChoiceBlock, listSelectableCandidates } from '@finos/calm-models/pattern'; +import { getPatternArray, resolveOperativeChoiceBlock, listSelectableCandidates } from '@finos/calm-models/pattern'; /** * A node within a CALM pattern's JSON schema. The pattern is unvalidated JSON @@ -94,7 +94,7 @@ type Item = { * @returns A list of items that match the selection predicate, or the item itself if it is not a oneOf or anyOf block */ function flattenOneOfAndAnyOf(item: Item, selectionPredicate: (item: SchemaNode) => boolean): object[] { - const block = readChoiceBlock(item); + const block = resolveOperativeChoiceBlock(item); if (!block) { if (item.oneOf || item.anyOf) { @@ -137,7 +137,7 @@ function flattenCalmItems(pattern: SchemaNode, calmType: 'nodes' | 'relationship // Only treat `items` as a decision catalog when it is a oneOf/anyOf of candidates. A plain // `items` schema (or `items: false` closing a tuple) is not part of the decision mechanism and // must be left untouched rather than stripped. - const catalogBlock = readChoiceBlock(itemsCatalog); + const catalogBlock = resolveOperativeChoiceBlock(itemsCatalog); const isCatalog = catalogBlock !== null; const selectedCatalogItems: SchemaNode[] = isCatalog ? (catalogBlock!.alternatives as SchemaNode[]).filter(selectionPredicate) diff --git a/shared/src/spectral/functions/pattern/candidate-helpers.ts b/shared/src/spectral/functions/pattern/candidate-helpers.ts index cb5f2f6a7..27262f4eb 100644 --- a/shared/src/spectral/functions/pattern/candidate-helpers.ts +++ b/shared/src/spectral/functions/pattern/candidate-helpers.ts @@ -20,7 +20,7 @@ export interface NodeInterface { /** * Reads a node candidate's own `interfaces.prefixItems`, in declaration order, skipping - * any entry with no `const`-pinned `unique-id` — the same rule `listCandidates` applies + * any entry with no `const`-pinned `unique-id` — the same rule `listDeclaredCandidates` applies * to node/relationship candidates themselves. */ export function listNodeInterfaces(node: SchemaNode): NodeInterface[] { diff --git a/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts b/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts index 9f770ef0c..5acf91040 100644 --- a/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts +++ b/shared/src/spectral/functions/pattern/decision-references-selectable-candidate.ts @@ -1,5 +1,5 @@ import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { listCandidates, listSelectableCandidates, type SchemaNode } from '@finos/calm-models/pattern'; +import { listDeclaredCandidates, listSelectableCandidates, type SchemaNode } from '@finos/calm-models/pattern'; /** * Reports a candidate that a decision names, but that selection cannot reach. @@ -25,7 +25,7 @@ export function decisionReferencesSelectableCandidate( } // Undeclared ids belong to the other rule. - const declared = listCandidates(pattern, calmType).some((c) => c.uniqueId === input); + const declared = listDeclaredCandidates(pattern, calmType).some((c) => c.uniqueId === input); if (!declared) { return []; } diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts index 69c27816a..b23a9efac 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.spec.ts @@ -265,7 +265,7 @@ describe('idsAreUnique', () => { it('detects a duplicate id declared across prefixItems oneOf alternatives (previously undetected)', () => { // Positional slot alternatives were invisible to this rule until the migration to - // listCandidates: the identical clash inside an items catalog already errored, but + // listDeclaredCandidates: the identical clash inside an items catalog already errored, but // two prefixItems[*].oneOf[*] alternatives sharing an id did not. const input = {}; const context = { diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.ts b/shared/src/spectral/functions/pattern/ids-are-unique.ts index 73a40c5dc..12e07acd4 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.ts @@ -1,5 +1,5 @@ import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { listCandidates, type Candidate, type SchemaNode } from '@finos/calm-models/pattern'; +import { listDeclaredCandidates, type Candidate, type SchemaNode } from '@finos/calm-models/pattern'; import { listNodeInterfaces } from './candidate-helpers'; // Spectral's IFunctionResult.path is an array of path segments, not a pointer string. @@ -42,8 +42,8 @@ export default (input: unknown, _: unknown, context: RulesetFunctionContext): IF } const pattern = context.document.data as SchemaNode; - const nodeCandidates = listCandidates(pattern, 'nodes'); - const relationshipCandidates = listCandidates(pattern, 'relationships'); + const nodeCandidates = listDeclaredCandidates(pattern, 'nodes'); + const relationshipCandidates = listDeclaredCandidates(pattern, 'relationships'); const interfaceEntries: DuplicateCheckEntry[] = nodeCandidates.flatMap((nodeCandidate) => listNodeInterfaces(nodeCandidate.node).map((iface) => ({ diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts index ac26930fb..4d88928ca 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.spec.ts @@ -226,7 +226,7 @@ describe('interfaceIdExistsOnNode', () => { describe('a prefixItems oneOf slot with two node alternatives', () => { // { oneOf: [A(interfaces:[iA]), B(interfaces:[iB])] }. Before the migration to - // listCandidates, the inner unwrapping always resolved to alternative 0 and unioned + // listDeclaredCandidates, the inner unwrapping always resolved to alternative 0 and unioned // interfaces across every alternative in the slot - so only the first two rows here // passed; B was never findable, and interfaces borrowed across alternatives passed // silently. All four rows must now behave correctly. diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts index 18ce52d08..384cb91da 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts @@ -1,6 +1,6 @@ import { difference } from 'lodash'; import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { listCandidates, type SchemaNode } from '@finos/calm-models/pattern'; +import { listDeclaredCandidates, type SchemaNode } from '@finos/calm-models/pattern'; import { listNodeInterfaces } from './candidate-helpers'; interface ConnectsRelationship { @@ -28,7 +28,7 @@ export function interfaceIdExistsOnNode(input: ConnectsRelationship | null | und // Each candidate carries its own unique-id, so this finds the exact alternative that // matches - not, as the old JSONPath-based lookup did, just the first alternative in // a oneOf/anyOf slot regardless of which one actually has this id. - const nodeCandidate = listCandidates(pattern, 'nodes').find((candidate) => candidate.uniqueId === nodeId); + const nodeCandidate = listDeclaredCandidates(pattern, 'nodes').find((candidate) => candidate.uniqueId === nodeId); if (!nodeCandidate) { // other rule will report undefined node return []; diff --git a/shared/src/spectral/functions/pattern/node-id-exists.ts b/shared/src/spectral/functions/pattern/node-id-exists.ts index 8c063cd6c..cf62ee80f 100644 --- a/shared/src/spectral/functions/pattern/node-id-exists.ts +++ b/shared/src/spectral/functions/pattern/node-id-exists.ts @@ -1,5 +1,5 @@ import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { listCandidates, type SchemaNode } from '@finos/calm-models/pattern'; +import { listDeclaredCandidates, type SchemaNode } from '@finos/calm-models/pattern'; /** * Checks that the input value exists as a node with a matching unique ID. @@ -10,7 +10,7 @@ export default (input: unknown, _: unknown, context: RulesetFunctionContext): IF } const pattern = context.document.data as SchemaNode; - const nodeIds = listCandidates(pattern, 'nodes').map((candidate) => candidate.uniqueId); + const nodeIds = listDeclaredCandidates(pattern, 'nodes').map((candidate) => candidate.uniqueId); const results: IFunctionResult[] = []; diff --git a/shared/src/spectral/rules-pattern.spec.ts b/shared/src/spectral/rules-pattern.spec.ts index 55c854ac1..b6e1a5a93 100644 --- a/shared/src/spectral/rules-pattern.spec.ts +++ b/shared/src/spectral/rules-pattern.spec.ts @@ -199,7 +199,7 @@ describe('pattern-items-catalog-must-declare-one-choice-keyword', () => { it('fires on a prefixItems slot declaring both oneOf and anyOf, not just an items catalog', async () => { // The bug this rule catches - oneOf silently winning over anyOf - resolves // identically for a prefixItems slot and an items catalog (both go through - // readChoiceBlock), and reproduces on main for prefixItems slots. The `given` + // resolveOperativeChoiceBlock), and reproduces on main for prefixItems slots. The `given` // already covers this site; this pins that it is actually exercised. const codes = await ruleCodesFor({ properties: { From 3085cdae5f8f3af8d030e91c15029f438341e03b Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Tue, 25 Aug 2026 10:01:37 +0000 Subject: [PATCH 36/41] docs(shared): add PATTERN-DECISIONS.md, trim AGENTS.md to a pointer The Pattern Decisions section in AGENTS.md had grown to cover four readers, the allOf disagreement, enforcement, visualiser folding, and duplicated code - a lot for a package AI-assistant guide to carry, and scoped to shared even though half of it describes calm-hub-ui and calm-models behaviour too. Moved it to shared/PATTERN-DECISIONS.md: a recap of current, intended behaviour for how patterns express decisions, tying together rules spread across calm-models, shared, and calm-hub-ui. Not a design proposal - it states what the code already does and why, including two disagreements between packages that are not fixed here: - allOf's three readers, and why each made its own narrow choice instead of a real intersection merge. - options.ts vs patternTransformer.ts on a decision holder that declares both oneOf and anyOf - confirmed pre-existing against main, and confirmed that no Spectral rule's given paths reach a decision holder's own nested options block, which is the reason nothing catches it today. AGENTS.md's Pattern Decisions section is now a pointer plus the one piece of testing guidance that belongs in a package guide, not a behaviour recap. Written in Simplified Technical English per the root AGENTS.md's documentation guidance. --- shared/AGENTS.md | 76 +------------- shared/PATTERN-DECISIONS.md | 199 ++++++++++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 71 deletions(-) create mode 100644 shared/PATTERN-DECISIONS.md diff --git a/shared/AGENTS.md b/shared/AGENTS.md index 3be29835d..a22b7bbae 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -55,81 +55,15 @@ npx vitest run ${TEST FILE} ## Pattern Decisions -See `calm-ai/tools/pattern-creation.md` for what a **decision holder** and **candidate** are. -Candidates are declared in four places: a plain `prefixItems` entry, a `prefixItems[i].oneOf` -or `.anyOf` alternative, or an `items.oneOf`/`items.anyOf` catalog. - -**A decision holder must be in `properties.relationships.prefixItems`.** `extractOptions` -finds decisions only through `getRelationshipsPrefixItems`, both in `options.ts`, which never -reads the `items` catalog — a holder placed there is invisible to it. +See [PATTERN-DECISIONS.md](./PATTERN-DECISIONS.md) before you change how a pattern's decisions +are read, merged, or rendered. It covers where a candidate can be declared, what `oneOf`/`anyOf` +mean in each location, which of the three pattern-reader functions to call for which question, +why `allOf` has three readers that disagree, the enforcement rules, how decisions fold into the +visualiser's boxes, and two known disagreements between packages with the reason each exists. Drive new tests from `extractOptions`, not from hand-built choices, or you do not test whether the decision is discoverable. See `catalog-decisions.spec.ts`. -### Three questions, three owners - -Use the wrong one and the failure is silent, so they are separate named functions. - -| Question | Function | Home | -|---|---|---| -| What does *this block* offer? (`oneOf` wins) | `resolveOperativeChoiceBlock` | `@finos/calm-models/pattern` | -| What does the pattern *declare*? (both keywords) | `listDeclaredCandidates` | `@finos/calm-models/pattern` | -| What can selection *reach*? (one keyword) | `listSelectableCandidates` | `@finos/calm-models/pattern` | - -Use *declared* for what a document says: uniqueness, dangling references. Use *selectable* -for "can this answer be honoured". They differ only where a block declares both keywords. - -`getPatternArray` is a fourth relevant function — see its row in the `allOf` table below. - -### `allOf` has three unreconciled meanings - -Treat `allOf` for nodes and relationships as unsupported. - -| Reader | Behaviour | -|---|---| -| `deepMergeSchemas` (`flatten-allof.ts`) | shallow merge; a repeated property loses `type`, so `instantiate` emits `{}` | -| `getPatternArray` | resolves one branch per property (root, else the first branch declaring it) and reads `prefixItems`/`items` from that same branch; later branches ignored; marked TEMPORARY | -| `listDeclaredCandidates` / `listSelectableCandidates` | ignore `allOf` entirely, to keep `path` correct for diagnostics | - -A pattern whose `prefixItems`/`items` sits under `allOf` yields no candidates at all from -`listDeclaredCandidates`/`listSelectableCandidates` (they ignore `allOf` entirely), even though -`getPatternArray` and `deepMergeSchemas` both still reach into it. `allOf` means -**intersection**, never union, because `calm validate` never flattens. `shared` previously kept -its own copy of `listDeclaredCandidates` that followed `allOf` through `getPatternArray`, disagreeing -with the `calm-models` copy and reporting a `path` the document did not contain; nothing tested -or relied on that behaviour, so the copy was removed rather than reconciled — it did not add -`allOf` support, only removed a second, wrong answer. - -### Enforcement - -`calm generate` **never validates**. These rules run only on `calm validate`. - -| Rule | Severity | Catches | -|---|---|---| -| `pattern-option-relationship-must-be-in-prefix-items` | `error` | a holder inside an `items` catalog | -| `pattern-decision-must-reference-selectable-nodes` / `-relationships` | `error` | a bundle naming a declared but unreachable candidate | -| `group-relationship-with-const-nodes-references-existing-nodes-in-pattern` | `error` | a bundle naming an id that does not exist | -| `pattern-items-catalog-must-declare-one-choice-keyword` | `warn` | a block declaring both keywords | - -The generate path has its own guard. `assertChoicesAreSelectable` throws from `runGenerate`. -It is not called from `selectChoices`, because validation calls that too. - -`pattern-nodes-must-be-referenced` does not help with holder placement. Its recursive -`$..relationship-type..*@string()` query (`node-has-relationship.ts`) matches a holder -regardless of which array it sits in. - -### Still duplicated - -Nothing keeps this pair in step. `listDeclaredCandidates`'s own duplicate (`shared/src/pattern-candidates.ts`) -is gone — both `listDeclaredCandidates` and `listSelectableCandidates` now live only in `@finos/calm-models/pattern`. - -| Duplicate | Sites | Note | -|---|---|---| -| decision-holder reading | `options.ts` (`isOptionsRelationship`, `getItemsInOptionsRelationship`), `patternTransformer.ts` (`isOptionsRelationship`, the `options.prefixItems` read) | already differ: one unions both keywords, the other picks `oneOf` | - -`calm-hub-ui` depends on `@finos/calm-models` and not on `shared`, so a shared reader must -live in `calm-models`. - ## Common Workflows **IMPORTANT**: Always run npm commands from the **repository root** using workspaces, not from within this package directory. diff --git a/shared/PATTERN-DECISIONS.md b/shared/PATTERN-DECISIONS.md new file mode 100644 index 000000000..3fe74c24d --- /dev/null +++ b/shared/PATTERN-DECISIONS.md @@ -0,0 +1,199 @@ +# Pattern Decisions + +This document explains how CALM patterns express decisions today. It ties together +rules that are spread across several packages. Read it before you change how a pattern +is read, merged, or rendered. + +This is a recap of current, intended behaviour. It is not a proposal for new +behaviour. Where two parts of the system disagree, this document says so and gives +the reason. It does not pick a winner unless the code already has. + +For what a **decision holder** and a **candidate** are, see +`calm-ai/tools/pattern-creation.md`. That guide is for pattern authors. This +document is for people who change the code that reads a pattern. + +## Where a candidate can be declared + +A pattern can declare a candidate in four places: + +- a plain `prefixItems` entry +- a `prefixItems[i].oneOf` alternative +- a `prefixItems[i].anyOf` alternative +- an `items.oneOf`/`items.anyOf` catalog member + +One function, `getPatternArray` (`@finos/calm-models/pattern`), finds the `prefixItems` +array and the `items` catalog for `nodes` or `relationships`. Other functions build on +top of it. + +## `oneOf` and `anyOf` mean different things by location + +The same two keywords carry different meaning depending on where they sit. Get this +wrong and the pattern still validates, but generation or the visualiser reads it +incorrectly. + +**Inside a decision holder's `options`.** This sets the cardinality. `oneOf` means the +user must pick exactly one choice. `anyOf` means the user may pick any number of +choices, including none. + +**Inside a `prefixItems[i]` slot.** This picks which single candidate fills that fixed +array position. `oneOf` and `anyOf` behave the same way here: only one alternative can +occupy the slot. + +**Inside an `items` catalog.** This constrains the shape of one array entry, not how +many entries exist. Each candidate pins its own `unique-id` with `const`, so an entry +can match at most one candidate schema either way. Use `oneOf` here. It is the accurate +keyword, and it is the one the readers below resolve to when both are present. + +**Never declare both `oneOf` and `anyOf` on one block.** Nothing in JSON Schema +forbids it, so a pattern can still do it by mistake. When it happens, every reader in +this codebase resolves it the same way: `oneOf` wins, and the `anyOf` alternatives are +dropped. See "A known disagreement" below for the one place that does not follow this +rule yet. + +## Three functions answer three different questions + +Each function lives in `@finos/calm-models/pattern`. Use the wrong one and the failure +is silent, not an error. + +| Question | Function | +|---|---| +| What does this one block resolve to? (`oneOf` wins) | `resolveOperativeChoiceBlock` | +| What does the pattern declare, in total? (both keywords) | `listDeclaredCandidates` | +| What can a selection actually reach? (one keyword) | `listSelectableCandidates` | + +Use `listDeclaredCandidates` for questions about the document itself: is an id unique, +does a reference dangle. Use `listSelectableCandidates` for questions about an answer: +can this choice be honoured. The two functions differ only where a block declares both +keywords — which a well-formed pattern never does. + +`getPatternArray` is a fourth relevant function. It resolves the array a decision +lives in before either of the two `list*` functions walks it. + +## `calm generate` never validates + +`calm generate` does not run `calm validate`. It has its own guard instead. +`assertChoicesAreSelectable` throws from `runGenerate` when a chosen answer names a +candidate that selection cannot reach. It is not called from `selectChoices`, because +`calm validate` calls `selectChoices` too, and a malformed pattern must show its own +schema error there instead. + +This means a decision holder placed somewhere illegal, such as inside an `items` +catalog, produces a `calm validate` error but not a `calm generate` error. A user who +only runs `calm generate` sees no error. The decision is simply never offered. This is +documented behaviour, not an oversight, but it is easy to miss. + +## `allOf` has three unreconciled readers + +Treat `allOf` for `nodes` and `relationships` as unsupported. Three parts of the +system read it, and they do not agree. + +| Reader | Behaviour | +|---|---| +| `deepMergeSchemas` (`flatten-allof.ts`) | Shallow merge. A repeated property loses its `type`, so `instantiate` emits `{}` for it. | +| `getPatternArray` | Resolves one branch per property: the root schema, or else the first `allOf` branch that declares it. Reads `prefixItems` and `items` from that same branch. Later branches are ignored. Marked TEMPORARY in the code. | +| `listDeclaredCandidates` / `listSelectableCandidates` | Ignore `allOf` entirely. This keeps the reported `path` correct for diagnostics. | + +**Why this is not one answer.** `allOf` means intersection, not union, because `calm +validate` never flattens a pattern before checking it. A correct merge would need to +combine the branches the way a real JSON Schema validator combines them. No part of +this codebase does that yet. Each reader above made its own narrow, expedient choice +instead, scoped to what its one caller needed. + +`shared` used to keep a second copy of `listDeclaredCandidates` that followed `allOf` +through `getPatternArray`. It disagreed with the `calm-models` copy and reported a +`path` the document did not contain. Nothing tested or relied on that behaviour, so +the copy was deleted rather than fixed. A full `allOf`-intersection rework is +separate, larger work, not started. + +## Enforcement + +These rules run only on `calm validate`. `calm generate` never runs them. + +| Rule | Severity | Catches | +|---|---|---| +| `pattern-option-relationship-must-be-in-prefix-items` | error | A decision holder placed inside an `items` catalog. | +| `pattern-decision-must-reference-selectable-nodes` / `-relationships` | error | A choice bundle naming a candidate that is declared but not reachable. | +| `group-relationship-with-const-nodes-references-existing-nodes-in-pattern` | error | A choice bundle naming an id that does not exist at all. | +| `pattern-items-catalog-must-declare-one-choice-keyword` | warn | An `items` catalog block, or a `prefixItems[*]` slot, that declares both keywords. | + +The last rule's `given` paths reach `properties..items` and +`properties..prefixItems[*]`. They do not reach inside a +decision holder's own `relationship-type.options.prefixItems[*]`. So a decision holder +that declares both keywords in its own options block is not caught by this rule, or +by any other rule today. This is the root cause of the disagreement below. + +`pattern-nodes-must-be-referenced` does not help with decision-holder placement. Its +recursive query matches a holder regardless of which array it sits in, so it cannot +tell a legal holder from an illegal one. + +## How decisions fold into the visualiser's boxes + +The pattern visualiser (`calm-hub-ui`) draws each decision as a box. The rules below +are current, deliberate behaviour, not bugs, unless stated otherwise. + +**Every decision gets its own box.** Two decisions drawing from the same catalog +produce two boxes, each with its own prompt. + +**A candidate can be drawn in one box only.** If two decisions name the same +candidate, the first decision (in document order) keeps it. The second decision's box +still offers that candidate as a choice, but does not draw it. If every one of a +decision's candidates is claimed by an earlier decision, that decision renders no box +at all. + +**A container beats a decision box.** If a candidate is both a decision candidate and +a child of a container (`deployed-in` or `composed-of`), it is drawn inside the +container, not inside the choice box. + +**A decision whose candidates are themselves containers keeps its box.** The box is +drawn next to the containers, not around them. Nesting a box around its containers is +future work, not current behaviour. + +**A decision whose every candidate is pulled into one shared container loses its box +entirely, prompt included.** This is the one case where container precedence removes +the question from the diagram, not just its candidates. It is deliberate and tested, +not an oversight — the alternative, an empty box with nothing inside it, was judged +worse. Nesting the box inside the container, so the question survives, is tracked as +future work in issue #2933. + +## A known disagreement: a decision holder that declares both keywords + +`options.ts` (used by `calm generate`) and `patternTransformer.ts` (used by the +visualiser) both read a decision holder's choice bundles. They disagree when one +block declares both `oneOf` and `anyOf`. + +- `options.ts`'s `extractOptions` reads the block once as `oneOf` and once as `anyOf`, + and offers the union of both as available choices. +- `patternTransformer.ts`'s `extractOptionsMetadata` calls + `resolveOperativeChoiceBlock`, which picks `oneOf` only, matching the rule stated + above for every other reader in this codebase. + +So a choice from the `anyOf` half is offered by `calm generate` but never drawn by the +visualiser. If a user answers with that choice, `calm generate` accepts it as valid +when it builds the prompt, but nothing downstream treats it as reachable the way the +rest of the system does. + +**Why this exists.** This predates the items-catalog feature entirely. It was checked +directly against `main`: `options.ts`'s union-both behaviour is unchanged, and the +visualiser's `oneOf`-wins behaviour was already there, hand-written, before it was +swapped to call the shared `resolveOperativeChoiceBlock`. Neither side was built +against the other. Nobody has reconciled them. + +**Which side is likely wrong.** `oneOf`-wins is the rule every other reader in this +codebase already follows — `resolveOperativeChoiceBlock`, `listSelectableCandidates`, +and the reasoning behind `pattern-decision-must-reference-selectable-nodes`. That +makes `options.ts`'s union-both behaviour the outlier, not the visualiser's. This is +not fixed here. It is tracked as a follow-up issue. + +## Still duplicated + +**Decision-holder reading.** `options.ts` (`isOptionsRelationship`, +`getItemsInOptionsRelationship`) and `patternTransformer.ts` (`isOptionsRelationship`, +its own `options.prefixItems` read) each re-implement finding a decision holder and +reading its choice bundles. Nothing keeps this pair in step, which is why they +disagree as described above. `calm-hub-ui` depends on `@finos/calm-models` and not on +`shared`, so a shared reader for this would have to live in `calm-models`, the same +place the readers above already live. + +**A small id-reading helper.** `shared/src/spectral/functions/pattern/candidate-helpers.ts` +re-implements `isObject` and `readUniqueId`, which already exist, privately, in +`calm-models/src/pattern/pattern-reader.ts`. Exporting them would close this one. From ed4474abc386470e49104b41308f4b7a5bfb0a20 Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Wed, 26 Aug 2026 05:52:24 +0000 Subject: [PATCH 37/41] docs(shared): make Pattern Decisions a durable reference Strips the PR-review voice: changelog narration, comparisons against main, and claims that follow-up work is tracked when no issue exists. Corrects the known-disagreement section. The divergence sits inside options.ts - extractOptionsFromBlock offers the union of both choice keywords while flattenOneOfAndAnyOf resolves oneOf only, so an answer from the anyOf half is accepted and then silently discarded. The visualiser already matches generation's resolution step, and the validation rule enumerates rather than resolves, so neither is a party to it. Replaces "still duplicated" with the three concrete divergences between the two decision-holder readers. Drops the candidate-helper entry: identical code with no behavioural disagreement is not what this reference is for. Corrects the enforcement note - the keyword rule does reach a decision holder as a relationships.prefixItems entry, it just never descends into the holder's own options block. --- calm-ai/tools/pattern-creation.md | 2 +- calm-models/src/pattern/pattern-reader.ts | 4 +- shared/AGENTS.md | 3 +- shared/PATTERN-DECISIONS.md | 169 ++++++++++-------- .../generate/components/flatten-allof.ts | 6 +- 5 files changed, 102 insertions(+), 82 deletions(-) diff --git a/calm-ai/tools/pattern-creation.md b/calm-ai/tools/pattern-creation.md index 5ca73350f..f151579b5 100644 --- a/calm-ai/tools/pattern-creation.md +++ b/calm-ai/tools/pattern-creation.md @@ -238,7 +238,7 @@ Guidance: - Keep every candidate that a single decision references within one declaration site. A decision whose candidates are split between a `prefixItems` slot and an `items` catalog, or spread across nodes with inconsistent container membership, is a pattern smell — model the choice at one consistent level. - Declare every decision holder (a relationship with `relationship-type.options`) in `properties.relationships.prefixItems`. A catalog with no holder pointing at it can never be selected from, and `calm validate` warns that its candidates are unreferenced. -- Duplicate `unique-id`s inside an `items` catalog are rejected by `calm validate`, and a catalog node that no relationship or decision references produces a warning — the same check a plain `prefixItems` entry gets. A candidate declared as a `prefixItems[i].oneOf`/`anyOf` alternative does not get this warning yet; that gap predates this feature and is tracked separately. +- Duplicate `unique-id`s inside an `items` catalog are rejected by `calm validate`, and a catalog node that no relationship or decision references produces a warning — the same check a plain `prefixItems` entry gets. A candidate declared as a `prefixItems[i].oneOf`/`anyOf` alternative does not get this warning — the rule reaches a plain `prefixItems` entry and an `items` catalog member, but not inside a slot's alternatives. ### Relationship Options with Decision Points diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index 48e21a700..f5fea316d 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -33,7 +33,9 @@ function isObject(value: unknown): value is SchemaNode { * branches, which would describe an array no single declaration site actually contains. * * It exists because candidate discovery runs on the raw pattern, before `flattenAllOf`. - * The `allOf` merge rework will delete this function. Do not correct the precedence here. + * Do not correct the precedence here - a reader that disagrees with `flattenAllOf` is worse + * than one that is wrong the same way. This function becomes removable once `allOf` branches + * are combined by real schema intersection, not before. */ function resolveArrayContainer(pattern: SchemaNode, key: string): SchemaNode | undefined { const direct = pattern['properties']; diff --git a/shared/AGENTS.md b/shared/AGENTS.md index a22b7bbae..d8373621a 100644 --- a/shared/AGENTS.md +++ b/shared/AGENTS.md @@ -59,7 +59,8 @@ See [PATTERN-DECISIONS.md](./PATTERN-DECISIONS.md) before you change how a patte are read, merged, or rendered. It covers where a candidate can be declared, what `oneOf`/`anyOf` mean in each location, which of the three pattern-reader functions to call for which question, why `allOf` has three readers that disagree, the enforcement rules, how decisions fold into the -visualiser's boxes, and two known disagreements between packages with the reason each exists. +visualiser's boxes, why `calm generate` offers decision choices it then discards, and what a +shared decision-holder reader would have to settle first. Drive new tests from `extractOptions`, not from hand-built choices, or you do not test whether the decision is discoverable. See `catalog-decisions.spec.ts`. diff --git a/shared/PATTERN-DECISIONS.md b/shared/PATTERN-DECISIONS.md index 3fe74c24d..9befda9c2 100644 --- a/shared/PATTERN-DECISIONS.md +++ b/shared/PATTERN-DECISIONS.md @@ -1,12 +1,12 @@ # Pattern Decisions -This document explains how CALM patterns express decisions today. It ties together -rules that are spread across several packages. Read it before you change how a pattern -is read, merged, or rendered. +This document explains how CALM patterns express decisions. It ties together rules +that are spread across several packages. Read it before you change how a pattern is +read, merged, or rendered. -This is a recap of current, intended behaviour. It is not a proposal for new -behaviour. Where two parts of the system disagree, this document says so and gives -the reason. It does not pick a winner unless the code already has. +It describes the behaviour the code has. Where two parts of the system disagree, this +document names the disagreement and gives the reason. It does not pick a winner unless +the code already has. For what a **decision holder** and a **candidate** are, see `calm-ai/tools/pattern-creation.md`. That guide is for pattern authors. This @@ -45,10 +45,12 @@ can match at most one candidate schema either way. Use `oneOf` here. It is the a keyword, and it is the one the readers below resolve to when both are present. **Never declare both `oneOf` and `anyOf` on one block.** Nothing in JSON Schema -forbids it, so a pattern can still do it by mistake. When it happens, every reader in -this codebase resolves it the same way: `oneOf` wins, and the `anyOf` alternatives are -dropped. See "A known disagreement" below for the one place that does not follow this -rule yet. +forbids it, so a pattern can still do it by mistake. When it happens, every operation +that resolves a block to what will actually exist reads it the same way: `oneOf` wins, +and the `anyOf` alternatives are dropped. Operations that only enumerate what a pattern +declares union both keywords instead, which is why `listDeclaredCandidates` exists +alongside `listSelectableCandidates`. See "A known disagreement" below for the one +operation that enumerates on the union and then resolves against it. ## Three functions answer three different questions @@ -79,8 +81,8 @@ schema error there instead. This means a decision holder placed somewhere illegal, such as inside an `items` catalog, produces a `calm validate` error but not a `calm generate` error. A user who -only runs `calm generate` sees no error. The decision is simply never offered. This is -documented behaviour, not an oversight, but it is easy to miss. +only runs `calm generate` sees no error. The decision is simply never offered. The +behaviour is deliberate, and it is easy to miss. ## `allOf` has three unreconciled readers @@ -94,16 +96,16 @@ system read it, and they do not agree. | `listDeclaredCandidates` / `listSelectableCandidates` | Ignore `allOf` entirely. This keeps the reported `path` correct for diagnostics. | **Why this is not one answer.** `allOf` means intersection, not union, because `calm -validate` never flattens a pattern before checking it. A correct merge would need to -combine the branches the way a real JSON Schema validator combines them. No part of -this codebase does that yet. Each reader above made its own narrow, expedient choice -instead, scoped to what its one caller needed. +validate` never flattens a pattern before checking it. A correct merge would combine +the branches the way a real JSON Schema validator combines them. No part of this +codebase does that. Each reader above makes its own narrow choice instead, scoped to +what its one caller needs. -`shared` used to keep a second copy of `listDeclaredCandidates` that followed `allOf` -through `getPatternArray`. It disagreed with the `calm-models` copy and reported a -`path` the document did not contain. Nothing tested or relied on that behaviour, so -the copy was deleted rather than fixed. A full `allOf`-intersection rework is -separate, larger work, not started. +Keep one implementation of each `list*` function, and keep it in `calm-models`. Do not +add a copy that follows `allOf` through `getPatternArray`: that combination reports a +`path` the document does not contain, which is wrong for a diagnostic. Reconciling the +three readers needs the real intersection merge described above, which is larger work +than any single caller justifies. ## Enforcement @@ -116,11 +118,12 @@ These rules run only on `calm validate`. `calm generate` never runs them. | `group-relationship-with-const-nodes-references-existing-nodes-in-pattern` | error | A choice bundle naming an id that does not exist at all. | | `pattern-items-catalog-must-declare-one-choice-keyword` | warn | An `items` catalog block, or a `prefixItems[*]` slot, that declares both keywords. | -The last rule's `given` paths reach `properties..items` and -`properties..prefixItems[*]`. They do not reach inside a -decision holder's own `relationship-type.options.prefixItems[*]`. So a decision holder -that declares both keywords in its own options block is not caught by this rule, or -by any other rule today. This is the root cause of the disagreement below. +The last rule's eight `given` paths reach `properties..items` and +`properties..prefixItems[*]`, each also under `allOf[*]`. So the rule +does reach a decision holder, as one of the `relationships.prefixItems[*]` entries, but it +only checks the keywords that entry declares itself. It never descends into the holder's own +`relationship-type.options.prefixItems[*]`, so no rule catches a decision holder that declares +both keywords in its options block. This is the root cause of the disagreement below. `pattern-nodes-must-be-referenced` does not help with decision-holder placement. Its recursive query matches a holder regardless of which array it sits in, so it cannot @@ -128,8 +131,8 @@ tell a legal holder from an illegal one. ## How decisions fold into the visualiser's boxes -The pattern visualiser (`calm-hub-ui`) draws each decision as a box. The rules below -are current, deliberate behaviour, not bugs, unless stated otherwise. +The pattern visualiser (`calm-hub-ui`) draws each decision as a box. Each rule below +is deliberate behaviour. **Every decision gets its own box.** Two decisions drawing from the same catalog produce two boxes, each with its own prompt. @@ -145,55 +148,67 @@ a child of a container (`deployed-in` or `composed-of`), it is drawn inside the container, not inside the choice box. **A decision whose candidates are themselves containers keeps its box.** The box is -drawn next to the containers, not around them. Nesting a box around its containers is -future work, not current behaviour. +drawn next to the containers, not around them. Issue #2933 covers nesting. **A decision whose every candidate is pulled into one shared container loses its box entirely, prompt included.** This is the one case where container precedence removes -the question from the diagram, not just its candidates. It is deliberate and tested, -not an oversight — the alternative, an empty box with nothing inside it, was judged -worse. Nesting the box inside the container, so the question survives, is tracked as -future work in issue #2933. - -## A known disagreement: a decision holder that declares both keywords - -`options.ts` (used by `calm generate`) and `patternTransformer.ts` (used by the -visualiser) both read a decision holder's choice bundles. They disagree when one -block declares both `oneOf` and `anyOf`. - -- `options.ts`'s `extractOptions` reads the block once as `oneOf` and once as `anyOf`, - and offers the union of both as available choices. -- `patternTransformer.ts`'s `extractOptionsMetadata` calls - `resolveOperativeChoiceBlock`, which picks `oneOf` only, matching the rule stated - above for every other reader in this codebase. - -So a choice from the `anyOf` half is offered by `calm generate` but never drawn by the -visualiser. If a user answers with that choice, `calm generate` accepts it as valid -when it builds the prompt, but nothing downstream treats it as reachable the way the -rest of the system does. - -**Why this exists.** This predates the items-catalog feature entirely. It was checked -directly against `main`: `options.ts`'s union-both behaviour is unchanged, and the -visualiser's `oneOf`-wins behaviour was already there, hand-written, before it was -swapped to call the shared `resolveOperativeChoiceBlock`. Neither side was built -against the other. Nobody has reconciled them. - -**Which side is likely wrong.** `oneOf`-wins is the rule every other reader in this -codebase already follows — `resolveOperativeChoiceBlock`, `listSelectableCandidates`, -and the reasoning behind `pattern-decision-must-reference-selectable-nodes`. That -makes `options.ts`'s union-both behaviour the outlier, not the visualiser's. This is -not fixed here. It is tracked as a follow-up issue. - -## Still duplicated - -**Decision-holder reading.** `options.ts` (`isOptionsRelationship`, -`getItemsInOptionsRelationship`) and `patternTransformer.ts` (`isOptionsRelationship`, -its own `options.prefixItems` read) each re-implement finding a decision holder and -reading its choice bundles. Nothing keeps this pair in step, which is why they -disagree as described above. `calm-hub-ui` depends on `@finos/calm-models` and not on -`shared`, so a shared reader for this would have to live in `calm-models`, the same -place the readers above already live. - -**A small id-reading helper.** `shared/src/spectral/functions/pattern/candidate-helpers.ts` -re-implements `isObject` and `readUniqueId`, which already exist, privately, in -`calm-models/src/pattern/pattern-reader.ts`. Exporting them would close this one. +the question from the diagram, not just its candidates. The alternative was an empty +box with nothing inside it. Issue #2933 covers nesting the box inside the container, +so that the question survives. + +## A known disagreement: generation offers more than it resolves + +Four operations read a decision holder's **question block** - the `oneOf`/`anyOf` array +under `relationship-type.options.prefixItems[i]`, whose elements are choice bundles. Inside +that block the keyword sets cardinality. It is not a candidate declaration site, so the +`nodes`/`relationships` rules above do not apply to it. + +| Operation | Surface | Reads the question block as | +|---|---|---| +| `extractOptionsFromBlock` (`options.ts`) | generation, offering | union of both keywords | +| `flattenOneOfAndAnyOf` (`options.ts`) | generation, resolving | `oneOf` wins | +| `extractOptionsMetadata` (`patternTransformer.ts`) | visualisation | `oneOf` wins | +| `pattern-decision-must-reference-selectable-nodes` | validation | union, by JSONPath recursive descent | + +**The disagreement is inside `options.ts`.** `extractOptionsFromBlock` builds the prompt from +both keywords. `flattenOneOfAndAnyOf` then applies the answer through +`resolveOperativeChoiceBlock`, which resolves `oneOf` only. So a choice from the `anyOf` half +is offered, accepted, and then discarded. `calm generate` reports success, and the holder is +dropped from the output. + +`assertChoicesAreSelectable` does not catch this. It checks the answer's ids against +`listSelectableCandidates`, which reads the candidate declaration site, not the question +block. Ids declared normally there pass, so the guard stays silent. + +Neither other surface is a party to this. `extractOptionsMetadata` already matches +generation's resolution step. The validation rule enumerates and never resolves, so union is +correct for it, and it reads the block through a JSONPath `given` rather than a reader +function. + +**Which side changes.** Resolution cannot read the union. Within a question block `oneOf` +means pick exactly one and `anyOf` means pick any number, so a block declaring both would +have to be single-select and multi-select at once. Resolution must drop one keyword, and +`oneOf`-wins is already that rule everywhere else. Enumeration is the side that moves: +`extractOptionsFromBlock` should call `resolveOperativeChoiceBlock` too. A prompt that offers +what resolution discards is a defect, not a competing policy. + +**What this blocks.** `options.ts` and `patternTransformer.ts` each re-implement finding a +decision holder and reading its question blocks. `isOptionsRelationship` is identical in both. +The helpers beside it are not, and they diverge three ways. + +| Divergence | `options.ts` | `patternTransformer.ts` | +|---|---|---| +| A block declaring both keywords | union when offering, `oneOf` wins when resolving | `oneOf` wins | +| Question blocks read per holder | every entry in `options.prefixItems` | the first entry yielding a described choice, then returns | +| A malformed holder | unguarded property access, throws | optional chaining, renders nothing | + +The second divergence also breaks generation on its own. Two question blocks under one holder +emit two options, and both take `optionId` from the holder's `unique-id`, so they collide. +`cli/src/command-helpers/generate-options.ts` resolves an answer with `find` and keys answers +by `optionId`, so only the first is addressable. Every pattern in this repository declares one +question block per holder, so this is unreachable today. + +One shared reader would close all three. `calm-hub-ui` depends on `@finos/calm-models` and not +on `shared`, so it would live in `calm-models`, beside the readers above. It would have three +callers, and two of them already want the same thing, so the keyword policy above is what +stands in the way. diff --git a/shared/src/commands/generate/components/flatten-allof.ts b/shared/src/commands/generate/components/flatten-allof.ts index 332ccfabb..ef2f3e4d3 100644 --- a/shared/src/commands/generate/components/flatten-allof.ts +++ b/shared/src/commands/generate/components/flatten-allof.ts @@ -207,8 +207,10 @@ function mergePrefixItems(target: unknown[], source: unknown[], logger: Logger): for (let i = 0; i < maxLen; i++) { if (i < target.length && i < source.length) { - // Both have items at this position - merge them. Positional merges never warn: - // they are the chimera path (see AGENTS.md), a separate, already-tracked issue. + // Both have items at this position - merge them. Positional merges stay silent: + // the result fuses index i of two branches into a schema neither branch declares + // on its own, so there is no authored declaration whose lost keys are worth + // reporting. result.push( deepMergeSchemas( target[i] as Record, From 0849d40f4ff8e8bc793a9a86dfac7ac24fd5296d Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Wed, 26 Aug 2026 05:52:40 +0000 Subject: [PATCH 38/41] refactor(calm-models): move listNodeInterfaces beside the candidate readers listNodeInterfaces reads pattern schema and has no Spectral coupling, but it lived in shared's Spectral rules folder, where every other file is a rule function. It carried private isObject/readUniqueId copies only because it was reading schema in a layer that holds no schema readers. Export it from calm-models/pattern and delete candidate-helpers.ts. The type is renamed DeclaredInterface - NodeInterface collided with CalmNodeInterface, which already means a relationship endpoint. No signature or behaviour change. Both rules still compose the document path themselves, because the reader sees a candidate's schema without knowing where that schema sits. --- calm-models/src/pattern/index.ts | 2 + calm-models/src/pattern/pattern-reader.ts | 31 ++++++++++++++ .../functions/pattern/candidate-helpers.ts | 41 ------------------- .../functions/pattern/ids-are-unique.ts | 3 +- .../pattern/interface-id-exists-on-node.ts | 3 +- 5 files changed, 35 insertions(+), 45 deletions(-) delete mode 100644 shared/src/spectral/functions/pattern/candidate-helpers.ts diff --git a/calm-models/src/pattern/index.ts b/calm-models/src/pattern/index.ts index 7bdee7e7d..658c7057e 100644 --- a/calm-models/src/pattern/index.ts +++ b/calm-models/src/pattern/index.ts @@ -3,8 +3,10 @@ export { resolveOperativeChoiceBlock, listDeclaredCandidates, listSelectableCandidates, + listNodeInterfaces, type SchemaNode, type PatternArray, type ChoiceBlock, type Candidate, + type DeclaredInterface, } from './pattern-reader.js'; diff --git a/calm-models/src/pattern/pattern-reader.ts b/calm-models/src/pattern/pattern-reader.ts index f5fea316d..ba8816419 100644 --- a/calm-models/src/pattern/pattern-reader.ts +++ b/calm-models/src/pattern/pattern-reader.ts @@ -241,3 +241,34 @@ export function listDeclaredCandidates(pattern: SchemaNode, calmType: 'nodes' | export function listSelectableCandidates(pattern: SchemaNode, calmType: 'nodes' | 'relationships'): Candidate[] { return walkCandidates(pattern, calmType, 'operative'); } + +/** An interface a node candidate declares, and its position in `interfaces.prefixItems`. */ +export type DeclaredInterface = { + uniqueId: string; + index: number; +}; + +/** + * Reads a node candidate's own `interfaces.prefixItems`, in declaration order, skipping any + * entry with no `const`-pinned `unique-id` - the same rule the candidate readers above apply + * to the candidates themselves. + * + * Reports `index`, not a `path`: it receives the candidate's schema without knowing where that + * schema sits, so only the caller can turn a position into a document location. + */ +export function listNodeInterfaces(node: SchemaNode): DeclaredInterface[] { + const properties = node['properties']; + const interfacesSchema = isObject(properties) ? properties['interfaces'] : undefined; + const prefixItems = isObject(interfacesSchema) && Array.isArray(interfacesSchema['prefixItems']) + ? (interfacesSchema['prefixItems'] as SchemaNode[]) + : []; + + const result: DeclaredInterface[] = []; + prefixItems.forEach((iface, index) => { + if (!isObject(iface)) return; + const uniqueId = readUniqueId(iface); + if (!uniqueId) return; + result.push({ uniqueId, index }); + }); + return result; +} diff --git a/shared/src/spectral/functions/pattern/candidate-helpers.ts b/shared/src/spectral/functions/pattern/candidate-helpers.ts deleted file mode 100644 index 27262f4eb..000000000 --- a/shared/src/spectral/functions/pattern/candidate-helpers.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { SchemaNode } from '@finos/calm-models/pattern'; - -function isObject(value: unknown): value is SchemaNode { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function readUniqueId(node: SchemaNode): string | undefined { - const properties = node['properties']; - if (!isObject(properties)) return undefined; - const uniqueIdSchema = properties['unique-id']; - if (!isObject(uniqueIdSchema)) return undefined; - const constValue = uniqueIdSchema['const']; - return typeof constValue === 'string' ? constValue : undefined; -} - -export interface NodeInterface { - uniqueId: string; - index: number; -} - -/** - * Reads a node candidate's own `interfaces.prefixItems`, in declaration order, skipping - * any entry with no `const`-pinned `unique-id` — the same rule `listDeclaredCandidates` applies - * to node/relationship candidates themselves. - */ -export function listNodeInterfaces(node: SchemaNode): NodeInterface[] { - const properties = node['properties']; - const interfacesSchema = isObject(properties) ? properties['interfaces'] : undefined; - const prefixItems = isObject(interfacesSchema) && Array.isArray(interfacesSchema['prefixItems']) - ? (interfacesSchema['prefixItems'] as SchemaNode[]) - : []; - - const result: NodeInterface[] = []; - prefixItems.forEach((iface, index) => { - if (!isObject(iface)) return; - const uniqueId = readUniqueId(iface); - if (!uniqueId) return; - result.push({ uniqueId, index }); - }); - return result; -} diff --git a/shared/src/spectral/functions/pattern/ids-are-unique.ts b/shared/src/spectral/functions/pattern/ids-are-unique.ts index 12e07acd4..b77765711 100644 --- a/shared/src/spectral/functions/pattern/ids-are-unique.ts +++ b/shared/src/spectral/functions/pattern/ids-are-unique.ts @@ -1,6 +1,5 @@ import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { listDeclaredCandidates, type Candidate, type SchemaNode } from '@finos/calm-models/pattern'; -import { listNodeInterfaces } from './candidate-helpers'; +import { listDeclaredCandidates, listNodeInterfaces, type Candidate, type SchemaNode } from '@finos/calm-models/pattern'; // Spectral's IFunctionResult.path is an array of path segments, not a pointer string. // candidate.path stops at the candidate's own schema object, so the message and the diff --git a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts index 384cb91da..bca105d74 100644 --- a/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts +++ b/shared/src/spectral/functions/pattern/interface-id-exists-on-node.ts @@ -1,7 +1,6 @@ import { difference } from 'lodash'; import { IFunctionResult, RulesetFunctionContext } from '@stoplight/spectral-core'; -import { listDeclaredCandidates, type SchemaNode } from '@finos/calm-models/pattern'; -import { listNodeInterfaces } from './candidate-helpers'; +import { listDeclaredCandidates, listNodeInterfaces, type SchemaNode } from '@finos/calm-models/pattern'; interface ConnectsRelationship { node?: string; From bb3fedafbe88ea427d85f4e94a1b77e1097f226c Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Wed, 26 Aug 2026 07:14:55 +0000 Subject: [PATCH 39/41] docs(shared): scope the allOf note to what the code does The opening line issued an authoring directive - treat allOf as unsupported for nodes and relationships - in a maintainer-facing doc that states it describes behaviour rather than prescribing it. It was also broader than the rule it restated: pattern-creation.md scopes "unsupported" to relationships, with a reason, and no in-repo pattern uses allOf for either property. State the fact instead, and point at the author-facing guide. Also correct the table: the TEMPORARY marker sits on the private resolveArrayContainer, not on getPatternArray. --- shared/PATTERN-DECISIONS.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shared/PATTERN-DECISIONS.md b/shared/PATTERN-DECISIONS.md index 9befda9c2..adcb8b87c 100644 --- a/shared/PATTERN-DECISIONS.md +++ b/shared/PATTERN-DECISIONS.md @@ -86,13 +86,14 @@ behaviour is deliberate, and it is easy to miss. ## `allOf` has three unreconciled readers -Treat `allOf` for `nodes` and `relationships` as unsupported. Three parts of the -system read it, and they do not agree. +Three parts of the system read `allOf` for `nodes` and `relationships`, and they do not +agree. Nothing may assume consistent behaviour across them. Pattern authors are told not +to split a property's definition across branches - see `calm-ai/tools/pattern-creation.md`. | Reader | Behaviour | |---|---| | `deepMergeSchemas` (`flatten-allof.ts`) | Shallow merge. A repeated property loses its `type`, so `instantiate` emits `{}` for it. | -| `getPatternArray` | Resolves one branch per property: the root schema, or else the first `allOf` branch that declares it. Reads `prefixItems` and `items` from that same branch. Later branches are ignored. Marked TEMPORARY in the code. | +| `getPatternArray` | Resolves one branch per property: the root schema, or else the first `allOf` branch that declares it. Reads `prefixItems` and `items` from that same branch. Later branches are ignored. Its private `resolveArrayContainer` carries the TEMPORARY marker, not `getPatternArray` itself. | | `listDeclaredCandidates` / `listSelectableCandidates` | Ignore `allOf` entirely. This keeps the reported `path` correct for diagnostics. | **Why this is not one answer.** `allOf` means intersection, not union, because `calm From 3bf62db15c3b2edba71f1a475bce2456e840463e Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Wed, 26 Aug 2026 10:40:25 +0000 Subject: [PATCH 40/41] docs(shared): say which surface uses each allOf reader, and why they differ The table listed three readers and their behaviour but not who calls them or what forces each to differ, which is the question it raises. Add the calling surfaces and the constraint behind each choice: the merge must hand instantiate one schema, getPatternArray must return a location and runs on the raw pattern, and the candidate readers must report a path the document actually contains. Add the observable consequence - on one pattern whose nodes live only in an allOf branch, extractOptions finds the decision while listDeclaredCandidates reports no candidates at all. --- shared/PATTERN-DECISIONS.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/shared/PATTERN-DECISIONS.md b/shared/PATTERN-DECISIONS.md index adcb8b87c..7914d4697 100644 --- a/shared/PATTERN-DECISIONS.md +++ b/shared/PATTERN-DECISIONS.md @@ -90,11 +90,16 @@ Three parts of the system read `allOf` for `nodes` and `relationships`, and they agree. Nothing may assume consistent behaviour across them. Pattern authors are told not to split a property's definition across branches - see `calm-ai/tools/pattern-creation.md`. -| Reader | Behaviour | -|---|---| -| `deepMergeSchemas` (`flatten-allof.ts`) | Shallow merge. A repeated property loses its `type`, so `instantiate` emits `{}` for it. | -| `getPatternArray` | Resolves one branch per property: the root schema, or else the first `allOf` branch that declares it. Reads `prefixItems` and `items` from that same branch. Later branches are ignored. Its private `resolveArrayContainer` carries the TEMPORARY marker, not `getPatternArray` itself. | -| `listDeclaredCandidates` / `listSelectableCandidates` | Ignore `allOf` entirely. This keeps the reported `path` correct for diagnostics. | +| Reader | Used by | `allOf` behaviour | Why it cannot do what the others do | +|---|---|---|---| +| `deepMergeSchemas` (`flatten-allof.ts`) | generation (`runGenerate`, before anything else) | Shallow merge of all branches. A repeated property loses its `type`, so `instantiate` emits `{}` for it. | It must hand `instantiate` one schema to materialise from, so it has to combine branches rather than choose one. | +| `getPatternArray` | generation (`extractOptions`), visualisation (`patternTransformer.ts`), pattern diff (`diff/pattern-diff.ts`) | Resolves one branch per property: the root schema, or else the first `allOf` branch declaring it. `prefixItems` and `items` always come from that same branch. | It returns a *location*, not a merged schema, and runs on the raw pattern. Merging two branches would describe an array no declaration site contains. Its private `resolveArrayContainer` carries the TEMPORARY marker. | +| `listDeclaredCandidates` / `listSelectableCandidates` | validation (4 Spectral rules), generation (`assertChoicesAreSelectable`) | Ignore `allOf` entirely. A candidate declared only inside a branch is invisible. | Each candidate reports a `path` used in diagnostics. `getPatternArray` discards which branch it read, so following `allOf` would produce a path the document does not contain. | + +The split is observable on one document. Give all three the same pattern whose `nodes` and +`relationships` live only inside an `allOf` branch: `extractOptions` finds the decision, and +`listDeclaredCandidates` reports no candidates at all. Generation offers a choice over a node +validation believes does not exist. **Why this is not one answer.** `allOf` means intersection, not union, because `calm validate` never flattens a pattern before checking it. A correct merge would combine From 41c74fc64f5bbac6caef9c2b6f771fcf6c19211e Mon Sep 17 00:00:00 2001 From: YoofiTT96 Date: Wed, 26 Aug 2026 13:59:34 +0000 Subject: [PATCH 41/41] docs(shared): correct four claims found by probing the doc Each fix below was checked by running the code, not by reading it. The optionId collision was described as making only the first question block addressable. It depends on the path: interactively both questions are asked and both answers applied, and only the logged --option-choices replay string is lossy; loadChoicesFromInput resolves with find, so the second block is unreachable there. "Unreachable today" read as a property of the format. It is a property of this repository's patterns - option-type in the meta-schema is an unbounded array of decision objects, so a holder is designed to ask several questions. The allOf table listed assertChoicesAreSelectable as a caller exposed to the split. It runs after flattenAllOf, so it never meets an allOf; the invisibility is validation's alone. The header said the doc never picks a winner, while "Which side changes" picks one. Allow it where only one answer is coherent. --- shared/PATTERN-DECISIONS.md | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/shared/PATTERN-DECISIONS.md b/shared/PATTERN-DECISIONS.md index 7914d4697..cf38df9b6 100644 --- a/shared/PATTERN-DECISIONS.md +++ b/shared/PATTERN-DECISIONS.md @@ -5,8 +5,8 @@ that are spread across several packages. Read it before you change how a pattern read, merged, or rendered. It describes the behaviour the code has. Where two parts of the system disagree, this -document names the disagreement and gives the reason. It does not pick a winner unless -the code already has. +document names the disagreement and gives the reason. It does not pick a winner, except +where only one answer is coherent - and then it says why the other is not available. For what a **decision holder** and a **candidate** are, see `calm-ai/tools/pattern-creation.md`. That guide is for pattern authors. This @@ -94,7 +94,7 @@ to split a property's definition across branches - see `calm-ai/tools/pattern-cr |---|---|---|---| | `deepMergeSchemas` (`flatten-allof.ts`) | generation (`runGenerate`, before anything else) | Shallow merge of all branches. A repeated property loses its `type`, so `instantiate` emits `{}` for it. | It must hand `instantiate` one schema to materialise from, so it has to combine branches rather than choose one. | | `getPatternArray` | generation (`extractOptions`), visualisation (`patternTransformer.ts`), pattern diff (`diff/pattern-diff.ts`) | Resolves one branch per property: the root schema, or else the first `allOf` branch declaring it. `prefixItems` and `items` always come from that same branch. | It returns a *location*, not a merged schema, and runs on the raw pattern. Merging two branches would describe an array no declaration site contains. Its private `resolveArrayContainer` carries the TEMPORARY marker. | -| `listDeclaredCandidates` / `listSelectableCandidates` | validation (4 Spectral rules), generation (`assertChoicesAreSelectable`) | Ignore `allOf` entirely. A candidate declared only inside a branch is invisible. | Each candidate reports a `path` used in diagnostics. `getPatternArray` discards which branch it read, so following `allOf` would produce a path the document does not contain. | +| `listDeclaredCandidates` / `listSelectableCandidates` | validation (4 Spectral rules), generation (`assertChoicesAreSelectable`, which runs after `flattenAllOf` and so never meets an `allOf`) | Ignore `allOf` entirely. A candidate declared only inside a branch is invisible to validation. | Each candidate reports a `path` used in diagnostics. `getPatternArray` discards which branch it read, so following `allOf` would produce a path the document does not contain. | The split is observable on one document. Give all three the same pattern whose `nodes` and `relationships` live only inside an `allOf` branch: `extractOptions` finds the decision, and @@ -209,10 +209,16 @@ The helpers beside it are not, and they diverge three ways. | A malformed holder | unguarded property access, throws | optional chaining, renders nothing | The second divergence also breaks generation on its own. Two question blocks under one holder -emit two options, and both take `optionId` from the holder's `unique-id`, so they collide. -`cli/src/command-helpers/generate-options.ts` resolves an answer with `find` and keys answers -by `optionId`, so only the first is addressable. Every pattern in this repository declares one -question block per holder, so this is unreachable today. +emit two options, and both take `optionId` from the holder's `unique-id`, so they collide. The +effect differs by path. Interactively, both questions are asked and both answers are applied; +only the replayable `--option-choices` string that gets logged is lossy, because the second +answer overwrites the first under the shared key. Non-interactively, `loadChoicesFromInput` +resolves with `find`, so the second block's choices are unreachable and naming one is rejected +as invalid. + +No pattern in this repository declares more than one question block per holder. The format +allows it: `option-type` in the meta-schema is an unbounded array of `decision` objects, so a +holder is designed to be able to ask several questions. One shared reader would close all three. `calm-hub-ui` depends on `@finos/calm-models` and not on `shared`, so it would live in `calm-models`, beside the readers above. It would have three