@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.
Files changed (46) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +77 -0
  3. package/bin/suitest-mcp.js +123 -0
  4. package/package.json +50 -0
  5. package/python/suitest_lifecycle/__init__.py +3 -0
  6. package/python/suitest_lifecycle/analyzers/__init__.py +1 -0
  7. package/python/suitest_lifecycle/analyzers/crawl.py +187 -0
  8. package/python/suitest_lifecycle/analyzers/express.py +226 -0
  9. package/python/suitest_lifecycle/analyzers/openapi.py +163 -0
  10. package/python/suitest_lifecycle/analyzers/postman.py +132 -0
  11. package/python/suitest_lifecycle/analyzers/react.py +107 -0
  12. package/python/suitest_lifecycle/analyzers/zod_schema.py +131 -0
  13. package/python/suitest_lifecycle/blackbox/__init__.py +11 -0
  14. package/python/suitest_lifecycle/blackbox/bootstrap.py +249 -0
  15. package/python/suitest_lifecycle/blackbox/crawler.py +383 -0
  16. package/python/suitest_lifecycle/blackbox/detector.py +169 -0
  17. package/python/suitest_lifecycle/blackbox/generator.py +608 -0
  18. package/python/suitest_lifecycle/blackbox/graph.py +107 -0
  19. package/python/suitest_lifecycle/blackbox/mcp.py +546 -0
  20. package/python/suitest_lifecycle/blackbox/models.py +299 -0
  21. package/python/suitest_lifecycle/blackbox/prd_ingest.py +108 -0
  22. package/python/suitest_lifecycle/blackbox/reporter.py +76 -0
  23. package/python/suitest_lifecycle/blackbox/selector.py +111 -0
  24. package/python/suitest_lifecycle/cli.py +127 -0
  25. package/python/suitest_lifecycle/config.py +314 -0
  26. package/python/suitest_lifecycle/enrich.py +140 -0
  27. package/python/suitest_lifecycle/exporters/__init__.py +1 -0
  28. package/python/suitest_lifecycle/exporters/backend.py +345 -0
  29. package/python/suitest_lifecycle/exporters/frontend.py +459 -0
  30. package/python/suitest_lifecycle/frontend_runtime.py +77 -0
  31. package/python/suitest_lifecycle/llm_bridge.py +365 -0
  32. package/python/suitest_lifecycle/mcp_server.py +187 -0
  33. package/python/suitest_lifecycle/models.py +166 -0
  34. package/python/suitest_lifecycle/orchestrator.py +500 -0
  35. package/python/suitest_lifecycle/paths.py +90 -0
  36. package/python/suitest_lifecycle/plan.py +366 -0
  37. package/python/suitest_lifecycle/plan_frontend.py +252 -0
  38. package/python/suitest_lifecycle/prd.py +92 -0
  39. package/python/suitest_lifecycle/process.py +111 -0
  40. package/python/suitest_lifecycle/publish.py +218 -0
  41. package/python/suitest_lifecycle/readiness.py +83 -0
  42. package/python/suitest_lifecycle/report.py +179 -0
  43. package/python/suitest_lifecycle/runner.py +138 -0
  44. package/python/suitest_lifecycle/serialize.py +131 -0
  45. package/python/suitest_lifecycle/tcm.py +149 -0
  46. 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"]