@livingdata/pipex 0.0.9 → 0.0.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.
Files changed (88) hide show
  1. package/README.md +154 -14
  2. package/dist/__tests__/errors.js +162 -0
  3. package/dist/__tests__/helpers.js +41 -0
  4. package/dist/__tests__/types.js +8 -0
  5. package/dist/cli/__tests__/condition.js +23 -0
  6. package/dist/cli/__tests__/dag.js +154 -0
  7. package/dist/cli/__tests__/pipeline-loader.js +267 -0
  8. package/dist/cli/__tests__/pipeline-runner.js +257 -0
  9. package/dist/cli/__tests__/state-persistence.js +80 -0
  10. package/dist/cli/__tests__/state.js +58 -0
  11. package/dist/cli/__tests__/step-runner.js +116 -0
  12. package/dist/cli/commands/bundle.js +35 -0
  13. package/dist/cli/commands/cat.js +54 -0
  14. package/dist/cli/commands/exec.js +89 -0
  15. package/dist/cli/commands/export.js +2 -2
  16. package/dist/cli/commands/inspect.js +1 -1
  17. package/dist/cli/commands/list.js +2 -1
  18. package/dist/cli/commands/logs.js +1 -1
  19. package/dist/cli/commands/prune.js +1 -1
  20. package/dist/cli/commands/rm-step.js +41 -0
  21. package/dist/cli/commands/run-bundle.js +59 -0
  22. package/dist/cli/commands/run.js +9 -4
  23. package/dist/cli/commands/show.js +42 -7
  24. package/dist/cli/condition.js +11 -0
  25. package/dist/cli/dag.js +143 -0
  26. package/dist/cli/index.js +6 -0
  27. package/dist/cli/interactive-reporter.js +227 -0
  28. package/dist/cli/pipeline-loader.js +10 -110
  29. package/dist/cli/pipeline-runner.js +164 -78
  30. package/dist/cli/reporter.js +2 -158
  31. package/dist/cli/state.js +8 -0
  32. package/dist/cli/step-loader.js +25 -0
  33. package/dist/cli/step-resolver.js +111 -0
  34. package/dist/cli/step-runner.js +226 -0
  35. package/dist/cli/utils.js +0 -46
  36. package/dist/core/__tests__/bundle.js +663 -0
  37. package/dist/core/__tests__/condition.js +23 -0
  38. package/dist/core/__tests__/dag.js +154 -0
  39. package/dist/core/__tests__/env-file.test.js +41 -0
  40. package/dist/core/__tests__/event-aggregator.js +244 -0
  41. package/dist/core/__tests__/pipeline-loader.js +267 -0
  42. package/dist/core/__tests__/pipeline-runner.js +257 -0
  43. package/dist/core/__tests__/state-persistence.js +80 -0
  44. package/dist/core/__tests__/state.js +58 -0
  45. package/dist/core/__tests__/step-runner.js +118 -0
  46. package/dist/core/__tests__/stream-reporter.js +142 -0
  47. package/dist/core/__tests__/transport.js +50 -0
  48. package/dist/core/__tests__/utils.js +40 -0
  49. package/dist/core/bundle.js +130 -0
  50. package/dist/core/condition.js +11 -0
  51. package/dist/core/dag.js +143 -0
  52. package/dist/core/env-file.js +6 -0
  53. package/dist/core/event-aggregator.js +114 -0
  54. package/dist/core/index.js +14 -0
  55. package/dist/core/pipeline-loader.js +81 -0
  56. package/dist/core/pipeline-runner.js +360 -0
  57. package/dist/core/reporter.js +11 -0
  58. package/dist/core/state.js +110 -0
  59. package/dist/core/step-loader.js +25 -0
  60. package/dist/core/step-resolver.js +117 -0
  61. package/dist/core/step-runner.js +225 -0
  62. package/dist/core/stream-reporter.js +41 -0
  63. package/dist/core/transport.js +9 -0
  64. package/dist/core/utils.js +56 -0
  65. package/dist/engine/__tests__/workspace.js +288 -0
  66. package/dist/engine/docker-executor.js +10 -2
  67. package/dist/engine/index.js +1 -0
  68. package/dist/engine/workspace.js +76 -12
  69. package/dist/errors.js +122 -0
  70. package/dist/index.js +3 -0
  71. package/dist/kits/__tests__/index.js +23 -0
  72. package/dist/kits/builtin/__tests__/node.js +74 -0
  73. package/dist/kits/builtin/__tests__/python.js +67 -0
  74. package/dist/kits/builtin/__tests__/shell.js +74 -0
  75. package/dist/kits/builtin/node.js +10 -5
  76. package/dist/kits/builtin/python.js +10 -5
  77. package/dist/kits/builtin/shell.js +2 -1
  78. package/dist/kits/index.js +2 -1
  79. package/package.json +6 -3
  80. package/dist/cli/types.js +0 -3
  81. package/dist/engine/docker-runtime.js +0 -65
  82. package/dist/engine/runtime.js +0 -2
  83. package/dist/kits/bash.js +0 -19
  84. package/dist/kits/builtin/bash.js +0 -19
  85. package/dist/kits/node.js +0 -56
  86. package/dist/kits/python.js +0 -51
  87. package/dist/kits/types.js +0 -1
  88. package/dist/reporter.js +0 -13
