archgraph-argo 0.27.0 → 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.
@@ -1,4 +1,4 @@
1
- 'use strict';
1
+ 'use strict';
2
2
 
3
3
  // ARGO schema-bundle resolution and ontology construction.
4
4
  //
@@ -6,18 +6,18 @@
6
6
  // "schema bundle" is a directory that carries the graph contract:
7
7
  //
8
8
  // <bundle>/SystemArchitecture.schema.json (required) JSON Schema of the graph
9
- // <bundle>/argob.config.json (optional) bundle descriptor:
9
+ // <bundle>/schema-bundle.config.json (optional) bundle descriptor:
10
10
  // language, enum locations, guide,
11
11
  // rules file, invariant switches
12
- // <bundle>/argob-rules.json (optional) ontology rules data
12
+ // <bundle>/schema-bundle.rules.json (optional) ontology rules data
13
13
  // (type metadata, relationship
14
14
  // categories, endpoint matrix)
15
- // <bundle>/ARGOB.md (optional) human-readable guide
15
+ // <bundle>/GUIDE.md (optional) human-readable guide
16
16
  //
17
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 ArgoBument schema
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
21
  //
22
22
  // The default bundle keeps the historical ArchiMate 3.2 + ARGO behaviour. A
23
23
  // custom bundle may define its own element/relationship types and, optionally,
@@ -29,9 +29,9 @@ const path = require('node:path');
29
29
  const { getArgoRoot } = require('./argo-paths.js');
30
30
 
31
31
  const SCHEMA_BASENAME = 'SystemArchitecture.schema.json';
32
- const CONFIG_BASENAME = 'argob.config.json';
33
- const RULES_BASENAME = 'argob-rules.json';
34
- const GUIDE_BASENAME = 'ARGOB.md';
32
+ const CONFIG_BASENAME = 'schema-bundle.config.json';
33
+ const RULES_BASENAME = 'schema-bundle.rules.json';
34
+ const GUIDE_BASENAME = 'GUIDE.md';
35
35
  const DEFAULT_GUIDE_BASENAME = 'archimate3.2.md';
36
36
  const DEFAULT_LANGUAGE = 'ArchiMate 3.2';
37
37
  const DEFAULT_ROOT_VIEW_NAME = 'SystemArchitecture';
@@ -39,8 +39,8 @@ const DEFAULT_MAX_ELEMENTS_PER_VIEW = 15;
39
39
  const DEFAULT_ACTOR_ELEMENT_TYPE = 'Business Actor';
40
40
 
41
41
  // Which relationship types express a delivery dependency and in which direction.
