@ranger1/dx 0.1.94 → 0.1.96

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.
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
- import { join, relative } from 'node:path'
2
+ import { dirname, join, relative } from 'node:path'
3
3
  import { logger } from '../../logger.js'
4
4
  import { confirmManager } from '../../confirm.js'
5
5
  import { execManager } from '../../exec.js'
@@ -83,11 +83,7 @@ export async function handleTest(cli, args) {
83
83
 
84
84
  // 解析 -t 参数用于指定特定测试用例(使用原始参数列表)
85
85
  const allArgs = cli.args // 使用原始参数列表包含所有标志
86
- const testNamePatternIndex = allArgs.indexOf('-t')
87
- let testNamePattern = null
88
- if (testNamePatternIndex !== -1 && testNamePatternIndex + 1 < allArgs.length) {
89
- testNamePattern = allArgs[testNamePatternIndex + 1]
90
- }
86
+ const testNamePattern = resolveTestNamePattern(allArgs)
91
87
 
92
88
  // 根据测试类型自动设置环境标志
93
89
  if (type === 'e2e' && !cli.flags.e2e) {
@@ -120,7 +116,8 @@ export async function handleTest(cli, args) {
120
116
  process.exit(1)
121
117
  }
122
118
 
123
- let command = fileCommand.replace('{TEST_PATH}', shellEscape(testPath))
119
+ const normalizedTestPath = normalizeE2eTestPathForCommand(cli, fileCommand, testPath)
120
+ let command = fileCommand.replace('{TEST_PATH}', shellEscape(normalizedTestPath))
124
121
 
125
122
  if (testNamePattern) {
126
123
  command += ` -t ${shellEscape(testNamePattern)}`
@@ -179,6 +176,15 @@ function shellEscape(value) {
179
176
  return `'${String(value).replace(/'/g, `'\\''`)}'`
180
177
  }
181
178
 
179
+ function resolveTestNamePattern(args = []) {
180
+ const aliases = ['-t', '--name', '--test-name-pattern']
181
+ for (let i = 0; i < args.length; i++) {
182
+ if (!aliases.includes(args[i])) continue
183
+ if (i + 1 < args.length) return args[i + 1]
184
+ }
185
+ return null
186
+ }
187
+
182
188
  function shouldUseDirectPathArg(command) {
183
189
  const text = String(command || '')
184
190
  return (
@@ -196,11 +202,11 @@ function normalizeUnitTestPathForCommand(cli, command, testPath) {
196
202
  return rawPath
197
203
  }
198
204
 
199
- const vitestProjectCwd = resolveNxVitestProjectCwd(cli, command)
200
- if (!vitestProjectCwd) return rawPath
205
+ const projectCwd = resolveNxTargetProjectCwd(cli, command, ['test'])
206
+ if (!projectCwd) return rawPath
201
207
 
202
208
  const projectRoot = cli?.projectRoot || process.cwd()
203
- const absoluteProjectCwd = join(projectRoot, vitestProjectCwd)
209
+ const absoluteProjectCwd = join(projectRoot, projectCwd)
204
210
  const absoluteTestPath = join(projectRoot, rawPath)
205
211
  const relativePath = relative(absoluteProjectCwd, absoluteTestPath)
206
212
 
@@ -211,33 +217,78 @@ function normalizeUnitTestPathForCommand(cli, command, testPath) {
211
217
  return relativePath
212
218
  }
213
219
 
214
- function resolveNxVitestProjectCwd(cli, command) {
220
+ function normalizeE2eTestPathForCommand(cli, command, testPath) {
221
+ const rawPath = String(testPath || '')
222
+ if (!rawPath) return rawPath
223
+
224
+ const projectCwd = resolveNxTargetProjectCwd(cli, command, ['test:e2e'])
225
+ if (!projectCwd) return rawPath
226
+
215
227
  const projectRoot = cli?.projectRoot || process.cwd()
216
- const nxTarget = extractNxTestTarget(command)
217
- if (!nxTarget) return null
228
+ const absoluteProjectCwd = join(projectRoot, projectCwd)
229
+ const absoluteTestPath = join(projectRoot, rawPath)
230
+ const relativePath = relative(absoluteProjectCwd, absoluteTestPath)
231
+
232
+ if (!relativePath || relativePath.startsWith('..')) {
233
+ return rawPath
234
+ }
218
235
 
219
- const projectConfigPath = join(projectRoot, 'apps', nxTarget, 'project.json')
236
+ return relativePath
237
+ }
238
+
239
+ function resolveNxTargetProjectCwd(cli, command, targetNames = []) {
240
+ const projectRoot = cli?.projectRoot || process.cwd()
241
+ const nxResolution = extractNxTarget(command, targetNames)
242
+ if (!nxResolution) return null
243
+
244
+ const projectDir = join(projectRoot, 'apps', nxResolution.project)
245
+ const projectConfigPath = join(projectDir, 'project.json')
220
246
  if (!existsSync(projectConfigPath)) return null
221
247
 
222
248
  try {
223
249
  const projectConfig = JSON.parse(readFileSync(projectConfigPath, 'utf8'))
224
- const testTarget = projectConfig?.targets?.test
225
- const command = String(testTarget?.options?.command || '')
226
- const cwd = testTarget?.options?.cwd
227
- if (!/\bvitest\s+run\b/.test(command)) return null
228
- if (typeof cwd !== 'string' || cwd.trim().length === 0) return null
229
- return cwd
250
+ const resolvedTarget = projectConfig?.targets?.[nxResolution.target]
251
+ const cwd = resolvedTarget?.options?.cwd
252
+ if (typeof cwd === 'string' && cwd.trim().length > 0) {
253
+ return cwd
254
+ }
255
+ if (nxResolution.target === 'test:e2e') {
256
+ const e2eDir = join(projectDir, 'e2e')
257
+ if (existsSync(e2eDir)) {
258
+ return relative(projectRoot, e2eDir)
259
+ }
260
+ }
261
+ return relative(projectRoot, dirname(projectConfigPath))
230
262
  } catch {
231
263
  return null
232
264
  }
233
265
  }
234
266
 
235
- function extractNxTestTarget(command) {
267
+ function extractNxTarget(command, targetNames = []) {
236
268
  const text = String(command || '').trim()
237
- const match =
238
- text.match(/\bnx(?:\.js)?\s+test\s+([^\s]+)/) ||
239
- text.match(/\bnx(?:\.js)?\s+run\s+([^:\s]+):test\b/)
240
- return match?.[1] || null
269
+ const names = Array.isArray(targetNames) && targetNames.length > 0 ? targetNames : ['test']
270
+
271
+ for (const targetName of names) {
272
+ if (targetName === 'test') {
273
+ const directMatch = text.match(/\bnx(?:\.js)?\s+test\s+([^\s]+)/)
274
+ if (directMatch?.[1]) {
275
+ return { project: directMatch[1], target: 'test' }
276
+ }
277
+ }
278
+
279
+ const escapedTarget = targetName.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&')
280
+ const runMatch = text.match(new RegExp(`\\bnx(?:\\.js)?\\s+run\\s+([^:\\s]+):${escapedTarget}\\b`))
281
+ if (runMatch?.[1]) {
282
+ return { project: runMatch[1], target: targetName }
283
+ }
284
+
285
+ const directColonMatch = text.match(new RegExp(`\\bnx(?:\\.js)?\\s+${escapedTarget}\\s+([^\\s]+)`))
286
+ if (directColonMatch?.[1]) {
287
+ return { project: directColonMatch[1], target: targetName }
288
+ }
289
+ }
290
+
291
+ return null
241
292
  }
242
293
 
243
294
  export async function handleLint(cli, args) {
package/lib/cli/flags.js CHANGED
@@ -24,7 +24,11 @@ export const FLAG_DEFINITIONS = {
24
24
  { flag: '--name', expectsValue: true },
25
25
  { flag: '-n', expectsValue: true },
26
26
  ],
27
- test: [{ flag: '-t', expectsValue: true }],
27
+ test: [
28
+ { flag: '-t', expectsValue: true },
29
+ { flag: '--name', expectsValue: true },
30
+ { flag: '--test-name-pattern', expectsValue: true },
31
+ ],
28
32
  package: [
29
33
  { flag: '--skip-build' },
30
34
  { flag: '--keep-workdir' },
package/lib/cli/help.js CHANGED
@@ -41,11 +41,11 @@ export function showHelp() {
41
41
  ' dx db script fix-pending-transfer-status --prod # 运行数据库脚本(生产环境,需确认)',
42
42
  ' dx db script my-script --dev -- --arg1 --arg2 # 向脚本传递额外参数(-- 后面的部分)',
43
43
  '',
44
- ' test [type] [target] [path] [-t pattern] 运行测试',
44
+ ' test [type] [target] [path] [-t pattern|--name pattern] 运行测试',
45
45
  ' type: e2e, unit (默认: e2e)',
46
46
  ' target: 由 commands.json 的 test.<type>.<target> 决定(e2e 默认会拒绝隐式 all)',
47
47
  ' path: 测试文件或目录路径 (guarded e2e target 必填,例如 backend/quantify)',
48
- ' -t pattern: 指定测试用例名称模式 (可选,需要和 path 一起使用)',
48
+ ' -t pattern / --name pattern: 指定测试用例名称模式 (可选,需要和 path 一起使用)',
49
49
  ' 说明: guarded E2E target 禁止无路径或 all 全量执行,dx test e2e all 也不受支持',
50
50
  '',
51
51
  ' worktree [action] [num...] Git Worktree管理',
@@ -94,6 +94,7 @@ export function showHelp() {
94
94
  ' dx test e2e backend apps/backend/e2e/auth # 按目录运行后端 E2E',
95
95
  ' dx test e2e backend apps/backend/e2e/activity/activity.admin.e2e-spec.ts # 运行单个E2E测试文件',
96
96
  ' dx test e2e backend apps/backend/e2e/activity/activity.admin.e2e-spec.ts -t "should list all activity definitions" # 运行特定测试用例',
97
+ ' dx test unit backend apps/backend/src/modules/chat/chat.service.spec.ts --name "should create chat" # 运行单测中的特定用例',
97
98
  ' dx test e2e quantify apps/quantify/e2e/health/health.e2e-spec.ts # 运行 Quantify E2E 文件',
98
99
  ' dx test unit backend apps/backend/src/modules/chat/chat.service.spec.ts # 运行单个后端单测文件',
99
100
  ' dx test e2e all # 不受支持,必须指定 target 和 path',
@@ -254,13 +255,13 @@ start 命令用法:
254
255
  case 'test':
255
256
  console.log(`
256
257
  test 命令用法:
257
- dx test [type] [target] [path] [-t pattern]
258
+ dx test [type] [target] [path] [-t pattern|--name pattern]
258
259
 
259
260
  参数说明:
260
261
  type: e2e, unit (默认: e2e)
261
262
  target: 由 commands.json 的 test.<type>.<target> 决定
262
263
  path: guarded e2e target 必须提供文件或目录路径
263
- -t pattern: 指定测试用例名称模式,需要和 path 一起使用
264
+ -t pattern / --name pattern: 指定测试用例名称模式,需要和 path 一起使用
264
265
 
265
266
  限制说明:
266
267
  guarded E2E target 禁止无路径全量执行。
@@ -59,6 +59,8 @@ async function copyDirMerge({ srcDir, dstDir }) {
59
59
 
60
60
  for (const file of files) {
61
61
  const rel = relative(srcDir, file)
62
+ const topLevelDir = rel.split('/')[0]
63
+ if (DEPRECATED_SKILL_DIRS.includes(topLevelDir)) continue
62
64
  const target = join(dstDir, rel)
63
65
  await ensureDir(dirname(target))
64
66
  await fs.copyFile(file, target)
@@ -91,6 +93,7 @@ export async function runCodexInitial(options = {}) {
91
93
  await ensureDir(target.dir)
92
94
  await removeDeprecatedSkillDirs(target.dir)
93
95
  const copyStats = await copyDirMerge({ srcDir: srcSkillsDir, dstDir: target.dir })
96
+ await removeDeprecatedSkillDirs(target.dir)
94
97
  stats.push({ ...target, ...copyStats })
95
98
  }
96
99
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.94",
3
+ "version": "0.1.96",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -9,23 +9,76 @@ description: Use when backend、NestJS、领域异常治理或错误处理审查
9
9
 
10
10
  先判断项目是否已经具备统一错误处理基础设施,再扫描业务代码是否绕过 `DomainException` / `ErrorCode` 体系。默认只输出审计结果和修复建议,不自动改代码;只有用户明确要求时才进入自动修复。
11
11
 
12
+ ## 扫描范围
13
+
14
+ 脚本不预设任何项目路径,需要通过 `--src-dir` / `--e2e-dir` 显式传入。执行前必须先完成项目探索(见下方步骤 0)。
15
+
16
+ 以下路径/后缀始终排除,不会产生误报:
17
+
18
+ | 排除类别 | 路径/后缀 |
19
+ |----------|-----------|
20
+ | 单元测试 | `*.spec.ts`、`*.test.ts`、`*.e2e-spec.ts` |
21
+ | 测试辅助 | `*.mock.ts`、`*.stub.ts`、`*.fixture.ts`、`fixtures/`、`test-utils/`、`testing/`、`__tests__/`、`__test__/` |
22
+ | 基础设施 | `*/filters/`、`prisma/`、`scripts/` |
23
+ | 异常定义 | `*.exception.ts` |
24
+ | 入口文件 | `main.ts` |
25
+
26
+ 只有在用户**明确要求**评估测试债务时,才传 `--e2e-dir` + `--scope e2e` 扫描测试代码。
27
+
12
28
  ## 快速开始
13
29
 
14
- 1. 先审计生产代码:
30
+ ### 步骤 0:项目探索(每个项目首次执行时必须完成)
31
+
32
+ 在调用脚本前,先探索项目结构,识别出后端生产代码和测试代码的实际路径。方法:
33
+
34
+ 1. 查看项目根目录结构(`ls` 或 Glob)
35
+ 2. 识别后端应用目录(可能是 `apps/backend/src`、`src`、`server/src` 等)
36
+ 3. 识别测试目录(可能是 `apps/backend/e2e`、`test`、`e2e`、`__tests__` 等)
37
+ 4. 如果是 monorepo,可能有多个后端服务,每个都需要单独传 `--src-dir`
38
+
39
+ 典型示例:
40
+
41
+ | 项目类型 | 生产代码 | 测试代码 |
42
+ |----------|----------|----------|
43
+ | NestJS monorepo | `apps/backend/src` | `apps/backend/e2e` |
44
+ | 单体 NestJS | `src` | `test` 或 `e2e` |
45
+ | 多服务 monorepo | `apps/api/src`、`apps/worker/src` | `apps/api/e2e`、`apps/worker/e2e` |
46
+
47
+ ### 步骤 1:审计生产代码
48
+
49
+ 将探索到的路径传给脚本(`--src-dir` 可传多个):
50
+
51
+ ```bash
52
+ SKILL_HOME="${SKILL_HOME:-$HOME/.claude/skills}"
53
+ python "$SKILL_HOME/error-handling-audit-fixer/scripts/error_handling_audit.py" \
54
+ --workspace "$PWD" \
55
+ --src-dir <探索到的生产代码路径>
56
+ ```
57
+
58
+ 示例(当前项目):
15
59
 
16
60
  ```bash
17
- CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
18
- python "$CODEX_HOME/skills/error-handling-audit-fixer/scripts/error_handling_audit.py" \
61
+ python "$SKILL_HOME/error-handling-audit-fixer/scripts/error_handling_audit.py" \
19
62
  --workspace "$PWD" \
20
- --scope src
63
+ --src-dir apps/backend/src
21
64
  ```
22
65
 
23
- 2. 再按需审计 E2E 或全量:
66
+ 多服务示例:
24
67
 
25
68
  ```bash
26
- python "$CODEX_HOME/skills/error-handling-audit-fixer/scripts/error_handling_audit.py" \
69
+ python "$SKILL_HOME/error-handling-audit-fixer/scripts/error_handling_audit.py" \
27
70
  --workspace "$PWD" \
28
- --scope all \
71
+ --src-dir apps/api/src \
72
+ --src-dir apps/worker/src
73
+ ```
74
+
75
+ ### 步骤 2:(仅在用户明确要求时)审计测试代码
76
+
77
+ ```bash
78
+ python "$SKILL_HOME/error-handling-audit-fixer/scripts/error_handling_audit.py" \
79
+ --workspace "$PWD" \
80
+ --e2e-dir <探索到的测试代码路径> \
81
+ --scope e2e \
29
82
  --output-json /tmp/error-handling-audit.json
30
83
  ```
31
84
 
@@ -37,7 +90,7 @@ python "$CODEX_HOME/skills/error-handling-audit-fixer/scripts/error_handling_aud
37
90
 
38
91
  ## 执行流程
39
92
 
40
- 1. 默认先扫描 `apps/backend/src`,只有在用户明确要求或需要评估测试债务时再扫描 `apps/backend/e2e`。
93
+ 1. 默认只扫描 `apps/backend/src`(生产代码)。测试代码、E2E、prisma seed、脚本等非生产路径全部排除。只有在用户明确要求评估测试债务时,才传 `--scope e2e` 扫描测试代码。
41
94
  2. 先判断基础设施状态:
42
95
  - 是否存在 `DomainException`
43
96
  - 是否存在 `ErrorCode`
@@ -56,47 +109,31 @@ python "$CODEX_HOME/skills/error-handling-audit-fixer/scripts/error_handling_aud
56
109
  6. 只有在用户明确说“自动修复”“直接改”或等价表述时,才进入落代码阶段。
57
110
  7. 若脚本结果与实际代码不一致,必须抽样打开命中文件复核,不要把脚本结果当成绝对真相。
58
111
 
59
- ## 审计命令
112
+ ## 审计命令(rg 快速复核)
60
113
 
61
- 项目具备 ripgrep 时,可先用下列命令快速复核:
114
+ 以下 rg 命令用于手工复核,将 `<SRC_DIR>` 替换为步骤 0 探索到的实际路径:
62
115
 
63
116
  ```bash
64
117
  rg "new (BadRequestException|UnauthorizedException|ForbiddenException|NotFoundException|HttpException|InternalServerErrorException)\(" \
65
- apps/backend/src apps/backend/e2e \
66
- --glob '!*spec.ts' \
118
+ <SRC_DIR> \
119
+ --glob '!*spec.ts' --glob '!*test.ts' \
67
120
  --glob '!*exception.ts' \
68
- --glob '!apps/backend/src/common/filters/**' \
69
- --glob '!apps/backend/src/main.ts'
121
+ --glob '!*/filters/**' \
122
+ --glob '!*main.ts'
70
123
  ```
71
124
 
72
125
  ```bash
73
- rg "new Error\(" apps/backend/src apps/backend/e2e --glob '!*spec.ts'
126
+ rg "new Error\(" <SRC_DIR> --glob '!*spec.ts' --glob '!*test.ts'
74
127
  ```
75
128
 
76
129
  ```bash
77
- rg "new DomainException\([^)]*$" -A3 apps/backend/src
130
+ rg "new DomainException\([^)]*$" -A3 <SRC_DIR>
78
131
  ```
79
132
 
80
133
  ```bash
81
- rg "DomainException\([^)]*[\u4e00-\u9fa5]" apps/backend/src apps/backend/e2e \
134
+ rg "DomainException\([^)]*[\u4e00-\u9fa5]" <SRC_DIR> \
82
135
  --glob '!*spec.ts' \
83
- --glob '!apps/backend/src/common/exceptions/**'
84
- ```
85
-
86
- 如果只想快速看生产代码,可优先改成:
87
-
88
- ```bash
89
- python "$CODEX_HOME/skills/error-handling-audit-fixer/scripts/error_handling_audit.py" \
90
- --workspace "$PWD" \
91
- --scope src
92
- ```
93
-
94
- 如果只想看 E2E 存量问题,可改成:
95
-
96
- ```bash
97
- python "$CODEX_HOME/skills/error-handling-audit-fixer/scripts/error_handling_audit.py" \
98
- --workspace "$PWD" \
99
- --scope e2e
136
+ --glob '!*/common/exceptions/**'
100
137
  ```
101
138
 
102
139
  ## 修复准则
@@ -60,16 +60,34 @@ def parse_args() -> argparse.Namespace:
60
60
  parser = argparse.ArgumentParser(description="审计后端错误处理是否绕过 DomainException / ErrorCode 体系")
61
61
  parser.add_argument("--workspace", required=True, help="仓库根目录")
62
62
  parser.add_argument(
63
- "--include-glob",
63
+ "--src-dir",
64
+ action="append",
65
+ default=None,
66
+ help="生产代码目录(相对于 workspace,可重复),如 apps/backend/src",
67
+ )
68
+ parser.add_argument(
69
+ "--e2e-dir",
64
70
  action="append",
65
71
  default=None,
66
- help="附加扫描 glob,可重复传入",
72
+ help="测试代码目录(相对于 workspace,可重复),如 apps/backend/e2e",
67
73
  )
68
74
  parser.add_argument(
69
75
  "--scope",
70
76
  choices=["all", "src", "e2e"],
71
- default="all",
72
- help="预设扫描范围:all=src+e2e,src=仅生产代码,e2e=仅测试代码",
77
+ default="src",
78
+ help="扫描范围:src=仅生产代码(默认),e2e=仅测试代码,all=src+e2e",
79
+ )
80
+ parser.add_argument(
81
+ "--include-glob",
82
+ action="append",
83
+ default=None,
84
+ help="附加扫描 glob(覆盖自动推导),可重复传入",
85
+ )
86
+ parser.add_argument(
87
+ "--exclude-pattern",
88
+ action="append",
89
+ default=None,
90
+ help="额外排除路径片段(可重复),如 prisma/ scripts/",
73
91
  )
74
92
  parser.add_argument("--output-json", help="输出 JSON 文件路径")
75
93
  parser.add_argument(
@@ -95,18 +113,44 @@ def iter_files(workspace: Path, globs: Iterable[str]) -> list[Path]:
95
113
  return sorted(files)
96
114
 
97
115
 
98
- def should_skip(path: Path) -> bool:
116
+ SKIP_SUFFIXES = (
117
+ ".spec.ts",
118
+ ".test.ts",
119
+ ".e2e-spec.ts",
120
+ ".exception.ts",
121
+ ".mock.ts",
122
+ ".stub.ts",
123
+ ".fixture.ts",
124
+ )
125
+
126
+ SKIP_DIR_FRAGMENTS = (
127
+ "__tests__/",
128
+ "__test__/",
129
+ "test-utils/",
130
+ "testing/",
131
+ "fixtures/",
132
+ "/filters/",
133
+ "prisma/",
134
+ "scripts/",
135
+ )
136
+
137
+ SKIP_FILENAMES = ("main.ts",)
138
+
139
+
140
+ def should_skip(path: Path, extra_excludes: list[str] | None = None) -> bool:
99
141
  path_text = path.as_posix()
100
- if path_text.endswith(".spec.ts"):
101
- return True
102
- if path_text.endswith(".exception.ts"):
103
- return True
104
- if path_text == "apps/backend/src/main.ts":
105
- return True
106
- if path_text.startswith("apps/backend/src/common/filters/"):
107
- return True
108
- if path_text.startswith("apps/backend/src/common/exceptions/"):
142
+ if path.name in SKIP_FILENAMES:
109
143
  return True
144
+ for suffix in SKIP_SUFFIXES:
145
+ if path_text.endswith(suffix):
146
+ return True
147
+ for fragment in SKIP_DIR_FRAGMENTS:
148
+ if fragment in path_text:
149
+ return True
150
+ if extra_excludes:
151
+ for pattern in extra_excludes:
152
+ if pattern in path_text:
153
+ return True
110
154
  return False
111
155
 
112
156
 
@@ -114,12 +158,14 @@ def line_no(content: str, index: int) -> int:
114
158
  return content.count("\n", 0, index) + 1
115
159
 
116
160
 
117
- def default_globs_for_scope(scope: str) -> list[str]:
161
+ def build_globs(scope: str, src_dirs: list[str], e2e_dirs: list[str]) -> list[str]:
118
162
  if scope == "src":
119
- return ["apps/backend/src/**/*.ts"]
120
- if scope == "e2e":
121
- return ["apps/backend/e2e/**/*.ts"]
122
- return ["apps/backend/src/**/*.ts", "apps/backend/e2e/**/*.ts"]
163
+ dirs = src_dirs
164
+ elif scope == "e2e":
165
+ dirs = e2e_dirs
166
+ else:
167
+ dirs = src_dirs + e2e_dirs
168
+ return [f"{d}/**/*.ts" for d in dirs]
123
169
 
124
170
 
125
171
  def safe_read_text(path: Path) -> str:
@@ -284,30 +330,32 @@ def find_constructor_calls(content: str, constructor_name: str) -> list[tuple[in
284
330
  return matches
285
331
 
286
332
 
287
- def collect_foundation_status(workspace: Path) -> FoundationStatus:
288
- backend_root = workspace / "apps/backend/src"
289
- all_ts_files = sorted(backend_root.glob("**/*.ts"))
333
+ def collect_foundation_status(workspace: Path, src_dirs: list[str]) -> FoundationStatus:
290
334
  has_domain_exception = False
291
335
  has_error_code = False
292
336
  has_exception_filters = False
293
337
  has_module_exceptions_dir = False
294
338
  has_structured_request_id_signal = False
295
339
 
296
- for path in all_ts_files:
297
- path_text = path.as_posix()
298
- content = safe_read_text(path)
299
- if not content:
340
+ for src_dir in src_dirs:
341
+ root = workspace / src_dir
342
+ if not root.is_dir():
300
343
  continue
301
- if re.search(r"\bclass\s+DomainException\b", content):
302
- has_domain_exception = True
303
- if re.search(r"\b(enum|const)\s+ErrorCode\b", content) or re.search(r"\bErrorCode\.[A-Z0-9_]+\b", content):
304
- has_error_code = True
305
- if "/filters/" in path_text and re.search(r"ExceptionFilter|Catch\s*\(", content):
306
- has_exception_filters = True
307
- if "/exceptions/" in path_text and not path_text.startswith("apps/backend/src/common/exceptions/"):
308
- has_module_exceptions_dir = True
309
- if "requestId" in content and re.search(r"\b(args|code)\b", content):
310
- has_structured_request_id_signal = True
344
+ for path in sorted(root.glob("**/*.ts")):
345
+ path_text = path.as_posix()
346
+ content = safe_read_text(path)
347
+ if not content:
348
+ continue
349
+ if re.search(r"\bclass\s+DomainException\b", content):
350
+ has_domain_exception = True
351
+ if re.search(r"\b(enum|const)\s+ErrorCode\b", content) or re.search(r"\bErrorCode\.[A-Z0-9_]+\b", content):
352
+ has_error_code = True
353
+ if "/filters/" in path_text and re.search(r"ExceptionFilter|Catch\s*\(", content):
354
+ has_exception_filters = True
355
+ if "/exceptions/" in path_text and "common/exceptions" not in path_text:
356
+ has_module_exceptions_dir = True
357
+ if "requestId" in content and re.search(r"\b(args|code)\b", content):
358
+ has_structured_request_id_signal = True
311
359
 
312
360
  return FoundationStatus(
313
361
  has_domain_exception=has_domain_exception,
@@ -506,9 +554,19 @@ def print_report(foundations: FoundationStatus, findings: list[Finding]) -> None
506
554
  def main() -> int:
507
555
  args = parse_args()
508
556
  workspace = Path(args.workspace).resolve()
509
- globs = args.include_glob or default_globs_for_scope(args.scope)
510
- files = [path for path in iter_files(workspace, globs) if not should_skip(path.relative_to(workspace))]
511
- foundations = collect_foundation_status(workspace)
557
+
558
+ src_dirs: list[str] = args.src_dir or []
559
+ e2e_dirs: list[str] = args.e2e_dir or []
560
+ extra_excludes: list[str] | None = args.exclude_pattern
561
+
562
+ if not src_dirs and not e2e_dirs and not args.include_glob:
563
+ print("错误:必须通过 --src-dir 或 --e2e-dir 指定扫描目录,或通过 --include-glob 指定 glob 模式")
564
+ print("示例:--src-dir apps/backend/src --e2e-dir apps/backend/e2e")
565
+ return 1
566
+
567
+ globs = args.include_glob or build_globs(args.scope, src_dirs, e2e_dirs)
568
+ files = [path for path in iter_files(workspace, globs) if not should_skip(path.relative_to(workspace), extra_excludes)]
569
+ foundations = collect_foundation_status(workspace, src_dirs or e2e_dirs)
512
570
 
513
571
  findings: list[Finding] = []
514
572
  for path in files:
@@ -442,7 +442,49 @@ MSG
442
442
 
443
443
  若继续:`ROUND += 1`,回到 4.1。
444
444
 
445
- 若结束且 `CODE_CHANGED_SINCE_LAST_CHECK=true`:执行最终验证——按串行顺序跑 `dx lint` -> `dx build affected --dev` -> 关联测试,有错即停。有失败则修复并 commit/push,无失败则完成。
445
+ 若结束且 `CODE_CHANGED_SINCE_LAST_CHECK=true`:执行最终验证——按串行顺序跑 `dx lint` -> `dx build affected --dev` -> 关联测试,有错即停。有失败则修复并 commit/push,无失败则进入 4.7 发布验证总结。
446
+
447
+ 若结束且 `CODE_CHANGED_SINCE_LAST_CHECK=false`(本轮零问题直接通过):同样进入 4.7,基于最近一次验证结果发布总结。
448
+
449
+ ### 4.7 发布验证总结报告到 PR
450
+
451
+ 最终验证通过后,在 PR 上发布一条验证总结,让审查者一眼看清本次交付的质量门禁结果。
452
+
453
+ 收集并汇总以下信息:
454
+ - **审查轮数**:共经历了几轮审查修复循环
455
+ - **Lint 结果**:通过/失败(最终状态)
456
+ - **构建结果**:通过/失败(最终状态)
457
+ - **测试执行明细**:列出实际执行的每条测试命令及其结果(如 `dx test e2e backend apps/backend/e2e/ai-model/virtual-model.e2e-spec.ts` -> 通过),未执行测试的类别注明"无相关改动,跳过"
458
+ - **代码审查**:共发现多少问题、修复多少、拒绝多少
459
+
460
+ ```bash
461
+ gh pr comment <PR_NUMBER> --body-file - <<'MSG'
462
+ ## ✅ 验证总结
463
+
464
+ ### 门禁结果
465
+
466
+ | 步骤 | 状态 | 备注 |
467
+ |------|------|------|
468
+ | Lint (`dx lint`) | ✅ 通过 | |
469
+ | 构建 (`dx build affected --dev`) | ✅ 通过 | |
470
+ | 后端 E2E | ✅ 通过 | `dx test e2e backend <实际执行的文件/目录>` |
471
+ | 前端单测 | ⏭️ 跳过 | 无相关改动 |
472
+ | 管理端单测 | ⏭️ 跳过 | 无相关改动 |
473
+
474
+ ### 审查统计
475
+
476
+ - 审查轮数:N
477
+ - 发现问题:X 个(Critical: a / Major: b / Minor: c)
478
+ - 已修复:Y 个
479
+ - 拒绝修复:Z 个(附理由见上方修复报告)
480
+
481
+ ### 结论
482
+
483
+ 所有质量门禁通过,PR 可合并。
484
+ MSG
485
+ ```
486
+
487
+ > **注意**:表格中的"状态"和"备注"列必须基于实际执行结果填写,不能编造。若某步骤失败后经修复再次通过,标注"✅ 通过(修复后重跑)"。测试命令列必须写出实际执行的完整命令,不能用占位符。
446
488
 
447
489
  ---
448
490