@ranger1/dx 0.1.111 → 0.1.113
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/lib/cli/commands/core.js +50 -11
- package/lib/cli/dx-cli.js +3 -1
- package/lib/cli/help-schema.js +13 -10
- package/package.json +1 -1
- package/skills/backend-audit-fixer/SKILL.md +8 -6
- package/skills/backend-audit-fixer/references/enum-single-source.md +113 -0
- package/skills/backend-audit-fixer/scripts/__pycache__/enum_single_source_audit.cpython-314.pyc +0 -0
- package/skills/backend-audit-fixer/scripts/enum_single_source_audit.py +390 -0
- package/skills/doctor/SKILL.md +12 -2
- package/skills/doctor/agents/openai.yaml +0 -7
package/lib/cli/commands/core.js
CHANGED
|
@@ -5,6 +5,8 @@ import { confirmManager } from '../../confirm.js'
|
|
|
5
5
|
import { execManager } from '../../exec.js'
|
|
6
6
|
import { showHelp, showCommandHelp } from '../help.js'
|
|
7
7
|
|
|
8
|
+
const DEFAULT_TEST_WORKERS = 8
|
|
9
|
+
|
|
8
10
|
export function handleHelp(cli, args = []) {
|
|
9
11
|
if (args[0]) showCommandHelp(args[0], cli)
|
|
10
12
|
else showHelp(cli)
|
|
@@ -71,7 +73,8 @@ export async function handleBuild(cli, args) {
|
|
|
71
73
|
export async function handleTest(cli, args) {
|
|
72
74
|
const type = args[0] || 'e2e'
|
|
73
75
|
const target = args[1] || 'all'
|
|
74
|
-
const
|
|
76
|
+
const testPaths = args.slice(2) // unit 可选多个测试文件路径;e2e 只使用第一个
|
|
77
|
+
const testPath = testPaths[0]
|
|
75
78
|
|
|
76
79
|
// 解析 -t 参数用于指定特定测试用例(使用原始参数列表)
|
|
77
80
|
const allArgs = cli.args // 使用原始参数列表包含所有标志
|
|
@@ -137,15 +140,17 @@ export async function handleTest(cli, args) {
|
|
|
137
140
|
} else {
|
|
138
141
|
logger.step(`运行单个 ${type} 测试: ${testPath}`)
|
|
139
142
|
}
|
|
140
|
-
} else if (type === 'unit' &&
|
|
143
|
+
} else if (type === 'unit' && testPaths.length > 0) {
|
|
141
144
|
let command = String(testConfig.command).trim()
|
|
142
145
|
const useDirectPathArg = shouldUseDirectPathArg(command)
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
+
const normalizedTestPaths = testPaths.map(path =>
|
|
147
|
+
useDirectPathArg
|
|
148
|
+
? normalizeUnitTestPathForCommand(cli, command, path)
|
|
149
|
+
: path
|
|
150
|
+
)
|
|
146
151
|
const forwardedArgs = useDirectPathArg
|
|
147
|
-
?
|
|
148
|
-
: [`--runTestsByPath ${shellEscape(
|
|
152
|
+
? normalizedTestPaths.map(shellEscape)
|
|
153
|
+
: [`--runTestsByPath ${normalizedTestPaths.map(shellEscape).join(' ')}`]
|
|
149
154
|
|
|
150
155
|
if (testNamePattern) {
|
|
151
156
|
forwardedArgs.push(`-t ${shellEscape(testNamePattern)}`)
|
|
@@ -161,19 +166,24 @@ export async function handleTest(cli, args) {
|
|
|
161
166
|
...testConfig,
|
|
162
167
|
command,
|
|
163
168
|
description: testNamePattern
|
|
164
|
-
?
|
|
165
|
-
:
|
|
169
|
+
? `运行单元测试文件的特定用例: ${testPaths.join(', ')} -> ${testNamePattern}`
|
|
170
|
+
: `运行单元测试文件: ${testPaths.join(', ')}`,
|
|
166
171
|
}
|
|
167
172
|
|
|
168
173
|
if (testNamePattern) {
|
|
169
|
-
logger.step(`运行 ${type} 测试用例: ${testNamePattern} (文件: ${
|
|
174
|
+
logger.step(`运行 ${type} 测试用例: ${testNamePattern} (文件: ${testPaths.join(', ')})`)
|
|
170
175
|
} else {
|
|
171
|
-
logger.step(
|
|
176
|
+
logger.step(`运行 ${type} 测试: ${testPaths.join(', ')}`)
|
|
172
177
|
}
|
|
173
178
|
} else {
|
|
174
179
|
logger.step(`运行 ${type} 测试`)
|
|
175
180
|
}
|
|
176
181
|
|
|
182
|
+
testConfig = {
|
|
183
|
+
...testConfig,
|
|
184
|
+
command: appendDefaultTestWorkers(testConfig.command, type),
|
|
185
|
+
}
|
|
186
|
+
|
|
177
187
|
await cli.executeCommand(testConfig)
|
|
178
188
|
}
|
|
179
189
|
|
|
@@ -205,6 +215,35 @@ function shouldUseDirectPathArg(command) {
|
|
|
205
215
|
)
|
|
206
216
|
}
|
|
207
217
|
|
|
218
|
+
function appendDefaultTestWorkers(command, type) {
|
|
219
|
+
const flag = getDefaultWorkerFlag(type)
|
|
220
|
+
if (!flag) return command
|
|
221
|
+
|
|
222
|
+
const text = String(command || '').trim()
|
|
223
|
+
if (!text || hasWorkerFlag(text, flag)) return command
|
|
224
|
+
|
|
225
|
+
if (isNodeEvalCommandWithoutArgSeparator(text)) {
|
|
226
|
+
return `${text} -- ${flag}=${DEFAULT_TEST_WORKERS}`
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return `${text} ${flag}=${DEFAULT_TEST_WORKERS}`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function getDefaultWorkerFlag(type) {
|
|
233
|
+
if (type === 'e2e') return '--workers'
|
|
234
|
+
if (type === 'unit') return '--maxWorkers'
|
|
235
|
+
return null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function hasWorkerFlag(command, flag) {
|
|
239
|
+
const escapedFlag = flag.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
240
|
+
return new RegExp(`(^|[\\s'"])${escapedFlag}(=|\\s|['"]|$)`).test(String(command || ''))
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function isNodeEvalCommandWithoutArgSeparator(command) {
|
|
244
|
+
return /(^|\s)node\s+-e\s/.test(String(command || '')) && !/\s--(\s|$)/.test(String(command || ''))
|
|
245
|
+
}
|
|
246
|
+
|
|
208
247
|
function normalizeUnitTestPathForCommand(cli, command, testPath) {
|
|
209
248
|
const rawPath = String(testPath || '')
|
|
210
249
|
if (!rawPath) return rawPath
|
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
|
-
|
|
563
|
+
if ((positionalArgs[0] || 'e2e') !== 'unit') {
|
|
564
|
+
ensureMax(3)
|
|
565
|
+
}
|
|
564
566
|
break
|
|
565
567
|
case 'worktree': {
|
|
566
568
|
if (positionalArgs.length === 0) return
|
package/lib/cli/help-schema.js
CHANGED
|
@@ -293,18 +293,18 @@ export function validateUsageAgainstRuntime(commandName, usageText, cli) {
|
|
|
293
293
|
}
|
|
294
294
|
}
|
|
295
295
|
|
|
296
|
-
const
|
|
296
|
+
const runtimePositionals = tokens
|
|
297
297
|
.slice(2)
|
|
298
|
-
.filter(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
|
-
|
|
303
|
+
runtimePositionals.length > runtimeMaxPositionals
|
|
304
304
|
) {
|
|
305
305
|
return {
|
|
306
306
|
ok: false,
|
|
307
|
-
reason: `usage for ${commandName} advertises ${
|
|
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
|
-
|
|
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
|
@@ -7,7 +7,7 @@ description: 必须显式调用才触发
|
|
|
7
7
|
|
|
8
8
|
## 概览
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
- 用户说"全量审计/扫一遍/检查合规"或没点名 → **用纯文本列出下面
|
|
33
|
+
- 用户说"全量审计/扫一遍/检查合规"或没点名 → **用纯文本列出下面 7 个维度的编号菜单**,让用户回复编号或维度名再继续。别默认全跑、别并行。
|
|
33
34
|
|
|
34
|
-
> ⚠️ 不要用 AskUserQuestion 列维度:它每题最多 4 个选项,
|
|
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.
|
|
42
|
-
5.
|
|
43
|
-
6.
|
|
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
|
+
```
|
package/skills/backend-audit-fixer/scripts/__pycache__/enum_single_source_audit.cpython-314.pyc
ADDED
|
Binary file
|
|
@@ -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())
|
package/skills/doctor/SKILL.md
CHANGED
|
@@ -21,7 +21,9 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
|
|
|
21
21
|
- `dx` 可用,并已完成必要初始化。
|
|
22
22
|
- `agent-browser` 可用,且 Chromium/浏览器依赖已安装到可运行状态。
|
|
23
23
|
- `rg`(rip grep)可用。
|
|
24
|
-
- `
|
|
24
|
+
- `gh`(GitHub CLI)可用;若当前工作流需要 GitHub 操作,还应完成认证。
|
|
25
|
+
- `rtk` 可用,且明确是 [rtk-ai/rtk](https://github.com/rtk-ai/rtk);不能把其他同名二进制当作通过。
|
|
26
|
+
- [rtk-ai/rtk](https://github.com/rtk-ai/rtk) 已分别完成 Codex CLI 与 Claude Code 的初始化。
|
|
25
27
|
- 常用 PATH 配置在当前 shell 中可生效;若需要持久化,说明写入了哪个 shell 配置文件。
|
|
26
28
|
|
|
27
29
|
## 执行原则
|
|
@@ -38,7 +40,9 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
|
|
|
38
40
|
1. 收集上下文:
|
|
39
41
|
- 操作系统与架构
|
|
40
42
|
- 当前 shell 与 PATH
|
|
41
|
-
- `python3`、`python`、`node`、`npm`、`pnpm`、`dx`、`agent-browser`、`rg` 的存在性与版本
|
|
43
|
+
- `python3`、`python`、`node`、`npm`、`pnpm`、`dx`、`agent-browser`、`rg`、`gh`、`rtk` 的存在性与版本
|
|
44
|
+
- `gh` 的认证状态(例如 `gh auth status`)
|
|
45
|
+
- `rtk` 是否为 [rtk-ai/rtk](https://github.com/rtk-ai/rtk),以及它对 Codex CLI 与 Claude Code 的初始化状态
|
|
42
46
|
2. 对照目标状态判断缺口。
|
|
43
47
|
3. 制定最小修复动作并执行。
|
|
44
48
|
4. 每次修复后重新验证相关项。
|
|
@@ -52,6 +56,11 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
|
|
|
52
56
|
- 每个目标命令是否可被当前 shell 找到。
|
|
53
57
|
- 每个目标命令的版本或基本健康输出。
|
|
54
58
|
- `dx` 的初始化结果或当前初始化状态。
|
|
59
|
+
- `gh --version` 能正常运行;若需要 GitHub 操作,`gh auth status` 应显示可用账号,否则报告未认证原因。
|
|
60
|
+
- `rtk` 必须确认来源为 [rtk-ai/rtk](https://github.com/rtk-ai/rtk)。至少用 `rtk --version`、`rtk --help`、安装来源或命令特征交叉验证;若发现同名非 rtk-ai/rtk 工具,判定为未通过。
|
|
61
|
+
- [rtk-ai/rtk](https://github.com/rtk-ai/rtk) 的 `rtk gain` 能正常运行。
|
|
62
|
+
- [rtk-ai/rtk](https://github.com/rtk-ai/rtk) 的 Codex CLI 初始化必须有明确证据。优先使用 `rtk init --show --codex`,确认全局或本地 Codex 配置已包含 `RTK.md` 与 `AGENTS.md` 引用;若当前 `rtk` 版本命令不同,使用等价的只读检查并在报告中说明。
|
|
63
|
+
- [rtk-ai/rtk](https://github.com/rtk-ai/rtk) 的 Claude Code 初始化必须有明确证据。优先使用 `rtk init --show --agent claude`,确认 Claude Code hook、`RTK.md`、`CLAUDE.md` 引用与 `settings.json` hook 配置处于健康状态;若当前 `rtk` 版本命令不同,使用等价的只读检查并在报告中说明。
|
|
55
64
|
- `agent-browser` 是否能找到并使用已安装的浏览器依赖。
|
|
56
65
|
- 对未通过项给出失败原因、已尝试动作和下一步建议。
|
|
57
66
|
|
|
@@ -61,6 +70,7 @@ description: 仅在用户显式调用 $doctor 或明确要求使用 doctor 技
|
|
|
61
70
|
|
|
62
71
|
- 环境摘要:系统、shell、关键 PATH 变更。
|
|
63
72
|
- 检查结果表:检查项、状态、版本/证据、说明。
|
|
73
|
+
- RTK 初始化:分别列出 Codex CLI 与 Claude Code 的检查命令、通过/失败状态、关键证据文件或 hook。
|
|
64
74
|
- 已执行修复:实际执行过的安装、链接、初始化或配置变更。
|
|
65
75
|
- 未完成项:若存在,说明阻塞原因和用户需要做什么。
|
|
66
76
|
- 结论:通过 / 部分通过 / 未通过。
|