@icyouo/evt-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,426 @@
1
+ const fs = require("node:fs");
2
+ const path = require("node:path");
3
+ const { spawnSync } = require("node:child_process");
4
+ const { cliRoot, configRoot, resolveCliPath } = require("../util/paths");
5
+
6
+ const repoRoot = path.resolve(cliRoot, "..");
7
+
8
+ const STRING_PATTERN = `"([^"]+)"|'([^']+)'|\`([^\`]+)\``;
9
+ const METHOD_MAP = {
10
+ get: "GET",
11
+ getRaw: "GET",
12
+ fetch: "GET",
13
+ post: "POST",
14
+ postForm: "POST",
15
+ patch: "PATCH",
16
+ put: "PUT",
17
+ delete: "DELETE",
18
+ deleteWithBody: "DELETE",
19
+ postUrlEncoded: "POST",
20
+ upload: "POST"
21
+ };
22
+
23
+ const LANGUAGE_DEFAULTS = {
24
+ kotlin: {
25
+ extensions: [".kt"],
26
+ receivers: ["http"],
27
+ helpers: ["get", "post", "patch", "put", "delete", "deleteWithBody", "postUrlEncoded", "postForm", "getRaw"],
28
+ functionPatterns: [
29
+ /(?:override\s+)?suspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)/g,
30
+ /fun\s+([A-Za-z_][A-Za-z0-9_]*)/g
31
+ ],
32
+ namespaceStripPrefixes: ["I"],
33
+ namespaceStripSuffixes: ["Service"]
34
+ },
35
+ swift: {
36
+ extensions: [".swift"],
37
+ receivers: ["http", "client", "api"],
38
+ helpers: ["get", "post", "patch", "put", "delete", "upload"],
39
+ functionPatterns: [/func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g],
40
+ namespaceStripSuffixes: ["Service", "API", "Api"]
41
+ },
42
+ js: {
43
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"],
44
+ receivers: ["http", "client", "api", "axios"],
45
+ helpers: ["get", "post", "patch", "put", "delete"],
46
+ functionPatterns: [
47
+ /(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g,
48
+ /(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(/g,
49
+ /([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*(?:async\s*)?function\s*\(/g,
50
+ /([A-Za-z_$][A-Za-z0-9_$]*)\s*\([^)]*\)\s*\{/g
51
+ ],
52
+ namespaceStripSuffixes: ["Service", "Api", "API"]
53
+ },
54
+ dart: {
55
+ extensions: [".dart"],
56
+ receivers: ["http", "client", "api", "dio"],
57
+ helpers: ["get", "post", "patch", "put", "delete"],
58
+ functionPatterns: [
59
+ /(?:Future(?:<[^>]+>)?|Stream(?:<[^>]+>)?|void|[A-Za-z_][A-Za-z0-9_<>?]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)\s*(?:async\s*)?\{/g
60
+ ],
61
+ namespaceStripSuffixes: ["Service", "Api", "API"]
62
+ }
63
+ };
64
+
65
+ function defaultScanConfig() {
66
+ return {
67
+ root: configRoot(),
68
+ targets: []
69
+ };
70
+ }
71
+
72
+ function parseCliScanOptions(tokens = []) {
73
+ const options = {};
74
+ for (let index = 0; index < tokens.length; index += 1) {
75
+ const token = tokens[index];
76
+ if (token === "--scan-config") {
77
+ index += 1;
78
+ options.scanConfig = tokens[index];
79
+ } else if (token.startsWith("--scan-config=")) {
80
+ options.scanConfig = token.slice("--scan-config=".length);
81
+ } else if (token === "--config-root") {
82
+ index += 1;
83
+ options.configRoot = tokens[index];
84
+ } else if (token.startsWith("--config-root=")) {
85
+ options.configRoot = token.slice("--config-root=".length);
86
+ }
87
+ }
88
+ return options;
89
+ }
90
+
91
+ function readJson(file) {
92
+ return JSON.parse(fs.readFileSync(file, "utf8"));
93
+ }
94
+
95
+ function normalizeConfig(config, baseDir = repoRoot) {
96
+ const root = config.root ? path.resolve(baseDir, config.root) : repoRoot;
97
+ return {
98
+ ...config,
99
+ root,
100
+ targets: (config.targets || []).map((target) => ({ ...target }))
101
+ };
102
+ }
103
+
104
+ function loadScanConfig(options = {}) {
105
+ if (options.config) {
106
+ return normalizeConfig(options.config, options.baseDir || repoRoot);
107
+ }
108
+
109
+ const explicit = options.scanConfig || process.env.EVERYTHING_SCAN_CONFIG || process.env.NEPTUNE_SCAN_CONFIG;
110
+ if (explicit) {
111
+ const file = path.resolve(explicit);
112
+ return normalizeConfig(readJson(file), path.dirname(file));
113
+ }
114
+
115
+ const localConfigs = [
116
+ resolveCliPath("scanner.config.json"),
117
+ resolveCliPath("data", "scanner.json")
118
+ ];
119
+ const localConfig = localConfigs.find((file) => fs.existsSync(file));
120
+ if (localConfig) {
121
+ return normalizeConfig(readJson(localConfig), path.dirname(localConfig));
122
+ }
123
+
124
+ return normalizeConfig(defaultScanConfig(), repoRoot);
125
+ }
126
+
127
+ function languageDefaults(language) {
128
+ const defaults = LANGUAGE_DEFAULTS[language];
129
+ if (!defaults) {
130
+ throw new Error(`Unsupported scanner language: ${language}`);
131
+ }
132
+ return defaults;
133
+ }
134
+
135
+ function makeRegexUnion(values) {
136
+ return values.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
137
+ }
138
+
139
+ function globToRegExp(glob) {
140
+ let pattern = glob.replace(/[.+^${}()|[\]\\]/g, "\\$&");
141
+ pattern = pattern
142
+ .replace(/\*\*/g, "__GLOBSTAR__")
143
+ .replace(/\*/g, "[^/]*")
144
+ .replace(/\?/g, ".")
145
+ .replace(/__GLOBSTAR__/g, ".*");
146
+ return new RegExp(`^${pattern}$`);
147
+ }
148
+
149
+ function matchesAny(file, patterns, root) {
150
+ if (!patterns || patterns.length === 0) return false;
151
+ const relative = path.relative(root, file).split(path.sep).join("/");
152
+ return patterns.some((pattern) => globToRegExp(pattern).test(relative));
153
+ }
154
+
155
+ function walkFiles(entry) {
156
+ if (!fs.existsSync(entry)) return [];
157
+ const stat = fs.statSync(entry);
158
+ if (stat.isFile()) return [entry];
159
+ if (!stat.isDirectory()) return [];
160
+ return fs.readdirSync(entry)
161
+ .flatMap((child) => walkFiles(path.join(entry, child)));
162
+ }
163
+
164
+ function targetFiles(config, target, defaults) {
165
+ const roots = (target.paths || target.path ? [].concat(target.paths || target.path) : []);
166
+ const extensions = target.extensions || defaults.extensions;
167
+ return roots
168
+ .map((entry) => path.resolve(config.root, entry))
169
+ .flatMap(walkFiles)
170
+ .filter((file) => extensions.includes(path.extname(file)))
171
+ .filter((file) => !target.include || matchesAny(file, target.include, config.root))
172
+ .filter((file) => !matchesAny(file, target.exclude, config.root))
173
+ .sort();
174
+ }
175
+
176
+ function isSkillTarget(target) {
177
+ return target.language === "skill" || target.language === "command" || target.type === "skill" || target.type === "command";
178
+ }
179
+
180
+ function namespaceFromFile(file, target, defaults) {
181
+ if (target.namespace) return target.namespace;
182
+ let namespace = path.basename(file, path.extname(file));
183
+ for (const prefix of target.namespaceStripPrefixes || defaults.namespaceStripPrefixes || []) {
184
+ if (namespace.startsWith(prefix)) namespace = namespace.slice(prefix.length);
185
+ }
186
+ for (const suffix of target.namespaceStripSuffixes || defaults.namespaceStripSuffixes || []) {
187
+ if (namespace.endsWith(suffix)) namespace = namespace.slice(0, -suffix.length);
188
+ }
189
+ return namespace.replace(/^[A-Z]/, (letter) => letter.toLowerCase());
190
+ }
191
+
192
+ function findNearestFunction(source, index, target, defaults) {
193
+ const before = source.slice(0, index);
194
+ const patterns = target.functionPatterns || defaults.functionPatterns;
195
+ let nearest = { index: -1, name: "unknown" };
196
+ for (const pattern of patterns) {
197
+ pattern.lastIndex = 0;
198
+ for (const match of before.matchAll(pattern)) {
199
+ if (match.index > nearest.index) {
200
+ nearest = { index: match.index, name: match[1] };
201
+ }
202
+ }
203
+ }
204
+ return nearest.name;
205
+ }
206
+
207
+ function bodyTypeFor(helper, target) {
208
+ if (target.bodyTypes && target.bodyTypes[helper]) return target.bodyTypes[helper];
209
+ if (helper === "postUrlEncoded") return "formUrlEncoded";
210
+ if (helper === "postForm" || helper === "upload") return "multipart";
211
+ return "json";
212
+ }
213
+
214
+ function firstQuoted(match, startIndex) {
215
+ for (let index = startIndex; index < match.length; index += 1) {
216
+ if (match[index] !== undefined) return match[index];
217
+ }
218
+ return undefined;
219
+ }
220
+
221
+ function methodForHelper(helper, explicitMethod) {
222
+ if (explicitMethod) return explicitMethod.toUpperCase();
223
+ return METHOD_MAP[helper] || helper.toUpperCase();
224
+ }
225
+
226
+ function normalizePathTemplate(rawPath, target, language) {
227
+ let pathTemplate = rawPath;
228
+ let service = target.service;
229
+
230
+ for (const [prefix, serviceName] of Object.entries(target.servicePrefixes || {})) {
231
+ if (pathTemplate.startsWith(prefix)) {
232
+ pathTemplate = pathTemplate.slice(prefix.length);
233
+ service = serviceName;
234
+ break;
235
+ }
236
+ }
237
+
238
+ for (const pattern of target.skipPathPatterns || []) {
239
+ if (new RegExp(pattern).test(pathTemplate)) return undefined;
240
+ }
241
+
242
+ pathTemplate = pathTemplate
243
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, ":$1")
244
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, ":$1");
245
+
246
+ if (language === "swift") {
247
+ pathTemplate = pathTemplate.replace(/\\\(([A-Za-z_][A-Za-z0-9_]*)\)/g, ":$1");
248
+ }
249
+
250
+ return { path: pathTemplate, service };
251
+ }
252
+
253
+ function pushEndpoint(endpoints, seen, source, target, defaults, file, matchIndex, helper, rawPath, explicitMethod) {
254
+ if (!rawPath) return;
255
+ const normalized = normalizePathTemplate(rawPath, target, target.language);
256
+ if (!normalized) return;
257
+ const namespace = namespaceFromFile(file, target, defaults);
258
+ if ((target.excludeNamespaces || []).includes(namespace)) return;
259
+ const functionName = findNearestFunction(source, matchIndex, target, defaults);
260
+ const method = methodForHelper(helper, explicitMethod);
261
+ const key = `${functionName}:${method}:${normalized.path}`;
262
+ if (seen.has(key)) return;
263
+ seen.add(key);
264
+ endpoints.push({
265
+ id: `${namespace}.${functionName}`,
266
+ namespace,
267
+ functionName,
268
+ method,
269
+ path: normalized.path,
270
+ bodyType: bodyTypeFor(helper, target),
271
+ service: normalized.service,
272
+ helper,
273
+ auth: target.auth,
274
+ dangerous: target.dangerous,
275
+ file: path.relative(repoRoot, file)
276
+ });
277
+ }
278
+
279
+ function scanReceiverCalls(source, target, defaults, file, endpoints, seen) {
280
+ const receivers = target.receivers || defaults.receivers;
281
+ const helpers = target.helpers || defaults.helpers;
282
+ const receiverPattern = makeRegexUnion(receivers);
283
+ const helperPattern = makeRegexUnion(helpers);
284
+ const directCall = new RegExp(`\\b(?:${receiverPattern})\\s*\\.\\s*(${helperPattern})(?:<[^>]+>)?\\s*\\(\\s*(?:${STRING_PATTERN})`, "g");
285
+ const namedUrlCall = new RegExp(`\\b(?:${receiverPattern})\\s*\\.\\s*(${helperPattern})(?:<[^>]+>)?\\s*\\([\\s\\S]{0,300}?\\burl\\s*=\\s*(?:${STRING_PATTERN})`, "g");
286
+ const uriParseCall = new RegExp(`\\b(?:${receiverPattern})\\s*\\.\\s*(${helperPattern})\\s*\\(\\s*Uri\\.parse\\s*\\(\\s*(?:${STRING_PATTERN})`, "g");
287
+
288
+ for (const re of [directCall, namedUrlCall, uriParseCall]) {
289
+ let match;
290
+ while ((match = re.exec(source)) !== null) {
291
+ pushEndpoint(endpoints, seen, source, target, defaults, file, match.index, match[1], firstQuoted(match, 2));
292
+ }
293
+ }
294
+ }
295
+
296
+ function scanFetchCalls(source, target, defaults, file, endpoints, seen) {
297
+ if (target.language !== "js" && !target.scanFetch) return;
298
+ const fetchCall = new RegExp(`\\bfetch\\s*\\(\\s*(?:${STRING_PATTERN})([\\s\\S]{0,300}?)\\)`, "g");
299
+ let match;
300
+ while ((match = fetchCall.exec(source)) !== null) {
301
+ const rawPath = firstQuoted(match, 1);
302
+ const optionsSource = match[4] || "";
303
+ const methodMatch = optionsSource.match(/\bmethod\s*:\s*["'`]([A-Za-z]+)["'`]/);
304
+ pushEndpoint(endpoints, seen, source, target, defaults, file, match.index, "fetch", rawPath, methodMatch && methodMatch[1]);
305
+ }
306
+ }
307
+
308
+ function scanServiceFile(file, target = defaultScanConfig().targets[0], config = defaultScanConfig()) {
309
+ const defaults = languageDefaults(target.language || "kotlin");
310
+ const source = fs.readFileSync(file, "utf8");
311
+ const endpoints = [];
312
+ const seen = new Set();
313
+ scanReceiverCalls(source, target, defaults, file, endpoints, seen);
314
+ scanFetchCalls(source, target, defaults, file, endpoints, seen);
315
+ return endpoints.map((endpoint) => ({
316
+ ...endpoint,
317
+ file: path.relative(config.root || repoRoot, file)
318
+ }));
319
+ }
320
+
321
+ function scanTarget(config, target) {
322
+ if (isSkillTarget(target)) {
323
+ return scanSkillTarget(config, target);
324
+ }
325
+ const defaults = languageDefaults(target.language || "kotlin");
326
+ return targetFiles(config, target, defaults)
327
+ .flatMap((file) => scanServiceFile(file, target, config));
328
+ }
329
+
330
+ function parseSkillOutput(stdout, target) {
331
+ let parsed;
332
+ try {
333
+ parsed = JSON.parse(stdout);
334
+ } catch (error) {
335
+ throw new Error(`Skill scanner ${target.name || target.command} must output JSON: ${error.message}`);
336
+ }
337
+ const endpoints = Array.isArray(parsed) ? parsed : parsed.endpoints;
338
+ if (!Array.isArray(endpoints)) {
339
+ throw new Error(`Skill scanner ${target.name || target.command} output must be an array or { endpoints: [] }`);
340
+ }
341
+ return endpoints;
342
+ }
343
+
344
+ function normalizeSkillEndpoint(rawEndpoint, target, config) {
345
+ if (!rawEndpoint || typeof rawEndpoint !== "object") {
346
+ throw new Error(`Skill scanner ${target.name || target.command} returned a non-object endpoint`);
347
+ }
348
+ const namespace = rawEndpoint.namespace || target.namespace;
349
+ const functionName = rawEndpoint.functionName || rawEndpoint.name ||
350
+ (rawEndpoint.id && String(rawEndpoint.id).includes(".") ? String(rawEndpoint.id).split(".").pop() : undefined);
351
+ const id = rawEndpoint.id || (namespace && functionName ? `${namespace}.${functionName}` : undefined);
352
+ if (!id || !namespace || !functionName) {
353
+ throw new Error(`Skill scanner endpoint must include id or namespace + name/functionName`);
354
+ }
355
+ if (!rawEndpoint.path) {
356
+ throw new Error(`Skill scanner endpoint ${id} is missing path`);
357
+ }
358
+
359
+ const method = String(rawEndpoint.method || "GET").toUpperCase();
360
+ const bodyType = rawEndpoint.bodyType || "json";
361
+ return {
362
+ id,
363
+ namespace,
364
+ functionName,
365
+ method,
366
+ path: rawEndpoint.path,
367
+ bodyType,
368
+ service: rawEndpoint.service || target.service,
369
+ helper: rawEndpoint.helper || target.name || "skill",
370
+ auth: rawEndpoint.auth !== undefined ? rawEndpoint.auth : target.auth,
371
+ dangerous: rawEndpoint.dangerous !== undefined ? rawEndpoint.dangerous : target.dangerous,
372
+ file: rawEndpoint.file || target.file || undefined,
373
+ source: target.name || "skill"
374
+ };
375
+ }
376
+
377
+ function scanSkillTarget(config, target) {
378
+ if (!target.command) {
379
+ throw new Error("Skill scanner target must define command");
380
+ }
381
+ const cwd = target.cwd ? path.resolve(config.root, target.cwd) : config.root;
382
+ const result = spawnSync(target.command, (target.args || []).map(String), {
383
+ cwd,
384
+ encoding: "utf8",
385
+ shell: false,
386
+ env: {
387
+ ...process.env,
388
+ ...(target.env || {})
389
+ },
390
+ maxBuffer: target.maxBuffer || 10 * 1024 * 1024
391
+ });
392
+
393
+ if (result.error) {
394
+ throw new Error(`Skill scanner ${target.name || target.command} failed: ${result.error.message}`);
395
+ }
396
+ if (result.status !== 0) {
397
+ const stderr = String(result.stderr || "").trim();
398
+ throw new Error(`Skill scanner ${target.name || target.command} exited with ${result.status}${stderr ? `: ${stderr}` : ""}`);
399
+ }
400
+
401
+ return parseSkillOutput(result.stdout, target)
402
+ .map((endpoint) => normalizeSkillEndpoint(endpoint, target, config));
403
+ }
404
+
405
+ function scanServices(options = {}) {
406
+ const config = loadScanConfig(options);
407
+ if (!config.targets || config.targets.length === 0) return [];
408
+ return config.targets.flatMap((target) => scanTarget(config, target));
409
+ }
410
+
411
+ function compareScanToRegistry(scanned, registry) {
412
+ const yamlByPath = new Map(Object.values(registry).map((endpoint) => [`${endpoint.method}:${endpoint.path}`, endpoint]));
413
+ const missing = scanned.filter((endpoint) => !yamlByPath.has(`${endpoint.method}:${endpoint.path}`));
414
+ const covered = scanned.filter((endpoint) => yamlByPath.has(`${endpoint.method}:${endpoint.path}`));
415
+ return { missing, covered };
416
+ }
417
+
418
+ module.exports = {
419
+ scanServices,
420
+ compareScanToRegistry,
421
+ scanServiceFile,
422
+ loadScanConfig,
423
+ defaultScanConfig,
424
+ parseCliScanOptions,
425
+ repoRoot
426
+ };
@@ -0,0 +1,189 @@
1
+ const VALID_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
2
+ const VALID_BODY_TYPES = new Set(["json", "formUrlEncoded", "multipart", "raw"]);
3
+ const VALID_INPUT_TYPES = new Set(["string", "password", "number", "boolean"]);
4
+ const VALID_SCHEMA_TYPES = new Set(["string", "number", "integer", "boolean", "array", "object"]);
5
+
6
+ function push(errors, file, message) {
7
+ errors.push(file ? `${file}: ${message}` : message);
8
+ }
9
+
10
+ function isObject(value) {
11
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ function validateProfile(profile, file) {
15
+ const errors = [];
16
+ if (!isObject(profile)) {
17
+ push(errors, file, "profile must be an object");
18
+ return errors;
19
+ }
20
+ if (!profile.name) push(errors, file, "profile.name is required");
21
+ if (!profile.baseUrl && !profile.baseUrls) push(errors, file, "profile.baseUrl or profile.baseUrls is required");
22
+ if (profile.baseUrls && !isObject(profile.baseUrls)) push(errors, file, "profile.baseUrls must be an object");
23
+ if (profile.headers && !isObject(profile.headers)) push(errors, file, "profile.headers must be an object");
24
+ if (profile.auth && !isObject(profile.auth)) push(errors, file, "profile.auth must be an object");
25
+ if (profile.inputs && !isObject(profile.inputs)) push(errors, file, "profile.inputs must be an object");
26
+ if (profile.fixtures && !isObject(profile.fixtures)) push(errors, file, "profile.fixtures must be an object");
27
+ if (profile.test && !isObject(profile.test)) push(errors, file, "profile.test must be an object");
28
+ return errors;
29
+ }
30
+
31
+ function validateApiDocument(api, file) {
32
+ const errors = [];
33
+ if (!isObject(api)) {
34
+ push(errors, file, "API YAML must be an object");
35
+ return errors;
36
+ }
37
+ if (!api.namespace || typeof api.namespace !== "string") {
38
+ push(errors, file, "namespace is required");
39
+ }
40
+ if (!isObject(api.endpoints)) {
41
+ push(errors, file, "endpoints must be an object");
42
+ return errors;
43
+ }
44
+
45
+ for (const [name, endpoint] of Object.entries(api.endpoints)) {
46
+ const label = `${api.namespace || "unknown"}.${name}`;
47
+ if (!isObject(endpoint)) {
48
+ push(errors, file, `${label} must be an object`);
49
+ continue;
50
+ }
51
+ const method = String(endpoint.method || "GET").toUpperCase();
52
+ const bodyType = endpoint.bodyType || "json";
53
+ if (!VALID_METHODS.has(method)) push(errors, file, `${label}.method is invalid: ${endpoint.method}`);
54
+ if (!endpoint.path || typeof endpoint.path !== "string") push(errors, file, `${label}.path is required`);
55
+ if (!VALID_BODY_TYPES.has(bodyType)) push(errors, file, `${label}.bodyType is invalid: ${bodyType}`);
56
+ if (endpoint.query && !isObject(endpoint.query)) push(errors, file, `${label}.query must be an object`);
57
+ if (endpoint.body && !isObject(endpoint.body)) push(errors, file, `${label}.body must be an object`);
58
+ if (endpoint.headers && !isObject(endpoint.headers)) push(errors, file, `${label}.headers must be an object`);
59
+ if (endpoint.service && typeof endpoint.service !== "string") push(errors, file, `${label}.service must be a string`);
60
+ if (endpoint.dangerous !== undefined && typeof endpoint.dangerous !== "boolean") {
61
+ push(errors, file, `${label}.dangerous must be a boolean`);
62
+ }
63
+ validateEndpointSchema(endpoint.schema, file, label, errors);
64
+ }
65
+ return errors;
66
+ }
67
+
68
+ function validateSchemaGroup(group, file, label, errors) {
69
+ if (group === undefined) return;
70
+ if (!isObject(group)) {
71
+ push(errors, file, `${label} must be an object`);
72
+ return;
73
+ }
74
+ for (const [name, definition] of Object.entries(group)) {
75
+ const field = `${label}.${name}`;
76
+ if (typeof definition === "string") {
77
+ if (!VALID_SCHEMA_TYPES.has(definition)) push(errors, file, `${field} type is invalid: ${definition}`);
78
+ continue;
79
+ }
80
+ if (!isObject(definition)) {
81
+ push(errors, file, `${field} must be an object or type string`);
82
+ continue;
83
+ }
84
+ if (definition.type && !VALID_SCHEMA_TYPES.has(definition.type)) {
85
+ push(errors, file, `${field}.type is invalid: ${definition.type}`);
86
+ }
87
+ if (definition.required !== undefined && typeof definition.required !== "boolean") {
88
+ push(errors, file, `${field}.required must be a boolean`);
89
+ }
90
+ if (definition.enum !== undefined && !Array.isArray(definition.enum)) {
91
+ push(errors, file, `${field}.enum must be an array`);
92
+ }
93
+ if (definition.from !== undefined && typeof definition.from !== "string") {
94
+ push(errors, file, `${field}.from must be a string`);
95
+ }
96
+ }
97
+ }
98
+
99
+ function validateEndpointSchema(schema, file, label, errors) {
100
+ if (schema === undefined) return;
101
+ if (!isObject(schema)) {
102
+ push(errors, file, `${label}.schema must be an object`);
103
+ return;
104
+ }
105
+ validateSchemaGroup(schema.path, file, `${label}.schema.path`, errors);
106
+ validateSchemaGroup(schema.query, file, `${label}.schema.query`, errors);
107
+ validateSchemaGroup(schema.body, file, `${label}.schema.body`, errors);
108
+ }
109
+
110
+ function validateApiRegistry(registry) {
111
+ const errors = [];
112
+ const seen = new Set();
113
+ for (const [id, endpoint] of Object.entries(registry)) {
114
+ if (seen.has(id)) push(errors, "", `duplicate endpoint id: ${id}`);
115
+ seen.add(id);
116
+ if (!endpoint.path) push(errors, "", `${id}.path is required`);
117
+ if (!isObject(endpoint.response) || Object.keys(endpoint.response).length === 0) {
118
+ push(errors, "", `${id}.response is required`);
119
+ }
120
+ }
121
+ return errors;
122
+ }
123
+
124
+ function validateFlow(flow, file) {
125
+ const errors = [];
126
+ if (!isObject(flow)) {
127
+ push(errors, file, "flow must be an object");
128
+ return errors;
129
+ }
130
+ if (!flow.name || typeof flow.name !== "string") push(errors, file, "flow.name is required");
131
+ if (flow.inputs !== undefined) {
132
+ if (!isObject(flow.inputs)) {
133
+ push(errors, file, "flow.inputs must be an object");
134
+ } else {
135
+ for (const [key, input] of Object.entries(flow.inputs)) {
136
+ if (!isObject(input)) {
137
+ push(errors, file, `inputs.${key} must be an object`);
138
+ continue;
139
+ }
140
+ if (input.type && !VALID_INPUT_TYPES.has(input.type)) {
141
+ push(errors, file, `inputs.${key}.type is invalid: ${input.type}`);
142
+ }
143
+ }
144
+ }
145
+ }
146
+ if (flow.steps !== undefined && !Array.isArray(flow.steps)) push(errors, file, "flow.steps must be an array");
147
+ for (const [index, step] of (flow.steps || []).entries()) {
148
+ if (!isObject(step)) {
149
+ push(errors, file, `steps[${index}] must be an object`);
150
+ continue;
151
+ }
152
+ if (!step.call && step.wait === undefined) push(errors, file, `steps[${index}] must declare call or wait`);
153
+ if (step.call && typeof step.call !== "string") push(errors, file, `steps[${index}].call must be a string`);
154
+ if (step.body && !isObject(step.body)) push(errors, file, `steps[${index}].body must be an object`);
155
+ if (step.query && !isObject(step.query)) push(errors, file, `steps[${index}].query must be an object`);
156
+ if (step.headers && !isObject(step.headers)) push(errors, file, `steps[${index}].headers must be an object`);
157
+ if (step.extract && !isObject(step.extract)) push(errors, file, `steps[${index}].extract must be an object`);
158
+ if (step.cache && !isObject(step.cache)) push(errors, file, `steps[${index}].cache must be an object`);
159
+ if (step.expect && !Array.isArray(step.expect) && !isObject(step.expect)) {
160
+ push(errors, file, `steps[${index}].expect must be an object or array`);
161
+ }
162
+ }
163
+ if (flow.save !== undefined) {
164
+ if (!isObject(flow.save)) {
165
+ push(errors, file, "flow.save must be an object");
166
+ } else {
167
+ if (flow.save.cache && !isObject(flow.save.cache)) push(errors, file, "flow.save.cache must be an object");
168
+ if (flow.save.required && !Array.isArray(flow.save.required)) push(errors, file, "flow.save.required must be an array");
169
+ }
170
+ }
171
+ return errors;
172
+ }
173
+
174
+ function throwIfInvalid(errors) {
175
+ if (errors.length > 0) {
176
+ throw new Error(`Configuration validation failed:\n${errors.join("\n")}`);
177
+ }
178
+ }
179
+
180
+ module.exports = {
181
+ validateProfile,
182
+ validateApiDocument,
183
+ validateApiRegistry,
184
+ validateFlow,
185
+ throwIfInvalid,
186
+ VALID_BODY_TYPES,
187
+ VALID_METHODS,
188
+ VALID_SCHEMA_TYPES
189
+ };