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,123 @@
1
+ const secretKeyFragments = [
2
+ "password",
3
+ "passwd",
4
+ "pwd",
5
+ "token",
6
+ "authorization",
7
+ "apikey",
8
+ "api_key",
9
+ "privatekey",
10
+ "private_key",
11
+ "jwt",
12
+ "secret",
13
+ "credential",
14
+ "bearer",
15
+ "access_token",
16
+ "refreshtoken",
17
+ "refresh_token",
18
+ "servicepassword",
19
+ "apikey",
20
+ "session",
21
+ "cookie",
22
+ "pin",
23
+ "cvv",
24
+ "cvc",
25
+ "cvv2"
26
+ ];
27
+ const cardKeyExact = new Set([
28
+ "pan",
29
+ "pans",
30
+ "cardnumber",
31
+ "cardnumbers",
32
+ "cardno",
33
+ "primaryaccountnumber",
34
+ "primaryaccountnumbers"
35
+ ]);
36
+ const REDACTED = "<redacted>";
37
+ export function redactSecrets(value) {
38
+ return redact(value);
39
+ }
40
+ export function evidenceVisibilityMode() {
41
+ return process.env.ADFINEM_EVIDENCE_VISIBILITY?.toLowerCase() === "redacted" ? "redacted" : "raw";
42
+ }
43
+ export function applyEvidenceVisibility(value, mode = evidenceVisibilityMode()) {
44
+ return mode === "redacted" ? redactSecrets(value) : value;
45
+ }
46
+ /**
47
+ * Heavier redaction intended for shared evidence artifacts.
48
+ * Combines key-based redaction with inline PAN-pattern scrubbing on every string.
49
+ */
50
+ export function redactEvidence(value) {
51
+ return redactDeep(value, true);
52
+ }
53
+ export function maskInlinePans(text) {
54
+ if (!text)
55
+ return text;
56
+ return text.replace(/(?<!\d)(\d[\d\s.-]{10,21}\d)(?!\d)/g, (match) => {
57
+ const digits = match.replace(/\D+/g, "");
58
+ if (digits.length < 12 || digits.length > 19)
59
+ return match;
60
+ if (!isLuhnValid(digits))
61
+ return match;
62
+ return `****${digits.slice(-4)}`;
63
+ });
64
+ }
65
+ export function isLuhnValid(digits) {
66
+ if (!/^\d+$/.test(digits))
67
+ return false;
68
+ let sum = 0;
69
+ let alt = false;
70
+ for (let index = digits.length - 1; index >= 0; index--) {
71
+ let n = digits.charCodeAt(index) - 48;
72
+ if (alt) {
73
+ n *= 2;
74
+ if (n > 9)
75
+ n -= 9;
76
+ }
77
+ sum += n;
78
+ alt = !alt;
79
+ }
80
+ return sum > 0 && sum % 10 === 0;
81
+ }
82
+ function redact(value) {
83
+ return redactDeep(value, false);
84
+ }
85
+ function redactDeep(value, scrubStrings) {
86
+ if (typeof value === "string")
87
+ return scrubStrings ? maskInlinePans(value) : value;
88
+ if (Array.isArray(value))
89
+ return value.map((entry) => redactDeep(entry, scrubStrings));
90
+ if (!value || typeof value !== "object")
91
+ return value;
92
+ return Object.fromEntries(Object.entries(value).map(([key, entry]) => {
93
+ const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, "");
94
+ if (secretKeyFragments.some((fragment) => normalized.includes(fragment))) {
95
+ return [key, REDACTED];
96
+ }
97
+ if (cardKeyExact.has(normalized)) {
98
+ return [key, maskCardLike(entry, scrubStrings)];
99
+ }
100
+ return [key, redactDeep(entry, scrubStrings)];
101
+ }));
102
+ }
103
+ function maskCardLike(value, scrubStrings) {
104
+ if (Array.isArray(value))
105
+ return value.map((entry) => maskCardLike(entry, scrubStrings));
106
+ if (typeof value === "string" || typeof value === "number") {
107
+ return maskPan(String(value));
108
+ }
109
+ if (value && typeof value === "object") {
110
+ return redactDeep(value, scrubStrings);
111
+ }
112
+ return value;
113
+ }
114
+ function maskPan(value) {
115
+ const digits = value.replace(/\D+/g, "");
116
+ if (digits.length < 12)
117
+ return value;
118
+ if (digits.length >= 12 && digits.length <= 19) {
119
+ return `${digits.slice(0, 4)}${"*".repeat(Math.max(4, digits.length - 8))}${digits.slice(-4)}`;
120
+ }
121
+ const last4 = digits.slice(-4);
122
+ return `****${last4}`;
123
+ }
@@ -0,0 +1,20 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import YAML from "yaml";
3
+ import { scenarioSchema, queryCatalogSchema, batchCatalogSchema, apiOperationsCatalogSchema } from "./schema.js";
4
+ import { importedOperationsFromCollections, loadApiCollections } from "../adapters/api/api-collections.js";
5
+ import { normalizeQueryCatalog } from "../adapters/db/query-catalog.js";
6
+ export async function loadYamlFile(path) {
7
+ const raw = await readFile(path, "utf8");
8
+ return YAML.parse(raw);
9
+ }
10
+ export async function loadScenario(path) {
11
+ const parsed = await loadYamlFile(path);
12
+ return scenarioSchema.parse(parsed);
13
+ }
14
+ export async function loadCatalogs(rootDir) {
15
+ const queries = normalizeQueryCatalog(queryCatalogSchema.parse(await loadYamlFile(`${rootDir}/catalogs/queries.yaml`)));
16
+ const batches = batchCatalogSchema.parse(await loadYamlFile(`${rootDir}/catalogs/batches.yaml`));
17
+ const apiOperations = apiOperationsCatalogSchema.parse(await loadYamlFile(`${rootDir}/catalogs/api-operations.yaml`));
18
+ const importedApiOperations = importedOperationsFromCollections(await loadApiCollections(rootDir));
19
+ return { queries, batches, apiOperations: { ...apiOperations, ...importedApiOperations } };
20
+ }
@@ -0,0 +1,182 @@
1
+ import { z } from "zod";
2
+ const value = z.union([z.string(), z.number(), z.boolean(), z.null()]);
3
+ const catalogParamType = z.enum(["string", "number", "boolean", "string[]", "number[]", "boolean[]"]);
4
+ const apiMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]);
5
+ const apiBodyMode = z.enum(["none", "json", "raw", "formdata", "urlencoded"]);
6
+ const apiRequestSpecSchema = z.object({
7
+ method: apiMethod.optional(),
8
+ path: z.string().optional(),
9
+ headers: z.record(z.string()).optional(),
10
+ query: z.record(z.unknown()).optional(),
11
+ body: z.unknown().optional(),
12
+ rawBody: z.string().optional(),
13
+ bodyMode: apiBodyMode.optional(),
14
+ auth: z.unknown().optional(),
15
+ acceptStatuses: z.array(z.number().int().min(100).max(599)).optional()
16
+ }).strict();
17
+ const apiAssertionSchema = z.discriminatedUnion("type", [
18
+ z.object({
19
+ type: z.literal("status"),
20
+ operator: z.enum(["in", "="]).optional(),
21
+ value: z.union([z.number().int().min(100).max(599), z.array(z.number().int().min(100).max(599))])
22
+ }).strict(),
23
+ z.object({ type: z.literal("jsonpath_exists"), path: z.string().min(1) }).strict(),
24
+ z.object({ type: z.literal("jsonpath_equals"), path: z.string().min(1), value: z.unknown() }).strict(),
25
+ z.object({ type: z.literal("jsonpath_contains"), path: z.string().min(1), value: z.unknown() }).strict(),
26
+ z.object({ type: z.literal("header_exists"), header: z.string().min(1) }).strict(),
27
+ z.object({ type: z.literal("header_equals"), header: z.string().min(1), value: z.string() }).strict(),
28
+ z.object({ type: z.literal("body_contains"), value: z.string() }).strict(),
29
+ z.object({ type: z.literal("body_not_contains"), value: z.string() }).strict()
30
+ ]);
31
+ const expectedOutcomeSchema = z.enum(["positive", "negative", "setup", "teardown"]);
32
+ const parallelJoinModeSchema = z.enum(["all", "any", "fail_fast"]);
33
+ const loopDateFormatSchema = z.enum(["YYYY-MM-DD", "DD/MM/YYYY", "MM/DD/YYYY"]);
34
+ const loopDateCursorSchema = z.object({
35
+ outputName: z.string().min(1).optional(),
36
+ start: z.string().min(1).optional(),
37
+ inputFormat: loopDateFormatSchema.optional(),
38
+ outputFormat: loopDateFormatSchema.optional(),
39
+ advance: z.object({
40
+ mode: z.enum(["days", "months", "nth_day_of_month", "first_of_month", "end_of_month"]),
41
+ amount: z.number().int().positive().optional(),
42
+ day: z.number().int().min(1).max(31).optional()
43
+ }).strict().optional()
44
+ }).strict();
45
+ const loopSpecSchema = z.object({
46
+ mode: z.enum(["count", "foreach"]),
47
+ count: z.union([z.number().int().nonnegative(), z.string()]).optional(),
48
+ items: z.unknown().optional(),
49
+ itemName: z.string().min(1).optional(),
50
+ maxIterations: z.number().int().positive().optional(),
51
+ dateCursor: loopDateCursorSchema.optional()
52
+ }).strict();
53
+ const catalogParamSchema = z.object({
54
+ required: z.boolean().optional(),
55
+ type: catalogParamType.optional(),
56
+ pattern: z.string().optional(),
57
+ luhn: z.boolean().optional()
58
+ }).strict();
59
+ const batchInputFileSchema = z.object({
60
+ name: z.string().min(1),
61
+ required: z.boolean().optional(),
62
+ remotePath: z.string().min(1).optional(),
63
+ paramName: z.string().min(1).optional(),
64
+ appendAsArg: z.boolean().optional()
65
+ }).strict();
66
+ const batchOutputFileSchema = z.object({
67
+ name: z.string().min(1),
68
+ required: z.boolean().optional(),
69
+ source: z.enum(["stdout", "stderr", "both", "explicit"]).optional(),
70
+ pathPattern: z.string().min(1).optional(),
71
+ remotePath: z.string().min(1).optional(),
72
+ download: z.boolean().optional(),
73
+ decrypt: z.object({
74
+ command: z.string().min(1).optional(),
75
+ outputRemotePath: z.string().min(1).optional(),
76
+ required: z.boolean().optional()
77
+ }).strict().optional()
78
+ }).strict();
79
+ export const scenarioStepSchema = z.lazy(() => z.object({
80
+ id: z.string().min(1).regex(/^[A-Za-z0-9_-]+$/),
81
+ action: z.string().min(1),
82
+ via: z.string().optional(),
83
+ retry: z.object({
84
+ attempts: z.number().int().positive().optional(),
85
+ delaySeconds: z.number().nonnegative().optional()
86
+ }).strict().optional(),
87
+ input: z.record(z.unknown()).optional(),
88
+ params: z.record(z.unknown()).optional(),
89
+ query: z.string().optional(),
90
+ batch: z.string().optional(),
91
+ request: apiRequestSpecSchema.optional(),
92
+ assertions: z.array(apiAssertionSchema).optional(),
93
+ capture: z.record(z.string()).optional(),
94
+ continueOnFailure: z.boolean().optional(),
95
+ expectedOutcome: expectedOutcomeSchema.optional(),
96
+ captureOnFailure: z.boolean().optional(),
97
+ control: z.enum(["parallel", "loop"]).optional(),
98
+ branches: z.array(z.object({
99
+ id: z.string().min(1).regex(/^[A-Za-z0-9_-]+$/),
100
+ label: z.string().min(1).optional(),
101
+ steps: z.array(scenarioStepSchema)
102
+ }).strict()).optional(),
103
+ steps: z.array(scenarioStepSchema).optional(),
104
+ loop: loopSpecSchema.optional(),
105
+ join: parallelJoinModeSchema.optional()
106
+ }).strict());
107
+ export const scenarioSchema = z.object({
108
+ id: z.string().min(1).regex(/^[A-Za-z0-9_-]+$/),
109
+ environment: z.string().min(1),
110
+ tenant: z.record(z.string()).optional(),
111
+ variables: z.record(z.union([value, z.array(value), z.record(value)])).optional(),
112
+ steps: z.array(scenarioStepSchema).min(1)
113
+ }).strict();
114
+ export const queryCatalogSchema = z.record(z.object({
115
+ description: z.string().optional(),
116
+ mode: z.enum(["query", "execute"]).optional(),
117
+ sql: z.string().min(1),
118
+ params: z.record(catalogParamSchema).optional(),
119
+ expect: z.object({
120
+ type: z.enum(["number", "string", "boolean", "rowCount"]),
121
+ column: z.string().optional(),
122
+ operator: z.enum(["=", "!=", ">", ">=", "<", "<=", "contains"]),
123
+ value: z.unknown()
124
+ }).strict()
125
+ .refine((expect) => expect.type === "rowCount" || Boolean(expect.column), {
126
+ message: "expect.column is required when expect.type is not rowCount."
127
+ })
128
+ .optional(),
129
+ captures: z.record(z.string()).optional(),
130
+ maxRows: z.number().int().positive().optional()
131
+ }).strict());
132
+ export const batchCatalogSchema = z.record(z.object({
133
+ description: z.string().optional(),
134
+ hostRef: z.string().min(1),
135
+ command: z.string().min(1).refine((value) => !/[\r\n]/.test(value), {
136
+ message: "command must be a single executable token; put arguments in fixedArgs."
137
+ }),
138
+ fixedArgs: z.array(z.union([z.string(), z.number(), z.boolean()])).optional(),
139
+ workingDirectory: z.string().min(1).optional(),
140
+ useWorkingDirectory: z.boolean().optional(),
141
+ environment: z.record(z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/), z.union([z.string(), z.number(), z.boolean()])).optional(),
142
+ args: z.array(z.object({
143
+ name: z.string().min(1),
144
+ required: z.boolean().optional(),
145
+ type: catalogParamType.optional(),
146
+ pattern: z.string().optional(),
147
+ luhn: z.boolean().optional()
148
+ }).strict()).optional(),
149
+ inputFiles: z.array(batchInputFileSchema).optional(),
150
+ outputFiles: z.array(batchOutputFileSchema).optional(),
151
+ timeoutSeconds: z.number().int().positive().optional(),
152
+ success: z.object({
153
+ exitCodes: z.array(z.number().int()).optional(),
154
+ requiredOutput: z.array(z.string()).optional()
155
+ }).strict().optional(),
156
+ captures: z.record(z.string()).optional()
157
+ }).strict());
158
+ export const apiOperationsCatalogSchema = z.record(z.object({
159
+ description: z.string().optional(),
160
+ type: z.enum(["rest", "soap"]),
161
+ method: apiMethod.optional(),
162
+ path: z.string().optional(),
163
+ headers: z.record(z.string()).optional(),
164
+ query: z.record(z.unknown()).optional(),
165
+ body: z.unknown().optional(),
166
+ rawBody: z.string().optional(),
167
+ bodyMode: apiBodyMode.optional(),
168
+ auth: z.unknown().optional(),
169
+ params: z.record(catalogParamSchema).optional(),
170
+ assertions: z.array(apiAssertionSchema).optional(),
171
+ requestTemplate: z.string().optional(),
172
+ captures: z.record(z.string()).optional(),
173
+ acceptStatuses: z.array(z.number().int().min(100).max(599)).optional(),
174
+ idempotent: z.boolean().optional(),
175
+ source: z.object({
176
+ collectionId: z.string().optional(),
177
+ collectionName: z.string().optional(),
178
+ requestId: z.string().optional(),
179
+ folderPath: z.array(z.string()).optional()
180
+ }).strict().optional()
181
+ }).strict());
182
+ export { apiRequestSpecSchema, apiAssertionSchema };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,264 @@
1
+ import { registeredActions } from "../config/registry.js";
2
+ import { normalizeBindParamRecord } from "../adapters/db/query-catalog.js";
3
+ import { batchArgParamsForValidation, batchInputFiles, hasBatchInputFilePayload } from "../adapters/unix/batch-input-files.js";
4
+ export function validateScenarioReferences(scenario, catalogs, options = {}) {
5
+ const errors = [];
6
+ const warnings = [];
7
+ if (options.knownEnvironments && options.knownEnvironments.length > 0
8
+ && !options.knownEnvironments.includes(scenario.environment)) {
9
+ warnings.push(`Scenario environment '${scenario.environment}' is not declared in config/environments.yaml. Known: ${options.knownEnvironments.join(", ")}.`);
10
+ }
11
+ const stepIds = new Set();
12
+ const availableVariables = new Set(Object.keys(scenario.variables ?? {}));
13
+ for (const key of Object.keys(scenario.tenant ?? {})) {
14
+ availableVariables.add(`tenant.${key}`);
15
+ }
16
+ for (const step of scenario.steps) {
17
+ if (step.control === "parallel" || step.action === "__parallel") {
18
+ validateControlStep(step, catalogs, errors, warnings, stepIds, availableVariables);
19
+ continue;
20
+ }
21
+ if (step.control === "loop" || step.action === "__loop") {
22
+ validateControlStep(step, catalogs, errors, warnings, stepIds, availableVariables);
23
+ const outputName = step.loop?.dateCursor?.outputName?.trim() || (step.loop?.dateCursor ? "business_date" : undefined);
24
+ if (outputName) {
25
+ availableVariables.add(outputName);
26
+ availableVariables.add(`${step.id}.${outputName}`);
27
+ availableVariables.add(`${step.id}.last.${outputName}`);
28
+ availableVariables.add(`${step.id}.all.${outputName}`);
29
+ }
30
+ for (const [childId, captureName] of loopChildCaptureNames(step, catalogs)) {
31
+ availableVariables.add(`${childId}.${captureName}`);
32
+ availableVariables.add(`${step.id}.last.${childId}.${captureName}`);
33
+ availableVariables.add(`${step.id}.all.${childId}.${captureName}`);
34
+ }
35
+ continue;
36
+ }
37
+ if (stepIds.has(step.id)) {
38
+ errors.push(`Duplicate step id '${step.id}'.`);
39
+ }
40
+ stepIds.add(step.id);
41
+ const action = registeredActions[step.action];
42
+ if (!action) {
43
+ if (step.via === "api" && catalogs.apiOperations[step.action]) {
44
+ validateApiStep(step, catalogs, errors);
45
+ }
46
+ else {
47
+ errors.push(`Step '${step.id}' uses unknown action '${step.action}'.`);
48
+ continue;
49
+ }
50
+ }
51
+ else if (step.via && !action.supportedVia.includes(step.via)) {
52
+ errors.push(`Step '${step.id}' action '${step.action}' does not support via '${step.via}'. Supported: ${action.supportedVia.join(", ")}.`);
53
+ }
54
+ if (step.action === "db_assert" || step.action === "db_query" || step.action === "db_execute") {
55
+ if (!step.query)
56
+ errors.push(`Step '${step.id}' must specify query.`);
57
+ if (step.query && !catalogs.queries[step.query])
58
+ errors.push(`Step '${step.id}' references unknown query '${step.query}'.`);
59
+ if (step.action === "db_assert" && step.query && catalogs.queries[step.query] && !catalogs.queries[step.query].expect) {
60
+ errors.push(`Step '${step.id}' uses db_assert but query '${step.query}' has no expect block.`);
61
+ }
62
+ if (step.action === "db_execute" && step.query && catalogs.queries[step.query]?.mode !== "execute") {
63
+ errors.push(`Step '${step.id}' uses db_execute but query '${step.query}' is not marked mode: execute.`);
64
+ }
65
+ if (step.query && catalogs.queries[step.query]) {
66
+ validateParams(step, normalizeBindParamRecord(catalogs.queries[step.query].params ?? {}), normalizeBindParamRecord(step.params ?? step.input ?? {}), errors, "query");
67
+ }
68
+ }
69
+ if (step.action === "unix_batch") {
70
+ if (!step.batch)
71
+ errors.push(`Step '${step.id}' must specify batch.`);
72
+ if (step.batch && !catalogs.batches[step.batch])
73
+ errors.push(`Step '${step.id}' references unknown batch '${step.batch}'.`);
74
+ if (step.batch && catalogs.batches[step.batch]) {
75
+ const batch = catalogs.batches[step.batch];
76
+ const params = step.params ?? step.input ?? {};
77
+ validateBatchInputFiles(step, batch, params, errors);
78
+ validateArgs(step, batch.args ?? [], batchArgParamsForValidation(params, batch), errors);
79
+ }
80
+ }
81
+ if (step.via === "api")
82
+ validateApiStep(step, catalogs, errors);
83
+ const localVariables = new Set(Object.keys(step.input ?? step.params ?? {}));
84
+ for (const variable of findVariableRefs(step)) {
85
+ if (!availableVariables.has(variable) && !localVariables.has(variable)) {
86
+ errors.push(`Step '${step.id}' references unknown variable '${variable}'.`);
87
+ }
88
+ }
89
+ const apiCatalogCaptures = step.via === "api" ? catalogs.apiOperations[step.action]?.captures ?? {} : {};
90
+ const queryCatalogCaptures = step.query ? catalogs.queries[step.query]?.captures ?? {} : {};
91
+ const batchCatalogCaptures = step.batch ? catalogs.batches[step.batch]?.captures ?? {} : {};
92
+ for (const captureName of [
93
+ ...Object.keys(apiCatalogCaptures),
94
+ ...Object.keys(queryCatalogCaptures),
95
+ ...Object.keys(batchCatalogCaptures),
96
+ ...Object.keys(step.capture ?? {})
97
+ ]) {
98
+ availableVariables.add(captureName);
99
+ availableVariables.add(`${step.id}.${captureName}`);
100
+ }
101
+ }
102
+ return { ok: errors.length === 0, errors, warnings: warnings.length ? warnings : undefined };
103
+ }
104
+ function validateApiStep(step, catalogs, errors) {
105
+ const operation = catalogs.apiOperations[step.action];
106
+ if (!operation) {
107
+ errors.push(`Step '${step.id}' uses via api but no API operation '${step.action}' exists in api-operations.yaml.`);
108
+ return;
109
+ }
110
+ validateParams(step, operation.params, step.input ?? step.params ?? {}, errors, "API variable");
111
+ }
112
+ function validateControlStep(step, catalogs, errors, warnings, stepIds, availableVariables) {
113
+ if (stepIds.has(step.id))
114
+ errors.push(`Duplicate step id '${step.id}'.`);
115
+ stepIds.add(step.id);
116
+ if (step.control === "parallel" || step.action === "__parallel") {
117
+ if (!step.branches?.length)
118
+ errors.push(`Parallel step '${step.id}' must contain at least one branch.`);
119
+ for (const branch of step.branches ?? []) {
120
+ const nested = validateScenarioReferences({ id: `${step.id}_${branch.id}`, environment: "nested", variables: Object.fromEntries([...availableVariables].map((key) => [key, "available"])), steps: branch.steps }, catalogs);
121
+ errors.push(...nested.errors.map((error) => `Parallel '${step.id}' branch '${branch.id}': ${error}`));
122
+ warnings.push(...(nested.warnings ?? []).map((warning) => `Parallel '${step.id}' branch '${branch.id}': ${warning}`));
123
+ }
124
+ }
125
+ if (step.control === "loop" || step.action === "__loop") {
126
+ if (!step.steps?.length)
127
+ errors.push(`Loop step '${step.id}' must contain at least one child step.`);
128
+ if (step.loop?.mode === "count" && step.loop.count === undefined)
129
+ errors.push(`Loop step '${step.id}' mode count requires loop.count.`);
130
+ if (step.loop?.mode === "foreach" && step.loop.items === undefined)
131
+ errors.push(`Loop step '${step.id}' mode foreach requires loop.items.`);
132
+ const loopVariables = new Set(availableVariables);
133
+ loopVariables.add(`${step.id}.index`);
134
+ loopVariables.add(`${step.id}.number`);
135
+ loopVariables.add(`${step.id}.total`);
136
+ loopVariables.add(`${step.id}.item`);
137
+ if (step.loop?.itemName) {
138
+ loopVariables.add(step.loop.itemName);
139
+ loopVariables.add(`${step.id}.${step.loop.itemName}`);
140
+ }
141
+ const outputName = step.loop?.dateCursor?.outputName?.trim() || (step.loop?.dateCursor ? "business_date" : undefined);
142
+ if (outputName) {
143
+ loopVariables.add(outputName);
144
+ loopVariables.add(`${step.id}.${outputName}`);
145
+ loopVariables.add(`${step.id}.last.${outputName}`);
146
+ loopVariables.add(`${step.id}.all.${outputName}`);
147
+ }
148
+ const nested = validateScenarioReferences({ id: `${step.id}_loop`, environment: "nested", variables: Object.fromEntries([...loopVariables].map((key) => [key, "available"])), steps: step.steps ?? [] }, catalogs);
149
+ errors.push(...nested.errors.map((error) => `Loop '${step.id}': ${error}`));
150
+ warnings.push(...(nested.warnings ?? []).map((warning) => `Loop '${step.id}': ${warning}`));
151
+ }
152
+ }
153
+ function loopChildCaptureNames(step, catalogs) {
154
+ const captures = [];
155
+ const visit = (child) => {
156
+ const catalogCaptures = child.via === "api"
157
+ ? catalogs.apiOperations[child.action]?.captures ?? {}
158
+ : child.query
159
+ ? catalogs.queries[child.query]?.captures ?? {}
160
+ : child.batch
161
+ ? catalogs.batches[child.batch]?.captures ?? {}
162
+ : {};
163
+ for (const captureName of [...Object.keys(catalogCaptures), ...Object.keys(child.capture ?? {})]) {
164
+ captures.push([child.id, captureName]);
165
+ }
166
+ if (child.control === "loop" || child.action === "__loop") {
167
+ for (const nested of child.steps ?? [])
168
+ visit(nested);
169
+ }
170
+ if (child.control === "parallel" || child.action === "__parallel") {
171
+ for (const branch of child.branches ?? []) {
172
+ for (const nested of branch.steps)
173
+ visit(nested);
174
+ }
175
+ }
176
+ };
177
+ for (const child of step.steps ?? [])
178
+ visit(child);
179
+ return captures;
180
+ }
181
+ function validateParams(step, specs, params, errors, label) {
182
+ for (const [name, spec] of Object.entries(specs ?? {})) {
183
+ const value = params[name];
184
+ if (spec.required && (value === undefined || value === null || value === "")) {
185
+ errors.push(`Step '${step.id}' is missing required ${label} param '${name}'.`);
186
+ continue;
187
+ }
188
+ validateValue(step.id, name, value, spec, errors);
189
+ }
190
+ }
191
+ function validateArgs(step, specs, params, errors) {
192
+ for (const spec of specs) {
193
+ if (!Object.prototype.hasOwnProperty.call(params, spec.name))
194
+ continue;
195
+ const value = params[spec.name];
196
+ if (spec.required !== false && (value === undefined || value === null || value === "")) {
197
+ errors.push(`Step '${step.id}' is missing required batch arg '${spec.name}'.`);
198
+ continue;
199
+ }
200
+ validateValue(step.id, spec.name, value, spec, errors);
201
+ }
202
+ }
203
+ function validateBatchInputFiles(step, batch, params, errors) {
204
+ for (const file of batchInputFiles(batch)) {
205
+ const value = params[file.name];
206
+ if (file.required !== false && !hasBatchInputFilePayload(value)) {
207
+ errors.push(`Step '${step.id}' is missing required batch input file '${file.name}'.`);
208
+ }
209
+ const remotePath = value && typeof value === "object" && !Array.isArray(value)
210
+ ? value.remotePath
211
+ : undefined;
212
+ if (hasBatchInputFilePayload(value) && !file.remotePath && !remotePath) {
213
+ errors.push(`Step '${step.id}' batch input file '${file.name}' needs a remote path.`);
214
+ }
215
+ }
216
+ }
217
+ function validateValue(stepId, name, value, spec, errors) {
218
+ if (value === undefined || value === null)
219
+ return;
220
+ if (typeof value === "string" && /^\$\{[A-Za-z0-9_.-]+\}$/.test(value))
221
+ return;
222
+ if (spec.type?.endsWith("[]")) {
223
+ const expectedItemType = spec.type.slice(0, -"[]".length);
224
+ const values = Array.isArray(value) ? value : [value];
225
+ if (values.length === 0) {
226
+ errors.push(`Step '${stepId}' param '${name}' must contain at least one value.`);
227
+ return;
228
+ }
229
+ for (const item of values) {
230
+ if (typeof item !== expectedItemType) {
231
+ errors.push(`Step '${stepId}' param '${name}' must be ${spec.type}; got ${typeof item} item.`);
232
+ }
233
+ if (spec.pattern && typeof item === "string" && !new RegExp(spec.pattern).test(item)) {
234
+ errors.push(`Step '${stepId}' param '${name}' does not match ${spec.pattern}.`);
235
+ }
236
+ }
237
+ return;
238
+ }
239
+ if (spec.type && typeof value !== spec.type) {
240
+ errors.push(`Step '${stepId}' param '${name}' must be ${spec.type}; got ${typeof value}.`);
241
+ }
242
+ if (spec.pattern && typeof value === "string" && !new RegExp(spec.pattern).test(value)) {
243
+ errors.push(`Step '${stepId}' param '${name}' does not match ${spec.pattern}.`);
244
+ }
245
+ }
246
+ function findVariableRefs(value) {
247
+ const refs = [];
248
+ const visit = (current) => {
249
+ if (typeof current === "string") {
250
+ for (const match of current.matchAll(/\$\{([A-Za-z0-9_.-]+)\}/g))
251
+ refs.push(match[1]);
252
+ for (const match of current.matchAll(/\{\{([A-Za-z0-9_.-]+)\}\}/g))
253
+ refs.push(match[1]);
254
+ }
255
+ else if (Array.isArray(current)) {
256
+ current.forEach(visit);
257
+ }
258
+ else if (current && typeof current === "object") {
259
+ Object.values(current).forEach(visit);
260
+ }
261
+ };
262
+ visit(value);
263
+ return refs;
264
+ }
@@ -0,0 +1,68 @@
1
+ import { JSONPath } from "jsonpath-plus";
2
+ export function extractCaptures(payload, specs) {
3
+ const captures = {};
4
+ for (const [name, expression] of Object.entries(specs ?? {})) {
5
+ const value = extractCaptureValue(payload, expression);
6
+ if (value !== undefined)
7
+ captures[name] = value;
8
+ }
9
+ return captures;
10
+ }
11
+ export function mergeCaptureSpecs(...specs) {
12
+ return Object.assign({}, ...specs.filter(Boolean));
13
+ }
14
+ function extractCaptureValue(payload, expression) {
15
+ const { expression: trimmed, required } = parseCaptureExpression(expression);
16
+ if (!trimmed)
17
+ throw new Error("Capture expression must not be empty.");
18
+ try {
19
+ if (trimmed.startsWith("literal:"))
20
+ return trimmed.slice("literal:".length);
21
+ if (trimmed.startsWith("regex:"))
22
+ return extractRegexCapture(payload, trimmed);
23
+ return extractJsonPathCapture(payload, normalizeJsonPath(trimmed), trimmed);
24
+ }
25
+ catch (error) {
26
+ if (!required && isCaptureNoMatch(error))
27
+ return undefined;
28
+ throw error;
29
+ }
30
+ }
31
+ function parseCaptureExpression(expression) {
32
+ const trimmed = expression.trim();
33
+ if (trimmed.startsWith("optional:")) {
34
+ return { expression: trimmed.slice("optional:".length).trim(), required: false };
35
+ }
36
+ return { expression: trimmed, required: true };
37
+ }
38
+ function extractJsonPathCapture(payload, jsonPath, original) {
39
+ const json = payload;
40
+ const values = JSONPath({ path: jsonPath, json, wrap: true });
41
+ if (values.length === 0)
42
+ throw new Error(`Capture expression '${original}' did not match any value.`);
43
+ return values.length === 1 ? values[0] : values;
44
+ }
45
+ function extractRegexCapture(payload, expression) {
46
+ const parts = expression.split(":");
47
+ if (parts.length < 3) {
48
+ throw new Error(`Invalid regex capture '${expression}'. Use regex:<jsonpath>:<pattern>.`);
49
+ }
50
+ const sourcePath = parts[1]?.trim() || "$";
51
+ const pattern = parts.slice(2).join(":");
52
+ const source = extractJsonPathCapture(payload, normalizeJsonPath(sourcePath), sourcePath);
53
+ const match = String(source ?? "").match(new RegExp(pattern, "m"));
54
+ if (!match)
55
+ throw new Error(`Regex capture '${expression}' did not match.`);
56
+ return match[1] ?? match[0];
57
+ }
58
+ function normalizeJsonPath(expression) {
59
+ if (expression.startsWith("$"))
60
+ return expression;
61
+ if (/^[A-Za-z_][A-Za-z0-9_]*(?:[.[].*)?$/.test(expression))
62
+ return `$.${expression}`;
63
+ throw new Error(`Unsupported capture expression '${expression}'. Use JSONPath, simple property path, literal:, or regex:.`);
64
+ }
65
+ function isCaptureNoMatch(error) {
66
+ const message = error instanceof Error ? error.message : String(error);
67
+ return /did not match any value|did not match\./i.test(message);
68
+ }