@icyouo/evt-cli 0.1.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.
@@ -0,0 +1,227 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { parseYaml } = require("../src/config/yaml");
6
+ const { loadScanConfig, parseCliScanOptions, scanServices } = require("../src/config/serviceScanner");
7
+ const { resolveCliPath, resolveConfigDir, setConfigRoot } = require("../src/util/paths");
8
+
9
+ const endpointKey = (endpoint) => `${String(endpoint.method || "GET").toUpperCase()}:${endpoint.path}`;
10
+
11
+ function readApiDocument(namespace) {
12
+ const apiDir = resolveApiOutputDir();
13
+ const file = path.join(apiDir, `${namespace}.yaml`);
14
+ if (!fs.existsSync(file)) {
15
+ return { namespace, endpoints: {} };
16
+ }
17
+ return parseYaml(fs.readFileSync(file, "utf8"));
18
+ }
19
+
20
+ function resolveApiOutputDir(scanOptions = {}) {
21
+ const scanConfig = loadScanConfig(scanOptions);
22
+ if (scanConfig.apiDir) return scanConfig.apiDir;
23
+ const legacyApiDir = resolveCliPath("apis");
24
+ const dataApiDir = resolveCliPath("data", "apis");
25
+ if (fs.existsSync(legacyApiDir)) return legacyApiDir;
26
+ if (fs.existsSync(dataApiDir)) return dataApiDir;
27
+ const resolved = resolveConfigDir("apis");
28
+ return fs.existsSync(resolved) ? resolved : dataApiDir;
29
+ }
30
+
31
+ function isPublicEndpoint(endpoint) {
32
+ const name = endpoint.functionName || endpoint.name || "";
33
+ const pathValue = endpoint.path || "";
34
+ return /^(login|register|oauth|public|health|status|version|languages?|config|list|get|search|check)/i.test(name) ||
35
+ /\/(public|health|status|version|config)\b/i.test(pathValue);
36
+ }
37
+
38
+ function isDangerousEndpoint(endpoint) {
39
+ const method = String(endpoint.method || "GET").toUpperCase();
40
+ if (method === "GET") return false;
41
+
42
+ const name = endpoint.functionName || endpoint.name || "";
43
+ const pathValue = endpoint.path || "";
44
+ if (/^(get|list|history|summary|check|send|login|verify|oauth)/i.test(name) && method === "POST") {
45
+ return false;
46
+ }
47
+ return /create|change|adjust|cancel|close|open|transfer|withdraw|bind|unbind|modify|patch|add|update|delete|disable|deactivate|identity|kyc|set|unlink|margin|leverage|order|password/i
48
+ .test(`${name} ${pathValue}`);
49
+ }
50
+
51
+ function makeEndpoint(scanned) {
52
+ const endpoint = {
53
+ method: scanned.method,
54
+ path: scanned.path,
55
+ auth: scanned.auth !== undefined ? scanned.auth : !isPublicEndpoint(scanned),
56
+ schema: {},
57
+ response: defaultResponse()
58
+ };
59
+
60
+ if (scanned.service) {
61
+ endpoint.service = scanned.service;
62
+ }
63
+ if (scanned.bodyType && scanned.bodyType !== "json") {
64
+ endpoint.bodyType = scanned.bodyType;
65
+ }
66
+ const dangerous = scanned.dangerous !== undefined ? scanned.dangerous : isDangerousEndpoint(scanned);
67
+ if (dangerous) {
68
+ endpoint.dangerous = true;
69
+ }
70
+ return endpoint;
71
+ }
72
+
73
+ function augmentEndpoint(namespace, name, endpoint) {
74
+ const inferred = { namespace, name, functionName: name, ...endpoint };
75
+ if (endpoint.dangerous === undefined && isDangerousEndpoint(inferred)) {
76
+ endpoint.dangerous = true;
77
+ }
78
+ endpoint.schema ||= {};
79
+ if (!endpoint.response || Object.keys(endpoint.response).length === 0) {
80
+ endpoint.response = defaultResponse();
81
+ }
82
+ return endpoint;
83
+ }
84
+
85
+ function defaultResponse() {
86
+ return {
87
+ data: {
88
+ type: "object"
89
+ }
90
+ };
91
+ }
92
+
93
+ function uniqueName(endpoints, preferred) {
94
+ if (!Object.prototype.hasOwnProperty.call(endpoints, preferred)) return preferred;
95
+ let suffix = 2;
96
+ while (Object.prototype.hasOwnProperty.call(endpoints, `${preferred}${suffix}`)) {
97
+ suffix += 1;
98
+ }
99
+ return `${preferred}${suffix}`;
100
+ }
101
+
102
+ function sortObjectByKeys(object) {
103
+ return Object.fromEntries(Object.entries(object).sort(([left], [right]) => left.localeCompare(right)));
104
+ }
105
+
106
+ function orderedEntries(object) {
107
+ const keyOrder = [
108
+ "service",
109
+ "method",
110
+ "path",
111
+ "auth",
112
+ "dangerous",
113
+ "bodyType",
114
+ "headers",
115
+ "query",
116
+ "body",
117
+ "schema",
118
+ "response"
119
+ ];
120
+ return Object.entries(object).sort(([left], [right]) => {
121
+ const leftIndex = keyOrder.indexOf(left);
122
+ const rightIndex = keyOrder.indexOf(right);
123
+ if (leftIndex !== -1 || rightIndex !== -1) {
124
+ return (leftIndex === -1 ? 999 : leftIndex) - (rightIndex === -1 ? 999 : rightIndex);
125
+ }
126
+ return left.localeCompare(right);
127
+ });
128
+ }
129
+
130
+ function yamlScalar(value) {
131
+ if (typeof value === "string") return JSON.stringify(value);
132
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
133
+ if (value === null) return "null";
134
+ return JSON.stringify(value);
135
+ }
136
+
137
+ function writeYamlValue(lines, key, value, indent) {
138
+ const prefix = " ".repeat(indent);
139
+ if (Array.isArray(value)) {
140
+ if (value.length === 0) {
141
+ lines.push(`${prefix}${key}: []`);
142
+ return;
143
+ }
144
+ lines.push(`${prefix}${key}:`);
145
+ for (const item of value) {
146
+ lines.push(`${prefix} - ${yamlScalar(item)}`);
147
+ }
148
+ return;
149
+ }
150
+ if (value && typeof value === "object") {
151
+ const entries = orderedEntries(value);
152
+ if (entries.length === 0) {
153
+ lines.push(`${prefix}${key}: {}`);
154
+ return;
155
+ }
156
+ lines.push(`${prefix}${key}:`);
157
+ for (const [childKey, childValue] of entries) {
158
+ writeYamlValue(lines, childKey, childValue, indent + 2);
159
+ }
160
+ return;
161
+ }
162
+ lines.push(`${prefix}${key}: ${yamlScalar(value)}`);
163
+ }
164
+
165
+ function serializeDocument(document) {
166
+ const lines = [`namespace: ${document.namespace}`, "", "endpoints:"];
167
+ for (const [name, endpoint] of Object.entries(sortObjectByKeys(document.endpoints))) {
168
+ lines.push(` ${name}:`);
169
+ for (const [key, value] of orderedEntries(endpoint)) {
170
+ writeYamlValue(lines, key, value, 4);
171
+ }
172
+ lines.push("");
173
+ }
174
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
175
+ }
176
+
177
+ function runSyncApiCoverage(argv = process.argv.slice(2)) {
178
+ const scanOptions = parseCliScanOptions(argv);
179
+ setConfigRoot(scanOptions.configRoot);
180
+ const apiDir = resolveApiOutputDir(scanOptions);
181
+ fs.mkdirSync(apiDir, { recursive: true });
182
+ const scanned = scanServices(scanOptions);
183
+ const existingNamespaces = fs.existsSync(apiDir)
184
+ ? fs.readdirSync(apiDir)
185
+ .filter((file) => file.endsWith(".yaml") || file.endsWith(".yml"))
186
+ .map((file) => path.basename(file, path.extname(file)))
187
+ : [];
188
+ const namespaces = [...new Set([...existingNamespaces, ...scanned.map((endpoint) => endpoint.namespace)])];
189
+ const documents = new Map(namespaces.map((namespace) => [namespace, readApiDocument(namespace)]));
190
+ const coveredPaths = new Set();
191
+
192
+ for (const document of documents.values()) {
193
+ document.endpoints ||= {};
194
+ for (const [name, endpoint] of Object.entries(document.endpoints)) {
195
+ document.endpoints[name] = augmentEndpoint(document.namespace, name, endpoint);
196
+ coveredPaths.add(endpointKey(endpoint));
197
+ }
198
+ }
199
+
200
+ let added = 0;
201
+ for (const endpoint of scanned) {
202
+ if (coveredPaths.has(endpointKey(endpoint))) continue;
203
+ const document = documents.get(endpoint.namespace);
204
+ const name = uniqueName(document.endpoints, endpoint.functionName);
205
+ document.endpoints[name] = makeEndpoint(endpoint);
206
+ coveredPaths.add(endpointKey(endpoint));
207
+ added += 1;
208
+ }
209
+
210
+ for (const namespace of namespaces) {
211
+ const document = documents.get(namespace);
212
+ if (!document.endpoints || Object.keys(document.endpoints).length === 0) continue;
213
+ fs.writeFileSync(path.join(apiDir, `${namespace}.yaml`), serializeDocument(document));
214
+ }
215
+
216
+ const payload = { scanned: scanned.length, added, namespaces: namespaces.length };
217
+ console.log(JSON.stringify(payload, null, 2));
218
+ return payload;
219
+ }
220
+
221
+ if (require.main === module) {
222
+ runSyncApiCoverage();
223
+ }
224
+
225
+ module.exports = {
226
+ runSyncApiCoverage
227
+ };
@@ -0,0 +1,60 @@
1
+ ---
2
+ name: evt-api-scanner
3
+ description: Use when an agent needs to discover API source locations, generate evt-cli scanner config, scan a codebase for HTTP endpoints, sync YAML API definitions, or check endpoint coverage for evt-cli projects.
4
+ ---
5
+
6
+ # evt-api-scanner
7
+
8
+ Use this skill when working with an evt-cli project and you need to discover,
9
+ scan, sync, or audit YAML API definitions.
10
+
11
+ ## Workflow
12
+
13
+ 1. Locate the project root and evt config root.
14
+ - Prefer the user's explicit `--config-root`.
15
+ - Otherwise use `EVT_CLI_ROOT`.
16
+ - Otherwise use `./cli` from the project root.
17
+ 2. Discover source locations and write scanner config:
18
+
19
+ ```bash
20
+ node skills/evt-api-scanner/scripts/discover.js --root . --config-root ./cli
21
+ ```
22
+
23
+ 3. Generate or update endpoint YAML:
24
+
25
+ ```bash
26
+ evt api sync --config-root ./cli
27
+ ```
28
+
29
+ 4. Check YAML coverage:
30
+
31
+ ```bash
32
+ evt api coverage --config-root ./cli
33
+ ```
34
+
35
+ ## Endpoint Format
36
+
37
+ Endpoint definitions are always persisted as YAML under `data/apis/*.yaml` or
38
+ legacy `apis/*.yaml`. JSON is only used as a machine-readable intermediate
39
+ between scanner scripts and evt-cli.
40
+
41
+ Example YAML:
42
+
43
+ ```yaml
44
+ namespace: auth
45
+
46
+ endpoints:
47
+ login:
48
+ method: "POST"
49
+ path: "/api/login"
50
+ auth: false
51
+ schema: {}
52
+ response: {}
53
+ ```
54
+
55
+ ## Fallback
56
+
57
+ evt-cli should prefer this skill scanner when configured. If the skill scanner
58
+ fails or returns no endpoints, evt-cli falls back to the built-in regex scanner
59
+ targets. The fallback exists for users without an agent or skill-aware workflow,
60
+ but it is less complete.
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const IGNORE_DIRS = new Set([
7
+ ".git",
8
+ ".gradle",
9
+ ".idea",
10
+ ".next",
11
+ ".nuxt",
12
+ ".svelte-kit",
13
+ ".ufoo",
14
+ "build",
15
+ "coverage",
16
+ "dist",
17
+ "node_modules",
18
+ "Pods",
19
+ "target"
20
+ ]);
21
+
22
+ const LANGUAGE_DEFS = {
23
+ kotlin: {
24
+ extensions: [".kt"],
25
+ hints: [/Service\.kt$/i, /Api\.kt$/i, /Repository\.kt$/i, /http\.(get|post|put|patch|delete)/]
26
+ },
27
+ swift: {
28
+ extensions: [".swift"],
29
+ hints: [/Service\.swift$/i, /API\.swift$/i, /Api\.swift$/i, /client\.(get|post|put|patch|delete)/]
30
+ },
31
+ js: {
32
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"],
33
+ hints: [/(service|api|client|request|http)\.[cm]?[jt]sx?$/i, /\b(fetch|axios)\s*\(/, /\.(get|post|put|patch|delete)\s*\(/]
34
+ },
35
+ dart: {
36
+ extensions: [".dart"],
37
+ hints: [/(service|api|client|repository)\.dart$/i, /\.(get|post|put|patch|delete)\s*\(/]
38
+ }
39
+ };
40
+
41
+ function parseArgs(argv) {
42
+ const options = {};
43
+ for (let index = 0; index < argv.length; index += 1) {
44
+ const token = argv[index];
45
+ if (!token.startsWith("--")) continue;
46
+ const raw = token.slice(2);
47
+ const equals = raw.indexOf("=");
48
+ const key = (equals >= 0 ? raw.slice(0, equals) : raw)
49
+ .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
50
+ if (key === "dryRun" || key === "json") {
51
+ options[key] = true;
52
+ } else {
53
+ options[key] = equals >= 0 ? raw.slice(equals + 1) : argv[++index];
54
+ }
55
+ }
56
+ return options;
57
+ }
58
+
59
+ function posixRelative(from, to) {
60
+ return path.relative(from, to).split(path.sep).join("/") || ".";
61
+ }
62
+
63
+ function defaultConfigRoot(cwd) {
64
+ if (process.env.EVT_CLI_ROOT) return path.resolve(process.env.EVT_CLI_ROOT);
65
+ if (process.env.EVERYTHING_CLI_ROOT) return path.resolve(process.env.EVERYTHING_CLI_ROOT);
66
+ if (path.basename(cwd) === "cli") return cwd;
67
+ return path.join(cwd, "cli");
68
+ }
69
+
70
+ function walkFiles(root, files = []) {
71
+ if (!fs.existsSync(root)) return files;
72
+ const stat = fs.statSync(root);
73
+ if (stat.isFile()) {
74
+ files.push(root);
75
+ return files;
76
+ }
77
+ if (!stat.isDirectory()) return files;
78
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
79
+ if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue;
80
+ walkFiles(path.join(root, entry.name), files);
81
+ }
82
+ return files;
83
+ }
84
+
85
+ function fileLooksRelevant(file, language, root) {
86
+ const def = LANGUAGE_DEFS[language];
87
+ const relative = posixRelative(root, file);
88
+ const basename = path.basename(file);
89
+ const text = fs.readFileSync(file, "utf8").slice(0, 20000);
90
+ return def.hints.some((hint) => hint.test(basename) || hint.test(relative) || hint.test(text));
91
+ }
92
+
93
+ function collapseDirs(root, files) {
94
+ const dirs = [...new Set(files.map((file) => path.dirname(file)))]
95
+ .map((dir) => posixRelative(root, dir))
96
+ .sort((left, right) => left.length - right.length || left.localeCompare(right));
97
+ const collapsed = [];
98
+ for (const dir of dirs) {
99
+ if (!collapsed.some((parent) => dir === parent || dir.startsWith(`${parent}/`))) {
100
+ collapsed.push(dir);
101
+ }
102
+ }
103
+ return collapsed;
104
+ }
105
+
106
+ function discoverTargets(projectRoot) {
107
+ const files = walkFiles(projectRoot);
108
+ const targets = [];
109
+ for (const [language, def] of Object.entries(LANGUAGE_DEFS)) {
110
+ const matching = files
111
+ .filter((file) => def.extensions.includes(path.extname(file)))
112
+ .filter((file) => fileLooksRelevant(file, language, projectRoot));
113
+ if (matching.length === 0) continue;
114
+ targets.push({ language, paths: collapseDirs(projectRoot, matching) });
115
+ }
116
+ return targets;
117
+ }
118
+
119
+ function main() {
120
+ const options = parseArgs(process.argv.slice(2));
121
+ const cwd = process.cwd();
122
+ const configRoot = path.resolve(options.configRoot || defaultConfigRoot(cwd));
123
+ const projectRoot = path.resolve(options.root || (path.basename(configRoot) === "cli" ? path.dirname(configRoot) : cwd));
124
+ const dataDir = path.join(configRoot, "data");
125
+ const apiDir = path.join(dataDir, "apis");
126
+ const scannerFile = path.resolve(options.scannerFile || path.join(dataDir, "scanner.json"));
127
+ const scannerDir = path.dirname(scannerFile);
128
+ const fallbackTargets = discoverTargets(projectRoot);
129
+ const config = {
130
+ root: posixRelative(scannerDir, projectRoot),
131
+ apiDir: posixRelative(scannerDir, apiDir),
132
+ targets: [
133
+ {
134
+ language: "skill",
135
+ name: "evt-api-scanner",
136
+ skill: "evt-api-scanner"
137
+ },
138
+ ...fallbackTargets
139
+ ]
140
+ };
141
+
142
+ if (!options.dryRun) {
143
+ fs.mkdirSync(apiDir, { recursive: true });
144
+ fs.mkdirSync(path.join(dataDir, "flows"), { recursive: true });
145
+ fs.mkdirSync(path.join(dataDir, "profiles"), { recursive: true });
146
+ fs.mkdirSync(scannerDir, { recursive: true });
147
+ fs.writeFileSync(scannerFile, `${JSON.stringify(config, null, 2)}\n`);
148
+ }
149
+
150
+ process.stdout.write(`${JSON.stringify({
151
+ wrote: !options.dryRun,
152
+ projectRoot,
153
+ configRoot,
154
+ scannerFile,
155
+ apiDir,
156
+ targets: config.targets
157
+ }, null, 2)}\n`);
158
+ }
159
+
160
+ main();
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ const IGNORE_DIRS = new Set([
7
+ ".git",
8
+ ".gradle",
9
+ ".idea",
10
+ ".next",
11
+ ".nuxt",
12
+ ".svelte-kit",
13
+ ".ufoo",
14
+ "build",
15
+ "coverage",
16
+ "dist",
17
+ "node_modules",
18
+ "Pods",
19
+ "target"
20
+ ]);
21
+
22
+ const LANGUAGE_DEFS = {
23
+ kotlin: {
24
+ extensions: [".kt"],
25
+ receivers: ["http", "client", "api"],
26
+ helpers: ["get", "getRaw", "post", "postUrlEncoded", "postForm", "put", "patch", "delete", "deleteWithBody", "upload"],
27
+ functions: [/(?:override\s+)?suspend\s+fun\s+([A-Za-z_][A-Za-z0-9_]*)/g, /fun\s+([A-Za-z_][A-Za-z0-9_]*)/g],
28
+ stripPrefixes: ["I"],
29
+ stripSuffixes: ["Service", "Api", "API", "Repository"]
30
+ },
31
+ swift: {
32
+ extensions: [".swift"],
33
+ receivers: ["http", "client", "api"],
34
+ helpers: ["get", "post", "put", "patch", "delete", "upload"],
35
+ functions: [/func\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g],
36
+ stripSuffixes: ["Service", "Api", "API"]
37
+ },
38
+ js: {
39
+ extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"],
40
+ receivers: ["http", "client", "api", "axios"],
41
+ helpers: ["get", "post", "put", "patch", "delete"],
42
+ functions: [
43
+ /(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g,
44
+ /(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(/g,
45
+ /([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*(?:async\s*)?function\s*\(/g
46
+ ],
47
+ stripSuffixes: ["Service", "Api", "API"]
48
+ },
49
+ dart: {
50
+ extensions: [".dart"],
51
+ receivers: ["http", "client", "api", "dio"],
52
+ helpers: ["get", "post", "put", "patch", "delete"],
53
+ functions: [
54
+ /(?:Future(?:<[^>]+>)?|Stream(?:<[^>]+>)?|void|[A-Za-z_][A-Za-z0-9_<>?]*)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\([^)]*\)\s*(?:async\s*)?\{/g
55
+ ],
56
+ stripSuffixes: ["Service", "Api", "API"]
57
+ }
58
+ };
59
+
60
+ const METHOD_BY_HELPER = {
61
+ get: "GET",
62
+ getRaw: "GET",
63
+ fetch: "GET",
64
+ post: "POST",
65
+ postForm: "POST",
66
+ postUrlEncoded: "POST",
67
+ put: "PUT",
68
+ patch: "PATCH",
69
+ delete: "DELETE",
70
+ deleteWithBody: "DELETE",
71
+ upload: "POST"
72
+ };
73
+
74
+ const STRING_PATTERN = `"([^"]+)"|'([^']+)'|\`([^\`]+)\``;
75
+
76
+ function parseArgs(argv) {
77
+ const options = { paths: [] };
78
+ for (let index = 0; index < argv.length; index += 1) {
79
+ const token = argv[index];
80
+ if (!token.startsWith("--")) {
81
+ options.paths.push(token);
82
+ continue;
83
+ }
84
+ const raw = token.slice(2);
85
+ const equals = raw.indexOf("=");
86
+ const key = (equals >= 0 ? raw.slice(0, equals) : raw)
87
+ .replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
88
+ options[key] = equals >= 0 ? raw.slice(equals + 1) : argv[++index];
89
+ }
90
+ return options;
91
+ }
92
+
93
+ function posixRelative(from, to) {
94
+ return path.relative(from, to).split(path.sep).join("/") || ".";
95
+ }
96
+
97
+ function walkFiles(root, files = []) {
98
+ if (!fs.existsSync(root)) return files;
99
+ const stat = fs.statSync(root);
100
+ if (stat.isFile()) {
101
+ files.push(root);
102
+ return files;
103
+ }
104
+ if (!stat.isDirectory()) return files;
105
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
106
+ if (entry.isDirectory() && IGNORE_DIRS.has(entry.name)) continue;
107
+ walkFiles(path.join(root, entry.name), files);
108
+ }
109
+ return files;
110
+ }
111
+
112
+ function languageForFile(file) {
113
+ const extension = path.extname(file);
114
+ return Object.entries(LANGUAGE_DEFS).find(([, def]) => def.extensions.includes(extension))?.[0];
115
+ }
116
+
117
+ function makeRegexUnion(values) {
118
+ return values.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
119
+ }
120
+
121
+ function firstQuoted(match, startIndex) {
122
+ for (let index = startIndex; index < match.length; index += 1) {
123
+ if (match[index] !== undefined) return match[index];
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ function nearestFunction(source, index, def) {
129
+ const before = source.slice(0, index);
130
+ let nearest = { index: -1, name: "unknown" };
131
+ for (const pattern of def.functions) {
132
+ pattern.lastIndex = 0;
133
+ for (const match of before.matchAll(pattern)) {
134
+ if (match.index > nearest.index) nearest = { index: match.index, name: match[1] };
135
+ }
136
+ }
137
+ return nearest.name;
138
+ }
139
+
140
+ function namespaceForFile(file, language) {
141
+ const def = LANGUAGE_DEFS[language];
142
+ let namespace = path.basename(file, path.extname(file));
143
+ for (const prefix of def.stripPrefixes || []) {
144
+ if (namespace.startsWith(prefix)) namespace = namespace.slice(prefix.length);
145
+ }
146
+ for (const suffix of def.stripSuffixes || []) {
147
+ if (namespace.endsWith(suffix)) namespace = namespace.slice(0, -suffix.length);
148
+ }
149
+ return namespace.replace(/^[A-Z]/, (letter) => letter.toLowerCase());
150
+ }
151
+
152
+ function normalizePath(rawPath, language) {
153
+ if (!rawPath || !rawPath.startsWith("/")) return undefined;
154
+ let value = rawPath
155
+ .replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, ":$1")
156
+ .replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, ":$1");
157
+ if (language === "swift") value = value.replace(/\\\(([A-Za-z_][A-Za-z0-9_]*)\)/g, ":$1");
158
+ return value;
159
+ }
160
+
161
+ function bodyTypeFor(helper) {
162
+ if (helper === "postUrlEncoded") return "formUrlEncoded";
163
+ if (helper === "postForm" || helper === "upload") return "multipart";
164
+ return "json";
165
+ }
166
+
167
+ function pushEndpoint(endpoints, seen, file, root, source, language, helper, rawPath, matchIndex, explicitMethod) {
168
+ const pathTemplate = normalizePath(rawPath, language);
169
+ if (!pathTemplate) return;
170
+ const namespace = namespaceForFile(file, language);
171
+ const functionName = nearestFunction(source, matchIndex, LANGUAGE_DEFS[language]);
172
+ const method = String(explicitMethod || METHOD_BY_HELPER[helper] || helper).toUpperCase();
173
+ const key = `${method}:${pathTemplate}:${functionName}`;
174
+ if (seen.has(key)) return;
175
+ seen.add(key);
176
+ endpoints.push({
177
+ id: `${namespace}.${functionName}`,
178
+ namespace,
179
+ functionName,
180
+ method,
181
+ path: pathTemplate,
182
+ bodyType: bodyTypeFor(helper),
183
+ helper,
184
+ file: posixRelative(root, file),
185
+ source: "evt-api-scanner"
186
+ });
187
+ }
188
+
189
+ function scanFile(file, root) {
190
+ const language = languageForFile(file);
191
+ if (!language) return [];
192
+ const def = LANGUAGE_DEFS[language];
193
+ const source = fs.readFileSync(file, "utf8");
194
+ const endpoints = [];
195
+ const seen = new Set();
196
+ const receiverPattern = makeRegexUnion(def.receivers);
197
+ const helperPattern = makeRegexUnion(def.helpers);
198
+ const calls = [
199
+ new RegExp(`\\b(?:${receiverPattern})\\s*\\.\\s*(${helperPattern})(?:<[^>]+>)?\\s*\\(\\s*(?:${STRING_PATTERN})`, "g"),
200
+ new RegExp(`\\b(?:${receiverPattern})\\s*\\.\\s*(${helperPattern})(?:<[^>]+>)?\\s*\\([\\s\\S]{0,300}?\\burl\\s*=\\s*(?:${STRING_PATTERN})`, "g"),
201
+ new RegExp(`\\b(?:${receiverPattern})\\s*\\.\\s*(${helperPattern})\\s*\\(\\s*Uri\\.parse\\s*\\(\\s*(?:${STRING_PATTERN})`, "g")
202
+ ];
203
+ for (const pattern of calls) {
204
+ let match;
205
+ while ((match = pattern.exec(source)) !== null) {
206
+ pushEndpoint(endpoints, seen, file, root, source, language, match[1], firstQuoted(match, 2), match.index);
207
+ }
208
+ }
209
+
210
+ if (language === "js") {
211
+ const fetchCall = new RegExp(`\\bfetch\\s*\\(\\s*(?:${STRING_PATTERN})([\\s\\S]{0,300}?)\\)`, "g");
212
+ let match;
213
+ while ((match = fetchCall.exec(source)) !== null) {
214
+ const optionsSource = match[4] || "";
215
+ const methodMatch = optionsSource.match(/\bmethod\s*:\s*["'`]([A-Za-z]+)["'`]/);
216
+ pushEndpoint(endpoints, seen, file, root, source, language, "fetch", firstQuoted(match, 1), match.index, methodMatch && methodMatch[1]);
217
+ }
218
+ }
219
+ return endpoints;
220
+ }
221
+
222
+ function main() {
223
+ const options = parseArgs(process.argv.slice(2));
224
+ const root = path.resolve(options.root || process.env.EVT_SCAN_ROOT || process.cwd());
225
+ const entries = options.paths.length > 0 ? options.paths.map((entry) => path.resolve(root, entry)) : [root];
226
+ const files = entries.flatMap((entry) => walkFiles(entry)).filter(languageForFile);
227
+ const endpoints = files.flatMap((file) => scanFile(file, root));
228
+ process.stdout.write(`${JSON.stringify({ endpoints }, null, 2)}\n`);
229
+ }
230
+
231
+ main();