42
- // Declared per bundle via argob.config.json "deliveryDependencies"; the default
43
- // ArgoBument bundle keeps the ArchiMate mapping below (previous behaviour).
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
44
  const ARCHIMATE_DELIVERY_DEPENDENCIES = Object.freeze({
45
45
  sourceDependsOnTarget: Object.freeze(['Access', 'Assignment', 'Specialization', 'Composition', 'Aggregation']),
46
46
  targetDependsOnSource: Object.freeze(['Serving', 'Realization', 'Flow', 'Triggering', 'Influence']),
@@ -99,12 +99,13 @@ function relativeLabel(workspaceRoot, absolutePath) {
99
99
 
100
100
  function buildBundle(kind, dir, workspaceRoot) {
101
101
  const schemaFile = path.join(dir, SCHEMA_BASENAME);
102
- const schema = readJsonFile(schemaFile);
102
+ const schemaExists = isFile(schemaFile);
103
+ const schema = schemaExists ? readJsonFile(schemaFile) : null;
103
104
 
104
105
  const configFile = path.join(dir, CONFIG_BASENAME);
105
106
  const fileConfig = isFile(configFile) ? readJsonFile(configFile) : {};
106
- const inlineConfig = schema && typeof schema['x-argob'] === 'object' && schema['x-argob'] !== null
107
- ? schema['x-argob']
107
+ const inlineConfig = schema && typeof schema['x-schema-bundle'] === 'object' && schema['x-schema-bundle'] !== null
108
+ ? schema['x-schema-bundle']
108
109
  : {};
109
110
  const config = { ...inlineConfig, ...fileConfig };
110
111
 
@@ -135,10 +136,9 @@ function buildBundle(kind, dir, workspaceRoot) {
135
136
  kind,
136
137
  dir,
137
138
  relativeDir: relativeLabel(workspaceRoot, dir),
138
- schema: {
139
- absolutePath: schemaFile,
140
- relativePath: relativeLabel(workspaceRoot, schemaFile),
141
- },
139
+ schema: schemaExists
140
+ ? { absolutePath: schemaFile, relativePath: relativeLabel(workspaceRoot, schemaFile) }
141
+ : null,
142
142
  schemaDocument: schema,
143
143
  config: {
144
144
  filePath: isFile(configFile) ? { absolutePath: configFile, relativePath: relativeLabel(workspaceRoot, configFile) } : null,
@@ -154,6 +154,262 @@ function buildBundle(kind, dir, workspaceRoot) {
154
154
  };
155
155
  }
156
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
+
157
413
  function resolveSchemaBundle(workspaceRoot, options = {}) {
158
414
  const root = path.resolve(workspaceRoot || process.cwd());
159
415
  const candidates = [];
@@ -172,8 +428,8 @@ function resolveSchemaBundle(workspaceRoot, options = {}) {
172
428
  continue;
173
429
  }
174
430
  seen.add(key);
175
- if (isFile(path.join(candidate.dir, SCHEMA_BASENAME))) {
176
- return buildBundle(candidate.kind, candidate.dir, root);
431
+ if (isFile(path.join(candidate.dir, SCHEMA_BASENAME)) || hasExtendsConfig(candidate.dir)) {
432
+ return applyExtends(buildBundle(candidate.kind, candidate.dir, root), root);
177
433
  }
178
434
  }
179
435
 
@@ -258,7 +514,77 @@ function resolveActorElementType(config) {
258
514
  return DEFAULT_ACTOR_ELEMENT_TYPE;
259
515
  }
260
516
 
261
- function validateBundle({ language, dialect, elementTypes, relationshipTypes, actorElementType, matrix, deliveryDependencies }) {
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 }) {
262
588
  const errors = [];
263
589
  const elementTypeList = Array.isArray(elementTypes) ? elementTypes : [];
264
590
  const relationshipTypeList = Array.isArray(relationshipTypes) ? relationshipTypes : [];
@@ -276,7 +602,7 @@ function validateBundle({ language, dialect, elementTypes, relationshipTypes, ac
276
602
  if (!elementTypeList.includes(actorElementType)) {
277
603
  errors.push(
278
604
  `schema bundle '${language}' declares actorElementType '${actorElementType}' which is not one of its element types; ` +
279
- `set a valid actorElementType in argob.config.json (one of: ${elementTypeList.join(', ') || '(none)'}) ` +
605
+ `set a valid actorElementType in schema-bundle.config.json (one of: ${elementTypeList.join(', ') || '(none)'}) ` +
280
606
  `or set "actorElementType": null if the schema has no actor concept`,
281
607
  );
282
608
  }
@@ -304,6 +630,7 @@ function validateBundle({ language, dialect, elementTypes, relationshipTypes, ac
304
630
  }
305
631
 
306
632
  errors.push(...validateDeliveryDependencies(language, relationshipTypeList, deliveryDependencies));
633
+ errors.push(...validateAttributeContracts(language, elementTypeList, attributesByElementType));
307
634
 
308
635
  return { status: errors.length === 0 ? 'passed' : 'failed', errors };
309
636
  }
@@ -338,7 +665,7 @@ function buildClassMatrixOntology(bundle) {
338
665
  let validateRelationshipEndpointTypes;
339
666
 
340
667
  if (rules) {
341
- // Data-driven default: the bundle ships its own rule data (argob-rules.json),
668
+ // Data-driven default: the bundle ships its own rule data (schema-bundle.rules.json),
342
669
  // so the DEFAULT schema is replaceable file-for-file exactly like a custom one.
343
670
  const classByType = rules.archimateClassByElementType || {};
344
671
  const classMatrix = rules.relationshipTargetMatrix || {};
@@ -371,7 +698,7 @@ function buildClassMatrixOntology(bundle) {
371
698
  return [];
372
699
  };
373
700
  } else {
374
- // Legacy fallback: an installation without argob-rules.json uses the bundled module.
701
+ // Legacy fallback: an installation without schema-bundle.rules.json uses the bundled module.
375
702
  const mod = require('./archimate32-rules.js');
376
703
  elementTypeMetadata = mod.elementTypeMetadata;
377
704
  relationshipCategoryByType = mod.relationshipCategoryByType;
@@ -386,6 +713,7 @@ function buildClassMatrixOntology(bundle) {
386
713
  const relationshipTypes = Array.from(relationshipCategoryByType.keys());
387
714
  const actorElementType = resolveActorElementType(bundle.config);
388
715
  const deliveryDependencies = resolveDeliveryDependencies(bundle.config, 'archimate-class-matrix');
716
+ const attributesByElementType = resolveAttributeContracts(bundle.config);
389
717
  return finalizeOntology({
390
718
  kind: bundle.kind,
391
719
  dialect: 'archimate-class-matrix',
@@ -397,7 +725,8 @@ function buildClassMatrixOntology(bundle) {
397
725
  relationshipTypes,
398
726
  actorElementType,
399
727
  deliveryDependencies,
400
- bundleValidation: validateBundle({ language, dialect: 'archimate-class-matrix', elementTypes, relationshipTypes, actorElementType, matrix: null, deliveryDependencies }),
728
+ attributesByElementType,
729
+ bundleValidation: validateBundle({ language, dialect: 'archimate-class-matrix', elementTypes, relationshipTypes, actorElementType, matrix: null, deliveryDependencies, attributesByElementType }),
401
730
  elementTypeMetadata,
402
731
  relationshipCategoryByType,
403
732
  isSupportedElementType,
@@ -456,6 +785,7 @@ function buildTypeMatrixOntology(bundle) {
456
785
  const elementTypes = Array.from(elementTypeMetadata.keys());
457
786
  const relationshipTypes = Array.from(relationshipCategoryByType.keys());
458
787
  const deliveryDependencies = resolveDeliveryDependencies(config, 'type-matrix');
788
+ const attributesByElementType = resolveAttributeContracts(config);
459
789
  const getArchiMateClass = (elementOrType) => {
460
790
  const type = typeof elementOrType === 'string' ? elementOrType : elementOrType && elementOrType.type;
461
791
  return type;
@@ -494,7 +824,8 @@ function buildTypeMatrixOntology(bundle) {
494
824
  relationshipTypes,
495
825
  actorElementType,
496
826
  deliveryDependencies,
497
- bundleValidation: validateBundle({ language, dialect: 'type-matrix', elementTypes, relationshipTypes, actorElementType, matrix, deliveryDependencies }),
827
+ attributesByElementType,
828
+ bundleValidation: validateBundle({ language, dialect: 'type-matrix', elementTypes, relationshipTypes, actorElementType, matrix, deliveryDependencies, attributesByElementType }),
498
829
  elementTypeMetadata,
499
830
  relationshipCategoryByType,
500
831
  isSupportedElementType: (type) => elementTypeMetadata.has(type),
@@ -552,9 +883,36 @@ function buildOntology(bundle) {
552
883
  return bundle.kind === 'default' ? buildClassMatrixOntology(bundle) : buildTypeMatrixOntology(bundle);
553
884
  }
554
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
+
555
913
  function loadSchemaBundleAndOntology(workspaceRoot, options = {}) {
556
914
  const bundle = resolveSchemaBundle(workspaceRoot, options);
557
- const cacheKey = `${bundle.kind}:${path.resolve(bundle.dir).toLowerCase()}`;
915
+ const cacheKey = `${bundle.kind}:${path.resolve(bundle.dir).toLowerCase()}:${bundleFingerprint(bundle)}`;
558
916
  if (ontologyCache.has(cacheKey)) {
559
917
  return { bundle, ontology: ontologyCache.get(cacheKey) };
560
918
  }