@onlineapps/cookbook-core 1.0.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,569 @@
1
+ /**
2
+ * @fileoverview
3
+ * Comprehensive validator for Workflow Cookbook JSON objects.
4
+ *
5
+ * Validates:
6
+ * - Top-level structure against JSON Schema (strict: no additional properties)
7
+ * - Recursive validation of each Step object, ensuring all required fields are present and no extraneous fields
8
+ * - Unique `id` across all steps (including nested steps in foreach, fork_join, switch, sub_workflow)
9
+ * - For TaskStep and DispatchStep: `input` and `output` objects each have at least one mapping, all values are non-empty strings
10
+ * - For Retry configurations: `maxAttempts` ≥ 1, `delayMs` ≥ 0
11
+ * - For ForeachStep: `iterator` is a non-empty string, `body` is a non-empty array of valid Steps, optional `output` maps have non-empty string values
12
+ * - For ForkJoinStep: `branches` is a non-empty array of Steps, `join.strategy` must be "merge", `join.output` has at least one mapping with non-empty string values
13
+ * - For SwitchStep: `expression` is non-empty string, `cases` has ≥1 key each mapping to a valid Step, optional `default` is a valid Step
14
+ * - For SubWorkflowStep: `input` maps have non-empty string values, `steps` is a non-empty array of valid Steps
15
+ * - For WaitStep: `durationMs` is integer ≥ 0
16
+ * - For DispatchStep: `method` must be "webhook", `target` must be a valid URI, `input` has ≥1 mappings with non-empty string values, optional `retry` as per Retry schema
17
+ * - Ensures no unexpected properties exist on any step object (additionalProperties = false)
18
+ *
19
+ * Throws:
20
+ * - Error with descriptive message upon first validation failure encountered.
21
+ *
22
+ * Usage:
23
+ * const { validateCookbook } = require('./cookbookValidator');
24
+ * validateCookbook(parsedObject);
25
+ */
26
+
27
+ const Ajv = require('ajv');
28
+ const addFormats = require('ajv-formats');
29
+ const path = require('path');
30
+ const fs = require('fs');
31
+
32
+ // Load schemas dynamically
33
+ const loadSchemaFile = (filename) => {
34
+ const schemaPath = path.join(__dirname, '../../schemas', filename);
35
+ if (fs.existsSync(schemaPath)) {
36
+ return JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
37
+ }
38
+ // Fallback to default schema
39
+ return JSON.parse(fs.readFileSync(path.join(__dirname, '../../schemas/cookbook.schema.json'), 'utf-8'));
40
+ };
41
+
42
+ const cookbookSchema = loadSchemaFile('cookbook.schema.json');
43
+ const strictSchema = loadSchemaFile('cookbook.strict.schema.json');
44
+ const relaxedSchema = loadSchemaFile('cookbook.relaxed.schema.json');
45
+
46
+ // Initialize AJV instance for JSON Schema validation
47
+ // Note: strict: false is needed because our schema uses "condition" as a property name,
48
+ // which would be interpreted as an AJV keyword in strict mode
49
+ const ajv = new Ajv({ allErrors: true, strict: false, allowUnionTypes: true });
50
+ addFormats(ajv);
51
+
52
+ // Compile schemas for different modes
53
+ const validators = {
54
+ default: ajv.compile(cookbookSchema),
55
+ strict: ajv.compile(strictSchema),
56
+ relaxed: ajv.compile(relaxedSchema)
57
+ };
58
+
59
+ /**
60
+ * Custom Error class to represent validation errors in the cookbook.
61
+ */
62
+ class CookbookValidationError extends Error {
63
+ constructor(message) {
64
+ super(message);
65
+ this.name = 'CookbookValidationError';
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Validate that a given string is non-empty.
71
+ * @param {any} val - Value to check.
72
+ * @param {string} context - Description for error messages.
73
+ */
74
+ function assertNonEmptyString(val, context) {
75
+ if (typeof val !== 'string' || val.trim() === '') {
76
+ throw new CookbookValidationError(`${context} must be a non-empty string.`);
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Validate that a given object has at least one key, and every value is a non-empty string.
82
+ * @param {object} obj - Object to validate.
83
+ * @param {string} context - Description for error messages.
84
+ * @param {boolean} allowEmpty - Whether to allow empty object
85
+ */
86
+ function assertNonEmptyStringMap(obj, context, allowEmpty = false) {
87
+ if (typeof obj !== 'object' || obj === null) {
88
+ throw new CookbookValidationError(`${context} must be an object.`);
89
+ }
90
+ const keys = Object.keys(obj);
91
+ if (!allowEmpty && keys.length < 1) {
92
+ throw new CookbookValidationError(`${context} must have at least one property.`);
93
+ }
94
+ keys.forEach((k) => {
95
+ const v = obj[k];
96
+ if (typeof v !== 'string' || v.trim() === '') {
97
+ throw new CookbookValidationError(`${context}['${k}'] must be a non-empty string.`);
98
+ }
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Validate Retry configuration: object with integer maxAttempts ≥1 and integer delayMs ≥0, no extra properties.
104
+ * @param {object} retry - Retry object to validate.
105
+ * @param {string} context - Description for error messages.
106
+ */
107
+ function validateRetryConfig(retry, context) {
108
+ if (typeof retry !== 'object' || retry === null || Array.isArray(retry)) {
109
+ throw new CookbookValidationError(`${context} must be an object.`);
110
+ }
111
+ const allowedProps = ['maxAttempts', 'delayMs'];
112
+ Object.keys(retry).forEach((prop) => {
113
+ if (!allowedProps.includes(prop)) {
114
+ throw new CookbookValidationError(`${context} has unexpected property '${prop}'.`);
115
+ }
116
+ });
117
+ if (!Number.isInteger(retry.maxAttempts) || retry.maxAttempts < 1) {
118
+ throw new CookbookValidationError(`${context}.maxAttempts must be an integer ≥ 1.`);
119
+ }
120
+ if (!Number.isInteger(retry.delayMs) || retry.delayMs < 0) {
121
+ throw new CookbookValidationError(`${context}.delayMs must be an integer ≥ 0.`);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Validate a URI string using simple regex for scheme://... . Does not cover every edge case but ensures a scheme and host.
127
+ * @param {string} uri - URI string to validate.
128
+ * @param {string} context - Context for error messages.
129
+ */
130
+ function assertValidUri(uri, context) {
131
+ // Basic URI regex: scheme://... , e.g. http://example.com or https://api.service/path
132
+ const uriRegex = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/\S+$/;
133
+ if (!uriRegex.test(uri)) {
134
+ throw new CookbookValidationError(`${context} ('${uri}') is not a valid URI.`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Collects all step IDs from a nested structure of steps. Throws if duplicate IDs found.
140
+ * @param {Array} steps - Array of Step objects (may contain nested steps).
141
+ * @param {Set<string>} seenIds - Set to accumulate seen IDs.
142
+ * @param {string} path - Recursion path for error context.
143
+ */
144
+ function collectAndValidateUniqueIds(steps, seenIds, path) {
145
+ if (!Array.isArray(steps)) {
146
+ throw new CookbookValidationError(`${path} must be an array of steps.`);
147
+ }
148
+ steps.forEach((step, index) => {
149
+ const context = `${path}[${index}]`;
150
+ if (typeof step !== 'object' || step === null) {
151
+ throw new CookbookValidationError(`${context} must be an object.`);
152
+ }
153
+ // id must exist and be a string
154
+ if (!('id' in step)) {
155
+ throw new CookbookValidationError(`${context} is missing required property 'id'.`);
156
+ }
157
+ assertNonEmptyString(step.id, `${context}.id`);
158
+ if (seenIds.has(step.id)) {
159
+ throw new CookbookValidationError(`Duplicate step ID '${step.id}' found at ${context}.`);
160
+ }
161
+ seenIds.add(step.id);
162
+
163
+ // Recurse into nested steps according to type
164
+ const stepType = step.type || 'task';
165
+ switch (stepType) {
166
+ case 'foreach':
167
+ if (!Array.isArray(step.body)) {
168
+ throw new CookbookValidationError(`${context} of type 'foreach' must have 'body' as an array.`);
169
+ }
170
+ collectAndValidateUniqueIds(step.body, seenIds, `${context}.body`);
171
+ break;
172
+ case 'fork_join':
173
+ if (!Array.isArray(step.branches)) {
174
+ throw new CookbookValidationError(`${context} of type 'fork_join' must have 'branches' as an array.`);
175
+ }
176
+ collectAndValidateUniqueIds(step.branches, seenIds, `${context}.branches`);
177
+ break;
178
+ case 'switch':
179
+ // cases: object mapping keys to Step
180
+ if (typeof step.cases === 'object' && step.cases !== null) {
181
+ Object.entries(step.cases).forEach(([caseKey, caseStep]) => {
182
+ collectAndValidateUniqueIds([caseStep], seenIds, `${context}.cases['${caseKey}']`);
183
+ });
184
+ }
185
+ if ('default' in step && step.default) {
186
+ collectAndValidateUniqueIds([step.default], seenIds, `${context}.default`);
187
+ }
188
+ break;
189
+ case 'sub_workflow':
190
+ if (!Array.isArray(step.steps)) {
191
+ throw new CookbookValidationError(`${context} of type 'sub_workflow' must have 'steps' as an array.`);
192
+ }
193
+ collectAndValidateUniqueIds(step.steps, seenIds, `${context}.steps`);
194
+ break;
195
+ // task, wait, dispatch have no nested steps for id collection
196
+ case 'task':
197
+ case 'wait':
198
+ case 'dispatch':
199
+ // no nested step arrays
200
+ break;
201
+ default:
202
+ throw new CookbookValidationError(`${context}.type '${stepType}' is not recognized.`);
203
+ }
204
+ });
205
+ }
206
+
207
+ /**
208
+ * Validate a single Step object deeply according to its `type`.
209
+ * @param {object} step - The Step object to validate.
210
+ * @param {string} context - String prefix describing location in the cookbook for error context.
211
+ * @param {string} mode - Validation mode (strict, relaxed, default)
212
+ */
213
+ function validateStep(step, context, mode = 'default') {
214
+ if (typeof step !== 'object' || step === null) {
215
+ throw new CookbookValidationError(`${context} must be an object.`);
216
+ }
217
+
218
+ // Validate required properties: id is required, type is optional (defaults to 'task')
219
+ if (!('id' in step)) {
220
+ throw new CookbookValidationError(`${context} is missing required property 'id'.`);
221
+ }
222
+ assertNonEmptyString(step.id, `${context}.id`);
223
+
224
+ // Type is optional, defaults to 'task' for simple service calls
225
+ const stepType = step.type || 'task';
226
+ if (step.type) {
227
+ assertNonEmptyString(step.type, `${context}.type`);
228
+ }
229
+
230
+ // Ensure no additional properties beyond { id, type, and those allowed per type }
231
+ const commonAllowed = new Set(['id', 'type']);
232
+ let allowedProps = new Set(commonAllowed);
233
+
234
+ switch (stepType) {
235
+ case 'task': {
236
+ // Required: service (input/output now optional)
237
+ const req = mode === 'strict' ? ['service'] : ['service'];
238
+ req.forEach((prop) => {
239
+ if (!(prop in step)) {
240
+ throw new CookbookValidationError(`${context} of type 'task' is missing required property '${prop}'.`);
241
+ }
242
+ });
243
+ // Validate service
244
+ assertNonEmptyString(step.service, `${context}.service`);
245
+
246
+ // Validate input and output maps - now optional in updated schema
247
+ if ('input' in step) {
248
+ assertNonEmptyStringMap(step.input, `${context}.input`, true);
249
+ }
250
+ if ('output' in step) {
251
+ assertNonEmptyStringMap(step.output, `${context}.output`, true);
252
+ }
253
+
254
+ // Optional properties
255
+ if ('name' in step) {
256
+ assertNonEmptyString(step.name, `${context}.name`);
257
+ }
258
+ if ('action' in step) {
259
+ const validActions = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
260
+ if (!validActions.includes(step.action)) {
261
+ throw new CookbookValidationError(`${context}.action must be one of: ${validActions.join(', ')}`);
262
+ }
263
+ }
264
+ if ('endpoint' in step) {
265
+ assertNonEmptyString(step.endpoint, `${context}.endpoint`);
266
+ }
267
+ if ('depends_on' in step) {
268
+ if (!Array.isArray(step.depends_on)) {
269
+ throw new CookbookValidationError(`${context}.depends_on must be an array.`);
270
+ }
271
+ step.depends_on.forEach((dep, idx) => {
272
+ assertNonEmptyString(dep, `${context}.depends_on[${idx}]`);
273
+ });
274
+ }
275
+ if ('retry' in step) {
276
+ validateRetryConfig(step.retry, `${context}.retry`);
277
+ }
278
+ if ('timeoutMs' in step) {
279
+ if (!Number.isInteger(step.timeoutMs) || step.timeoutMs < 1) {
280
+ throw new CookbookValidationError(`${context}.timeoutMs must be an integer ≥ 1.`);
281
+ }
282
+ }
283
+
284
+ allowedProps = new Set([...commonAllowed, 'name', 'service', 'action', 'endpoint', 'input', 'output', 'depends_on', 'retry', 'timeoutMs']);
285
+ break;
286
+ }
287
+ case 'foreach': {
288
+ // Required: iterator, body
289
+ const req = ['iterator', 'body'];
290
+ req.forEach((prop) => {
291
+ if (!(prop in step)) {
292
+ throw new CookbookValidationError(`${context} of type 'foreach' is missing required property '${prop}'.`);
293
+ }
294
+ });
295
+ assertNonEmptyString(step.iterator, `${context}.iterator`);
296
+
297
+ if (!Array.isArray(step.body) || step.body.length < 1) {
298
+ throw new CookbookValidationError(`${context}.body must be a non-empty array of Step objects.`);
299
+ }
300
+ // Recursively validate each nested step in body
301
+ step.body.forEach((childStep, idx) => {
302
+ validateStep(childStep, `${context}.body[${idx}]`, mode);
303
+ });
304
+
305
+ // Optional properties
306
+ if ('name' in step) {
307
+ assertNonEmptyString(step.name, `${context}.name`);
308
+ }
309
+ if ('output' in step) {
310
+ assertNonEmptyStringMap(step.output, `${context}.output`);
311
+ }
312
+
313
+ allowedProps = new Set([...commonAllowed, 'name', 'iterator', 'body', 'output']);
314
+ break;
315
+ }
316
+ case 'fork_join': {
317
+ // Required: branches, join
318
+ const req = ['branches', 'join'];
319
+ req.forEach((prop) => {
320
+ if (!(prop in step)) {
321
+ throw new CookbookValidationError(`${context} of type 'fork_join' is missing required property '${prop}'.`);
322
+ }
323
+ });
324
+ if (!Array.isArray(step.branches) || step.branches.length < 1) {
325
+ throw new CookbookValidationError(`${context}.branches must be a non-empty array of Step objects.`);
326
+ }
327
+ // Recursively validate each branch
328
+ step.branches.forEach((branchStep, idx) => {
329
+ validateStep(branchStep, `${context}.branches[${idx}]`, mode);
330
+ });
331
+
332
+ const join = step.join;
333
+ if (typeof join !== 'object' || join === null) {
334
+ throw new CookbookValidationError(`${context}.join must be an object.`);
335
+ }
336
+ // Validate join.strategy
337
+ const validStrategies = ['merge', 'first', 'last', 'all', 'custom'];
338
+ if (!validStrategies.includes(join.strategy)) {
339
+ throw new CookbookValidationError(`${context}.join.strategy must be one of: ${validStrategies.join(', ')}`);
340
+ }
341
+ // Validate join.output if present (now optional)
342
+ if ('output' in join) {
343
+ assertNonEmptyStringMap(join.output, `${context}.join.output`, true);
344
+ }
345
+
346
+ // Ensure no additional props on join
347
+ const joinAllowed = new Set(['strategy', 'output']);
348
+ Object.keys(join).forEach((prop) => {
349
+ if (!joinAllowed.has(prop)) {
350
+ throw new CookbookValidationError(`${context}.join has unexpected property '${prop}'.`);
351
+ }
352
+ });
353
+
354
+ // Optional properties
355
+ if ('name' in step) {
356
+ assertNonEmptyString(step.name, `${context}.name`);
357
+ }
358
+
359
+ allowedProps = new Set([...commonAllowed, 'name', 'branches', 'join']);
360
+ break;
361
+ }
362
+ case 'switch': {
363
+ // Required: expression, cases, and default in strict mode
364
+ const req = mode === 'strict' ? ['expression', 'cases', 'default'] : ['expression', 'cases'];
365
+ req.forEach((prop) => {
366
+ if (!(prop in step)) {
367
+ throw new CookbookValidationError(`${context} of type 'switch' is missing required property '${prop}'.`);
368
+ }
369
+ });
370
+ assertNonEmptyString(step.expression, `${context}.expression`);
371
+
372
+ if (typeof step.cases !== 'object' || step.cases === null) {
373
+ throw new CookbookValidationError(`${context}.cases must be an object with ≥1 key.`);
374
+ }
375
+ const caseKeys = Object.keys(step.cases);
376
+ if (caseKeys.length < 1) {
377
+ throw new CookbookValidationError(`${context}.cases must have at least one case.`);
378
+ }
379
+ // Validate each case's Step
380
+ caseKeys.forEach((caseKey) => {
381
+ const caseStep = step.cases[caseKey];
382
+ validateStep(caseStep, `${context}.cases['${caseKey}']`, mode);
383
+ });
384
+
385
+ // Optional properties
386
+ if ('name' in step) {
387
+ assertNonEmptyString(step.name, `${context}.name`);
388
+ }
389
+ if ('depends_on' in step) {
390
+ if (!Array.isArray(step.depends_on)) {
391
+ throw new CookbookValidationError(`${context}.depends_on must be an array.`);
392
+ }
393
+ step.depends_on.forEach((dep, idx) => {
394
+ assertNonEmptyString(dep, `${context}.depends_on[${idx}]`);
395
+ });
396
+ }
397
+ if ('default' in step) {
398
+ validateStep(step.default, `${context}.default`, mode);
399
+ } else if (mode === 'strict') {
400
+ throw new CookbookValidationError(`${context} of type 'switch' in strict mode must have a 'default' case.`);
401
+ }
402
+
403
+ allowedProps = new Set([...commonAllowed, 'name', 'expression', 'cases', 'default', 'depends_on']);
404
+ break;
405
+ }
406
+ case 'sub_workflow': {
407
+ // Required: steps (input now optional)
408
+ const req = ['steps'];
409
+ req.forEach((prop) => {
410
+ if (!(prop in step)) {
411
+ throw new CookbookValidationError(`${context} of type 'sub_workflow' is missing required property '${prop}'.`);
412
+ }
413
+ });
414
+ // Validate input map if present
415
+ if ('input' in step) {
416
+ assertNonEmptyStringMap(step.input, `${context}.input`, true);
417
+ }
418
+
419
+ // Validate nested steps
420
+ if (!Array.isArray(step.steps) || step.steps.length < 1) {
421
+ throw new CookbookValidationError(`${context}.steps must be a non-empty array of Step objects.`);
422
+ }
423
+ step.steps.forEach((childStep, idx) => {
424
+ validateStep(childStep, `${context}.steps[${idx}]`, mode);
425
+ });
426
+
427
+ // Optional properties
428
+ if ('name' in step) {
429
+ assertNonEmptyString(step.name, `${context}.name`);
430
+ }
431
+
432
+ allowedProps = new Set([...commonAllowed, 'name', 'input', 'steps']);
433
+ break;
434
+ }
435
+ case 'wait': {
436
+ // Required: durationMs
437
+ if (!('durationMs' in step)) {
438
+ throw new CookbookValidationError(`${context} of type 'wait' is missing required property 'durationMs'.`);
439
+ }
440
+ if (!Number.isInteger(step.durationMs) || step.durationMs < 0) {
441
+ throw new CookbookValidationError(`${context}.durationMs must be an integer ≥ 0.`);
442
+ }
443
+ // Optional properties
444
+ if ('name' in step) {
445
+ assertNonEmptyString(step.name, `${context}.name`);
446
+ }
447
+ allowedProps = new Set([...commonAllowed, 'name', 'durationMs']);
448
+ break;
449
+ }
450
+ case 'dispatch': {
451
+ // Required: method, target (input now optional)
452
+ const req = ['method', 'target'];
453
+ req.forEach((prop) => {
454
+ if (!(prop in step)) {
455
+ throw new CookbookValidationError(`${context} of type 'dispatch' is missing required property '${prop}'.`);
456
+ }
457
+ });
458
+ // method must be valid
459
+ const validMethods = ['webhook', 'http', 'grpc', 'kafka', 'sqs', 'custom'];
460
+ if (!validMethods.includes(step.method)) {
461
+ throw new CookbookValidationError(`${context}.method must be one of: ${validMethods.join(', ')}`);
462
+ }
463
+ // target must be valid URI
464
+ assertValidUri(step.target, `${context}.target`);
465
+ // input map if present
466
+ if ('input' in step) {
467
+ assertNonEmptyStringMap(step.input, `${context}.input`, true);
468
+ }
469
+ // Optional: retry
470
+ if ('retry' in step) {
471
+ validateRetryConfig(step.retry, `${context}.retry`);
472
+ }
473
+ // Optional properties
474
+ if ('name' in step) {
475
+ assertNonEmptyString(step.name, `${context}.name`);
476
+ }
477
+
478
+ allowedProps = new Set([...commonAllowed, 'name', 'method', 'target', 'input', 'retry']);
479
+ break;
480
+ }
481
+ default:
482
+ throw new CookbookValidationError(`${context}.type '${stepType}' is not a supported step type.`);
483
+ }
484
+
485
+ // Finally, ensure no additional unexpected properties on this step object
486
+ Object.keys(step).forEach((prop) => {
487
+ if (!allowedProps.has(prop)) {
488
+ throw new CookbookValidationError(`${context} has unexpected property '${prop}'.`);
489
+ }
490
+ });
491
+ }
492
+
493
+ /**
494
+ * Main entrypoint: Validate an entire workbook JSON object.
495
+ * Throws CookbookValidationError on first error encountered.
496
+ * @param {object} cookbook - Parsed JSON object for the cookbook.
497
+ * @param {object} options - Validation options
498
+ * @param {string} options.mode - Validation mode: 'strict', 'relaxed', or 'default'
499
+ * @param {string} options.schemaVersion - Schema version to use (for migration)
500
+ * @param {boolean} options.warnOnly - If true, log warnings instead of throwing
501
+ */
502
+ function validateCookbook(cookbook, options = {}) {
503
+ const mode = options.mode || 'default';
504
+ const warnOnly = options.warnOnly || false;
505
+ // Top-level type must be object
506
+ if (typeof cookbook !== 'object' || cookbook === null || Array.isArray(cookbook)) {
507
+ throw new CookbookValidationError(`Cookbook must be a JSON object.`);
508
+ }
509
+
510
+ // Select validator based on mode
511
+ const validateSchema = validators[mode] || validators.default;
512
+
513
+ // Validate against JSON Schema (structure, required top-level props, basic types)
514
+ const valid = validateSchema(cookbook);
515
+ if (!valid) {
516
+ // Collect all errors for better debugging
517
+ const errors = validateSchema.errors.map(err => {
518
+ return `${err.instancePath || 'cookbook'} ${err.message}`;
519
+ });
520
+ const message = `Schema validation errors (${mode} mode):\n${errors.join('\n')}`;
521
+
522
+ if (warnOnly) {
523
+ console.warn(`WARNING: ${message}`);
524
+ } else {
525
+ throw new CookbookValidationError(message);
526
+ }
527
+ }
528
+
529
+ // Collect and validate unique IDs across all nested steps
530
+ const seenIds = new Set();
531
+ collectAndValidateUniqueIds(cookbook.steps, seenIds, 'steps');
532
+
533
+ // Validate each top-level Step deeply (skip in relaxed mode)
534
+ if (mode !== 'relaxed') {
535
+ cookbook.steps.forEach((step, idx) => {
536
+ try {
537
+ validateStep(step, `steps[${idx}]`, mode);
538
+ } catch (err) {
539
+ if (warnOnly) {
540
+ console.warn(`WARNING: ${err.message}`);
541
+ } else {
542
+ throw err;
543
+ }
544
+ }
545
+ });
546
+ }
547
+
548
+ // Validate references if not in relaxed mode
549
+ if (mode !== 'relaxed' && options.validateReferences !== false) {
550
+ try {
551
+ const { validateAllReferences } = require('../referenceValidator');
552
+ validateAllReferences(cookbook);
553
+ } catch (err) {
554
+ if (warnOnly) {
555
+ console.warn(`WARNING: Reference validation error: ${err.message}`);
556
+ } else {
557
+ throw new CookbookValidationError(`Reference validation error: ${err.message}`);
558
+ }
559
+ }
560
+ }
561
+
562
+ // If we reach this point, the cookbook is valid
563
+ }
564
+
565
+ module.exports = {
566
+ validateCookbook,
567
+ validateStep,
568
+ CookbookValidationError,
569
+ };
package/src/parser.js ADDED
@@ -0,0 +1,7 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Re-export parser functions from the parser module
5
+ */
6
+
7
+ module.exports = require('./parser/cookbookParser');