From 625cb08de378590ff1b421d9fa347103f488a81e Mon Sep 17 00:00:00 2001 From: Atharva15007 Date: Thu, 12 Nov 2026 15:19:06 +0530 Subject: [PATCH 1/3] fix(concerto-core): support AST objects in addModelFiles and updateModelFile (#1625) Signed-off-by: Atharva15007 --- .../concerto-core/src/basemodelmanager.ts | 31 ++++-- .../concerto-core/test/astmodelmanager.js | 104 ++++++++++++++++++ 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/packages/concerto-core/src/basemodelmanager.ts b/packages/concerto-core/src/basemodelmanager.ts index 994d98bd5..6c0790c5a 100644 --- a/packages/concerto-core/src/basemodelmanager.ts +++ b/packages/concerto-core/src/basemodelmanager.ts @@ -30,7 +30,11 @@ import decoratorModelModule from './decoratormodelhelper'; import type { ModelFileSource, ModelManagerOptions } from './types'; import type { AstNode } from './introspect/decorated'; type ModelFileInstance = InstanceType; -type ModelFileInput = string | ModelFileInstance; +type ModelFileInput = string | ModelFileInstance | Record; + +function isModelFile(obj: unknown): obj is ModelFileInstance { + return Boolean(obj) && (obj instanceof ModelFile || (typeof (obj as any).isModelFile === 'function' && (obj as any).isModelFile() === true)); +} function getFileNameFromIdentifier(fileIdentifier) { const normalizedIdentifier = fileIdentifier.replace(/[\\/]+$/, ''); @@ -184,12 +188,13 @@ class BaseModelManager { * @throws {IllegalModelException} */ validateModelFile(modelFile, fileName?) { - if (typeof modelFile === 'string') { + if (isModelFile(modelFile)) { + modelFile.validate(); + } else { const { ast } = this.processFile(fileName, modelFile); - let m = new ModelFile(this, ast as AstNode, modelFile, fileName); + const definitions = typeof modelFile === 'string' ? modelFile : null; + let m = new ModelFile(this, ast as AstNode, definitions, fileName); m.validate(); - } else { - modelFile.validate(); } } @@ -343,10 +348,11 @@ class BaseModelManager { updateModelFile(modelFile, fileName?, disableValidation?) { const NAME = 'updateModelFile'; debug(NAME, 'updateModelFile', modelFile, fileName); - if (typeof modelFile === 'string') { + if (!isModelFile(modelFile)) { const { ast } = this.processFile(fileName, modelFile); - let m = new ModelFile(this, ast as AstNode, modelFile, fileName); - return this.updateModelFile(m,fileName,disableValidation); + const definitions = typeof modelFile === 'string' ? modelFile : null; + let m = new ModelFile(this, ast as AstNode, definitions, fileName); + return this.updateModelFile(m, fileName, disableValidation); } else { let existing = this.modelFiles[modelFile.getNamespace()]; if (!existing) { @@ -400,11 +406,12 @@ class BaseModelManager { } let m: ModelFileInstance; - if (typeof modelFile === 'string') { - const { ast } = this.processFile(fileName, modelFile); - m = new ModelFile(this, ast as AstNode, modelFile, fileName); - } else { + if (isModelFile(modelFile)) { m = modelFile; + } else { + const { ast } = this.processFile(fileName, modelFile); + const definitions = typeof modelFile === 'string' ? modelFile : null; + m = new ModelFile(this, ast as AstNode, definitions, fileName); } if (!m.getVersion()) { throw new Error(`Cannot add an unversioned namespace: ${m.getNamespace()}`); diff --git a/packages/concerto-core/test/astmodelmanager.js b/packages/concerto-core/test/astmodelmanager.js index 9f72e11ea..18c6bfd7c 100644 --- a/packages/concerto-core/test/astmodelmanager.js +++ b/packages/concerto-core/test/astmodelmanager.js @@ -110,4 +110,108 @@ describe('AstModelManager', () => { }); }); + describe('#addModelFiles', () => { + it('should add multiple model files from AST objects', () => { + const modelContent1 = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.test1@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'Concept1', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + const modelContent2 = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.test2@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'Concept2', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + const added = modelManager.addModelFiles([modelContent1, modelContent2]); + added.should.have.lengthOf(2); + modelManager.getModelFile('org.example.test1@1.0.0').should.not.be.undefined; + modelManager.getModelFile('org.example.test2@1.0.0').should.not.be.undefined; + }); + }); + + describe('#updateModelFile', () => { + it('should update a model file from an AST object', () => { + const initialContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.update@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'InitialConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + modelManager.addModel(initialContent); + modelManager.getType('org.example.update@1.0.0.InitialConcept').should.not.be.null; + + const updatedContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.update@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'UpdatedConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + modelManager.updateModelFile(updatedContent); + modelManager.getType('org.example.update@1.0.0.UpdatedConcept').should.not.be.null; + (() => { + modelManager.getType('org.example.update@1.0.0.InitialConcept'); + }).should.throw(); + }); + }); + + describe('#validateModelFile', () => { + it('should validate an AST object model', () => { + const modelContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.validate@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'ValidConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + + (() => { + modelManager.validateModelFile(modelContent); + }).should.not.throw(); + }); + }); + }); From d5ef1d44989268d45ec6787e19066c2ea2cb7992 Mon Sep 17 00:00:00 2001 From: Atharva15007 Date: Thu, 12 Nov 2026 18:24:13 +0530 Subject: [PATCH 2/3] test(concerto-core): address review feedback on AstModelManager tests and JSDocs Signed-off-by: Atharva15007 --- .../concerto-core/src/basemodelmanager.ts | 6 +- .../concerto-core/test/astmodelmanager.js | 92 ++++++++++++++++++- 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/concerto-core/src/basemodelmanager.ts b/packages/concerto-core/src/basemodelmanager.ts index 6c0790c5a..dd0f16ceb 100644 --- a/packages/concerto-core/src/basemodelmanager.ts +++ b/packages/concerto-core/src/basemodelmanager.ts @@ -183,7 +183,7 @@ class BaseModelManager { * Note that if there are dependencies between multiple files the files * must be added in dependency order, or the addModelFiles method can be * used to add a set of files irrespective of dependencies. - * @param {string|ModelFile} modelFile - The Concerto file as a string + * @param {string|ModelFile|object} modelFile - The Concerto file as a string, AST object, or ModelFile * @param {string} [fileName] - a file name to associate with the model file * @throws {IllegalModelException} */ @@ -339,7 +339,7 @@ class BaseModelManager { * Concerto files have a single namespace. If a Concerto file with the * same namespace has already been added to the ModelManager then it * will be replaced. - * @param {string|ModelFile} modelFile - Model as a string or object + * @param {string|ModelFile|object} modelFile - Model as a string, AST object, or ModelFile * @param {string} [fileName] - a file name to associate with the model file * @param {boolean} [disableValidation] - If true then the model files are not validated * @throws {IllegalModelException} @@ -383,7 +383,7 @@ class BaseModelManager { /** * Add a set of Concerto files to the model manager. - * @param {string[]|ModelFile[]} modelFiles - An array of models as strings or ModelFile objects. + * @param {string[]|ModelFile[]|object[]} modelFiles - An array of models as strings, AST objects, or ModelFile objects. * @param {string[]} [fileNames] - A array of file names to associate with the model files * @param {boolean} [disableValidation] - If true then the model files are not validated * @returns {Object[]} The newly added model files (internal). diff --git a/packages/concerto-core/test/astmodelmanager.js b/packages/concerto-core/test/astmodelmanager.js index 18c6bfd7c..03641e524 100644 --- a/packages/concerto-core/test/astmodelmanager.js +++ b/packages/concerto-core/test/astmodelmanager.js @@ -16,6 +16,7 @@ const { AstModelManager } = require('../src/astmodelmanager'); const { IllegalModelException } = require('../src/introspect/illegalmodelexception'); +const { TypeNotFoundException } = require('../src/typenotfoundexception'); const chai = require('chai'); chai.should(); @@ -145,6 +146,58 @@ describe('AstModelManager', () => { added.should.have.lengthOf(2); modelManager.getModelFile('org.example.test1@1.0.0').should.not.be.undefined; modelManager.getModelFile('org.example.test2@1.0.0').should.not.be.undefined; + modelManager.getType('org.example.test1@1.0.0.Concept1').getName().should.equal('Concept1'); + modelManager.getType('org.example.test2@1.0.0.Concept2').getName().should.equal('Concept2'); + }); + + it('should rollback added models if a later AST is invalid', () => { + const validModel = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.valid@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'ValidConcept', + 'isAbstract': false, + 'properties': [], + 'decorators': [] + } + ] + }; + const invalidModel = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.invalid@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'InvalidConcept', + 'isAbstract': false, + 'properties': [ + { + '$class': 'concerto.metamodel@1.0.0.RelationshipProperty', + 'name': 'target', + 'type': { + '$class': 'concerto.metamodel@1.0.0.TypeIdentifier', + 'name': 'NonExistentType' + }, + 'isArray': false, + 'isOptional': false, + 'decorators': [] + } + ], + 'decorators': [] + } + ] + }; + + (() => { + modelManager.addModelFiles([validModel, invalidModel]); + }).should.throw(IllegalModelException, /NonExistentType/); + + chai.expect(modelManager.getModelFile('org.example.valid@1.0.0')).to.be.undefined; + chai.expect(modelManager.getModelFile('org.example.invalid@1.0.0')).to.be.undefined; }); }); @@ -166,7 +219,7 @@ describe('AstModelManager', () => { }; modelManager.addModel(initialContent); - modelManager.getType('org.example.update@1.0.0.InitialConcept').should.not.be.null; + modelManager.getType('org.example.update@1.0.0.InitialConcept').getName().should.equal('InitialConcept'); const updatedContent = { '$class': 'concerto.metamodel@1.0.0.Model', @@ -184,10 +237,10 @@ describe('AstModelManager', () => { }; modelManager.updateModelFile(updatedContent); - modelManager.getType('org.example.update@1.0.0.UpdatedConcept').should.not.be.null; + modelManager.getType('org.example.update@1.0.0.UpdatedConcept').getName().should.equal('UpdatedConcept'); (() => { modelManager.getType('org.example.update@1.0.0.InitialConcept'); - }).should.throw(); + }).should.throw(TypeNotFoundException, /InitialConcept/); }); }); @@ -212,6 +265,39 @@ describe('AstModelManager', () => { modelManager.validateModelFile(modelContent); }).should.not.throw(); }); + + it('should throw IllegalModelException for an invalid AST object model', () => { + const invalidContent = { + '$class': 'concerto.metamodel@1.0.0.Model', + 'namespace': 'org.example.validate.invalid@1.0.0', + 'imports': [], + 'declarations': [ + { + '$class': 'concerto.metamodel@1.0.0.ConceptDeclaration', + 'name': 'InvalidConcept', + 'isAbstract': false, + 'properties': [ + { + '$class': 'concerto.metamodel@1.0.0.RelationshipProperty', + 'name': 'target', + 'type': { + '$class': 'concerto.metamodel@1.0.0.TypeIdentifier', + 'name': 'NonExistentType' + }, + 'isArray': false, + 'isOptional': false, + 'decorators': [] + } + ], + 'decorators': [] + } + ] + }; + + (() => { + modelManager.validateModelFile(invalidContent); + }).should.throw(IllegalModelException, /NonExistentType/); + }); }); }); From f9a032cc90bf30cb7a32a0fa4f49c2c0e922c362 Mon Sep 17 00:00:00 2001 From: Atharva15007 Date: Fri, 13 Nov 2026 18:16:21 +0530 Subject: [PATCH 3/3] fix(concerto-core): give a clear error when a non-string is passed to ModelManager.processFile Signed-off-by: Atharva15007 --- packages/concerto-core/src/basemodelmanager.ts | 9 ++++++--- packages/concerto-core/src/modelmanager.ts | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/concerto-core/src/basemodelmanager.ts b/packages/concerto-core/src/basemodelmanager.ts index dd0f16ceb..7b07800ca 100644 --- a/packages/concerto-core/src/basemodelmanager.ts +++ b/packages/concerto-core/src/basemodelmanager.ts @@ -183,7 +183,8 @@ class BaseModelManager { * Note that if there are dependencies between multiple files the files * must be added in dependency order, or the addModelFiles method can be * used to add a set of files irrespective of dependencies. - * @param {string|ModelFile|object} modelFile - The Concerto file as a string, AST object, or ModelFile + * Note: AST objects require an AST-based model manager (such as AstModelManager). + * @param {string|ModelFile|object} modelFile - The Concerto file as a string, AST object (requires an AST-based model manager), or ModelFile * @param {string} [fileName] - a file name to associate with the model file * @throws {IllegalModelException} */ @@ -339,7 +340,8 @@ class BaseModelManager { * Concerto files have a single namespace. If a Concerto file with the * same namespace has already been added to the ModelManager then it * will be replaced. - * @param {string|ModelFile|object} modelFile - Model as a string, AST object, or ModelFile + * Note: AST objects require an AST-based model manager (such as AstModelManager). + * @param {string|ModelFile|object} modelFile - Model as a string, AST object (requires an AST-based model manager), or ModelFile * @param {string} [fileName] - a file name to associate with the model file * @param {boolean} [disableValidation] - If true then the model files are not validated * @throws {IllegalModelException} @@ -383,7 +385,8 @@ class BaseModelManager { /** * Add a set of Concerto files to the model manager. - * @param {string[]|ModelFile[]|object[]} modelFiles - An array of models as strings, AST objects, or ModelFile objects. + * Note: AST objects require an AST-based model manager (such as AstModelManager). + * @param {string[]|ModelFile[]|object[]} modelFiles - An array of models as strings, AST objects (requires an AST-based model manager), or ModelFile objects. * @param {string[]} [fileNames] - A array of file names to associate with the model files * @param {boolean} [disableValidation] - If true then the model files are not validated * @returns {Object[]} The newly added model files (internal). diff --git a/packages/concerto-core/src/modelmanager.ts b/packages/concerto-core/src/modelmanager.ts index 55e2869c2..4cd7f8851 100644 --- a/packages/concerto-core/src/modelmanager.ts +++ b/packages/concerto-core/src/modelmanager.ts @@ -27,9 +27,12 @@ import type ModelFile from './introspect/modelfile'; // How to create a modelfile from a cto file const ctoProcessFile = (options: ModelManagerOptions = {}): ((name: string | null, data: unknown) => ModelFileSource) => (name: string | null, data: unknown) => { + if (typeof data !== 'string') { + throw new Error('ModelManager expects CTO model content as a string. For AST objects, use AstModelManager.'); + } // Clone individual properties to avoid options injection to Peggy. const parserOptions = { skipLocationNodes: options?.skipLocationNodes }; - const content = typeof data === 'string' ? data : String(data); + const content = data; const fileName = name ?? 'UNKNOWN'; return { ast: Parser.parse(content, fileName, parserOptions),