@ranger1/dx 0.1.111 → 0.1.112

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.
@@ -71,7 +71,8 @@ export async function handleBuild(cli, args) {
71
71
  export async function handleTest(cli, args) {
72
72
  const type = args[0] || 'e2e'
73
73
  const target = args[1] || 'all'
74
- const testPath = args[2] // 可选的测试文件路径
74
+ const testPaths = args.slice(2) // unit 可选多个测试文件路径;e2e 只使用第一个
75
+ const testPath = testPaths[0]
75
76
 
76
77
  // 解析 -t 参数用于指定特定测试用例(使用原始参数列表)
77
78
  const allArgs = cli.args // 使用原始参数列表包含所有标志
@@ -137,15 +138,17 @@ export async function handleTest(cli, args) {
137
138
  } else {
138
139
  logger.step(`运行单个 ${type} 测试: ${testPath}`)
139
140
  }
140
- } else if (type === 'unit' && testPath) {
141
+ } else if (type === 'unit' && testPaths.length > 0) {
141
142
  let command = String(testConfig.command).trim()
142
143
  const useDirectPathArg = shouldUseDirectPathArg(command)
143
- const normalizedTestPath = useDirectPathArg
144
- ? normalizeUnitTestPathForCommand(cli, command, testPath)
145
- : testPath
144
+ const normalizedTestPaths = testPaths.map(path =>
145
+ useDirectPathArg
146
+ ? normalizeUnitTestPathForCommand(cli, command, path)
147
+ : path
148
+ )
146
149
  const forwardedArgs = useDirectPathArg
147
- ? [shellEscape(normalizedTestPath)]
148
- : [`--runTestsByPath ${shellEscape(normalizedTestPath)}`]
150
+ ? normalizedTestPaths.map(shellEscape)
151
+ : [`--runTestsByPath ${normalizedTestPaths.map(shellEscape).join(' ')}`]
149
152
 
150
153
  if (testNamePattern) {
151
154
  forwardedArgs.push(`-t ${shellEscape(testNamePattern)}`)
@@ -161,14 +164,14 @@ export async function handleTest(cli, args) {
161
164
  ...testConfig,
162
165
  command,
163
166
  description: testNamePattern
164
- ? `运行单个单元测试文件的特定用例: ${testPath} -> ${testNamePattern}`
165
- : `运行单个单元测试文件: ${testPath}`,
167
+ ? `运行单元测试文件的特定用例: ${testPaths.join(', ')} -> ${testNamePattern}`
168
+ : `运行单元测试文件: ${testPaths.join(', ')}`,
166
169
  }
167
170
 
168
171
  if (testNamePattern) {
169
- logger.step(`运行 ${type} 测试用例: ${testNamePattern} (文件: ${testPath})`)
172
+ logger.step(`运行 ${type} 测试用例: ${testNamePattern} (文件: ${testPaths.join(', ')})`)
170
173
  } else {
171
- logger.step(`运行单个 ${type} 测试: ${testPath}`)
174
+ logger.step(`运行 ${type} 测试: ${testPaths.join(', ')}`)
172
175
  }
173
176
  } else {
174
177
  logger.step(`运行 ${type} 测试`)
package/lib/cli/dx-cli.js CHANGED
@@ -560,7 +560,9 @@ class DxCli {
560
560
  }
561
561
  case 'test':
562
562
  this.validateTestPositionals(positionalArgs)
563
- ensureMax(3)
563
+ if ((positionalArgs[0] || 'e2e') !== 'unit') {
564
+ ensureMax(3)
565
+ }
564
566
  break
565
567
  case 'worktree': {
566
568
  if (positionalArgs.length === 0) return
@@ -293,18 +293,18 @@ export function validateUsageAgainstRuntime(commandName, usageText, cli) {
293
293
  }
294
294
  }
295
295
 
296
- const placeholderTokens = tokens
296
+ const runtimePositionals = tokens
297
297
  .slice(2)
298
- .filter(token => isPlaceholderToken(token) && !isEnvironmentPlaceholder(token))
299
- const runtimeMaxPositionals = getRuntimeMaxPositionals(commandName)
298
+ .filter(token => !isEnvironmentPlaceholder(token))
299
+ const runtimeMaxPositionals = getRuntimeMaxPositionals(commandName, tokens)
300
300
 
301
301
  if (
302
302
  Number.isInteger(runtimeMaxPositionals) &&
303
- placeholderTokens.length > runtimeMaxPositionals
303
+ runtimePositionals.length > runtimeMaxPositionals
304
304
  ) {
305
305
  return {
306
306
  ok: false,
307
- reason: `usage for ${commandName} advertises ${placeholderTokens.length} positionals but runtime allows ${runtimeMaxPositionals}`,
307
+ reason: `usage for ${commandName} advertises ${runtimePositionals.length} positionals but runtime allows ${runtimeMaxPositionals}`,
308
308
  }
309
309
  }
310
310
 
@@ -322,7 +322,7 @@ export function validateUsageAgainstRuntime(commandName, usageText, cli) {
322
322
  return { ok: true }
323
323
  }
324
324
 
325
- function getRuntimeMaxPositionals(commandName) {
325
+ function getRuntimeMaxPositionals(commandName, tokens = []) {
326
326
  switch (commandName) {
327
327
  case 'help':
328
328
  return 1
@@ -333,10 +333,10 @@ function getRuntimeMaxPositionals(commandName) {
333
333
  return 1
334
334
  case 'db':
335
335
  return 2
336
- case 'test':
337
- return 3
338
336
  case 'worktree':
339
337
  return 3
338
+ case 'test':
339
+ return tokens[2] === 'unit' ? null : 3
340
340
  case 'start':
341
341
  return 1
342
342
  case 'lint':
@@ -442,7 +442,6 @@ function validateArgumentTokens(args, cli) {
442
442
  build: 1,
443
443
  package: 1,
444
444
  db: 2,
445
- test: 3,
446
445
  worktree: 3,
447
446
  start: 1,
448
447
  lint: 0,
@@ -451,7 +450,11 @@ function validateArgumentTokens(args, cli) {
451
450
  status: 0,
452
451
  }
453
452
 
454
- const max = maxByCommand[commandName]
453
+ let max = maxByCommand[commandName]
454
+ if (commandName === 'test') {
455
+ max = positionalArgs[0] === 'unit' ? null : 3
456
+ }
457
+
455
458
  if (Number.isInteger(max) && positionalArgs.length > max) {
456
459
  return { ok: false, reason: `命令 ${commandName} 存在未识别的额外参数: ${positionalArgs.slice(max).join(', ')}` }
457
460
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ranger1/dx",
3
- "version": "0.1.111",
3
+ "version": "0.1.112",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,7 +7,7 @@ description: 必须显式调用才触发
7
7
 
8
8
  ## 概览
9
9
 
10
- 6 个后端审计维度的统一入口。规则太多,**一维度一 subagent,每次只跑一个**:用户每次选一个维度,派一个 subagent 只载那份 reference、只跑那个脚本,回来出该维度报告,再询问是否继续下一个。**不并行扇出、不一次跑全部。**
10
+ 7 个后端审计维度的统一入口。规则太多,**一维度一 subagent,每次只跑一个**:用户每次选一个维度,派一个 subagent 只载那份 reference、只跑那个脚本,回来出该维度报告,再询问是否继续下一个。**不并行扇出、不一次跑全部。**
11
11
 
12
12
  默认只审计出报告;用户明确说"修复/直接改"才进落代码阶段。
13
13
 
@@ -18,6 +18,7 @@ description: 必须显式调用才触发
18
18
  | backend-layering | references/backend-layering.md | 无(纯 rg) | conventions §4/§5/§6 |
19
19
  | e2e | references/e2e.md | scripts/e2e_audit.py | ruler/e2e-audit.md |
20
20
  | env-accessor | references/env-accessor.md | scripts/env_accessor_audit.py | conventions §2 |
21
+ | enum-single-source | references/enum-single-source.md | scripts/enum_single_source_audit.py | 枚举唯一真源约定 |
21
22
  | error-handling | references/error-handling.md | scripts/error_handling_audit.py | conventions §9 |
22
23
  | naming | references/naming.md | scripts/naming_audit.py(需先拼 config) | conventions §10 |
23
24
  | pagination-dto | references/pagination-dto.md | scripts/pagination_dto_audit.py | conventions §12 |
@@ -29,18 +30,19 @@ description: 必须显式调用才触发
29
30
  **每次只跑一个维度。** 进入 skill 后:
30
31
 
31
32
  - 用户已点名某维度("检查命名"/"分页规范")→ 直接跑该维度,跳到 Step 2。
32
- - 用户说"全量审计/扫一遍/检查合规"或没点名 → **用纯文本列出下面 6 个维度的编号菜单**,让用户回复编号或维度名再继续。别默认全跑、别并行。
33
+ - 用户说"全量审计/扫一遍/检查合规"或没点名 → **用纯文本列出下面 7 个维度的编号菜单**,让用户回复编号或维度名再继续。别默认全跑、别并行。
33
34
 
34
- > ⚠️ 不要用 AskUserQuestion 列维度:它每题最多 4 个选项,6 个维度会被截断。必须用文本菜单。
35
+ > ⚠️ 不要用 AskUserQuestion 列维度:它每题最多 4 个选项,7 个维度会被截断。必须用文本菜单。
35
36
 
36
37
  ```
37
38
  请选择要审计的维度(每次只跑一个,回复编号或名字):
38
39
  1. backend-layering — 三层架构/事务/Repository 越层(conventions §4/§5/§6)
39
40
  2. e2e — E2E 中文标题/手工 JWT/请求 helper/fixture 复用(ruler/e2e-audit.md)
40
41
  3. env-accessor — 业务代码直读 process.env(conventions §2)
41
- 4. error-handling BadRequestException/无 code HttpException(conventions §9)
42
- 5. naming 文件/文件夹命名规范(conventions §10
43
- 6. pagination-dto 分页 DTO 未继承基类/手工拼装分页返回(conventions §12
42
+ 4. enum-single-source 枚举类型唯一真源/DB enum 生成链路/重复字面量
43
+ 5. error-handling 裸 BadRequestException/无 code HttpException(conventions §9
44
+ 6. naming 文件/文件夹命名规范(conventions §10
45
+ 7. pagination-dto — 分页 DTO 未继承基类/手工拼装分页返回(conventions §12)
44
46
  ```
45
47
 
46
48
  ### Step 2:派 1 个 subagent 跑选中的维度
@@ -0,0 +1,113 @@
1
+ # 维度:枚举唯一真源(enum-single-source)
2
+
3
+ 脚本:`scripts/enum_single_source_audit.py`。规则来源:数据库枚举唯一真源与共享枚举生成约定。
4
+
5
+ 先区分枚举来源:数据库枚举只允许在 Prisma schema 中手写定义,再通过生成脚本产出 shared 枚举;非数据库枚举也必须只有一个 shared 或模块内真源,其他地方 import/derive 复用。
6
+
7
+ ## 运行
8
+
9
+ ```bash
10
+ SKILL_HOME="${SKILL_HOME:-$HOME/.claude/skills}"
11
+ python "$SKILL_HOME/backend-audit-fixer/scripts/enum_single_source_audit.py" \
12
+ --workspace /Users/a1/work/ai-monorepo \
13
+ --output-json /tmp/audit-enum-single-source.json
14
+ ```
15
+
16
+ 必要时用 `--schema-dir` 指定 Prisma schema 目录,用 `--include-glob` 扩展扫描范围。
17
+
18
+ ## 执行流程
19
+
20
+ 1. 读取 `apps/backend/prisma/schema/*.prisma` 中的 `enum`,建立 DB enum 名称和值集合。
21
+ 2. 检查生成产物是否存在且与 schema 同步:
22
+ - `packages/shared/src/generated/prisma-enums.ts`
23
+ - `packages/shared/src/generated/prisma-enum-names.json`(若项目使用该文件约束 lint)
24
+ 3. 扫描 `apps/backend/src/**/*.ts` 与 `packages/shared/src/**/*.ts`,排除 generated、测试文件与声明文件。
25
+ 4. 识别 DB enum 绕过真源的候选问题:
26
+ - 在业务代码里重新 `export enum X`,且名称或值匹配 Prisma enum。
27
+ - 在 DTO/常量里用 `['A', 'B'] as const` 或本地 `*_VALUES` 重写 Prisma enum 值。
28
+ - Swagger 装饰器里手写 `enum: ['A', 'B']`。
29
+ - 从 `@prisma/client` 直接 import DB enum,而不是经 shared 生成枚举。
30
+ - 生成产物缺失或与 Prisma schema 不一致。
31
+ 5. 打开命中文件复核。脚本结果是候选,不是最终真相;每条必须给 `verdict`。
32
+
33
+ ## 合法模式
34
+
35
+ - DB enum 真源:只在 `apps/backend/prisma/schema/*.prisma` 手写。
36
+ - DB enum 生成产物:`packages/shared/src/generated/prisma-enums.ts`,文件头应标记 generated,禁止手改。
37
+ - DB enum values:用 `Object.values(PrismaEnum)` 派生,例如:
38
+ ```typescript
39
+ import { TaskRunStatus } from '../generated/prisma-enums'
40
+
41
+ export const TASK_RUN_STATUS_VALUES = Object.values(TaskRunStatus) as TaskRunStatus[]
42
+ ```
43
+ - 非 DB 枚举:不存在于 Prisma schema 时,可在 shared 统一定义(如 `packages/shared/src/constants/backend-enums.ts`)或模块私有 enum 文件定义;同一语义不得在 DTO、service、controller 中重复字面量数组。
44
+ - 组合视图枚举:可以由 DB enum 成员加少量业务状态组合而成,但必须显式从 DB enum 引用已有成员,不要复制 DB enum 全量字面量。
45
+
46
+ ## 违规模式
47
+
48
+ ```typescript
49
+ // DB enum 已存在于 Prisma schema,又在业务代码重写
50
+ export enum TaskRunStatus {
51
+ PENDING = 'PENDING',
52
+ RUNNING = 'RUNNING',
53
+ }
54
+ ```
55
+
56
+ ```typescript
57
+ // 本地重复 Prisma enum values
58
+ export const TASK_RUN_STATUS_VALUES = ['PENDING', 'RUNNING', 'SUCCESS', 'FAILED'] as const
59
+ ```
60
+
61
+ ```typescript
62
+ // DTO/Swagger 重复字面量
63
+ @ApiProperty({ enum: ['PENDING', 'RUNNING', 'SUCCESS', 'FAILED'] })
64
+ status!: string
65
+ ```
66
+
67
+ ## 修复准则
68
+
69
+ - **DB enum 新增/变更**:只改 Prisma schema,创建迁移,再运行项目枚举生成命令(常见为 `pnpm run generate:enums` 或仓库约定脚本)。
70
+ - **业务代码使用 DB enum**:从 shared 生成产物或 shared 统一出口导入,禁止重新定义。
71
+ - **需要 values 数组**:在 shared 常量层用 `Object.values(PrismaEnum)` 派生,然后业务代码 import 该数组。
72
+ - **Swagger enum**:引用生成枚举或 shared values,不手写字符串数组。
73
+ - **非 DB enum 被多处复制**:抽到 shared 或模块唯一 enum 文件;DTO/service/controller 只 import。
74
+ - **生成产物过期**:运行生成命令,确认 `packages/shared/src/generated/prisma-enums.ts` 与 Prisma schema 同步。
75
+
76
+ ## 复核原则
77
+
78
+ - 名称匹配但值不匹配:标 `needs-review`,确认是不是同名不同语义;不要直接改。
79
+ - 值集合是 Prisma enum 子集:多数是视图状态/筛选条件,只有在语义确认为同一 DB enum 时才报 `real`。
80
+ - 非 DB enum:脚本可能不报或只报候选;人工检查是否存在重复定义与是否适合提升到 shared。
81
+ - 生成产物下的重复结构不算违规,除非缺少 generated 标记或内容与 schema 不一致。
82
+
83
+ ## 返回给主 agent 的 findings
84
+
85
+ `total` 是脚本候选命中数;`real_total` 是复核后真违规数。每条 violation 必须带 `verdict`。
86
+
87
+ ```json
88
+ {
89
+ "dimension": "enum-single-source",
90
+ "infra_status": {"prisma_schema_enums": 0, "generated_prisma_enums": false, "generation_script": false},
91
+ "total": 0,
92
+ "real_total": 0,
93
+ "by_rule": {
94
+ "db-enum-redeclared": 0,
95
+ "db-enum-values-duplicated": 0,
96
+ "swagger-db-enum-literal": 0,
97
+ "db-enum-imported-from-prisma-client": 0,
98
+ "generated-prisma-enum-missing": 0,
99
+ "generated-prisma-enum-stale": 0
100
+ },
101
+ "violations": [
102
+ {
103
+ "file": "",
104
+ "line": 0,
105
+ "rule": "",
106
+ "symbol": "",
107
+ "prisma_enum": "",
108
+ "verdict": "real|false-positive|needs-review",
109
+ "note": ""
110
+ }
111
+ ]
112
+ }
113
+ ```
@@ -0,0 +1,390 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ from dataclasses import asdict, dataclass
8
+ from pathlib import Path
9
+ from typing import Iterable
10
+
11
+
12
+ DEFAULT_SCAN_GLOBS = (
13
+ "apps/backend/src/**/*.ts",
14
+ "packages/shared/src/**/*.ts",
15
+ )
16
+
17
+ EXCLUDED_PARTS = (
18
+ "/generated/",
19
+ ".spec.ts",
20
+ ".test.ts",
21
+ ".e2e-spec.ts",
22
+ ".d.ts",
23
+ )
24
+
25
+ ALLOWED_GENERATED_ENUM_FILE = "packages/shared/src/generated/prisma-enums.ts"
26
+
27
+
28
+ @dataclass
29
+ class PrismaEnum:
30
+ name: str
31
+ values: list[str]
32
+ source_file: str
33
+ line: int
34
+
35
+
36
+ @dataclass
37
+ class Finding:
38
+ rule: str
39
+ file: str
40
+ line: int
41
+ symbol: str
42
+ prisma_enum: str
43
+ verdict: str
44
+ note: str
45
+ snippet: str
46
+
47
+
48
+ def line_no(content: str, index: int) -> int:
49
+ return content.count("\n", 0, index) + 1
50
+
51
+
52
+ def parse_prisma_enums(schema_dir: Path) -> dict[str, PrismaEnum]:
53
+ enums: dict[str, PrismaEnum] = {}
54
+ enum_pattern = re.compile(r"^enum\s+(\w+)\s*\{(?P<body>[\s\S]*?)^\}", re.MULTILINE)
55
+ for path in sorted(schema_dir.glob("*.prisma")):
56
+ content = path.read_text(encoding="utf-8")
57
+ for match in enum_pattern.finditer(content):
58
+ name = match.group(1)
59
+ values: list[str] = []
60
+ for raw_line in match.group("body").splitlines():
61
+ line = raw_line.split("//", 1)[0].strip()
62
+ if not line or line.startswith("@@") or line.startswith("@"):
63
+ continue
64
+ value = re.sub(r"\s+@.*$", "", line).strip()
65
+ if value:
66
+ values.append(value)
67
+ enums[name] = PrismaEnum(
68
+ name=name,
69
+ values=values,
70
+ source_file=str(path),
71
+ line=line_no(content, match.start()),
72
+ )
73
+ return enums
74
+
75
+
76
+ def iter_scan_files(workspace: Path, globs: Iterable[str]) -> list[Path]:
77
+ seen: set[Path] = set()
78
+ files: list[Path] = []
79
+ for pattern in globs:
80
+ for path in workspace.glob(pattern):
81
+ if not path.is_file() or path in seen:
82
+ continue
83
+ rel = path.relative_to(workspace).as_posix()
84
+ if any(part in rel for part in EXCLUDED_PARTS):
85
+ continue
86
+ seen.add(path)
87
+ files.append(path)
88
+ return sorted(files)
89
+
90
+
91
+ def parse_ts_enum_values(body: str) -> list[str]:
92
+ values: list[str] = []
93
+ for raw_line in body.splitlines():
94
+ line = raw_line.split("//", 1)[0].strip().rstrip(",")
95
+ if not line:
96
+ continue
97
+ literal_match = re.search(r"=\s*['\"]([^'\"]+)['\"]", line)
98
+ if literal_match:
99
+ values.append(literal_match.group(1))
100
+ continue
101
+ name_match = re.match(r"([A-Za-z_]\w*)", line)
102
+ if name_match:
103
+ values.append(name_match.group(1))
104
+ return values
105
+
106
+
107
+ def normalized_name(text: str) -> str:
108
+ return re.sub(r"[^a-z0-9]", "", text.lower())
109
+
110
+
111
+ def best_matching_prisma_enum(
112
+ symbol: str,
113
+ values: list[str],
114
+ prisma_enums: dict[str, PrismaEnum],
115
+ ) -> tuple[str, str] | None:
116
+ value_set = set(values)
117
+ symbol_norm = normalized_name(symbol)
118
+ for enum in prisma_enums.values():
119
+ enum_values = set(enum.values)
120
+ if value_set and value_set == enum_values:
121
+ return enum.name, "exact-values"
122
+ if value_set and len(value_set) >= 2 and value_set.issubset(enum_values):
123
+ return enum.name, "subset-values"
124
+ if symbol_norm == normalized_name(enum.name):
125
+ return enum.name, "name-match"
126
+ return None
127
+
128
+
129
+ def redeclared_enum_note(enum_name: str, reason: str) -> str:
130
+ if reason == "name-match":
131
+ return (
132
+ f"TypeScript enum has the same name as Prisma enum {enum_name}; open the source and compare values/semantics "
133
+ "before deciding whether to replace it with the generated @ai/shared enum."
134
+ )
135
+ return f"TypeScript enum matches Prisma enum by {reason}; import generated @ai/shared enum instead."
136
+
137
+
138
+ def collect_string_literals(text: str) -> list[str]:
139
+ return re.findall(r"['\"]([A-Za-z0-9_:-]+)['\"]", text)
140
+
141
+
142
+ def scan_ts_enum_declarations(
143
+ rel: str,
144
+ content: str,
145
+ prisma_enums: dict[str, PrismaEnum],
146
+ ) -> list[Finding]:
147
+ findings: list[Finding] = []
148
+ pattern = re.compile(r"(?:export\s+)?enum\s+(\w+)\s*\{(?P<body>[\s\S]*?)\}", re.MULTILINE)
149
+ for match in pattern.finditer(content):
150
+ symbol = match.group(1)
151
+ values = parse_ts_enum_values(match.group("body"))
152
+ matched = best_matching_prisma_enum(symbol, values, prisma_enums)
153
+ if not matched:
154
+ continue
155
+ enum_name, reason = matched
156
+ findings.append(
157
+ Finding(
158
+ rule="db-enum-redeclared",
159
+ file=rel,
160
+ line=line_no(content, match.start()),
161
+ symbol=symbol,
162
+ prisma_enum=enum_name,
163
+ verdict="candidate",
164
+ note=redeclared_enum_note(enum_name, reason),
165
+ snippet=content[match.start() : match.end()].splitlines()[0].strip(),
166
+ )
167
+ )
168
+ return findings
169
+
170
+
171
+ def scan_literal_enum_arrays(
172
+ rel: str,
173
+ content: str,
174
+ prisma_enums: dict[str, PrismaEnum],
175
+ ) -> list[Finding]:
176
+ findings: list[Finding] = []
177
+ pattern = re.compile(
178
+ r"(?:export\s+)?const\s+(\w+)\s*=\s*\[(?P<body>[\s\S]*?)\]\s*(?:as\s+const)?",
179
+ re.MULTILINE,
180
+ )
181
+ for match in pattern.finditer(content):
182
+ symbol = match.group(1)
183
+ body = match.group("body")
184
+ if "Object.values(" in body:
185
+ continue
186
+ values = collect_string_literals(body)
187
+ matched = best_matching_prisma_enum(symbol, values, prisma_enums)
188
+ if not matched:
189
+ continue
190
+ enum_name, reason = matched
191
+ findings.append(
192
+ Finding(
193
+ rule="db-enum-values-duplicated",
194
+ file=rel,
195
+ line=line_no(content, match.start()),
196
+ symbol=symbol,
197
+ prisma_enum=enum_name,
198
+ verdict="candidate",
199
+ note=f"Literal value array matches Prisma enum by {reason}; derive with Object.values({enum_name}) from generated shared enum.",
200
+ snippet=content[match.start() : match.end()].splitlines()[0].strip(),
201
+ )
202
+ )
203
+ return findings
204
+
205
+
206
+ def scan_swagger_literal_enums(
207
+ rel: str,
208
+ content: str,
209
+ prisma_enums: dict[str, PrismaEnum],
210
+ ) -> list[Finding]:
211
+ findings: list[Finding] = []
212
+ pattern = re.compile(r"enum\s*:\s*\[(?P<body>[^\]]+)\]", re.MULTILINE)
213
+ for match in pattern.finditer(content):
214
+ values = collect_string_literals(match.group("body"))
215
+ matched = best_matching_prisma_enum("swagger-enum", values, prisma_enums)
216
+ if not matched:
217
+ continue
218
+ enum_name, reason = matched
219
+ findings.append(
220
+ Finding(
221
+ rule="swagger-db-enum-literal",
222
+ file=rel,
223
+ line=line_no(content, match.start()),
224
+ symbol="ApiProperty.enum",
225
+ prisma_enum=enum_name,
226
+ verdict="candidate",
227
+ note=f"Swagger literal enum matches Prisma enum by {reason}; reference generated enum or derived values instead.",
228
+ snippet=content[match.start() : match.end()].replace("\n", " ").strip(),
229
+ )
230
+ )
231
+ return findings
232
+
233
+
234
+ def scan_prisma_client_enum_imports(
235
+ rel: str,
236
+ content: str,
237
+ prisma_enums: dict[str, PrismaEnum],
238
+ ) -> list[Finding]:
239
+ findings: list[Finding] = []
240
+ pattern = re.compile(r"import\s*\{(?P<body>[^}]+)\}\s*from\s*['\"]@prisma/client['\"]")
241
+ for match in pattern.finditer(content):
242
+ imported = [part.strip().split(" as ", 1)[0].strip() for part in match.group("body").split(",")]
243
+ for symbol in imported:
244
+ if symbol not in prisma_enums:
245
+ continue
246
+ findings.append(
247
+ Finding(
248
+ rule="db-enum-imported-from-prisma-client",
249
+ file=rel,
250
+ line=line_no(content, match.start()),
251
+ symbol=symbol,
252
+ prisma_enum=symbol,
253
+ verdict="candidate",
254
+ note="DB enum should flow through generated @ai/shared prisma-enums, not direct @prisma/client imports.",
255
+ snippet=content[match.start() : match.end()].strip(),
256
+ )
257
+ )
258
+ return findings
259
+
260
+
261
+ def check_generated_file(workspace: Path, prisma_enums: dict[str, PrismaEnum]) -> list[Finding]:
262
+ generated = workspace / ALLOWED_GENERATED_ENUM_FILE
263
+ if not generated.exists():
264
+ return [
265
+ Finding(
266
+ rule="generated-prisma-enums-missing",
267
+ file=ALLOWED_GENERATED_ENUM_FILE,
268
+ line=0,
269
+ symbol="generated-prisma-enums",
270
+ prisma_enum="*",
271
+ verdict="real",
272
+ note="Generated Prisma enum file is missing; run the project enum generation command.",
273
+ snippet="",
274
+ )
275
+ ]
276
+
277
+ content = generated.read_text(encoding="utf-8")
278
+ findings: list[Finding] = []
279
+ for enum in prisma_enums.values():
280
+ object_match = re.search(
281
+ rf"export\s+const\s+{re.escape(enum.name)}\s*=\s*\{{(?P<body>[\s\S]*?)\}}\s*as\s+const",
282
+ content,
283
+ )
284
+ if not object_match:
285
+ findings.append(
286
+ Finding(
287
+ rule="generated-prisma-enum-missing",
288
+ file=ALLOWED_GENERATED_ENUM_FILE,
289
+ line=0,
290
+ symbol=enum.name,
291
+ prisma_enum=enum.name,
292
+ verdict="real",
293
+ note="Prisma enum is not present in generated shared output; regenerate enums.",
294
+ snippet="",
295
+ )
296
+ )
297
+ continue
298
+ generated_values = collect_string_literals(object_match.group("body"))
299
+ if set(generated_values) != set(enum.values):
300
+ findings.append(
301
+ Finding(
302
+ rule="generated-prisma-enum-stale",
303
+ file=ALLOWED_GENERATED_ENUM_FILE,
304
+ line=line_no(content, object_match.start()),
305
+ symbol=enum.name,
306
+ prisma_enum=enum.name,
307
+ verdict="real",
308
+ note="Generated shared enum values differ from Prisma schema; regenerate enums.",
309
+ snippet=f"generated={generated_values} prisma={enum.values}",
310
+ )
311
+ )
312
+ return findings
313
+
314
+
315
+ def scan_file(path: Path, workspace: Path, prisma_enums: dict[str, PrismaEnum]) -> list[Finding]:
316
+ rel = path.relative_to(workspace).as_posix()
317
+ if rel == ALLOWED_GENERATED_ENUM_FILE:
318
+ return []
319
+ content = path.read_text(encoding="utf-8")
320
+ findings: list[Finding] = []
321
+ findings.extend(scan_ts_enum_declarations(rel, content, prisma_enums))
322
+ findings.extend(scan_literal_enum_arrays(rel, content, prisma_enums))
323
+ findings.extend(scan_swagger_literal_enums(rel, content, prisma_enums))
324
+ findings.extend(scan_prisma_client_enum_imports(rel, content, prisma_enums))
325
+ return findings
326
+
327
+
328
+ def summarize(findings: list[Finding]) -> dict[str, int]:
329
+ by_rule: dict[str, int] = {}
330
+ for finding in findings:
331
+ by_rule[finding.rule] = by_rule.get(finding.rule, 0) + 1
332
+ return by_rule
333
+
334
+
335
+ def parse_args() -> argparse.Namespace:
336
+ parser = argparse.ArgumentParser(description="审计 Prisma/业务枚举唯一真源约束")
337
+ parser.add_argument("--workspace", required=True, help="仓库根目录")
338
+ parser.add_argument(
339
+ "--schema-dir",
340
+ default="apps/backend/prisma/schema",
341
+ help="Prisma schema 目录,默认 apps/backend/prisma/schema",
342
+ )
343
+ parser.add_argument(
344
+ "--include-glob",
345
+ action="append",
346
+ help="附加扫描 glob;默认扫描 apps/backend/src/**/*.ts 与 packages/shared/src/**/*.ts",
347
+ )
348
+ parser.add_argument("--output-json", help="输出 JSON 文件路径")
349
+ return parser.parse_args()
350
+
351
+
352
+ def main() -> int:
353
+ args = parse_args()
354
+ workspace = Path(args.workspace).resolve()
355
+ schema_dir = (workspace / args.schema_dir).resolve()
356
+ scan_globs = tuple(args.include_glob) if args.include_glob else DEFAULT_SCAN_GLOBS
357
+
358
+ prisma_enums = parse_prisma_enums(schema_dir)
359
+ findings = check_generated_file(workspace, prisma_enums)
360
+ for path in iter_scan_files(workspace, scan_globs):
361
+ findings.extend(scan_file(path, workspace, prisma_enums))
362
+
363
+ report = {
364
+ "dimension": "enum-single-source",
365
+ "workspace": str(workspace),
366
+ "schema_dir": str(schema_dir),
367
+ "scan_globs": list(scan_globs),
368
+ "prisma_enum_count": len(prisma_enums),
369
+ "total": len(findings),
370
+ "by_rule": summarize(findings),
371
+ "violations": [asdict(finding) for finding in findings],
372
+ }
373
+
374
+ if findings:
375
+ print(f"共发现 {len(findings)} 个枚举唯一真源候选问题:")
376
+ for finding in findings:
377
+ print(f"- {finding.file}:{finding.line} [{finding.rule}] {finding.symbol} -> {finding.note}")
378
+ else:
379
+ print("未发现枚举唯一真源候选问题。")
380
+
381
+ if args.output_json:
382
+ output_path = Path(args.output_json)
383
+ output_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
384
+ print(f"\nJSON 已输出到 {output_path}")
385
+
386
+ return 0
387
+
388
+
389
+ if __name__ == "__main__":
390
+ raise SystemExit(main())
@@ -21,7 +21,8 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
21
21
  - `dx` 可用,并已完成必要初始化。
22
22
  - `agent-browser` 可用,且 Chromium/浏览器依赖已安装到可运行状态。
23
23
  - `rg`(rip grep)可用。
24
- - `rtk` 可用。
24
+ - `gh`(GitHub CLI)可用;若当前工作流需要 GitHub 操作,还应完成认证。
25
+ - `rtk` 可用,并且已分别完成 Codex CLI 与 Claude Code 的初始化。
25
26
  - 常用 PATH 配置在当前 shell 中可生效;若需要持久化,说明写入了哪个 shell 配置文件。
26
27
 
27
28
  ## 执行原则
@@ -38,7 +39,9 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
38
39
  1. 收集上下文:
39
40
  - 操作系统与架构
40
41
  - 当前 shell 与 PATH
41
- - `python3`、`python`、`node`、`npm`、`pnpm`、`dx`、`agent-browser`、`rg` 的存在性与版本
42
+ - `python3`、`python`、`node`、`npm`、`pnpm`、`dx`、`agent-browser`、`rg`、`gh`、`rtk` 的存在性与版本
43
+ - `gh` 的认证状态(例如 `gh auth status`)
44
+ - `rtk` 对 Codex CLI 与 Claude Code 的初始化状态
42
45
  2. 对照目标状态判断缺口。
43
46
  3. 制定最小修复动作并执行。
44
47
  4. 每次修复后重新验证相关项。
@@ -52,6 +55,10 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
52
55
  - 每个目标命令是否可被当前 shell 找到。
53
56
  - 每个目标命令的版本或基本健康输出。
54
57
  - `dx` 的初始化结果或当前初始化状态。
58
+ - `gh --version` 能正常运行;若需要 GitHub 操作,`gh auth status` 应显示可用账号,否则报告未认证原因。
59
+ - `rtk --version` 与 `rtk gain` 能正常运行。
60
+ - `rtk` 的 Codex CLI 初始化必须有明确证据。优先使用 `rtk init --show --codex`,确认全局或本地 Codex 配置已包含 `RTK.md` 与 `AGENTS.md` 引用;若当前 `rtk` 版本命令不同,使用等价的只读检查并在报告中说明。
61
+ - `rtk` 的 Claude Code 初始化必须有明确证据。优先使用 `rtk init --show --agent claude`,确认 Claude Code hook、`RTK.md`、`CLAUDE.md` 引用与 `settings.json` hook 配置处于健康状态;若当前 `rtk` 版本命令不同,使用等价的只读检查并在报告中说明。
55
62
  - `agent-browser` 是否能找到并使用已安装的浏览器依赖。
56
63
  - 对未通过项给出失败原因、已尝试动作和下一步建议。
57
64
 
@@ -61,6 +68,7 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
61
68
 
62
69
  - 环境摘要:系统、shell、关键 PATH 变更。
63
70
  - 检查结果表:检查项、状态、版本/证据、说明。
71
+ - RTK 初始化:分别列出 Codex CLI 与 Claude Code 的检查命令、通过/失败状态、关键证据文件或 hook。
64
72
  - 已执行修复:实际执行过的安装、链接、初始化或配置变更。
65
73
  - 未完成项:若存在,说明阻塞原因和用户需要做什么。
66
74
  - 结论:通过 / 部分通过 / 未通过。
@@ -1,7 +0,0 @@
1
- interface:
2
- display_name: "Doctor"
3
- short_description: "按目标状态自主诊断、修复并验证 Codex 开发环境"
4
- default_prompt: "使用 $doctor 对当前系统进行 Codex 开发环境体检;根据实际缺口自主选择修复方式,完成后复检并输出报告。"
5
-
6
- policy:
7
- allow_implicit_invocation: false