@icyouo/evt-cli 0.1.0 → 0.1.2
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.
- package/README.md +76 -7
- package/README.zh-CN.md +66 -7
- package/data/scanner.example.json +2 -5
- package/package.json +7 -3
- package/scripts/build-package.js +173 -0
- package/scripts/check-api-audit.js +260 -0
- package/scripts/check-api-coverage.js +41 -0
- package/scripts/local-ci.js +38 -0
- package/scripts/sync-api-coverage.js +227 -0
- package/skills/evt-api-scanner/SKILL.md +147 -0
- package/skills/evt-api-scanner/scripts/discover.js +160 -0
- package/skills/evt-api-scanner/scripts/scan.js +231 -0
- package/skills/evt-flow-generator/SKILL.md +139 -0
- package/skills/evt-profile-generator/SKILL.md +122 -0
- package/src/config/serviceScanner.js +60 -10
- package/src/index.js +45 -1
- package/src/util/args.js +3 -0
- package/test/apiAudit.test.js +137 -0
- package/test/duration.test.js +11 -0
- package/test/flow.test.js +215 -0
- package/test/liveTester.test.js +54 -0
- package/test/requestBuilder.test.js +214 -0
- package/test/serviceScanner.test.js +196 -0
- package/test/template.test.js +18 -0
- package/test/yaml.test.js +20 -0
|
@@ -0,0 +1,147 @@
|
|
|
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, check endpoint coverage, or bootstrap endpoint data 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. Read any existing non-example API YAML first. Preserve existing endpoint IDs,
|
|
18
|
+
service names, auth flags, schema, response, and dangerous markers unless
|
|
19
|
+
source evidence proves they are wrong.
|
|
20
|
+
3. Discover source locations and write scanner config:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node skills/evt-api-scanner/scripts/discover.js --root . --config-root ./cli
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
4. Generate or update endpoint YAML:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
evt api sync --config-root ./cli
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
5. Enrich the generated YAML from source. Do not stop at scanner skeletons.
|
|
33
|
+
6. Audit YAML quality:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
evt api audit --config-root ./cli --strict
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If strict audit fails because of empty schemas, generic responses, unresolved
|
|
40
|
+
paths, or unmatched services, use the audit samples as the next edit queue and
|
|
41
|
+
continue enrichment.
|
|
42
|
+
|
|
43
|
+
7. Check YAML coverage:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
evt api coverage --config-root ./cli
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
8. When bootstrapping an empty project, continue with:
|
|
50
|
+
- `evt-profile-generator` to create `data/profiles/*.json`.
|
|
51
|
+
- `evt-flow-generator` to create `data/flows/*.yaml`.
|
|
52
|
+
|
|
53
|
+
## Source Evidence
|
|
54
|
+
|
|
55
|
+
Collect these signals before finalizing YAML:
|
|
56
|
+
|
|
57
|
+
- HTTP service declarations for method, path, body type, and function names.
|
|
58
|
+
- Repository or client call sites for auth requirements, default arguments,
|
|
59
|
+
endpoint aliases, and feature ownership.
|
|
60
|
+
- Request DTOs, method parameters, annotations, or typed request builders for
|
|
61
|
+
`schema.path`, `schema.query`, and `schema.body`.
|
|
62
|
+
- Response DTOs, generic wrappers, serializers, or model declarations for
|
|
63
|
+
`response.envelope`, `response.data.model`, `response.data.type`, and fields.
|
|
64
|
+
- App config, DI modules, HTTP clients, interceptors, or base URL providers for
|
|
65
|
+
`service` names and absolute URL handling.
|
|
66
|
+
- Existing tests, docs, and product flows for dangerous operations and required
|
|
67
|
+
ordering.
|
|
68
|
+
|
|
69
|
+
For Kotlin/KMP projects, inspect service interfaces, repositories, DTO/model
|
|
70
|
+
files, app config, DI modules, and header/interceptor builders. For Swift, JS,
|
|
71
|
+
and Dart projects, inspect equivalent API clients, request models, response
|
|
72
|
+
models, environment config, and auth/header middleware.
|
|
73
|
+
|
|
74
|
+
## Endpoint Format
|
|
75
|
+
|
|
76
|
+
Endpoint definitions are always persisted as YAML under `data/apis/*.yaml` or
|
|
77
|
+
legacy `apis/*.yaml`. JSON is only used as a machine-readable intermediate
|
|
78
|
+
between scanner scripts and evt-cli.
|
|
79
|
+
|
|
80
|
+
Example YAML:
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
namespace: auth
|
|
84
|
+
|
|
85
|
+
endpoints:
|
|
86
|
+
login:
|
|
87
|
+
method: "POST"
|
|
88
|
+
path: "/api/login"
|
|
89
|
+
auth: false
|
|
90
|
+
schema:
|
|
91
|
+
body:
|
|
92
|
+
email:
|
|
93
|
+
type: "string"
|
|
94
|
+
required: true
|
|
95
|
+
response:
|
|
96
|
+
envelope: "Resp"
|
|
97
|
+
data:
|
|
98
|
+
type: "object"
|
|
99
|
+
model: "LoginData"
|
|
100
|
+
fields:
|
|
101
|
+
token: "string"
|
|
102
|
+
user:
|
|
103
|
+
type: "object"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Quality Rules
|
|
107
|
+
|
|
108
|
+
- Endpoint IDs should be stable. Preserve existing IDs. If no YAML exists, use
|
|
109
|
+
source function names exactly and do not add, remove, or normalize prefixes
|
|
110
|
+
such as `get` unless a project convention proves that mapping.
|
|
111
|
+
- Do not leave `schema: {}` when source parameters or request DTOs exist.
|
|
112
|
+
- Do not use generic `response.data.type: object` when a response type or DTO
|
|
113
|
+
can be resolved.
|
|
114
|
+
- For wrapped responses such as `Resp<T>`, record the envelope and the nested
|
|
115
|
+
data model.
|
|
116
|
+
- Preserve path parameters, query parameters embedded in path strings, and
|
|
117
|
+
absolute URLs. Map alternate hosts to `service` when profiles can provide a
|
|
118
|
+
named base URL.
|
|
119
|
+
- Infer `auth` from the HTTP client, repository call path, interceptor, or
|
|
120
|
+
feature state. Public market/config endpoints are usually unauthenticated;
|
|
121
|
+
user/account/order/history endpoints usually require auth. Mark uncertain
|
|
122
|
+
cases in the final report.
|
|
123
|
+
- Mark mutating or irreversible endpoints with `dangerous: true`; examples are
|
|
124
|
+
order creation/cancelation, withdrawal, transfer, account deletion, and
|
|
125
|
+
credential changes.
|
|
126
|
+
- Keep scanner JSON internal. Runtime endpoint data remains YAML.
|
|
127
|
+
|
|
128
|
+
## Completion Check
|
|
129
|
+
|
|
130
|
+
Run these before final answer:
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
evt validate --config-root ./cli
|
|
134
|
+
evt api audit --config-root ./cli --strict
|
|
135
|
+
evt api coverage --config-root ./cli
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Report endpoint count, empty-schema count, generic-response count, auth
|
|
139
|
+
uncertain count, and any endpoints whose ID or path could not be resolved
|
|
140
|
+
confidently.
|
|
141
|
+
|
|
142
|
+
## Fallback
|
|
143
|
+
|
|
144
|
+
evt-cli should prefer this skill scanner when configured. If the skill scanner
|
|
145
|
+
fails or returns no endpoints, evt-cli falls back to the built-in regex scanner
|
|
146
|
+
targets. The fallback exists for users without an agent or skill-aware workflow,
|
|
147
|
+
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();
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: evt-flow-generator
|
|
3
|
+
description: Use when an agent needs to create or update evt-cli YAML flows from API YAML definitions, product workflows, authentication requirements, or test scenarios, including interactive inputs, waits, token cache saves, and feature smoke flows.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# evt-flow-generator
|
|
7
|
+
|
|
8
|
+
Use this skill to produce executable evt-cli flows under `data/flows/*.yaml` or
|
|
9
|
+
legacy `flows/*.yaml`.
|
|
10
|
+
|
|
11
|
+
## Workflow
|
|
12
|
+
|
|
13
|
+
1. Locate the 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. Inspect available endpoints:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
evt api list --config-root ./cli
|
|
21
|
+
evt api show <namespace.endpoint> --config-root ./cli
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
3. Read existing flow examples and product docs/source to identify:
|
|
25
|
+
- login or session refresh sequence
|
|
26
|
+
- token field path in the login response
|
|
27
|
+
- prerequisite calls for feature flows
|
|
28
|
+
- required waits between calls
|
|
29
|
+
- safe smoke-test values from profile fixtures
|
|
30
|
+
4. Read the evt runtime semantics before saving values:
|
|
31
|
+
- each step result contains `response`, the full parsed response
|
|
32
|
+
- each step result contains `data`, the unwrapped `response.data` when present
|
|
33
|
+
- for wrapped APIs, prefer `steps.<id>.response.data.<field>` for cache saves
|
|
34
|
+
5. Write YAML flows such as:
|
|
35
|
+
- `data/flows/login.yaml`
|
|
36
|
+
- `data/flows/smoke.yaml`
|
|
37
|
+
- `data/flows/<feature>.yaml`
|
|
38
|
+
6. Validate:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
evt validate --config-root ./cli
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Flow Format
|
|
45
|
+
|
|
46
|
+
```yaml
|
|
47
|
+
name: login
|
|
48
|
+
description: Login and save session token.
|
|
49
|
+
|
|
50
|
+
inputs:
|
|
51
|
+
email:
|
|
52
|
+
prompt: Email
|
|
53
|
+
type: string
|
|
54
|
+
required: true
|
|
55
|
+
password:
|
|
56
|
+
prompt: Password
|
|
57
|
+
type: password
|
|
58
|
+
required: true
|
|
59
|
+
|
|
60
|
+
steps:
|
|
61
|
+
- id: login
|
|
62
|
+
call: auth.login
|
|
63
|
+
body:
|
|
64
|
+
email: "{{inputs.email}}"
|
|
65
|
+
password: "{{inputs.password}}"
|
|
66
|
+
expect:
|
|
67
|
+
path: response.data.token
|
|
68
|
+
exists: true
|
|
69
|
+
|
|
70
|
+
save:
|
|
71
|
+
cache:
|
|
72
|
+
token: "{{steps.login.response.data.token}}"
|
|
73
|
+
required:
|
|
74
|
+
- cache.token
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Use waits when the backend needs spacing:
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
steps:
|
|
81
|
+
- id: createOrder
|
|
82
|
+
call: trade.createOrder
|
|
83
|
+
body:
|
|
84
|
+
symbol: "{{inputs.symbol}}"
|
|
85
|
+
side: "BUY"
|
|
86
|
+
- wait: 2s
|
|
87
|
+
- id: queryOrder
|
|
88
|
+
call: trade.orderDetail
|
|
89
|
+
query:
|
|
90
|
+
orderId: "{{steps.createOrder.response.data.orderId}}"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Use `afterWait` when only one step needs a post-call interval.
|
|
94
|
+
|
|
95
|
+
## Rules
|
|
96
|
+
|
|
97
|
+
- Runtime flows are YAML. Do not persist flow definitions as JSON.
|
|
98
|
+
- Keep bundled `*.example.yaml` generic; write project-specific flows to
|
|
99
|
+
non-example YAML files.
|
|
100
|
+
- Use `inputs` for values a user may need to type dynamically.
|
|
101
|
+
- Use `profile.inputs` by naming flow inputs the same way as profile keys.
|
|
102
|
+
- Use profile-backed values for device/session fields when source does so, for
|
|
103
|
+
example `{{profile.device.fingerprintId}}` instead of a new placeholder input.
|
|
104
|
+
- Use `wait` or `afterWait` for backend intervals; valid values include `500ms`,
|
|
105
|
+
`2s`, and `1m`.
|
|
106
|
+
- Preserve waits found in source, tests, docs, or existing flows.
|
|
107
|
+
- Save login state through `save.cache.token` and `save.required`. For wrapped
|
|
108
|
+
responses, prefer `{{steps.login.response.data.token}}`.
|
|
109
|
+
- Reference previous step outputs with `{{steps.<id>.<path>}}`.
|
|
110
|
+
- Prefer `expect` checks for required response fields when a flow depends on
|
|
111
|
+
them.
|
|
112
|
+
- Put logout last in flows that include logout.
|
|
113
|
+
- For feature flows, include prerequisite reads, state-changing calls, follow-up
|
|
114
|
+
reads, waits, cleanup, and final assertions when the scenario needs them.
|
|
115
|
+
- Use endpoint schema and profile fixtures to build request bodies. Do not
|
|
116
|
+
invent field names that are absent from endpoint YAML or source models.
|
|
117
|
+
- Do not include destructive business actions unless the project or user states
|
|
118
|
+
that the environment is safe for testing.
|
|
119
|
+
|
|
120
|
+
## Completion Check
|
|
121
|
+
|
|
122
|
+
After writing flows, run:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
evt validate --config-root ./cli
|
|
126
|
+
evt flow run login --config-root ./cli --profile <profile> --dry-run
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For login flows, inspect the dry-run output and verify:
|
|
130
|
+
|
|
131
|
+
- the expected endpoint order is present
|
|
132
|
+
- required waits are present
|
|
133
|
+
- token save path matches the response model
|
|
134
|
+
- headers come from the selected profile
|
|
135
|
+
- no required input is missing
|
|
136
|
+
|
|
137
|
+
When real credentials and a safe test environment are available, run the login
|
|
138
|
+
flow without `--dry-run`, confirm the token is written to cache, then run the
|
|
139
|
+
authenticated smoke or feature flow. Report any step that was only inferred.
|