@suiflex/suitest-mcp 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.
- package/LICENSE +201 -0
- package/README.md +77 -0
- package/bin/suitest-mcp.js +123 -0
- package/package.json +50 -0
- package/python/suitest_lifecycle/__init__.py +3 -0
- package/python/suitest_lifecycle/analyzers/__init__.py +1 -0
- package/python/suitest_lifecycle/analyzers/crawl.py +187 -0
- package/python/suitest_lifecycle/analyzers/express.py +226 -0
- package/python/suitest_lifecycle/analyzers/openapi.py +163 -0
- package/python/suitest_lifecycle/analyzers/postman.py +132 -0
- package/python/suitest_lifecycle/analyzers/react.py +107 -0
- package/python/suitest_lifecycle/analyzers/zod_schema.py +131 -0
- package/python/suitest_lifecycle/blackbox/__init__.py +11 -0
- package/python/suitest_lifecycle/blackbox/bootstrap.py +249 -0
- package/python/suitest_lifecycle/blackbox/crawler.py +383 -0
- package/python/suitest_lifecycle/blackbox/detector.py +169 -0
- package/python/suitest_lifecycle/blackbox/generator.py +608 -0
- package/python/suitest_lifecycle/blackbox/graph.py +107 -0
- package/python/suitest_lifecycle/blackbox/mcp.py +546 -0
- package/python/suitest_lifecycle/blackbox/models.py +299 -0
- package/python/suitest_lifecycle/blackbox/prd_ingest.py +108 -0
- package/python/suitest_lifecycle/blackbox/reporter.py +76 -0
- package/python/suitest_lifecycle/blackbox/selector.py +111 -0
- package/python/suitest_lifecycle/cli.py +127 -0
- package/python/suitest_lifecycle/config.py +314 -0
- package/python/suitest_lifecycle/enrich.py +140 -0
- package/python/suitest_lifecycle/exporters/__init__.py +1 -0
- package/python/suitest_lifecycle/exporters/backend.py +345 -0
- package/python/suitest_lifecycle/exporters/frontend.py +459 -0
- package/python/suitest_lifecycle/frontend_runtime.py +77 -0
- package/python/suitest_lifecycle/llm_bridge.py +365 -0
- package/python/suitest_lifecycle/mcp_server.py +187 -0
- package/python/suitest_lifecycle/models.py +166 -0
- package/python/suitest_lifecycle/orchestrator.py +500 -0
- package/python/suitest_lifecycle/paths.py +90 -0
- package/python/suitest_lifecycle/plan.py +366 -0
- package/python/suitest_lifecycle/plan_frontend.py +252 -0
- package/python/suitest_lifecycle/prd.py +92 -0
- package/python/suitest_lifecycle/process.py +111 -0
- package/python/suitest_lifecycle/publish.py +218 -0
- package/python/suitest_lifecycle/readiness.py +83 -0
- package/python/suitest_lifecycle/report.py +179 -0
- package/python/suitest_lifecycle/runner.py +138 -0
- package/python/suitest_lifecycle/serialize.py +131 -0
- package/python/suitest_lifecycle/tcm.py +149 -0
- package/python/suitest_lifecycle/tools.py +217 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Deterministic Express/Node route analyzer (ZERO tier — no LLM).
|
|
2
|
+
|
|
3
|
+
Handles the canonical modular Express layout used by real TS backends:
|
|
4
|
+
|
|
5
|
+
// app.ts
|
|
6
|
+
app.get("/api/health", ...) // direct route
|
|
7
|
+
app.use("/api/auth", authRoutes) // mounted router
|
|
8
|
+
app.use("/api/products", productRoutes)
|
|
9
|
+
|
|
10
|
+
// auth.routes.ts
|
|
11
|
+
router.post("/login", loginController)
|
|
12
|
+
router.get("/me", authMiddleware, meController)
|
|
13
|
+
|
|
14
|
+
// product.routes.ts
|
|
15
|
+
router.use(authMiddleware) // router-level auth
|
|
16
|
+
router.get("/", listController)
|
|
17
|
+
|
|
18
|
+
Output: a :class:`CodeSummary` with fully-qualified, auth-aware endpoints. It is
|
|
19
|
+
heuristic (regex over source) but traceable: every endpoint records the file it
|
|
20
|
+
came from. Anything it cannot resolve is simply omitted rather than guessed.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import re
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from suitest_lifecycle.models import CodeSummary, Endpoint, Mode
|
|
30
|
+
|
|
31
|
+
_METHODS = ("get", "post", "put", "delete", "patch")
|
|
32
|
+
# app.use("/prefix", routerVar)
|
|
33
|
+
_MOUNT_RE = re.compile(r"""\.use\(\s*['"](?P<prefix>/[^'"]*)['"]\s*,\s*(?P<var>\w+)\s*\)""")
|
|
34
|
+
# import authRoutes from "./modules/auth/auth.routes"
|
|
35
|
+
_IMPORT_RE = re.compile(r"""import\s+(?P<var>\w+)\s+from\s+['"](?P<spec>[^'"]+)['"]""")
|
|
36
|
+
# app.get("/api/health", ...) / router.post("/login", authMiddleware, ctrl)
|
|
37
|
+
_ROUTE_RE = re.compile(
|
|
38
|
+
r"""\b(?P<obj>app|router)\s*\.\s*(?P<method>get|post|put|delete|patch)\s*\(\s*"""
|
|
39
|
+
r"""['"](?P<path>[^'"]*)['"](?P<rest>[^)]*)\)""",
|
|
40
|
+
re.IGNORECASE,
|
|
41
|
+
)
|
|
42
|
+
_ROUTER_USE_AUTH_RE = re.compile(r"""\brouter\s*\.\s*use\(\s*(?P<mw>\w+)\s*\)""")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _ts_files(src: Path) -> list[Path]:
|
|
46
|
+
return sorted(
|
|
47
|
+
p for p in src.rglob("*.ts") if ".d.ts" not in p.name and "node_modules" not in p.parts
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _join(prefix: str, sub: str) -> str:
|
|
52
|
+
prefix = "/" + prefix.strip("/")
|
|
53
|
+
sub = sub.strip("/")
|
|
54
|
+
return prefix if not sub else f"{prefix.rstrip('/')}/{sub}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_import(from_file: Path, spec: str, src: Path) -> Path | None:
|
|
58
|
+
if not spec.startswith("."):
|
|
59
|
+
return None
|
|
60
|
+
base = (from_file.parent / spec).resolve()
|
|
61
|
+
for cand in (base.with_suffix(".ts"), base / "index.ts", Path(str(base) + ".ts")):
|
|
62
|
+
if cand.is_file():
|
|
63
|
+
return cand
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _auth_markers(text: str) -> tuple[bool, set[str]]:
|
|
68
|
+
"""Return (router_level_auth, set of auth middleware identifiers seen)."""
|
|
69
|
+
auth_ids: set[str] = set()
|
|
70
|
+
for m in _ROUTER_USE_AUTH_RE.finditer(text):
|
|
71
|
+
mw = m.group("mw")
|
|
72
|
+
if "auth" in mw.lower():
|
|
73
|
+
auth_ids.add(mw)
|
|
74
|
+
router_level = bool(auth_ids)
|
|
75
|
+
# also collect any identifier that looks like auth middleware referenced inline
|
|
76
|
+
for ident in re.findall(r"\b(\w*[Aa]uth\w*)\b", text):
|
|
77
|
+
if "middleware" in ident.lower() or ident.lower() in {
|
|
78
|
+
"authmiddleware",
|
|
79
|
+
"requireauth",
|
|
80
|
+
"protect",
|
|
81
|
+
}:
|
|
82
|
+
auth_ids.add(ident)
|
|
83
|
+
return router_level, auth_ids
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _routes_in_file(path: Path, src: Path) -> tuple[list[tuple[str, str, bool, str]], bool]:
|
|
87
|
+
"""Parse one router/app file.
|
|
88
|
+
|
|
89
|
+
Returns (routes, router_level_auth) where each route is
|
|
90
|
+
(method, sub_path, inline_auth, handler).
|
|
91
|
+
"""
|
|
92
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
93
|
+
router_level, auth_ids = _auth_markers(text)
|
|
94
|
+
routes: list[tuple[str, str, bool, str]] = []
|
|
95
|
+
for m in _ROUTE_RE.finditer(text):
|
|
96
|
+
method = m.group("method").upper()
|
|
97
|
+
sub = m.group("path")
|
|
98
|
+
rest = m.group("rest")
|
|
99
|
+
inline_auth = any(aid in rest for aid in auth_ids)
|
|
100
|
+
handler = ""
|
|
101
|
+
ids = re.findall(r"\b(\w+)\b", rest)
|
|
102
|
+
if ids:
|
|
103
|
+
handler = ids[-1]
|
|
104
|
+
routes.append((method, sub, inline_auth, handler))
|
|
105
|
+
return routes, router_level
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _tech_stack(project_path: Path) -> tuple[list[str], str]:
|
|
109
|
+
stack: list[str] = ["TypeScript", "Node.js"]
|
|
110
|
+
auth_flow = ""
|
|
111
|
+
pkg = project_path / "package.json"
|
|
112
|
+
if pkg.is_file():
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(pkg.read_text(encoding="utf-8"))
|
|
115
|
+
except json.JSONDecodeError:
|
|
116
|
+
data = {}
|
|
117
|
+
deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
|
|
118
|
+
known = {
|
|
119
|
+
"express": "Express",
|
|
120
|
+
"@prisma/client": "Prisma",
|
|
121
|
+
"prisma": "Prisma",
|
|
122
|
+
"jsonwebtoken": "JWT",
|
|
123
|
+
"zod": "Zod",
|
|
124
|
+
"bcryptjs": "bcrypt",
|
|
125
|
+
"cors": "CORS",
|
|
126
|
+
}
|
|
127
|
+
for dep, label in known.items():
|
|
128
|
+
if dep in deps and label not in stack:
|
|
129
|
+
stack.append(label)
|
|
130
|
+
if "jsonwebtoken" in deps:
|
|
131
|
+
auth_flow = (
|
|
132
|
+
"JWT bearer: POST login returns a token used as 'Authorization: Bearer <token>'."
|
|
133
|
+
)
|
|
134
|
+
return stack, auth_flow
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def analyze_express(project_path: Path, project_name: str) -> CodeSummary:
|
|
138
|
+
src = project_path / "src"
|
|
139
|
+
if not src.is_dir():
|
|
140
|
+
src = project_path
|
|
141
|
+
files = _ts_files(src)
|
|
142
|
+
|
|
143
|
+
# 1) imports per file (var -> resolved file)
|
|
144
|
+
# 2) mounts (prefix -> var) across all files (app entry)
|
|
145
|
+
mounts: list[tuple[str, str, Path]] = [] # (prefix, var, file)
|
|
146
|
+
imports_by_file: dict[Path, dict[str, Path]] = {}
|
|
147
|
+
direct_routes: list[Endpoint] = []
|
|
148
|
+
|
|
149
|
+
for f in files:
|
|
150
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
151
|
+
imap: dict[str, Path] = {}
|
|
152
|
+
for im in _IMPORT_RE.finditer(text):
|
|
153
|
+
resolved = _resolve_import(f, im.group("spec"), src)
|
|
154
|
+
if resolved is not None:
|
|
155
|
+
imap[im.group("var")] = resolved
|
|
156
|
+
imports_by_file[f] = imap
|
|
157
|
+
for mm in _MOUNT_RE.finditer(text):
|
|
158
|
+
mounts.append((mm.group("prefix"), mm.group("var"), f))
|
|
159
|
+
# direct app.<method> routes (e.g. health)
|
|
160
|
+
for m in _ROUTE_RE.finditer(text):
|
|
161
|
+
if m.group("obj").lower() == "app":
|
|
162
|
+
method = m.group("method").upper()
|
|
163
|
+
path = m.group("path")
|
|
164
|
+
rest = m.group("rest")
|
|
165
|
+
_, auth_ids = _auth_markers(text)
|
|
166
|
+
inline_auth = any(aid in rest for aid in auth_ids)
|
|
167
|
+
direct_routes.append(
|
|
168
|
+
Endpoint(
|
|
169
|
+
method=method,
|
|
170
|
+
path="/" + path.strip("/"),
|
|
171
|
+
auth_required=inline_auth,
|
|
172
|
+
source_file=str(f.relative_to(project_path)),
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
endpoints: list[Endpoint] = list(direct_routes)
|
|
177
|
+
|
|
178
|
+
# 3) expand each mount into its router file's routes
|
|
179
|
+
for prefix, var, mount_file in mounts:
|
|
180
|
+
router_file = imports_by_file.get(mount_file, {}).get(var)
|
|
181
|
+
if router_file is None or not router_file.is_file():
|
|
182
|
+
continue
|
|
183
|
+
routes, router_level_auth = _routes_in_file(router_file, src)
|
|
184
|
+
for method, sub, inline_auth, handler in routes:
|
|
185
|
+
endpoints.append(
|
|
186
|
+
Endpoint(
|
|
187
|
+
method=method,
|
|
188
|
+
path=_join(prefix, sub),
|
|
189
|
+
auth_required=router_level_auth or inline_auth,
|
|
190
|
+
source_file=str(router_file.relative_to(project_path)),
|
|
191
|
+
handler=handler,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# de-dup (method, path), keep first
|
|
196
|
+
seen: set[tuple[str, str]] = set()
|
|
197
|
+
unique: list[Endpoint] = []
|
|
198
|
+
for ep in endpoints:
|
|
199
|
+
key = (ep.method, ep.path)
|
|
200
|
+
if key not in seen:
|
|
201
|
+
seen.add(key)
|
|
202
|
+
unique.append(ep)
|
|
203
|
+
unique.sort(key=lambda e: (e.path, e.method))
|
|
204
|
+
|
|
205
|
+
stack, auth_flow = _tech_stack(project_path)
|
|
206
|
+
features = _infer_features(unique)
|
|
207
|
+
|
|
208
|
+
return CodeSummary(
|
|
209
|
+
project_name=project_name,
|
|
210
|
+
mode=Mode.BACKEND,
|
|
211
|
+
tech_stack=stack,
|
|
212
|
+
endpoints=unique,
|
|
213
|
+
features=features,
|
|
214
|
+
auth_flow=auth_flow,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _infer_features(endpoints: list[Endpoint]) -> list[str]:
|
|
219
|
+
groups: dict[str, int] = {}
|
|
220
|
+
for ep in endpoints:
|
|
221
|
+
parts = [p for p in ep.path.strip("/").split("/") if p and not p.startswith(":")]
|
|
222
|
+
# drop leading 'api'
|
|
223
|
+
parts = [p for p in parts if p != "api"]
|
|
224
|
+
key = parts[0] if parts else "root"
|
|
225
|
+
groups[key] = groups.get(key, 0) + 1
|
|
226
|
+
return [k for k, _ in sorted(groups.items(), key=lambda kv: (-kv[1], kv[0]))]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""No-repo backend discovery from an OpenAPI spec (deterministic, ZERO tier).
|
|
2
|
+
|
|
3
|
+
When QA has no source checkout, the API contract is the discovery source. Reads
|
|
4
|
+
an OpenAPI 3.x document (local file or fetched URL), and emits endpoints with
|
|
5
|
+
auth flags + an example request body (so the backend exporter can build a valid
|
|
6
|
+
create payload without the project's Zod schema).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import urllib.request
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from suitest_lifecycle.models import CodeSummary, Endpoint, Mode
|
|
16
|
+
|
|
17
|
+
_METHODS = ("get", "post", "put", "delete", "patch")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_spec(*, url: str, file: str, base_url: str) -> dict[str, object]:
|
|
21
|
+
"""Load an OpenAPI doc from a local file or a URL (absolute, or relative to base_url)."""
|
|
22
|
+
if file:
|
|
23
|
+
text = Path(file).read_text(encoding="utf-8")
|
|
24
|
+
return _as_dict(json.loads(text))
|
|
25
|
+
if url:
|
|
26
|
+
full = url if url.startswith("http") else base_url.rstrip("/") + "/" + url.lstrip("/")
|
|
27
|
+
with urllib.request.urlopen(full, timeout=15) as resp:
|
|
28
|
+
return _as_dict(json.loads(resp.read().decode("utf-8")))
|
|
29
|
+
raise ValueError("no openapi url or file provided")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _as_dict(data: object) -> dict[str, object]:
|
|
33
|
+
if not isinstance(data, dict):
|
|
34
|
+
raise ValueError("OpenAPI spec must be a JSON object")
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _resolve_ref(ref: str, spec: dict[str, object]) -> dict[str, object]:
|
|
39
|
+
# only local refs: #/components/schemas/Name
|
|
40
|
+
node: object = spec
|
|
41
|
+
for part in ref.lstrip("#/").split("/"):
|
|
42
|
+
if isinstance(node, dict):
|
|
43
|
+
node = node.get(part, {})
|
|
44
|
+
return node if isinstance(node, dict) else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _example_from_schema(
|
|
48
|
+
schema: dict[str, object], spec: dict[str, object], depth: int = 0
|
|
49
|
+
) -> object:
|
|
50
|
+
if depth > 5 or not isinstance(schema, dict):
|
|
51
|
+
return None
|
|
52
|
+
if "$ref" in schema and isinstance(schema["$ref"], str):
|
|
53
|
+
schema = _resolve_ref(schema["$ref"], spec)
|
|
54
|
+
if "example" in schema:
|
|
55
|
+
return schema["example"]
|
|
56
|
+
stype = schema.get("type")
|
|
57
|
+
if stype == "object" or "properties" in schema:
|
|
58
|
+
props = schema.get("properties", {})
|
|
59
|
+
required = schema.get("required", [])
|
|
60
|
+
out: dict[str, object] = {}
|
|
61
|
+
if isinstance(props, dict):
|
|
62
|
+
for name, sub in props.items():
|
|
63
|
+
# include required fields (+ a couple optionals are fine)
|
|
64
|
+
if isinstance(required, list) and name not in required:
|
|
65
|
+
continue
|
|
66
|
+
out[str(name)] = _example_from_schema(
|
|
67
|
+
sub if isinstance(sub, dict) else {}, spec, depth + 1
|
|
68
|
+
)
|
|
69
|
+
return out
|
|
70
|
+
if stype == "string":
|
|
71
|
+
fmt = schema.get("format")
|
|
72
|
+
if fmt == "email":
|
|
73
|
+
return "user@example.com"
|
|
74
|
+
enum = schema.get("enum")
|
|
75
|
+
if isinstance(enum, list) and enum:
|
|
76
|
+
return enum[0]
|
|
77
|
+
return "suitest-sample"
|
|
78
|
+
if stype == "integer":
|
|
79
|
+
return 1
|
|
80
|
+
if stype == "number":
|
|
81
|
+
return 9.99
|
|
82
|
+
if stype == "boolean":
|
|
83
|
+
return True
|
|
84
|
+
if stype == "array":
|
|
85
|
+
return []
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _request_example(op: dict[str, object], spec: dict[str, object]) -> dict[str, object] | None:
|
|
90
|
+
body = op.get("requestBody")
|
|
91
|
+
if not isinstance(body, dict):
|
|
92
|
+
return None
|
|
93
|
+
content = body.get("content")
|
|
94
|
+
if not isinstance(content, dict):
|
|
95
|
+
return None
|
|
96
|
+
media = content.get("application/json")
|
|
97
|
+
if not isinstance(media, dict):
|
|
98
|
+
return None
|
|
99
|
+
schema = media.get("schema")
|
|
100
|
+
example = _example_from_schema(schema if isinstance(schema, dict) else {}, spec)
|
|
101
|
+
return example if isinstance(example, dict) else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _auth_required(op: dict[str, object], spec: dict[str, object]) -> bool:
|
|
105
|
+
if "security" in op:
|
|
106
|
+
sec = op.get("security")
|
|
107
|
+
return bool(sec) # [] means explicitly public
|
|
108
|
+
return bool(spec.get("security"))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def analyze_openapi(spec: dict[str, object], project_name: str) -> CodeSummary:
|
|
112
|
+
paths = spec.get("paths", {})
|
|
113
|
+
endpoints: list[Endpoint] = []
|
|
114
|
+
if isinstance(paths, dict):
|
|
115
|
+
for raw_path, item in paths.items():
|
|
116
|
+
if not isinstance(item, dict):
|
|
117
|
+
continue
|
|
118
|
+
for method in _METHODS:
|
|
119
|
+
op = item.get(method)
|
|
120
|
+
if not isinstance(op, dict):
|
|
121
|
+
continue
|
|
122
|
+
endpoints.append(
|
|
123
|
+
Endpoint(
|
|
124
|
+
method=method.upper(),
|
|
125
|
+
path=str(raw_path),
|
|
126
|
+
auth_required=_auth_required(op, spec),
|
|
127
|
+
source_file="openapi",
|
|
128
|
+
handler=str(op.get("operationId", "")),
|
|
129
|
+
summary=str(op.get("summary", "")),
|
|
130
|
+
request_example=_request_example(op, spec),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
endpoints.sort(key=lambda e: (e.path, e.method))
|
|
134
|
+
|
|
135
|
+
info = spec.get("info", {})
|
|
136
|
+
title = info.get("title", project_name) if isinstance(info, dict) else project_name
|
|
137
|
+
stack = ["OpenAPI", "HTTP API"]
|
|
138
|
+
groups: dict[str, int] = {}
|
|
139
|
+
for ep in endpoints:
|
|
140
|
+
parts = [p for p in ep.path.strip("/").split("/") if p and not _is_param(p) and p != "api"]
|
|
141
|
+
key = parts[0] if parts else "root"
|
|
142
|
+
groups[key] = groups.get(key, 0) + 1
|
|
143
|
+
auth_flow = (
|
|
144
|
+
"Auth required on secured operations (per OpenAPI security)."
|
|
145
|
+
if any(e.auth_required for e in endpoints)
|
|
146
|
+
else ""
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return CodeSummary(
|
|
150
|
+
project_name=str(title) or project_name,
|
|
151
|
+
mode=Mode.BACKEND,
|
|
152
|
+
tech_stack=stack,
|
|
153
|
+
endpoints=endpoints,
|
|
154
|
+
features=[k for k, _ in sorted(groups.items(), key=lambda kv: (-kv[1], kv[0]))],
|
|
155
|
+
auth_flow=auth_flow,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _is_param(seg: str) -> bool:
|
|
160
|
+
return seg.startswith(":") or (seg.startswith("{") and seg.endswith("}"))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
__all__ = ["analyze_openapi", "load_spec"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""No-repo backend discovery from a Postman v2 collection (deterministic).
|
|
2
|
+
|
|
3
|
+
Walks the collection (folders nested), extracting method + path + an example
|
|
4
|
+
request body per request. Auth is inferred from an Authorization header or an
|
|
5
|
+
auth block. Path is taken from ``url.path`` (or parsed from ``url.raw``), with
|
|
6
|
+
``{{baseUrl}}`` and host stripped.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from suitest_lifecycle.models import CodeSummary, Endpoint, Mode
|
|
16
|
+
|
|
17
|
+
_PARAM = re.compile(r"\{\{[^}]+\}\}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_collection(file: str) -> dict[str, object]:
|
|
21
|
+
data = json.loads(Path(file).read_text(encoding="utf-8"))
|
|
22
|
+
if not isinstance(data, dict):
|
|
23
|
+
raise ValueError("Postman collection must be a JSON object")
|
|
24
|
+
return data
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _path_from_url(url: object) -> str:
|
|
28
|
+
if isinstance(url, dict):
|
|
29
|
+
segs = url.get("path")
|
|
30
|
+
if isinstance(segs, list):
|
|
31
|
+
parts = [str(s) for s in segs]
|
|
32
|
+
return "/" + "/".join(p.strip("/") for p in parts if p)
|
|
33
|
+
raw = str(url.get("raw", ""))
|
|
34
|
+
else:
|
|
35
|
+
raw = str(url)
|
|
36
|
+
raw = _PARAM.sub("", raw) # drop {{baseUrl}} etc.
|
|
37
|
+
raw = re.sub(r"^https?://[^/]+", "", raw) # drop scheme+host
|
|
38
|
+
raw = raw.split("?", 1)[0] # drop query
|
|
39
|
+
return "/" + raw.strip("/")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _example_body(request: dict[str, object]) -> dict[str, object] | None:
|
|
43
|
+
body = request.get("body")
|
|
44
|
+
if not isinstance(body, dict):
|
|
45
|
+
return None
|
|
46
|
+
if body.get("mode") == "raw":
|
|
47
|
+
raw = body.get("raw")
|
|
48
|
+
if isinstance(raw, str) and raw.strip():
|
|
49
|
+
try:
|
|
50
|
+
parsed = json.loads(raw)
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
return None
|
|
53
|
+
return parsed if isinstance(parsed, dict) else None
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _auth_required(request: dict[str, object]) -> bool:
|
|
58
|
+
auth = request.get("auth")
|
|
59
|
+
if isinstance(auth, dict) and auth.get("type") not in (None, "noauth"):
|
|
60
|
+
return True
|
|
61
|
+
headers = request.get("header")
|
|
62
|
+
if isinstance(headers, list):
|
|
63
|
+
for h in headers:
|
|
64
|
+
if isinstance(h, dict) and str(h.get("key", "")).lower() == "authorization":
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _walk(items: list[object], out: list[Endpoint]) -> None:
|
|
70
|
+
for it in items:
|
|
71
|
+
if not isinstance(it, dict):
|
|
72
|
+
continue
|
|
73
|
+
sub = it.get("item")
|
|
74
|
+
if isinstance(sub, list): # folder
|
|
75
|
+
_walk(sub, out)
|
|
76
|
+
continue
|
|
77
|
+
request = it.get("request")
|
|
78
|
+
if not isinstance(request, dict):
|
|
79
|
+
continue
|
|
80
|
+
method = str(request.get("method", "GET")).upper()
|
|
81
|
+
path = _path_from_url(request.get("url", ""))
|
|
82
|
+
out.append(
|
|
83
|
+
Endpoint(
|
|
84
|
+
method=method,
|
|
85
|
+
path=path,
|
|
86
|
+
auth_required=_auth_required(request),
|
|
87
|
+
source_file="postman",
|
|
88
|
+
handler=str(it.get("name", "")),
|
|
89
|
+
request_example=_example_body(request),
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def analyze_postman(collection: dict[str, object], project_name: str) -> CodeSummary:
|
|
95
|
+
endpoints: list[Endpoint] = []
|
|
96
|
+
items = collection.get("item")
|
|
97
|
+
if isinstance(items, list):
|
|
98
|
+
_walk(items, endpoints)
|
|
99
|
+
|
|
100
|
+
# de-dup (method, path)
|
|
101
|
+
seen: set[tuple[str, str]] = set()
|
|
102
|
+
unique: list[Endpoint] = []
|
|
103
|
+
for ep in endpoints:
|
|
104
|
+
key = (ep.method, ep.path)
|
|
105
|
+
if key not in seen:
|
|
106
|
+
seen.add(key)
|
|
107
|
+
unique.append(ep)
|
|
108
|
+
unique.sort(key=lambda e: (e.path, e.method))
|
|
109
|
+
|
|
110
|
+
info = collection.get("info", {})
|
|
111
|
+
name = info.get("name", project_name) if isinstance(info, dict) else project_name
|
|
112
|
+
groups: dict[str, int] = {}
|
|
113
|
+
for ep in unique:
|
|
114
|
+
parts = [
|
|
115
|
+
p for p in ep.path.strip("/").split("/") if p and not p.startswith(":") and p != "api"
|
|
116
|
+
]
|
|
117
|
+
key = parts[0] if parts else "root"
|
|
118
|
+
groups[key] = groups.get(key, 0) + 1
|
|
119
|
+
|
|
120
|
+
return CodeSummary(
|
|
121
|
+
project_name=str(name) or project_name,
|
|
122
|
+
mode=Mode.BACKEND,
|
|
123
|
+
tech_stack=["Postman", "HTTP API"],
|
|
124
|
+
endpoints=unique,
|
|
125
|
+
features=[k for k, _ in sorted(groups.items(), key=lambda kv: (-kv[1], kv[0]))],
|
|
126
|
+
auth_flow="Auth inferred from Authorization header / auth block."
|
|
127
|
+
if any(e.auth_required for e in unique)
|
|
128
|
+
else "",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
__all__ = ["analyze_postman", "load_collection"]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Deterministic React Router analyzer (ZERO tier).
|
|
2
|
+
|
|
3
|
+
Parses ``<Route path="…" element={<Component …/>}>`` declarations and marks a
|
|
4
|
+
route protected when it sits inside a ``<ProtectedRoute …>`` subtree. Also
|
|
5
|
+
harvests ``data-testid`` attributes per page so the frontend exporter can drive
|
|
6
|
+
the UI with stable selectors instead of brittle text matching.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from suitest_lifecycle.models import CodeSummary, Mode, Page
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_ROUTE_RE = re.compile(
|
|
20
|
+
r"""<Route\s+[^>]*?path=["'](?P<path>[^"']+)["'][^>]*?element=\{<(?P<comp>\w+)""",
|
|
21
|
+
re.DOTALL,
|
|
22
|
+
)
|
|
23
|
+
_PROTECTED_OPEN_RE = re.compile(r"element=\{<ProtectedRoute")
|
|
24
|
+
_TESTID_RE = re.compile(r"""data-testid=["'](?P<id>[^"']+)["']""")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _find_app_file(src: Path) -> Path | None:
|
|
28
|
+
for name in ("App.tsx", "App.jsx", "routes.tsx", "main.tsx"):
|
|
29
|
+
cand = src / name
|
|
30
|
+
if cand.is_file():
|
|
31
|
+
return cand
|
|
32
|
+
matches = [
|
|
33
|
+
p for p in src.rglob("*.tsx") if "<Route" in p.read_text(encoding="utf-8", errors="replace")
|
|
34
|
+
]
|
|
35
|
+
return matches[0] if matches else None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _collect_testids(src: Path) -> dict[str, list[str]]:
|
|
39
|
+
ids: dict[str, list[str]] = {}
|
|
40
|
+
for f in sorted((src / "pages").glob("*.tsx")) if (src / "pages").is_dir() else []:
|
|
41
|
+
text = f.read_text(encoding="utf-8", errors="replace")
|
|
42
|
+
found = sorted({m.group("id") for m in _TESTID_RE.finditer(text)})
|
|
43
|
+
ids[f.stem] = found
|
|
44
|
+
return ids
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def analyze_react(project_path: Path, project_name: str) -> CodeSummary:
|
|
48
|
+
src = project_path / "src"
|
|
49
|
+
if not src.is_dir():
|
|
50
|
+
src = project_path
|
|
51
|
+
app = _find_app_file(src)
|
|
52
|
+
pages: list[Page] = []
|
|
53
|
+
|
|
54
|
+
if app is not None:
|
|
55
|
+
text = app.read_text(encoding="utf-8", errors="replace")
|
|
56
|
+
protected_idx = -1
|
|
57
|
+
pm = _PROTECTED_OPEN_RE.search(text)
|
|
58
|
+
if pm:
|
|
59
|
+
protected_idx = pm.start()
|
|
60
|
+
catchall_idx = text.find('path="*"')
|
|
61
|
+
for m in _ROUTE_RE.finditer(text):
|
|
62
|
+
route = m.group("path")
|
|
63
|
+
comp = m.group("comp")
|
|
64
|
+
if route == "*":
|
|
65
|
+
continue
|
|
66
|
+
pos = m.start()
|
|
67
|
+
protected = protected_idx != -1 and pos > protected_idx
|
|
68
|
+
if catchall_idx != -1 and pos > catchall_idx:
|
|
69
|
+
protected = False
|
|
70
|
+
if route in {"/login"}:
|
|
71
|
+
protected = False
|
|
72
|
+
pages.append(
|
|
73
|
+
Page(
|
|
74
|
+
route=route,
|
|
75
|
+
name=comp,
|
|
76
|
+
protected=protected,
|
|
77
|
+
source_file=str(app.relative_to(project_path)),
|
|
78
|
+
)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
stack = ["TypeScript", "React", "Vite"]
|
|
82
|
+
pkg = project_path / "package.json"
|
|
83
|
+
if pkg.is_file():
|
|
84
|
+
text = pkg.read_text(encoding="utf-8", errors="replace")
|
|
85
|
+
if "react-router" in text:
|
|
86
|
+
stack.append("React Router")
|
|
87
|
+
if "axios" in text:
|
|
88
|
+
stack.append("axios")
|
|
89
|
+
|
|
90
|
+
return CodeSummary(
|
|
91
|
+
project_name=project_name,
|
|
92
|
+
mode=Mode.FRONTEND,
|
|
93
|
+
tech_stack=stack,
|
|
94
|
+
pages=pages,
|
|
95
|
+
features=[p.name for p in pages],
|
|
96
|
+
auth_flow="Form login at /login; protected routes redirect anonymous users to /login.",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def collect_testids(project_path: Path) -> dict[str, list[str]]:
|
|
101
|
+
src = project_path / "src"
|
|
102
|
+
if not src.is_dir():
|
|
103
|
+
src = project_path
|
|
104
|
+
return _collect_testids(src)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
__all__ = ["analyze_react", "collect_testids"]
|