@onlineapps/cookbook-core 2.1.9 → 2.1.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onlineapps/cookbook-core",
3
- "version": "2.1.9",
3
+ "version": "2.1.10",
4
4
  "description": "Core cookbook parsing and validation - lightweight foundation for workflow definitions",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview
5
+ * Cookbook 2.1 Helper Functions
6
+ *
7
+ * STRICT RULES:
8
+ * - steps is ALWAYS an array
9
+ * - Access by index is FORBIDDEN - use findStep(stepId) instead
10
+ * - Reference steps ONLY by step_id
11
+ * - Iteration via forEach/map/filter ONLY
12
+ *
13
+ * These helpers enforce safe access patterns for cookbook steps.
14
+ */
15
+
16
+ /**
17
+ * CookbookHelpers - Safe access to cookbook steps
18
+ *
19
+ * Usage:
20
+ * const { findStep, forEachStep, getStepIds } = require('@onlineapps/cookbook-core');
21
+ *
22
+ * // Find step by step_id
23
+ * const step = findStep(cookbook.steps, 'my-step-id');
24
+ *
25
+ * // Iterate all steps
26
+ * forEachStep(cookbook.steps, (step, stepId) => { ... });
27
+ *
28
+ * // Get all step IDs in order
29
+ * const ids = getStepIds(cookbook.steps);
30
+ */
31
+ class CookbookHelpers {
32
+ /**
33
+ * Find a step by its step_id
34
+ * This is the PRIMARY method for accessing steps - NEVER use index!
35
+ *
36
+ * @param {Array} steps - Array of step objects
37
+ * @param {string} stepId - The step_id to find
38
+ * @returns {Object|null} The step object or null if not found
39
+ * @throws {Error} If steps is not an array or stepId is not provided
40
+ */
41
+ static findStep(steps, stepId) {
42
+ if (!stepId) {
43
+ throw new Error('[CookbookHelpers.findStep] stepId is required - never access by index!');
44
+ }
45
+
46
+ if (!Array.isArray(steps)) {
47
+ // Handle V2.0 object format for backwards compatibility during transition
48
+ if (steps && typeof steps === 'object') {
49
+ return steps[stepId] || null;
50
+ }
51
+ throw new Error('[CookbookHelpers.findStep] steps must be an array (V2.1 format)');
52
+ }
53
+
54
+ return steps.find(step => step.step_id === stepId) || null;
55
+ }
56
+
57
+ /**
58
+ * Iterate over all steps with their step_ids
59
+ *
60
+ * @param {Array} steps - Array of step objects
61
+ * @param {Function} callback - Function(step, stepId, index) - index for internal use only!
62
+ * @throws {Error} If steps is not an array
63
+ */
64
+ static forEachStep(steps, callback) {
65
+ if (!Array.isArray(steps)) {
66
+ // Handle V2.0 object format for backwards compatibility during transition
67
+ if (steps && typeof steps === 'object') {
68
+ Object.entries(steps).forEach(([stepId, step], index) => {
69
+ callback(step, stepId, index);
70
+ });
71
+ return;
72
+ }
73
+ throw new Error('[CookbookHelpers.forEachStep] steps must be an array (V2.1 format)');
74
+ }
75
+
76
+ steps.forEach((step, index) => {
77
+ callback(step, step.step_id, index);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Map over all steps
83
+ *
84
+ * @param {Array} steps - Array of step objects
85
+ * @param {Function} callback - Function(step, stepId) => newValue
86
+ * @returns {Array} Mapped values
87
+ * @throws {Error} If steps is not an array
88
+ */
89
+ static mapSteps(steps, callback) {
90
+ if (!Array.isArray(steps)) {
91
+ // Handle V2.0 object format for backwards compatibility during transition
92
+ if (steps && typeof steps === 'object') {
93
+ return Object.entries(steps).map(([stepId, step]) => callback(step, stepId));
94
+ }
95
+ throw new Error('[CookbookHelpers.mapSteps] steps must be an array (V2.1 format)');
96
+ }
97
+
98
+ return steps.map(step => callback(step, step.step_id));
99
+ }
100
+
101
+ /**
102
+ * Get all step IDs in their defined order
103
+ *
104
+ * @param {Array} steps - Array of step objects
105
+ * @returns {Array<string>} Array of step_ids in order
106
+ * @throws {Error} If steps is not an array
107
+ */
108
+ static getStepIds(steps) {
109
+ if (!Array.isArray(steps)) {
110
+ // Handle V2.0 object format for backwards compatibility during transition
111
+ if (steps && typeof steps === 'object') {
112
+ return Object.keys(steps);
113
+ }
114
+ throw new Error('[CookbookHelpers.getStepIds] steps must be an array (V2.1 format)');
115
+ }
116
+
117
+ return steps.map(step => step.step_id);
118
+ }
119
+
120
+ /**
121
+ * Get the first step (for workflow initiation)
122
+ *
123
+ * @param {Array} steps - Array of step objects
124
+ * @returns {Object|null} The first step or null if empty
125
+ * @throws {Error} If steps is not an array
126
+ */
127
+ static getFirstStep(steps) {
128
+ if (!Array.isArray(steps)) {
129
+ // Handle V2.0 object format for backwards compatibility during transition
130
+ if (steps && typeof steps === 'object') {
131
+ const keys = Object.keys(steps);
132
+ return keys.length > 0 ? steps[keys[0]] : null;
133
+ }
134
+ throw new Error('[CookbookHelpers.getFirstStep] steps must be an array (V2.1 format)');
135
+ }
136
+
137
+ return steps.length > 0 ? steps[0] : null;
138
+ }
139
+
140
+ /**
141
+ * Get the next step after a given step_id
142
+ *
143
+ * @param {Array} steps - Array of step objects
144
+ * @param {string} currentStepId - The current step_id
145
+ * @returns {Object|null} The next step or null if last/not found
146
+ * @throws {Error} If steps is not an array
147
+ */
148
+ static getNextStep(steps, currentStepId) {
149
+ if (!Array.isArray(steps)) {
150
+ // Handle V2.0 object format for backwards compatibility during transition
151
+ if (steps && typeof steps === 'object') {
152
+ const keys = Object.keys(steps);
153
+ const currentIndex = keys.indexOf(currentStepId);
154
+ if (currentIndex === -1 || currentIndex >= keys.length - 1) {
155
+ return null;
156
+ }
157
+ return steps[keys[currentIndex + 1]];
158
+ }
159
+ throw new Error('[CookbookHelpers.getNextStep] steps must be an array (V2.1 format)');
160
+ }
161
+
162
+ const currentIndex = steps.findIndex(step => step.step_id === currentStepId);
163
+ if (currentIndex === -1 || currentIndex >= steps.length - 1) {
164
+ return null;
165
+ }
166
+ return steps[currentIndex + 1];
167
+ }
168
+
169
+ /**
170
+ * Get total step count
171
+ *
172
+ * @param {Array} steps - Array of step objects
173
+ * @returns {number} Number of steps
174
+ */
175
+ static getStepCount(steps) {
176
+ if (!Array.isArray(steps)) {
177
+ // Handle V2.0 object format for backwards compatibility during transition
178
+ if (steps && typeof steps === 'object') {
179
+ return Object.keys(steps).length;
180
+ }
181
+ return 0;
182
+ }
183
+
184
+ return steps.length;
185
+ }
186
+
187
+ /**
188
+ * Check if a step_id exists
189
+ *
190
+ * @param {Array} steps - Array of step objects
191
+ * @param {string} stepId - The step_id to check
192
+ * @returns {boolean} True if step exists
193
+ */
194
+ static hasStep(steps, stepId) {
195
+ return this.findStep(steps, stepId) !== null;
196
+ }
197
+
198
+ /**
199
+ * Filter steps by a predicate
200
+ *
201
+ * @param {Array} steps - Array of step objects
202
+ * @param {Function} predicate - Function(step, stepId) => boolean
203
+ * @returns {Array} Filtered steps
204
+ */
205
+ static filterSteps(steps, predicate) {
206
+ if (!Array.isArray(steps)) {
207
+ // Handle V2.0 object format for backwards compatibility during transition
208
+ if (steps && typeof steps === 'object') {
209
+ return Object.entries(steps)
210
+ .filter(([stepId, step]) => predicate(step, stepId))
211
+ .map(([_, step]) => step);
212
+ }
213
+ throw new Error('[CookbookHelpers.filterSteps] steps must be an array (V2.1 format)');
214
+ }
215
+
216
+ return steps.filter(step => predicate(step, step.step_id));
217
+ }
218
+
219
+ /**
220
+ * Check if steps format is V2.1 (array)
221
+ *
222
+ * @param {any} steps - Steps to check
223
+ * @returns {boolean} True if V2.1 array format
224
+ */
225
+ static isV21Format(steps) {
226
+ return Array.isArray(steps);
227
+ }
228
+
229
+ /**
230
+ * Check if steps format is V2.0 (object)
231
+ *
232
+ * @param {any} steps - Steps to check
233
+ * @returns {boolean} True if V2.0 object format
234
+ */
235
+ static isV20Format(steps) {
236
+ return steps && typeof steps === 'object' && !Array.isArray(steps);
237
+ }
238
+
239
+ /**
240
+ * Convert V2.0 object format to V2.1 array format
241
+ *
242
+ * @param {Object} stepsObject - V2.0 steps object
243
+ * @returns {Array} V2.1 steps array
244
+ */
245
+ static convertV20ToV21(stepsObject) {
246
+ if (Array.isArray(stepsObject)) {
247
+ return stepsObject; // Already V2.1
248
+ }
249
+
250
+ if (!stepsObject || typeof stepsObject !== 'object') {
251
+ return [];
252
+ }
253
+
254
+ return Object.entries(stepsObject).map(([stepId, step]) => ({
255
+ ...step,
256
+ step_id: step.step_id || stepId // Ensure step_id is set
257
+ }));
258
+ }
259
+ }
260
+
261
+ // Export both class and individual functions for convenience
262
+ module.exports = {
263
+ CookbookHelpers,
264
+ findStep: CookbookHelpers.findStep.bind(CookbookHelpers),
265
+ forEachStep: CookbookHelpers.forEachStep.bind(CookbookHelpers),
266
+ mapSteps: CookbookHelpers.mapSteps.bind(CookbookHelpers),
267
+ getStepIds: CookbookHelpers.getStepIds.bind(CookbookHelpers),
268
+ getFirstStep: CookbookHelpers.getFirstStep.bind(CookbookHelpers),
269
+ getNextStep: CookbookHelpers.getNextStep.bind(CookbookHelpers),
270
+ getStepCount: CookbookHelpers.getStepCount.bind(CookbookHelpers),
271
+ hasStep: CookbookHelpers.hasStep.bind(CookbookHelpers),
272
+ filterSteps: CookbookHelpers.filterSteps.bind(CookbookHelpers),
273
+ isV21Format: CookbookHelpers.isV21Format.bind(CookbookHelpers),
274
+ isV20Format: CookbookHelpers.isV20Format.bind(CookbookHelpers),
275
+ convertV20ToV21: CookbookHelpers.convertV20ToV21.bind(CookbookHelpers)
276
+ };
package/src/index.js CHANGED
@@ -5,6 +5,11 @@
5
5
  *
6
6
  * Core cookbook parsing and validation library.
7
7
  * Lightweight foundation for workflow definitions - no heavy dependencies.
8
+ *
9
+ * V2.1 FORMAT:
10
+ * - steps is an ARRAY (guaranteed order)
11
+ * - Access via step_id ONLY (use findStep, forEachStep)
12
+ * - Index access is FORBIDDEN
8
13
  */
9
14
 
10
15
  const {
@@ -33,6 +38,23 @@ const {
33
38
  validateErrorHandlerReferences
34
39
  } = require('./referenceValidator');
35
40
 
41
+ // V2.1 Helper functions for safe step access
42
+ const {
43
+ CookbookHelpers,
44
+ findStep,
45
+ forEachStep,
46
+ mapSteps,
47
+ getStepIds,
48
+ getFirstStep,
49
+ getNextStep,
50
+ getStepCount,
51
+ hasStep,
52
+ filterSteps,
53
+ isV21Format,
54
+ isV20Format,
55
+ convertV20ToV21
56
+ } = require('./CookbookHelpers');
57
+
36
58
  module.exports = {
37
59
  // Parser functions
38
60
  parseCookbookFromFile,
@@ -58,7 +80,22 @@ module.exports = {
58
80
  // Error classes
59
81
  CookbookValidationError,
60
82
 
83
+ // V2.1 Helper class and functions
84
+ CookbookHelpers,
85
+ findStep,
86
+ forEachStep,
87
+ mapSteps,
88
+ getStepIds,
89
+ getFirstStep,
90
+ getNextStep,
91
+ getStepCount,
92
+ hasStep,
93
+ filterSteps,
94
+ isV21Format,
95
+ isV20Format,
96
+ convertV20ToV21,
97
+
61
98
  // Utility exports
62
- VERSION: '2.0.0',
99
+ VERSION: '2.1.0',
63
100
  SCHEMA_VERSION: '2.1.0'
64
101
  };
@@ -29,12 +29,21 @@ class CookbookValidationError extends Error {
29
29
  }
30
30
 
31
31
  /**
32
- * Load V2 schema - V1 IS BANNED, only one schema exists
32
+ * Load V2.1 schema - V1 and V2.0 are BANNED
33
33
  */
34
- function loadSchema(version = '2.0') {
34
+ function loadSchema(version = '2.1') {
35
35
  // V1 is BANNED - reject immediately
36
36
  if (version.startsWith('1.')) {
37
- throw new CookbookValidationError('V1 FORMAT BANNED! Use version 2.x.x');
37
+ throw new CookbookValidationError(
38
+ 'FAIL-FAST: V1 FORMAT BANNED! Use version 2.1.x with array steps.'
39
+ );
40
+ }
41
+
42
+ // V2.0 is BANNED - reject immediately
43
+ if (version === '2.0') {
44
+ throw new CookbookValidationError(
45
+ 'FAIL-FAST: V2.0 FORMAT BANNED! Use version 2.1.x with array steps (not object).'
46
+ );
38
47
  }
39
48
 
40
49
  const schemaPath = path.join(__dirname, '../../schemas/cookbook.v2.schema.json');
@@ -113,30 +122,39 @@ function formatErrors(errors) {
113
122
  }
114
123
 
115
124
  /**
116
- * Detect cookbook version and suggest migration if needed
125
+ * Detect cookbook version
126
+ *
127
+ * V1.x - BANNED (array steps with 'id')
128
+ * V2.0 - BANNED (object steps keyed by step_id)
129
+ * V2.1 - REQUIRED (array steps with 'step_id')
117
130
  */
118
131
  function detectVersion(cookbook) {
119
132
  // Check version field first
120
133
  if (cookbook.version) {
121
- const major = cookbook.version.split('.')[0];
122
- if (major === '1') return '1.0';
123
- if (major === '2') return '2.0';
134
+ const [major, minor] = cookbook.version.split('.').map(Number);
135
+ if (major === 1) return '1.0';
136
+ if (major === 2 && minor >= 1) return '2.1';
137
+ if (major === 2) return '2.0';
124
138
  }
125
139
 
126
- // Auto-detect based on field names
127
- const hasV1Fields = cookbook.steps && cookbook.steps.some(step =>
128
- 'id' in step && !('step_id' in step)
129
- );
130
-
131
- const hasV2Fields = cookbook.steps && cookbook.steps.some(step =>
132
- 'step_id' in step && !('id' in step)
133
- );
134
-
135
- if (hasV1Fields) return '1.0';
136
- if (hasV2Fields) return '2.0';
140
+ // Auto-detect based on steps format
141
+ if (cookbook.steps) {
142
+ // V2.1: steps is array with step_id
143
+ if (Array.isArray(cookbook.steps)) {
144
+ const hasStepId = cookbook.steps.length === 0 || cookbook.steps.every(s => s.step_id);
145
+ const hasId = cookbook.steps.some(s => s.id && !s.step_id);
146
+
147
+ if (hasId) return '1.0'; // V1 format (array with 'id')
148
+ if (hasStepId) return '2.1'; // V2.1 format (array with 'step_id')
149
+ }
150
+ // V2.0: steps is object
151
+ if (typeof cookbook.steps === 'object' && !Array.isArray(cookbook.steps)) {
152
+ return '2.0';
153
+ }
154
+ }
137
155
 
138
- // Default to v2.0 for new cookbooks
139
- return '2.0';
156
+ // Default to v2.1 for new cookbooks
157
+ return '2.1';
140
158
  }
141
159
 
142
160
  /**
@@ -179,6 +197,8 @@ function checkMigration(cookbook, detectedVersion) {
179
197
  /**
180
198
  * Main validation function
181
199
  *
200
+ * V2.1 ONLY - V1 and V2.0 are BANNED
201
+ *
182
202
  * @param {object} cookbook - The cookbook object to validate
183
203
  * @param {object} options - Validation options
184
204
  * @param {string} options.version - Force specific version validation
@@ -196,11 +216,34 @@ function validateCookbook(cookbook, options = {}) {
196
216
  // Detect version if not specified
197
217
  const cookbookVersion = version || detectVersion(cookbook);
198
218
 
199
- // Check if migration needed
219
+ // STRICT: Reject V1 and V2.0
220
+ if (cookbookVersion === '1.0') {
221
+ throw new CookbookValidationError(
222
+ 'FAIL-FAST: V1 format (1.x.x) is BANNED. ' +
223
+ 'Migrate to V2.1 format:\n' +
224
+ ' - Change version to "2.1.0"\n' +
225
+ ' - Keep steps as array\n' +
226
+ ' - Rename "id" to "step_id" in each step\n' +
227
+ ' - Access steps via findStep(stepId), not by index'
228
+ );
229
+ }
230
+
231
+ if (cookbookVersion === '2.0') {
232
+ throw new CookbookValidationError(
233
+ 'FAIL-FAST: V2.0 format (object steps) is BANNED. ' +
234
+ 'Migrate to V2.1 format:\n' +
235
+ ' - Change version to "2.1.0"\n' +
236
+ ' - Convert steps from object to array\n' +
237
+ ' - Each step must have step_id property\n' +
238
+ ' - Access steps via findStep(stepId), not by key'
239
+ );
240
+ }
241
+
242
+ // Check if migration needed (for warnings)
200
243
  checkMigration(cookbook, cookbookVersion);
201
244
 
202
- // Load appropriate schema
203
- const schema = loadSchema(cookbookVersion);
245
+ // Load V2.1 schema
246
+ const schema = loadSchema('2.1');
204
247
 
205
248
  // Create validator
206
249
  const validate = createValidator(schema);
@@ -222,7 +265,7 @@ function validateCookbook(cookbook, options = {}) {
222
265
  }
223
266
  }
224
267
 
225
- // Additional semantic validation (still using schema as source of truth)
268
+ // Additional semantic validation
226
269
  validateSemantics(cookbook, cookbookVersion);
227
270
 
228
271
  return true;
@@ -230,66 +273,108 @@ function validateCookbook(cookbook, options = {}) {
230
273
 
231
274
  /**
232
275
  * Semantic validation for things JSON Schema can't easily express
276
+ *
277
+ * V2.1 REQUIREMENTS:
278
+ * - steps MUST be an array
279
+ * - Each step MUST have step_id
280
+ * - No index-based access allowed
233
281
  */
234
282
  function validateSemantics(cookbook, version) {
235
283
  const stepIds = new Set();
236
284
 
237
- // Check for duplicate step_ids (or ids in v1)
238
- const idField = version === '1.0' ? 'id' : 'step_id';
239
-
240
285
  function checkStep(step, path) {
241
- const stepId = step[idField];
242
- if (stepId) {
243
- if (stepIds.has(stepId)) {
244
- throw new CookbookValidationError(
245
- `Duplicate ${idField} '${stepId}' found at ${path}`
246
- );
247
- }
248
- stepIds.add(stepId);
249
- }
250
-
251
- // Check nested steps - V2: all nested steps are objects keyed by step_id
252
- if (step.steps && typeof step.steps === 'object') {
253
- Object.entries(step.steps).forEach(([nestedKey, nested]) =>
254
- checkStep(nested, `${path}.steps.${nestedKey}`)
286
+ // V2.1: step_id is REQUIRED
287
+ const stepId = step.step_id;
288
+ if (!stepId) {
289
+ throw new CookbookValidationError(
290
+ `Missing required 'step_id' at ${path}. V2.1 requires step_id for all steps.`
255
291
  );
256
292
  }
257
- if (step.body && typeof step.body === 'object') {
258
- Object.entries(step.body).forEach(([nestedKey, nested]) =>
259
- checkStep(nested, `${path}.body.${nestedKey}`)
293
+
294
+ if (stepIds.has(stepId)) {
295
+ throw new CookbookValidationError(
296
+ `Duplicate step_id '${stepId}' found at ${path}`
260
297
  );
261
298
  }
262
- if (step.branches && typeof step.branches === 'object') {
263
- Object.entries(step.branches).forEach(([branchKey, branch]) => {
264
- checkStep(branch, `${path}.branches.${branchKey}`);
265
- });
299
+ stepIds.add(stepId);
300
+
301
+ // Check nested steps - V2.1: nested steps are also arrays
302
+ if (step.steps) {
303
+ if (Array.isArray(step.steps)) {
304
+ step.steps.forEach((nested, idx) =>
305
+ checkStep(nested, `${path}.steps[${idx}]`)
306
+ );
307
+ } else if (typeof step.steps === 'object') {
308
+ // Allow object format for nested steps (backwards compat during transition)
309
+ Object.entries(step.steps).forEach(([nestedKey, nested]) =>
310
+ checkStep(nested, `${path}.steps.${nestedKey}`)
311
+ );
312
+ }
313
+ }
314
+
315
+ if (step.body) {
316
+ if (Array.isArray(step.body)) {
317
+ step.body.forEach((nested, idx) =>
318
+ checkStep(nested, `${path}.body[${idx}]`)
319
+ );
320
+ } else if (typeof step.body === 'object') {
321
+ Object.entries(step.body).forEach(([nestedKey, nested]) =>
322
+ checkStep(nested, `${path}.body.${nestedKey}`)
323
+ );
324
+ }
266
325
  }
326
+
327
+ if (step.branches) {
328
+ if (Array.isArray(step.branches)) {
329
+ step.branches.forEach((branch, idx) => {
330
+ if (branch.steps) {
331
+ if (Array.isArray(branch.steps)) {
332
+ branch.steps.forEach((nested, nidx) =>
333
+ checkStep(nested, `${path}.branches[${idx}].steps[${nidx}]`)
334
+ );
335
+ }
336
+ }
337
+ });
338
+ } else if (typeof step.branches === 'object') {
339
+ Object.entries(step.branches).forEach(([branchKey, branch]) => {
340
+ checkStep(branch, `${path}.branches.${branchKey}`);
341
+ });
342
+ }
343
+ }
344
+
267
345
  if (step.cases) {
268
346
  Object.keys(step.cases).forEach(caseKey => {
269
347
  const caseVal = step.cases[caseKey];
270
- checkStep(caseVal, `${path}.cases.${caseKey}`);
348
+ if (caseVal && typeof caseVal === 'object') {
349
+ checkStep(caseVal, `${path}.cases.${caseKey}`);
350
+ }
271
351
  });
272
352
  }
273
353
  }
274
354
 
275
- // Check all steps - V2: steps is an OBJECT keyed by step_id, NOT an array
355
+ // V2.1: steps MUST be an array
276
356
  if (cookbook.steps) {
277
- if (Array.isArray(cookbook.steps)) {
278
- throw new CookbookValidationError('V1 FORMAT BANNED! steps must be an object keyed by step_id, not an array');
357
+ if (!Array.isArray(cookbook.steps)) {
358
+ throw new CookbookValidationError(
359
+ 'FAIL-FAST: V2.0 format (object steps) is DEPRECATED. ' +
360
+ 'Use V2.1 format with steps as an ARRAY. ' +
361
+ 'Each step must have step_id. Access steps via findStep(stepId), not by index.'
362
+ );
279
363
  }
280
- Object.entries(cookbook.steps).forEach(([stepKey, step]) =>
281
- checkStep(step, `steps.${stepKey}`)
364
+
365
+ cookbook.steps.forEach((step, idx) =>
366
+ checkStep(step, `steps[${idx}]`)
282
367
  );
283
368
  }
284
369
 
285
370
  // Check dependencies exist
286
- if (version === '2.0' && cookbook.steps) {
287
- Object.entries(cookbook.steps).forEach(([stepKey, step]) => {
371
+ if (cookbook.steps && Array.isArray(cookbook.steps)) {
372
+ cookbook.steps.forEach((step) => {
288
373
  if (step.depends_on) {
289
374
  step.depends_on.forEach(dep => {
290
375
  if (!stepIds.has(dep)) {
291
376
  throw new CookbookValidationError(
292
- `Step '${stepKey}' depends on non-existent step_id '${dep}'`
377
+ `Step '${step.step_id}' depends on non-existent step_id '${dep}'`
293
378
  );
294
379
  }
295
380
  });