@@ -130,6 +130,295 @@ describe('validateCalmArchitecture', () => {
130130 expect ( errors . length ) . toBeGreaterThan ( 0 ) ;
131131 } ) ;
132132
133+ it ( 'skips semantic rules when nodes is not an array' , ( ) => {
134+ const arch = { nodes : 'bad' , relationships : [ ] } as unknown as CalmArchitecture ;
135+ const issues = validateCalmArchitecture ( arch ) ;
136+ // Should have schema error but no semantic warnings (orphan, etc.)
137+ const warnings = issues . filter ( ( i ) => i . severity === 'warning' ) ;
138+ expect ( warnings ) . toHaveLength ( 0 ) ;
139+ } ) ;
140+
141+ it ( 'skips semantic rules when relationships is not an array' , ( ) => {
142+ const arch = { nodes : [ ] , relationships : 'bad' } as unknown as CalmArchitecture ;
143+ const issues = validateCalmArchitecture ( arch ) ;
144+ const warnings = issues . filter ( ( i ) => i . severity === 'warning' ) ;
145+ expect ( warnings ) . toHaveLength ( 0 ) ;
146+ } ) ;
147+
148+ it ( 'schema error on relationship extracts relationshipId' , ( ) => {
149+ const arch : CalmArchitecture = {
150+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
151+ relationships : [
152+ {
153+ 'unique-id' : 'rel-bad' ,
154+ 'relationship-type' : '' as never , // minLength violation
155+ source : 'node-a' ,
156+ destination : 'node-a'
157+ } as never
158+ ]
159+ } ;
160+ const issues = validateCalmArchitecture ( arch ) ;
161+ const schemaErrors = issues . filter (
162+ ( i ) => i . severity === 'error' && i . path ?. startsWith ( '/relationships/' )
163+ ) ;
164+ expect ( schemaErrors . length ) . toBeGreaterThan ( 0 ) ;
165+ expect ( schemaErrors [ 0 ] ! . relationshipId ) . toBe ( 'rel-bad' ) ;
166+ } ) ;
167+
168+ it ( 'schema error on node without unique-id does not set nodeId' , ( ) => {
169+ const arch = {
170+ nodes : [ { 'node-type' : '' , name : 'X' } ] , // missing unique-id, empty node-type
171+ relationships : [ ]
172+ } as unknown as CalmArchitecture ;
173+ const issues = validateCalmArchitecture ( arch ) ;
174+ const nodeSchemaErrors = issues . filter (
175+ ( i ) => i . severity === 'error' && i . path ?. startsWith ( '/nodes/' )
176+ ) ;
177+ expect ( nodeSchemaErrors . length ) . toBeGreaterThan ( 0 ) ;
178+ // nodeId should be undefined since the node has no unique-id
179+ expect ( nodeSchemaErrors [ 0 ] ! . nodeId ) . toBeUndefined ( ) ;
180+ } ) ;
181+
182+ it ( 'schema error with empty instancePath omits path prefix in message' , ( ) => {
183+ // Architecture missing required 'nodes' property entirely
184+ const arch = { relationships : [ ] } as unknown as CalmArchitecture ;
185+ const issues = validateCalmArchitecture ( arch ) ;
186+ const errors = issues . filter ( ( i ) => i . severity === 'error' ) ;
187+ expect ( errors . length ) . toBeGreaterThan ( 0 ) ;
188+ // At least one error should have empty path (root-level schema error)
189+ const rootError = errors . find ( ( i ) => i . path === '' ) ;
190+ expect ( rootError ) . toBeDefined ( ) ;
191+ // Message should not start with ':'
192+ expect ( rootError ! . message ) . not . toMatch ( / ^ : / ) ;
193+ } ) ;
194+
195+ it ( 'relationship missing source returns error' , ( ) => {
196+ const arch = {
197+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
198+ relationships : [
199+ { 'unique-id' : 'rel-1' , 'relationship-type' : 'connects' , destination : 'node-a' }
200+ ]
201+ } as unknown as CalmArchitecture ;
202+ const issues = validateCalmArchitecture ( arch ) ;
203+ const errors = issues . filter (
204+ ( i ) => i . severity === 'error' && i . message . includes ( 'missing a source' )
205+ ) ;
206+ expect ( errors . length ) . toBeGreaterThan ( 0 ) ;
207+ expect ( errors [ 0 ] ! . relationshipId ) . toBe ( 'rel-1' ) ;
208+ } ) ;
209+
210+ it ( 'relationship missing destination returns error' , ( ) => {
211+ const arch = {
212+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
213+ relationships : [
214+ { 'unique-id' : 'rel-1' , 'relationship-type' : 'connects' , source : 'node-a' }
215+ ]
216+ } as unknown as CalmArchitecture ;
217+ const issues = validateCalmArchitecture ( arch ) ;
218+ const errors = issues . filter (
219+ ( i ) => i . severity === 'error' && i . message . includes ( 'missing a destination' )
220+ ) ;
221+ expect ( errors . length ) . toBeGreaterThan ( 0 ) ;
222+ expect ( errors [ 0 ] ! . relationshipId ) . toBe ( 'rel-1' ) ;
223+ } ) ;
224+
225+ it ( 'dangling destination reference returns error' , ( ) => {
226+ const arch : CalmArchitecture = {
227+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
228+ relationships : [ makeRel ( 'rel-1' , 'node-a' , 'ghost-node' ) ]
229+ } ;
230+ const issues = validateCalmArchitecture ( arch ) ;
231+ const errors = issues . filter (
232+ ( i ) => i . severity === 'error' && i . message . includes ( 'destination' ) && i . message . includes ( 'ghost-node' )
233+ ) ;
234+ expect ( errors . length ) . toBeGreaterThan ( 0 ) ;
235+ expect ( errors [ 0 ] ! . relationshipId ) . toBe ( 'rel-1' ) ;
236+ } ) ;
237+
238+ it ( 'duplicate relationship unique-ids returns error' , ( ) => {
239+ const arch : CalmArchitecture = {
240+ nodes : [ makeNode ( 'node-a' , 'Node A' ) , makeNode ( 'node-b' , 'Node B' ) ] ,
241+ relationships : [ makeRel ( 'rel-dup' , 'node-a' , 'node-b' ) , makeRel ( 'rel-dup' , 'node-b' , 'node-a' ) ]
242+ } ;
243+ const issues = validateCalmArchitecture ( arch ) ;
244+ const errors = issues . filter (
245+ ( i ) => i . severity === 'error' && i . message . includes ( 'Duplicate relationship' )
246+ ) ;
247+ expect ( errors . length ) . toBeGreaterThan ( 0 ) ;
248+ expect ( errors [ 0 ] ! . relationshipId ) . toBe ( 'rel-dup' ) ;
249+ } ) ;
250+
251+ it ( 'relationship without unique-id skips duplicate check and uses ? in messages' , ( ) => {
252+ const arch = {
253+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
254+ relationships : [
255+ { 'relationship-type' : 'connects' , source : 'node-a' , destination : 'ghost' }
256+ ]
257+ } as unknown as CalmArchitecture ;
258+ const issues = validateCalmArchitecture ( arch ) ;
259+ // Should have dangling ref error with '?' as relId
260+ const danglingErrors = issues . filter (
261+ ( i ) => i . severity === 'error' && i . message . includes ( '"?"' )
262+ ) ;
263+ expect ( danglingErrors . length ) . toBeGreaterThan ( 0 ) ;
264+ // Should NOT have relationshipId set
265+ expect ( danglingErrors [ 0 ] ! . relationshipId ) . toBeUndefined ( ) ;
266+ } ) ;
267+
268+ it ( 'node without unique-id is skipped in duplicate/semantic checks' , ( ) => {
269+ const arch = {
270+ nodes : [
271+ { 'node-type' : 'service' , name : 'No ID' , description : 'desc' } ,
272+ makeNode ( 'node-b' , 'Node B' )
273+ ] ,
274+ relationships : [ ]
275+ } as unknown as CalmArchitecture ;
276+ const issues = validateCalmArchitecture ( arch ) ;
277+ // Should not crash; no duplicate errors for undefined ids
278+ const dupErrors = issues . filter ( ( i ) => i . message . includes ( 'Duplicate node' ) ) ;
279+ expect ( dupErrors ) . toHaveLength ( 0 ) ;
280+ } ) ;
281+
282+ it ( 'node with empty string description returns info' , ( ) => {
283+ const arch : CalmArchitecture = {
284+ nodes : [
285+ { 'unique-id' : 'node-1' , 'node-type' : 'service' as const , name : 'Test' , description : ' ' }
286+ ] ,
287+ relationships : [ ]
288+ } ;
289+ const issues = validateCalmArchitecture ( arch ) ;
290+ const infos = issues . filter (
291+ ( i ) => i . severity === 'info' && i . nodeId === 'node-1' && i . message . includes ( 'no description' )
292+ ) ;
293+ expect ( infos . length ) . toBeGreaterThan ( 0 ) ;
294+ } ) ;
295+
296+ it ( 'dangling source with zero nodes does not report dangling ref' , ( ) => {
297+ // nodeIds.size === 0 branch: skip dangling check
298+ const arch = {
299+ nodes : [ ] ,
300+ relationships : [
301+ { 'unique-id' : 'rel-1' , 'relationship-type' : 'connects' , source : 'x' , destination : 'y' }
302+ ]
303+ } as unknown as CalmArchitecture ;
304+ const issues = validateCalmArchitecture ( arch ) ;
305+ const danglingErrors = issues . filter (
306+ ( i ) => i . severity === 'error' && i . message . includes ( 'does not reference a known node' )
307+ ) ;
308+ expect ( danglingErrors ) . toHaveLength ( 0 ) ;
309+ } ) ;
310+
311+ it ( 'relationship with missing source AND no relId omits relationshipId' , ( ) => {
312+ const arch = {
313+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
314+ relationships : [
315+ { 'relationship-type' : 'connects' , destination : 'node-a' }
316+ ]
317+ } as unknown as CalmArchitecture ;
318+ const issues = validateCalmArchitecture ( arch ) ;
319+ const missingSource = issues . filter (
320+ ( i ) => i . severity === 'error' && i . message . includes ( 'missing a source' )
321+ ) ;
322+ expect ( missingSource . length ) . toBeGreaterThan ( 0 ) ;
323+ expect ( missingSource [ 0 ] ! . relationshipId ) . toBeUndefined ( ) ;
324+ } ) ;
325+
326+ it ( 'relationship with missing destination AND no relId omits relationshipId' , ( ) => {
327+ const arch = {
328+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
329+ relationships : [
330+ { 'relationship-type' : 'connects' , source : 'node-a' }
331+ ]
332+ } as unknown as CalmArchitecture ;
333+ const issues = validateCalmArchitecture ( arch ) ;
334+ const missingDest = issues . filter (
335+ ( i ) => i . severity === 'error' && i . message . includes ( 'missing a destination' )
336+ ) ;
337+ expect ( missingDest . length ) . toBeGreaterThan ( 0 ) ;
338+ expect ( missingDest [ 0 ] ! . relationshipId ) . toBeUndefined ( ) ;
339+ } ) ;
340+
341+ it ( 'self-loop with no relId uses ? and omits relationshipId' , ( ) => {
342+ const arch = {
343+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
344+ relationships : [
345+ { 'relationship-type' : 'connects' , source : 'node-a' , destination : 'node-a' }
346+ ]
347+ } as unknown as CalmArchitecture ;
348+ const issues = validateCalmArchitecture ( arch ) ;
349+ const selfLoop = issues . filter (
350+ ( i ) => i . severity === 'warning' && i . message . includes ( 'connects a node to itself' )
351+ ) ;
352+ expect ( selfLoop . length ) . toBeGreaterThan ( 0 ) ;
353+ expect ( selfLoop [ 0 ] ! . message ) . toContain ( '"?"' ) ;
354+ expect ( selfLoop [ 0 ] ! . relationshipId ) . toBeUndefined ( ) ;
355+ } ) ;
356+
357+ it ( 'issues are sorted by severity: errors first, then warnings, then info' , ( ) => {
358+ const arch : CalmArchitecture = {
359+ nodes : [
360+ { 'unique-id' : 'node-a' , 'node-type' : 'service' as const , name : 'A' } , // info: no desc
361+ { 'unique-id' : 'node-b' , 'node-type' : 'service' as const , name : 'B' } , // warning: orphan, info: no desc
362+ ] ,
363+ relationships : [ makeRel ( 'rel-1' , 'node-a' , 'ghost' ) ] // error: dangling dest
364+ } ;
365+ const issues = validateCalmArchitecture ( arch ) ;
366+ expect ( issues . length ) . toBeGreaterThanOrEqual ( 3 ) ;
367+ const severities = issues . map ( ( i ) => i . severity ) ;
368+ const errorIdx = severities . indexOf ( 'error' ) ;
369+ const warnIdx = severities . indexOf ( 'warning' ) ;
370+ const infoIdx = severities . indexOf ( 'info' ) ;
371+ if ( errorIdx >= 0 && warnIdx >= 0 ) expect ( errorIdx ) . toBeLessThan ( warnIdx ) ;
372+ if ( warnIdx >= 0 && infoIdx >= 0 ) expect ( warnIdx ) . toBeLessThan ( infoIdx ) ;
373+ } ) ;
374+
375+ it ( 'schema error on relationship without unique-id does not set relationshipId' , ( ) => {
376+ const arch = {
377+ nodes : [ ] ,
378+ relationships : [
379+ { 'relationship-type' : '' , source : 'a' , destination : 'b' } // minLength violation, no unique-id
380+ ]
381+ } as unknown as CalmArchitecture ;
382+ const issues = validateCalmArchitecture ( arch ) ;
383+ const relSchemaErrors = issues . filter (
384+ ( i ) => i . severity === 'error' && i . path ?. startsWith ( '/relationships/' )
385+ ) ;
386+ expect ( relSchemaErrors . length ) . toBeGreaterThan ( 0 ) ;
387+ expect ( relSchemaErrors [ 0 ] ! . relationshipId ) . toBeUndefined ( ) ;
388+ } ) ;
389+
390+ it ( 'dangling source ref without relId uses ? and omits relationshipId' , ( ) => {
391+ const arch = {
392+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
393+ relationships : [
394+ { 'relationship-type' : 'connects' , source : 'ghost' , destination : 'node-a' }
395+ ]
396+ } as unknown as CalmArchitecture ;
397+ const issues = validateCalmArchitecture ( arch ) ;
398+ const dangling = issues . filter (
399+ ( i ) => i . severity === 'error' && i . message . includes ( 'source' ) && i . message . includes ( 'ghost' )
400+ ) ;
401+ expect ( dangling . length ) . toBeGreaterThan ( 0 ) ;
402+ expect ( dangling [ 0 ] ! . message ) . toContain ( '"?"' ) ;
403+ expect ( dangling [ 0 ] ! . relationshipId ) . toBeUndefined ( ) ;
404+ } ) ;
405+
406+ it ( 'dangling destination ref without relId uses ? and omits relationshipId' , ( ) => {
407+ const arch = {
408+ nodes : [ makeNode ( 'node-a' , 'Node A' ) ] ,
409+ relationships : [
410+ { 'relationship-type' : 'connects' , source : 'node-a' , destination : 'ghost' }
411+ ]
412+ } as unknown as CalmArchitecture ;
413+ const issues = validateCalmArchitecture ( arch ) ;
414+ const dangling = issues . filter (
415+ ( i ) => i . severity === 'error' && i . message . includes ( 'destination' ) && i . message . includes ( 'ghost' )
416+ ) ;
417+ expect ( dangling . length ) . toBeGreaterThan ( 0 ) ;
418+ expect ( dangling [ 0 ] ! . message ) . toContain ( '"?"' ) ;
419+ expect ( dangling [ 0 ] ! . relationshipId ) . toBeUndefined ( ) ;
420+ } ) ;
421+
133422 it ( 'ValidationIssue includes optional path, nodeId, relationshipId fields' , ( ) => {
134423 // Ensure the type has the right shape — compile-time check via TypeScript
135424 const issue : ValidationIssue = {
0 commit comments