From 8119c5d30fa6c55de2038b4030bfd0755b9885f8 Mon Sep 17 00:00:00 2001 From: Michael Pursifull Date: Wed, 4 Feb 2026 13:27:57 -0600 Subject: [PATCH] feat: implement workflow schema validator with Zod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Zod schema for workflow.yaml files and the CLI validator tool, completing the GREEN phase of TDD. - tools/schema/workflow.js: Zod schema with 5 required fields, template polymorphism (string|false), input_file_patterns with load_strategy enum, execution_hints, and instructions extension validation. Passthrough for varying optional string fields. - tools/validate-workflow-schema.js: CLI tool mirroring validate-agent-schema.js with --strict flag and GitHub Actions ::warning annotations. - package.json: Wire test:schemas and validate:schemas to include workflow schema validation. 27/27 tests passing. 12/13 real workflow files pass validation (qa/automate missing standalone — pre-existing upstream defect). Ref: MSSCI-12749 --- package.json | 4 +- tools/schema/workflow.js | 76 +++++++++++++++---- tools/validate-workflow-schema.js | 119 ++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+), 18 deletions(-) create mode 100644 tools/validate-workflow-schema.js diff --git a/package.json b/package.json index 8798a6208..eb930de0b 100644 --- a/package.json +++ b/package.json @@ -48,9 +48,9 @@ "test": "npm run test:schemas && npm run test:install && npm run validate:schemas && npm run lint && npm run lint:md && npm run format:check", "test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas", "test:install": "node test/test-installation-components.js", - "test:schemas": "node test/test-agent-schema.js", + "test:schemas": "node test/test-agent-schema.js && node test/test-workflow-schema.js", "validate:refs": "node tools/validate-file-refs.js", - "validate:schemas": "node tools/validate-agent-schema.js" + "validate:schemas": "node tools/validate-agent-schema.js && node tools/validate-workflow-schema.js" }, "lint-staged": { "*.{js,cjs,mjs}": [ diff --git a/tools/schema/workflow.js b/tools/schema/workflow.js index 3e2a0dfff..80b963084 100644 --- a/tools/schema/workflow.js +++ b/tools/schema/workflow.js @@ -1,6 +1,63 @@ // Zod schema definition for workflow.yaml files -// STUB — implementation pending. All validations return failure. -// See: sprint/reference/mssci-12749-discovery.md for field inventory and validation rules. +const { z } = require('zod'); + +// Load strategy enum — the three recognized values across all existing workflow files +const loadStrategyEnum = z.enum(['FULL_LOAD', 'SELECTIVE_LOAD', 'INDEX_GUIDED']); + +// Schema for individual input_file_patterns entries +const inputFilePatternEntrySchema = z + .object({ + load_strategy: loadStrategyEnum, + description: z.string().optional(), + whole: z.string().optional(), + sharded: z.string().optional(), + sharded_index: z.string().optional(), + sharded_single: z.string().optional(), + pattern: z.string().optional(), + }) + .passthrough(); + +// Schema for execution_hints +const executionHintsSchema = z + .object({ + interactive: z.boolean().optional(), + autonomous: z.boolean().optional(), + iterative: z.boolean().optional(), + }) + .passthrough(); + +// Main workflow schema +const workflowSchema = z + .object({ + // Required fields (all 13 workflow.yaml files have these) + name: z.string().min(1), + description: z.string().min(1), + author: z.string().min(1), + standalone: z.boolean(), + web_bundle: z.boolean(), + + // Structured optional fields + template: z.union([z.string(), z.literal(false)]).optional(), + input_file_patterns: z.record(z.string(), inputFilePatternEntrySchema).optional(), + execution_hints: executionHintsSchema.optional(), + tags: z.array(z.string()).optional(), + required_tools: z.array(z.string()).optional(), + variables: z.record(z.string(), z.string()).optional(), + instructions: z.string().optional(), + }) + .passthrough() + .refine( + (data) => { + if (typeof data.instructions === 'string') { + return data.instructions.endsWith('.md') || data.instructions.endsWith('.xml'); + } + return true; + }, + { + message: 'Instructions file must end with .md or .xml', + path: ['instructions'], + }, + ); /** * Validate a workflow YAML payload against the schema. @@ -11,20 +68,7 @@ * @returns {import('zod').SafeParseReturnType} SafeParse result. */ function validateWorkflowFile(filePath, workflowYaml) { - // TODO: Implement Zod schema validation - // This stub returns failure so tests are in RED state. - return { - success: false, - error: { - issues: [ - { - code: 'custom', - message: 'Schema not yet implemented', - path: [], - }, - ], - }, - }; + return workflowSchema.safeParse(workflowYaml); } module.exports = { validateWorkflowFile }; diff --git a/tools/validate-workflow-schema.js b/tools/validate-workflow-schema.js new file mode 100644 index 000000000..44da334ed --- /dev/null +++ b/tools/validate-workflow-schema.js @@ -0,0 +1,119 @@ +/** + * Workflow Schema Validator CLI + * + * Scans all workflow.yaml files in src/{core,bmm}/workflows/ + * and validates them against the Zod schema. + * + * Usage: node tools/validate-workflow-schema.js [--strict] [project_root] + * Exit codes: + * 0 = success (or warnings-only in default mode) + * 1 = validation failures in --strict mode, or no files found + * + * Options: + * --strict Exit 1 when validation errors exist (for CI enforcement) + */ + +const { glob } = require('glob'); +const yaml = require('yaml'); +const fs = require('node:fs'); +const path = require('node:path'); +const { validateWorkflowFile } = require('./schema/workflow.js'); + +const isCI = !!process.env.GITHUB_ACTIONS; + +/** + * Main validation routine + * @param {object} options + * @param {boolean} options.strict - Exit 1 on validation errors + * @param {string} [options.projectRoot] - Optional project root to scan + */ +async function main({ strict, projectRoot }) { + console.log('šŸ” Scanning for workflow files...\n'); + + const root = projectRoot || path.join(__dirname, '..'); + + const workflowFiles = await glob('src/{core,bmm}/workflows/**/workflow.yaml', { + cwd: root, + absolute: true, + }); + + if (workflowFiles.length === 0) { + console.log('āŒ No workflow files found. This likely indicates a configuration error.'); + console.log(' Expected to find workflow.yaml files in src/{core,bmm}/workflows/'); + process.exit(1); + } + + console.log(`Found ${workflowFiles.length} workflow file(s)\n`); + + const errors = []; + + for (const filePath of workflowFiles.sort()) { + const relativePath = path.relative(process.cwd(), filePath); + + try { + const fileContent = fs.readFileSync(filePath, 'utf8'); + const workflowData = yaml.parse(fileContent); + + const result = validateWorkflowFile(relativePath, workflowData); + + if (result.success) { + console.log(`āœ… ${relativePath}`); + } else { + errors.push({ + file: relativePath, + issues: result.error.issues, + }); + } + } catch (error) { + errors.push({ + file: relativePath, + issues: [ + { + code: 'parse_error', + message: `Failed to parse YAML: ${error.message}`, + path: [], + }, + ], + }); + } + } + + if (errors.length > 0) { + console.log(`\nāš ļø Validation issues in ${errors.length} file(s):\n`); + + for (const { file, issues } of errors) { + console.log(`šŸ“„ ${file}`); + for (const issue of issues) { + const pathString = issue.path.length > 0 ? issue.path.join('.') : '(root)'; + console.log(` Path: ${pathString}`); + console.log(` Error: ${issue.message}`); + + if (isCI) { + console.log(`::warning file=${file},title=Workflow Schema::${pathString}: ${issue.message}`); + } + } + console.log(''); + } + + if (strict) { + console.log(`šŸ’„ ${errors.length} file(s) failed validation (--strict mode)\n`); + process.exit(1); + } + + console.log(`āš ļø ${errors.length} file(s) have warnings (non-blocking)\n`); + process.exit(0); + } + + console.log(`\n✨ All ${workflowFiles.length} workflow file(s) passed validation!\n`); + process.exit(0); +} + +// Parse CLI arguments +const args = process.argv.slice(2); +const strict = args.includes('--strict'); +const projectRoot = args.find((arg) => !arg.startsWith('--')); + +main({ strict, projectRoot }).catch((error) => { + console.error('Fatal error:', error); + process.exit(1); +});