@@ -0,0 +1,143 @@
1
+ import { CyclicDependencyError, ValidationError } from '../errors.js';
2
+ /** Build a dependency graph from resolved steps. */
3
+ export function buildGraph(steps) {
4
+ const graph = new Map();
5
+ for (const step of steps) {
6
+ const deps = new Set();
7
+ if (step.inputs) {
8
+ for (const input of step.inputs) {
9
+ deps.add(input.step);
10
+ }
11
+ }
12
+ graph.set(step.id, deps);
13
+ }
14
+ return graph;
15
+ }
16
+ /** Validate graph: check for missing refs (non-optional) and cycles. */
17
+ export function validateGraph(graph, steps) {
18
+ validateReferences(graph, steps);
19
+ detectCycles(graph);
20
+ }
21
+ function validateReferences(graph, steps) {
22
+ const optionalInputs = new Set();
23
+ for (const step of steps) {
24
+ if (step.inputs) {
25
+ for (const input of step.inputs) {
26
+ if (input.optional) {
27
+ optionalInputs.add(`${step.id}:${input.step}`);
28
+ }
29
+ }
30
+ }
31
+ }
32
+ for (const [stepId, deps] of graph) {
33
+ for (const dep of deps) {
34
+ if (!graph.has(dep) && !optionalInputs.has(`${stepId}:${dep}`)) {
35
+ throw new ValidationError(`Step '${stepId}' references unknown step '${dep}'`);
36
+ }
37
+ }
38
+ }
39
+ }
40
+ function detectCycles(graph) {
41
+ const inDeg = computeInDegree(graph);
42
+ const queue = [];
43
+ for (const [id, deg] of inDeg) {
44
+ if (deg === 0) {
45
+ queue.push(id);
46
+ }
47
+ }
48
+ let processed = 0;
49
+ while (queue.length > 0) {
50
+ const current = queue.shift();
51
+ processed++;
52
+ for (const [id, deps] of graph) {
53
+ if (deps.has(current)) {
54
+ const newDeg = inDeg.get(id) - 1;
55
+ inDeg.set(id, newDeg);
56
+ if (newDeg === 0) {
57
+ queue.push(id);
58
+ }
59
+ }
60
+ }
61
+ }
62
+ if (processed < graph.size) {
63
+ throw new CyclicDependencyError('Pipeline contains a dependency cycle');
64
+ }
65
+ }
66
+ /** Compute in-degree for each node (number of existing deps). */
67
+ function computeInDegree(graph) {
68
+ const inDeg = new Map();
69
+ for (const [id, deps] of graph) {
70
+ let count = 0;
71
+ for (const dep of deps) {
72
+ if (graph.has(dep)) {
73
+ count++;
74
+ }
75
+ }
76
+ inDeg.set(id, count);
77
+ }
78
+ return inDeg;
79
+ }
80
+ /** Return steps grouped by topological level (parallelizable groups). */
81
+ export function topologicalLevels(graph) {
82
+ const inDeg = computeInDegree(graph);
83
+ const levels = [];
84
+ const remaining = new Set(graph.keys());
85
+ while (remaining.size > 0) {
86
+ const level = [];
87
+ for (const id of remaining) {
88
+ if (inDeg.get(id) === 0) {
89
+ level.push(id);
90
+ }
91
+ }
92
+ if (level.length === 0) {
93
+ break; // Cycle — should not happen after validateGraph
94
+ }
95
+ levels.push(level);
96
+ for (const id of level) {
97
+ remaining.delete(id);
98
+ for (const [nodeId, deps] of graph) {
99
+ if (deps.has(id) && remaining.has(nodeId)) {
100
+ inDeg.set(nodeId, inDeg.get(nodeId) - 1);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ return levels;
106
+ }
107
+ /** BFS backward from targets to collect all ancestors + targets. */
108
+ export function subgraph(graph, targets) {
109
+ const result = new Set();
110
+ const queue = [...targets];
111
+ while (queue.length > 0) {
112
+ const current = queue.shift();
113
+ if (result.has(current)) {
114
+ continue;
115
+ }
116
+ result.add(current);
117
+ const deps = graph.get(current);
118
+ if (deps) {
119
+ for (const dep of deps) {
120
+ if (!result.has(dep)) {
121
+ queue.push(dep);
122
+ }
123
+ }
124
+ }
125
+ }
126
+ return result;
127
+ }
128
+ /** Return steps that no other step depends on (leaf/terminal nodes). */
129
+ export function leafNodes(graph) {
130
+ const depended = new Set();
131
+ for (const deps of graph.values()) {
132
+ for (const dep of deps) {
133
+ depended.add(dep);
134
+ }
135
+ }
136
+ const leaves = [];
137
+ for (const id of graph.keys()) {
138
+ if (!depended.has(id)) {
139
+ leaves.push(id);
140
+ }
141
+ }
142
+ return leaves;
143
+ }
package/dist/cli/index.js CHANGED
@@ -11,6 +11,9 @@ import { registerPruneCommand } from './commands/prune.js';
11
11
  import { registerListCommand } from './commands/list.js';
12
12
  import { registerRmCommand } from './commands/rm.js';
13
13
  import { registerCleanCommand } from './commands/clean.js';
14
+ import { registerExecCommand } from './commands/exec.js';
15
+ import { registerCatCommand } from './commands/cat.js';
16
+ import { registerRmStepCommand } from './commands/rm-step.js';
14
17
  async function main() {
15
18
  const program = new Command();
16
19
  program
@@ -28,6 +31,9 @@ async function main() {
28
31
  registerListCommand(program);
29
32
  registerRmCommand(program);
30
33
  registerCleanCommand(program);
34
+ registerExecCommand(program);
35
+ registerCatCommand(program);
36
+ registerRmStepCommand(program);
31
37
  await program.parseAsync();
32
38
  }
33
39
  try {
@@ -0,0 +1,227 @@
1
+ import process from 'node:process';
2
+ import { createLogUpdate } from 'log-update';
3
+ import chalk from 'chalk';
4
+ import { formatDuration, formatSize } from '../core/utils.js';
5
+ const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
6
+ /**
7
+ * Reporter with interactive terminal UI using log-update for multi-step display.
8
+ * Suitable for local development and manual execution.
9
+ */
10
+ export class InteractiveReporter {
11
+ static get maxStderrLines() {
12
+ return 20;
13
+ }
14
+ verbose;
15
+ logUpdate = createLogUpdate(process.stderr);
16
+ steps = new Map();
17
+ stderrBuffers = new Map();
18
+ frame = 0;
19
+ timer;
20
+ constructor(options) {
21
+ this.verbose = options?.verbose ?? false;
22
+ }
23
+ emit(event) {
24
+ switch (event.event) {
25
+ case 'PIPELINE_START': {
26
+ console.error(chalk.bold(`\n▶ Pipeline: ${chalk.cyan(event.pipelineName)}\n`));
27
+ for (const step of event.steps) {
28
+ this.steps.set(step.id, { displayName: step.displayName, status: 'pending' });
29
+ }
30
+ this.startRendering();
31
+ break;
32
+ }
33
+ case 'STEP_STARTING': {
34
+ const step = this.steps.get(event.step.id);
35
+ if (step) {
36
+ step.status = 'running';
37
+ }
38
+ else {
39
+ this.steps.set(event.step.id, { displayName: event.step.displayName, status: 'running' });
40
+ this.startRendering();
41
+ }
42
+ break;
43
+ }
44
+ case 'STEP_SKIPPED': {
45
+ const reasonLabel = event.reason === 'cached'
46
+ ? '(cached)'
47
+ : (event.reason === 'condition'
48
+ ? '(condition)'
49
+ : '(dependency skipped)');
50
+ const step = this.steps.get(event.step.id);
51
+ if (step) {
52
+ step.status = 'skipped';
53
+ step.detail = ` ${reasonLabel}`;
54
+ }
55
+ else {
56
+ this.steps.set(event.step.id, { displayName: event.step.displayName, status: 'skipped', detail: ` ${reasonLabel}` });
57
+ }
58
+ break;
59
+ }
60
+ case 'STEP_FINISHED': {
61
+ this.handleStepFinished(event);
62
+ break;
63
+ }
64
+ case 'STEP_FAILED': {
65
+ this.handleStepFailed(event);
66
+ break;
67
+ }
68
+ case 'STEP_RETRYING': {
69
+ const step = this.steps.get(event.step.id);
70
+ if (step) {
71
+ step.displayName = `${event.step.displayName} (retry ${event.attempt}/${event.maxRetries})`;
72
+ }
73
+ break;
74
+ }
75
+ case 'STEP_WOULD_RUN': {
76
+ const step = this.steps.get(event.step.id);
77
+ if (step) {
78
+ step.status = 'would-run';
79
+ }
80
+ break;
81
+ }
82
+ case 'PIPELINE_FINISHED': {
83
+ this.stopRendering();
84
+ const parts = ['Pipeline completed'];
85
+ if (event.totalArtifactSize > 0) {
86
+ parts.push(`(${formatSize(event.totalArtifactSize)})`);
87
+ }
88
+ console.error(chalk.bold.green(`\n✓ ${parts.join(' ')}\n`));
89
+ break;
90
+ }
91
+ case 'PIPELINE_FAILED': {
92
+ this.stopRendering();
93
+ this.printFailedStderr();
94
+ console.error(chalk.bold.red('\n✗ Pipeline failed\n'));
95
+ break;
96
+ }
97
+ case 'STEP_LOG': {
98
+ if (this.verbose) {
99
+ const prefix = chalk.gray(` [${event.step.id}]`);
100
+ this.logUpdate.clear();
101
+ console.error(`${prefix} ${event.line}`);
102
+ this.render();
103
+ }
104
+ if (event.stream === 'stderr') {
105
+ let buffer = this.stderrBuffers.get(event.step.id);
106
+ if (!buffer) {
107
+ buffer = [];
108
+ this.stderrBuffers.set(event.step.id, buffer);
109
+ }
110
+ buffer.push(event.line);
111
+ if (buffer.length > InteractiveReporter.maxStderrLines) {
112
+ buffer.shift();
113
+ }
114
+ }
115
+ break;
116
+ }
117
+ }
118
+ }
119
+ render() {
120
+ const lines = [];
121
+ for (const step of this.steps.values()) {
122
+ const symbol = this.symbolFor(step);
123
+ const text = this.textFor(step);
124
+ lines.push(` ${symbol} ${text}`);
125
+ }
126
+ this.logUpdate(lines.join('\n'));
127
+ this.frame++;
128
+ }
129
+ symbolFor(step) {
130
+ switch (step.status) {
131
+ case 'pending': {
132
+ return chalk.gray('○');
133
+ }
134
+ case 'running': {
135
+ return chalk.cyan(spinnerFrames[this.frame % spinnerFrames.length]);
136
+ }
137
+ case 'done': {
138
+ return chalk.green('✓');
139
+ }
140
+ case 'skipped': {
141
+ return chalk.gray('⊙');
142
+ }
143
+ case 'failed': {
144
+ return chalk.red('✗');
145
+ }
146
+ case 'would-run': {
147
+ return chalk.yellow('○');
148
+ }
149
+ }
150
+ }
151
+ textFor(step) {
152
+ const suffix = step.detail ?? '';
153
+ switch (step.status) {
154
+ case 'pending': {
155
+ return chalk.gray(step.displayName);
156
+ }
157
+ case 'running': {
158
+ return step.displayName;
159
+ }
160
+ case 'done': {
161
+ return chalk.green(`${step.displayName}${suffix}`);
162
+ }
163
+ case 'skipped': {
164
+ return chalk.gray(`${step.displayName}${suffix}`);
165
+ }
166
+ case 'failed': {
167
+ return chalk.red(`${step.displayName}${suffix}`);
168
+ }
169
+ case 'would-run': {
170
+ return chalk.yellow(`${step.displayName} (would run)`);
171
+ }
172
+ }
173
+ }
174
+ startRendering() {
175
+ if (!this.timer) {
176
+ this.render();
177
+ this.timer = setInterval(() => {
178
+ this.render();
179
+ }, 80);
180
+ }
181
+ }
182
+ stopRendering() {
183
+ if (this.timer) {
184
+ clearInterval(this.timer);
185
+ this.timer = undefined;
186
+ }
187
+ this.render();
188
+ this.logUpdate.done();
189
+ }
190
+ handleStepFinished(event) {
191
+ const step = this.steps.get(event.step.id);
192
+ if (step) {
193
+ step.status = 'done';
194
+ const details = [];
195
+ if (typeof event.durationMs === 'number') {
196
+ details.push(formatDuration(event.durationMs));
197
+ }
198
+ if (typeof event.artifactSize === 'number' && event.artifactSize > 0) {
199
+ details.push(formatSize(event.artifactSize));
200
+ }
201
+ if (details.length > 0) {
202
+ step.detail = ` (${details.join(', ')})`;
203
+ }
204
+ }
205
+ this.stderrBuffers.delete(event.step.id);
206
+ }
207
+ handleStepFailed(event) {
208
+ const step = this.steps.get(event.step.id);
209
+ if (step) {
210
+ step.status = 'failed';
211
+ step.detail = ` (exit ${event.exitCode})`;
212
+ }
213
+ }
214
+ printFailedStderr() {
215
+ for (const [stepId, step] of this.steps) {
216
+ if (step.status === 'failed') {
217
+ const stderr = this.stderrBuffers.get(stepId);
218
+ if (stderr?.length) {
219
+ console.error(chalk.red(` ── ${step.displayName} stderr ──`));
220
+ for (const line of stderr) {
221
+ console.error(chalk.red(` ${line}`));
222
+ }
223
+ }
224
+ }
225
+ }
226
+ }
227
+ }
@@ -2,8 +2,9 @@ import { readFile } from 'node:fs/promises';
2
2
  import { extname } from 'node:path';
3
3
  import { deburr } from 'lodash-es';
4
4
  import { parse as parseYaml } from 'yaml';
5
- import { getKit } from '../kits/index.js';
6
- import { isKitStep } from '../types.js';
5
+ import { ValidationError } from '../errors.js';
6
+ import { buildGraph, validateGraph } from './dag.js';
7
+ import { resolveStep, validateStep } from './step-resolver.js';
7
8
  export class PipelineLoader {
8
9
  async load(filePath) {
9
10
  const content = await readFile(filePath, 'utf8');
@@ -12,127 +13,26 @@ export class PipelineLoader {
12
13
  parse(content, filePath) {
13
14
  const input = parsePipelineFile(content, filePath);
14
15
  if (!input.id && !input.name) {
15
- throw new Error('Invalid pipeline: at least one of "id" or "name" must be defined');
16
+ throw new ValidationError('Invalid pipeline: at least one of "id" or "name" must be defined');
16
17
  }
17
18
  const pipelineId = input.id ?? slugify(input.name);
18
19
  if (!Array.isArray(input.steps) || input.steps.length === 0) {
19
- throw new Error('Invalid pipeline: steps must be a non-empty array');
20
+ throw new ValidationError('Invalid pipeline: steps must be a non-empty array');
20
21
  }
21
- const steps = input.steps.map(step => this.resolveStep(step));
22
+ const steps = input.steps.map(step => resolveStep(step));
22
23
  for (const step of steps) {
23
- this.validateStep(step);
24
+ validateStep(step);
24
25
  }
25
26
  this.validateUniqueStepIds(steps);
27
+ const graph = buildGraph(steps);
28
+ validateGraph(graph, steps);
26
29
  return { id: pipelineId, name: input.name, steps };
27
30
  }
28
- resolveStep(step) {
29
- if (!step.id && !step.name) {
30
- throw new Error('Invalid step: at least one of "id" or "name" must be defined');
31
- }
32
- const id = step.id ?? slugify(step.name);
33
- const { name } = step;
34
- if (!isKitStep(step)) {
35
- return { ...step, id, name };
36
- }
37
- return this.resolveKitStep(step, id, name);
38
- }
39
- resolveKitStep(step, id, name) {
40
- const kit = getKit(step.uses);
41
- const kitOutput = kit.resolve(step.with ?? {});
42
- return {
43
- id,
44
- name,
45
- image: kitOutput.image,
46
- cmd: kitOutput.cmd,
47
- env: mergeEnv(kitOutput.env, step.env),
48
- inputs: step.inputs,
49
- outputPath: step.outputPath,
50
- caches: mergeCaches(kitOutput.caches, step.caches),
51
- mounts: mergeMounts(kitOutput.mounts, step.mounts),
52
- sources: mergeMounts(kitOutput.sources, step.sources),
53
- timeoutSec: step.timeoutSec,
54
- allowFailure: step.allowFailure,
55
- allowNetwork: step.allowNetwork ?? kitOutput.allowNetwork
56
- };
57
- }
58
- validateStep(step) {
59
- this.validateIdentifier(step.id, 'step id');
60
- if (!step.image || typeof step.image !== 'string') {
61
- throw new Error(`Invalid step ${step.id}: image is required`);
62
- }
63
- if (!Array.isArray(step.cmd) || step.cmd.length === 0) {
64
- throw new Error(`Invalid step ${step.id}: cmd must be a non-empty array`);
65
- }
66
- if (step.inputs) {
67
- for (const input of step.inputs) {
68
- this.validateIdentifier(input.step, `input step name in step ${step.id}`);
69
- }
70
- }
71
- if (step.mounts) {
72
- this.validateMounts(step.id, step.mounts);
73
- }
74
- if (step.sources) {
75
- this.validateMounts(step.id, step.sources);
76
- }
77
- if (step.caches) {
78
- this.validateCaches(step.id, step.caches);
79
- }
80
- }
81
- validateMounts(stepId, mounts) {
82
- if (!Array.isArray(mounts)) {
83
- throw new TypeError(`Step ${stepId}: mounts must be an array`);
84
- }
85
- for (const mount of mounts) {
86
- if (!mount.host || typeof mount.host !== 'string') {
87
- throw new Error(`Step ${stepId}: mount.host is required and must be a string`);
88
- }
89
- if (mount.host.startsWith('/')) {
90
- throw new Error(`Step ${stepId}: mount.host '${mount.host}' must be a relative path`);
91
- }
92
- if (mount.host.includes('..')) {
93
- throw new Error(`Step ${stepId}: mount.host '${mount.host}' must not contain '..'`);
94
- }
95
- if (!mount.container || typeof mount.container !== 'string') {
96
- throw new Error(`Step ${stepId}: mount.container is required and must be a string`);
97
- }
98
- if (!mount.container.startsWith('/')) {
99
- throw new Error(`Step ${stepId}: mount.container '${mount.container}' must be an absolute path`);
100
- }
101
- if (mount.container.includes('..')) {
102
- throw new Error(`Step ${stepId}: mount.container '${mount.container}' must not contain '..'`);
103
- }
104
- }
105
- }
106
- validateCaches(stepId, caches) {
107
- if (!Array.isArray(caches)) {
108
- throw new TypeError(`Step ${stepId}: caches must be an array`);
109
- }
110
- for (const cache of caches) {
111
- if (!cache.name || typeof cache.name !== 'string') {
112
- throw new Error(`Step ${stepId}: cache.name is required and must be a string`);
113
- }
114
- this.validateIdentifier(cache.name, `cache name in step ${stepId}`);
115
- if (!cache.path || typeof cache.path !== 'string') {
116
- throw new Error(`Step ${stepId}: cache.path is required and must be a string`);
117
- }
118
- if (!cache.path.startsWith('/')) {
119
- throw new Error(`Step ${stepId}: cache.path '${cache.path}' must be an absolute path`);
120
- }
121
- }
122
- }
123
- validateIdentifier(id, context) {
124
- if (!/^[\w-]+$/.test(id)) {
125
- throw new Error(`Invalid ${context}: '${id}' must contain only alphanumeric characters, underscore, and hyphen`);
126
- }
127
- if (id.includes('..')) {
128
- throw new Error(`Invalid ${context}: '${id}' cannot contain '..'`);
129
- }
130
- }
131
31
  validateUniqueStepIds(steps) {
132
32
  const seen = new Set();
133
33
  for (const step of steps) {
134
34
  if (seen.has(step.id)) {
135
- throw new Error(`Duplicate step id: '${step.id}'`);
35
+ throw new ValidationError(`Duplicate step id: '${step.id}'`);
136
36
  }
137
37
  seen.add(step.id);
138
38
  }