@transitrix/diagrams 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/dist/__tests__/yaml-normalize.test.js +9 -9
  2. package/dist/activities/__tests__/validate.test.js +77 -74
  3. package/dist/activities/validate.js +5 -12
  4. package/dist/activity-card/__tests__/example.test.js +3 -5
  5. package/dist/activity-card/__tests__/layout.test.js +2 -9
  6. package/dist/activity-card/__tests__/resolver.test.js +25 -0
  7. package/dist/activity-card/__tests__/validate.test.js +29 -18
  8. package/dist/activity-card/layout.js +1 -7
  9. package/dist/activity-card/resolver.js +11 -2
  10. package/dist/activity-card/types.d.ts +1 -1
  11. package/dist/activity-card/validate.js +3 -10
  12. package/dist/actor/__tests__/validate.test.js +6 -0
  13. package/dist/actor/types.d.ts +1 -1
  14. package/dist/actor/types.js +2 -0
  15. package/dist/applications/types.d.ts +7 -0
  16. package/dist/applications/validate.js +18 -0
  17. package/dist/blocks/__tests__/layout.test.js +1 -1
  18. package/dist/blocks/layout.js +1 -1
  19. package/dist/business-service/__tests__/validate.test.d.ts +1 -0
  20. package/dist/business-service/__tests__/validate.test.js +135 -0
  21. package/dist/business-service/index.d.ts +3 -0
  22. package/dist/business-service/index.js +2 -0
  23. package/dist/business-service/types.d.ts +20 -0
  24. package/dist/business-service/types.js +3 -0
  25. package/dist/business-service/validate.d.ts +2 -0
  26. package/dist/business-service/validate.js +74 -0
  27. package/dist/fgca/__tests__/parse-canonical.test.js +24 -11
  28. package/dist/fgca/__tests__/resolver.test.js +7 -7
  29. package/dist/fgca/__tests__/validate.test.js +11 -11
  30. package/dist/fgca/parse-canonical.js +18 -32
  31. package/dist/fgca/preview-layout.js +50 -1
  32. package/dist/fgca/resolver.d.ts +2 -2
  33. package/dist/fgca/resolver.js +5 -5
  34. package/dist/fgca/validate.js +4 -13
  35. package/dist/index.d.ts +5 -0
  36. package/dist/index.js +5 -0
  37. package/dist/integration/__tests__/validate.test.d.ts +1 -0
  38. package/dist/integration/__tests__/validate.test.js +169 -0
  39. package/dist/integration/index.d.ts +2 -0
  40. package/dist/integration/index.js +1 -0
  41. package/dist/integration/types.d.ts +27 -0
  42. package/dist/integration/types.js +5 -0
  43. package/dist/integration/validate.d.ts +2 -0
  44. package/dist/integration/validate.js +94 -0
  45. package/dist/location/__tests__/validate.test.d.ts +1 -0
  46. package/dist/location/__tests__/validate.test.js +107 -0
  47. package/dist/location/index.d.ts +3 -0
  48. package/dist/location/index.js +2 -0
  49. package/dist/location/types.d.ts +20 -0
  50. package/dist/location/types.js +3 -0
  51. package/dist/location/validate.d.ts +2 -0
  52. package/dist/location/validate.js +63 -0
  53. package/dist/node/__tests__/validate.test.d.ts +1 -0
  54. package/dist/node/__tests__/validate.test.js +100 -0
  55. package/dist/node/index.d.ts +2 -0
  56. package/dist/node/index.js +1 -0
  57. package/dist/node/types.d.ts +18 -0
  58. package/dist/node/types.js +3 -0
  59. package/dist/node/validate.d.ts +2 -0
  60. package/dist/node/validate.js +53 -0
  61. package/dist/repo-validate/__tests__/validate-repo.test.js +444 -0
  62. package/dist/repo-validate/validate-repo.js +224 -10
  63. package/dist/technology-service/__tests__/validate.test.d.ts +1 -0
  64. package/dist/technology-service/__tests__/validate.test.js +121 -0
  65. package/dist/technology-service/index.d.ts +2 -0
  66. package/dist/technology-service/index.js +1 -0
  67. package/dist/technology-service/types.d.ts +18 -0
  68. package/dist/technology-service/types.js +3 -0
  69. package/dist/technology-service/validate.d.ts +2 -0
  70. package/dist/technology-service/validate.js +64 -0
  71. package/dist/webview/__tests__/entry-notations.test.js +2 -4
  72. package/dist/webview/__tests__/render-activities.test.d.ts +1 -0
  73. package/dist/webview/__tests__/render-activities.test.js +91 -0
  74. package/dist/webview/__tests__/render-blocks.test.d.ts +1 -0
  75. package/dist/webview/__tests__/render-blocks.test.js +130 -0
  76. package/dist/webview/entry.d.ts +1 -1
  77. package/dist/webview/entry.js +8 -15
  78. package/dist/webview/render-activities.d.ts +12 -0
  79. package/dist/webview/render-activities.js +9 -3
  80. package/dist/webview/render-activity-card.js +2 -3
  81. package/dist/webview/render-blocks.js +97 -18
  82. package/dist/webview/render-fgca.js +1 -1
  83. package/dist/webview/render-goals.js +28 -5
  84. package/package.json +1 -1
  85. package/src/__tests__/yaml-normalize.test.ts +11 -11
  86. package/src/activities/__tests__/validate.test.ts +77 -74
  87. package/src/activities/validate.ts +5 -12
  88. package/src/activity-card/__tests__/example.test.ts +3 -5
  89. package/src/activity-card/__tests__/layout.test.ts +2 -9
  90. package/src/activity-card/__tests__/resolver.test.ts +27 -0
  91. package/src/activity-card/__tests__/validate.test.ts +30 -18
  92. package/src/activity-card/layout.ts +1 -8
  93. package/src/activity-card/resolver.ts +11 -2
  94. package/src/activity-card/types.ts +1 -1
  95. package/src/activity-card/validate.ts +3 -9
  96. package/src/actor/__tests__/validate.test.ts +8 -0
  97. package/src/actor/types.ts +2 -0
  98. package/src/applications/types.ts +8 -0
  99. package/src/applications/validate.ts +20 -0
  100. package/src/blocks/__tests__/layout.test.ts +1 -1
  101. package/src/blocks/layout.ts +1 -1
  102. package/src/business-service/__tests__/validate.test.ts +160 -0
  103. package/src/business-service/index.ts +3 -0
  104. package/src/business-service/types.ts +29 -0
  105. package/src/business-service/validate.ts +82 -0
  106. package/src/fgca/__tests__/parse-canonical.test.ts +25 -11
  107. package/src/fgca/__tests__/resolver.test.ts +7 -7
  108. package/src/fgca/__tests__/validate.test.ts +11 -11
  109. package/src/fgca/parse-canonical.ts +18 -38
  110. package/src/fgca/preview-layout.ts +53 -1
  111. package/src/fgca/resolver.ts +5 -5
  112. package/src/fgca/validate.ts +4 -12
  113. package/src/index.ts +5 -0
  114. package/src/integration/__tests__/validate.test.ts +203 -0
  115. package/src/integration/index.ts +2 -0
  116. package/src/integration/types.ts +38 -0
  117. package/src/integration/validate.ts +104 -0
  118. package/src/location/__tests__/validate.test.ts +127 -0
  119. package/src/location/index.ts +3 -0
  120. package/src/location/types.ts +29 -0
  121. package/src/location/validate.ts +70 -0
  122. package/src/node/__tests__/validate.test.ts +118 -0
  123. package/src/node/index.ts +2 -0
  124. package/src/node/types.ts +27 -0
  125. package/src/node/validate.ts +59 -0
  126. package/src/repo-validate/__tests__/validate-repo.test.ts +550 -0
  127. package/src/repo-validate/validate-repo.ts +233 -10
  128. package/src/technology-service/__tests__/validate.test.ts +143 -0
  129. package/src/technology-service/index.ts +2 -0
  130. package/src/technology-service/types.ts +27 -0
  131. package/src/technology-service/validate.ts +71 -0
  132. package/src/webview/__tests__/entry-notations.test.ts +2 -4
  133. package/src/webview/__tests__/render-activities.test.ts +100 -0
  134. package/src/webview/__tests__/render-blocks.test.ts +155 -0
  135. package/src/webview/entry.ts +10 -19
  136. package/src/webview/render-activities.ts +29 -4
  137. package/src/webview/render-activity-card.ts +2 -3
  138. package/src/webview/render-blocks.ts +101 -20
  139. package/src/webview/render-fgca.ts +1 -1
  140. package/src/webview/render-goals.ts +26 -5
