adfinem 0.0.0 → 0.1.1

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 (112) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +17 -0
  3. package/CODE_OF_CONDUCT.md +21 -0
  4. package/CONTRIBUTING.md +29 -0
  5. package/LICENSE +21 -0
  6. package/README.md +97 -3
  7. package/SECURITY.md +13 -0
  8. package/catalogs/.gitkeep +0 -0
  9. package/catalogs/api-operations.yaml +21 -0
  10. package/catalogs/batches.yaml +74 -0
  11. package/catalogs/queries.yaml +75 -0
  12. package/config/environments.yaml +13 -0
  13. package/dist/actions/assert-db.js +3 -0
  14. package/dist/actions/run-eod.js +3 -0
  15. package/dist/adapters/api/api-collections.js +296 -0
  16. package/dist/adapters/api/body-utils.js +9 -0
  17. package/dist/adapters/api/rest-client.js +557 -0
  18. package/dist/adapters/api/soap-client.js +5 -0
  19. package/dist/adapters/db/assertions.js +87 -0
  20. package/dist/adapters/db/oracle-client.js +115 -0
  21. package/dist/adapters/db/query-catalog.js +75 -0
  22. package/dist/adapters/unix/batch-catalog.js +71 -0
  23. package/dist/adapters/unix/batch-input-files.js +36 -0
  24. package/dist/adapters/unix/batch-runner.js +382 -0
  25. package/dist/adapters/unix/ssh-client.js +228 -0
  26. package/dist/app/server.js +827 -0
  27. package/dist/cli.js +516 -0
  28. package/dist/config/environments.js +138 -0
  29. package/dist/config/registry.js +18 -0
  30. package/dist/config/secrets.js +123 -0
  31. package/dist/dsl/parser.js +20 -0
  32. package/dist/dsl/schema.js +182 -0
  33. package/dist/dsl/types.js +1 -0
  34. package/dist/dsl/validator.js +264 -0
  35. package/dist/engine/captures.js +68 -0
  36. package/dist/engine/context.js +69 -0
  37. package/dist/engine/evidence.js +33 -0
  38. package/dist/engine/known-errors.js +129 -0
  39. package/dist/engine/retry.js +13 -0
  40. package/dist/engine/runner.js +710 -0
  41. package/dist/engine/step-result.js +58 -0
  42. package/dist/flows/catalog-normalizer.js +72 -0
  43. package/dist/flows/compiler.js +237 -0
  44. package/dist/flows/concat.js +130 -0
  45. package/dist/flows/parser.js +21 -0
  46. package/dist/flows/schema.js +142 -0
  47. package/dist/flows/types.js +1 -0
  48. package/dist/flows/validator.js +470 -0
  49. package/dist/reports/html-report.js +112 -0
  50. package/dist/reports/junit-report.js +48 -0
  51. package/docs/.gitkeep +0 -0
  52. package/docs/DB_UNIX_OPERATIONS.md +118 -0
  53. package/docs/FLOW_BUILDER.md +87 -0
  54. package/flows/account_processing_cycle.flow.yaml +88 -0
  55. package/flows/new_flow.flow.yaml +22 -0
  56. package/package.json +98 -11
  57. package/scenarios/smoke/account-processing-smoke.yaml +44 -0
  58. package/scenarios/smoke/api-db-batch-check.yaml +40 -0
  59. package/src/actions/assert-db.ts +6 -0
  60. package/src/actions/run-eod.ts +6 -0
  61. package/src/adapters/api/api-collections.ts +375 -0
  62. package/src/adapters/api/body-utils.ts +10 -0
  63. package/src/adapters/api/rest-client.ts +587 -0
  64. package/src/adapters/api/soap-client.ts +7 -0
  65. package/src/adapters/db/assertions.ts +83 -0
  66. package/src/adapters/db/oracle-client.ts +133 -0
  67. package/src/adapters/db/query-catalog.ts +80 -0
  68. package/src/adapters/unix/batch-catalog.ts +81 -0
  69. package/src/adapters/unix/batch-input-files.ts +39 -0
  70. package/src/adapters/unix/batch-runner.ts +456 -0
  71. package/src/adapters/unix/ssh-client.ts +248 -0
  72. package/src/app/server.ts +914 -0
  73. package/src/cli.ts +517 -0
  74. package/src/config/environments.ts +193 -0
  75. package/src/config/registry.ts +23 -0
  76. package/src/config/secrets.ts +128 -0
  77. package/src/dsl/parser.ts +24 -0
  78. package/src/dsl/schema.ts +189 -0
  79. package/src/dsl/types.ts +371 -0
  80. package/src/dsl/validator.ts +282 -0
  81. package/src/engine/captures.ts +66 -0
  82. package/src/engine/context.ts +76 -0
  83. package/src/engine/evidence.ts +35 -0
  84. package/src/engine/known-errors.ts +145 -0
  85. package/src/engine/retry.ts +11 -0
  86. package/src/engine/runner.ts +746 -0
  87. package/src/engine/step-result.ts +64 -0
  88. package/src/flows/catalog-normalizer.ts +86 -0
  89. package/src/flows/compiler.ts +247 -0
  90. package/src/flows/concat.ts +149 -0
  91. package/src/flows/parser.ts +27 -0
  92. package/src/flows/schema.ts +154 -0
  93. package/src/flows/types.ts +130 -0
  94. package/src/flows/validator.ts +468 -0
  95. package/src/llm/system-prompt.md +9 -0
  96. package/src/reports/html-report.ts +113 -0
  97. package/src/reports/junit-report.ts +55 -0
  98. package/src/types/oracledb.d.ts +1 -0
  99. package/templates/.gitkeep +0 -0
  100. package/templates/api/create-test-case.json +5 -0
  101. package/templates/api/record-test-activity.json +6 -0
  102. package/tsconfig.json +15 -0
  103. package/vite.config.ts +17 -0
  104. package/web/index.html +12 -0
  105. package/web/src/App.tsx +6588 -0
  106. package/web/src/main.tsx +10 -0
  107. package/web/src/styles.css +3147 -0
  108. package/web-dist/assets/elk.bundled-ChwRCIWJ.js +24 -0
  109. package/web-dist/assets/index-CArbX4zm.css +1 -0
  110. package/web-dist/assets/index-vDCbj8xB.js +28 -0
  111. package/web-dist/index.html +13 -0
  112. package/index.js +0 -1
