From d85003611db38f3ddb3e778bf47c5624aad2daad Mon Sep 17 00:00:00 2001 From: Apoorv <130035517+APOORV7G@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:07:43 +0530 Subject: [PATCH] feat(verification): add PlantUML and Vocabulary support for Docker-based verification - Introduced PlantUML and Vocabulary as new targets in the Docker verification setup. - Updated package.json and Dockerfiles to include necessary configurations for PlantUML and Vocabulary validation. - Added new validation tests for PlantUML syntax and Vocabulary structure to ensure proper generation and verification. - Enhanced the verification manifest to include PlantUML and Vocabulary as targets. This commit expands the verification capabilities to include PlantUML and Vocabulary, ensuring a consistent environment for testing and validation. Signed-off-by: Apoorv <130035517+APOORV7G@users.noreply.github.com> --- .github/workflows/verify-codegen.yml | 2 + .gitignore | 1 + package.json | 2 + scripts/verification/docker-run.js | 2 +- test/verification/cases.js | 2 +- test/verification/plantuml.validate.test.js | 169 ++++++++++++++++++ test/verification/vocabulary.validate.test.js | 87 +++++++++ verification/docker/plantuml/Dockerfile | 14 ++ verification/docker/plantuml/entrypoint.sh | 31 ++++ verification/docker/targets.json | 12 ++ verification/docker/vocabulary/Dockerfile | 12 ++ verification/docker/vocabulary/entrypoint.sh | 28 +++ verification/docker/vocabulary/validate.js | 52 ++++++ 13 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 test/verification/plantuml.validate.test.js create mode 100644 test/verification/vocabulary.validate.test.js create mode 100644 verification/docker/plantuml/Dockerfile create mode 100644 verification/docker/plantuml/entrypoint.sh create mode 100644 verification/docker/vocabulary/Dockerfile create mode 100644 verification/docker/vocabulary/entrypoint.sh create mode 100644 verification/docker/vocabulary/validate.js diff --git a/.github/workflows/verify-codegen.yml b/.github/workflows/verify-codegen.yml index a2e700a..b907fe4 100644 --- a/.github/workflows/verify-codegen.yml +++ b/.github/workflows/verify-codegen.yml @@ -34,6 +34,8 @@ jobs: - xmlschema - openapi - avro + - plantuml + - vocabulary steps: - name: Harden the runner (Audit all outbound calls) diff --git a/.gitignore b/.gitignore index ac3b542..530c78d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ verification/work verification/work-* verification/templates/jackson-annotations-*.jar verification/templates/avro-tools-*.jar +verification/templates/plantuml-*.jar # Runtime data pids diff --git a/package.json b/package.json index d75d7a1..2a24312 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "verify:docker:xmlschema": "node scripts/verification/docker-run.js xmlschema", "verify:docker:openapi": "node scripts/verification/docker-run.js openapi", "verify:docker:avro": "node scripts/verification/docker-run.js avro", + "verify:docker:plantuml": "node scripts/verification/docker-run.js plantuml", + "verify:docker:vocabulary": "node scripts/verification/docker-run.js vocabulary", "test:updateSnapshots": "nyc mocha --updateSnapshot --recursive -t 10000", "test:watch": "nyc mocha --watch --recursive -t 10000", "mocha": "mocha --recursive -t 10000", diff --git a/scripts/verification/docker-run.js b/scripts/verification/docker-run.js index 3491fd9..9a06d47 100644 --- a/scripts/verification/docker-run.js +++ b/scripts/verification/docker-run.js @@ -20,7 +20,7 @@ const path = require('path'); const ROOT = path.join(__dirname, '../..'); const BASE_IMAGE = 'concerto-verify-base:local'; -const TARGETS = ['typescript', 'jsonschema', 'graphql', 'protobuf', 'csharp', 'rust', 'java', 'odata', 'mermaid', 'xmlschema', 'openapi', 'avro']; +const TARGETS = ['typescript', 'jsonschema', 'graphql', 'protobuf', 'csharp', 'rust', 'java', 'odata', 'mermaid', 'xmlschema', 'openapi', 'avro', 'plantuml', 'vocabulary']; /** * Run a command synchronously with inherited stdio. diff --git a/test/verification/cases.js b/test/verification/cases.js index ce4e111..117ff2a 100644 --- a/test/verification/cases.js +++ b/test/verification/cases.js @@ -26,7 +26,7 @@ const MODEL_DIR = path.join(__dirname, '../codegen/fromcto/data/model'); * Each case loads one fixture (or valid combination) into its own ModelManager. * Do not combine unrelated CTO files — some share namespaces or have import deps. * - * `skip` may be a string (all targets) or `{ typescript: '...', jsonschema: '...', protobuf: '...', graphql: '...', csharp: '...', rust: '...', java: '...', xmlschema: '...', openapi: '...', avro: '...' }`. + * `skip` may be a string (all targets) or `{ typescript: '...', jsonschema: '...', protobuf: '...', graphql: '...', csharp: '...', rust: '...', java: '...', xmlschema: '...', openapi: '...', avro: '...', plantuml: '...', vocabulary: '...' }`. */ const CASES = [ { diff --git a/test/verification/plantuml.validate.test.js b/test/verification/plantuml.validate.test.js new file mode 100644 index 0000000..2d409cc --- /dev/null +++ b/test/verification/plantuml.validate.test.js @@ -0,0 +1,169 @@ +/* + * 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. + */ + +'use strict'; + +const { execFileSync, spawnSync } = require('child_process'); +const fs = require('fs'); +const https = require('https'); +const path = require('path'); +const { dir } = require('tmp-promise'); + +const { FileWriter } = require('@accordproject/concerto-util'); +const PlantUMLVisitor = require('../../lib/codegen/fromcto/plantuml/plantumlvisitor.js'); + +const { + CASES, + getSkipReason, + createModelManager, + applyVerificationEnv, +} = require('./cases.js'); + +const PLANTUML_VERSION = '1.2024.7'; +const PLANTUML_JAR = process.env.PLANTUML_JAR || path.join( + __dirname, + '../../verification/templates', + `plantuml-${PLANTUML_VERSION}.jar` +); +const PLANTUML_URL = `https://github.com/plantuml/plantuml/releases/download/v${PLANTUML_VERSION}/plantuml-${PLANTUML_VERSION}.jar`; + +/** + * Download the official PlantUML JAR used to syntax-check generated diagrams. + * @param {string} [url] URL to fetch, following redirects as needed + * @param {number} [redirects] remaining redirects to follow + * @returns {Promise} resolves when the JAR has been written to disk + */ +function downloadPlantumlJar(url = PLANTUML_URL, redirects = 5) { + return new Promise((resolve, reject) => { + https.get(url, (response) => { + if (response.statusCode >= 300 && response.statusCode < 400 && response.headers.location) { + if (redirects === 0) { + reject(new Error('Too many redirects while downloading plantuml.jar')); + return; + } + response.resume(); + resolve(downloadPlantumlJar(response.headers.location, redirects - 1)); + return; + } + if (response.statusCode !== 200) { + reject(new Error(`Failed to download plantuml.jar (${response.statusCode})`)); + return; + } + fs.mkdirSync(path.dirname(PLANTUML_JAR), { recursive: true }); + const file = fs.createWriteStream(PLANTUML_JAR); + response.pipe(file); + file.on('finish', () => { + file.close(resolve); + }); + }).on('error', reject); + }); +} + +/** + * Return the path to the plantuml JAR, downloading it if needed. + * @returns {Promise} absolute path to the JAR + */ +async function ensurePlantumlJar() { + if (!fs.existsSync(PLANTUML_JAR)) { + await downloadPlantumlJar(); + } + return PLANTUML_JAR; +} + +/** + * Return a skip reason when java is not available. + * @returns {string|null} skip reason or null when java is available + */ +function getJavaSkipReason() { + const version = spawnSync('java', ['-version'], { encoding: 'utf-8' }); + if (version.error || version.status !== 0) { + return 'java (JRE) not installed'; + } + return null; +} + +const JAVA_SKIP_REASON = getJavaSkipReason(); +let plantumlJar; + +/** + * Generate PlantUML from a model and verify each .puml has valid syntax + * using `java -jar plantuml.jar -syntax`. + * @param {ModelManager} modelManager populated model manager + * @param {object} [visitorOptions] options passed to PlantUMLVisitor + */ +async function verifyPlantUmlSyntax(modelManager, visitorOptions = {}) { + const { path: outputDir, cleanup } = await dir({ unsafeCleanup: true }); + + try { + modelManager.accept(new PlantUMLVisitor(), { + fileWriter: new FileWriter(outputDir), + ...visitorOptions, + }); + + const pumlFiles = fs.readdirSync(outputDir) + .filter((name) => name.endsWith('.puml')) + .map((name) => path.join(outputDir, name)); + + if (pumlFiles.length === 0) { + throw new Error('PlantUMLVisitor produced no .puml files'); + } + + for (const puml of pumlFiles) { + const input = fs.readFileSync(puml, 'utf-8'); + let result; + try { + result = execFileSync('java', ['-jar', plantumlJar, '-syntax'], { + input, + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch (err) { + const details = [err.stdout, err.stderr].filter(Boolean).join('\n').trim(); + throw new Error(`${path.basename(puml)}: ${details || err.message}`); + } + if (/^ERROR/m.test(result)) { + throw new Error(`${path.basename(puml)}: ${result.trim()}`); + } + } + } finally { + await cleanup(); + } +} + +describe('verification', function () { + this.timeout(120000); + + before(async function () { + applyVerificationEnv(); + if (JAVA_SKIP_REASON) { + // eslint-disable-next-line no-console + console.warn(`Skipping PlantUML verification tests: ${JAVA_SKIP_REASON}`); + return; + } + plantumlJar = await ensurePlantumlJar(); + }); + + CASES.forEach(function (testCase) { + const skipReason = getSkipReason(testCase, 'plantuml') || JAVA_SKIP_REASON; + const title = skipReason + ? `generated PlantUML from ${testCase.name} has valid syntax (pending: ${skipReason})` + : `generated PlantUML from ${testCase.name} has valid syntax`; + const run = skipReason ? it.skip : it; + + run(title, async function () { + const modelManager = createModelManager(testCase); + await verifyPlantUmlSyntax(modelManager, testCase.visitorOptions || {}); + }); + }); +}); diff --git a/test/verification/vocabulary.validate.test.js b/test/verification/vocabulary.validate.test.js new file mode 100644 index 0000000..885beb0 --- /dev/null +++ b/test/verification/vocabulary.validate.test.js @@ -0,0 +1,87 @@ +/* + * 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. + */ + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { dir } = require('tmp-promise'); + +const { FileWriter } = require('@accordproject/concerto-util'); +const VocabularyVisitor = require('../../lib/codegen/fromcto/vocabulary/vocabularyvisitor.js'); +const { validateVocabulary } = require('../../verification/docker/vocabulary/validate.js'); + +const { + CASES, + getSkipReason, + createModelManager, + applyVerificationEnv, +} = require('./cases.js'); + +/** + * Generate Vocabulary YAML and verify each .voc file has a valid vocabulary + * structure using @accordproject/concerto-vocabulary's VocabularyManager. + * @param {ModelManager} modelManager populated model manager + * @param {object} [visitorOptions] options passed to VocabularyVisitor + */ +async function verifyVocabularyValidates(modelManager, visitorOptions = {}) { + const { path: outputDir, cleanup } = await dir({ unsafeCleanup: true }); + + try { + modelManager.accept(new VocabularyVisitor(), { + fileWriter: new FileWriter(outputDir), + ...visitorOptions, + }); + + const vocFiles = fs.readdirSync(outputDir) + .filter((name) => name.endsWith('.voc')) + .map((name) => path.join(outputDir, name)); + + if (vocFiles.length === 0) { + throw new Error('VocabularyVisitor produced no .voc files'); + } + + for (const voc of vocFiles) { + const text = fs.readFileSync(voc, 'utf-8'); + try { + validateVocabulary(text); + } catch (err) { + throw new Error(`${path.basename(voc)}: ${err.message}`); + } + } + } finally { + await cleanup(); + } +} + +describe('verification', function () { + this.timeout(60000); + + before(function () { + applyVerificationEnv(); + }); + + CASES.forEach(function (testCase) { + const skipReason = getSkipReason(testCase, 'vocabulary'); + const title = skipReason + ? `generated Vocabulary from ${testCase.name} validates (pending: ${skipReason})` + : `generated Vocabulary from ${testCase.name} validates`; + const run = skipReason ? it.skip : it; + + run(title, async function () { + const modelManager = createModelManager(testCase); + await verifyVocabularyValidates(modelManager, testCase.visitorOptions || {}); + }); + }); +}); diff --git a/verification/docker/plantuml/Dockerfile b/verification/docker/plantuml/Dockerfile new file mode 100644 index 0000000..46c363e --- /dev/null +++ b/verification/docker/plantuml/Dockerfile @@ -0,0 +1,14 @@ +ARG BASE_IMAGE=concerto-verify-base:local +FROM ${BASE_IMAGE} + +# Syntax-check generated PlantUML (.puml) via the official PlantUML jar. +# Use the full JRE (not headless): PlantUML loads fontmanager even for -syntax. +# fontconfig + DejaVu satisfy the native font path on Alpine. +RUN apk add --no-cache openjdk17-jre fontconfig ttf-dejavu curl \ + && curl -fsSL -o /usr/local/share/plantuml.jar \ + https://github.com/plantuml/plantuml/releases/download/v1.2024.7/plantuml-1.2024.7.jar + +COPY verification/docker/plantuml/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/verification/docker/plantuml/entrypoint.sh b/verification/docker/plantuml/entrypoint.sh new file mode 100644 index 0000000..eee7798 --- /dev/null +++ b/verification/docker/plantuml/entrypoint.sh @@ -0,0 +1,31 @@ +#!/bin/sh +# Generate PlantUML from the corpus and verify each .puml has valid syntax +# using `java -jar plantuml.jar -syntax`. +set -eu + +CLI_TARGET=PlantUML +TARGET_KEY=plantuml +PLANTUML_JAR=/usr/local/share/plantuml.jar + +for case_name in $(jq -r '.cases[].name' "${CORPUS_DIR}/manifest.json"); do + out="${WORK_DIR}/${case_name}" + mkdir -p "$out" + + run-case.sh "$case_name" "$CLI_TARGET" "$TARGET_KEY" "$out" + + puml_files=$(find "$out" -name '*.puml' | sort) + if [ -z "$puml_files" ]; then + continue + fi + + echo "==> VERIFY $case_name with plantuml -syntax" + # shellcheck disable=SC2086 + for puml in $puml_files; do + echo " $puml" + result=$(java -jar "$PLANTUML_JAR" -syntax < "$puml") + if echo "$result" | grep -q '^ERROR'; then + echo "$result" >&2 + exit 1 + fi + done +done diff --git a/verification/docker/targets.json b/verification/docker/targets.json index ec7b9f4..fcc7442 100644 --- a/verification/docker/targets.json +++ b/verification/docker/targets.json @@ -70,5 +70,17 @@ "baseImage": "node:20-alpine", "tool": "avro-tools idl", "type": "schema" + }, + "plantuml": { + "cli": "PlantUML", + "baseImage": "openjdk:17-alpine", + "tool": "plantuml -syntax", + "type": "schema" + }, + "vocabulary": { + "cli": "Vocabulary", + "baseImage": "node:20-alpine", + "tool": "concerto-vocabulary VocabularyManager", + "type": "schema" } } diff --git a/verification/docker/vocabulary/Dockerfile b/verification/docker/vocabulary/Dockerfile new file mode 100644 index 0000000..b34f813 --- /dev/null +++ b/verification/docker/vocabulary/Dockerfile @@ -0,0 +1,12 @@ +ARG BASE_IMAGE=concerto-verify-base:local +FROM ${BASE_IMAGE} + +# Validate generated Vocabulary YAML (.voc) files with the official +# @accordproject/concerto-vocabulary package, already installed in the base +# image, which parses and validates the locale/namespace/declarations shape. +# validate.js is baked into /opt/concerto-codegen by the base image's +# `COPY . .`, so it resolves node_modules there - no extra COPY needed. +COPY verification/docker/vocabulary/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/verification/docker/vocabulary/entrypoint.sh b/verification/docker/vocabulary/entrypoint.sh new file mode 100644 index 0000000..b073928 --- /dev/null +++ b/verification/docker/vocabulary/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Generate Vocabulary YAML from the corpus and verify each .voc file has a +# valid vocabulary structure (locale, namespace, declarations) using +# @accordproject/concerto-vocabulary's VocabularyManager. +set -eu + +CLI_TARGET=Vocabulary +TARGET_KEY=vocabulary +VALIDATE_JS=/opt/concerto-codegen/verification/docker/vocabulary/validate.js + +for case_name in $(jq -r '.cases[].name' "${CORPUS_DIR}/manifest.json"); do + out="${WORK_DIR}/${case_name}" + mkdir -p "$out" + + run-case.sh "$case_name" "$CLI_TARGET" "$TARGET_KEY" "$out" + + voc_files=$(find "$out" -name '*.voc' | sort) + if [ -z "$voc_files" ]; then + continue + fi + + echo "==> VERIFY $case_name with concerto-vocabulary" + # shellcheck disable=SC2086 + for voc in $voc_files; do + echo " $voc" + node "$VALIDATE_JS" "$voc" + done +done diff --git a/verification/docker/vocabulary/validate.js b/verification/docker/vocabulary/validate.js new file mode 100644 index 0000000..e819b66 --- /dev/null +++ b/verification/docker/vocabulary/validate.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/* + * 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. + */ + +'use strict'; + +const { VocabularyManager } = require('@accordproject/concerto-vocabulary'); + +/** + * Parse and structurally validate Vocabulary YAML; throws on invalid content. + * @param {string} contents Vocabulary YAML source + * @returns {object} the parsed Vocabulary + */ +function validateVocabulary(contents) { + const vocabularyManager = new VocabularyManager(); + return vocabularyManager.addVocabulary(contents); +} + +/** + * Read and validate a .voc file path (CLI entry). + * @param {string} filePath path to a .voc file + */ +function main(filePath) { + if (!filePath) { + throw new Error('usage: validate.js '); + } + const fs = require('fs'); + validateVocabulary(fs.readFileSync(filePath, 'utf8')); +} + +module.exports = { validateVocabulary }; + +if (require.main === module) { + try { + main(process.argv[2]); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err.message || err); + process.exit(1); + } +}