@ranger1/dx 0.1.85 → 0.1.86

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 CHANGED
@@ -343,6 +343,14 @@ dx deploy backend --prod --skip-migration
343
343
  "installCommand": "pnpm install --prod --no-frozen-lockfile --ignore-workspace",
344
344
  "prismaGenerate": true,
345
345
  "prismaMigrateDeploy": true
346
+ },
347
+ "verify": {
348
+ "healthCheck": {
349
+ "url": "http://127.0.0.1:3005/api/v1/health",
350
+ "timeoutSeconds": 10,
351
+ "maxWaitSeconds": 24,
352
+ "retryIntervalSeconds": 2
353
+ }
346
354
  }
347
355
  }
348
356
  }
@@ -365,6 +373,56 @@ dx deploy backend --prod --skip-migration
365
373
  - 所有本地路径字段都会被解析为相对项目根目录,并且必须留在项目根目录内;例如 `build.distDir`、`runtime.prismaSchemaDir`、`artifact.outputDir` 不能通过 `../` 逃逸到仓库外。
366
374
  - `remote.baseDir` 必须是绝对路径,并且只能包含 `/`、字母、数字、`.`、`_`、`-`;不要使用空格或 shell 特殊字符。
367
375
 
376
+ 部署后验活与成功摘要:
377
+
378
+ - `dx deploy backend` 在远端启动完成后,会继续校验 `current` 软链接是否切到本次 release。
379
+ - 如果 `startup.mode` 是 `pm2`,还会校验 PM2 进程是否存在,以及 PM2 中的 `APP_ENV` / `NODE_ENV` 是否与部署环境一致。
380
+ - 如果配置了 `verify.healthCheck`,dx 会在远端对健康检查地址做重试探测;适合处理不同项目启动时间不一致的问题。
381
+ - 成功后,dx 会在本地 CLI 回显一段摘要,默认包含:
382
+ - release 版本名
383
+ - current 当前指向的 release 目录
384
+ - service/status
385
+ - APP_ENV / NODE_ENV
386
+ - health 地址
387
+
388
+ 示例成功输出:
389
+
390
+ ```text
391
+ ✅ 后端部署成功: backend-v0.0.24-20260313-174425
392
+ 🚀 [deploy-summary] current=/opt/work/noveai/releases/backend-v0.0.24-20260313-174425
393
+ 🚀 [deploy-summary] service=noveai-backend status=online
394
+ 🚀 [deploy-summary] APP_ENV=staging NODE_ENV=production
395
+ 🚀 [deploy-summary] health=http://127.0.0.1:3005/api/v1/health
396
+ ```
397
+
398
+ `verify.healthCheck` 配置说明:
399
+
400
+ - `url`:健康检查地址。未配置时,dx 会跳过 health check,但仍会执行 `current` / PM2 验活。
401
+ - `timeoutSeconds`:单次 `curl` 请求超时。
402
+ - `maxWaitSeconds`:从启动后开始,health check 最长等待多久;超过这个时间仍未成功则失败。
403
+ - `retryIntervalSeconds`:两次 health check 之间的等待间隔。
404
+
405
+ 推荐配置:
406
+
407
+ ```json
408
+ {
409
+ "verify": {
410
+ "healthCheck": {
411
+ "url": "http://127.0.0.1:3005/api/v1/health",
412
+ "timeoutSeconds": 10,
413
+ "maxWaitSeconds": 24,
414
+ "retryIntervalSeconds": 2
415
+ }
416
+ }
417
+ }
418
+ ```
419
+
420
+ 说明:
421
+
422
+ - `timeoutSeconds` 控制“单次请求能等多久”。
423
+ - `maxWaitSeconds` 控制“服务从启动到 ready 最多允许多久”。
424
+ - `retryIntervalSeconds` 越小,ready 后越快通过;越大,请求频率越低。
425
+
368
426
  SSH 认证说明:
369
427
 