@@ -0,0 +1,371 @@
1
+ export type StepStatus = "passed" | "failed" | "skipped" | "cancelled";
2
+ export type StepLayer = "api" | "db" | "unix" | "dsl" | "engine";
3
+
4
+ export interface Scenario {
5
+ id: string;
6
+ environment: string;
7
+ tenant?: Record<string, string>;
8
+ variables?: Record<string, unknown>;
9
+ steps: ScenarioStep[];
10
+ }
11
+
12
+ export interface ScenarioStep {
13
+ id: string;
14
+ action: string;
15
+ via?: string;
16
+ retry?: {
17
+ attempts?: number;
18
+ delaySeconds?: number;
19
+ };
20
+ input?: Record<string, unknown>;
21
+ params?: Record<string, unknown>;
22
+ query?: string;
23
+ batch?: string;
24
+ request?: ApiRequestSpec;
25
+ assertions?: ApiAssertion[];
26
+ capture?: Record<string, string>;
27
+ continueOnFailure?: boolean;
28
+ expectedOutcome?: ExpectedOutcome;
29
+ captureOnFailure?: boolean;
30
+ control?: ControlStep;
31
+ branches?: ScenarioBranch[];
32
+ steps?: ScenarioStep[];
33
+ loop?: LoopSpec;
34
+ join?: ParallelJoinMode;
35
+ }
36
+
37
+ export type ControlStep = "parallel" | "loop";
38
+ export type ParallelJoinMode = "all" | "any" | "fail_fast";
39
+
40
+ export interface ScenarioBranch {
41
+ id: string;
42
+ label?: string;
43
+ steps: ScenarioStep[];
44
+ }
45
+
46
+ export interface LoopSpec {
47
+ mode: "count" | "foreach";
48
+ count?: number | string;
49
+ items?: unknown;
50
+ itemName?: string;
51
+ maxIterations?: number;
52
+ dateCursor?: LoopDateCursor;
53
+ }
54
+
55
+ export type LoopDateFormat = "YYYY-MM-DD" | "DD/MM/YYYY" | "MM/DD/YYYY";
56
+ export type LoopDateAdvanceMode = "days" | "months" | "nth_day_of_month" | "first_of_month" | "end_of_month";
57
+
58
+ export interface LoopDateCursor {
59
+ outputName?: string;
60
+ start?: string;
61
+ inputFormat?: LoopDateFormat;
62
+ outputFormat?: LoopDateFormat;
63
+ advance?: {
64
+ mode: LoopDateAdvanceMode;
65
+ amount?: number;
66
+ day?: number;
67
+ };
68
+ }
69
+
70
+ export interface QueryCatalogEntry {
71
+ description?: string;
72
+ mode?: "query" | "execute";
73
+ sql: string;
74
+ params?: Record<string, CatalogParam>;
75
+ expect?: QueryExpectation;
76
+ captures?: Record<string, string>;
77
+ maxRows?: number;
78
+ }
79
+
80
+ export interface DbExecuteResult {
81
+ status: "passed";
82
+ rowsAffected?: number;
83
+ outBinds?: Record<string, unknown>;
84
+ }
85
+
86
+ export interface QueryExpectation {
87
+ type: "number" | "string" | "boolean" | "rowCount";
88
+ column?: string;
89
+ operator: "=" | "!=" | ">" | ">=" | "<" | "<=" | "contains";
90
+ value: unknown;
91
+ }
92
+
93
+ export interface BatchCatalogEntry {
94
+ description?: string;
95
+ hostRef: string;
96
+ command: string;
97
+ fixedArgs?: Array<string | number | boolean>;
98
+ workingDirectory?: string;
99
+ useWorkingDirectory?: boolean;
100
+ environment?: Record<string, string | number | boolean>;
101
+ args?: CatalogArg[];
102
+ inputFiles?: BatchInputFileSpec[];
103
+ outputFiles?: BatchOutputFileSpec[];
104
+ timeoutSeconds?: number;
105
+ success?: {
106
+ exitCodes?: number[];
107
+ requiredOutput?: string[];
108
+ };
109
+ captures?: Record<string, string>;
110
+ }
111
+
112
+ export interface BatchInputFileSpec {
113
+ name: string;
114
+ required?: boolean;
115
+ remotePath?: string;
116
+ paramName?: string;
117
+ appendAsArg?: boolean;
118
+ }
119
+
120
+ export interface BatchOutputFileSpec {
121
+ name: string;
122
+ required?: boolean;
123
+ source?: "stdout" | "stderr" | "both" | "explicit";
124
+ pathPattern?: string;
125
+ remotePath?: string;
126
+ download?: boolean;
127
+ decrypt?: BatchOutputDecryptSpec;
128
+ }
129
+
130
+ export interface BatchOutputDecryptSpec {
131
+ command?: string;
132
+ outputRemotePath?: string;
133
+ required?: boolean;
134
+ }
135
+
136
+ export interface BatchInputFileValue {
137
+ fileName?: string;
138
+ localPath?: string;
139
+ contentBase64?: string;
140
+ remotePath?: string;
141
+ sizeBytes?: number;
142
+ }
143
+
144
+ export interface BatchFileDownloadEvidence {
145
+ name: string;
146
+ source?: "stdout" | "stderr" | "both" | "explicit";
147
+ remotePath?: string;
148
+ localPath?: string;
149
+ sizeBytes?: number;
150
+ status: "downloaded" | "failed" | "skipped";
151
+ error?: string;
152
+ decryptCommand?: string;
153
+ decryptedRemotePath?: string;
154
+ decryptExitCode?: number;
155
+ decryptStdout?: string;
156
+ decryptStderr?: string;
157
+ }
158
+
159
+ export interface BatchFileUploadEvidence {
160
+ name: string;
161
+ fileName?: string;
162
+ localPath?: string;
163
+ remotePath: string;
164
+ sizeBytes: number;
165
+ paramName?: string;
166
+ appendedAsArg?: boolean;
167
+ status: "uploaded" | "failed";
168
+ error?: string;
169
+ }
170
+
171
+ export interface ApiOperationEntry {
172
+ description?: string;
173
+ type: "rest" | "soap";
174
+ method?: ApiHttpMethod;
175
+ path?: string;
176
+ headers?: Record<string, string>;
177
+ query?: Record<string, unknown>;
178
+ body?: unknown;
179
+ rawBody?: string;
180
+ bodyMode?: ApiBodyMode;
181
+ auth?: unknown;
182
+ params?: Record<string, CatalogParam>;
183
+ assertions?: ApiAssertion[];
184
+ requestTemplate?: string;
185
+ captures?: Record<string, string>;
186
+ acceptStatuses?: number[];
187
+ idempotent?: boolean;
188
+ source?: {
189
+ collectionId?: string;
190
+ collectionName?: string;
191
+ requestId?: string;
192
+ folderPath?: string[];
193
+ };
194
+ }
195
+
196
+ export type ApiHttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
197
+ export type ApiBodyMode = "none" | "json" | "raw" | "formdata" | "urlencoded";
198
+
199
+ export interface ApiRequestSpec {
200
+ method?: ApiHttpMethod;
201
+ path?: string;
202
+ headers?: Record<string, string>;
203
+ query?: Record<string, unknown>;
204
+ body?: unknown;
205
+ rawBody?: string;
206
+ bodyMode?: ApiBodyMode;
207
+ auth?: unknown;
208
+ acceptStatuses?: number[];
209
+ }
210
+
211
+ export type ApiAssertion =
212
+ | { type: "status"; operator?: "in" | "="; value: number | number[] }
213
+ | { type: "jsonpath_exists"; path: string }
214
+ | { type: "jsonpath_equals"; path: string; value: unknown }
215
+ | { type: "jsonpath_contains"; path: string; value: unknown }
216
+ | { type: "header_exists"; header: string }
217
+ | { type: "header_equals"; header: string; value: string }
218
+ | { type: "body_contains"; value: string }
219
+ | { type: "body_not_contains"; value: string };
220
+
221
+ export type ExpectedOutcome = "positive" | "negative" | "setup" | "teardown";
222
+ export type EvidenceVisibilityMode = "raw" | "redacted";
223
+
224
+ export interface ApiAssertionResult {
225
+ assertion: ApiAssertion;
226
+ passed: boolean;
227
+ expected?: unknown;
228
+ actual?: unknown;
229
+ message?: string;
230
+ }
231
+
232
+ export interface CaptureResult {
233
+ name: string;
234
+ expression: string;
235
+ source: "bodyJson" | "bodyText" | "header" | "status" | "cookie";
236
+ required: boolean;
237
+ status: "extracted" | "missing" | "error";
238
+ published: boolean;
239
+ value?: unknown;
240
+ message?: string;
241
+ }
242
+
243
+ export interface ApiRequestEvidence {
244
+ method?: ApiHttpMethod;
245
+ path?: string;
246
+ headers?: Record<string, unknown>;
247
+ query?: Record<string, unknown>;
248
+ auth?: unknown;
249
+ body?: unknown;
250
+ }
251
+
252
+ export interface ApiResponseEvidence {
253
+ status: number;
254
+ statusText: string;
255
+ headers: Record<string, unknown>;
256
+ body?: unknown;
257
+ bodyText?: string;
258
+ bodyJson?: unknown;
259
+ contentType?: string;
260
+ durationMs: number;
261
+ sizeBytes: number;
262
+ bodyTruncated?: boolean;
263
+ bodyPreviewKind: "json" | "text" | "binary" | "empty";
264
+ }
265
+
266
+ export interface ApiTransportErrorEvidence {
267
+ kind: "dns" | "timeout" | "tls" | "connection" | "invalid_url" | "runtime" | "unknown";
268
+ message: string;
269
+ code?: string;
270
+ stackSafe?: string;
271
+ timeoutMs?: number;
272
+ retryable: boolean;
273
+ }
274
+
275
+ export interface ApiStepEvidence {
276
+ visibility: EvidenceVisibilityMode;
277
+ expectedOutcome: ExpectedOutcome;
278
+ acceptStatuses: number[];
279
+ statusAccepted: boolean;
280
+ request?: ApiRequestEvidence;
281
+ resolvedRequest?: ApiRequestEvidence;
282
+ response?: ApiResponseEvidence;
283
+ transportError?: ApiTransportErrorEvidence;
284
+ assertionResults: ApiAssertionResult[];
285
+ evidenceCaptures: CaptureResult[];
286
+ publishedCaptures: Record<string, unknown>;
287
+ finalStatus: "passed" | "failed";
288
+ failureReason?: string;
289
+ }
290
+
291
+ export interface CatalogParam {
292
+ required?: boolean;
293
+ type?: "string" | "number" | "boolean" | "string[]" | "number[]" | "boolean[]";
294
+ pattern?: string;
295
+ luhn?: boolean;
296
+ }
297
+
298
+ export interface CatalogArg extends CatalogParam {
299
+ name: string;
300
+ }
301
+
302
+ export interface Catalogs {
303
+ queries: Record<string, QueryCatalogEntry>;
304
+ batches: Record<string, BatchCatalogEntry>;
305
+ apiOperations: Record<string, ApiOperationEntry>;
306
+ }
307
+
308
+ export interface StepResult {
309
+ stepId: string;
310
+ layer: StepLayer;
311
+ status: StepStatus;
312
+ startedAt: string;
313
+ endedAt: string;
314
+ durationMs?: number;
315
+ inputHash: string;
316
+ captures: Record<string, unknown>;
317
+ evidence: string[];
318
+ api?: ApiStepEvidence;
319
+ unix?: UnixStepEvidence;
320
+ error?: {
321
+ message: string;
322
+ stack?: string;
323
+ rawOutput?: string;
324
+ };
325
+ }
326
+
327
+ export interface UnixAttemptEvidence {
328
+ attempt: number;
329
+ startedAt: string;
330
+ endedAt: string;
331
+ command: string;
332
+ displayCommand?: string;
333
+ stdout: string;
334
+ stderr: string;
335
+ exitCode?: number;
336
+ tracePath?: string;
337
+ errno?: string;
338
+ stdoutTruncated?: boolean;
339
+ stderrTruncated?: boolean;
340
+ status: "passed" | "failed";
341
+ error?: string;
342
+ }
343
+
344
+ export interface UnixStepEvidence {
345
+ command: string;
346
+ displayCommand?: string;
347
+ status: "passed" | "failed";
348
+ fileUploads?: BatchFileUploadEvidence[];
349
+ fileDownloads?: BatchFileDownloadEvidence[];
350
+ attempts: UnixAttemptEvidence[];
351
+ stdout: string;
352
+ stderr: string;
353
+ exitCode?: number;
354
+ tracePath?: string;
355
+ errno?: string;
356
+ stdoutTruncated?: boolean;
357
+ stderrTruncated?: boolean;
358
+ }
359
+
360
+ export type RunStatus = "passed" | "failed" | "cancelled";
361
+
362
+ export interface RunResult {
363
+ scenarioId: string;
364
+ runId: string;
365
+ status: RunStatus;
366
+ startedAt: string;
367
+ endedAt: string;
368
+ evidenceDir: string;
369
+ steps: StepResult[];
370
+ durationMs?: number;
371
+ }
@@ -0,0 +1,282 @@
1
+ import type { CatalogArg, CatalogParam, Catalogs, Scenario, ScenarioStep } from "./types.js";
2
+ import { registeredActions } from "../config/registry.js";
3
+ import { normalizeBindParamRecord } from "../adapters/db/query-catalog.js";
4
+ import { batchArgParamsForValidation, batchInputFiles, hasBatchInputFilePayload } from "../adapters/unix/batch-input-files.js";
5
+
6
+ export interface ValidationResult {
7
+ ok: boolean;
8
+ errors: string[];
9
+ warnings?: string[];
10
+ }
11
+
12
+ export function validateScenarioReferences(scenario: Scenario, catalogs: Catalogs, options: { knownEnvironments?: string[] } = {}): ValidationResult {
13
+ const errors: string[] = [];
14
+ const warnings: string[] = [];
15
+
16
+ if (options.knownEnvironments && options.knownEnvironments.length > 0
17
+ && !options.knownEnvironments.includes(scenario.environment)) {
18
+ warnings.push(
19
+ `Scenario environment '${scenario.environment}' is not declared in config/environments.yaml. Known: ${options.knownEnvironments.join(", ")}.`
20
+ );
21
+ }
22
+ const stepIds = new Set<string>();
23
+ const availableVariables = new Set<string>(Object.keys(scenario.variables ?? {}));
24
+ for (const key of Object.keys(scenario.tenant ?? {})) {
25
+ availableVariables.add(`tenant.${key}`);
26
+ }
27
+
28
+ for (const step of scenario.steps) {
29
+ if (step.control === "parallel" || step.action === "__parallel") {
30
+ validateControlStep(step, catalogs, errors, warnings, stepIds, availableVariables);
31
+ continue;
32
+ }
33
+ if (step.control === "loop" || step.action === "__loop") {
34
+ validateControlStep(step, catalogs, errors, warnings, stepIds, availableVariables);
35
+ const outputName = step.loop?.dateCursor?.outputName?.trim() || (step.loop?.dateCursor ? "business_date" : undefined);
36
+ if (outputName) {
37
+ availableVariables.add(outputName);
38
+ availableVariables.add(`${step.id}.${outputName}`);
39
+ availableVariables.add(`${step.id}.last.${outputName}`);
40
+ availableVariables.add(`${step.id}.all.${outputName}`);
41
+ }
42
+ for (const [childId, captureName] of loopChildCaptureNames(step, catalogs)) {
43
+ availableVariables.add(`${childId}.${captureName}`);
44
+ availableVariables.add(`${step.id}.last.${childId}.${captureName}`);
45
+ availableVariables.add(`${step.id}.all.${childId}.${captureName}`);
46
+ }
47
+ continue;
48
+ }
49
+ if (stepIds.has(step.id)) {
50
+ errors.push(`Duplicate step id '${step.id}'.`);
51
+ }
52
+ stepIds.add(step.id);
53
+
54
+ const action = registeredActions[step.action];
55
+ if (!action) {
56
+ if (step.via === "api" && catalogs.apiOperations[step.action]) {
57
+ validateApiStep(step, catalogs, errors);
58
+ } else {
59
+ errors.push(`Step '${step.id}' uses unknown action '${step.action}'.`);
60
+ continue;
61
+ }
62
+ } else if (step.via && !action.supportedVia.includes(step.via)) {
63
+ errors.push(`Step '${step.id}' action '${step.action}' does not support via '${step.via}'. Supported: ${action.supportedVia.join(", ")}.`);
64
+ }
65
+
66
+ if (step.action === "db_assert" || step.action === "db_query" || step.action === "db_execute") {
67
+ if (!step.query) errors.push(`Step '${step.id}' must specify query.`);
68
+ if (step.query && !catalogs.queries[step.query]) errors.push(`Step '${step.id}' references unknown query '${step.query}'.`);
69
+ if (step.action === "db_assert" && step.query && catalogs.queries[step.query] && !catalogs.queries[step.query].expect) {
70
+ errors.push(`Step '${step.id}' uses db_assert but query '${step.query}' has no expect block.`);
71
+ }
72
+ if (step.action === "db_execute" && step.query && catalogs.queries[step.query]?.mode !== "execute") {
73
+ errors.push(`Step '${step.id}' uses db_execute but query '${step.query}' is not marked mode: execute.`);
74
+ }
75
+ if (step.query && catalogs.queries[step.query]) {
76
+ validateParams(
77
+ step,
78
+ normalizeBindParamRecord(catalogs.queries[step.query].params ?? {}),
79
+ normalizeBindParamRecord(step.params ?? step.input ?? {}),
80
+ errors,
81
+ "query"
82
+ );
83
+ }
84
+ }
85
+
86
+ if (step.action === "unix_batch") {
87
+ if (!step.batch) errors.push(`Step '${step.id}' must specify batch.`);
88
+ if (step.batch && !catalogs.batches[step.batch]) errors.push(`Step '${step.id}' references unknown batch '${step.batch}'.`);
89
+ if (step.batch && catalogs.batches[step.batch]) {
90
+ const batch = catalogs.batches[step.batch];
91
+ const params = step.params ?? step.input ?? {};
92
+ validateBatchInputFiles(step, batch, params, errors);
93
+ validateArgs(step, batch.args ?? [], batchArgParamsForValidation(params, batch), errors);
94
+ }
95
+ }
96
+
97
+ if (step.via === "api") validateApiStep(step, catalogs, errors);
98
+
99
+ const localVariables = new Set(Object.keys(step.input ?? step.params ?? {}));
100
+ for (const variable of findVariableRefs(step)) {
101
+ if (!availableVariables.has(variable) && !localVariables.has(variable)) {
102
+ errors.push(`Step '${step.id}' references unknown variable '${variable}'.`);
103
+ }
104
+ }
105
+
106
+ const apiCatalogCaptures = step.via === "api" ? catalogs.apiOperations[step.action]?.captures ?? {} : {};
107
+ const queryCatalogCaptures = step.query ? catalogs.queries[step.query]?.captures ?? {} : {};
108
+ const batchCatalogCaptures = step.batch ? catalogs.batches[step.batch]?.captures ?? {} : {};
109
+ for (const captureName of [
110
+ ...Object.keys(apiCatalogCaptures),
111
+ ...Object.keys(queryCatalogCaptures),
112
+ ...Object.keys(batchCatalogCaptures),
113
+ ...Object.keys(step.capture ?? {})
114
+ ]) {
115
+ availableVariables.add(captureName);
116
+ availableVariables.add(`${step.id}.${captureName}`);
117
+ }
118
+ }
119
+
120
+ return { ok: errors.length === 0, errors, warnings: warnings.length ? warnings : undefined };
121
+ }
122
+
123
+ function validateApiStep(step: ScenarioStep, catalogs: Catalogs, errors: string[]): void {
124
+ const operation = catalogs.apiOperations[step.action];
125
+ if (!operation) {
126
+ errors.push(`Step '${step.id}' uses via api but no API operation '${step.action}' exists in api-operations.yaml.`);
127
+ return;
128
+ }
129
+ validateParams(step, operation.params, step.input ?? step.params ?? {}, errors, "API variable");
130
+ }
131
+
132
+ function validateControlStep(
133
+ step: ScenarioStep,
134
+ catalogs: Catalogs,
135
+ errors: string[],
136
+ warnings: string[],
137
+ stepIds: Set<string>,
138
+ availableVariables: Set<string>
139
+ ): void {
140
+ if (stepIds.has(step.id)) errors.push(`Duplicate step id '${step.id}'.`);
141
+ stepIds.add(step.id);
142
+ if (step.control === "parallel" || step.action === "__parallel") {
143
+ if (!step.branches?.length) errors.push(`Parallel step '${step.id}' must contain at least one branch.`);
144
+ for (const branch of step.branches ?? []) {
145
+ const nested = validateScenarioReferences({ id: `${step.id}_${branch.id}`, environment: "nested", variables: Object.fromEntries([...availableVariables].map((key) => [key, "available"])), steps: branch.steps }, catalogs);
146
+ errors.push(...nested.errors.map((error) => `Parallel '${step.id}' branch '${branch.id}': ${error}`));
147
+ warnings.push(...(nested.warnings ?? []).map((warning) => `Parallel '${step.id}' branch '${branch.id}': ${warning}`));
148
+ }
149
+ }
150
+ if (step.control === "loop" || step.action === "__loop") {
151
+ if (!step.steps?.length) errors.push(`Loop step '${step.id}' must contain at least one child step.`);
152
+ if (step.loop?.mode === "count" && step.loop.count === undefined) errors.push(`Loop step '${step.id}' mode count requires loop.count.`);
153
+ if (step.loop?.mode === "foreach" && step.loop.items === undefined) errors.push(`Loop step '${step.id}' mode foreach requires loop.items.`);
154
+ const loopVariables = new Set(availableVariables);
155
+ loopVariables.add(`${step.id}.index`);
156
+ loopVariables.add(`${step.id}.number`);
157
+ loopVariables.add(`${step.id}.total`);
158
+ loopVariables.add(`${step.id}.item`);
159
+ if (step.loop?.itemName) {
160
+ loopVariables.add(step.loop.itemName);
161
+ loopVariables.add(`${step.id}.${step.loop.itemName}`);
162
+ }
163
+ const outputName = step.loop?.dateCursor?.outputName?.trim() || (step.loop?.dateCursor ? "business_date" : undefined);
164
+ if (outputName) {
165
+ loopVariables.add(outputName);
166
+ loopVariables.add(`${step.id}.${outputName}`);
167
+ loopVariables.add(`${step.id}.last.${outputName}`);
168
+ loopVariables.add(`${step.id}.all.${outputName}`);
169
+ }
170
+ const nested = validateScenarioReferences({ id: `${step.id}_loop`, environment: "nested", variables: Object.fromEntries([...loopVariables].map((key) => [key, "available"])), steps: step.steps ?? [] }, catalogs);
171
+ errors.push(...nested.errors.map((error) => `Loop '${step.id}': ${error}`));
172
+ warnings.push(...(nested.warnings ?? []).map((warning) => `Loop '${step.id}': ${warning}`));
173
+ }
174
+ }
175
+
176
+ function loopChildCaptureNames(step: ScenarioStep, catalogs: Catalogs): Array<[string, string]> {
177
+ const captures: Array<[string, string]> = [];
178
+ const visit = (child: ScenarioStep) => {
179
+ const catalogCaptures = child.via === "api"
180
+ ? catalogs.apiOperations[child.action]?.captures ?? {}
181
+ : child.query
182
+ ? catalogs.queries[child.query]?.captures ?? {}
183
+ : child.batch
184
+ ? catalogs.batches[child.batch]?.captures ?? {}
185
+ : {};
186
+ for (const captureName of [...Object.keys(catalogCaptures), ...Object.keys(child.capture ?? {})]) {
187
+ captures.push([child.id, captureName]);
188
+ }
189
+ if (child.control === "loop" || child.action === "__loop") {
190
+ for (const nested of child.steps ?? []) visit(nested);
191
+ }
192
+ if (child.control === "parallel" || child.action === "__parallel") {
193
+ for (const branch of child.branches ?? []) {
194
+ for (const nested of branch.steps) visit(nested);
195
+ }
196
+ }
197
+ };
198
+ for (const child of step.steps ?? []) visit(child);
199
+ return captures;
200
+ }
201
+
202
+ function validateParams(step: ScenarioStep, specs: Record<string, CatalogParam> | undefined, params: Record<string, unknown>, errors: string[], label: string): void {
203
+ for (const [name, spec] of Object.entries(specs ?? {})) {
204
+ const value = params[name];
205
+ if (spec.required && (value === undefined || value === null || value === "")) {
206
+ errors.push(`Step '${step.id}' is missing required ${label} param '${name}'.`);
207
+ continue;
208
+ }
209
+ validateValue(step.id, name, value, spec, errors);
210
+ }
211
+ }
212
+
213
+ function validateArgs(step: ScenarioStep, specs: CatalogArg[], params: Record<string, unknown>, errors: string[]): void {
214
+ for (const spec of specs) {
215
+ if (!Object.prototype.hasOwnProperty.call(params, spec.name)) continue;
216
+ const value = params[spec.name];
217
+ if (spec.required !== false && (value === undefined || value === null || value === "")) {
218
+ errors.push(`Step '${step.id}' is missing required batch arg '${spec.name}'.`);
219
+ continue;
220
+ }
221
+ validateValue(step.id, spec.name, value, spec, errors);
222
+ }
223
+ }
224
+
225
+ function validateBatchInputFiles(step: ScenarioStep, batch: Catalogs["batches"][string], params: Record<string, unknown>, errors: string[]): void {
226
+ for (const file of batchInputFiles(batch)) {
227
+ const value = params[file.name];
228
+ if (file.required !== false && !hasBatchInputFilePayload(value)) {
229
+ errors.push(`Step '${step.id}' is missing required batch input file '${file.name}'.`);
230
+ }
231
+ const remotePath = value && typeof value === "object" && !Array.isArray(value)
232
+ ? (value as { remotePath?: unknown }).remotePath
233
+ : undefined;
234
+ if (hasBatchInputFilePayload(value) && !file.remotePath && !remotePath) {
235
+ errors.push(`Step '${step.id}' batch input file '${file.name}' needs a remote path.`);
236
+ }
237
+ }
238
+ }
239
+
240
+ function validateValue(stepId: string, name: string, value: unknown, spec: CatalogParam, errors: string[]): void {
241
+ if (value === undefined || value === null) return;
242
+ if (typeof value === "string" && /^\$\{[A-Za-z0-9_.-]+\}$/.test(value)) return;
243
+ if (spec.type?.endsWith("[]")) {
244
+ const expectedItemType = spec.type.slice(0, -"[]".length);
245
+ const values = Array.isArray(value) ? value : [value];
246
+ if (values.length === 0) {
247
+ errors.push(`Step '${stepId}' param '${name}' must contain at least one value.`);
248
+ return;
249
+ }
250
+ for (const item of values) {
251
+ if (typeof item !== expectedItemType) {
252
+ errors.push(`Step '${stepId}' param '${name}' must be ${spec.type}; got ${typeof item} item.`);
253
+ }
254
+ if (spec.pattern && typeof item === "string" && !new RegExp(spec.pattern).test(item)) {
255
+ errors.push(`Step '${stepId}' param '${name}' does not match ${spec.pattern}.`);
256
+ }
257
+ }
258
+ return;
259
+ }
260
+ if (spec.type && typeof value !== spec.type) {
261
+ errors.push(`Step '${stepId}' param '${name}' must be ${spec.type}; got ${typeof value}.`);
262
+ }
263
+ if (spec.pattern && typeof value === "string" && !new RegExp(spec.pattern).test(value)) {
264
+ errors.push(`Step '${stepId}' param '${name}' does not match ${spec.pattern}.`);
265
+ }
266
+ }
267
+
268
+ function findVariableRefs(value: unknown): string[] {
269
+ const refs: string[] = [];
270
+ const visit = (current: unknown) => {
271
+ if (typeof current === "string") {
272
+ for (const match of current.matchAll(/\$\{([A-Za-z0-9_.-]+)\}/g)) refs.push(match[1]);
273
+ for (const match of current.matchAll(/\{\{([A-Za-z0-9_.-]+)\}\}/g)) refs.push(match[1]);
274
+ } else if (Array.isArray(current)) {
275
+ current.forEach(visit);
276
+ } else if (current && typeof current === "object") {
277
+ Object.values(current).forEach(visit);
278
+ }
279
+ };
280
+ visit(value);
281
+ return refs;
282
+ }