@tamyla/clodo-framework 4.5.0 → 4.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,503 @@
1
+ /**
2
+ * Config Schema Validator
3
+ * Validates CLI configuration files against Zod schemas
4
+ *
5
+ * Integrates with existing infrastructure:
6
+ * - ConfigLoader for file loading
7
+ * - configSchemas.js for Zod schema definitions
8
+ * - service-schema-config.js for canonical enums
9
+ * - payloadValidation.js patterns for result format
10
+ *
11
+ * Usage:
12
+ * const validator = new ConfigSchemaValidator();
13
+ * const result = validator.validateConfig(config, 'create');
14
+ * // result: { valid, errors[], warnings[], schema, commandType }
15
+ *
16
+ * const result = await validator.validateConfigFile('/path/to/config.json', 'deploy');
17
+ */
18
+
19
+ import { readFileSync, existsSync } from 'fs';
20
+ import { resolve } from 'path';
21
+ import { CONFIG_SCHEMAS, getConfigSchema, getRegisteredConfigTypes, CreateConfigSchema, DeployConfigSchema, ValidateConfigSchema, UpdateConfigSchema } from './configSchemas.js';
22
+ import { getConfig } from '../config/service-schema-config.js';
23
+ export class ConfigSchemaValidator {
24
+ constructor(options = {}) {
25
+ this.verbose = options.verbose || false;
26
+ this.strict = options.strict || false;
27
+ }
28
+
29
+ /**
30
+ * Validate a config object against the schema for a given command type
31
+ * @param {Object} config - Configuration object to validate
32
+ * @param {string} commandType - Command type (create, deploy, validate, update)
33
+ * @returns {Object} Validation result { valid, errors[], warnings[], commandType }
34
+ */
35
+ validateConfig(config, commandType) {
36
+ const result = {
37
+ valid: true,
38
+ errors: [],
39
+ warnings: [],
40
+ commandType,
41
+ fieldCount: 0
42
+ };
43
+
44
+ // Get the schema for this command type
45
+ const schema = getConfigSchema(commandType);
46
+ if (!schema) {
47
+ result.valid = false;
48
+ result.errors.push({
49
+ field: 'commandType',
50
+ code: 'UNKNOWN_COMMAND_TYPE',
51
+ message: `Unknown command type: '${commandType}'. Valid types: ${getRegisteredConfigTypes().join(', ')}`
52
+ });
53
+ return result;
54
+ }
55
+
56
+ // Basic type check
57
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
58
+ result.valid = false;
59
+ result.errors.push({
60
+ field: '_root',
61
+ code: 'INVALID_CONFIG_TYPE',
62
+ message: 'Configuration must be a non-null object'
63
+ });
64
+ return result;
65
+ }
66
+
67
+ // Filter out comment keys (e.g., "// Comment") before validation
68
+ const cleanConfig = this._stripComments(config);
69
+ result.fieldCount = Object.keys(cleanConfig).length;
70
+
71
+ // Run Zod validation (safeParse for non-throwing)
72
+ const zodResult = schema.safeParse(cleanConfig);
73
+ if (!zodResult.success) {
74
+ result.valid = false;
75
+ for (const issue of zodResult.error.issues) {
76
+ const field = issue.path.length > 0 ? issue.path.join('.') : '_root';
77
+ result.errors.push({
78
+ field,
79
+ code: this._mapZodCode(issue.code),
80
+ message: issue.message,
81
+ expected: issue.expected,
82
+ received: issue.received
83
+ });
84
+ }
85
+ }
86
+
87
+ // Add semantic warnings (non-blocking)
88
+ this._addSemanticWarnings(cleanConfig, commandType, result);
89
+ return result;
90
+ }
91
+
92
+ /**
93
+ * Load and validate a config file
94
+ * @param {string} filePath - Path to JSON config file
95
+ * @param {string} commandType - Command type (create, deploy, validate, update)
96
+ * @returns {Object} Validation result with parsed config
97
+ */
98
+ validateConfigFile(filePath, commandType) {
99
+ const result = {
100
+ valid: true,
101
+ errors: [],
102
+ warnings: [],
103
+ commandType,
104
+ filePath,
105
+ config: null,
106
+ fieldCount: 0
107
+ };
108
+
109
+ // Resolve and check file existence
110
+ const fullPath = resolve(filePath);
111
+ if (!existsSync(fullPath)) {
112
+ result.valid = false;
113
+ result.errors.push({
114
+ field: '_file',
115
+ code: 'FILE_NOT_FOUND',
116
+ message: `Configuration file not found: ${fullPath}`
117
+ });
118
+ return result;
119
+ }
120
+
121
+ // Parse JSON
122
+ let config;
123
+ try {
124
+ const content = readFileSync(fullPath, 'utf8');
125
+ config = JSON.parse(content);
126
+ } catch (parseError) {
127
+ result.valid = false;
128
+ result.errors.push({
129
+ field: '_file',
130
+ code: 'INVALID_JSON',
131
+ message: `Invalid JSON in configuration file: ${parseError.message}`
132
+ });
133
+ return result;
134
+ }
135
+
136
+ // Validate against schema
137
+ const validation = this.validateConfig(config, commandType);
138
+ result.valid = validation.valid;
139
+ result.errors = validation.errors;
140
+ result.warnings = validation.warnings;
141
+ result.fieldCount = validation.fieldCount;
142
+ result.config = config;
143
+ return result;
144
+ }
145
+
146
+ /**
147
+ * Auto-detect the command type from a config object based on its fields
148
+ * @param {Object} config - Configuration object
149
+ * @returns {Object} Detection result { detected, commandType, confidence, candidates[] }
150
+ */
151
+ detectConfigType(config) {
152
+ if (!config || typeof config !== 'object') {
153
+ return {
154
+ detected: false,
155
+ commandType: null,
156
+ confidence: 0,
157
+ candidates: []
158
+ };
159
+ }
160
+ const cleanConfig = this._stripComments(config);
161
+ const keys = Object.keys(cleanConfig);
162
+
163
+ // Signature fields for each command type
164
+ const signatures = {
165
+ create: {
166
+ strong: ['projectName', 'serviceName', 'serviceType', 'template', 'typescript'],
167
+ moderate: ['features', 'advanced', 'metadata', 'middlewareStrategy']
168
+ },
169
+ deploy: {
170
+ strong: ['deployment', 'routing', 'monitoring', 'dryRun', 'skipDoctor', 'doctorStrict'],
171
+ moderate: ['security', 'token', 'servicePath']
172
+ },
173
+ validate: {
174
+ strong: ['deepScan', 'checks', 'requirements', 'reporting'],
175
+ moderate: ['validation', 'exportReport']
176
+ },
177
+ update: {
178
+ strong: ['updates', 'migration', 'notification', 'performance'],
179
+ moderate: ['preview', 'interactive']
180
+ }
181
+ };
182
+ const candidates = [];
183
+ for (const [type, sig] of Object.entries(signatures)) {
184
+ let score = 0;
185
+ const matchedFields = [];
186
+ for (const key of keys) {
187
+ if (sig.strong.includes(key)) {
188
+ score += 3;
189
+ matchedFields.push(key);
190
+ } else if (sig.moderate.includes(key)) {
191
+ score += 1;
192
+ matchedFields.push(key);
193
+ }
194
+ }
195
+ if (score > 0) {
196
+ candidates.push({
197
+ commandType: type,
198
+ score,
199
+ matchedFields
200
+ });
201
+ }
202
+ }
203
+
204
+ // Sort by score descending
205
+ candidates.sort((a, b) => b.score - a.score);
206
+ if (candidates.length === 0) {
207
+ return {
208
+ detected: false,
209
+ commandType: null,
210
+ confidence: 0,
211
+ candidates: []
212
+ };
213
+ }
214
+ const best = candidates[0];
215
+ const confidence = Math.min(best.score / 6, 1); // Normalize to 0-1
216
+
217
+ return {
218
+ detected: true,
219
+ commandType: best.commandType,
220
+ confidence,
221
+ candidates
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Get human-readable schema definition for a command type
227
+ * @param {string} commandType - Command type
228
+ * @returns {Object} Schema definition with field descriptions
229
+ */
230
+ getSchemaDefinition(commandType) {
231
+ const schema = getConfigSchema(commandType);
232
+ if (!schema) return null;
233
+ const schemaConfig = getConfig();
234
+ const definitions = {};
235
+
236
+ // Extract shape from Zod schema
237
+ const shape = schema.shape;
238
+ for (const [key, fieldSchema] of Object.entries(shape)) {
239
+ definitions[key] = this._describeZodField(key, fieldSchema, schemaConfig);
240
+ }
241
+ return {
242
+ commandType,
243
+ description: schema.description || `Configuration schema for '${commandType}' command`,
244
+ fields: definitions,
245
+ fieldCount: Object.keys(definitions).length,
246
+ validServiceTypes: schemaConfig.serviceTypes,
247
+ validFeatures: schemaConfig.features
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Validate all example config files in the config directory
253
+ * @param {string} configDir - Path to config directory
254
+ * @returns {Object} Results for each example file
255
+ */
256
+ validateExampleConfigs(configDir) {
257
+ const results = {};
258
+ const exampleFiles = {
259
+ 'clodo-create.example.json': 'create',
260
+ 'clodo-deploy.example.json': 'deploy',
261
+ 'clodo-validate.example.json': 'validate',
262
+ 'clodo-update.example.json': 'update'
263
+ };
264
+ for (const [filename, commandType] of Object.entries(exampleFiles)) {
265
+ const filePath = resolve(configDir, filename);
266
+ if (existsSync(filePath)) {
267
+ results[filename] = this.validateConfigFile(filePath, commandType);
268
+ } else {
269
+ results[filename] = {
270
+ valid: false,
271
+ errors: [{
272
+ field: '_file',
273
+ code: 'FILE_NOT_FOUND',
274
+ message: `Example file not found: ${filename}`
275
+ }],
276
+ warnings: [],
277
+ commandType
278
+ };
279
+ }
280
+ }
281
+ return results;
282
+ }
283
+
284
+ /**
285
+ * Get all registered config types
286
+ * @returns {string[]}
287
+ */
288
+ getRegisteredTypes() {
289
+ return getRegisteredConfigTypes();
290
+ }
291
+
292
+ // ─── Private Helpers ─────────────────────────────────────────────────────────
293
+
294
+ /**
295
+ * Strip comment keys (e.g., "// Comment") from config objects
296
+ */
297
+ _stripComments(config) {
298
+ const clean = {};
299
+ for (const [key, value] of Object.entries(config)) {
300
+ if (!key.startsWith('//')) {
301
+ clean[key] = value;
302
+ }
303
+ }
304
+ return clean;
305
+ }
306
+
307
+ /**
308
+ * Map Zod error codes to our error codes
309
+ */
310
+ _mapZodCode(zodCode) {
311
+ const codeMap = {
312
+ invalid_type: 'INVALID_TYPE',
313
+ invalid_string: 'INVALID_STRING',
314
+ too_small: 'VALUE_TOO_SMALL',
315
+ too_big: 'VALUE_TOO_BIG',
316
+ invalid_enum_value: 'INVALID_ENUM',
317
+ custom: 'CUSTOM_VALIDATION',
318
+ invalid_union: 'INVALID_UNION',
319
+ unrecognized_keys: 'UNRECOGNIZED_KEYS'
320
+ };
321
+ return codeMap[zodCode] || 'SCHEMA_VALIDATION';
322
+ }
323
+
324
+ /**
325
+ * Add semantic warnings (non-blocking checks)
326
+ */
327
+ _addSemanticWarnings(config, commandType, result) {
328
+ // Warn about env var placeholders that weren't substituted
329
+ this._checkEnvVarPlaceholders(config, result);
330
+
331
+ // Command-specific warnings
332
+ switch (commandType) {
333
+ case 'create':
334
+ this._addCreateWarnings(config, result);
335
+ break;
336
+ case 'deploy':
337
+ this._addDeployWarnings(config, result);
338
+ break;
339
+ case 'update':
340
+ this._addUpdateWarnings(config, result);
341
+ break;
342
+ }
343
+ }
344
+
345
+ /**
346
+ * Check for unsubstituted environment variable placeholders
347
+ */
348
+ _checkEnvVarPlaceholders(config, result, prefix = '') {
349
+ for (const [key, value] of Object.entries(config)) {
350
+ const field = prefix ? `${prefix}.${key}` : key;
351
+ if (typeof value === 'string' && /\$\{[^}]+\}/.test(value)) {
352
+ result.warnings.push({
353
+ field,
354
+ code: 'ENV_VAR_PLACEHOLDER',
355
+ message: `Field '${field}' contains environment variable placeholder: ${value}. Ensure env vars are set at runtime.`
356
+ });
357
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
358
+ this._checkEnvVarPlaceholders(value, result, field);
359
+ }
360
+ }
361
+ }
362
+
363
+ /**
364
+ * Warnings specific to create configs
365
+ */
366
+ _addCreateWarnings(config, result) {
367
+ if (config.features && Array.isArray(config.features)) {
368
+ const duplicates = config.features.filter((v, i, a) => a.indexOf(v) !== i);
369
+ if (duplicates.length) {
370
+ result.warnings.push({
371
+ field: 'features',
372
+ code: 'DUPLICATE_FEATURES',
373
+ message: `Duplicate features: ${[...new Set(duplicates)].join(', ')}`
374
+ });
375
+ }
376
+ }
377
+
378
+ // Both projectName and serviceName provided — potential confusion
379
+ if (config.projectName && config.serviceName && config.projectName !== config.serviceName) {
380
+ result.warnings.push({
381
+ field: 'serviceName',
382
+ code: 'NAME_MISMATCH',
383
+ message: `projectName ('${config.projectName}') differs from serviceName ('${config.serviceName}'). serviceName is used for the service identifier.`
384
+ });
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Warnings specific to deploy configs
390
+ */
391
+ _addDeployWarnings(config, result) {
392
+ // Production without security settings
393
+ if (config.environment === 'production') {
394
+ if (!config.security) {
395
+ result.warnings.push({
396
+ field: 'security',
397
+ code: 'MISSING_SECURITY',
398
+ message: 'Production deployment without security configuration. Consider adding security settings.'
399
+ });
400
+ }
401
+ if (!config.monitoring) {
402
+ result.warnings.push({
403
+ field: 'monitoring',
404
+ code: 'MISSING_MONITORING',
405
+ message: 'Production deployment without monitoring configuration. Consider adding monitoring settings.'
406
+ });
407
+ }
408
+ }
409
+
410
+ // Dry-run with force is contradictory
411
+ if (config.dryRun && config.force) {
412
+ result.warnings.push({
413
+ field: 'dryRun',
414
+ code: 'CONTRADICTORY_FLAGS',
415
+ message: 'Both dryRun and force are set. dryRun simulates without changes, force skips confirmations — these are contradictory.'
416
+ });
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Warnings specific to update configs
422
+ */
423
+ _addUpdateWarnings(config, result) {
424
+ // Update without backup
425
+ if (config.migration && config.migration.runMigrations && !config.migration.backupBeforeUpdate) {
426
+ result.warnings.push({
427
+ field: 'migration.backupBeforeUpdate',
428
+ code: 'NO_BACKUP',
429
+ message: 'Running migrations without backupBeforeUpdate enabled. Consider enabling backups for safety.'
430
+ });
431
+ }
432
+ }
433
+
434
+ /**
435
+ * Describe a Zod field for human-readable output
436
+ */
437
+ _describeZodField(key, fieldSchema, schemaConfig) {
438
+ const def = {
439
+ name: key,
440
+ required: !fieldSchema.isOptional(),
441
+ type: this._getZodType(fieldSchema)
442
+ };
443
+
444
+ // Add description if available
445
+ if (fieldSchema.description) {
446
+ def.description = fieldSchema.description;
447
+ }
448
+ return def;
449
+ }
450
+
451
+ /**
452
+ * Get human-readable type from Zod schema
453
+ */
454
+ _getZodType(schema) {
455
+ if (!schema || !schema._def) return 'unknown';
456
+
457
+ // Zod v4 uses _def.type (string), Zod v3 uses _def.typeName
458
+ const typeName = schema._def.type || schema._def.typeName;
459
+ switch (typeName) {
460
+ case 'string':
461
+ case 'ZodString':
462
+ return 'string';
463
+ case 'number':
464
+ case 'ZodNumber':
465
+ return 'number';
466
+ case 'boolean':
467
+ case 'ZodBoolean':
468
+ return 'boolean';
469
+ case 'array':
470
+ case 'ZodArray':
471
+ return 'array';
472
+ case 'object':
473
+ case 'ZodObject':
474
+ return 'object';
475
+ case 'enum':
476
+ case 'ZodEnum':
477
+ {
478
+ const values = schema._def.entries || schema._def.values;
479
+ return values ? `enum(${Array.isArray(values) ? values.join(', ') : Object.keys(values).join(', ')})` : 'enum';
480
+ }
481
+ case 'optional':
482
+ case 'ZodOptional':
483
+ return this._getZodType(schema._def.innerType);
484
+ case 'default':
485
+ case 'ZodDefault':
486
+ return this._getZodType(schema._def.innerType);
487
+ case 'pipe':
488
+ case 'ZodEffects':
489
+ return this._getZodType(schema._def.in || schema._def.schema);
490
+ case 'record':
491
+ case 'ZodRecord':
492
+ return 'record';
493
+ case 'any':
494
+ case 'ZodAny':
495
+ return 'any';
496
+ default:
497
+ {
498
+ const name = String(typeName || '');
499
+ return name.replace('Zod', '').toLowerCase() || 'unknown';
500
+ }
501
+ }
502
+ }
503
+ }