@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
@@ -141,17 +141,240 @@ function checkReferentialIntegrity(input: RepoModelInput, findings: RepoFinding[
141
141
  }
142
142
  }
143
143
 
144
- /** Phase 5 — ArchiMate layer-semantics on relations.
144
+ /** Phase 5 — ArchiMate layer-semantics on relations and elements.
145
145
  *
146
- * lint.py ships `_check_semantic_rules` as a no-op stub (`pass`). It is ported
147
- * here faithfully as a no-op so repo-scope validation reaches parity with
148
- * lint.py on the reference fixture. Real layer-semantics rules are DEFERRED
149
- * until the methodology defines them; when it does, they land here (a relation
150
- * whose `type` is illegal between its endpoints' ArchiMate layers). Kept as a
151
- * named hook so the intent — and the gap — is explicit rather than missing. */
152
- function checkLayerSemantics(_input: RepoModelInput, _findings: RepoFinding[]): void {
153
- // Intentionally empty — parity with lint.py's stubbed phase 5. Do not add
154
- // rules here without coordinating the methodology semantics canon.
146
+ * Enforces endpoint-type constraints for relation kinds whose methodology
147
+ * semantics are formally defined. Rules added here must have a corresponding
148
+ * entry in methodology `notations/elements/17-relations.md`; do not add
149
+ * speculative rules.
150
+ *
151
+ * Implemented relation kinds:
152
+ * unit_located_at — ACTOR(business_unit) → LOCATION (21-locations.md)
153
+ * located_at — ACTOR(person|business_unit) → LOCATION (canonical form)
154
+ * offers — ACTOR(business_unit)|ROLE → BUSINESS_SERVICE (25-business-services.md)
155
+ * realizes — BUSINESS_SERVICE → CAPABILITY (25-business-services.md)
156
+ * hosts — NODE → TECHNOLOGY_SERVICE (25-nodes.md / 26-technology-services.md)
157
+ * uses — APPLICATION → TECHNOLOGY_SERVICE (26-technology-services.md)
158
+ *
159
+ * Implemented element checks:
160
+ * TSVC-003 — TECHNOLOGY_SERVICE.node must resolve to NODE notation
161
+ * (26-technology-services.md §5)
162
+ *
163
+ * Implemented element checks:
164
+ * INT-002 — INTEGRATION(interface_semantics: true) source/target → APPLICATION
165
+ * (ELEMENT_PRIMITIVES.md §7.8.1)
166
+ *
167
+ * lint.py ships this as a no-op stub; Studio is ahead of the Python tool here.
168
+ * These findings are non-blocking for cross-tool parity — they surface only in
169
+ * the TypeScript validator path (CLI `validate --scope=repo`). */
170
+ function checkLayerSemantics(input: RepoModelInput, findings: RepoFinding[]): void {
171
+ const elementById = new Map<string, Record<string, unknown>>();
172
+ for (const doc of input.elements) {
173
+ const id = docId(doc);
174
+ if (id && doc.data) elementById.set(id, doc.data);
175
+ }
176
+
177
+ for (const doc of input.relations) {
178
+ if (!doc.data) continue;
179
+ const relId = docId(doc) ?? '';
180
+ const relType = doc.data['type'];
181
+ if (typeof relType !== 'string') continue;
182
+
183
+ const fromId = endpointId(doc.data['from']) ?? endpointId(doc.data['source']);
184
+ const toId = endpointId(doc.data['to']) ?? endpointId(doc.data['target']);
185
+
186
+ if (relType === 'unit_located_at') {
187
+ if (fromId) {
188
+ const from = elementById.get(fromId);
189
+ if (from && (from['notation'] !== 'actor' || from['type'] !== 'business_unit')) {
190
+ findings.push({
191
+ scope: PScope,
192
+ id: relId,
193
+ message:
194
+ `Layer-semantics: '${relId}' type 'unit_located_at' requires from to be ` +
195
+ `ACTOR(business_unit); got notation='${from['notation']}', type='${from['type']}'.`,
196
+ });
197
+ }
198
+ }
199
+ if (toId) {
200
+ const to = elementById.get(toId);
201
+ if (to && to['notation'] !== 'location') {
202
+ findings.push({
203
+ scope: PScope,
204
+ id: relId,
205
+ message:
206
+ `Layer-semantics: '${relId}' type 'unit_located_at' requires to to be a LOCATION; ` +
207
+ `got notation='${to['notation']}'.`,
208
+ });
209
+ }
210
+ }
211
+ } else if (relType === 'located_at') {
212
+ if (fromId) {
213
+ const from = elementById.get(fromId);
214
+ if (
215
+ from &&
216
+ (from['notation'] !== 'actor' ||
217
+ (from['type'] !== 'person' && from['type'] !== 'business_unit'))
218
+ ) {
219
+ findings.push({
220
+ scope: PScope,
221
+ id: relId,
222
+ message:
223
+ `Layer-semantics: '${relId}' type 'located_at' requires from to be ` +
224
+ `ACTOR(person|business_unit); got notation='${from['notation']}', type='${from['type']}'.`,
225
+ });
226
+ }
227
+ }
228
+ if (toId) {
229
+ const to = elementById.get(toId);
230
+ if (to && to['notation'] !== 'location') {
231
+ findings.push({
232
+ scope: PScope,
233
+ id: relId,
234
+ message:
235
+ `Layer-semantics: '${relId}' type 'located_at' requires to to be a LOCATION; ` +
236
+ `got notation='${to['notation']}'.`,
237
+ });
238
+ }
239
+ }
240
+ } else if (relType === 'offers') {
241
+ if (fromId) {
242
+ const from = elementById.get(fromId);
243
+ if (
244
+ from &&
245
+ from['notation'] !== 'actor' &&
246
+ from['notation'] !== 'role'
247
+ ) {
248
+ findings.push({
249
+ scope: PScope,
250
+ id: relId,
251
+ message:
252
+ `Layer-semantics: '${relId}' type 'offers' requires from to be ` +
253
+ `ACTOR(business_unit) or ROLE; got notation='${from['notation']}'.`,
254
+ });
255
+ }
256
+ }
257
+ if (toId) {
258
+ const to = elementById.get(toId);
259
+ if (to && to['notation'] !== 'business-service') {
260
+ findings.push({
261
+ scope: PScope,
262
+ id: relId,
263
+ message:
264
+ `Layer-semantics: '${relId}' type 'offers' requires to to be a BUSINESS_SERVICE; ` +
265
+ `got notation='${to['notation']}'.`,
266
+ });
267
+ }
268
+ }
269
+ } else if (relType === 'realizes') {
270
+ if (fromId) {
271
+ const from = elementById.get(fromId);
272
+ if (from && from['notation'] !== 'business-service') {
273
+ findings.push({
274
+ scope: PScope,
275
+ id: relId,
276
+ message:
277
+ `Layer-semantics: '${relId}' type 'realizes' requires from to be ` +
278
+ `BUSINESS_SERVICE; got notation='${from['notation']}'.`,
279
+ });
280
+ }
281
+ }
282
+ } else if (relType === 'hosts') {
283
+ if (fromId) {
284
+ const from = elementById.get(fromId);
285
+ if (from && from['notation'] !== 'node') {
286
+ findings.push({
287
+ scope: PScope,
288
+ id: relId,
289
+ message:
290
+ `Layer-semantics: '${relId}' type 'hosts' requires from to be a NODE; ` +
291
+ `got notation='${from['notation']}'.`,
292
+ });
293
+ }
294
+ }
295
+ if (toId) {
296
+ const to = elementById.get(toId);
297
+ if (to && to['notation'] !== 'technology-service') {
298
+ findings.push({
299
+ scope: PScope,
300
+ id: relId,
301
+ message:
302
+ `Layer-semantics: '${relId}' type 'hosts' requires to to be a TECHNOLOGY_SERVICE; ` +
303
+ `got notation='${to['notation']}'.`,
304
+ });
305
+ }
306
+ }
307
+ } else if (relType === 'uses') {
308
+ if (fromId) {
309
+ const from = elementById.get(fromId);
310
+ if (from && from['notation'] !== 'application') {
311
+ findings.push({
312
+ scope: PScope,
313
+ id: relId,
314
+ message:
315
+ `Layer-semantics: '${relId}' type 'uses' requires from to be an APPLICATION; ` +
316
+ `got notation='${from['notation']}'.`,
317
+ });
318
+ }
319
+ }
320
+ if (toId) {
321
+ const to = elementById.get(toId);
322
+ if (to && to['notation'] !== 'technology-service') {
323
+ findings.push({
324
+ scope: PScope,
325
+ id: relId,
326
+ message:
327
+ `Layer-semantics: '${relId}' type 'uses' requires to to be a TECHNOLOGY_SERVICE; ` +
328
+ `got notation='${to['notation']}'.`,
329
+ });
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ // TSVC-003 — TECHNOLOGY_SERVICE.node must resolve to a NODE-notation element.
336
+ for (const doc of input.elements) {
337
+ if (!doc.data) continue;
338
+ if (doc.data['notation'] !== 'technology-service') continue;
339
+ const nodeRef = doc.data['node'];
340
+ if (typeof nodeRef !== 'string' || nodeRef.trim() === '') continue;
341
+
342
+ const svcId = docId(doc) ?? '';
343
+ const resolved = elementById.get(nodeRef);
344
+ if (resolved && resolved['notation'] !== 'node') {
345
+ findings.push({
346
+ scope: PScope,
347
+ id: svcId,
348
+ message:
349
+ `TSVC-003: TECHNOLOGY_SERVICE '${svcId}' node '${nodeRef}' ` +
350
+ `must resolve to a NODE element; got notation='${resolved['notation']}'.`,
351
+ });
352
+ }
353
+ }
354
+
355
+ // INT-002 — INTEGRATION elements with interface_semantics: true must have
356
+ // source and target that resolve to APPLICATION-notation elements.
357
+ for (const doc of input.elements) {
358
+ if (!doc.data) continue;
359
+ if (doc.data['notation'] !== 'integration') continue;
360
+ if (doc.data['interface_semantics'] !== true) continue;
361
+
362
+ const intId = docId(doc) ?? '';
363
+ for (const field of ['source', 'target'] as const) {
364
+ const endpointId = doc.data[field];
365
+ if (typeof endpointId !== 'string' || endpointId.trim() === '') continue;
366
+ const resolved = elementById.get(endpointId);
367
+ if (resolved && resolved['notation'] !== 'application') {
368
+ findings.push({
369
+ scope: PScope,
370
+ id: intId,
371
+ message:
372
+ `INT-002: INTEGRATION '${intId}' interface_semantics endpoint '${endpointId}' ` +
373
+ `(${field}) must resolve to an APPLICATION element; got notation='${resolved['notation']}'.`,
374
+ });
375
+ }
376
+ }
377
+ }
155
378
  }
156
379
 
157
380
  /** Phase 6 — an element that is Active/Production must declare an owner. */
@@ -0,0 +1,143 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateTechnologyService } from '../validate.js';
3
+
4
+ function valid(): Record<string, unknown> {
5
+ return {
6
+ notation: 'technology-service',
7
+ id: 'TECHNOLOGY_SERVICE-KAFKA-1',
8
+ name: 'Kafka Event Bus',
9
+ type: 'messaging',
10
+ description: 'Managed Kafka cluster for async event streaming.',
11
+ node: 'NODE-KAFKA-HOST-1',
12
+ endpoint: 'kafka.internal:9092',
13
+ zone: 'canon',
14
+ admitted_at: '2026-06-28',
15
+ admitted_by: 'v.korobeinikov',
16
+ gate_checks: { uniqueness: 'pass', consistency: 'pass', completeness: 'pass' },
17
+ valid_from: '2024-01-01',
18
+ valid_to: null,
19
+ };
20
+ }
21
+
22
+ const codes = (input: unknown): string[] =>
23
+ validateTechnologyService(input).errors.map(e => e.code);
24
+
25
+ describe('validateTechnologyService — positive', () => {
26
+ it('accepts a well-formed messaging service with all optional fields', () => {
27
+ const r = validateTechnologyService(valid());
28
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
29
+ });
30
+
31
+ it('accepts a storage service with no optional fields', () => {
32
+ const a = valid();
33
+ a.id = 'TECHNOLOGY_SERVICE-S3-1';
34
+ a.name = 'Object Storage';
35
+ a.type = 'storage';
36
+ delete a.description;
37
+ delete a.node;
38
+ delete a.endpoint;
39
+ expect(validateTechnologyService(a).valid).toBe(true);
40
+ });
41
+
42
+ it('accepts all five type values', () => {
43
+ for (const t of ['messaging', 'storage', 'api_gateway', 'database', 'compute']) {
44
+ expect(validateTechnologyService({ ...valid(), type: t }).valid).toBe(true);
45
+ }
46
+ });
47
+
48
+ it('accepts valid_to as a date string', () => {
49
+ expect(validateTechnologyService({ ...valid(), valid_to: '2026-12-31' }).valid).toBe(true);
50
+ });
51
+
52
+ it('accepts absent node without error', () => {
53
+ const a = valid();
54
+ delete a.node;
55
+ expect(validateTechnologyService(a).valid).toBe(true);
56
+ });
57
+ });
58
+
59
+ describe('validateTechnologyService — TSVC-001 (shape / id grammar)', () => {
60
+ it('flags a non-object input', () => {
61
+ expect(codes(null)).toEqual(['TSVC-001']);
62
+ });
63
+
64
+ it('flags an id that violates the grammar', () => {
65
+ expect(codes({ ...valid(), id: 'TECHNOLOGY_SERVICE-001' })).toContain('TSVC-001');
66
+ expect(codes({ ...valid(), id: 'TSVC-1' })).toContain('TSVC-001');
67
+ expect(codes({ ...valid(), id: 'technology-service-kafka-1' })).toContain('TSVC-001');
68
+ });
69
+
70
+ it('flags a wrong notation tag', () => {
71
+ expect(codes({ ...valid(), notation: 'technology_service' })).toContain('TSVC-001');
72
+ expect(codes({ ...valid(), notation: 'service' })).toContain('TSVC-001');
73
+ });
74
+
75
+ it('flags a non-canon zone', () => {
76
+ expect(codes({ ...valid(), zone: 'sandbox' })).toContain('TSVC-001');
77
+ });
78
+
79
+ it('flags a missing zone', () => {
80
+ const a = valid();
81
+ delete a.zone;
82
+ expect(codes(a)).toContain('TSVC-001');
83
+ });
84
+
85
+ it('flags a missing name', () => {
86
+ const a = valid();
87
+ delete a.name;
88
+ expect(codes(a)).toContain('TSVC-001');
89
+ });
90
+
91
+ it('flags a missing valid_to key', () => {
92
+ const a = valid();
93
+ delete a.valid_to;
94
+ expect(codes(a)).toContain('TSVC-001');
95
+ });
96
+
97
+ it('flags missing gate_checks', () => {
98
+ const a = valid();
99
+ delete a.gate_checks;
100
+ expect(codes(a)).toContain('TSVC-001');
101
+ });
102
+
103
+ it('flags a missing type', () => {
104
+ const a = valid();
105
+ delete a.type;
106
+ expect(codes(a)).toContain('TSVC-001');
107
+ });
108
+ });
109
+
110
+ describe('validateTechnologyService — TSVC-002 (type enum)', () => {
111
+ it('flags a type outside the allowed enum', () => {
112
+ expect(codes({ ...valid(), type: 'queue' })).toContain('TSVC-002');
113
+ expect(codes({ ...valid(), type: 'event_streaming' })).toContain('TSVC-002');
114
+ expect(codes({ ...valid(), type: 'cache' })).toContain('TSVC-002');
115
+ });
116
+
117
+ it('does not flag a missing type with TSVC-002 (TSVC-001 fires instead)', () => {
118
+ const a = valid();
119
+ delete a.type;
120
+ expect(codes(a)).not.toContain('TSVC-002');
121
+ expect(codes(a)).toContain('TSVC-001');
122
+ });
123
+ });
124
+
125
+ describe('validateTechnologyService — TSVC-003 (node grammar)', () => {
126
+ it('flags a node reference that is not a NODE-… id', () => {
127
+ expect(codes({ ...valid(), node: 'ACTOR-OPS-1' })).toContain('TSVC-003');
128
+ expect(codes({ ...valid(), node: 'not-an-id' })).toContain('TSVC-003');
129
+ expect(codes({ ...valid(), node: 'NODE-001' })).toContain('TSVC-003');
130
+ });
131
+
132
+ it('accepts a null or absent node without error', () => {
133
+ expect(codes({ ...valid(), node: undefined })).not.toContain('TSVC-003');
134
+ const a = valid();
135
+ delete a.node;
136
+ expect(codes(a)).not.toContain('TSVC-003');
137
+ });
138
+
139
+ it('accepts a well-formed NODE-… id', () => {
140
+ expect(codes({ ...valid(), node: 'NODE-KAFKA-HOST-1' })).not.toContain('TSVC-003');
141
+ expect(codes({ ...valid(), node: 'NODE-K8S-CLUSTER-1' })).not.toContain('TSVC-003');
142
+ });
143
+ });
@@ -0,0 +1,2 @@
1
+ export { validateTechnologyService } from './validate.js';
2
+ export type { TechnologyService, TechnologyServiceType } from './types.js';
@@ -0,0 +1,27 @@
1
+ // TECHNOLOGY_SERVICE — technology-layer platform service primitive (ArchiMate Technology Service).
2
+ // Schema: methodology notations/elements/26-technology-services.md
3
+
4
+ import type { GateChecks } from '../requirement/types.js';
5
+
6
+ export const TECHNOLOGY_SERVICE_TYPES = ['messaging', 'storage', 'api_gateway', 'database', 'compute'] as const;
7
+ export type TechnologyServiceType = typeof TECHNOLOGY_SERVICE_TYPES[number];
8
+
9
+ export interface TechnologyService {
10
+ notation: 'technology-service';
11
+ id: string;
12
+ name: string;
13
+ type: TechnologyServiceType;
14
+ description?: string;
15
+ node?: string;
16
+ endpoint?: string;
17
+
18
+ // Admission record (CONTRACT.md §6).
19
+ zone: 'canon';
20
+ admitted_at: string;
21
+ admitted_by: string;
22
+ gate_checks: GateChecks;
23
+
24
+ // Primitive lifecycle (CONTRACT.md §7).
25
+ valid_from: string;
26
+ valid_to: string | null;
27
+ }
@@ -0,0 +1,71 @@
1
+ // TECHNOLOGY_SERVICE validator — methodology notations/elements/26-technology-services.md §5.
2
+ //
3
+ // Implements TSVC-001, TSVC-002, TSVC-003. The shared HDR-/LIFECYCLE- rules are
4
+ // cross-cutting concerns (CONTRACT.md §8) and out of scope for the
5
+ // single-artefact validator. TSVC-003 (node resolves to admitted NODE) is also
6
+ // enforced at repo scope in validate-repo.ts checkLayerSemantics.
7
+
8
+ import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
9
+ import { isCanonicalIdOfType } from '../typed-id.js';
10
+ import { TECHNOLOGY_SERVICE_TYPES } from './types.js';
11
+
12
+ const REQUIRED_STRING_FIELDS = [
13
+ 'name', 'admitted_at', 'admitted_by', 'valid_from',
14
+ ] as const;
15
+
16
+ const TYPES: readonly string[] = TECHNOLOGY_SERVICE_TYPES;
17
+
18
+ export function validateTechnologyService(input: unknown): ValidationResult {
19
+ const errors: ValidationError[] = [];
20
+ const warnings: ValidationWarning[] = [];
21
+
22
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
23
+ errors.push({ code: 'TSVC-001', message: 'Technology service must be a YAML mapping.' });
24
+ return { valid: false, errors, warnings };
25
+ }
26
+ const a = input as Record<string, unknown>;
27
+
28
+ // TSVC-001 — id grammar + notation tag + envelope.
29
+ if (!isCanonicalIdOfType(a.id, 'TECHNOLOGY_SERVICE')) {
30
+ errors.push({ code: 'TSVC-001', message: `id "${String(a.id)}" must match TECHNOLOGY_SERVICE-[<middle>-]<INTEGER>.`, path: 'id' });
31
+ }
32
+ if (a.notation !== 'technology-service') {
33
+ errors.push({ code: 'TSVC-001', message: 'notation must be the fixed value "technology-service".', path: 'notation' });
34
+ }
35
+ if (a.zone !== undefined && a.zone !== 'canon') {
36
+ errors.push({ code: 'TSVC-001', message: 'zone must be "canon" for a TECHNOLOGY_SERVICE.', path: 'zone' });
37
+ } else if (a.zone === undefined) {
38
+ errors.push({ code: 'TSVC-001', message: 'zone is required.', path: 'zone' });
39
+ }
40
+ for (const field of REQUIRED_STRING_FIELDS) {
41
+ const v = a[field];
42
+ if (typeof v !== 'string' || v.trim() === '') {
43
+ errors.push({ code: 'TSVC-001', message: `${field} is required.`, path: field });
44
+ }
45
+ }
46
+ if (a.gate_checks === null || typeof a.gate_checks !== 'object') {
47
+ errors.push({ code: 'TSVC-001', message: 'gate_checks is required and must be a mapping.', path: 'gate_checks' });
48
+ }
49
+ if (!('valid_to' in a) || !(typeof a.valid_to === 'string' || a.valid_to === null)) {
50
+ errors.push({ code: 'TSVC-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
51
+ }
52
+ if (typeof a.type !== 'string' || a.type.trim() === '') {
53
+ errors.push({ code: 'TSVC-001', message: 'type is required.', path: 'type' });
54
+ } else if (!TYPES.includes(a.type)) {
55
+ // TSVC-002 — type enum.
56
+ errors.push({ code: 'TSVC-002', message: `type "${a.type}" must be one of ${TYPES.join(', ')}.`, path: 'type' });
57
+ }
58
+
59
+ // TSVC-003 — node must be a NODE-… id when present.
60
+ if ('node' in a && a.node !== undefined && a.node !== null) {
61
+ if (typeof a.node !== 'string' || !isCanonicalIdOfType(a.node, 'NODE')) {
62
+ errors.push({
63
+ code: 'TSVC-003',
64
+ message: `node "${String(a.node)}" must be a canonical NODE-… id.`,
65
+ path: 'node',
66
+ });
67
+ }
68
+ }
69
+
70
+ return { valid: errors.length === 0, errors, warnings };
71
+ }
@@ -28,10 +28,8 @@ const FIXTURES: Fixture[] = [
28
28
  { kind: 'goals', file: 'goals/strategy-2026.goals.transitrix.yaml', markup: 'svg' },
29
29
  { kind: 'dgca', file: 'dgca/strategy-2026.dgca.transitrix.yaml', markup: 'svg' },
30
30
  { kind: 'dga', file: 'dga/strategy-2026.dga.transitrix.yaml', markup: 'svg' },
31
- { kind: 'fgca', file: 'fgca/strategy-2026.fgca.transitrix.yaml', markup: 'svg' },
32
- { kind: 'fga', file: 'fga/strategy-2026.fga.transitrix.yaml', markup: 'svg' },
33
- { kind: 'activities', file: 'activities/platform-launch.activities.transitrix.yaml', markup: 'svg' },
34
- { kind: 'activity-card', file: 'activity-card/eu-programme.activity-card.transitrix.yaml', markup: 'svg' },
31
+ { kind: 'action', file: 'action/platform-launch.action.transitrix.yaml', markup: 'svg' },
32
+ { kind: 'action-card', file: 'action-card/eu-programme.action-card.transitrix.yaml', markup: 'svg' },
35
33
  { kind: 'process-blueprint', file: 'process-blueprint/order-fulfilment.process-blueprint.transitrix.yaml', markup: 'svg' },
36
34
  { kind: 'blocks', file: 'blocks/architecture.blocks.transitrix.yaml', markup: 'svg' },
37
35
  { kind: 'applications', file: 'applications/portfolio-2026.applications.transitrix.yaml', markup: 'section' },
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Unit tests for renderActivitiesSvg — project-node suppression (#421).
3
+ *
4
+ * Convention: activities with activity_type === 'project' are omitted from
5
+ * the Network/PSND layout by default (suppressProjectNodes: true). Text/Tree
6
+ * views keep them via the caller; data is never mutated.
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { renderActivitiesSvg } from '../render-activities.js';
10
+ import type { ActivityDoc } from '../../activities/types.js';
11
+
12
+ const BASE_DOC: ActivityDoc = {
13
+ notation: 'action',
14
+ activities: [
15
+ {
16
+ id: 'PROJ-1',
17
+ name: 'Platform Launch',
18
+ activity_type: 'project',
19
+ },
20
+ {
21
+ id: 'TASK-1',
22
+ name: 'Design',
23
+ activity_type: 'task',
24
+ parent: 'PROJ-1',
25
+ },
26
+ {
27
+ id: 'TASK-2',
28
+ name: 'Implement',
29
+ activity_type: 'task',
30
+ parent: 'PROJ-1',
31
+ predecessors: ['TASK-1'],
32
+ },
33
+ ],
34
+ };
35
+
36
+ describe('renderActivitiesSvg — project-node suppression (#421)', () => {
37
+ it('suppresses project-type nodes in the network SVG by default', () => {
38
+ const svg = renderActivitiesSvg(BASE_DOC);
39
+ // TASK-1 and TASK-2 should appear as rendered nodes.
40
+ expect(svg).toContain('TASK-1');
41
+ expect(svg).toContain('TASK-2');
42
+ // The project container should not appear as a rendered node.
43
+ expect(svg).not.toContain('PROJ-1');
44
+ });
45
+
46
+ it('suppresses project nodes when suppressProjectNodes is explicitly true', () => {
47
+ const svg = renderActivitiesSvg(BASE_DOC, { suppressProjectNodes: true });
48
+ expect(svg).not.toContain('PROJ-1');
49
+ expect(svg).toContain('TASK-1');
50
+ });
51
+
52
+ it('renders project nodes when suppressProjectNodes is false', () => {
53
+ const svg = renderActivitiesSvg(BASE_DOC, { suppressProjectNodes: false });
54
+ expect(svg).toContain('PROJ-1');
55
+ expect(svg).toContain('TASK-1');
56
+ expect(svg).toContain('TASK-2');
57
+ });
58
+
59
+ it('does not mutate the original doc', () => {
60
+ const original = BASE_DOC.activities.length;
61
+ renderActivitiesSvg(BASE_DOC);
62
+ expect(BASE_DOC.activities).toHaveLength(original);
63
+ });
64
+
65
+ it('suppresses project_type matching case-insensitively', () => {
66
+ const doc: ActivityDoc = {
67
+ notation: 'action',
68
+ activities: [
69
+ { id: 'P1', name: 'Container', activity_type: 'Project' },
70
+ { id: 'T1', name: 'Leaf', activity_type: 'task' },
71
+ ],
72
+ };
73
+ const svg = renderActivitiesSvg(doc);
74
+ expect(svg).not.toContain('P1');
75
+ expect(svg).toContain('T1');
76
+ });
77
+
78
+ it('renders a valid SVG when all activities are project-type (graceful degrade)', () => {
79
+ const doc: ActivityDoc = {
80
+ notation: 'action',
81
+ activities: [{ id: 'P1', name: 'Only project', activity_type: 'project' }],
82
+ };
83
+ const svg = renderActivitiesSvg(doc);
84
+ // When all activities are filtered out, the renderer returns an empty SVG.
85
+ expect(svg).toContain('<svg');
86
+ });
87
+
88
+ it('renders activities without activity_type unchanged', () => {
89
+ const doc: ActivityDoc = {
90
+ notation: 'action',
91
+ activities: [
92
+ { id: 'A1', name: 'Analysis', duration: 5 },
93
+ { id: 'A2', name: 'Design', duration: 3, predecessors: ['A1'] },
94
+ ],
95
+ };
96
+ const svg = renderActivitiesSvg(doc);
97
+ expect(svg).toContain('A1');
98
+ expect(svg).toContain('A2');
99
+ });
100
+ });