@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
@@ -183,7 +183,7 @@ export function layoutActivityCard(
183
183
  cursorY += height + SECTION_GAP;
184
184
  }
185
185
 
186
- // ── 5. Chain: Drivers → Assessments → Goals → Changes ────────────────────
186
+ // ── 5. Chain: Drivers → Goals → Changes ──────────────────────────────────
187
187
  // Each section is a full-width band with a header label and stacked node rows.
188
188
  // An empty section shows a gap indicator ("— not on file") so the author
189
189
  // sees which parts of the narrative are missing.
@@ -222,14 +222,10 @@ export function layoutActivityCard(
222
222
  }
223
223
 
224
224
  const { drivers, goals, changes } = card.motivation;
225
- const assessments = card.assessments ?? [];
226
225
 
227
226
  pushChainSection('drivers', 'Drivers', 'What prompted this initiative?',
228
227
  drivers.map((d) => ({ id: d.id, name: d.name })));
229
228
 
230
- pushChainSection('assessments', 'Assessments', 'What is the current gap?',
231
- assessments.map((a) => ({ id: a.id, name: a.name, meta: a.observed_at })));
232
-
233
229
  pushChainSection('goals', 'Goals', 'What outcome do we target?',
234
230
  goals.map((g) => ({ id: g.id, name: g.name })));
235
231
 
