From 6622e15a2be15623c16f4f1c0a042aa95aaa8dfe Mon Sep 17 00:00:00 2001 From: nabiha Date: Mon, 26 Jan 2026 18:38:17 +0500 Subject: [PATCH 1/2] Add template validation system with tests Signed-off-by: nabiha --- src/TemplateValidator.ts | 261 +++++++++++++++++++++++++++++++++ test/TemplateValidator.test.ts | 54 +++++++ 2 files changed, 315 insertions(+) create mode 100644 src/TemplateValidator.ts create mode 100644 test/TemplateValidator.test.ts diff --git a/src/TemplateValidator.ts b/src/TemplateValidator.ts new file mode 100644 index 0000000..4469854 --- /dev/null +++ b/src/TemplateValidator.ts @@ -0,0 +1,261 @@ +import jp from 'jsonpath'; +import traverse from 'traverse'; +import { TemplateMarkTransformer } from '@accordproject/markdown-template'; +import { ModelManager, ClassDeclaration } from '@accordproject/concerto-core'; +import { TemplateMarkModel } from '@accordproject/markdown-common'; +import { TypeScriptToJavaScriptCompiler } from './TypeScriptToJavaScriptCompiler'; +import { getTemplateClassDeclaration, writeFunctionToString } from './utils'; +import { CodeType } from './model-gen/org.accordproject.templatemark@0.5.0'; + +export interface ValidationError { + type: 'missing_variable' | 'invalid_formula' | 'type_mismatch' | 'syntax_error'; + message: string; + line?: number; + column?: number; + context?: string; + variable?: string; + expectedType?: string; + actualType?: string; + formula?: string; +} + +export interface ValidationOptions { + debug?: boolean; + strict?: boolean; + errorRecovery?: boolean; +} + +export interface ValidationResult { + errors: ValidationError[]; + warnings: ValidationError[]; + isValid: boolean; +} + +export class TemplateValidator { + private modelManager: ModelManager; + private options: ValidationOptions; + private templateClass?: ClassDeclaration; + private compiler?: TypeScriptToJavaScriptCompiler; + + constructor(modelManager: ModelManager, templateConceptFqn?: string, options: ValidationOptions = {}) { + this.modelManager = modelManager; + this.options = { debug: false, strict: false, errorRecovery: true, ...options }; + this.templateClass = getTemplateClassDeclaration(modelManager, templateConceptFqn); + this.compiler = new TypeScriptToJavaScriptCompiler(modelManager, templateConceptFqn); + } + + async initialize(): Promise { + if (this.compiler) { + await this.compiler.initialize(); + } + } + + /** + * Validate a template against data and model + * @param template - Template markdown string or parsed JSON + * @param data - Template data object + * @returns Validation result with errors and warnings + */ + async validate(template: string | any, data: any): Promise { + const errors: ValidationError[] = []; + const warnings: ValidationError[] = []; + + try { + if (this.options.debug) console.log('Starting template validation...'); + + const templateJson = typeof template === 'string' ? this.parseTemplate(template) : template; + + if (this.options.debug) console.log('Template parsed successfully'); + + const variables = this.extractVariables(templateJson); + const formulas = this.extractFormulas(templateJson); + + if (this.options.debug) { + console.log(`Found ${variables.length} variables and ${formulas.length} formulas`); + } + + const variableErrors = this.validateVariables(variables, data); + errors.push(...variableErrors); + + const formulaErrors = await this.validateFormulas(formulas); + errors.push(...formulaErrors); + + } catch (error) { + if (this.options.debug) console.error('Validation error:', error); + const err = error as Error; + errors.push({ + type: 'syntax_error', + message: `Template parsing failed: ${err.message}`, + context: err.stack + }); + } + + const isValid = errors.length === 0; + + if (this.options.debug) { + console.log(`Validation complete. Valid: ${isValid}, Errors: ${errors.length}, Warnings: ${warnings.length}`); + } + + return { errors, warnings, isValid }; + } + + private parseTemplate(template: string): any { + const transformer = new TemplateMarkTransformer(); + return transformer.fromMarkdown(template, this.modelManager); + } + + private extractVariables(templateJson: any): Array<{name: string, path: string, location?: any}> { + const variables: Array<{name: string, path: string, location?: any}> = []; + + traverse(templateJson).forEach((x) => { + if (x && x.$class === `${TemplateMarkModel.NAMESPACE}.VariableDefinition`) { + variables.push({ + name: x.name, + path: x.name, + location: x.location + }); + } + }); + + return variables; + } + + private extractFormulas(templateJson: any): Array<{code: string, location?: any, nodeId?: string}> { + const formulas: Array<{code: string, location?: any, nodeId?: string}> = []; + + traverse(templateJson).forEach((x) => { + if (x && x.$class === `${TemplateMarkModel.NAMESPACE}.FormulaDefinition` && x.code) { + formulas.push({ + code: x.code.contents, + location: x.location, + nodeId: x.name + }); + } + }); + + return formulas; + } + + private validateVariables(variables: Array<{name: string, path: string, location?: any}>, data: any): ValidationError[] { + const errors: ValidationError[] = []; + + for (const variable of variables) { + if (!this.hasVariable(data, variable.path)) { + errors.push({ + type: 'missing_variable', + message: `Variable '${variable.name}' is missing from template data`, + line: variable.location?.start?.line, + column: variable.location?.start?.column, + variable: variable.name, + context: this.getLineContext(variable.location) + }); + } else { + const expectedType = this.getExpectedType(variable.name); + const actualType = this.getActualType(data, variable.path); + + if (expectedType && actualType && !this.typesCompatible(expectedType, actualType)) { + const error: ValidationError = { + type: 'type_mismatch', + message: `Variable '${variable.name}' has type '${actualType}', expected '${expectedType}'`, + line: variable.location?.start?.line, + column: variable.location?.start?.column, + variable: variable.name, + expectedType, + actualType, + context: this.getLineContext(variable.location) + }; + + if (this.options.errorRecovery) { + errors.push(error); + } else { + errors.push(error); + } + } + } + } + + return errors; + } + + private async validateFormulas(formulas: Array<{code: string, location?: any, nodeId?: string}>): Promise { + const errors: ValidationError[] = []; + + if (!this.compiler || !this.templateClass) { + return errors; // Skip if no compiler + } + + for (const formula of formulas) { + try { + const result = this.compiler.compile(writeFunctionToString(this.templateClass, formula.nodeId || 'formula', 'any', formula.code)); + + if (result.errors && result.errors.length > 0) { + for (const tsError of result.errors) { + errors.push({ + type: 'invalid_formula', + message: `Formula error: ${tsError.renderedMessage}`, + line: formula.location?.start?.line, + column: tsError.start, + formula: formula.code, + context: this.getLineContext(formula.location) + }); + } + } + } catch (error) { + const err = error as Error; + errors.push({ + type: 'invalid_formula', + message: `Formula compilation failed: ${err.message}`, + line: formula.location?.start?.line, + formula: formula.code, + context: this.getLineContext(formula.location) + }); + } + } + + return errors; + } + + private hasVariable(data: any, path: string): boolean { + try { + const value = jp.value(data, `$.${path}`); + return value !== undefined; + } catch { + return false; + } + } + + private getExpectedType(variableName: string): string | null { + return null; + } + + private getActualType(data: any, path: string): string | null { + try { + const value = jp.value(data, `$.${path}`); + return typeof value; + } catch { + return null; + } + } + + private typesCompatible(expected: string, actual: string): boolean { + if (expected === actual) return true; + return false; + } + + private getLineContext(location?: any): string | undefined { + if (!location) return undefined; + return `Line ${location.start?.line || 'unknown'}`; + } +} + +export async function validateTemplate( + template: string | any, + data: any, + modelManager: ModelManager, + templateConceptFqn?: string, + options?: ValidationOptions +): Promise { + const validator = new TemplateValidator(modelManager, templateConceptFqn, options); + await validator.initialize(); + return validator.validate(template, data); +} \ No newline at end of file diff --git a/test/TemplateValidator.test.ts b/test/TemplateValidator.test.ts new file mode 100644 index 0000000..84aaf95 --- /dev/null +++ b/test/TemplateValidator.test.ts @@ -0,0 +1,54 @@ +import { ModelManager } from '@accordproject/concerto-core'; +import { TemplateValidator } from '../src'; + +describe('TemplateValidator', () => { + let modelManager: ModelManager; + let validator: TemplateValidator; + + beforeAll(async () => { + modelManager = new ModelManager(); + await modelManager.addCTOModel(` + namespace test@1.0.0 + + concept TemplateData { + o String name + o Integer age + } + `); + validator = new TemplateValidator(modelManager, 'test@1.0.0.TemplateData'); + await validator.initialize(); + }); + + test('detects missing variable', async () => { + const template = 'Hello {{name}}! You are {{age}} years old.'; + const data = { name: 'World' }; + + const result = await validator.validate(template, data); + + expect(result.isValid).toBe(false); + expect(result.errors.length).toBe(1); + expect(result.errors[0].type).toBe('missing_variable'); + expect(result.errors[0].variable).toBe('age'); + }); + + test('passes with valid data', async () => { + const template = 'Hello {{name}}!'; + const data = { name: 'World', age: 30 }; + + const result = await validator.validate(template, data); + + expect(result.isValid).toBe(true); + }); + + test('catches formula errors', async () => { + const template = 'Result: {{% INVALID CODE %}}'; + const data = { name: 'World' }; + + const result = await validator.validate(template, data); + + expect(result.isValid).toBe(false); + // check if we got a formula error + const hasFormulaError = result.errors.some(e => e.type === 'invalid_formula'); + expect(hasFormulaError).toBe(true); + }); +}); \ No newline at end of file From ad619c8e76d78ef93c33d85f6df11675eff446ad Mon Sep 17 00:00:00 2001 From: nabiha Date: Mon, 26 Jan 2026 20:40:51 +0500 Subject: [PATCH 2/2] refactor: add dedicated compile step with caching in TemplateArchiveProcessor Signed-off-by: nabiha --- src/TemplateArchiveProcessor.ts | 323 +++++++++++--------- src/TypeScriptToJavaScriptCompiler.ts | 406 ++++++++++++++++++++------ src/index.ts | 1 + test/TemplateValidator.test.ts | 12 + 4 files changed, 502 insertions(+), 240 deletions(-) diff --git a/src/TemplateArchiveProcessor.ts b/src/TemplateArchiveProcessor.ts index 3fd8f56..fff9edb 100644 --- a/src/TemplateArchiveProcessor.ts +++ b/src/TemplateArchiveProcessor.ts @@ -1,184 +1,219 @@ -/* - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* eslint-disable @typescript-eslint/no-explicit-any */ - import { Template } from '@accordproject/cicero-core'; import { TemplateMarkInterpreter } from './TemplateMarkInterpreter'; import { TemplateMarkTransformer } from '@accordproject/markdown-template'; import { transform } from '@accordproject/markdown-transform'; import { TypeScriptToJavaScriptCompiler } from './TypeScriptToJavaScriptCompiler'; -import Script from '@accordproject/cicero-core/types/src/script'; -import { TwoSlashReturn } from '@typescript/twoslash'; +import type Script from '@accordproject/cicero-core/types/src/script'; +import type { TwoSlashReturn } from '@typescript/twoslash'; import { JavaScriptEvaluator } from './JavaScriptEvaluator'; import { SMART_LEGAL_CONTRACT_BASE64 } from './runtime/declarations'; -export type State = object; -export type Response = object; -export type Event = object; +export type State = Record; +export type Response = Record; +export type Event = Record; -export type TriggerResponse = { +export interface TriggerResponse { result: Response; state: State; events: Event[]; } -export type InitResponse = { +export interface InitResponse { state: State; } +export interface CompileOptions { + compiledTemplate?: Record; + currentTime?: string; + utcOffset?: number; +} + +export interface DraftOptions { + currentTime?: string; + [key: string]: unknown; +} + +interface TemplateData { + [key: string]: unknown; +} + +interface TemplateRequest { + [key: string]: unknown; +} + /** - * A template archive processor: can draft content using the - * templatemark for the archive and trigger the logic of the archive + * A template archive processor with compilation caching */ export class TemplateArchiveProcessor { - template: Template; + private template: Template; + private static compilationCache = new Map>(); + private compilationCacheKey: string; + private isInitialized = false; - /** - * Creates a template archive processor - * @param {Template} template - the template to be used by the processor - */ constructor(template: Template) { this.template = template; + this.compilationCacheKey = this.generateCompilationCacheKey(); + } + + private generateCompilationCacheKey(): string { + const metadata = this.template.getMetadata(); + const identifier = this.template.getIdentifier(); + const version = metadata.getVersion(); + + const logicManager = this.template.getLogicManager(); + const scripts = logicManager.getScriptManager().getScriptsForTarget('typescript'); + const scriptContents = scripts.map((script: Script) => script.getContents()).join(''); + + return `${identifier}@${version}:${Buffer.from(scriptContents).toString('base64').substring(0, 32)}`; + } + + private async compileTemplate(): Promise> { + if (TemplateArchiveProcessor.compilationCache.has(this.compilationCacheKey)) { + return TemplateArchiveProcessor.compilationCache.get(this.compilationCacheKey)!; + } + + const logicManager = this.template.getLogicManager(); + if (logicManager.getLanguage() !== 'typescript') { + throw new Error('Only TypeScript is supported at this time'); + } + + const compiledCode: Record = {}; + const tsFiles: Script[] = logicManager.getScriptManager().getScriptsForTarget('typescript'); + + const compiler = new TypeScriptToJavaScriptCompiler( + this.template.getModelManager(), + this.template.getTemplateModel().getFullyQualifiedName() + ); + + await compiler.initialize(); + const runtimeDefinitions = Buffer.from(SMART_LEGAL_CONTRACT_BASE64, 'base64').toString(); + + for (const tsFile of tsFiles) { + const code = `${runtimeDefinitions}\n${tsFile.getContents()}`; + const result = compiler.compile(code); + compiledCode[tsFile.getIdentifier()] = result; + } + + TemplateArchiveProcessor.compilationCache.set(this.compilationCacheKey, compiledCode); + return compiledCode; + } + + private async executeLogic( + functionName: 'init' | 'trigger', + data: TemplateData, + request?: TemplateRequest, + state?: State, + currentTime?: string, + utcOffset?: number + ): Promise { + const compiledCode = await this.compileTemplate(); + const mainLogic = compiledCode['logic/logic.ts']; + + if (!mainLogic) { + throw new Error('Main logic file not found'); + } + + const evaluator = new JavaScriptEvaluator(); + + const args = functionName === 'init' + ? [data, currentTime, utcOffset] + : [data, request, state, currentTime, utcOffset]; + + const argNames = functionName === 'init' + ? ['data', 'currentTime', 'utcOffset'] + : ['data', 'request', 'state', 'currentTime', 'utcOffset']; + + const evalResponse = await evaluator.evalDangerously({ + templateLogic: true, + verbose: false, + functionName, + code: mainLogic.code, + argumentNames: argNames, + arguments: args + }); + + if (evalResponse.result) { + return evalResponse.result as InitResponse | TriggerResponse; + } else { + throw new Error(`${functionName.charAt(0).toUpperCase() + functionName.slice(1)} failed: ${evalResponse.message || 'Unknown error'}`); + } } - /** - * Drafts a template by merging it with data - * @param {any} data the data to merge with the template - * @param {string} format the output format - * @param {any} options merge options - * @param {[string]} currentTime the current value for 'now' - * @returns {Promise} the drafted content - */ - async draft(data: any, format: string, options: any, currentTime?: string): Promise { - // Setup + async draft( + data: TemplateData, + format: string, + options: DraftOptions = {}, + currentTime?: string + ): Promise { const metadata = this.template.getMetadata(); const templateKind = metadata.getTemplateType() !== 0 ? 'clause' : 'contract'; - // Get the data const modelManager = this.template.getModelManager(); const engine = new TemplateMarkInterpreter(modelManager, {}); const templateMarkTransformer = new TemplateMarkTransformer(); + const templateMarkDom = templateMarkTransformer.fromMarkdownTemplate( - { content: this.template.getTemplate() }, modelManager, templateKind, {options}); - const now = currentTime ? currentTime : new Date().toISOString(); - // console.log(JSON.stringify(templateMarkDom, null, 2)); + { content: this.template.getTemplate() }, + modelManager, + templateKind, + { options: options as any } + ); + + const now = currentTime || new Date().toISOString(); const ciceroMark = await engine.generate(templateMarkDom, data, { now }); - // console.log(JSON.stringify(ciceroMark)); - const result = transform(ciceroMark.toJSON(), 'ciceromark', ['ciceromark_unquoted', format], null, options); - // console.log(result); - return result; - + + return transform( + ciceroMark.toJSON(), + 'ciceromark', + ['ciceromark_unquoted', format], + null, + options + ); } - /** - * Trigger the logic of a template - * @param {object} request - the request to send to the template logic - * @param {object} state - the current state of the template - * @param {[string]} currentTime - the current time, defaults to now - * @param {[number]} utcOffset - the UTC offer, defaults to zero - * @returns {Promise} the response and any events - */ - async trigger(data: any, request: any, state?: any, currentTime?: string, utcOffset?: number): Promise { - const logicManager = this.template.getLogicManager(); - if(logicManager.getLanguage() === 'typescript') { - const compiledCode:Record = {}; - const tsFiles:Array