370
428
  - `dx deploy backend` 当前直接调用系统 `ssh` / `scp`,不会单独解析 `sshKey`、`identityFile` 之类的 dx 配置项。
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: e2e-audit-fixer
3
+ description: 对 backend 的 E2E 用例进行中文名称、通用 fixture 重复实现与测试请求构建规范检查,并按可配置路径输出修复建议与可选自动修复。适用于需要审计或修复 E2E 测试中的中文用例名、手工请求构造、手工 JWT、以及本应复用通用测试基建的重复实现。
4
+ ---
5
+
6
+ # E2E 测试可维护性检查与修复
7
+
8
+ ## 触发场景
9
+
10
+ - 需要检查 `apps/backend/e2e/**/*.e2e-spec.ts` 的 E2E 用例是否符合英文命名规范。
11
+ - 需要识别直接操作 Prisma、手工 JWT、手工 API URL、手工请求实现,并区分哪些适合改用全局通用 fixture,哪些更适合抽成本地 helper。
12
+ - 需要一次性生成问题清单并按规则应用可控修复。
13
+
14
+ ## 准备
15
+
16
+ 执行前确认项目有可读权限,并准备以下路径参数(全部可覆盖,避免硬编码):
17
+
18
+ 1. `--workspace`:代码根目录,默认当前目录。
19
+ 2. `--e2e-glob`:扫描文件模式,默认 `apps/backend/e2e/**/*.e2e-spec.ts`。
20
+ 3. `--fixtures`:fixtures 文件路径,默认 `${workspace}/apps/backend/e2e/fixtures/fixtures.ts`。
21
+
22
+ ## 执行流程
23
+
24
+ 1. 先扫描问题:
25
+
26
+ ```bash
27
+ CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
28
+ python "$CODEX_HOME/skills/e2e-audit-fixer/scripts/e2e_e2e_audit.py" \
29
+ --workspace /Users/a1/work/ai-monorepo
30
+ ```
31
+
32
+ 2. 生成输出 JSON 用于复核:
33
+
34
+ ```bash
35
+ python "$CODEX_HOME/skills/e2e-audit-fixer/scripts/e2e_e2e_audit.py" \
36
+ --workspace /Users/a1/work/ai-monorepo \
37
+ --output-json /tmp/e2e-audit.json
38
+ ```
39
+
40
+ 3. 按修复策略应用:
41
+
42
+ 1. 中文测试名:先翻译为英文。可直接提供翻译映射:
43
+
44
+ ```bash
45
+ python "$CODEX_HOME/skills/e2e-audit-fixer/scripts/e2e_e2e_audit.py" \
46
+ --workspace /Users/a1/work/ai-monorepo \
47
+ --translation-map /tmp/name-map.json \
48
+ --apply
49
+ ```
50
+
51
+ 2. fixture 重复实现:自动加注释提示并给出替换建议,保留原有逻辑不变。仅 `user` / `userCredential` 作为全局通用 fixture 候选,其余默认建议抽成本地 helper。
52
+
53
+ 4. 必要时清理:对比扫描结果再次运行,确保无回归。
54
+
55
+ ## 检查规则(脚本输出)
56
+
57
+ - `e2e-chinese`:`describe/it/test/context` 名称包含中文字符。
58
+ - `e2e-fixtures`:检测 `prisma.user.*`、`prisma.userCredential.*`、`jwtService.sign/jwt.sign`、未使用 `buildApiUrl()` 的 API URL 片段、未使用 `createAuthRequest/createAdminAuthRequest/createPublicRequest` 的手工请求调用。
59
+ - `e2e-local-helper`:检测其他 `prisma.*.create*` / `upsert` 类重复实现,默认只建议在当前测试文件内抽成本地 helper,不建议直接上收为全局 fixtures。
60
+
61
+ ## 可选翻译入口
62
+
63
+ 支持三种方式之一:
64
+
65
+ 1. `--translation-map`:JSON 映射文件 `{ "中文原文": "English text" }`(推荐,稳定、可审计)。
66
+ 2. `--translate-service openai`:提供 `OPENAI_API_KEY` 后自动调用 OpenAI 批量翻译。
67
+ 3. 不提供翻译参数:仅输出中文字符串与建议,不改写文件。
68
+
69
+ ## 注意
70
+
71
+ - 扫描不改变业务逻辑,只做问题检测与可控注释插入。
72
+ - 自动修复默认只对中文测试名称做真实替换。
73
+ - `user` / `userCredential` 以外的重复造数默认视为“本地 helper 候选”,不默认建议进入全局 fixtures。
74
+ - fixture 与请求构造类问题仅插入 TODO 注释,不宣称已完成重构。
75
+ - 翻译服务仅处理测试名称,不处理其他文本。
76
+ - 扫描路径来源全部来自参数,不会固定死为 `apps/backend/e2e/**/*.e2e-spec.ts`。
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "E2E Audit Fixer"
3
+ short_description: "扫描 E2E 测试中文名称、手工请求与重复实现,并输出可控修复建议"
4
+ default_prompt: "使用 $e2e-audit-fixer 对 E2E 测试进行 e2e-chinese 与 e2e-fixtures 检查,并按修复建议输出可执行改动。"
@@ -0,0 +1,523 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ E2E 检查与修复脚本(中文测试名 + fixtures 重复实现模式)
4
+ 默认扫描 backend e2e 用例文件,可通过参数传入扫描路径与 fixtures 路径。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import glob
11
+ import json
12
+ import os
13
+ import re
14
+ import sys
15
+ import urllib.error
16
+ import urllib.request
17
+ from pathlib import Path
18
+ from typing import Dict, Iterable, List, Optional
19
+
20
+
21
+ CHINESE = re.compile(r"[\u4e00-\u9fff]")
22
+ TITLE_PATTERN = re.compile(
23
+ r"\b(?P<kind>describe|it|test|context)\s*\(\s*(?P<quote>[`'\"])(?P<name>(?:(?!(?P=quote)).)*?[\u4e00-\u9fff].*?)(?P=quote)",
24
+ re.IGNORECASE,
25
+ )
26
+ PRISMA_PATTERN = re.compile(r"\bprisma\.(?P<model>\w+)\.(?P<method>create|createMany|createManyAndReturn|upsert)\s*\(")
27
+ JWT_PATTERN = re.compile(r"\b(?:jwtService|jwt)\.sign\s*\(")
28
+ AUTH_REQUEST_PATTERN = re.compile(r"\b(?:request|supertest)\s*\(\s*app\.getHttpServer\(\)\s*\)")
29
+ API_URL_PATTERN = re.compile(r"[`'\"]([^`'\"]*/api/[^`'\"]*)[`'\"]")
30
+ REQUEST_CALL_PATTERN = re.compile(r"\b(?:request|supertest|axios|fetch|got)\s*\(")
31
+ MANUAL_REQUEST_PATTERN = re.compile(r"\.(?:get|post|put|patch|delete|head)\(\s*[`'\"]")
32
+ TOP_LEVEL_HELPER_PATTERN = re.compile(
33
+ r"^\s*(?:export\s+)?(?:(?:async\s+)?function\s+(?P<fn>[A-Za-z_][A-Za-z0-9_]*)\s*\(|const\s+(?P<const>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:async\s*)?\([^)]*\)\s*=>\s*\{)"
34
+ )
35
+ TOP_LEVEL_HELPER_START_PATTERN = re.compile(
36
+ r"^\s*(?:export\s+)?const\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:async\s*)?\("
37
+ )
38
+ TOP_LEVEL_FUNCTION_START_PATTERN = re.compile(
39
+ r"^\s*(?:export\s+)?(?:async\s+)?function\s+(?P<name>[A-Za-z_][A-Za-z0-9_]*)\b"
40
+ )
41
+ HELPER_NAME_PREFIXES = ("create", "ensure", "seed", "build", "find", "get", "reset", "wait", "pick")
42
+
43
+
44
+ KNOWN_FIXTURE_TOOLS: Dict[str, str] = {
45
+ "user": "createUserRecord",
46
+ "usercredential": "createUserCredentialRecord",
47
+ }
48
+
49
+ ISSUE_TYPES = {
50
+ "e2e-chinese": "E2E 测试名称包含中文字符",
51
+ "prisma-create": "直接操作 prisma.create 族接口",
52
+ "e2e-local-helper": "重复造数更适合抽成本地 helper",
53
+ "jwt-sign": "手动创建 JWT Token",
54
+ "manual-api-url": "手工拼接 API URL(未使用 buildApiUrl)",
55
+ "manual-auth-request": "手工创建 HTTP 请求(未使用 createAuthRequest 系列)",
56
+ }
57
+
58
+
59
+ def parse_args() -> argparse.Namespace:
60
+ parser = argparse.ArgumentParser(description="E2E 规则扫描与可控修复")
61
+ parser.add_argument("--workspace", default=".", help="扫描根目录(默认当前目录)")
62
+ parser.add_argument(
63
+ "--e2e-glob",
64
+ default="apps/backend/e2e/**/*.e2e-spec.ts",
65
+ help="扫描 glob 模式,默认 apps/backend/e2e/**/*.e2e-spec.ts",
66
+ )
67
+ parser.add_argument(
68
+ "--fixtures",
69
+ default=None,
70
+ help="fixtures 文件路径(默认 workspace/apps/backend/e2e/fixtures/fixtures.ts)",
71
+ )
72
+ parser.add_argument("--output-json", default=None, help="导出结果路径(JSON)")
73
+ parser.add_argument(
74
+ "--format",
75
+ choices=["text", "json"],
76
+ default="text",
77
+ help="输出格式(默认 text)",
78
+ )
79
+ parser.add_argument("--apply", action="store_true", help="启用可控修复(中文名称替换、TODO 注入)")
80
+ parser.add_argument("--translation-map", default=None, help="中文->英文 JSON 映射文件路径")
81
+ parser.add_argument(
82
+ "--translate-service",
83
+ choices=["none", "openai"],
84
+ default="none",
85
+ help="无映射时可用 openai 进行批量翻译",
86
+ )
87
+ parser.add_argument("--openai-key", default=None, help="OPENAI API Key(默认读取环境变量 OPENAI_API_KEY)")
88
+ parser.add_argument(
89
+ "--openai-model",
90
+ default="gpt-4o-mini",
91
+ help="OpenAI Chat Completions model,默认 gpt-4o-mini",
92
+ )
93
+ parser.add_argument(
94
+ "--openai-endpoint",
95
+ default="https://api.openai.com/v1/chat/completions",
96
+ help="OpenAI 兼容端点",
97
+ )
98
+ parser.add_argument("--dry-run", action="store_true", help="应用修复时仅预览")
99
+ return parser.parse_args()
100
+
101
+
102
+ def load_text(path: Path) -> str:
103
+ return path.read_text(encoding="utf-8")
104
+
105
+
106
+ def write_text(path: Path, text: str, dry_run: bool) -> None:
107
+ if dry_run:
108
+ return
109
+ path.write_text(text, encoding="utf-8")
110
+
111
+
112
+ def has_chinese(text: str) -> bool:
113
+ return CHINESE.search(text) is not None
114
+
115
+
116
+ def parse_fixture_exports(path: Optional[Path]) -> List[str]:
117
+ if not path or not path.exists():
118
+ return []
119
+ text = load_text(path)
120
+ pattern = re.compile(r"\bexport\s+(?:async\s+)?(?:function|const)\s+([A-Za-z_][A-Za-z0-9_]*)")
121
+ return sorted(set(pattern.findall(text)))
122
+
123
+
124
+ def build_issue(path: Path, line: int, kind: str, detail: str, suggestion: str, snippet: str = "") -> Dict:
125
+ return {
126
+ "path": str(path),
127
+ "line": line,
128
+ "type": kind,
129
+ "kind_cn": ISSUE_TYPES.get(kind, kind),
130
+ "detail": detail,
131
+ "suggestion": suggestion,
132
+ "snippet": snippet,
133
+ }
134
+
135
+
136
+ def suggest_for_model(model: str, fixtures: List[str]) -> str:
137
+ key = model.replace("_", "").lower()
138
+ exact_match = KNOWN_FIXTURE_TOOLS.get(key)
139
+ if exact_match:
140
+ if exact_match in fixtures:
141
+ return f"建议替换为 {exact_match}()"
142
+ return f"建议在 fixtures.ts 新增并复用 {exact_match}()"
143
+ return "建议在当前测试文件内抽成本地 helper;不要默认上收为全局 fixture"
144
+
145
+
146
+ def scan_file(path: Path, fixtures: List[str]) -> List[Dict]:
147
+ text = load_text(path)
148
+ lines = text.splitlines()
149
+ issues: List[Dict] = []
150
+ helper_lines = collect_local_helper_lines(lines)
151
+
152
+ for i, line in enumerate(lines, start=1):
153
+ for match in TITLE_PATTERN.finditer(line):
154
+ name = match.group("name")
155
+ if has_chinese(name):
156
+ issues.append(
157
+ build_issue(
158
+ path=path,
159
+ line=i,
160
+ kind="e2e-chinese",
161
+ detail=f"检测到含中文标题: {name}",
162
+ suggestion="将中文标题翻译为英文(如 should ...)。",
163
+ snippet=match.group(0),
164
+ )
165
+ )
166
+
167
+ for match in PRISMA_PATTERN.finditer(line):
168
+ if i in helper_lines:
169
+ continue
170
+ model = match.group("model")
171
+ suggestion = suggest_for_model(model, fixtures)
172
+ issue_type = "prisma-create" if model.replace("_", "").lower() in KNOWN_FIXTURE_TOOLS else "e2e-local-helper"
173
+ issues.append(
174
+ build_issue(
175
+ path=path,
176
+ line=i,
177
+ kind=issue_type,
178
+ detail=f"发现直接调用 prisma.{model}.create 族实现",
179
+ suggestion=suggestion,
180
+ snippet=line.strip(),
181
+ )
182
+ )
183
+
184
+ if JWT_PATTERN.search(line):
185
+ issues.append(
186
+ build_issue(
187
+ path=path,
188
+ line=i,
189
+ kind="jwt-sign",
190
+ detail="发现手工调用 jwt.sign",
191
+ suggestion="建议使用 generateTestJwtToken(app, ...) 替代。",
192
+ snippet=line.strip(),
193
+ )
194
+ )
195
+
196
+ if API_URL_PATTERN.search(line):
197
+ if ("buildApiUrl(" not in line) and (REQUEST_CALL_PATTERN.search(line) or MANUAL_REQUEST_PATTERN.search(line)):
198
+ issues.append(
199
+ build_issue(
200
+ path=path,
201
+ line=i,
202
+ kind="manual-api-url",
203
+ detail="发现 /api/ 片段未经过 buildApiUrl",
204
+ suggestion="建议使用 buildApiUrl(path) 统一拼接。",
205
+ snippet=line.strip(),
206
+ )
207
+ )
208
+
209
+ if "app.getHttpServer()" in line and REQUEST_CALL_PATTERN.search(line):
210
+ issues.append(
211
+ build_issue(
212
+ path=path,
213
+ line=i,
214
+ kind="manual-auth-request",
215
+ detail="发现 app.getHttpServer() 直接请求调用",
216
+ suggestion="建议优先使用 createAuthRequest(app, ...)、createAdminAuthRequest(app, ...) 或 createPublicRequest(app)。",
217
+ snippet=line.strip(),
218
+ )
219
+ )
220
+
221
+ return issues
222
+
223
+
224
+ def collect_local_helper_lines(lines: List[str]) -> set[int]:
225
+ helper_lines: set[int] = set()
226
+ idx = 0
227
+ total = len(lines)
228
+
229
+ while idx < total:
230
+ line = lines[idx]
231
+ match = TOP_LEVEL_HELPER_PATTERN.match(line)
232
+ multiline_match = TOP_LEVEL_HELPER_START_PATTERN.match(line)
233
+ function_start_match = TOP_LEVEL_FUNCTION_START_PATTERN.match(line)
234
+ if not match:
235
+ if multiline_match:
236
+ name = multiline_match.group("name") or ""
237
+ if not name.startswith(HELPER_NAME_PREFIXES):
238
+ idx += 1
239
+ continue
240
+
241
+ probe = idx
242
+ arrow_line = line
243
+ while probe + 1 < total and "=>" not in arrow_line:
244
+ probe += 1
245
+ arrow_line = lines[probe]
246
+
247
+ if "=>" not in arrow_line:
248
+ idx += 1
249
+ continue
250
+
251
+ block_start = probe
252
+ end = probe + 1
253
+
254
+ if "{" not in arrow_line:
255
+ while end <= total and not lines[end - 1].strip().endswith((")", "})", "})", ");", "),")):
256
+ end += 1
257
+ for lineno in range(idx + 1, min(end, total) + 1):
258
+ helper_lines.add(lineno)
259
+ idx = end
260
+ continue
261
+
262
+ brace_depth = arrow_line.count("{") - arrow_line.count("}")
263
+ while end < total and brace_depth > 0:
264
+ brace_depth += lines[end].count("{") - lines[end].count("}")
265
+ end += 1
266
+
267
+ for lineno in range(idx + 1, min(end, total) + 1):
268
+ helper_lines.add(lineno)
269
+ idx = end
270
+ continue
271
+ if function_start_match:
272
+ name = function_start_match.group("name") or ""
273
+ if not name.startswith(HELPER_NAME_PREFIXES):
274
+ idx += 1
275
+ continue
276
+
277
+ probe = idx
278
+ signature_line = line
279
+ while probe + 1 < total and "{" not in signature_line:
280
+ probe += 1
281
+ signature_line = lines[probe]
282
+
283
+ if "{" not in signature_line:
284
+ idx += 1
285
+ continue
286
+
287
+ brace_depth = signature_line.count("{") - signature_line.count("}")
288
+ end = probe + 1
289
+ while end < total and brace_depth > 0:
290
+ brace_depth += lines[end].count("{") - lines[end].count("}")
291
+ end += 1
292
+
293
+ for lineno in range(idx + 1, min(end, total) + 1):
294
+ helper_lines.add(lineno)
295
+ idx = end
296
+ continue
297
+ idx += 1
298
+ continue
299
+
300
+ name = match.group("fn") or match.group("const") or ""
301
+ if not name.startswith(HELPER_NAME_PREFIXES):
302
+ idx += 1
303
+ continue
304
+
305
+ brace_depth = line.count("{") - line.count("}")
306
+ start = idx + 1
307
+ end = start
308
+ probe = idx
309
+
310
+ while probe + 1 < total and brace_depth > 0:
311
+ probe += 1
312
+ brace_depth += lines[probe].count("{") - lines[probe].count("}")
313
+ end = probe + 1
314
+
315
+ for lineno in range(start, end + 1):
316
+ helper_lines.add(lineno)
317
+
318
+ idx = probe + 1
319
+
320
+ return helper_lines
321
+
322
+
323
+ def load_translation_map(path: Optional[str]) -> Dict[str, str]:
324
+ if not path:
325
+ return {}
326
+ map_path = Path(path)
327
+ if not map_path.exists():
328
+ return {}
329
+ data = json.loads(map_path.read_text(encoding="utf-8"))
330
+ if not isinstance(data, dict):
331
+ return {}
332
+ return {str(k): str(v) for k, v in data.items()}
333
+
334
+
335
+ def translate_batch_openai(texts: Iterable[str], api_key: str, model: str, endpoint: str) -> Dict[str, str]:
336
+ items = list(dict.fromkeys([t for t in texts if t.strip()]))
337
+ if not items:
338
+ return {}
339
+
340
+ prompt = (
341
+ "You are a strict technical translator for test titles.\n"
342
+ "Translate each input string to concise English test-case style.\n"
343
+ "Only output JSON object: {\"translations\": {\"原文\": \"译文\", ...}}."
344
+ )
345
+ payload = {
346
+ "model": model,
347
+ "temperature": 0.2,
348
+ "messages": [
349
+ {"role": "system", "content": prompt},
350
+ {"role": "user", "content": json.dumps(items, ensure_ascii=False)},
351
+ ],
352
+ }
353
+
354
+ req = urllib.request.Request(
355
+ endpoint,
356
+ data=json.dumps(payload).encode("utf-8"),
357
+ headers={
358
+ "Authorization": f"Bearer {api_key}",
359
+ "Content-Type": "application/json",
360
+ },
361
+ method="POST",
362
+ )
363
+
364
+ try:
365
+ with urllib.request.urlopen(req, timeout=60) as r:
366
+ data = json.loads(r.read().decode("utf-8"))
367
+ content = data["choices"][0]["message"]["content"]
368
+ parsed = json.loads(content)
369
+ return parsed.get("translations", {})
370
+ except (urllib.error.URLError, KeyError, ValueError):
371
+ return {}
372
+
373
+
374
+ def infer_translation_map(issues: List[Dict], translation_map: Dict[str, str], use_openai: bool, args: argparse.Namespace) -> Dict[str, str]:
375
+ if translation_map:
376
+ return translation_map
377
+ if not use_openai:
378
+ return {}
379
+
380
+ need = sorted(
381
+ {
382
+ i["detail"].split(": ", 1)[1]
383
+ for i in issues
384
+ if i["type"] == "e2e-chinese" and ": " in i["detail"]
385
+ }
386
+ )
387
+ api_key = args.openai_key or os.environ.get("OPENAI_API_KEY")
388
+ if not api_key or not need:
389
+ return {}
390
+ remote = translate_batch_openai(need, api_key=api_key, model=args.openai_model, endpoint=args.openai_endpoint)
391
+ return remote
392
+
393
+
394
+ def apply_fixes(path: Path, issues: List[Dict], translations: Dict[str, str], dry_run: bool) -> Dict[str, int]:
395
+ if not issues:
396
+ return {"changed": 0, "title_fixed": 0, "todo_injected": 0}
397
+
398
+ lines = path.read_text(encoding="utf-8").splitlines()
399
+ added = {"changed": 0, "title_fixed": 0, "todo_injected": 0}
400
+ issue_lines = sorted(issues, key=lambda i: i["line"], reverse=True)
401
+ for issue in issue_lines:
402
+ idx = issue["line"] - 1
403
+ if idx < 0 or idx >= len(lines):
404
+ continue
405
+ line = lines[idx]
406
+ if issue["type"] == "e2e-chinese" and "检测到含中文标题" in issue["detail"]:
407
+ name = issue["detail"].replace("检测到含中文标题: ", "")
408
+ target = translations.get(name)
409
+ if not target:
410
+ comment = f"{line}\n// TODO(e2e-chinese): 当前无翻译映射,后续替换为英文。"
411
+ if "e2e-chinese" not in lines[max(0, idx - 1)]:
412
+ lines[idx] = comment
413
+ added["changed"] += 1
414
+ added["todo_injected"] += 1
415
+ continue
416
+ replaced = TITLE_PATTERN.sub(
417
+ lambda m: (
418
+ f"{m.group('kind')}({m.group('quote')}{target}{m.group('quote')}"
419
+ if m.group("name") == name
420
+ else m.group(0)
421
+ ),
422
+ line,
423
+ count=1,
424
+ )
425
+ if replaced != line:
426
+ lines[idx] = replaced
427
+ added["changed"] += 1
428
+ added["title_fixed"] += 1
429
+ continue
430
+
431
+ if issue["type"] in {"prisma-create", "jwt-sign", "manual-api-url", "manual-auth-request"}:
432
+ prefix = " " * (len(line) - len(line.lstrip(" ")))
433
+ todo = f"{prefix}// TODO(e2e-fixtures): {issue['suggestion']}"
434
+ if idx > 0 and todo.strip() == lines[idx - 1].strip():
435
+ continue
436
+ lines.insert(idx, todo)
437
+ added["changed"] += 1
438
+ added["todo_injected"] += 1
439
+
440
+ if added["changed"] > 0:
441
+ write_text(path, "\n".join(lines) + "\n", dry_run)
442
+ return added
443
+
444
+
445
+ def main() -> None:
446
+ args = parse_args()
447
+ workspace = Path(args.workspace).resolve()
448
+ fixtures_path = Path(args.fixtures or (workspace / "apps" / "backend" / "e2e" / "fixtures" / "fixtures.ts"))
449
+ fixture_exports = parse_fixture_exports(fixtures_path)
450
+
451
+ glob_pattern = str(workspace / args.e2e_glob)
452
+ files = sorted(glob.glob(glob_pattern, recursive=True))
453
+ if not files:
454
+ print("未发现匹配文件,请检查 workspace/e2e-glob 参数。")
455
+ return
456
+
457
+ total: List[Dict] = []
458
+ for item in files:
459
+ total.extend(scan_file(Path(item), fixture_exports))
460
+
461
+ total = sorted(total, key=lambda x: (x["path"], x["line"], x["type"]))
462
+ translation_map = load_translation_map(args.translation_map)
463
+ remote_translations = infer_translation_map(total, translation_map, args.translate_service == "openai", args)
464
+ if not translation_map:
465
+ translation_map = remote_translations
466
+
467
+ output = {
468
+ "summary": {
469
+ "count": len(total),
470
+ "by_type": {t: sum(1 for i in total if i["type"] == t) for t in sorted({i["type"] for i in total})},
471
+ "fixtures_file": str(fixtures_path),
472
+ "fixtures_exports": fixture_exports,
473
+ },
474
+ "issues": total,
475
+ }
476
+
477
+ if args.output_json:
478
+ Path(args.output_json).write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8")
479
+
480
+ if args.format == "json":
481
+ if args.output_json:
482
+ print(f"已导出: {args.output_json}")
483
+ else:
484
+ print(json.dumps(output, ensure_ascii=False, indent=2))
485
+ else:
486
+ print(f"扫描根目录: {workspace}")
487
+ print(f"扫描范围: {args.e2e_glob}")
488
+ print(f"fixtures 文件: {fixtures_path}")
489
+ print(f"问题总数: {len(total)}")
490
+ for t in sorted({i["type"] for i in total}):
491
+ print(f"- {t}: {sum(1 for i in total if i['type'] == t)}")
492
+ print("")
493
+ for issue in total[:300]:
494
+ print(f"{issue['path']}:{issue['line']} [{issue['type']}]")
495
+ print(f" {issue['detail']}")
496
+ print(f" 建议: {issue['suggestion']}")
497
+ if issue["snippet"]:
498
+ print(f" 片段: {issue['snippet']}")
499
+ if len(total) > 300:
500
+ print(f"... 省略其余 {len(total) - 300} 条,建议加参数 output-json 落盘复核")
501
+
502
+ if args.apply:
503
+ by_path: Dict[str, List[Dict]] = {}
504
+ for issue in total:
505
+ by_path.setdefault(issue["path"], []).append(issue)
506
+
507
+ stats = {"changed_files": 0, "changed": 0, "title_fixed": 0, "todo_injected": 0}
508
+ for path_str, issues in by_path.items():
509
+ rst = apply_fixes(Path(path_str), issues, translation_map, args.dry_run)
510
+ if rst["changed"] > 0:
511
+ stats["changed_files"] += 1
512
+ stats["changed"] += rst["changed"]
513
+ stats["title_fixed"] += rst["title_fixed"]
514
+ stats["todo_injected"] += rst["todo_injected"]
515
+
516
+ if args.dry_run:
517
+ print(f"\n预演完成(未落盘): {stats}")
518
+ else:
519
+ print(f"\n已应用修复: {stats}")
520
+
521
+
522
+ if __name__ == "__main__":
523
+ main()