@@ -242,9 +238,6 @@ export function layoutActivityCard(
242
238
  // Node-level chain edges.
243
239
  const driverIdSet = new Set(drivers.map((d) => d.id));
244
240
  const goalIdSet = new Set(goals.map((g) => g.id));
245
- for (const a of assessments) {
246
- if (driverIdSet.has(a.driverId)) chainEdges.push({ sourceId: a.driverId, targetId: a.id });
247
- }
248
241
  for (const g of goals) {
249
242
  for (const did of g.driverIds) {
250
243
  if (driverIdSet.has(did)) chainEdges.push({ sourceId: did, targetId: g.id });
@@ -372,10 +372,19 @@ export function resolveActivityCard(
372
372
  // element name, falling back to the id when the element is absent.
373
373
  const goalNames = projectGoalIds.map((gid) => str(goalElems.get(gid)?.['name']) ?? gid);
374
374
 
375
- // ── Stakeholders — active `activity_stakeholder` relations from the project ──
375
+ // ── Stakeholders — inline `stakeholders: []` on the Action element PLUS active
376
+ // `action_stakeholder` / `activity_stakeholder` REL files. Both sources are
377
+ // merged; REL entries take precedence (they carry the role field).
376
378
  const stakeholderElems = collectByNotation(sources.elements, 'stakeholder');
379
+ const relStakeholderRefs = activeActivityStakeholders(sources.relations, projectId);
380
+ const relStakeholderIds = new Set(relStakeholderRefs.map((s) => s.id));
381
+ const inlineStakeholderIds = strArray(projectRec['stakeholders']).filter((id) => !relStakeholderIds.has(id));
382
+ const allStakeholderRefs: Array<{ id: string; role?: string }> = [
383
+ ...relStakeholderRefs,
384
+ ...inlineStakeholderIds.map((id) => ({ id })),
385
+ ];
377
386
  const stakeholders: ResolvedStakeholder[] = [];
378
- for (const { id: sid, role } of activeActivityStakeholders(sources.relations, projectId)) {
387
+ for (const { id: sid, role } of allStakeholderRefs) {
379
388
  const rec = stakeholderElems.get(sid);
380
389
  if (!rec) {
381
390
  warnings.push({
@@ -285,7 +285,7 @@ export interface ChainEdge {
285
285
  * so the author sees the missing data.
286
286
  */
287
287
  export interface ChainSectionLayout {
288
- type: 'drivers' | 'assessments' | 'goals' | 'changes';
288
+ type: 'drivers' | 'goals' | 'changes';
289
289
  /** Section header label. */
290
290
  label: string;
291
291
  /** Short question answered by this section, shown in grey after the label. */
@@ -49,21 +49,15 @@ export function validateActivityCard(input: unknown): ValidationResult {
49
49
  // ── Header (CONTRACT.md §2) ────────────────────────────────────────────────
50
50
  if (!('notation' in raw)) {
51
51
  errors.push({ code: 'HDR-001', message: 'notation field is required' });
52
- } else if (raw['notation'] !== 'action-card' && raw['notation'] !== 'activity-card') {
52
+ } else if (raw['notation'] !== 'action-card') {
53
53
  errors.push({
54
54
  code: 'HDR-002',
55
55
  message: `notation must be "action-card", got "${String(raw['notation'])}"`,
56
56
  });
57
- } else if (raw['notation'] === 'activity-card') {
58
- warnings.push({ code: 'DEPRECATED_NOTATION', message: 'notation: "activity-card" is deprecated — migrate to notation: "action-card"' });
59
57
  }
60
58
 
61
- // ── action_card / activity_card block ───────────────────────────────────────
62
- // "action_card:" is canonical; "activity_card:" is accepted for compat.
63
- const rawBlock = raw['action_card'] ?? raw['activity_card'];
64
- if (raw['activity_card'] !== undefined && raw['action_card'] === undefined) {
65
- warnings.push({ code: 'DEPRECATED_FIELD', message: 'Root key "activity_card:" is deprecated — rename to "action_card:"' });
66
- }
59
+ // ── action_card block ────────────────────────────────────────────────────────
60
+ const rawBlock = raw['action_card'];
67
61
  const block = rawBlock;
68
62
  if (!block || typeof block !== 'object' || Array.isArray(block)) {
69
63
  errors.push({ code: 'AC-001', message: 'Missing required root key: action_card (object)' });
@@ -103,4 +103,12 @@ describe('validateActor — ACTOR-003 (engagement fields forbidden inline)', ()
103
103
  it('flags inline owner', () => {
104
104
  expect(codes({ ...valid(), owner: 'ACTOR-OPS-1' })).toContain('ACTOR-003');
105
105
  });
106
+
107
+ it('flags inline located_at (location belongs in a REL file)', () => {
108
+ expect(codes({ ...valid(), located_at: 'LOCATION-TBILISI-1' })).toContain('ACTOR-003');
109
+ });
110
+
111
+ it('flags inline unit_located_at (location belongs in a REL file)', () => {
112
+ expect(codes({ ...valid(), unit_located_at: 'LOCATION-TBILISI-1' })).toContain('ACTOR-003');
113
+ });
106
114
  });
@@ -18,6 +18,8 @@ export const ACTOR_FORBIDDEN_INLINE_FIELDS = [
18
18
  'community_membership',
19
19
  'contracting',
20
20
  'unit_parent',
21
+ 'located_at',
22
+ 'unit_located_at',
21
23
  'roles',
22
24
  'owner',
23
25
  ] as const;
@@ -2,11 +2,19 @@ export type ApplicationType = 'application' | 'integration' | 'platform' | 'data
2
2
  export type ApplicationStatus = 'Draft' | 'Active' | 'Deprecated' | 'Decommissioning';
3
3
  export type IntegrationDirection = 'inbound' | 'outbound' | 'bidirectional';
4
4
 
5
+ export type IntegrationSensitivity = 'public' | 'internal' | 'confidential' | 'restricted';
6
+ export type IntegrationDirectionality = 'producer' | 'consumer' | 'request_reply' | 'bidirectional_stream';
7
+
5
8
  export interface ApplicationIntegration {
6
9
  target?: string;
10
+ source?: string;
7
11
  direction?: IntegrationDirection;
8
12
  protocol?: string;
9
13
  description?: string;
14
+ interface_semantics?: boolean;
15
+ payload_class?: string;
16
+ sensitivity?: IntegrationSensitivity;
17
+ directionality?: IntegrationDirectionality;
10
18
  }
11
19
 
12
20
  export interface Application {
@@ -6,8 +6,12 @@ export type { ValidationError, ValidationWarning, ValidationResult };
6
6
  const VALID_TYPES = new Set<ApplicationType>(['application', 'integration', 'platform', 'data_store']);
7
7
  const VALID_STATUSES = new Set<ApplicationStatus>(['Draft', 'Active', 'Deprecated', 'Decommissioning']);
8
8
  const VALID_DIRECTIONS = new Set<IntegrationDirection>(['inbound', 'outbound', 'bidirectional']);
9
+ const VALID_SENSITIVITY = new Set(['public', 'internal', 'confidential', 'restricted']);
10
+ const VALID_DIRECTIONALITY = new Set(['producer', 'consumer', 'request_reply', 'bidirectional_stream']);
9
11
  const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
10
12
 
13
+ const IFACE_CONDITIONAL = ['protocol', 'payload_class', 'sensitivity', 'directionality'] as const;
14
+
11
15
  export function validateApplicationsCatalogue(input: unknown): ValidationResult {
12
16
  const errors: ValidationError[] = [];
13
17
  const warnings: ValidationWarning[] = [];
@@ -123,6 +127,22 @@ export function validateApplicationsCatalogue(input: unknown): ValidationResult
123
127
  if (intg['direction'] !== undefined && !VALID_DIRECTIONS.has(intg['direction'] as IntegrationDirection)) {
124
128
  errors.push({ code: 'APP-009', message: `${idx}.integrations[${j}]: direction "${intg['direction']}" must be one of: inbound, outbound, bidirectional` });
125
129
  }
130
+
131
+ // INT-001 — when interface_semantics: true, four fields become required.
132
+ if (intg['interface_semantics'] === true) {
133
+ for (const field of IFACE_CONDITIONAL) {
134
+ const v = intg[field];
135
+ if (typeof v !== 'string' || (v as string).trim() === '') {
136
+ errors.push({ code: 'INT-001', message: `${idx}.integrations[${j}]: ${field} is required when interface_semantics is true.`, path: `${idx}.integrations[${j}].${field}` });
137
+ }
138
+ }
139
+ if (typeof intg['sensitivity'] === 'string' && !VALID_SENSITIVITY.has(intg['sensitivity'])) {
140
+ errors.push({ code: 'INT-001', message: `${idx}.integrations[${j}]: sensitivity "${intg['sensitivity']}" must be one of public, internal, confidential, restricted.`, path: `${idx}.integrations[${j}].sensitivity` });
141
+ }
142
+ if (typeof intg['directionality'] === 'string' && !VALID_DIRECTIONALITY.has(intg['directionality'])) {
143
+ errors.push({ code: 'INT-001', message: `${idx}.integrations[${j}]: directionality "${intg['directionality']}" must be one of producer, consumer, request_reply, bidirectional_stream.`, path: `${idx}.integrations[${j}].directionality` });
144
+ }
145
+ }
126
146
  }
127
147
  }
128
148
  }
@@ -119,7 +119,7 @@ describe('layoutNestedBlocks', () => {
119
119
  });
120
120
  const a = layout.blocks[0];
121
121
  expect(a.width).toBe(160);
122
- expect(a.height).toBe(60);
122
+ expect(a.height).toBe(72);
123
123
  });
124
124
 
125
125
  it('honours custom layout options', () => {
@@ -8,7 +8,7 @@ import type {
8
8
 
9
9
  const DEFAULTS: Required<BlocksLayoutOptions> = {
10
10
  leafWidth: 160,
11
- leafHeight: 60,
11
+ leafHeight: 72,
12
12
  padding: 12,
13
13
  headerHeight: 28,
14
14
  childGap: 12,
@@ -0,0 +1,160 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateBusinessService } from '../validate.js';
3
+
4
+ function valid(): Record<string, unknown> {
5
+ return {
6
+ notation: 'business-service',
7
+ id: 'BUSINESS_SERVICE-CRM-1',
8
+ name: 'CRM Service',
9
+ type: 'internal',
10
+ description: 'Customer relationship management service offering contact management and deal tracking.',
11
+ offering_unit: 'ACTOR-SALES-OPS-1',
12
+ capability: 'CAPABILITY-V2.1',
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
+ validateBusinessService(input).errors.map(e => e.code);
24
+
25
+ describe('validateBusinessService — positive', () => {
26
+ it('accepts a well-formed internal service with all optional fields', () => {
27
+ const r = validateBusinessService(valid());
28
+ expect(r.valid, JSON.stringify(r.errors)).toBe(true);
29
+ });
30
+
31
+ it('accepts an external service with no optional fields', () => {
32
+ const a = valid();
33
+ a.id = 'BUSINESS_SERVICE-ONBOARDING-1';
34
+ a.name = 'Onboarding Service';
35
+ a.type = 'external';
36
+ delete a.description;
37
+ delete a.offering_unit;
38
+ delete a.capability;
39
+ expect(validateBusinessService(a).valid).toBe(true);
40
+ });
41
+
42
+ it('accepts a shared service offered by a ROLE', () => {
43
+ expect(validateBusinessService({ ...valid(), type: 'shared', offering_unit: 'ROLE-PLATFORM-OWNER-1' }).valid).toBe(true);
44
+ });
45
+
46
+ it('accepts valid_to as a date string', () => {
47
+ expect(validateBusinessService({ ...valid(), valid_to: '2026-12-31' }).valid).toBe(true);
48
+ });
49
+
50
+ it('accepts a service with no offering_unit or capability', () => {
51
+ const a = valid();
52
+ delete a.offering_unit;
53
+ delete a.capability;
54
+ expect(validateBusinessService(a).valid).toBe(true);
55
+ });
56
+ });
57
+
58
+ describe('validateBusinessService — BSV-001 (shape / id grammar)', () => {
59
+ it('flags a missing required envelope field (name)', () => {
60
+ const a = valid();
61
+ delete a.name;
62
+ expect(codes(a)).toContain('BSV-001');
63
+ });
64
+
65
+ it('flags an id that violates the grammar', () => {
66
+ expect(codes({ ...valid(), id: 'BUSINESS_SERVICE-001' })).toContain('BSV-001');
67
+ expect(codes({ ...valid(), id: 'BSV-1' })).toContain('BSV-001');
68
+ expect(codes({ ...valid(), id: 'business-service-1' })).toContain('BSV-001');
69
+ });
70
+
71
+ it('flags a wrong notation tag', () => {
72
+ expect(codes({ ...valid(), notation: 'service' })).toContain('BSV-001');
73
+ expect(codes({ ...valid(), notation: 'business_service' })).toContain('BSV-001');
74
+ });
75
+
76
+ it('flags a non-canon zone', () => {
77
+ expect(codes({ ...valid(), zone: 'sandbox' })).toContain('BSV-001');
78
+ });
79
+
80
+ it('flags a missing zone', () => {
81
+ const a = valid();
82
+ delete a.zone;
83
+ expect(codes(a)).toContain('BSV-001');
84
+ });
85
+
86
+ it('flags a missing type', () => {
87
+ const a = valid();
88
+ delete a.type;
89
+ expect(codes(a)).toContain('BSV-001');
90
+ });
91
+
92
+ it('flags a missing valid_to key', () => {
93
+ const a = valid();
94
+ delete a.valid_to;
95
+ expect(codes(a)).toContain('BSV-001');
96
+ });
97
+
98
+ it('flags missing gate_checks', () => {
99
+ const a = valid();
100
+ delete a.gate_checks;
101
+ expect(codes(a)).toContain('BSV-001');
102
+ });
103
+
104
+ it('rejects a non-object', () => {
105
+ expect(codes(null)).toEqual(['BSV-001']);
106
+ expect(codes('string')).toEqual(['BSV-001']);
107
+ });
108
+ });
109
+
110
+ describe('validateBusinessService — BSV-002 (type enum)', () => {
111
+ it('flags a type outside the allowed enum', () => {
112
+ expect(codes({ ...valid(), type: 'public' })).toContain('BSV-002');
113
+ expect(codes({ ...valid(), type: 'platform' })).toContain('BSV-002');
114
+ });
115
+
116
+ it('accepts all valid type values', () => {
117
+ for (const t of ['internal', 'external', 'shared']) {
118
+ expect(codes({ ...valid(), type: t })).not.toContain('BSV-002');
119
+ }
120
+ });
121
+ });
122
+
123
+ describe('validateBusinessService — BSV-003 (offering_unit grammar)', () => {
124
+ it('flags an offering_unit that is not an ACTOR-… or ROLE-… id', () => {
125
+ expect(codes({ ...valid(), offering_unit: 'PRODUCT-PLATFORM-1' })).toContain('BSV-003');
126
+ expect(codes({ ...valid(), offering_unit: 'not-an-id' })).toContain('BSV-003');
127
+ expect(codes({ ...valid(), offering_unit: 'BUSINESS_SERVICE-CRM-1' })).toContain('BSV-003');
128
+ });
129
+
130
+ it('accepts a null or absent offering_unit without error', () => {
131
+ expect(codes({ ...valid(), offering_unit: undefined })).not.toContain('BSV-003');
132
+ const a = valid();
133
+ delete a.offering_unit;
134
+ expect(codes(a)).not.toContain('BSV-003');
135
+ });
136
+
137
+ it('accepts ACTOR-… and ROLE-… ids', () => {
138
+ expect(codes({ ...valid(), offering_unit: 'ACTOR-OPS-1' })).not.toContain('BSV-003');
139
+ expect(codes({ ...valid(), offering_unit: 'ROLE-SERVICE-OWNER-1' })).not.toContain('BSV-003');
140
+ });
141
+ });
142
+
143
+ describe('validateBusinessService — BSV-004 (capability grammar)', () => {
144
+ it('flags a capability that is not a CAPABILITY-… id', () => {
145
+ expect(codes({ ...valid(), capability: 'PRODUCT-MOBILE-1' })).toContain('BSV-004');
146
+ expect(codes({ ...valid(), capability: 'not-a-capability' })).toContain('BSV-004');
147
+ });
148
+
149
+ it('accepts a null or absent capability without error', () => {
150
+ expect(codes({ ...valid(), capability: undefined })).not.toContain('BSV-004');
151
+ const a = valid();
152
+ delete a.capability;
153
+ expect(codes(a)).not.toContain('BSV-004');
154
+ });
155
+
156
+ it('accepts a CAPABILITY-… id', () => {
157
+ expect(codes({ ...valid(), capability: 'CAPABILITY-V2.1' })).not.toContain('BSV-004');
158
+ expect(codes({ ...valid(), capability: 'CAPABILITY-H3.1' })).not.toContain('BSV-004');
159
+ });
160
+ });
@@ -0,0 +1,3 @@
1
+ export type { BusinessService, BusinessServiceType } from './types.js';
2
+ export { BUSINESS_SERVICE_TYPES } from './types.js';
3
+ export { validateBusinessService } from './validate.js';
@@ -0,0 +1,29 @@
1
+ // BUSINESS_SERVICE — externally visible behaviour primitive (ArchiMate Business Service).
2
+ // Schema: methodology notations/elements/25-business-services.md
3
+
4
+ import type { GateChecks } from '../requirement/types.js';
5
+
6
+ export const BUSINESS_SERVICE_TYPES = ['internal', 'external', 'shared'] as const;
7
+ export type BusinessServiceType = typeof BUSINESS_SERVICE_TYPES[number];
8
+
9
+ export interface BusinessService {
10
+ notation: 'business-service';
11
+ id: string;
12
+ name: string;
13
+ type: BusinessServiceType;
14
+ description?: string;
15
+ offering_unit?: string;
16
+ capability?: string;
17
+ owner_role?: string;
18
+ status?: string;
19
+
20
+ // Admission record (CONTRACT.md §6).
21
+ zone: 'canon';
22
+ admitted_at: string;
23
+ admitted_by: string;
24
+ gate_checks: GateChecks;
25
+
26
+ // Primitive lifecycle (CONTRACT.md §7).
27
+ valid_from: string;
28
+ valid_to: string | null;
29
+ }
@@ -0,0 +1,82 @@
1
+ // BUSINESS_SERVICE validator — methodology notations/elements/25-business-services.md §5.
2
+ //
3
+ // Implements BSV-001..004. The shared HDR-/LIFECYCLE- rules are
4
+ // cross-cutting concerns (CONTRACT.md §8) and out of scope for the
5
+ // single-artefact validator.
6
+
7
+ import type { ValidationError, ValidationWarning, ValidationResult } from '../validation-types.js';
8
+ import { isCanonicalIdOfType, typeOfId } from '../typed-id.js';
9
+ import { BUSINESS_SERVICE_TYPES } from './types.js';
10
+
11
+ const REQUIRED_STRING_FIELDS = [
12
+ 'name', 'admitted_at', 'admitted_by', 'valid_from',
13
+ ] as const;
14
+
15
+ const TYPES: readonly string[] = BUSINESS_SERVICE_TYPES;
16
+
17
+ export function validateBusinessService(input: unknown): ValidationResult {
18
+ const errors: ValidationError[] = [];
19
+ const warnings: ValidationWarning[] = [];
20
+
21
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
22
+ errors.push({ code: 'BSV-001', message: 'Business service must be a YAML mapping.' });
23
+ return { valid: false, errors, warnings };
24
+ }
25
+ const a = input as Record<string, unknown>;
26
+
27
+ // BSV-001 — id grammar + notation tag + envelope.
28
+ if (!isCanonicalIdOfType(a.id, 'BUSINESS_SERVICE')) {
29
+ errors.push({ code: 'BSV-001', message: `id "${String(a.id)}" must match BUSINESS_SERVICE-[<middle>-]<INTEGER>.`, path: 'id' });
30
+ }
31
+ if (a.notation !== 'business-service') {
32
+ errors.push({ code: 'BSV-001', message: 'notation must be the fixed value "business-service".', path: 'notation' });
33
+ }
34
+ if (a.zone !== undefined && a.zone !== 'canon') {
35
+ errors.push({ code: 'BSV-001', message: 'zone must be "canon" for a BUSINESS_SERVICE.', path: 'zone' });
36
+ } else if (a.zone === undefined) {
37
+ errors.push({ code: 'BSV-001', message: 'zone is required.', path: 'zone' });
38
+ }
39
+ for (const field of REQUIRED_STRING_FIELDS) {
40
+ const v = a[field];
41
+ if (typeof v !== 'string' || v.trim() === '') {
42
+ errors.push({ code: 'BSV-001', message: `${field} is required.`, path: field });
43
+ }
44
+ }
45
+ if (a.gate_checks === null || typeof a.gate_checks !== 'object') {
46
+ errors.push({ code: 'BSV-001', message: 'gate_checks is required and must be a mapping.', path: 'gate_checks' });
47
+ }
48
+ if (!('valid_to' in a) || !(typeof a.valid_to === 'string' || a.valid_to === null)) {
49
+ errors.push({ code: 'BSV-001', message: 'valid_to is required (an ISO date string or null).', path: 'valid_to' });
50
+ }
51
+ if (typeof a.type !== 'string' || a.type.trim() === '') {
52
+ errors.push({ code: 'BSV-001', message: 'type is required.', path: 'type' });
53
+ } else if (!TYPES.includes(a.type)) {
54
+ // BSV-002 — type enum.
55
+ errors.push({ code: 'BSV-002', message: `type "${a.type}" must be one of ${TYPES.join(', ')}.`, path: 'type' });
56
+ }
57
+
58
+ // BSV-003 — offering_unit must resolve to ACTOR(business_unit) or ROLE when present.
59
+ if ('offering_unit' in a && a.offering_unit !== undefined && a.offering_unit !== null) {
60
+ const t = typeOfId(a.offering_unit);
61
+ if (t !== 'ACTOR' && t !== 'ROLE') {
62
+ errors.push({
63
+ code: 'BSV-003',
64
+ message: `offering_unit "${String(a.offering_unit)}" must be an ACTOR-… or ROLE-… canonical id.`,
65
+ path: 'offering_unit',
66
+ });
67
+ }
68
+ }
69
+
70
+ // BSV-004 — capability must resolve to CAPABILITY when present.
71
+ if ('capability' in a && a.capability !== undefined && a.capability !== null) {
72
+ if (typeOfId(a.capability) !== 'CAPABILITY') {
73
+ errors.push({
74
+ code: 'BSV-004',
75
+ message: `capability "${String(a.capability)}" must be a CAPABILITY-… canonical id.`,
76
+ path: 'capability',
77
+ });
78
+ }
79
+ }
80
+
81
+ return { valid: errors.length === 0, errors, warnings };
82
+ }
@@ -5,7 +5,7 @@ import yaml from 'js-yaml';
5
5
  import { parseCanonicalFGCA, parseCanonicalFGA } from '../parse-canonical.js';
6
6
 
7
7
  const VALID = {
8
- notation: 'fgca',
8
+ notation: 'dgca',
9
9
  spec_version: '0.1',
10
10
  id: 'FGCA-SAMPLE-1',
11
11
  name: 'Sample FGCA chain',
@@ -18,7 +18,7 @@ const VALID = {
18
18
  changes: [
19
19
  { id: 'CHANGE-1', name: 'Transformation', goals: ['GOAL-1'] },
20
20
  ],
21
- activities: [
21
+ actions: [
22
22
  { id: 'ACTIVITY-1', name: 'Workstream', changes: ['CHANGE-1'] },
23
23
  ],
24
24
  };
@@ -108,7 +108,7 @@ describe('parseCanonicalFGCA', () => {
108
108
  it('FGCA-010: rejects activity.changes[] referencing undefined change', () => {
109
109
  const r = parseCanonicalFGCA({
110
110
  ...VALID,
111
- activities: [{ id: 'ACTIVITY-1', name: 'A', changes: ['CHANGE-99'] }],
111
+ actions: [{ id: 'ACTIVITY-1', name: 'A', changes: ['CHANGE-99'] }],
112
112
  });
113
113
  expect(r.valid).toBe(false);
114
114
  expect(r.errors.some((e) => e.code === 'FGCA-010')).toBe(true);
@@ -135,7 +135,7 @@ describe('parseCanonicalFGCA', () => {
135
135
  const r = parseCanonicalFGCA({
136
136
  ...VALID,
137
137
  changes: [{ id: 'CHANGE-1', name: 'C', goals: ['GOAL-1'] }],
138
- activities: [
138
+ actions: [
139
139
  { id: 'ACTIVITY-1', name: 'A1', changes: ['CHANGE-1'] },
140
140
  { id: 'ACTIVITY-2', name: 'A2', changes: ['CHANGE-1'] },
141
141
  ],
@@ -159,20 +159,34 @@ describe('parseCanonicalFGCA — edge-driving fields populated', () => {
159
159
  expect(parsed.goals[0].factor).toHaveLength(1);
160
160
  // goal → change edge
161
161
  expect(parsed.changes[0].goal_id).not.toBe(0);
162
+ expect(parsed.changes[0].goal_id).not.toBe('');
162
163
  // change → activity edge
163
164
  expect(parsed.changes[0].activity_ids).toHaveLength(1);
164
165
  });
166
+
167
+ it('preserves canonical string IDs — not converted to sequential numbers', () => {
168
+ const r = parseCanonicalFGCA(VALID);
169
+ expect(r.valid).toBe(true);
170
+ const parsed = r.parsed!;
171
+ expect(parsed.factors[0].id).toBe('FACTOR-1');
172
+ expect(parsed.goals[0].id).toBe('GOAL-1');
173
+ expect(parsed.goals[0].factor![0].id).toBe('FACTOR-1');
174
+ expect(parsed.changes[0].id).toBe('CHANGE-1');
175
+ expect(parsed.changes[0].goal_id).toBe('GOAL-1');
176
+ expect(parsed.changes[0].activity_ids[0]).toBe('ACTIVITY-1');
177
+ expect(parsed.activities[0].id).toBe('ACTIVITY-1');
178
+ });
165
179
  });
166
180
 
167
181
  describe('parseCanonicalFGA', () => {
168
182
  const VALID_FGA = {
169
- notation: 'fga',
183
+ notation: 'dga',
170
184
  spec_version: '0.1',
171
185
  id: 'FGA-SAMPLE-1',
172
186
  name: 'Sample FGA chain',
173
187
  factors: [{ id: 'FACTOR-1', name: 'Driver' }],
174
188
  goals: [{ id: 'GOAL-1', name: 'Outcome', factors: ['FACTOR-1'] }],
175
- activities: [{ id: 'ACTIVITY-1', name: 'Workstream', goals: ['GOAL-1'] }],
189
+ actions: [{ id: 'ACTIVITY-1', name: 'Workstream', goals: ['GOAL-1'] }],
176
190
  };
177
191
 
178
192
  it('accepts a valid canonical FGA document (no changes layer)', () => {
@@ -215,12 +229,12 @@ describe('parseCanonicalFGA', () => {
215
229
  });
216
230
 
217
231
  describe('parseCanonicalFGCA — example file regression', () => {
218
- const EXAMPLES_DIR = path.resolve(process.cwd(), '..', '..', 'tests', 'fixtures', 'notation-corpus', 'fgca');
232
+ const EXAMPLES_DIR = path.resolve(process.cwd(), '..', '..', 'tests', 'fixtures', 'notation-corpus', 'dgca');
219
233
  const files = fs.existsSync(EXAMPLES_DIR)
220
234
  ? fs.readdirSync(EXAMPLES_DIR).filter((f) => f.endsWith('.transitrix.yaml'))
221
235
  : [];
222
236
  for (const file of files) {
223
- it(`accepts tests/fixtures/notation-corpus/fgca/${file}`, () => {
237
+ it(`accepts tests/fixtures/notation-corpus/dgca/${file}`, () => {
224
238
  const text = fs.readFileSync(path.join(EXAMPLES_DIR, file), 'utf8');
225
239
  const parsedYaml = yaml.load(text);
226
240
  const r = parseCanonicalFGCA(parsedYaml);
@@ -231,18 +245,18 @@ describe('parseCanonicalFGCA — example file regression', () => {
231
245
  });
232
246
 
233
247
  describe('parseCanonicalFGA — example file regression', () => {
234
- const EXAMPLES_DIR = path.resolve(process.cwd(), '..', '..', 'tests', 'fixtures', 'notation-corpus', 'fga');
248
+ const EXAMPLES_DIR = path.resolve(process.cwd(), '..', '..', 'tests', 'fixtures', 'notation-corpus', 'dga');
235
249
  const files = fs.existsSync(EXAMPLES_DIR)
236
250
  ? fs.readdirSync(EXAMPLES_DIR).filter((f) => f.endsWith('.transitrix.yaml'))
237
251
  : [];
238
252
  for (const file of files) {
239
- it(`accepts tests/fixtures/notation-corpus/fga/${file} and resolves every activity to a goal`, () => {
253
+ it(`accepts tests/fixtures/notation-corpus/dga/${file} and resolves every activity to a goal`, () => {
240
254
  const text = fs.readFileSync(path.join(EXAMPLES_DIR, file), 'utf8');
241
255
  const r = parseCanonicalFGA(yaml.load(text));
242
256
  expect(r.errors).toEqual([]);
243
257
  expect(r.valid).toBe(true);
244
258
  // Every activity must carry a goal_id, or the FGA preview draws no edges.
245
- expect(r.parsed!.activities.every((a) => a.goal_id != null && a.goal_id !== 0)).toBe(true);
259
+ expect(r.parsed!.activities.every((a) => a.goal_id != null && a.goal_id !== 0 && a.goal_id !== '')).toBe(true);
246
260
  });
247
261
  }
248
262
  });
@@ -26,7 +26,7 @@ const ELEMENTS = [
26
26
  const SOURCES = { elements: ELEMENTS, relations: [] };
27
27
 
28
28
  const VIEW_DOC = {
29
- notation: 'fgca',
29
+ notation: 'dgca',
30
30
  id: 'FGCA-STRAT-1',
31
31
  name: 'Strategy 2026 — FGCA chain',
32
32
  spec_version: '0.1',
@@ -47,7 +47,7 @@ describe('isFGCAViewDoc', () => {
47
47
 
48
48
  it('returns false for an inline doc with factors/goals arrays', () => {
49
49
  expect(isFGCAViewDoc({
50
- notation: 'fgca', id: 'FGCA-1', name: 'x',
50
+ notation: 'dgca', id: 'FGCA-1', name: 'x',
51
51
  factors: [], goals: [], changes: [], activities: [],
52
52
  })).toBe(false);
53
53
  });
@@ -66,7 +66,7 @@ describe('resolveFGCA — goals.filter=all', () => {
66
66
  const doc = resolveFGCA(VIEW_DOC, SOURCES);
67
67
  expect((doc['goals'] as unknown[]).length).toBe(3);
68
68
  expect((doc['changes'] as unknown[]).length).toBe(3);
69
- expect((doc['activities'] as unknown[]).length).toBe(5);
69
+ expect((doc['actions'] as unknown[]).length).toBe(5);
70
70
  });
71
71
 
72
72
  it('derives factors from all goal.factors[] refs', () => {
@@ -90,7 +90,7 @@ describe('resolveFGCA — goals.filter=all', () => {
90
90
 
91
91
  it('carries view doc metadata (id, name, spec_version)', () => {
92
92
  const doc = resolveFGCA(VIEW_DOC, SOURCES);
93
- expect(doc['notation']).toBe('fgca');
93
+ expect(doc['notation']).toBe('dgca');
94
94
  expect(doc['id']).toBe('FGCA-STRAT-1');
95
95
  expect(doc['name']).toBe('Strategy 2026 — FGCA chain');
96
96
  expect(doc['spec_version']).toBe('0.1');
@@ -110,7 +110,7 @@ describe('resolveFGCA — goals.filter=ids', () => {
110
110
  expect(goalIds).toEqual(['GOAL-1']);
111
111
  const changeIds = (doc['changes'] as Array<{ id: string }>).map((c) => c.id);
112
112
  expect(changeIds).toEqual(['CHANGE-1']);
113
- const actIds = (doc['activities'] as Array<{ id: string }>).map((a) => a.id).sort();
113
+ const actIds = (doc['actions'] as Array<{ id: string }>).map((a) => a.id).sort();
114
114
  expect(actIds).toEqual(['ACTIVITY-1', 'ACTIVITY-2']);
115
115
  const r = parseCanonicalFGCA(doc);
116
116
  expect(r.valid).toBe(true);
@@ -159,7 +159,7 @@ describe('resolveFGCA — empty sources', () => {
159
159
  expect((doc['goals'] as unknown[]).length).toBe(0);
160
160
  expect((doc['factors'] as unknown[]).length).toBe(0);
161
161
  expect((doc['changes'] as unknown[]).length).toBe(0);
162
- expect((doc['activities'] as unknown[]).length).toBe(0);
162
+ expect((doc['actions'] as unknown[]).length).toBe(0);
163
163
  });
164
164
 
165
165
  it('returns empty arrays for a non-object viewDoc', () => {
@@ -172,7 +172,7 @@ describe('resolveFGCA — empty sources', () => {
172
172
 
173
173
  describe('resolveFGCA — file-based regression against canon fixture', () => {
174
174
  const CANON_ROOT = path.resolve(
175
- process.cwd(), '..', '..', 'tests', 'fixtures', 'notation-corpus', 'fgca', 'canon',
175
+ process.cwd(), '..', '..', 'tests', 'fixtures', 'notation-corpus', 'dgca', 'canon',
176
176
  );
177
177
  const VIEW_DIR = path.join(CANON_ROOT, 'views');
178
178
  const ELEM_DIR = path.join(CANON_ROOT, 'elements');