@ranger1/dx 0.1.85 → 0.1.87

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 (24) hide show
  1. package/README.md +61 -1
  2. package/codex/skills/e2e-audit-fixer/SKILL.md +76 -0
  3. package/codex/skills/e2e-audit-fixer/agents/openai.yaml +4 -0
  4. package/codex/skills/e2e-audit-fixer/scripts/e2e_e2e_audit.py +523 -0
  5. package/codex/skills/env-accessor-audit-fixer/SKILL.md +149 -0
  6. package/codex/skills/env-accessor-audit-fixer/agents/openai.yaml +7 -0
  7. package/codex/skills/env-accessor-audit-fixer/references/bootstrap-env-foundation.md +156 -0
  8. package/codex/skills/env-accessor-audit-fixer/scripts/env_accessor_audit.py +250 -0
  9. package/codex/skills/error-handling-audit-fixer/SKILL.md +150 -0
  10. package/codex/skills/error-handling-audit-fixer/agents/openai.yaml +7 -0
  11. package/codex/skills/error-handling-audit-fixer/references/error-handling-standard.md +152 -0
  12. package/codex/skills/error-handling-audit-fixer/references/foundation-bootstrap.md +85 -0
  13. package/codex/skills/error-handling-audit-fixer/scripts/error_handling_audit.py +537 -0
  14. package/codex/skills/pagination-dto-audit-fixer/SKILL.md +69 -0
  15. package/codex/skills/pagination-dto-audit-fixer/agents/openai.yaml +7 -0
  16. package/codex/skills/pagination-dto-audit-fixer/references/pagination-standard.md +67 -0
  17. package/codex/skills/pagination-dto-audit-fixer/scripts/pagination_dto_audit.py +244 -0
  18. package/lib/cli/commands/core.js +24 -8
  19. package/lib/cli/dx-cli.js +19 -9
  20. package/lib/cli/help.js +11 -6
  21. package/lib/codex-initial.js +155 -3
  22. package/lib/exec.js +21 -2
  23. package/lib/run-with-version-env.js +2 -1
  24. package/package.json +1 -1
package/README.md CHANGED
@@ -199,11 +199,13 @@ dx deploy front --staging
199
199
  dx deploy backend --prod
200
200
  dx lint
201
201
  dx test e2e backend apps/backend/e2e/auth
202
+ dx test e2e quantify apps/quantify/e2e/health/health.e2e-spec.ts
202
203
  ```
203
204
 
204
205
  命令约束摘要:
205
206
 
206
- - `dx test e2e backend` 必须提供文件或目录路径,禁止无路径全量执行
207
+ - 对声明了 `requiresPath: true` 的 E2E target,`dx test e2e <target>` 必须提供文件或目录路径,禁止无路径或 `all` 全量执行
208
+ - `dx test e2e all` 不受支持,必须显式指定 target 和路径
207
209
  - `dx db migrate` 仅允许在 `--dev` 环境创建迁移;非开发环境请使用 `dx db deploy`
208
210
  - `dx start` 未指定服务时默认是开发套件,仅允许 `--dev`
209
211
  - `dx start` 下的单层目标(如 `stagewise-front`)默认仅支持 `--dev`
@@ -343,6 +345,14 @@ dx deploy backend --prod --skip-migration
343
345
  "installCommand": "pnpm install --prod --no-frozen-lockfile --ignore-workspace",
344
346
  "prismaGenerate": true,
345
347
  "prismaMigrateDeploy": true
348
+ },
349
+ "verify": {
350
+ "healthCheck": {
351
+ "url": "http://127.0.0.1:3005/api/v1/health",
352
+ "timeoutSeconds": 10,
353
+ "maxWaitSeconds": 24,
354
+ "retryIntervalSeconds": 2
355
+ }
346
356
  }
347
357
  }
348
358
  }
@@ -365,6 +375,56 @@ dx deploy backend --prod --skip-migration
365
375
  - 所有本地路径字段都会被解析为相对项目根目录,并且必须留在项目根目录内;例如 `build.distDir`、`runtime.prismaSchemaDir`、`artifact.outputDir` 不能通过 `../` 逃逸到仓库外。
366
376
  - `remote.baseDir` 必须是绝对路径,并且只能包含 `/`、字母、数字、`.`、`_`、`-`;不要使用空格或 shell 特殊字符。
367
377
 
378
+ 部署后验活与成功摘要:
379
+
380
+ - `dx deploy backend` 在远端启动完成后,会继续校验 `current` 软链接是否切到本次 release。
381
+ - 如果 `startup.mode` 是 `pm2`,还会校验 PM2 进程是否存在,以及 PM2 中的 `APP_ENV` / `NODE_ENV` 是否与部署环境一致。
382
+ - 如果配置了 `verify.healthCheck`,dx 会在远端对健康检查地址做重试探测;适合处理不同项目启动时间不一致的问题。
383
+ - 成功后,dx 会在本地 CLI 回显一段摘要,默认包含:
384
+ - release 版本名
385
+ - current 当前指向的 release 目录
386
+ - service/status
387
+ - APP_ENV / NODE_ENV
388
+ - health 地址
389
+
390
+ 示例成功输出:
391
+
392
+ ```text
393
+ ✅ 后端部署成功: backend-v0.0.24-20260313-174425
394
+ 🚀 [deploy-summary] current=/opt/work/noveai/releases/backend-v0.0.24-20260313-174425
395
+ 🚀 [deploy-summary] service=noveai-backend status=online
396
+ 🚀 [deploy-summary] APP_ENV=staging NODE_ENV=production
397
+ 🚀 [deploy-summary] health=http://127.0.0.1:3005/api/v1/health
398
+ ```
399
+
400
+ `verify.healthCheck` 配置说明:
401
+
402
+ - `url`:健康检查地址。未配置时,dx 会跳过 health check,但仍会执行 `current` / PM2 验活。
403
+ - `timeoutSeconds`:单次 `curl` 请求超时。
404
+ - `maxWaitSeconds`:从启动后开始,health check 最长等待多久;超过这个时间仍未成功则失败。
405
+ - `retryIntervalSeconds`:两次 health check 之间的等待间隔。
406
+
407
+ 推荐配置:
408
+
409
+ ```json
410
+ {
411
+ "verify": {
412
+ "healthCheck": {
413
+ "url": "http://127.0.0.1:3005/api/v1/health",
414
+ "timeoutSeconds": 10,
415
+ "maxWaitSeconds": 24,
416
+ "retryIntervalSeconds": 2
417
+ }
418
+ }
419
+ }
420
+ ```
421
+
422
+ 说明:
423
+
424
+ - `timeoutSeconds` 控制“单次请求能等多久”。
425
+ - `maxWaitSeconds` 控制“服务从启动到 ready 最多允许多久”。
426
+ - `retryIntervalSeconds` 越小,ready 后越快通过;越大,请求频率越低。
427
+
368
428
  SSH 认证说明:
369
429
 
370
430
  - `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()