@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
@@ -0,0 +1,537 @@
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
+ NEST_EXCEPTION_NAMES = (
13
+ "BadRequestException",
14
+ "UnauthorizedException",
15
+ "ForbiddenException",
16
+ "NotFoundException",
17
+ "HttpException",
18
+ "InternalServerErrorException",
19
+ )
20
+
21
+ BUSINESS_HINTS = {
22
+ "balance": "InsufficientBalanceException",
23
+ "wallet": "WalletException",
24
+ "permission": "PermissionDeniedException",
25
+ "forbidden": "PermissionDeniedException",
26
+ "unauthorized": "UnauthorizedOperationException",
27
+ "not found": "ResourceNotFoundException",
28
+ "duplicate": "DuplicateResourceException",
29
+ "conflict": "ConflictException",
30
+ "quota": "QuotaExceededException",
31
+ "limit": "LimitExceededException",
32
+ "expired": "ExpiredException",
33
+ "invalid": "InvalidOperationException",
34
+ }
35
+
36
+ CHINESE_PATTERN = re.compile(r"[\u4e00-\u9fff]")
37
+
38
+
39
+ @dataclass
40
+ class Finding:
41
+ kind: str
42
+ path: str
43
+ line: int
44
+ symbol: str
45
+ message: str
46
+ suggestion: str
47
+ suggested_exception: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class FoundationStatus:
52
+ has_domain_exception: bool
53
+ has_error_code: bool
54
+ has_exception_filters: bool
55
+ has_module_exceptions_dir: bool
56
+ has_structured_request_id_signal: bool
57
+
58
+
59
+ def parse_args() -> argparse.Namespace:
60
+ parser = argparse.ArgumentParser(description="审计后端错误处理是否绕过 DomainException / ErrorCode 体系")
61
+ parser.add_argument("--workspace", required=True, help="仓库根目录")
62
+ parser.add_argument(
63
+ "--include-glob",
64
+ action="append",
65
+ default=None,
66
+ help="附加扫描 glob,可重复传入",
67
+ )
68
+ parser.add_argument(
69
+ "--scope",
70
+ choices=["all", "src", "e2e"],
71
+ default="all",
72
+ help="预设扫描范围:all=src+e2e,src=仅生产代码,e2e=仅测试代码",
73
+ )
74
+ parser.add_argument("--output-json", help="输出 JSON 文件路径")
75
+ parser.add_argument(
76
+ "--format",
77
+ choices=["text", "json"],
78
+ default="text",
79
+ help="输出格式,默认 text",
80
+ )
81
+ return parser.parse_args()
82
+
83
+
84
+ def iter_files(workspace: Path, globs: Iterable[str]) -> list[Path]:
85
+ seen: set[Path] = set()
86
+ files: list[Path] = []
87
+ for pattern in globs:
88
+ for path in workspace.glob(pattern):
89
+ if not path.is_file():
90
+ continue
91
+ if path in seen:
92
+ continue
93
+ seen.add(path)
94
+ files.append(path)
95
+ return sorted(files)
96
+
97
+
98
+ def should_skip(path: Path) -> bool:
99
+ 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/"):
109
+ return True
110
+ return False
111
+
112
+
113
+ def line_no(content: str, index: int) -> int:
114
+ return content.count("\n", 0, index) + 1
115
+
116
+
117
+ def default_globs_for_scope(scope: str) -> list[str]:
118
+ 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"]
123
+
124
+
125
+ def safe_read_text(path: Path) -> str:
126
+ try:
127
+ return path.read_text(encoding="utf-8")
128
+ except UnicodeDecodeError:
129
+ return ""
130
+
131
+
132
+ def mask_comments_and_strings(content: str) -> str:
133
+ chars = list(content)
134
+ i = 0
135
+ length = len(chars)
136
+ state = "normal"
137
+ template_depth = 0
138
+
139
+ while i < length:
140
+ ch = chars[i]
141
+ nxt = chars[i + 1] if i + 1 < length else ""
142
+
143
+ if state == "normal":
144
+ if ch == "/" and nxt == "/":
145
+ chars[i] = " "
146
+ chars[i + 1] = " "
147
+ i += 2
148
+ state = "line_comment"
149
+ continue
150
+ if ch == "/" and nxt == "*":
151
+ chars[i] = " "
152
+ chars[i + 1] = " "
153
+ i += 2
154
+ state = "block_comment"
155
+ continue
156
+ if ch == "'":
157
+ chars[i] = " "
158
+ i += 1
159
+ state = "single_quote"
160
+ continue
161
+ if ch == '"':
162
+ chars[i] = " "
163
+ i += 1
164
+ state = "double_quote"
165
+ continue
166
+ if ch == "`":
167
+ chars[i] = " "
168
+ i += 1
169
+ state = "template"
170
+ template_depth = 0
171
+ continue
172
+ i += 1
173
+ continue
174
+
175
+ if state == "line_comment":
176
+ if ch != "\n":
177
+ chars[i] = " "
178
+ else:
179
+ state = "normal"
180
+ i += 1
181
+ continue
182
+
183
+ if state == "block_comment":
184
+ if ch == "*" and nxt == "/":
185
+ chars[i] = " "
186
+ chars[i + 1] = " "
187
+ i += 2
188
+ state = "normal"
189
+ continue
190
+ if ch != "\n":
191
+ chars[i] = " "
192
+ i += 1
193
+ continue
194
+
195
+ if state == "single_quote":
196
+ if ch == "\\" and i + 1 < length:
197
+ chars[i] = " "
198
+ if chars[i + 1] != "\n":
199
+ chars[i + 1] = " "
200
+ i += 2
201
+ continue
202
+ if ch == "'":
203
+ chars[i] = " "
204
+ i += 1
205
+ state = "normal"
206
+ continue
207
+ if ch != "\n":
208
+ chars[i] = " "
209
+ i += 1
210
+ continue
211
+
212
+ if state == "double_quote":
213
+ if ch == "\\" and i + 1 < length:
214
+ chars[i] = " "
215
+ if chars[i + 1] != "\n":
216
+ chars[i + 1] = " "
217
+ i += 2
218
+ continue
219
+ if ch == '"':
220
+ chars[i] = " "
221
+ i += 1
222
+ state = "normal"
223
+ continue
224
+ if ch != "\n":
225
+ chars[i] = " "
226
+ i += 1
227
+ continue
228
+
229
+ if state == "template":
230
+ if ch == "\\" and i + 1 < length:
231
+ chars[i] = " "
232
+ if chars[i + 1] != "\n":
233
+ chars[i + 1] = " "
234
+ i += 2
235
+ continue
236
+ if ch == "`" and template_depth == 0:
237
+ chars[i] = " "
238
+ i += 1
239
+ state = "normal"
240
+ continue
241
+ if ch == "$" and nxt == "{":
242
+ chars[i] = " "
243
+ chars[i + 1] = "{"
244
+ template_depth += 1
245
+ i += 2
246
+ continue
247
+ if ch == "}" and template_depth > 0:
248
+ template_depth -= 1
249
+ i += 1
250
+ continue
251
+ if ch != "\n":
252
+ chars[i] = " "
253
+ i += 1
254
+ continue
255
+
256
+ return "".join(chars)
257
+
258
+
259
+ def find_balanced_call_end(masked_content: str, open_paren_index: int) -> int | None:
260
+ depth = 0
261
+ for index in range(open_paren_index, len(masked_content)):
262
+ ch = masked_content[index]
263
+ if ch == "(":
264
+ depth += 1
265
+ elif ch == ")":
266
+ depth -= 1
267
+ if depth == 0:
268
+ return index
269
+ return None
270
+
271
+
272
+ def find_constructor_calls(content: str, constructor_name: str) -> list[tuple[int, int]]:
273
+ masked_content = mask_comments_and_strings(content)
274
+ pattern = re.compile(rf"new\s+{re.escape(constructor_name)}\s*\(")
275
+ matches: list[tuple[int, int]] = []
276
+ for match in pattern.finditer(masked_content):
277
+ open_paren_index = masked_content.find("(", match.start())
278
+ if open_paren_index == -1:
279
+ continue
280
+ end_index = find_balanced_call_end(masked_content, open_paren_index)
281
+ if end_index is None:
282
+ continue
283
+ matches.append((match.start(), end_index + 1))
284
+ return matches
285
+
286
+
287
+ def collect_foundation_status(workspace: Path) -> FoundationStatus:
288
+ backend_root = workspace / "apps/backend/src"
289
+ all_ts_files = sorted(backend_root.glob("**/*.ts"))
290
+ has_domain_exception = False
291
+ has_error_code = False
292
+ has_exception_filters = False
293
+ has_module_exceptions_dir = False
294
+ has_structured_request_id_signal = False
295
+
296
+ for path in all_ts_files:
297
+ path_text = path.as_posix()
298
+ content = safe_read_text(path)
299
+ if not content:
300
+ 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
311
+
312
+ return FoundationStatus(
313
+ has_domain_exception=has_domain_exception,
314
+ has_error_code=has_error_code,
315
+ has_exception_filters=has_exception_filters,
316
+ has_module_exceptions_dir=has_module_exceptions_dir,
317
+ has_structured_request_id_signal=has_structured_request_id_signal,
318
+ )
319
+
320
+
321
+ def infer_exception_name(snippet: str) -> str | None:
322
+ lowered = snippet.lower()
323
+ for keyword, name in BUSINESS_HINTS.items():
324
+ if keyword in lowered:
325
+ return name
326
+ if CHINESE_PATTERN.search(snippet):
327
+ if "余额" in snippet:
328
+ return "InsufficientBalanceException"
329
+ if "权限" in snippet:
330
+ return "PermissionDeniedException"
331
+ if "不存在" in snippet or "未找到" in snippet:
332
+ return "ResourceNotFoundException"
333
+ if "过期" in snippet:
334
+ return "ExpiredException"
335
+ if "重复" in snippet:
336
+ return "DuplicateResourceException"
337
+ return None
338
+
339
+
340
+ def build_suggestion(kind: str, snippet: str, inferred_exception: str | None) -> str:
341
+ if kind == "nest-standard-exception":
342
+ if inferred_exception:
343
+ return f"优先检查模块 exceptions/ 是否已有同义异常;若无,建议改为新增或复用 {inferred_exception}"
344
+ return "优先复用模块 exceptions/ 中已有领域异常;若无,再新增专用异常类,不要继续直接抛 Nest 标准异常"
345
+ if kind == "raw-error":
346
+ if inferred_exception:
347
+ return f"建议改为抛出领域异常,如 {inferred_exception};若暂时无法抽类,至少改为带 code 的 DomainException"
348
+ return "建议改为模块领域异常;若暂时无法抽类,至少改为带 code 与 args 的 DomainException"
349
+ if kind == "domain-exception-missing-code":
350
+ return "补齐 ErrorCode,并把必要上下文放入 args;若该语义重复出现,建议抽专用异常类"
351
+ if kind == "domain-exception-chinese-message":
352
+ return "避免直接返回中文 message,优先改为稳定 message key 或内部标识,并通过 ErrorCode + args 透出语义"
353
+ return f"建议复核该写法:{snippet.strip()}"
354
+
355
+
356
+ def scan_nest_standard_exceptions(path: Path, content: str) -> list[Finding]:
357
+ findings: list[Finding] = []
358
+ masked_content = mask_comments_and_strings(content)
359
+ pattern = re.compile(
360
+ rf"new\s+(?P<name>{'|'.join(NEST_EXCEPTION_NAMES)})\s*\(",
361
+ re.MULTILINE,
362
+ )
363
+ for match in pattern.finditer(masked_content):
364
+ snippet = content[match.start() : min(len(content), match.start() + 220)]
365
+ inferred_exception = infer_exception_name(snippet)
366
+ findings.append(
367
+ Finding(
368
+ kind="nest-standard-exception",
369
+ path=str(path),
370
+ line=line_no(content, match.start()),
371
+ symbol=match.group("name"),
372
+ message="检测到业务代码直接实例化 Nest 标准异常,疑似绕过 DomainException / ErrorCode 体系",
373
+ suggestion=build_suggestion("nest-standard-exception", snippet, inferred_exception),
374
+ suggested_exception=inferred_exception,
375
+ )
376
+ )
377
+ return findings
378
+
379
+
380
+ def scan_raw_error(path: Path, content: str) -> list[Finding]:
381
+ findings: list[Finding] = []
382
+ masked_content = mask_comments_and_strings(content)
383
+ pattern = re.compile(r"new\s+Error\s*\(", re.MULTILINE)
384
+ for match in pattern.finditer(masked_content):
385
+ snippet = content[max(0, match.start() - 40) : min(len(content), match.start() + 220)]
386
+ inferred_exception = infer_exception_name(snippet)
387
+ findings.append(
388
+ Finding(
389
+ kind="raw-error",
390
+ path=str(path),
391
+ line=line_no(content, match.start()),
392
+ symbol="Error",
393
+ message="检测到直接创建 Error,缺少稳定 ErrorCode 和结构化上下文",
394
+ suggestion=build_suggestion("raw-error", snippet, inferred_exception),
395
+ suggested_exception=inferred_exception,
396
+ )
397
+ )
398
+ return findings
399
+
400
+
401
+ def scan_domain_exception_missing_code(path: Path, content: str) -> list[Finding]:
402
+ findings: list[Finding] = []
403
+ for start, end in find_constructor_calls(content, "DomainException"):
404
+ snippet = content[start:end]
405
+ body = snippet[snippet.find("(") + 1 : snippet.rfind(")")]
406
+ if "code:" in body or "code :" in body:
407
+ continue
408
+ findings.append(
409
+ Finding(
410
+ kind="domain-exception-missing-code",
411
+ path=str(path),
412
+ line=line_no(content, start),
413
+ symbol="DomainException",
414
+ message="检测到 DomainException 未显式提供 ErrorCode",
415
+ suggestion=build_suggestion("domain-exception-missing-code", snippet[:240], None),
416
+ suggested_exception=None,
417
+ )
418
+ )
419
+ return findings
420
+
421
+
422
+ def scan_domain_exception_chinese_message(path: Path, content: str) -> list[Finding]:
423
+ findings: list[Finding] = []
424
+ for start, end in find_constructor_calls(content, "DomainException"):
425
+ snippet = content[start:end]
426
+ body = snippet[snippet.find("(") + 1 : snippet.rfind(")")]
427
+ if not CHINESE_PATTERN.search(body):
428
+ continue
429
+ findings.append(
430
+ Finding(
431
+ kind="domain-exception-chinese-message",
432
+ path=str(path),
433
+ line=line_no(content, start),
434
+ symbol="DomainException",
435
+ message="检测到 DomainException 直接携带中文 message,后端可能在决定展示文案",
436
+ suggestion=build_suggestion("domain-exception-chinese-message", snippet[:240], None),
437
+ suggested_exception=None,
438
+ )
439
+ )
440
+ return findings
441
+
442
+
443
+ def scan_file(path: Path) -> list[Finding]:
444
+ content = safe_read_text(path)
445
+ if not content:
446
+ return []
447
+ findings: list[Finding] = []
448
+ findings.extend(scan_nest_standard_exceptions(path, content))
449
+ findings.extend(scan_raw_error(path, content))
450
+ findings.extend(scan_domain_exception_missing_code(path, content))
451
+ findings.extend(scan_domain_exception_chinese_message(path, content))
452
+ return findings
453
+
454
+
455
+ def build_summary(foundations: FoundationStatus) -> list[str]:
456
+ summary: list[str] = []
457
+ if not foundations.has_domain_exception:
458
+ summary.append("缺少 DomainException")
459
+ if not foundations.has_error_code:
460
+ summary.append("缺少 ErrorCode")
461
+ if not foundations.has_exception_filters:
462
+ summary.append("缺少可识别的异常过滤器")
463
+ if not foundations.has_module_exceptions_dir:
464
+ summary.append("缺少模块 exceptions/ 目录信号")
465
+ if not foundations.has_structured_request_id_signal:
466
+ summary.append("缺少 requestId + 结构化错误字段信号")
467
+ return summary
468
+
469
+
470
+ def print_report(foundations: FoundationStatus, findings: list[Finding]) -> None:
471
+ foundation_gaps = build_summary(foundations)
472
+ print("基础设施状态:")
473
+ print(f"- DomainException: {'是' if foundations.has_domain_exception else '否'}")
474
+ print(f"- ErrorCode: {'是' if foundations.has_error_code else '否'}")
475
+ print(f"- 异常过滤器: {'是' if foundations.has_exception_filters else '否'}")
476
+ print(f"- 模块 exceptions 目录: {'是' if foundations.has_module_exceptions_dir else '否'}")
477
+ print(f"- requestId 结构化信号: {'是' if foundations.has_structured_request_id_signal else '否'}")
478
+
479
+ if foundation_gaps:
480
+ print("\n基础设施缺口:")
481
+ for item in foundation_gaps:
482
+ print(f"- {item}")
483
+ print("- 结论:建议先补齐基础设施,再决定是否批量修复业务代码")
484
+ else:
485
+ print("\n基础设施结论:已检测到基础设施信号,可继续推进违规抛错治理")
486
+
487
+ if not findings:
488
+ print("\n未发现命中项。")
489
+ return
490
+
491
+ grouped: dict[str, list[Finding]] = {}
492
+ for finding in findings:
493
+ grouped.setdefault(finding.kind, []).append(finding)
494
+
495
+ print(f"\n共发现 {len(findings)} 个问题:")
496
+ for kind in sorted(grouped):
497
+ print(f"\n[{kind}] {len(grouped[kind])} 个")
498
+ for finding in grouped[kind]:
499
+ suggestion = finding.suggestion
500
+ if finding.suggested_exception:
501
+ suggestion = f"{suggestion}(推断异常:{finding.suggested_exception})"
502
+ print(f"- {finding.path}:{finding.line} {finding.symbol} -> {finding.message}")
503
+ print(f" 建议:{suggestion}")
504
+
505
+
506
+ def main() -> int:
507
+ args = parse_args()
508
+ 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)
512
+
513
+ findings: list[Finding] = []
514
+ for path in files:
515
+ findings.extend(scan_file(path))
516
+
517
+ payload = {
518
+ "foundation": asdict(foundations),
519
+ "foundation_gaps": build_summary(foundations),
520
+ "findings": [asdict(finding) for finding in findings],
521
+ }
522
+
523
+ if args.format == "json":
524
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
525
+ else:
526
+ print_report(foundations, findings)
527
+
528
+ if args.output_json:
529
+ output = Path(args.output_json)
530
+ output.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
531
+ if args.format != "json":
532
+ print(f"\nJSON 已输出到 {output}")
533
+ return 0
534
+
535
+
536
+ if __name__ == "__main__":
537
+ raise SystemExit(main())
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: pagination-dto-audit-fixer
3
+ description: Use when backend 或 NestJS 接口涉及分页列表,需要检查或修复分页 Request DTO 未继承 BasePaginationRequestDto、分页 Response DTO 未继承 BasePaginationResponseDto、Controller/Service 手工拼装分页返回结构,或需要输出不符合统一分页规范的文件清单并按标准改造。
4
+ ---
5
+
6
+ # 分页 DTO 规范检查与修复
7
+
8
+ ## 概览
9
+
10
+ 对后端分页接口执行统一规范审计,先稳定识别非标准分页 DTO 和手工分页返回,再按统一基类完成改造。优先使用随技能提供的扫描脚本作为问题清单真值源,再根据结果实施代码修复。
11
+
12
+ ## 快速开始
13
+
14
+ 1. 先运行扫描脚本:
15
+
16
+ ```bash
17
+ CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
18
+ python "$CODEX_HOME/skills/pagination-dto-audit-fixer/scripts/pagination_dto_audit.py" \
19
+ --workspace /Users/a1/work/ai-monorepo
20
+ ```
21
+
22
+ 2. 需要结构化结果时输出 JSON:
23
+
24
+ ```bash
25
+ python "$CODEX_HOME/skills/pagination-dto-audit-fixer/scripts/pagination_dto_audit.py" \
26
+ --workspace /Users/a1/work/ai-monorepo \
27
+ --output-json /tmp/pagination-dto-audit.json
28
+ ```
29
+
30
+ 3. 根据扫描结果逐项修复,再重新运行扫描确认问题是否消失。
31
+
32
+ ## 执行流程
33
+
34
+ 1. 扫描 `apps/backend/src/**/*.ts`,必要时用 `--include-glob` 扩大范围。
35
+ 2. 优先识别三类问题:
36
+ - 请求 DTO 自己声明 `page/limit/pageSize/currentPage`,但未继承 `BasePaginationRequestDto`
37
+ - 响应 DTO 命中 `items/data/total/page/limit/pageSize/currentPage` 等分页字段,但未继承 `BasePaginationResponseDto`
38
+ - Controller / Service / UseCase 直接 `return { ... }` 手工拼装分页结构
39
+ 3. 输出问题清单后,再阅读 [references/pagination-standard.md](./references/pagination-standard.md) 对照修复。
40
+ 4. 修复时保持接口兼容性;若历史字段名不是标准字段,显式说明保留策略或转换策略。
41
+ 5. 修复完成后重新扫描,并按项目常规命令补跑测试或最小验证。
42
+
43
+ ## 修复准则
44
+
45
+ - 请求 DTO:改为继承 `BasePaginationRequestDto`,仅保留额外查询参数。
46
+ - 响应 DTO:优先改为 `extends BasePaginationResponseDto<ItemResponseDto>`,或改用工厂 `createPaginationResponseDto(ItemResponseDto)`。
47
+ - 返回构造:优先返回统一分页 DTO 实例,不继续在 Controller 中拼 `{ items, total, page, limit }`。
48
+ - OpenAPI:补齐 `@ApiProperty`,确保 `items` 的元素类型明确。
49
+ - 兼容处理:若线上消费者依赖 `data/currentPage/pageSize`,在过渡期显式映射,不要静默删字段。
50
+
51
+ ## 判断原则
52
+
53
+ - 同时出现 `total` 且出现 `items` 或 `data`,再叠加 `page/limit/pageSize/currentPage` 中任一字段,可视为强分页信号。
54
+ - 若类名或返回变量名包含 `Pagination`、`Paginated`、`ListResponse`、`PageResult`,优先人工复核。
55
+ - 若只是普通列表返回且没有总数或页码字段,不归入本技能。
56
+
57
+ ## 输出要求
58
+
59
+ 执行这个技能时,最终输出至少包含:
60
+
61
+ 1. 问题文件清单
62
+ 2. 每个问题的类型与定位
63
+ 3. 建议修复方式
64
+ 4. 已应用的修改与剩余风险
65
+
66
+ ## 资源
67
+
68
+ - 扫描脚本:`scripts/pagination_dto_audit.py`
69
+ - 参考规范:`references/pagination-standard.md`
@@ -0,0 +1,7 @@
1
+ interface:
2
+ display_name: "分页 DTO 检查修复"
3
+ short_description: "检查并修复后端分页 DTO 是否符合统一分页基类规范"
4
+ default_prompt: "使用 $pagination-dto-audit-fixer 检查 backend 中分页 DTO 和分页返回结构是否符合统一规范,并给出可执行修复建议。"
5
+
6
+ policy:
7
+ allow_implicit_invocation: true
@@ -0,0 +1,67 @@
1
+ # 分页标准参考
2
+
3
+ ## 请求 DTO 标准
4
+
5
+ ```typescript
6
+ import { BasePaginationRequestDto } from '@/common/dto/base.pagination.request.dto'
7
+
8
+ export class ListItemsDto extends BasePaginationRequestDto {
9
+ // 额外查询参数
10
+ }
11
+ ```
12
+
13
+ ## 响应 DTO 标准
14
+
15
+ ```typescript
16
+ import { BasePaginationResponseDto } from '@/common/dto/base.pagination.response.dto'
17
+ import { ItemResponseDto } from './item.response.dto'
18
+
19
+ export class ItemPaginationResponseDto extends BasePaginationResponseDto<ItemResponseDto> {
20
+ @ApiProperty({
21
+ description: '数据列表',
22
+ type: ItemResponseDto,
23
+ isArray: true,
24
+ })
25
+ items: ItemResponseDto[]
26
+ }
27
+ ```
28
+
29
+ ## 工厂写法
30
+
31
+ ```typescript
32
+ export const ItemPaginationResponseDto =
33
+ BasePaginationResponseDto.createPaginationResponseDto(ItemResponseDto)
34
+ ```
35
+
36
+ ## 典型反例
37
+
38
+ ```typescript
39
+ export class CustomListResponseDto {
40
+ data: Item[]
41
+ total: number
42
+ pageSize: number
43
+ currentPage: number
44
+ }
45
+ ```
46
+
47
+ ```typescript
48
+ return {
49
+ items: results,
50
+ total: count,
51
+ page: query.page,
52
+ limit: query.limit,
53
+ }
54
+ ```
55
+
56
+ ## 修复建议
57
+
58
+ - 自定义分页 DTO:改为继承 `BasePaginationResponseDto`
59
+ - 手工返回对象:替换为统一分页 DTO 实例或工厂产物
60
+ - 请求参数 DTO:改为继承 `BasePaginationRequestDto`
61
+ - OpenAPI 注解:补齐 `items` 的具体类型注解
62
+
63
+ ## 兼容策略
64
+
65
+ - 历史字段为 `data` 时,优先评估是否允许切换为 `items`
66
+ - 若短期不能切换,保留兼容字段并在代码中显式注释过渡原因
67
+ - 不要一边保留旧字段,一边继续新增新的手工分页结构