@@ -147,3 +147,553 @@ describe('validateRepoModel — syntax', () => {
147
147
  expect(findings[0].message).toContain('broken.yaml');
148
148
  });
149
149
  });
150
+
151
+ describe('validateRepoModel — layer-semantics (unit_located_at / located_at)', () => {
152
+ function locationModel() {
153
+ const model = cleanModel();
154
+ model.elements.push(
155
+ el('canon/elements/02_business/actors/ACTOR-OPS-1.yaml', {
156
+ notation: 'actor',
157
+ id: 'ACTOR-OPS-1',
158
+ type: 'business_unit',
159
+ }),
160
+ el('canon/elements/02_business/actors/ACTOR-JANE-1.yaml', {
161
+ notation: 'actor',
162
+ id: 'ACTOR-JANE-1',
163
+ type: 'person',
164
+ }),
165
+ el('canon/elements/02_business/locations/LOCATION-TBILISI-1.yaml', {
166
+ notation: 'location',
167
+ id: 'LOCATION-TBILISI-1',
168
+ }),
169
+ );
170
+ return model;
171
+ }
172
+
173
+ it('accepts a valid unit_located_at (business_unit → LOCATION)', () => {
174
+ const model = locationModel();
175
+ model.relations.push(
176
+ el('canon/relations/REL-LOC-1.yaml', {
177
+ notation: 'relation',
178
+ id: 'REL-LOC-1',
179
+ type: 'unit_located_at',
180
+ from: 'ACTOR-OPS-1',
181
+ to: 'LOCATION-TBILISI-1',
182
+ }),
183
+ );
184
+ expect(validateRepoModel(model)).toEqual([]);
185
+ });
186
+
187
+ it('accepts a valid located_at for a business_unit', () => {
188
+ const model = locationModel();
189
+ model.relations.push(
190
+ el('canon/relations/REL-LOC-2.yaml', {
191
+ notation: 'relation',
192
+ id: 'REL-LOC-2',
193
+ type: 'located_at',
194
+ from: 'ACTOR-OPS-1',
195
+ to: 'LOCATION-TBILISI-1',
196
+ }),
197
+ );
198
+ expect(validateRepoModel(model)).toEqual([]);
199
+ });
200
+
201
+ it('accepts a valid located_at for a person', () => {
202
+ const model = locationModel();
203
+ model.relations.push(
204
+ el('canon/relations/REL-LOC-3.yaml', {
205
+ notation: 'relation',
206
+ id: 'REL-LOC-3',
207
+ type: 'located_at',
208
+ from: 'ACTOR-JANE-1',
209
+ to: 'LOCATION-TBILISI-1',
210
+ }),
211
+ );
212
+ expect(validateRepoModel(model)).toEqual([]);
213
+ });
214
+
215
+ it('flags unit_located_at with a wrong from type (person instead of business_unit)', () => {
216
+ const model = locationModel();
217
+ model.relations.push(
218
+ el('canon/relations/REL-BAD-1.yaml', {
219
+ notation: 'relation',
220
+ id: 'REL-BAD-1',
221
+ type: 'unit_located_at',
222
+ from: 'ACTOR-JANE-1',
223
+ to: 'LOCATION-TBILISI-1',
224
+ }),
225
+ );
226
+ const findings = validateRepoModel(model);
227
+ expect(findings).toHaveLength(1);
228
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-BAD-1' });
229
+ expect(findings[0].message).toContain('unit_located_at');
230
+ expect(findings[0].message).toContain('business_unit');
231
+ });
232
+
233
+ it('flags unit_located_at with a non-ACTOR from element', () => {
234
+ const model = locationModel();
235
+ model.relations.push(
236
+ el('canon/relations/REL-BAD-2.yaml', {
237
+ notation: 'relation',
238
+ id: 'REL-BAD-2',
239
+ type: 'unit_located_at',
240
+ from: 'GOAL-OPS-1',
241
+ to: 'LOCATION-TBILISI-1',
242
+ }),
243
+ );
244
+ const findings = validateRepoModel(model);
245
+ expect(findings).toHaveLength(1);
246
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-BAD-2' });
247
+ expect(findings[0].message).toContain('unit_located_at');
248
+ });
249
+
250
+ it('flags unit_located_at with a non-LOCATION to element', () => {
251
+ const model = locationModel();
252
+ model.relations.push(
253
+ el('canon/relations/REL-BAD-3.yaml', {
254
+ notation: 'relation',
255
+ id: 'REL-BAD-3',
256
+ type: 'unit_located_at',
257
+ from: 'ACTOR-OPS-1',
258
+ to: 'GOAL-OPS-1',
259
+ }),
260
+ );
261
+ const findings = validateRepoModel(model);
262
+ expect(findings).toHaveLength(1);
263
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-BAD-3' });
264
+ expect(findings[0].message).toContain('LOCATION');
265
+ });
266
+
267
+ it('flags located_at with a non-actor from element', () => {
268
+ const model = locationModel();
269
+ model.relations.push(
270
+ el('canon/relations/REL-BAD-4.yaml', {
271
+ notation: 'relation',
272
+ id: 'REL-BAD-4',
273
+ type: 'located_at',
274
+ from: 'CAPABILITY-V1',
275
+ to: 'LOCATION-TBILISI-1',
276
+ }),
277
+ );
278
+ const findings = validateRepoModel(model);
279
+ expect(findings).toHaveLength(1);
280
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-BAD-4' });
281
+ expect(findings[0].message).toContain('located_at');
282
+ });
283
+
284
+ it('does not emit layer-semantic findings for unknown endpoints (ref-integrity handles those)', () => {
285
+ const model = locationModel();
286
+ model.relations.push(
287
+ el('canon/relations/REL-MISSING.yaml', {
288
+ notation: 'relation',
289
+ id: 'REL-MISSING',
290
+ type: 'unit_located_at',
291
+ from: 'ACTOR-UNKNOWN-99',
292
+ to: 'LOCATION-UNKNOWN-99',
293
+ }),
294
+ );
295
+ const findings = validateRepoModel(model);
296
+ // ref-integrity fires for missing endpoints; no duplicate layer-semantic finding
297
+ expect(findings.every((f) => !f.message.includes('Layer-semantics'))).toBe(true);
298
+ expect(findings.some((f) => f.message.includes('ACTOR-UNKNOWN-99'))).toBe(true);
299
+ });
300
+ });
301
+
302
+ describe('validateRepoModel — layer-semantics (offers / realizes)', () => {
303
+ function serviceModel() {
304
+ const model = cleanModel();
305
+ model.elements.push(
306
+ el('canon/elements/02_business/actors/ACTOR-OPS-1.yaml', {
307
+ notation: 'actor',
308
+ id: 'ACTOR-OPS-1',
309
+ type: 'business_unit',
310
+ }),
311
+ el('canon/elements/02_business/roles/ROLE-OWNER-1.yaml', {
312
+ notation: 'role',
313
+ id: 'ROLE-OWNER-1',
314
+ }),
315
+ el('canon/elements/02_business/business-services/BUSINESS_SERVICE-CRM-1.yaml', {
316
+ notation: 'business-service',
317
+ id: 'BUSINESS_SERVICE-CRM-1',
318
+ }),
319
+ el('canon/elements/02_business/capabilities/CAPABILITY-V2.yaml', {
320
+ notation: 'capability',
321
+ id: 'CAPABILITY-V2',
322
+ }),
323
+ );
324
+ return model;
325
+ }
326
+
327
+ it('accepts a valid offers (ACTOR(business_unit) → BUSINESS_SERVICE)', () => {
328
+ const model = serviceModel();
329
+ model.relations.push(
330
+ el('canon/relations/REL-SVC-1.yaml', {
331
+ notation: 'relation',
332
+ id: 'REL-SVC-1',
333
+ type: 'offers',
334
+ from: 'ACTOR-OPS-1',
335
+ to: 'BUSINESS_SERVICE-CRM-1',
336
+ }),
337
+ );
338
+ expect(validateRepoModel(model)).toEqual([]);
339
+ });
340
+
341
+ it('accepts a valid offers (ROLE → BUSINESS_SERVICE)', () => {
342
+ const model = serviceModel();
343
+ model.relations.push(
344
+ el('canon/relations/REL-SVC-2.yaml', {
345
+ notation: 'relation',
346
+ id: 'REL-SVC-2',
347
+ type: 'offers',
348
+ from: 'ROLE-OWNER-1',
349
+ to: 'BUSINESS_SERVICE-CRM-1',
350
+ }),
351
+ );
352
+ expect(validateRepoModel(model)).toEqual([]);
353
+ });
354
+
355
+ it('accepts a valid realizes (BUSINESS_SERVICE → CAPABILITY)', () => {
356
+ const model = serviceModel();
357
+ model.relations.push(
358
+ el('canon/relations/REL-SVC-3.yaml', {
359
+ notation: 'relation',
360
+ id: 'REL-SVC-3',
361
+ type: 'realizes',
362
+ from: 'BUSINESS_SERVICE-CRM-1',
363
+ to: 'CAPABILITY-V2',
364
+ }),
365
+ );
366
+ expect(validateRepoModel(model)).toEqual([]);
367
+ });
368
+
369
+ it('flags offers with a non-ACTOR/non-ROLE from element', () => {
370
+ const model = serviceModel();
371
+ model.relations.push(
372
+ el('canon/relations/REL-SVC-BAD-1.yaml', {
373
+ notation: 'relation',
374
+ id: 'REL-SVC-BAD-1',
375
+ type: 'offers',
376
+ from: 'CAPABILITY-V2',
377
+ to: 'BUSINESS_SERVICE-CRM-1',
378
+ }),
379
+ );
380
+ const findings = validateRepoModel(model);
381
+ expect(findings).toHaveLength(1);
382
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-SVC-BAD-1' });
383
+ expect(findings[0].message).toContain('offers');
384
+ expect(findings[0].message).toContain('ACTOR');
385
+ });
386
+
387
+ it('flags offers with a non-BUSINESS_SERVICE to element', () => {
388
+ const model = serviceModel();
389
+ model.relations.push(
390
+ el('canon/relations/REL-SVC-BAD-2.yaml', {
391
+ notation: 'relation',
392
+ id: 'REL-SVC-BAD-2',
393
+ type: 'offers',
394
+ from: 'ACTOR-OPS-1',
395
+ to: 'CAPABILITY-V2',
396
+ }),
397
+ );
398
+ const findings = validateRepoModel(model);
399
+ expect(findings).toHaveLength(1);
400
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-SVC-BAD-2' });
401
+ expect(findings[0].message).toContain('BUSINESS_SERVICE');
402
+ });
403
+
404
+ it('flags realizes with a non-BUSINESS_SERVICE from element', () => {
405
+ const model = serviceModel();
406
+ model.relations.push(
407
+ el('canon/relations/REL-SVC-BAD-3.yaml', {
408
+ notation: 'relation',
409
+ id: 'REL-SVC-BAD-3',
410
+ type: 'realizes',
411
+ from: 'ACTOR-OPS-1',
412
+ to: 'CAPABILITY-V2',
413
+ }),
414
+ );
415
+ const findings = validateRepoModel(model);
416
+ expect(findings).toHaveLength(1);
417
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-SVC-BAD-3' });
418
+ expect(findings[0].message).toContain('realizes');
419
+ expect(findings[0].message).toContain('BUSINESS_SERVICE');
420
+ });
421
+ });
422
+
423
+ describe('validateRepoModel — layer-semantics (INT-002 integration endpoints)', () => {
424
+ function integrationModel() {
425
+ const model = cleanModel();
426
+ model.elements.push(
427
+ el('canon/elements/03_application/applications/APPLICATION-OMS-1.yaml', {
428
+ notation: 'application',
429
+ id: 'APPLICATION-OMS-1',
430
+ }),
431
+ el('canon/elements/03_application/applications/APPLICATION-CRM-1.yaml', {
432
+ notation: 'application',
433
+ id: 'APPLICATION-CRM-1',
434
+ }),
435
+ el('canon/elements/02_business/actors/ACTOR-OPS-1.yaml', {
436
+ notation: 'actor',
437
+ id: 'ACTOR-OPS-1',
438
+ type: 'business_unit',
439
+ }),
440
+ );
441
+ return model;
442
+ }
443
+
444
+ it('accepts an interface_semantics integration whose endpoints resolve to APPLICATION elements', () => {
445
+ const model = integrationModel();
446
+ model.elements.push(
447
+ el('canon/elements/03_application/integrations/INTEGRATION-OMS-EVENTS-1.yaml', {
448
+ notation: 'integration',
449
+ id: 'INTEGRATION-OMS-EVENTS-1',
450
+ interface_semantics: true,
451
+ source: 'APPLICATION-OMS-1',
452
+ target: 'APPLICATION-CRM-1',
453
+ }),
454
+ );
455
+ expect(validateRepoModel(model)).toEqual([]);
456
+ });
457
+
458
+ it('does not fire INT-002 for an integration without interface_semantics', () => {
459
+ const model = integrationModel();
460
+ model.elements.push(
461
+ el('canon/elements/03_application/integrations/INTEGRATION-PLAIN-1.yaml', {
462
+ notation: 'integration',
463
+ id: 'INTEGRATION-PLAIN-1',
464
+ source: 'ACTOR-OPS-1',
465
+ target: 'APPLICATION-CRM-1',
466
+ }),
467
+ );
468
+ expect(validateRepoModel(model)).toEqual([]);
469
+ });
470
+
471
+ it('flags INT-002 when source resolves to a non-APPLICATION element', () => {
472
+ const model = integrationModel();
473
+ model.elements.push(
474
+ el('canon/elements/03_application/integrations/INTEGRATION-BAD-1.yaml', {
475
+ notation: 'integration',
476
+ id: 'INTEGRATION-BAD-1',
477
+ interface_semantics: true,
478
+ source: 'ACTOR-OPS-1',
479
+ target: 'APPLICATION-CRM-1',
480
+ }),
481
+ );
482
+ const findings = validateRepoModel(model);
483
+ expect(findings).toHaveLength(1);
484
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'INTEGRATION-BAD-1' });
485
+ expect(findings[0].message).toContain('INT-002');
486
+ expect(findings[0].message).toContain('ACTOR-OPS-1');
487
+ expect(findings[0].message).toContain('source');
488
+ });
489
+
490
+ it('flags INT-002 when target resolves to a non-APPLICATION element', () => {
491
+ const model = integrationModel();
492
+ model.elements.push(
493
+ el('canon/elements/03_application/integrations/INTEGRATION-BAD-2.yaml', {
494
+ notation: 'integration',
495
+ id: 'INTEGRATION-BAD-2',
496
+ interface_semantics: true,
497
+ source: 'APPLICATION-OMS-1',
498
+ target: 'ACTOR-OPS-1',
499
+ }),
500
+ );
501
+ const findings = validateRepoModel(model);
502
+ expect(findings).toHaveLength(1);
503
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'INTEGRATION-BAD-2' });
504
+ expect(findings[0].message).toContain('INT-002');
505
+ expect(findings[0].message).toContain('ACTOR-OPS-1');
506
+ expect(findings[0].message).toContain('target');
507
+ });
508
+
509
+ it('flags INT-002 for both endpoints when neither resolves to APPLICATION', () => {
510
+ const model = integrationModel();
511
+ model.elements.push(
512
+ el('canon/elements/03_application/integrations/INTEGRATION-BAD-3.yaml', {
513
+ notation: 'integration',
514
+ id: 'INTEGRATION-BAD-3',
515
+ interface_semantics: true,
516
+ source: 'ACTOR-OPS-1',
517
+ target: 'GOAL-OPS-1',
518
+ }),
519
+ );
520
+ const findings = validateRepoModel(model);
521
+ expect(findings).toHaveLength(2);
522
+ expect(findings.every(f => f.message.includes('INT-002'))).toBe(true);
523
+ });
524
+
525
+ it('does not flag INT-002 when endpoint id is not in the element registry', () => {
526
+ // Unresolved IDs are caught by phase 4 (referential integrity), not INT-002.
527
+ const model = integrationModel();
528
+ model.elements.push(
529
+ el('canon/elements/03_application/integrations/INTEGRATION-UNRESOLVED-1.yaml', {
530
+ notation: 'integration',
531
+ id: 'INTEGRATION-UNRESOLVED-1',
532
+ interface_semantics: true,
533
+ source: 'APPLICATION-MISSING-99',
534
+ target: 'APPLICATION-CRM-1',
535
+ }),
536
+ );
537
+ const findings = validateRepoModel(model).filter(f => f.message.includes('INT-002'));
538
+ expect(findings).toHaveLength(0);
539
+ });
540
+ });
541
+
542
+ describe('validateRepoModel — layer-semantics (hosts / uses / TSVC-003)', () => {
543
+ function techModel() {
544
+ const model = cleanModel();
545
+ model.elements.push(
546
+ el('canon/elements/04_technology/nodes/NODE-KAFKA-HOST-1.yaml', {
547
+ notation: 'node',
548
+ id: 'NODE-KAFKA-HOST-1',
549
+ type: 'cloud_instance',
550
+ }),
551
+ el('canon/elements/04_technology/services/TECHNOLOGY_SERVICE-KAFKA-1.yaml', {
552
+ notation: 'technology-service',
553
+ id: 'TECHNOLOGY_SERVICE-KAFKA-1',
554
+ type: 'messaging',
555
+ node: 'NODE-KAFKA-HOST-1',
556
+ }),
557
+ el('canon/elements/03_application/applications/APPLICATION-OMS-1.yaml', {
558
+ notation: 'application',
559
+ id: 'APPLICATION-OMS-1',
560
+ }),
561
+ );
562
+ return model;
563
+ }
564
+
565
+ it('accepts a valid hosts relation (NODE → TECHNOLOGY_SERVICE)', () => {
566
+ const model = techModel();
567
+ model.relations.push(
568
+ el('canon/relations/REL-HOSTS-1.yaml', {
569
+ notation: 'relation',
570
+ id: 'REL-HOSTS-1',
571
+ type: 'hosts',
572
+ from: 'NODE-KAFKA-HOST-1',
573
+ to: 'TECHNOLOGY_SERVICE-KAFKA-1',
574
+ }),
575
+ );
576
+ expect(validateRepoModel(model)).toEqual([]);
577
+ });
578
+
579
+ it('accepts a valid uses relation (APPLICATION → TECHNOLOGY_SERVICE)', () => {
580
+ const model = techModel();
581
+ model.relations.push(
582
+ el('canon/relations/REL-USES-1.yaml', {
583
+ notation: 'relation',
584
+ id: 'REL-USES-1',
585
+ type: 'uses',
586
+ from: 'APPLICATION-OMS-1',
587
+ to: 'TECHNOLOGY_SERVICE-KAFKA-1',
588
+ }),
589
+ );
590
+ expect(validateRepoModel(model)).toEqual([]);
591
+ });
592
+
593
+ it('flags hosts with a non-NODE from element', () => {
594
+ const model = techModel();
595
+ model.relations.push(
596
+ el('canon/relations/REL-HOSTS-BAD-1.yaml', {
597
+ notation: 'relation',
598
+ id: 'REL-HOSTS-BAD-1',
599
+ type: 'hosts',
600
+ from: 'APPLICATION-OMS-1',
601
+ to: 'TECHNOLOGY_SERVICE-KAFKA-1',
602
+ }),
603
+ );
604
+ const findings = validateRepoModel(model);
605
+ expect(findings).toHaveLength(1);
606
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-HOSTS-BAD-1' });
607
+ expect(findings[0].message).toContain('hosts');
608
+ expect(findings[0].message).toContain('NODE');
609
+ });
610
+
611
+ it('flags hosts with a non-TECHNOLOGY_SERVICE to element', () => {
612
+ const model = techModel();
613
+ model.relations.push(
614
+ el('canon/relations/REL-HOSTS-BAD-2.yaml', {
615
+ notation: 'relation',
616
+ id: 'REL-HOSTS-BAD-2',
617
+ type: 'hosts',
618
+ from: 'NODE-KAFKA-HOST-1',
619
+ to: 'APPLICATION-OMS-1',
620
+ }),
621
+ );
622
+ const findings = validateRepoModel(model);
623
+ expect(findings).toHaveLength(1);
624
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-HOSTS-BAD-2' });
625
+ expect(findings[0].message).toContain('hosts');
626
+ expect(findings[0].message).toContain('TECHNOLOGY_SERVICE');
627
+ });
628
+
629
+ it('flags uses with a non-APPLICATION from element', () => {
630
+ const model = techModel();
631
+ model.relations.push(
632
+ el('canon/relations/REL-USES-BAD-1.yaml', {
633
+ notation: 'relation',
634
+ id: 'REL-USES-BAD-1',
635
+ type: 'uses',
636
+ from: 'NODE-KAFKA-HOST-1',
637
+ to: 'TECHNOLOGY_SERVICE-KAFKA-1',
638
+ }),
639
+ );
640
+ const findings = validateRepoModel(model);
641
+ expect(findings).toHaveLength(1);
642
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-USES-BAD-1' });
643
+ expect(findings[0].message).toContain('uses');
644
+ expect(findings[0].message).toContain('APPLICATION');
645
+ });
646
+
647
+ it('flags uses with a non-TECHNOLOGY_SERVICE to element', () => {
648
+ const model = techModel();
649
+ model.relations.push(
650
+ el('canon/relations/REL-USES-BAD-2.yaml', {
651
+ notation: 'relation',
652
+ id: 'REL-USES-BAD-2',
653
+ type: 'uses',
654
+ from: 'APPLICATION-OMS-1',
655
+ to: 'NODE-KAFKA-HOST-1',
656
+ }),
657
+ );
658
+ const findings = validateRepoModel(model);
659
+ expect(findings).toHaveLength(1);
660
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'REL-USES-BAD-2' });
661
+ expect(findings[0].message).toContain('uses');
662
+ expect(findings[0].message).toContain('TECHNOLOGY_SERVICE');
663
+ });
664
+
665
+ it('TSVC-003: does not flag a technology-service whose node resolves to a NODE element', () => {
666
+ const model = techModel();
667
+ expect(validateRepoModel(model)).toEqual([]);
668
+ });
669
+
670
+ it('TSVC-003: flags a technology-service whose node resolves to a non-NODE element', () => {
671
+ const model = techModel();
672
+ model.elements.push(
673
+ el('canon/elements/04_technology/services/TECHNOLOGY_SERVICE-BAD-1.yaml', {
674
+ notation: 'technology-service',
675
+ id: 'TECHNOLOGY_SERVICE-BAD-1',
676
+ type: 'messaging',
677
+ node: 'APPLICATION-OMS-1',
678
+ }),
679
+ );
680
+ const findings = validateRepoModel(model).filter(f => f.message.includes('TSVC-003'));
681
+ expect(findings).toHaveLength(1);
682
+ expect(findings[0]).toMatchObject({ scope: 'repo', id: 'TECHNOLOGY_SERVICE-BAD-1' });
683
+ expect(findings[0].message).toContain('APPLICATION-OMS-1');
684
+ });
685
+
686
+ it('TSVC-003: does not flag a technology-service with an unresolved node (phase 4 handles that)', () => {
687
+ const model = techModel();
688
+ model.elements.push(
689
+ el('canon/elements/04_technology/services/TECHNOLOGY_SERVICE-UNRESOLVED-1.yaml', {
690
+ notation: 'technology-service',
691
+ id: 'TECHNOLOGY_SERVICE-UNRESOLVED-1',
692
+ type: 'storage',
693
+ node: 'NODE-MISSING-99',
694
+ }),
695
+ );
696
+ const findings = validateRepoModel(model).filter(f => f.message.includes('TSVC-003'));
697
+ expect(findings).toHaveLength(0);
698
+ });
699
+ });