archgraph-argo 0.26.2 → 0.28.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.
@@ -0,0 +1,932 @@
1
+ 'use strict';
2
+
3
+ // ARGO schema-bundle resolution and ontology construction.
4
+ //
5
+ // The toolchain is no longer hard-wired to a single modeling language. A
6
+ // "schema bundle" is a directory that carries the graph contract:
7
+ //
8
+ // <bundle>/SystemArchitecture.schema.json (required) JSON Schema of the graph
9
+ // <bundle>/schema-bundle.config.json (optional) bundle descriptor:
10
+ // language, enum locations, guide,
11
+ // rules file, invariant switches
12
+ // <bundle>/schema-bundle.rules.json (optional) ontology rules data
13
+ // (type metadata, relationship
14
+ // categories, endpoint matrix)
15
+ // <bundle>/GUIDE.md (optional) human-readable guide
16
+ //
17
+ // Resolution precedence (first bundle that has SystemArchitecture.schema.json):
18
+ // 1. ARGO_SCHEMA_DIR 鈥?explicit override (tests / hosts)
19
+ // 2. <workspaceRoot>/.argo/schema 鈥?the repository's own schema
20
+ // 3. <argoRoot>/schema 鈥?the default ArchiMate 3.2 schema
21
+ //
22
+ // The default bundle keeps the historical ArchiMate 3.2 + ARGO behaviour. A
23
+ // custom bundle may define its own element/relationship types and, optionally,
24
+ // its own endpoint rules and invariants.
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ const { getArgoRoot } = require('./argo-paths.js');
30
+
31
+ const SCHEMA_BASENAME = 'SystemArchitecture.schema.json';
32
+ const CONFIG_BASENAME = 'schema-bundle.config.json';
33
+ const RULES_BASENAME = 'schema-bundle.rules.json';
34
+ const GUIDE_BASENAME = 'GUIDE.md';
35
+ const DEFAULT_GUIDE_BASENAME = 'archimate3.2.md';
36
+ const DEFAULT_LANGUAGE = 'ArchiMate 3.2';
37
+ const DEFAULT_ROOT_VIEW_NAME = 'SystemArchitecture';
38
+ const DEFAULT_MAX_ELEMENTS_PER_VIEW = 15;
39
+ const DEFAULT_ACTOR_ELEMENT_TYPE = 'Business Actor';
40
+
41
+ // Which relationship types express a delivery dependency and in which direction.
42
+ // Declared per bundle via schema-bundle.config.json "deliveryDependencies"; the default
43
+ // ArchiMate 3.2 bundle keeps the ArchiMate mapping below (previous behaviour).
44
+ const ARCHIMATE_DELIVERY_DEPENDENCIES = Object.freeze({
45
+ sourceDependsOnTarget: Object.freeze(['Access', 'Assignment', 'Specialization', 'Composition', 'Aggregation']),
46
+ targetDependsOnSource: Object.freeze(['Serving', 'Realization', 'Flow', 'Triggering', 'Influence']),
47
+ });
48
+
49
+ function resolveDeliveryDependencies(config, dialect) {
50
+ const raw = config && typeof config.deliveryDependencies === 'object' && config.deliveryDependencies !== null
51
+ ? config.deliveryDependencies
52
+ : null;
53
+ if (raw) {
54
+ return {
55
+ sourceDependsOnTarget: Array.isArray(raw.sourceDependsOnTarget) ? raw.sourceDependsOnTarget.slice() : [],
56
+ targetDependsOnSource: Array.isArray(raw.targetDependsOnSource) ? raw.targetDependsOnSource.slice() : [],
57
+ };
58
+ }
59
+ if (dialect === 'archimate-class-matrix') {
60
+ return {
61
+ sourceDependsOnTarget: ARCHIMATE_DELIVERY_DEPENDENCIES.sourceDependsOnTarget.slice(),
62
+ targetDependsOnSource: ARCHIMATE_DELIVERY_DEPENDENCIES.targetDependsOnSource.slice(),
63
+ };
64
+ }
65
+ // Custom schema with no declared dependency semantics: no delivery ordering
66
+ // (tests still run, in declaration order).
67
+ return { sourceDependsOnTarget: [], targetDependsOnSource: [] };
68
+ }
69
+ const ELEMENT_ENUM_KEYS = ['archimateElementType', 'elementType', 'elementTypes'];
70
+ const RELATIONSHIP_ENUM_KEYS = ['archimateRelationshipType', 'relationshipType', 'relationshipTypes'];
71
+
72
+ function toPosix(value) {
73
+ return String(value == null ? '' : value).replace(/\\/g, '/');
74
+ }
75
+
76
+ function readJsonFile(absolutePath) {
77
+ return JSON.parse(fs.readFileSync(absolutePath, 'utf8'));
78
+ }
79
+
80
+ function isFile(p) {
81
+ try {
82
+ return fs.statSync(p).isFile();
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ function relativeLabel(workspaceRoot, absolutePath) {
89
+ const workspaceRelative = path.relative(workspaceRoot, absolutePath);
90
+ if (workspaceRelative && !workspaceRelative.startsWith('..') && !path.isAbsolute(workspaceRelative)) {
91
+ return toPosix(workspaceRelative);
92
+ }
93
+ const argoRelative = path.relative(getArgoRoot(), absolutePath);
94
+ if (argoRelative && !argoRelative.startsWith('..') && !path.isAbsolute(argoRelative)) {
95
+ return `<argo>/${toPosix(argoRelative)}`;
96
+ }
97
+ return toPosix(absolutePath);
98
+ }
99
+
100
+ function buildBundle(kind, dir, workspaceRoot) {
101
+ const schemaFile = path.join(dir, SCHEMA_BASENAME);
102
+ const schemaExists = isFile(schemaFile);
103
+ const schema = schemaExists ? readJsonFile(schemaFile) : null;
104
+
105
+ const configFile = path.join(dir, CONFIG_BASENAME);
106
+ const fileConfig = isFile(configFile) ? readJsonFile(configFile) : {};
107
+ const inlineConfig = schema && typeof schema['x-schema-bundle'] === 'object' && schema['x-schema-bundle'] !== null
108
+ ? schema['x-schema-bundle']
109
+ : {};
110
+ const config = { ...inlineConfig, ...fileConfig };
111
+
112
+ const rulesFile = typeof config.rules === 'string' && config.rules.trim() !== ''
113
+ ? path.resolve(dir, config.rules)
114
+ : path.join(dir, RULES_BASENAME);
115
+ const rules = isFile(rulesFile) ? readJsonFile(rulesFile) : null;
116
+
117
+ let guidePath = null;
118
+ if (typeof config.guide === 'string' && config.guide.trim() !== '') {
119
+ const candidate = path.resolve(dir, config.guide);
120
+ if (isFile(candidate)) {
121
+ guidePath = candidate;
122
+ }
123
+ } else if (kind === 'default') {
124
+ const candidate = path.join(dir, DEFAULT_GUIDE_BASENAME);
125
+ if (isFile(candidate)) {
126
+ guidePath = candidate;
127
+ }
128
+ } else {
129
+ const candidate = path.join(dir, GUIDE_BASENAME);
130
+ if (isFile(candidate)) {
131
+ guidePath = candidate;
132
+ }
133
+ }
134
+
135
+ return {
136
+ kind,
137
+ dir,
138
+ relativeDir: relativeLabel(workspaceRoot, dir),
139
+ schema: schemaExists
140
+ ? { absolutePath: schemaFile, relativePath: relativeLabel(workspaceRoot, schemaFile) }
141
+ : null,
142
+ schemaDocument: schema,
143
+ config: {
144
+ filePath: isFile(configFile) ? { absolutePath: configFile, relativePath: relativeLabel(workspaceRoot, configFile) } : null,
145
+ ...config,
146
+ },
147
+ rulesPath: rules
148
+ ? { absolutePath: rulesFile, relativePath: relativeLabel(workspaceRoot, rulesFile) }
149
+ : null,
150
+ rules,
151
+ guidePath: guidePath
152
+ ? { absolutePath: guidePath, relativePath: relativeLabel(workspaceRoot, guidePath) }
153
+ : null,
154
+ };
155
+ }
156
+
157
+ // --- Bundle inheritance (issue #4) -----------------------------------------
158
+ // A bundle may declare `extends` to inherit a base bundle (the built-in default,
159
+ // or another bundle directory) and add/override on top of it instead of forking
160
+ // the whole rules file. `addElementTypes` / `addRelationships` / `overrideMatrix`
161
+ // are the delta; the effective element universe becomes base ∪ add (single
162
+ // source of truth), matrix/metadata merge by key.
163
+
164
+ function bundleConfig(dir) {
165
+ const configFile = path.join(dir, CONFIG_BASENAME);
166
+ if (!isFile(configFile)) {
167
+ return {};
168
+ }
169
+ try {
170
+ return readJsonFile(configFile);
171
+ } catch {
172
+ return {};
173
+ }
174
+ }
175
+
176
+ function hasExtendsConfig(dir) {
177
+ const config = bundleConfig(dir);
178
+ return typeof config.extends === 'string' && config.extends.trim() !== '';
179
+ }
180
+
181
+ // Symbolic names a bundle declares about itself (`id` + `aliases`) in its config.
182
+ // The framework resolves an `extends` value against these DECLARED names — never a
183
+ // name hardcoded in framework logic — so a bundle's identity/aliases are data.
184
+ function bundleSymbolicNames(dir) {
185
+ const config = bundleConfig(dir);
186
+ const names = new Set();
187
+ if (typeof config.id === 'string' && config.id.trim() !== '') {
188
+ names.add(config.id.trim());
189
+ }
190
+ if (Array.isArray(config.aliases)) {
191
+ for (const alias of config.aliases) {
192
+ if (typeof alias === 'string' && alias.trim() !== '') {
193
+ names.add(alias.trim());
194
+ }
195
+ }
196
+ }
197
+ return names;
198
+ }
199
+
200
+ function isPathLikeExt(value) {
201
+ return value.includes('/') || value.includes('\\') || value.startsWith('.') || value.startsWith('~')
202
+ || /^[a-zA-Z]:/.test(value);
203
+ }
204
+
205
+ // Bounded, general candidate list for symbolic bundle resolution: the built-in
206
+ // default bundle, any directory a bundle lists in its own `basePaths`, the bundle's
207
+ // own directory, and its immediate sub-directories. The framework matches a name
208
+ // against each candidate's SELF-DECLARED id/aliases (data) — it hardcodes no
209
+ // modeling-language name. A non-matching symbolic name falls back to a path, so
210
+ // bare relative directory references keep working.
211
+ function candidateBaseDirs(childDir, config) {
212
+ const dirs = [];
213
+ const seen = new Set();
214
+ const push = (dir) => {
215
+ if (!dir) {
216
+ return;
217
+ }
218
+ const resolved = path.resolve(dir);
219
+ const key = resolved.toLowerCase();
220
+ if (!seen.has(key)) {
221
+ seen.add(key);
222
+ dirs.push(resolved);
223
+ }
224
+ };
225
+ push(path.join(getArgoRoot(), 'schema'));
226
+ const basePaths = config && Array.isArray(config.basePaths) ? config.basePaths : [];
227
+ for (const basePath of basePaths) {
228
+ if (typeof basePath === 'string' && basePath.trim() !== '') {
229
+ push(path.resolve(childDir, basePath.trim()));
230
+ }
231
+ }
232
+ push(childDir);
233
+ let entries = [];
234
+ try {
235
+ entries = fs.readdirSync(childDir, { withFileTypes: true });
236
+ } catch {
237
+ entries = [];
238
+ }
239
+ for (const entry of entries) {
240
+ if (entry.isDirectory()) {
241
+ push(path.join(childDir, entry.name));
242
+ }
243
+ }
244
+ return dirs;
245
+ }
246
+
247
+ function resolveBaseBundleDir(ext, childDir, config) {
248
+ const value = String(ext).trim();
249
+ // `default` is the reserved, language-neutral name of the built-in bundle.
250
+ if (value === 'default') {
251
+ return path.join(getArgoRoot(), 'schema');
252
+ }
253
+ if (!isPathLikeExt(value)) {
254
+ for (const dir of candidateBaseDirs(childDir, config)) {
255
+ if (bundleSymbolicNames(dir).has(value)) {
256
+ return dir;
257
+ }
258
+ }
259
+ }
260
+ // Not a symbolic hit: treat as a path relative to the bundle directory.
261
+ return path.resolve(childDir, value);
262
+ }
263
+
264
+ function deepCloneJson(value) {
265
+ return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
266
+ }
267
+
268
+ function mergeMatrixInto(target, source) {
269
+ if (!source || typeof source !== 'object') {
270
+ return target;
271
+ }
272
+ for (const [relationshipType, bySource] of Object.entries(source)) {
273
+ if (!bySource || typeof bySource !== 'object') {
274
+ target[relationshipType] = deepCloneJson(bySource);
275
+ continue;
276
+ }
277
+ if (!target[relationshipType] || typeof target[relationshipType] !== 'object') {
278
+ target[relationshipType] = {};
279
+ }
280
+ for (const [sourceType, targets] of Object.entries(bySource)) {
281
+ target[relationshipType][sourceType] = deepCloneJson(targets);
282
+ }
283
+ }
284
+ return target;
285
+ }
286
+
287
+ // Append added types to a JSON-Schema enum def (the element/relationship type
288
+ // universe), so a schema-less inheriting Profile's own added types are structurally
289
+ // valid — not just present at the ontology level.
290
+ function extendSchemaEnum(schemaDocument, enumKeys, additions) {
291
+ const defs = schemaDocument && typeof schemaDocument.$defs === 'object' ? schemaDocument.$defs : null;
292
+ if (!defs) {
293
+ return;
294
+ }
295
+ for (const key of enumKeys) {
296
+ const node = defs[key];
297
+ if (node && Array.isArray(node.enum)) {
298
+ for (const value of additions) {
299
+ if (typeof value === 'string' && value !== '' && !node.enum.includes(value)) {
300
+ node.enum.push(value);
301
+ }
302
+ }
303
+ return;
304
+ }
305
+ }
306
+ }
307
+
308
+ function mergeExtendsBundle(base, child) {
309
+ const baseRules = base.rules || {};
310
+ const childRules = child.rules || {};
311
+ const config = { ...(base.config || {}), ...(child.config || {}) };
312
+
313
+ const elementTypeMetadata = { ...(baseRules.elementTypeMetadata || {}), ...(childRules.elementTypeMetadata || {}) };
314
+ const archimateClassByElementType = { ...(baseRules.archimateClassByElementType || {}), ...(childRules.archimateClassByElementType || {}) };
315
+ const relationshipCategoryByType = { ...(baseRules.relationshipCategoryByType || {}), ...(childRules.relationshipCategoryByType || {}) };
316
+
317
+ const addElementTypes = config.addElementTypes && typeof config.addElementTypes === 'object' ? config.addElementTypes : {};
318
+ for (const [type, meta] of Object.entries(addElementTypes)) {
319
+ if (!elementTypeMetadata[type]) {
320
+ elementTypeMetadata[type] = { layer: (meta && meta.layer) || null, aspect: (meta && meta.aspect) || null };
321
+ }
322
+ if (meta && typeof meta.class === 'string' && meta.class !== '') {
323
+ archimateClassByElementType[type] = meta.class;
324
+ }
325
+ }
326
+ const addRelationships = config.addRelationships && typeof config.addRelationships === 'object' ? config.addRelationships : {};
327
+ for (const [type, category] of Object.entries(addRelationships)) {
328
+ relationshipCategoryByType[type] = category;
329
+ }
330
+
331
+ // Guarantee base ∪ add for the element/relationship universe regardless of the
332
+ // base dialect (class-matrix derives types from rules metadata, type-matrix from
333
+ // schema enums) — an inheriting Profile must never silently drop a base type.
334
+ const baseOntology = buildOntology(base);
335
+ for (const type of baseOntology.elementTypes) {
336
+ if (!elementTypeMetadata[type]) {
337
+ elementTypeMetadata[type] = { layer: null, aspect: null };
338
+ }
339
+ }
340
+ for (const type of baseOntology.relationshipTypes) {
341
+ if (!relationshipCategoryByType[type]) {
342
+ relationshipCategoryByType[type] = 'Custom';
343
+ }
344
+ }
345
+
346
+ const relationshipTargetMatrix = deepCloneJson(baseRules.relationshipTargetMatrix || {}) || {};
347
+ mergeMatrixInto(relationshipTargetMatrix, childRules.relationshipTargetMatrix || {});
348
+ mergeMatrixInto(relationshipTargetMatrix, config.overrideMatrix || {});
349
+
350
+ // Inherit the base dialect as-is; never default to a specific dialect NAME here
351
+ // (that would couple the generic merge to one modeling language). When both are
352
+ // absent the ontology builder decides from the bundle's declared rules.
353
+ const dialect = childRules.dialect || baseRules.dialect;
354
+ const rules = {
355
+ ...baseRules,
356
+ ...childRules,
357
+ dialect,
358
+ elementTypeMetadata,
359
+ archimateClassByElementType,
360
+ relationshipCategoryByType,
361
+ relationshipTargetMatrix,
362
+ };
363
+
364
+ // Structural schema: when the child has none, it inherits the base schema — but
365
+ // its added types must be valid against the base's $defs enum too, otherwise
366
+ // whole-graph validation (validateAgainstSchema) rejects elements of the added
367
+ // types. Extend the inherited schema enum in place (clone, never mutate base).
368
+ let schemaDocument = child.schemaDocument || base.schemaDocument;
369
+ if (!child.schemaDocument && base.schemaDocument) {
370
+ const extraElementTypes = Object.keys(addElementTypes).filter(Boolean);
371
+ const extraRelationshipTypes = Object.keys(addRelationships).filter(Boolean);
372
+ const extraFromConfigElements = Array.isArray(config.elementTypes) ? config.elementTypes : [];
373
+ const extraFromConfigRelationships = Array.isArray(config.relationshipTypes) ? config.relationshipTypes : [];
374
+ if (extraElementTypes.length || extraRelationshipTypes.length || extraFromConfigElements.length || extraFromConfigRelationships.length) {
375
+ schemaDocument = deepCloneJson(base.schemaDocument);
376
+ extendSchemaEnum(schemaDocument, ELEMENT_ENUM_KEYS, extraElementTypes.concat(extraFromConfigElements));
377
+ extendSchemaEnum(schemaDocument, RELATIONSHIP_ENUM_KEYS, extraRelationshipTypes.concat(extraFromConfigRelationships));
378
+ }
379
+ }
380
+
381
+ return {
382
+ ...child,
383
+ schema: child.schema || base.schema,
384
+ schemaDocument,
385
+ rules,
386
+ config,
387
+ inheritedFrom: base.dir,
388
+ chainDirs: Array.from(new Set([
389
+ ...(Array.isArray(base.chainDirs) && base.chainDirs.length > 0 ? base.chainDirs : [path.resolve(base.dir).toLowerCase()]),
390
+ path.resolve(child.dir).toLowerCase(),
391
+ ])),
392
+ };
393
+ }
394
+
395
+ function applyExtends(bundle, workspaceRoot, seen = new Set()) {
396
+ const ext = bundle && bundle.config && typeof bundle.config.extends === 'string' ? bundle.config.extends.trim() : '';
397
+ if (!ext) {
398
+ return bundle;
399
+ }
400
+ seen.add(path.resolve(bundle.dir).toLowerCase());
401
+ const baseDir = resolveBaseBundleDir(ext, bundle.dir, bundle.config);
402
+ const baseKey = path.resolve(baseDir).toLowerCase();
403
+ if (seen.has(baseKey)) {
404
+ throw new Error(`schema bundle extends cycle detected at '${baseDir}'`);
405
+ }
406
+ if (!isFile(path.join(baseDir, SCHEMA_BASENAME)) && !hasExtendsConfig(baseDir)) {
407
+ throw new Error(`schema bundle extends '${ext}' could not be resolved: no schema bundle at '${baseDir}'`);
408
+ }
409
+ const baseBundle = applyExtends(buildBundle('extends', baseDir, workspaceRoot), workspaceRoot, seen);
410
+ return mergeExtendsBundle(baseBundle, bundle);
411
+ }
412
+
413
+ function resolveSchemaBundle(workspaceRoot, options = {}) {
414
+ const root = path.resolve(workspaceRoot || process.cwd());
415
+ const candidates = [];
416
+
417
+ const envDir = options.schemaDir || process.env.ARGO_SCHEMA_DIR;
418
+ if (typeof envDir === 'string' && envDir.trim() !== '') {
419
+ candidates.push({ kind: 'override', dir: path.resolve(root, envDir.trim()) });
420
+ }
421
+ candidates.push({ kind: 'workspace', dir: path.join(root, '.argo', 'schema') });
422
+ candidates.push({ kind: 'default', dir: path.join(getArgoRoot(), 'schema') });
423
+
424
+ const seen = new Set();
425
+ for (const candidate of candidates) {
426
+ const key = path.resolve(candidate.dir).toLowerCase();
427
+ if (seen.has(key)) {
428
+ continue;
429
+ }
430
+ seen.add(key);
431
+ if (isFile(path.join(candidate.dir, SCHEMA_BASENAME)) || hasExtendsConfig(candidate.dir)) {
432
+ return applyExtends(buildBundle(candidate.kind, candidate.dir, root), root);
433
+ }
434
+ }
435
+
436
+ throw new Error(
437
+ `Unable to locate '${SCHEMA_BASENAME}'. Checked: ${candidates.map(c => c.dir).join(', ')}`,
438
+ );
439
+ }
440
+
441
+ function resolveEnumFromSchema(schema, keys, configPathKey) {
442
+ if (configPathKey && Array.isArray(configPathKey)) {
443
+ let current = schema;
444
+ for (const segment of configPathKey) {
445
+ if (!current || typeof current !== 'object' || !(segment in current)) {
446
+ current = undefined;
447
+ break;
448
+ }
449
+ current = current[segment];
450
+ }
451
+ if (Array.isArray(current)) {
452
+ return current.slice();
453
+ }
454
+ }
455
+ const defs = schema && typeof schema.$defs === 'object' && schema.$defs !== null ? schema.$defs : {};
456
+ for (const key of keys) {
457
+ const node = defs[key];
458
+ if (node && Array.isArray(node.enum)) {
459
+ return node.enum.slice();
460
+ }
461
+ }
462
+ return [];
463
+ }
464
+
465
+ function resolveTypeEnums(bundle) {
466
+ const config = bundle.config || {};
467
+ const schema = bundle.schemaDocument || {};
468
+
469
+ let elementTypes = Array.isArray(config.elementTypes) ? config.elementTypes.slice() : [];
470
+ if (elementTypes.length === 0) {
471
+ elementTypes = resolveEnumFromSchema(schema, ELEMENT_ENUM_KEYS, config.elementTypeEnumPath);
472
+ }
473
+
474
+ let relationshipTypes = Array.isArray(config.relationshipTypes) ? config.relationshipTypes.slice() : [];
475
+ if (relationshipTypes.length === 0) {
476
+ relationshipTypes = resolveEnumFromSchema(schema, RELATIONSHIP_ENUM_KEYS, config.relationshipTypeEnumPath);
477
+ }
478
+
479
+ return { elementTypes, relationshipTypes };
480
+ }
481
+
482
+ function resolveInvariants(config, defaults) {
483
+ const raw = config && typeof config.invariants === 'object' && config.invariants !== null
484
+ ? config.invariants
485
+ : {};
486
+ const invariants = { ...defaults };
487
+ if (typeof raw.statementGrammar === 'boolean') {
488
+ invariants.statementGrammar = raw.statementGrammar;
489
+ }
490
+ if (typeof raw.endpointMatrix === 'boolean') {
491
+ invariants.endpointMatrix = raw.endpointMatrix;
492
+ }
493
+ if (raw.rootViewName === null) {
494
+ invariants.rootViewName = null;
495
+ } else if (typeof raw.rootViewName === 'string' && raw.rootViewName.trim() !== '') {
496
+ invariants.rootViewName = raw.rootViewName.trim();
497
+ }
498
+ if (raw.maxElementsPerView === null) {
499
+ invariants.maxElementsPerView = null;
500
+ } else if (Number.isInteger(raw.maxElementsPerView) && raw.maxElementsPerView >= 0) {
501
+ invariants.maxElementsPerView = raw.maxElementsPerView;
502
+ }
503
+ return invariants;
504
+ }
505
+
506
+ function resolveActorElementType(config) {
507
+ // The Actor element type is part of the bundle contract: the ARGO workflow
508
+ // identifies the agent through an Actor element (wakeup gate). A bundle may
509
+ // rename it, or set it to null to declare that the schema has no actor concept
510
+ // (actor identification is then skipped). Absent => the default 'Business Actor'.
511
+ if (config && Object.prototype.hasOwnProperty.call(config, 'actorElementType')) {
512
+ return config.actorElementType;
513
+ }
514
+ return DEFAULT_ACTOR_ELEMENT_TYPE;
515
+ }
516
+
517
+ // Per-element-type attribute contract (issue #3): declare that an element type
518
+ // must carry certain attributes, that some attribute values are a controlled
519
+ // vocabulary, and that some attribute values are unique within the type.
520
+ // { "Rule": { "required": ["ruleId","normativity"], "unique": ["ruleId"],
521
+ // "enumByAttr": { "normativity": ["MUST","SHOULD","MAY","MUST_NOT"] } } }
522
+ // Absent => no per-type attribute contract (backward compatible).
523
+ function resolveAttributeContracts(config) {
524
+ const raw = config && typeof config.attributesByElementType === 'object' && config.attributesByElementType !== null
525
+ ? config.attributesByElementType
526
+ : null;
527
+ if (!raw) {
528
+ return undefined;
529
+ }
530
+ const contracts = {};
531
+ for (const [type, contract] of Object.entries(raw)) {
532
+ if (!contract || typeof contract !== 'object') {
533
+ contracts[type] = {};
534
+ continue;
535
+ }
536
+ contracts[type] = {
537
+ required: Array.isArray(contract.required) ? contract.required.slice() : undefined,
538
+ unique: Array.isArray(contract.unique) ? contract.unique.slice() : undefined,
539
+ enumByAttr: contract.enumByAttr && typeof contract.enumByAttr === 'object' ? { ...contract.enumByAttr } : undefined,
540
+ };
541
+ }
542
+ return contracts;
543
+ }
544
+
545
+ function validateAttributeContracts(language, elementTypeList, contracts) {
546
+ const errors = [];
547
+ if (!contracts || typeof contracts !== 'object') {
548
+ return errors;
549
+ }
550
+ for (const [type, contract] of Object.entries(contracts)) {
551
+ if (!elementTypeList.includes(type)) {
552
+ errors.push(`schema bundle '${language}' attributesByElementType references unknown element type '${type}'`);
553
+ }
554
+ if (!contract || typeof contract !== 'object') {
555
+ continue;
556
+ }
557
+ for (const key of ['required', 'unique']) {
558
+ const list = contract[key];
559
+ if (list === undefined) {
560
+ continue;
561
+ }
562
+ if (!Array.isArray(list)) {
563
+ errors.push(`schema bundle '${language}' attributesByElementType['${type}'].${key} must be an array`);
564
+ continue;
565
+ }
566
+ for (const name of list) {
567
+ if (typeof name !== 'string' || name === '') {
568
+ errors.push(`schema bundle '${language}' attributesByElementType['${type}'].${key} entries must be non-empty strings`);
569
+ }
570
+ }
571
+ }
572
+ if (contract.enumByAttr !== undefined) {
573
+ if (!contract.enumByAttr || typeof contract.enumByAttr !== 'object') {
574
+ errors.push(`schema bundle '${language}' attributesByElementType['${type}'].enumByAttr must be an object`);
575
+ } else {
576
+ for (const [attr, allowed] of Object.entries(contract.enumByAttr)) {
577
+ if (!Array.isArray(allowed) || allowed.length === 0) {
578
+ errors.push(`schema bundle '${language}' attributesByElementType['${type}'].enumByAttr['${attr}'] must be a non-empty array`);
579
+ }
580
+ }
581
+ }
582
+ }
583
+ }
584
+ return errors;
585
+ }
586
+
587
+ function validateBundle({ language, dialect, elementTypes, relationshipTypes, actorElementType, matrix, deliveryDependencies, attributesByElementType }) {
588
+ const errors = [];
589
+ const elementTypeList = Array.isArray(elementTypes) ? elementTypes : [];
590
+ const relationshipTypeList = Array.isArray(relationshipTypes) ? relationshipTypes : [];
591
+
592
+ if (elementTypeList.length === 0) {
593
+ errors.push(`schema bundle '${language}' defines no element types`);
594
+ }
595
+ if (relationshipTypeList.length === 0) {
596
+ errors.push(`schema bundle '${language}' defines no relationship types`);
597
+ }
598
+
599
+ if (actorElementType === null) {
600
+ // Explicit opt-out: the schema has no actor/agent identity concept.
601
+ } else if (typeof actorElementType === 'string' && actorElementType.trim() !== '') {
602
+ if (!elementTypeList.includes(actorElementType)) {
603
+ errors.push(
604
+ `schema bundle '${language}' declares actorElementType '${actorElementType}' which is not one of its element types; ` +
605
+ `set a valid actorElementType in schema-bundle.config.json (one of: ${elementTypeList.join(', ') || '(none)'}) ` +
606
+ `or set "actorElementType": null if the schema has no actor concept`,
607
+ );
608
+ }
609
+ } else {
610
+ errors.push(`schema bundle '${language}' actorElementType must be a non-empty string or null; got ${JSON.stringify(actorElementType)}`);
611
+ }
612
+
613
+ // The endpoint matrix (type-keyed bundles only) must only reference declared types.
614
+ if (dialect !== 'archimate-class-matrix' && matrix && typeof matrix === 'object') {
615
+ for (const [relationshipType, targetsBySource] of Object.entries(matrix)) {
616
+ if (!relationshipTypeList.includes(relationshipType)) {
617
+ errors.push(`relationshipTargetMatrix references unknown relationship type '${relationshipType}'`);
618
+ }
619
+ for (const [sourceType, targetList] of Object.entries(targetsBySource || {})) {
620
+ if (!elementTypeList.includes(sourceType)) {
621
+ errors.push(`relationshipTargetMatrix['${relationshipType}'] references unknown element type '${sourceType}'`);
622
+ }
623
+ for (const targetType of Array.isArray(targetList) ? targetList : []) {
624
+ if (targetType !== '*' && !elementTypeList.includes(targetType)) {
625
+ errors.push(`relationshipTargetMatrix['${relationshipType}']['${sourceType}'] references unknown element type '${targetType}'`);
626
+ }
627
+ }
628
+ }
629
+ }
630
+ }
631
+
632
+ errors.push(...validateDeliveryDependencies(language, relationshipTypeList, deliveryDependencies));
633
+ errors.push(...validateAttributeContracts(language, elementTypeList, attributesByElementType));
634
+
635
+ return { status: errors.length === 0 ? 'passed' : 'failed', errors };
636
+ }
637
+
638
+ function validateDeliveryDependencies(language, relationshipTypeList, deliveryDependencies) {
639
+ const errors = [];
640
+ if (!deliveryDependencies || typeof deliveryDependencies !== 'object') {
641
+ return errors;
642
+ }
643
+ const relSet = new Set(relationshipTypeList);
644
+ for (const key of ['sourceDependsOnTarget', 'targetDependsOnSource']) {
645
+ for (const type of Array.isArray(deliveryDependencies[key]) ? deliveryDependencies[key] : []) {
646
+ if (!relSet.has(type)) {
647
+ errors.push(`schema bundle '${language}' deliveryDependencies.${key} references unknown relationship type '${type}'`);
648
+ }
649
+ }
650
+ }
651
+ return errors;
652
+ }
653
+
654
+ function buildClassMatrixOntology(bundle) {
655
+ const language = typeof bundle.config.language === 'string' && bundle.config.language.trim() !== ''
656
+ ? bundle.config.language.trim()
657
+ : DEFAULT_LANGUAGE;
658
+ const rules = bundle.rules;
659
+ let elementTypeMetadata;
660
+ let relationshipCategoryByType;
661
+ let isSupportedElementType;
662
+ let isSupportedRelationshipType;
663
+ let getMetadata;
664
+ let getArchiMateClass;
665
+ let validateRelationshipEndpointTypes;
666
+
667
+ if (rules) {
668
+ // Data-driven default: the bundle ships its own rule data (schema-bundle.rules.json),
669
+ // so the DEFAULT schema is replaceable file-for-file exactly like a custom one.
670
+ const classByType = rules.archimateClassByElementType || {};
671
+ const classMatrix = rules.relationshipTargetMatrix || {};
672
+ elementTypeMetadata = new Map(Object.entries(rules.elementTypeMetadata || {}));
673
+ relationshipCategoryByType = new Map(Object.entries(rules.relationshipCategoryByType || {}));
674
+ isSupportedElementType = (type) => elementTypeMetadata.has(type);
675
+ isSupportedRelationshipType = (type) => relationshipCategoryByType.has(type);
676
+ getMetadata = (element) => elementTypeMetadata.get(element && element.type) || {};
677
+ getArchiMateClass = (elementOrType) => {
678
+ const type = typeof elementOrType === 'string' ? elementOrType : elementOrType && elementOrType.type;
679
+ return classByType[type];
680
+ };
681
+ validateRelationshipEndpointTypes = (relationship, source, target) => {
682
+ if (!relationship || !source || !target) {
683
+ return [];
684
+ }
685
+ const type = relationship.type;
686
+ if (!relationshipCategoryByType.has(type)) {
687
+ return ['relationships \'' + relationship.id + '\' uses unsupported ArchiMate relationship type \'' + type + '\''];
688
+ }
689
+ const sourceClass = getArchiMateClass(source);
690
+ const targetClass = getArchiMateClass(target);
691
+ if (!sourceClass || !targetClass) {
692
+ return [];
693
+ }
694
+ const allowedTargets = classMatrix[type] && classMatrix[type][sourceClass];
695
+ if (!allowedTargets || !allowedTargets.some((allowed) => allowed === targetClass || allowed === 'ModelConcept')) {
696
+ return ['relationships \'' + relationship.id + '\' violates ArchiMate 3.2 relationship matrix: ' + source.type + ' \'' + source.name + '\' cannot ' + type + ' ' + target.type + ' \'' + target.name + '\''];
697
+ }
698
+ return [];
699
+ };
700
+ } else {
701
+ // Legacy fallback: an installation without schema-bundle.rules.json uses the bundled module.
702
+ const mod = require('./archimate32-rules.js');
703
+ elementTypeMetadata = mod.elementTypeMetadata;
704
+ relationshipCategoryByType = mod.relationshipCategoryByType;
705
+ isSupportedElementType = mod.isSupportedElementType;
706
+ isSupportedRelationshipType = mod.isSupportedRelationshipType;
707
+ getMetadata = mod.getMetadata;
708
+ getArchiMateClass = mod.getArchiMateClass;
709
+ validateRelationshipEndpointTypes = mod.validateRelationshipEndpointTypes;
710
+ }
711
+
712
+ const elementTypes = Array.from(elementTypeMetadata.keys());
713
+ const relationshipTypes = Array.from(relationshipCategoryByType.keys());
714
+ const actorElementType = resolveActorElementType(bundle.config);
715
+ const deliveryDependencies = resolveDeliveryDependencies(bundle.config, 'archimate-class-matrix');
716
+ const attributesByElementType = resolveAttributeContracts(bundle.config);
717
+ return finalizeOntology({
718
+ kind: bundle.kind,
719
+ dialect: 'archimate-class-matrix',
720
+ language,
721
+ elementTypeErrorLabel: 'ArchiMate',
722
+ relationshipTypeErrorLabel: 'ArchiMate',
723
+ matrixErrorLabel: 'ArchiMate 3.2 relationship matrix',
724
+ elementTypes,
725
+ relationshipTypes,
726
+ actorElementType,
727
+ deliveryDependencies,
728
+ attributesByElementType,
729
+ bundleValidation: validateBundle({ language, dialect: 'archimate-class-matrix', elementTypes, relationshipTypes, actorElementType, matrix: null, deliveryDependencies, attributesByElementType }),
730
+ elementTypeMetadata,
731
+ relationshipCategoryByType,
732
+ isSupportedElementType,
733
+ isSupportedRelationshipType,
734
+ getMetadata,
735
+ getArchiMateClass,
736
+ validateRelationshipEndpointTypes,
737
+ invariants: resolveInvariants(bundle.config, {
738
+ statementGrammar: true,
739
+ endpointMatrix: true,
740
+ rootViewName: DEFAULT_ROOT_VIEW_NAME,
741
+ maxElementsPerView: DEFAULT_MAX_ELEMENTS_PER_VIEW,
742
+ }),
743
+ });
744
+ }
745
+
746
+ function buildTypeMatrixOntology(bundle) {
747
+ const config = bundle.config || {};
748
+ const language = typeof config.language === 'string' && config.language.trim() !== ''
749
+ ? config.language.trim()
750
+ : 'Custom Ontology';
751
+ const enums = resolveTypeEnums(bundle);
752
+ const rules = bundle.rules || {};
753
+ const rawMetadata = rules.elementTypeMetadata && typeof rules.elementTypeMetadata === 'object'
754
+ ? rules.elementTypeMetadata
755
+ : {};
756
+ const rawCategories = rules.relationshipCategoryByType && typeof rules.relationshipCategoryByType === 'object'
757
+ ? rules.relationshipCategoryByType
758
+ : {};
759
+ const matrix = rules.relationshipTargetMatrix && typeof rules.relationshipTargetMatrix === 'object'
760
+ ? rules.relationshipTargetMatrix
761
+ : null;
762
+
763
+ const elementTypeMetadata = new Map();
764
+ for (const type of enums.elementTypes) {
765
+ elementTypeMetadata.set(type, rawMetadata[type] || { layer: null, aspect: null });
766
+ }
767
+ for (const [type, meta] of Object.entries(rawMetadata)) {
768
+ if (!elementTypeMetadata.has(type)) {
769
+ elementTypeMetadata.set(type, meta);
770
+ }
771
+ }
772
+
773
+ const relationshipCategoryByType = new Map();
774
+ for (const type of enums.relationshipTypes) {
775
+ relationshipCategoryByType.set(type, rawCategories[type] || 'Custom');
776
+ }
777
+ for (const [type, category] of Object.entries(rawCategories)) {
778
+ if (!relationshipCategoryByType.has(type)) {
779
+ relationshipCategoryByType.set(type, category);
780
+ }
781
+ }
782
+
783
+ const matrixErrorLabel = `${language} relationship matrix`;
784
+ const actorElementType = resolveActorElementType(config);
785
+ const elementTypes = Array.from(elementTypeMetadata.keys());
786
+ const relationshipTypes = Array.from(relationshipCategoryByType.keys());
787
+ const deliveryDependencies = resolveDeliveryDependencies(config, 'type-matrix');
788
+ const attributesByElementType = resolveAttributeContracts(config);
789
+ const getArchiMateClass = (elementOrType) => {
790
+ const type = typeof elementOrType === 'string' ? elementOrType : elementOrType && elementOrType.type;
791
+ return type;
792
+ };
793
+
794
+ function validateRelationshipEndpointTypes(relationship, source, target) {
795
+ if (!relationship || !source || !target) {
796
+ return [];
797
+ }
798
+ const type = relationship.type;
799
+ if (!relationshipCategoryByType.has(type)) {
800
+ return [`relationships '${relationship.id}' uses unsupported ${language} relationship type '${type}'`];
801
+ }
802
+ if (!matrix) {
803
+ return [];
804
+ }
805
+ const allowedTargets = matrix[type] && matrix[type][source.type];
806
+ if (!allowedTargets) {
807
+ return [];
808
+ }
809
+ const ok = allowedTargets.some((allowed) => allowed === target.type || allowed === '*');
810
+ if (!ok) {
811
+ return [`relationships '${relationship.id}' violates ${matrixErrorLabel}: ${source.type} '${source.name}' cannot ${type} ${target.type} '${target.name}'`];
812
+ }
813
+ return [];
814
+ }
815
+
816
+ return finalizeOntology({
817
+ kind: bundle.kind,
818
+ dialect: 'type-matrix',
819
+ language,
820
+ elementTypeErrorLabel: language,
821
+ relationshipTypeErrorLabel: language,
822
+ matrixErrorLabel,
823
+ elementTypes,
824
+ relationshipTypes,
825
+ actorElementType,
826
+ deliveryDependencies,
827
+ attributesByElementType,
828
+ bundleValidation: validateBundle({ language, dialect: 'type-matrix', elementTypes, relationshipTypes, actorElementType, matrix, deliveryDependencies, attributesByElementType }),
829
+ elementTypeMetadata,
830
+ relationshipCategoryByType,
831
+ isSupportedElementType: (type) => elementTypeMetadata.has(type),
832
+ isSupportedRelationshipType: (type) => relationshipCategoryByType.has(type),
833
+ getMetadata: (element) => elementTypeMetadata.get(element && element.type) || {},
834
+ getArchiMateClass,
835
+ validateRelationshipEndpointTypes,
836
+ invariants: resolveInvariants(config, {
837
+ statementGrammar: true,
838
+ endpointMatrix: Boolean(matrix),
839
+ rootViewName: DEFAULT_ROOT_VIEW_NAME,
840
+ maxElementsPerView: DEFAULT_MAX_ELEMENTS_PER_VIEW,
841
+ }),
842
+ });
843
+ }
844
+
845
+ function finalizeOntology(ontology) {
846
+ const elementById = new Map();
847
+ function auditRelationshipEndpointTypes(document, relationshipIds) {
848
+ const errors = [];
849
+ const idSet = Array.isArray(relationshipIds) && relationshipIds.length > 0
850
+ ? new Set(relationshipIds)
851
+ : undefined;
852
+ for (const element of (document && document.elements) || []) {
853
+ elementById.set(element.id, element);
854
+ }
855
+ for (const relationship of (document && document.relationships) || []) {
856
+ if (idSet && !idSet.has(relationship.id)) {
857
+ continue;
858
+ }
859
+ const source = elementById.get(relationship.source_id);
860
+ const target = elementById.get(relationship.target_id);
861
+ errors.push(...ontology.validateRelationshipEndpointTypes(relationship, source, target));
862
+ }
863
+ return errors;
864
+ }
865
+ return {
866
+ ...ontology,
867
+ auditRelationshipEndpointTypes,
868
+ };
869
+ }
870
+
871
+ const ontologyCache = new Map();
872
+
873
+ function buildOntology(bundle) {
874
+ const rules = bundle.rules;
875
+ if (rules && rules.dialect === 'archimate-class-matrix') {
876
+ return buildClassMatrixOntology(bundle);
877
+ }
878
+ if (rules) {
879
+ return buildTypeMatrixOntology(bundle);
880
+ }
881
+ // No rules file: the default bundle falls back to the bundled module; a custom
882
+ // bundle without rules is validated permissively (types from schema enums).
883
+ return bundle.kind === 'default' ? buildClassMatrixOntology(bundle) : buildTypeMatrixOntology(bundle);
884
+ }
885
+
886
+ // Cheap on-disk fingerprint of a bundle (and its extends chain) so a running MCP
887
+ // picks up edits to the bundle files without a restart: the ontology cache key
888
+ // changes when any key file's mtime/size changes.
889
+ function bundleFingerprint(bundle) {
890
+ const dirs = Array.isArray(bundle.chainDirs) && bundle.chainDirs.length > 0
891
+ ? bundle.chainDirs
892
+ : [path.resolve(bundle.dir).toLowerCase()];
893
+ const statFile = (absolutePath) => {
894
+ try {
895
+ const stat = fs.statSync(absolutePath);
896
+ return `${Math.round(stat.mtimeMs)}:${stat.size}`;
897
+ } catch {
898
+ return '-';
899
+ }
900
+ };
901
+ const parts = [];
902
+ for (const dir of dirs) {
903
+ for (const name of [SCHEMA_BASENAME, CONFIG_BASENAME, RULES_BASENAME]) {
904
+ parts.push(`${name}=${statFile(path.join(dir, name))}`);
905
+ }
906
+ }
907
+ if (bundle.config && typeof bundle.config.rules === 'string' && bundle.config.rules.trim() !== '') {
908
+ parts.push(`rules=${statFile(path.resolve(bundle.dir, bundle.config.rules))}`);
909
+ }
910
+ return parts.join('|');
911
+ }
912
+
913
+ function loadSchemaBundleAndOntology(workspaceRoot, options = {}) {
914
+ const bundle = resolveSchemaBundle(workspaceRoot, options);
915
+ const cacheKey = `${bundle.kind}:${path.resolve(bundle.dir).toLowerCase()}:${bundleFingerprint(bundle)}`;
916
+ if (ontologyCache.has(cacheKey)) {
917
+ return { bundle, ontology: ontologyCache.get(cacheKey) };
918
+ }
919
+ const ontology = buildOntology(bundle);
920
+ ontologyCache.set(cacheKey, ontology);
921
+ return { bundle, ontology };
922
+ }
923
+
924
+ module.exports = {
925
+ SCHEMA_BASENAME,
926
+ CONFIG_BASENAME,
927
+ RULES_BASENAME,
928
+ resolveSchemaBundle,
929
+ resolveTypeEnums,
930
+ buildOntology,
931
+ loadSchemaBundleAndOntology,
932
+ };