validate_templates.ts (287 lines of code) (raw):

import { program } from 'commander'; import { z } from "zod"; import { existsSync, readFileSync, readdirSync, statSync } from 'fs'; import path from 'path'; import { fromError } from 'zod-validation-error'; const allowedCategories = ["Design Patterns", "AI", "B2B", "EDI", "Approval", "RAG", "Automation", "BizTalk Migration", "Mainframe Modernization"]; const templateManifestSchema = z.object({ id: z.string(), title: z.string(), summary: z.string(), description: z.string().optional(), artifacts: z.array(z.object({ type: z.union([z.literal('map'), z.literal('schema'), z.literal('assembly')]), file: z.string().regex(/^\S+\.\S+$/, { message: 'File field must not contain spaces and must have an extension' }) })).optional(), skus: z.array(z.union([z.literal('standard'), z.literal('consumption')])), workflows: z.record( z.string().regex(/^[a-z-]+$/, { message: 'Workflow key must only contain lowercase letters and hyphens' }), z.object({ name: z.string().transform((val) => val.replace(/-([a-z])/g, (_, letter) => `_${letter.toUpperCase()}`) .replace(/^[a-z]/, (letter) => letter.toUpperCase()) ), }) ), featuredConnectors: z.array( z.object({ id: z.string().regex(/^(\/.*|connectionProviders.*)$/, { message: 'Connections "id" field must start with a forward slash or with "connectionProviders" (builtin connectors)' }), kind: z.union([z.literal('inapp'), z.literal('shared'), z.literal('custom'), z.literal('builtin')]) }) ), details: z.object({ By: z.string().regex(/^[A-Z].*$/, { message: 'By field must start with the first letter capitalized' }), Type: z.union([z.literal('Workflow'), z.literal('Accelerator')]), Category: z.string().optional(), Trigger: z.union([z.literal('Request'), z.literal('Recurrence'), z.literal('Event'), z.literal('Automated'), z.literal('Scheduled')]).optional(), }), tags: z.array(z.string()).optional(), }); const workflowManifestSchema = z.object({ id: z.string(), title: z.string(), summary: z.string(), description: z.string().optional(), prerequisites: z.string().optional(), kinds: z.array(z.union([z.literal('stateful'), z.literal('stateless')])).optional(), artifacts: z.array(z.object({ type: z.literal('workflow'), file: z.string().regex(/^\S+\.\S+$/, { message: 'Workflow File field must not contain spaces and must have an extension' }) })).optional(), images: z.object({ light: z.string().regex(/^[a-z-_]+$/, { message: 'Image field must only contain lowercase letters, hyphens, and underscore' }), dark: z.string().regex(/^[a-z-_]+$/, { message: 'Image field must only contain lowercase letters, hyphens, and underscore' }) }), parameters: z.array( z.object({ name: z.string().regex(/^\S*_#workflowname#$/, { message: 'parameters "name" field must end with _#workflowname#' }), displayName: z.string().regex(/^[A-Z].*$/, { message: 'parameters "displayName" field must start with the first letter capitalized. Suggested naming convention: "Display Name" (O), "display-name" (X)' }), type: z.union([z.literal('String'), z.literal('Bool'), z.literal('Array'), z.literal('Float'), z.literal('Int'), z.literal('Object')]), description: z.string(), required: z.boolean(), allowedValues: z.array( z.object({ value: z.string(), displayName: z.string() }) ).optional() }) ), connections: z.record( z.string().regex(/^\S*_#workflowname#$/, { message: 'connections "name" field must end with _#workflowname#' }), z.object({ connectorId: z.string().regex(/^\/.*/, { message: 'Connections "connectorId" field must start with a forward slash' }), kind: z.union([z.literal('inapp'), z.literal('shared'), z.literal('custom')]), })), }); program.parse(); const manifestNamesList: string[] = JSON.parse(readFileSync(path.resolve('./manifest.json'), { encoding: 'utf-8' })); const allManifestDirectories = readdirSync("./").filter(file => statSync(path.join("./", file)).isDirectory() && existsSync(path.join("./", file, "manifest.json")) ); const checkFilesExistCaseSensitive = (fileNamesInFolder: string[], folderName: string, listedFileNames: string[]) => { for (const fileName of listedFileNames) { if (!fileNamesInFolder.includes(fileName)) { console.error(`Template Failed Validation: ${`./${folderName}/${fileName}`} not found`); throw ''; } } } const invalidLinkPatternMD = z.string().regex(/^.*\[\S+\]\s+\(\S+\).*$/); const validateTemplateManifest = (folderName: string, templateManifest) => { const summaryInvalidPattern = invalidLinkPatternMD.safeParse(templateManifest?.summary ?? ""); const detailsDescriptionInvalidPattern = invalidLinkPatternMD.safeParse(templateManifest?.description ?? ""); if (summaryInvalidPattern.success) { console.error(`Template Manifest "${folderName}" Failed Validation: summary link is invalid, ensure no space between the [text] and the (link)`); throw ''; } if (detailsDescriptionInvalidPattern.success) { console.error(`Template Manifest "${folderName}" Failed Validation: description link is invalid, ensure no space between the [text] and the (link)`); throw ''; } const workflowsCount = Object.keys(templateManifest?.workflows ??{}).length; const workflowTypeByCount = workflowsCount === 1 ? "Workflow" : workflowsCount > 1 ? "Accelerator" : undefined; if (templateManifest.details.Type !== workflowTypeByCount) { console.error(`Template Manifest "${folderName}" Failed Validation: ${ workflowsCount ? `There are ${workflowsCount} workflows, please ensure "details.Type" is ${workflowTypeByCount}` : "None of the workflows are registered in the manifest.json." }`); throw ''; } if (templateManifest.details?.Category) { for (const category of templateManifest.details?.Category?.split(",") ?? []) { if (!allowedCategories.includes(category)) { console.error(`Template Manifest "${folderName}" Failed Validation: Category "${category}" is invalid`); throw ''; } } } if (templateManifest.tags?.some((tag) => tag.includes(","))) { console.error(`Template Manifest "${folderName}" Failed Validation: Tags should be separate strings, not one string separated by ","`); throw ''; } // Check all artifacts/images listed in manifest.json exist (case sensitive check) const fileNamesInFolder = readdirSync(path.resolve(`./${folderName}`)); checkFilesExistCaseSensitive(fileNamesInFolder, folderName, templateManifest?.artifacts?.map((artifact) => artifact.file) ?? []); // Note: Disabled the check for now as we have "sample" artifacts that don't fall under the defined artifact types // const allArtifactsInFolder = readdirSync(`./${folderName}`).filter(file => // !file.endsWith(".png") && file !== "manifest.json" // ); // // Give warning if all the artifacts in the template/manifest.json is not registered // const allRegisteredArtifacts = manifestFile.artifacts.map(artifact => artifact.file); // const artifactsNotRegistered = allArtifactsInFolder.filter(item => !allRegisteredArtifacts.includes(item)); // if (artifactsNotRegistered.length) { // console.error(`Artifacts(s) ${JSON.stringify(artifactsNotRegistered)} found in the repository not registered in ${folderName}/manifest.json.`); // throw ''; // } } const getUnusedConnectors = (workflowConnections, featuredConnectors) => { return featuredConnectors.filter(value => value.kind !== "builtin" && !workflowConnections.some((item: any) => item.connectorId === value.id && item.kind === value.kind)); } const validateWorkflowManifest = (folderName: string, isWorkflowTemplate: boolean, templateSkus: string[] | undefined, workflowManifest) => { const prerequisitesInvalidPattern = invalidLinkPatternMD.safeParse(workflowManifest?.prerequisites ?? ""); const summaryInvalidPattern = invalidLinkPatternMD.safeParse(workflowManifest?.summary ?? ""); const detailsDescriptionInvalidPattern = invalidLinkPatternMD.safeParse(workflowManifest?.description ?? ""); if (prerequisitesInvalidPattern.success) { console.error(`Workflow Manifest "${folderName}" Failed Validation: prerequisites link is invalid, ensure no space between the [text] and the (link)`); throw ''; } if (summaryInvalidPattern.success) { console.error(`Workflow Manifest "${folderName}" Failed Validation: summary link is invalid, ensure no space between the [text] and the (link)`); throw ''; } if (detailsDescriptionInvalidPattern.success) { console.error(`Workflow Manifest "${folderName}" Failed Validation: description link is invalid, ensure no space between the [text] and the (link)`); throw ''; } // Check all artifacts/images listed in manifest.json exist (case sensitive check) const fileNamesInFolder = readdirSync(path.resolve(`./${folderName}`)); checkFilesExistCaseSensitive(fileNamesInFolder, folderName, workflowManifest?.artifacts?.map((artifact) => artifact.file) ?? []); checkFilesExistCaseSensitive(fileNamesInFolder, folderName, [`${workflowManifest.images.light}.png`, `${workflowManifest.images.dark}.png`]); const workflowFilePath = workflowManifest.artifacts.find((artifact) => artifact.type === "workflow")?.file; if (!workflowFilePath) { console.error(`Workflow Manifest "${folderName}" Failed Validation: workflow file not found`); throw ''; } const workflowFile = JSON.parse(readFileSync(path.resolve(`./${folderName}/${workflowFilePath}`), { encoding: 'utf-8' })); if (workflowFile.definition || workflowFile.kind) { console.error(`Workflow "./${folderName}/${workflowFilePath}" Failed Validation: workflow.json is invalid - please only keep what's under "definition"`); throw ''; } const workflowFileString = JSON.stringify(workflowFile); const parameterNames = workflowManifest.parameters.map(parameter => parameter.name); const connectionNames = Object.keys(workflowManifest.connections); const parameterMatches = workflowFileString.matchAll(/parameters\('\s*(?!\$connections)([^"']+)\s*'\)/g); for (const match of parameterMatches) { if (!parameterNames.includes(match[1])) { console.error(`Workflow "${folderName}" Failed Validation: parameter "${match[1]}" not found in manifest.json. Hint: Make sure the parameter name is in the format <parameterName>_#workflowname#`); throw ''; } } const connectionReferenceMatches = workflowFileString.matchAll(/"connection":\s*\{\s*"referenceName":\s*"([^"]+)"\}/g); for (const match of connectionReferenceMatches) { if (!connectionNames.includes(match[1])) { console.error(`Workflow "${folderName}" Failed Validation: connection used in "referenceName": "${match[1]}" not found in manifest.json. Hint: Make sure the connection name is in the format <connectionName>_#workflowname#`); throw ''; } } const connectionNameMatches = workflowFileString.matchAll(/"connectionName":\s*"([^"]+)"/g); for (const match of connectionNameMatches) { if (!connectionNames.includes(match[1])) { console.error(`Workflow "${folderName}" Failed Validation: connection used in "connectionName": "${match[1]}" not found in manifest.json. Hint: Make sure the connection name is in the format <connectionName>_#workflowname#`); throw ''; } } const parameterConnectionsMatches = [...workflowFileString.matchAll(/parameters\('\$connections'\)\['([^']+)'\]\['connectionId'\]/g)]; // If skus is not defined, it supports both if (parameterConnectionsMatches?.length && (!isWorkflowTemplate || (templateSkus?.includes("standard") ?? true))) { console.error(`Workflow "${folderName}" Failed Validation: parameters('$connections') is invalid for standard workflows. Either remove the parameters('$connections') or set the sku to "consumption" in manifest.json`); throw ''; } for (const match of parameterConnectionsMatches) { if (!connectionNames.includes(match[1])) { console.error(`Workflow "${folderName}" Failed Validation: parameters('$connections') "${match[1]}" not found in manifest.json. Hint: Make sure the connection name is in the format <connectionName>_#workflowname#`); throw ''; } } } const checkTitleDescriptionToBeEqual = (folderName, templateManifest, workflowManifest) => { if (templateManifest.title !== workflowManifest.title) { console.error(`Template "${folderName}" Failed Validation: Template title and Workflow title must be identical`); throw ''; } if (templateManifest.summary !== workflowManifest.summary) { console.error(`Template "${folderName}" Failed Validation: Template summary and Workflow summary must be identical`); throw ''; } } const checkFolderNameEqualToId = (folderName: string, manifestId: string, relativePath: string, manifestType: "Workflow" | "Template") => { if (manifestId !== folderName) { console.error(`${manifestType} Manifest "${relativePath}" Failed Validation: ${manifestType} manifest id and folder name must be identical`); throw ''; } } const manifestNamesSet = new Set(manifestNamesList); if (manifestNamesSet.size !== manifestNamesList.length) { console.error(`manifest.json contains ${manifestNamesList.length - manifestNamesSet.size} duplicate Template name(s)`); throw ''; } // Check all registered folders in manifest.json exist with another manifest.json const registeredNotExisting = manifestNamesList.filter(item => !allManifestDirectories.includes(item)); if (registeredNotExisting.length) { console.error(`Template(s) registered in manifest.json: ${JSON.stringify(registeredNotExisting)} not found in the repository`); throw ''; } // Give warning if all the folders in the repo is registered in the main manifest.json const templatesNotRegistered = allManifestDirectories.filter(item => !manifestNamesList.includes(item)); if (templatesNotRegistered.length) { console.error(`Template(s) ${JSON.stringify(templatesNotRegistered)} found in the repository are not registered in manifest.json.`); // throw ''; // Disabling error throwing, considering purposefully non-registered templates } for (const folderName of manifestNamesList) { const templateManifest = JSON.parse(readFileSync(path.resolve(`./${folderName}/manifest.json`), { encoding: 'utf-8' })); const result = templateManifestSchema.safeParse(templateManifest); if (!result.success) { console.log(`Template Manifest "${folderName}" Failed Validation`); const validationError = fromError(result.error); console.error(validationError.toString()); throw ''; } validateTemplateManifest(folderName, templateManifest); const isWorkflowTemplate = templateManifest.details.Type === "Workflow"; checkFolderNameEqualToId(folderName, templateManifest.id, `${folderName}/manifest.json`, "Workflow"); let unregistered_featuredConnectors = [...(templateManifest?.featuredConnectors ?? [])]; for (const workflowFolder of Object.keys(templateManifest.workflows)) { const workflowManifest = JSON.parse(readFileSync(path.resolve(`./${folderName}/${workflowFolder}/manifest.json`), { encoding: 'utf-8' })); if (isWorkflowTemplate) { checkTitleDescriptionToBeEqual(folderName, templateManifest, workflowManifest); if (workflowFolder !== 'default' || workflowManifest.id !== 'default') { console.error(`Workflow Manifest "${folderName}/${workflowFolder}" Failed Validation: Workflow folder name and workflow manifest id must be "default" for single workflow template`); } } const workflowManifestResult = workflowManifestSchema.safeParse(workflowManifest); if (!workflowManifestResult.success) { console.log(`Workflow Manifest "${folderName}/${workflowFolder}" Failed Validation`); const validationError = fromError(workflowManifestResult.error); console.error(validationError.toString()); throw ''; } checkFolderNameEqualToId(workflowFolder, workflowManifest.id, `${folderName}/${workflowFolder}/manifest.json`, "Workflow"); validateWorkflowManifest(`${folderName}/${workflowFolder}`, isWorkflowTemplate, templateManifest.skus, workflowManifest); unregistered_featuredConnectors = getUnusedConnectors(Object.values(workflowManifest.connections), unregistered_featuredConnectors); } if (unregistered_featuredConnectors?.length) { console.error(`Template Manifest "${folderName}" Failed Validation: Featured connectors ${JSON.stringify(unregistered_featuredConnectors)} are not used in any workflow`); throw ''; } } console.log("Test Passed");