diff --git a/calm-models/src/diff/diff-types.ts b/calm-models/src/diff/diff-types.ts index d598c6a4c..f0ab05fdd 100644 --- a/calm-models/src/diff/diff-types.ts +++ b/calm-models/src/diff/diff-types.ts @@ -1,6 +1,7 @@ import type { CalmControlDetailSchema, CalmControlsSchema, + CalmFlowSchema, CalmNodeSchema, CalmRelationshipSchema, } from '../types/index.js'; @@ -15,6 +16,11 @@ export interface RelationshipChange { updated: CalmRelationshipSchema; } +export interface FlowChange { + original: CalmFlowSchema; + updated: CalmFlowSchema; +} + export interface RenameMapping { oldId: string; newId: string; @@ -105,7 +111,14 @@ export interface ControlDiffResult { controlItemsModified: { [controlId: string]: ControlItemDiffResult }; } +export interface FlowDiffResult { + flowsAdded: CalmFlowSchema[]; + flowsRemoved: CalmFlowSchema[]; + flowsModified: FlowChange[]; + flowsSame: CalmFlowSchema[]; +} + /** - * Represents the result of diffing two CALM architecture instances: includes nodes, relationships, ADRs and controls. TODO: incorporate flows and metadata. + * Represents the result of diffing two CALM architecture instances: includes nodes, relationships, flows, ADRs and controls. TODO: incorporate metadata. */ -export type ArchitectureDiffResult = NodesAndRelationshipsDiffResult & AdrDiffResult & ControlDiffResult; +export type ArchitectureDiffResult = NodesAndRelationshipsDiffResult & AdrDiffResult & ControlDiffResult & FlowDiffResult; diff --git a/calm-models/src/diff/diff.spec.ts b/calm-models/src/diff/diff.spec.ts index 24cfaeb64..56e209769 100644 --- a/calm-models/src/diff/diff.spec.ts +++ b/calm-models/src/diff/diff.spec.ts @@ -555,6 +555,35 @@ describe('diff', () => { }); }); + describe('diffArchitectures - flow changes', () => { + const flow = { + 'unique-id': 'write-flow', + name: 'Write flow', + description: 'Writes a record.', + transitions: [{ 'relationship-unique-id': 'gateway-to-payment', 'sequence-number': 1, description: 'Write.' }], + }; + + it('detects added, removed, modified, and unchanged flows', () => { + const base = { ...testArchitectures.baseArchitecture, flows: [flow] } as CalmArchitectureSchema; + const changed = { + ...base, + flows: [ + { ...flow, transitions: [...flow.transitions, { 'relationship-unique-id': 'gateway-to-payment', 'sequence-number': 2, description: 'Confirm.' }] }, + { ...flow, 'unique-id': 'read-flow', name: 'Read flow' }, + ], + } as CalmArchitectureSchema; + const result = diffArchitectures(base, changed); + expect(result.flowsAdded.map((item) => item['unique-id'])).toEqual(['read-flow']); + expect(result.flowsRemoved).toEqual([]); + expect(result.flowsModified).toHaveLength(1); + expect(result.flowsModified[0].original['unique-id']).toBe('write-flow'); + expect(result.flowsSame).toEqual([]); + + const removed = diffArchitectures(base, { ...base, flows: [] }); + expect(removed.flowsRemoved.map((item) => item['unique-id'])).toEqual(['write-flow']); + }); + }); + describe('diffArchitectures - comprehensive scenarios', () => { const empty = (): CalmArchitectureSchema => ({ nodes: [], diff --git a/calm-models/src/diff/diff.ts b/calm-models/src/diff/diff.ts index a06401fc8..386cab807 100644 --- a/calm-models/src/diff/diff.ts +++ b/calm-models/src/diff/diff.ts @@ -4,6 +4,7 @@ import type { CalmControlDetailSchema, CalmControlSchema, CalmControlsSchema, + CalmFlowSchema, CalmNodeSchema, CalmRelationshipSchema, } from '../types/index.js'; @@ -17,6 +18,8 @@ import type { ArchitectureDiffResult, ControlDiffResult, ControlItemDiffResult, + FlowChange, + FlowDiffResult, ChangeType, } from './diff-types.js'; @@ -92,14 +95,37 @@ export function diffArchitectures( const adrDiff = diffAdrs(archA.adrs ?? [], archB.adrs ?? []); const controlsDiff = diffControls(archA.controls ?? {}, archB.controls ?? {}); + const flowsDiff = diffFlows(archA.flows ?? [], archB.flows ?? []); return { ...nodesAndRelationshipsDiff, ...adrDiff, - ...controlsDiff + ...controlsDiff, + ...flowsDiff, }; } +export function diffFlows(flowsA: CalmFlowSchema[], flowsB: CalmFlowSchema[]): FlowDiffResult { + const flowsByIdA = new Map(flowsA.map((flow) => [flow['unique-id'], flow])); + const flowsByIdB = new Map(flowsB.map((flow) => [flow['unique-id'], flow])); + const flowsAdded = [...flowsByIdB.values()].filter((flow) => !flowsByIdA.has(flow['unique-id'])); + const flowsRemoved = [...flowsByIdA.values()].filter((flow) => !flowsByIdB.has(flow['unique-id'])); + const flowsModified: FlowChange[] = []; + const flowsSame: CalmFlowSchema[] = []; + + for (const [id, flowA] of flowsByIdA) { + const flowB = flowsByIdB.get(id); + if (!flowB) continue; + if (valuesEqual(flowA, flowB)) { + flowsSame.push(flowA); + } else { + flowsModified.push({ original: flowA, updated: flowB }); + } + } + + return { flowsAdded, flowsRemoved, flowsModified, flowsSame }; +} + /** * Core diff over the node/relationship arrays that both architectures and * (normalised) patterns reduce to. Matching is by `unique-id`; items missing diff --git a/calm-models/src/diff/index.ts b/calm-models/src/diff/index.ts index 21772dde8..3bbcbce4a 100644 --- a/calm-models/src/diff/index.ts +++ b/calm-models/src/diff/index.ts @@ -2,6 +2,7 @@ export { diffAdrs, diffArchitectures, diffControls, + diffFlows, diffNodesAndRelationships, nodeStructureMatches, relationshipStructureMatches, @@ -26,6 +27,8 @@ export type { AdrDiffResult, ArchitectureDiffResult, ControlDiffResult, + FlowChange, + FlowDiffResult, NodeChange, NodesAndRelationshipsDiffResult, RelationshipChange, diff --git a/shared/src/commands/diff/diff-core.ts b/shared/src/commands/diff/diff-core.ts index b8da9efc1..ac0abad7d 100644 --- a/shared/src/commands/diff/diff-core.ts +++ b/shared/src/commands/diff/diff-core.ts @@ -4,6 +4,7 @@ import { diffTimelineAdjacent, diffTimelineMoments, type ArchitectureResolver, + type FlowDiffResult, type MomentDiff, type NodesAndRelationshipsDiffResult, type TimelineInput, @@ -15,13 +16,15 @@ export type DiffOutputFormat = 'json' | 'summary'; export type DiffDocumentType = 'architecture' | 'pattern'; +type DiffResult = NodesAndRelationshipsDiffResult & Partial; + export interface DiffRunResult { - diff: NodesAndRelationshipsDiffResult; + diff: DiffResult; formatted: string; hasChanges: boolean; } -export function hasChanges(diff: NodesAndRelationshipsDiffResult): boolean { +export function hasChanges(diff: DiffResult): boolean { return ( diff.nodesAdded.length > 0 || diff.nodesRemoved.length > 0 || @@ -31,6 +34,9 @@ export function hasChanges(diff: NodesAndRelationshipsDiffResult): boolean { diff.edgesRemoved.length > 0 || diff.edgesModified.length > 0 || diff.edgesRenamed.length > 0 || + (diff.flowsAdded?.length ?? 0) > 0 || + (diff.flowsRemoved?.length ?? 0) > 0 || + (diff.flowsModified?.length ?? 0) > 0 || (diff.invalidItems?.nodes.length ?? 0) > 0 || (diff.invalidItems?.relationships.length ?? 0) > 0 || (diff.undiffableItems?.nodes.length ?? 0) > 0 || @@ -56,7 +62,7 @@ function edgeLabel(edge: CalmRelationshipSchema): string { } export function formatDiff( - diff: NodesAndRelationshipsDiffResult, + diff: DiffResult, format: DiffOutputFormat, documentType: DiffDocumentType = 'architecture', ): string { @@ -74,6 +80,9 @@ export function formatDiff( `Nodes: +${diff.nodesAdded.length} -${diff.nodesRemoved.length} ~${diff.nodesModified.length} ↔${diff.nodesRenamed.length} =${diff.nodesSame.length}`, `Relationships: +${diff.edgesAdded.length} -${diff.edgesRemoved.length} ~${diff.edgesModified.length} ↔${diff.edgesRenamed.length} =${diff.edgesSame.length}`, ]; + if (diff.flowsAdded) { + lines.push(`Flows: +${diff.flowsAdded.length} -${diff.flowsRemoved!.length} ~${diff.flowsModified!.length} =${diff.flowsSame!.length}`); + } if (invalidNodes + invalidEdges > 0) { lines.push(`Invalid items: ${invalidNodes} node(s) + ${invalidEdges} relationship(s) skipped (missing unique-id)`); } @@ -95,6 +104,9 @@ export function formatDiff( list('Relationships removed:', diff.edgesRemoved.map(edgeLabel)); list('Relationships modified:', diff.edgesModified.map((e) => edgeLabel(e.original))); list('Relationships renamed:', diff.edgesRenamed.map((r) => `${r.oldId} -> ${r.newId}`)); + list('Flows added:', diff.flowsAdded?.map((flow) => flow['unique-id']) ?? []); + list('Flows removed:', diff.flowsRemoved?.map((flow) => flow['unique-id']) ?? []); + list('Flows modified:', diff.flowsModified?.map((flow) => flow.original['unique-id']) ?? []); return lines.join('\n'); } diff --git a/shared/src/commands/diff/diff.spec.ts b/shared/src/commands/diff/diff.spec.ts index 9a9dfa54d..57d9e48a9 100644 --- a/shared/src/commands/diff/diff.spec.ts +++ b/shared/src/commands/diff/diff.spec.ts @@ -244,6 +244,9 @@ describe('hasChanges', () => { ['edgesRemoved'], ['edgesModified'], ['edgesRenamed'], + ['flowsAdded'], + ['flowsRemoved'], + ['flowsModified'], ] as const)('returns true when %s has entries', (key) => { const r: NodesAndRelationshipsDiffResult = { ...emptyResult, [key]: [{ placeholder: true }] as never }; expect(hasChanges(r)).toBe(true); @@ -327,6 +330,23 @@ describe('diff core', () => { expect(result.formatted).toContain('Nodes added:'); }); + it('reports flow changes in the summary and hasChanges result', async () => { + const { diffDocuments } = await import('./diff-core'); + const withFlow = { + ...archA, + flows: [{ + 'unique-id': 'write-flow', + name: 'Write flow', + description: 'Writes a record.', + transitions: [], + }], + }; + const result = diffDocuments(archA, withFlow, { format: 'summary' }); + expect(result.hasChanges).toBe(true); + expect(result.formatted).toContain('Flows: +1 -0 ~0 =0'); + expect(result.formatted).toContain('Flows added:\n - write-flow'); + }); + it('uses labels in the mismatch error instead of file paths', async () => { const { diffDocuments } = await import('./diff-core'); expect(() => diffDocuments(archA, archB, { documentType: 'pattern', labels: ['left.json', 'right.json'] }))