From dbaeba88f47d1bb998c222e43df6949d6880c365 Mon Sep 17 00:00:00 2001 From: KeWang <1002245584@qq.com> Date: Sat, 25 Jul 2026 21:07:10 +0800 Subject: [PATCH 1/3] examples: add skill-driven code review agent Add a code review example that loads the code-review Skill, stages review inputs in supported workspace runtimes, applies execution governance, and persists structured reports and audit metadata. The example supports dry-run, FakeModel, and OpenAI-compatible model paths, with Docker/Cube runtime configuration, fixtures, and Chinese documentation. RELEASE NOTES: Added a skill-driven code review Agent example with sandboxed review execution and structured reports. --- examples/skills_code_review_agent/.env | 20 + examples/skills_code_review_agent/Dockerfile | 6 + examples/skills_code_review_agent/README.md | 264 ++++ .../skills_code_review_agent/agent/agent.py | 97 ++ .../skills_code_review_agent/agent/filter.py | 120 ++ .../skills_code_review_agent/agent/models.py | 23 + .../skills_code_review_agent/agent/parser.py | 65 + .../skills_code_review_agent/agent/policy.py | 33 + .../skills_code_review_agent/agent/prompts.py | 20 + .../agent/redactor.py | 22 + .../skills_code_review_agent/agent/rules.py | 53 + .../skills_code_review_agent/agent/storage.py | 96 ++ .../skills_code_review_agent/agent/tools.py | 316 +++++ .../skills_code_review_agent/run_review.py | 107 ++ .../skills/code-review/LICENSE | 21 + .../skills/code-review/SKILL.md | 245 ++++ .../code-review/assets/pr-review-template.md | 137 +++ .../code-review/assets/review-checklist.md | 123 ++ .../skills/code-review/reference/angular.md | 768 ++++++++++++ .../reference/architecture-review-guide.md | 472 ++++++++ .../skills/code-review/reference/c.md | 890 ++++++++++++++ .../reference/code-quality-universal.md | 488 ++++++++ .../reference/code-review-best-practices.md | 136 +++ .../reference/common-bugs-checklist.md | 286 +++++ .../skills/code-review/reference/cpp.md | 893 ++++++++++++++ .../async-concurrency-patterns.md | 515 ++++++++ .../error-handling-principles.md | 492 ++++++++ .../cross-cutting/n-plus-one-queries.md | 309 +++++ .../cross-cutting/sql-injection-prevention.md | 308 +++++ .../reference/cross-cutting/xss-prevention.md | 264 ++++ .../skills/code-review/reference/csharp.md | 525 ++++++++ .../code-review/reference/css-less-sass.md | 661 ++++++++++ .../skills/code-review/reference/django.md | 985 +++++++++++++++ .../skills/code-review/reference/fastapi.md | 580 +++++++++ .../skills/code-review/reference/go.md | 993 +++++++++++++++ .../skills/code-review/reference/java.md | 409 +++++++ .../skills/code-review/reference/java8.md | 586 +++++++++ .../skills/code-review/reference/kotlin.md | 1018 ++++++++++++++++ .../skills/code-review/reference/nestjs.md | 593 +++++++++ .../reference/performance-review-guide.md | 816 +++++++++++++ .../skills/code-review/reference/php.md | 684 +++++++++++ .../skills/code-review/reference/python.md | 1073 +++++++++++++++++ .../skills/code-review/reference/qt.md | 757 ++++++++++++ .../skills/code-review/reference/react.md | 871 +++++++++++++ .../skills/code-review/reference/ruby.md | 964 +++++++++++++++ .../skills/code-review/reference/rust.md | 846 +++++++++++++ .../reference/security-review-guide.md | 494 ++++++++ .../skills/code-review/reference/svelte.md | 1064 ++++++++++++++++ .../skills/code-review/reference/swift.md | 936 ++++++++++++++ .../code-review/reference/typescript.md | 1016 ++++++++++++++++ .../skills/code-review/reference/vue.md | 924 ++++++++++++++ .../skills/code-review/reference/zig.md | 440 +++++++ .../skills/code-review/scripts/pr-analyzer.py | 435 +++++++ .../code-review/scripts/test_pr_analyzer.py | 380 ++++++ .../tests/fixtures/01_clean/expected.json | 1 + .../tests/fixtures/01_clean/input.diff | 20 + .../fixtures/02_hardcoded_token/input.diff | 6 + .../tests/fixtures/03_async_leak/input.diff | 10 + .../04_db_transaction_leak/input.diff | 12 + .../tests/fixtures/05_missing_test/input.diff | 8 + .../fixtures/06_duplicate_finding/input.diff | 6 + .../fixtures/07_sandbox_failure/input.diff | 6 + .../fixtures/08_secret_redaction/input.diff | 7 + tests/examples/test_code_review_agent.py | 278 +++++ 64 files changed, 25993 insertions(+) create mode 100644 examples/skills_code_review_agent/.env create mode 100644 examples/skills_code_review_agent/Dockerfile create mode 100644 examples/skills_code_review_agent/README.md create mode 100644 examples/skills_code_review_agent/agent/agent.py create mode 100644 examples/skills_code_review_agent/agent/filter.py create mode 100644 examples/skills_code_review_agent/agent/models.py create mode 100644 examples/skills_code_review_agent/agent/parser.py create mode 100644 examples/skills_code_review_agent/agent/policy.py create mode 100644 examples/skills_code_review_agent/agent/prompts.py create mode 100644 examples/skills_code_review_agent/agent/redactor.py create mode 100644 examples/skills_code_review_agent/agent/rules.py create mode 100644 examples/skills_code_review_agent/agent/storage.py create mode 100644 examples/skills_code_review_agent/agent/tools.py create mode 100644 examples/skills_code_review_agent/run_review.py create mode 100644 examples/skills_code_review_agent/skills/code-review/LICENSE create mode 100644 examples/skills_code_review_agent/skills/code-review/SKILL.md create mode 100644 examples/skills_code_review_agent/skills/code-review/assets/pr-review-template.md create mode 100644 examples/skills_code_review_agent/skills/code-review/assets/review-checklist.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/angular.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/architecture-review-guide.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/c.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/code-quality-universal.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/code-review-best-practices.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/common-bugs-checklist.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/cpp.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/async-concurrency-patterns.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/error-handling-principles.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/n-plus-one-queries.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/sql-injection-prevention.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/xss-prevention.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/csharp.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/css-less-sass.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/django.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/fastapi.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/go.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/java.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/java8.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/kotlin.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/nestjs.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/performance-review-guide.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/php.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/python.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/qt.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/react.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/ruby.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/rust.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/security-review-guide.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/svelte.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/swift.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/typescript.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/vue.md create mode 100644 examples/skills_code_review_agent/skills/code-review/reference/zig.md create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/pr-analyzer.py create mode 100644 examples/skills_code_review_agent/skills/code-review/scripts/test_pr_analyzer.py create mode 100644 examples/skills_code_review_agent/tests/fixtures/01_clean/expected.json create mode 100644 examples/skills_code_review_agent/tests/fixtures/01_clean/input.diff create mode 100644 examples/skills_code_review_agent/tests/fixtures/02_hardcoded_token/input.diff create mode 100644 examples/skills_code_review_agent/tests/fixtures/03_async_leak/input.diff create mode 100644 examples/skills_code_review_agent/tests/fixtures/04_db_transaction_leak/input.diff create mode 100644 examples/skills_code_review_agent/tests/fixtures/05_missing_test/input.diff create mode 100644 examples/skills_code_review_agent/tests/fixtures/06_duplicate_finding/input.diff create mode 100644 examples/skills_code_review_agent/tests/fixtures/07_sandbox_failure/input.diff create mode 100644 examples/skills_code_review_agent/tests/fixtures/08_secret_redaction/input.diff create mode 100644 tests/examples/test_code_review_agent.py diff --git a/examples/skills_code_review_agent/.env b/examples/skills_code_review_agent/.env new file mode 100644 index 000000000..db5f680af --- /dev/null +++ b/examples/skills_code_review_agent/.env @@ -0,0 +1,20 @@ +# OpenAI-compatible model used by the code-review Agent. +# Fill OPENAI_API_KEY locally; never commit a real key. +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.deepseek.com +OPENAI_MODEL=deepseek-v4-flash + +# Docker builds examples/skills_code_review_agent/Dockerfile once (with cache) +# to provide the ruff and pytest commands used by the review Skill. +CODE_REVIEW_DOCKER_IMAGE=trpc-code-review:latest +CODE_REVIEW_DOCKER_BUILD_CONTEXT= + +# Audit/report size limits. Values are KiB except MODEL_RUN_MAX_COUNT. +CODE_REVIEW_TOOL_OUTPUT_MAX_KIB=8 +CODE_REVIEW_MODEL_AUDIT_MAX_KIB=16 +CODE_REVIEW_MODEL_RUN_MAX_COUNT=20 + +# Optional official Cube/E2B workspace runtime configuration. +CUBE_TEMPLATE_ID= +E2B_API_URL= +E2B_API_KEY= diff --git a/examples/skills_code_review_agent/Dockerfile b/examples/skills_code_review_agent/Dockerfile new file mode 100644 index 000000000..9d9653e8c --- /dev/null +++ b/examples/skills_code_review_agent/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.12-slim + +# Review commands are installed at image-build time. Runtime networking remains +# disabled by ContainerWorkspaceRuntime, so a Skill cannot install dependencies. +RUN pip install --no-cache-dir pytest ruff + diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md new file mode 100644 index 000000000..3fb5c191a --- /dev/null +++ b/examples/skills_code_review_agent/README.md @@ -0,0 +1,264 @@ +# 自动代码评审 Agent + +这是一个基于 tRPC-Agent 原生 `LlmAgent`、`SkillToolSet`、Workspace Runtime、Filter 和 SQLite 的代码评审示例。输入可以是 unified diff、Git 工作区或文件列表;输出为结构化 findings、JSON/Markdown 报告和按 task id 回读的审计记录。 + +## Agent 编排 + +真实模型路径采用“CLI 预处理 → Agent 决策 → 受控执行 → 结果融合”的编排: + +```text +CLI + ├─ 解析 --diff-file / --repo-path / --files + ├─ 脱敏 diff、hunk、候选变更行 + ├─ 生成 task_id 与 workspace 输入 + └─ Runner + InMemorySessionService + └─ LlmAgent + ├─ SkillToolSet + │ ├─ skill_load + │ ├─ skill_list_docs + │ └─ skill_select_docs + ├─ FunctionTool: run_selected_review_actions + │ └─ 原生 SkillRunTool → Workspace Runtime + └─ FunctionTool: save_review_report + ├─ 规则融合、校验、去重、降噪 + └─ JSON / Markdown / SQLite +``` + +1. CLI 在模型调用前解析并脱敏输入;模型不会获取宿主机绝对路径。 +2. `SkillToolSet(require_skill_loaded=True)` 要求先加载 Skill。模型可按语言选择参考文档,避免一次性加载全部资料。 +3. 模型不能直接调用 shell 或 `skill_run`,只能调用 `run_selected_review_actions(action_ids=[...])`。该 FunctionTool 将 action id 展开为确定性白名单命令,再委托仓库原生 `SkillRunTool`。 +4. 当前受控执行器实现的 action 是 `analyze_pr`,对应 `scripts/pr-analyzer.py`。`ruff`、`pytest` 仍受白名单和 Filter 治理,但尚未作为独立 action id 暴露给模型。 +5. `CodeReviewSkillRunFilter` 在进入 Runtime 前检查命令、网络/依赖安装、禁止路径、超时和 stdin;`deny` 与 `needs_human_review` 不执行。 +6. `save_review_report` 融合确定性规则和模型 findings,校验 file/line 与 evidence,完成去重、降噪和持久化。 + +| 模式 | 参数 | 模型 API | SDK Agent / Runtime | 用途 | +|---|---|---:|---:|---| +| dry-run | `--dry-run` | 不需要 | 不启动 | 解析、规则、脱敏、报告、SQLite 的快速验证。 | +| FakeModel | `--fake-model` | 不需要 | 启动 | 验证 `Runner → LlmAgent → FunctionTool → 报告`。 | +| 真实模型 | 默认 | 需要 | 启动 | 使用 OpenAI 兼容模型进行 Skill 驱动评审。 | + +`--dry-run` 和 `--fake-model` 互斥。 + +## 目录 + +```text +agent/ + agent.py # LlmAgent 工厂、OpenAI 兼容模型、FakeModel + tools.py # SkillToolSet、输入 staging、受控 action、报告生成 + filter.py # Agent/SkillRun 审计与 SDK Filter + policy.py # 命令、网络、路径、超时策略 + parser.py # unified diff、hunk 与候选行解析 + rules.py # 敏感信息、异步、数据库、测试缺失规则 + redactor.py # API key/token/password 脱敏 + storage.py # SQLite 审计存储与 task 回读 +skills/code-review/ + SKILL.md、reference/、scripts/pr-analyzer.py +run_review.py # CLI 入口 +``` + +## Skill 来源与许可证 + +`skills/code-review/` 基于上游 [awesome-skills/code-review-skill](https://github.com/awesome-skills/code-review-skill) 引入。本示例在其 `SKILL.md` 中补充了面向自动化 Agent 的 `review-agent-plan`,并在示例侧实现了输入 staging、受控 Skill 执行、Filter 治理、审计存储和结构化报告。 + +上游 Skill 的许可证保留在 `skills/code-review/LICENSE`;修改或精简上游文件时,必须继续保留该许可证和上游来源说明。 + +## 前置条件 + +在仓库根目录安装项目依赖并激活虚拟环境。Linux 通常使用 `python3`,Windows 通常使用 `python`。 + +### Docker 镜像 + +Docker Runtime 需要带 Python、Ruff、pytest 的镜像。首次建议手动构建一次,后续复用缓存。 + +PowerShell: + +```powershell +docker build -t trpc-code-review:latest -f .\examples\skills_code_review_agent\Dockerfile .\examples\skills_code_review_agent +``` + +Bash: + +```bash +docker build -t trpc-code-review:latest -f examples/skills_code_review_agent/Dockerfile examples/skills_code_review_agent +``` + +### `.env` + +编辑仓库提供的 `examples/skills_code_review_agent/.env` 模板,或通过 shell 环境变量覆盖配置。提交前必须确保其中没有真实密钥;若密钥曾进入 Git 历史,应立即在服务商侧轮换。 + +```dotenv +# 真实模型;--fake-model 和 --dry-run 不需要 API Key。 +OPENAI_API_KEY=your-api-key +OPENAI_BASE_URL=https://your-openai-compatible-endpoint/v1 +OPENAI_MODEL=your-model-name + +# 镜像已手动构建时保持 BUILD_CONTEXT 为空,避免每次运行重建。 +CODE_REVIEW_DOCKER_IMAGE=trpc-code-review:latest +CODE_REVIEW_DOCKER_BUILD_CONTEXT= + +# 单位 KiB;MODEL_RUN_MAX_COUNT 为条数。 +CODE_REVIEW_TOOL_OUTPUT_MAX_KIB=8 +CODE_REVIEW_MODEL_AUDIT_MAX_KIB=16 +CODE_REVIEW_MODEL_RUN_MAX_COUNT=20 + +# Cube/E2B 预留配置。 +CUBE_TEMPLATE_ID= +E2B_API_URL= +E2B_API_KEY= +``` + +## 运行命令 + +以下命令都从仓库根目录执行。默认输出为: + +```text +examples/skills_code_review_agent/review-output// +``` + +每次运行生成新 task id,例如 `cr-20260725-153640-hardcoded-token-13a11c26`。可用 `--output-dir` 覆盖输出目录。 + +### PowerShell:dry-run + +```powershell +# 评审 unified diff +python .\examples\skills_code_review_agent\run_review.py --diff-file .\examples\skills_code_review_agent\tests\fixtures\02_hardcoded_token\input.diff --dry-run + +# 评审当前 Git 工作区变更 +python .\examples\skills_code_review_agent\run_review.py --repo-path . --dry-run + +# 评审文件列表(会构造成 diff) +python .\examples\skills_code_review_agent\run_review.py --files .\src\example.py --dry-run + +# 自定义输出目录 +python .\examples\skills_code_review_agent\run_review.py --diff-file .\examples\skills_code_review_agent\tests\fixtures\02_hardcoded_token\input.diff --dry-run --output-dir .\review-output +``` + +### PowerShell:FakeModel + +```powershell +# Docker 中验证 SDK Agent 链路,无需 API Key +python .\examples\skills_code_review_agent\run_review.py --diff-file .\examples\skills_code_review_agent\tests\fixtures\02_hardcoded_token\input.diff --runtime docker --fake-model + +# Docker 中评审当前 Git 工作区 +python .\examples\skills_code_review_agent\run_review.py --repo-path . --runtime docker --fake-model + +# local Runtime 仅用于开发调试,不是生产默认方案 +python .\examples\skills_code_review_agent\run_review.py --diff-file .\examples\skills_code_review_agent\tests\fixtures\02_hardcoded_token\input.diff --runtime local --fake-model +``` + +### PowerShell:真实模型 + Docker + +```powershell +# 评审 patch +python .\examples\skills_code_review_agent\run_review.py --diff-file .\examples\skills_code_review_agent\tests\fixtures\02_hardcoded_token\input.diff --runtime docker + +# 评审当前 Git 工作区;会 stage diff 与变更源码 +python .\examples\skills_code_review_agent\run_review.py --repo-path . --runtime docker + +# 指定模型地址、模型名和输出目录 +python .\examples\skills_code_review_agent\run_review.py --diff-file .\examples\skills_code_review_agent\tests\fixtures\02_hardcoded_token\input.diff --runtime docker --model-base-url https://your-openai-compatible-endpoint/v1 --model your-model-name --output-dir .\review-output-real + +# 从指定环境变量读取 API Key +python .\examples\skills_code_review_agent\run_review.py --repo-path . --runtime docker --model-api-key-env DEEPSEEK_API_KEY +``` + +### Bash:dry-run、FakeModel、真实 Docker + +```bash +# dry-run +python3 examples/skills_code_review_agent/run_review.py \ + --diff-file examples/skills_code_review_agent/tests/fixtures/02_hardcoded_token/input.diff \ + --dry-run + +# FakeModel + Docker +python3 examples/skills_code_review_agent/run_review.py \ + --diff-file examples/skills_code_review_agent/tests/fixtures/02_hardcoded_token/input.diff \ + --runtime docker \ + --fake-model + +# 真实模型 + Docker,评审当前工作区 +python3 examples/skills_code_review_agent/run_review.py \ + --repo-path . \ + --runtime docker \ + --output-dir ./review-output-real +``` + +若 Bash 环境中的 `python` 已指向 Python 3,可替换 `python3`。PowerShell 多行命令使用反引号 `` ` ``,不能使用 Bash 的反斜杠 `\`。 + +### Cube / E2B + +```bash +# Cube + FakeModel +python3 examples/skills_code_review_agent/run_review.py --repo-path . --runtime cube --fake-model + +# Cube + 真实模型 +python3 examples/skills_code_review_agent/run_review.py --repo-path . --runtime cube +``` + +CLI 接受 `--runtime cube` 与 `--runtime e2b`。当前异步远程 Runtime 实际通过 SDK Cube workspace 创建;`e2b` 仅保留了 CLI 名称和环境变量,尚未实现独立 E2B client 初始化。因此当前可验证的隔离 Runtime 是 Docker 和 Cube。 + +## 输入、Sandbox 与 Filter + +- `--diff-file`:stage 脱敏后的 `input.diff`。没有完整源码时,源码级 Ruff 检查会转入 `needs_human_review`,不会执行。 +- `--repo-path`:运行 `git -C diff --no-ext-diff --`,stage 脱敏 diff 和 diff 中的变更源码;适合 Docker/Cube 实际检查。 +- `--files`:读取文件并构造 diff,适合简单开发验证。 +- 宿主输入通过 `WorkspaceInputSpec` stage 至 `$WORK_DIR/work/inputs/`;Skill stage 至 workspace 的 `skills/code-review/`。 +- Filter 仅允许 `python`、`ruff`、`pytest`,拒绝 `curl`、`wget`、`pip install`、`npm install`、敏感路径、超时任务和 stdin;原因写入报告与 SQLite。 +- Docker 是默认生产 Runtime;`local` 仅是开发 fallback。Docker 镜像和远程模板应在基础设施侧限制网络与权限。 + +## 输出、审计与安全 + +每个 task 目录包含: + +```text +review_report.json # 机器可读结果 +review_report.md # 人工可读摘要 +``` + +输出根目录还包含 `reviews.sqlite`。`ReviewStorage.get_task(task_id)` 可读取 task、findings、Filter 决策、skill runs、model runs、监控指标和最终报告。 + +finding 字段为: + +```text +severity, category, file, line, title, evidence, +recommendation, confidence, source +``` + +报告还含 `needs_human_review`、`skill_runs`、`model_runs`、`filter_decisions` 和以下指标: + +```text +total_duration_seconds +sandbox_duration_seconds +tool_call_count +finding_count +needs_human_review_count +sandbox_run_count +blocked_count +severity_distribution +exception_distribution +skill_run_status_distribution +``` + +确定性规则覆盖敏感信息、异步资源、数据库连接/事务和测试缺失。模型 finding 必须满足:file/line 位于 diff、evidence 可追溯、同一 `category + file + line` 只保留一条;`confidence < 0.7` 转入 `needs_human_review`。 + +所有 diff、日志、finding、模型审计内容先脱敏。stdout/stderr 按 `CODE_REVIEW_TOOL_OUTPUT_MAX_KIB` 截断;模型审计按 `CODE_REVIEW_MODEL_AUDIT_MAX_KIB` 和 `CODE_REVIEW_MODEL_RUN_MAX_COUNT` 限制。报告和 SQLite 不应保存明文 API Key、token 或 password。 + +## 验证 + +PowerShell: + +```powershell +python -m pytest tests\examples\test_code_review_agent.py -q +python -m compileall -q examples\skills_code_review_agent +git diff --check +``` + +Bash: + +```bash +python3 -m pytest tests/examples/test_code_review_agent.py -q +python3 -m compileall -q examples/skills_code_review_agent +git diff --check +``` + +Windows 部分环境直接运行完整 SDK 路径可能受 `python-magic/libmagic` 原生 DLL 影响;此时优先使用 `--dry-run`,真实 Docker/FakeModel 验证建议在 Linux、WSL 或已配置 Docker 的环境运行。 diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py new file mode 100644 index 000000000..d79dfcf51 --- /dev/null +++ b/examples/skills_code_review_agent/agent/agent.py @@ -0,0 +1,97 @@ +"""Native LlmAgent factory with Windows-safe, delayed SDK imports.""" +from __future__ import annotations + +import json +from typing import Any + +from .prompts import INSTRUCTION + +class OpenAIReviewModel: + """OpenAI-compatible adapter backed by the repository's SDK model.""" + + def __init__(self, api_key: str, base_url: str, model_name: str): + self.api_key, self.base_url, self.model_name = api_key, base_url, model_name + + def create(self) -> Any: + from trpc_agent_sdk.models import OpenAIModel + return OpenAIModel(model_name=self.model_name, api_key=self.api_key, base_url=self.base_url) + + +def create_fake_model() -> Any: + """Create a deterministic SDK ``LLMModel`` only when an SDK Runner is used.""" + from trpc_agent_sdk.models import LLMModel, LlmResponse + from trpc_agent_sdk.types import Content, FunctionCall, Part + from .parser import parse_unified_diff + + class FakeReviewModel(LLMModel): + @classmethod + def supported_models(cls) -> list[str]: + return [r"code-review-fake"] + + def validate_request(self, request: Any) -> None: + return None + + def __init__(self) -> None: + super().__init__(model_name="code-review-fake") + self.payload: dict[str, Any] = {} + self.step = 0 + + async def _generate_async_impl(self, request: Any, stream: bool = False, ctx: Any = None): + if self.step == 0: + for content in request.contents: + for part in content.parts: + if part.text: + try: + self.payload = json.loads(part.text) + except json.JSONDecodeError: + continue + self.step += 1 + changed = parse_unified_diff(str(self.payload.get("diff", ""))) + finding = [] if not changed else [{ + "severity": "warning", "category": "semantic_review", "file": changed[0].file, + "line": changed[0].line, "title": "Review changed behavior", "evidence": changed[0].content[:200], + "recommendation": "Confirm this change has a focused test.", "confidence": 0.4, "source": "fake_model", + }] + yield LlmResponse(content=Content(role="model", parts=[Part(function_call=FunctionCall( + name="save_review_report", + args={"task_id": self.payload.get("task_id", "dry-run"), "findings": finding, + "evidence": {"changed_lines": [{"file": item.file, "line": item.line, "content": item.content} for item in changed], + "skill_runs": [], "filter_decisions": []}, + "output_dir": self.payload.get("output_dir", "")}, + ))])) + return + yield LlmResponse(content=Content(role="model", parts=[Part(text="Review report saved.")])) + + return FakeReviewModel() + + +def create_agent(*, runtime: str = "docker", model: Any = None) -> Any: + """Create the SDK-native Agent; call only in a supported SDK runtime.""" + from trpc_agent_sdk.agents import LlmAgent + from .tools import create_review_tools + from .filter import before_model_audit, after_model_audit + + skill_tool_set, review_tools, repository = create_review_tools(runtime) + return LlmAgent( + name="code_review_agent", + description="A policy-governed code review agent powered by loaded Skills.", + model=model or create_fake_model(), instruction=INSTRUCTION, + tools=[skill_tool_set, *review_tools], skill_repository=repository, + filters_name=["code_review_agent_filter"], + before_model_callback=before_model_audit, after_model_callback=after_model_audit, + ) + + +async def create_agent_async(*, runtime: str = "docker", model: Any = None) -> Any: + """Async variant used by Cube/E2B, whose SDK workspace starts asynchronously.""" + if runtime not in {"cube", "e2b"}: + return create_agent(runtime=runtime, model=model) + from trpc_agent_sdk.agents import LlmAgent + from .tools import create_review_tools_async + from .filter import before_model_audit, after_model_audit + + skill_tool_set, review_tools, repository = await create_review_tools_async(runtime) + return LlmAgent(name="code_review_agent", description="A policy-governed code review agent powered by loaded Skills.", + model=model or create_fake_model(), instruction=INSTRUCTION, tools=[skill_tool_set, *review_tools], + skill_repository=repository, filters_name=["code_review_agent_filter"], + before_model_callback=before_model_audit, after_model_callback=after_model_audit) diff --git a/examples/skills_code_review_agent/agent/filter.py b/examples/skills_code_review_agent/agent/filter.py new file mode 100644 index 000000000..ad65b05f0 --- /dev/null +++ b/examples/skills_code_review_agent/agent/filter.py @@ -0,0 +1,120 @@ +"""Native SDK filters for review telemetry and pre-execution policy.""" +from __future__ import annotations + +from typing import Any +import json +import time + +from trpc_agent_sdk.context import AgentContext +from trpc_agent_sdk.filter import BaseFilter, FilterResult, register_agent_filter, register_tool_filter + +from .policy import FilterDecision, evaluate_command +from .redactor import redact + +def _safe(value: Any) -> Any: + """Serialize SDK values for audit without storing credentials.""" + if hasattr(value, "model_dump"): + value = value.model_dump() + if isinstance(value, str): + return redact(value) + if isinstance(value, dict): + return {str(key): _safe(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_safe(item) for item in value] + return value + + +@register_agent_filter("code_review_agent_filter") +class CodeReviewAgentFilter(BaseFilter): + """Attach lightweight lifecycle audit data to the Agent context.""" + + async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: + ctx.metadata["code_review_started"] = True + ctx.metadata.setdefault("code_review_started_at", time.monotonic()) + + +@register_tool_filter("code_review_skill_run_filter") +class CodeReviewSkillRunFilter(BaseFilter): + """Block risky skill commands before the SDK workspace is invoked.""" + + async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: + args = getattr(req, "args", req if isinstance(req, dict) else {}) + command = args.get("command", "") if isinstance(args, dict) else "" + timeout = int(args.get("timeout", 30)) if isinstance(args, dict) else 30 + if isinstance(args, dict) and not args.get("inputs"): + args["inputs"] = ctx.metadata.get("code_review_workspace_inputs", []) + if isinstance(args, dict) and str(args.get("stdin", "")).strip(): + decision = FilterDecision( + "needs_human_review", + "stdin is not permitted; use the staged diff file path instead", + ) + else: + decision = evaluate_command(command.split(), timeout) + if decision.decision == "allow" and command.split()[:1] == ["ruff"]: + staged = {item.get("dst", "") for item in args.get("inputs", []) if isinstance(item, dict)} + if not any(path != "work/inputs/input.diff" for path in staged): + decision = type(decision)("needs_human_review", "ruff requires a staged source file, not only a diff") + decisions = ctx.metadata.setdefault("code_review_filter_decisions", []) + decisions.append({ + "decision": decision.decision, "reason": decision.reason, + }) + if decision.decision != "allow": + ctx.metadata.setdefault("code_review_skill_runs", []).append({ + "runtime": "skill_run", "command": command, "status": "blocked", "exit_code": None, + "stdout": "", "stderr": decision.reason, "timed_out": False, "duration_seconds": 0, + "output_files": [], + }) + rsp.error = PermissionError(f"{decision.decision}: {decision.reason}") + rsp.is_continue = False + + async def _after(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: + args = getattr(req, "args", req if isinstance(req, dict) else {}) + command = args.get("command", "") if isinstance(args, dict) else "" + output = _safe(rsp.rsp) if rsp.rsp is not None else {} + if not isinstance(output, dict): + output = {"stdout": str(output)} + exit_code = output.get("exit_code") + timed_out = bool(output.get("timed_out", False)) + ctx.metadata.setdefault("code_review_skill_runs", []).append({ + "runtime": "skill_run", "command": command, + "status": "timed_out" if timed_out else "passed" if exit_code == 0 else "failed", + "stdout": output.get("stdout", ""), "stderr": output.get("stderr", ""), + "exit_code": exit_code, "timed_out": timed_out, + "duration_seconds": output.get("duration_ms", 0) / 1000, + "output_files": output.get("output_files", []), + }) + + +def before_model_audit(invocation_context: Any, request: Any) -> None: + """Record the redacted model input and start time in InvocationContext state.""" + state = invocation_context.agent_context.metadata + # The first user message already contains the host-backed input mappings. + # Capture them before the model requests a selected review action. + if not state.get("code_review_workspace_inputs"): + for content in getattr(request, "contents", []): + for part in getattr(content, "parts", []): + try: + payload = json.loads(getattr(part, "text", "")) + except (TypeError, json.JSONDecodeError): + continue + inputs = payload.get("workspace_inputs") if isinstance(payload, dict) else None + if isinstance(inputs, list) and inputs: + state["code_review_workspace_inputs"] = inputs + state["code_review_changed_lines"] = payload.get("changed_lines", []) + break + if state.get("code_review_workspace_inputs"): + break + state["code_review_model_pending"] = { + "model": getattr(invocation_context.agent.model, "_model_name", type(invocation_context.agent.model).__name__), + "input": _safe(request), "started": time.monotonic(), + } + + +def after_model_audit(invocation_context: Any, response: Any) -> None: + """Record each model result, including provider-declared failures.""" + pending = invocation_context.agent_context.metadata.pop("code_review_model_pending", {}) + invocation_context.agent_context.metadata.setdefault("code_review_model_runs", []).append({ + "model": pending.get("model", "unknown"), "input": pending.get("input", {}), "output": _safe(response), + "duration_seconds": time.monotonic() - pending.get("started", time.monotonic()), + "exception": getattr(response, "error_message", "") or "", + }) diff --git a/examples/skills_code_review_agent/agent/models.py b/examples/skills_code_review_agent/agent/models.py new file mode 100644 index 000000000..be17c337f --- /dev/null +++ b/examples/skills_code_review_agent/agent/models.py @@ -0,0 +1,23 @@ +"""Structured data returned by the code-review-agent.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + + +@dataclass(frozen=True) +class Finding: + severity: str + category: str + file: str + line: int | None + title: str + evidence: str + recommendation: str + confidence: float + source: str = "rule" + needs_human_review: bool = False + fingerprint: str = "" + + def as_dict(self) -> dict: + return asdict(self) diff --git a/examples/skills_code_review_agent/agent/parser.py b/examples/skills_code_review_agent/agent/parser.py new file mode 100644 index 000000000..aac8f8310 --- /dev/null +++ b/examples/skills_code_review_agent/agent/parser.py @@ -0,0 +1,65 @@ +"""Small unified-diff parser that preserves changed-file line numbers.""" + +from __future__ import annotations + +from dataclasses import dataclass +import re + + +@dataclass(frozen=True) +class ChangedLine: + file: str + line: int + content: str + + +@dataclass(frozen=True) +class DiffHunk: + file: str + header: str + context: list[str] + + +@dataclass(frozen=True) +class ParsedDiff: + changed_lines: list[ChangedLine] + hunks: list[DiffHunk] + + +def parse_unified_diff_with_hunks(diff: str) -> ParsedDiff: + """Preserve hunk headers and unchanged context alongside candidate lines.""" + changed = parse_unified_diff(diff) + hunks: list[DiffHunk] = [] + file_name = "" + header = "" + context: list[str] = [] + for raw in diff.splitlines(): + if raw.startswith("+++ b/"): + file_name = raw[6:] + elif raw.startswith("@@"): + if header: + hunks.append(DiffHunk(file_name, header, context)) + header, context = raw, [] + elif header and raw.startswith(" "): + context.append(raw[1:]) + if header: + hunks.append(DiffHunk(file_name, header, context)) + return ParsedDiff(changed, hunks) + + +def parse_unified_diff(diff: str) -> list[ChangedLine]: + lines: list[ChangedLine] = [] + file_name = "" + new_line = 0 + for raw_line in diff.splitlines(): + if raw_line.startswith("+++ b/"): + file_name = raw_line[6:] + elif raw_line.startswith("@@"): + match = re.search(r"\+(\d+)(?:,\d+)?", raw_line) + new_line = int(match.group(1)) if match else 0 + elif raw_line.startswith("+") and not raw_line.startswith("+++"): + lines.append(ChangedLine(file_name, new_line, raw_line[1:])) + new_line += 1 + elif raw_line.startswith(" ") or (raw_line and not raw_line.startswith("-")): + new_line += 1 + return lines diff --git a/examples/skills_code_review_agent/agent/policy.py b/examples/skills_code_review_agent/agent/policy.py new file mode 100644 index 000000000..c815ba4d9 --- /dev/null +++ b/examples/skills_code_review_agent/agent/policy.py @@ -0,0 +1,33 @@ +"""Pre-execution command governance for the review workflow.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FilterDecision: + decision: str + reason: str + + +class ReviewPolicyEngine: + """Allow only bounded static checks; reject or escalate risky actions.""" + + def decide(self, command: list[str], timeout_seconds: int = 30) -> FilterDecision: + if not command or command[0] not in {"python", "pytest", "ruff"}: + return FilterDecision("deny", "command executable is outside the review allowlist") + text = " ".join(command).lower() + if command[:2] == ["python", "-c"] and any(token in text for token in ("subprocess", "os.system", "socket")): + return FilterDecision("needs_human_review", "dynamic Python code requests process or network access") + if timeout_seconds > 120: + return FilterDecision("deny", "command exceeds the 120 second review budget") + if any(token in text for token in ("curl ", "wget ", "pip install", "npm install")): + return FilterDecision("deny", "network or dependency installation is not allowed in review sandboxes") + if any(token in text for token in (".git/", "/root/", "~/.ssh")): + return FilterDecision("deny", "command targets a forbidden path") + return FilterDecision("allow", "command is within the static-review allowlist") + + +def evaluate_command(command: list[str], timeout_seconds: int) -> FilterDecision: + """Compatibility helper for direct policy checks in tests and integrations.""" + return ReviewPolicyEngine().decide(command, timeout_seconds) diff --git a/examples/skills_code_review_agent/agent/prompts.py b/examples/skills_code_review_agent/agent/prompts.py new file mode 100644 index 000000000..da1b3b70d --- /dev/null +++ b/examples/skills_code_review_agent/agent/prompts.py @@ -0,0 +1,20 @@ +"""Instruction for the native Skill-driven code review Agent.""" + +INSTRUCTION = """You are a policy-governed code review agent. + +The input JSON was already parsed, redacted and staged by the CLI: never call +parse_review_input or invent host paths. In a real review, load the code-review +Skill. The upstream Skill has many large, cross-language reference documents: +never use include_all_docs, and select at most two documents. Never pass stdin to +run_selected_review_actions: select only action IDs declared by review-agent-plan. +Do not call skill_run directly; the deterministic executor expands action IDs into +the exact command, Skill-root cwd and staged diff path. For Python changes, +select only reference/python.md and, if security-sensitive code changed, +reference/security-review-guide.md. For other languages, select only that +language's reference document. Run only useful Skill commands using the returned +workspace_inputs; you may omit inputs because the CLI already provided them to +the executor securely. Never use skill_exec, workspace_exec, skill_run, or interactive stdin. +Never invent commands, evidence, files, or line numbers. Respect tool-filter denials and mark uncertain conclusions for +human review. Finish every review by calling save_review_report with +schema-shaped findings and evidence from the parsed diff or skill_run results. +""" diff --git a/examples/skills_code_review_agent/agent/redactor.py b/examples/skills_code_review_agent/agent/redactor.py new file mode 100644 index 000000000..d4a755cbf --- /dev/null +++ b/examples/skills_code_review_agent/agent/redactor.py @@ -0,0 +1,22 @@ +"""Never persist raw credentials from a review input.""" + +from __future__ import annotations + +import re + + +_SECRET_PATTERNS = ( + re.compile(r"sk-[A-Za-z0-9_-]{8,}"), + re.compile(r"(?:Bearer\s+)[A-Za-z0-9._-]{8,}", re.IGNORECASE), + re.compile(r"((?:password|token|api[_-]?key)\s*=\s*[\"'])[^\"']+([\"'])", re.IGNORECASE), +) + + +def redact(value: str) -> str: + """Replace credential-like values before logging, reporting, or storing.""" + for pattern in _SECRET_PATTERNS: + if pattern is _SECRET_PATTERNS[-1]: + value = pattern.sub(r"\1[REDACTED]\2", value) + else: + value = pattern.sub("[REDACTED]", value) + return value diff --git a/examples/skills_code_review_agent/agent/rules.py b/examples/skills_code_review_agent/agent/rules.py new file mode 100644 index 000000000..b604334b3 --- /dev/null +++ b/examples/skills_code_review_agent/agent/rules.py @@ -0,0 +1,53 @@ +"""Deterministic review rules used by the bundled code-review skill.""" + +from __future__ import annotations + +from dataclasses import replace +import hashlib +import re + +from .models import Finding +from .parser import ChangedLine +from .redactor import redact + + +def _finding(line: ChangedLine, *, severity: str, category: str, title: str, recommendation: str) -> Finding: + evidence = redact(line.content.strip()) + key = f"{category}:{line.file}:{line.line}:{evidence}".encode() + return Finding(severity, category, line.file, line.line, title, evidence, recommendation, 0.95, + fingerprint=hashlib.sha256(key).hexdigest()) + + +def scan(lines: list[ChangedLine]) -> list[Finding]: + findings: list[Finding] = [] + all_content = "\n".join(line.content for line in lines) + for line in lines: + content = line.content + if re.search(r"sk-[A-Za-z0-9_-]{8,}|(?:api[_-]?key|token|password)\s*=\s*[\"']", content, re.I): + findings.append(_finding(line, severity="critical", category="secret", title="Hard-coded credential", + recommendation="Read the credential from an approved secret provider or environment variable.")) + if "aiohttp.ClientSession(" in content or "AsyncClient(" in content: + findings.append(_finding(line, severity="high", category="async", title="Async client may not be closed", + recommendation="Use async with, or close the client in a finally block.")) + if re.search(r"\b(?:engine|conn|connection)\.connect\(", content): + findings.append(_finding(line, severity="high", category="database", title="Database connection lifecycle is unsafe", + recommendation="Use a context manager so connections close on both success and failure paths.")) + if re.search(r"\b(?:tx|transaction)\.begin\(", content) and "rollback" not in all_content: + findings.append(_finding(line, severity="high", category="database", title="Transaction has no rollback path", + recommendation="Rollback in the exception path, or use an atomic transaction context manager.")) + changed_code = any(not line.file.startswith("tests/") and line.file.endswith(".py") for line in lines) + changed_tests = any(line.file.startswith("tests/") and line.file.endswith(".py") for line in lines) + if changed_code and not changed_tests: + marker = next(line for line in lines if not line.file.startswith("tests/") and line.file.endswith(".py")) + findings.append(_finding(marker, severity="medium", category="tests", title="Changed behavior has no matching test diff", + recommendation="Add a focused regression or behavior test for this change.")) + return deduplicate(findings) + + +def deduplicate(findings: list[Finding]) -> list[Finding]: + unique: dict[tuple[str, str, str], Finding] = {} + for finding in findings: + # One secret printed twice in the same changed file is one remediation task. + # Evidence has already been redacted, so it is safe to use as the stable key. + unique.setdefault((finding.category, finding.file, finding.evidence), finding) + return list(unique.values()) \ No newline at end of file diff --git a/examples/skills_code_review_agent/agent/storage.py b/examples/skills_code_review_agent/agent/storage.py new file mode 100644 index 000000000..909644a3b --- /dev/null +++ b/examples/skills_code_review_agent/agent/storage.py @@ -0,0 +1,96 @@ +"""SQLite audit storage for the review example.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sqlite3 + + +class ReviewStorage: + def __init__(self, path: Path): + self.path = path + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as db: + db.executescript(""" + CREATE TABLE IF NOT EXISTS review_tasks (task_id TEXT PRIMARY KEY, status TEXT, input_digest TEXT); + CREATE TABLE IF NOT EXISTS sandbox_runs (task_id TEXT, runtime TEXT, status TEXT, exit_code INTEGER, output TEXT); + CREATE TABLE IF NOT EXISTS filter_decisions (task_id TEXT, decision TEXT, reason TEXT); + CREATE TABLE IF NOT EXISTS findings (task_id TEXT, fingerprint TEXT, severity TEXT, category TEXT, file TEXT, line INTEGER, evidence TEXT, recommendation TEXT, confidence REAL, UNIQUE(task_id, fingerprint)); + CREATE TABLE IF NOT EXISTS review_reports (task_id TEXT PRIMARY KEY, report_json TEXT); + CREATE TABLE IF NOT EXISTS review_metrics (task_id TEXT PRIMARY KEY, finding_count INTEGER, sandbox_run_count INTEGER, blocked_count INTEGER); + CREATE TABLE IF NOT EXISTS skill_loads (task_id TEXT, operation TEXT, documents_json TEXT, result_json TEXT); + CREATE TABLE IF NOT EXISTS skill_runs (task_id TEXT, runtime TEXT, command_json TEXT, status TEXT, exit_code INTEGER, output TEXT, stderr TEXT, timed_out INTEGER, duration_seconds REAL, output_files_json TEXT); + CREATE TABLE IF NOT EXISTS model_runs (task_id TEXT, model TEXT, duration_seconds REAL, result_json TEXT); + CREATE TABLE IF NOT EXISTS review_state_events (task_id TEXT, from_state TEXT, to_state TEXT, reason TEXT, timestamp REAL); + """) + columns = {row[1] for row in db.execute("PRAGMA table_info(review_metrics)")} + if "metrics_json" not in columns: + db.execute("ALTER TABLE review_metrics ADD COLUMN metrics_json TEXT") + + def _connect(self): + return sqlite3.connect(self.path) + + def save_native(self, task_id: str, report: dict, input_digest: str) -> None: + """Persist the FunctionTool-owned report without depending on legacy models.""" + with self._connect() as db: + db.execute("INSERT OR REPLACE INTO review_tasks VALUES (?, ?, ?)", (task_id, report["status"], input_digest)) + db.execute("INSERT OR REPLACE INTO review_reports VALUES (?, ?)", (task_id, json.dumps(report, ensure_ascii=False))) + metrics = report["metrics"] + db.execute( + "INSERT OR REPLACE INTO review_metrics (task_id, finding_count, sandbox_run_count, blocked_count, metrics_json) VALUES (?, ?, ?, ?, ?)", + (task_id, metrics["finding_count"], metrics["sandbox_run_count"], metrics["blocked_count"], json.dumps(metrics)), + ) + db.execute("DELETE FROM findings WHERE task_id = ?", (task_id,)) + db.executemany("INSERT OR IGNORE INTO findings VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [ + (task_id, f"{item['category']}:{item['file']}:{item['line']}", item["severity"], item["category"], item["file"], + item["line"], item["evidence"], item["recommendation"], item["confidence"]) + for item in report["findings"] + report.get("needs_human_review", []) + ]) + db.execute("DELETE FROM filter_decisions WHERE task_id = ?", (task_id,)) + db.executemany("INSERT INTO filter_decisions VALUES (?, ?, ?)", [ + (task_id, item.get("decision", ""), item.get("reason", "")) + for item in report.get("filter_decisions", []) + ]) + db.execute("DELETE FROM skill_runs WHERE task_id = ?", (task_id,)) + db.executemany("INSERT INTO skill_runs VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [ + (task_id, item.get("runtime", "skill_run"), json.dumps(item.get("command", "")), + item.get("status", "passed"), item.get("exit_code"), item.get("stdout", ""), item.get("stderr", ""), + int(item.get("timed_out", False)), item.get("duration_seconds", 0), + json.dumps(item.get("output_files", []), ensure_ascii=False)) + for item in report.get("skill_runs", []) + ]) + db.execute("DELETE FROM model_runs WHERE task_id = ?", (task_id,)) + db.executemany("INSERT INTO model_runs VALUES (?, ?, ?, ?)", [ + (task_id, item.get("model", ""), item.get("duration_seconds", 0), + json.dumps({"input": item.get("input", {}), "output": item.get("output", {}), "exception": item.get("exception", "")}, ensure_ascii=False)) + for item in report.get("model_runs", []) + ]) + + def get_task(self, task_id: str) -> dict: + """Return the complete persisted audit trail for a single review task.""" + with self._connect() as db: + task = db.execute("SELECT status, input_digest FROM review_tasks WHERE task_id = ?", (task_id,)).fetchone() + if not task: + raise KeyError(f"Unknown review task: {task_id}") + runs = db.execute("SELECT runtime, status, exit_code, output FROM sandbox_runs WHERE task_id = ?", (task_id,)).fetchall() + decisions = db.execute("SELECT decision, reason FROM filter_decisions WHERE task_id = ?", (task_id,)).fetchall() + findings = db.execute("SELECT severity, category, file, line, evidence, recommendation, confidence FROM findings WHERE task_id = ?", (task_id,)).fetchall() + metric = db.execute("SELECT finding_count, sandbox_run_count, blocked_count, metrics_json FROM review_metrics WHERE task_id = ?", (task_id,)).fetchone() + skill_loads = db.execute("SELECT operation, documents_json, result_json FROM skill_loads WHERE task_id = ?", (task_id,)).fetchall() + skill_runs = db.execute("SELECT runtime, command_json, status, exit_code, output, stderr, timed_out, duration_seconds, output_files_json FROM skill_runs WHERE task_id = ?", (task_id,)).fetchall() + model_runs = db.execute("SELECT model, duration_seconds, result_json FROM model_runs WHERE task_id = ?", (task_id,)).fetchall() + state_events = db.execute("SELECT from_state, to_state, reason, timestamp FROM review_state_events WHERE task_id = ? ORDER BY timestamp", (task_id,)).fetchall() + return { + "task_id": task_id, + "status": task[0], + "input_digest": task[1], + "sandbox_runs": [dict(zip(("runtime", "status", "exit_code", "output"), row)) for row in runs], + "filter_decisions": [dict(zip(("decision", "reason"), row)) for row in decisions], + "findings": [dict(zip(("severity", "category", "file", "line", "evidence", "recommendation", "confidence"), row)) for row in findings], + "metrics": json.loads(metric[3]) if metric[3] else dict(zip(("finding_count", "sandbox_run_count", "blocked_count"), metric[:3])), + "skill_loads": [{"operation": row[0], "documents": json.loads(row[1]), "result": json.loads(row[2])} for row in skill_loads], + "skill_runs": [{"runtime": row[0], "command": json.loads(row[1]), "status": row[2], "exit_code": row[3], "output": row[4], "stderr": row[5], "timed_out": bool(row[6]), "duration_seconds": row[7], "output_files": json.loads(row[8])} for row in skill_runs], + "model_runs": [{"model": row[0], "duration_seconds": row[1], "result": json.loads(row[2])} for row in model_runs], + "state_events": [dict(zip(("from_state", "to_state", "reason", "timestamp"), row)) for row in state_events], + } diff --git a/examples/skills_code_review_agent/agent/tools.py b/examples/skills_code_review_agent/agent/tools.py new file mode 100644 index 000000000..fcf1c8b19 --- /dev/null +++ b/examples/skills_code_review_agent/agent/tools.py @@ -0,0 +1,316 @@ +"""Native SkillToolSet factory and deterministic code-review FunctionTools.""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +REVIEW_SKILL_TOOL_NAMES = ["skill_load", "skill_select_docs", "skill_list_docs"] +_ANALYZE_PR_COMMAND = "python scripts/pr-analyzer.py --diff-file ../../work/inputs/input.diff" + + +def plan_review_actions(action_ids: list[str], workspace_inputs: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Expand code-review Skill action IDs into fixed no-stdin skill_run inputs.""" + actions: list[dict[str, Any]] = [] + for action_id in action_ids: + if action_id == "analyze_pr": + actions.append({"skill": "code-review", "command": _ANALYZE_PR_COMMAND, "cwd": "", "stdin": "", + "timeout": 30, "inputs": workspace_inputs}) + return actions + + +@dataclass(frozen=True) +class WorkspaceInputSpec: + """Import-free equivalent accepted by SDK SkillRunInput on real execution.""" + src: str + dst: str + mode: str = "copy" + + def model_dump(self) -> dict[str, str]: + return {"src": self.src, "dst": self.dst, "mode": self.mode} + + +def _stage(path: Path, dst: str) -> WorkspaceInputSpec: + return WorkspaceInputSpec(f"host://{path.resolve().as_posix()}", dst) + + +def _env_positive_int(name: str, default: int) -> int: + """Read a bounded audit limit from the environment.""" + try: + return max(1, int(os.getenv(name, str(default)))) + except ValueError: + return default + + +def _truncate_text(text: Any, limit_bytes: int) -> tuple[str, bool, int]: + value = str(text or "") + raw = value.encode("utf-8") + if len(raw) <= limit_bytes: + return value, False, len(raw) + # Keep both ends: command failures frequently place the useful reason last. + half = max(1, limit_bytes // 2) + head = raw[:half].decode("utf-8", errors="ignore") + tail = raw[-half:].decode("utf-8", errors="ignore") + return f"{head}\n...[truncated {len(raw) - limit_bytes} bytes]...\n{tail}", True, len(raw) + + +def _audit_summary(value: Any, limit_bytes: int, redact_text: Any) -> dict[str, Any]: + text = redact_text(json.dumps(value, ensure_ascii=False, default=str, sort_keys=True)) + preview, truncated, original_bytes = _truncate_text(text, limit_bytes) + return { + "preview": preview, + "sha256": hashlib.sha256(text.encode("utf-8")).hexdigest(), + "original_bytes": original_bytes, + "truncated": truncated, + } + + +def _compact_skill_runs(runs: list[dict], limit_bytes: int, redact_text: Any) -> list[dict]: + compact: list[dict] = [] + for run in runs: + item = dict(run) + for field in ("stdout", "stderr"): + value, truncated, original_bytes = _truncate_text(redact_text(item.get(field, "")), limit_bytes) + item[field] = value + item[f"{field}_truncated"] = truncated + item[f"{field}_original_bytes"] = original_bytes + compact.append(item) + return compact + + +def _compact_model_runs(runs: list[dict], limit: int, limit_bytes: int, redact_text: Any) -> list[dict]: + return [{ + "model": item.get("model", ""), + "input_summary": _audit_summary(item.get("input", {}), limit_bytes, redact_text), + "output_summary": _audit_summary(item.get("output", {}), limit_bytes, redact_text), + "duration_seconds": item.get("duration_seconds", 0), + "exception": redact_text(item.get("exception", "")), + } for item in runs[:limit]] + + +def _review_metrics(findings: list[dict], human: list[dict], skill_runs: list[dict], model_runs: list[dict], + decisions: list[dict], total_duration: float) -> dict[str, Any]: + severity: dict[str, int] = {} + for finding in findings + human: + severity[finding["severity"]] = severity.get(finding["severity"], 0) + 1 + statuses: dict[str, int] = {} + for run in skill_runs: + statuses[run.get("status", "unknown")] = statuses.get(run.get("status", "unknown"), 0) + 1 + exceptions: dict[str, int] = {} + for run in model_runs: + exception = str(run.get("exception", "")).strip() + if exception: + exceptions[exception] = exceptions.get(exception, 0) + 1 + return { + "total_duration_seconds": round(max(0.0, total_duration), 3), + "sandbox_duration_seconds": round(sum(float(item.get("duration_seconds", 0) or 0) for item in skill_runs), 3), + "tool_call_count": len(skill_runs), + "finding_count": len(findings), + "needs_human_review_count": len(human), + "sandbox_run_count": len(skill_runs), + "blocked_count": sum(item.get("decision") != "allow" for item in decisions), + "severity_distribution": severity, + "exception_distribution": exceptions, + "skill_run_status_distribution": statuses, + } + + +def _markdown_report(task_id: str, findings: list[dict], human: list[dict], metrics: dict, + skill_runs: list[dict], decisions: list[dict], model_runs: list[dict]) -> str: + lines = [f"# Code Review: {task_id}", "", "## Findings"] + lines += [f"- [{item['severity']}] `{item['file']}:{item['line']}` {item['title']}" for item in findings + human] or ["- No findings."] + lines += ["", "## 监控指标"] + lines += [f"- {key}: {value}" for key, value in metrics.items()] + lines += ["", "## 沙箱执行摘要"] + lines += [f"- [{item.get('status')}] `{item.get('command')}` (exit={item.get('exit_code')}, {item.get('duration_seconds', 0)}s)" for item in skill_runs] or ["- 未执行沙箱命令。"] + lines += ["", "## Filter 拦截摘要"] + lines += [f"- [{item.get('decision')}] {item.get('reason')}" for item in decisions] or ["- 无拦截。"] + lines += ["", "## 模型调用摘要"] + lines += [f"- `{item.get('model')}` ({item.get('duration_seconds', 0)}s, input={item['input_summary']['original_bytes']} bytes, output={item['output_summary']['original_bytes']} bytes)" for item in model_runs] or ["- 未调用模型。"] + return "\n".join(lines) + "\n" + + +def parse_review_input(*, diff: str = "", diff_file: str = "", repo_path: str = "", files: list[str] = [], + task_id: str = "", output_dir: str = "", staging_dir: str = "", + tool_context: Any = None) -> dict[str, Any]: + """Parse and redact input; declare files copied to ``$WORK_DIR/inputs``.""" + if not diff and not diff_file and not repo_path and not files: + raise ValueError("one of diff, diff_file, repo_path, or files is required") + patch = Path(diff_file).resolve() if diff_file else None + root = Path(repo_path).resolve() if repo_path else None + selected = [Path(item).resolve() for item in files or []] + if patch: + diff = patch.read_text(encoding="utf-8") + elif root: + completed = subprocess.run(["git", "-C", str(root), "diff", "--no-ext-diff", "--"], capture_output=True, text=True, check=False) + if completed.returncode: + raise ValueError(f"could not read repository diff: {completed.stderr.strip()}") + diff = completed.stdout + elif not diff: + sections = [] + for source in selected: + lines = source.read_text(encoding="utf-8").splitlines() + sections += [f"+++ b/{source.name}", f"@@ -0,0 +1,{len(lines)} @@", *[f"+{line}" for line in lines]] + diff = "\n".join(sections) + "\n" + + from .parser import parse_unified_diff_with_hunks + from .redactor import redact + parsed = parse_unified_diff_with_hunks(diff) + redacted_diff = redact(diff) + # ContainerWorkspaceRuntime copies a host input into ``dirname(dst)``. + # Stage the parent directory (not the file, whose tar member is ``.``), + # while retaining the filename in dst so the archive lands at + # ``work/inputs/input.diff``. + staged = [_stage(patch.parent, "work/inputs/input.diff")] if patch else [] + if root and staging_dir: + generated_diff = Path(staging_dir) / "input.diff" + generated_diff.parent.mkdir(parents=True, exist_ok=True) + generated_diff.write_text(redacted_diff, encoding="utf-8") + staged.append(_stage(generated_diff.parent, "work/inputs/input.diff")) + sources = {item for item in selected if item.is_file()} + if root: + for line in parsed.changed_lines: + candidate = (root / line.file).resolve() + if candidate.is_file(): + sources.add(candidate) + for source in sorted(sources): + relative = source.relative_to(root).as_posix() if root and source.is_relative_to(root) else source.name + staged.append(_stage(source, f"work/inputs/{relative}")) + unique = {item.dst: item for item in staged} + changed = [{"file": item.file, "line": item.line, "content": redact(item.content)} for item in parsed.changed_lines] + result = { + "diff": redacted_diff, "changed_files": sorted({item["file"] for item in changed if item["file"]}), + "changed_lines": changed, + "hunks": [{"file": item.file, "header": item.header, "context": [redact(text) for text in item.context]} for item in parsed.hunks], + "workspace_inputs": [item.model_dump() for item in unique.values()], + } + if tool_context is not None: + state = tool_context.agent_context.metadata + # A model often calls this tool with inline diff text. Such a call has + # no host file to stage, so retain the input mappings pre-seeded from + # the original CLI payload instead of accidentally discarding them. + if not result["workspace_inputs"] and state.get("code_review_workspace_inputs"): + result["workspace_inputs"] = state["code_review_workspace_inputs"] + state["code_review_workspace_inputs"] = result["workspace_inputs"] + state["code_review_changed_lines"] = changed + return result + + +def save_review_report(*, task_id: str, findings: list[dict], evidence: dict, output_dir: str = "", tool_context: Any = None) -> dict[str, Any]: + """Validate evidence, apply deterministic rules, deduplicate, redact and persist.""" + from .parser import ChangedLine + from .redactor import redact + from .rules import scan + from .storage import ReviewStorage + + review_started = time.monotonic() + if tool_context is not None: + state = tool_context.agent_context.metadata + review_started = state.get("code_review_started_at", review_started) + evidence = { + **evidence, + "changed_lines": state.get("code_review_changed_lines", evidence.get("changed_lines", [])), + "skill_runs": state.get("code_review_skill_runs", evidence.get("skill_runs", [])), + "model_runs": state.get("code_review_model_runs", evidence.get("model_runs", [])), + "filter_decisions": state.get("code_review_filter_decisions", evidence.get("filter_decisions", [])), + } + changed = {(item.get("file"), item.get("line")) for item in evidence.get("changed_lines", [])} + evidence_text = "\n".join(str(item.get("content", "")) for item in evidence.get("changed_lines", [])) + deterministic = [item.as_dict() for item in scan([ChangedLine(str(item.get("file", "")), int(item.get("line", 0)), str(item.get("content", ""))) for item in evidence.get("changed_lines", [])])] + accepted, human, seen = [], [], set() + for raw in [*deterministic, *findings]: + try: + item = {key: raw[key] for key in ("severity", "category", "file", "line", "title", "evidence", "recommendation", "confidence", "source")} + item["line"], item["confidence"] = int(item["line"]), float(item["confidence"]) + except (KeyError, TypeError, ValueError): + continue + item["evidence"] = redact(str(item["evidence"])) + key = (item["category"], item["file"], item["line"]) + if key in seen or (item["file"], item["line"]) not in changed or (item["evidence"] and item["evidence"] not in redact(evidence_text)): + continue + seen.add(key) + (human if item["confidence"] < 0.7 else accepted).append(item) + root, task_root = Path(output_dir or Path(__file__).parents[1] / "review-output"), None + task_root = root / task_id + task_root.mkdir(parents=True, exist_ok=True) + tool_limit = _env_positive_int("CODE_REVIEW_TOOL_OUTPUT_MAX_KIB", 8) * 1024 + model_limit = _env_positive_int("CODE_REVIEW_MODEL_AUDIT_MAX_KIB", 16) * 1024 + model_count = _env_positive_int("CODE_REVIEW_MODEL_RUN_MAX_COUNT", 20) + raw_model_runs = evidence.get("model_runs", []) + skill_runs = _compact_skill_runs(evidence.get("skill_runs", []), tool_limit, redact) + model_runs = _compact_model_runs(raw_model_runs, model_count, model_limit, redact) + decisions = evidence.get("filter_decisions", []) + metrics = _review_metrics(accepted, human, skill_runs, raw_model_runs, decisions, time.monotonic() - review_started) + report = {"task_id": task_id, "status": "completed", "findings": accepted, "needs_human_review": human, + "skill_runs": skill_runs, "model_runs": model_runs, "filter_decisions": decisions, + "metrics": metrics} + json_path, markdown_path = task_root / "review_report.json", task_root / "review_report.md" + json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + markdown_path.write_text(_markdown_report(task_id, accepted, human, metrics, skill_runs, decisions, model_runs), encoding="utf-8") + ReviewStorage(root / "reviews.sqlite").save_native(task_id, report, hashlib.sha256(redact(evidence_text).encode()).hexdigest()) + return {**report, "json_path": str(json_path), "markdown_path": str(markdown_path)} + + +def _review_action_tool(skill_tools: Any) -> Any: + """Create the only model-visible execution tool; it delegates to native skill_run.""" + async def run_selected_review_actions(*, action_ids: list[str], tool_context: Any = None) -> dict[str, Any]: + if tool_context is None: + raise ValueError("tool_context is required") + inputs = tool_context.agent_context.metadata.get("code_review_workspace_inputs", []) + actions = plan_review_actions(action_ids, inputs) + if not actions: + return {"runs": [], "skipped": action_ids} + runs = [] + for action in actions: + runs.append(await skill_tools._run_tool.run_async(tool_context=tool_context, args=action)) + return {"runs": runs, "skipped": [item for item in action_ids if item != "analyze_pr"]} + + return run_selected_review_actions + + +def create_review_tools(runtime: str = "docker") -> tuple[Any, list[Any], Any]: + """Build SDK SkillToolSet plus exactly parse/save FunctionTools.""" + from trpc_agent_sdk.skills import SkillToolSet, create_default_skill_repository + from trpc_agent_sdk.skills.tools import CopySkillStager + from trpc_agent_sdk.tools import FunctionTool + from .filter import CodeReviewSkillRunFilter + if runtime == "local": + from trpc_agent_sdk.code_executors import create_local_workspace_runtime + workspace = create_local_workspace_runtime() + elif runtime == "docker": + from trpc_agent_sdk.code_executors import create_container_workspace_runtime + from trpc_agent_sdk.code_executors.container import ContainerConfig + example_root = Path(__file__).parents[1] + workspace = create_container_workspace_runtime(container_config=ContainerConfig( + image=os.getenv("CODE_REVIEW_DOCKER_IMAGE", "trpc-code-review:latest"), + docker_path=os.getenv("CODE_REVIEW_DOCKER_BUILD_CONTEXT", str(example_root)), + )) + else: + raise RuntimeError("Cube/E2B runtime must be constructed asynchronously by the SDK deployment entry point") + repository = create_default_skill_repository(str(Path(__file__).parents[1] / "skills"), workspace_runtime=workspace) + skill_tools = SkillToolSet(repository=repository, require_skill_loaded=True, allowed_cmds=["python", "ruff", "pytest"], filters=[CodeReviewSkillRunFilter()], force_save_artifacts=True, skill_stager=CopySkillStager(), tool_filter=REVIEW_SKILL_TOOL_NAMES, is_include_all_tools=False) + return skill_tools, [FunctionTool(_review_action_tool(skill_tools)), FunctionTool(save_review_report)], repository + + +async def create_review_tools_async(runtime: str = "docker") -> tuple[Any, list[Any], Any]: + """Create the Cube/E2B-backed ToolSet when the runtime needs async startup.""" + if runtime not in {"cube", "e2b"}: + return create_review_tools(runtime) + from trpc_agent_sdk.code_executors.cube import CubeClientConfig, CubeWorkspaceRuntimeConfig + from trpc_agent_sdk.code_executors.cube import create_cube_sandbox_client, create_cube_workspace_runtime + from trpc_agent_sdk.skills import SkillToolSet, create_default_skill_repository + from trpc_agent_sdk.skills.tools import CopySkillStager + from trpc_agent_sdk.tools import FunctionTool + from .filter import CodeReviewSkillRunFilter + config = CubeClientConfig(execute_timeout=30.0, idle_timeout=600, auto_recover=True) + client = await create_cube_sandbox_client(config) + workspace = create_cube_workspace_runtime(sandbox_client=client, execute_timeout=config.execute_timeout, + workspace_cfg=CubeWorkspaceRuntimeConfig()) + repository = create_default_skill_repository(str(Path(__file__).parents[1] / "skills"), workspace_runtime=workspace) + skill_tools = SkillToolSet(repository=repository, require_skill_loaded=True, allowed_cmds=["python", "ruff", "pytest"], filters=[CodeReviewSkillRunFilter()], force_save_artifacts=True, skill_stager=CopySkillStager(), tool_filter=REVIEW_SKILL_TOOL_NAMES, is_include_all_tools=False) + return skill_tools, [FunctionTool(_review_action_tool(skill_tools)), FunctionTool(save_review_report)], repository diff --git a/examples/skills_code_review_agent/run_review.py b/examples/skills_code_review_agent/run_review.py new file mode 100644 index 000000000..c91aae382 --- /dev/null +++ b/examples/skills_code_review_agent/run_review.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Run the native Skill-driven code-review Agent.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import re +import shutil +import sys +from datetime import datetime +from pathlib import Path +from uuid import uuid4 + +from dotenv import load_dotenv + +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +load_dotenv(Path(__file__).with_name(".env")) + +from examples.skills_code_review_agent.agent.tools import parse_review_input, save_review_report + + +def create_task_id(*, diff_file: str = "", repo_path: str = "", files: list[str] | None = None, + now: datetime | None = None, suffix: str | None = None) -> str: + """Create a readable, time-sortable review id with a collision-safe suffix.""" + if diff_file: + label = Path(diff_file).resolve().parent.name + elif repo_path: + label = Path(repo_path).resolve().name + elif files: + label = Path(files[0]).stem + else: + label = "review" + label = re.sub(r"^\d+[-_]+", "", label) + label = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") or "review" + timestamp = (now or datetime.now()).strftime("%Y%m%d-%H%M%S") + short_suffix = (suffix or uuid4().hex[:8]).lower() + return f"cr-{timestamp}-{label}-{short_suffix}" + + +async def _run_sdk_agent(payload: dict, runtime: str, model: object) -> None: + """Use the repository Runner in a supported (normally Linux) SDK environment.""" + from trpc_agent_sdk.runners import Runner + from trpc_agent_sdk.sessions import InMemorySessionService + from trpc_agent_sdk.types import Content, Part + from examples.skills_code_review_agent.agent.agent import create_agent_async + + session_service = InMemorySessionService() + agent = await create_agent_async(runtime=runtime, model=model) + runner = Runner(app_name="skills_code_review_agent", agent=agent, session_service=session_service) + await session_service.create_session(app_name="skills_code_review_agent", user_id="reviewer", session_id=payload["task_id"]) + async for _ in runner.run_async(user_id="reviewer", session_id=payload["task_id"], + new_message=Content(parts=[Part.from_text(text=json.dumps(payload))])): + pass + + +async def main() -> None: + parser = argparse.ArgumentParser(description="Run the native Skill code-review Agent.") + source = parser.add_mutually_exclusive_group(required=True) + source.add_argument("--diff-file", type=str) + source.add_argument("--repo-path", type=str) + source.add_argument("--files", type=str, nargs="+") + parser.add_argument("--output-dir", type=str, default=str(Path(__file__).resolve().parent / "review-output")) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--dry-run", action="store_true", help="Run deterministic no-key review without SDK sandbox startup.") + mode.add_argument("--fake-model", action="store_true", help="Run the SDK Agent with its deterministic FakeModel; no API key is required.") + parser.add_argument("--runtime", choices=("docker", "local", "cube", "e2b"), default="docker") + parser.add_argument("--model-base-url") + parser.add_argument("--model") + parser.add_argument("--model-api-key-env", default="OPENAI_API_KEY") + args = parser.parse_args() + + task_id = create_task_id(diff_file=args.diff_file or "", repo_path=args.repo_path or "", files=args.files) + staging_dir = Path(args.output_dir) / ".staging" / task_id + try: + review_input = parse_review_input( + diff_file=args.diff_file or "", repo_path=args.repo_path or "", files=args.files, + staging_dir=str(staging_dir), + ) + payload = {**review_input, "task_id": task_id, "output_dir": args.output_dir} + if args.dry_run: + # Windows-safe fallback: same deterministic parse/report FunctionTool boundary, + # without importing the SDK path that currently loads python-magic. + report = save_review_report(task_id=payload["task_id"], findings=[], evidence={"changed_lines": payload["changed_lines"]}, output_dir=args.output_dir) + print(f"completed: {report['json_path']}") + return + if args.fake_model: + from examples.skills_code_review_agent.agent.agent import create_fake_model + model = create_fake_model() + else: + api_key = os.environ.get(args.model_api_key_env, "") + if not api_key: + raise SystemExit(f"missing model API key: set {args.model_api_key_env} in .env or the environment") + from examples.skills_code_review_agent.agent.agent import OpenAIReviewModel + model = OpenAIReviewModel( + api_key, args.model_base_url or os.getenv("OPENAI_BASE_URL", ""), + args.model or os.getenv("OPENAI_MODEL", ""), + ).create() + await _run_sdk_agent(payload, args.runtime, model) + print(f"submitted: {Path(args.output_dir) / payload['task_id'] / 'review_report.json'}") + finally: + shutil.rmtree(staging_dir, ignore_errors=True) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/skills_code_review_agent/skills/code-review/LICENSE b/examples/skills_code_review_agent/skills/code-review/LICENSE new file mode 100644 index 000000000..636215f69 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 awesome-skills + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/skills_code_review_agent/skills/code-review/SKILL.md b/examples/skills_code_review_agent/skills/code-review/SKILL.md new file mode 100644 index 000000000..6408d4827 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/SKILL.md @@ -0,0 +1,245 @@ +--- +name: code-review +description: | + Provides comprehensive code review guidance for React 19, Vue 3, Angular 17+, Svelte 5, + Rust, TypeScript, Java, Java 8, PHP, Ruby, Rails, Python, Django, FastAPI, Go, C#/.NET, Kotlin, Swift, + NestJS, C/C++, Zig, CSS/Less/Sass, Qt, and more. + Covers architecture review, performance review, security audit, code quality anti-patterns, + and common bugs across all ecosystems. + Use when: reviewing pull requests, conducting PR reviews, code review, reviewing code changes, + establishing review standards, mentoring developers, architecture reviews, security audits, + performance reviews, checking code quality, finding bugs, giving feedback on code. +allowed-tools: + - Read + - Grep + - Glob + - Bash # 运行 lint/test/build 命令验证代码质量 + - WebFetch # 查阅最新文档和最佳实践 +--- + +# Code Review Skill + +## Review-Agent Plan Directory + +Use this machine-readable directory when an Agent plans automated checks. Select only actions +whose `when` condition and `requires_documents` match the loaded review context. Commands are +run through the Skill after Agent policy approval; do not invent additional commands. + +```review-agent-plan +{"actions":[ + {"id":"analyze_pr","when":"always","command":["python","scripts/pr-analyzer.py","--diff-file","../../work/inputs/input.diff"]}, + {"id":"python_lint","when":"python","requires_documents":["reference/python.md"],"command":["ruff","check","{{changed_python_files}}"]}, + {"id":"python_tests","when":"python_tests","requires_documents":["reference/python.md"],"command":["pytest","{{changed_test_files}}"]} +]} +``` + +Transform code reviews from gatekeeping to knowledge sharing through constructive feedback, systematic analysis, and collaborative improvement. + +## When to Use This Skill + +- Reviewing pull requests and code changes +- Establishing code review standards for teams +- Mentoring junior developers through reviews +- Conducting architecture reviews +- Creating review checklists and guidelines +- Improving team collaboration +- Reducing code review cycle time +- Maintaining code quality standards + +## Core Principles + +### 1. The Review Mindset + +**Goals of Code Review:** +- Catch bugs and edge cases +- Ensure code maintainability +- Share knowledge across team +- Enforce coding standards +- Improve design and architecture +- Build team culture + +**Not the Goals:** +- Show off knowledge +- Nitpick formatting (use linters) +- Block progress unnecessarily +- Rewrite to your preference + +### 2. Effective Feedback + +**Good Feedback is:** +- Specific and actionable +- Educational, not judgmental +- Focused on the code, not the person +- Balanced (praise good work too) +- Prioritized (critical vs nice-to-have) + +```markdown +❌ Bad: "This is wrong." +✅ Good: "This could cause a race condition when multiple users + access simultaneously. Consider using a mutex here." + +❌ Bad: "Why didn't you use X pattern?" +✅ Good: "Have you considered the Repository pattern? It would + make this easier to test. Here's an example: [link]" + +❌ Bad: "Rename this variable." +✅ Good: "[nit] Consider `userCount` instead of `uc` for + clarity. Not blocking if you prefer to keep it." +``` + +### 3. Review Scope + +**What to Review:** +- Logic correctness and edge cases +- Security vulnerabilities +- Performance implications +- Test coverage and quality +- Error handling +- Documentation and comments +- API design and naming +- Architectural fit + +**What Not to Review Manually:** +- Code formatting (use Prettier, Black, etc.) +- Import organization +- Linting violations +- Simple typos + +## Review Process + +### Phase 1: Context Gathering (2-3 minutes) + +Before diving into code, understand: +1. Read PR description and linked issue +2. Check PR size (>400 lines? Ask to split) +3. Review CI/CD status (tests passing?) +4. Understand the business requirement +5. Note any relevant architectural decisions + +> For large diffs, pipe the diff through [`scripts/pr-analyzer.py`](scripts/pr-analyzer.py) (`git diff main...HEAD | python scripts/pr-analyzer.py`) to triage complexity and get a suggested review approach before reading. + +### Phase 2: High-Level Review (5-10 minutes) + +1. **Architecture & Design** - Does the solution fit the problem? + - For significant changes, consult [Architecture Review Guide](reference/architecture-review-guide.md) + - Check: SOLID principles, coupling/cohesion, anti-patterns +2. **Performance Assessment** - Are there performance concerns? + - For performance-critical code, consult [Performance Review Guide](reference/performance-review-guide.md) + - Check: Algorithm complexity, N+1 queries, memory usage +3. **File Organization** - Are new files in the right places? +4. **Testing Strategy** - Are there tests covering edge cases? + +### Phase 3: Line-by-Line Review (10-20 minutes) + +For each file, check: +- **Logic & Correctness** - Edge cases, off-by-one, null checks, race conditions +- **Security** - Input validation, injection risks, XSS, sensitive data +- **Performance** - N+1 queries, unnecessary loops, memory leaks +- **Maintainability** - Clear names, single responsibility, comments +- **Reuse** - Before accepting new code, search for existing utilities/helpers that could replace it. Check adjacent files and shared modules for similar patterns. See [Universal Quality Guide](reference/code-quality-universal.md) for anti-patterns like parameter sprawl, leaky abstractions, nested conditionals, stringly-typed code, TOCTOU, and no-op updates. + +### Phase 4: Summary & Decision (2-3 minutes) + +1. Summarize key concerns +2. Highlight what you liked +3. Make clear decision: + - ✅ Approve + - 💬 Comment (minor suggestions) + - 🔄 Request Changes (must address) +4. Offer to pair if complex + +## Review Techniques + +### Technique 1: The Checklist Method + +Use checklists for consistent reviews. See [Security Review Guide](reference/security-review-guide.md) for comprehensive security checklist. + +### Technique 2: The Question Approach + +Instead of stating problems, ask questions: + +```markdown +❌ "This will fail if the list is empty." +✅ "What happens if `items` is an empty array?" + +❌ "You need error handling here." +✅ "How should this behave if the API call fails?" +``` + +### Technique 3: Suggest, Don't Command + +Use collaborative language: + +```markdown +❌ "You must change this to use async/await" +✅ "Suggestion: async/await might make this more readable. What do you think?" + +❌ "Extract this into a function" +✅ "This logic appears in 3 places. Would it make sense to extract it?" +``` + +### Technique 4: Differentiate Severity + +Use labels to indicate priority: + +- 🔴 `[blocking]` - Must fix before merge +- 🟡 `[important]` - Should fix, discuss if disagree +- 🟢 `[nit]` - Nice to have, not blocking +- 💡 `[suggestion]` - Alternative approach to consider +- 📚 `[learning]` - Educational comment, no action needed +- 🎉 `[praise]` - Good work, keep it up! + +**Severity levels:** 🔴 / 🟡 / 🟢 are the three severity tiers used as the standard across all guides in this skill — 🔴 blocks the merge, 🟡 should be addressed, 🟢 is optional. The remaining markers (💡 / 📚 / 🎉) are non-blocking annotations. + +## Language-Specific Guides + +根据审查的代码语言,查阅对应的详细指南: + +| Language/Framework | Reference File | Key Topics | +|-------------------|----------------|------------| +| **React** | [React Guide](reference/react.md) | Hooks, useEffect, React 19 Actions, RSC, Suspense, TanStack Query v5 | +| **Vue 3** | [Vue Guide](reference/vue.md) | Composition API, 响应性系统, Props/Emits, Watchers, Composables | +| **Angular 17+** | [Angular Guide](reference/angular.md) | Signals, Standalone, RxJS, Zoneless, 模板优化, 测试, 路由守卫, HttpInterceptor | +| **Rust** | [Rust Guide](reference/rust.md) | 所有权/借用, Unsafe 审查, 异步代码, 取消安全性, 错误处理 | +| **TypeScript** | [TypeScript Guide](reference/typescript.md) | 类型安全, async/await, 不可变性, 测试, 模块解析, TS 5.x | +| **Python** | [Python Guide](reference/python.md) | 可变默认参数, 异常处理, 类属性 | +| **Django / DRF** | [Django Guide](reference/django.md) | 安全审查, N+1 查询, Serializer 反模式, ViewSet, 异步视图 | +| **FastAPI** | [FastAPI Guide](reference/fastapi.md) | Depends, Pydantic v2 validation, async correctness, sessions/N+1, auth vs authorization, test-driven verification | +| **Java** | [Java Guide](reference/java.md) | Java 17/21 新特性, Spring Boot 3, 虚拟线程, Stream/Optional | +| **Java 8 / Legacy** | [Java 8 Guide](reference/java8.md) | Java 8, Spring Boot 2, javax.*, Stream/Optional, java.time, CompletableFuture | +| **PHP** | [PHP Guide](reference/php.md) | PHP 8.x type system, PDO, security review, Composer, PHPUnit/PHPStan | +| **Ruby / Rails** | [Ruby Guide](reference/ruby.md) | Ruby semantics, Rails 8, Active Record, Active Job, security, testing | +| **C# / .NET** | [C# Guide](reference/csharp.md) | C# 12 特性, 异步编程, EF Core 性能, ASP.NET Core, LINQ | +| **Go** | [Go Guide](reference/go.md) | 错误处理, goroutine/channel, context, 接口设计 | +| **Kotlin / Android** | [Kotlin Guide](reference/kotlin.md) | 协程, Flow, Jetpack Compose, 空安全, 内存泄漏, 架构模式 | +| **Swift / SwiftUI** | [Swift Guide](reference/swift.md) | Optionals, Swift Concurrency, Sendable/actors, SwiftUI property wrappers, value vs reference types, API design | +| **NestJS** | [NestJS Guide](reference/nestjs.md) | 依赖注入, 分层架构, DTO 验证, Guard/Interceptor, 循环依赖 | +| **Svelte / SvelteKit** | [Svelte Guide](reference/svelte.md) | Runes, Load 函数, Form Actions, Store 迁移, SSR/CSR 边界 | +| **C** | [C Guide](reference/c.md) | 指针/缓冲区, 内存安全, UB, 安全编码, 可移植性, 测试 | +| **C++** | [C++ Guide](reference/cpp.md) | RAII, 智能指针, C++20/23, constexpr, 测试 | +| **Zig** | [Zig Guide](reference/zig.md) | Allocators, error unions, defer/errdefer, comptime, C interop | +| **CSS/Less/Sass** | [CSS Guide](reference/css-less-sass.md) | 变量规范, !important, 性能优化, 响应式, 兼容性 | +| **Qt** | [Qt Guide](reference/qt.md) | 对象模型, 信号/槽, Model/View, QML, Qt6 迁移, 测试 | + +## Cross-Cutting Guides + +Language-agnostic patterns applicable to all code reviews: + +| Topic | Reference File | Key Topics | +|-------|----------------|------------| +| **Architecture Review** | [Architecture Review Guide](reference/architecture-review-guide.md) | SOLID, anti-patterns, coupling/cohesion, dependency direction | +| **Performance Review** | [Performance Review Guide](reference/performance-review-guide.md) | Web Vitals, N+1, algorithm complexity, memory leaks, caching | +| **Security Review** | [Security Review Guide](reference/security-review-guide.md) | SQLi, XSS, CSRF, SSRF, IDOR, 命令注入, 跨语言示例 | +| **Universal Quality** | [Universal Quality Guide](reference/code-quality-universal.md) | Reuse audit, parameter sprawl, leaky abstractions, nested conditionals, stringly-typed code, TOCTOU, no-op updates, redundant state | +| **Common Bugs** | [Common Bugs Checklist](reference/common-bugs-checklist.md) | Language-specific bug patterns, common pitfalls | +| **SQL Injection Prevention** | [SQL Injection Guide](reference/cross-cutting/sql-injection-prevention.md) | Parameterized queries, ORM safety, 6 languages, dynamic identifiers, detection | +| **XSS Prevention** | [XSS Prevention Guide](reference/cross-cutting/xss-prevention.md) | Output encoding, CSP, 5 frameworks, input validation vs encoding, detection | +| **N+1 Queries** | [N+1 Queries Guide](reference/cross-cutting/n-plus-one-queries.md) | Eager loading, batch fetching, DataLoader, 5 languages, detection | +| **Error Handling** | [Error Handling Guide](reference/cross-cutting/error-handling-principles.md) | Fail fast, error hierarchy, 7 languages, anti-patterns, logging | +| **Async & Concurrency** | [Concurrency Guide](reference/cross-cutting/async-concurrency-patterns.md) | Goroutines, async/await, actors, structured concurrency, 7 languages | +| **Review Best Practices** | [Code Review Best Practices](reference/code-review-best-practices.md) | Communication, reviewer mindset, giving feedback, severity labels | + +## Additional Resources + +- [PR Review Template](assets/pr-review-template.md) - PR 审查评论模板 +- [Review Checklist](assets/review-checklist.md) - 快速参考清单 diff --git a/examples/skills_code_review_agent/skills/code-review/assets/pr-review-template.md b/examples/skills_code_review_agent/skills/code-review/assets/pr-review-template.md new file mode 100644 index 000000000..9efd227e6 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/assets/pr-review-template.md @@ -0,0 +1,137 @@ +# PR Review Template + +Copy and use this template for your code reviews. + +--- + +## Summary + +[Brief overview of what was reviewed - 1-2 sentences] + +**PR Size:** [Small/Medium/Large] (~X lines) +**Review Time:** [X minutes] + +## Strengths + +- [What was done well] +- [Good patterns or approaches used] +- [Improvements from previous code] + +## Architecture & Performance + +**Architecture Assessment** +- [ ] Separation of concerns — are responsibilities clearly divided? +- [ ] Module responsibilities — does each module have a single purpose? +- [ ] Dependency direction — do dependencies flow toward stability? +- [ ] Consistent with existing patterns and conventions + +> See [Architecture Review Guide](../reference/architecture-review-guide.md) for detailed SOLID, anti-pattern, and coupling analysis. + +**Performance Assessment** +- [ ] Algorithm complexity — any O(n²) or worse on large inputs? +- [ ] Memory impact — large allocations, leaks, unbounded growth? +- [ ] I/O impact — excessive API calls, unbatched writes, missing caching? +- [ ] Database queries — N+1 risks, missing indexes, unoptimized joins? + +> See [Performance Review Guide](../reference/performance-review-guide.md) for comprehensive Web Vitals, N+1, and caching guidance. + +## Required Changes + +🔴 **[blocking]** [Issue description] +> [Code location or example] +> [Suggested fix or explanation] + +🔴 **[blocking]** [Issue description] +> [Details] + +## Important Suggestions + +🟡 **[important]** [Issue description] +> [Why this matters] +> [Suggested approach] + +## Minor Suggestions + +🟢 **[nit]** [Minor improvement suggestion] + +💡 **[suggestion]** [Alternative approach to consider] + +## Learning Notes + +📚 [Educational context worth sharing about X] + +📚 [Background behind design decision Y] + +## Security Considerations + +- [ ] No hardcoded secrets +- [ ] Input validation present +- [ ] Authorization checks in place +- [ ] No SQL/XSS injection risks +- [ ] CSRF protection for state-changing operations +- [ ] Sensitive data not leaked in logs/errors +- [ ] Dependency vulnerabilities checked (npm audit / pip audit / cargo audit) + +> See [Security Review Guide](../reference/security-review-guide.md) for comprehensive injection, XSS, CSRF, secrets, and auth checklist. + +## Test Coverage + +- [ ] Unit tests added/updated +- [ ] Edge cases covered +- [ ] Error cases tested + +## Verdict + +**[ ] ✅ Approve** - Ready to merge +**[ ] 💬 Comment** - Minor suggestions, can merge +**[ ] 🔄 Request Changes** - Must address blocking issues + +--- + +## Quick Copy Templates + +### Blocking Issue +``` +🔴 **[blocking]** [Title] + +[Description of the issue] + +**Location:** `file.ts:123` + +**Suggested fix:** +\`\`\`typescript +// Your suggested code +\`\`\` +``` + +### Important Suggestion +``` +🟡 **[important]** [Title] + +[Why this is important] + +**Consider:** +- Option A: [description] +- Option B: [description] +``` + +### Minor Suggestion +``` +🟢 **[nit]** [Suggestion] + +Not blocking, but consider [improvement]. +``` + +### Praise +``` +🎉 **[praise]** Great work on [specific thing]! + +[Why this is good] +``` + +### Learning +``` +📚 **[learning]** [Educational note] + +For context, [X] works this way because [Y]. No action needed — just sharing. +``` diff --git a/examples/skills_code_review_agent/skills/code-review/assets/review-checklist.md b/examples/skills_code_review_agent/skills/code-review/assets/review-checklist.md new file mode 100644 index 000000000..c7963333f --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/assets/review-checklist.md @@ -0,0 +1,123 @@ +# Code Review Quick Checklist + +Quick reference checklist for code reviews. + +## Pre-Review (2 min) + +- [ ] Read PR description and linked issue +- [ ] Check PR size (<400 lines ideal) +- [ ] Verify CI/CD status (tests passing?) +- [ ] Understand the business requirement + +## Architecture & Design (5 min) + +- [ ] Solution fits the problem +- [ ] Consistent with existing patterns +- [ ] No simpler approach exists +- [ ] Will it scale? +- [ ] Changes in right location + +## Logic & Correctness (10 min) + +- [ ] Edge cases handled +- [ ] Null/undefined checks present +- [ ] Off-by-one errors checked +- [ ] Race conditions considered +- [ ] Error handling complete +- [ ] Correct data types used + +## Security (5 min) + +- [ ] No hardcoded secrets +- [ ] Input validated/sanitized +- [ ] SQL injection prevented +- [ ] XSS prevented +- [ ] Authorization checks present +- [ ] Sensitive data protected + +## Performance (3 min) + +- [ ] No N+1 queries +- [ ] Expensive operations optimized +- [ ] Large lists paginated +- [ ] No memory leaks +- [ ] Caching considered where appropriate + +## Testing (5 min) + +- [ ] Tests exist for new code +- [ ] Edge cases tested +- [ ] Error cases tested +- [ ] Tests are readable +- [ ] Tests are deterministic + +## Code Quality (3 min) + +- [ ] Clear variable/function names +- [ ] No code duplication +- [ ] Functions do one thing +- [ ] Complex code commented +- [ ] No magic numbers + +## Documentation (2 min) + +- [ ] Public APIs documented +- [ ] README updated if needed +- [ ] Breaking changes noted +- [ ] Complex logic explained + +--- + +## Severity Labels + +| Label | Meaning | Action | +|-------|---------|--------| +| 🔴 `[blocking]` | Must fix | Block merge | +| 🟡 `[important]` | Should fix | Discuss if disagree | +| 🟢 `[nit]` | Nice to have | Non-blocking | +| 💡 `[suggestion]` | Alternative | Consider | +| 📚 `[learning]` | Educational comment | No action needed | +| 🎉 `[praise]` | Good work | Celebrate! | + +--- + +## Decision Matrix + +| Situation | Decision | +|-----------|----------| +| Critical security issue | 🔴 Block, fix immediately | +| Breaking change without migration | 🔴 Block | +| Missing error handling | 🟡 Should fix | +| No tests for new code | 🟡 Should fix | +| Style preference | 🟢 Non-blocking | +| Minor naming improvement | 🟢 Non-blocking | +| Clever but working code | 💡 Suggest simpler | + +--- + +## Time Budget + +This checklist is designed for a **lightweight quick review**. For comprehensive reviews covering architecture and performance analysis, use the full four-phase process in [SKILL.md](../SKILL.md) (19–36 minutes). Smaller PRs trend toward the lower end of each phase; larger PRs toward the upper end. + +| PR Size | Quick Review | Full Review (4-phase) | +|---------|-------------|----------------------| +| < 100 lines | 10–15 min | ~19–28 min | +| 100–400 lines | 20–40 min | ~28–36 min | +| > 400 lines | Ask to split | Ask to split | + +--- + +## Red Flags + +Watch for these patterns: + +- `// TODO` in production code +- `console.log` left in code +- Commented out code +- `any` type in TypeScript +- Empty catch blocks +- `unwrap()` in Rust production code +- Magic numbers/strings +- Copy-pasted code blocks +- Missing null checks +- Hardcoded URLs/credentials diff --git a/examples/skills_code_review_agent/skills/code-review/reference/angular.md b/examples/skills_code_review_agent/skills/code-review/reference/angular.md new file mode 100644 index 000000000..f7e135701 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/angular.md @@ -0,0 +1,768 @@ +# Angular Code Review Guide + +> Angular 17+ 代码审查指南,覆盖 Signals、Standalone 组件、RxJS 反模式、Zoneless 变更检测、模板最佳实践及性能优化等核心主题。 + +## 目录 + +- [Signals 与变更检测](#signals-与变更检测) +- [Standalone 组件迁移](#standalone-组件迁移) +- [RxJS 反模式](#rxjs-反模式) +- [Zoneless 变更检测](#zoneless-变更检测) +- [模板最佳实践](#模板最佳实践) +- [性能优化](#性能优化) +- [测试](#测试) +- [路由守卫](#路由守卫) +- [依赖注入模式](#依赖注入模式) +- [HttpInterceptor](#httpinterceptor) +- [Review Checklist](#review-checklist) + +--- + +## Signals 与变更检测 + +### Signal + OnPush 自动触发变更检测 + +```typescript +// ❌ 可变状态 + OnPush = 界面不更新 +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: `

{{ data.name }}

`, +}) +export class UserProfile { + data = { name: 'Alice' }; + changeName() { this.data.name = 'Bob'; } // UI 不会更新! +} + +// ✅ Signal + OnPush = 自动变更检测 +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + template: `

{{ name() }}

`, +}) +export class UserProfile { + name = signal('Alice'); + changeName() { this.name.set('Bob'); } // 自动触发 CD +} +``` + +### @Input() 对象变异不会被 OnPush 检测 + +```typescript +// ❌ 变异 Input 对象——引用不变,OnPush 不检测 +@Input() config!: Config; +updateConfig() { this.config.theme = 'dark'; } + +// ✅ 创建新引用 +updateConfig() { this.config = { ...this.config, theme: 'dark' }; } +``` + +### computed() 用于派生状态 + +```typescript +// ❌ effect 用于同步状态——反模式,可能触发额外 CD 周期 +export class CartComponent { + total = signal(0); + discounted = signal(0); + + constructor() { + effect(() => this.discounted.set(this.total() * 0.9)); + } +} + +// ✅ computed 用于派生状态——惰性计算,无副作用 +export class CartComponent { + total = signal(0); + discounted = computed(() => this.total() * 0.9); +} +``` + +### effect() 中 Signal 读取在 await 后不会被追踪 + +```typescript +// ❌ await 之后读取 Signal——依赖未被追踪 +effect(async () => { + const data = await fetchUserData(); + console.log(`Theme: ${theme()}`); // theme() 未被追踪! +}); + +// ✅ 在 await 之前同步读取 +effect(async () => { + const currentTheme = theme(); // 同步读取,被追踪 + const data = await fetchUserData(); + console.log(`Theme: ${currentTheme}`); +}); +``` + +### effect 只在特定场景使用 + +```typescript +// ❌ 用 effect 同步两个 Signal——永远用 computed +effect(() => { this.filtered.set(this.items().filter(i => i.active)); }); + +// ✅ effect 的合理场景:DOM 操作、分析日志、订阅外部源 +effect(() => { + const canvas = this.canvasRef.nativeElement; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = this.color(); + ctx.fillRect(0, 0, this.size(), this.size()); +}); + +// 💡 "There are no situations where effect is good, +// only situations where it is appropriate." +``` + +--- + +## Standalone 组件迁移 + +### Angular 19+ standalone 是默认值 + +```typescript +// ❌ Legacy NgModule 组件 +@Component({ + selector: 'old-component', + standalone: false, +}) +export class OldComponent {} + +// ✅ 现代 Standalone 组件(Angular 19+ standalone 是默认值) +@Component({ + selector: 'user-profile', + imports: [ProfilePhoto, RouterLink], + template: `Edit`, +}) +export class UserProfile {} +``` + +### 审查标记 + +```typescript +// ⚠️ 需要迁移的信号: +// 1. standalone: false +// 2. @NgModule declarations +// 3. 组件通过 NgModule 而非直接 import + +// ✅ 迁移路径: +// 1. 删除 standalone: false +// 2. 将依赖添加到组件的 imports 数组 +// 3. 如果不再有 declarations,删除 NgModule +``` + +--- + +## RxJS 反模式 + +### subscribe() 必须配 takeUntilDestroyed + +```typescript +// ❌ 裸 subscribe——内存泄漏!组件销毁后仍继续接收数据 +@Component({ /* ... */ }) +export class UserProfile implements OnInit { + ngOnInit() { + this.data$.subscribe(data => this.processData(data)); + } +} + +// ✅ takeUntilDestroyed——自动在组件销毁时取消(需在构造函数或注入上下文中调用) +@Component({ /* ... */ }) +export class UserProfile { + constructor() { + this.data$.pipe(takeUntilDestroyed()).subscribe(data => { + this.processData(data); + }); + } +} + +// ✅ 在构造函数外使用——传入 DestroyRef +@Component({ /* ... */ }) +export class UserProfile { + private destroyRef = inject(DestroyRef); + + startListening() { + this.data$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(/* ... */); + } +} +``` + +### toSignal 优于 AsyncPipe + +```typescript +// ❌ AsyncPipe——需要导入,模板中有 | async +@Component({ + imports: [AsyncPipe], + template: `{{ data$ | async }}`, +}) + +// ✅ toSignal——自动取消订阅,可在任何地方使用 +export class UserProfile { + data = toSignal(this.data$, { initialValue: null }); + // 模板直接用 data() +} +``` + +### 避免重复 toSignal 调用 + +```typescript +// ❌ toSignal 每次调用都创建新订阅 +getData() { + return toSignal(this.http.get('/api/data')); +} + +// ✅ 存储结果 +data = toSignal(this.http.get('/api/data'), { initialValue: null }); +``` + +--- + +## Zoneless 变更检测 + +### 普通属性变异不会被检测(Angular 21+) + +```typescript +// ❌ Zoneless 下普通属性赋值不触发 CD +export class UserService { + user: User | null = null; + loadUser() { this.user = fetchResult; } // 不触发! +} + +// ✅ Signal 自动触发 CD +export class UserService { + private _user = signal(null); + readonly user = this._user.asReadonly(); + loadUser() { this._user.set(fetchResult); } +} +``` + +### NgZone API 在 Zoneless 中失效 + +```typescript +// ❌ NgZone.onStable 在 zoneless 中永远不会触发 +ngZone.onStable.subscribe(() => { /* 永远不触发 */ }); + +// ✅ 使用 afterNextRender +afterNextRender({ write: () => { /* CD 之后执行 */ } }); +``` + +### Reactive Forms 变异需要 markForCheck + +```typescript +// ❌ Reactive Forms 的 setValue/patchValue 在 zoneless 中不自动调度 CD +this.form.patchValue({ name: 'Alice' }); // UI 可能不更新 + +// ✅ 手动标记或通过 Signal 反映 +this.form.patchValue({ name: 'Alice' }); +this.cdr.markForCheck(); +``` + +### Zoneless 下有效的 CD 触发器 + +| 触发器 | 说明 | +|--------|------| +| `signal.set()` / `.update()` | Signal 更新自动触发 | +| `ChangeDetectorRef.markForCheck()` | 手动标记 | +| `ComponentRef.setInput()` | 输入绑定 | +| 模板事件监听器回调 | 用户交互 | + +--- + +## 模板最佳实践 + +### 复杂逻辑提取为 computed Signal + +```typescript +// ❌ 模板中复杂表达式 +template: `
` + +// ✅ 提取为 computed +filteredItems = computed(() => this.items().filter(i => i.active)); +shouldShow = computed(() => this.filteredItems().length > 0 && this.user().role === 'admin'); +template: `@if (shouldShow()) {
...
}` +``` + +### 原生绑定优于 NgClass / NgStyle + +```typescript +// ❌ NgClass/NgStyle——额外指令开销 +template: `
` + +// ✅ 原生 class/style 绑定——性能更好 +template: `
` +``` + +### 模板专用成员标记 protected + +```typescript +// ❂ 模板专用方法暴露为 public +export class UserProfile { + formatName(name: string) { return name.trim(); } +} + +// ✅ 模板专用成员用 protected +export class UserProfile { + protected formatName(name: string) { return name.trim(); } +} +``` + +### Angular 管理的属性标记 readonly + +```typescript +// ❌ input/output/model 可被意外覆盖 +userId = input(); +userSaved = output(); + +// ✅ readonly 防止意外赋值 +readonly userId = input(); +readonly userSaved = output(); +readonly userName = model(); +``` + +### 命名规范:操作名而非事件名 + +```typescript +// ❌ 以事件命名 +template: `` + +// ✅ 以操作命名 +template: `` +``` + +--- + +## 性能优化 + +### effect 是最后手段——优先 computed + +```typescript +// ❌ effect 用于状态同步——触发额外 CD,可能无限循环 +effect(() => { + this.filteredItems.set(this.items().filter(i => i.active)); +}); + +// ✅ computed——惰性计算,无副作用,无额外 CD +filteredItems = computed(() => this.items().filter(i => i.active)); +``` + +### afterRenderEffect 分离读写阶段 + +```typescript +// ❌ 无阶段指定 = mixedReadWrite = 额外 DOM 回流 +afterRenderEffect(() => { + const height = el.offsetHeight; // 读 + el.style.height = height + 10 + 'px'; // 写 +}); + +// ✅ 分离阶段减少回流 +afterRenderEffect({ + earlyRead: () => el.offsetHeight, + write: (height) => { el.style.height = height() + 10 + 'px'; }, + read: () => verifyLayout(), +}); +``` + +### inject() 优于构造函数注入 + +```typescript +// ❌ 构造函数注入——多依赖时难以阅读 +export class UserService { + constructor( + private http: HttpClient, + private router: Router, + private auth: AuthService, + ) {} +} + +// ✅ inject()——更好的类型推断和可读性 +export class UserService { + private http = inject(HttpClient); + private router = inject(Router); + private auth = inject(AuthService); +} +``` + +--- + +--- + +## 测试 + +### 组件测试(TestBed) + +```typescript +// ✅ 独立组件测试 +@Component({ + standalone: true, + template: ``, +}) +export class CounterComponent { + count = signal(0); + increment() { this.count.update(c => c + 1); } +} + +describe('CounterComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CounterComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(CounterComponent); + fixture.detectChanges(); + }); + + it('should increment on click', () => { + const button = fixture.nativeElement.querySelector('button'); + button.click(); + fixture.detectChanges(); + expect(button.textContent.trim()).toBe('1'); + }); +}); +``` + +### 服务测试(依赖注入 Mock) + +```typescript +// ✅ 使用 TestBed.inject + provide 覆盖 +@Injectable({ providedIn: 'root' }) +export class UserService { + private http = inject(HttpClient); + getUser(id: number) { + return this.http.get(`/api/users/${id}`); + } +} + +describe('UserService', () => { + let service: UserService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(UserService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => httpMock.verify()); + + it('should fetch user', () => { + const mockUser = { id: 1, name: 'Alice' }; + + service.getUser(1).subscribe(user => { + expect(user).toEqual(mockUser); + }); + + const req = httpMock.expectOne('/api/users/1'); + expect(req.request.method).toBe('GET'); + req.flush(mockUser); + }); +}); +``` + +### 集成测试策略 + +```typescript +// ❌ 过度 Mock——测试的是 Mock 而非真实行为 +provideHttpClient: () => ({ + get: jasmine.createSpy().and.returnValue(of(mockData)), +}), + +// ✅ 使用 HttpTestingController 验证真实 HTTP 交互 +TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ], +}); + +// ✅ 浅渲染:只测试组件本身,Mock 子组件 +describe('UserProfile', () => { + it('should pass user to child', () => { + const fixture = TestBed.createComponent(UserProfile); + fixture.componentRef.setInput('user', testUser); + fixture.detectChanges(); + + const child = fixture.debugElement.query(By.directive(UserAvatar)); + expect(child.componentInstance.user()).toEqual(testUser); + }); +}); +``` + +--- + +## 路由守卫 + +### AuthGuard / CanActivate + +```typescript +// ✅ 函数式路由守卫(Angular 15+ 推荐) +export const authGuard: CanActivateFn = (route, state) => { + const auth = inject(AuthService); + const router = inject(Router); + + if (auth.isAuthenticated()) return true; + + return router.createUrlTree(['/login'], { + queryParams: { returnUrl: state.url }, + }); +}; + +// 使用 +export const routes: Routes = [ + { + path: 'dashboard', + component: DashboardComponent, + canActivate: [authGuard], + }, +]; +``` + +### 延迟加载路由守卫 + +```typescript +// ✅ 守卫在路由加载时才被解析 +// auth.guard.ts +export const authGuard: CanActivateFn = () => { + const auth = inject(AuthService); + return auth.isAuthenticated(); +}; + +// routes.ts +export const routes: Routes = [ + { + path: 'admin', + loadChildren: () => import('./admin/routes').then(m => m.routes), + canActivate: [authGuard], + }, +]; +``` + +### 参数化路由守卫 + +```typescript +// ✅ 带角色参数的守卫 +export function roleGuard(allowedRoles: string[]): CanActivateFn { + return (route, state) => { + const auth = inject(AuthService); + const user = auth.currentUser(); + return user ? allowedRoles.includes(user.role) : false; + }; +} + +// 使用 +{ + path: 'admin', + component: AdminComponent, + canActivate: [roleGuard(['admin', 'superadmin'])], +} +``` + +### CanDeactivate 守卫 + +```typescript +// ✅ 防止未保存修改的导航离开 +export const unsavedChangesGuard: CanDeactivateFn = ( + component +) => { + if (component.hasUnsavedChanges()) { + return confirm('You have unsaved changes. Leave anyway?'); + } + return true; +}; +``` + +--- + +## 依赖注入模式 + +### InjectionToken 使用 + +```typescript +// ❌ 使用字符串 token——易冲突且无类型安全 +providers: [{ provide: 'API_URL', useValue: 'https://api.example.com' }] + +// ✅ InjectionToken 提供类型安全 +export const API_URL = new InjectionToken('API_URL'); + +providers: [{ provide: API_URL, useValue: 'https://api.example.com' }] + +// 使用 +private apiUrl = inject(API_URL); +``` + +### 多级提供者 + +```typescript +// ✅ 不同注入层级 +// 根级——全局单例 +@Injectable({ providedIn: 'root' }) +export class GlobalService {} + +// 组件级——每个组件实例独立 +@Component({ + providers: [LocalService], +}) +export class MyComponent { + private local = inject(LocalService); +} + +// 路由级——路由及其子路由共享 +{ + path: 'checkout', + providers: [CheckoutService], + children: [/* ... */], +} +``` + +### 工厂提供者 + +```typescript +// ✅ 根据条件动态提供不同实现 +export const themeProvider: FactoryProvider = { + provide: ThemeService, + useFactory: () => { + const platform = inject(PLATFORM_ID); + if (isPlatformServer(platform)) { + return new ServerThemeService(); + } + return new BrowserThemeService(); + }, +}; + +// ✅ 使用环境变量配置 +export const apiConfigProvider: FactoryProvider = { + provide: ApiConfig, + useFactory: () => { + const env = inject(ENVIRONMENT); + return env.production + ? new ProductionApiConfig() + : new DevelopmentApiConfig(); + }, +}; +``` + +--- + +## HttpInterceptor + +### 认证 Token 拦截器 + +```typescript +// ✅ 函数式拦截器——自动附加 Auth Token +export function authInterceptor( + req: HttpRequest, + next: HttpHandlerFn +): Observable> { + const token = inject(AuthService).token(); + if (!token) return next(req); + + return next(req.clone({ + setHeaders: { Authorization: `Bearer ${token}` }, + })); +} + +// 注册 +provideHttpClient(withInterceptors([authInterceptor])) +``` + +### 错误处理拦截器 + +```typescript +// ✅ 函数式拦截器——统一错误处理 +export function errorInterceptor( + req: HttpRequest, + next: HttpHandlerFn +): Observable> { + const router = inject(Router); + + return next(req).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 401) { + router.navigate(['/login']); + } + if (error.status === 500) { + console.error('Server error:', error); + } + return throwError(() => error); + }) + ); +} +``` + +### 请求/响应转换 + +```typescript +// ✅ 函数式拦截器——自动 camelCase ↔ snake_case +export function transformInterceptor( + req: HttpRequest, + next: HttpHandlerFn +): Observable> { + const transformedBody = req.body ? toSnakeCase(req.body) : null; + const transformedReq = req.clone({ body: transformedBody }); + + return next(transformedReq).pipe( + map(event => { + if (event instanceof HttpResponse) { + return event.clone({ body: toCamelCase(event.body) }); + } + return event; + }) + ); +} +``` + +### 拦截器顺序 + +```typescript +// ✅ 拦截器按注册顺序执行 +// 请求:A → B → C → 后端 +// 响应:后端 → C → B → A +provideHttpClient( + withInterceptors([ + authInterceptor, + loggingInterceptor, + errorInterceptor, + ]) +) +``` + +## Review Checklist + +### Signals 与变更检测 + +- [ ] Signal + OnPush 用于模板状态(非可变对象) +- [ ] `@Input()` 对象通过新引用更新(非变异) +- [ ] 派生状态用 `computed()`,不用 `effect()` +- [ ] `effect()` 中 Signal 读取在 `await` 之前 +- [ ] `effect()` 只用于 DOM 操作、日志、外部源订阅 + +### Standalone 组件 + +- [ ] 无 `standalone: false`(Angular 19+) +- [ ] 组件通过 `imports` 数组导入依赖 +- [ ] 无不必要的 `@NgModule` + +### RxJS + +- [ ] `.subscribe()` 配 `takeUntilDestroyed` 或 `async` pipe +- [ ] 优先 `toSignal` 而非 `AsyncPipe` +- [ ] 无重复 `toSignal` 调用 + +### Zoneless + +- [ ] 模板状态通过 Signal 管理(非普通属性) +- [ ] 无 `NgZone.onStable` / `NgZone.onMicrotaskEmpty` +- [ ] Reactive Forms 变异后有 `markForCheck()` + +### 模板 + +- [ ] 复杂逻辑提取为 `computed` Signal +- [ ] 使用原生 `[class]`/`[style]` 而非 `NgClass`/`NgStyle` +- [ ] 模板专用成员标记 `protected` +- [ ] `input`/`output`/`model` 属性标记 `readonly` +- [ ] 事件处理器以操作命名(`saveData` 而非 `handleClick`) + +### 性能 + +- [ ] `effect()` 不用于状态同步 +- [ ] `afterRenderEffect` 分离读写阶段 +- [ ] `inject()` 用于依赖注入 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/architecture-review-guide.md b/examples/skills_code_review_agent/skills/code-review/reference/architecture-review-guide.md new file mode 100644 index 000000000..abde68ce4 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/architecture-review-guide.md @@ -0,0 +1,472 @@ +# Architecture Review Guide + +架构设计审查指南,帮助评估代码的架构是否合理、设计是否恰当。 + +## SOLID 原则检查清单 + +### S - 单一职责原则 (SRP) + +**检查要点:** +- 这个类/模块是否只有一个改变的理由? +- 类中的方法是否都服务于同一个目的? +- 如果要向非技术人员描述这个类,能否用一句话说清楚? + +**代码审查中的识别信号:** +``` +⚠️ 类名包含 "And"、"Manager"、"Handler"、"Processor" 等泛化词汇 +⚠️ 一个类超过 200-300 行代码 +⚠️ 类有超过 5-7 个公共方法 +⚠️ 不同的方法操作完全不同的数据 +``` + +**审查问题:** +- "这个类负责哪些事情?能否拆分?" +- "如果 X 需求变化,哪些方法需要改?如果 Y 需求变化呢?" + +### O - 开闭原则 (OCP) + +**检查要点:** +- 添加新功能时,是否需要修改现有代码? +- 是否可以通过扩展(继承、组合)来添加新行为? +- 是否存在大量的 if/else 或 switch 语句来处理不同类型? + +**代码审查中的识别信号:** +``` +⚠️ switch/if-else 链处理不同类型 +⚠️ 添加新功能需要修改核心类 +⚠️ 类型检查 (instanceof, typeof) 散布在代码中 +``` + +**审查问题:** +- "如果要添加新的 X 类型,需要修改哪些文件?" +- "这个 switch 语句会随着新类型增加而增长吗?" + +### L - 里氏替换原则 (LSP) + +**检查要点:** +- 子类是否可以完全替代父类使用? +- 子类是否改变了父类方法的预期行为? +- 是否存在子类抛出父类未声明的异常? + +**代码审查中的识别信号:** +``` +⚠️ 显式类型转换 (casting) +⚠️ 子类方法抛出 NotImplementedException +⚠️ 子类方法为空实现或只有 return +⚠️ 使用基类的地方需要检查具体类型 +``` + +**审查问题:** +- "如果用子类替换父类,调用方代码是否需要修改?" +- "这个方法在子类中的行为是否符合父类的契约?" + +### I - 接口隔离原则 (ISP) + +**检查要点:** +- 接口是否足够小且专注? +- 实现类是否被迫实现不需要的方法? +- 客户端是否依赖了它不使用的方法? + +**代码审查中的识别信号:** +``` +⚠️ 接口超过 5-7 个方法 +⚠️ 实现类有空方法或抛出 NotImplementedException +⚠️ 接口名称过于宽泛 (IManager, IService) +⚠️ 不同的客户端只使用接口的部分方法 +``` + +**审查问题:** +- "这个接口的所有方法是否都被每个实现类使用?" +- "能否将这个大接口拆分为更小的专用接口?" + +### D - 依赖倒置原则 (DIP) + +**检查要点:** +- 高层模块是否依赖于抽象而非具体实现? +- 是否使用依赖注入而非直接 new 对象? +- 抽象是否由高层模块定义而非低层模块? + +**代码审查中的识别信号:** +``` +⚠️ 高层模块直接 new 低层模块的具体类 +⚠️ 导入具体实现类而非接口/抽象类 +⚠️ 配置和连接字符串硬编码在业务逻辑中 +⚠️ 难以为某个类编写单元测试 +``` + +**审查问题:** +- "这个类的依赖能否在测试时被 mock 替换?" +- "如果要更换数据库/API 实现,需要修改多少地方?" + +--- + +## 架构反模式识别 + +### 致命反模式 + +| 反模式 | 识别信号 | 影响 | +|--------|----------|------| +| **大泥球 (Big Ball of Mud)** | 没有清晰的模块边界,任何代码都可能调用任何其他代码 | 难以理解、修改和测试 | +| **上帝类 (God Object)** | 单个类承担过多职责,知道太多、做太多 | 高耦合,难以重用和测试 | +| **意大利面条代码** | 控制流程混乱,goto 或深层嵌套,难以追踪执行路径 | 难以理解和维护 | +| **熔岩流 (Lava Flow)** | 没人敢动的古老代码,缺乏文档和测试 | 技术债务累积 | + +### 设计反模式 + +| 反模式 | 识别信号 | 建议 | +|--------|----------|------| +| **金锤子 (Golden Hammer)** | 对所有问题使用同一种技术/模式 | 根据问题选择合适的解决方案 | +| **过度工程 (Gas Factory)** | 简单问题用复杂方案解决,滥用设计模式 | YAGNI 原则,先简单后复杂 | +| **船锚 (Boat Anchor)** | 为"将来可能需要"而写的未使用代码 | 删除未使用代码,需要时再写 | +| **复制粘贴编程** | 相同逻辑出现在多处 | 提取公共方法或模块 | + +### 审查问题 + +```markdown +🔴 [blocking] "这个类有 2000 行代码,建议拆分为多个专注的类" +🟡 [important] "这段逻辑在 3 个地方重复,考虑提取为公共方法?" +💡 [suggestion] "这个 switch 语句可以用策略模式替代,更易扩展" +``` + +--- + +## 耦合度与内聚性评估 + +### 耦合类型(从好到差) + +| 类型 | 描述 | 示例 | +|------|------|------| +| **消息耦合** ✅ | 通过参数传递数据 | `calculate(price, quantity)` | +| **数据耦合** ✅ | 共享简单数据结构 | `processOrder(orderDTO)` | +| **印记耦合** ⚠️ | 共享复杂数据结构但只用部分 | 传入整个 User 对象但只用 name | +| **控制耦合** ⚠️ | 传递控制标志影响行为 | `process(data, isAdmin=true)` | +| **公共耦合** ❌ | 共享全局变量 | 多个模块读写同一个全局状态 | +| **内容耦合** ❌ | 直接访问另一模块的内部 | 直接操作另一个类的私有属性 | + +### 内聚类型(从好到差) + +| 类型 | 描述 | 质量 | +|------|------|------| +| **功能内聚** | 所有元素完成单一任务 | ✅ 最佳 | +| **顺序内聚** | 输出作为下一步输入 | ✅ 良好 | +| **通信内聚** | 操作相同数据 | ⚠️ 可接受 | +| **时间内聚** | 同时执行的任务 | ⚠️ 较差 | +| **逻辑内聚** | 逻辑相关但功能不同 | ❌ 差 | +| **偶然内聚** | 没有明显关系 | ❌ 最差 | + +### 度量指标参考 + +```yaml +耦合指标: + CBO (类间耦合): + 好: < 5 + 警告: 5-10 + 危险: > 10 + + Ce (传出耦合): + 描述: 依赖多少外部类 + 好: < 7 + + Ca (传入耦合): + 描述: 被多少类依赖 + 高值意味着: 修改影响大,需要稳定 + +内聚指标: + LCOM4 (方法缺乏内聚): + 1: 单一职责 ✅ + 2-3: 可能需要拆分 ⚠️ + >3: 应该拆分 ❌ +``` + +### 审查问题 + +- "这个模块依赖了多少其他模块?能否减少?" +- "修改这个类会影响多少其他地方?" +- "这个类的方法是否都操作相同的数据?" + +--- + +## 分层架构审查 + +### Clean Architecture 层次检查 + +``` +┌─────────────────────────────────────┐ +│ Frameworks & Drivers │ ← 最外层:Web、DB、UI +├─────────────────────────────────────┤ +│ Interface Adapters │ ← Controllers、Gateways、Presenters +├─────────────────────────────────────┤ +│ Application Layer │ ← Use Cases、Application Services +├─────────────────────────────────────┤ +│ Domain Layer │ ← Entities、Domain Services +└─────────────────────────────────────┘ + ↑ 依赖方向只能向内 ↑ +``` + +### 依赖规则检查 + +**核心规则:源代码依赖只能指向内层** + +```typescript +// ❌ 违反依赖规则:Domain 层依赖 Infrastructure +// domain/User.ts +import { MySQLConnection } from '../infrastructure/database'; + +// ✅ 正确:Domain 层定义接口,Infrastructure 实现 +// domain/UserRepository.ts (接口) +interface UserRepository { + findById(id: string): Promise; +} + +// infrastructure/MySQLUserRepository.ts (实现) +class MySQLUserRepository implements UserRepository { + findById(id: string): Promise { /* ... */ } +} +``` + +### 审查清单 + +**层次边界检查:** +- [ ] Domain 层是否有外部依赖(数据库、HTTP、文件系统)? +- [ ] Application 层是否直接操作数据库或调用外部 API? +- [ ] Controller 是否包含业务逻辑? +- [ ] 是否存在跨层调用(UI 直接调用 Repository)? + +**关注点分离检查:** +- [ ] 业务逻辑是否与展示逻辑分离? +- [ ] 数据访问是否封装在专门的层? +- [ ] 配置和环境相关代码是否集中管理? + +### 审查问题 + +```markdown +🔴 [blocking] "Domain 实体直接导入了数据库连接,违反依赖规则" +🟡 [important] "Controller 包含业务计算逻辑,建议移到 Service 层" +💡 [suggestion] "考虑使用依赖注入来解耦这些组件" +``` + +--- + +## 设计模式使用评估 + +### 何时使用设计模式 + +| 模式 | 适用场景 | 不适用场景 | +|------|----------|------------| +| **Factory** | 需要创建不同类型对象,类型在运行时确定 | 只有一种类型,或类型固定不变 | +| **Strategy** | 算法需要在运行时切换,有多种可互换的行为 | 只有一种算法,或算法不会变化 | +| **Observer** | 一对多依赖,状态变化需要通知多个对象 | 简单的直接调用即可满足需求 | +| **Singleton** | 确实需要全局唯一实例,如配置管理 | 可以通过依赖注入传递的对象 | +| **Decorator** | 需要动态添加职责,避免继承爆炸 | 职责固定,不需要动态组合 | + +### 过度设计警告信号 + +``` +⚠️ Patternitis(模式炎)识别信号: + +1. 简单的 if/else 被替换为策略模式 + 工厂 + 注册表 +2. 只有一个实现的接口 +3. 为了"将来可能需要"而添加的抽象层 +4. 代码行数因模式应用而大幅增加 +5. 新人需要很长时间才能理解代码结构 +``` + +### 审查原则 + +```markdown +✅ 正确使用模式: +- 解决了实际的可扩展性问题 +- 代码更容易理解和测试 +- 添加新功能变得更简单 + +❌ 过度使用模式: +- 为了使用模式而使用 +- 增加了不必要的复杂度 +- 违反了 YAGNI 原则 +``` + +### 审查问题 + +- "使用这个模式解决了什么具体问题?" +- "如果不用这个模式,代码会有什么问题?" +- "这个抽象层带来的价值是否大于它的复杂度?" + +--- + +## 可扩展性评估 + +### 扩展性检查清单 + +**功能扩展性:** +- [ ] 添加新功能是否需要修改核心代码? +- [ ] 是否提供了扩展点(hooks、plugins、events)? +- [ ] 配置是否外部化(配置文件、环境变量)? + +**数据扩展性:** +- [ ] 数据模型是否支持新增字段? +- [ ] 是否考虑了数据量增长的场景? +- [ ] 查询是否有合适的索引? + +**负载扩展性:** +- [ ] 是否可以水平扩展(添加更多实例)? +- [ ] 是否有状态依赖(session、本地缓存)? +- [ ] 数据库连接是否使用连接池? + +### 扩展点设计检查 + +```typescript +// ✅ 好的扩展设计:使用事件/钩子 +class OrderService { + private hooks: OrderHooks; + + async createOrder(order: Order) { + await this.hooks.beforeCreate?.(order); + const result = await this.save(order); + await this.hooks.afterCreate?.(result); + return result; + } +} + +// ❌ 差的扩展设计:硬编码所有行为 +class OrderService { + async createOrder(order: Order) { + await this.sendEmail(order); // 硬编码 + await this.updateInventory(order); // 硬编码 + await this.notifyWarehouse(order); // 硬编码 + return await this.save(order); + } +} +``` + +### 审查问题 + +```markdown +💡 [suggestion] "如果将来需要支持新的支付方式,这个设计是否容易扩展?" +🟡 [important] "这里的逻辑是硬编码的,考虑使用配置或策略模式?" +📚 [learning] "事件驱动架构可以让这个功能更容易扩展" +``` + +--- + +## 代码结构最佳实践 + +### 目录组织 + +**按功能/领域组织(推荐):** +``` +src/ +├── user/ +│ ├── User.ts (实体) +│ ├── UserService.ts (服务) +│ ├── UserRepository.ts (数据访问) +│ └── UserController.ts (API) +├── order/ +│ ├── Order.ts +│ ├── OrderService.ts +│ └── ... +└── shared/ + ├── utils/ + └── types/ +``` + +**按技术层组织(不推荐):** +``` +src/ +├── controllers/ ← 不同领域混在一起 +│ ├── UserController.ts +│ └── OrderController.ts +├── services/ +├── repositories/ +└── models/ +``` + +### 命名约定检查 + +| 类型 | 约定 | 示例 | +|------|------|------| +| 类名 | PascalCase,名词 | `UserService`, `OrderRepository` | +| 方法名 | camelCase,动词 | `createUser`, `findOrderById` | +| 接口名 | I 前缀或无前缀 | `IUserService` 或 `UserService` | +| 常量 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` | +| 私有属性 | 下划线前缀或无 | `_cache` 或 `#cache` | + +### 文件大小指南 + +```yaml +建议限制: + 单个文件: < 300 行 + 单个函数: < 50 行 + 单个类: < 200 行 + 函数参数: < 4 个 + 嵌套深度: < 4 层 + +超出限制时: + - 考虑拆分为更小的单元 + - 使用组合而非继承 + - 提取辅助函数或类 +``` + +### 审查问题 + +```markdown +🟢 [nit] "这个 500 行的文件可以考虑按职责拆分" +🟡 [important] "建议按功能领域而非技术层组织目录结构" +💡 [suggestion] "函数名 `process` 不够明确,考虑改为 `calculateOrderTotal`?" +``` + +--- + +## 快速参考清单 + +### 架构审查 5 分钟速查 + +```markdown +□ 依赖方向是否正确?(外层依赖内层) +□ 是否存在循环依赖? +□ 核心业务逻辑是否与框架/UI/数据库解耦? +□ 是否遵循 SOLID 原则? +□ 是否存在明显的反模式? +``` + +### 红旗信号(必须处理) + +```markdown +🔴 God Object - 单个类超过 1000 行 +🔴 循环依赖 - A → B → C → A +🔴 Domain 层包含框架依赖 +🔴 硬编码的配置和密钥 +🔴 没有接口的外部服务调用 +``` + +### 黄旗信号(建议处理) + +```markdown +🟡 类间耦合度 (CBO) > 10 +🟡 方法参数超过 5 个 +🟡 嵌套深度超过 4 层 +🟡 重复代码块 > 10 行 +🟡 只有一个实现的接口 +``` + +--- + +## 工具推荐 + +| 工具 | 用途 | 语言支持 | +|------|------|----------| +| **SonarQube** | 代码质量、耦合度分析 | 多语言 | +| **NDepend** | 依赖分析、架构规则 | .NET | +| **JDepend** | 包依赖分析 | Java | +| **Madge** | 模块依赖图 | JavaScript/TypeScript | +| **ESLint** | 代码规范、复杂度检查 | JavaScript/TypeScript | +| **CodeScene** | 技术债务、热点分析 | 多语言 | + +--- + +## 参考资源 + +- [Clean Architecture - Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) +- [SOLID Principles in Code Review - JetBrains](https://blog.jetbrains.com/upsource/2015/08/31/what-to-look-for-in-a-code-review-solid-principles-2/) +- [Software Architecture Anti-Patterns](https://medium.com/@christophnissle/anti-patterns-in-software-architecture-3c8970c9c4f5) +- [Coupling and Cohesion in System Design](https://www.geeksforgeeks.org/system-design/coupling-and-cohesion-in-system-design/) +- [Design Patterns - Refactoring Guru](https://refactoring.guru/design-patterns) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/c.md b/examples/skills_code_review_agent/skills/code-review/reference/c.md new file mode 100644 index 000000000..5ef026f76 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/c.md @@ -0,0 +1,890 @@ +# C Code Review Guide + +> C code review guide focused on memory safety, undefined behavior, portability, testing, and secure coding. Examples assume C11/C17. + +## Table of Contents + +- [Pointer and Buffer Safety](#pointer-and-buffer-safety) +- [Ownership and Resource Management](#ownership-and-resource-management) +- [Undefined Behavior Pitfalls](#undefined-behavior-pitfalls) +- [Integer Types and Overflow](#integer-types-and-overflow) +- [Error Handling](#error-handling) +- [Concurrency](#concurrency) +- [Macros and Preprocessor](#macros-and-preprocessor) +- [API Design and Const](#api-design-and-const) +- [Secure Coding Practices](#secure-coding-practices) +- [Cross-Platform Portability](#cross-platform-portability) +- [Testing](#testing) +- [Tooling and Build Checks](#tooling-and-build-checks) +- [Review Checklist](#review-checklist) + +--- + +## Pointer and Buffer Safety + +### Always carry size with buffers + +```c +// ❌ Bad: ignores destination size +bool copy_name(char *dst, size_t dst_size, const char *src) { + strcpy(dst, src); + return true; +} + +// ✅ Good: validate size and terminate +bool copy_name(char *dst, size_t dst_size, const char *src) { + size_t len = strlen(src); + if (len + 1 > dst_size) { + return false; + } + memcpy(dst, src, len + 1); + return true; +} +``` + +### Avoid dangerous APIs + +Prefer `snprintf`, `fgets`, and explicit bounds over `gets`, `strcpy`, or `sprintf`. + +```c +// ❌ Bad: unbounded write +sprintf(buf, "%s", input); + +// ✅ Good: bounded write +snprintf(buf, buf_size, "%s", input); +``` + +### Use the right copy primitive + +```c +// ❌ Bad: memcpy with overlapping regions +memcpy(dst, src, len); + +// ✅ Good: memmove handles overlap +memmove(dst, src, len); +``` + +### Validate pointer arguments + +```c +// ❌ Bad: no NULL check +int process(char *buf, size_t len) { + buf[0] = '\0'; + return 0; +} + +// ✅ Good: validate before use +int process(char *buf, size_t len) { + if (!buf || len == 0) { + return -EINVAL; + } + buf[0] = '\0'; + return 0; +} +``` + +### Beware of pointer-to-pointer pitfalls + +```c +// ❌ Bad: caller cannot distinguish success from failure +void allocate(int **out) { + *out = malloc(sizeof(int)); +} + +// ✅ Good: return status, set output only on success +int allocate(int **out) { + if (!out) return -EINVAL; + int *p = malloc(sizeof(int)); + if (!p) return -ENOMEM; + *out = p; + return 0; +} +``` + +--- + +## Ownership and Resource Management + +### One allocation, one free + +Track ownership and clean up on every error path. + +```c +// ✅ Good: cleanup label avoids leaks +int load_file(const char *path) { + int rc = -1; + FILE *f = NULL; + char *buf = NULL; + + f = fopen(path, "rb"); + if (!f) { + goto cleanup; + } + buf = malloc(4096); + if (!buf) { + goto cleanup; + } + + if (fread(buf, 1, 4096, f) == 0) { + goto cleanup; + } + + rc = 0; + +cleanup: + free(buf); + if (f) { + fclose(f); + } + return rc; +} +``` + +### Document ownership transfer + +```c +// ✅ Good: comment clarifies that caller takes ownership +// Caller must free() the returned buffer. +char *read_line(FILE *f); + +// ✅ Good: comment clarifies that callee does NOT take ownership +// The function borrows `buf`; caller retains ownership. +int parse_header(const char *buf, size_t len, struct Header *out); +``` + +### Free exactly once, set pointer to NULL + +```c +// ❌ Bad: double free possible +void destroy(struct Cache *c) { + free(c->entries); + // caller might call destroy() again → double free +} + +// ✅ Good: NULL after free prevents double free +void destroy(struct Cache *c) { + if (!c) return; + free(c->entries); + c->entries = NULL; + c->count = 0; +} +``` + +--- + +## Undefined Behavior Pitfalls + +### Signed integer overflow + +Signed overflow is UB in C; unsigned wraps around. + +```c +// ❌ Bad: signed overflow is UB +int sum = INT_MAX + 1; // undefined behavior + +// ✅ Good: check before overflow +if (a > 0 && b > INT_MAX - a) { + return -EOVERFLOW; +} +int sum = a + b; +``` + +### Dangling pointers + +```c +// ❌ Bad: returning pointer to local array +char *greet(void) { + char buf[64]; + snprintf(buf, sizeof(buf), "hello"); + return buf; // UB: buf is gone when function returns +} + +// ✅ Good: caller provides buffer or use static storage +void greet(char *out, size_t out_size) { + snprintf(out, out_size, "hello"); +} +``` + +### Uninitialized variables + +```c +// ❌ Bad: x may be anything +int x; +if (x > 0) { /* UB: reading uninitialized automatic variable */ } + +// ✅ Good: always initialize +int x = 0; +if (x > 0) { /* well-defined */ } +``` + +### Sequence point violations + +```c +// ❌ Bad: undefined — order of evaluation of operands +int i = 0; +int a[] = { i++, i++ }; // UB: two modifications without sequence point + +// ❌ Bad: modification and read without sequence point +int j = i + i++; // UB + +// ✅ Good: separate statements +int a0 = i++; +int a1 = i++; +int a[] = { a0, a1 }; +``` + +### Strict aliasing violations + +```c +// ❌ Bad: violates strict aliasing +float f = 3.14f; +int i = *(int *)&f; // UB + +// ✅ Good: use memcpy or union (C11 allows type-punning via union) +int i; +memcpy(&i, &f, sizeof(i)); + +// ✅ Also acceptable in C11: +union { float f; int i; } u; +u.f = 3.14f; +int i = u.i; +``` + +### Shift operations + +```c +// ❌ Bad: shift by negative or >= width is UB +int x = 1 << 32; // UB if int is 32-bit +int y = 1 << -1; // UB + +// ✅ Good: validate shift amount +if (shift >= 0 && shift < (int)(sizeof(int) * CHAR_BIT)) { + int result = 1 << shift; +} +``` + +--- + +## Integer Types and Overflow + +### Avoid signed/unsigned surprises + +```c +// ❌ Bad: negative converted to large size_t +int len = -1; +size_t n = len; // wraps to SIZE_MAX + +// ✅ Good: validate before converting +if (len < 0) { + return -1; +} +size_t n = (size_t)len; +``` + +### Check for overflow in size calculations + +```c +// ❌ Bad: potential overflow in multiplication +size_t bytes = count * sizeof(Item); + +// ✅ Good: check before multiplying +if (count > SIZE_MAX / sizeof(Item)) { + return NULL; +} +size_t bytes = count * sizeof(Item); +``` + +### Use fixed-width types for binary protocols + +```c +// ❌ Bad: int size varies by platform +struct PacketHeader { + int type; + int length; +}; + +// ✅ Good: explicit widths for wire format +#include +struct PacketHeader { + uint32_t type; + uint32_t length; +}; +``` + +### Beware of implicit promotion + +```c +// ❌ Bad: uint8_t promotes to int in arithmetic +uint8_t a = 200, b = 100; +uint8_t sum = a + b; // truncation: 300 → 44 + +// ✅ Good: be explicit about width +uint16_t sum = (uint16_t)a + (uint16_t)b; // 300 +``` + +--- + +## Error Handling + +### Always check return values + +```c +// ❌ Bad: ignore errors +fread(buf, 1, size, f); + +// ✅ Good: handle errors +size_t read = fread(buf, 1, size, f); +if (read != size && ferror(f)) { + return -1; +} +``` + +### Consistent error contracts + +- Use a clear convention: 0 for success, negative for failure. +- Document ownership rules on success and failure. +- If using `errno`, set it only for actual failures. + +```c +// ✅ Good: clear error contract with errno +// Returns 0 on success, -1 on failure (sets errno). +// On failure, *out is unchanged. +int parse_int(const char *s, int *out); +``` + +### Avoid errno across function boundaries + +```c +// ❌ Bad: errno may be overwritten by intermediate calls +errno = 0; +long val = strtol(s, &end, 10); +log_debug("parsed: %ld", val); // might change errno! +if (errno != 0) { /* unreliable */ } + +// ✅ Good: capture errno immediately +errno = 0; +long val = strtol(s, &end, 10); +int saved_errno = errno; +log_debug("parsed: %ld", val); +if (saved_errno != 0) { /* reliable */ } +``` + +--- + +## Concurrency + +### volatile is not synchronization + +```c +// ❌ Bad: data race +volatile int stop = 0; +void worker(void) { + while (!stop) { /* ... */ } +} + +// ✅ Good: C11 atomics +_Atomic int stop = 0; +void worker(void) { + while (!atomic_load(&stop)) { /* ... */ } +} +``` + +### Use mutexes for shared state + +Protect shared data with `pthread_mutex_t` or equivalent. Avoid holding locks while doing I/O. + +```c +// ✅ Good: mutex + RAII-style cleanup +static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER; +static int g_counter = 0; + +void increment(void) { + pthread_mutex_lock(&g_lock); + g_counter++; + pthread_mutex_unlock(&g_lock); +} +``` + +### Avoid lock ordering issues + +```c +// ❌ Bad: inconsistent lock ordering → deadlock +// Thread 1: lock(A); lock(B); +// Thread 2: lock(B); lock(A); + +// ✅ Good: always acquire locks in the same order +// All threads: lock(A); lock(B); +``` + +--- + +## Macros and Preprocessor + +### Parenthesize arguments + +```c +// ❌ Bad: macro with side effects +#define MIN(a, b) ((a) < (b) ? (a) : (b)) +int x = MIN(i++, j++); // evaluates argument twice + +// ✅ Good: static inline function +static inline int min_int(int a, int b) { + return a < b ? a : b; +} +``` + +### Multi-statement macros + +```c +// ❌ Bad: breaks in if-else without braces +#define LOG_AND_RETURN(msg) \ + fprintf(stderr, "%s\n", msg); \ + return -1 + +// ✅ Good: do { ... } while(0) idiom +#define LOG_AND_RETURN(msg) do { \ + fprintf(stderr, "%s\n", msg); \ + return -1; \ +} while (0) +``` + +### Include guards + +```c +// ✅ Good: traditional include guard +#ifndef MY_HEADER_H +#define MY_HEADER_H +// ... declarations ... +#endif /* MY_HEADER_H */ + +// ✅ Also acceptable (non-standard but widely supported): +#pragma once +``` + +--- + +## API Design and Const + +### Const-correctness and sizes + +```c +// ✅ Good: explicit size and const input +int hash_bytes(const uint8_t *data, size_t len, uint8_t *out); +``` + +### Document nullability + +Clearly document whether pointers may be NULL. Prefer returning error codes instead of NULL when possible. + +```c +// ✅ Good: document contract in the header +// @param name Non-NULL, NUL-terminated string. +// @param out Non-NULL output pointer. +// @return 0 on success, -EINVAL if name or out is NULL. +int lookup(const char *name, struct Result *out); +``` + +### Opaque types for encapsulation + +```c +// ✅ Good: header exposes only a pointer +typedef struct Parser Parser; + +Parser *parser_create(const char *input); +int parser_next(Parser *p, struct Token *out); +void parser_destroy(Parser *p); +``` + +--- + +## Secure Coding Practices + +### CERT C: buffer overflow prevention + +```c +// ❌ Bad: strncpy does NOT guarantee NUL termination +char dst[32]; +strncpy(dst, src, sizeof(dst)); // if src >= 32 bytes, dst is not terminated! + +// ✅ Good: explicit NUL termination after strncpy +char dst[32]; +strncpy(dst, src, sizeof(dst) - 1); +dst[sizeof(dst) - 1] = '\0'; + +// ✅ Better: use snprintf for bounded string copy +char dst[32]; +snprintf(dst, sizeof(dst), "%s", src); +``` + +### Format string vulnerability + +```c +// ❌ Bad: user-controlled format string +printf(user_input); // if user_input = "%x %x %x", reads stack + +// ✅ Good: always use a format literal +printf("%s", user_input); +``` + +### Integer overflow in allocation + +```c +// ❌ Bad: count * size may overflow before malloc sees it +void *items = malloc(count * sizeof(Item)); + +// ✅ Good: check for overflow +if (count != 0 && SIZE_MAX / count < sizeof(Item)) { + errno = ENOMEM; + return NULL; +} +void *items = malloc(count * sizeof(Item)); + +// ✅ Also good: use calloc (checks internally) +Item *items = calloc(count, sizeof(Item)); +``` + +### Validate external input lengths + +```c +// ❌ Bad: trusting header-declared length +struct Msg { uint32_t len; char data[]; }; +void handle(struct Msg *m) { + char buf[256]; + memcpy(buf, m->data, m->len); // attacker controls m->len +} + +// ✅ Good: validate before use +void handle(struct Msg *m, size_t total_size) { + if (m->len > total_size - sizeof(struct Msg)) { + return -EINVAL; + } + char buf[256]; + if (m->len > sizeof(buf)) { + return -E2BIG; + } + memcpy(buf, m->data, m->len); +} +``` + +### Avoid TOCTOU race conditions + +```c +// ❌ Bad: check-then-use is a race (TOCTOU) +if (access(path, R_OK) == 0) { + FILE *f = fopen(path, "r"); // file may have changed between access() and fopen() +} + +// ✅ Good: try and check the result +FILE *f = fopen(path, "r"); +if (!f) { + // handle error (ENOENT, EACCES, etc.) +} +``` + +### Secure temporary files + +```c +// ❌ Bad: predictable name +char path[] = "/tmp/myapp_XXXXXX"; +FILE *f = fopen(path, "w"); // predictable, race condition + +// ✅ Good: mkstemp creates and opens atomically +char tmpl[] = "/tmp/myapp_XXXXXX"; +int fd = mkstemp(tmpl); +if (fd < 0) { /* handle error */ } +FILE *f = fdopen(fd, "w"); +``` + +--- + +## Cross-Platform Portability + +### Preprocessor conditionals best practices + +```c +// ❌ Bad: nested #ifdef soup +#ifdef _WIN32 + #ifdef _WIN64 + // 64-bit Windows + #else + // 32-bit Windows + #endif +#else + #ifdef __linux__ + // Linux + #endif +#endif + +// ✅ Good: abstract behind feature macros +#if defined(PLATFORM_WINDOWS) + #include "platform_win.h" +#elif defined(PLATFORM_LINUX) + #include "platform_linux.h" +#elif defined(PLATFORM_MACOS) + #include "platform_macos.h" +#else + #error "Unsupported platform" +#endif +``` + +### Byte order (endianness) + +```c +// ❌ Bad: assumes little-endian +uint32_t read_u32(const uint8_t *buf) { + return *(const uint32_t *)buf; // alignment + endianness issues +} + +// ✅ Good: explicit byte-order handling +static inline uint32_t read_u32_le(const uint8_t *buf) { + return (uint32_t)buf[0] + | ((uint32_t)buf[1] << 8) + | ((uint32_t)buf[2] << 16) + | ((uint32_t)buf[3] << 24); +} + +static inline uint32_t read_u32_be(const uint8_t *buf) { + return ((uint32_t)buf[0] << 24) + | ((uint32_t)buf[1] << 16) + | ((uint32_t)buf[2] << 8) + | (uint32_t)buf[3]; +} +``` + +### Alignment-aware access + +```c +// ❌ Bad: unaligned access is UB on many architectures +uint32_t val = *(const uint32_t *)ptr; + +// ✅ Good: memcpy is safe for any alignment +uint32_t val; +memcpy(&val, ptr, sizeof(val)); +``` + +### Avoid platform-specific extensions in portable code + +```c +// ❌ Bad: GCC extension in shared code +typeof(x) y = x; + +// ✅ Good: use standard C or isolate extensions +// In a platform-specific header: +#ifdef __GNUC__ + #define TYPEOF(x) typeof(x) +#else + #define TYPEOF(x) decltype(x) /* C++23 or compiler-specific */ +#endif +``` + +### Use feature detection, not platform detection + +```c +// ❌ Bad: assumes POSIX because Linux +#ifdef __linux__ + #include +#endif + +// ✅ Good: feature test via CMake/configure +#ifdef HAVE_MMAP + #include +#endif +``` + +--- + +## Testing + +### Choosing a test framework + +| Framework | Use Case | Notes | +|-----------|----------|-------| +| **Unity** | Embedded / bare-metal | Single-file, no dependencies, C89 compatible | +| **CUnit** | Desktop / CI | Richer assertions, HTML/XML output | +| **CMocka** | System-level code | Mocking via function pointers, works with `setjmp`/`longjmp` | + +### Basic test structure with Unity + +```c +#include "unity.h" +#include "parser.h" + +void setUp(void) { /* runs before each test */ } +void tearDown(void) { /* runs after each test */ } + +void test_parse_empty_string_returns_null(void) { + struct Token *t = parse(""); + TEST_ASSERT_NULL(t); +} + +void test_parse_valid_integer(void) { + struct Token *t = parse("42"); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_INT(TOKEN_INT, t->type); + TEST_ASSERT_EQUAL_INT(42, t->value); + token_free(t); +} + +void test_parse_negative_number(void) { + struct Token *t = parse("-7"); + TEST_ASSERT_NOT_NULL(t); + TEST_ASSERT_EQUAL_INT(-7, t->value); + token_free(t); +} + +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_parse_empty_string_returns_null); + RUN_TEST(test_parse_valid_integer); + RUN_TEST(test_parse_negative_number); + return UNITY_END(); +} +``` + +### Test isolation: mock system calls + +```c +// ✅ Good: inject dependencies for testability +// Production code: +struct FileOps { + int (*read)(void *buf, size_t size, void *ctx); + void *ctx; +}; + +int load_config(const struct FileOps *ops, struct Config *out); + +// Test code: +static int mock_read(void *buf, size_t size, void *ctx) { + const char *data = (const char *)ctx; + size_t len = strlen(data); + if (len < size) size = len; + memcpy(buf, data, size); + return (int)size; +} + +void test_load_config_with_mock(void) { + const char *fake_data = "key=value\n"; + struct FileOps ops = { .read = mock_read, .ctx = (void *)fake_data }; + struct Config cfg; + int rc = load_config(&ops, &cfg); + TEST_ASSERT_EQUAL_INT(0, rc); + TEST_ASSERT_EQUAL_STRING("value", cfg.key); +} +``` + +### Memory leak testing with sanitizers + +```bash +# Run tests under AddressSanitizer +cc -fsanitize=address -fno-omit-frame-pointer -g -o test_runner tests/*.c src/*.c +./test_runner + +# Run tests under Valgrind +cc -g -O0 -o test_runner tests/*.c src/*.c +valgrind --leak-check=full --error-exitcode=1 ./test_runner +``` + +```c +// ✅ Good: test that error paths don't leak +void test_parse_invalid_frees_resources(void) { + // Valgrind/ASan will catch any leaks from this call + struct Token *t = parse("not_a_number"); + TEST_ASSERT_NULL(t); + // If parse() allocated internal state and forgot to free on error, + // the sanitizer will report it. +} +``` + +### Test edge cases systematically + +```c +void test_edge_cases(void) { + // Zero-length input + TEST_ASSERT_EQUAL_INT(-EINVAL, process(NULL, 0)); + + // Maximum valid input + char buf[256]; + memset(buf, 'a', sizeof(buf) - 1); + buf[sizeof(buf) - 1] = '\0'; + TEST_ASSERT_EQUAL_INT(0, process(buf, sizeof(buf) - 1)); + + // One byte over the limit + TEST_ASSERT_EQUAL_INT(-E2BIG, process(buf, sizeof(buf))); +} +``` + +--- + +## Tooling and Build Checks + +```bash +# Warnings +clang -Wall -Wextra -Werror -Wconversion -Wshadow -std=c11 ... + +# Sanitizers (debug builds) +clang -fsanitize=address,undefined -fno-omit-frame-pointer -g ... +clang -fsanitize=thread -fno-omit-frame-pointer -g ... + +# Static analysis +clang-tidy src/*.c -- -std=c11 +cppcheck --enable=warning,performance,portability src/ + +# Formatting +clang-format -i src/*.c include/*.h +``` + +### CI integration checklist + +```bash +# Typical CI pipeline for a C project +clang -Wall -Wextra -Werror -std=c11 -c src/*.c # compile with strict warnings +clang -fsanitize=address,undefined -g -o test test/*.c src/*.c # sanitizer build +./test # run tests +valgrind --leak-check=full --error-exitcode=1 ./test # memory check +cppcheck --error-exitcode=1 --enable=all src/ # static analysis +``` + +--- + +## Review Checklist + +### Memory and UB +- [ ] All buffers have explicit size parameters +- [ ] No out-of-bounds access or pointer arithmetic past objects +- [ ] No use after free or uninitialized reads +- [ ] Signed overflow and shift rules are respected +- [ ] Strict aliasing rules are respected +- [ ] Sequence point rules are respected + +### Secure Coding +- [ ] No format string vulnerabilities (user input never used as format) +- [ ] No unchecked allocation sizes (overflow in count * size) +- [ ] No TOCTOU races on file operations +- [ ] External input lengths are validated before use +- [ ] Temporary files use mkstemp or equivalent + +### API and Design +- [ ] Ownership rules are documented and consistent +- [ ] const-correctness is applied for inputs +- [ ] Error contracts are clear and consistent +- [ ] Pointer nullability is documented +- [ ] Opaque types used for encapsulation + +### Portability +- [ ] No unaligned memory access +- [ ] Byte order handled explicitly for wire/binary formats +- [ ] Fixed-width types used for binary protocols +- [ ] Platform-specific code isolated behind feature macros + +### Concurrency +- [ ] No data races on shared state +- [ ] volatile is not used for synchronization +- [ ] Locks are held for minimal time +- [ ] Lock ordering is consistent + +### Testing and Tooling +- [ ] Unit tests cover happy path, error paths, and edge cases +- [ ] Builds clean with warnings enabled (-Wall -Wextra -Werror) +- [ ] Sanitizers (ASan, UBSan) run on critical code paths +- [ ] Valgrind or ASan confirms no memory leaks +- [ ] Static analysis results are addressed diff --git a/examples/skills_code_review_agent/skills/code-review/reference/code-quality-universal.md b/examples/skills_code_review_agent/skills/code-review/reference/code-quality-universal.md new file mode 100644 index 000000000..97b3d3c59 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/code-quality-universal.md @@ -0,0 +1,488 @@ +# Universal Code Quality Anti-Patterns + +> 语言无关的代码质量反模式指南,覆盖代码复用、抽象泄漏、参数膨胀、嵌套条件、字符串类型化、TOCTOU、空操作更新等核心主题。适用于所有语言的 PR 审查。 + +## 目录 + +- [代码复用审查](#代码复用审查) +- [参数膨胀](#参数膨胀) +- [抽象泄漏](#抽象泄漏) +- [字符串类型化](#字符串类型化) +- [嵌套条件表达式](#嵌套条件表达式) +- [复制粘贴变种](#复制粘贴变种) +- [空操作更新](#空操作更新) +- [TOCTOU 竞争条件](#toctou-竞争条件) +- [过度宽泛操作](#过度宽泛操作) +- [冗余状态](#冗余状态) +- [通用质量审查清单](#通用质量审查清单) + +--- + +## 代码复用审查 + +Before accepting new code, search the existing codebase for reusable utilities. + +### 搜索现有工具函数 + +```python +# ❌ 新写的路径拼接逻辑——项目中已有 PathBuilder +def get_config_path(name): + base = os.environ.get("APP_ROOT", ".") + return os.path.join(base, "config", name + ".json") + +# ✅ 使用已有的 PathBuilder +def get_config_path(name): + return PathBuilder.config(f"{name}.json") +``` + +```javascript +// ❌ 手写 debounce——项目已有 lodash 或 utils/debounce.ts +function debounce(fn, ms) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }; +} + +// ✅ 使用已有的工具函数 +import { debounce } from "@/utils/debounce"; +``` + +**审查要点:** +- 新增函数是否与已有 utility 重名或功能重叠? +- inline 逻辑是否可以提取为已有模块的调用? +- 检查相邻文件和 shared/utils 目录 + +--- + +## 参数膨胀 + +### 函数参数不断增长 + +```python +# ❌ 每次新需求加一个参数 +def create_user(name, email, role, team, active, avatar_url, timezone): + ... + +# ✅ 使用配置对象 / dataclass +@dataclass +class CreateUserParams: + name: str + email: str + role: Role = Role.MEMBER + team: str | None = None + active: bool = True + avatar_url: str | None = None + timezone: str = "UTC" + +def create_user(params: CreateUserParams) -> User: + ... +``` + +```typescript +// ❌ 6+ 个 positional 参数 +function renderWidget( + title: string, width: number, height: number, + theme: string, collapsible: boolean, icon: string +) { ... } + +// ✅ Options object pattern +interface WidgetOptions { + title: string; + width?: number; + height?: number; + theme?: "light" | "dark"; + collapsible?: boolean; + icon?: string; +} +function renderWidget(options: WidgetOptions) { ... } +``` + +**审查要点:** +- 函数参数是否 ≥ 4 个?考虑 options object / dataclass +- 新参数是否只是布尔标志?考虑 enum 或 strategy pattern +- 是否有 `enable_x`, `disable_y` 这类互斥参数? + +--- + +## 抽象泄漏 + +### 暴露内部实现细节 + +```python +# ❌ 返回内部 ORM 对象——调用者被迫了解 SQLAlchemy +def get_users(): + return session.query(User).filter(User.active == True).all() + +# ✅ 返回 domain 对象,隐藏持久化层 +def get_active_users() -> list[UserDTO]: + rows = user_repo.find_active() + return [UserDTO.from_row(r) for r in rows] +``` + +```typescript +// ❌ 组件接收 API response 原始结构 + + +// ✅ 组件接收 domain 类型,adapter 处理映射 +interface UserSummary { + displayName: string; + avatarUrl: string; +} + +``` + +**审查要点:** +- 函数返回类型是否泄露底层实现(ORM, HTTP client, file format)? +- 组件/函数是否依赖外部系统的数据结构? +- 是否破坏了已有的抽象边界? + +--- + +## 字符串类型化 + +### 用原始字符串代替常量/枚举 + +```python +# ❌ Magic strings 散落各处 +if status == "active": + ... +if role == "admin": + ... + +# ✅ 使用 enum +class Status(StrEnum): + ACTIVE = "active" + SUSPENDED = "suspended" + ARCHIVED = "archived" + +if user.status == Status.ACTIVE: + ... +``` + +```typescript +// ❌ Raw string event names——拼写错误不会报错 +emitter.emit("userCreated", data); +emitter.on("usercreated", handler); // bug: typo + +// ✅ 常量或 branded type +const Events = { + USER_CREATED: "userCreated", + USER_SUSPENDED: "userSuspended", +} as const; +emitter.emit(Events.USER_CREATED, data); +``` + +**审查要点:** +- 是否用字符串代替了已有的 enum/union type? +- 事件名、action type、status 值是否散落在多个文件? +- 字符串比较是否 case-sensitive 但未验证? + +--- + +## 嵌套条件表达式 + +### 三元链和嵌套 if/else + +```python +# ❌ 三元链难以阅读 +label = ( + "Admin" if role == "admin" else + "Manager" if role == "manager" else + "Viewer" if role == "viewer" else + "Unknown" +) + +# ✅ 查找表或 match +ROLE_LABELS = { + "admin": "Admin", + "manager": "Manager", + "viewer": "Viewer", +} +label = ROLE_LABELS.get(role, "Unknown") +``` + +```typescript +// ❌ 嵌套三元 +const bg = isHovered + ? isSelected ? "blue" : "gray" + : isSelected ? "navy" : "white"; + +// ✅ 查找表(lookup map) +const bgMap: Record = { + "true-true": "blue", + "true-false": "gray", + "false-true": "navy", + "false-false": "white", +}; +const bg = bgMap[`${isHovered}-${isSelected}`]; +``` + +```python +# ❌ 嵌套 if 3+ 层 +def process(order): + if order is not None: + if order.items: + for item in order.items: + if item.price > 0: + ... + +# ✅ Early return + guard clauses +def process(order): + if not order or not order.items: + return + for item in order.items: + if item.price <= 0: + continue + ... +``` + +**审查要点:** +- 三元表达式是否嵌套 ≥ 2 层? +- if/else 嵌套是否 ≥ 3 层? +- 能否用 lookup table、early return 或 match 替换? + +--- + +## 复制粘贴变种 + +### 近乎重复的代码块 + +```python +# ❌ 两个函数几乎一样,只有字段名不同 +def format_user(user): + return f"{user.first_name} {user.last_name} ({user.email})" + +def format_employee(emp): + return f"{emp.first_name} {emp.last_name} ({emp.work_email})" + +# ✅ 统一抽象 +def format_person(first: str, last: str, email: str) -> str: + return f"{first} {last} ({email})" +``` + +```typescript +// ❌ Copy-paste handler 只改了 URL +async function deletePost(id: string) { + await fetch(`/api/posts/${id}`, { method: "DELETE" }); + router.push("/posts"); +} +async function deleteComment(id: string) { + await fetch(`/api/comments/${id}`, { method: "DELETE" }); + router.push("/comments"); +} + +// ✅ 参数化 +async function deleteResource(resource: string, id: string) { + await fetch(`/api/${resource}/${id}`, { method: "DELETE" }); + router.push(`/${resource}`); +} +``` + +**审查要点:** +- 是否有 ≥ 2 段代码仅变量名/URL/字符串不同? +- 能否提取参数化的共享函数? +- 是否可以用 template method 或 strategy 消除变种? + +--- + +## 空操作更新 + +### 无条件触发状态更新 + +```typescript +// ❌ 每次 poll 都触发 update——即使数据未变 +useEffect(() => { + const interval = setInterval(() => { + fetch("/api/status").then(r => r.json()).then(setStatus); + }, 5000); + return () => clearInterval(interval); +}, []); + +// ✅ 仅在值变化时更新 +useEffect(() => { + const interval = setInterval(() => { + fetch("/api/status") + .then(r => r.json()) + .then(data => { + setStatus(prev => isEqual(prev, data) ? prev : data); + }); + }, 5000); + return () => clearInterval(interval); +}, []); +``` + +```python +# ❌ 每次 loop 都写 DB——即使值未变 +for item in items: + item.status = compute_status(item) + session.commit() + +# ✅ 仅在变化时写入 +for item in items: + new_status = compute_status(item) + if item.status != new_status: + item.status = new_status + session.commit() +``` + +**审查要点:** +- polling / interval / event handler 是否无条件更新? +- wrapper function 是否尊重 same-reference return? +- DB 写入是否检查了实际变化? + +--- + +## TOCTOU 竞争条件 + +### Time-of-Check-to-Time-of-Use + +```python +# ❌ 先检查后操作——中间文件可能被删除/创建 +if os.path.exists(path): + with open(path) as f: + data = f.read() + +# ✅ 直接操作 + 处理异常 +try: + with open(path) as f: + data = f.read() +except FileNotFoundError: + data = None +``` + +```python +# ❌ 检查余额 → 扣款 两步操作不是原子的 +if account.balance >= amount: + account.balance -= amount + +# ✅ 原子操作或锁 +with account.lock: + if account.balance < amount: + raise InsufficientFundsError() + account.balance -= amount +``` + +```typescript +// ❌ Check-then-act 在 async 环境中不安全 +if (!fileExists(path)) { + await writeFile(path, content); +} + +// ✅ 直接操作 + catch +try { + await writeFile(path, content, { flag: "wx" }); +} catch (e) { + if (e.code === "EEXIST") { /* handle */ } + else throw e; +} +``` + +**审查要点:** +- `if exists → operate` 模式是否可替换为 `try operate → catch`? +- 多步状态变更是否在事务/锁内? +- async 操作中 check 和 act 之间是否有 await? + +--- + +## 过度宽泛操作 + +### 读取过多数据 + +```python +# ❌ 读取整个文件再取第一行 +content = Path("log.txt").read_text() +first_line = content.split("\n")[0] + +# ✅ 只读第一行,不加载整个文件 +with open("log.txt") as f: + first_line = f.readline() +``` + +```typescript +// ❌ 加载所有 items 再过滤 +const allItems = await db.query("SELECT * FROM orders"); +const pending = allItems.filter(o => o.status === "pending"); + +// ✅ 数据库层过滤 +const pending = await db.query( + "SELECT * FROM orders WHERE status = ?", ["pending"] +); +``` + +```python +# ❌ 读取整个列表找一条记录 +users = list(User.objects.all()) +user = next(u for u in users if u.id == user_id) + +# ✅ 精确查询 +user = User.objects.get(id=user_id) +``` + +**审查要点:** +- 是否读取了整个集合/文件再只用一小部分? +- 能否将过滤推到数据库/存储层? +- API 调用是否支持 pagination/limit 参数? + +--- + +## 冗余状态 + +### 状态可以被推导 + +```typescript +// ❌ 同时存储 fullName 和 firstName + lastName +interface User { + firstName: string; + lastName: string; + fullName: string; // redundant +} + +// ✅ fullName 是推导值 +interface User { + firstName: string; + lastName: string; +} +const fullName = `${user.firstName} ${user.lastName}`; +``` + +```python +# ❌ 缓存值在源数据变化时可能过时 +class Order: + total: float + item_count: int # redundant if len(items) gives the same + items: list[Item] + +# ✅ 推导或 property +class Order: + items: list[Item] + + @property + def total(self) -> float: + return sum(item.price for item in self.items) + + @property + def item_count(self) -> int: + return len(self.items) +``` + +**审查要点:** +- 是否有字段可以从其他字段推导? +- 缓存值是否有 invalidation 机制? +- observer/effect 是否可以替换为直接调用? + +--- + +## 通用质量审查清单 + +- [ ] **复用审查**: 搜索了现有 utility/helper,没有重复造轮子? +- [ ] **参数数量**: 函数参数 ≤ 3 个?超过则用 options object / dataclass? +- [ ] **抽象边界**: 返回类型没有暴露内部实现细节(ORM、HTTP client、file format)? +- [ ] **类型安全**: 没有 magic strings 代替已有的 enum/constant/union type? +- [ ] **条件深度**: 三元嵌套 ≤ 1 层?if/else 嵌套 ≤ 2 层? +- [ ] **DRY**: 没有 copy-paste-with-variation(≥ 2 段近似代码)? +- [ ] **空操作防护**: polling / interval / event handler 有 change-detection guard? +- [ ] **TOCTOU**: `if exists → operate` 替换为 `try operate → catch`? +- [ ] **数据精度**: 没有读取整个集合/文件只为了取子集? +- [ ] **冗余状态**: 没有可以从其他字段推导的存储字段? diff --git a/examples/skills_code_review_agent/skills/code-review/reference/code-review-best-practices.md b/examples/skills_code_review_agent/skills/code-review/reference/code-review-best-practices.md new file mode 100644 index 000000000..8c6b9cdb1 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/code-review-best-practices.md @@ -0,0 +1,136 @@ +# Code Review Best Practices + +Comprehensive guidelines for conducting effective code reviews. + +## Review Philosophy + +### Goals of Code Review + +**Primary Goals:** +- Catch bugs and edge cases before production +- Ensure code maintainability and readability +- Share knowledge across the team +- Enforce coding standards consistently +- Improve design and architecture decisions + +**Secondary Goals:** +- Mentor junior developers +- Build team culture and trust +- Document design decisions through discussions + +### What Code Review is NOT + +- A gatekeeping mechanism to block progress +- An opportunity to show off knowledge +- A place to nitpick formatting (use linters) +- A way to rewrite code to personal preference + +## Review Timing + +### When to Review + +| Trigger | Action | +|---------|--------| +| PR opened | Review within 24 hours, ideally same day | +| Changes requested | Re-review within 4 hours | +| Blocking issue found | Communicate immediately | + +### Time Allocation + +- **Small PR (<100 lines)**: 10-15 minutes +- **Medium PR (100-400 lines)**: 20-40 minutes +- **Large PR (>400 lines)**: Request to split, or 60+ minutes + +## Review Depth Levels + +### Level 1: Skim Review (5 minutes) +- Check PR description and linked issues +- Verify CI/CD status +- Look at file changes overview +- Identify if deeper review needed + +### Level 2: Standard Review (20-30 minutes) +- Full code walkthrough +- Logic verification +- Test coverage check +- Security scan + +### Level 3: Deep Review (60+ minutes) +- Architecture evaluation +- Performance analysis +- Security audit +- Edge case exploration + +## Communication Guidelines + +### Tone and Language + +**Use collaborative language:** +- "What do you think about..." instead of "You should..." +- "Could we consider..." instead of "This is wrong" +- "I'm curious about..." instead of "Why didn't you..." + +**Be specific and actionable:** +- Include code examples when suggesting changes +- Link to documentation or past discussions +- Explain the "why" behind suggestions + +### Handling Disagreements + +1. **Seek to understand**: Ask clarifying questions +2. **Acknowledge valid points**: Show you've considered their perspective +3. **Provide data**: Use benchmarks, docs, or examples +4. **Escalate if needed**: Involve senior dev or architect +5. **Know when to let go**: Not every hill is worth dying on + +## Review Prioritization + +### Must Fix (Blocking) +- Security vulnerabilities +- Data corruption risks +- Breaking changes without migration +- Critical performance issues +- Missing error handling for user-facing features + +### Should Fix (Important) +- Test coverage gaps +- Moderate performance concerns +- Code duplication +- Unclear naming or structure +- Missing documentation for complex logic + +### Nice to Have (Non-blocking) +- Style preferences beyond linting +- Minor optimizations +- Additional test cases +- Documentation improvements + +## Anti-Patterns to Avoid + +### Reviewer Anti-Patterns +- **Rubber stamping**: Approving without actually reviewing +- **Bike shedding**: Debating trivial details extensively +- **Scope creep**: "While you're at it, can you also..." +- **Ghosting**: Requesting changes then disappearing +- **Perfectionism**: Blocking for minor style preferences + +### Author Anti-Patterns +- **Mega PRs**: Submitting 1000+ line changes +- **No context**: Missing PR description or linked issues +- **Defensive responses**: Arguing every suggestion +- **Silent updates**: Making changes without responding to comments + +## Metrics and Improvement + +### Track These Metrics +- Time to first review +- Review cycle time +- Number of review rounds +- Defect escape rate +- Review coverage percentage + +### Continuous Improvement +- Hold retrospectives on review process +- Share learnings from escaped bugs +- Update checklists based on common issues +- Celebrate good reviews and catches diff --git a/examples/skills_code_review_agent/skills/code-review/reference/common-bugs-checklist.md b/examples/skills_code_review_agent/skills/code-review/reference/common-bugs-checklist.md new file mode 100644 index 000000000..6e3b9e187 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/common-bugs-checklist.md @@ -0,0 +1,286 @@ +# Common Bugs Checklist + +Quick-reference bug patterns organized by category. For detailed code examples, explanations, and comprehensive review checklists, see the dedicated language guides linked below. + +## Universal Issues + +### Logic Errors +- [ ] Off-by-one errors in loops and array access +- [ ] Incorrect boolean logic (De Morgan's law violations) +- [ ] Missing null/undefined checks +- [ ] Race conditions in concurrent code +- [ ] Incorrect comparison operators (`==` vs `===`, `=` vs `==`) +- [ ] Integer overflow/underflow +- [ ] Floating point comparison issues + +### Resource Management +- [ ] Memory leaks (unclosed connections, listeners) +- [ ] File handles not closed +- [ ] Database connections not released +- [ ] Event listeners not removed +- [ ] Timers/intervals not cleared + +### Error Handling +- [ ] Swallowed exceptions (empty catch blocks) +- [ ] Generic exception handling hiding specific errors +- [ ] Missing error propagation +- [ ] Incorrect error types thrown +- [ ] Missing finally/cleanup blocks + +## TypeScript/JavaScript + +- [ ] `==` instead of `===` +- [ ] Using `any` — prefer proper types or `unknown` with type guards +- [ ] Missing `await` on async calls +- [ ] Unhandled promise rejections (no try-catch around await) +- [ ] `this` context lost in callbacks +- [ ] Missing `key` prop in lists +- [ ] Closure capturing stale loop variable +- [ ] `parseInt` without radix parameter +- [ ] Modifying array/object during iteration + +**Full guide:** [TypeScript Review Guide](typescript.md) + +## React / React 19 + +- [ ] Hooks called conditionally or in loops (violates Rules of Hooks) +- [ ] `useEffect` dependency array incomplete or incorrect +- [ ] `useEffect` missing cleanup function (subscriptions, timers, fetches) +- [ ] `useEffect` used for derived state (use `useMemo` instead) +- [ ] `useMemo`/`useCallback` over-used or used without `React.memo` +- [ ] Component defined inside another component (re-mounts every render) +- [ ] Unstable props (inline objects/functions passed to memo components) +- [ ] Direct mutation of props +- [ ] List missing `key` or using array index as key (reorderable lists) +- [ ] Server Component using client APIs (`useState`, `useEffect`, `onClick`) +- [ ] `'use client'` on parent making entire subtree client-side +- [ ] `useActionState` calling `setState` instead of returning new state +- [ ] `useFormStatus` called in same component as `
` (must be in child) +- [ ] `useOptimistic` used for critical operations (payments, deletions) +- [ ] Single Suspense boundary for entire page (slow blocks fast) +- [ ] Missing Error Boundary wrapping Suspense +- [ ] `use()` Hook receiving a new Promise each render + +**TanStack Query v5:** +- [ ] `queryKey` missing parameters that affect data +- [ ] Default `staleTime: 0` causing excessive refetches +- [ ] `useSuspenseQuery` with `enabled` option (not supported) +- [ ] Mutation not invalidating related queries on success +- [ ] Optimistic update missing rollback in `onError` +- [ ] Using v4 array syntax (`useQuery(['key'], fn)`) instead of v5 object syntax + +**Testing:** +- [ ] Using `container.querySelector` instead of `screen.getByRole` +- [ ] Using `fireEvent` instead of `userEvent` +- [ ] Testing implementation details instead of user-visible behavior +- [ ] Using `getBy*` for async content (use `findBy*`) + +**Full guide:** [React Review Guide](react.md) + +## Vue 3 + +- [ ] Destructuring `reactive()` object loses reactivity (use `toRefs`) +- [ ] Passing `props.x` to composable instead of `() => props.x` or `toRef(props, 'x')` +- [ ] `watch` with async callback missing `onCleanup` (race condition) +- [ ] `computed` with side effects (mutations, API calls) +- [ ] `v-for` using index as `:key` when list can reorder +- [ ] `v-if` and `v-for` on the same element +- [ ] `defineProps` without TypeScript type declaration +- [ ] `withDefaults` object default values not using factory functions +- [ ] Directly mutating props instead of emitting events +- [ ] `watchEffect` with unclear dependencies causing over-triggering + +**Full guide:** [Vue 3 Review Guide](vue.md) + +## Python + +- [ ] Mutable default arguments (`def f(x=[])`) +- [ ] Bare `except:` catching `KeyboardInterrupt` and `SystemExit` +- [ ] Shared mutable class attributes (`class C: items = []`) +- [ ] Using `is` instead of `==` for value comparison +- [ ] Forgetting `self` parameter in methods +- [ ] Modifying list while iterating +- [ ] String concatenation in loops (use `"".join()`) +- [ ] Not closing files (use `with` statement) +- [ ] Missing type annotations on public functions + +**Full guide:** [Python Review Guide](python.md) + +## Rust + +**Ownership & Borrowing:** +- [ ] Unnecessary `clone()` to work around borrow checker +- [ ] `Arc>` when single-owner would suffice +- [ ] Storing borrows in structs when owned data is simpler +- [ ] Unnecessary `RefCell` (runtime checks vs compile-time) + +**Unsafe Code:** +- [ ] `unsafe` block without `SAFETY:` comment explaining invariants +- [ ] `unsafe fn` without `# Safety` doc section +- [ ] Unsafe invariants split across modules + +**Async & Concurrency:** +- [ ] Blocking in async context (`std::fs`, `std::thread::sleep`) +- [ ] Holding `std::sync::Mutex` across `.await` +- [ ] Spawned task missing `'static` lifetime bound +- [ ] Dropping a Future without awaiting (forgotten work) + +**Error Handling:** +- [ ] `unwrap()`/`expect()` in production code +- [ ] Library using `anyhow` instead of `thiserror` (callers can't match) +- [ ] Swallowing error context (`map_err(|_| ...)`) +- [ ] Ignoring `must_use` return values + +**Performance:** +- [ ] Unnecessary `.collect()` — prefer lazy iterators +- [ ] String concatenation in loops without `with_capacity` +- [ ] `Box` when `impl Trait` would work + +**Full guide:** [Rust Review Guide](rust.md) + +## Go + +- [ ] Ignoring errors (`result, _ := SomeFunction()`) +- [ ] Goroutine with no exit mechanism (leak) +- [ ] Missing or incorrect `context.Context` propagation +- [ ] Loop variable capture issue (Go < 1.22) +- [ ] `defer` in loops (deferred until function, not loop iteration) +- [ ] Variable shadowing +- [ ] Map used before initialization +- [ ] Error wrapping with `%v` instead of `%w` (breaks `errors.Is`/`errors.As`) + +**Full guide:** [Go Review Guide](go.md) + +## Java / Spring Boot + +- [ ] POJO/DTO with manual boilerplate instead of `record` *(Java 17+)* +- [ ] Traditional switch missing `break` (use switch expressions) *(Java 14+)* +- [ ] Field injection instead of constructor injection +- [ ] JPA N+1 query (missing `fetch join` or `@EntityGraph`) +- [ ] Incorrect `equals`/`hashCode` on JPA entities (avoid `@Data`; prefer stable business key or null-safe id — never all lazy fields) +- [ ] `Optional.get()` without `isPresent()` check +- [ ] Stream operations with side effects + +**Full guide:** [Java Review Guide](java.md) (17/21 + Boot 3) + +## Java 8 / Spring Boot 2 (Legacy) + +- [ ] Shared `SimpleDateFormat` / legacy `Date` instead of `java.time` +- [ ] `Collectors.toMap` with null values or missing merge function +- [ ] `Optional` used as field/parameter, or `isPresent()`+`get()` as null-check +- [ ] `CompletableFuture.supplyAsync` I/O on `commonPool` (no explicit executor) +- [ ] `RestTemplate` without connect/read timeouts +- [ ] `@Transactional` on private method or same-class self-invocation +- [ ] `parallelStream` with shared mutable state +- [ ] Mixing `javax.*` and `jakarta.*` on Boot 2 + +**Full guide:** [Java 8 Review Guide](java8.md) + +## PHP + +- [ ] Missing `declare(strict_types=1);` in new files +- [ ] Weak comparison (`==`, `!=`) in auth, token, payment, or state logic +- [ ] `in_array()` / `array_search()` used without strict mode +- [ ] SQL built with string concatenation instead of prepared statements +- [ ] User input echoed without context-aware escaping +- [ ] Passwords stored with `md5()` / `sha1()` instead of `password_hash()` +- [ ] Untrusted data passed to `unserialize()` +- [ ] PHP 8.2+ dynamic properties used instead of declared properties +- [ ] Errors hidden with `@` or swallowed in empty `catch` blocks +- [ ] File uploads using client-provided names or missing MIME/size validation + +**Full guide:** [PHP Review Guide](php.md) + +## Ruby / Rails + +- [ ] Condition assumes `0`, `""`, or `[]` is falsey +- [ ] Mutable Hash/Array default shared across entries (`Hash.new([])`, `Array.new(3, [])`) +- [ ] Bang method return value treated as the transformed object +- [ ] Bare or broad `rescue` hides unrelated failures or exposes `error.message` +- [ ] Dynamic `send`, `constantize`, `eval`, or SQL fragment controlled by user input +- [ ] Untrusted data passed to `Marshal.load`, unsafe YAML loading, or an interpolated shell command +- [ ] Strong parameters use `permit!`, `to_unsafe_h`, or an empty hash allowlist +- [ ] Nested `params.expect` arrays use a flat shape instead of the required `[[...]]` form +- [ ] Active Record query interpolates values or dynamic identifiers into SQL +- [ ] `Model.find(params[:id])` loads a record before ownership or policy scoping (IDOR) +- [ ] `redirect_to` accepts a user-controlled URL with `allow_other_host: true` (open redirect) +- [ ] Browser-authenticated state changes skip CSRF protection or use unsafe session cookie flags +- [ ] Association access in a loop causes N+1 queries +- [ ] Model validation lacks a matching database constraint for a critical invariant +- [ ] `update_all` / `delete_all` unexpectedly skips callbacks and validations +- [ ] Bulk writes can drift a `counter_cache` without reconciliation +- [ ] Active Job retry can duplicate a payment, email, or other external side effect +- [ ] GlobalID job argument can be deleted before deserialization +- [ ] Transaction contains external side effects that cannot roll back +- [ ] Retried create/payment request can duplicate committed work without an idempotency key + +**Full guide:** [Ruby and Rails Review Guide](ruby.md) + +## Swift + +- [ ] Force-unwrap (`!`) or `try!` where safe unwrapping is possible +- [ ] Closure capturing `self` strongly without `[weak self]` (retain cycle) +- [ ] Reference type (`class`) used where a value type (`struct`) is intended +- [ ] Errors swallowed instead of propagated via `throws` / `Result` +- [ ] Data race across concurrency boundaries (missing `Sendable`, `@MainActor`, actor isolation) +- [ ] Fire-and-forget `Task {}` that is never cancelled or leaks +- [ ] `@ObservedObject` used where `@StateObject` is required for ownership +- [ ] Implicitly unwrapped optional (`var x: T!`) outside IBOutlets +- [ ] Over-broad access control (`public` / `open` where `internal` suffices) + +**Full guide:** [Swift Review Guide](swift.md) + +## C + +- [ ] Pointer/buffer overflow or underflow +- [ ] Undefined behavior (use-after-free, double-free, null deref) +- [ ] Missing error handling after allocation (`malloc` can return `NULL`) +- [ ] Integer overflow in size calculations +- [ ] Resource leaks (missing `free`, `fclose`, etc.) +- [ ] Missing `static` on file-local functions/variables + +**Full guide:** [C Review Guide](c.md) + +## C++ + +- [ ] Missing RAII wrapper for resources +- [ ] Violating Rule of 0/3/5 (destructor, copy, move) +- [ ] Exception safety issues (no `noexcept` where applicable) +- [ ] Dangling references from returned iterators or references +- [ ] Unnecessary copies (missing `std::move` or pass-by-reference) + +**Full guide:** [C++ Review Guide](cpp.md) + +## SQL + +- [ ] String concatenation for queries (SQL injection risk) — use parameterized queries +- [ ] Missing indexes on filtered/joined columns +- [ ] `SELECT *` instead of specific columns +- [ ] N+1 query patterns +- [ ] Missing `LIMIT` on large tables +- [ ] Not handling `NULL` comparisons correctly (`IS NULL` vs `= NULL`) +- [ ] Missing transactions for related operations +- [ ] Incorrect JOIN types +- [ ] Collation / case sensitivity surprises across databases (MySQL vs Postgres defaults) +- [ ] Date and timezone handling errors (naive timestamps, server-local `NOW()`, DST) + +**See also:** [Security Review Guide](security-review-guide.md) for SQL injection prevention + +## API Design + +- [ ] Inconsistent resource naming +- [ ] Wrong HTTP methods (POST for idempotent operations) +- [ ] Missing pagination for list endpoints +- [ ] Incorrect status codes +- [ ] Missing rate limiting +- [ ] Missing input validation and sanitization +- [ ] Trusting client-side validation only + +## Testing + +- [ ] Testing implementation details instead of behavior +- [ ] Missing edge case tests +- [ ] Flaky tests (non-deterministic) +- [ ] Tests with external dependencies (no mocks) +- [ ] Missing negative tests (error cases) +- [ ] Overly complex test setup diff --git a/examples/skills_code_review_agent/skills/code-review/reference/cpp.md b/examples/skills_code_review_agent/skills/code-review/reference/cpp.md new file mode 100644 index 000000000..452b9dbbc --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/cpp.md @@ -0,0 +1,893 @@ +# C++ Code Review Guide + +> C++ code review guide focused on memory safety, lifetime, API design, modern features, and performance. Examples assume C++17/20/23. + +## Table of Contents + +- [Ownership and RAII](#ownership-and-raii) +- [Smart Pointer Selection Guide](#smart-pointer-selection-guide) +- [Lifetime and References](#lifetime-and-references) +- [Copy and Move Semantics](#copy-and-move-semantics) +- [Const-Correctness and API Design](#const-correctness-and-api-design) +- [Error Handling and Exception Safety](#error-handling-and-exception-safety) +- [Modern C++20/23 Features](#modern-c2023-features) +- [constexpr and consteval](#constexpr-and-consteval) +- [Concurrency](#concurrency) +- [Performance and Allocation](#performance-and-allocation) +- [Templates and Type Safety](#templates-and-type-safety) +- [Testing](#testing) +- [Tooling and Build Checks](#tooling-and-build-checks) +- [Review Checklist](#review-checklist) + +--- + +## Ownership and RAII + +### Prefer RAII and smart pointers + +Use RAII to express ownership. Default to `std::unique_ptr`, use `std::shared_ptr` only for shared lifetime. + +```cpp +// ❌ Bad: manual new/delete with early returns +Foo* make_foo() { + Foo* foo = new Foo(); + if (!foo->Init()) { + delete foo; + return nullptr; + } + return foo; +} + +// ✅ Good: RAII with unique_ptr +std::unique_ptr make_foo() { + auto foo = std::make_unique(); + if (!foo->Init()) { + return {}; + } + return foo; +} +``` + +### Wrap C resources + +```cpp +// ✅ Good: wrap FILE* with unique_ptr +using FilePtr = std::unique_ptr; + +FilePtr open_file(const char* path) { + return FilePtr(fopen(path, "rb"), &fclose); +} +``` + +### RAII best practices + +```cpp +// ✅ Good: RAII wrapper for POSIX file descriptors +class Fd { + int fd_ = -1; +public: + explicit Fd(int fd) : fd_(fd) {} + ~Fd() { if (fd_ >= 0) ::close(fd_); } + + Fd(const Fd&) = delete; + Fd& operator=(const Fd&) = delete; + Fd(Fd&& o) noexcept : fd_(std::exchange(o.fd_, -1)) {} + Fd& operator=(Fd&& o) noexcept { + if (this != &o) { + if (fd_ >= 0) ::close(fd_); + fd_ = std::exchange(o.fd_, -1); + } + return *this; + } + + int get() const { return fd_; } + int release() { return std::exchange(fd_, -1); } +}; +``` + +### Never mix ownership styles + +```cpp +// ❌ Bad: raw new + container of raw pointers — who deletes? +std::vector widgets; +widgets.push_back(new Widget()); +// When is delete called? Unclear. + +// ✅ Good: container of unique_ptr +std::vector> widgets; +widgets.push_back(std::make_unique()); +// Automatically cleaned up when vector is destroyed. +``` + +--- + +## Smart Pointer Selection Guide + +### Decision matrix + +| Scenario | Pointer | Why | +|----------|---------|-----| +| Single owner | `unique_ptr` | Zero overhead, clear ownership | +| Shared ownership (few owners) | `shared_ptr` | Reference counted, thread-safe refcount | +| Non-owning observer | `weak_ptr` | Breaks cycles, checks liveness | +| Never-null reference | raw reference `T&` | No ownership, cannot be null | +| Maybe-null observer | raw pointer `T*` | No ownership, can be null | + +### Avoid shared_ptr when unique_ptr suffices + +```cpp +// ❌ Bad: unnecessary shared ownership +class Window { + std::shared_ptr renderer_; +public: + Window() : renderer_(std::make_shared()) {} +}; + +// ✅ Good: sole owner uses unique_ptr +class Window { + std::unique_ptr renderer_; +public: + Window() : renderer_(std::make_unique()) {} +}; +``` + +### Break cycles with weak_ptr + +```cpp +// ❌ Bad: cycle → memory leak +struct Node { + std::shared_ptr parent; + std::shared_ptr child; +}; + +// ✅ Good: weak_ptr breaks the back-reference +struct Node { + std::weak_ptr parent; // non-owning back-reference + std::shared_ptr child; // owning forward-reference +}; +``` + +--- + +## Lifetime and References + +### Avoid dangling references and views + +`std::string_view` and `std::span` do not own data. Make sure the owner outlives the view. + +```cpp +// ❌ Bad: returning string_view to a temporary +std::string_view bad_view() { + std::string s = make_name(); + return s; // dangling +} + +// ✅ Good: return owning string +std::string good_name() { + return make_name(); +} + +// ✅ Good: view tied to caller-owned data +std::string_view good_view(const std::string& s) { + return s; +} +``` + +### Lambda captures + +```cpp +// ❌ Bad: capture reference that escapes +std::function make_task() { + int value = 42; + return [&]() { use(value); }; // dangling +} + +// ✅ Good: capture by value +std::function make_task() { + int value = 42; + return [value]() { use(value); }; +} +``` + +### Beware of temporary lifetime extension pitfalls + +```cpp +// ❌ Bad: reference bound to temporary that is destroyed +const std::string& name = get_name(); // temporary destroyed at end of statement +use(name); // dangling reference + +// ✅ Good: store the value +std::string name = get_name(); +use(name); +``` + +--- + +## Copy and Move Semantics + +### Rule of 0/3/5 + +Prefer the Rule of 0 by using RAII types. If you own a resource, define or delete copy and move operations. + +```cpp +// ❌ Bad: raw ownership with default copy +struct Buffer { + int* data; + size_t size; + explicit Buffer(size_t n) : data(new int[n]), size(n) {} + ~Buffer() { delete[] data; } + // copy ctor/assign are implicitly generated -> double delete +}; + +// ✅ Good: Rule of 0 with std::vector +struct Buffer { + std::vector data; + explicit Buffer(size_t n) : data(n) {} +}; +``` + +### Delete unwanted copies + +```cpp +struct Socket { + Socket() = default; + ~Socket() { close(); } + + Socket(const Socket&) = delete; + Socket& operator=(const Socket&) = delete; + Socket(Socket&&) noexcept = default; + Socket& operator=(Socket&&) noexcept = default; +}; +``` + +### Use std::move explicitly + +```cpp +// ❌ Bad: copies instead of moves +std::string name = get_name(); +data_.push_back(name); // copy + +// ✅ Good: move when source is no longer needed +std::string name = get_name(); +data_.push_back(std::move(name)); +``` + +--- + +## Const-Correctness and API Design + +### Use const and explicit + +```cpp +class User { +public: + const std::string& name() const { return name_; } + void set_name(std::string name) { name_ = std::move(name); } + +private: + std::string name_; +}; + +struct Millis { + explicit Millis(int v) : value(v) {} + int value; +}; +``` + +### Avoid object slicing + +```cpp +struct Shape { virtual ~Shape() = default; }; +struct Circle : Shape { void draw() const; }; + +// ❌ Bad: slices Circle into Shape +void draw(Shape shape); + +// ✅ Good: pass by reference +void draw(const Shape& shape); +``` + +### Use override and final + +```cpp +struct Base { + virtual void run() = 0; +}; + +struct Worker final : Base { + void run() override {} +}; +``` + +--- + +## Error Handling and Exception Safety + +### Prefer RAII for cleanup + +```cpp +// ✅ Good: RAII handles cleanup on exceptions +void process() { + std::vector data = load_data(); // safe cleanup + do_work(data); +} +``` + +### Do not throw from destructors + +```cpp +struct File { + ~File() noexcept { close(); } + void close(); +}; +``` + +### Use expected results for normal failures + +```cpp +// ✅ C++23: std::expected +#include + +std::expected parse_int(std::string_view s) { + try { + return std::stoi(std::string(s)); + } catch (const std::invalid_argument&) { + return std::unexpected(ParseError::InvalidFormat); + } catch (const std::out_of_range&) { + return std::unexpected(ParseError::OutOfRange); + } +} + +// ✅ Pre-C++23: std::optional +std::optional parse_int(const std::string& s) { + try { + return std::stoi(s); + } catch (...) { + return std::nullopt; + } +} +``` + +### Exception safety levels + +- **No-throw guarantee**: `noexcept` — destructors, swap, move operations. +- **Strong guarantee**: operation either succeeds or state is unchanged. Use copy-and-swap idiom. +- **Basic guarantee**: on exception, no resources leaked, object in valid (but possibly modified) state. + +```cpp +// ✅ Good: strong guarantee via copy-and-swap +void Container::push_back(const Item& item) { + Container tmp(*this); // copy + tmp.push_back_impl(item); // may throw, but tmp is a copy + swap(*this, tmp); // noexcept swap +} +``` + +--- + +## Modern C++20/23 Features + +### Concepts (C++20) + +```cpp +// ❌ Bad: SFINAE boilerplate +template , int> = 0> +T gcd(T a, T b) { + while (b) { a %= b; std::swap(a, b); } + return a; +} + +// ✅ Good: concepts are readable and composable +template +T gcd(T a, T b) { + while (b) { a %= b; std::swap(a, b); } + return a; +} + +// ✅ Define custom concepts +template +concept Printable = requires(T t, std::ostream& os) { + { os << t } -> std::convertible_to; +}; + +void log(const Printable auto& value) { + std::cout << "[LOG] " << value << '\n'; +} +``` + +### Ranges and views (C++20) + +```cpp +#include +#include +#include + +// ✅ Good: composable range pipelines +std::vector scores = {85, 92, 67, 73, 98, 55}; + +auto top_scores = scores + | std::views::filter([](int s) { return s >= 80; }) + | std::views::transform([](int s) { return s * 1.1; }) // bonus + | std::views::take(3); + +// Iterate without allocating intermediate containers +for (double s : top_scores) { + std::cout << s << ' '; +} +``` + +### Modules (C++20) + +```cpp +// ✅ Module interface unit (math.cppm) +export module math; + +export int add(int a, int b) { return a + b; } +export constexpr double pi = 3.14159265358979; + +// ✅ Module implementation unit (math_impl.cpp) +module math; + +int internal_helper() { /* not exported */ } + +// Consumer: +import math; +int result = add(1, 2); +``` + +**Review note**: Modules are still maturing in tooling support. Check that your build system (CMake 3.28+, MSVC 17.x, Clang 16+) supports them before adopting. Headers remain the safe default. + +### Deducing this (C++23) + +```cpp +// ❌ Bad: verbose CRTP for static polymorphism +template +struct Base { + void call() { static_cast(this)->impl(); } +}; + +// ✅ Good: C++23 explicit object parameter +struct Widget { + template + void log(this Self&& self) { + // self is Widget& or Widget&& depending on call context + std::cout << self.name << '\n'; + } + std::string name; +}; +``` + +--- + +## constexpr and consteval + +### When to use constexpr vs consteval + +- `constexpr`: can be evaluated at compile time *or* runtime. +- `consteval`: **must** be evaluated at compile time (immediate function). + +```cpp +// ✅ constexpr: compile-time when possible, runtime otherwise +constexpr int factorial(int n) { + int result = 1; + for (int i = 2; i <= n; ++i) result *= i; + return result; +} + +constexpr int c = factorial(5); // compile-time +int r = factorial(argc); // runtime + +// ✅ consteval: enforce compile-time evaluation +consteval int forced_compiletime(int n) { + return n * n; +} + +constexpr int v = forced_compiletime(42); // OK +// int v2 = forced_compiletime(argc); // ERROR: not a constant expression +``` + +### Compile-time computation for performance + +```cpp +// ✅ Good: lookup table generated at compile time +constexpr auto make_crc_table() { + std::array table{}; + for (uint32_t i = 0; i < 256; ++i) { + uint32_t crc = i; + for (int j = 0; j < 8; ++j) { + crc = (crc >> 1) ^ (crc & 1 ? 0xEDB88320 : 0); + } + table[i] = crc; + } + return table; +} + +static constexpr auto crc_table = make_crc_table(); + +// Use at runtime with zero initialization cost +uint32_t crc32(const uint8_t* data, size_t len) { + uint32_t crc = 0xFFFFFFFF; + for (size_t i = 0; i < len; ++i) { + crc = (crc >> 8) ^ crc_table[(crc ^ data[i]) & 0xFF]; + } + return ~crc; +} +``` + +### constinit for guaranteed static initialization + +```cpp +// ✅ Good: prevent static initialization order fiasco +constinit int global_counter = 0; // guaranteed static init, not dynamic +``` + +--- + +## Concurrency + +### Protect shared data + +```cpp +// ❌ Bad: data race +int counter = 0; +void inc() { counter++; } + +// ✅ Good: atomic +std::atomic counter{0}; +void inc() { counter.fetch_add(1, std::memory_order_relaxed); } +``` + +### Use RAII locks + +```cpp +std::mutex mu; +std::vector data; + +void add(int v) { + std::lock_guard lock(mu); + data.push_back(v); +} +``` + +### Prefer std::jthread over std::thread (C++20) + +```cpp +// ❌ Bad: std::thread requires manual join +void run() { + std::thread t([]{ do_work(); }); + // forgot to join → std::terminate +} + +// ✅ Good: jthread joins automatically on destruction +void run() { + std::jthread t([](std::stop_token st) { + while (!st.stop_requested()) { + do_work(); + } + }); + // automatically joined; stop token enables cooperative cancellation +} +``` + +### Structured concurrency with std::execution (future C++26) + +Note: As of C++23, use `std::jthread` + `std::stop_token` for cooperative cancellation. The `std::execution` library (P2300) is expected in C++26. + +--- + +## Performance and Allocation + +### Avoid repeated allocations + +```cpp +// ❌ Bad: repeated reallocation +std::vector build(int n) { + std::vector out; + for (int i = 0; i < n; ++i) { + out.push_back(i); + } + return out; +} + +// ✅ Good: reserve upfront +std::vector build(int n) { + std::vector out; + out.reserve(static_cast(n)); + for (int i = 0; i < n; ++i) { + out.push_back(i); + } + return out; +} +``` + +### String concatenation + +```cpp +// ❌ Bad: repeated allocation +std::string join(const std::vector& parts) { + std::string out; + for (const auto& p : parts) { + out += p; + } + return out; +} + +// ✅ Good: reserve total size +std::string join(const std::vector& parts) { + size_t total = 0; + for (const auto& p : parts) { + total += p.size(); + } + std::string out; + out.reserve(total); + for (const auto& p : parts) { + out += p; + } + return out; +} +``` + +### Small Buffer Optimization (SBO) + +```cpp +// ✅ Good: avoid heap for small data +void process(const char* name) { + // Use stack for short names, heap only for long ones + std::string buf; + buf.reserve(64); // typically stays on stack via SSO + buf = name; + // ... +} +``` + +### Use std::span for zero-copy views + +```cpp +// ❌ Bad: copies the vector +void process(std::vector data); + +// ✅ Good: non-owning view, works with vector, array, C array +void process(std::span data); + +std::vector v = {1, 2, 3}; +process(v); // no copy +int arr[] = {4, 5, 6}; +process(arr); // no copy +``` + +--- + +## Templates and Type Safety + +### Prefer constrained templates (C++20) + +```cpp +// ❌ Bad: overly generic +template +T add(T a, T b) { + return a + b; +} + +// ✅ Good: constrained +template +requires std::is_integral_v +T add(T a, T b) { + return a + b; +} +``` + +### Use static_assert for invariants + +```cpp +template +struct Packet { + static_assert(std::is_trivially_copyable_v, + "Packet payload must be trivially copyable"); + T payload; +}; +``` + +### Avoid template bloat + +```cpp +// ❌ Bad: full template instantiation for each T, even if only one method varies +template +class Service { + void connect() { /* 100 lines of identical code */ } + void process(T item) { /* type-specific */ } +}; + +// ✅ Good: factor out type-independent code into a non-template base +class ServiceBase { +protected: + void connect() { /* 100 lines of shared code */ } +}; + +template +class Service : public ServiceBase { + void process(T item) { /* type-specific */ } +}; +``` + +--- + +## Testing + +### Framework selection + +| Framework | Best For | +|-----------|----------| +| **Google Test (GTest)** | Large projects, CI, GMock integration | +| **Catch2** | Header-only, BDD-style, modern C++ | +| **doctest** | Lightweight, single-header, fast compile | + +### Google Test basics + +```cpp +#include +#include "parser.h" + +TEST(ParserTest, EmptyInputReturnsNull) { + auto token = parse(""); + EXPECT_EQ(token, nullptr); +} + +TEST(ParserTest, ValidInteger) { + auto token = parse("42"); + ASSERT_NE(token, nullptr); + EXPECT_EQ(token->type, TokenType::Int); + EXPECT_EQ(token->value, 42); +} + +TEST(ParserTest, NegativeNumber) { + auto token = parse("-7"); + ASSERT_NE(token, nullptr); + EXPECT_EQ(token->value, -7); +} +``` + +### Test fixtures + +```cpp +class DatabaseTest : public ::testing::Test { +protected: + void SetUp() override { + db_ = std::make_unique(":memory:"); + db_->execute("CREATE TABLE users (id INTEGER, name TEXT)"); + } + + void TearDown() override { + db_.reset(); + } + + std::unique_ptr db_; +}; + +TEST_F(DatabaseTest, InsertAndQuery) { + db_->execute("INSERT INTO users VALUES (1, 'Alice')"); + auto rows = db_->query("SELECT * FROM users"); + ASSERT_EQ(rows.size(), 1); + EXPECT_EQ(rows[0].get("name"), "Alice"); +} + +TEST_F(DatabaseTest, EmptyTableReturnsNoRows) { + auto rows = db_->query("SELECT * FROM users"); + EXPECT_TRUE(rows.empty()); +} +``` + +### Mock objects with GMock + +```cpp +#include + +class HttpClient { +public: + virtual ~HttpClient() = default; + virtual HttpResponse get(const std::string& url) = 0; +}; + +class MockHttpClient : public HttpClient { +public: + MOCK_METHOD(HttpResponse, get, (const std::string& url), (override)); +}; + +TEST(UserServiceTest, FetchesUserProfile) { + MockHttpClient client; + EXPECT_CALL(client, get("https://api.example.com/user/1")) + .WillOnce(Return(HttpResponse{200, R"({"name":"Alice"})"})); + + UserService svc(&client); + auto profile = svc.get_profile(1); + EXPECT_EQ(profile.name, "Alice"); +} +``` + +### Test exception safety + +```cpp +TEST(AllocatorTest, ThrowsOnOverflow) { + EXPECT_THROW(allocate(SIZE_MAX), std::bad_alloc); +} + +TEST(AllocatorTest, NoLeakOnException) { + // Run under ASan to verify no leaks when exception is thrown + try { + auto buf = allocate(1024); + throw std::runtime_error("simulated failure"); + } catch (...) { + // ASan will catch any leaks + } +} +``` + +--- + +## Tooling and Build Checks + +```bash +# Warnings +clang++ -Wall -Wextra -Werror -Wconversion -Wshadow -std=c++20 ... + +# Sanitizers (debug builds) +clang++ -fsanitize=address,undefined -fno-omit-frame-pointer -g ... +clang++ -fsanitize=thread -fno-omit-frame-pointer -g ... + +# Static analysis +clang-tidy src/*.cpp -- -std=c++20 + +# Formatting +clang-format -i src/*.cpp include/*.h +``` + +### Recommended compiler flags for safety + +```bash +# Strict mode for new code +clang++ -std=c++20 -Wall -Wextra -Werror -Wshadow -Wconversion \ + -Wsign-conversion -Wold-style-cast -Wnon-virtual-dtor \ + -Woverloaded-virtual -Wnull-dereference -Wformat=2 \ + -fsanitize=address,undefined -fno-omit-frame-pointer +``` + +--- + +## Review Checklist + +### Safety and Lifetime +- [ ] Ownership is explicit (RAII, unique_ptr by default) +- [ ] No dangling references or views +- [ ] Rule of 0/3/5 followed for resource-owning types +- [ ] No raw new/delete in business logic +- [ ] Destructors are noexcept and do not throw +- [ ] Smart pointer types match ownership semantics (unique vs shared vs weak) +- [ ] No shared_ptr cycles (use weak_ptr for back-references) + +### API and Design +- [ ] const-correctness is applied consistently +- [ ] Constructors are explicit where needed +- [ ] Override/final used for virtual functions +- [ ] No object slicing (pass by ref or pointer) +- [ ] Concepts constrain template parameters (C++20) + +### Modern Features +- [ ] constexpr used for compile-time computation where beneficial +- [ ] Ranges preferred over manual loops for data pipelines (C++20) +- [ ] std::jthread preferred over std::thread for new code (C++20) +- [ ] std::expected used for error handling (C++23) where available + +### Concurrency +- [ ] Shared data is protected (mutex or atomics) +- [ ] Locking order is consistent +- [ ] No blocking while holding locks + +### Performance +- [ ] Unnecessary allocations avoided (reserve, move, span) +- [ ] Copies avoided in hot paths +- [ ] Algorithmic complexity is reasonable + +### Testing and Tooling +- [ ] Unit tests cover happy path, error paths, and edge cases +- [ ] Builds clean with warnings enabled +- [ ] Sanitizers run on critical code paths +- [ ] Static analysis (clang-tidy) results are addressed diff --git a/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/async-concurrency-patterns.md b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/async-concurrency-patterns.md new file mode 100644 index 000000000..bebb89bb9 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/async-concurrency-patterns.md @@ -0,0 +1,515 @@ +# 异步与并发模式 — 跨语言通用指南 + +> 本文档覆盖并发模型对比、常见陷阱、跨语言最佳实践和结构化并发模式。 + +## 目录 + +- [并发模型对比](#并发模型对比) +- [常见陷阱](#常见陷阱) +- [最佳实践](#最佳实践) +- [跨语言代码示例](#跨语言代码示例) +- [Review Checklist](#review-checklist) + +--- + +## 并发模型对比 + +| 模型 | 语言 | 核心概念 | 优点 | 缺点 | +|------|------|----------|------|------| +| **Goroutines + Channels** | Go | 轻量级协程 + CSP 通信 | 极简语法、低开销 | 手动取消传播 | +| **async/await + Event Loop** | Python, TypeScript | 单线程协作式多任务 | 无锁、易推理 | 不能阻塞事件循环 | +| **async/await + Tokio** | Rust | Futures + 运行时调度 | 零成本抽象、编译期安全 | 学习曲线陡 | +| **Coroutines + Flow** | Kotlin | 挂起函数 + 结构化并发 | 自动取消、生命周期绑定 | Dispatchers 选择复杂 | +| **async/await + Actors** | Swift | 结构化并发 + Actor 隔离 | 编译期数据竞争检查 | Swift 6 迁移成本 | +| **async/await + TPL** | C# | Task + 线程池 | 成熟生态、ConfigureAwait | 隐式线程切换 | +| **Threads + Mutexes** | C++, Java, 所有 | OS 线程 + 共享内存 | 真正并行 | 锁管理复杂、死锁风险 | + +### 何时选择什么 + +``` +I/O 密集型(网络、数据库、文件): + → async/await(Python, TS, Rust, Swift, C#) + → goroutines(Go) + → coroutines(Kotlin) + +CPU 密集型(计算、图像处理): + → 线程池(Java, C++, C#) + → multiprocessing(Python) + → spawn_blocking(Rust tokio) + → Dispatchers.Default(Kotlin) + +混合型: + → async + spawn_blocking(Rust) + → async + run_in_executor(Python) + → goroutines + sync.Mutex(Go) +``` + +--- + +## 常见陷阱 + +### 陷阱 1: 竞态条件(Race Condition) + +多个并发任务读写共享状态,结果依赖执行顺序。 + +``` +// 通用伪代码 +counter = 0 + +task1: counter += 1 // 读 counter=0, 写 counter=1 +task2: counter += 1 // 读 counter=0, 写 counter=1 +// 期望 counter=2, 实际 counter=1 +``` + +**解决方案**:互斥锁、原子操作、或将共享状态封装在 Actor 中。 + +### 陷阱 2: 死锁(Deadlock) + +两个或多个任务互相等待对方持有的锁。 + +``` +task1: lock(A); lock(B); // 持有 A,等待 B +task2: lock(B); lock(A); // 持有 B,等待 A +// 两者永远等待 +``` + +**解决方案**: +- 一致的锁获取顺序 +- 超时锁(tryLock with timeout) +- 避免嵌套锁 + +### 陷阱 3: Starvation + +低优先级任务永远得不到执行机会。 + +``` +// 高优先级任务持续到达,低优先级任务永远排队 +``` + +**解决方案**:公平锁、任务优先级队列、限制并发数。 + +### 陷阱 4: Goroutine / Task 泄漏 + +启动并发任务但没有确保其退出。 + +```go +// ❌ Go: goroutine 泄漏 +func process() { + ch := make(chan int) + go func() { + result := <-ch // 如果没有人发送,goroutine 永远阻塞 + }() + // 函数返回,但 goroutine 仍在等待 +} +``` + +```python +# ❌ Python: Task 泄漏 +async def process(): + task = asyncio.create_task(long_running()) + # 函数返回,但 task 仍在运行 +``` + +**解决方案**:使用 context/done channel (Go)、TaskGroup (Python)、structured concurrency (Kotlin/Swift)。 + +### 陷阱 5: 在异步上下文中阻塞 + +```python +# ❌ Python: 在 async 函数中使用同步 I/O 阻塞事件循环 +async def handle(): + result = requests.get(url) # 阻塞!整个事件循环停滞 + return result + +# ✅ 使用异步 I/O 或将阻塞操作放到线程池 +async def handle(): + result = await aiohttp.get(url) # 非阻塞 + return result + +# 或将同步代码放到线程池 +async def handle(): + result = await asyncio.to_thread(requests.get, url) + return result +``` + +```rust +// ❌ Rust: 在 async 函数中阻塞 +async fn handle() { + let result = std::fs::read_to_string("large.txt"); // 阻塞 tokio 运行时 +} + +// ✅ 使用 spawn_blocking +async fn handle() { + let result = tokio::task::spawn_blocking(|| { + std::fs::read_to_string("large.txt") + }).await?; +} +``` + +--- + +## 最佳实践 + +### 1. 结构化并发 + +确保并发任务的生命周期与创建它们的 scope 绑定。父任务取消时,子任务自动取消。 + +```kotlin +// ✅ Kotlin: coroutineScope 确保子协程在 scope 结束时全部完成 +suspend fun processItems(items: List) = coroutineScope { + items.forEach { item -> + launch { processItem(item) } // 子协程 + } + // scope 结束时等待所有子协程完成 +} + +// 如果 processItems 被取消,所有子协程自动取消 +``` + +```swift +// ✅ Swift: async let + TaskGroup +func processItems() async throws { + async let resultA = fetchA() // 并发执行 + async let resultB = fetchB() + let combined = try await (resultA, resultB) // 等待两者 +} +``` + +```python +# ✅ Python 3.11+: TaskGroup +async def process_items(): + async with asyncio.TaskGroup() as tg: + for item in items: + tg.create_task(process_item(item)) + # TaskGroup 退出时等待所有任务完成 + # 如果一个任务失败,其余任务自动取消 +``` + +### 2. 取消传播 + +确保取消信号能正确传播到所有子任务。 + +```go +// ✅ Go: context 传播取消 +func processAll(ctx context.Context, items []Item) error { + g, ctx := errgroup.WithContext(ctx) + for _, item := range items { + item := item + g.Go(func() error { + return processItem(ctx, item) + }) + } + return g.Wait() // 任一失败,context 取消,其余任务收到信号 +} +``` + +```rust +// ✅ Rust: tokio::select! + JoinHandle +async fn process_with_timeout(item: Item) -> Result { + tokio::select! { + result = process(item) => result, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + Err(anyhow!("processing timed out")) + } + } +} +``` + +### 3. Backpressure(反压) + +当生产者速度远超消费者时,需要限制队列大小,防止内存膨胀。 + +```go +// ✅ Go: 有缓冲 channel 作为自然反压 +func process(items <-chan Item) <-chan Result { + results := make(chan Result, 10) // 缓冲 10 个结果 + go func() { + for item := range items { + results <- processItem(item) // 缓冲满时阻塞 + } + close(results) + }() + return results +} +``` + +```kotlin +// ✅ Kotlin: Flow 自带反压 +fun itemsFlow(): Flow = flow { + for (item in fetchAll()) { + emit(item) // collector 未准备好时挂起 + } +} +// 使用 buffer() 控制缓冲策略 +itemsFlow() + .buffer(capacity = 10, onBufferOverflow = BufferOverflow.SUSPEND) + .collect { process(it) } +``` + +### 4. 限制并发数 + +防止同时启动过多任务导致资源耗尽。 + +```python +# ✅ Python: Semaphore 限制并发 +async def fetch_all(urls: list[str], max_concurrent: int = 10): + semaphore = asyncio.Semaphore(max_concurrent) + + async def fetch_one(url: str): + async with semaphore: + return await aiohttp.get(url) + + return await asyncio.gather(*[fetch_one(url) for url in urls]) +``` + +```go +// ✅ Go: errgroup + semaphore +func fetchAll(ctx context.Context, urls []string, maxConcurrent int) error { + g, ctx := errgroup.WithContext(ctx) + sem := make(chan struct{}, maxConcurrent) + + for _, url := range urls { + url := url + g.Go(func() error { + sem <- struct{}{} // 获取信号量 + defer func() { <-sem }() // 释放信号量 + return fetch(ctx, url) + }) + } + return g.Wait() +} +``` + +--- + +## 跨语言代码示例 + +### Go: Goroutines + Channels + Context + +```go +// ✅ 完整模式: context 取消 + errgroup + 有界并发 +func processBatch(ctx context.Context, items []Item) ([]Result, error) { + g, ctx := errgroup.WithContext(ctx) + results := make([]Result, len(items)) + sem := make(chan struct{}, 10) // 最多 10 个并发 + + for i, item := range items { + i, item := i, item + g.Go(func() error { + select { + case sem <- struct{}{}: + case <-ctx.Done(): + return ctx.Err() + } + defer func() { <-sem }() + + result, err := process(ctx, item) + if err != nil { + return fmt.Errorf("item %d: %w", i, err) + } + results[i] = result + return nil + }) + } + + if err := g.Wait(); err != nil { + return nil, err + } + return results, nil +} +``` + +### Python: asyncio + TaskGroup + +```python +# ✅ Python 3.11+: 结构化并发 + 有界并发 + 超时 +import asyncio + +async def process_batch(items: list[Item], max_concurrent: int = 10) -> list[Result]: + semaphore = asyncio.Semaphore(max_concurrent) + + async def process_one(item: Item) -> Result: + async with semaphore: + return await process(item) + + async with asyncio.TaskGroup() as tg: + tasks = [tg.create_task(process_one(item)) for item in items] + + return [task.result() for task in tasks] +``` + +### Rust: tokio + select + spawn_blocking + +```rust +// ✅ 有界并发 + 超时 + 阻塞操作隔离 +use tokio::sync::Semaphore; +use std::sync::Arc; + +async fn process_batch(items: Vec, max_concurrent: usize) -> Result> { + let sem = Arc::new(Semaphore::new(max_concurrent)); + let mut handles = Vec::new(); + + for item in items { + let permit = sem.clone().acquire_owned().await?; + handles.push(tokio::spawn(async move { + let _permit = permit; // drop on completion + tokio::select! { + result = process(item) => result, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + Err(anyhow!("timeout")) + } + } + })); + } + + let mut results = Vec::new(); + for handle in handles { + results.push(handle.await??); + } + Ok(results) +} +``` + +### Kotlin: Coroutines + Flow + Dispatchers + +```kotlin +// ✅ 结构化并发 + 有界并发 + 取消安全 +suspend fun processBatch(items: List, maxConcurrent: Int = 10): List { + val semaphore = Semaphore(maxConcurrent) + + return coroutineScope { + items.map { item -> + async(Dispatchers.IO) { + semaphore.withPermit { + process(item) + } + } + }.awaitAll() + } +} + +// ✅ Flow: 流式处理 + 反压 +fun itemStream(): Flow = flow { + for (item in fetchAllItems()) { + emit(process(item)) + } +} + .flowOn(Dispatchers.IO) + .buffer(capacity = 10) + .catch { e -> logger.error("stream failed", e) } +``` + +### Swift: async/await + TaskGroup + Actors + +```swift +// ✅ 结构化并发 + actor 隔离 +actor ResultCollector { + private var results: [Result] = [] + func add(_ result: Result) { results.append(result) } + func all() -> [Result] { results } +} + +func processBatch(items: [Item], maxConcurrent: Int = 10) async throws -> [Result] { + let collector = ResultCollector() + + try await withThrowingTaskGroup(of: Void.self) { group in + var active = 0 + for item in items { + if active >= maxConcurrent { + try await group.next() + active -= 1 + } + group.addTask { + let result = try await process(item) + await collector.add(result) + } + active += 1 + } + } + + return await collector.all() +} +``` + +### C#: async/await + SemaphoreSlim + CancellationToken + +```csharp +// ✅ 有界并发 + 取消 + 异常处理 +async Task> ProcessBatchAsync( + List items, + int maxConcurrent = 10, + CancellationToken ct = default) +{ + using var semaphore = new SemaphoreSlim(maxConcurrent); + var tasks = items.Select(async item => + { + await semaphore.WaitAsync(ct); + try + { + return await ProcessAsync(item, ct); + } + finally + { + semaphore.Release(); + } + }); + + var results = await Task.WhenAll(tasks); + return results.ToList(); +} +``` + +### TypeScript: Worker-pool 并发限制 + +```typescript +// ✅ Worker-pool pattern: 固定数量 worker 竞争任务队列 +// 结果按原始索引赋值,保证输出顺序与输入一致。 +async function processWithLimit( + items: T[], + fn: (item: T) => Promise, + limit: number, +): Promise { + const results: R[] = []; + let index = 0; + + const workers = Array.from({ length: limit }, async () => { + while (index < items.length) { + const i = index++; + results[i] = await fn(items[i]); + } + }); + + await Promise.all(workers); + return results; +} +``` + +--- + +## Review Checklist + +### 基本检查 +- [ ] 并发任务有明确的退出机制(不会泄漏) +- [ ] 共享状态有适当保护(mutex、actor、channel) +- [ ] 没有在异步上下文中执行阻塞操作 +- [ ] 取消信号正确传播到所有子任务 + +### 架构检查 +- [ ] 使用结构化并发(TaskGroup / coroutineScope / errgroup) +- [ ] 并发数有上限(semaphore / bounded channel) +- [ ] 长时间运行的任务支持超时 +- [ ] 背压机制防止内存膨胀 + +### 性能检查 +- [ ] 并发粒度合理(不过细也不过粗) +- [ ] I/O 密集使用 async,CPU 密集使用线程/进程 +- [ ] 锁的持有时间最小化 +- [ ] 没有不必要的 await(可并行的操作串行执行) + +### 语言特定 +- [ ] Go: context 传播、errgroup 使用、channel 缓冲合理 +- [ ] Python: 事件循环不阻塞、TaskGroup 管理生命周期 +- [ ] Rust: spawn_blocking 隔离阻塞操作、select! 处理超时 +- [ ] Kotlin: coroutineScope 结构化并发、Dispatchers 选择正确 +- [ ] Swift: @MainActor 保护 UI、actor 隔离可变状态 +- [ ] C#: CancellationToken 传播、ConfigureAwait(false) 在库代码中 +- [ ] TypeScript: Promise.all + 并发限制、AbortController 取消 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/error-handling-principles.md b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/error-handling-principles.md new file mode 100644 index 000000000..521924e3e --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/error-handling-principles.md @@ -0,0 +1,492 @@ +# 错误处理原则 — 跨语言通用指南 + +> 本文档覆盖错误处理的核心原则、常见反模式、错误层次设计和日志最佳实践。每个原则附带跨语言代码示例。 + +## 目录 + +- [核心原则](#核心原则) +- [反模式](#反模式) +- [错误层次设计](#错误层次设计) +- [日志最佳实践](#日志最佳实践) +- [跨语言代码示例](#跨语言代码示例) +- [Review Checklist](#review-checklist) + +--- + +## 核心原则 + +### 原则 1: 不要吞掉错误 + +每个错误都必须被处理:向上传播、记录日志、或转换为更有意义的错误。**永远不要**静默忽略。 + +``` +// 伪代码 +result = risky_operation() +if error: + // 必须做以下之一: + // 1. return error to caller(传播) + // 2. log + return fallback(降级) + // 3. panic/crash(不可恢复时) +``` + +### 原则 2: 添加上下文 + +错误信息应包含**操作描述**和**关键参数**,使调试者无需阅读调用链即可定位问题。 + +``` +// ❌ 无上下文 +"failed" + +// ✅ 有上下文 +"failed to process order #12345: payment gateway timeout after 30s" +``` + +### 原则 3: 使用特定类型 + +用错误类型区分失败原因,让调用者能精确处理不同的失败场景。 + +``` +// ❌ 通用错误 +throw new Error("something went wrong") + +// ✅ 特定类型 +throw new OrderNotFoundError(orderId) +throw new PaymentTimeoutException(gatewayName, timeoutMs) +``` + +### 原则 4: Fail Fast + +在操作开始前验证前置条件,尽早失败。这避免了部分执行后才发现错误导致的不一致状态。 + +``` +// ❌ 执行到一半才发现参数无效 +def process(data, config): + result = expensive_computation(data) # 已花费 5 秒 + if not config.valid: + raise ValueError("invalid config") # 5 秒白费了 + +// ✅ 先验证 +def process(data, config): + if not config.valid: + raise ValueError("invalid config") + result = expensive_computation(data) +``` + +### 原则 5: 错误处理只做一次 + +不要在每个层级都处理同一个错误(既 log 又 return 又 wrap)。选择一种方式,让调用者决定如何处理。 + +``` +// ❌ 既 log 又 return(重复处理) +if err: + log.error("failed: %s", err) + return err + +// ✅ 只包装并返回,让顶层统一处理 +if err: + return wrap_error("operation failed", err) +``` + +--- + +## 反模式 + +### 反模式 1: 空 catch 块 + +```python +# ❌ Python: 空 except 吞掉所有异常(包括 KeyboardInterrupt) +try: + result = risky() +except: + pass + +# ❌ Java: 空 catch 吞掉异常 +try { + result = risky(); +} catch (Exception e) { + // 什么都不做 +} + +# ❌ Go: 忽略 error +result, _ := risky() + +# ❌ Rust: unwrap() 在生产代码中 +let result = risky().unwrap(); // panic on error +``` + +### 反模式 2: 过宽的 catch + +```python +# ❌ 捕获所有异常,无法区分失败类型 +try: + result = risky() +except Exception as e: + logger.error(f"failed: {e}") + +# ✅ 捕获特定异常 +try: + result = risky() +except ConnectionError as e: + logger.warning(f"network issue, retrying: {e}") + result = retry(risky) +except ValueError as e: + logger.error(f"bad input: {e}") + raise +``` + +### 反模式 3: 丢失原始异常 + +```python +# ❌ 丢失了原始异常的堆栈和信息 +try: + result = external_api.call() +except APIError as e: + raise RuntimeError("API failed") # 丢失了原因 + +# ✅ 保留异常链 +try: + result = external_api.call() +except APIError as e: + raise RuntimeError("API failed") from e +``` + +```java +// ❌ 丢失原始异常 +catch (IOException e) { + throw new ServiceException("IO failed"); +} + +// ✅ 保留原因 +catch (IOException e) { + throw new ServiceException("IO failed", e); +} +``` + +### 反模式 4: 用异常做流程控制 + +```python +# ❌ 异常做正常流程控制(慢且不清晰) +try: + user = users[name] +except KeyError: + user = create_default_user(name) + +# ✅ 显式检查 +user = users.get(name) or create_default_user(name) +``` + +```go +// ❌ Go: panic 做流程控制 +func getUser(id int) User { + if id <= 0 { + panic("invalid id") + } +} + +// ✅ Go: 返回 error +func getUser(id int) (User, error) { + if id <= 0 { + return User{}, fmt.Errorf("invalid user id: %d", id) + } +} +``` + +### 反模式 5: 忽略返回值 + +```csharp +// ❌ 忽略返回的 bool/Result +dict.TryGetValue("key", out var value); +// value 可能是默认值,但代码继续执行如同成功 + +// ✅ 检查返回值 +if (!dict.TryGetValue("key", out var value)) +{ + throw new KeyNotFoundException("key not found"); +} +``` + +--- + +## 错误层次设计 + +### 三层错误架构 + +``` +┌─────────────────────────────────────────────────┐ +│ Application Errors(应用级) │ +│ - AppError / ServiceError │ +│ - 全局异常处理器捕获,返回用户友好的响应 │ +├─────────────────────────────────────────────────┤ +│ Module Errors(模块级) │ +│ - PaymentError, AuthError, ValidationError │ +│ - 每个业务模块定义自己的错误类型 │ +├─────────────────────────────────────────────────┤ +│ Infrastructure Errors(基础设施级) │ +│ - IOError, NetworkError, DatabaseError │ +│ - 来自操作系统、网络、数据库的底层错误 │ +└─────────────────────────────────────────────────┘ +``` + +### 设计规则 + +1. **模块级错误继承自应用级基类**,便于全局 catch +2. **基础设施错误在模块边界转换为模块级错误**,不暴露给上层 +3. **每个错误类型包含足够的上下文**用于调试(ID、时间戳、操作名称) + +### 示例层次(Python) + +```python +class AppError(Exception): + """应用基础异常""" + pass + +class PaymentError(AppError): + """支付模块错误""" + def __init__(self, order_id: str, reason: str): + self.order_id = order_id + super().__init__(f"payment failed for order {order_id}: {reason}") + +class PaymentGatewayTimeout(PaymentError): + """支付网关超时""" + def __init__(self, order_id: str, gateway: str, timeout_ms: int): + self.gateway = gateway + self.timeout_ms = timeout_ms + super().__init__(order_id, f"gateway {gateway} timed out after {timeout_ms}ms") +``` + +### 示例层次(Java) + +```java +public class AppException extends RuntimeException { + private final String errorCode; + public AppException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } +} + +public class OrderNotFoundException extends AppException { + public OrderNotFoundException(Long orderId) { + super("ORDER_NOT_FOUND", "Order " + orderId + " not found", null); + } +} +``` + +--- + +## 日志最佳实践 + +### 日志级别选择 + +| 级别 | 何时使用 | 示例 | +|------|---------|------| +| **ERROR** | 需要人工介入的故障 | 支付失败、数据不一致 | +| **WARN** | 可自动恢复的异常 | 重试成功、降级处理 | +| **INFO** | 正常业务事件 | 订单创建、用户登录 | +| **DEBUG** | 调试信息 | 函数参数、中间状态 | + +### 日志格式 + +``` +// ❌ 无结构化信息 +log.error("failed to process") + +// ✅ 结构化信息 + 上下文 +log.error("payment_failed", { + "order_id": "12345", + "gateway": "stripe", + "error_code": "card_declined", + "amount": 99.99, + "duration_ms": 2340 +}) +``` + +### 日志安全 + +- **不要记录敏感信息**:密码、token、PII、完整信用卡号 +- **脱敏处理**:`email: a***@example.com` +- **日志注入防护**:对用户输入做转义,防止伪造日志行 + +--- + +## 跨语言代码示例 + +### Python + +```python +# ✅ 特定异常 + 上下文 + 异常链 +try: + response = http_client.post(url, data=payload) + response.raise_for_status() +except requests.ConnectionError as e: + raise PaymentGatewayError(f"cannot reach {gateway_name}") from e +except requests.HTTPError as e: + if response.status_code == 429: + raise RateLimitError(f"rate limited by {gateway_name}") from e + raise PaymentGatewayError(f"HTTP {response.status_code} from {gateway_name}") from e +``` + +### Java + +```java +// ✅ 特定异常 + 上下文 + 原因链 +try { + var response = httpClient.send(request, BodyHandlers.ofString()); + if (response.statusCode() == 404) { + throw new OrderNotFoundException(orderId); + } +} catch (IOException e) { + throw new PaymentGatewayException( + "gateway unreachable: " + gatewayUrl, e); +} +``` + +### Go + +```go +// ✅ 错误包装 + 上下文 + %w 保留链 +result, err := client.Do(req) +if err != nil { + return fmt.Errorf("payment gateway %s request failed: %w", gatewayName, err) +} +defer result.Body.Close() + +if result.StatusCode == http.StatusNotFound { + return fmt.Errorf("order %d not found: %w", orderID, ErrNotFound) +} +``` + +### Rust + +```rust +// ✅ thiserror 定义错误类型 + 上下文 +#[derive(Debug, thiserror::Error)] +enum PaymentError { + #[error("gateway {gateway} unreachable")] + GatewayUnreachable { + gateway: String, + #[source] + source: reqwest::Error, + }, + #[error("order {order_id} not found")] + OrderNotFound { order_id: u64 }, +} + +async fn process_payment(gateway: &str, order_id: u64) -> Result<(), PaymentError> { + let response = client.post(url) + .send() + .await + .map_err(|e| PaymentError::GatewayUnreachable { + gateway: gateway.into(), + source: e, + })?; + Ok(()) +} +``` + +### C# + +```csharp +// ✅ 特定异常 + 上下文 +try +{ + var response = await httpClient.PostAsync(url, content); + response.EnsureSuccessStatusCode(); +} +catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) +{ + throw new OrderNotFoundException(orderId, ex); +} +catch (HttpRequestException ex) +{ + throw new PaymentGatewayException($"gateway unreachable: {url}", ex); +} +``` + +### Swift + +```swift +// ✅ Error enum + 上下文 +enum PaymentError: Error { + case gatewayUnreachable(name: String, underlying: Error) + case orderNotFound(id: Int) + case declined(reason: String) +} + +func processPayment(orderId: Int) throws -> Receipt { + guard orderId > 0 else { + throw PaymentError.orderNotFound(id: orderId) + } + do { + let response = try networkClient.post(url, body: payload) + return try Receipt(from: response) + } catch let error as NetworkError { + throw PaymentError.gatewayUnreachable(name: gateway, underlying: error) + } +} +``` + +### TypeScript + +```typescript +// ✅ 自定义错误类 + 上下文 +class PaymentError extends Error { + constructor( + message: string, + public readonly orderId: string, + public readonly gateway: string, + public readonly cause?: Error, + ) { + super(message); + this.name = 'PaymentError'; + } +} + +async function processPayment(orderId: string): Promise { + try { + const response = await fetch(url, { method: 'POST', body: payload }); + if (!response.ok) { + throw new PaymentError( + `gateway returned ${response.status}`, + orderId, + gatewayName, + ); + } + return await response.json(); + } catch (err) { + if (err instanceof TypeError) { + throw new PaymentError('gateway unreachable', orderId, gatewayName, err); + } + throw err; + } +} +``` + +--- + +## Review Checklist + +### 核心检查 +- [ ] 没有空 catch 块或静默忽略错误 +- [ ] 错误信息包含操作描述和关键参数 +- [ ] 使用特定错误类型(非通用 Error/Exception) +- [ ] 异常链保留(from / cause / %w) +- [ ] 前置条件在操作开始前验证(fail fast) + +### 架构检查 +- [ ] 定义了清晰的错误层次(应用/模块/基础设施) +- [ ] 全局异常处理器捕获未处理错误 +- [ ] API 边界将内部错误转换为适当的 HTTP 状态码 + +### 日志检查 +- [ ] 错误日志包含结构化上下文 +- [ ] 没有记录敏感信息(密码、token、PII) +- [ ] 日志级别使用正确(ERROR vs WARN vs INFO) + +### 语言特定 +- [ ] Go: error 不忽略,使用 `%w` 包装 +- [ ] Python: catch 特定异常,使用 `from` 保留链 +- [ ] Java: 异常有 cause,使用特定类型 +- [ ] Rust: `?` 传播,自定义 Error 类型 +- [ ] C#: when 过滤器,特定异常类型 +- [ ] Swift: do-catch,Result 用于延迟处理 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/n-plus-one-queries.md b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/n-plus-one-queries.md new file mode 100644 index 000000000..53c09de12 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/n-plus-one-queries.md @@ -0,0 +1,309 @@ +# N+1 查询问题 — 跨语言通用指南 + +> N+1 查询是 ORM 和数据库访问层最常见的性能反模式。本文档覆盖问题定义、检测方法、通用解决方案和跨语言代码示例。 + +## 目录 + +- [问题定义](#问题定义) +- [性能影响](#性能影响) +- [检测方法](#检测方法) +- [通用解决方案](#通用解决方案) +- [语言特定实现](#语言特定实现) +- [Review Checklist](#review-checklist) + +--- + +## 问题定义 + +N+1 查询是指:**1 次查询获取 N 条记录,随后在循环中触发 N 次额外查询**来获取关联数据。 + +``` +请求流程: + 1 query → 获取 N 条主记录 + N queries → 每条主记录查一次关联数据 + ───────── + Total: 1 + N queries +``` + +### 危害 + +| 问题 | 影响 | +|------|------| +| **查询数量线性增长** | 100 条记录 = 101 条 SQL,1000 条 = 1001 条 | +| **网络延迟叠加** | 每条查询都有往返延迟(RTT),N 次往返 >> 1 次批量查询 | +| **连接池耗尽** | 大量查询占满数据库连接,拖慢整个应用 | +| **难以在开发中发现** | 开发环境数据少,N+1 不明显;生产环境数据量大时性能崩塌 | + +--- + +## 性能影响 + +### 场景对比:获取 100 个用户及其订单 + +| 方案 | SQL 数量 | 延迟(假设 RTT=1ms) | 适用场景 | +|------|----------|---------------------|---------| +| N+1 懒加载 | 101 条 | ~101ms | 极少数据量 | +| Eager loading (JOIN) | 1 条 | ~1ms | 一对多,数据量适中 | +| Eager loading (IN) | 2 条 | ~2ms | 多对多,大数据集 | +| DataLoader / batch | 2 条 | ~2ms | GraphQL / 复杂图查询 | + +### SQL 数量对比 + +```sql +-- ❌ N+1: 1 + 100 = 101 queries +SELECT * FROM users; -- 1 query +SELECT * FROM orders WHERE user_id = 1; -- query 2 +SELECT * FROM orders WHERE user_id = 2; -- query 3 +... +SELECT * FROM orders WHERE user_id = 100; -- query 101 + +-- ✅ Batch: 2 queries +SELECT * FROM users; +SELECT * FROM orders WHERE user_id IN (1,2,...,100); +``` + +--- + +## 检测方法 + +### 1. ORM SQL 日志 + +开启 SQL 日志,在测试或开发环境中观察查询数量: + +```python +# Django +import logging +logging.getLogger('django.db.backends').setLevel(logging.DEBUG) + +# SQLAlchemy +import logging +logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) +``` + +```java +// Spring Boot application.yml +spring: + jpa: + show-sql: true + properties: + hibernate.format_sql: true +``` + +```csharp +// EF Core +optionsBuilder.LogTo(Console.WriteLine, LogLevel.Information); +``` + +### 2. 查询计数断言 + +在测试中断言 SQL 查询数量: + +```python +# Django: django-assert-num-queries +from django.test.utils import CaptureQueriesContext +from django.db import connection + +with CaptureQueriesContext(connection) as ctx: + list(User.objects.select_related("profile").all()) +assert len(ctx) <= 2 # 预期最多 2 条查询 +``` + +```java +// Hibernate: p6spy 或 datasource-proxy +// 在测试中统计 SQL 执行次数 +assertThat(sqlCount).isLessThanOrEqualTo(2); +``` + +### 3. APM / 数据库监控工具 + +- **Django Debug Toolbar** — 实时显示 SQL 数量和时间 +- **p6spy** (Java) — JDBC 层拦截,记录所有 SQL +- **MiniProfiler** (.NET) — 页面内嵌 SQL 统计 +- **DataDog / New Relic** — 生产环境慢查询告警 + +--- + +## 通用解决方案 + +### 方案 1: Eager Loading(JOIN 预加载) + +一次 JOIN 查询获取主记录和关联记录。适用于一对一、一对多。 + +### 方案 2: Batch Fetching(IN 子句批量查询) + +两次查询:主记录 + `WHERE id IN (...)` 批量获取关联记录。适用于多对多、大数据集。 + +### 方案 3: DataLoader Pattern + +在 GraphQL 或复杂图查询场景中,收集所有需要的 ID,合并为一次批量查询。 + +``` +// DataLoader 伪代码 +class DataLoader { + load(K key) → V // 注册需求,不立即查询 + loadAll([K]) → [V] // 合并为一次批量查询 +} +``` + +### 方案 4: Projection(投影) + +只查询需要的字段,减少数据传输量: + +```sql +-- ❌ 获取所有列 +SELECT * FROM users JOIN profiles ON ... + +-- ✅ 只投影需要的字段 +SELECT u.name, p.avatar_url FROM users u JOIN profiles p ON ... +``` + +--- + +## 语言特定实现 + +### Python / Django + +> 详见 [Django Guide](../django.md#n1-查询优化) + +```python +# ForeignKey / OneToOne → select_related (SQL JOIN) +books = Book.objects.select_related("publisher") + +# M2M / reverse FK → prefetch_related (2 queries + Python merge) +authors = Author.objects.prefetch_related("books") + +# 嵌套预加载 +authors = Author.objects.prefetch_related("books__publisher") + +# Prefetch 对象精细控制 +from django.db.models import Prefetch +authors = Author.objects.prefetch_related( + Prefetch("books", queryset=Book.objects.filter(published=True), to_attr="published_books") +) +``` + +### Python / SQLAlchemy (FastAPI) + +> 详见 [FastAPI Guide](../fastapi.md#database-sessions--n1) + +```python +from sqlalchemy.orm import selectinload + +# selectinload: IN 子句批量加载(推荐异步场景) +stmt = select(Order).options(selectinload(Order.customer)) + +# joinedload: JOIN 加载 +stmt = select(Order).options(joinedload(Order.customer)) +``` + +### Java / JPA (Spring Boot) + +> 详见 [Java Guide](../java.md) + +```java +// ❌ FetchType.EAGER 或循环中触发懒加载 +@OneToMany(fetch = FetchType.EAGER) // 危险! + +// ✅ JOIN FETCH +@Query("SELECT u FROM User u JOIN FETCH u.orders") +List findAllWithOrders(); + +// ✅ @EntityGraph(声明式) +@EntityGraph(attributePaths = {"orders", "profile"}) +List findAll(); + +// ✅ @BatchSize(减少 N+1 为 N/batchSize + 1) +@OneToMany +@BatchSize(size = 50) +private List orders; +``` + +### C# / EF Core + +> 详见 [C# Guide](../csharp.md) + +```csharp +// ❌ N+1: foreach 触发懒加载 +foreach (var blog in await context.Blogs.ToListAsync()) + foreach (var post in blog.Posts) // 每次循环都查询! + +// ✅ Include + ThenInclude +var blogs = await context.Blogs + .Include(b => b.Posts) + .ToListAsync(); + +// ✅ 投影(最安全,避免过度获取) +var data = await context.Blogs + .Select(b => new { b.Url, PostTitles = b.Posts.Select(p => p.Title) }) + .ToListAsync(); +``` + +### PHP / Laravel / Doctrine + +> 详见 [PHP Guide](../php.md) + +```php +// ❌ 循环内查询 +foreach ($orders as $order) { + $customer = $customerRepo->find($order->customerId); + render($order, $customer); +} + +// ✅ 批量预加载 +$customerIds = array_unique(array_map(fn($o) => $o->customerId, $orders)); +$customers = $customerRepo->findByIds($customerIds); + +foreach ($orders as $order) { + render($order, $customers[$order->customerId] ?? null); +} + +// Laravel Eloquent: with() +$orders = Order::with('customer')->get(); + +// Doctrine: JOIN FETCH +$dql = 'SELECT o, c FROM Order o JOIN o.customer c'; +``` + +### TypeScript / Prisma + +```typescript +// ❌ N+1 +const users = await prisma.user.findMany(); +for (const user of users) { + user.posts = await prisma.post.findMany({ where: { userId: user.id } }); +} + +// ✅ include(Prisma 自动生成 JOIN 或批量查询) +const users = await prisma.user.findMany({ + include: { posts: true }, +}); + +// ✅ 嵌套 include +const users = await prisma.user.findMany({ + include: { + posts: { + include: { comments: true }, + }, + }, +}); +``` + +--- + +## Review Checklist + +### 检测 +- [ ] 开启了 SQL 日志或查询计数监控 +- [ ] 测试中有查询数量断言 +- [ ] APM 工具配置了 N+1 告警 + +### 修复 +- [ ] ForeignKey / OneToOne 关系使用 JOIN eager loading +- [ ] M2M / 反向关系使用 IN 批量预加载 +- [ ] 避免在循环中触发数据库查询 +- [ ] 使用投影只获取需要的字段 + +### 架构 +- [ ] 列表 API 分页,避免一次加载过多记录 +- [ ] GraphQL 场景使用 DataLoader +- [ ] 缓存策略(Redis)处理高频读取的关联数据 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/sql-injection-prevention.md b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/sql-injection-prevention.md new file mode 100644 index 000000000..e4d405a02 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/sql-injection-prevention.md @@ -0,0 +1,308 @@ +# SQL Injection Prevention Guide + +Language-agnostic SQL injection prevention strategies with cross-language code examples. + +> **Related**: [Security Review Guide](../security-review-guide.md) for comprehensive security checklist and decision framework. + +## Attack Types + +SQL injection (SQLi) is ranked #3 in the OWASP Top 10 (2021). Three common variants: + +| Type | Description | Risk | +|------|-------------|------| +| **Classic (In-band)** | Attacker receives results directly in the HTTP response | Data exfiltration, authentication bypass | +| **Blind (Boolean/Time-based)** | Attacker infers data from response differences or timing | Slower but still viable for data extraction | +| **Out-of-band** | Attacker uses DNS/HTTP callbacks to exfiltrate data | Less common but harder to detect | + +## Universal Prevention Strategy + +1. **Parameterized queries** — always (the #1 defense) +2. **ORM safe usage** — understand what your ORM escapes +3. **Input validation** — whitelist over blacklist +4. **Least privilege** — database user with minimal permissions +5. **WAF** — web application firewall as defense-in-depth + +--- + +## Cross-Language Examples + +### Python + +```python +# ❌ Vulnerable: string formatting +query = f"SELECT * FROM users WHERE id = {user_id}" +cursor.execute(query) + +# ❌ Vulnerable: % formatting +cursor.execute("SELECT * FROM users WHERE id = %s" % user_id) + +# ✅ Parameterized (DB-API) +cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,)) + +# ✅ SQLAlchemy ORM +User.query.filter(User.id == user_id).all() + +# ❌ SQLAlchemy raw SQL with string interpolation +session.execute(text(f"SELECT * FROM users WHERE id = {user_id}")) + +# ✅ SQLAlchemy raw SQL with bound parameters +session.execute(text("SELECT * FROM users WHERE id = :id"), {"id": user_id}) + +# ✅ Django ORM +User.objects.filter(id=user_id) + +# ❌ Django extra() with string interpolation +User.objects.extra(where=[f"username = '{username}'"]) + +# ✅ Django raw() with parameters +User.objects.raw("SELECT * FROM users WHERE id = %s", [user_id]) +``` + +### Java + +```java +// ❌ Vulnerable: string concatenation +String query = "SELECT * FROM users WHERE id = " + userId; +Statement stmt = connection.createStatement(); +ResultSet rs = stmt.executeQuery(query); + +// ✅ JDBC PreparedStatement +String query = "SELECT * FROM users WHERE id = ?"; +PreparedStatement stmt = connection.prepareStatement(query); +stmt.setLong(1, userId); +ResultSet rs = stmt.executeQuery(); + +// ✅ JPA parameter binding +@Query("SELECT u FROM User u WHERE u.id = :id") +User findById(@Param("id") Long id); + +// ✅ Spring Data JPA method naming +User findById(Long id); + +// ❌ JPA native query with string concatenation +entityManager.createNativeQuery( + "SELECT * FROM users WHERE name = '" + name + "'" +); + +// ✅ JPA native query with parameter binding +Query query = entityManager.createNativeQuery( + "SELECT * FROM users WHERE name = :name" +); +query.setParameter("name", name); +``` + +### Go + +```go +// ❌ Vulnerable: fmt.Sprintf +query := fmt.Sprintf("SELECT * FROM users WHERE id = %s", userID) +rows, err := db.Query(query) + +// ✅ database/sql parameterized +rows, err := db.Query("SELECT * FROM users WHERE id = ?", userID) + +// ✅ Named parameters (sqlx) +rows, err := db.NamedQuery( + "SELECT * FROM users WHERE id = :id", + map[string]interface{}{"id": userID}, +) + +// ⚠️ Dynamic identifiers (table/column names) can't use placeholders +// Must validate against whitelist +var allowedColumns = map[string]bool{ + "id": true, "name": true, "email": true, "created_at": true, +} + +func queryWithOrder(db *sql.DB, orderBy string) (*sql.Rows, error) { + if !allowedColumns[orderBy] { + return nil, fmt.Errorf("invalid column: %s", orderBy) + } + return db.Query( + fmt.Sprintf("SELECT * FROM users ORDER BY %s", orderBy), + ) +} +``` + +### Node.js + +```typescript +// ❌ Vulnerable: template literal +const query = `SELECT * FROM users WHERE id = ${userId}`; +const result = await client.query(query); + +// ✅ pg parameterized ($1, $2, ...) +const result = await client.query( + "SELECT * FROM users WHERE id = $1", + [userId] +); + +// ✅ Prisma ORM (parameterized by default) +const user = await prisma.user.findUnique({ + where: { id: userId }, +}); + +// ❌ Prisma $queryRawUnsafe with string interpolation +await prisma.$queryRawUnsafe( + `SELECT * FROM users WHERE id = ${userId}` +); + +// ✅ Prisma $queryRaw with tagged template (safe) +await prisma.$queryRaw` + SELECT * FROM users WHERE id = ${userId} +`; +``` + +### PHP + +```php +query($sql)->fetch(); + +// ✅ PDO prepared statements +$stmt = $pdo->prepare("SELECT * FROM users WHERE email = :email"); +$stmt->execute(['email' => $email]); +$user = $stmt->fetch(PDO::FETCH_ASSOC); + +// ✅ PDO positional placeholders +$stmt = $pdo->prepare("SELECT * FROM users WHERE id = ?"); +$stmt->execute([$id]); + +// ❌ mysqli with string interpolation +$result = mysqli_query($conn, + "SELECT * FROM users WHERE id = " . $id +); + +// ✅ mysqli prepared statements +$stmt = mysqli_prepare($conn, "SELECT * FROM users WHERE id = ?"); +mysqli_stmt_bind_param($stmt, "i", $id); +mysqli_stmt_execute($stmt); + +// ✅ Laravel Eloquent ORM +User::where('id', $id)->first(); + +// ❌ Laravel DB::raw with interpolation +DB::select(DB::raw("SELECT * FROM users WHERE id = {$id}")); + +// ✅ Laravel parameterized raw +DB::select("SELECT * FROM users WHERE id = ?", [$id]); +``` + +### C# / .NET + +```csharp +// ❌ Vulnerable: string concatenation +var query = $"SELECT * FROM Users WHERE Id = {userId}"; +using var cmd = new SqlCommand(query, connection); +var reader = cmd.ExecuteReader(); + +// ✅ ADO.NET parameterized +var query = "SELECT * FROM Users WHERE Id = @Id"; +using var cmd = new SqlCommand(query, connection); +cmd.Parameters.AddWithValue("@Id", userId); + +// ✅ Dapper parameterized +var users = connection.Query( + "SELECT * FROM Users WHERE Id = @Id", + new { Id = userId } +); + +// ❌ Dapper with string interpolation +var users = connection.Query( + $"SELECT * FROM Users WHERE Id = {userId}" +); + +// ✅ EF Core (parameterized by default) +var user = await context.Users + .Where(u => u.Id == userId) + .FirstOrDefaultAsync(); + +// ❌ EF Core FromSqlRaw with interpolation +var users = context.Users + .FromSqlRaw($"SELECT * FROM Users WHERE Id = {userId}") + .ToList(); + +// ✅ EF Core FromSql with FormattableString (parameterized) +var users = context.Users + .FromSql($"SELECT * FROM Users WHERE Id = {userId}") + .ToList(); +``` + +--- + +## ORM Unsafe Usage Patterns + +ORMs do NOT automatically prevent SQL injection in all cases: + +```python +# ❌ SQLAlchemy: text() with f-string +session.execute(text(f"SELECT * FROM users WHERE id = {user_id}")) + +# ❌ Django: extra() / RawSQL() with string interpolation +User.objects.extra(where=[f"username = '{username}'"]) +User.objects.annotate( + val=RawSQL(f"SELECT col FROM other WHERE id = {user_id}") +) + +# ❌ JPA: createNativeQuery with string concatenation +entityManager.createNativeQuery("SELECT * FROM users WHERE name = '" + name + "'") + +# ❌ EF Core: FromSqlRaw with string interpolation +context.Users.FromSqlRaw($"SELECT * FROM Users WHERE Id = {userId}") +``` + +**Rule**: Every ORM has a "raw SQL" escape hatch. String interpolation in that escape hatch = SQL injection. Always use the ORM's parameter binding mechanism. + +--- + +## Dynamic Identifiers (Table/Column Names) + +Placeholders can only bind **values**, not table names, column names, or SQL keywords. For dynamic identifiers: + +```python +# ✅ Whitelist validation +ALLOWED_COLUMNS = {"id", "name", "email", "created_at"} +ALLOWED_DIRECTIONS = {"ASC", "DESC"} + +def get_users(order_by: str, direction: str) -> list[User]: + if order_by not in ALLOWED_COLUMNS: + raise ValueError(f"Invalid column: {order_by}") + if direction.upper() not in ALLOWED_DIRECTIONS: + raise ValueError(f"Invalid direction: {direction}") + + return User.objects.order_by( + f"{'-' if direction.upper() == 'DESC' else ''}{order_by}" + ) +``` + +--- + +## Detection & Testing + +```bash +# Automated scanning +sqlmap -u "https://example.com/api/users?id=1" --batch + +# Static analysis (Python) +bandit -r src/ -f custom + +# Static analysis (Java) +spotbugs -textui build/classes + +# Code review keywords to search for +grep -rn "f\".*SELECT\|f'.*SELECT\|fmt.Sprintf.*SELECT\|format.*SELECT" src/ +grep -rn "query.*\+.*\|query.*&\|query.*concat" src/ +``` + +--- + +## Review Checklist + +- [ ] All SQL queries use parameterized queries (no string interpolation) +- [ ] ORM raw SQL methods use bound parameters, not string formatting +- [ ] Dynamic identifiers (table/column names) validated against whitelist +- [ ] Database user has least privilege (no DROP/ALTER for app user) +- [ ] No SQL queries constructed from user input without parameterization +- [ ] Static analysis tools (Bandit, SpotBugs, SonarQube) run in CI \ No newline at end of file diff --git a/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/xss-prevention.md b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/xss-prevention.md new file mode 100644 index 000000000..f5b48bb34 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/cross-cutting/xss-prevention.md @@ -0,0 +1,264 @@ +# XSS Prevention Guide + +Language-agnostic Cross-Site Scripting prevention strategies with cross-framework code examples. + +> **Related**: [Security Review Guide](../security-review-guide.md) for comprehensive security checklist and decision framework. + +## XSS Types + +XSS is ranked #3 in the OWASP Top 10 (2021, merged with Injection). Three variants: + +| Type | Description | Attack Vector | +|------|-------------|---------------| +| **Reflected** | Malicious script reflected off the server in the response | URL parameters, form submissions | +| **Stored (Persistent)** | Malicious script stored in the database and served to users | Comments, profiles, messages | +| **DOM-based** | Client-side JavaScript modifies the DOM unsafely | `innerHTML`, `document.write()`, `eval()` | + +## Universal Prevention Strategy + +1. **Output encoding** — encode data for the context it's rendered in (HTML, JS, URL, CSS) +2. **Content Security Policy (CSP)** — restrict which scripts can execute +3. **Input sanitization** — only when rich text is required (DOMPurify) +4. **Framework auto-escaping** — rely on framework defaults, audit escape hatches + +> **Key distinction**: Input validation prevents bad data from entering the system. Output encoding prevents bad data from being rendered as code. Both are necessary; neither alone is sufficient. + +--- + +## Cross-Framework Examples + +### React + +```typescript +// ✅ React auto-escapes JSX expressions (default safe) +return
{userInput}
; + +// ❌ dangerouslySetInnerHTML bypasses escaping +return
; + +// ✅ If HTML is required, sanitize first +import DOMPurify from 'dompurify'; +return
; + +// ❌ href with javascript: protocol +return Click; + +// ✅ Validate URL protocol +const safeUrl = userInput.startsWith('https://') ? userInput : '#'; +return Click; + +// ❌ eval / new Function with user input +const result = eval(userInput); + +// ❌ innerHTML in refs / effects +useEffect(() => { + ref.current.innerHTML = userInput; +}, [userInput]); +``` + +### Vue + +```html + +
{{ userInput }}
+ + +
+ + +
+``` + +```typescript +import DOMPurify from 'dompurify'; + +export default { + methods: { + sanitized(input: string): string { + return DOMPurify.sanitize(input); + } + } +}; + +// ❌ v-bind:href with javascript: protocol +// Click — userInput could be "javascript:alert(1)" +``` + +### Angular + +```typescript +// ✅ Angular auto-escapes interpolation (default safe) +template: `
{{ userInput }}
` + +// ❌ bypassSecurityTrustHtml disables sanitization +import { DomSanitizer } from '@angular/platform-browser'; + +constructor(private sanitizer: DomSanitizer) { + this.unsafe = this.sanitizer.bypassSecurityTrustHtml(userInput); +} + +// ❌ bypassSecurityTrustUrl with javascript: protocol +this.unsafeUrl = this.sanitizer.bypassSecurityTrustUrl(userInput); + +// ✅ Only use bypassSecurityTrust* with server-validated content +// and document the reason +``` + +### Svelte + +```svelte + +
{userInput}
+ + +
{@html userInput}
+ + + +
{@html sanitized}
+``` + +### Django (Server-Side) + +```python +# ✅ Django auto-escapes template variables +# template:

{{ user_bio }}

+ +# ❌ mark_safe bypasses auto-escaping +from django.utils.safestring import mark_safe +return HttpResponse(mark_safe(f"

{user_bio}

")) + +# ❌ autoescape off in template +# {% autoescape off %}{{ user_bio }}{% endautoescape %} + +# ✅ If mark_safe is necessary, escape first +from django.utils.html import escape +return HttpResponse(mark_safe(f"

{escape(user_bio)}

")) +``` + +### Server-Side Rendering + +```typescript +// ❌ SSR: injecting raw user data into HTML +const html = `
${userInput}
`; + +// ✅ Always escape server-side rendered content +import escapeHtml from 'escape-html'; +const html = `
${escapeHtml(userInput)}
`; + +// ❌ JSON serialization without escaping +const json = JSON.stringify({ name: userInput }); +// userInput could contain to break out of script tags + +// ✅ JSON in HTML: escape < and > +const safe = JSON.stringify({ name: userInput }) + .replace(//g, '\\u003e'); +``` + +--- + +## Content Security Policy (CSP) + +CSP is defense-in-depth. Even if XSS escapes output encoding, CSP limits what an attacker can do. + +```nginx +# ✅ Recommended CSP (strict) +Content-Security-Policy: + default-src 'self'; + script-src 'self' 'nonce-{random}' 'strict-dynamic'; + style-src 'self' 'unsafe-inline'; + img-src 'self' data: https:; + object-src 'none'; + base-uri 'self'; + form-action 'self'; + frame-ancestors 'none'; +``` + +```typescript +// ✅ Express middleware +import helmet from 'helmet'; + +app.use(helmet.contentSecurityPolicy({ + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'nonce-{random}'"], + styleSrc: ["'self'", "'unsafe-inline'"], + objectSrc: ["'none'"], + baseUri: ["'self'"], + formAction: ["'self'"], + frameAncestors: ["'none'"], + }, +})); +``` + +```html + + + + + + + + +``` + +**CSP anti-patterns to avoid:** +- `script-src 'unsafe-inline'` without nonce/hash +- `script-src 'unsafe-eval'` (enables `eval()`) +- `default-src *` (allows loading from any origin) +- `script-src https:` (allows any HTTPS origin, including attacker-controlled) + +--- + +## Input Validation vs Output Encoding + +| Layer | What | When | Example | +|-------|------|------|---------| +| **Input validation** | Reject/clean data on entry | At API boundary | Reject ` + +" onmouseover="alert(1) +javascript:alert(1) +'-alert(1)-' + +# Static analysis (code review) +grep -rn "innerHTML\|dangerouslySetInnerHTML\|v-html\|bypassSecurityTrust\|mark_safe\|@html\|{@html" src/ +grep -rn "eval(\|new Function\|document.write\|setTimeout.*string\|setInterval.*string" src/ +``` + +--- + +## Review Checklist + +- [ ] Framework auto-escaping is relied upon by default (no manual escaping) +- [ ] `dangerouslySetInnerHTML` / `v-html` / `bypassSecurityTrust` / `{@html}` / `mark_safe` are audited +- [ ] All HTML rendering escape hatches are preceded by `DOMPurify.sanitize()` or equivalent +- [ ] CSP is configured with nonce-based or hash-based script-src +- [ ] No `eval()`, `new Function()`, or `javascript:` URLs with user input +- [ ] No inline event handlers (`onclick="..."`) when CSP is enabled +- [ ] Server-side rendered content is escaped before injection +- [ ] JSON in HTML is properly escaped (`` → `\u003c/script\u003e`) \ No newline at end of file diff --git a/examples/skills_code_review_agent/skills/code-review/reference/csharp.md b/examples/skills_code_review_agent/skills/code-review/reference/csharp.md new file mode 100644 index 000000000..1e0439307 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/csharp.md @@ -0,0 +1,525 @@ +# C# / .NET Code Review Guide + +> C# / .NET 8 代码审查指南,覆盖 C# 12 新特性、异步编程、EF Core 性能、ASP.NET Core 最佳实践、依赖注入、LINQ 等核心主题。 + +## 目录 + +- [C# 12 新特性](#c-12-新特性) +- [异步编程](#异步编程) +- [EF Core 性能](#ef-core-性能) +- [ASP.NET Core 最佳实践](#aspnet-core-最佳实践) +- [依赖注入](#依赖注入) +- [LINQ 最佳实践](#linq-最佳实践) +- [Review Checklist](#review-checklist) + +--- + +## C# 12 新特性 + +### Primary Constructors(非 record 类型) + +```csharp +// ❌ 样板代码过多的传统构造函数 +public class ProductService +{ + private readonly ProductDbContext _db; + private readonly ILogger _logger; + + public ProductService(ProductDbContext db, ILogger logger) + { + _db = db; + _logger = logger; + } +} + +// ✅ Primary Constructor——简洁的依赖注入 +public class ProductService(ProductDbContext db, ILogger logger) +{ + public async Task GetAsync(int id) + => await db.Products.FindAsync(id); +} + +// ⚠️ 注意:primary constructor 参数不是属性,不能被重新赋值 +// ⚠️ 如果需要长期存储,显式声明字段 +public class OrderService(OrderDbContext db) +{ + private readonly OrderDbContext _db = db; // 显式捕获 +} +``` + +### Collection Expressions + +```csharp +// ❌ 传统集合初始化 +int[] nums = new int[] { 1, 2, 3 }; +List names = new List { "alice", "bob" }; + +// ✅ 集合表达式 +int[] nums = [1, 2, 3]; +List names = ["alice", "bob"]; +Span span = ['a', 'b']; + +// ✅ 展开运算符 +int[] merged = [..nums, 4, 5]; +``` + +### Default Lambda Parameters + +```csharp +// ❌ 重载 lambda +var add = (int a, int b) => a + b; +var addDefault = (int a) => a + 1; + +// ✅ 默认参数 +var add = (int a, int b = 1) => a + b; +``` + +--- + +## 异步编程 + +> 📖 通用并发模式和跨语言示例详见 [异步与并发跨语言指南](cross-cutting/async-concurrency-patterns.md) + +### Task.Wait() / .Result / async void 是严重反模式 + +```csharp +// ❌ Task.Wait() —— 死锁风险(同步阻塞异步操作) +public ActionResult Get(int id) +{ + var data = _service.GetDataAsync(id).Result; // 死锁! + return Ok(data); +} + +// ❌ async void —— 异常无法捕获,会崩溃进程 +public async void HandleEvent() +{ + await _service.ProcessAsync(); // 异常直接崩溃 +} + +// ✅ async Task —— 全链路异步 +public async Task> Get(int id) +{ + var data = await _service.GetDataAsync(id); + return Ok(data); +} +``` + +### ConfigureAwait(false) 用于库代码 + +```csharp +// ❌ 库代码不必要地捕获 SynchronizationContext +public class LibraryService +{ + public async Task GetDataAsync() + { + var response = await _httpClient.GetAsync("/api/data"); + return await response.Content.ReadAsStringAsync(); + } +} + +// ✅ 库代码使用 ConfigureAwait(false) 避免死锁 +public class LibraryService +{ + public async Task GetDataAsync() + { + var response = await _httpClient.GetAsync("/api/data").ConfigureAwait(false); + return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } +} +``` + +### CancellationToken 传播 + +```csharp +// ❌ 丢弃 CancellationToken +public async Task> SearchAsync(string query) +{ + return await _db.Users.Where(u => u.Name.Contains(query)).ToListAsync(); +} + +// ✅ 全链路传递 CancellationToken +public async Task> SearchAsync(string query, CancellationToken ct = default) +{ + return await _db.Users + .Where(u => u.Name.Contains(query)) + .ToListAsync(ct); +} +``` + +### Async Disposal + +```csharp +// ❌ 同步 dispose 异步资源 +public class DataClient : IDisposable +{ + public void Dispose() + { + _httpClient.Dispose(); // 可能丢弃正在进行的请求 + } +} + +// ✅ IAsyncDisposable +public class DataClient : IAsyncDisposable +{ + public async ValueTask DisposeAsync() + { + await _stream.DisposeAsync(); + } +} + +// ✅ 调用方使用 await using +await using var client = new DataClient(); +``` + +--- + +## EF Core 性能 + +### N+1 查询问题 + +> 📖 通用原理和跨语言方案详见 [N+1 查询跨语言指南](cross-cutting/n-plus-one-queries.md) + +```csharp +// ❌ 经典 N+1——每个 Blog 触发一次查询获取 Posts +foreach (var blog in await context.Blogs.ToListAsync()) +{ + foreach (var post in blog.Posts) // 每次循环都查询数据库! + { + Console.WriteLine(post.Title); + } +} + +// ✅ Eager Loading + 投影 +await foreach (var blog in context.Blogs + .Select(b => new { b.Url, b.Posts }) + .AsAsyncEnumerable()) +{ + foreach (var post in blog.Posts) + Console.WriteLine(post.Title); +} +``` + +### 过度获取(不投影) + +```csharp +// ❌ 加载所有列——只需要 Url 时加载了全部字段 +var urls = await context.Blogs.ToListAsync(); + +// ✅ 只投影需要的字段 +var urls = await context.Blogs + .Select(b => b.Url) + .ToListAsync(); +``` + +### 缺少分页 + +```csharp +// ❌ 无界结果集 +var posts = await context.Posts + .Where(p => p.Title.StartsWith("A")) + .ToListAsync(); // 可能有百万条记录! + +// ✅ 限制结果数量 +var posts = await context.Posts + .Where(p => p.Title.StartsWith("A")) + .OrderBy(p => p.Id) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); +``` + +### Cartesian Explosion(JOIN 笛卡尔爆炸) + +```csharp +// ❌ 多个 Include 创建大量重复数据 +var blogs = await context.Blogs + .Include(b => b.Posts) + .Include(b => b.Tags) + .ToListAsync(); // 每行重复 Blog 数据 + +// ✅ 使用 AsSplitQuery 拆分查询 +var blogs = await context.Blogs + .Include(b => b.Posts) + .Include(b => b.Tags) + .AsSplitQuery() + .ToListAsync(); +``` + +### 只读场景缺少 AsNoTracking + +```csharp +// ❌ 默认跟踪——只读查询也付出跟踪开销 +var products = await context.Products.ToListAsync(); + +// ✅ AsNoTracking——跳过变更跟踪,更快且更省内存 +var products = await context.Products + .AsNoTracking() + .ToListAsync(); +``` + +### 列上函数阻止索引使用 + +```csharp +// ✅ 可以使用索引——sargable +var posts1 = await context.Posts + .Where(p => p.Title.StartsWith("A")) + .ToListAsync(); + +// ❌ 无法使用索引——全表扫描 +var posts2 = await context.Posts + .Where(p => p.Title.EndsWith("A")) + .ToListAsync(); + +// ❌ 列上套函数——全表扫描 +var posts3 = await context.Posts + .Where(p => p.Title.ToLower() == "foo") + .ToListAsync(); +``` + +### 同步 vs 异步数据库访问 + +```csharp +// ❌ 同步数据库调用——阻塞线程 +var products = context.Products.ToList(); +context.SaveChanges(); + +// ✅ 异步数据库调用 +var products = await context.Products.ToListAsync(); +await context.SaveChangesAsync(); +``` + +--- + +## ASP.NET Core 最佳实践 + +### HttpClient 误用 + +```csharp +// ❌ 每次请求创建新的 HttpClient——socket 耗尽 +using var client = new HttpClient(); +var response = await client.GetAsync("https://api.example.com/data"); + +// ✅ IHttpClientFactory 注入 +public class MyService +{ + private readonly HttpClient _client; + public MyService(HttpClient client) => _client = client; // 从工厂注入 +} +``` + +### HttpContext 在后台线程中使用 + +```csharp +// ❌ 在后台任务中捕获 scoped 服务——请求结束后已释放 +_ = Task.Run(async () => +{ + await context.SaveChangesAsync(); // ObjectDisposedException! +}); + +// ✅ 创建新的 scope +_ = Task.Run(async () => +{ + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + await db.SaveChangesAsync(); +}); +``` + +### Request.Form 同步访问 + +```csharp +// ❌ 同步读取 Form——sync over async +var form = HttpContext.Request.Form; + +// ✅ 异步读取 +var form = await HttpContext.Request.ReadFormAsync(); +``` + +### 异常用于控制流 + +```csharp +// ❌ 用异常判断是否存在——异常开销大,比直接检查慢得多 +try +{ + var user = await _db.Users.FirstAsync(u => u.Id == id); +} +catch (InvalidOperationException) +{ + return NotFound(); +} + +// ✅ 使用检查而非异常 +var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id); +if (user is null) return NotFound(); +``` + +### 响应头在 Body 之后设置 + +```csharp +// ❌ body 已发送后再设置 header——抛异常 +await next(context); +context.Response.Headers["X-Custom"] = "value"; // 可能抛异常! + +// ✅ 使用 OnStarting 回调 +context.Response.OnStarting(() => +{ + context.Response.Headers["X-Custom"] = "value"; + return Task.CompletedTask; +}); +await next(context); +``` + +--- + +## 依赖注入 + +### Scoped 服务注入 Singleton + +```csharp +// ❌ Scoped 服务注入 Singleton——生命周期不匹配 +services.AddSingleton(); +services.AddScoped(); + +// BackgroundWorker 是 Singleton,UserRepository 是 Scoped +// → UserRepository 在多个请求间共享或已释放 + +// ✅ 在 Singleton 中通过 IServiceProvider 创建 scope +public class BackgroundWorker : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory; + + public BackgroundWorker(IServiceScopeFactory scopeFactory) + => _scopeFactory = scopeFactory; + + protected override async Task ExecuteAsync(CancellationToken ct) + { + await using var scope = _scopeFactory.CreateAsyncScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + } +} +``` + +--- + +## LINQ 最佳实践 + +### ToList 之后再 LINQ + +```csharp +// ❌ 先 ToList 再过滤——全表加载到内存 +var results = context.Posts + .Where(p => p.Title.StartsWith("A")) + .ToList() + .Where(p => SomeClientFilter(p)); // 客户端过滤,已加载全部行 + +// ✅ 尽可能让数据库执行过滤 +var results = await context.Posts + .Where(p => p.Title.StartsWith("A") && SomeDbFilter(p)) + .AsAsyncEnumerable() + .Where(p => SomeClientFilter(p)) // 只过滤数据库返回的行 + .ToListAsync(); +``` + +### Count() vs Any() + +```csharp +// ❌ Count() 执行完整查询 +if (context.Users.Count() > 0) { /* ... */ } + +// ✅ Any() 更高效——遇到第一条记录就返回 +if (await context.Users.AnyAsync()) { /* ... */ } +``` + +### 多次枚举 IEnumerable + +```csharp +// ❌ IEnumerable 被枚举两次 +public void Process(IEnumerable numbers) +{ + if (numbers.Any()) // 第一次枚举 + { + foreach (var n in numbers) // 第二次枚举(可能是重新查询) + { + Console.WriteLine(n); + } + } +} + +// ✅ 如果需要多次使用,先物化 +public void Process(IEnumerable numbers) +{ + var list = numbers.ToList(); // 只枚举一次 + if (list.Any()) + { + foreach (var n in list) + { + Console.WriteLine(n); + } + } +} +``` + +### Select 中的副作用 + +```csharp +// ❌ Select 中执行副作用——不可预测的执行时机 +var results = users.Select(u => +{ + _logger.LogInformation($"Processing {u.Name}"); // 副作用! + return u.Email; +}).ToList(); + +// ✅ 副作用放在 foreach 中 +foreach (var user in users) +{ + _logger.LogInformation("Processing {Name}", user.Name); +} +var results = users.Select(u => u.Email).ToList(); +``` + +--- + +## Review Checklist + +### C# 12 新特性 + +- [ ] Primary constructor 参数不被重新赋值 +- [ ] 集合表达式语法一致(不混用新旧风格) + +### 异步编程 + +- [ ] 无 `Task.Wait()`、`.Result`、`async void` +- [ ] 库代码使用 `ConfigureAwait(false)` +- [ ] `CancellationToken` 全链路传递 +- [ ] 异步资源使用 `IAsyncDisposable` / `await using` +- [ ] 不混用同步和异步数据访问 + +### EF Core + +- [ ] 无 N+1 查询(导航属性在循环中访问) +- [ ] 投影 `Select()` 避免过度获取 +- [ ] 分页:`ToListAsync()` 前有 `Take()`/`Skip()` +- [ ] 多个 `Include()` 使用 `AsSplitQuery()` +- [ ] 只读查询使用 `AsNoTracking()` +- [ ] 列上无函数调用阻止索引使用 +- [ ] 数据库调用全部异步 + +### ASP.NET Core + +- [ ] HttpClient 通过 `IHttpClientFactory` 获取 +- [ ] 后台任务中不直接使用 scoped 服务 +- [ ] 使用 `ReadFormAsync` 代替 `Request.Form` +- [ ] 异常不用于控制流 +- [ ] 响应头通过 `OnStarting` 设置 + +### 依赖注入 + +- [ ] Scoped 服务不注入 Singleton +- [ ] 后台任务创建新 scope + +### LINQ + +- [ ] 无不必要的 `ToList()` 后再 LINQ +- [ ] `Any()` 代替 `Count() > 0` +- [ ] IEnumerable 不被多次枚举(或先物化) +- [ ] Select 中无副作用 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/css-less-sass.md b/examples/skills_code_review_agent/skills/code-review/reference/css-less-sass.md new file mode 100644 index 000000000..782c5d996 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/css-less-sass.md @@ -0,0 +1,661 @@ +# CSS / Less / Sass Review Guide + +CSS 及预处理器代码审查指南,覆盖性能、可维护性、响应式设计和浏览器兼容性。 + +## CSS 变量 vs 硬编码 + +### 应该使用变量的场景 + +```css +/* ❌ 硬编码 - 难以维护 */ +.button { + background: #3b82f6; + border-radius: 8px; +} +.card { + border: 1px solid #3b82f6; + border-radius: 8px; +} + +/* ✅ 使用 CSS 变量 */ +:root { + --color-primary: #3b82f6; + --radius-md: 8px; +} +.button { + background: var(--color-primary); + border-radius: var(--radius-md); +} +.card { + border: 1px solid var(--color-primary); + border-radius: var(--radius-md); +} +``` + +### 变量命名规范 + +```css +/* 推荐的变量分类 */ +:root { + /* 颜色 */ + --color-primary: #3b82f6; + --color-primary-hover: #2563eb; + --color-text: #1f2937; + --color-text-muted: #6b7280; + --color-bg: #ffffff; + --color-border: #e5e7eb; + + /* 间距 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 16px; + --spacing-lg: 24px; + --spacing-xl: 32px; + + /* 字体 */ + --font-size-sm: 14px; + --font-size-base: 16px; + --font-size-lg: 18px; + --font-weight-normal: 400; + --font-weight-bold: 700; + + /* 圆角 */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-full: 9999px; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + + /* 过渡 */ + --transition-fast: 150ms ease; + --transition-normal: 300ms ease; +} +``` + +### 变量作用域建议 + +```css +/* ✅ 组件级变量 - 减少全局污染 */ +.card { + --card-padding: var(--spacing-md); + --card-radius: var(--radius-md); + + padding: var(--card-padding); + border-radius: var(--card-radius); +} + +/* ⚠️ 避免频繁用 JS 动态修改变量 - 影响性能 */ +``` + +### 审查清单 + +- [ ] 颜色值是否使用变量? +- [ ] 间距是否来自设计系统? +- [ ] 重复值是否提取为变量? +- [ ] 变量命名是否语义化? + +--- + +## !important 使用规范 + +### 何时可以使用 + +```css +/* ✅ 工具类 - 明确需要覆盖 */ +.hidden { display: none !important; } +.sr-only { position: absolute !important; } + +/* ✅ 覆盖第三方库样式(无法修改源码时) */ +.third-party-modal { + z-index: 9999 !important; +} + +/* ✅ 打印样式 */ +@media print { + .no-print { display: none !important; } +} +``` + +### 何时禁止使用 + +```css +/* ❌ 解决特异性问题 - 应该重构选择器 */ +.button { + background: blue !important; /* 为什么需要 !important? */ +} + +/* ❌ 覆盖自己写的样式 */ +.card { padding: 20px; } +.card { padding: 30px !important; } /* 直接修改原规则 */ + +/* ❌ 在组件样式中 */ +.my-component .title { + font-size: 24px !important; /* 破坏组件封装 */ +} +``` + +### 替代方案 + +```css +/* 问题:需要覆盖 .btn 的样式 */ + +/* ❌ 使用 !important */ +.my-btn { + background: red !important; +} + +/* ✅ 提高特异性 */ +button.my-btn { + background: red; +} + +/* ✅ 使用更具体的选择器 */ +.container .my-btn { + background: red; +} + +/* ✅ 使用 :where() 降低被覆盖样式的特异性 */ +:where(.btn) { + background: blue; /* 特异性为 0 */ +} +.my-btn { + background: red; /* 可以正常覆盖 */ +} +``` + +### 审查问题 + +```markdown +🔴 [blocking] "发现 15 处 !important,请说明每处的必要性" +🟡 [important] "这个 !important 可以通过调整选择器特异性来解决" +💡 [suggestion] "考虑使用 CSS Layers (@layer) 来管理样式优先级" +``` + +--- + +## 性能考虑 + +### 🔴 高危性能问题 + +#### 1. `transition: all` 问题 + +```css +/* ❌ 性能杀手 - 浏览器检查所有可动画属性 */ +.button { + transition: all 0.3s ease; +} + +/* ✅ 明确指定属性 */ +.button { + transition: background-color 0.3s ease, transform 0.3s ease; +} + +/* ✅ 多属性时使用变量 */ +.button { + --transition-duration: 0.3s; + transition: + background-color var(--transition-duration) ease, + box-shadow var(--transition-duration) ease, + transform var(--transition-duration) ease; +} +``` + +#### 2. box-shadow 动画 + +```css +/* ❌ 每帧触发重绘 - 严重影响性能 */ +.card { + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + transition: box-shadow 0.3s ease; +} +.card:hover { + box-shadow: 0 8px 16px rgba(0,0,0,0.2); +} + +/* ✅ 使用伪元素 + opacity */ +.card { + position: relative; +} +.card::after { + content: ''; + position: absolute; + inset: 0; + box-shadow: 0 8px 16px rgba(0,0,0,0.2); + opacity: 0; + transition: opacity 0.3s ease; + pointer-events: none; + border-radius: inherit; +} +.card:hover::after { + opacity: 1; +} +``` + +#### 3. 触发布局(Reflow)的属性 + +```css +/* ❌ 动画这些属性会触发布局重计算 */ +.bad-animation { + transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s, margin 0.3s; +} + +/* ✅ 只动画 transform 和 opacity(仅触发合成) */ +.good-animation { + transition: transform 0.3s, opacity 0.3s; +} + +/* 位移用 translate 代替 top/left */ +.move { + transform: translateX(100px); /* ✅ */ + /* left: 100px; */ /* ❌ */ +} + +/* 缩放用 scale 代替 width/height */ +.grow { + transform: scale(1.1); /* ✅ */ + /* width: 110%; */ /* ❌ */ +} +``` + +### 🟡 中等性能问题 + +#### 复杂选择器 + +```css +/* ❌ 过深的嵌套 - 选择器匹配慢 */ +.page .container .content .article .section .paragraph span { + color: red; +} + +/* ✅ 扁平化 */ +.article-text { + color: red; +} + +/* ❌ 通配符选择器 */ +* { box-sizing: border-box; } /* 影响所有元素 */ +[class*="icon-"] { display: inline; } /* 属性选择器较慢 */ + +/* ✅ 限制范围 */ +.icon-box * { box-sizing: border-box; } +``` + +#### 大量阴影和滤镜 + +```css +/* ⚠️ 复杂阴影影响渲染性能 */ +.heavy-shadow { + box-shadow: + 0 1px 2px rgba(0,0,0,0.1), + 0 2px 4px rgba(0,0,0,0.1), + 0 4px 8px rgba(0,0,0,0.1), + 0 8px 16px rgba(0,0,0,0.1), + 0 16px 32px rgba(0,0,0,0.1); /* 5 层阴影 */ +} + +/* ⚠️ 滤镜消耗 GPU */ +.blur-heavy { + filter: blur(20px) brightness(1.2) contrast(1.1); + backdrop-filter: blur(10px); /* 更消耗性能 */ +} +``` + +### 性能优化建议 + +```css +/* 使用 will-change 提示浏览器(谨慎使用) */ +.animated-element { + will-change: transform, opacity; +} + +/* 动画完成后移除 will-change */ +.animated-element.idle { + will-change: auto; +} + +/* 使用 contain 限制重绘范围 */ +.card { + contain: layout paint; /* 告诉浏览器内部变化不影响外部 */ +} +``` + +### 审查清单 + +- [ ] 是否使用 `transition: all`? +- [ ] 是否动画 width/height/top/left? +- [ ] box-shadow 是否被动画? +- [ ] 选择器嵌套是否超过 3 层? +- [ ] 是否有不必要的 `will-change`? + +--- + +## 响应式设计检查点 + +### Mobile First 原则 + +```css +/* ✅ Mobile First - 基础样式针对移动端 */ +.container { + padding: 16px; + display: flex; + flex-direction: column; +} + +/* 逐步增强 */ +@media (min-width: 768px) { + .container { + padding: 24px; + flex-direction: row; + } +} + +@media (min-width: 1024px) { + .container { + padding: 32px; + max-width: 1200px; + margin: 0 auto; + } +} + +/* ❌ Desktop First - 需要覆盖更多样式 */ +.container { + max-width: 1200px; + padding: 32px; + flex-direction: row; +} + +@media (max-width: 1023px) { + .container { + padding: 24px; + } +} + +@media (max-width: 767px) { + .container { + padding: 16px; + flex-direction: column; + max-width: none; + } +} +``` + +### 断点建议 + +```css +/* 推荐断点(基于内容而非设备) */ +:root { + --breakpoint-sm: 640px; /* 大手机 */ + --breakpoint-md: 768px; /* 平板竖屏 */ + --breakpoint-lg: 1024px; /* 平板横屏/小笔记本 */ + --breakpoint-xl: 1280px; /* 桌面 */ + --breakpoint-2xl: 1536px; /* 大桌面 */ +} + +/* 使用示例 */ +@media (min-width: 768px) { /* md */ } +@media (min-width: 1024px) { /* lg */ } +``` + +### 响应式审查清单 + +- [ ] 是否采用 Mobile First? +- [ ] 断点是否基于内容断裂点而非设备? +- [ ] 是否避免断点重叠? +- [ ] 文字是否使用相对单位(rem/em)? +- [ ] 触摸目标是否足够大(≥44px)? +- [ ] 是否测试了横竖屏切换? + +### 常见问题 + +```css +/* ❌ 固定宽度 */ +.container { + width: 1200px; +} + +/* ✅ 最大宽度 + 弹性 */ +.container { + width: 100%; + max-width: 1200px; + padding-inline: 16px; +} + +/* ❌ 固定高度的文本容器 */ +.text-box { + height: 100px; /* 文字可能溢出 */ +} + +/* ✅ 最小高度 */ +.text-box { + min-height: 100px; +} + +/* ❌ 小触摸目标 */ +.small-button { + padding: 4px 8px; /* 太小,难以点击 */ +} + +/* ✅ 足够的触摸区域 */ +.touch-button { + min-height: 44px; + min-width: 44px; + padding: 12px 16px; +} +``` + +--- + +## 浏览器兼容性 + +### 需要检查的特性 + +| 特性 | 兼容性 | 建议 | +|------|--------|------| +| CSS Grid | 现代浏览器 ✅ | IE 需要 Autoprefixer + 测试 | +| Flexbox | 广泛支持 ✅ | 旧版需要前缀 | +| CSS Variables | 现代浏览器 ✅ | IE 不支持,需要回退 | +| `gap` (flexbox) | 较新 ⚠️ | Safari 14.1+ | +| `:has()` | 较新 ⚠️ | Firefox 121+ | +| `container queries` | 较新 ⚠️ | 2023 年后的浏览器 | +| `@layer` | 较新 ⚠️ | 检查目标浏览器 | + +### 回退策略 + +```css +/* CSS 变量回退 */ +.button { + background: #3b82f6; /* 回退值 */ + background: var(--color-primary); /* 现代浏览器 */ +} + +/* Flexbox gap 回退 */ +.flex-container { + display: flex; + gap: 16px; +} +/* 旧浏览器回退 */ +.flex-container > * + * { + margin-left: 16px; +} + +/* Grid 回退 */ +.grid { + display: flex; + flex-wrap: wrap; +} +@supports (display: grid) { + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + } +} +``` + +### Autoprefixer 配置 + +```javascript +// postcss.config.js +module.exports = { + plugins: [ + require('autoprefixer')({ + // 根据 browserslist 配置 + grid: 'autoplace', // 启用 Grid 前缀(IE 支持) + flexbox: 'no-2009', // 只用现代 flexbox 语法 + }), + ], +}; + +// package.json +{ + "browserslist": [ + "> 1%", + "last 2 versions", + "not dead", + "not ie 11" // 根据项目需求 + ] +} +``` + +### 审查清单 + +- [ ] 是否检查了 [Can I Use](https://caniuse.com)? +- [ ] 新特性是否有回退方案? +- [ ] 是否配置了 Autoprefixer? +- [ ] browserslist 是否符合项目要求? +- [ ] 是否在目标浏览器中测试? + +--- + +## Less / Sass 特定问题 + +### 嵌套深度 + +```scss +/* ❌ 过深嵌套 - 编译后选择器过长 */ +.page { + .container { + .content { + .article { + .title { + color: red; // 编译为 .page .container .content .article .title + } + } + } + } +} + +/* ✅ 最多 3 层 */ +.article { + &__title { + color: red; + } + + &__content { + p { margin-bottom: 1em; } + } +} +``` + +### Mixin vs Extend vs 变量 + +```scss +@use 'sass:color'; + +/* 变量 - 用于单个值 */ +$primary-color: #3b82f6; + +/* Mixin - 用于可配置的代码块 */ +@mixin button-variant($bg, $text) { + background: $bg; + color: $text; + &:hover { + // Dart Sass 已弃用全局 darken()/lighten(),改用 color 模块 + background: color.adjust($bg, $lightness: -10%); + // color.scale($bg, $lightness: -10%) 按比例调整,深浅过渡更自然 + } +} + +/* Extend - 用于共享相同样式(谨慎使用) */ +%visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); /* clip: rect() 已弃用,改用 clip-path */ + white-space: nowrap; /* 避免内容被挤成一列后撑开布局 */ +} + +.sr-only { + @extend %visually-hidden; +} + +/* ⚠️ @extend 的问题 */ +// 可能产生意外的选择器组合 +// 不能在 @media 中使用 +// 优先使用 mixin +``` + +### 审查清单 + +- [ ] 嵌套是否超过 3 层? +- [ ] 是否滥用 @extend? +- [ ] Mixin 是否过于复杂? +- [ ] 编译后的 CSS 大小是否合理? + +--- + +## 快速审查清单 + +### 🔴 必须修复 + +```markdown +□ transition: all +□ 动画 width/height/top/left/margin +□ 大量 !important +□ 硬编码的颜色/间距重复 >3 次 +□ 选择器嵌套 >4 层 +``` + +### 🟡 建议修复 + +```markdown +□ 缺少响应式处理 +□ 使用 Desktop First +□ 复杂 box-shadow 被动画 +□ 缺少浏览器兼容回退 +□ CSS 变量作用域过大 +``` + +### 🟢 优化建议 + +```markdown +□ 可以使用 CSS Grid 简化布局 +□ 可以使用 CSS 变量提取重复值 +□ 可以使用 @layer 管理优先级 +□ 可以添加 contain 优化性能 +``` + +--- + +## 工具推荐 + +| 工具 | 用途 | +|------|------| +| [Stylelint](https://stylelint.io/) | CSS 代码检查 | +| [PurgeCSS](https://purgecss.com/) | 移除未使用 CSS | +| [Autoprefixer](https://autoprefixer.github.io/) | 自动添加前缀 | +| [CSS Stats](https://cssstats.com/) | 分析 CSS 统计 | +| [Can I Use](https://caniuse.com/) | 浏览器兼容性查询 | + +--- + +## 参考资源 + +- [CSS Performance Optimization - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Performance/CSS) +- [What a CSS Code Review Might Look Like - CSS-Tricks](https://css-tricks.com/what-a-css-code-review-might-look-like/) +- [How to Animate Box-Shadow - Tobias Ahlin](https://tobiasahlin.com/blog/how-to-animate-box-shadow/) +- [Media Query Fundamentals - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Media_queries) +- [Autoprefixer - GitHub](https://github.com/postcss/autoprefixer) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/django.md b/examples/skills_code_review_agent/skills/code-review/reference/django.md new file mode 100644 index 000000000..55b8109f5 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/django.md @@ -0,0 +1,985 @@ +# Django / DRF Code Review Guide + +> Django / DRF 代码审查指南,覆盖安全审查、N+1 查询优化、Serializer 反模式、ViewSet 最佳实践、异步视图及生产安全配置等核心主题。 + +## 目录 + +- [安全审查](#安全审查) +- [N+1 查询优化](#n1-查询优化) +- [Serializer 反模式](#serializer-反模式) +- [ViewSet 最佳实践](#viewset-最佳实践) +- [异步视图](#异步视图) +- [中间件与设置](#中间件与设置) +- [Review Checklist](#review-checklist) + +--- + +## 安全审查 + +### XSS 防护 + +Django 模板引擎默认自动转义。审查重点:`mark_safe`、`autoescape off`、`format_html` 的使用。 + +> **跨框架 XSS 防护详见 [XSS Prevention Guide](cross-cutting/xss-prevention.md)**,含 React/Vue/Angular/Svelte 示例及 CSP 配置。 + +### CSRF 防护 + +```python +from django.views.decorators.csrf import csrf_exempt + +# ❌ 禁用 CSRF 保护 +@csrf_exempt +def process_payment(request): + # 任何恶意网站都可以提交表单 + amount = request.POST["amount"] + charge(amount) + +# ✅ 保留默认 CSRF 保护 +from django.middleware.csrf import CsrfViewMiddleware + +# settings.py — 确保 CSRF 中间件已启用 +MIDDLEWARE = [ + # ... + "django.middleware.csrf.CsrfViewMiddleware", + # ... +] + +# ✅ API 使用 token 认证代替 CSRF +# settings.py +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + "rest_framework.authentication.TokenAuthentication", + ], +} + +# ✅ 前端 AJAX 请求带上 CSRF token +# JavaScript: fetch("/api/endpoint/", { +# headers: {"X-CSRFToken": document.querySelector("[name=csrfmiddlewaretoken]").value} +# }) +``` + +### Cookie 安全设置 + +```python +# settings.py + +# ❌ 不安全的 cookie 配置 +SESSION_COOKIE_SECURE = False +CSRF_COOKIE_SECURE = False +SESSION_COOKIE_HTTPONLY = False + +# ✅ 生产环境 cookie 安全配置 +SESSION_COOKIE_SECURE = True # HTTPS only +SESSION_COOKIE_HTTPONLY = True # JavaScript 无法读取 +SESSION_COOKIE_SAMESITE = "Lax" # 防止 CSRF +CSRF_COOKIE_SECURE = True +CSRF_COOKIE_HTTPONLY = True +CSRF_COOKIE_SAMESITE = "Lax" +``` + +### SQL 注入防护 + +Django ORM 自动参数化查询。审查重点:`raw()`、`extra()`、`RawSQL`、`connection.cursor()` 中的字符串拼接。 + +> **跨语言 SQL 注入防护详见 [SQL Injection Prevention Guide](cross-cutting/sql-injection-prevention.md)**,含 Python/Java/Go/Node.js/PHP/C# 示例及 ORM 不安全用法。 + +### 文件上传安全 + +```python +# settings.py + +# ❌ 默认上传配置不安全 +FILE_UPLOAD_MAX_MEMORY_SIZE = 2621440 # 2.5 MB — 可以接受 +MEDIA_ROOT = "/var/www/uploads" # web 根目录下 +ALLOWED_UPLOAD_TYPES = None # 没有类型限制 + +# ✅ 限制上传大小和位置 +DATA_UPLOAD_MAX_MEMORY_SIZE = 10485760 # 10 MB +FILE_UPLOAD_MAX_MEMORY_SIZE = 2621440 # 2.5 MB in-memory +MEDIA_ROOT = "/srv/media/" # web 根目录之外 + +# ✅ 验证文件类型 +import mimetypes +from pathlib import Path + +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".pdf"} + +def validate_upload(file): + ext = Path(file.name).suffix.lower() + if ext not in ALLOWED_EXTENSIONS: + raise ValidationError(f"File type {ext} is not allowed.") + mime, _ = mimetypes.guess_type(file.name) + if mime not in {"image/jpeg", "image/png", "application/pdf"}: + raise ValidationError("Invalid MIME type.") +``` + +--- + +## N+1 查询优化 + +> 📖 通用原理和跨语言方案详见 [N+1 查询跨语言指南](cross-cutting/n-plus-one-queries.md) + +### select_related(ForeignKey / OneToOne) + +```python +# ❌ N+1: 每本书查一次出版社 +books = Book.objects.all() +for book in books: + print(book.publisher.name) # 额外 N 条查询 + +# ✅ select_related 一次 JOIN 查询 +books = Book.objects.select_related("publisher") +for book in books: + print(book.publisher.name) # 无额外查询 + +# ✅ 多层关系 +books = Book.objects.select_related("publisher", "publisher__country") + +# ✅ 只查需要的字段(延迟加载优化) +books = Book.objects.select_related("publisher").only( + "title", "publisher__name" +) +``` + +### prefetch_related(M2M / 反向 ForeignKey) + +```python +# ❌ N+1: 每个作者查一次书 +authors = Author.objects.all() +for author in authors: + print(author.books.all()) # 额外 N 条查询 + +# ✅ prefetch_related 两条查询 + Python 合并 +authors = Author.objects.prefetch_related("books") +for author in authors: + print(list(author.books.all())) # 无额外查询 + +# ✅ 嵌套 prefetch +authors = Author.objects.prefetch_related( + "books", + "books__publisher", +) + +# ✅ Prefetch 对象控制预查行为 +from django.db.models import Prefetch + +authors = Author.objects.prefetch_related( + Prefetch( + "books", + queryset=Book.objects.filter(published=True).only("title", "author_id"), + to_attr="published_books", + ) +) +for author in authors: + print(author.published_books) # 已过滤,存在 to_attr 中 +``` + +### QuerySet 缓存误用 + +```python +# ❌ count() 后再迭代 —— 两次查询 +qs = Book.objects.all() +count = qs.count() # 查询 1: SELECT COUNT(*) — 不填充缓存 +titles = [b.title for b in qs] # 查询 2: SELECT * — 重新评估 + +# ✅ 既要对象又要数量时,用 len() 触发一次评估并复用缓存 +qs = Book.objects.all() +count = len(qs) # 查询 1: SELECT * — 全部加载并缓存 +titles = [b.title for b in qs] # 复用缓存,无新查询 + +# ✅ 如果需要多次迭代,先转 list +books = list(Book.objects.all()) # 一次查询 +count = len(books) +titles = [b.title for b in books] +``` + +### 切片/索引不填充缓存 + +```python +# ❌ 反复索引未评估的 QuerySet —— 每次都查库 +qs = Book.objects.all() +qs[0] # 查询 1: SELECT ... LIMIT 1 +qs[0] # 查询 2 — 切片/索引不会填充缓存 + +# ✅ 先整体评估,缓存保存所有行,之后索引走缓存 +qs = Book.objects.all() +list(qs) # SELECT * — 评估并缓存全部行 +qs[0] # 走缓存,无查询 +qs[5] # 走缓存,无查询 + +# ✅ 只需要前 N 条时,切一次并转 list +books = list(Book.objects.all()[:10]) # 一次查询:SELECT ... LIMIT 10 +first = books[0] +rest = books[1:] # 已是 Python list,无查询 +``` + +### len() vs count() + +```python +# ❌ len() 加载全部对象到内存 +total = len(Book.objects.all()) # SELECT * FROM book — 全表加载 + +# ✅ count() 在数据库端计数 +total = Book.objects.count() # SELECT COUNT(*) — 高效 + +# ✅ 如果已经需要 QuerySet 结果,再用 len +books = list(Book.objects.filter(published=True)) +total = len(books) # 已在内存中,不需要额外查询 +``` + +### if qs vs qs.exists() + +```python +# ❌ if qs 加载全部记录 +qs = Book.objects.filter(author_id=author_id) +if qs: # SELECT * FROM book WHERE ... — 全部加载 + return qs[0] + +# ✅ exists() 只检查是否有记录 +if Book.objects.filter(author_id=author_id).exists(): + return Book.objects.filter(author_id=author_id).first() + +# ✅ 或者直接 get/first 判空 +book = Book.objects.filter(author_id=author_id).first() +if book is not None: + return book +``` + +--- + +## Serializer 反模式 + +### 排除敏感字段 + +```python +from rest_framework import serializers + +# ❌ __all__ 暴露所有字段,包括敏感数据 +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = "__all__" # 密码 hash、is_superuser 等全部暴露 + +# ✅ 显式列出允许的字段 +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ["id", "username", "email", "first_name", "last_name"] + +# ✅ 使用 exclude 时也要注意 +class UserProfileSerializer(serializers.ModelSerializer): + class Meta: + model = UserProfile + exclude = ["internal_notes", "admin_flags"] + +# ✅ 密码字段用 write_only +class RegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ["id", "username", "email", "password"] + + def create(self, validated_data): + user = User(**validated_data) + user.set_password(validated_data["password"]) + user.save() + return user +``` + +### 缺少验证 + +```python +from rest_framework import serializers + +# ❌ 没有验证,信任所有输入 +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + fields = ["quantity", "price", "discount"] + +# ✅ 字段级验证 +class OrderSerializer(serializers.ModelSerializer): + quantity = serializers.IntegerField(min_value=1, max_value=100) + price = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=0) + discount = serializers.DecimalField( + max_digits=5, decimal_places=2, min_value=0, max_value=1, required=False + ) + + class Meta: + model = Order + fields = ["quantity", "price", "discount"] + +# ✅ 对象级验证 +class OrderSerializer(serializers.ModelSerializer): + class Meta: + model = Order + fields = ["quantity", "price", "discount"] + + def validate(self, attrs): + if attrs.get("discount", 0) > 0.5 and attrs.get("quantity", 0) < 10: + raise serializers.ValidationError( + "Bulk discount requires minimum 10 items." + ) + return attrs + +# ✅ 自定义字段验证方法 +class BookingSerializer(serializers.ModelSerializer): + class Meta: + model = Booking + fields = ["start_date", "end_date", "room"] + + def validate_start_date(self, value): + if value < date.today(): + raise serializers.ValidationError("Start date cannot be in the past.") + return value + + def validate(self, attrs): + if attrs["end_date"] <= attrs["start_date"]: + raise serializers.ValidationError("End date must be after start date.") + return attrs +``` + +### 嵌套写入 + +```python +from rest_framework import serializers + +# ❌ 嵌套 Serializer 只读但没有实现 create/update +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ["id", "name"] + +class ArticleSerializer(serializers.ModelSerializer): + tags = TagSerializer(many=True) # 嵌套写入会失败 + + class Meta: + model = Article + fields = ["id", "title", "tags"] + +# ✅ 方案 1: 嵌套只读 + PrimaryKeyRelatedField 写入 +class ArticleSerializer(serializers.ModelSerializer): + tags = TagSerializer(many=True, read_only=True) + tag_ids = serializers.PrimaryKeyRelatedField( + queryset=Tag.objects.all(), + many=True, + write_only=True, + source="tags", + ) + + class Meta: + model = Article + fields = ["id", "title", "tags", "tag_ids"] + +# ✅ 方案 2: 实现 create() 处理嵌套 +class ArticleSerializer(serializers.ModelSerializer): + tags = TagSerializer(many=True) + + class Meta: + model = Article + fields = ["id", "title", "tags"] + + def create(self, validated_data): + tags_data = validated_data.pop("tags") + article = Article.objects.create(**validated_data) + for tag_data in tags_data: + tag, _ = Tag.objects.get_or_create(**tag_data) + article.tags.add(tag) + return article + + def update(self, instance, validated_data): + tags_data = validated_data.pop("tags", None) + instance = super().update(instance, validated_data) + if tags_data is not None: + instance.tags.clear() + for tag_data in tags_data: + tag, _ = Tag.objects.get_or_create(**tag_data) + instance.tags.add(tag) + return instance +``` + +### read_only_fields 遗漏 + +```python +from rest_framework import serializers + +# ❌ 计算字段和自动字段可被用户覆盖 +class CommentSerializer(serializers.ModelSerializer): + class Meta: + model = Comment + fields = ["id", "body", "author", "created_at", "updated_at"] + # created_at, updated_at, author 可被客户端篡改 + +# ✅ 标记只读字段 +class CommentSerializer(serializers.ModelSerializer): + class Meta: + model = Comment + fields = ["id", "body", "author", "created_at", "updated_at"] + read_only_fields = ["author", "created_at", "updated_at"] + +# ✅ 在视图中设置只读字段(如当前用户) +class CommentViewSet(viewsets.ModelViewSet): + serializer_class = CommentSerializer + + def get_serializer_context(self): + context = super().get_serializer_context() + context["request"] = self.request + return context + + def perform_create(self, serializer): + serializer.save(author=self.request.user) +``` + +--- + +## ViewSet 最佳实践 + +### 选择正确的基类 + +```python +from rest_framework import viewsets + +# ❌ ModelViewSet 提供完整 CRUD,但只需要读取 +class TagViewSet(viewsets.ModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + # 暴露了 destroy, update, create — 标签不应被随意修改 + +# ✅ 只读场景用 ReadOnlyModelViewSet +class TagViewSet(viewsets.ReadOnlyModelViewSet): + queryset = Tag.objects.all() + serializer_class = TagSerializer + # 只提供 list 和 retrieve + +# ✅ 需要自定义操作时用 Mixin +from rest_framework import mixins + +class TagViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + generics.GenericAPIView, +): + queryset = Tag.objects.all() + serializer_class = TagSerializer +``` + +### 用户级数据范围限定 + +```python +from rest_framework import viewsets + +# ❌ 任何用户可以看到所有数据 +class DocumentViewSet(viewsets.ModelViewSet): + queryset = Document.objects.all() + serializer_class = DocumentSerializer + +# ✅ get_queryset 限定当前用户数据 +class DocumentViewSet(viewsets.ModelViewSet): + serializer_class = DocumentSerializer + + def get_queryset(self): + return Document.objects.filter( + owner=self.request.user + ).select_related("owner") + +# ✅ 管理员看全部,普通用户看自己的 +class DocumentViewSet(viewsets.ModelViewSet): + serializer_class = DocumentSerializer + + def get_queryset(self): + qs = Document.objects.select_related("owner") + if self.request.user.is_staff: + return qs + return qs.filter(owner=self.request.user) + +# ✅ perform_create 自动关联当前用户 +class DocumentViewSet(viewsets.ModelViewSet): + serializer_class = DocumentSerializer + + def get_queryset(self): + return Document.objects.filter(owner=self.request.user) + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) +``` + +### 权限控制 + +```python +from rest_framework import permissions, viewsets + +# ❌ 没有权限控制 +class ArticleViewSet(viewsets.ModelViewSet): + queryset = Article.objects.all() + serializer_class = ArticleSerializer + +# ✅ 类级别权限 +class ArticleViewSet(viewsets.ModelViewSet): + queryset = Article.objects.all() + serializer_class = ArticleSerializer + permission_classes = [permissions.IsAuthenticated] + +# ✅ 操作级别权限 +from rest_framework.decorators import action + +class ArticleViewSet(viewsets.ModelViewSet): + queryset = Article.objects.all() + serializer_class = ArticleSerializer + + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [permissions.AllowAny()] + if self.action == "create": + return [permissions.IsAuthenticated()] + return [permissions.IsAdminUser()] + +# ✅ 自定义对象级权限 +class IsOwnerOrReadOnly(permissions.BasePermission): + def has_object_permission(self, request, view, obj): + if request.method in permissions.SAFE_METHODS: + return True + return obj.owner == request.user +``` + +### 分页和节流 + +```python +# settings.py + +# ❌ 没有分页和节流配置 +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "rest_framework.authentication.SessionAuthentication", + ], +} + +# ✅ 全局分页和节流 +REST_FRAMEWORK = { + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 20, + "DEFAULT_THROTTLE_CLASSES": [ + "rest_framework.throttling.AnonRateThrottle", + "rest_framework.throttling.UserRateThrottle", + ], + "DEFAULT_THROTTLE_RATES": { + "anon": "100/hour", + "user": "1000/hour", + }, +} + +# ✅ 自定义分页器 +from rest_framework.pagination import PageNumberPagination + +class StandardPagination(PageNumberPagination): + page_size = 25 + page_size_query_param = "page_size" + max_page_size = 100 + +class ArticleViewSet(viewsets.ModelViewSet): + queryset = Article.objects.all() + serializer_class = ArticleSerializer + pagination_class = StandardPagination +``` + +--- + +## 异步视图 + +### 同步 ORM 在异步视图中的正确使用 + +```python +import asyncio +from asgiref.sync import sync_to_async +from django.http import JsonResponse + +# ❌ 在 async 视图中直接调用同步 ORM — 阻塞事件循环 +async def user_list(request): + users = User.objects.all() # Synchronous ORM call in async context! + data = [{"id": u.id, "name": u.username} for u in users] + return JsonResponse(data, safe=False) + +# ✅ 使用 async ORM(Django 4.1+) +async def user_list(request): + users = User.objects.all() + data = [] + async for user in users: # async iteration + data.append({"id": user.id, "name": user.username}) + return JsonResponse(data, safe=False) + +# ✅ 使用 aget / afilter / acreate +async def user_detail(request, pk): + user = await User.objects.aget(pk=pk) + return JsonResponse({"id": user.id, "name": user.username}) + +# ✅ 复杂查询用 sync_to_async +@sync_to_async +def get_user_with_profile(pk): + return User.objects.select_related("profile").get(pk=pk) + +async def user_profile(request, pk): + user = await get_user_with_profile(pk) + return JsonResponse({ + "id": user.id, + "name": user.username, + "bio": user.profile.bio, + }) +``` + +### 遗漏 await + +```python +from django.http import JsonResponse + +# ❌ 忘记 await — coroutine 不会执行,返回协程对象而非数据 +async def user_detail(request, pk): + user = User.objects.aget(pk=pk) # Missing await! + # user 是一个 coroutine 对象,不是 User 实例 + return JsonResponse({"name": user.username}) # RuntimeError + +# ✅ 始终 await 异步 ORM 调用 +async def user_detail(request, pk): + user = await User.objects.aget(pk=pk) + return JsonResponse({"name": user.username}) + +# ✅ 使用aget_or_404 的异步版本 +from django.shortcuts import aget_object_or_404 + +async def user_detail(request, pk): + user = await aget_object_or_404(User, pk=pk) + return JsonResponse({"name": user.username}) +``` + +### 异步视图中的事务 + +```python +from django.db import transaction +from asgiref.sync import sync_to_async + +# ❌ transaction.atomic() 是同步的,不能直接在 async 中用 +async def create_order(request): + async with transaction.atomic(): # Error! Not async-compatible + order = await Order.objects.acreate(total=100) + await OrderItem.objects.acreate(order=order, product_id=1) + return JsonResponse({"order_id": order.id}) + +# ✅ 用 sync_to_async 包装事务块 +@sync_to_async +def _create_order_with_items(): + with transaction.atomic(): + order = Order.objects.create(total=100) + OrderItem.objects.create(order=order, product_id=1) + return order.id + +async def create_order(request): + order_id = await _create_order_with_items() + return JsonResponse({"order_id": order_id}) + +# ✅ 多个操作打包到一个 sync_to_async 中 +@sync_to_async +def _bulk_create_products(items): + with transaction.atomic(): + products = Product.objects.bulk_create([Product(**i) for i in items]) + return [p.id for p in products] + +async def import_products(request): + ids = await _bulk_create_products(request.data) + return JsonResponse({"ids": ids}) +``` + +### 同步中间件拖慢异步性能 + +```python +# ❌ 同步中间件会把 async 视图降级为同步执行 +class TimingMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): # sync — blocks async views + start = time.time() + response = self.get_response(request) + elapsed = time.time() - start + response["X-Elapsed"] = str(elapsed) + return response + +# ✅ async-capable 中间件:async def __call__,并在 __init__ 里标记实例 +import time +from asgiref.sync import iscoroutinefunction, markcoroutinefunction + +class TimingMiddleware: + async_capable = True + sync_capable = False + + def __init__(self, get_response): + self.get_response = get_response + # get_response 是协程函数时标记自己,Django 才会 await 这个实例 + if iscoroutinefunction(self.get_response): + markcoroutinefunction(self) + + async def __call__(self, request): + start = time.time() + response = await self.get_response(request) + elapsed = time.time() - start + response["X-Elapsed"] = str(elapsed) + return response + +# ✅ 要同时兼容同步和异步,用工厂函数 + 内置装饰器 +from django.utils.decorators import sync_and_async_middleware +``` + +### async for 迭代模式 + +```python +from django.http import JsonResponse + +# ❌ 同步迭代大型 QuerySet 在 async 视图中阻塞 +async def export_users(request): + users = User.objects.all() + data = [] # 同步迭代阻塞事件循环 + for user in users: + data.append({"id": user.id, "name": user.username}) + return JsonResponse(data, safe=False) + +# ✅ 使用 async for 异步迭代 +async def export_users(request): + data = [] + async for user in User.objects.all(): + data.append({"id": user.id, "name": user.username}) + return JsonResponse(data, safe=False) + +# ✅ 大数据集使用 aiterator() + 分块处理 +async def export_large_dataset(request): + data = [] + async for user in User.objects.all().aiterator(chunk_size=500): + data.append({"id": user.id, "name": user.username}) + return JsonResponse(data, safe=False) + +# ✅ 使用 values() 减少内存 +async def lightweight_export(request): + data = [] + async for row in User.objects.values("id", "username"): + data.append(row) + return JsonResponse(data, safe=False) +``` + +--- + +## 中间件与设置 + +### 生产安全配置清单 + +```python +# settings.py — 生产环境必须的安全设置 + +# ❌ 开发默认值不应出现在生产环境 +DEBUG = True +SECRET_KEY = "django-insecure-..." +ALLOWED_HOSTS = ["*"] +SECURE_SSL_REDIRECT = False + +# ✅ 生产环境安全配置 + +# --- 基础安全 --- +DEBUG = False +SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] # 从环境变量读取 +ALLOWED_HOSTS = ["example.com", "www.example.com"] + +# --- HTTPS --- +SECURE_SSL_REDIRECT = True # HTTP 重定向到 HTTPS +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +# --- 安全头 --- +SECURE_HSTS_SECONDS = 31536000 # 1 year HSTS +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_PRELOAD = True +SECURE_CONTENT_TYPE_NOSNIFF = True # X-Content-Type-Options: nosniff +X_FRAME_OPTIONS = "DENY" # 防止 clickjacking +SECURE_REFERRER_POLICY = "strict-origin-when-cross-origin" + +# --- 密码验证 --- +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 12}}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, +] + +# --- Session --- +SESSION_COOKIE_AGE = 3600 * 8 # 8 hours +SESSION_SAVE_EVERY_REQUEST = True +SESSION_EXPIRE_AT_BROWSER_CLOSE = True +``` + +### 数据库连接安全 + +```python +# settings.py + +# ❌ 明文密码在代码中 +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "mydb", + "USER": "admin", + "PASSWORD": "hunter2", # 不要硬编码密码 + "HOST": "localhost", + "PORT": "5432", + } +} + +# ✅ 从环境变量读取 +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.environ.get("DB_NAME", "mydb"), + "USER": os.environ.get("DB_USER", "mydb_user"), + "PASSWORD": os.environ["DB_PASSWORD"], + "HOST": os.environ.get("DB_HOST", "localhost"), + "PORT": os.environ.get("DB_PORT", "5432"), + "OPTIONS": { + "sslmode": "require", # 强制 SSL 连接 + }, + "CONN_MAX_AGE": 60, # 持久连接 + } +} +``` + +### CORS 配置 + +```python +# settings.py (using django-cors-headers) + +# ❌ 允许所有来源 +CORS_ALLOW_ALL_ORIGINS = True + +# ✅ 限制允许的来源 +CORS_ALLOWED_ORIGINS = [ + "https://example.com", + "https://app.example.com", +] + +# ✅ 生产环境 CORS 设置 +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ORIGINS", "").split(",") +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"] +CORS_ALLOW_HEADERS = [ + "authorization", + "content-type", + "x-csrftoken", +] +``` + +### 日志配置 + +```python +# settings.py + +# ❌ 默认日志配置(或不配置) +LOGGING = {} + +# ✅ 生产环境日志配置 +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {process:d} {thread:d} {message}", + "style": "{", + }, + }, + "handlers": { + "file": { + "level": "INFO", + "class": "logging.handlers.RotatingFileHandler", + "filename": "/var/log/django/app.log", + "maxBytes": 10 * 1024 * 1024, # 10 MB + "backupCount": 5, + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["file"], + "level": "INFO", + "propagate": False, + }, + "myapp": { + "handlers": ["file"], + "level": "DEBUG" if DEBUG else "INFO", + "propagate": False, + }, + }, +} +``` + +--- + +## Review Checklist + +### 安全审查 + +- [ ] 没有使用 `mark_safe` 渲染未转义的用户输入 +- [ ] CSRF 中间件已启用,没有 `@csrf_exempt` +- [ ] Session 和 CSRF cookie 设置 `Secure`, `HttpOnly`, `SameSite` +- [ ] SQL 查询使用参数化(ORM 或参数化 `raw()`),无字符串拼接 +- [ ] 文件上传有类型和大小限制 +- [ ] `SECRET_KEY` 从环境变量读取,不在代码仓库中 +- [ ] `DEBUG = False` 在生产环境 + +### HTTPS 与安全头 + +- [ ] `SECURE_SSL_REDIRECT = True` +- [ ] `SECURE_HSTS_SECONDS` 已设置(≥ 31536000) +- [ ] `SECURE_CONTENT_TYPE_NOSNIFF = True` +- [ ] `X_FRAME_OPTIONS` 设置为 `DENY` 或 `SAMEORIGIN` +- [ ] `ALLOWED_HOSTS` 不包含 `"*"` +- [ ] 数据库连接使用 SSL + +### N+1 查询 + +- [ ] ForeignKey 关系使用 `select_related` +- [ ] M2M / 反向关系使用 `prefetch_related` +- [ ] 没有在循环中访问关联对象 +- [ ] 使用 `count()` 代替 `len(queryset)` 做计数 +- [ ] 使用 `exists()` 代替 `if queryset` 做存在性检查 +- [ ] 大数据集使用 `only()` / `defer()` 或 `values()` 减少查询字段 +- [ ] 切片后的 QuerySet 不重复迭代 + +### Serializer + +- [ ] 不使用 `fields = "__all__"` 在敏感模型上 +- [ ] 密码字段标记 `write_only=True` +- [ ] 有字段级和对象级验证 +- [ ] 嵌套写入实现了 `create()` / `update()` 或使用 `read_only=True` +- [ ] 计算字段和自动字段在 `read_only_fields` 中 +- [ ] Serializer 不包含不应被修改的字段 + +### ViewSet + +- [ ] 只读场景使用 `ReadOnlyModelViewSet` +- [ ] `get_queryset()` 限定当前用户数据范围 +- [ ] 设置了 `permission_classes` +- [ ] 创建时用 `perform_create()` 自动设置 owner/author +- [ ] 配置了分页(全局或 ViewSet 级别) +- [ ] 配置了节流(throttling) + +### 异步视图 + +- [ ] async 视图中不直接调用同步 ORM(用 `aget`/`afilter`/`sync_to_async`) +- [ ] 所有异步调用都有 `await` +- [ ] `transaction.atomic()` 用 `sync_to_async` 包装 +- [ ] 中间件标记 `async_capable = True` 以避免降级 +- [ ] 大型 QuerySet 使用 `async for` + `aiterator()` + +### 生产配置 + +- [ ] `CORS_ALLOWED_ORIGINS` 不使用 `CORS_ALLOW_ALL_ORIGINS = True` +- [ ] 密码验证器已配置(最小长度、常见密码检查) +- [ ] Session 过期时间合理(`SESSION_COOKIE_AGE`) +- [ ] 日志配置使用 RotatingFileHandler,不在生产环境输出到 stdout +- [ ] 数据库连接使用 `CONN_MAX_AGE` 持久连接 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/fastapi.md b/examples/skills_code_review_agent/skills/code-review/reference/fastapi.md new file mode 100644 index 000000000..daadab7d4 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/fastapi.md @@ -0,0 +1,580 @@ +# FastAPI Code Review Guide + +> FastAPI code review guide covering dependency injection (`Depends`), Pydantic v2 validation boundaries, async correctness, database session lifecycle and N+1, security, and a test-driven verification workflow that turns the reviewer's in-process test client into a tool for *proving* bugs rather than guessing at them. + +## Table of Contents + +- [Dependency Injection (`Depends`)](#dependency-injection-depends) +- [Pydantic v2 Models & Validation](#pydantic-v2-models--validation) +- [Async Correctness](#async-correctness) +- [Database Sessions & N+1](#database-sessions--n1) +- [Security](#security) +- [Test-Driven Verification](#test-driven-verification) +- [Review Checklist](#review-checklist) +- [References](#references) + +--- + +## Dependency Injection (`Depends`) + +FastAPI's `Depends` is the seam that keeps routes thin and testable. Most review problems here come from doing real work in the route function instead of behind a dependency. + +### Business logic belongs behind a dependency or service, not in the route + +```python +# ❌ Bad — DB access, auth, and business rules all inline in the route +@app.get("/orders/{order_id}") +async def get_order(order_id: int): + conn = await asyncpg.connect(DATABASE_URL) # connection created per request + row = await conn.fetchrow("SELECT * FROM orders WHERE id = $1", order_id) + await conn.close() + if row is None: + raise HTTPException(404) + return dict(row) + +# ✅ Good — the route declares what it needs; the session is injected and pooled +async def get_session() -> AsyncIterator[AsyncSession]: + async with SessionLocal() as session: + yield session + +@app.get("/orders/{order_id}", response_model=OrderOut) +async def get_order(order_id: int, session: AsyncSession = Depends(get_session)): + order = await session.get(Order, order_id) + if order is None: + raise HTTPException(status_code=404, detail="Order not found") + return order +``` + +The injected version is also the version you can override in tests (see [Test-Driven Verification](#test-driven-verification)). + +### `yield` dependencies must clean up, and cleanup runs even on error + +```python +# ❌ Bad — no cleanup; the session leaks if the route raises +async def get_session() -> AsyncSession: + return SessionLocal() + +# ✅ Good — the context manager closes the session on success AND on exception +async def get_session() -> AsyncIterator[AsyncSession]: + async with SessionLocal() as session: + yield session +``` + +Review point: confirm any `yield` dependency holding a resource (DB session, file handle, lock) releases it through a context manager or `try/finally`, so an exception in the route does not leak it. + +### Don't re-create singletons per request + +```python +# ❌ Bad — a new HTTP client (and connection pool) per request +@app.get("/proxy") +async def proxy(client: httpx.AsyncClient = Depends(lambda: httpx.AsyncClient())): + ... + +# ✅ Good — one client for the app lifetime, injected by reference +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.http = httpx.AsyncClient() + yield + await app.state.http.aclose() + +def get_http(request: Request) -> httpx.AsyncClient: + return request.app.state.http +``` + +### Prefer the `Annotated` form and async dependencies + +Since FastAPI 0.95 the idiomatic way to declare a dependency is `Annotated[T, Depends(...)]`, not the default-value form. It is reusable across routes and plays well with type checkers. Also prefer `async def` dependencies: a sync (`def`) dependency runs in the threadpool, which is wasted overhead for a small non-I/O check. + +```python +# ⚠️ Older form — still works, but not the current idiom +@app.get("/items") +async def list_items(session: AsyncSession = Depends(get_session)): ... + +# ✅ Good — Annotated form; define once, reuse everywhere +SessionDep = Annotated[AsyncSession, Depends(get_session)] + +@app.get("/items") +async def list_items(session: SessionDep): ... +``` + +### Use dependencies to validate existence and permissions — they're cached per request + +A dependency is the natural place to answer "does this resource exist and may this caller touch it?" Pydantic validates *shape*; a dependency validates against the database. FastAPI caches each dependency's result within a single request, so chaining small dependencies costs nothing extra and removes duplicated lookups. + +```python +# ✅ Good — small dependencies chain; valid_post is resolved once per request +async def valid_post(post_id: int, session: SessionDep) -> Post: + post = await session.get(Post, post_id) + if post is None: + raise HTTPException(status_code=404, detail="Post not found") + return post + +async def owned_post(post: Annotated[Post, Depends(valid_post)], user: CurrentUser) -> Post: + if post.owner_id != user.id: + raise HTTPException(status_code=403, detail="Forbidden") + return post + +@app.delete("/posts/{post_id}", status_code=204) +async def delete_post(post: Annotated[Post, Depends(owned_post)], session: SessionDep): + await session.delete(post) # existence + ownership already enforced + await session.commit() +``` + +This is also the cleanest place to fix the auth-vs-authorization bug from the [Security](#security) section: the ownership check moves into a reusable `owned_post` dependency. + +--- + +## Pydantic v2 Models & Validation + +### Separate input and output models; never echo the ORM object directly + +```python +# ❌ Bad — response_model is the DB model, so hashed_password leaks to the client +@app.post("/users", response_model=UserTable) +async def create_user(user: UserTable): # also accepts client-set id, is_admin... + ... + +# ✅ Good — distinct schemas draw the trust boundary +class UserCreate(BaseModel): + email: EmailStr + password: str + +class UserOut(BaseModel): + id: int + email: EmailStr + model_config = ConfigDict(from_attributes=True) # read from ORM safely + +@app.post("/users", response_model=UserOut, status_code=201) +async def create_user(payload: UserCreate, session: AsyncSession = Depends(get_session)): + ... +``` + +`response_model` is a filter, not just documentation — fields absent from the output model are stripped from the response. Reusing the DB model as the response is the most common way sensitive fields leak. + +### Use distinct Create and Update schemas + +```python +# ❌ Bad — one schema for create and update means every field is required on PATCH +class ItemSchema(BaseModel): + name: str + price: float + +# ✅ Good — update is a partial; create requires the full payload +class ItemCreate(BaseModel): + name: str + price: float = Field(gt=0) + +class ItemUpdate(BaseModel): + name: str | None = None + price: float | None = Field(default=None, gt=0) +``` + +### Validate at the boundary, not after the DB write + +```python +# ❌ Bad — negative quantity reaches the database before anything checks it +@app.post("/cart") +async def add_to_cart(item_id: int, quantity: int): + await save(item_id, quantity) # quantity = -5 silently accepted + +# ✅ Good — the type system rejects it before the handler body runs +class CartLine(BaseModel): + item_id: int + quantity: int = Field(gt=0) + +@app.post("/cart") +async def add_to_cart(line: CartLine): + await save(line.item_id, line.quantity) +``` + +--- + +## Async Correctness + +This is the axis on which FastAPI differs most from Django and Flask, and the one most worth a reviewer's attention. FastAPI's throughput comes from a single event loop interleaving many concurrent requests. That model only holds if the loop is **never blocked**: one synchronous call on the loop stalls *every* in-flight request, not just its own. Get this wrong across the codebase and FastAPI does not just lose its edge — it performs *worse* than a sync framework like Flask, because Flask's worker-per-request model has no shared loop to choke. The reviewer's job is to keep work on the loop genuinely non-blocking and to treat every escape hatch as a cost, not a fix. + +### Never call blocking code inside an `async def` route + +```python +# ❌ Bad — blocking I/O on the loop freezes ALL concurrent requests, not just this one +@app.get("/report") +async def report(): + data = requests.get("https://slow-api.example.com").json() # blocking socket + time.sleep(2) # blocks the loop + return data + +# ✅ Good — await a native-async client; the loop serves other requests meanwhile +@app.get("/report") +async def report(client: httpx.AsyncClient = Depends(get_http)): + resp = await client.get("https://slow-api.example.com") + return resp.json() +``` + +### Prefer native-async SDKs over sync libraries + +The right fix for blocking I/O is almost always a library that speaks `async` natively — not wrapping a sync one. Reach for the async client first; the threadpool is the last resort, not the default. + +| Sync (blocks the loop) | Native-async replacement | +|------------------------|--------------------------| +| `requests` | `httpx.AsyncClient`, `aiohttp` | +| `psycopg2` (sync) | `asyncpg`, SQLAlchemy async engine | +| `redis-py` (sync) | `redis.asyncio` | +| `pymongo` | `motor` | +| `boto3` | `aioboto3` | + +If you find `asyncio.run(...)`, a new event loop, or a manually started thread *inside* a route, that is a red flag — it's an attempt to bolt sync code onto the loop. `asyncio.run()` inside a running loop raises `RuntimeError` outright; the rest quietly burns the performance you adopted FastAPI for. + +```python +# ❌ Bad — spinning up a loop/thread to call an async SDK from a sync context +@app.get("/users/{uid}") +def get_user(uid: int): + return asyncio.run(repo.fetch(uid)) # RuntimeError under the running loop + +# ✅ Good — let the route be async and await the native client directly +@app.get("/users/{uid}") +async def get_user(uid: int): + return await repo.fetch(uid) +``` + +### The threadpool is a bounded escape hatch, not a default + +A plain `def` route — and `run_in_threadpool(...)` — does not run on the loop; FastAPI runs it in a **bounded** worker threadpool (AnyIO's default cap is 40 threads). For an occasional, genuinely-unavoidable blocking call this is the correct tool: + +```python +from fastapi.concurrency import run_in_threadpool + +@app.get("/legacy") +async def legacy(): + return await run_in_threadpool(blocking_library_call) # only if no async SDK exists +``` + +But it does not scale the way the loop does. Route every hot path through the threadpool and, under load, all workers block at once; further requests queue behind the cap and throughput collapses. Spawning your own threads or processes to "add concurrency" makes it worse: once live threads exceed the machine's core count, context-switch and GIL contention degrade performance sharply rather than improving it. The escape hatch is for the rare blocking dependency you cannot replace — not a substitute for choosing async SDKs. + +Review heuristic: a `def` route is acceptable for a low-traffic endpoint with no async equivalent. A high-traffic endpoint doing blocking work in a `def` route (or via `run_in_threadpool`) is a scaling bug — flag it and ask for an async SDK. + +### CPU-bound work belongs in a worker process, not the loop or the threadpool + +Neither the event loop nor the threadpool helps CPU-bound work: under the GIL only one thread runs Python bytecode at a time, so a heavy computation blocks just as badly from a threadpool as from the loop. Offload it to a separate process (Celery, Arq, RQ, or `multiprocessing`). + +```python +# ❌ Bad — a CPU-heavy job pins a worker; throughput drops for everyone +@app.post("/render") +async def render(doc: Doc): + return heavy_pdf_render(doc) # seconds of pure CPU on the loop + +# ✅ Good — enqueue to a worker process; return a job handle +@app.post("/render", status_code=202) +async def render(doc: Doc): + job = await queue.enqueue(heavy_pdf_render, doc) + return {"job_id": job.id} +``` + +### Don't fire-and-forget unawaited coroutines + +```python +# ❌ Bad — coroutine never awaited; the email is never sent (and no error surfaces) +@app.post("/signup") +async def signup(user: UserCreate): + send_welcome_email(user.email) # returns a coroutine, silently dropped + +# ✅ Good — defer post-response work with BackgroundTasks +@app.post("/signup") +async def signup(user: UserCreate, tasks: BackgroundTasks): + tasks.add_task(send_welcome_email, user.email) +``` + +`BackgroundTasks` runs in-process and offers no retries or persistence — use it only for short, fire-and-forget work (send an email, log an event). Anything long-running or retry-critical (data processing, payments) belongs in a real task queue (Celery/Arq/RQ). + +--- + +## Database Sessions & N+1 + +> 📖 For cross-language N+1 patterns and solutions, see [N+1 Queries Guide](cross-cutting/n-plus-one-queries.md) + +### One session per request, injected — not a global + +```python +# ❌ Bad — a module-level session is shared across concurrent requests (not safe) +session = SessionLocal() + +# ✅ Good — request-scoped session via dependency (see get_session above) +@app.get("/items") +async def list_items(session: AsyncSession = Depends(get_session)): + ... +``` + +### Eager-load relationships to avoid N+1 + +```python +# ❌ Bad — one query for orders, then one query per order for its customer +orders = (await session.execute(select(Order))).scalars().all() +return [{"id": o.id, "customer": o.customer.name} for o in orders] # N+1 + +# ✅ Good — a single query with the relationship eager-loaded +stmt = select(Order).options(selectinload(Order.customer)) +orders = (await session.execute(stmt)).scalars().all() +return [{"id": o.id, "customer": o.customer.name} for o in orders] +``` + +With async SQLAlchemy, lazy attribute access outside the session often raises instead of silently querying — but the design issue is the same. Look for relationship access inside a loop without an `options(...)` eager load. + +### Paginate list endpoints + +```python +# ❌ Bad — returns every row; degrades as the table grows +@app.get("/users") +async def list_users(session: AsyncSession = Depends(get_session)): + return (await session.execute(select(User))).scalars().all() + +# ✅ Good — bounded page with a sane cap +@app.get("/users", response_model=list[UserOut]) +async def list_users( + session: AsyncSession = Depends(get_session), + limit: int = Query(default=50, le=100), + offset: int = Query(default=0, ge=0), +): + stmt = select(User).limit(limit).offset(offset) + return (await session.execute(stmt)).scalars().all() +``` + +### Aggregate and join in SQL, not in Python + +If a handler pulls rows into memory and then loops to group, count, or join them, the database is being used as dumb storage. Push the work down — the database does set operations far faster, and you transfer less data. + +```python +# ❌ Bad — fetch every order, then tally per customer in Python +orders = (await session.execute(select(Order))).scalars().all() +totals: dict[int, float] = {} +for o in orders: + totals[o.customer_id] = totals.get(o.customer_id, 0) + o.amount + +# ✅ Good — let the database group and sum +stmt = select(Order.customer_id, func.sum(Order.amount)).group_by(Order.customer_id) +totals = dict((await session.execute(stmt)).all()) +``` + +--- + +## Security + +### A declared auth dependency is not an enforced authorization check + +This is the highest-value thing to look for. `Depends(get_current_user)` proves *who* the caller is — it does **not** prove they may touch *this* resource. + +```python +# ❌ Bad — any authenticated user can delete any other user's document +@app.delete("/documents/{doc_id}") +async def delete_document( + doc_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + doc = await session.get(Document, doc_id) + await session.delete(doc) # never checks doc.owner_id == user.id + await session.commit() + +# ✅ Good — ownership is verified before the mutation +@app.delete("/documents/{doc_id}", status_code=204) +async def delete_document( + doc_id: int, + user: User = Depends(get_current_user), + session: AsyncSession = Depends(get_session), +): + doc = await session.get(Document, doc_id) + if doc is None: + raise HTTPException(status_code=404, detail="Not found") + if doc.owner_id != user.id: + raise HTTPException(status_code=403, detail="Forbidden") + await session.delete(doc) + await session.commit() +``` + +The [Test-Driven Verification](#test-driven-verification) section reproduces exactly this bug with a failing test. + +### Parameterize SQL; never f-string user input + +> **跨语言 SQL 注入防护详见 [SQL Injection Prevention Guide](cross-cutting/sql-injection-prevention.md)**,含 Python/Java/Go/Node.js/PHP/C# 示例及 ORM 不安全用法。 + +### Don't widen CORS to credentials + wildcard + +```python +# ❌ Bad — wildcard origin together with credentials is rejected by browsers and unsafe +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True) + +# ✅ Good — enumerate trusted origins when credentials are allowed +app.add_middleware( + CORSMiddleware, + allow_origins=["https://app.example.com"], + allow_credentials=True, +) +``` + +Also check: secrets read from config/env (not hard-coded), `HTTPException` details that don't leak internals (stack traces, SQL), and rate limiting on auth endpoints. + +--- + +## Test-Driven Verification + +> Inspired by the test-driven development discipline: *if you didn't watch the test fail, you don't know it tests the right thing.* This matters even more for a coding agent than for a human reviewer. An agent's reading and reasoning are fallible — it can misread control flow, hallucinate a guarantee that isn't there, or rationalize a comfortable conclusion — so a prose verdict like "this looks safe" carries little weight on its own. An executable test is the one piece of **objective ground truth** the agent fully controls: it either passes or it doesn't, regardless of how confident the reasoning felt. That is what makes tests the agent's anchor of confidence. Reviewing the same way the discipline writes code — reproduce, don't assert — turns a hunch into proof. + +A natural-language review comment ("this might let users delete each other's data") is exactly that kind of fallible hypothesis. FastAPI makes the ground truth cheap to obtain: an in-process client (`httpx.AsyncClient` over `ASGITransport`) runs the whole app, and `app.dependency_overrides` swaps out auth and the database without patching internals. So instead of trusting its own read of the code, the agent settles the question by reproduction. + +### Reproduce a suspected bug with a failing test (Verify RED) + +Suppose the reviewer suspects the `DELETE /documents/{doc_id}` route above never checks ownership. Write the test that asserts the *secure* behavior, then run it and **watch it fail** — the failure is the proof. + +```python +# test_document_authorization.py +import pytest +from httpx import AsyncClient, ASGITransport +from fastapi import Header +from app.main import app +from app.deps import get_current_user, get_session + +# Two users; the override picks one based on a test header. +USERS = {"alice": User(id=1, email="alice@example.com"), + "bob": User(id=2, email="bob@example.com")} + +def fake_current_user(x_test_user: str = Header(default="alice")) -> User: + return USERS[x_test_user] + +@pytest.mark.asyncio +async def test_user_cannot_delete_another_users_document(session): # async fixture + # Arrange: a document owned by Alice (id=1) + session.add(Document(id=10, owner_id=1, title="Alice's doc")) + await session.commit() + + app.dependency_overrides[get_current_user] = fake_current_user + app.dependency_overrides[get_session] = lambda: session + + # Act: Bob tries to delete Alice's document + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + resp = await client.delete("/documents/10", headers={"X-Test-User": "bob"}) + + # Assert the SECURE behavior we expect + assert resp.status_code == 403 + + app.dependency_overrides.clear() +``` + +Run it against the unfixed code and confirm the failure is the bug, not a typo: + +```bash +$ pytest test_document_authorization.py +FAILED assert 204 == 403 +# ^ the endpoint deleted Alice's document for Bob — vulnerability confirmed +``` + +A failure of `204 == 403` (not an import error, not a 404) is what makes the finding credible: the route returned success for an action that should have been forbidden. Now the fix from the [Security](#security) section turns it green: + +```bash +$ pytest test_document_authorization.py +PASSED +``` + +Attach this test to the review. It documents the vulnerability, proves the fix, and guards against regression — far stronger than "consider checking ownership here." + +### Prefer `dependency_overrides` over `patch`/`mock` + +FastAPI's DI is the seam the TDD discipline asks for: when something is hard to test without mocking everything, that usually signals coupling — and `Depends` already gives you the injection point, so you rarely need `unittest.mock.patch`. + +```python +# ❌ Bad — patching internals: brittle, couples the test to import paths +@patch("app.routes.orders.asyncpg.connect") +def test_get_order(mock_connect): ... + +# ✅ Good — override the dependency with a real in-memory fake +app.dependency_overrides[get_session] = lambda: in_memory_session +app.dependency_overrides[get_current_user] = lambda: test_user +``` + +Always reset overrides between tests (`app.dependency_overrides.clear()` in a fixture teardown) so state doesn't leak across tests. + +The reproduction above uses `httpx.AsyncClient` over `ASGITransport` with `@pytest.mark.asyncio` — the community convention for an async app, so the suite shares the app's event loop and you avoid loop-mismatch errors later. The synchronous `TestClient` is simpler and fine for a fully sync app, but standardizing on the async client from the start saves a painful migration once any route or fixture becomes async. + +### Critique the PR's own tests, not just its source + +A PR that ships tests is not automatically safe. Apply these checks to the *tests* in the diff: + +```python +# ❌ Bad — happy-path only. Proves the route works when everything is correct, +# says nothing about the validation and authorization paths. +def test_create_item(): + resp = client.post("/items", json={"name": "x", "price": 5}) + assert resp.status_code == 201 + +# ✅ Good — the boundary and failure paths are where bugs live +def test_create_item_rejects_negative_price(): + resp = client.post("/items", json={"name": "x", "price": -5}) + assert resp.status_code == 422 + +def test_create_item_requires_authentication(): + resp = client_without_auth.post("/items", json={"name": "x", "price": 5}) + assert resp.status_code == 401 +``` + +Review questions for the test suite: + +- **Does it test behavior, or the mock?** An assertion that only confirms a mock was called proves the test's own setup, not the endpoint. +- **Are the failure paths covered?** 401/403/404/422 — not just 200/201. Bugs cluster at the boundaries. +- **Is the mock complete?** A partial mock of an external API response that omits fields the handler reads passes in the test and fails in production. +- **Were the tests written after the fact?** Tests added alongside an implementation and passing on the first run never demonstrated that they can fail — and so prove little. A test that reproduces the bug (fails first, then passes) is worth more than one that was green from birth. + +--- + +## Review Checklist + +### Dependency Injection + +- [ ] Routes stay thin — DB access and business rules live behind `Depends`/services +- [ ] `yield` dependencies release resources via context manager or `try/finally` +- [ ] Singletons (HTTP clients, pools) created once in `lifespan`, not per request +- [ ] `Annotated[T, Depends(...)]` form used; dependencies are `async def` unless they do blocking I/O +- [ ] Existence/permission checks live in (cached) dependencies, not copy-pasted into routes +- [ ] Dependencies are overridable in tests (no resources created inline in the route) + +### Validation + +- [ ] Input and output use distinct Pydantic models; ORM objects are not the `response_model` +- [ ] `response_model` set so sensitive fields can't leak +- [ ] Separate Create vs Update schemas (update is partial) +- [ ] Constraints (`gt`, `le`, `EmailStr`, ...) enforced at the boundary, before the DB write + +### Async + +- [ ] No blocking calls (`requests`, `time.sleep`, blocking DB drivers) inside `async def` +- [ ] Native-async SDKs preferred (`httpx`, `asyncpg`, `redis.asyncio`, ...) over sync ones +- [ ] No `asyncio.run`/manual event loops/manual threads inside routes +- [ ] `run_in_threadpool`/`def` routes used only as a last resort, not on hot paths +- [ ] CPU-bound work offloaded to a worker process (Celery/Arq/RQ), not the loop or threadpool +- [ ] No unawaited coroutines; `BackgroundTasks` only for short fire-and-forget work + +### Database + +- [ ] One request-scoped session via dependency; no module-level shared session +- [ ] Relationships eager-loaded (`selectinload`/`joinedload`) where accessed in a loop +- [ ] Joins/aggregations done in SQL, not by looping in Python +- [ ] List endpoints are paginated with a capped `limit` + +### Security + +- [ ] Authentication dependency is backed by an explicit **authorization** check (ownership/role) +- [ ] All SQL parameterized; no f-string interpolation of user input +- [ ] CORS does not combine `allow_origins=["*"]` with `allow_credentials=True` +- [ ] Secrets come from config/env; error responses don't leak internals + +### Tests + +- [ ] Suspected bugs reproduced with a failing test (`TestClient`/`AsyncClient`) before being claimed +- [ ] `dependency_overrides` used instead of patching internals; overrides reset between tests +- [ ] Failure paths covered (401/403/404/422), not just the happy path +- [ ] Mocks of external responses are complete, not partial +- [ ] New tests demonstrate they can fail (reproduce-then-fix), not green from birth + +--- + +## References + +- [FastAPI official documentation](https://fastapi.tiangolo.com/) — async, dependencies, testing +- [zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices) — production conventions (async routes, dependency caching, project structure) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/go.md b/examples/skills_code_review_agent/skills/code-review/reference/go.md new file mode 100644 index 000000000..640ff07c1 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/go.md @@ -0,0 +1,993 @@ +# Go 代码审查指南 + +基于 Go 官方指南、Effective Go 和社区最佳实践的代码审查清单。 + +## 快速审查清单 + +### 必查项 +- [ ] 错误是否正确处理(不忽略、有上下文) +- [ ] goroutine 是否有退出机制(避免泄漏) +- [ ] context 是否正确传递和取消 +- [ ] 接收器类型选择是否合理(值/指针) +- [ ] 是否使用 `gofmt` 格式化代码 + +### 高频问题 +- [ ] 循环变量捕获问题(Go < 1.22) +- [ ] nil 检查是否完整 +- [ ] map 是否初始化后使用 +- [ ] defer 在循环中的使用 +- [ ] 变量遮蔽(shadowing) + +--- + +## 1. 错误处理 + +> 📖 通用原则和跨语言示例详见 [错误处理跨语言指南](cross-cutting/error-handling-principles.md) + +### 1.1 永远不要忽略错误 + +```go +// ❌ 错误:忽略错误 +result, _ := SomeFunction() + +// ✅ 正确:处理错误 +result, err := SomeFunction() +if err != nil { + return fmt.Errorf("some function failed: %w", err) +} +``` + +### 1.2 错误包装与上下文 + +```go +// ❌ 错误:丢失上下文 +if err != nil { + return err +} + +// ❌ 错误:使用 %v 丢失错误链 +if err != nil { + return fmt.Errorf("failed: %v", err) +} + +// ✅ 正确:使用 %w 保留错误链 +if err != nil { + return fmt.Errorf("failed to process user %d: %w", userID, err) +} +``` + +### 1.3 使用 errors.Is 和 errors.As + +```go +// ❌ 错误:直接比较(无法处理包装错误) +if err == sql.ErrNoRows { + // ... +} + +// ✅ 正确:使用 errors.Is(支持错误链) +if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound +} + +// ✅ 正确:使用 errors.As 提取特定类型 +var pathErr *os.PathError +if errors.As(err, &pathErr) { + log.Printf("path error: %s", pathErr.Path) +} +``` + +### 1.4 自定义错误类型 + +```go +// ✅ 推荐:定义 sentinel 错误 +var ( + ErrNotFound = errors.New("not found") + ErrUnauthorized = errors.New("unauthorized") +) + +// ✅ 推荐:带上下文的自定义错误 +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message) +} +``` + +### 1.5 错误处理只做一次 + +```go +// ❌ 错误:既记录又返回(重复处理) +if err != nil { + log.Printf("error: %v", err) + return err +} + +// ✅ 正确:只返回,让调用者决定 +if err != nil { + return fmt.Errorf("operation failed: %w", err) +} + +// ✅ 或者:只记录并处理(不返回) +if err != nil { + log.Printf("non-critical error: %v", err) + // 继续执行备用逻辑 +} +``` + +--- + +## 2. 并发与 Goroutine + +> 📖 通用并发模式和跨语言示例详见 [异步与并发跨语言指南](cross-cutting/async-concurrency-patterns.md) + +### 2.1 避免 Goroutine 泄漏 + +```go +// ❌ 错误:goroutine 永远无法退出 +func bad() { + ch := make(chan int) + go func() { + val := <-ch // 永远阻塞,无人发送 + fmt.Println(val) + }() + // 函数返回,goroutine 泄漏 +} + +// ✅ 正确:使用 context 或 done channel +func good(ctx context.Context) { + ch := make(chan int) + go func() { + select { + case val := <-ch: + fmt.Println(val) + case <-ctx.Done(): + return // 优雅退出 + } + }() +} +``` + +### 2.2 Channel 使用规范 + +```go +// ❌ 错误:向 nil channel 发送(永久阻塞) +var ch chan int +ch <- 1 // 永久阻塞 + +// ❌ 错误:向已关闭的 channel 发送(panic) +close(ch) +ch <- 1 // panic! + +// ✅ 正确:发送方关闭 channel +func producer(ch chan<- int) { + defer close(ch) // 发送方负责关闭 + for i := 0; i < 10; i++ { + ch <- i + } +} + +// ✅ 正确:接收方检测关闭 +for val := range ch { + process(val) +} +// 或者 +val, ok := <-ch +if !ok { + // channel 已关闭 +} +``` + +### 2.3 使用 sync.WaitGroup + +```go +// ❌ 错误:Add 在 goroutine 内部 +var wg sync.WaitGroup +for i := 0; i < 10; i++ { + go func() { + wg.Add(1) // 竞态条件! + defer wg.Done() + work() + }() +} +wg.Wait() + +// ✅ 正确:Add 在 goroutine 启动前 +var wg sync.WaitGroup +for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + work() + }() +} +wg.Wait() +``` + +### 2.4 避免在循环中捕获变量(Go < 1.22) + +```go +// ❌ 错误(Go < 1.22):捕获循环变量 +for _, item := range items { + go func() { + process(item) // 所有 goroutine 可能使用同一个 item + }() +} + +// ✅ 正确:传递参数 +for _, item := range items { + go func(it Item) { + process(it) + }(item) +} + +// ✅ Go 1.22+:默认行为已修复,每次迭代创建新变量 +``` + +### 2.5 Worker Pool 模式 + +```go +// ✅ 推荐:限制并发数量 +func processWithWorkerPool(ctx context.Context, items []Item, workers int) error { + jobs := make(chan Item, len(items)) + results := make(chan error, len(items)) + + // 启动 worker + for w := 0; w < workers; w++ { + go func() { + for item := range jobs { + results <- process(item) + } + }() + } + + // 发送任务 + for _, item := range items { + jobs <- item + } + close(jobs) + + // 收集结果 + for range items { + if err := <-results; err != nil { + return err + } + } + return nil +} +``` + +--- + +## 3. Context 使用 + +### 3.1 Context 作为第一个参数 + +```go +// ❌ 错误:context 不是第一个参数 +func Process(data []byte, ctx context.Context) error + +// ❌ 错误:context 存储在 struct 中 +type Service struct { + ctx context.Context // 不要这样做! +} + +// ✅ 正确:context 作为第一个参数,命名为 ctx +func Process(ctx context.Context, data []byte) error +``` + +### 3.2 传播而非创建新的根 Context + +```go +// ❌ 错误:在调用链中创建新的根 context +func middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() // 丢失了请求的 context! + process(ctx) + next.ServeHTTP(w, r) + }) +} + +// ✅ 正确:从请求中获取并传播 +func middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + ctx = context.WithValue(ctx, key, value) + process(ctx) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +### 3.3 始终调用 cancel 函数 + +```go +// ❌ 错误:未调用 cancel +ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) +// 缺少 cancel() 调用,可能资源泄漏 + +// ✅ 正确:使用 defer 确保调用 +ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second) +defer cancel() // 即使超时也要调用 +``` + +### 3.4 响应 Context 取消 + +```go +// ✅ 推荐:在长时间操作中检查 context +func LongRunningTask(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() // 返回 context.Canceled 或 context.DeadlineExceeded + default: + // 执行一小部分工作 + if err := doChunk(); err != nil { + return err + } + } + } +} +``` + +### 3.5 区分取消原因 + +```go +// ✅ 根据 ctx.Err() 区分取消原因 +if err := ctx.Err(); err != nil { + switch { + case errors.Is(err, context.Canceled): + log.Println("operation was canceled") + case errors.Is(err, context.DeadlineExceeded): + log.Println("operation timed out") + } + return err +} +``` + +--- + +## 4. 接口设计 + +### 4.1 接受接口,返回结构体 + +```go +// ❌ 不推荐:接受具体类型 +func SaveUser(db *sql.DB, user User) error + +// ✅ 推荐:接受接口(解耦、易测试) +type UserStore interface { + Save(ctx context.Context, user User) error +} + +func SaveUser(store UserStore, user User) error + +// ❌ 不推荐:返回接口 +func NewUserService() UserServiceInterface + +// ✅ 推荐:返回具体类型 +func NewUserService(store UserStore) *UserService +``` + +### 4.2 在消费者处定义接口 + +```go +// ❌ 不推荐:在实现包中定义接口 +// package database +type Database interface { + Query(ctx context.Context, query string) ([]Row, error) + // ... 20 个方法 +} + +// ✅ 推荐:在消费者包中定义所需的最小接口 +// package userservice +type UserQuerier interface { + QueryUsers(ctx context.Context, filter Filter) ([]User, error) +} +``` + +### 4.3 保持接口小而专注 + +```go +// ❌ 不推荐:大而全的接口 +type Repository interface { + GetUser(id int) (*User, error) + CreateUser(u *User) error + UpdateUser(u *User) error + DeleteUser(id int) error + GetOrder(id int) (*Order, error) + CreateOrder(o *Order) error + // ... 更多方法 +} + +// ✅ 推荐:小而专注的接口 +type UserReader interface { + GetUser(ctx context.Context, id int) (*User, error) +} + +type UserWriter interface { + CreateUser(ctx context.Context, u *User) error + UpdateUser(ctx context.Context, u *User) error +} + +// 组合接口 +type UserRepository interface { + UserReader + UserWriter +} +``` + +### 4.4 避免空接口滥用 + +```go +// ❌ 不推荐:过度使用 interface{} +func Process(data interface{}) interface{} + +// ✅ 推荐:使用泛型(Go 1.18+) +func Process[T any](data T) T + +// ✅ 推荐:定义具体接口 +type Processor interface { + Process() Result +} +``` + +--- + +## 5. 接收器类型选择 + +### 5.1 使用指针接收器的情况 + +```go +// ✅ 需要修改接收器时 +func (u *User) SetName(name string) { + u.Name = name +} + +// ✅ 接收器包含 sync.Mutex 等同步原语 +type SafeCounter struct { + mu sync.Mutex + count int +} + +func (c *SafeCounter) Inc() { + c.mu.Lock() + defer c.mu.Unlock() + c.count++ +} + +// ✅ 接收器是大型结构体(避免复制开销) +type LargeStruct struct { + Data [1024]byte + // ... +} + +func (l *LargeStruct) Process() { /* ... */ } +``` + +### 5.2 使用值接收器的情况 + +```go +// ✅ 接收器是小型不可变结构体 +type Point struct { + X, Y float64 +} + +func (p Point) Distance(other Point) float64 { + return math.Sqrt(math.Pow(p.X-other.X, 2) + math.Pow(p.Y-other.Y, 2)) +} + +// ✅ 接收器是基本类型的别名 +type Counter int + +func (c Counter) String() string { + return fmt.Sprintf("%d", c) +} + +// ✅ 接收器是 map、func、chan(本身是引用类型) +type StringSet map[string]struct{} + +func (s StringSet) Contains(key string) bool { + _, ok := s[key] + return ok +} +``` + +### 5.3 一致性原则 + +```go +// ❌ 不推荐:混合使用接收器类型 +func (u User) GetName() string // 值接收器 +func (u *User) SetName(n string) // 指针接收器 + +// ✅ 推荐:如果有任何方法需要指针接收器,全部使用指针 +func (u *User) GetName() string { return u.Name } +func (u *User) SetName(n string) { u.Name = n } +``` + +--- + +## 6. 性能优化 + +### 6.1 预分配 Slice + +```go +// ❌ 不推荐:动态增长 +var result []int +for i := 0; i < 10000; i++ { + result = append(result, i) // 多次分配和复制 +} + +// ✅ 推荐:预分配已知大小 +result := make([]int, 0, 10000) +for i := 0; i < 10000; i++ { + result = append(result, i) +} + +// ✅ 或者直接初始化 +result := make([]int, 10000) +for i := 0; i < 10000; i++ { + result[i] = i +} +``` + +### 6.2 避免不必要的堆分配 + +```go +// ❌ 可能逃逸到堆 +func NewUser() *User { + return &User{} // 逃逸到堆 +} + +// ✅ 考虑返回值(如果适用) +func NewUser() User { + return User{} // 可能在栈上分配 +} + +// 检查逃逸分析 +// go build -gcflags '-m -m' ./... +``` + +### 6.3 使用 sync.Pool 复用对象 + +```go +// ✅ 推荐:高频创建/销毁的对象使用 sync.Pool +var bufferPool = sync.Pool{ + New: func() interface{} { + return new(bytes.Buffer) + }, +} + +func ProcessData(data []byte) string { + buf := bufferPool.Get().(*bytes.Buffer) + defer func() { + buf.Reset() + bufferPool.Put(buf) + }() + + buf.Write(data) + return buf.String() +} +``` + +### 6.4 字符串拼接优化 + +```go +// ❌ 不推荐:循环中使用 + 拼接 +var result string +for _, s := range strings { + result += s // 每次创建新字符串 +} + +// ✅ 推荐:使用 strings.Builder +var builder strings.Builder +for _, s := range strings { + builder.WriteString(s) +} +result := builder.String() + +// ✅ 或者使用 strings.Join +result := strings.Join(strings, "") +``` + +### 6.5 避免 interface{} 转换开销 + +```go +// ❌ 热路径中使用 interface{} +func process(data interface{}) { + switch v := data.(type) { // 类型断言有开销 + case int: + // ... + } +} + +// ✅ 热路径中使用泛型或具体类型 +func process[T int | int64 | float64](data T) { + // 编译时确定类型,无运行时开销 +} +``` + +--- + +## 7. 测试 + +### 7.1 表驱动测试 + +```go +// ✅ 推荐:表驱动测试 +func TestAdd(t *testing.T) { + tests := []struct { + name string + a, b int + expected int + }{ + {"positive numbers", 1, 2, 3}, + {"with zero", 0, 5, 5}, + {"negative numbers", -1, -2, -3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Add(tt.a, tt.b) + if result != tt.expected { + t.Errorf("Add(%d, %d) = %d; want %d", + tt.a, tt.b, result, tt.expected) + } + }) + } +} +``` + +### 7.2 并行测试 + +```go +// ✅ 推荐:独立测试用例并行执行 +func TestParallel(t *testing.T) { + tests := []struct { + name string + input string + }{ + {"test1", "input1"}, + {"test2", "input2"}, + } + + for _, tt := range tests { + tt := tt // Go < 1.22 需要复制 + t.Run(tt.name, func(t *testing.T) { + t.Parallel() // 标记为可并行 + result := Process(tt.input) + // assertions... + }) + } +} +``` + +### 7.3 使用接口进行 Mock + +```go +// ✅ 定义接口以便测试 +type EmailSender interface { + Send(to, subject, body string) error +} + +// 生产实现 +type SMTPSender struct { /* ... */ } + +// 测试 Mock +type MockEmailSender struct { + SendFunc func(to, subject, body string) error +} + +func (m *MockEmailSender) Send(to, subject, body string) error { + return m.SendFunc(to, subject, body) +} + +func TestUserRegistration(t *testing.T) { + mock := &MockEmailSender{ + SendFunc: func(to, subject, body string) error { + if to != "test@example.com" { + t.Errorf("unexpected recipient: %s", to) + } + return nil + }, + } + + service := NewUserService(mock) + // test... +} +``` + +### 7.4 测试辅助函数 + +```go +// ✅ 使用 t.Helper() 标记辅助函数 +func assertEqual(t *testing.T, got, want interface{}) { + t.Helper() // 错误报告时显示调用者位置 + if got != want { + t.Errorf("got %v, want %v", got, want) + } +} + +// ✅ 使用 t.Cleanup() 清理资源 +func TestWithTempFile(t *testing.T) { + f, err := os.CreateTemp("", "test") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + os.Remove(f.Name()) + }) + // test... +} +``` + +--- + +## 8. 常见陷阱 + +### 8.1 Nil Slice vs Empty Slice + +```go +var nilSlice []int // nil, len=0, cap=0 +emptySlice := []int{} // not nil, len=0, cap=0 +made := make([]int, 0) // not nil, len=0, cap=0 + +// ✅ JSON 编码差异 +json.Marshal(nilSlice) // null +json.Marshal(emptySlice) // [] + +// ✅ 推荐:需要空数组 JSON 时显式初始化 +if slice == nil { + slice = []int{} +} +``` + +### 8.2 Map 初始化 + +```go +// ❌ 错误:未初始化的 map +var m map[string]int +m["key"] = 1 // panic: assignment to entry in nil map + +// ✅ 正确:使用 make 初始化 +m := make(map[string]int) +m["key"] = 1 + +// ✅ 或者使用字面量 +m := map[string]int{} +``` + +### 8.3 Defer 在循环中 + +```go +// ❌ 潜在问题:defer 在函数结束时才执行 +func processFiles(files []string) error { + for _, file := range files { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() // 所有文件在函数结束时才关闭! + // process... + } + return nil +} + +// ✅ 正确:使用闭包或提取函数 +func processFiles(files []string) error { + for _, file := range files { + if err := processFile(file); err != nil { + return err + } + } + return nil +} + +func processFile(file string) error { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + // process... + return nil +} +``` + +### 8.4 Slice 底层数组共享 + +```go +// ❌ 潜在问题:切片共享底层数组 +original := []int{1, 2, 3, 4, 5} +slice := original[1:3] // [2, 3] +slice[0] = 100 // 修改了 original! +// original 变成 [1, 100, 3, 4, 5] + +// ✅ 正确:需要独立副本时显式复制 +slice := make([]int, 2) +copy(slice, original[1:3]) +slice[0] = 100 // 不影响 original +``` + +### 8.5 字符串子串内存泄漏 + +```go +// ❌ 潜在问题:子串持有整个底层数组 +func getPrefix(s string) string { + return s[:10] // 仍引用整个 s 的底层数组 +} + +// ✅ 正确:创建独立副本(Go 1.18+) +func getPrefix(s string) string { + return strings.Clone(s[:10]) +} + +// ✅ Go 1.18 之前 +func getPrefix(s string) string { + return string([]byte(s[:10])) +} +``` + +### 8.6 Interface Nil 陷阱 + +```go +// ❌ 陷阱:interface 的 nil 判断 +type MyError struct{} +func (e *MyError) Error() string { return "error" } + +func returnsError() error { + var e *MyError = nil + return e // 返回的 error 不是 nil! +} + +func main() { + err := returnsError() + if err != nil { // true! interface{type: *MyError, value: nil} + fmt.Println("error:", err) + } +} + +// ✅ 正确:显式返回 nil +func returnsError() error { + var e *MyError = nil + if e == nil { + return nil // 显式返回 nil + } + return e +} +``` + +### 8.7 Time 比较 + +```go +// ❌ 不推荐:直接使用 == 比较 time.Time +if t1 == t2 { // 可能因为单调时钟差异而失败 + // ... +} + +// ✅ 推荐:使用 Equal 方法 +if t1.Equal(t2) { + // ... +} + +// ✅ 比较时间范围 +if t1.Before(t2) || t1.After(t2) { + // ... +} +``` + +--- + +## 9. 代码组织 + +### 9.1 包命名 + +```go +// ❌ 不推荐 +package common // 过于宽泛 +package utils // 过于宽泛 +package helpers // 过于宽泛 +package models // 按类型分组 + +// ✅ 推荐:按功能命名 +package user // 用户相关功能 +package order // 订单相关功能 +package postgres // PostgreSQL 实现 +``` + +### 9.2 避免循环依赖 + +```go +// ❌ 循环依赖 +// package a imports package b +// package b imports package a + +// ✅ 解决方案1:提取共享类型到独立包 +// package types (共享类型) +// package a imports types +// package b imports types + +// ✅ 解决方案2:使用接口解耦 +// package a 定义接口 +// package b 实现接口 +``` + +### 9.3 导出标识符规范 + +```go +// ✅ 只导出必要的标识符 +type UserService struct { + db *sql.DB // 私有 +} + +func (s *UserService) GetUser(id int) (*User, error) // 公开 +func (s *UserService) validate(u *User) error // 私有 + +// ✅ 内部包限制访问 +// internal/database/... 只能被同项目代码导入 +``` + +--- + +## 10. 工具与检查 + +### 10.1 必须使用的工具 + +```bash +# 格式化(必须) +gofmt -w . +goimports -w . + +# 静态分析 +go vet ./... + +# 竞态检测 +go test -race ./... + +# 逃逸分析 +go build -gcflags '-m -m' ./... +``` + +### 10.2 推荐的 Linter + +```bash +# golangci-lint(集成多个 linter) +golangci-lint run + +# 常用检查项 +# - errcheck: 检查未处理的错误 +# - gosec: 安全检查 +# - ineffassign: 无效赋值 +# - staticcheck: 静态分析 +# - unused: 未使用的代码 +``` + +### 10.3 Benchmark 测试 + +```go +// ✅ 性能基准测试 +func BenchmarkProcess(b *testing.B) { + data := prepareData() + b.ResetTimer() // 重置计时器 + + for i := 0; i < b.N; i++ { + Process(data) + } +} + +// 运行 benchmark +// go test -bench=. -benchmem ./... +``` + +--- + +## 参考资源 + +- [Effective Go](https://go.dev/doc/effective_go) +- [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments) +- [Go Common Mistakes](https://go.dev/wiki/CommonMistakes) +- [100 Go Mistakes](https://100go.co/) +- [Go Proverbs](https://go-proverbs.github.io/) +- [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/java.md b/examples/skills_code_review_agent/skills/code-review/reference/java.md new file mode 100644 index 000000000..d56024ace --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/java.md @@ -0,0 +1,409 @@ +# Java Code Review Guide + +Java 审查重点:Java 17/21 新特性、Spring Boot 3 最佳实践、并发编程(虚拟线程)、JPA 性能优化以及代码可维护性。 + +> For Java 8 / Spring Boot 2 / `javax.*` legacy stacks, use the [Java 8 Guide](java8.md). + +## 目录 + +- [现代 Java 特性 (17/21+)](#现代-java-特性-1721) +- [Stream API & Optional](#stream-api--optional) +- [Spring Boot 最佳实践](#spring-boot-最佳实践) +- [JPA 与 数据库性能](#jpa-与-数据库性能) +- [并发与虚拟线程](#并发与虚拟线程) +- [Lombok 使用规范](#lombok-使用规范) +- [异常处理](#异常处理) +- [测试规范](#测试规范) +- [Review Checklist](#review-checklist) + +--- + +## 现代 Java 特性 (17/21+) + +### Record (记录类) + +```java +// ❌ 传统的 POJO/DTO:样板代码多 +public class UserDto { + private final String name; + private final int age; + + public UserDto(String name, int age) { + this.name = name; + this.age = age; + } + // getters, equals, hashCode, toString... +} + +// ✅ 使用 Record:简洁、不可变、语义清晰 +public record UserDto(String name, int age) { + // 紧凑构造函数进行验证 + public UserDto { + if (age < 0) throw new IllegalArgumentException("Age cannot be negative"); + } +} +``` + +### Switch 表达式与模式匹配 + +```java +// ❌ 传统的 Switch:容易漏掉 break,不仅冗长且易错 +String type = ""; +switch (obj) { + case Integer i: // Java 16+ + type = String.format("int %d", i); + break; + case String s: + type = String.format("string %s", s); + break; + default: + type = "unknown"; +} + +// ✅ Switch 表达式:无穿透风险,强制返回值 +String type = switch (obj) { + case Integer i -> "int %d".formatted(i); + case String s -> "string %s".formatted(s); + case null -> "null value"; // Java 21 处理 null + default -> "unknown"; +}; +``` + +### 文本块 (Text Blocks) + +```java +// ❌ 拼接 SQL/JSON 字符串 +String json = "{\n" + + " \"name\": \"Alice\",\n" + + " \"age\": 20\n" + + "}"; + +// ✅ 使用文本块:所见即所得 +String json = """ + { + "name": "Alice", + "age": 20 + } + """; +``` + +--- + +## Stream API & Optional + +### 避免滥用 Stream + +```java +// ❌ 简单的循环不需要 Stream(性能开销 + 可读性差) +items.stream().forEach(item -> { + process(item); +}); + +// ✅ 简单场景直接用 for-each +for (var item : items) { + process(item); +} + +// ❌ 极其复杂的 Stream 链 +List result = list.stream() + .filter(...) + .map(...) + .peek(...) + .sorted(...) + .collect(...); // 难以调试 + +// ✅ 拆分为有意义的步骤 +var filtered = list.stream().filter(...).toList(); +// ... +``` + +### Optional 正确用法 + +```java +// ❌ 将 Optional 用作参数或字段(序列化问题,增加调用复杂度) +public void process(Optional name) { ... } +public class User { + private Optional email; // 不推荐 +} + +// ✅ Optional 仅用于返回值 +public Optional findUser(String id) { ... } + +// ❌ 既然用了 Optional 还在用 isPresent() + get() +Optional userOpt = findUser(id); +if (userOpt.isPresent()) { + return userOpt.get().getName(); +} else { + return "Unknown"; +} + +// ✅ 使用函数式 API +return findUser(id) + .map(User::getName) + .orElse("Unknown"); +``` + +--- + +## Spring Boot 最佳实践 + +### 依赖注入 (DI) + +```java +// ❌ 字段注入 (@Autowired) +// 缺点:难以测试(需要反射注入),掩盖了依赖过多的问题,且不可变性差 +@Service +public class UserService { + @Autowired + private UserRepository userRepo; +} + +// ✅ 构造器注入 (Constructor Injection) +// 优点:依赖明确,易于单元测试 (Mock),字段可为 final +@Service +public class UserService { + private final UserRepository userRepo; + + public UserService(UserRepository userRepo) { + this.userRepo = userRepo; + } +} +// 💡 提示:结合 Lombok @RequiredArgsConstructor 可简化代码,但要小心循环依赖 +``` + +### 配置管理 + +```java +// ❌ 硬编码配置值 +@Service +public class PaymentService { + private String apiKey = "sk_live_12345"; +} + +// ❌ 直接使用 @Value 散落在代码中 +@Value("${app.payment.api-key}") +private String apiKey; + +// ✅ 使用 @ConfigurationProperties 类型安全配置 +@ConfigurationProperties(prefix = "app.payment") +public record PaymentProperties(String apiKey, int timeout, String url) {} +``` + +--- + +## JPA 与 数据库性能 + +### N+1 查询问题 + +> 📖 通用原理和跨语言方案详见 [N+1 查询跨语言指南](cross-cutting/n-plus-one-queries.md) + +```java +// ❌ FetchType.EAGER 或 循环中触发懒加载 +// Entity 定义 +@Entity +public class User { + @OneToMany(fetch = FetchType.EAGER) // 危险! + private List orders; +} + +// 业务代码 +List users = userRepo.findAll(); // 1 条 SQL +for (User user : users) { + // 如果是 Lazy,这里会触发 N 条 SQL + System.out.println(user.getOrders().size()); +} + +// ✅ 使用 @EntityGraph 或 JOIN FETCH +@Query("SELECT u FROM User u JOIN FETCH u.orders") +List findAllWithOrders(); +``` + +### 事务管理 + +```java +// ❌ 在 Controller 层开启事务(数据库连接占用时间过长) +// ❌ 在 private 方法上加 @Transactional(AOP 不生效) +@Transactional +private void saveInternal() { ... } + +// ✅ 在 Service 层公共方法加 @Transactional +// ✅ 读操作显式标记 readOnly = true (性能优化) +@Service +public class UserService { + @Transactional(readOnly = true) + public User getUser(Long id) { ... } + + @Transactional + public void createUser(UserDto dto) { ... } +} +``` + +### Entity 设计 + +```java +// ❌ 在 Entity 中使用 Lombok @Data +// @Data 生成的 equals/hashCode 包含所有字段,可能触发懒加载导致性能问题或异常 +@Entity +@Data +public class User { ... } + +// ✅ 仅使用 @Getter, @Setter +// ✅ 自定义 equals/hashCode (通常基于 ID) +@Entity +@Getter +@Setter +public class User { + @Id + private Long id; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof User)) return false; + return id != null && id.equals(((User) o).id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} +``` + +--- + +## 并发与虚拟线程 + +### 虚拟线程 (Java 21+) + +```java +// ❌ 传统线程池处理大量 I/O 阻塞任务(资源耗尽) +ExecutorService executor = Executors.newFixedThreadPool(100); + +// ✅ 使用虚拟线程处理 I/O 密集型任务(高吞吐量) +// Spring Boot 3.2+ 开启:spring.threads.virtual.enabled=true +ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + +// 在虚拟线程中,阻塞操作(如 DB 查询、HTTP 请求)几乎不消耗 OS 线程资源 +``` + +### 线程安全 + +```java +// ❌ SimpleDateFormat 是线程不安全的 +private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + +// ✅ 使用 DateTimeFormatter (Java 8+) +private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + +// ❌ HashMap 在多线程环境会数据丢失(Java 7 及之前 resize 还可能死循环,Java 8 修复了死循环但仍非线程安全) +// ✅ 使用 ConcurrentHashMap +Map cache = new ConcurrentHashMap<>(); +``` + +--- + +## Lombok 使用规范 + +```java +// ❌ 滥用 @Builder 导致无法强制校验必填字段 +@Builder +public class Order { + private String id; // 必填 + private String note; // 选填 +} +// 调用者可能漏掉 id: Order.builder().note("hi").build(); + +// ✅ 关键业务对象建议手动编写 Builder 或构造函数以确保不变量 +// 或者在 build() 方法中添加校验逻辑 (Lombok @Builder.Default 等) +``` + +--- + +## 异常处理 + +### 全局异常处理 + +```java +// ❌ 到处 try-catch 吞掉异常或只打印日志 +try { + userService.create(user); +} catch (Exception e) { + e.printStackTrace(); // 不应该在生产环境使用 + // return null; // 吞掉异常,上层不知道发生了什么 +} + +// ✅ 自定义异常 + @ControllerAdvice (Spring Boot 3 ProblemDetail) +public class UserNotFoundException extends RuntimeException { ... } + +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(UserNotFoundException.class) + public ProblemDetail handleNotFound(UserNotFoundException e) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage()); + } +} +``` + +--- + +## 测试规范 + +### 单元测试 vs 集成测试 + +```java +// ❌ 单元测试依赖真实数据库或外部服务 +@SpringBootTest // 启动整个 Context,慢 +public class UserServiceTest { ... } + +// ✅ 单元测试使用 Mockito +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + @Mock UserRepository repo; + @InjectMocks UserService service; + + @Test + void shouldCreateUser() { ... } +} + +// ✅ 集成测试使用 Testcontainers +@Testcontainers +@SpringBootTest +class UserRepositoryTest { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15"); + // ... +} +``` + +--- + +## Review Checklist + +### 基础与规范 +- [ ] 遵循 Java 17/21 新特性(Switch 表达式, Records, 文本块) +- [ ] 避免使用已过时的类(Date, Calendar, SimpleDateFormat) +- [ ] 集合操作是否优先使用了 Stream API 或 Collections 方法? +- [ ] Optional 仅用于返回值,未用于字段或参数 + +### Spring Boot +- [ ] 使用构造器注入而非 @Autowired 字段注入 +- [ ] 配置属性使用了 @ConfigurationProperties +- [ ] Controller 职责单一,业务逻辑下沉到 Service +- [ ] 全局异常处理使用了 @ControllerAdvice / ProblemDetail + +### 数据库 & 事务 +- [ ] 读操作事务标记了 `@Transactional(readOnly = true)` +- [ ] 检查是否存在 N+1 查询(EAGER fetch 或循环调用) +- [ ] Entity 类未使用 @Data,正确实现了 equals/hashCode +- [ ] 数据库索引是否覆盖了查询条件 + +### 并发与性能 +- [ ] I/O 密集型任务是否考虑了虚拟线程? +- [ ] 线程安全类是否使用正确(ConcurrentHashMap vs HashMap) +- [ ] 锁的粒度是否合理?避免在锁内进行 I/O 操作 + +### 可维护性 +- [ ] 关键业务逻辑有充分的单元测试 +- [ ] 日志记录恰当(使用 Slf4j,避免 System.out) +- [ ] 魔法值提取为常量或枚举 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/java8.md b/examples/skills_code_review_agent/skills/code-review/reference/java8.md new file mode 100644 index 000000000..da50c1a4c --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/java8.md @@ -0,0 +1,586 @@ +# Java 8 / Legacy Stack Code Review Guide + +Review guidance for codebases still on **Java 8** (and nearby legacy stacks: Spring Boot 2.x, `javax.*`, Hibernate 5). Do **not** require Java 17/21 features (records, text blocks, virtual threads, `ProblemDetail`, etc.) on these PRs. + +> For modern stacks (Java 17/21 + Spring Boot 3), use the [Java Guide](java.md). + +## Table of Contents + +- [When to Use This Guide](#when-to-use-this-guide) +- [Lambdas & Functional Interfaces](#lambdas--functional-interfaces) +- [Stream API](#stream-api) +- [Optional](#optional) +- [Date/Time API (`java.time`)](#datetime-api-javatime) +- [Concurrency: Thread Pools & CompletableFuture](#concurrency-thread-pools--completablefuture) +- [Spring Boot 2](#spring-boot-2) +- [JPA / Hibernate 5](#jpa--hibernate-5) +- [Exception Handling](#exception-handling) +- [Testing](#testing) +- [Review Checklist](#review-checklist) +- [References](#references) + +--- + +## When to Use This Guide + +| Scenario | Load | +|----------|------| +| Java 8 / 11 (no modern syntax), Spring Boot 2.x, `javax.persistence` | **This file** | +| Java 17/21, Spring Boot 3, `jakarta.*`, virtual threads | [java.md](java.md) | +| Migrating Boot 2 → Boot 3 | Use both: this guide for legacy pitfalls, `java.md` for the target | + +Confirm `java.version`, Spring Boot version, and `javax` vs `jakarta` package names in `pom.xml` / `build.gradle` before choosing a guide. + +--- + +## Lambdas & Functional Interfaces + +### Keep them short; prefer method references + +```java +// ❌ Long lambdas are hard to read, test, and debug +users.stream().forEach(u -> { + // dozens of lines of business logic... +}); + +// ✅ Extract a method, or use a method reference +users.forEach(this::processUser); +``` + +### Prefer JDK functional interfaces + +```java +// ❌ Unnecessary custom functional interface +@FunctionalInterface +interface UserCallback { + void accept(User u); +} + +// ✅ Use Consumer / Function / Predicate / Supplier / BiFunction, etc. +void process(Consumer callback) { ... } +``` + +### Captured variables must be effectively final + +```java +// ❌ Mutating a captured variable → compile error (or Atomic/array hacks) +int sum = 0; +list.forEach(n -> sum += n); // does not compile + +// ✅ Reduce with a stream, or use an explicit mutable accumulator type +int sum = list.stream().mapToInt(Integer::intValue).sum(); +``` + +--- + +## Stream API + +### Do not force Streams for simple loops + +```java +// ❌ Side effects only — Stream adds no value +items.stream().forEach(item -> process(item)); + +// ✅ Plain for-each is clearer +for (Item item : items) { + process(item); +} +``` + +### Collect with `Collectors` on Java 8 + +```java +// ❌ Stream.toList() is Java 16+ — not available on Java 8 +list.stream().map(...).toList(); + +// ✅ Java 8 +List result = list.stream() + .map(...) + .collect(Collectors.toList()); +``` + +Note: `Collectors.toList()` is not guaranteed immutable. For an unmodifiable list, wrap with `Collections.unmodifiableList(...)` or use Guava / `List.copyOf` (the latter needs a newer JDK). + +### Two classic `Collectors.toMap` pitfalls + +```java +// ❌ Null values → NPE (internally uses Map.merge, which forbids null values) +Map map = users.stream() + .collect(Collectors.toMap(User::getId, User::getNickname)); // nickname may be null + +// ✅ Filter first, or handle nulls explicitly +Map map = users.stream() + .filter(u -> u.getNickname() != null) + .collect(Collectors.toMap(User::getId, User::getNickname)); + +// ❌ Duplicate keys → IllegalStateException +.collect(Collectors.toMap(User::getName, Function.identity())); + +// ✅ Provide a merge function +.collect(Collectors.toMap(User::getName, Function.identity(), (a, b) -> a)); +``` + +### Be careful with `parallelStream()` + +```java +// ❌ Small collections / I/O / shared mutable state — often slower or unsafe +list.parallelStream().forEach(sharedList::add); // race + +// ❌ Side-effecting forEach into a non-concurrent collection +map.entrySet().parallelStream().forEach(e -> result.put(e.getKey(), e.getValue())); + +// ✅ Consider only for CPU-bound work, no shared mutable state, and large enough data +// ✅ Collect with toMap / toConcurrentMap — do not forEach into an external Map +Map result = list.parallelStream() + .collect(Collectors.toConcurrentMap(Item::getKey, Item::getValue, (a, b) -> a)); +``` + +Parallel streams use `ForkJoinPool.commonPool()` by default and compete with other parallel / CompletableFuture work in the same process. + +### Do not mutate the stream source; avoid nested `forEach` + +```java +// ❌ Mutating the source during the pipeline → ConcurrentModificationException +list.stream().peek(list::add).count(); + +// ❌ Nested forEach is hard to read and hard to short-circuit +a.forEach(x -> b.forEach(y -> ...)); + +// ✅ Prefer flatMap or ordinary loops for cartesian / join-style logic +``` + +### Prefer primitive streams to avoid boxing + +```java +// ❌ Stream boxing overhead +int sum = list.stream().map(Order::getAmount).reduce(0, Integer::sum); + +// ✅ IntStream / LongStream / DoubleStream +int sum = list.stream().mapToInt(Order::getAmount).sum(); +``` + +--- + +## Optional + +**Intent:** express possible absence as a **return type**, not as a general null replacement. + +```java +// ❌ Optional as field / parameter / collection element (serialization, reflection, API noise) +class User { + private Optional email; +} +void send(Optional email) { ... } +Optional> findOrders(); // empty list already means "none" + +// ✅ Return type only; return empty collections for "none" +public Optional findById(Long id) { ... } +public List findOrders(Long userId) { ... } // emptyList when none +``` + +### Do not use `isPresent()` + `get()` as a null check + +```java +// ❌ More verbose than a null check, and get() can still blow up +if (userOpt.isPresent()) { + return userOpt.get().getName(); +} +return "Unknown"; + +// ✅ Functional chain (available on Java 8) +return userOpt.map(User::getName).orElse("Unknown"); +``` + +### `orElse` vs `orElseGet` + +```java +// ❌ orElse argument is always evaluated (even when the Optional is present) +return findUser(id).orElse(loadDefaultFromDb()); // always hits DB + +// ✅ Expensive defaults → orElseGet +return findUser(id).orElseGet(this::loadDefaultFromDb); + +// ✅ Required value (Java 8) +return findUser(id).orElseThrow(() -> new UserNotFoundException(id)); +// Note: no-arg orElseThrow() is Java 10+; Java 8 requires a Supplier +``` + +### `of` vs `ofNullable`; nest with `flatMap` + +```java +// ❌ of(null) → immediate NPE +Optional.of(possiblyNull); + +// ✅ +Optional.ofNullable(possiblyNull); + +// ❌ map returning Optional → Optional> +optional.map(this::findOther); // findOther returns Optional + +// ✅ +optional.flatMap(this::findOther); +``` + +Java 8 has **no** `Optional.stream()` / `ifPresentOrElse` / `or` (those are Java 9+). Filtering a collection of Optionals: + +```java +list.stream() + .map(this::findUser) + .filter(Optional::isPresent) + .map(Optional::get) // OK after filter; or extract a helper + .collect(Collectors.toList()); +``` + +--- + +## Date/Time API (`java.time`) + +One of the most common production footguns in legacy systems: keeping `Date` / `Calendar` / `SimpleDateFormat`. + +```java +// ❌ SimpleDateFormat is not thread-safe; a shared static instance corrupts state +private static final SimpleDateFormat SDF = new SimpleDateFormat("yyyy-MM-dd"); + +// ✅ DateTimeFormatter is immutable and thread-safe +private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); +``` + +### Pick the right type + +| Type | Use for | +|------|---------| +| `Instant` | Machine timestamps, audit, cross-service events (UTC timeline) | +| `LocalDate` | Date only (birthday, business day) | +| `LocalDateTime` | Date-time **without** zone; do not use for "when an event happened" | +| `ZonedDateTime` / `OffsetDateTime` | Human time that needs a zone or offset | + +```java +// ❌ LocalDateTime for "order placed at" — ambiguous across zones / DST +private LocalDateTime createdAt = LocalDateTime.now(); + +// ✅ Instant for event time; convert to a zone only for display +private Instant createdAt = Instant.now(); + +// ❌ now() depends on the JVM default zone — CI / prod / laptop disagree +LocalDate.now(); + +// ✅ Explicit ZoneId, or inject Clock for tests +LocalDate.now(ZoneOffset.UTC); +LocalDate.now(clock); +``` + +### Formatting traps + +```java +// ❌ YYYY is week-based year — wrong year near year boundaries +DateTimeFormatter.ofPattern("YYYY-MM-dd"); + +// ✅ Calendar year uses yyyy +DateTimeFormatter.ofPattern("yyyy-MM-dd"); +``` + +Interop with legacy APIs: `date.toInstant()`, `Date.from(instant)`, `LocalDateTime.ofInstant(instant, zone)`. + +--- + +## Concurrency: Thread Pools & CompletableFuture + +Java 8 has **no virtual threads**. I/O-heavy work needs a well-sized pool — not `newCachedThreadPool` or an unbounded queue. + +```java +// ❌ Unbounded queue + default rejection — latency explodes or OOM under load +ExecutorService exec = Executors.newFixedThreadPool(8); // unbounded queue + +// ❌ Never shut down → thread leak +Executors.newFixedThreadPool(8).submit(task); + +// ✅ Bounded queue + explicit rejection policy + lifecycle management +ThreadPoolExecutor exec = new ThreadPoolExecutor( + 8, 16, 60L, TimeUnit.SECONDS, + new ArrayBlockingQueue<>(500), + new ThreadPoolExecutor.CallerRunsPolicy()); +// On shutdown: shutdown → awaitTermination → shutdownNow +``` + +### CompletableFuture + +```java +// ❌ I/O on the common pool (supplyAsync with no Executor) +CompletableFuture.supplyAsync(() -> restTemplate.getForObject(url, Dto.class)); + +// ✅ Explicit I/O executor +CompletableFuture.supplyAsync(() -> callRemote(), ioExecutor); + +// ❌ thenApply returning a CF → nested CompletableFuture> +.thenApply(id -> findAsync(id)); + +// ✅ Dependent async work → thenCompose +.thenCompose(id -> findAsync(id)); + +// ❌ get()/join() inside async callbacks — starves the pool or deadlocks +.thenApply(x -> other.join()); + +// ✅ Combine with allOf / thenCombine; join once at the boundary +``` + +**Timeouts:** Java 8 has no `orTimeout` / `completeOnTimeout` (Java 9+). Use `get(timeout, unit)`, or a scheduler + `applyToEither` that completes exceptionally. + +**Exceptions:** Attach `exceptionally` / `handle` / `whenComplete` at the chain boundary so failures are not swallowed. + +```java +// ❌ Shared mutable SimpleDateFormat / HashMap as a cache +private static final SimpleDateFormat SDF = ...; +private final Map cache = new HashMap<>(); // concurrent puts + +// ✅ ConcurrentHashMap; dates via java.time +private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); +``` + +--- + +## Spring Boot 2 + +Packages are **`javax.*`**, not `jakarta.*`. Do not require a Jakarta migration unless the PR's goal is upgrading to Boot 3. + +### Dependency injection + +```java +// ❌ Field @Autowired: hard to test, opaque dependencies +@Autowired +private UserRepository userRepo; + +// ✅ Constructor injection (Boot 2: single constructor can omit @Autowired) +private final UserRepository userRepo; + +public UserService(UserRepository userRepo) { + this.userRepo = userRepo; +} +``` + +### Configuration + +```java +// ❌ Hard-coded secrets; @Value scattered everywhere +@Value("${app.payment.api-key}") +private String apiKey; + +// ✅ @ConfigurationProperties (Java 8 uses a class, not a record) +@ConfigurationProperties(prefix = "app.payment") +public class PaymentProperties { + private String apiKey; + private int timeoutMs; + // getters / setters +} +``` + +Register with `@EnableConfigurationProperties`. On Boot **2.2+**, `@ConfigurationPropertiesScan` also works (`@SpringBootApplication` scans the startup class package by default). + +### RestTemplate must have timeouts + +Default is **infinite wait**. A hung downstream can exhaust Tomcat / worker threads. + +```java +// ❌ Bare new RestTemplate() — no timeouts +return new RestTemplate(); + +// ✅ Boot 2.1+ Duration-based timeouts +@Bean +public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .setConnectTimeout(Duration.ofSeconds(2)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); +} +``` + +If you customize `ClientHttpRequestFactory` (e.g. Apache HttpClient), set connect / read / connectionRequest timeouts on the factory too — otherwise builder timeouts may be ignored. + +### Transaction proxy trap (same on Boot 2/3) + +```java +// ❌ Same-class self-call — @Transactional does not apply (proxy not invoked) +public void create(Order o) { + save(o); // internal call +} +@Transactional +public void save(Order o) { ... } + +// ✅ Put the transaction boundary on the public entry point, or split into another bean +@Transactional +public void create(Order o) { saveInternal(o); } +``` + +`@Transactional` on `private` methods is also ineffective. + +--- + +## JPA / Hibernate 5 + +Entity annotations come from `javax.persistence.*`. + +### N+1 + +> Cross-language background: [N+1 query guide](cross-cutting/n-plus-one-queries.md) + +```java +// ❌ EAGER, or loops that trigger lazy loads +@OneToMany(fetch = FetchType.EAGER) +private List orders; + +for (User u : userRepo.findAll()) { + u.getOrders().size(); // N queries when lazy +} + +// ✅ JOIN FETCH / @EntityGraph; keep LAZY by default +@Query("SELECT u FROM User u JOIN FETCH u.orders") +List findAllWithOrders(); +``` + +### Transactions & read-only + +```java +// ❌ Transactions opened in Controllers; or @Transactional on private methods +// ✅ Public Service methods; mark reads readOnly +@Transactional(readOnly = true) +public User get(Long id) { ... } +``` + +### Entities & Lombok + +```java +// ❌ @Data equals/hashCode often pulls in lazy associations +@Entity +@Data +public class User { ... } + +// ✅ @Getter/@Setter; equals/hashCode on a stable business key, or null-safe id +// ⚠️ Do not include lazy associations; new unsaved entities all have null id, +// so id-only equals treats them as unequal (usually acceptable) +@Entity +@Getter +@Setter +public class User { + @Id + private Long id; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof User)) return false; + return id != null && id.equals(((User) o).id); + } + + @Override + public int hashCode() { + return getClass().hashCode(); + } +} +``` + +### Temporal fields + +Hibernate 5 supports `java.time`, but mapping `ZonedDateTime` to a zone-less `TIMESTAMP` normalizes with the JVM zone and drifts across regions. Prefer: + +- Store UTC: `Instant` or `OffsetDateTime` +- Set `spring.jpa.properties.hibernate.jdbc.time_zone=UTC` when the team agrees +- Migrate legacy `java.util.Date` fields deliberately; do not add new `Date` / `@Temporal` in new code + +--- + +## Exception Handling + +Boot 2 does not treat Spring 6 `ProblemDetail` as a first-class citizen (that is a Boot 3 story). Use a shared `@ControllerAdvice` with clear HTTP statuses. + +```java +// ❌ Swallow exceptions, printStackTrace, return null to hide failure +try { + userService.create(user); +} catch (Exception e) { + e.printStackTrace(); + return null; +} + +// ✅ Domain exceptions + global handler +@RestControllerAdvice +public class GlobalExceptionHandler { + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity handleNotFound(UserNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ApiError("USER_NOT_FOUND", e.getMessage())); + } +} +``` + +Close resources with try-with-resources (Java 7+). Avoid hand-rolled `finally { close() }` that forgets null checks. + +--- + +## Testing + +```java +// ❌ @SpringBootTest for every unit test (slow and brittle) +@SpringBootTest +public class UserServiceTest { ... } + +// ✅ Pure unit tests: JUnit 4/5 + Mockito +@RunWith(MockitoJUnitRunner.class) // JUnit 4 +// or @ExtendWith(MockitoExtension.class) // JUnit 5 +public class UserServiceTest { + @Mock private UserRepository repo; + @InjectMocks private UserService service; + + @Test + public void shouldCreateUser() { ... } +} +``` + +Inject `Clock` into time-sensitive logic so tests do not depend on `Instant.now()`. + +Legacy stacks often use JUnit 4; if JUnit 5 is mixed in, keep one runner style per module. + +--- + +## Review Checklist + +### Version & scope +- [ ] Confirmed Java 8 / Boot 2 / `javax.*` — do not demand Java 17+ APIs or `jakarta.*` +- [ ] Do not treat "use records / virtual threads / text blocks" as blocking feedback + +### Language features +- [ ] Lambdas stay short; prefer method references and standard functional interfaces +- [ ] Streams used for transform / filter / reduce, not simple side-effect loops +- [ ] Collection uses `Collectors.*` (no `Stream.toList()`) +- [ ] `toMap` handles null values and duplicate keys +- [ ] `parallelStream` is justified and has no shared mutable state +- [ ] Optional is return-type only; no `isPresent`+`get` abuse; expensive defaults use `orElseGet` +- [ ] Dates use `java.time`; no shared `SimpleDateFormat`; correct type (`Instant` vs `LocalDateTime`) +- [ ] Patterns use `yyyy`, not week-based `YYYY` (unless week-year is intentional) + +### Concurrency +- [ ] Thread pools are bounded and shut down; I/O does not use `ForkJoinPool.commonPool()` +- [ ] CompletableFuture uses an explicit Executor; `thenCompose` for nested async; timeouts and error handling present +- [ ] Shared state uses concurrent collections; no static mutable `DateFormat` + +### Spring Boot 2 / JPA +- [ ] Constructor injection; config via `@ConfigurationProperties` +- [ ] `RestTemplate` (and RequestFactory) has connect/read timeouts +- [ ] `@Transactional` on public entry points — no self-invocation / private-method failures +- [ ] No N+1; entities avoid `@Data`; temporal strategy is explicit (UTC) +- [ ] Packages stay consistently `javax.*` — no javax/jakarta mix + +### Quality +- [ ] Exceptions are not swallowed; error responses are centralized +- [ ] try-with-resources for I/O and DB resources +- [ ] Core logic has unit tests; time is injectable via `Clock` + +--- + +## References + +- [What to Look for in Java 8 Code (JetBrains)](https://blog.jetbrains.com/upsource/2016/08/03/what-to-look-for-in-java-8-code/) +- [JDK-8148463: Collectors.toMap fails on null values](https://bugs.openjdk.org/browse/JDK-8148463) +- [Oracle Tutorial: Parallelism](https://docs.oracle.com/javase/tutorial/collections/streams/parallelism.html) +- [Baeldung: Migrating to Java 8 Date/Time API](https://www.baeldung.com/migrating-to-java-8-date-time-api) +- [Baeldung: CompletableFuture and ThreadPool](https://www.baeldung.com/java-completablefuture-threadpool) +- [Spring Boot RestTemplate customization](https://docs.spring.io/spring-boot/docs/2.7.x/reference/html/io.html#io.rest-client.resttemplate) +- [Thorben Janssen: Hibernate/JPA Date and Time](https://thorben-janssen.com/hibernate-jpa-date-and-time/) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/kotlin.md b/examples/skills_code_review_agent/skills/code-review/reference/kotlin.md new file mode 100644 index 000000000..428b05a8a --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/kotlin.md @@ -0,0 +1,1018 @@ +# Kotlin / Android Code Review Guide + +> Kotlin/Android 代码审查指南,覆盖协程作用域与取消、Flow 陷阱、Compose 重组、空安全、内存泄漏、架构分层与密封类状态建模等核心主题。 + +## 目录 + +- [协程:作用域与取消](#协程作用域与取消) +- [Flow 陷阱](#flow-陷阱) +- [Jetpack Compose 重组](#jetpack-compose-重组) +- [空安全模式](#空安全模式) +- [内存泄漏](#内存泄漏) +- [架构:ViewModel 与 Repository](#架构viewmodel-与-repository) +- [密封类与状态管理](#密封类与状态管理) +- [Review Checklist](#review-checklist) + +--- + +## 协程:作用域与取消 + +> 📖 通用并发模式和跨语言示例详见 [异步与并发跨语言指南](cross-cutting/async-concurrency-patterns.md) + +### 避免 GlobalScope + +```kotlin +// ❌ GlobalScope 生命周期不受控,Activity/Fragment 销毁后协程仍在运行 +GlobalScope.launch { + val data = api.fetchData() + binding.textView.text = data.title // Crash: view destroyed +} + +// ✅ 使用 viewModelScope,ViewModel 清除时自动取消 +class MyViewModel(private val repo: Repository) : ViewModel() { + fun loadData() { + viewModelScope.launch { + val data = repo.fetchData() + _uiState.value = UiState.Success(data) + } + } +} + +// ✅ 在 Activity/Fragment 中使用 lifecycleScope +class MyActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + lifecycleScope.launch { + val data = repo.fetchData() + binding.textView.text = data.title + } + } +} +``` + +### CancellationException 不能吞掉 + +```kotlin +// ❌ 捕获所有异常导致取消信号丢失 +viewModelScope.launch { + try { + repo.fetchData() + } catch (e: Exception) { + // CancellationException 被吞掉,协程无法取消 + showError(e) + } +} + +// ✅ 重新抛出 CancellationException +viewModelScope.launch { + try { + repo.fetchData() + } catch (e: CancellationException) { + throw e // Must rethrow + } catch (e: Exception) { + showError(e) + } +} + +// ✅ 或使用 catch 配合 ensureActive +viewModelScope.launch { + try { + repo.fetchData() + } catch (e: Exception) { + ensureActive() // Rethrows if cancelled + showError(e) + } +} +``` + +### CPU-bound 任务需要检查取消 + +```kotlin +// ❌ CPU 密集计算不响应取消,即使协程已取消也会跑完 +viewModelScope.launch(Dispatchers.Default) { + for (item in largeList) { + heavyComputation(item) + } +} + +// ✅ 定期检查 isActive 或调用 ensureActive +viewModelScope.launch(Dispatchers.Default) { + for (item in largeList) { + ensureActive() // Throws CancellationException if cancelled + heavyComputation(item) + } +} + +// ✅ 或使用 yield 让出执行权 +viewModelScope.launch(Dispatchers.Default) { + for (item in largeList) { + yield() // Checks cancellation + yields to other coroutines + heavyComputation(item) + } +} +``` + +### 阻塞操作使用 runInterruptible + +```kotlin +// ❌ 在协程中直接调用阻塞 I/O,阻塞线程池线程 +viewModelScope.launch(Dispatchers.IO) { + val result = blockingLibraryCall() // Blocks IO thread +} + +// ✅ 使用 runInterruptible 包装阻塞调用,支持取消中断 +viewModelScope.launch(Dispatchers.IO) { + val result = runInterruptible { + blockingLibraryCall() // Interrupted on cancellation + } +} +``` + +### 正确选择调度器 + +```kotlin +// ❌ CPU 密集任务用了 IO 调度器(线程池过大,浪费资源) +viewModelScope.launch(Dispatchers.IO) { + val bitmap = decodeImage(byteArray) // CPU-bound on IO pool +} + +// ✅ CPU 密集用 Default,I/O 操作用 IO +viewModelScope.launch(Dispatchers.Default) { + val bitmap = decodeImage(byteArray) // CPU-bound on Default pool +} + +// ❌ IO 操作用了 Default 调度器(线程池太小,容易饥饿) +viewModelScope.launch(Dispatchers.Default) { + val response = okHttpClient.newCall(request).execute() // I/O on Default pool +} + +// ✅ I/O 操作用 IO 调度器 +viewModelScope.launch(Dispatchers.IO) { + val response = okHttpClient.newCall(request).execute() +} +``` + +### launch vs async + +```kotlin +// ❌ async 只用于"发个火",不需要返回值 +viewModelScope.launch { + async { analytics.trackEvent("click") } // Overkill +} + +// ✅ 不需要返回值用 launch +viewModelScope.launch { + launch { analytics.trackEvent("click") } +} + +// ✅ 需要返回值且可能并行时用 async +viewModelScope.launch { + val deferredA = async { api.fetchA() } + val deferredB = async { api.fetchB() } + val result = combine(deferredA.await(), deferredB.await()) +} +``` + +### 不要用 Job() 破坏父子关系 + +```kotlin +// ❌ Job() 切断了父协程的取消传播 +viewModelScope.launch { + launch(Job()) { // Detached from parent scope! + importantWork() // Will NOT be cancelled when viewModelScope cancels + } +} + +// ✅ 保持默认的父子关系 +viewModelScope.launch { + launch { // Child of viewModelScope + importantWork() // Cancelled when viewModelScope cancels + } +} + +// ✅ 如果确实需要独立生命周期,显式管理并说明原因 +class MyManager(private val scope: CoroutineScope) { + // Independent lifecycle managed by MyManager.shutdown() + private val managerJob = Job(scope.coroutineContext[Job]) + private val managerScope = scope + managerJob + Dispatchers.IO + + fun shutdown() { + managerJob.cancel() + } +} +``` + +### NonCancellable 的正确使用 + +```kotlin +// ❌ 整个协程都包在 withContext(NonCancellable) 中,无法取消 +viewModelScope.launch { + withContext(NonCancellable) { // Entire block is uncancellable! + val data = repo.fetchData() // Cannot be cancelled + db.saveData(data) // Cannot be cancelled + analytics.track("saved") + } +} + +// ✅ NonCancellable 只用于清理操作 +viewModelScope.launch { + try { + val data = repo.fetchData() + db.saveData(data) + } catch (e: CancellationException) { + throw e + } finally { + withContext(NonCancellable) { + db.cleanup() // Only cleanup is uncancellable + } + } +} +``` + +--- + +## Flow 陷阱 + +### 冷流与热流混淆 + +```kotlin +// ❌ 每次 collect 都重新执行 flow {} 块(冷流特性被误解) +val userFlow = flow { + emit(api.fetchUser()) // Called once per collector! +} + +// Two collectors = two network requests +lifecycleScope.launch { userFlow.collect { } } +lifecycleScope.launch { userFlow.collect { } } + +// ✅ 共享数据用 StateFlow/SharedFlow(热流) +class MyViewModel(private val repo: Repository) : ViewModel() { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + viewModelScope.launch { + _uiState.value = UiState.Success(repo.fetchUser()) + } + } +} +// Multiple collectors share the same StateFlow +``` + +### 不要在 flow {} 中切换上下文 + +```kotlin +// ❌ 在 flow builder 中使用 withContext,违反约束 +val dataFlow = flow { + withContext(Dispatchers.IO) { // IllegalStateException! + emit(api.fetchData()) + } +} + +// ✅ 使用 flowOn 操作符切换上游上下文 +val dataFlow = flow { + emit(api.fetchData()) // Runs on IO via flowOn +}.flowOn(Dispatchers.IO) + +// ✅ 或使用 channelFlow / callbackFlow 需要切换时 +val dataFlow = channelFlow { + withContext(Dispatchers.IO) { + send(api.fetchData()) // send() is safe in channelFlow + } +} +``` + +### collect 需要生命周期感知 + +```kotlin +// ❌ 在 Activity/Fragment 中 collect 不感知生命周期 +lifecycleScope.launch { + viewModel.uiState.collect { state -> + binding.textView.text = state.title // Crash if view destroyed + } +} + +// ✅ 在 Fragment 中使用 viewLifecycleOwner.lifecycleScope + repeatOnLifecycle +viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect { state -> + binding.textView.text = state.title + } + } +} + +// ✅ 在 Compose 中使用 collectAsStateWithLifecycle +@Composable +fun MyScreen(viewModel: MyViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + // ... +} +``` + +### 异常透明性:使用 catch 操作符 + +```kotlin +// ❌ 在 collect 中 try-catch 处理上游异常 +viewModelScope.launch { + try { + dataFlow.collect { data -> + processData(data) + } + } catch (e: Exception) { + // This also catches exceptions from processData, not just upstream + showError(e) + } +} + +// ✅ 使用 catch 操作符保持异常透明性 +viewModelScope.launch { + dataFlow + .catch { e -> showError(e) } // Only catches upstream exceptions + .collect { data -> + processData(data) // Exceptions here propagate normally + } +} +``` + +### StateFlow vs SharedFlow 选择 + +```kotlin +// ❌ 用 SharedFlow 模拟 StateFlow,丢失最新值语义 +private val _state = MutableSharedFlow() +val state: SharedFlow = _state + +// ✅ UI 状态用 StateFlow:总是有值、新订阅者立即获得最新值 +class MyViewModel : ViewModel() { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() +} + +// ✅ 事件(一次性通知)用 SharedFlow + replay(0) +class MyViewModel : ViewModel() { + private val _navigationEvent = MutableSharedFlow(extraBufferCapacity = 1) + val navigationEvent: SharedFlow = _navigationEvent.asSharedFlow() + + fun navigate(target: NavTarget) { + _navigationEvent.tryEmit(target) + } +} + +// ✅ Channel 用于一次性事件(替代方案) +private val _navigationEvent = Channel(Channel.BUFFERED) +val navigationEvent = _navigationEvent.receiveAsFlow() +``` + +--- + +## Jetpack Compose 重组 + +### 不稳定参数导致多余重组 + +```kotlin +// ❌ 使用不稳定的类作为参数,Compose 无法判断是否变化 +data class UserProfile( + val name: String, + val friends: List, // Unstable! List is not @Stable +) + +@Composable +fun ProfileCard(profile: UserProfile) { // Recomposes even if profile didn't change + Text(profile.name) +} + +// ✅ 使用 @Immutable 标注或使用稳定的集合类型 +@Immutable +data class UserProfile( + val name: String, + val friends: ImmutableList, // kotlinx.collections.immutable +) + +// ✅ 或将不稳定属性提取为单独的参数 +@Composable +fun ProfileCard( + name: String, // Stable: String is primitive + friendCount: Int, // Stable: Int is primitive +) { + Text(name) + Text("$friendCount friends") +} +``` + +### Lambda 不稳定与记忆化 + +```kotlin +// ❌ 每次重组都创建新的 Lambda,导致子组件不必要的重组 +@Composable +fun MyScreen(viewModel: MyViewModel) { + LazyColumn { + items(items, key = { it.id }) { item -> + ItemRow( + item = item, + onClick = { viewModel.handleClick(item.id) } // New lambda each recomposition! + ) + } + } +} + +// ✅ 使用 remember 包装 Lambda,或让 ViewModel 暴露稳定回调 +@Composable +fun MyScreen(viewModel: MyViewModel) { + LazyColumn { + items(items, key = { it.id }) { item -> + ItemRow( + item = item, + onClick = remember(item.id) { { viewModel.handleClick(item.id) } } + ) + } + } +} +``` + +### 使用 derivedStateOf 避免高频率重组 + +```kotlin +// ❌ 每次滚动都重组整个组件 +@Composable +fun ScrollToTopButton(lazyListState: LazyListState) { + val showButton = lazyListState.firstVisibleItemIndex > 0 // Recomposes on every scroll + if (showButton) { + Button(onClick = { /* scroll to top */ }) { + Text("Top") + } + } +} + +// ✅ 使用 derivedStateOf 只在结果变化时触发重组 +@Composable +fun ScrollToTopButton(lazyListState: LazyListState) { + val showButton by remember { + derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } + } + if (showButton) { + Button(onClick = { /* scroll to top */ }) { + Text("Top") + } + } +} +``` + +### 不要在 Composable 函数体中执行副作用 + +```kotlin +// ❌ 在 Composable 函数体中直接触发副作用,每次重组都会执行 +@Composable +fun MyScreen(userId: String, viewModel: MyViewModel) { + viewModel.loadUser(userId) // Called on every recomposition! + val user by viewModel.user.collectAsStateWithLifecycle() + Text(user?.name ?: "Loading...") +} + +// ✅ 使用 LaunchedEffect 在 key 变化时执行副作用 +@Composable +fun MyScreen(userId: String, viewModel: MyViewModel) { + LaunchedEffect(userId) { + viewModel.loadUser(userId) // Only when userId changes + } + val user by viewModel.user.collectAsStateWithLifecycle() + Text(user?.name ?: "Loading...") +} + +// ✅ 一次性初始化用 remember { ... } +@Composable +fun MyScreen(viewModel: MyViewModel) { + val initialData = remember { viewModel.getInitialData() } +} +``` + +### 状态提升 + +```kotlin +// ❌ 状态和逻辑耦合在 Composable 内部,无法复用和测试 +@Composable +fun ToggleButton() { + var isChecked by remember { mutableStateOf(false) } + Switch( + checked = isChecked, + onCheckedChange = { isChecked = it } + ) +} + +// ✅ 状态提升:调用者控制状态 +@Composable +fun ToggleButton( + isChecked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + Switch( + checked = isChecked, + onCheckedChange = onCheckedChange, + modifier = modifier, + ) +} + +// ✅ 调用者持有状态 +@Composable +fun ParentScreen() { + var enabled by rememberSaveable { mutableStateOf(false) } + ToggleButton( + isChecked = enabled, + onCheckedChange = { enabled = it }, + ) +} +``` + +--- + +## 空安全模式 + +### 避免非空断言 !! + +```kotlin +// ❌ 非空断言:如果为 null 直接 NPE +val user = getUser()!! +val name = user.name!! + +// ✅ 安全调用 + 空合并 +val name = getUser()?.name ?: "Unknown" + +// ✅ requireNotNull 提供有意义的错误信息 +val user = requireNotNull(getUser()) { "User must not be null at this point" } + +// ✅ 提前返回 +fun process(user: User?) { + val nonNullUser = user ?: return + nonNullUser.doSomething() +} +``` + +### lateinit vs nullable vs lazy + +```kotlin +// ❌ lateinit 用于可能为 null 的值(语义不对) +lateinit var optionalConfig: Config // Might never be set + +// ✅ lateinit 用于一定会在使用前初始化的值 +class MyActivity : AppCompatActivity() { + lateinit var binding: ActivityMainBinding // Set in onCreate + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(layoutInflater) + setContentView(binding.root) + } +} + +// ✅ nullable + lateinit 取决于初始化时机 +// lateinit: 生命周期保证在使用前初始化 +// nullable: 不确定是否初始化,需要 null 检查 +// lazy: 确定在首次访问时初始化 + +class MyViewModel(private val repo: Repository) : ViewModel() { + // lazy: 首次访问时初始化,线程安全 + val expensiveObject by lazy { ExpensiveObject(repo) } + + // nullable: 可能不会初始化 + var cachedData: Data? = null + private set +} +``` + +### Java 互操作:平台类型泄漏 + +```kotlin +// ❌ Java 返回平台类型(可能 null),Kotlin 当作非空使用 +// Java: +// public User getUser() { return null; } +val name: String = javaService.getUser().name // NPE! + +// ✅ 使用可空类型接收 Java 返回值 +val user: User? = javaService.getUser() +val name = user?.name ?: "Unknown" + +// ✅ 在 Kotlin 侧包装 Java API,提供安全的类型 +class SafeUserService(private val delegate: JavaUserService) { + fun getUser(): User? = delegate.getUser() // Explicitly nullable +} +``` + +--- + +## 内存泄漏 + +### 避免在长生命周期协程中捕获 Context/View + +```kotlin +// ❌ 协程捕获了 Activity Context,Activity 销毁后无法回收 +class MyActivity : AppCompatActivity() { + fun loadData() { + // Leaking Activity via coroutine + GlobalScope.launch { + val data = repo.fetchData() + // 'this' (Activity) is captured + binding.textView.text = data // Activity leaked! + } + } +} + +// ✅ 使用 viewModelScope + 生命周期感知 +class MyViewModel(private val repo: Repository) : ViewModel() { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun loadData() { + viewModelScope.launch { + val data = repo.fetchData() + _uiState.value = UiState.Success(data) // No Activity reference + } + } +} +``` + +### 注销监听器 + +```kotlin +// ❌ 注册监听器但从不注销 +class MyFragment : Fragment() { + private val sensorListener = object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { } + override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) { } + } + + override fun onResume() { + super.onResume() + sensorManager.registerListener(sensorListener, sensor, SensorManager.SENSOR_DELAY_UI) + // Never unregistered! + } +} + +// ✅ 在 onPause/onDestroyView 中注销 +override fun onResume() { + super.onResume() + sensorManager.registerListener(sensorListener, sensor, SensorManager.SENSOR_DELAY_UI) +} + +override fun onPause() { + super.onPause() + sensorManager.unregisterListener(sensorListener) +} +``` + +### 取消自定义 CoroutineScope + +```kotlin +// ❌ 创建 CoroutineScope 但从不取消 +class MyManager(private val scope: CoroutineScope) { + private val job = SupervisorJob() + private val managerScope = scope + job + Dispatchers.IO + + fun start() { + managerScope.launch { + while (isActive) { + pollServer() + delay(5000) + } + } + } + // Never cancelled! job lives forever. +} + +// ✅ 提供关闭方法并取消 Job +class MyManager(private val scope: CoroutineScope) { + private val job = SupervisorJob() + private val managerScope = scope + job + Dispatchers.IO + + fun start() { + managerScope.launch { + while (isActive) { + pollServer() + delay(5000) + } + } + } + + fun shutdown() { + job.cancel() + } +} + +// ✅ ViewModel 里直接用内置的 viewModelScope,不用自己管生命周期 +class MyViewModel : ViewModel() { + private val scope = viewModelScope + Dispatchers.IO + // Automatically cancelled when ViewModel is cleared +} +``` + +--- + +## 架构:ViewModel 与 Repository + +### ViewModel 不暴露可变状态 + +```kotlin +// ❌ 直接暴露 MutableStateFlow,外部可以随意修改 +class MyViewModel : ViewModel() { + val uiState = MutableStateFlow(UiState.Loading) // Mutable! + + fun load() { + viewModelScope.launch { + uiState.value = UiState.Success(repo.fetchData()) + } + } +} + +// ✅ 暴露不可变接口,内部持有可变版本 +class MyViewModel(private val repo: Repository) : ViewModel() { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + fun load() { + viewModelScope.launch { + _uiState.value = UiState.Success(repo.fetchData()) + } + } +} +``` + +### 业务逻辑下沉到 Repository + +```kotlin +// ❌ ViewModel 中包含数据处理和业务规则逻辑 +class UserViewModel(private val api: Api) : ViewModel() { + private val _users = MutableStateFlow>(emptyList()) + val users: StateFlow> = _users.asStateFlow() + + fun loadUsers() { + viewModelScope.launch { + val raw = api.getUsers() + val filtered = raw.filter { it.isActive } + val sorted = filtered.sortedBy { it.name.lowercase() } + val enriched = sorted.map { user -> + user.copy(displayName = "${user.firstName} ${user.lastName}") + } + _users.value = enriched + } + } +} + +// ✅ ViewModel 只做状态管理,逻辑下沉到 Repository +class UserRepository(private val api: Api) { + suspend fun getActiveUsersSorted(): List { + return api.getUsers() + .filter { it.isActive } + .sortedBy { it.name.lowercase() } + .map { it.copy(displayName = "${it.firstName} ${it.lastName}") } + } +} + +class UserViewModel(private val repo: UserRepository) : ViewModel() { + private val _users = MutableStateFlow>(emptyList()) + val users: StateFlow> = _users.asStateFlow() + + fun loadUsers() { + viewModelScope.launch { + _users.value = repo.getActiveUsersSorted() + } + } +} +``` + +### 单一数据源(Offline-First) + +```kotlin +// ❌ ViewModel 直接从网络获取,无缓存,离线不可用 +class MyViewModel(private val api: Api) : ViewModel() { + fun load() { + viewModelScope.launch { + _uiState.value = UiState.Success(api.fetchData()) + } + } +} + +// ✅ Repository 作为单一数据源,先展示本地缓存再更新网络数据 +class MyRepository( + private val api: Api, + private val dao: DataDao, +) { + val data: Flow> = dao.getAll() + .map { entities -> entities.map { it.toDomain() } } + + suspend fun refresh() { + val remote = api.fetchData() + dao.replaceAll(remote.map { it.toEntity() }) + } +} + +class MyViewModel(private val repo: MyRepository) : ViewModel() { + val uiState = repo.data.map { UiState.Success(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading) + + fun refresh() { + viewModelScope.launch { repo.refresh() } + } +} +``` + +### Use Case 用于复杂业务逻辑 + +```kotlin +// ❌ Repository 方法名变成动词短语,职责膨胀 +class OrderRepository { + suspend fun validateAndSubmitOrder(order: Order) { } + suspend fun calculateOrderTotalWithDiscounts(order: Order): Money { } + suspend fun checkInventoryAndReserve(items: List) { } +} + +// ✅ 使用 Use Case 封装复杂业务逻辑,Repository 只做数据访问 +class SubmitOrderUseCase( + private val orderRepo: OrderRepository, + private val inventoryRepo: InventoryRepository, + private val paymentRepo: PaymentRepository, +) { + suspend operator fun invoke(order: Order): Result { + val validated = order.validate() + inventoryRepo.reserve(validated.items) + val total = CalculateOrderTotalUseCase().invoke(validated) + return paymentRepo.charge(total).map { confirmation -> + orderRepo.save(validated.copy(status = OrderStatus.CONFIRMED)) + confirmation + } + } +} + +class OrderRepository { + suspend fun save(order: Order) { } + suspend fun getById(id: String): Order? { } + fun observeOrders(): Flow> { } +} +``` + +--- + +## 密封类与状态管理 + +### UI 状态建模:让不可能的状态无法表达 + +```kotlin +// ❌ 用 nullable 组合表示状态,可能产生无效组合 +data class UiState( + val isLoading: Boolean = false, + val data: List? = null, + val error: String? = null, +) +// Invalid: isLoading=true AND error != null +// Invalid: data != null AND error != null + +// ✅ 使用密封类建模,每种状态互斥 +sealed interface UiState { + data object Loading : UiState + data class Success(val data: List) : UiState + data class Error(val message: String, val cause: Throwable? = null) : UiState +} + +class MyViewModel(private val repo: Repository) : ViewModel() { + private val _uiState = MutableStateFlow(UiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() +} + +// ✅ Compose 中 exhaustive when +@Composable +fun MyScreen(viewModel: MyViewModel) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + when (state) { + is UiState.Loading -> CircularProgressIndicator() + is UiState.Success -> DataList((state as UiState.Success).data) + is UiState.Error -> ErrorMessage((state as UiState.Error).message) + } +} +``` + +### 导航事件建模 + +```kotlin +// ❌ 用枚举或字符串表示导航事件,无法携带参数 +sealed class NavEvent { + object ToDetail : NavEvent() + object ToSettings : NavEvent() +} +// How to pass orderId to ToDetail? + +// ✅ 密封类携带类型安全参数 +sealed interface NavEvent { + data class ToDetail(val orderId: String) : NavEvent + data class ToSettings(val tab: SettingsTab) : NavEvent + data class ToProfile(val userId: String, val mode: ProfileMode) : NavEvent +} + +// ✅ 处理导航事件 +navController.handleNavEvent { event -> + when (event) { + is NavEvent.ToDetail -> navController.navigate(DetailRoute(event.orderId)) + is NavEvent.ToSettings -> navController.navigate(SettingsRoute(event.tab)) + is NavEvent.ToProfile -> navController.navigate(ProfileRoute(event.userId, event.mode)) + } +} +``` + +### 网络结果包装 + +```kotlin +// ❌ 用 Result? 或 nullable 表示网络结果,丢失错误信息 +suspend fun fetchUser(id: String): User? { + return try { + api.getUser(id) + } catch (e: Exception) { + null // What went wrong? + } +} + +// ✅ 使用密封类包装网络结果 +sealed interface NetworkResult { + data class Success(val data: T) : NetworkResult + data class Error(val code: Int, val message: String) : NetworkResult + data class Exception(val cause: Throwable) : NetworkResult +} + +suspend fun fetchUser(id: String): NetworkResult { + return try { + val response = api.getUser(id) + if (response.isSuccessful) { + NetworkResult.Success(response.body()!!) + } else { + NetworkResult.Error(response.code(), response.message()) + } + } catch (e: Exception) { + NetworkResult.Exception(e) + } +} + +// ✅ 在 ViewModel 中映射为 UI 状态 +fun loadUser(id: String) { + viewModelScope.launch { + when (val result = repo.fetchUser(id)) { + is NetworkResult.Success -> _uiState.value = UiState.Success(result.data) + is NetworkResult.Error -> _uiState.value = UiState.Error("Server error: ${result.code}") + is NetworkResult.Exception -> _uiState.value = UiState.Error(result.cause.message ?: "Unknown") + } + } +} +``` + +--- + +## Review Checklist + +### 协程 + +- [ ] 不使用 `GlobalScope`,使用 `viewModelScope` / `lifecycleScope` +- [ ] `CancellationException` 被正确重新抛出,未被吞掉 +- [ ] CPU 密集任务使用 `Dispatchers.Default`,I/O 操作使用 `Dispatchers.IO` +- [ ] 长时间运行的 CPU 任务定期调用 `ensureActive()` 或 `yield()` +- [ ] 阻塞调用使用 `runInterruptible` 包装 +- [ ] 不使用 `Job()` 破坏父子协程关系 +- [ ] `NonCancellable` 仅用于 `finally` 块中的清理操作 +- [ ] 不需要返回值用 `launch`,需要并行结果用 `async` + +### Flow + +- [ ] 理解冷流(`flow {}`)与热流(`StateFlow`/`SharedFlow`)的区别 +- [ ] 不在 `flow {}` builder 中使用 `withContext`,使用 `flowOn` 操作符 +- [ ] `collect` 配合 `repeatOnLifecycle` 或 `collectAsStateWithLifecycle` 使用 +- [ ] 异常处理使用 `.catch` 操作符而非 `try-catch` 包裹 `collect` +- [ ] UI 状态用 `StateFlow`,一次性事件用 `SharedFlow` 或 `Channel` + +### Compose + +- [ ] Composable 参数使用稳定类型,避免不必要重组 +- [ ] Lambda 参数使用 `remember` 包装,避免每次重组创建新实例 +- [ ] 派生状态使用 `derivedStateOf` 避免高频率重组 +- [ ] 副作用使用 `LaunchedEffect` / `SideEffect`,不在函数体中直接调用 +- [ ] 状态正确提升(state hoisting),Composable 无状态且可复用 + +### 空安全 + +- [ ] 不滥用非空断言 `!!`,使用安全调用 `?.` 或空合并 `?:` +- [ ] `lateinit` 仅用于生命周期保证初始化的属性 +- [ ] Java 互操作返回值使用可空类型接收 +- [ ] `lazy` 用于首次访问时初始化的昂贵对象 + +### 内存泄漏 + +- [ ] 协程不捕获 `Context` / `View` 等短生命周期对象 +- [ ] 监听器在 `onPause` / `onDestroyView` 中正确注销 +- [ ] 自定义 `CoroutineScope` 提供取消机制 +- [ ] 单例不持有 `Activity` / `Fragment` 引用 + +### 架构 + +- [ ] ViewModel 不暴露 `MutableStateFlow` / `MutableLiveData`,使用不可变接口 +- [ ] 业务逻辑下沉到 Repository / Use Case,ViewModel 只做状态管理 +- [ ] 实现 offline-first:Repository 作为单一数据源 +- [ ] 复杂业务逻辑封装为独立的 Use Case 类 + +### 密封类与状态 + +- [ ] UI 状态使用密封类建模,让不可能的状态无法表达 +- [ ] 导航事件使用密封类携带类型安全参数 +- [ ] 网络请求结果使用密封类包装,不丢失错误信息 +- [ ] `when` 表达式覆盖所有分支(exhaustive check) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/nestjs.md b/examples/skills_code_review_agent/skills/code-review/reference/nestjs.md new file mode 100644 index 000000000..04ba6c069 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/nestjs.md @@ -0,0 +1,593 @@ +# NestJS Code Review Guide + +> NestJS 代码审查指南,覆盖依赖注入与分层架构、模块组织、Guard/Interceptor/Pipe、DTO 验证、错误处理、循环依赖及测试模式等核心主题。 + +## 目录 + +- [依赖注入与分层架构](#依赖注入与分层架构) +- [模块组织](#模块组织) +- [Guard / Interceptor / Pipe](#guard--interceptor--pipe) +- [验证模式 (DTO)](#验证模式-dto) +- [错误处理](#错误处理) +- [循环依赖](#循环依赖) +- [测试模式](#测试模式) +- [Review Checklist](#review-checklist) + +--- + +## 依赖注入与分层架构 + +### 三层架构:Controller → Service → Repository + +```typescript +// ❌ ORM 直接注入 Controller,跳过 Service 层 +@Controller('users') +export class UsersController { + constructor(private readonly prisma: PrismaService) {} + + @Get() + findAll() { + return this.prisma.user.findMany(); + } +} + +// ✅ Controller → Service → Repository +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + findAll() { + return this.usersService.findAll(); + } +} + +@Injectable() +export class UsersService { + constructor(private readonly usersRepo: UsersRepository) {} + + findAll() { + return this.usersRepo.findAll(); + } +} +``` + +### Repository 之间不应互相注入 + +```typescript +// ❌ Repository 导入另一个 Repository——编排逻辑属于 Service +@Injectable() +export class OrdersRepository { + constructor(private readonly usersRepository: UsersRepository) {} +} + +// ✅ 跨 Repository 编排在 Service 中完成 +@Injectable() +export class OrdersService { + constructor( + private readonly ordersRepo: OrdersRepository, + private readonly usersRepo: UsersRepository, + ) {} +} +``` + +### God Service:依赖超过 8 个时拆分 + +```typescript +// ❌ 9 个依赖的巨型 Service +@Injectable() +export class OrdersService { + constructor( + private readonly ordersRepo: OrdersRepository, + private readonly usersRepo: UsersRepository, + private readonly productsRepo: ProductsRepository, + private readonly paymentsService: PaymentsService, + private readonly mailerService: MailerService, + private readonly inventoryService: InventoryService, + private readonly discountService: DiscountService, + private readonly taxService: TaxService, + private readonly auditService: AuditService, + ) {} +} + +// ✅ 拆分为 Use-Case Service(一个文件一个操作) +@Injectable() +export class CreateOrderService { + constructor( + private readonly ordersRepo: OrdersRepository, + private readonly paymentsService: PaymentsService, + ) {} + + async execute(dto: CreateOrderDto) { /* ... */ } +} +``` + +### Symbol Token 实现依赖反转 + +```typescript +// ❌ 直接依赖具体实现——测试时无法替换 +@Injectable() +export class UsersService { + constructor(private readonly repo: TypeOrmUserRepository) {} +} + +// ✅ 接口 + Symbol Token——可替换为内存实现 +export const USER_REPOSITORY = Symbol('USER_REPOSITORY'); + +export interface UserRepository { + findAll(): Promise; + findById(id: string): Promise; +} + +// module: +{ + provide: USER_REPOSITORY, + useClass: TypeOrmUserRepository, +} + +// service: +@Injectable() +export class UsersService { + constructor(@Inject(USER_REPOSITORY) private readonly repo: UserRepository) {} +} +``` + +--- + +## 模块组织 + +### 推荐四层结构 + +``` +src/ + common/ ← 全局技术基础设施(Guards、Filters、Interceptors、Decorators) + core/ ← 内部基础设施(Config、Database、Queue 配置) + integrations/ ← 外部服务封装(Mailer、Storage、Stripe、SMS) + modules/ ← 按领域组织的业务逻辑 + [feature]/ + dtos/ + repositories/ + services/ + internal/ ← 模块内共享 Service + use-cases/ ← 一个文件 = 一个操作 + types/ + [feature].controller.ts + [feature].module.ts +``` + +### Domain 必须框架无关 + +```typescript +// ❌ Domain Entity 依赖 NestJS——不可独立测试 +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class User { + constructor(private readonly email: string) {} +} + +// ✅ Domain 是纯类,无框架装饰器 +export class User { + private constructor(private readonly email: string) {} + + static create(email: string): User { + return new User(email); + } +} +``` + +### 关键规则 + +- `common/` 必须 **不涉及业务**——如果需要知道"订单",它不属于这里 +- `integrations/` 封装每个外部服务;换 SendGrid → AWS SES 只改一个目录 +- 使用 **Use-Case Service**(一个文件一个操作)而非 15 个方法的巨型 `XxxService` + +--- + +## Guard / Interceptor / Pipe + +### 业务逻辑不应放在 Guard 中 + +```typescript +// ❌ Guard 中查询数据库 + 业务判断 +@Injectable() +export class OrderOwnershipGuard implements CanActivate { + constructor(private readonly prisma: PrismaService) {} + + async canActivate(context: ExecutionContext): Promise { + const req = context.switchToHttp().getRequest(); + const order = await this.prisma.order.findUnique({ + where: { id: req.params.id }, + }); + if (order.userId !== req.user.id) { + return false; // 数据获取 + 业务规则判断都在 Guard 里 + } + return true; + } +} + +// ✅ Guard 只做授权检查(角色/权限) +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private readonly reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride('roles', [ + context.getHandler(), + context.getClass(), + ]); + if (!requiredRoles) return true; + const { user } = context.switchToHttp().getRequest(); + return requiredRoles.some((role) => user.roles?.includes(role)); + } +} +``` + +### Interceptor 只用于横切关注点 + +```typescript +// ❌ Interceptor 中执行业务逻辑 +@Injectable() +export class PricingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler) { + // 计算折扣——这不是横切关注点! + return next.handle().pipe(map(data => applyDiscount(data))); + } +} + +// ✅ Interceptor 用于日志、缓存、响应转换、计时 +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler) { + const now = Date.now(); + const req = context.switchToHttp().getRequest(); + return next.handle().pipe( + tap(() => console.log(`${req.method} ${req.url} - ${Date.now() - now}ms`)), + ); + } +} +``` + +### 全局 ValidationPipe 必须配置 whitelist + +```typescript +// ❌ 没有 whitelist——请求体中的额外属性直接传入 +async function bootstrap() { + const app = await NestFactory.create(AppModule); + await app.listen(3000); +} + +// ✅ 全局 ValidationPipe + whitelist 过滤未知属性 +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.listen(3000); +} +``` + +--- + +## 验证模式 (DTO) + +### @ValidateNested() 必须搭配 @Type() + +```typescript +// ❌ 只有 @ValidateNested——嵌套对象验证被静默跳过! +export class CreateOrderDto { + @ValidateNested() + shipping: AddressDto; +} + +// ✅ @ValidateNested + @Type 配对使用 +import { Type } from 'class-transformer'; + +export class CreateOrderDto { + @ValidateNested() + @Type(() => AddressDto) + shipping: AddressDto; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => OrderItemDto) + items: OrderItemDto[]; +} +``` + +### 禁止裸 any Body + +```typescript +// ❌ 没有 DTO——无验证、无类型安全、无 Swagger 文档 +@Post() +create(@Body() body: any) { + return this.service.create(body); +} + +// ✅ 为每个操作创建 DTO +export class CreateUserDto { + @IsEmail() + email: string; + + @IsString() + @MinLength(2) + @MaxLength(100) + name: string; +} + +@Post() +create(@Body() dto: CreateUserDto) { + return this.service.create(dto); +} +``` + +### Create 和 Update 应使用不同 DTO + +```typescript +// ❌ PATCH 也要求所有字段——不合理的 API 设计 +@Patch(':id') +update(@Body() dto: CreateUserDto) { /* all fields required */ } + +// ✅ Update 使用 PartialType +export class UpdateUserDto extends PartialType(CreateUserDto) {} + +@Patch(':id') +update(@Body() dto: UpdateUserDto) { /* all fields optional */ } +``` + +### 可选嵌套对象 + +```typescript +// ❌ 可选嵌套对象缺少 @IsOptional +export class UpdateOrderDto { + @ValidateNested() + @Type(() => AddressDto) + shipping?: AddressDto; // undefined 时仍尝试验证 +} + +// ✅ @IsOptional + @ValidateNested + @Type +export class UpdateOrderDto { + @IsOptional() + @ValidateNested() + @Type(() => AddressDto) + shipping?: AddressDto; +} +``` + +--- + +## 错误处理 + +### 禁止吞掉错误 + +```typescript +// ❌ catch { return null }——隐藏了问题,调用者无法区分"不存在"和"出错了" +async findOne(id: string) { + try { + return await this.repo.findById(id); + } catch (e) { + return null; + } +} + +// ✅ 抛出有意义的异常 +async findOne(id: string): Promise { + const user = await this.repo.findById(id); + if (!user) { + throw new NotFoundException(`User ${id} not found`); + } + return user; +} +``` + +### 使用内置异常类 + +```typescript +// ❌ 手动构造 HTTP 响应 +throw new HttpException('Bad request', 400); + +// ✅ 使用语义化的内置异常 +throw new BadRequestException('Invalid email format'); +throw new NotFoundException('User not found'); +throw new ConflictException('Email already taken'); +throw new ForbiddenException('Insufficient permissions'); +throw new UnauthorizedException('Invalid credentials'); +``` + +### 自定义异常过滤器 + +```typescript +// ✅ 全局异常过滤器——统一响应格式 +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + + this.logger.error(`${request.method} ${request.url} - ${status}`, exception instanceof Error ? exception.stack : ''); + + response.status(status).json({ + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} +``` + +--- + +## 循环依赖 + +### 模块间循环引用 + +```typescript +// ❌ Module A ↔ Module B +@Module({ imports: [UsersModule] }) +export class OrdersModule {} + +@Module({ imports: [OrdersModule] }) +export class UsersModule {} + +// ✅ 提取共享逻辑到第三个模块 +@Module({ + providers: [SharedService], + exports: [SharedService], +}) +export class SharedModule {} + +@Module({ imports: [SharedModule] }) +export class OrdersModule {} + +@Module({ imports: [SharedModule] }) +export class UsersModule {} +``` + +### forwardRef 是最后手段 + +```typescript +// ⚠️ forwardRef 表示设计有问题——优先重新设计 +@Module({ + imports: [forwardRef(() => UsersModule)], +}) +export class OrdersModule {} + +// ✅ 重新设计消除循环: +// 1. 提取共享模块 +// 2. 使用事件驱动(EventEmitter)代替直接调用 +// 3. 将共享逻辑提升到上层 Service +``` + +--- + +## 测试模式 + +### Use-Case 可脱离 NestJS 测试 + +```typescript +// ✅ 无需 NestFactory——直接 new +describe('CreateUserHandler', () => { + let handler: CreateUserHandler; + let repo: InMemoryUserRepository; + + beforeEach(() => { + repo = new InMemoryUserRepository(); + handler = new CreateUserHandler(repo); + }); + + it('creates a user', async () => { + const id = await handler.execute( + new CreateUserCommand('user@example.com', 'Alice'), + ); + expect(id).toBeDefined(); + }); + + it('rejects duplicate email', async () => { + await handler.execute(new CreateUserCommand('user@example.com', 'Alice')); + await expect( + handler.execute(new CreateUserCommand('user@example.com', 'Bob')), + ).rejects.toThrow('already exists'); + }); +}); +``` + +### E2E 测试应配置与生产一致的 Pipes + +```typescript +describe('UsersController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + // 必须与 main.ts 中相同的全局配置 + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + }); + + it('/POST users - valid', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ email: 'test@test.com', name: 'Test' }) + .expect(201); + }); + + it('/POST users - extra fields rejected', () => { + return request(app.getHttpServer()) + .post('/users') + .send({ email: 'test@test.com', name: 'Test', role: 'admin' }) + .expect(400); + }); +}); +``` + +--- + +## Review Checklist + +### 分层架构 + +- [ ] ORM/Prisma 未直接注入 Controller +- [ ] 业务逻辑不在 Controller 中 +- [ ] Repository 之间无互相注入 +- [ ] Service 依赖数 ≤ 8(超出则拆分为 Use-Case) + +### 依赖注入 + +- [ ] 接口 + Symbol Token 用于可替换的依赖 +- [ ] 无 `forwardRef()`(如有,需设计文档说明原因) +- [ ] Scoped 服务未注入到 Singleton 中 + +### 验证 + +- [ ] 每个 `@ValidateNested()` 都有对应的 `@Type()` +- [ ] 全局 `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` 已配置 +- [ ] 无 `@Body() body: any`——必须使用 DTO +- [ ] Create 和 Update 使用不同 DTO(`PartialType`) +- [ ] 数组验证使用 `{ each: true }` +- [ ] 可选嵌套对象使用 `@IsOptional()` + `@ValidateNested()` + `@Type()` + +### Guard / Interceptor / Pipe + +- [ ] Guard 只做授权检查,不查询数据库 +- [ ] Interceptor 只用于横切关注点(日志、缓存、响应转换) +- [ ] 业务规则在 Service 中 + +### 错误处理 + +- [ ] 无 `catch { return null }`——抛出有意义的异常 +- [ ] 使用 NestJS 内置异常类 +- [ ] 自定义异常过滤器在 `common/filters/` 中 + +### 模块 + +- [ ] 无循环模块引用 +- [ ] Domain Entity 无框架装饰器(`@Injectable` 等) +- [ ] 外部服务调用在 `integrations/` 中 + +### 测试 + +- [ ] Use-Case Service 可脱离 NestJS 测试 +- [ ] E2E 测试配置与生产一致的全局 Pipes/Guards +- [ ] Domain Entity 零框架依赖 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/performance-review-guide.md b/examples/skills_code_review_agent/skills/code-review/reference/performance-review-guide.md new file mode 100644 index 000000000..58aebee99 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/performance-review-guide.md @@ -0,0 +1,816 @@ +# Performance Review Guide + +性能审查指南,覆盖前端、后端、数据库、算法复杂度和 API 性能。 + +## 目录 + +- [前端性能 (Core Web Vitals)](#前端性能-core-web-vitals) +- [JavaScript 性能](#javascript-性能) +- [内存管理](#内存管理) +- [数据库性能](#数据库性能) +- [API 性能](#api-性能) +- [算法复杂度](#算法复杂度) +- [性能审查清单](#性能审查清单) + +--- + +## 前端性能 (Core Web Vitals) + +### 2024 核心指标 + +| 指标 | 全称 | 目标值 | 含义 | +|------|------|--------|------| +| **LCP** | Largest Contentful Paint | ≤ 2.5s | 最大内容绘制时间 | +| **INP** | Interaction to Next Paint | ≤ 200ms | 交互响应时间(2024 年替代 FID)| +| **CLS** | Cumulative Layout Shift | ≤ 0.1 | 累积布局偏移 | +| **FCP** | First Contentful Paint | ≤ 1.8s | 首次内容绘制 | +| **TBT** | Total Blocking Time | ≤ 200ms | 主线程阻塞时间 | + +### LCP 优化检查 + +```javascript +// ❌ LCP 图片懒加载 - 延迟关键内容 + + +// ✅ LCP 图片立即加载 + + +// ❌ 未优化的图片格式 + // PNG 文件过大 + +// ✅ 现代图片格式 + 响应式 + + + + Hero + +``` + +**审查要点:** +- [ ] LCP 元素是否设置 `fetchpriority="high"`? +- [ ] 是否使用 WebP/AVIF 格式? +- [ ] 是否有服务端渲染或静态生成? +- [ ] CDN 是否配置正确? + +### FCP 优化检查 + +```html + + + + + + + + +@font-face { + font-family: 'CustomFont'; + src: url('font.woff2'); +} + + +@font-face { + font-family: 'CustomFont'; + src: url('font.woff2'); + font-display: swap; /* 先用系统字体,加载后切换 */ +} +``` + +### INP 优化检查 + +```javascript +// ❌ 长任务阻塞主线程 +button.addEventListener('click', () => { + // 耗时 500ms 的同步操作 + processLargeData(data); + updateUI(); +}); + +// ✅ 拆分长任务 +button.addEventListener('click', async () => { + // 让出主线程 + await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0)); + + // 分批处理 + for (const chunk of chunks) { + processChunk(chunk); + await scheduler.yield?.(); + } + updateUI(); +}); + +// ✅ 使用 Web Worker 处理复杂计算 +const worker = new Worker('heavy-computation.js'); +worker.postMessage(data); +worker.onmessage = (e) => updateUI(e.data); +``` + +### CLS 优化检查 + +```css +/* ❌ 未指定尺寸的媒体 */ +img { width: 100%; } + +/* ✅ 预留空间 */ +img { + width: 100%; + aspect-ratio: 16 / 9; +} + +/* ❌ 动态插入内容导致布局偏移 */ +.ad-container { } + +/* ✅ 预留固定高度 */ +.ad-container { + min-height: 250px; +} +``` + +**CLS 审查清单:** +- [ ] 图片/视频是否有 width/height 或 aspect-ratio? +- [ ] 字体加载是否使用 `font-display: swap`? +- [ ] 动态内容是否预留空间? +- [ ] 是否避免在现有内容上方插入内容? + +--- + +## JavaScript 性能 + +### 代码分割与懒加载 + +```javascript +// ❌ 一次性加载所有代码 +import { HeavyChart } from './charts'; +import { PDFExporter } from './pdf'; +import { AdminPanel } from './admin'; + +// ✅ 按需加载 +const HeavyChart = lazy(() => import('./charts')); +const PDFExporter = lazy(() => import('./pdf')); + +// ✅ 路由级代码分割 +const routes = [ + { + path: '/dashboard', + component: lazy(() => import('./pages/Dashboard')), + }, + { + path: '/admin', + component: lazy(() => import('./pages/Admin')), + }, +]; +``` + +### Bundle 体积优化 + +```javascript +// ❌ 导入整个库 +import _ from 'lodash'; +import moment from 'moment'; + +// ✅ 按需导入 +import debounce from 'lodash/debounce'; +import { format } from 'date-fns'; + +// ❌ 未使用 Tree Shaking +export default { + fn1() {}, + fn2() {}, // 未使用但被打包 +}; + +// ✅ 命名导出支持 Tree Shaking +export function fn1() {} +export function fn2() {} +``` + +**Bundle 审查清单:** +- [ ] 是否使用动态 import() 进行代码分割? +- [ ] 大型库是否按需导入? +- [ ] 是否分析过 bundle 大小?(webpack-bundle-analyzer) +- [ ] 是否有未使用的依赖? + +### 列表渲染优化 + +```javascript +// ❌ 渲染大列表 +function List({ items }) { + return ( +
    + {items.map(item =>
  • {item.name}
  • )} +
+ ); // 10000 条数据 = 10000 个 DOM 节点 +} + +// ✅ 虚拟列表 - 只渲染可见项 +import { FixedSizeList } from 'react-window'; + +function VirtualList({ items }) { + return ( + + {({ index, style }) => ( +
{items[index].name}
+ )} +
+ ); +} +``` + +**大数据审查要点:** +- [ ] 列表超过 100 项是否使用虚拟滚动? +- [ ] 表格是否支持分页或虚拟化? +- [ ] 是否有不必要的全量渲染? + +--- + +## 内存管理 + +### 常见内存泄漏 + +#### 1. 未清理的事件监听 + +```javascript +// ❌ 组件卸载后事件仍在监听 +useEffect(() => { + window.addEventListener('resize', handleResize); +}, []); + +// ✅ 清理事件监听 +useEffect(() => { + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); +}, []); +``` + +#### 2. 未清理的定时器 + +```javascript +// ❌ 定时器未清理 +useEffect(() => { + setInterval(fetchData, 5000); +}, []); + +// ✅ 清理定时器 +useEffect(() => { + const timer = setInterval(fetchData, 5000); + return () => clearInterval(timer); +}, []); +``` + +#### 3. 闭包引用 + +```javascript +// ❌ 闭包持有大对象引用 +function createHandler() { + const largeData = new Array(1000000).fill('x'); + + return function handler() { + // largeData 被闭包引用,无法被回收 + console.log(largeData.length); + }; +} + +// ✅ 只保留必要数据 +function createHandler() { + const largeData = new Array(1000000).fill('x'); + const length = largeData.length; // 只保留需要的值 + + return function handler() { + console.log(length); + }; +} +``` + +#### 4. 未清理的订阅 + +```javascript +// ❌ WebSocket/EventSource 未关闭 +useEffect(() => { + const ws = new WebSocket('wss://...'); + ws.onmessage = handleMessage; +}, []); + +// ✅ 清理连接 +useEffect(() => { + const ws = new WebSocket('wss://...'); + ws.onmessage = handleMessage; + return () => ws.close(); +}, []); +``` + +### 内存审查清单 + +```markdown +- [ ] useEffect 是否都有清理函数? +- [ ] 事件监听是否在组件卸载时移除? +- [ ] 定时器是否被清理? +- [ ] WebSocket/SSE 连接是否关闭? +- [ ] 大对象是否及时释放? +- [ ] 是否有全局变量累积数据? +``` + +### 检测工具 + +| 工具 | 用途 | +|------|------| +| Chrome DevTools Memory | 堆快照分析 | +| MemLab (Meta) | 自动化内存泄漏检测 | +| Performance Monitor | 实时内存监控 | + +--- + +## 数据库性能 + +### N+1 查询问题 + +```python +# ❌ N+1 问题 - 1 + N 次查询 +users = User.objects.all() # 1 次查询 +for user in users: + print(user.profile.bio) # N 次查询(每个用户一次) + +# ✅ Eager Loading - 2 次查询 +users = User.objects.select_related('profile').all() +for user in users: + print(user.profile.bio) # 无额外查询 + +# ✅ 多对多关系用 prefetch_related +posts = Post.objects.prefetch_related('tags').all() +``` + +```javascript +// TypeORM 示例 +// ❌ N+1 问题 +const users = await userRepository.find(); +for (const user of users) { + const posts = await user.posts; // 每次循环都查询 +} + +// ✅ Eager Loading +const users = await userRepository.find({ + relations: ['posts'], +}); +``` + +### 索引优化 + +```sql +-- ❌ 全表扫描 +SELECT * FROM orders WHERE status = 'pending'; + +-- ✅ 添加索引 +CREATE INDEX idx_orders_status ON orders(status); + +-- ❌ 索引失效:函数操作 +SELECT * FROM users WHERE YEAR(created_at) = 2024; + +-- ✅ 范围查询可用索引 +SELECT * FROM users +WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; + +-- ❌ 索引失效:LIKE 前缀通配符 +SELECT * FROM products WHERE name LIKE '%phone%'; + +-- ✅ 前缀匹配可用索引 +SELECT * FROM products WHERE name LIKE 'phone%'; +``` + +### 查询优化 + +```sql +-- ❌ SELECT * 获取不需要的列 +SELECT * FROM users WHERE id = 1; + +-- ✅ 只查询需要的列 +SELECT id, name, email FROM users WHERE id = 1; + +-- ❌ 大表无 LIMIT +SELECT * FROM logs WHERE type = 'error'; + +-- ✅ 分页查询 +SELECT * FROM logs WHERE type = 'error' LIMIT 100 OFFSET 0; + +-- ❌ 在循环中执行查询 +for id in user_ids: + cursor.execute("SELECT * FROM users WHERE id = %s", (id,)) + +-- ✅ 批量查询 +cursor.execute("SELECT * FROM users WHERE id IN %s", (tuple(user_ids),)) +``` + +### 数据库审查清单 + +```markdown +🔴 必须检查: +- [ ] 是否存在 N+1 查询? +- [ ] WHERE 子句列是否有索引? +- [ ] 是否避免了 SELECT *? +- [ ] 大表查询是否有 LIMIT? + +🟡 建议检查: +- [ ] 是否使用了 EXPLAIN 分析查询计划? +- [ ] 复合索引列顺序是否正确? +- [ ] 是否有未使用的索引? +- [ ] 是否有慢查询日志监控? +``` + +--- + +## API 性能 + +### 分页实现 + +```javascript +// ❌ 返回全部数据 +app.get('/users', async (req, res) => { + const users = await User.findAll(); // 可能返回 100000 条 + res.json(users); +}); + +// ✅ 分页 + 限制最大数量 +app.get('/users', async (req, res) => { + const page = parseInt(req.query.page) || 1; + const limit = Math.min(parseInt(req.query.limit) || 20, 100); // 最大 100 + const offset = (page - 1) * limit; + + const { rows, count } = await User.findAndCountAll({ + limit, + offset, + order: [['id', 'ASC']], + }); + + res.json({ + data: rows, + pagination: { + page, + limit, + total: count, + totalPages: Math.ceil(count / limit), + }, + }); +}); +``` + +### 缓存策略 + +```javascript +// ✅ Redis 缓存示例 +async function getUser(id) { + const cacheKey = `user:${id}`; + + // 1. 检查缓存 + const cached = await redis.get(cacheKey); + if (cached) { + return JSON.parse(cached); + } + + // 2. 查询数据库 + const user = await db.users.findById(id); + + // 3. 写入缓存(设置过期时间) + await redis.setex(cacheKey, 3600, JSON.stringify(user)); + + return user; +} + +// ✅ HTTP 缓存头 +app.get('/static-data', (req, res) => { + res.set({ + 'Cache-Control': 'public, max-age=86400', // 24 小时 + 'ETag': 'abc123', + }); + res.json(data); +}); +``` + +### 响应压缩 + +```javascript +// ✅ 启用 Gzip/Brotli 压缩 +const compression = require('compression'); +app.use(compression()); + +// ✅ 只返回必要字段 +// 请求: GET /users?fields=id,name,email +app.get('/users', async (req, res) => { + const fields = req.query.fields?.split(',') || ['id', 'name']; + const users = await User.findAll({ + attributes: fields, + }); + res.json(users); +}); +``` + +### 限流保护 + +```javascript +// ✅ 速率限制 +const rateLimit = require('express-rate-limit'); + +const limiter = rateLimit({ + windowMs: 60 * 1000, // 1 分钟 + max: 100, // 最多 100 次请求 + message: { error: 'Too many requests, please try again later.' }, +}); + +app.use('/api/', limiter); +``` + +### API 审查清单 + +```markdown +- [ ] 列表接口是否有分页? +- [ ] 是否限制了每页最大数量? +- [ ] 热点数据是否有缓存? +- [ ] 是否启用了响应压缩? +- [ ] 是否有速率限制? +- [ ] 是否只返回必要字段? +``` + +--- + +## 算法复杂度 + +### 常见复杂度对比 + +| 复杂度 | 名称 | 10 条 | 1000 条 | 100 万条 | 示例 | +|--------|------|-------|---------|----------|------| +| O(1) | 常数 | 1 | 1 | 1 | 哈希查找 | +| O(log n) | 对数 | 3 | 10 | 20 | 二分查找 | +| O(n) | 线性 | 10 | 1000 | 100 万 | 遍历数组 | +| O(n log n) | 线性对数 | 33 | 10000 | 2000 万 | 快速排序 | +| O(n²) | 平方 | 100 | 100 万 | 1 万亿 | 嵌套循环 | +| O(2ⁿ) | 指数 | 1024 | ∞ | ∞ | 递归斐波那契 | + +### 代码审查中的识别 + +```javascript +// ❌ O(n²) - 嵌套循环 +function findDuplicates(arr) { + const duplicates = []; + for (let i = 0; i < arr.length; i++) { + for (let j = i + 1; j < arr.length; j++) { + if (arr[i] === arr[j]) { + duplicates.push(arr[i]); + } + } + } + return duplicates; +} + +// ✅ O(n) - 使用 Set +function findDuplicates(arr) { + const seen = new Set(); + const duplicates = new Set(); + for (const item of arr) { + if (seen.has(item)) { + duplicates.add(item); + } + seen.add(item); + } + return [...duplicates]; +} +``` + +```javascript +// ❌ O(n²) - 每次循环都调用 includes +function removeDuplicates(arr) { + const result = []; + for (const item of arr) { + if (!result.includes(item)) { // includes 是 O(n) + result.push(item); + } + } + return result; +} + +// ✅ O(n) - 使用 Set +function removeDuplicates(arr) { + return [...new Set(arr)]; +} +``` + +```javascript +// ❌ O(n) 查找 - 每次都遍历 +const users = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, ...]; + +function getUser(id) { + return users.find(u => u.id === id); // O(n) +} + +// ✅ O(1) 查找 - 使用 Map +const userMap = new Map(users.map(u => [u.id, u])); + +function getUser(id) { + return userMap.get(id); // O(1) +} +``` + +### 空间复杂度考虑 + +```javascript +// ⚠️ O(n) 空间 - 创建新数组 +const doubled = arr.map(x => x * 2); + +// ✅ O(1) 空间 - 原地修改(如果允许) +for (let i = 0; i < arr.length; i++) { + arr[i] *= 2; +} + +// ⚠️ 递归深度过大可能栈溢出 +function factorial(n) { + if (n <= 1) return 1; + return n * factorial(n - 1); // O(n) 栈空间 +} + +// ✅ 迭代版本 O(1) 空间 +function factorial(n) { + let result = 1; + for (let i = 2; i <= n; i++) { + result *= i; + } + return result; +} +``` + +### 复杂度审查问题 + +```markdown +💡 "这个嵌套循环的复杂度是 O(n²),数据量大时会有性能问题" +🔴 "这里用 Array.includes() 在循环中,整体是 O(n²),建议用 Set" +🟡 "这个递归深度可能导致栈溢出,建议改为迭代或尾递归" +``` + +--- + +## 性能审查清单 + +### 🔴 必须检查(阻塞级) + +**前端:** +- [ ] LCP 图片是否懒加载?(不应该) +- [ ] 是否有 `transition: all`? +- [ ] 是否动画 width/height/top/left? +- [ ] 列表 >100 项是否虚拟化? + +**后端:** +- [ ] 是否存在 N+1 查询? +- [ ] 列表接口是否有分页? +- [ ] 是否有 SELECT * 查大表? + +**通用:** +- [ ] 是否有 O(n²) 或更差的嵌套循环? +- [ ] useEffect/事件监听是否有清理? + +### 🟡 建议检查(重要级) + +**前端:** +- [ ] 是否使用代码分割? +- [ ] 大型库是否按需导入? +- [ ] 图片是否使用 WebP/AVIF? +- [ ] 是否有未使用的依赖? + +**后端:** +- [ ] 热点数据是否有缓存? +- [ ] WHERE 列是否有索引? +- [ ] 是否有慢查询监控? + +**API:** +- [ ] 是否启用响应压缩? +- [ ] 是否有速率限制? +- [ ] 是否只返回必要字段? + +### 🟢 优化建议(建议级) + +- [ ] 是否分析过 bundle 大小? +- [ ] 是否使用 CDN? +- [ ] 是否有性能监控? +- [ ] 是否做过性能基准测试? + +--- + +## 性能度量阈值 + +### 前端指标 + +| 指标 | 好 | 需改进 | 差 | +|------|-----|--------|-----| +| LCP | ≤ 2.5s | 2.5-4s | > 4s | +| INP | ≤ 200ms | 200-500ms | > 500ms | +| CLS | ≤ 0.1 | 0.1-0.25 | > 0.25 | +| FCP | ≤ 1.8s | 1.8-3s | > 3s | +| Bundle Size (JS) | < 200KB | 200-500KB | > 500KB | + +### 后端指标 + +| 指标 | 好 | 需改进 | 差 | +|------|-----|--------|-----| +| API 响应时间 | < 100ms | 100-500ms | > 500ms | +| 数据库查询 | < 50ms | 50-200ms | > 200ms | +| 页面加载 | < 3s | 3-5s | > 5s | + +--- + +## 工具推荐 + +### 前端性能 + +| 工具 | 用途 | +|------|------| +| [Lighthouse](https://developer.chrome.com/docs/lighthouse/) | Core Web Vitals 测试 | +| [WebPageTest](https://www.webpagetest.org/) | 详细性能分析 | +| [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) | Bundle 分析 | +| [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/) | 运行时性能分析 | + +### 内存检测 + +| 工具 | 用途 | +|------|------| +| [MemLab](https://github.com/facebookincubator/memlab) | 自动化内存泄漏检测 | +| Chrome Memory Tab | 堆快照分析 | + +### 后端性能 + +| 工具 | 用途 | +|------|------| +| EXPLAIN | 数据库查询计划分析 | +| [pganalyze](https://pganalyze.com/) | PostgreSQL 性能监控 | +| [New Relic](https://newrelic.com/) / [Datadog](https://www.datadoghq.com/) | APM 监控 | + +--- + +## 低级别效率反模式 + +代码层面的效率失误,独立于架构层面的性能问题。补充 [common-bugs-checklist.md](common-bugs-checklist.md) 中已涵盖的资源管理与并发缺陷。 + +### 不必要的重复工作 + +- [ ] 同一函数 / 查询是否在同一 request/render 中被重复调用? +- [ ] 文件 / 配置是否在循环内重复读取(loop-invariant)? +- [ ] 计算结果是否可以被缓存或向下游传递? + +```typescript +// ❌ loop-invariant 在循环内反复执行 +for (const path of paths) { + const config = JSON.parse(fs.readFileSync("config.json", "utf-8")); + processFile(path, config); +} + +// ✅ 提到循环外 +const config = JSON.parse(fs.readFileSync("config.json", "utf-8")); +for (const path of paths) processFile(path, config); +``` + +### 错失的并发机会 + +- [ ] 独立的 async 操作是否顺序 `await`? +- [ ] 是否可以用 `Promise.all` / `asyncio.gather` / `tokio::join!` 并发? + +```typescript +// ❌ 顺序 await +const a = await fetchA(); +const b = await fetchB(); + +// ✅ 并发 +const [a, b] = await Promise.all([fetchA(), fetchB()]); +``` + +### 热路径膨胀 + +- [ ] 模块级 / import 时代码是否执行重操作(文件 I/O、网络、大对象构造)? +- [ ] per-request 路径是否有可延迟的初始化? +- [ ] 启动时代码是否阻塞首次请求? + +### 无界数据结构 + +> 资源生命周期相关缺陷(未关闭的连接、未移除的监听器、未清除的定时器)见 [common-bugs-checklist.md → Resource Management](common-bugs-checklist.md#resource-management)。本节聚焦 *容量边界*。 + +- [ ] 全局 dict / list / 缓存是否有 `max-size` 或 TTL? +- [ ] 累积型数据结构(队列、日志、metrics buffer)是否有上限? +- [ ] 每请求分配的对象是否会被持久引用而无法 GC? + +```python +# ❌ 无界缓存 +_cache: dict[str, Any] = {} + +# ✅ 有界 LRU +from functools import lru_cache + +@lru_cache(maxsize=256) +def get_cached(key: str) -> Any: + return expensive_computation(key) +``` + +--- + +## 参考资源 + +- [Core Web Vitals - web.dev](https://web.dev/articles/vitals) +- [Optimizing Core Web Vitals - Vercel](https://vercel.com/guides/optimizing-core-web-vitals-in-2024) +- [MemLab - Meta Engineering](https://engineering.fb.com/2022/09/12/open-source/memlab/) +- [Big O Cheat Sheet](https://www.bigocheatsheet.com/) +- [N+1 Query Problem - Stack Overflow](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping) +- [API Performance Optimization](https://algorithmsin60days.com/blog/optimizing-api-performance/) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/php.md b/examples/skills_code_review_agent/skills/code-review/reference/php.md new file mode 100644 index 000000000..2f0c702a4 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/php.md @@ -0,0 +1,684 @@ +# PHP Code Review Guide + +> PHP 8.x code review guide covering the type system, modern language features, OOP modeling, PDO data access, security, error handling, Composer dependencies, performance, and testing. + +## Table of Contents + +- [Quick Review Checklist](#quick-review-checklist) +- [Type System & Modern PHP](#type-system--modern-php) +- [Object Modeling](#object-modeling) +- [Input, Output & Security](#input-output--security) +- [Database Access](#database-access) +- [Error Handling](#error-handling) +- [Composer & Dependencies](#composer--dependencies) +- [Performance & Resource Management](#performance--resource-management) +- [Testing & Static Analysis](#testing--static-analysis) +- [Review Checklist](#review-checklist) +- [References](#references) + +--- + +## Quick Review Checklist + +### Must-check + +- [ ] New files enable `declare(strict_types=1);` +- [ ] Public APIs have parameter, return, and property types +- [ ] User input is validated; output is escaped per context +- [ ] SQL uses parameterized queries or ORM binding +- [ ] Passwords use `password_hash()` / `password_verify()` +- [ ] File uploads validate MIME, size, extension, and storage path +- [ ] `composer.lock` is committed; dependency ranges are reasonable +- [ ] PHPUnit/Pest tests and PHPStan/Psalm static analysis are present + +### Common issues + +- [ ] Loose comparison `==` / `!=` causing type-juggling vulnerabilities +- [ ] `md5()` / `sha1()` used to store passwords +- [ ] Concatenating SQL, HTML, shell commands, or file paths +- [ ] Using `@` to suppress errors +- [ ] `unserialize()` on untrusted data +- [ ] `$_GET` / `$_POST` / `$_FILES` flowing straight into business logic +- [ ] PHP 8.2+ dynamic properties trigger a deprecation; PHP 9 may turn it into an error + +--- + +## Type System & Modern PHP + +### strict_types and explicit types + +```php + 'ok', + 404 => 'not_found', + default => 'unknown', +}; +``` + +Pay attention to `==`, `!=`, and `in_array($x, $list)` (loose by default) in auth, payment, state machine, and permission logic. Use `===`, `!==`, and `in_array($x, $list, true)` where it matters. + +### Union / intersection / nullable types + +```php +customer?->profile?->country; + +// ✅ branch explicitly on critical business state +if ($order === null) { + throw new OrderNotFound(); +} + +$customer = $order->customer(); +if ($customer === null) { + throw new MissingCustomer($order->id); +} + +$country = $customer->profile()?->country; +``` + +Distinguish "optional display field" from "business invariant that must exist." The former is a good fit for `?->`; the latter should fail loudly. + +--- + +## Object Modeling + +### Use readonly properties and value objects + +```php +status === 'paied') { + ship($order); +} + +// ✅ an enum surfaces illegal states earlier +enum OrderStatus: string +{ + case Pending = 'pending'; + case Paid = 'paid'; + case Cancelled = 'cancelled'; +} + +if ($order->status === OrderStatus::Paid) { + ship($order); +} +``` + +When reviewing state machines, permissions, or type fields, look for "magic string values." If the value set is stable, suggest an enum; if it comes from an external system, convert it to an internal enum before it enters the business layer. + +### Don't rely on dynamic properties + +```php +emali = 'a@example.com'; // a typo also silently creates a property + +// ✅ declare properties or use a dedicated data structure +final class User +{ + public function __construct( + public string $email, + ) {} +} +``` + +`#[AllowDynamicProperties]` should be an exception for legacy compatibility, not the default for new code. Watch for serialization, ORM hydration, and test doubles that secretly rely on dynamic properties. + +### Don't do heavy I/O in constructors + +```php +pdo = new PDO($_ENV['DSN']); + } +} + +// ✅ inject dependencies from the outside +final class ReportService +{ + public function __construct( + private PDO $pdo, + ) {} +} +``` + +A constructor should establish the object's invariants — not send HTTP requests, open connections, read large files, or run complex queries. + +--- + +## Input, Output & Security + +### Validate input at the boundary + +```php +create($_POST['email'], $_POST['age']); + +// ✅ validate and coerce types at the boundary first +$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL); +$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [ + 'options' => ['min_range' => 0, 'max_range' => 130], +]); + +if ($email === false || $email === null || $age === false || $age === null) { + throw new InvalidInput(); +} + +$user = $service->create($email, $age); +``` + +`filter_input()` only handles a slice of basic validation. Complex rules, cross-field constraints, and business constraints still need a dedicated validator or request DTO. + +### Escape output per context + +```php +Hello {$_GET['name']}"; + +// ✅ use htmlspecialchars in an HTML text context +$name = (string) ($_GET['name'] ?? ''); +echo '

Hello ' . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '

'; +``` + +Different contexts need different escaping: HTML text, HTML attributes, URLs, JavaScript strings, and CSS are all different. When a template engine's default escaping is turned off, treat it as a security risk. + +### Passwords and randomness + +```php +file($file['tmp_name']); +if (!in_array($mime, ['image/png', 'image/jpeg'], true)) { + throw new InvalidFileType(); +} + +$target = __DIR__ . '/uploads/' . bin2hex(random_bytes(16)) . '.jpg'; +move_uploaded_file($file['tmp_name'], $target); +``` + +When reviewing upload features, check size limits, MIME detection, extensions, a non-executable storage directory, path traversal, overwrite protection, and any virus-scan or async-processing requirements. + +--- + +## Database Access + +### Use parameterized queries + +PHP's PDO and mysqli both support prepared statements. Never concatenate user input into SQL strings. Dynamic identifiers (table/column names) must go through a whitelist mapping. + +> **跨语言 SQL 注入防护详见 [SQL Injection Prevention Guide](cross-cutting/sql-injection-prevention.md)**,含 Python/Java/Go/Node.js/PHP/C# 示例及 ORM 不安全用法。 + +### Wrap multi-step writes in transactions + +```php +create($cart); +$inventory->reserve($cart); +$payments->charge($orderId); + +// ✅ explicit transaction boundary +$pdo->beginTransaction(); +try { + $orderId = $orders->create($cart); + $inventory->reserve($cart); + $payments->recordIntent($orderId); + $pdo->commit(); +} catch (Throwable $e) { + $pdo->rollBack(); + throw $e; +} +``` + +Don't casually put external, non-rollbackable side effects (an actual charge, an email, a message dispatch) inside a database transaction. Common patterns are an outbox, an idempotency key, or triggering after the transaction commits. + +### Avoid N+1 queries + +> 📖 For cross-language N+1 patterns and solutions, see [N+1 Queries Guide](cross-cutting/n-plus-one-queries.md) + +```php +find($order->customerId); + render($order, $customer); +} + +// ✅ batch-load, then map +$customerIds = array_unique(array_map(fn ($o) => $o->customerId, $orders)); +$customers = $customerRepo->findByIds($customerIds); + +foreach ($orders as $order) { + render($order, $customers[$order->customerId] ?? null); +} +``` + +In ORMs like Laravel/Doctrine, check eager loading, join fetch, selected columns, pagination, and indexes. + +--- + +## Error Handling + +> 📖 For cross-language error handling principles, see [Error Handling Guide](cross-cutting/error-handling-principles.md) + +### Catch specific exceptions, keep context + +```php +send($message); +} catch (Exception $e) { +} + +// ✅ catch a specific exception, keep context, and rethrow +try { + $mailer->send($message); +} catch (TransportException $e) { + throw new NotificationFailed($userId, previous: $e); +} +``` + +Empty `catch` blocks, `error_log()`-and-continue without surfacing the error, and turning every exception into `RuntimeException('failed')` in production code all deserve a question. + +### Don't suppress errors with @ + +```php +error('Login failed', ['request' => $_POST]); + +// ✅ log non-sensitive context that still helps locate the problem +$logger->warning('Login failed', [ + 'email_hash' => hash('sha256', strtolower($email)), + 'ip' => $requestIp, +]); +``` + +Check logs, exception messages, the debug toolbar, error pages, and failed-queue records. Sensitive data includes passwords, tokens, sessions, PII, payment data, and full cookies. + +--- + +## Composer & Dependencies + +### Lock reproducible dependencies + +```json +{ + "require": { + "php": "^8.2", + "monolog/monolog": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "phpstan/phpstan": "^1.10" + } +} +``` + +When reviewing `composer.json` / `composer.lock`, watch for: + +- Application repos commit `composer.lock`; library repos usually don't +- `require-dev` shouldn't make it into the production image +- The PHP platform version matches the CI version +- Autoload rules aren't too broad (don't load test or script directories) +- `scripts` commands don't depend on a developer's local secret config + +### Dependency security and maintenance + +```bash +composer audit +composer outdated --direct +composer validate --strict +``` + +When adding a package, look at its maintenance status — download count isn't the only signal. What matters is its security history, release cadence, minimal dependency footprint, and whether it duplicates the standard library or a framework built-in. + +--- + +## Performance & Resource Management + +### Stream large datasets with generators or pagination + +```php +all(); +foreach ($rows as $row) { + exportRow($row); +} + +// ✅ paginate or use a generator to avoid a memory spike +foreach ($repo->cursor() as $row) { + exportRow($row); +} +``` + +A PHP request lifecycle is short, but CLI jobs, queue workers, and export tasks run for a long time. For that kind of code, watch memory growth, unclosed resources, and global-state pollution especially closely. + +### Avoid expensive work inside loops + +```php +send($item); +} + +// ✅ create reusable dependencies outside the loop +$client = new ApiClient($_ENV['API_KEY']); +foreach ($items as $item) { + $client->send($item); +} +``` + +Watch for database queries, HTTP requests, regex compilation, large array copies, accumulating `array_merge()` appends, and repeatedly reading env vars or config files inside loops. + +### Release or scope resources + +```php +expects($this->once())->method('buildTemplate'); + +// ✅ assert observable results +$service->sendWelcomeEmail($user); + +$this->assertTrue($mailbox->hasMessageFor($user->email)); +``` + +For business services, controllers, and queue jobs, prefer covering observable behavior: inputs/outputs, database state, published events, and dispatched messages. + +### Static analysis and formatting + +```bash +vendor/bin/phpunit +vendor/bin/phpstan analyse +vendor/bin/psalm +vendor/bin/php-cs-fixer fix --dry-run --diff +vendor/bin/rector process --dry-run +``` + +When reviewing a PR, check whether the new code lowers the PHPStan/Psalm level, leans heavily on baseline ignores, or uses `@phpstan-ignore-next-line` to paper over a real type problem. + +### Isolate test data + +```php +expireOldSessions(); + +// ✅ inject a clock and a fake gateway +$clock->setNow(new DateTimeImmutable('2026-01-01T00:00:00Z')); +$service->expireOldSessions(); +``` + +Watch for database transaction rollback, fixture cleanup, randomness, time, queues, caches, and external APIs. Slow PHP tests are usually not a language problem — it's that the boundaries aren't isolated. + +--- + +## Review Checklist + +### Types & modeling + +- [ ] `declare(strict_types=1);` at the top of the file +- [ ] Parameters, return values, and properties have explicit types +- [ ] `===` / `!==` used; collection lookups use strict mode +- [ ] Stable state sets use an enum, not magic strings +- [ ] New code doesn't rely on dynamic properties +- [ ] Value objects are readonly or otherwise immutable + +### Security + +- [ ] Input is validated and type-coerced at the boundary +- [ ] Output is escaped per HTML/URL/JS/CSS context +- [ ] SQL uses prepared statements or ORM binding +- [ ] Dynamic table/column/sort names go through a whitelist +- [ ] Passwords use `password_hash()` / `password_verify()` +- [ ] Tokens, codes, and filenames use `random_bytes()` / `random_int()` +- [ ] Untrusted input never reaches `unserialize()` +- [ ] File uploads check the error code, size, MIME, extension, and storage directory +- [ ] No injection or leakage risk in shell commands, path building, or log output + +### Data & transactions + +- [ ] Multi-step writes have a transaction or compensation mechanism +- [ ] External side effects are designed to be idempotent +- [ ] N+1 queries avoided +- [ ] Pagination, indexes, and selected columns are reasonable +- [ ] Database errors aren't swallowed + +### Maintainability + +- [ ] Constructors don't do heavy I/O +- [ ] Dependency injection is clear; no hidden global state +- [ ] No `@` error suppression +- [ ] Exceptions preserve context and `previous` +- [ ] Composer dependency ranges, autoload, and scripts are reasonable +- [ ] Application repos commit `composer.lock` + +### Testing & tooling + +- [ ] PHPUnit/Pest cover the critical and failure paths +- [ ] PHPStan/Psalm config doesn't lower strictness +- [ ] New ignores/baselines are explained +- [ ] Formatting tools and CI commands are reproducible +- [ ] Tests isolate time, randomness, the database, queues, and external APIs + +--- + +## References + +- [PHP Manual: Type declarations](https://www.php.net/manual/en/language.types.declarations.php) +- [PHP Manual: match](https://www.php.net/match) +- [PHP Manual: Enumerations](https://www.php.net/manual/en/language.enumerations.overview.php) +- [PHP Manual: Properties](https://www.php.net/manual/en/language.oop5.properties.php) +- [PHP Manual: PDO](https://www.php.net/manual/en/class.pdo.php) +- [PHP Manual: password_hash](https://www.php.net/manual/en/function.password-hash.php) +- [PHP Manual: random_bytes](https://www.php.net/manual/en/function.random-bytes.php) +- [Composer documentation](https://getcomposer.org/doc/) +- [PHPUnit documentation](https://docs.phpunit.de/) +- [PHPStan documentation](https://phpstan.org/user-guide/getting-started) +- [Psalm documentation](https://psalm.dev/docs/) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/python.md b/examples/skills_code_review_agent/skills/code-review/reference/python.md new file mode 100644 index 000000000..7aa583003 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/python.md @@ -0,0 +1,1073 @@ +# Python Code Review Guide + +> Python 代码审查指南,覆盖类型注解、async/await、测试、异常处理、性能优化等核心主题。 + +## 目录 + +- [类型注解](#类型注解) +- [异步编程](#异步编程) +- [异常处理](#异常处理) +- [常见陷阱](#常见陷阱) +- [测试最佳实践](#测试最佳实践) +- [性能优化](#性能优化) +- [代码风格](#代码风格) +- [Review Checklist](#review-checklist) + +--- + +## 类型注解 + +### 基础类型注解 + +```python +# ❌ 没有类型注解,IDE 无法提供帮助 +def process_data(data, count): + return data[:count] + +# ✅ 使用类型注解 +def process_data(data: str, count: int) -> str: + return data[:count] + +# ✅ 复杂类型使用 typing 模块 +from typing import Optional, Union + +def find_user(user_id: int) -> Optional[User]: + """返回用户或 None""" + return db.get(user_id) + +def handle_input(value: Union[str, int]) -> str: + """接受字符串或整数""" + return str(value) +``` + +### 容器类型注解 + +```python +from typing import List, Dict, Set, Tuple, Sequence + +# ❌ 不精确的类型 +def get_names(users: list) -> list: + return [u.name for u in users] + +# ✅ 精确的容器类型(Python 3.9+ 可直接用 list[User]) +def get_names(users: List[User]) -> List[str]: + return [u.name for u in users] + +# ✅ 只读序列用 Sequence(更灵活) +def process_items(items: Sequence[str]) -> int: + return len(items) + +# ✅ 字典类型 +def count_words(text: str) -> Dict[str, int]: + words: Dict[str, int] = {} + for word in text.split(): + words[word] = words.get(word, 0) + 1 + return words + +# ✅ 元组(固定长度和类型) +def get_point() -> Tuple[float, float]: + return (1.0, 2.0) + +# ✅ 可变长度元组 +def get_scores() -> Tuple[int, ...]: + return (90, 85, 92, 88) +``` + +### 泛型与 TypeVar + +```python +from typing import TypeVar, Generic, List, Callable + +T = TypeVar('T') +K = TypeVar('K') +V = TypeVar('V') + +# ✅ 泛型函数 +def first(items: List[T]) -> T | None: + return items[0] if items else None + +# ✅ 有约束的 TypeVar +from typing import Hashable +H = TypeVar('H', bound=Hashable) + +def dedupe(items: List[H]) -> List[H]: + return list(set(items)) + +# ✅ 泛型类 +class Cache(Generic[K, V]): + def __init__(self) -> None: + self._data: Dict[K, V] = {} + + def get(self, key: K) -> V | None: + return self._data.get(key) + + def set(self, key: K, value: V) -> None: + self._data[key] = value +``` + +### Callable 与回调函数 + +```python +from typing import Callable, Awaitable + +# ✅ 函数类型注解 +Handler = Callable[[str, int], bool] + +def register_handler(name: str, handler: Handler) -> None: + handlers[name] = handler + +# ✅ 异步回调 +AsyncHandler = Callable[[str], Awaitable[dict]] + +async def fetch_with_handler( + url: str, + handler: AsyncHandler +) -> dict: + return await handler(url) + +# ✅ 返回函数的函数 +def create_multiplier(factor: int) -> Callable[[int], int]: + def multiplier(x: int) -> int: + return x * factor + return multiplier +``` + +### TypedDict 与结构化数据 + +```python +from typing import TypedDict, Required, NotRequired + +# ✅ 定义字典结构 +class UserDict(TypedDict): + id: int + name: str + email: str + age: NotRequired[int] # Python 3.11+ + +def create_user(data: UserDict) -> User: + return User(**data) + +# ✅ 部分必需字段 +class ConfigDict(TypedDict, total=False): + debug: bool + timeout: int + host: Required[str] # 这个必须有 +``` + +### Protocol 与结构化子类型 + +```python +from typing import Protocol, runtime_checkable + +# ✅ 定义协议(鸭子类型的类型检查) +class Readable(Protocol): + def read(self, size: int = -1) -> bytes: ... + +class Closeable(Protocol): + def close(self) -> None: ... + +# 组合协议 +class ReadableCloseable(Readable, Closeable, Protocol): + pass + +def process_stream(stream: Readable) -> bytes: + return stream.read() + +# ✅ 运行时可检查的协议 +@runtime_checkable +class Drawable(Protocol): + def draw(self) -> None: ... + +def render(obj: object) -> None: + if isinstance(obj, Drawable): # 运行时检查 + obj.draw() +``` + +--- + +## 异步编程 + +> 📖 通用并发模式和跨语言示例详见 [异步与并发跨语言指南](cross-cutting/async-concurrency-patterns.md) + +### async/await 基础 + +```python +import asyncio + +# ❌ 同步阻塞调用 +def fetch_all_sync(urls: list[str]) -> list[str]: + results = [] + for url in urls: + results.append(requests.get(url).text) # 串行执行 + return results + +# ✅ 异步并发调用 +async def fetch_url(url: str) -> str: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + return await response.text() + +async def fetch_all(urls: list[str]) -> list[str]: + tasks = [fetch_url(url) for url in urls] + return await asyncio.gather(*tasks) # 并发执行 +``` + +### 异步上下文管理器 + +```python +from contextlib import asynccontextmanager +from typing import AsyncIterator + +# ✅ 异步上下文管理器类 +class AsyncDatabase: + async def __aenter__(self) -> 'AsyncDatabase': + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + await self.disconnect() + +# ✅ 使用装饰器 +@asynccontextmanager +async def get_connection() -> AsyncIterator[Connection]: + conn = await create_connection() + try: + yield conn + finally: + await conn.close() + +async def query_data(): + async with get_connection() as conn: + return await conn.fetch("SELECT * FROM users") +``` + +### 异步迭代器 + +```python +from typing import AsyncIterator + +# ✅ 异步生成器 +async def fetch_pages(url: str) -> AsyncIterator[dict]: + page = 1 + while True: + data = await fetch_page(url, page) + if not data['items']: + break + yield data + page += 1 + +# ✅ 使用异步迭代 +async def process_all_pages(): + async for page in fetch_pages("https://api.example.com"): + await process_page(page) +``` + +### 任务管理与取消 + +```python +import asyncio + +# ❌ 忘记处理取消 +async def bad_worker(): + while True: + await do_work() # 无法正常取消 + +# ✅ 正确处理取消 +async def good_worker(): + try: + while True: + await do_work() + except asyncio.CancelledError: + await cleanup() # 清理资源 + raise # 重新抛出,让调用者知道已取消 + +# ✅ 超时控制 +async def fetch_with_timeout(url: str) -> str: + try: + async with asyncio.timeout(10): # Python 3.11+ + return await fetch_url(url) + except asyncio.TimeoutError: + return "" + +# ✅ 任务组(Python 3.11+) +async def fetch_multiple(): + async with asyncio.TaskGroup() as tg: + task1 = tg.create_task(fetch_url("url1")) + task2 = tg.create_task(fetch_url("url2")) + # 所有任务完成后自动等待,异常会传播 + return task1.result(), task2.result() +``` + +### 同步与异步混合 + +```python +import asyncio +from concurrent.futures import ThreadPoolExecutor + +# ✅ 在异步代码中运行同步函数 +async def run_sync_in_async(): + loop = asyncio.get_event_loop() + # 使用线程池执行阻塞操作 + result = await loop.run_in_executor( + None, # 默认线程池 + blocking_io_function, + arg1, arg2 + ) + return result + +# ✅ 在同步代码中运行异步函数 +def run_async_in_sync(): + return asyncio.run(async_function()) + +# ❌ 不要在异步代码中使用 time.sleep +async def bad_delay(): + time.sleep(1) # 会阻塞整个事件循环! + +# ✅ 使用 asyncio.sleep +async def good_delay(): + await asyncio.sleep(1) +``` + +### 信号量与限流 + +```python +import asyncio + +# ✅ 使用信号量限制并发 +async def fetch_with_limit(urls: list[str], max_concurrent: int = 10): + semaphore = asyncio.Semaphore(max_concurrent) + + async def fetch_one(url: str) -> str: + async with semaphore: + return await fetch_url(url) + + return await asyncio.gather(*[fetch_one(url) for url in urls]) + +# ✅ 使用 asyncio.Queue 实现生产者-消费者 +async def producer_consumer(): + queue: asyncio.Queue[str] = asyncio.Queue(maxsize=100) + + async def producer(): + for item in items: + await queue.put(item) + await queue.put(None) # 结束信号 + + async def consumer(): + while True: + item = await queue.get() + if item is None: + break + await process(item) + queue.task_done() + + await asyncio.gather(producer(), consumer()) +``` + +--- + +## 异常处理 + +> 📖 通用原则和跨语言示例详见 [错误处理跨语言指南](cross-cutting/error-handling-principles.md) + +### 异常捕获最佳实践 + +```python +# ❌ Catching too broad +try: + result = risky_operation() +except: # Catches everything, even KeyboardInterrupt! + pass + +# ❌ 捕获 Exception 但不处理 +try: + result = risky_operation() +except Exception: + pass # 吞掉所有异常,难以调试 + +# ✅ Catch specific exceptions +try: + result = risky_operation() +except ValueError as e: + logger.error(f"Invalid value: {e}") + raise +except IOError as e: + logger.error(f"IO error: {e}") + return default_value + +# ✅ 多个异常类型 +try: + result = parse_and_process(data) +except (ValueError, TypeError, KeyError) as e: + logger.error(f"Data error: {e}") + raise DataProcessingError(str(e)) from e +``` + +### 异常链 + +```python +# ❌ 丢失原始异常信息 +try: + result = external_api.call() +except APIError as e: + raise RuntimeError("API failed") # 丢失了原因 + +# ✅ 使用 from 保留异常链 +try: + result = external_api.call() +except APIError as e: + raise RuntimeError("API failed") from e + +# ✅ 显式断开异常链(少见情况) +try: + result = external_api.call() +except APIError: + raise RuntimeError("API failed") from None +``` + +### 自定义异常 + +```python +# ✅ 定义业务异常层次结构 +class AppError(Exception): + """应用基础异常""" + pass + +class ValidationError(AppError): + """数据验证错误""" + def __init__(self, field: str, message: str): + self.field = field + self.message = message + super().__init__(f"{field}: {message}") + +class NotFoundError(AppError): + """资源未找到""" + def __init__(self, resource: str, id: str | int): + self.resource = resource + self.id = id + super().__init__(f"{resource} with id {id} not found") + +# 使用 +def get_user(user_id: int) -> User: + user = db.get(user_id) + if not user: + raise NotFoundError("User", user_id) + return user +``` + +### 上下文管理器中的异常 + +```python +from contextlib import contextmanager + +# ✅ 正确处理上下文管理器中的异常 +@contextmanager +def transaction(): + conn = get_connection() + try: + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() + +# ✅ 使用 ExceptionGroup(Python 3.11+) +def process_batch(items: list) -> None: + errors = [] + for item in items: + try: + process(item) + except Exception as e: + errors.append(e) + + if errors: + raise ExceptionGroup("Batch processing failed", errors) +``` + +--- + +## 常见陷阱 + +### 可变默认参数 + +```python +# ❌ Mutable default arguments +def add_item(item, items=[]): # Bug! Shared across calls + items.append(item) + return items + +# 问题演示 +add_item(1) # [1] +add_item(2) # [1, 2] 而不是 [2]! + +# ✅ Use None as default +def add_item(item, items=None): + if items is None: + items = [] + items.append(item) + return items + +# ✅ 或使用 dataclass 的 field +from dataclasses import dataclass, field + +@dataclass +class Container: + items: list = field(default_factory=list) +``` + +### 可变类属性 + +```python +# ❌ Using mutable class attributes +class User: + permissions = [] # Shared across all instances! + +# 问题演示 +u1 = User() +u2 = User() +u1.permissions.append("admin") +print(u2.permissions) # ["admin"] - 被意外共享! + +# ✅ Initialize in __init__ +class User: + def __init__(self): + self.permissions = [] + +# ✅ 使用 dataclass +@dataclass +class User: + permissions: list = field(default_factory=list) +``` + +### 循环中的闭包 + +```python +# ❌ 闭包捕获循环变量 +funcs = [] +for i in range(3): + funcs.append(lambda: i) + +print([f() for f in funcs]) # [2, 2, 2] 而不是 [0, 1, 2]! + +# ✅ 使用默认参数捕获值 +funcs = [] +for i in range(3): + funcs.append(lambda i=i: i) + +print([f() for f in funcs]) # [0, 1, 2] + +# ✅ 使用 functools.partial +from functools import partial + +funcs = [partial(lambda x: x, i) for i in range(3)] +``` + +### is vs == + +```python +# ❌ 用 is 比较值 +if x is 1000: # 可能不工作! + pass + +# Python 会缓存小整数 (-5 到 256) +a = 256 +b = 256 +a is b # True + +a = 257 +b = 257 +a is b # False! + +# ✅ 用 == 比较值 +if x == 1000: + pass + +# ✅ is 只用于 None 和单例 +if x is None: + pass + +if x is True: # 严格检查布尔值 + pass +``` + +### 字符串拼接性能 + +```python +# ❌ 循环中拼接字符串 +result = "" +for item in large_list: + result += str(item) # O(n²) 复杂度 + +# ✅ 使用 join +result = "".join(str(item) for item in large_list) # O(n) + +# ✅ 使用 StringIO 构建大字符串 +from io import StringIO + +buffer = StringIO() +for item in large_list: + buffer.write(str(item)) +result = buffer.getvalue() +``` + +--- + +## 测试最佳实践 + +### pytest 基础 + +```python +import pytest + +# ✅ 清晰的测试命名 +def test_user_creation_with_valid_email(): + user = User(email="test@example.com") + assert user.email == "test@example.com" + +def test_user_creation_with_invalid_email_raises_error(): + with pytest.raises(ValidationError): + User(email="invalid") + +# ✅ 使用参数化测试 +@pytest.mark.parametrize("input,expected", [ + ("hello", "HELLO"), + ("World", "WORLD"), + ("", ""), + ("123", "123"), +]) +def test_uppercase(input: str, expected: str): + assert input.upper() == expected + +# ✅ 测试异常 +def test_division_by_zero(): + with pytest.raises(ZeroDivisionError) as exc_info: + 1 / 0 + assert "division by zero" in str(exc_info.value) +``` + +### Fixtures + +```python +import pytest +from typing import Generator + +# ✅ 基础 fixture +@pytest.fixture +def user() -> User: + return User(name="Test User", email="test@example.com") + +def test_user_name(user: User): + assert user.name == "Test User" + +# ✅ 带清理的 fixture +@pytest.fixture +def database() -> Generator[Database, None, None]: + db = Database() + db.connect() + yield db + db.disconnect() # 测试后清理 + +# ✅ 异步 fixture +@pytest.fixture +async def async_client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient() as client: + yield client + +# ✅ 共享 fixture(conftest.py) +# conftest.py +@pytest.fixture(scope="session") +def app(): + """整个测试会话共享的 app 实例""" + return create_app() + +@pytest.fixture(scope="module") +def db(app): + """每个测试模块共享的数据库连接""" + return app.db +``` + +### Mock 与 Patch + +```python +from unittest.mock import Mock, patch, AsyncMock + +# ✅ Mock 外部依赖 +def test_send_email(): + mock_client = Mock() + mock_client.send.return_value = True + + service = EmailService(client=mock_client) + result = service.send_welcome_email("user@example.com") + + assert result is True + mock_client.send.assert_called_once_with( + to="user@example.com", + subject="Welcome!", + body=ANY, + ) + +# ✅ Patch 模块级函数 +@patch("myapp.services.external_api.call") +def test_with_patched_api(mock_call): + mock_call.return_value = {"status": "ok"} + + result = process_data() + + assert result["status"] == "ok" + +# ✅ 异步 Mock +async def test_async_function(): + mock_fetch = AsyncMock(return_value={"data": "test"}) + + with patch("myapp.client.fetch", mock_fetch): + result = await get_data() + + assert result == {"data": "test"} +``` + +### 测试组织 + +```python +# ✅ 使用类组织相关测试 +class TestUserAuthentication: + """用户认证相关测试""" + + def test_login_with_valid_credentials(self, user): + assert authenticate(user.email, "password") is True + + def test_login_with_invalid_password(self, user): + assert authenticate(user.email, "wrong") is False + + def test_login_locks_after_failed_attempts(self, user): + for _ in range(5): + authenticate(user.email, "wrong") + assert user.is_locked is True + +# ✅ 使用 mark 标记测试 +@pytest.mark.slow +def test_large_data_processing(): + pass + +@pytest.mark.integration +def test_database_connection(): + pass + +# 运行特定标记的测试:pytest -m "not slow" +``` + +### 覆盖率与质量 + +```python +# pytest.ini 或 pyproject.toml +[tool.pytest.ini_options] +addopts = "--cov=myapp --cov-report=term-missing --cov-fail-under=80" +testpaths = ["tests"] + +# ✅ 测试边界情况 +def test_empty_input(): + assert process([]) == [] + +def test_none_input(): + with pytest.raises(TypeError): + process(None) + +def test_large_input(): + large_data = list(range(100000)) + result = process(large_data) + assert len(result) == 100000 +``` + +--- + +## 性能优化 + +### 数据结构选择 + +```python +# ❌ 列表查找 O(n) +if item in large_list: # 慢 + pass + +# ✅ 集合查找 O(1) +large_set = set(large_list) +if item in large_set: # 快 + pass + +# ✅ 使用 collections 模块 +from collections import Counter, defaultdict, deque + +# 计数 +word_counts = Counter(words) +most_common = word_counts.most_common(10) + +# 默认字典 +graph = defaultdict(list) +graph[node].append(neighbor) + +# 双端队列(两端操作 O(1)) +queue = deque() +queue.appendleft(item) # O(1) vs list.insert(0, item) O(n) +``` + +### 生成器与迭代器 + +```python +# ❌ 一次性加载所有数据 +def get_all_users(): + return [User(row) for row in db.fetch_all()] # 内存占用大 + +# ✅ 使用生成器 +def get_all_users(): + for row in db.fetch_all(): + yield User(row) # 懒加载 + +# ✅ 生成器表达式 +sum_of_squares = sum(x**2 for x in range(1000000)) # 不创建列表 + +# ✅ itertools 模块 +from itertools import islice, chain, groupby + +# 只取前 10 个 +first_10 = list(islice(infinite_generator(), 10)) + +# 链接多个迭代器 +all_items = chain(list1, list2, list3) + +# 分组 +for key, group in groupby(sorted(items, key=get_key), key=get_key): + process_group(key, list(group)) +``` + +### 缓存 + +```python +from functools import lru_cache, cache + +# ✅ LRU 缓存 +@lru_cache(maxsize=128) +def expensive_computation(n: int) -> int: + return sum(i**2 for i in range(n)) + +# ✅ 无限缓存(Python 3.9+) +@cache +def fibonacci(n: int) -> int: + if n < 2: + return n + return fibonacci(n - 1) + fibonacci(n - 2) + +# ✅ 手动缓存(需要更多控制时) +class DataService: + def __init__(self): + self._cache: dict[str, Any] = {} + self._cache_ttl: dict[str, float] = {} + + def get_data(self, key: str) -> Any: + if key in self._cache: + if time.time() < self._cache_ttl[key]: + return self._cache[key] + + data = self._fetch_data(key) + self._cache[key] = data + self._cache_ttl[key] = time.time() + 300 # 5 分钟 + return data +``` + +### 并行处理 + +```python +from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor + +# ✅ IO 密集型使用线程池 +def fetch_all_urls(urls: list[str]) -> list[str]: + with ThreadPoolExecutor(max_workers=10) as executor: + results = list(executor.map(fetch_url, urls)) + return results + +# ✅ CPU 密集型使用进程池 +def process_large_dataset(data: list) -> list: + with ProcessPoolExecutor() as executor: + results = list(executor.map(heavy_computation, data)) + return results + +# ✅ 使用 as_completed 获取最先完成的结果 +from concurrent.futures import as_completed + +with ThreadPoolExecutor() as executor: + futures = {executor.submit(fetch, url): url for url in urls} + for future in as_completed(futures): + url = futures[future] + try: + result = future.result() + except Exception as e: + print(f"{url} failed: {e}") +``` + +--- + +## 代码风格 + +### PEP 8 要点 + +```python +# ✅ 命名规范 +class MyClass: # 类名 PascalCase + MAX_SIZE = 100 # 常量 UPPER_SNAKE_CASE + + def method_name(self): # 方法 snake_case + local_var = 1 # 变量 snake_case + +# ✅ 导入顺序 +# 1. 标准库 +import os +import sys +from typing import Optional + +# 2. 第三方库 +import numpy as np +import pandas as pd + +# 3. 本地模块 +from myapp import config +from myapp.utils import helper + +# ✅ 行长度限制(79 或 88 字符) +# 长表达式的换行 +result = ( + long_function_name(arg1, arg2, arg3) + + another_long_function(arg4, arg5) +) + +# ✅ 空行规范 +class MyClass: + """类文档字符串""" + + def method_one(self): + pass + + def method_two(self): # 方法间一个空行 + pass + + +def top_level_function(): # 顶层定义间两个空行 + pass +``` + +### 文档字符串 + +```python +# ✅ Google 风格文档字符串 +def calculate_area(width: float, height: float) -> float: + """计算矩形面积。 + + Args: + width: 矩形的宽度(必须为正数)。 + height: 矩形的高度(必须为正数)。 + + Returns: + 矩形的面积。 + + Raises: + ValueError: 如果 width 或 height 为负数。 + + Example: + >>> calculate_area(3, 4) + 12.0 + """ + if width < 0 or height < 0: + raise ValueError("Dimensions must be positive") + return width * height + +# ✅ 类文档字符串 +class DataProcessor: + """处理和转换数据的工具类。 + + Attributes: + source: 数据来源路径。 + format: 输出格式('json' 或 'csv')。 + + Example: + >>> processor = DataProcessor("data.csv") + >>> processor.process() + """ +``` + +### 现代 Python 特性 + +```python +# ✅ f-string(Python 3.6+) +name = "World" +print(f"Hello, {name}!") + +# 带表达式 +print(f"Result: {1 + 2 = }") # "Result: 1 + 2 = 3" + +# ✅ 海象运算符(Python 3.8+) +if (n := len(items)) > 10: + print(f"List has {n} items") + +# ✅ 位置参数分隔符(Python 3.8+) +def greet(name, /, greeting="Hello", *, punctuation="!"): + """name 只能位置传参,punctuation 只能关键字传参""" + return f"{greeting}, {name}{punctuation}" + +# ✅ 模式匹配(Python 3.10+) +def handle_response(response: dict): + match response: + case {"status": "ok", "data": data}: + return process_data(data) + case {"status": "error", "message": msg}: + raise APIError(msg) + case _: + raise ValueError("Unknown response format") +``` + +--- + +## Review Checklist + +### 类型安全 +- [ ] 函数有类型注解(参数和返回值) +- [ ] 使用 `Optional` 明确可能为 None +- [ ] 泛型类型正确使用 +- [ ] mypy 检查通过(无错误) +- [ ] 避免使用 `Any`,必要时添加注释说明 + +### 异步代码 +- [ ] async/await 正确配对使用 +- [ ] 没有在异步代码中使用阻塞调用 +- [ ] 正确处理 `CancelledError` +- [ ] 使用 `asyncio.gather` 或 `TaskGroup` 并发执行 +- [ ] 资源正确清理(async context manager) + +### 异常处理 +- [ ] 捕获特定异常类型,不使用裸 `except:` +- [ ] 异常链使用 `from` 保留原因 +- [ ] 自定义异常继承自合适的基类 +- [ ] 异常信息有意义,便于调试 + +### 数据结构 +- [ ] 没有使用可变默认参数(list、dict、set) +- [ ] 类属性不是可变对象 +- [ ] 选择正确的数据结构(set vs list 查找) +- [ ] 大数据集使用生成器而非列表 + +### 测试 +- [ ] 测试覆盖率达标(建议 ≥80%) +- [ ] 测试命名清晰描述测试场景 +- [ ] 边界情况有测试覆盖 +- [ ] Mock 正确隔离外部依赖 +- [ ] 异步代码有对应的异步测试 + +### 代码风格 +- [ ] 遵循 PEP 8 风格指南 +- [ ] 函数和类有 docstring +- [ ] 导入顺序正确(标准库、第三方、本地) +- [ ] 命名一致且有意义 +- [ ] 使用现代 Python 特性(f-string、walrus operator 等) + +### 性能 +- [ ] 避免循环中重复创建对象 +- [ ] 字符串拼接使用 join +- [ ] 合理使用缓存(@lru_cache) +- [ ] IO/CPU 密集型使用合适的并行方式 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/qt.md b/examples/skills_code_review_agent/skills/code-review/reference/qt.md new file mode 100644 index 000000000..db3ffeec5 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/qt.md @@ -0,0 +1,757 @@ +# Qt Code Review Guide + +> Code review guidelines focusing on object model, signals/slots, Model/View, QML, Qt6 migration, event loop, testing, and GUI performance. Examples based on Qt 5.15 / Qt 6. + +## Table of Contents + +- [Object Model & Memory Management](#object-model--memory-management) +- [Signals & Slots](#signals--slots) +- [Containers & Strings](#containers--strings) +- [Threads & Concurrency](#threads--concurrency) +- [GUI & Widgets](#gui--widgets) +- [Model/View Architecture](#modelview-architecture) +- [Meta-Object System](#meta-object-system) +- [QML / Qt Quick](#qml--qt-quick) +- [Qt5 → Qt6 Migration](#qt5--qt6-migration) +- [Testing](#testing) +- [Review Checklist](#review-checklist) + +--- + +## Object Model & Memory Management + +### Use Parent-Child Ownership Mechanism +Qt's `QObject` hierarchy automatically manages memory. For `QObject`, prefer setting a parent object over manual `delete` or smart pointers. + +```cpp +// ❌ Manual management prone to memory leaks +QWidget* w = new QWidget(); +QLabel* l = new QLabel(); +l->setParent(w); +// ... If w is deleted, l is automatically deleted. But if w leaks, l also leaks. + +// ✅ Specify parent in constructor +QWidget* w = new QWidget(this); // Owned by 'this' +QLabel* l = new QLabel(w); // Owned by 'w' +``` + +### Use Smart Pointers with QObject +If a `QObject` has no parent, use `QScopedPointer` or `std::unique_ptr` with a custom deleter (use `deleteLater` if cross-thread). Avoid `std::shared_ptr` for `QObject` unless necessary, as it confuses the parent-child ownership system. + +```cpp +// ✅ Scoped pointer for local/member QObject without parent +QScopedPointer obj(new MyObject()); + +// ✅ Safe pointer to prevent dangling pointers +QPointer safePtr = obj.data(); +if (safePtr) { + safePtr->doSomething(); +} +``` + +### Use `deleteLater()` +For asynchronous deletion, especially in slots or event handlers, use `deleteLater()` instead of `delete` to ensure pending events in the event loop are processed. + +```cpp +// ❌ Bad: delete in a slot may invalidate sender during signal emission +void MyWidget::onFinished() { + delete this; // UB: may be called from within a signal chain +} + +// ✅ Good: safe deferred deletion +void MyWidget::onFinished() { + deleteLater(); +} +``` + +### Avoid double ownership + +```cpp +// ❌ Bad: parent owns the dialog, but we also store it in unique_ptr +auto dialog = std::make_unique(this); // 'this' is parent AND unique_ptr owns it + +// ✅ Good: parent owns it, raw pointer for access +auto* dialog = new QDialog(this); +``` + +--- + +## Signals & Slots + +### Prefer Function Pointer Syntax +Use compile-time checked syntax (Qt 5+). + +```cpp +// ❌ String-based (runtime check only, slower) +connect(sender, SIGNAL(valueChanged(int)), receiver, SLOT(updateValue(int))); + +// ✅ Compile-time check +connect(sender, &Sender::valueChanged, receiver, &Receiver::updateValue); +``` + +### Lambda connections — specify context object + +```cpp +// ❌ Bad: lambda captures `this` raw; crashes if object is deleted +connect(timer, &QTimer::timeout, [this]() { + update(); // crashes if 'this' was destroyed +}); + +// ✅ Good: context object disconnects automatically on destruction +connect(timer, &QTimer::timeout, this, [this]() { + update(); +}); +``` + +### Connection Types +Be explicit or aware of connection types when crossing threads. +- `Qt::AutoConnection` (Default): Direct if same thread, Queued if different thread. +- `Qt::QueuedConnection`: Always posts event (thread-safe across threads). +- `Qt::DirectConnection`: Immediate call (dangerous if accessing non-thread-safe data across threads). + +### Avoid Loops +Check logic that might cause infinite signal loops (e.g., `valueChanged` -> `setValue` -> `valueChanged`). Block signals or check for equality before setting values. + +```cpp +void MyClass::setValue(int v) { + if (m_value == v) return; // ✅ Good: Break loop + m_value = v; + emit valueChanged(v); +} +``` + +### Disconnect when appropriate + +```cpp +// ✅ Good: explicit disconnect before changing target +disconnect(oldSource, &Source::data, this, &Receiver::onData); +connect(newSource, &Source::data, this, &Receiver::onData); +``` + +--- + +## Containers & Strings + +### QString Efficiency +- Use `QStringLiteral("...")` for compile-time string creation to avoid runtime allocation. +- Use `QLatin1String` for comparison with ASCII literals (in Qt 5). +- Prefer `arg()` for formatting (or `QStringBuilder`'s `%` operator). + +```cpp +// ❌ Runtime conversion +if (str == "test") ... + +// ✅ Prefer QLatin1String for comparison with ASCII literals (in Qt 5) +if (str == QLatin1String("test")) ... // Qt 5 +if (str == u"test"_s) ... // Qt 6 +``` + +### Container Selection +- **Qt 6**: `QList` is now the default choice (unified with `QVector`). +- **Qt 5**: Prefer `QVector` over `QList` for contiguous memory and cache performance, unless stable references are needed. +- Be aware of Implicit Sharing (Copy-on-Write). Passing containers by value is cheap *until* modified. Use `const &` for read-only access. + +```cpp +// ❌ Forces deep copy if function modifies 'list' +void process(QVector list) { + list[0] = 1; +} + +// ✅ Read-only reference +void process(const QVector& list) { ... } +``` + +### Use constBegin/constEnd for read-only iteration + +```cpp +// ❌ Bad: begin()/end() may trigger detach +for (auto it = list.begin(); it != list.end(); ++it) { + qDebug() << *it; +} + +// ✅ Good: const iteration avoids detach +for (auto it = list.constBegin(); it != list.constEnd(); ++it) { + qDebug() << *it; +} + +// ✅ Best: range-based for with const ref (Qt 5.7+) +for (const auto& item : list) { + qDebug() << item; +} +``` + +--- + +## Threads & Concurrency + +### Subclassing QThread vs Worker Object +Prefer the "Worker Object" pattern over subclassing `QThread` implementation details. + +```cpp +// ❌ Business logic inside QThread::run() +class MyThread : public QThread { + void run() override { ... } +}; + +// ✅ Worker object moved to thread +QThread* thread = new QThread; +Worker* worker = new Worker; +worker->moveToThread(thread); +connect(thread, &QThread::started, worker, &Worker::process); +connect(thread, &QThread::finished, worker, &QObject::deleteLater); +thread->start(); +``` + +### GUI Thread Safety +**NEVER** access UI widgets (`QWidget` and subclasses) from a background thread. Use signals/slots to communicate updates to the main thread. + +```cpp +// ❌ Bad: accessing widget from worker thread +void Worker::onResult(Data data) { + label->setText(data.toString()); // CRASH: not in GUI thread +} + +// ✅ Good: signal to GUI thread +void Worker::onResult(Data data) { + emit resultReady(data); // connected via QueuedConnection +} +// In main thread: +connect(worker, &Worker::resultReady, this, [this](const Data& d) { + label->setText(d.toString()); +}); +``` + +### QtConcurrent for simple parallelism + +```cpp +// ✅ Good: simple parallel computation +auto future = QtConcurrent::run([data]() { + return heavyComputation(data); +}); + +// ✅ Good: map-reduce pattern +auto results = QtConcurrent::mappedReduced( + inputList, + [](const Item& item) { return process(item); }, + [](int& result, int value) { result += value; } +); +``` + +--- + +## GUI & Widgets + +### Logic Separation +Keep business logic out of UI classes (`MainWindow`, `Dialog`). UI classes should only handle display and user input forwarding. + +### Layouts +Avoid fixed sizes (`setGeometry`, `resize`). Use layouts (`QVBoxLayout`, `QGridLayout`) to handle different DPIs and window resizing gracefully. + +### Blocking Event Loop +Never execute long-running operations on the main thread (freezes GUI). +- **Bad**: `Sleep()`, `while(busy)`, synchronous network calls. +- **Good**: `QProcess`, `QThread`, `QtConcurrent`, or asynchronous APIs (`QNetworkAccessManager`). + +### High-DPI scaling + +```cpp +// ✅ Qt 5: enable high-DPI scaling +QGuiApplication::setAttribute(Qt::AA_EnableHighDpiScaling); + +// ✅ Qt 6: enabled by default, but verify icons and custom painting scale correctly +``` + +--- + +## Model/View Architecture + +### Subclass QAbstractItemModel correctly + +When implementing a custom model, the following methods are **required**: + +```cpp +class TaskModel : public QAbstractTableModel { + Q_OBJECT +public: + int rowCount(const QModelIndex& parent = {}) const override { + if (parent.isValid()) return 0; // table model has no tree + return m_tasks.size(); + } + + int columnCount(const QModelIndex& parent = {}) const override { + if (parent.isValid()) return 0; + return 3; // title, priority, status + } + + QVariant data(const QModelIndex& index, int role) const override { + if (!index.isValid() || index.row() >= m_tasks.size()) + return {}; + + if (role == Qt::DisplayRole) { + switch (index.column()) { + case 0: return m_tasks[index.row()].title; + case 1: return m_tasks[index.row()].priority; + case 2: return m_tasks[index.row()].status; + } + } + return {}; + } + + // Required for headers + QVariant headerData(int section, Qt::Orientation orientation, int role) const override { + if (role != Qt::DisplayRole) return {}; + if (orientation == Qt::Horizontal) { + switch (section) { + case 0: return "Title"; + case 1: return "Priority"; + case 2: return "Status"; + } + } + return section + 1; // row numbers + } + +private: + QVector m_tasks; +}; +``` + +### Notify the view of changes + +```cpp +// ❌ Bad: modifying data without notifying the view +void TaskModel::addTask(const Task& task) { + m_tasks.append(task); // view doesn't know about the change +} + +// ✅ Good: emit proper signals +void TaskModel::addTask(const Task& task) { + beginInsertRows({}, m_tasks.size(), m_tasks.size()); + m_tasks.append(task); + endInsertRows(); +} + +void TaskModel::updateStatus(int row, const QString& status) { + m_tasks[row].status = status; + emit dataChanged(index(row, 2), index(row, 2), {Qt::DisplayRole}); +} + +void TaskModel::clearAll() { + beginResetModel(); + m_tasks.clear(); + endResetModel(); +} +``` + +### Delegate pattern for custom rendering + +```cpp +class PriorityDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override { + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + + // Color-code by priority + QString priority = index.data().toString(); + if (priority == "High") { + opt.backgroundBrush = QColor("#ffcccc"); + } else if (priority == "Low") { + opt.backgroundBrush = QColor("#ccffcc"); + } + + QStyledItemDelegate::paint(painter, opt, index); + } +}; + +// Usage: +tableView->setItemDelegateForColumn(1, new PriorityDelegate(this)); +``` + +### Performance with large datasets + +- Use `beginInsertRows`/`endInsertRows` for batch inserts, not one row at a time. +- For 100K+ rows, consider `QSortFilterProxyModel` for filtering instead of re-querying. +- Use `model()->fetchMore()` for lazy loading / pagination. +- Avoid `Qt::UserRole + N` with heavy objects; use a lightweight key and look up externally. + +--- + +## Meta-Object System + +### Properties & Enums +Use `Q_PROPERTY` for values exposed to QML or needing introspection. +Use `Q_ENUM` to enable string conversion for enums. + +```cpp +class MyObject : public QObject { + Q_OBJECT + Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged) +public: + enum State { Idle, Running }; + Q_ENUM(State) + // ... +}; +``` + +### qobject_cast +Use `qobject_cast` for QObjects instead of `dynamic_cast`. It is faster and doesn't require RTTI. + +### Q_GADGET for value types + +```cpp +// ✅ Good: introspection without QObject overhead +struct Coordinate { + Q_GADGET + Q_PROPERTY(double x MEMBER x) + Q_PROPERTY(double y MEMBER y) +public: + double x = 0.0; + double y = 0.0; +}; +Q_DECLARE_METATYPE(Coordinate) +``` + +--- + +## QML / Qt Quick + +### C++/QML boundary design + +```cpp +// ✅ Good: expose C++ model to QML via context property (Qt 5) or QML_SINGLETON (Qt 6) + +// Qt 6: QML_ELEMENT + QML_SINGLETON +class AppSettings : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + Q_PROPERTY(QString theme READ theme WRITE setTheme NOTIFY themeChanged) +public: + QString theme() const { return m_theme; } + void setTheme(const QString& t) { + if (m_theme != t) { + m_theme = t; + emit themeChanged(); + } + } +signals: + void themeChanged(); +private: + QString m_theme; +}; +``` + +### QML performance best practices + +```qml +// ❌ Bad: JavaScript in onCompleted blocks UI thread +Component.onCompleted: { + for (var i = 0; i < 10000; i++) { + model.append({"value": i}); // slow, blocks rendering + } +} + +// ✅ Good: use C++ model, or WorkerScript for heavy JS +// Prefer C++ QAbstractListModel for large datasets +``` + +```qml +// ❌ Bad: frequent property bindings cause re-evaluation +Rectangle { + width: parent.width * 0.8 + someComplexCalc() + height: parent.height * 0.6 + anotherCalc() +} + +// ✅ Good: minimize binding complexity +Rectangle { + width: parent.width * 0.8 + height: parent.height * 0.6 +} +``` + +### QML object lifecycle + +- QML-created objects are owned by the QML engine. +- `Qt.createComponent()` + `createObject()` — caller manages lifetime. +- Use `Loader` for lazy instantiation of heavy components. +- `property var myObj: QtObject {}` — the QML engine owns it. + +```qml +// ✅ Good: Loader for conditional heavy UI +Loader { + id: detailLoader + active: selectedItem !== null + sourceComponent: active ? detailComponent : null +} +``` + +--- + +## Qt5 → Qt6 Migration + +### Key breaking changes + +| Qt 5 | Qt 6 | Notes | +|------|------|-------| +| `QList` ≠ `QVector` | `QList` = `QVector` | Unified; QList is now QVector internally | +| `QStringRef` | `QStringView` | QStringView is non-owning, more like string_view | +| `QLatin1String` | `QLatin1StringView` | Or use `u"..."_s` string literals | +| `QTextStream(stream)` | `QTextStream(&string)` | Constructor changes | +| `QMouseEvent::pos()` | `QMouseEvent::position()` | Returns QPointF instead of QPoint | +| `QWheelEvent::delta()` | `QWheelEvent::angleDelta()` | Already deprecated in Qt 5 | +| `QComboBox::activated(int)` | `QComboBox::textActivated(QString)` | Overload disambiguation | + +### CMake replaces qmake + +```cmake +# ✅ Qt 6 CMakeLists.txt +cmake_minimum_required(VERSION 3.16) +project(MyApp LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) + +find_package(Qt6 REQUIRED COMPONENTS Widgets Quick Core) + +qt_add_executable(MyApp + main.cpp + MainWindow.cpp + MainWindow.h + resources.qrc +) + +target_link_libraries(MyApp PRIVATE + Qt6::Widgets + Qt6::Quick + Qt6::Core +) +``` + +### Qt6 new APIs and improvements + +```cpp +// ✅ Qt 6: QStringView instead of QStringRef +void process(QStringView sv); // non-owning, efficient + +// ✅ Qt 6: QCalendar API for date handling +QCalendar cal(QCalendar::System::Gregorian); +QDate date = cal.dateFromParts(2024, 3, 15); + +// ✅ Qt 6: Qt Concurrent improvements +auto future = QtConcurrent::run(QThreadPool::globalInstance(), + []() { return heavyWork(); }); + +// ✅ Qt 6: Compare API for containers +QList a = {1, 2, 3}; +QList b = {1, 2, 3}; +bool eq = a == b; // works correctly in Qt 6 +``` + +### Migration checklist + +- [ ] Replace `qmake` with `CMake` (or use `qt-cmake`) +- [ ] Replace `QStringRef` with `QStringView` +- [ ] Replace deprecated event accessors (`pos()` → `position()`) +- [ ] Update signal/slot connections for overloaded signals (use `qOverload`) +- [ ] Verify `QList`/`QVector` interchangeability +- [ ] Test with Qt 6 compatibility module: `find_package(Qt6 COMPONENTS Core5Compat)` + +--- + +## Testing + +### QTest framework + +```cpp +#include +#include "parser.h" + +class TestParser : public QObject { + Q_OBJECT +private slots: + void testEmptyInput() { + Parser p(""); + QVERIFY(p.nextToken().isNull()); + } + + void testIntegerToken() { + Parser p("42"); + auto token = p.nextToken(); + QCOMPARE(token.type(), Token::Integer); + QCOMPARE(token.value().toInt(), 42); + } + + void testNegativeNumber() { + Parser p("-7"); + auto token = p.nextToken(); + QCOMPARE(token.value().toInt(), -7); + } + + // Data-driven test + void testValidTokens_data() { + QTest::addColumn("input"); + QTest::addColumn("expectedType"); + + QTest::newRow("integer") << "42" << static_cast(Token::Integer); + QTest::newRow("string") << "\"hello\"" << static_cast(Token::String); + QTest::newRow("operator") << "+" << static_cast(Token::Operator); + } + + void testValidTokens() { + QFETCH(QString, input); + QFETCH(int, expectedType); + + Parser p(input); + auto token = p.nextToken(); + QCOMPARE(token.type(), static_cast(expectedType)); + } +}; + +QTEST_MAIN(TestParser) +#include "test_parser.moc" +``` + +### GUI testing with QTest + +```cpp +class TestLoginDialog : public QObject { + Q_OBJECT +private slots: + void testLoginButtonDisabledWhenEmpty() { + LoginDialog dialog; + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + + // Initially, login button should be disabled + QPushButton* loginBtn = dialog.findChild("loginButton"); + QVERIFY(loginBtn != nullptr); + QVERIFY(!loginBtn->isEnabled()); + } + + void testLoginEnabledAfterInput() { + LoginDialog dialog; + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + + QLineEdit* userField = dialog.findChild("usernameField"); + QLineEdit* passField = dialog.findChild("passwordField"); + + QTest::keyClicks(userField, "alice"); + QTest::keyClicks(passField, "secret123"); + + QPushButton* loginBtn = dialog.findChild("loginButton"); + QVERIFY(loginBtn->isEnabled()); + } + + void testSubmitOnEnter() { + LoginDialog dialog; + dialog.show(); + QVERIFY(QTest::qWaitForWindowExposed(&dialog)); + + QLineEdit* userField = dialog.findChild("usernameField"); + QTest::keyClicks(userField, "alice"); + QTest::keyClick(userField, Qt::Key_Return); + + // Verify the dialog emitted the accepted signal + QTRY_COMPARE(dialog.result(), static_cast(QDialog::Accepted)); + } +}; +``` + +### Mock Qt objects for unit testing + +```cpp +// ✅ Good: inject dependencies for testability +class NetworkService { +public: + virtual ~NetworkService() = default; + virtual QJsonObject fetchUser(int id) = 0; +}; + +class MockNetworkService : public NetworkService { +public: + QJsonObject fetchUser(int id) override { + return m_responses.value(id, {}); + } + void setResponse(int id, const QJsonObject& json) { + m_responses[id] = json; + } +private: + QHash m_responses; +}; + +// In test: +void testProfileDisplay() { + MockNetworkService mock; + mock.setResponse(1, {{"name", "Alice"}, {"role", "admin"}}); + + ProfileController controller(&mock); + controller.loadProfile(1); + QCOMPARE(controller.name(), "Alice"); + QCOMPARE(controller.role(), "admin"); +} +``` + +### CI integration + +```bash +# Qt 6 test runner +mkdir build && cd build +cmake .. -DCMAKE_BUILD_TYPE=Debug -DBUILD_TESTING=ON +cmake --build . +ctest --output-on-failure + +# With Xvfb for GUI tests on headless CI +xvfb-run -a ctest --output-on-failure +``` + +--- + +## Review Checklist + +### Memory +- [ ] Is parent-child relationship correct? Are dangling pointers avoided (using `QPointer`)? +- [ ] No double ownership (parent + smart pointer) +- [ ] `deleteLater()` used instead of `delete` in slots + +### Signals & Slots +- [ ] Function pointer syntax used (compile-time checked) +- [ ] Lambda connections have context object for auto-disconnect +- [ ] No signal loops (guard with equality check or blockSignals) +- [ ] Proper disconnect when changing signal sources + +### Threads +- [ ] Is UI accessed only from main thread? +- [ ] Are long tasks offloaded (QThread worker, QtConcurrent)? +- [ ] Worker object pattern preferred over QThread subclassing +- [ ] Proper cleanup: thread quit + wait before delete + +### Strings & Containers +- [ ] `QStringLiteral` or `u"..."_s` used for compile-time strings +- [ ] `const &` used for read-only container access +- [ ] No implicit detach in loops (use const iterators or range-for with const ref) + +### Model/View +- [ ] begin/end Insert/Remove/Reset signals emitted correctly +- [ ] dataChanged emitted for individual item updates +- [ ] Delegate used for custom rendering (not subclassing view) + +### QML +- [ ] C++/QML boundary uses QML_ELEMENT (Qt 6) or registered types +- [ ] Heavy computation not in QML JS +- [ ] Loader used for conditional/lazy component instantiation + +### Testing +- [ ] QTest unit tests for core logic +- [ ] GUI tests use QTest::keyClicks / qWaitForWindowExposed +- [ ] Dependencies injected for mockability +- [ ] Tests run in CI (with Xvfb for GUI tests) + +### Style +- [ ] Naming conventions (camelCase for methods, PascalCase for classes) +- [ ] Resources loaded from `.qrc` +- [ ] `Q_OBJECT` macro present in all QObject subclasses diff --git a/examples/skills_code_review_agent/skills/code-review/reference/react.md b/examples/skills_code_review_agent/skills/code-review/reference/react.md new file mode 100644 index 000000000..1cce2fc68 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/react.md @@ -0,0 +1,871 @@ +# React Code Review Guide + +React 审查重点:Hooks 规则、性能优化的适度性、组件设计、以及现代 React 19/RSC 模式。 + +## 目录 + +- [基础 Hooks 规则](#基础-hooks-规则) +- [useEffect 模式](#useeffect-模式) +- [useMemo / useCallback](#usememo--usecallback) +- [组件设计](#组件设计) +- [Error Boundaries & Suspense](#error-boundaries--suspense) +- [Server Components (RSC)](#server-components-rsc) +- [React 19 Actions & Forms](#react-19-actions--forms) +- [Suspense & Streaming SSR](#suspense--streaming-ssr) +- [TanStack Query v5](#tanstack-query-v5) +- [Review Checklists](#review-checklists) + +--- + +## 基础 Hooks 规则 + +```tsx +// ❌ 条件调用 Hooks — 违反 Hooks 规则 +function BadComponent({ isLoggedIn }) { + if (isLoggedIn) { + const [user, setUser] = useState(null); // Error! + } + return
...
; +} + +// ✅ Hooks 必须在组件顶层调用 +function GoodComponent({ isLoggedIn }) { + const [user, setUser] = useState(null); + if (!isLoggedIn) return ; + return
{user?.name}
; +} +``` + +--- + +## useEffect 模式 + +```tsx +// ❌ 依赖数组缺失或不完整 +function BadEffect({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + fetchUser(userId).then(setUser); + }, []); // 缺少 userId 依赖! +} + +// ✅ 完整的依赖数组 +function GoodEffect({ userId }) { + const [user, setUser] = useState(null); + useEffect(() => { + let cancelled = false; + fetchUser(userId).then(data => { + if (!cancelled) setUser(data); + }); + return () => { cancelled = true; }; // 清理函数 + }, [userId]); +} + +// ❌ useEffect 用于派生状态(反模式) +function BadDerived({ items }) { + const [filteredItems, setFilteredItems] = useState([]); + useEffect(() => { + setFilteredItems(items.filter(i => i.active)); + }, [items]); // 不必要的 effect + 额外渲染 + return ; +} + +// ✅ 直接在渲染时计算,或用 useMemo +function GoodDerived({ items }) { + const filteredItems = useMemo( + () => items.filter(i => i.active), + [items] + ); + return ; +} + +// ❌ useEffect 用于事件响应 +function BadEventEffect() { + const [query, setQuery] = useState(''); + useEffect(() => { + if (query) { + analytics.track('search', { query }); // 应该在事件处理器中 + } + }, [query]); +} + +// ✅ 在事件处理器中执行副作用 +function GoodEvent() { + const [query, setQuery] = useState(''); + const handleSearch = (q: string) => { + setQuery(q); + analytics.track('search', { query: q }); + }; +} +``` + +--- + +## useMemo / useCallback + +```tsx +// ❌ 过度优化 — 常量不需要 useMemo +function OverOptimized() { + const config = useMemo(() => ({ timeout: 5000 }), []); // 无意义 + const handleClick = useCallback(() => { + console.log('clicked'); + }, []); // 如果不传给 memo 组件,无意义 +} + +// ✅ 只在需要时优化 +function ProperlyOptimized() { + const config = { timeout: 5000 }; // 简单对象直接定义 + const handleClick = () => console.log('clicked'); +} + +// ❌ useCallback 依赖总是变化 +function BadCallback({ data }) { + // data 每次渲染都是新对象,useCallback 无效 + const process = useCallback(() => { + return data.map(transform); + }, [data]); +} + +// ✅ useMemo + useCallback 配合 React.memo 使用 +const MemoizedChild = React.memo(function Child({ onClick, items }) { + return
{items.length}
; +}); + +function Parent({ rawItems }) { + const items = useMemo(() => processItems(rawItems), [rawItems]); + const handleClick = useCallback(() => { + console.log(items.length); + }, [items]); + return ; +} +``` + +--- + +## 组件设计 + +```tsx +// ❌ 在组件内定义组件 — 每次渲染都创建新组件 +function BadParent() { + function ChildComponent() { // 每次渲染都是新函数! + return
child
; + } + return ; +} + +// ✅ 组件定义在外部 +function ChildComponent() { + return
child
; +} +function GoodParent() { + return ; +} + +// ❌ Props 总是新对象引用 +function BadProps() { + return ( + {}} // 每次渲染新函数 + /> + ); +} + +// ✅ 稳定的引用 +const style = { color: 'red' }; +function GoodProps() { + const handleClick = useCallback(() => {}, []); + return ; +} +``` + +--- + +## Error Boundaries & Suspense + +```tsx +// ❌ 没有错误边界 +function BadApp() { + return ( + }> + {/* 错误会导致整个应用崩溃 */} + + ); +} + +// ✅ Error Boundary 包裹 Suspense +function GoodApp() { + return ( + }> + }> + + + + ); +} +``` + +--- + +## Server Components (RSC) + +```tsx +// ❌ 在 Server Component 中使用客户端特性 +// app/page.tsx (Server Component by default) +function BadServerComponent() { + const [count, setCount] = useState(0); // Error! No hooks in RSC + return ; +} + +// ✅ 交互逻辑提取到 Client Component +// app/counter.tsx +'use client'; +function Counter() { + const [count, setCount] = useState(0); + return ; +} + +// app/page.tsx (Server Component) +async function GoodServerComponent() { + const data = await fetchData(); // 可以直接 await + return ( +
+

{data.title}

+ {/* 客户端组件 */} +
+ ); +} + +// ❌ 'use client' 放置不当 — 整个树都变成客户端 +// layout.tsx +'use client'; // 这会让所有子组件都成为客户端组件 +export default function Layout({ children }) { ... } + +// ✅ 只在需要交互的组件使用 'use client' +// 将客户端逻辑隔离到叶子组件 +``` + +--- + +## React 19 Actions & Forms + +React 19 引入了 Actions 系统和新的表单处理 Hooks,简化异步操作和乐观更新。 + +### useActionState + +```tsx +// ❌ 传统方式:多个状态变量 +function OldForm() { + const [isPending, setIsPending] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + + const handleSubmit = async (formData: FormData) => { + setIsPending(true); + setError(null); + try { + const result = await submitForm(formData); + setData(result); + } catch (e) { + setError(e.message); + } finally { + setIsPending(false); + } + }; +} + +// ✅ React 19: useActionState 统一管理 +import { useActionState } from 'react'; + +function NewForm() { + const [state, formAction, isPending] = useActionState( + async (prevState, formData: FormData) => { + try { + const result = await submitForm(formData); + return { success: true, data: result }; + } catch (e) { + return { success: false, error: e.message }; + } + }, + { success: false, data: null, error: null } + ); + + return ( + + + + {state.error &&

{state.error}

} + + ); +} +``` + +### useFormStatus + +```tsx +// ❌ Props 透传表单状态 +function BadSubmitButton({ isSubmitting }) { + return ; +} + +// ✅ useFormStatus 访问父
状态(无需 props) +import { useFormStatus } from 'react-dom'; + +function SubmitButton() { + const { pending, data, method, action } = useFormStatus(); + // 注意:必须在 内部的子组件中使用 + return ( + + ); +} + +// ❌ useFormStatus 在 form 同级组件中调用——不工作 +function BadForm() { + const { pending } = useFormStatus(); // 这里无法获取状态! + return ( + + +
+ ); +} + +// ✅ useFormStatus 必须在 form 的子组件中 +function GoodForm() { + return ( +
+ {/* useFormStatus 在这里面调用 */} + + ); +} +``` + +### useOptimistic + +```tsx +// ❌ 等待服务器响应再更新 UI +function SlowLike({ postId, likes }) { + const [likeCount, setLikeCount] = useState(likes); + const [isPending, setIsPending] = useState(false); + + const handleLike = async () => { + setIsPending(true); + const newCount = await likePost(postId); // 等待... + setLikeCount(newCount); + setIsPending(false); + }; +} + +// ✅ useOptimistic 即时反馈,失败自动回滚 +import { useOptimistic } from 'react'; + +function FastLike({ postId, likes }) { + const [optimisticLikes, addOptimisticLike] = useOptimistic( + likes, + (currentLikes, increment: number) => currentLikes + increment + ); + + const handleLike = async () => { + addOptimisticLike(1); // 立即更新 UI + try { + await likePost(postId); // 后台同步 + } catch { + // React 自动回滚到 likes 原值 + } + }; + + return ; +} +``` + +### Server Actions (Next.js 15+) + +```tsx +// ❌ 客户端调用 API +'use client'; +function ClientForm() { + const handleSubmit = async (formData: FormData) => { + const res = await fetch('/api/submit', { + method: 'POST', + body: formData, + }); + // ... + }; +} + +// ✅ Server Action + useActionState +// actions.ts +'use server'; +export async function createPost(prevState: any, formData: FormData) { + const title = formData.get('title'); + await db.posts.create({ title }); + revalidatePath('/posts'); + return { success: true }; +} + +// form.tsx +'use client'; +import { createPost } from './actions'; + +function PostForm() { + const [state, formAction, isPending] = useActionState(createPost, null); + return ( +
+ + + + ); +} +``` + +--- + +## Suspense & Streaming SSR + +Suspense 和 Streaming 是 React 18+ 的核心特性,在 2025 年的 Next.js 15 等框架中广泛使用。 + +### 基础 Suspense + +```tsx +// ❌ 传统加载状态管理 +function OldComponent() { + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchData().then(setData).finally(() => setIsLoading(false)); + }, []); + + if (isLoading) return ; + return ; +} + +// ✅ Suspense 声明式加载状态 +function NewComponent() { + return ( + }> + {/* 内部使用 use() 或支持 Suspense 的数据获取 */} + + ); +} +``` + +### 多个独立 Suspense 边界 + +```tsx +// ❌ 单一边界——所有内容一起加载 +function BadLayout() { + return ( + }> +
+ {/* 慢 */} + {/* 快 */} + + ); +} + +// ✅ 独立边界——各部分独立流式传输 +function GoodLayout() { + return ( + <> +
{/* 立即显示 */} +
+ }> + {/* 独立加载 */} + + }> + {/* 独立加载 */} + +
+ + ); +} +``` + +### Next.js 15 Streaming + +```tsx +// app/page.tsx - 自动 Streaming +export default async function Page() { + // 这个 await 不会阻塞整个页面 + const data = await fetchSlowData(); + return
{data}
; +} + +// app/loading.tsx - 自动 Suspense 边界 +export default function Loading() { + return ; +} +``` + +### use() Hook (React 19) + +```tsx +// ✅ 在组件中读取 Promise +import { use } from 'react'; + +function Comments({ commentsPromise }) { + const comments = use(commentsPromise); // 自动触发 Suspense + return ( +
    + {comments.map(c =>
  • {c.text}
  • )} +
+ ); +} + +// 父组件创建 Promise,子组件消费 +function Post({ postId }) { + const commentsPromise = fetchComments(postId); // 不 await + return ( +
+ + }> + + +
+ ); +} +``` + +--- + +## TanStack Query v5 + +TanStack Query 是 React 生态中最流行的数据获取库,v5 是当前稳定版本。 + +### 基础配置 + +```tsx +// ❌ 不正确的默认配置 +const queryClient = new QueryClient(); // 默认配置可能不适合 + +// ✅ 生产环境推荐配置 +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, // 5 分钟内数据视为新鲜 + gcTime: 1000 * 60 * 30, // 30 分钟后垃圾回收(v5 重命名) + retry: 3, + refetchOnWindowFocus: false, // 根据需求决定 + }, + }, +}); +``` + +### queryOptions (v5 新增) + +```tsx +// ❌ 重复定义 queryKey 和 queryFn +function Component1() { + const { data } = useQuery({ + queryKey: ['users', userId], + queryFn: () => fetchUser(userId), + }); +} + +function prefetchUser(queryClient, userId) { + queryClient.prefetchQuery({ + queryKey: ['users', userId], // 重复! + queryFn: () => fetchUser(userId), // 重复! + }); +} + +// ✅ queryOptions 统一定义,类型安全 +import { queryOptions } from '@tanstack/react-query'; + +const userQueryOptions = (userId: string) => + queryOptions({ + queryKey: ['users', userId], + queryFn: () => fetchUser(userId), + }); + +function Component1({ userId }) { + const { data } = useQuery(userQueryOptions(userId)); +} + +function prefetchUser(queryClient, userId) { + queryClient.prefetchQuery(userQueryOptions(userId)); +} + +// getQueryData 也是类型安全的 +const user = queryClient.getQueryData(userQueryOptions(userId).queryKey); +``` + +### 常见陷阱 + +```tsx +// ❌ staleTime 为 0 导致过度请求 +useQuery({ + queryKey: ['data'], + queryFn: fetchData, + // staleTime 默认为 0,每次组件挂载都会 refetch +}); + +// ✅ 设置合理的 staleTime +useQuery({ + queryKey: ['data'], + queryFn: fetchData, + staleTime: 1000 * 60, // 1 分钟内不会重新请求 +}); + +// ❌ 在 queryFn 中使用不稳定的引用 +function BadQuery({ filters }) { + useQuery({ + queryKey: ['items'], // queryKey 没有包含 filters! + queryFn: () => fetchItems(filters), // filters 变化不会触发重新请求 + }); +} + +// ✅ queryKey 包含所有影响数据的参数 +function GoodQuery({ filters }) { + useQuery({ + queryKey: ['items', filters], // filters 是 queryKey 的一部分 + queryFn: () => fetchItems(filters), + }); +} +``` + +### useSuspenseQuery + +> **重要限制**:useSuspenseQuery 与 useQuery 有显著差异,选择前需了解其限制。 + +#### useSuspenseQuery 的限制 + +| 特性 | useQuery | useSuspenseQuery | +|------|----------|------------------| +| `enabled` 选项 | ✅ 支持 | ❌ 不支持 | +| `placeholderData` | ✅ 支持 | ❌ 不支持 | +| `data` 类型 | `T \| undefined` | `T`(保证有值)| +| 错误处理 | `error` 属性 | 抛出到 Error Boundary | +| 加载状态 | `isLoading` 属性 | 挂起到 Suspense | + +#### 不支持 enabled 的替代方案 + +```tsx +// ❌ 使用 useQuery + enabled 实现条件查询 +function BadSuspenseQuery({ userId }) { + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + enabled: !!userId, // useSuspenseQuery 不支持 enabled! + }); +} + +// ✅ 组件组合实现条件渲染 +function GoodSuspenseQuery({ userId }) { + // useSuspenseQuery 保证 data 是 T 不是 T | undefined + const { data } = useSuspenseQuery({ + queryKey: ['user', userId], + queryFn: () => fetchUser(userId), + }); + return ; +} + +function Parent({ userId }) { + if (!userId) return ; + return ( + }> + + + ); +} +``` + +#### 错误处理差异 + +```tsx +// ❌ useSuspenseQuery 没有 error 属性 +function BadErrorHandling() { + const { data, error } = useSuspenseQuery({...}); + if (error) return ; // error 总是 null! +} + +// ✅ 使用 Error Boundary 处理错误 +function GoodErrorHandling() { + return ( + }> + }> + + + + ); +} + +function DataComponent() { + // 错误会抛出到 Error Boundary + const { data } = useSuspenseQuery({ + queryKey: ['data'], + queryFn: fetchData, + }); + return ; +} +``` + +#### 何时选择 useSuspenseQuery + +```tsx +// ✅ 适合场景: +// 1. 数据总是需要的(无条件查询) +// 2. 组件必须有数据才能渲染 +// 3. 使用 React 19 的 Suspense 模式 +// 4. 服务端组件 + 客户端 hydration + +// ❌ 不适合场景: +// 1. 条件查询(根据用户操作触发) +// 2. 需要 placeholderData 或初始数据 +// 3. 需要在组件内处理 loading/error 状态 +// 4. 多个查询有依赖关系 + +// ✅ 多个独立查询用 useSuspenseQueries +function MultipleQueries({ userId }) { + const [userQuery, postsQuery] = useSuspenseQueries({ + queries: [ + { queryKey: ['user', userId], queryFn: () => fetchUser(userId) }, + { queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) }, + ], + }); + // 两个查询并行执行,都完成后组件渲染 + return ; +} +``` + +### 乐观更新 (v5 简化) + +```tsx +// ❌ 手动管理缓存的乐观更新(复杂) +const mutation = useMutation({ + mutationFn: updateTodo, + onMutate: async (newTodo) => { + await queryClient.cancelQueries({ queryKey: ['todos'] }); + const previousTodos = queryClient.getQueryData(['todos']); + queryClient.setQueryData(['todos'], (old) => [...old, newTodo]); + return { previousTodos }; + }, + onError: (err, newTodo, context) => { + queryClient.setQueryData(['todos'], context.previousTodos); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, +}); + +// ✅ v5 简化:使用 variables 进行乐观 UI +function TodoList() { + const { data: todos } = useQuery(todosQueryOptions); + const { mutate, variables, isPending } = useMutation({ + mutationFn: addTodo, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['todos'] }); + }, + }); + + return ( +
    + {todos?.map(todo => )} + {/* 乐观显示正在添加的 todo */} + {isPending && } +
+ ); +} +``` + +### v5 状态字段变化 + +```tsx +// v4: isLoading 表示首次加载或后续获取 +// v5: isPending 表示没有数据,isLoading = isPending && isFetching + +const { data, isPending, isFetching, isLoading } = useQuery({...}); + +// isPending: 缓存中没有数据(首次加载) +// isFetching: 正在请求中(包括后台刷新) +// isLoading: isPending && isFetching(首次加载中) + +// ❌ v4 代码直接迁移 +if (isLoading) return ; // v5 中行为可能不同 + +// ✅ 明确意图 +if (isPending) return ; // 没有数据时显示加载 +// 或 +if (isLoading) return ; // 首次加载中 +``` + +--- + +## Review Checklists + +### Hooks 规则 + +- [ ] Hooks 在组件/自定义 Hook 顶层调用 +- [ ] 没有条件/循环中调用 Hooks +- [ ] useEffect 依赖数组完整 +- [ ] useEffect 有清理函数(订阅/定时器/请求) +- [ ] 没有用 useEffect 计算派生状态 + +### 性能优化(适度原则) + +- [ ] useMemo/useCallback 只用于真正需要的场景 +- [ ] React.memo 配合稳定的 props 引用 +- [ ] 没有在组件内定义子组件 +- [ ] 没有在 JSX 中创建新对象/函数(除非传给非 memo 组件) +- [ ] 长列表使用虚拟化(react-window/react-virtual) + +### 组件设计 + +- [ ] 组件职责单一,不超过 200 行 +- [ ] 逻辑与展示分离(Custom Hooks) +- [ ] Props 接口清晰,使用 TypeScript +- [ ] 避免 Props Drilling(考虑 Context 或组合) + +### 状态管理 + +- [ ] 状态就近原则(最小必要范围) +- [ ] 复杂状态用 useReducer +- [ ] 全局状态用 Context 或状态库 +- [ ] 避免不必要的状态(派生 > 存储) + +### 错误处理 + +- [ ] 关键区域有 Error Boundary +- [ ] Suspense 配合 Error Boundary 使用 +- [ ] 异步操作有错误处理 + +### Server Components (RSC) + +- [ ] 'use client' 只用于需要交互的组件 +- [ ] Server Component 不使用 Hooks/事件处理 +- [ ] 客户端组件尽量放在叶子节点 +- [ ] 数据获取在 Server Component 中进行 + +### React 19 Forms + +- [ ] 使用 useActionState 替代多个 useState +- [ ] useFormStatus 在 form 子组件中调用 +- [ ] useOptimistic 不用于关键业务(支付等) +- [ ] Server Action 正确标记 'use server' + +### Suspense & Streaming + +- [ ] 按用户体验需求划分 Suspense 边界 +- [ ] 每个 Suspense 有对应的 Error Boundary +- [ ] 提供有意义的 fallback(骨架屏 > Spinner) +- [ ] 避免在 layout 层级 await 慢数据 + +### TanStack Query + +- [ ] queryKey 包含所有影响数据的参数 +- [ ] 设置合理的 staleTime(不是默认 0) +- [ ] useSuspenseQuery 不使用 enabled +- [ ] Mutation 成功后 invalidate 相关查询 +- [ ] 理解 isPending vs isLoading 区别 + +### 测试 + +- [ ] 使用 @testing-library/react +- [ ] 用 screen 查询元素 +- [ ] 用 userEvent 代替 fireEvent +- [ ] 优先使用 *ByRole 查询 +- [ ] 测试行为而非实现细节 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/ruby.md b/examples/skills_code_review_agent/skills/code-review/reference/ruby.md new file mode 100644 index 000000000..79bcc31c7 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/ruby.md @@ -0,0 +1,964 @@ +# Ruby and Rails Code Review Guide + +> Code review guidance for Ruby 3.4+/4.0 and Rails 8.x, with emphasis on Ruby semantics, controller boundaries, Active Record correctness, query performance, background jobs, and tests. + +## Table of Contents + +- [Scope and Version Awareness](#scope-and-version-awareness) +- [Ruby Semantics and API Contracts](#ruby-semantics-and-api-contracts) +- [Collections, Mutation, and Nil](#collections-mutation-and-nil) +- [Exceptions and Resource Safety](#exceptions-and-resource-safety) +- [Rails Controllers and Security](#rails-controllers-and-security) +- [Active Record Correctness](#active-record-correctness) +- [Query Performance](#query-performance) +- [Transactions and Concurrency](#transactions-and-concurrency) +- [Active Job and External Side Effects](#active-job-and-external-side-effects) +- [Testing and Tooling](#testing-and-tooling) +- [Review Checklist](#review-checklist) +- [References](#references) + +--- + +## Scope and Version Awareness + +This guide targets maintained Ruby 3.4/4.0 applications and Rails 8.x. Before applying version-specific advice, inspect `.ruby-version`, `Gemfile.lock`, `config.load_defaults`, the database adapter, queue adapter, and CI matrix. + +Do not require a newer API only because it exists. For example, Rails 8's `params.expect` is a concise strong-parameters API, while an existing explicit `require(...).permit(...)` contract can still be correct. Ruby implementation details also differ across MRI, JRuby, and TruffleRuby, so do not treat MRI's Global VM Lock as a substitute for synchronization. + +Review questions: +- Which Ruby, Rails, database, and queue versions are actually deployed? +- Does the change rely on a default that differs across Rails versions or adapters? +- Are upgrade-only recommendations separated from correctness or security findings? + +--- + +## Ruby Semantics and API Contracts + +### Remember Ruby Truthiness + +Only `false` and `nil` are falsey. Values such as `0`, `""`, and `[]` are truthy, so code translated from other languages can silently choose the wrong branch. + +```ruby +# Bad: zero is truthy, so this does not test whether any rows were found. +if relation.count + publish_report +end + +# Good: state the condition directly and let the database answer efficiently. +publish_report if relation.exists? +``` + +Review questions: +- Does a condition rely on `0`, an empty string, or an empty collection being falsey? +- Would `empty?`, `any?`, `exists?`, `present?`, or an explicit comparison communicate the intent? +- Is a database relation being loaded only to test whether a row exists? + +### Preserve Predicate and Comparison Contracts + +Predicate methods should normally end in `?` and return a boolean. Review custom equality carefully: `==`, `eql?`, `equal?`, and `hash` have different contracts. + +```ruby +class Money + attr_reader :amount, :currency + + def initialize(amount, currency) + @amount = amount + @currency = currency + end + + def ==(other) + other.is_a?(Money) && + amount == other.amount && + currency == other.currency + end + + alias eql? == + + def hash + [amount, currency].hash + end +end +``` + +Review questions: +- If `eql?` is overridden, is `hash` consistent so Hash and Set lookups work? +- Is `equal?` being used accidentally where value equality is intended? +- Does a predicate return a domain object or `nil` when callers expect `true` or `false`? + +### Keep Keyword Arguments Explicit + +Ruby separates positional and keyword arguments. Broad `**options` parameters can hide typos and weaken public API contracts. + +```ruby +# Bad: silently accepts misspelled or unsupported options. +def charge(customer, **options) + gateway.charge(customer, options) +end + +# Good: required and optional keywords are visible to callers. +def charge(customer, amount:, currency: "USD", idempotency_key:) + gateway.charge( + customer, + amount: amount, + currency: currency, + idempotency_key: idempotency_key + ) +end +``` + +Use `...` for transparent delegation only when forwarding every positional argument, keyword argument, and block is intentional. + +Review questions: +- Are required keywords declared without defaults? +- Does a wrapper preserve keywords and blocks correctly? +- Would accepting arbitrary keywords turn a caller typo into delayed or silent behavior? + +### Keep Metaprogramming Boundaries Small + +Ruby makes dynamic APIs easy to build, but reviewers should treat `eval`, `class_eval` with strings, `send`, `const_get`, and dynamic constantization as trust boundaries. + +```ruby +# Bad: user input controls the method that is invoked. +account.send(params[:operation]) + +# Good: map external input to a fixed internal operation. +OPERATIONS = { + "activate" => :activate!, + "suspend" => :suspend! +}.freeze + +operation = OPERATIONS.fetch(params[:operation]) +account.public_send(operation) +``` + +Review questions: +- Can external input select a method, class, constant, template, or code string? +- Is a fixed allowlist used before `public_send` or `constantize`? +- Is metaprogramming isolated and covered by contract tests? + +### Treat Deserialization and Shell Execution as Trust Boundaries + +`Marshal.load` can instantiate Ruby objects and must not receive untrusted bytes. Prefer data-only formats such as JSON, and use `YAML.safe_load` or `Psych.safe_load` with an explicit allowlist when YAML is required. Build process arguments as separate values instead of interpolating a shell command. + +```ruby +# Bad: untrusted input can trigger unsafe object deserialization. +payload = Marshal.load(request.raw_post) + +# Good: parse data-only JSON values, then validate their shape. +payload = JSON.parse(request.raw_post) + +# Good: permit only the YAML types and aliases the format requires. +payload = YAML.safe_load(request.raw_post, permitted_classes: [], aliases: false) + +# Bad: a filename can inject additional shell syntax. +system("convert #{uploaded_path} output.png") + +# Better: argv form bypasses shell parsing, but not path validation. +system("convert", uploaded_path, "output.png") +``` + +Review questions: +- Can request, cache, cookie, queue, or file data reach `Marshal.load`, `YAML.load`, or unrestricted `Psych.load`? +- Is parsed data validated before it selects a class, method, path, or database operation? +- Are `permitted_classes` and `aliases` as restrictive as the YAML contract allows? +- Are subprocess arguments passed separately, with the path constrained to the intended upload root and exit status/timeouts handled? + +--- + +## Collections, Mutation, and Nil + +### Avoid Shared Collection Defaults + +`Hash.new(object)` reuses the same object for every missing key. Use a block when each key needs independent mutable state. + +```ruby +# Bad: all missing keys share one array. +grouped = Hash.new([]) +grouped[:paid] << 1 +grouped[:failed] << 2 +# The keys were never assigned; both reads mutated the same hidden default. +grouped # => {} +grouped[:paid] # => [1, 2] +grouped[:failed] # => [1, 2] + +# Bad: every element refers to the same array. +matrix = Array.new(3, []) + +# Good: the block creates an independent array for each element. +matrix = Array.new(3) { [] } + +# Good: each missing key receives its own array. +grouped = Hash.new { |hash, key| hash[key] = [] } +grouped[:paid] << 1 +grouped[:failed] << 2 +``` + +Review questions: +- Does a Hash default contain a mutable Array, Hash, or String? +- Is the default block storing the generated value back into the hash when that is intended? +- Could a shared constant or class attribute be mutated across requests or tests? + +### Treat Bang Methods as Semantic Signals, Not Guarantees + +Ruby's `!` convention usually means a more dangerous or mutating counterpart, but it does not universally mean "raises on failure." Many mutating methods return `nil` when no change was made. + +```ruby +name = "ready" + +# Bad: `downcase!` returns nil when the string is already lowercase. +normalized = name.downcase! + +# Good: use the non-bang form when the return value is the result. +normalized = name.downcase +``` + +Review questions: +- Is code relying on a bang method's return value without checking its contract? +- Is in-place mutation visible to every owner of the object? +- Would a non-mutating transformation make the data flow clearer? + +### Do Not Use Safe Navigation to Hide Broken Invariants + +`&.` is useful for genuinely optional relationships. Repeated safe navigation can also turn missing required data into a late `nil` and hide the source of an invalid state. + +```ruby +# Bad when every order must have a customer and email. +recipient = order&.customer&.email + +# Good: enforce required associations and fail close to the invalid state. +recipient = order.customer.email +``` + +Review questions: +- Is nil part of the domain, or is it evidence of a broken invariant? +- Should the model, database, or caller guarantee presence instead? +- Does `dig` or `&.` make an error disappear only for it to fail later? + +### Choose the Enumerable Operation That Matches the Intent + +Use `each` for side effects, `map` for transformation, `filter_map` for transform-and-compact, and `each_with_object` for accumulation. Avoid chains that allocate intermediate arrays in hot paths. + +```ruby +# Bad: uses map for side effects and leaves an unused array behind. +orders.map { |order| AuditLog.write(order) } + +# Good: side effect is explicit. +orders.each { |order| AuditLog.write(order) } + +# Good: transform and discard nil values in one pass. +emails = users.filter_map { |user| user.email if user.subscribed? } +``` + +Review questions: +- Is `map` used when its returned collection is ignored? +- Does a long chain allocate large intermediate collections? +- Is a relation converted to an Array before the database has applied filtering, ordering, or aggregation? + +--- + +## Exceptions and Resource Safety + +### Rescue the Smallest Expected Failure + +A bare `rescue` catches `StandardError` and its subclasses. It still combines many unrelated failures, including programming errors, database errors, timeouts, and validation errors. + +```ruby +# Bad: converts every ordinary application failure into the same response. +def create + order = Orders::Create.call(order_params) + render json: order +rescue => error + Rails.logger.info(error.message) + render json: { error: error.message }, status: :internal_server_error +end + +# Good: translate expected errors at the boundary and let unexpected errors +# reach centralized reporting. +rescue_from Orders::InvalidOrder, with: :render_invalid_order + +def create + order = Orders::Create.call(order_params) + render json: { id: order.id, status: order.status }, status: :created +end +``` + +Review questions: +- Is the rescued exception expected at this boundary? +- Is the protected block narrow enough to avoid catching unrelated bugs? +- Does the response expose an internal exception message, SQL, path, token, or vendor detail? + +### Preserve the Original Backtrace and Cause + +Use bare `raise` to re-raise the current exception. `raise error` also re-raises the same exception object and preserves its existing backtrace, but bare `raise` makes that intent clearer. When translating to a domain error, report the original exception object and keep its cause chain. + +```ruby +begin + gateway.charge(payload) +rescue Gateway::Timeout => error + Rails.error.report(error, context: { order_id: order.id }) + raise Payments::Unavailable, "payment gateway timed out" +end +``` + +Inside a `rescue` block, raising a new exception keeps the rescued exception as its implicit `cause`. Constructing a new exception, such as `raise Payments::Unavailable, error.message`, creates a new backtrace; do that only when the boundary needs a domain-specific error. + +See [Error Handling Guide](cross-cutting/error-handling-principles.md) for boundary, cause-chain, and reporting principles shared across ecosystems. + +Review questions: +- Is structured context logged without secrets or full payment data? +- Does the error retain a useful cause chain? +- Is the log level appropriate, and will the error be reported exactly once? + +### Use Blocks or Ensure for Cleanup + +Prefer block-based resource APIs because they close resources even when an exception is raised. Use `ensure` when no block form exists. + +```ruby +# Good: File.open closes the handle after the block. +File.open(path, "rb") do |file| + checksum(file) +end + +connection = pool.checkout +begin + consume(connection) +ensure + pool.checkin(connection) +end +``` + +Review questions: +- Are files, locks, temporary directories, and checked-out resources released on every path? +- Does an `ensure` block accidentally `return` and suppress an exception? +- Is retry logic bounded and limited to transient failures? + +--- + +## Rails Controllers and Security + +### Require and Permit Parameters Explicitly + +Rails 8 provides `params.expect` to require the expected shape and allow only named attributes. For older supported Rails versions, follow the established `require(...).permit(...)` convention. + +```ruby +# Bad: unfiltered controller parameters at a mass-assignment boundary. +Order.create!(params[:order]) + +# Bad: permits current and future attributes, including sensitive columns. +params.expect(order: {}) + +# Good: allowlist the flat request contract. +def order_params + params.expect(order: [:product_id, :quantity, :shipping_address_id]) +end + +# Bad: a single array does not express an array of nested parameter hashes. +params.expect(order: [:product_id, line_items_attributes: [:id, :sku, :quantity]]) + +# Good: use the double-array form for nested resource arrays. +params.expect(order: [ + :product_id, + line_items_attributes: [[:id, :sku, :quantity]] +]) +``` + +Review questions: +- Are authorization-sensitive fields such as `user_id`, `role`, `paid`, or `admin` excluded? +- Do nested resource arrays use the `[[...]]` form, rather than treating them as a flat nested hash? +- Are nested hashes and arrays permitted with the exact expected shape? +- Is `permit!`, `to_unsafe_h`, or an empty hash permission widening the contract? + +### Parameterize Active Record Queries + +Never interpolate request data into SQL fragments. Prefer hash conditions or placeholders, and allowlist dynamic identifiers such as sort columns. + +```ruby +# Bad: SQL injection. +Order.where("status = '#{params[:status]}'") + +# Good: hash conditions are parameterized. +Order.where(status: params[:status]) + +# Good: placeholders for non-equality predicates. +Order.where("total_cents >= ?", params[:minimum_cents]) + +# Bad: values are quoted, but the column name is still attacker-controlled. +Order.order(Arel.sql("#{params[:sort]} DESC")) + +# Good: map external values to fixed SQL identifiers. +SORTS = { + "newest" => { created_at: :desc }, + "total" => { total_cents: :desc } +}.freeze + +Order.order(SORTS.fetch(params[:sort], SORTS.fetch("newest"))) +``` + +Review questions: +- Does user input reach `where`, `order`, `select`, `joins`, `having`, or `find_by_sql` as a string? +- Is `Arel.sql` used only for a developer-controlled literal? +- Are dynamic columns and directions selected from an allowlist? + +See [SQL Injection Guide](cross-cutting/sql-injection-prevention.md) for parameterization and dynamic-identifier patterns across ORMs. + +### Keep Rendering and Redirects on an Allowlist + +Rendering an Active Record object directly can expose newly added columns without a controller change. Use a serializer or explicit field list. Treat redirects, file paths, and HTML safety overrides as security boundaries. + +```ruby +# Bad: future columns can become part of the API response. +render json: order + +# Good: response fields are intentional and versionable. +render json: { + id: order.id, + status: order.status, + total_cents: order.total_cents +}, status: :created + +# Bad: open redirect when a user controls the destination. +redirect_to params[:return_to], allow_other_host: true + +# Good: redirect to an application route. +redirect_to order_path(order) +``` + +Review questions: +- Are secrets, password digests, tokens, internal notes, or personal data serialized? +- Does `html_safe`, `raw`, or `safe_join` receive untrusted content? +- Can user input control an external redirect, file download path, or response header? + +### Keep Authentication, Authorization, and Scoping Separate + +Authentication establishes who the caller is; authorization decides what that caller may do. Loading a record by global ID before authorization can create an insecure direct object reference. + +```ruby +# Bad: any authenticated user may be able to load another user's order. +order = Order.find(params[:id]) + +# Good: scope the lookup through the authorized owner or policy scope. +order = current_user.orders.find(params[:id]) +``` + +Review questions: +- Is every member action authorized, including newly added controller actions? +- Is the record lookup scoped before update, destroy, download, or enqueue? +- For browser sessions, are state-changing requests protected against CSRF? + +### Protect Session-Backed Browser Requests + +CSRF protection is required when a browser automatically sends an authenticated session cookie. API-only endpoints that use an explicit bearer token can use a different strategy, but disabling CSRF protection is not safe merely because an endpoint returns JSON. + +```ruby +# Good: session-backed controllers keep forgery protection enabled. +class ApplicationController < ActionController::Base + protect_from_forgery with: :exception +end +``` + +```ruby +# Good: configure the session store in an initializer (for example +# config/initializers/session_store.rb), not inside a controller class. +# Keep cookies unavailable to JavaScript, HTTPS-only in production, and make +# the cross-site policy explicit for the application's flows. +Rails.application.config.session_store :cookie_store, + key: "_app_session", + secure: Rails.env.production?, + httponly: true, + same_site: :lax +``` + +Review questions: +- Does any browser-authenticated `POST`, `PATCH`, `PUT`, or `DELETE` skip `protect_from_forgery`? +- Are `secure`, `httponly`, and `same_site` cookie settings appropriate for the deployment and login flows? +- Does an API-only endpoint avoid cookie authentication, or otherwise use a deliberate CSRF defense? + +### Review Active Storage and Server-Side Fetches + +For Active Storage uploads and any server-side URL fetch, verify blob ownership, content-type and size validation, and SSRF controls before accepting a user-controlled URL. Signed/expiring URLs, direct-upload limits, and variant parameters should stay under an allowlist. See the [Security Review Guide](security-review-guide.md) for broader request and asset review guidance. + +Review questions: +- Can an attachment download, redirect, or server-side fetch access a file or URL outside the authorized scope? +- Are content type, size, and ownership checked before persisting or transforming a blob? +- Does a user-controlled URL used for server-side fetching enforce host allowlists and block private network ranges? + +### Make Retried Write Requests Idempotent + +A client or proxy can retry a request after the database commits but before it receives the response. For operations such as order creation, require a caller-supplied idempotency key, scope it to the authenticated principal and operation, and enforce uniqueness in the database. + +```ruby +# The service stores both the key and a fingerprint of the validated request. +order = Orders::CreateOnce.call( + actor: current_user, + idempotency_key: request.headers.fetch("Idempotency-Key"), + attributes: order_params +) + +# Migration: require the key when this API requires the header, then close +# concurrent duplicate-create races. On an existing populated table, backfill +# or supply a temporary default before adding `null: false`. +add_column :orders, :idempotency_key, :string, null: false +add_index :orders, [:user_id, :idempotency_key], unique: true +``` + +When the same key is reused, return the original result only if the request fingerprint matches. Reject key reuse with different attributes instead of silently returning or mutating the wrong resource. + +Review questions: +- Can a timeout or enqueue failure happen after the write commits? +- Will a caller retry create, payment, invitation, or other non-idempotent work? +- Is the required key rejected before insert and stored in a `null: false` column? A unique index permits multiple `NULL` values on many adapters. +- For existing tables, does the migration backfill values before enforcing `null: false`? +- Is idempotency enforced by a unique constraint and tested under concurrent requests? + +--- + +## Active Record Correctness + +### Pair Model Validation With Database Constraints + +Model validations improve error messages but do not protect against concurrent writers or non-Rails clients. Important invariants need database constraints and indexes. + +```ruby +class Membership < ApplicationRecord + validates :user_id, uniqueness: { scope: :team_id } +end + +# Migration also required: +add_index :memberships, [:team_id, :user_id], unique: true +add_check_constraint :orders, "total_cents >= 0", name: "orders_total_nonnegative" +``` + +Review questions: +- Do uniqueness, non-null, foreign-key, and range invariants exist in the database? +- Does application code handle the constraint violation from a concurrent request? +- Is a new query pattern backed by an appropriate index? + +### Know Which APIs Skip Validations and Callbacks + +Bulk methods are valuable, but methods such as `update_all`, `delete_all`, `insert_all`, and direct SQL bypass parts of the ordinary model lifecycle. + +```ruby +# This does not run model validations or update callbacks. +Order.where(expired: true).update_all(status: "cancelled", updated_at: Time.current) +``` + +Review questions: +- Is skipping validations, callbacks, timestamps, auditing, and dependent behavior intentional? +- Would `destroy_all` be required for dependent cleanup, despite being slower? +- Could a bulk write bypass a counter-cache callback and require `reset_counters` or another explicit reconciliation step? +- Are bulk operations bounded, observable, and safe to retry? + +### Keep Callbacks Small and Predictable + +Callbacks that send emails, call APIs, enqueue multiple workflows, or mutate unrelated models make persistence hard to reason about. + +Prefer explicit application services for orchestration. If work must happen only after a transaction commits, use `after_commit` or enqueue-after-commit behavior deliberately. + +Review questions: +- Can saving a model unexpectedly trigger network I/O or a large cascade? +- Could a callback run during tests, data migrations, console scripts, or retries? +- Is callback ordering part of an undocumented correctness dependency? + +### Treat Enum and Scope Changes as Data Contract Changes + +Integer-backed enums depend on stable ordinal mappings. Append new values or use explicit mappings; do not reorder existing entries. + +```ruby +# Safer for long-lived data and cross-service contracts. +enum :status, { + pending: 0, + paid: 1, + cancelled: 2 +} +``` + +Scopes should return relations consistently. A conditional scope that returns `nil` or an Array breaks composability. + +Review questions: +- Does an enum change reinterpret existing rows? +- Does a scope remain chainable for every input? +- Is a `default_scope` hiding records or ordering in surprising contexts? + +### Keep Counter Caches and Query Caches Correct + +Counter caches are denormalized data maintained by callbacks. Bulk writes, direct SQL, imports, and deleted rows that bypass the normal lifecycle can make them drift; repair deliberately with `reset_counters` after verifying the source-of-truth query. + +Long-running jobs should also avoid assuming a query result remains current for the whole job. Check the query-cache scope and use an uncached block or a fresh query when later steps must observe writes or concurrent changes. + +Review questions: +- Can `update_all`, `delete_all`, imports, or direct SQL bypass a counter-cache update? +- Is a counter-cache repair observable and based on the current source of truth? +- Could a cached query result become stale across phases of a long-running job? + +--- + +## Query Performance + +### Detect and Prevent N+1 Queries + +Association access inside a loop is a review hotspot. Choose eager-loading behavior based on whether the association is only loaded or also used in SQL conditions. + +```ruby +# Bad: one query for orders, then one query per customer. +orders = Order.where(status: "paid") +orders.each { |order| puts order.customer.name } + +# Good: usually two queries, with predictable object loading. +orders = Order.where(status: "paid").preload(:customer) + +# Good when Rails should select an eager-loading strategy for access. +orders = Order.includes(:customer).where(status: "paid") + +# Use eager_load when a LEFT OUTER JOIN is intentionally required. +orders = Order.eager_load(:customer).where(customers: { active: true }) +``` + +`strict_loading` can turn accidental lazy loads into visible failures in development or tests. + +`eager_load` uses a `LEFT OUTER JOIN`. It can change result cardinality when it joins a collection association: a parent may appear once per matching child. Use `distinct`, a subquery, or separate the filtering query from `preload` when the caller needs one parent row per record. + +See [N+1 Queries Guide](cross-cutting/n-plus-one-queries.md) for cross-framework detection and loading strategies. + +Review questions: +- Does a serializer, view, GraphQL resolver, or job walk unloaded associations? +- Is the chosen eager-loading method compatible with filtering, ordering, and result cardinality? +- Could a collection join duplicate parent rows or require `distinct`, a subquery, or a separate `preload` step? +- Are query-count assertions or strict loading protecting important endpoints? + +### Keep Filtering and Aggregation in the Database + +Loading records before filtering wastes memory and can change semantics. + +```ruby +# Bad: loads every paid order and filters in Ruby. +large_orders = Order.paid.to_a.select { |order| order.total_cents >= 10_000 } + +# Good: database applies the predicate. +large_orders = Order.paid.where(total_cents: 10_000..) + +# Bad: instantiates records just to read one column. +emails = User.active.map(&:email) + +# Good: reads only the requested column. +emails = User.active.pluck(:email) +``` + +Review questions: +- Is `to_a`, `map`, `select`, or `sort_by` forcing work into Ruby too early? +- Would `pluck`, `pick`, `ids`, `exists?`, `count`, `sum`, or `maximum` avoid model instantiation? +- Does the selected column, SQL expression, adapter, or custom attribute type preserve the value type the caller expects? +- Does the query select more columns or rows than the caller needs? + +### Batch Large Data Sets and Paginate Endpoints + +Use `find_each` or `in_batches` for large background processing, and use stable pagination for list endpoints. + +```ruby +Order.where(status: "pending").find_each(batch_size: 1_000) do |order| + Reconciliation.check(order) +end +``` + +`find_each` and `in_batches` batch by a cursor (the primary key by default) and can ignore or replace a relation's custom `ORDER BY`. Use an explicit cursor/order supported by the current Rails version, or a dedicated query, when business ordering matters. + +Review questions: +- Can this query grow without a bound? +- Is batch processing compatible with its cursor, ordering, and mutation behavior? +- Does pagination use a deterministic order and an indexed cursor or key? + +### Review Caching With Invalidation in Mind + +Caching can hide N+1 queries while introducing stale or cross-tenant data. Cache keys must include every input that changes the result. + +Review questions: +- Does the key include tenant, locale, authorization scope, and versioned data? +- Is invalidation tied to the records that affect the cached value? +- Could sensitive data be served to another user or tenant? + +--- + +## Transactions and Concurrency + +### Keep Transactions Focused on Database State + +Database transactions do not roll back HTTP calls, messages already delivered to an external broker, files, or payments. + +```ruby +# Bad: payment can succeed even if the database transaction rolls back. +Order.transaction do + gateway.charge(order.total_cents) + order.update!(status: "paid") +end + +# Better: configure this job to enqueue only after commit, then persist an +# explicit transition and reconcile the idempotent external operation. +class PaymentJob < ApplicationJob + self.enqueue_after_transaction_commit = true +end + +Order.transaction do + order.update!(status: "payment_pending") + PaymentJob.perform_later(order) +end +``` + +Do not rely on a version-dependent enqueue default. Rails 8.0/8.1 applications and queue adapters can enqueue immediately depending on `config.load_defaults` and deployment topology; newer Rails defaults may defer more often, but the job should declare the behavior it requires. If the enqueue itself must be durable with the state change, use an outbox or a reconciliation path. + +Review questions: +- Which side effects are actually covered by the transaction? +- Does the exact job class declare post-commit enqueue behavior, rather than relying on an adapter or Rails-version default? +- Could a timeout mean "failed" or "succeeded but the response was lost"? +- Is there a recovery or reconciliation path for partial completion? + +### Do Not Swallow Database Errors Inside a Broken Transaction + +Some adapters, notably PostgreSQL, leave a transaction unusable after a statement error until it is rolled back. Catch expected constraint errors outside the transaction boundary or restart the whole transaction deliberately. + +Review questions: +- Is `ActiveRecord::StatementInvalid` rescued inside a transaction and then followed by more SQL? +- Is retry limited to known transient conflicts such as deadlocks or serialization failures? +- Does retry repeat an external side effect? + +### Lock the Invariant, Not Just the Code Path + +Ruby process locks do not protect data across multiple processes or hosts. Use unique constraints, atomic updates, optimistic locking, or row locks for shared database invariants. + +```ruby +order.with_lock do + return if order.paid? + + order.update!(status: "payment_pending") +end +``` + +Review questions: +- Can two requests observe the same old state and both proceed? +- Would an atomic conditional update or unique index be simpler than a lock? +- Is lock ordering consistent to avoid deadlocks? + +### Choose Optimistic or Pessimistic Locking Deliberately + +Use optimistic locking for ordinary edits where conflicts are uncommon and the caller can reload or resolve a conflict. Use a short pessimistic lock such as `with_lock` for a small critical state transition that needs serialized access. + +```ruby +# Migration: Rails increments this column and rejects stale updates. +add_column :orders, :lock_version, :integer, default: 0, null: false + +begin + order.update!(shipping_address: new_address) +rescue ActiveRecord::StaleObjectError + # Reload, return a conflict response, or ask the user to reconcile changes. +end +``` + +Review questions: +- Can a low-contention user edit use `lock_version` and a conflict response instead of holding a row lock? +- Does pessimistic locking cover only the minimal state transition and preserve a consistent lock order? + +### Treat Mutable Global State as Concurrent State + +Class variables, class instance variables, constants containing mutable objects, memoization, and singleton clients may be shared by request threads. + +Review questions: +- Is lazy initialization thread-safe and safe during code reload? +- Is request-specific data stored in a global, class variable, or long-lived thread local? +- Are shared clients documented as thread-safe by their library? + +--- + +## Active Job and External Side Effects + +### Make Retried Jobs Idempotent + +Jobs may be retried when configured by Active Job or the queue backend. A process can also stop after an external side effect succeeds but before local state is updated. + +```ruby +class CapturePaymentJob < ApplicationJob + self.enqueue_after_transaction_commit = true + retry_on PaymentGateway::Timeout, wait: :polynomially_longer, attempts: 5 + + def perform(order) + order.with_lock do + return if order.paid? + order.update!(status: "payment_processing") + end + + result = PaymentGateway.capture( + amount: order.total_cents, + idempotency_key: "order-#{order.id}-capture" + ) + + order.update!(status: "paid", payment_reference: result.reference) + end +end +``` + +The gateway idempotency key, persisted state, and reconciliation process must work together. A database flag alone cannot prove that an external charge did not already happen. + +Review questions: +- What happens if the worker stops after the side effect but before the final update? +- Is the idempotency key stable across retries but unique to the intended operation? +- Are retryable and permanent failures handled differently? +- Which states are terminal, retryable, or recoverable, and how does a stuck `payment_processing` state become visible for reconciliation? + +### Understand GlobalID Arguments + +Active Job can serialize Active Record objects with GlobalID. The job loads the record at execution time, not enqueue time. If the record has been deleted, deserialization raises `ActiveJob::DeserializationError` before `perform` runs. + +When absence is an expected business case, pass an ID and handle `ActiveRecord::RecordNotFound` narrowly inside the job. Do not use a blanket `discard_on ActiveJob::DeserializationError` unless every deserialization failure is intentionally disposable; it can also hide serializer or deployment problems. + +Review questions: +- Is the job intentionally using current record state rather than a snapshot? +- What should happen when the record is deleted or no longer eligible? +- Does `discard_on ActiveJob::DeserializationError` lose work that should be investigated instead? + +### Enqueue With Transaction Boundaries Deliberately + +Jobs that can run before their records commit may fail to find those records. Rails supports enqueue-after-commit behavior, but transactional guarantees depend on the exact job setting, queue adapter, Rails load defaults, and database topology. + +Review questions: +- Can the worker run before the creating transaction commits? +- Does the code accidentally rely on the job table sharing the application database? +- If enqueue fails after data commits, is there an outbox, retry, or reconciliation path? + +### Put Timeouts and Observability Around Network Work + +Every external call needs bounded connect/read/write timeouts, structured error reporting, and enough identifiers to trace a retry without logging secrets. + +Review questions: +- Are timeouts explicit in the client configuration? +- Are queue latency, attempts, external request IDs, and final outcomes observable? +- Is a long-running job split into resumable or checkpointed units when appropriate? + +--- + +## Testing and Tooling + +### Test Behavior at the Right Boundary + +Use model tests for domain rules, request/system tests for controller behavior, and job tests for serialization, retry, and side-effect boundaries. Avoid tests that only assert a callback or private method was invoked. + +High-value cases include: +- unpermitted parameters cannot update protected attributes; +- unsafe sort/filter input cannot alter SQL structure; +- authorization scopes records before lookup; +- serializers do not expose sensitive columns; +- N+1 regressions fail through query-count assertions or strict loading; +- jobs are safe when performed twice; +- deleted GlobalID records and permanent gateway failures are handled intentionally; +- a timeout after a successful external side effect is reconciled without duplication. + +### Keep Time and Asynchronous Tests Deterministic + +Use Rails time helpers instead of real sleeps. Run jobs through the test adapter or a backend-specific integration test, and assert both enqueue behavior and performed behavior where each matters. + +```ruby +travel_to(Time.zone.local(2026, 7, 14, 10, 0, 0)) do + assert_enqueued_with(job: ExpireOrderJob, at: 30.minutes.from_now) do + order.schedule_expiration! + end +end +``` + +Review questions: +- Does a test depend on wall-clock timing, global order, or previously created data? +- Are external services replaced at a clear adapter boundary? +- Do parallel tests share mutable constants, files, ports, or non-transactional state? + +### Run the Checks the Project Actually Configures + +Common commands include: + +```bash +bundle exec ruby -wc path/to/file.rb +bundle exec rubocop +bundle exec brakeman -q +bundle exec rails test +bundle exec rspec +``` + +Run only tools present in the repository, and inspect their configuration before treating style or complexity thresholds as universal rules. Security findings from Brakeman and dependency scanners require human validation, not blind suppression. + +--- + +## Review Checklist + +### Ruby Semantics +- [ ] Conditions account for Ruby truthiness (`0`, `""`, and `[]` are truthy). +- [ ] Equality and `hash` contracts are consistent. +- [ ] Keyword arguments and forwarding preserve the intended API contract. +- [ ] Dynamic method or constant lookup is allowlisted. +- [ ] Mutable Hash/Array defaults, constants, and class state are not shared accidentally. +- [ ] Untrusted data never reaches unsafe deserialization or interpolated shell commands. +- [ ] Bang-method and safe-navigation return semantics are understood. + +### Exceptions and Resources +- [ ] Rescue clauses handle specific expected failures at narrow boundaries. +- [ ] Unexpected errors retain their cause and backtrace. +- [ ] Client responses do not expose internal exception messages. +- [ ] Logs include safe structured context and the exception object. +- [ ] Resources and locks are released on every path. +- [ ] Retries are bounded and cannot duplicate non-idempotent work. + +### Controllers and Security +- [ ] Strong parameters use an exact allowlist; no `permit!` or unsafe hash conversion. +- [ ] Nested resource arrays use `params.expect(...: [[...]])` with the intended shape. +- [ ] SQL values are parameterized and dynamic identifiers are allowlisted. +- [ ] Authentication, authorization, and record scoping are all present. +- [ ] An unscoped `Model.find(params[:id])` cannot bypass ownership or policy checks. +- [ ] Retried write requests use scoped idempotency keys and database uniqueness where required. +- [ ] Serialized fields are explicit and exclude sensitive data. +- [ ] Redirects, HTML safety overrides, files, and headers do not trust user input or permit open redirects. +- [ ] State-changing browser requests have CSRF protection, and session cookies use intentional `secure`, `httponly`, and `same_site` settings. +- [ ] Active Storage uploads and server-side URL fetches validate ownership, content, and SSRF boundaries. + +### Active Record +- [ ] Critical model validations are backed by database constraints and indexes. +- [ ] Bulk APIs intentionally account for skipped callbacks, validations, and timestamps. +- [ ] Bulk writes cannot silently drift counter caches; repair and reconciliation are explicit. +- [ ] Callbacks are small and do not hide orchestration or network side effects. +- [ ] Enum mappings preserve existing persisted values. +- [ ] Scopes remain relations and compose for every input. +- [ ] Concurrency invariants use constraints, atomic updates, optimistic locking, or short database locks. + +### Queries and Performance +- [ ] Association access in loops is preloaded or explicitly justified. +- [ ] `includes`, `preload`, or `eager_load` matches the query semantics. +- [ ] Collection joins cannot duplicate parent rows or change cardinality unnoticed. +- [ ] Filtering, sorting, aggregation, and existence checks stay in the database. +- [ ] Large data sets use batches; list endpoints are paginated with stable ordering. +- [ ] New filter/join/order patterns have supporting indexes. +- [ ] Cache keys include tenant, authorization, locale, and data versions as needed. + +### Jobs and External Services +- [ ] Jobs are safe under retry, duplicate delivery, and worker interruption. +- [ ] External operations use stable idempotency keys where supported. +- [ ] GlobalID deletion and stale/current-state semantics are intentional; expected absence is handled narrowly. +- [ ] Enqueue timing is declared on the job and does not rely accidentally on queue/database topology or version defaults. +- [ ] Partial completion has reconciliation, compensation, or an outbox path. +- [ ] Terminal, retryable, and stuck processing states are observable and recoverable. +- [ ] Network calls have timeouts, observability, and secret-safe logging. + +### Tests and Automation +- [ ] Request tests cover parameter filtering, authorization, status codes, and response fields. +- [ ] Query-count or strict-loading tests protect performance-sensitive paths. +- [ ] Job tests cover retries, duplicate execution, deletion, and partial failure. +- [ ] Time-dependent tests use deterministic Rails time helpers. +- [ ] The repository's configured Ruby, Rails, lint, test, and security checks pass. + +--- + +## References + +- [Ruby Releases](https://www.ruby-lang.org/en/downloads/releases/) +- [Ruby Syntax: Methods and Arguments](https://ruby-doc.org/3.4/syntax/methods_rdoc.html) +- [Ruby Syntax: Exceptions](https://ruby-doc.org/3.4/syntax/exceptions_rdoc.html) +- [Rails Action Controller Overview](https://guides.rubyonrails.org/action_controller_overview.html) +- [Rails Active Record Query Interface](https://guides.rubyonrails.org/active_record_querying.html) +- [Rails Active Record Transactions](https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html) +- [Rails Active Job Basics](https://guides.rubyonrails.org/active_job_basics.html) +- [Securing Rails Applications](https://guides.rubyonrails.org/security.html) +- [Testing Rails Applications](https://guides.rubyonrails.org/testing.html) +- [RuboCop Documentation](https://docs.rubocop.org/rubocop/) +- [RuboCop Rails Documentation](https://docs.rubocop.org/rubocop-rails/) +- [Brakeman](https://brakemanscanner.org/) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/rust.md b/examples/skills_code_review_agent/skills/code-review/reference/rust.md new file mode 100644 index 000000000..34ce5defe --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/rust.md @@ -0,0 +1,846 @@ +# Rust Code Review Guide + +> Rust 代码审查指南。编译器能捕获内存安全问题,但审查者需要关注编译器无法检测的问题——业务逻辑、API 设计、性能、取消安全性和可维护性。 + +## 目录 + +- [所有权与借用](#所有权与借用) +- [Unsafe 代码审查](#unsafe-代码审查最关键) +- [异步代码](#异步代码) +- [取消安全性](#取消安全性) +- [spawn vs await](#spawn-vs-await) +- [错误处理](#错误处理) +- [性能](#性能) +- [Trait 设计](#trait-设计) +- [Review Checklist](#rust-review-checklist) + +--- + +## 所有权与借用 + +### 避免不必要的 clone() + +```rust +// ❌ clone() 是"Rust 的胶带"——用于绕过借用检查器 +fn bad_process(data: &Data) -> Result<()> { + let owned = data.clone(); // 为什么需要 clone? + expensive_operation(owned) +} + +// ✅ 审查时问:clone 是否必要?能否用借用? +fn good_process(data: &Data) -> Result<()> { + expensive_operation(data) // 传递引用 +} + +// ✅ 如果确实需要 clone,添加注释说明原因 +fn justified_clone(data: &Data) -> Result<()> { + // Clone needed: data will be moved to spawned task + let owned = data.clone(); + tokio::spawn(async move { + process(owned).await + }); + Ok(()) +} +``` + +### Arc> 的使用 + +```rust +// ❌ Arc> 可能隐藏不必要的共享状态 +struct BadService { + cache: Arc>>, // 真的需要共享? +} + +// ✅ 考虑是否需要共享,或者设计可以避免 +struct GoodService { + cache: HashMap, // 单一所有者 +} + +// ✅ 如果确实需要并发访问,考虑更好的数据结构 +use dashmap::DashMap; + +struct ConcurrentService { + cache: DashMap, // 更细粒度的锁 +} +``` + +### Cow (Copy-on-Write) 模式 + +```rust +use std::borrow::Cow; + +// ❌ 总是分配新字符串 +fn bad_process_name(name: &str) -> String { + if name.is_empty() { + "Unknown".to_string() // 分配 + } else { + name.to_string() // 不必要的分配 + } +} + +// ✅ 使用 Cow 避免不必要的分配 +fn good_process_name(name: &str) -> Cow<'_, str> { + if name.is_empty() { + Cow::Borrowed("Unknown") // 静态字符串,无分配 + } else { + Cow::Borrowed(name) // 借用原始数据 + } +} + +// ✅ 只在需要修改时才分配 +fn normalize_name(name: &str) -> Cow<'_, str> { + if name.chars().any(|c| c.is_uppercase()) { + Cow::Owned(name.to_lowercase()) // 需要修改,分配 + } else { + Cow::Borrowed(name) // 无需修改,借用 + } +} +``` + +--- + +## Unsafe 代码审查(最关键!) + +### 基本要求 + +```rust +// ❌ unsafe 没有安全文档——这是红旗 +unsafe fn bad_transmute(t: T) -> U { + std::mem::transmute(t) +} + +// ✅ 每个 unsafe 必须解释:为什么安全?什么不变量? +/// Transmutes `T` to `U`. +/// +/// # Safety +/// +/// - `T` and `U` must have the same size and alignment +/// - `T` must be a valid bit pattern for `U` +/// - The caller ensures no references to `t` exist after this call +unsafe fn documented_transmute(t: T) -> U { + // SAFETY: Caller guarantees size/alignment match and bit validity + std::mem::transmute(t) +} +``` + +### Unsafe 块注释 + +```rust +// ❌ 没有解释的 unsafe 块 +fn bad_get_unchecked(slice: &[u8], index: usize) -> u8 { + unsafe { *slice.get_unchecked(index) } +} + +// ✅ 每个 unsafe 块必须有 SAFETY 注释 +fn good_get_unchecked(slice: &[u8], index: usize) -> u8 { + debug_assert!(index < slice.len(), "index out of bounds"); + // SAFETY: We verified index < slice.len() via debug_assert. + // In release builds, callers must ensure valid index. + unsafe { *slice.get_unchecked(index) } +} + +// ✅ 封装 unsafe 提供安全 API +pub fn checked_get(slice: &[u8], index: usize) -> Option { + if index < slice.len() { + // SAFETY: bounds check performed above + Some(unsafe { *slice.get_unchecked(index) }) + } else { + None + } +} +``` + +### 常见 unsafe 模式 + +```rust +// ✅ FFI 边界 +extern "C" { + fn external_function(ptr: *const u8, len: usize) -> i32; +} + +pub fn safe_wrapper(data: &[u8]) -> Result { + // SAFETY: data.as_ptr() is valid for data.len() bytes, + // and external_function only reads from the buffer. + let result = unsafe { + external_function(data.as_ptr(), data.len()) + }; + if result < 0 { + Err(Error::from_code(result)) + } else { + Ok(result) + } +} + +// ✅ 性能关键路径的 unsafe +pub fn fast_copy(src: &[u8], dst: &mut [u8]) { + assert_eq!(src.len(), dst.len(), "slices must be equal length"); + // SAFETY: src and dst are valid slices of equal length, + // and dst is mutable so no aliasing. + unsafe { + std::ptr::copy_nonoverlapping( + src.as_ptr(), + dst.as_mut_ptr(), + src.len() + ); + } +} +``` + +--- + +## 异步代码 + +> 📖 通用并发模式和跨语言示例详见 [异步与并发跨语言指南](cross-cutting/async-concurrency-patterns.md) + +### 避免阻塞操作 + +```rust +// ❌ 在 async 上下文中阻塞——会饿死其他任务 +async fn bad_async() { + let data = std::fs::read_to_string("file.txt").unwrap(); // 阻塞! + std::thread::sleep(Duration::from_secs(1)); // 阻塞! +} + +// ✅ 使用异步 API +async fn good_async() -> Result { + let data = tokio::fs::read_to_string("file.txt").await?; + tokio::time::sleep(Duration::from_secs(1)).await; + Ok(data) +} + +// ✅ 如果必须使用阻塞操作,用 spawn_blocking +async fn with_blocking() -> Result { + let result = tokio::task::spawn_blocking(|| { + // 这里可以安全地进行阻塞操作 + expensive_cpu_computation() + }).await?; + Ok(result) +} +``` + +### Mutex 和 .await + +```rust +// ❌ 跨 .await 持有 std::sync::Mutex——可能死锁 +async fn bad_lock(mutex: &std::sync::Mutex) { + let guard = mutex.lock().unwrap(); + async_operation().await; // 持锁等待! + process(&guard); +} + +// ✅ 方案1:最小化锁范围 +async fn good_lock_scoped(mutex: &std::sync::Mutex) { + let data = { + let guard = mutex.lock().unwrap(); + guard.clone() // 立即释放锁 + }; + async_operation().await; + process(&data); +} + +// ✅ 方案2:使用 tokio::sync::Mutex(可跨 await) +async fn good_lock_tokio(mutex: &tokio::sync::Mutex) { + let guard = mutex.lock().await; + async_operation().await; // OK: tokio Mutex 设计为可跨 await + process(&guard); +} + +// 💡 选择指南: +// - std::sync::Mutex:低竞争、短临界区、不跨 await +// - tokio::sync::Mutex:需要跨 await、高竞争场景 +``` + +### 异步 trait 方法 + +```rust +// ❌ async trait 方法的陷阱(旧版本) +#[async_trait] +trait BadRepository { + async fn find(&self, id: i64) -> Option; // 隐式 Box +} + +// ✅ Rust 1.75+:原生 async trait 方法 +trait Repository { + async fn find(&self, id: i64) -> Option; + + // 返回具体 Future 类型以避免 allocation + fn find_many(&self, ids: &[i64]) -> impl Future> + Send; +} + +// ✅ 对于需要 dyn 的场景 +trait DynRepository: Send + Sync { + fn find(&self, id: i64) -> Pin> + Send + '_>>; +} +``` + +--- + +## 取消安全性 + +### 什么是取消安全 + +```rust +// 当一个 Future 在 .await 点被 drop 时,它处于什么状态? +// 取消安全的 Future:可以在任何 await 点安全取消 +// 取消不安全的 Future:取消可能导致数据丢失或不一致状态 + +// ❌ 取消不安全的例子 +async fn cancel_unsafe(conn: &mut Connection) -> Result<()> { + let data = receive_data().await; // 如果这里被取消... + conn.send_ack().await; // ...确认永远不会发送,数据可能丢失 + Ok(()) +} + +// ✅ 取消安全的版本 +async fn cancel_safe(conn: &mut Connection) -> Result<()> { + // 使用事务或原子操作确保一致性 + let transaction = conn.begin_transaction().await?; + let data = receive_data().await; + transaction.commit_with_ack(data).await?; // 原子操作 + Ok(()) +} +``` + +### select! 中的取消安全 + +```rust +use tokio::select; + +// ❌ 在 select! 中使用取消不安全的 Future +async fn bad_select(stream: &mut TcpStream) { + let mut buffer = vec![0u8; 1024]; + loop { + select! { + // read_exact 不是取消安全的:timeout 先完成时, + // 已经读进 buffer 的部分字节会随 Future 一起丢弃 + result = stream.read_exact(&mut buffer) => { + result?; + handle_data(&buffer); + } + _ = tokio::time::sleep(Duration::from_secs(5)) => { + println!("Timeout"); + } + } + } +} + +// ✅ 使用取消安全的 API +async fn good_select(stream: &mut TcpStream) { + let mut buffer = vec![0u8; 1024]; + loop { + select! { + // read 是取消安全的:被取消时未读取的数据仍留在流中 + // 真的需要按定长读取时,把 read_exact 丢到单独的 task 里, + // 这里 select! 它的 JoinHandle,取消就不会丢字节 + result = stream.read(&mut buffer) => { + match result { + Ok(0) => break, // EOF + Ok(n) => handle_data(&buffer[..n]), + Err(e) => return Err(e), + } + } + _ = tokio::time::sleep(Duration::from_secs(5)) => { + println!("Timeout, retrying..."); + } + } + } +} + +// ✅ 使用 tokio::pin! 确保 Future 可以安全重用 +async fn pinned_select() { + let sleep = tokio::time::sleep(Duration::from_secs(10)); + tokio::pin!(sleep); + + loop { + select! { + _ = &mut sleep => { + println!("Timer elapsed"); + break; + } + data = receive_data() => { + process(data).await; + // sleep 继续倒计时,不会重置 + } + } + } +} +``` + +### 文档化取消安全性 + +```rust +/// Reads a complete message from the stream. +/// +/// # Cancel Safety +/// +/// This method is **not** cancel safe. If cancelled while reading, +/// partial data may be lost and the stream state becomes undefined. +/// Use `read_message_cancel_safe` if cancellation is expected. +async fn read_message(stream: &mut TcpStream) -> Result { + let len = stream.read_u32().await?; + let mut buffer = vec![0u8; len as usize]; + stream.read_exact(&mut buffer).await?; + Ok(Message::from_bytes(&buffer)) +} + +/// Reads a message with cancel safety. +/// +/// # Cancel Safety +/// +/// This method is cancel safe. If cancelled, any partial data +/// is preserved in the internal buffer for the next call. +async fn read_message_cancel_safe(reader: &mut BufferedReader) -> Result { + reader.read_message_buffered().await +} +``` + +--- + +## spawn vs await + +### 何时使用 spawn + +```rust +// ❌ 不必要的 spawn——增加开销,失去结构化并发 +async fn bad_unnecessary_spawn() { + let handle = tokio::spawn(async { + simple_operation().await + }); + handle.await.unwrap(); // 为什么不直接 await? +} + +// ✅ 直接 await 简单操作 +async fn good_direct_await() { + simple_operation().await; +} + +// ✅ spawn 用于真正的并行执行 +async fn good_parallel_spawn() { + let task1 = tokio::spawn(fetch_from_service_a()); + let task2 = tokio::spawn(fetch_from_service_b()); + + // 两个请求并行执行 + let (result1, result2) = tokio::try_join!(task1, task2)?; +} + +// ✅ spawn 用于后台任务(fire-and-forget) +async fn good_background_spawn() { + // 启动后台任务,不等待完成 + tokio::spawn(async { + cleanup_old_sessions().await; + log_metrics().await; + }); + + // 继续执行其他工作 + handle_request().await; +} +``` + +### spawn 的 'static 要求 + +```rust +// ❌ spawn 的 Future 必须是 'static +async fn bad_spawn_borrow(data: &Data) { + tokio::spawn(async { + process(data).await; // Error: `data` 不是 'static + }); +} + +// ✅ 方案1:克隆数据 +async fn good_spawn_clone(data: &Data) { + let owned = data.clone(); + tokio::spawn(async move { + process(&owned).await; + }); +} + +// ✅ 方案2:使用 Arc 共享 +async fn good_spawn_arc(data: Arc) { + let data = Arc::clone(&data); + tokio::spawn(async move { + process(&data).await; + }); +} + +// ✅ 方案3:使用作用域任务(tokio-scoped 或 async-scoped) +async fn good_scoped_spawn(data: &Data) { + // 假设使用 async-scoped crate + async_scoped::scope(|s| async { + s.spawn(async { + process(data).await; // 可以借用 + }); + }).await; +} +``` + +### JoinHandle 错误处理 + +```rust +// ❌ 忽略 spawn 的错误 +async fn bad_ignore_spawn_error() { + let handle = tokio::spawn(async { + risky_operation().await + }); + let _ = handle.await; // 忽略了 panic 和错误 +} + +// ✅ 正确处理 JoinHandle 结果 +async fn good_handle_spawn_error() -> Result<()> { + let handle = tokio::spawn(async { + risky_operation().await + }); + + match handle.await { + Ok(Ok(result)) => { + // 任务成功完成 + process_result(result); + Ok(()) + } + Ok(Err(e)) => { + // 任务内部错误 + Err(e.into()) + } + Err(join_err) => { + // 任务 panic 或被取消 + if join_err.is_panic() { + error!("Task panicked: {:?}", join_err); + } + Err(anyhow!("Task failed: {}", join_err)) + } + } +} +``` + +### 结构化并发 vs spawn + +```rust +// ✅ 优先使用 join!(结构化并发) +async fn structured_concurrency() -> Result<(A, B, C)> { + // 所有任务在同一个作用域内 + // 如果任何一个失败,其他的会被取消 + tokio::try_join!( + fetch_a(), + fetch_b(), + fetch_c() + ) +} + +// ✅ 使用 spawn 时考虑任务生命周期 +struct TaskManager { + handles: Vec>, +} + +impl TaskManager { + async fn shutdown(self) { + // 优雅关闭:等待所有任务完成 + for handle in self.handles { + if let Err(e) = handle.await { + error!("Task failed during shutdown: {}", e); + } + } + } + + async fn abort_all(self) { + // 强制关闭:取消所有任务 + for handle in self.handles { + handle.abort(); + } + } +} +``` + +--- + +## 错误处理 + +> 📖 通用原则和跨语言示例详见 [错误处理跨语言指南](cross-cutting/error-handling-principles.md) + +### 库 vs 应用的错误类型 + +```rust +// ❌ 库代码用 anyhow——调用者无法 match 错误 +pub fn parse_config(s: &str) -> anyhow::Result { ... } + +// ✅ 库用 thiserror,应用用 anyhow +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("invalid syntax at line {line}: {message}")] + Syntax { line: usize, message: String }, + #[error("missing required field: {0}")] + MissingField(String), + #[error(transparent)] + Io(#[from] std::io::Error), +} + +pub fn parse_config(s: &str) -> Result { ... } +``` + +### 保留错误上下文 + +```rust +// ❌ 吞掉错误上下文 +fn bad_error() -> Result<()> { + operation().map_err(|_| anyhow!("failed"))?; // 原始错误丢失 + Ok(()) +} + +// ✅ 使用 context 保留错误链 +fn good_error() -> Result<()> { + operation().context("failed to perform operation")?; + Ok(()) +} + +// ✅ 使用 with_context 进行懒计算 +fn good_error_lazy() -> Result<()> { + operation() + .with_context(|| format!("failed to process file: {}", filename))?; + Ok(()) +} +``` + +### 错误类型设计 + +```rust +// ✅ 使用 #[source] 保留错误链 +#[derive(Debug, thiserror::Error)] +pub enum ServiceError { + #[error("database error")] + Database(#[source] sqlx::Error), + + #[error("network error: {message}")] + Network { + message: String, + #[source] + source: reqwest::Error, + }, + + #[error("validation failed: {0}")] + Validation(String), +} + +// ✅ 为常见转换实现 From +impl From for ServiceError { + fn from(err: sqlx::Error) -> Self { + ServiceError::Database(err) + } +} +``` + +--- + +## 性能 + +### 避免不必要的 collect() + +```rust +// ❌ 不必要的 collect——中间分配 +fn bad_sum(items: &[i32]) -> i32 { + items.iter() + .filter(|x| **x > 0) + .collect::>() // 不必要! + .iter() + .sum() +} + +// ✅ 惰性迭代 +fn good_sum(items: &[i32]) -> i32 { + items.iter().filter(|x| **x > 0).copied().sum() +} +``` + +### 字符串拼接 + +```rust +// ❌ 字符串拼接在循环中重复分配 +fn bad_concat(items: &[&str]) -> String { + let mut s = String::new(); + for item in items { + s = s + item; // 每次都重新分配! + } + s +} + +// ✅ 预分配或用 join +fn good_concat(items: &[&str]) -> String { + items.join("") +} + +// ✅ 使用 with_capacity 预分配 +fn good_concat_capacity(items: &[&str]) -> String { + let total_len: usize = items.iter().map(|s| s.len()).sum(); + let mut result = String::with_capacity(total_len); + for item in items { + result.push_str(item); + } + result +} + +// ✅ 使用 write! 宏 +use std::fmt::Write; + +fn good_concat_write(items: &[&str]) -> String { + let mut result = String::new(); + for item in items { + write!(result, "{}", item).unwrap(); + } + result +} +``` + +### 避免不必要的分配 + +```rust +// ❌ 不必要的 Vec 分配 +fn bad_check_any(items: &[Item]) -> bool { + let filtered: Vec<_> = items.iter() + .filter(|i| i.is_valid()) + .collect(); + !filtered.is_empty() +} + +// ✅ 使用迭代器方法 +fn good_check_any(items: &[Item]) -> bool { + items.iter().any(|i| i.is_valid()) +} + +// ❌ String::from 用于静态字符串 +fn bad_static() -> String { + String::from("error message") // 运行时分配 +} + +// ✅ 返回 &'static str +fn good_static() -> &'static str { + "error message" // 无分配 +} +``` + +--- + +## Trait 设计 + +### 避免过度抽象 + +```rust +// ❌ 过度抽象——不是 Java,不需要 Interface 一切 +trait Processor { fn process(&self); } +trait Handler { fn handle(&self); } +trait Manager { fn manage(&self); } // Trait 过多 + +// ✅ 只在需要多态时创建 trait +// 具体类型通常更简单、更快 +struct DataProcessor { + config: Config, +} + +impl DataProcessor { + fn process(&self, data: &Data) -> Result { + // 直接实现 + } +} +``` + +### Trait 对象 vs 泛型 + +```rust +// ❌ 不必要的 trait 对象(动态分发) +fn bad_process(handler: &dyn Handler) { + handler.handle(); // 虚表调用 +} + +// ✅ 使用泛型(静态分发,可内联) +fn good_process(handler: &H) { + handler.handle(); // 可能被内联 +} + +// ✅ trait 对象适用场景:异构集合 +fn store_handlers(handlers: Vec>) { + // 需要存储不同类型的 handlers +} + +// ✅ 使用 impl Trait 返回类型 +fn create_handler() -> impl Handler { + ConcreteHandler::new() +} +``` + +--- + +## Rust Review Checklist + +### 编译器不能捕获的问题 + +**业务逻辑正确性** +- [ ] 边界条件处理正确 +- [ ] 状态机转换完整 +- [ ] 并发场景下的竞态条件 + +**API 设计** +- [ ] 公共 API 难以误用 +- [ ] 类型签名清晰表达意图 +- [ ] 错误类型粒度合适 + +### 所有权与借用 + +- [ ] clone() 是有意为之,文档说明了原因 +- [ ] Arc> 真的需要共享状态吗? +- [ ] RefCell 的使用有正当理由 +- [ ] 生命周期不过度复杂 +- [ ] 考虑使用 Cow 避免不必要的分配 + +### Unsafe 代码(最重要) + +- [ ] 每个 unsafe 块有 SAFETY 注释 +- [ ] unsafe fn 有 # Safety 文档节 +- [ ] 解释了为什么是安全的,不只是做什么 +- [ ] 列出了必须维护的不变量 +- [ ] unsafe 边界尽可能小 +- [ ] 考虑过是否有 safe 替代方案 + +### 异步/并发 + +- [ ] 没有在 async 中阻塞(std::fs、thread::sleep) +- [ ] 没有跨 .await 持有 std::sync 锁 +- [ ] spawn 的任务满足 'static +- [ ] 锁的获取顺序一致 +- [ ] Channel 缓冲区大小合理 + +### 取消安全性 + +- [ ] select! 中的 Future 是取消安全的 +- [ ] 文档化了 async 函数的取消安全性 +- [ ] 取消不会导致数据丢失或不一致状态 +- [ ] 使用 tokio::pin! 正确处理需要重用的 Future + +### spawn vs await + +- [ ] spawn 只用于真正需要并行的场景 +- [ ] 简单操作直接 await,不要 spawn +- [ ] spawn 的 JoinHandle 结果被正确处理 +- [ ] 考虑任务的生命周期和关闭策略 +- [ ] 优先使用 join!/try_join! 进行结构化并发 + +### 错误处理 + +- [ ] 库:thiserror 定义结构化错误 +- [ ] 应用:anyhow + context +- [ ] 没有生产代码 unwrap/expect +- [ ] 错误消息对调试有帮助 +- [ ] must_use 返回值被处理 +- [ ] 使用 #[source] 保留错误链 + +### 性能 + +- [ ] 避免不必要的 collect() +- [ ] 大数据传引用 +- [ ] 字符串用 with_capacity 或 write! +- [ ] impl Trait vs Box 选择合理 +- [ ] 热路径避免分配 +- [ ] 考虑使用 Cow 减少克隆 + +### 代码质量 + +- [ ] cargo clippy 零警告 +- [ ] cargo fmt 格式化 +- [ ] 文档注释完整 +- [ ] 测试覆盖边界条件 +- [ ] 公共 API 有文档示例 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/security-review-guide.md b/examples/skills_code_review_agent/skills/code-review/reference/security-review-guide.md new file mode 100644 index 000000000..d2ab45f1c --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/security-review-guide.md @@ -0,0 +1,494 @@ +# Security Review Guide + +Security-focused code review checklist based on OWASP Top 10 and best practices. + +## Authentication & Authorization + +### Authentication +- [ ] Passwords hashed with strong algorithm (bcrypt, argon2) +- [ ] Password complexity requirements enforced +- [ ] Account lockout after failed attempts +- [ ] Secure password reset flow +- [ ] Multi-factor authentication for sensitive operations +- [ ] Session tokens are cryptographically random +- [ ] Session timeout implemented + +### Authorization +- [ ] Authorization checks on every request +- [ ] Principle of least privilege applied +- [ ] Role-based access control (RBAC) properly implemented +- [ ] No privilege escalation paths +- [ ] Direct object reference checks (IDOR prevention) +- [ ] API endpoints protected appropriately + +### JWT Security +```typescript +// ❌ Insecure JWT configuration +jwt.sign(payload, 'weak-secret'); + +// ✅ Secure JWT configuration +jwt.sign(payload, process.env.JWT_SECRET, { + algorithm: 'RS256', + expiresIn: '15m', + issuer: 'your-app', + audience: 'your-api' +}); + +// ❌ Not verifying JWT properly +const decoded = jwt.decode(token); // No signature verification! + +// ✅ Verify signature and claims +const decoded = jwt.verify(token, publicKey, { + algorithms: ['RS256'], + issuer: 'your-app', + audience: 'your-api' +}); +``` + +## Input Validation + +### SQL Injection Prevention + +**The #1 rule**: Always use parameterized queries. Never concatenate user input into SQL strings. + +Every major language and framework has a parameterized query mechanism: +- Python: `cursor.execute("SELECT ...", params)` / ORM filter methods +- Java: `PreparedStatement` / JPA `@Query` with `@Param` +- Go: `db.Query("SELECT ...", args...)` +- Node.js: `client.query("SELECT ...", [args])` / Prisma ORM +- PHP: PDO prepared statements / Laravel Eloquent +- C#: ADO.NET `SqlParameter` / Dapper / EF Core LINQ + +> **See [SQL Injection Prevention Guide](cross-cutting/sql-injection-prevention.md) for complete cross-language examples, ORM unsafe patterns, dynamic identifier handling, and detection tools.** + +### XSS Prevention + +**The #1 rule**: Rely on framework auto-escaping. Audit every escape hatch. + +Every major framework auto-escapes by default: +- React: JSX auto-escapes. Audit `dangerouslySetInnerHTML`. +- Vue: `{{ }}` auto-escapes. Audit `v-html`. +- Angular: Interpolation auto-escapes. Audit `bypassSecurityTrustHtml`. +- Svelte: `{ }` auto-escapes. Audit `{@html}`. +- Django: Templates auto-escape. Audit `mark_safe`. + +For defense-in-depth, configure Content Security Policy (CSP) with nonce-based `script-src`. + +> **See [XSS Prevention Guide](cross-cutting/xss-prevention.md) for complete cross-framework examples, CSP configuration, input validation vs output encoding, and detection tools.** + +### CSRF Prevention + +**CSRF Token Implementation** +```typescript +// ✅ Server: generate and validate CSRF token +import crypto from 'node:crypto'; + +function generateCsrfToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +// Middleware: validate token on state-changing requests +app.post('/api/data', (req, res) => { + const token = req.headers['x-csrf-token']; + const sessionToken = req.session.csrfToken; + if (!token || token !== sessionToken) { + return res.status(403).json({ error: 'Invalid CSRF token' }); + } + // ...handle request +}); +``` + +**Python (Django)** +```python +# ✅ Django: built-in CSRF protection +# settings.py +MIDDLEWARE = [ + 'django.middleware.csrf.CsrfViewMiddleware', # 默认启用 +] + +# templates: include CSRF token +#
+# {% csrf_token %} +#
+ +# ❌ Disabling CSRF on a view +@csrf_exempt # 除非绝对必要,否则不使用 +def my_view(request): + ... +``` + +**Java (Spring Boot)** +```java +// ✅ Spring Security: CSRF enabled by default +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) { + http.csrf(csrf -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + ); + return http.build(); + } +} +``` + +**SameSite Cookie** +```typescript +// ✅ Set SameSite cookie as additional defense +res.cookie('session', sessionId, { + httpOnly: true, + secure: true, + sameSite: 'strict', // 或 'lax' 用于允许导航 GET 请求 + maxAge: 3600000, +}); +``` + +### SSRF Prevention + +```python +# ❌ Vulnerable: user-controlled URL +import requests +url = request.GET.get('url') +response = requests.get(url) + +# ✅ Validate URL against whitelist +ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com'] + +def is_safe_url(url: str) -> bool: + from urllib.parse import urlparse + parsed = urlparse(url) + return parsed.hostname in ALLOWED_HOSTS + +if is_safe_url(url): + response = requests.get(url) +``` + +```typescript +// ❌ Vulnerable: fetching arbitrary URLs +const url = req.query.url; +const response = await fetch(url); + +// ✅ Validate URL before fetching +const ALLOWED_DOMAINS = ['api.internal.com']; + +function isSafeUrl(url: string): boolean { + try { + const parsed = new URL(url); + // Block internal IPs + if (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1') { + return false; + } + if (parsed.hostname.match(/^10\.|^172\.(1[6-9]|2\d|3[01])\.|^192\.168\./)) { + return false; // Block private IP ranges + } + return ALLOWED_DOMAINS.includes(parsed.hostname); + } catch { + return false; + } +} +``` + +```go +// ✅ Go: validate URL before making requests +import "net/url" + +func isSafeURL(rawURL string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + // Block internal IPs + if u.Hostname() == "localhost" || u.Hostname() == "127.0.0.1" { + return false + } + // Only allow HTTPS + if u.Scheme != "https" { + return false + } + return true +} +``` + +### IDOR(不安全直接对象引用) + +```python +# ❌ Vulnerable: no ownership check +def get_order(request, order_id): + order = Order.objects.get(id=order_id) # 任何用户可查看任何订单 + return JsonResponse(order.to_dict()) + +# ✅ Check ownership before returning +def get_order(request, order_id): + order = Order.objects.filter(id=order_id, user=request.user).first() + if not order: + return JsonResponse({'error': 'Not found'}, status=404) + return JsonResponse(order.to_dict()) +``` + +```typescript +// ❌ Vulnerable: no authorization check +app.get('/api/orders/:id', async (req, res) => { + const order = await db.order.findUnique({ + where: { id: Number(req.params.id) } + }); + res.json(order); +}); + +// ✅ Include user context in query +app.get('/api/orders/:id', async (req, res) => { + const order = await db.order.findFirst({ + where: { + id: Number(req.params.id), + userId: req.user.id, // Scope to current user + } + }); + if (!order) return res.status(404).json({ error: 'Not found' }); + res.json(order); +}); +``` + +```java +// ✅ Spring Security: method-level authorization +@GetMapping("/api/orders/{id}") +@PreAuthorize("@orderService.isOwner(#id, authentication.principal.id)") +public Order getOrder(@PathVariable Long id) { + return orderService.findById(id); +} +``` + +**UUID vs 自增 ID** +```typescript +// ❌ 自增 ID 可被枚举 +// GET /api/users/1, /api/users/2, /api/users/3 ... + +// ✅ UUID 不可预测 +// GET /api/users/550e8400-e29b-41d4-a716-446655440000 + +// ⚠️ UUID 只是防止枚举,不是权限控制 +// 仍然需要验证当前用户是否有权访问该资源 +``` + +### Command Injection Prevention + +**Python** +```python +# ❌ Vulnerable: shell=True +import subprocess +subprocess.run(f"convert {filename} output.png", shell=True) + +# ✅ Use list arguments without shell +subprocess.run(['convert', filename, 'output.png'], check=True) + +# ✅ Validate and sanitize input +import shlex +safe_filename = shlex.quote(filename) +``` + +**Node.js** +```typescript +// ❌ Vulnerable: exec with string interpolation +import { exec } from 'node:child_process'; +exec(`convert ${filename} output.png`); + +// ✅ Use execFile with array arguments +import { execFile } from 'node:child_process'; +execFile('convert', [filename, 'output.png'], (error, stdout) => { + if (error) throw error; +}); + +// ❌ Never pass user input to shell +exec(`echo ${userInput}`); // userInput = "; rm -rf /" + +// ✅ Sanitize or use non-shell alternatives +import { writeFile } from 'node:fs/promises'; +await writeFile('output.txt', userInput); // No shell involved +``` + +**Go** +```go +// ❌ Vulnerable: shell command with user input +cmd := exec.Command("sh", "-c", "echo " + userInput) + +// ✅ Use exec.Command with separate arguments +cmd := exec.Command("echo", userInput) + +// ❌ Passing user input to shell +out, _ := exec.Command("bash", "-c", "cat "+filename).Output() + +// ✅ Read file directly without shell +data, err := os.ReadFile(filename) +``` + +**Java** +```java +// ❌ Vulnerable: Runtime.exec with string concatenation +Runtime.getRuntime().exec("convert " + filename + " output.png"); + +// ✅ Use ProcessBuilder with separate arguments +ProcessBuilder pb = new ProcessBuilder("convert", filename, "output.png"); +Process process = pb.start(); + +// ❌ Dangerous: passing user input to shell +Runtime.getRuntime().exec(new String[]{"sh", "-c", "echo " + userInput}); +``` + +## Data Protection + +### Sensitive Data Handling +- [ ] No secrets in source code +- [ ] Secrets stored in environment variables or secret manager +- [ ] Sensitive data encrypted at rest +- [ ] Sensitive data encrypted in transit (HTTPS) +- [ ] PII handled according to regulations (GDPR, etc.) +- [ ] Sensitive data not logged +- [ ] Secure data deletion when required + +### Configuration Security +```yaml +# ❌ Secrets in config files +database: + password: "super-secret-password" + +# ✅ Reference environment variables +database: + password: ${DATABASE_PASSWORD} +``` + +### Error Messages +```typescript +// ❌ Leaking sensitive information +catch (error) { + return res.status(500).json({ + error: error.stack, // Exposes internal details + query: sqlQuery // Exposes database structure + }); +} + +// ✅ Generic error messages +catch (error) { + logger.error('Database error', { error, userId }); // Log internally + return res.status(500).json({ + error: 'An unexpected error occurred' + }); +} +``` + +## API Security + +### Rate Limiting +- [ ] Rate limiting on all public endpoints +- [ ] Stricter limits on authentication endpoints +- [ ] Per-user and per-IP limits +- [ ] Graceful handling when limits exceeded + +### CORS Configuration +```typescript +// ❌ Overly permissive CORS +app.use(cors({ origin: '*' })); + +// ✅ Restrictive CORS +app.use(cors({ + origin: ['https://your-app.com'], + methods: ['GET', 'POST'], + credentials: true +})); +``` + +### HTTP Headers +```typescript +// Security headers to set +app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + } + }, + hsts: { maxAge: 31536000, includeSubDomains: true }, + noSniff: true, + xssFilter: true, + frameguard: { action: 'deny' } +})); +``` + +## Cryptography + +### Secure Practices +- [ ] Using well-established algorithms (AES-256, RSA-2048+) +- [ ] Not implementing custom cryptography +- [ ] Using cryptographically secure random number generation +- [ ] Proper key management and rotation +- [ ] Secure key storage (HSM, KMS) + +### Common Mistakes +```typescript +// ❌ Weak random generation +const token = Math.random().toString(36); + +// ✅ Cryptographically secure random +const crypto = require('crypto'); +const token = crypto.randomBytes(32).toString('hex'); + +// ❌ MD5/SHA1 for passwords +const hash = crypto.createHash('md5').update(password).digest('hex'); + +// ✅ Use bcrypt or argon2 +const bcrypt = require('bcrypt'); +const hash = await bcrypt.hash(password, 12); +``` + +## Dependency Security + +### Checklist +- [ ] Dependencies from trusted sources only +- [ ] No known vulnerabilities (npm audit, cargo audit) +- [ ] Dependencies kept up to date +- [ ] Lock files committed (package-lock.json, Cargo.lock) +- [ ] Minimal dependency usage +- [ ] License compliance verified + +### Audit Commands +```bash +# Node.js +npm audit +npm audit fix + +# Python +pip-audit +safety check + +# Rust +cargo audit + +# General +snyk test +``` + +## Logging & Monitoring + +### Secure Logging +- [ ] No sensitive data in logs (passwords, tokens, PII) +- [ ] Logs protected from tampering +- [ ] Appropriate log retention +- [ ] Security events logged (login attempts, permission changes) +- [ ] Log injection prevented + +```typescript +// ❌ Logging sensitive data +logger.info(`User login: ${email}, password: ${password}`); + +// ✅ Safe logging +logger.info('User login attempt', { email, success: true }); +``` + +## Security Review Severity Levels + +| Severity | Description | Action | +|----------|-------------|--------| +| **Critical** | Immediate exploitation possible, data breach risk | Block merge, fix immediately | +| **High** | Significant vulnerability, requires specific conditions | Block merge, fix before release | +| **Medium** | Moderate risk, defense in depth concern | Should fix, can merge with tracking | +| **Low** | Minor issue, best practice violation | Nice to fix, non-blocking | +| **Info** | Suggestion for improvement | Optional enhancement | diff --git a/examples/skills_code_review_agent/skills/code-review/reference/svelte.md b/examples/skills_code_review_agent/skills/code-review/reference/svelte.md new file mode 100644 index 000000000..8993d6773 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/svelte.md @@ -0,0 +1,1064 @@ +# Svelte / SvelteKit Code Review Guide + +Svelte 5 / SvelteKit 审查重点:Runes 响应式系统、Server/Client 边界、Form Actions、Store 迁移、以及安全性。 + +## 目录 + +- [Runes: $state / $derived / $effect](#runes-state--derived--effect) +- [Load 函数(Server vs Client)](#load-函数server-vs-client) +- [Form Actions](#form-actions) +- [Store 迁移(→ $state)](#store-迁移) +- [SSR vs CSR 边界](#ssr-vs-csr-边界) +- [响应式语句迁移($: → Runes)](#响应式语句迁移) +- [性能优化](#性能优化) +- [安全审查](#安全审查) +- [Review Checklist](#review-checklist) + +--- + +## Runes: $state / $derived / $effect + +### $state 基础用法 + +```svelte + + + + + +``` + +### $state.raw 与大型对象 + +```svelte + + + + + +``` + +### $state.snapshot 用于外部库 + +```svelte + + + + + +``` + +### 解构 $state 丢失响应性 + +```svelte + + +

{count}

+ + + +

{state.count}

+ + + +``` + +--- + +### $derived 正确用法 + +```svelte + + + + + +``` + +### $derived 中不应有副作用 + +```svelte + + + + + +``` + +--- + +### $effect 正确用法 + +#### $effect vs $derived + +```svelte + + + + + +``` + +#### 无限循环 + +```svelte + + + + + +``` + +#### 清理函数 + +```svelte + + + + + + + + +``` + +#### async $effect 的追踪陷阱 + +```svelte + + + + + +``` + +#### untrack 排除依赖 + +```svelte + + + + + +``` + +--- + +## Load 函数(Server vs Client) + +### +page.server.js vs +page.js + +```typescript +// ❌ 在 +page.js 中访问数据库或 secrets +// src/routes/admin/+page.js +export async function load({ fetch }) { + // universal load runs on both server and client + const data = await db.query('SELECT * FROM users'); // db not available in browser! + return { users: data }; +} + +// ✅ 服务端逻辑放在 +page.server.js +// src/routes/admin/+page.server.js +import { db } from '$lib/server/db'; + +export async function load() { + const users = await db.query('SELECT * FROM users'); + return { users }; +} +``` + +```typescript +// ✅ +page.js 用于客户端也可用的数据(如 fetch 聚合) +// src/routes/dashboard/+page.js +export async function load({ fetch, parent }) { + const [analytics, notifications] = await Promise.all([ + fetch('/api/analytics').then(r => r.json()), + fetch('/api/notifications').then(r => r.json()) + ]); + return { analytics, notifications }; +} +``` + +### await parent() 瀑布流 + +```typescript +// ❌ 顺序 await parent → 瀑布流 +// src/routes/blog/[slug]/+page.js +export async function load({ parent, fetch }) { + const parentData = await parent(); // wait for parent + const post = await fetch(`/api/posts/${parentData.blogId}`); + return { post }; +} + +// ✅ 尽可能并行,避免不必要的 parent await +// src/routes/blog/[slug]/+page.js +export async function load({ parent, fetch }) { + // only await parent if you truly need its data + const post = await fetch('/api/posts/slug'); + return { post }; +} + +// ✅ 如果确实需要 parent 数据,无法避免瀑布流,但要明确注释 +// src/routes/blog/[slug]/+page.js +export async function load({ parent, fetch }) { + const { blogId } = await parent(); // required: need blogId for post URL + const post = await fetch(`/api/posts/${blogId}`); + return { post }; +} +``` + +### 不可序列化的返回值 + +```typescript +// ❌ 从 server load 返回不可序列化的值 +// src/routes/api/+page.server.js +export async function load() { + return { + stream: fs.createReadStream('data.csv'), // not serializable! + callback: () => console.log('hi'), // functions not serializable! + date: new Date(), // OK — devalue serializes Date/Map/Set fine + }; +} + +// ✅ 只返回可序列化的数据 +// src/routes/api/+page.server.js +export async function load() { + return { + data: await readFile('data.csv', 'utf-8'), + timestamp: Date.now(), + }; +} +``` + +--- + +## Form Actions + +### 使用 POST 处理副作用 + +```svelte + + + + + +``` + +```typescript +// src/routes/users/+page.server.js +import { fail, redirect } from '@sveltejs/kit'; + +export const actions = { + delete: async ({ request, locals }) => { + const formData = await request.formData(); + const id = formData.get('id'); + + if (!id) return fail(400, { message: 'Missing id' }); + + await locals.db.users.delete(id); + throw redirect(303, '/users'); + } +}; +``` + +```svelte + + + +
+ + +
+``` + +### fail() 中不暴露敏感信息 + +```typescript +// ❌ fail() 中返回敏感信息 +// src/routes/login/+page.server.js +export const actions = { + default: async ({ request, locals }) => { + const formData = await request.formData(); + const user = await locals.db.users.findByEmail(formData.get('email')); + + return fail(401, { + password: formData.get('password'), // ❌ exposes password in page data! + hint: user.passwordHint, // ❌ leaks internal data! + }); + } +}; + +// ✅ 只返回安全的错误信息 +export const actions = { + default: async ({ request }) => { + const formData = await request.formData(); + const email = formData.get('email'); + + return fail(401, { + email, // ✅ safe to echo back + incorrect: true, // ✅ generic error flag + }); + } +}; +``` + +### use:enhance 渐进增强 + +```svelte + +
+ + +
+ + + + +
{ + return ({ update }) => { + update({ reset: false }); // customize behavior + }; +}}> + + +
+ + +
{ + submitting = true; + return ({ update }) => { + update(); + submitting = false; + }; + }} +> + +
+``` + +--- + +## Store 迁移(→ $state) + +### writable/readable → $state + +```typescript +// ❌ Legacy store pattern (Svelte 4) +// src/lib/stores/user.js +import { writable, derived } from 'svelte/store'; + +export const user = writable(null); +export const isLoggedIn = derived(user, $user => !!$user); + +// usage with $ prefix +// $user = { name: 'John' }; + +// ✅ Svelte 5: shared state in .svelte.js files +// src/lib/stores/user.svelte.js +let currentUser = $state(null); + +export function getUser() { + return currentUser; +} + +export function setUser(user: User | null) { + currentUser = user; +} + +export function isLoggedIn() { + return currentUser !== null; +} +``` + +### $ 前缀 store 语法是遗留语法 + +```svelte + + +

{$count}

+ + + +

{count}

+ + + +

{counter.value}

+``` + +### .svelte.js / .svelte.ts 扩展名 + +```typescript +// ❌ 在普通 .js 文件中使用 runes → 编译错误 +// src/lib/utils.js +let state = $state(0); // runes only work in .svelte.js files! + +// ✅ 使用 .svelte.js 扩展名 +// src/lib/utils.svelte.js +let state = $state(0); + +export function getState() { + return state; +} + +export function setState(val: number) { + state = val; +} +``` + +--- + +## SSR vs CSR 边界 + +### ssr=false SPA 模式 + +```typescript +// ❌ 在根 layout 中禁用 SSR → 全部变成 CSR +// src/routes/+layout.js +export const ssr = false; // entire app becomes SPA + +// ✅ 只在需要的页面禁用 SSR +// src/routes/admin/dashboard/+page.js +export const ssr = false; // only this page skips SSR + +// ✅ 更好的做法:按路由配置 +// src/routes/editor/+page.js +export const ssr = false; // editor needs browser APIs, skip SSR +``` + +### 浏览器全局变量在 SSR 中 + +```svelte + + + + + +``` + +### prerender 与 actions 冲突 + +```typescript +// ❌ prerender 页面中定义 actions → 编译错误 +// src/routes/contact/+page.server.js +export const prerender = true; + +export const actions = { + // Error: prerendered pages cannot have server-side form actions + default: async ({ request }) => { /* ... */ } +}; + +// ✅ prerender 页面不使用 server actions +// src/routes/about/+page.server.js +export const prerender = true; +// no actions — static page + +// ✅ 需要 actions 的页面不 prerender +// src/routes/contact/+page.server.js +export const actions = { + default: async ({ request }) => { + // handle form submission + } +}; +``` + +--- + +## 响应式语句迁移 + +### $: → $derived / $effect + +```svelte + + + + + +``` + +### export let → $props() + +```svelte + + + + + +``` + +### on:click → onclick + +```svelte + + + + + + + +``` + +### createEventDispatcher → 回调 props + +```svelte + + + + + + + + removeItem(e.id)} /> +``` + +### slot → @render children() + +```svelte + + +
+ +
+ + + + +
+ {@render children()} +
+ + + + +
+
{@render header?.()}
+
{@render children()}
+
{@render footer?.()}
+
+ + + + {#snippet header()}

Title

{/snippet} +

Body content

+ {#snippet footer()}

Footer

{/snippet} +
+``` + +### beforeUpdate / afterUpdate → $effect.pre + +```svelte + + + + + +``` + +--- + +## 性能优化 + +### $state.raw 用于大型不可变数据 + +```svelte + + + + + +``` + +### Keyed {#each} + +```svelte + +{#each items as item} +
{item.name}
+{/each} + + +{#each items as item (item.id)} +
{item.name}
+{/each} + + +{#each items as item (`${item.category}-${item.id}`)} +
{item.name}
+{/each} +``` + +### Streaming 与 load 中的 Promise + +```typescript +// ❌ 串行等待所有数据 → 页面阻塞 +// src/routes/+page.server.js +export async function load({ params }) { + const posts = await getPosts(); // slow + const comments = await getComments(); // slow + const tags = await getTags(); // slow + return { posts, comments, tags }; +} + +// ✅ 并行加载独立数据 +export async function load({ params }) { + return { + posts: getPosts(), // return promises directly for streaming + comments: getComments(), + tags: getTags(), + }; +} +``` + +```svelte + +{#await data.posts} +

Loading posts...

+{:then posts} +
    + {#each posts as post (post.id)} +
  • {post.title}
  • + {/each} +
+{:catch error} +

Failed to load posts: {error.message}

+{/await} +``` + +--- + +## 安全审查 + +Svelte/SvelteKit 默认自动转义模板表达式。审查重点:`{@html}` 的使用、`$env/static/private` 泄露、CSRF 内建防护。 + +> **跨框架 XSS 防护详见 [XSS Prevention Guide](cross-cutting/xss-prevention.md)**,含 React/Vue/Angular/Svelte 示例及 CSP 配置。 + +### 不暴露私有环境变量 + +```typescript +// ❌ 在 universal load 中暴露服务端 secrets +// src/routes/admin/+page.js (universal — runs on client too!) +export async function load() { + return { + apiKey: process.env.SECRET_API_KEY, // exposed to client bundle! + dbUrl: import.meta.env.DATABASE_URL, // leaks to browser! + }; +} + +// ✅ 私有环境变量只在 server load 中使用 +// src/routes/admin/+page.server.js (server-only) +export async function load({ locals }) { + // secrets stay on server + const data = await fetch(process.env.SECRET_API_KEY + '/admin'); + return { data }; // only derived data is sent to client +} + +// ✅ 公开变量使用 PUBLIC_ 前缀 +// .env +// PUBLIC_API_URL=https://api.example.com +// SECRET_API_KEY=xxx (no PUBLIC_ prefix = server-only) +``` + +### $lib/server/ 服务端隔离 + +```typescript +// ❌ 服务端代码放在可被客户端导入的位置 +// src/lib/db.js +import { SECRET_DB_URL } from '$env/static/private'; +// any client component importing this gets the secret! + +// ✅ 放在 $lib/server/ 目录 → 客户端导入会编译报错 +// src/lib/server/db.js +import { SECRET_DB_URL } from '$env/static/private'; + +export async function query(sql: string) { + // safe: client cannot import from $lib/server/ +} + +// usage in server files only +// src/routes/api/users/+server.js +import { query } from '$lib/server/db'; +``` + +### CSRF 内建防护 + +```typescript +// ✅ SvelteKit 内建 CSRF 防护 +// Origin header is checked automatically for POST/PUT/DELETE/PATCH +// No additional CSRF tokens needed for form actions + +// ❌ 不要禁用 CSRF 检查(除非有充分理由) +// src/hooks.server.js +export const handle = sequence( + // do NOT do this without understanding the implications + // ({ event, resolve }) => resolve(event, { filterSerializedResponseHeaders: () => true }) +); +``` + +### Cookie 安全设置 + +```typescript +// ❌ 不安全的 Cookie 设置 +// src/hooks.server.js +export async function handle({ event, resolve }) { + const token = event.cookies.get('session'); + // cookie without httpOnly, secure, sameSite flags + event.cookies.set('session', token, { + path: '/', + // missing: httpOnly, secure, sameSite + }); +} + +// ✅ 安全的 Cookie 配置 +import { dev } from '$app/environment'; + +event.cookies.set('session', token, { + path: '/', + httpOnly: true, // not accessible via JS + secure: !dev, // HTTPS only in production + sameSite: 'lax', // CSRF protection + maxAge: 60 * 60 * 24 * 7 // 1 week, explicit expiry +}); +``` + +--- + +## Review Checklist + +### Runes: $state / $derived / $effect + +- [ ] $state 只用于会变化的值,常量直接声明 +- [ ] 大型不可变数据使用 $state.raw +- [ ] 没有解构 $state 对象(会丢失响应性) +- [ ] 外部库使用 $state.snapshot 传入普通对象 +- [ ] $derived 中没有副作用 +- [ ] 没有用 $effect 替代 $derived 做状态同步 +- [ ] $effect 中不修改被追踪的状态(避免无限循环) +- [ ] $effect 有清理函数(订阅、定时器、WebSocket) +- [ ] async $effect 在 await 前读取所有需要追踪的状态 +- [ ] 使用 untrack 排除不相关的依赖 + +### Load 函数 + +- [ ] 服务端逻辑放在 +page.server.js(不是 +page.js) +- [ ] 避免不必要的 await parent() 瀑布流 +- [ ] 独立数据并行加载(Promise.all 或直接返回 Promise) +- [ ] server load 只返回可序列化的数据 + +### Form Actions + +- [ ] 副作用操作(增删改)使用 form actions + POST +- [ ] fail() 不返回敏感信息(密码、内部数据) +- [ ] 使用 use:enhance 实现渐进增强 + +### Store 迁移 + +- [ ] writable/readable → $state 在 .svelte.js 文件中 +- [ ] 不在普通 .js 文件中使用 runes +- [ ] 不使用遗留的 $ 前缀 store 语法 + +### SSR vs CSR 边界 + +- [ ] 不在根 layout 中全局禁用 SSR +- [ ] 浏览器 API(window、document)在 onMount 或 browser guard 中使用 +- [ ] prerender 页面不包含 server actions + +### Svelte 4 → 5 迁移 + +- [ ] $: → $derived / $effect +- [ ] export let → $props() +- [ ] on:click → onclick +- [ ] createEventDispatcher → 回调 props +- [ ] slot → @render children() +- [ ] beforeUpdate/afterUpdate → $effect.pre / $effect + +### 性能优化 + +- [ ] 大型不可变数据使用 $state.raw +- [ ] {#each} 使用唯一 key +- [ ] load 函数返回 Promise 实现流式传输 +- [ ] 独立数据并行加载 + +### 安全审查 + +- [ ] 私有环境变量只在 server load 中使用 +- [ ] 服务端代码放在 $lib/server/ 目录 +- [ ] 不禁用内建 CSRF 防护 +- [ ] Cookie 设置 httpOnly、secure、sameSite +- [ ] server load 不泄露密钥和内部数据 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/swift.md b/examples/skills_code_review_agent/skills/code-review/reference/swift.md new file mode 100644 index 000000000..651eeac8c --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/swift.md @@ -0,0 +1,936 @@ +# Swift Code Review Guide + +A code review checklist for modern Swift (5.9+/6), covering SwiftUI, Swift Concurrency, and the Swift API Design Guidelines. + +## Quick Review Checklist + +### Must-Check Items +- [ ] Are force-unwraps (`!`) and `try!` avoided in favor of safe unwrapping +- [ ] Do closures that capture `self` use `[weak self]` to avoid retain cycles +- [ ] Is the value vs reference type choice intentional (struct vs class) +- [ ] Are errors propagated with `throws`/`Result` instead of being swallowed +- [ ] Are concurrency boundaries data-race-safe (`Sendable`, `@MainActor`, actors) + +### Common Issues +- [ ] Fire-and-forget `Task {}` that leaks or is never cancelled +- [ ] Wrong SwiftUI property wrapper (`@ObservedObject` where `@StateObject` is needed) +- [ ] O(n^2) lookups in loops that could use a `Set` or `Dictionary` +- [ ] Implicitly unwrapped optionals (`var x: T!`) outside of IBOutlets +- [ ] Over-broad access control (`public`/`open` where `internal` suffices) +- [ ] Naming that ignores the Swift API Design Guidelines + +--- + +## 1. Optionals and Unwrapping + +### 1.1 Avoid Force-Unwrapping + +```swift +// ❌ Wrong: crashes at runtime if nil +let name = user.name! +let url = URL(string: urlString)! + +// ✅ Correct: bind with guard let / if let +guard let name = user.name else { + return +} + +if let url = URL(string: urlString) { + load(url) +} +``` + +### 1.2 Use Nil-Coalescing for Defaults + +```swift +// ❌ Wrong: verbose and crash-prone +let count: Int +if let c = dictionary["count"] { + count = c +} else { + count = 0 +} + +// ✅ Correct: nil-coalescing +let count = dictionary["count"] ?? 0 +``` + +### 1.3 Prefer guard let for Early Exit + +```swift +// ❌ Wrong: deep nesting (pyramid of doom) +func process(_ input: String?) { + if let input = input { + if let value = Int(input) { + if value > 0 { + handle(value) + } + } + } +} + +// ✅ Correct: guard keeps the happy path unindented +func process(_ input: String?) { + guard let input, + let value = Int(input), + value > 0 else { + return + } + handle(value) +} +``` + +### 1.4 Avoid Implicitly Unwrapped Optionals + +```swift +// ❌ Wrong: T! is a hidden force-unwrap on every access +class ViewModel { + var service: NetworkService! +} + +// ✅ Correct: inject a non-optional dependency +class ViewModel { + private let service: NetworkService + + init(service: NetworkService) { + self.service = service + } +} +``` + +### 1.5 Use Optional Chaining and map/flatMap + +```swift +// ❌ Wrong: manual unwrapping just to transform +var initial: String? +if let name = user.name { + initial = String(name.prefix(1)) +} + +// ✅ Correct: optional chaining + map +let initial = user.name.map { String($0.prefix(1)) } + +// ✅ Correct: flatMap to avoid double optionals +let port: Int? = components.port.flatMap { Int(exactly: $0) } +``` + +--- + +## 2. Memory Management and Retain Cycles + +### 2.1 Use [weak self] in Escaping Closures + +```swift +// ❌ Wrong: closure strongly captures self, creating a retain cycle +class ImageLoader { + var onComplete: (() -> Void)? + + func load() { + service.fetch { data in + self.cache = data // self is retained by the closure + self.onComplete?() + } + } +} + +// ✅ Correct: capture self weakly and guard +class ImageLoader { + var onComplete: (() -> Void)? + + func load() { + service.fetch { [weak self] data in + guard let self else { return } + self.cache = data + self.onComplete?() + } + } +} +``` + +### 2.2 weak vs unowned + +```swift +// ✅ Use weak when the reference can legitimately become nil +class Controller { + weak var delegate: ControllerDelegate? +} + +// ✅ Use unowned only when the captured object is guaranteed to +// outlive the closure (e.g. self owns the closure tightly). +// unowned crashes if accessed after deallocation. +class Owner { + lazy var describe: () -> String = { [unowned self] in + self.name + } + let name = "owner" +} + +// ❌ Wrong: unowned on something that can outlive self -> crash +networkClient.onResponse = { [unowned self] in self.update() } +// Prefer [weak self] here, since onResponse may fire after self is gone. +``` + +### 2.3 Break Delegate Retain Cycles + +```swift +// ❌ Wrong: strong delegate keeps both objects alive forever +protocol DataSourceDelegate: AnyObject {} + +class DataSource { + var delegate: DataSourceDelegate? // strong by default +} + +// ✅ Correct: delegates should be weak (and protocol AnyObject-bound) +class DataSource { + weak var delegate: DataSourceDelegate? +} +``` + +### 2.4 Closures Stored as Properties + +```swift +// ❌ Wrong: stored closure captures self strongly -> permanent cycle +class Timer { + var tick: (() -> Void)! + func configure() { + tick = { self.count += 1 } + } + var count = 0 +} + +// ✅ Correct: weak capture for stored closures referencing self +class Timer { + var tick: (() -> Void)? + func configure() { + tick = { [weak self] in self?.count += 1 } + } + var count = 0 +} +``` + +--- + +## 3. Value vs Reference Types + +### 3.1 Prefer Structs by Default + +```swift +// ✅ Use a struct for data/models with value semantics +struct Coordinate { + var latitude: Double + var longitude: Double +} + +// Copies are independent; no shared mutable state, thread-friendly. +var a = Coordinate(latitude: 1, longitude: 2) +var b = a +b.latitude = 99 // a is unchanged +``` + +### 3.2 Use a Class for Identity or Shared State + +```swift +// ✅ Use a class when instances have identity or must be shared/mutated +// by reference, or when you need inheritance / Objective-C interop. +final class DatabaseConnection { + private(set) var isOpen = false + func open() { isOpen = true } +} + +// Two references point to the same connection. +let conn1 = DatabaseConnection() +let conn2 = conn1 +conn1.open() +// conn2.isOpen == true +``` + +### 3.3 Mark Classes final When Not Subclassed + +```swift +// ❌ Wrong: open to subclassing unintentionally (slower dispatch, fragile API) +class UserViewModel {} + +// ✅ Correct: final enables static dispatch and signals intent +final class UserViewModel {} +``` + +### 3.4 Beware Reference Types Inside Structs + +```swift +// ❌ Surprising: struct copy still shares the inner class instance +final class Box { var value = 0 } +struct Container { var box = Box() } + +var x = Container() +var y = x +y.box.value = 42 // x.box.value is also 42 (shared reference!) + +// ✅ Correct: use value semantics throughout, or copy on write deliberately +struct Container { + var value = 0 // plain value type, copies are independent +} +``` + +--- + +## 4. Error Handling + +> 📖 For cross-language error handling principles, see [Error Handling Guide](cross-cutting/error-handling-principles.md) + +### 4.1 Avoid try! and try? + +```swift +// ❌ Wrong: try! crashes on any thrown error +let data = try! Data(contentsOf: url) + +// ❌ Often wrong: try? silently discards the error and the cause +let data = try? Data(contentsOf: url) // data is nil, you lose "why" + +// ✅ Correct: propagate or handle with do-catch +do { + let data = try Data(contentsOf: url) + process(data) +} catch { + log.error("failed to read \(url): \(error)") +} +``` + +### 4.2 Define Meaningful Error Types + +```swift +// ✅ Recommended: an Error enum communicates failure modes precisely +enum NetworkError: Error { + case invalidURL + case unauthorized + case server(statusCode: Int) + case decoding(underlying: Error) +} + +func fetch(_ path: String) throws -> Data { + guard let url = URL(string: path) else { + throw NetworkError.invalidURL + } + // ... +} +``` + +### 4.3 Use Result for Stored or Deferred Outcomes + +```swift +// ✅ Result is useful at callback boundaries or when storing an outcome +func load(completion: @escaping (Result) -> Void) { + // completion(.success(user)) or completion(.failure(.unauthorized)) +} + +// ✅ Convert between Result and throws as needed +let user = try result.get() +``` + +### 4.4 Typed Throws (Swift 6) + +```swift +// ✅ Typed throws constrains the error type when it is fully known. +// Use it for closed, exhaustive error domains; prefer untyped +// `throws` for library APIs that may grow new error cases. +func parse(_ raw: String) throws(ParsingError) -> Token { + guard let token = Token(raw) else { + throw ParsingError.malformed + } + return token +} + +do { + let token = try parse(input) +} catch { + // `error` is statically known to be ParsingError + handle(error) +} +``` + +### 4.5 Don't Catch and Rethrow Without Value + +```swift +// ❌ Wrong: catch that adds nothing but obscures the trace +do { + try work() +} catch { + throw error // pointless +} + +// ✅ Correct: only catch to add context or recover +do { + try work() +} catch { + throw AppError.workFailed(underlying: error) +} +``` + +--- + +## 5. Swift Concurrency + +> 📖 For cross-language concurrency patterns, see [Async & Concurrency Guide](cross-cutting/async-concurrency-patterns.md) + +### 5.1 Prefer async/await Over Nested Callbacks + +```swift +// ❌ Wrong: callback pyramid, error handling scattered +func loadProfile(completion: @escaping (Result) -> Void) { + fetchUser { userResult in + switch userResult { + case .success(let user): + fetchAvatar(user) { avatarResult in /* ... */ } + case .failure(let error): + completion(.failure(error)) + } + } +} + +// ✅ Correct: linear async/await +func loadProfile() async throws -> Profile { + let user = try await fetchUser() + let avatar = try await fetchAvatar(user) + return Profile(user: user, avatar: avatar) +} +``` + +### 5.2 Use @MainActor for UI State + +```swift +// ❌ Wrong: mutating UI state from a background context (data race / crash) +func refresh() async { + let items = try? await api.load() + self.items = items ?? [] // may run off the main thread +} + +// ✅ Correct: isolate UI-facing types to the main actor +@MainActor +final class FeedViewModel: ObservableObject { + @Published var items: [Item] = [] + + func refresh() async { + let loaded = (try? await api.load()) ?? [] + items = loaded // guaranteed on the main actor + } +} +``` + +### 5.3 Protect Mutable State with Actors + +```swift +// ❌ Wrong: shared mutable state without synchronization (data race) +final class Counter { + var value = 0 + func increment() { value += 1 } +} + +// ✅ Correct: an actor serializes access to its mutable state +actor Counter { + private(set) var value = 0 + func increment() { value += 1 } +} + +let counter = Counter() +await counter.increment() // access is awaited and serialized +``` + +### 5.4 Conform Shared Types to Sendable + +```swift +// ❌ Wrong: passing a non-Sendable class across actors (Swift 6 error) +final class Config { // mutable, not Sendable + var retries = 3 +} + +// ✅ Correct: make shared types Sendable (immutable value type is ideal) +struct Config: Sendable { + let retries: Int +} + +// ✅ For reference types, use final + immutable stored properties, +// or @unchecked Sendable only with manual synchronization. +final class Cache: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String: Data] = [:] + // all access guarded by lock +} +``` + +### 5.5 Handle Task Cancellation + +```swift +// ❌ Wrong: ignores cancellation, keeps working after the view is gone +func search(_ query: String) async -> [Result] { + var results: [Result] = [] + for page in 0..<100 { + results += await fetchPage(query, page) // never stops + } + return results +} + +// ✅ Correct: check for cancellation cooperatively +func search(_ query: String) async throws -> [Result] { + var results: [Result] = [] + for page in 0..<100 { + try Task.checkCancellation() + results += try await fetchPage(query, page) + } + return results +} +``` + +### 5.6 Don't Leak Fire-and-Forget Tasks + +```swift +// ❌ Wrong: unstructured Task with no handle, never cancelled +final class ViewModel { + func onAppear() { + Task { + await self.stream() // runs forever even after dismissal + } + } +} + +// ✅ Correct: retain the handle and cancel it (or use .task in SwiftUI) +final class ViewModel { + private var streamTask: Task? + + func onAppear() { + streamTask = Task { [weak self] in + await self?.stream() + } + } + + func onDisappear() { + streamTask?.cancel() + } +} +``` + +### 5.7 Use Structured Concurrency for Parallelism + +```swift +// ❌ Wrong: sequential awaits where work could run concurrently +let a = await loadA() +let b = await loadB() // waits for A to finish first + +// ✅ Correct: async let runs them concurrently +async let a = loadA() +async let b = loadB() +let (resultA, resultB) = await (a, b) + +// ✅ For a dynamic number of children, use a task group +try await withThrowingTaskGroup(of: Item.self) { group in + for id in ids { + group.addTask { try await fetch(id) } + } + for try await item in group { + store(item) + } +} +``` + +--- + +## 6. SwiftUI + +### 6.1 Choose the Right State Wrapper + +```swift +// ✅ @State: simple value-type state owned by this view +struct Toggle: View { + @State private var isOn = false + var body: some View { /* ... */ } +} + +// ✅ @StateObject: the view CREATES and OWNS a reference-type model +struct ProfileScreen: View { + @StateObject private var model = ProfileViewModel() + var body: some View { /* ... */ } +} + +// ✅ @ObservedObject: the model is OWNED elsewhere and passed in +struct ProfileHeader: View { + @ObservedObject var model: ProfileViewModel + var body: some View { /* ... */ } +} + +// ✅ @Binding: a two-way reference to state owned by a parent +struct SearchField: View { + @Binding var text: String + var body: some View { /* ... */ } +} +``` + +### 6.2 @StateObject vs @ObservedObject + +```swift +// ❌ Wrong: @ObservedObject for an object the view itself creates. +// SwiftUI may recreate the view, re-instantiating the model and +// losing its state on every re-render. +struct CounterView: View { + @ObservedObject var model = CounterModel() // recreated unexpectedly +} + +// ✅ Correct: @StateObject ties the model's lifetime to the view +struct CounterView: View { + @StateObject private var model = CounterModel() +} +``` + +### 6.3 Preserve View Identity + +```swift +// ❌ Wrong: index-based id reuses identity when the array reorders, +// causing wrong animations and stale state. +ForEach(0.. fresh state +``` + +### 6.4 Avoid Over-Rendering + +```swift +// ❌ Wrong: a single huge body re-renders everything on any change +struct Dashboard: View { + @ObservedObject var model: DashboardModel + var body: some View { + VStack { + // header + heavy chart + list all recompute together + } + } +} + +// ✅ Correct: extract subviews so only the affected part re-renders. +// Each child observes only the state it needs. +struct Dashboard: View { + var body: some View { + VStack { + HeaderView() + ChartView() + ItemList() + } + } +} +``` + +### 6.5 Do Async Work with .task + +```swift +// ❌ Wrong: kicking off work in onAppear without cancellation +.onAppear { + Task { await model.load() } // not cancelled when view disappears +} + +// ✅ Correct: .task is tied to the view's lifetime and auto-cancels +.task { + await model.load() +} + +// ✅ Re-run when an input changes +.task(id: query) { + await model.search(query) +} +``` + +--- + +## 7. Protocols and Generics + +### 7.1 Protocol-Oriented Design + +```swift +// ✅ Compose behavior with protocols and default implementations +protocol Identifiable2 { + var id: String { get } +} + +protocol Describable { + var description: String { get } +} + +extension Describable { + var description: String { "no description" } // default +} +``` + +### 7.2 Prefer some Over any + +```swift +// ❌ Slower: `any` is an existential box with dynamic dispatch +func makeShape() -> any Shape { Circle() } + +// ✅ Faster: `some` is an opaque type resolved at compile time, +// preserving the concrete type and enabling static dispatch. +func makeShape() -> some Shape { Circle() } + +// Use `any` only when you genuinely need heterogeneous values: +let shapes: [any Shape] = [Circle(), Square()] +``` + +### 7.3 Generic Constraints Over Existentials + +```swift +// ❌ Wrong: existential parameter loses the concrete type and is slower +func logTotal(_ items: [any Numeric]) { + // awkward: the concrete numeric type is erased, so arithmetic needs casts +} + +// ✅ Correct: a generic constraint keeps full type information +func total(_ items: [T]) -> T { + items.reduce(.zero, +) +} +``` + +### 7.4 Associated Types with Primary Associated Types + +```swift +// ✅ Primary associated types (Swift 5.7+) allow lightweight constraints +protocol Container { + associatedtype Item + var count: Int { get } + subscript(_ index: Int) -> Item { get } +} + +// Constrain the element type without a where-clause: +func first(in container: some Container) -> Int { + container[0] +} +``` + +--- + +## 8. Access Control and API Design + +### 8.1 Use the Narrowest Access Level + +```swift +// ❌ Wrong: everything public exposes internal details as API surface +public class Service { + public var cache: [String: Data] = [:] + public func reset() {} +} + +// ✅ Correct: expose only the intended API; hide the rest +public final class Service { + private var cache: [String: Data] = [:] + public func reset() { cache.removeAll() } +} +``` + +### 8.2 private vs fileprivate vs internal vs public/open + +```swift +// private: visible only within the enclosing declaration (and its extensions in the same file) +// fileprivate: visible within the same source file +// internal: visible within the module (the default) +// public: visible outside the module, but not subclassable/overridable +// open: visible outside the module AND subclassable/overridable + +// ✅ Use private(set) to expose read-only state +public final class Account { + public private(set) var balance: Decimal = 0 +} +``` + +### 8.3 Follow the Swift API Design Guidelines + +```swift +// ❌ Wrong: redundant words, unclear argument roles +func insertObject(_ object: Element, atIndex index: Int) +list.removeElement(at: 0) + +// ✅ Correct: read at the call site like a phrase; omit needless words +func insert(_ element: Element, at index: Int) +list.insert(item, at: 0) // reads as "insert item at 0" +list.remove(at: 0) + +// ✅ Boolean properties read as assertions +var isEmpty: Bool +var hasChanges: Bool +``` + +### 8.4 Name Methods by Side Effects + +```swift +// ✅ Mutating verb vs non-mutating noun pairs (the "ed/ing" rule) +var sorted = array.sorted() // returns a new value (non-mutating) +array.sort() // mutates in place (imperative verb) + +let reversed = text.reversed() +text.reverse() +``` + +--- + +## 9. Collections and Functional Style + +### 9.1 Prefer map/filter/compactMap + +```swift +// ❌ Verbose: manual loop with mutable accumulator +var names: [String] = [] +for user in users { + if user.isActive { + names.append(user.name) + } +} + +// ✅ Correct: declarative transform +let names = users.filter(\.isActive).map(\.name) +``` + +### 9.2 compactMap to Drop nils + +```swift +// ❌ Wrong: map leaves an [Int?] you then have to unwrap +let numbers = strings.map { Int($0) } // [Int?] + +// ✅ Correct: compactMap removes nils and unwraps +let numbers = strings.compactMap { Int($0) } // [Int] +``` + +### 9.3 Avoid O(n^2) Membership Checks + +```swift +// ❌ Wrong: contains on an Array is O(n); the loop is O(n*m) +let result = candidates.filter { blocked.contains($0) } // blocked: [ID] + +// ✅ Correct: a Set makes membership O(1) +let blockedSet = Set(blocked) +let result = candidates.filter { blockedSet.contains($0) } +``` + +### 9.4 reduce and Dictionary Grouping + +```swift +// ✅ Group with Dictionary(grouping:) +let byFirstLetter = Dictionary(grouping: words) { $0.first } + +// ❌ Wrong: reduce(into:) is preferred over reduce that copies each step +let total = numbers.reduce(0) { $0 + $1 } // fine for scalars + +// ✅ Use reduce(into:) when accumulating into a collection (avoids copies) +let counts = words.reduce(into: [:]) { acc, word in + acc[word, default: 0] += 1 +} +``` + +### 9.5 Use lazy for Chained Transforms on Large Sequences + +```swift +// ❌ Wrong: each step allocates an intermediate array +let firstMatch = bigArray.map(expensive).filter(isValid).first + +// ✅ Correct: lazy avoids intermediate arrays and stops early +let firstMatch = bigArray.lazy.map(expensive).filter(isValid).first +``` + +--- + +## 10. Testing + +### 10.1 Arrange-Act-Assert with XCTest + +```swift +import XCTest +@testable import MyApp + +final class PriceCalculatorTests: XCTestCase { + func testDiscountApplied() { + // Arrange + let calculator = PriceCalculator(discount: 0.1) + // Act + let total = calculator.total(for: 100) + // Assert + XCTAssertEqual(total, 90, accuracy: 0.001) + } +} +``` + +### 10.2 Testing async Code + +```swift +// ✅ Mark the test method async and await directly +func testFetchUser() async throws { + let service = UserService(client: MockClient()) + let user = try await service.fetchUser(id: "42") + XCTAssertEqual(user.id, "42") +} + +// ✅ Assert that an async call throws the expected error +func testFetchUserUnauthorized() async { + let service = UserService(client: UnauthorizedClient()) + do { + _ = try await service.fetchUser(id: "42") + XCTFail("expected to throw") + } catch NetworkError.unauthorized { + // expected + } catch { + XCTFail("unexpected error: \(error)") + } +} +``` + +### 10.3 Inject Dependencies via Protocols + +```swift +// ✅ Depend on a protocol so tests can substitute a mock +protocol HTTPClient { + func get(_ url: URL) async throws -> Data +} + +struct MockClient: HTTPClient { + var result: Result + func get(_ url: URL) async throws -> Data { + try result.get() + } +} +``` + +### 10.4 Avoid Sleeps; Await Expectations or Values + +```swift +// ❌ Wrong: arbitrary sleep makes tests slow and flaky +func testCallback() { + var done = false + object.run { done = true } + Thread.sleep(forTimeInterval: 1) + XCTAssertTrue(done) +} + +// ✅ Correct: use XCTestExpectation for callback APIs +func testCallback() { + let expectation = expectation(description: "callback fired") + object.run { expectation.fulfill() } + wait(for: [expectation], timeout: 1.0) +} + +// ✅ Better: refactor to async and await the value directly +func testCallback() async { + let value = await object.run() + XCTAssertEqual(value, expected) +} +``` + +--- + +## References + +- [Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/) +- [The Swift Programming Language](https://docs.swift.org/swift-book/) +- [Swift Concurrency (TSPL)](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) +- [Migrating to Swift 6](https://www.swift.org/migration/documentation/migrationguide/) +- [Apple: Managing Model Data in Your App (SwiftUI)](https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app) +- [Apple: Automatic Reference Counting](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/automaticreferencecounting/) +- [WWDC: Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/) +- [Swift Evolution](https://github.com/apple/swift-evolution) diff --git a/examples/skills_code_review_agent/skills/code-review/reference/typescript.md b/examples/skills_code_review_agent/skills/code-review/reference/typescript.md new file mode 100644 index 000000000..9a2750441 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/typescript.md @@ -0,0 +1,1016 @@ +# TypeScript/JavaScript Code Review Guide + +> TypeScript 代码审查指南,覆盖类型系统、泛型、条件类型、strict 模式、async/await 模式等核心主题。 + +## 目录 + +- [类型安全基础](#类型安全基础) +- [泛型模式](#泛型模式) +- [高级类型](#高级类型) +- [Strict 模式配置](#strict-模式配置) +- [异步处理](#异步处理) +- [不可变性](#不可变性) +- [ESLint 规则](#eslint-规则) +- [测试](#测试) +- [模块解析](#模块解析) +- [TS 4.9+ / 5.x 新特性](#ts-49--5x-新特性) +- [Review Checklist](#review-checklist) + +--- + +## 类型安全基础 + +### 避免使用 any + +```typescript +// ❌ Using any defeats type safety +function processData(data: any) { + return data.value; // 无类型检查,运行时可能崩溃 +} + +// ✅ Use proper types +interface DataPayload { + value: string; +} +function processData(data: DataPayload) { + return data.value; +} + +// ✅ 未知类型用 unknown + 类型守卫 +function processUnknown(data: unknown) { + if (typeof data === 'object' && data !== null && 'value' in data) { + return (data as { value: string }).value; + } + throw new Error('Invalid data'); +} +``` + +### 类型收窄 + +```typescript +// ❌ 不安全的类型断言 +function getLength(value: string | string[]) { + return (value as string[]).length; // 如果是 string 会出错 +} + +// ✅ 使用类型守卫 +function getLength(value: string | string[]): number { + if (Array.isArray(value)) { + return value.length; + } + return value.length; +} + +// ✅ 使用 in 操作符 +interface Dog { bark(): void } +interface Cat { meow(): void } + +function speak(animal: Dog | Cat) { + if ('bark' in animal) { + animal.bark(); + } else { + animal.meow(); + } +} +``` + +### 字面量类型与 as const + +```typescript +// ❌ 类型过于宽泛 +const config = { + endpoint: '/api', + method: 'GET' // 类型是 string +}; + +// ✅ 使用 as const 获得字面量类型 +const config = { + endpoint: '/api', + method: 'GET' +} as const; // method 类型是 'GET' + +// ✅ 用于函数参数 +function request(method: 'GET' | 'POST', url: string) { ... } +request(config.method, config.endpoint); // 正确! +``` + +--- + +## 泛型模式 + +### 基础泛型 + +```typescript +// ❌ 重复代码 +function getFirstString(arr: string[]): string | undefined { + return arr[0]; +} +function getFirstNumber(arr: number[]): number | undefined { + return arr[0]; +} + +// ✅ 使用泛型 +function getFirst(arr: T[]): T | undefined { + return arr[0]; +} +``` + +### 泛型约束 + +```typescript +// ❌ 泛型没有约束,无法访问属性 +function getProperty(obj: T, key: string) { + return obj[key]; // Error: 无法索引 +} + +// ✅ 使用 keyof 约束 +function getProperty(obj: T, key: K): T[K] { + return obj[key]; +} + +const user = { name: 'Alice', age: 30 }; +getProperty(user, 'name'); // 返回类型是 string +getProperty(user, 'age'); // 返回类型是 number +getProperty(user, 'foo'); // Error: 'foo' 不在 keyof User +``` + +### 泛型默认值 + +```typescript +// ✅ 提供合理的默认类型 +interface ApiResponse { + data: T; + status: number; + message: string; +} + +// 可以不指定泛型参数 +const response: ApiResponse = { data: null, status: 200, message: 'OK' }; +// 也可以指定 +const userResponse: ApiResponse = { ... }; +``` + +### 常见泛型工具类型 + +```typescript +// ✅ 善用内置工具类型 +interface User { + id: number; + name: string; + email: string; +} + +type PartialUser = Partial; // 所有属性可选 +type RequiredUser = Required; // 所有属性必需 +type ReadonlyUser = Readonly; // 所有属性只读 +type UserKeys = keyof User; // 'id' | 'name' | 'email' +type NameOnly = Pick; // { name: string } +type WithoutId = Omit; // { name: string; email: string } +type UserRecord = Record; // { [key: string]: User } +``` + +--- + +## 高级类型 + +### 条件类型 + +```typescript +// ✅ 根据输入类型返回不同类型 +type IsString = T extends string ? true : false; + +type A = IsString; // true +type B = IsString; // false + +// ✅ 提取数组元素类型 +type ElementType = T extends (infer U)[] ? U : never; + +type Elem = ElementType; // string + +// ✅ 提取函数返回类型(内置 ReturnType) +type MyReturnType = T extends (...args: any[]) => infer R ? R : never; +``` + +### 映射类型 + +```typescript +// ✅ 转换对象类型的所有属性 +type Nullable = { + [K in keyof T]: T[K] | null; +}; + +interface User { + name: string; + age: number; +} + +type NullableUser = Nullable; +// { name: string | null; age: number | null } + +// ✅ 添加前缀 +type Getters = { + [K in keyof T as `get${Capitalize}`]: () => T[K]; +}; + +type UserGetters = Getters; +// { getName: () => string; getAge: () => number } +``` + +### 模板字面量类型 + +```typescript +// ✅ 类型安全的事件名称 +type EventName = 'click' | 'focus' | 'blur'; +type HandlerName = `on${Capitalize}`; +// 'onClick' | 'onFocus' | 'onBlur' + +// ✅ API 路由类型 +type ApiRoute = `/api/${string}`; +const route: ApiRoute = '/api/users'; // OK +const badRoute: ApiRoute = '/users'; // Error +``` + +### Discriminated Unions + +```typescript +// ✅ 使用判别属性实现类型安全 +type Result = + | { success: true; data: T } + | { success: false; error: E }; + +function handleResult(result: Result) { + if (result.success) { + console.log(result.data.name); // TypeScript 知道 data 存在 + } else { + console.log(result.error.message); // TypeScript 知道 error 存在 + } +} + +// ✅ Redux Action 模式 +type Action = + | { type: 'INCREMENT'; payload: number } + | { type: 'DECREMENT'; payload: number } + | { type: 'RESET' }; + +function reducer(state: number, action: Action): number { + switch (action.type) { + case 'INCREMENT': + return state + action.payload; // payload 类型已知 + case 'DECREMENT': + return state - action.payload; + case 'RESET': + return 0; // 这里没有 payload + } +} +``` + +--- + +## Strict 模式配置 + +### 推荐的 tsconfig.json + +```json +{ + "compilerOptions": { + // ✅ 必须开启的 strict 选项 + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "useUnknownInCatchVariables": true, + + // ✅ 额外推荐选项 + "noUncheckedIndexedAccess": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "exactOptionalPropertyTypes": true, + "noPropertyAccessFromIndexSignature": true + } +} +``` + +### noUncheckedIndexedAccess 的影响 + +```typescript +// tsconfig: "noUncheckedIndexedAccess": true + +const arr = [1, 2, 3]; +const first = arr[0]; // 类型是 number | undefined + +// ❌ 直接使用可能出错 +console.log(first.toFixed(2)); // Error: 可能是 undefined + +// ✅ 先检查 +if (first !== undefined) { + console.log(first.toFixed(2)); +} + +// ✅ 或使用非空断言(确定时) +console.log(arr[0]!.toFixed(2)); +``` + +--- + +## 异步处理 + +### Promise 错误处理 + +```typescript +// ❌ Not handling async errors +async function fetchUser(id: string) { + const response = await fetch(`/api/users/${id}`); + return response.json(); // 网络错误未处理 +} + +// ✅ Handle errors properly +async function fetchUser(id: string): Promise { + try { + const response = await fetch(`/api/users/${id}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return await response.json(); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to fetch user: ${error.message}`); + } + throw error; + } +} +``` + +### Promise.all vs Promise.allSettled + +```typescript +// ❌ Promise.all 一个失败全部失败 +async function fetchAllUsers(ids: string[]) { + const users = await Promise.all(ids.map(fetchUser)); + return users; // 一个失败就全部失败 +} + +// ✅ Promise.allSettled 获取所有结果 +async function fetchAllUsers(ids: string[]) { + const results = await Promise.allSettled(ids.map(fetchUser)); + + const users: User[] = []; + const errors: Error[] = []; + + for (const result of results) { + if (result.status === 'fulfilled') { + users.push(result.value); + } else { + errors.push(result.reason); + } + } + + return { users, errors }; +} +``` + +### 竞态条件处理 + +```typescript +// ❌ 竞态条件:旧请求可能覆盖新请求 +function useSearch() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + + useEffect(() => { + fetch(`/api/search?q=${query}`) + .then(r => r.json()) + .then(setResults); // 旧请求可能后返回! + }, [query]); +} + +// ✅ 使用 AbortController +function useSearch() { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + + useEffect(() => { + const controller = new AbortController(); + + fetch(`/api/search?q=${query}`, { signal: controller.signal }) + .then(r => r.json()) + .then(setResults) + .catch(e => { + if (e.name !== 'AbortError') throw e; + }); + + return () => controller.abort(); + }, [query]); +} +``` + +--- + +## 不可变性 + +### Readonly 与 ReadonlyArray + +```typescript +// ❌ 可变参数可能被意外修改 +function processUsers(users: User[]) { + users.sort((a, b) => a.name.localeCompare(b.name)); // 修改了原数组! + return users; +} + +// ✅ 使用 readonly 防止修改 +function processUsers(users: readonly User[]): User[] { + return [...users].sort((a, b) => a.name.localeCompare(b.name)); +} + +// ✅ 深度只读 +type DeepReadonly = { + readonly [K in keyof T]: T[K] extends object ? DeepReadonly : T[K]; +}; +``` + +### 不变式函数参数 + +```typescript +// ✅ 使用 as const 和 readonly 保护数据 +function createConfig(routes: T) { + return routes; +} + +const routes = createConfig(['home', 'about', 'contact'] as const); +// 类型是 readonly ['home', 'about', 'contact'] +``` + +--- + +## ESLint 规则 + +### 推荐的 @typescript-eslint 规则 + +```javascript +// eslint.config.js(flat config,typescript-eslint v8) +import eslint from '@eslint/js'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + eslint.configs.recommended, + // 需要类型信息的规则集,对应旧的 recommended-requiring-type-checking + tseslint.configs.recommendedTypeChecked, + tseslint.configs.strictTypeChecked, + { + languageOptions: { + parserOptions: { + // 让带类型的规则自动找到对应 tsconfig + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + // ✅ 类型安全 + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-unsafe-assignment': 'error', + '@typescript-eslint/no-unsafe-member-access': 'error', + '@typescript-eslint/no-unsafe-call': 'error', + '@typescript-eslint/no-unsafe-return': 'error', + + // ✅ 最佳实践 + '@typescript-eslint/explicit-function-return-type': 'warn', + '@typescript-eslint/no-floating-promises': 'error', + '@typescript-eslint/await-thenable': 'error', + '@typescript-eslint/no-misused-promises': 'error', + + // ✅ 代码风格 + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/prefer-nullish-coalescing': 'error', + '@typescript-eslint/prefer-optional-chain': 'error', + }, + }, +); +``` + +### 常见 ESLint 错误修复 + +```typescript +// ❌ no-floating-promises: Promise 必须被处理 +async function save() { ... } +save(); // Error: 未处理的 Promise + +// ✅ 显式处理 +await save(); +// 或 +save().catch(console.error); +// 或明确忽略 +void save(); + +// ❌ no-misused-promises: 不能在非 async 位置使用 Promise +const items = [1, 2, 3]; +items.forEach(async (item) => { // Error! + await processItem(item); +}); + +// ✅ 使用 for...of +for (const item of items) { + await processItem(item); +} +// 或 Promise.all +await Promise.all(items.map(processItem)); +``` + +--- + +--- + +## 测试 + +### Vitest vs Jest 选择 + +```typescript +// ✅ 新项目推荐 Vitest(与 Vite 生态集成,原生 ESM 支持) +// vitest.config.ts +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'lcov'], + }, + }, +}); + +// ✅ 已有 Jest 项目可保持,注意配置差异 +// jest.config.ts +import type { Config } from 'jest'; + +const config: Config = { + preset: 'ts-jest', + testEnvironment: 'node', + moduleNameMapper: { + '^@/(.*)$': '/src/$1', + }, +}; +export default config; +``` + +### 类型测试(tsd / expect-type) + +```typescript +// ✅ 使用 expect-type 验证类型推断 +import { expectTypeOf } from 'vitest'; + +function getFirst(arr: T[]): T | undefined { + return arr[0]; +} + +it('should infer correct return type', () => { + const result = getFirst([1, 2, 3]); + expectTypeOf(result).toEqualTypeOf(); +}); + +// ✅ 使用 expect-type 验证函数签名 +const fn = (a: string, b: number) => a.repeat(b); +expectTypeOf(fn).parameters.toEqualTypeOf<[string, number]>(); +expectTypeOf(fn).returns.toBeString(); + +// ❌ 类型错误会在编译时被捕获 +const result = getFirst(['a', 'b']); +// @ts-expect-error: 类型不匹配 +expectTypeOf(result).toEqualTypeOf(); +``` + +### Snapshot 测试最佳实践 + +```typescript +// ✅ Snapshot 适合:稳定的输出结构、配置对象、错误消息 +it('should match serialized config', () => { + const config = createAppConfig(); + expect(config).toMatchSnapshot(); +}); + +// ❌ 避免:大对象、动态数据、随机值 +it('should not snapshot large payloads', () => { + const hugePayload = { users: generateRandomUsers(1000) }; + // 太长的 snapshot 难以审查,变更时不知道意图 +}); + +// ✅ 使用 inline snapshot 处理小片段 +it('should generate correct error message', () => { + expect(formatError('INVALID_INPUT')).toMatchInlineSnapshot( + `"Error: Invalid input provided"` + ); +}); + +// ✅ 使用 snapshot 属性匹配器处理动态值 +it('should match user with generated id', () => { + expect(createUser('Alice')).toMatchSnapshot({ + id: expect.any(String), + createdAt: expect.any(Date), + }); +}); +``` + +### Mock 策略 + +```typescript +// ✅ Vitest: vi.mock 自动 hoist +import { vi, describe, it, expect } from 'vitest'; + +vi.mock('./api', () => ({ + fetchUser: vi.fn().mockResolvedValue({ id: 1, name: 'Alice' }), +})); + +it('should display user', async () => { + const { fetchUser } = await import('./api'); + const user = await fetchUser('1'); + expect(user.name).toBe('Alice'); +}); + +// ✅ Jest: jest.mock 同样自动 hoist +jest.mock('./database', () => ({ + query: jest.fn().mockResolvedValue([{ id: 1 }]), +})); + +// ❌ 避免部分 Mock——测试的是 Mock 而非真实行为 +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + calculateTotal: jest.fn(), // 其他函数是真实的,这个是假的 +})); +``` + +### 测试辅助工具 + +```typescript +// ✅ 使用 testing-library 进行 DOM 测试 +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +it('should submit form', async () => { + render(); + await userEvent.type(screen.getByLabelText('Email'), 'alice@example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByText('Welcome, Alice!')).toBeInTheDocument(); +}); + +// ✅ 使用 MSW 进行 API mock(Mock Service Worker) +import { http, HttpResponse } from 'msw'; +import { setupServer } from 'msw/node'; + +const server = setupServer( + http.get('/api/users/:id', ({ params }) => { + return HttpResponse.json({ id: params.id, name: 'Alice' }); + }) +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +``` + +--- + +## 模块解析 + +### ESM vs CJS 差异和陷阱 + +```typescript +// ❌ CJS 风格在 ESM 中不可用 +// package.json: "type": "module" +const fs = require('fs'); // Error: require is not defined +module.exports = { foo: 'bar' }; // Error: module is not defined + +// ✅ ESM 正确写法 +import fs from 'node:fs'; +export const foo = 'bar'; + +// ✅ 在 ESM 中获取 __dirname +import { fileURLToPath } from 'node:url'; +import { dirname } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// ❌ ESM 中动态 require +const moduleName = 'lodash'; +const _ = require(moduleName); // Error! + +// ✅ ESM 动态 import +const _ = await import(moduleName); +``` + +### tsconfig paths 与 path aliases + +```json +// tsconfig.json +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@components/*": ["./src/components/*"], + "@utils/*": ["./src/utils/*"] + } + } +} +``` + +```typescript +// ✅ 使用别名前 +import { Button } from '../../components/ui/Button'; +import { formatDate } from '../../../utils/date'; + +// ✅ 使用别名后——清晰且不易因文件移动而断裂 +import { Button } from '@components/ui/Button'; +import { formatDate } from '@utils/date'; +``` + +```typescript +// ⚠️ tsconfig paths 只影响 TS 编译,不影响运行时 +// 需要配合打包工具(Vite、webpack)或 tsx 的别名解析 + +// vite.config.ts +import { resolve } from 'node:path'; + +export default defineConfig({ + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, +}); + +// ⚠️ 发布 npm 包时,tsconfig paths 不会自动解析 +// 需要 tsc-alias 或 tsconfig-paths 处理 +``` + +### package.json exports field + +```json +// package.json +{ + "name": "my-library", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "types": "./dist/index.d.ts" + }, + "./utils": { + "import": "./dist/utils.mjs", + "require": "./dist/utils.cjs", + "types": "./dist/utils.d.ts" + }, + "./*": "./dist/*" + } +} +``` + +```typescript +// ✅ 消费者使用 +import { foo } from 'my-library'; // 解析到 "." 条件 +import { bar } from 'my-library/utils'; // 解析到 "./utils" 条件 + +// ❌ 没有 exports 映射的路径无法访问 +import { secret } from 'my-library/internal'; // Error! +``` + +### 动态 import() 和代码分割 + +```typescript +// ✅ 条件加载模块 +async function loadChartLibrary() { + if (typeof window === 'undefined') return null; // SSR 跳过 + const { Chart } = await import('chart.js'); + return Chart; +} + +// ✅ React 懒加载组件 +const AdminPanel = lazy(() => import('./AdminPanel')); +// 配合 Suspense 使用 +}> + + + +// ✅ 带错误处理 +const AdminPanel = lazy(() => + import('./AdminPanel').catch(() => ({ + default: () => , + })) +); +``` + +--- + +## TS 4.9+ / 5.x 新特性 + +### satisfies 关键字(TS 4.9+) + +```typescript +// ❌ 没有 satisfies:类型太宽泛 +const palette = { + red: '#ff0000', + green: '#00ff00', + blue: '#0000ff', +}; +// palette.red 类型是 string,丢失了 '#ff0000' 的精确值 + +// ✅ satisfies 保留字面量类型,同时验证结构 +const palette = { + red: '#ff0000', + green: '#00ff00', + blue: '#0000ff', +} satisfies Record; + +// palette.red 类型是 '#ff0000'(不是 string) +// 但添加新属性时仍会验证格式 +``` + +```typescript +// ✅ satisfies 用于验证对象符合接口 +interface UserConfig { + theme: 'light' | 'dark'; + locale: string; +} + +const config = { + theme: 'dark', + locale: 'en-US', +} satisfies UserConfig; +// config.theme 类型是 'dark'(不是 'light' | 'dark') +// 所有属性都通过 satisfies 类型检查 +``` + +### const 类型参数(TS 5.0+) + +```typescript +// ❌ 之前:需要 as const 断言 +function getRoutes(routes: T) { + return routes; +} +const routes = getRoutes(['home', 'about'] as const); + +// ✅ TS 5.0+:const 类型参数 +function getRoutes(routes: T) { + return routes; +} +const routes = getRoutes(['home', 'about']); +// routes 类型是 readonly ['home', 'about'] +``` + +```typescript +// ✅ 真实场景:类型安全的配置对象 +declare function createConfig>( + config: T +): T; + +const config = createConfig({ + api: { url: 'https://api.example.com', version: 2 }, + features: { newDashboard: true }, +}); +// config.api.url 类型是 'https://api.example.com'(字面量) +``` + +### 装饰器(Stage 3 Decorators, TS 5.0+) + +```typescript +// ✅ Stage 3 装饰器(TS 5.0+,experimentalDecorators 不再需要) +function logged( + target: (this: This, ...args: Args) => Return, + context: ClassMethodDecoratorContext +) { + return function (this: This, ...args: Args): Return { + console.log(`Calling ${String(context.name)} with`, args); + return target.apply(this, args); + }; +} + +class Calculator { + @logged + add(a: number, b: number): number { + return a + b; + } +} + +// 输出: Calling add with [1, 2] +new Calculator().add(1, 2); +``` + +```typescript +// ⚠️ Stage 3 装饰器与旧版 experimentalDecorators 不同 +// 旧版:tsconfig 中需要 "experimentalDecorators": true +// 新版(TS 5.0+):默认支持,无需额外配置 + +// ❌ 旧版装饰器签名(仍支持但标记为 legacy) +function deprecated(constructor: T) { + return class extends constructor { /* ... */ }; +} + +// ✅ 新版装饰器按类型区分 context +function sealed( + target: T, + context: ClassDecoratorContext +) { + // context.kind === 'class' +} +``` + +### using 声明(显式资源管理,TS 5.2+) + +```typescript +// ✅ 使用 Symbol.dispose 实现自动清理 +class TempFile implements Disposable { + private path: string; + + constructor() { + this.path = `/tmp/file-${Date.now()}`; + } + + write(data: string) { /* ... */ } + + [Symbol.dispose]() { + // 自动清理——无论函数如何退出(正常/异常) + fs.unlinkSync(this.path); + console.log(`Cleaned up: ${this.path}`); + } +} + +function processFile() { + using file = new TempFile(); // using 声明 + file.write('data'); + // 作用域结束时自动调用 file[Symbol.dispose]() +} +``` + +```typescript +// ✅ AsyncDisposable 用于异步资源(TS 5.2+) +class DatabaseConnection implements AsyncDisposable { + private db: sqlite3.Database; + + async connect() { + this.db = new sqlite3.Database(':memory:'); + } + + async [Symbol.asyncDispose]() { + await this.db.close(); + } +} + +async function query() { + await using conn = new DatabaseConnection(); // await using + await conn.connect(); + // 作用域结束时自动 await conn[Symbol.asyncDispose]() +} +``` + +### 枚举改进(TS 5.0+) + +```typescript +// ✅ 所有枚举现在都是 union 枚举(TS 5.0+) +enum Color { + Red = 'RED', + Green = 'GREEN', +} + +// 之前:Color 作为类型时行为不一致 +// 现在:Color 完全作为字符串字面量联合类型 +const color: Color = Color.Red; // TypeScript 现在对 Color 类型有更好的推断 +``` + +## Review Checklist + +### 类型系统 +- [ ] 没有使用 `any`(使用 `unknown` + 类型守卫代替) +- [ ] 接口和类型定义完整且有意义的命名 +- [ ] 使用泛型提高代码复用性 +- [ ] 联合类型有正确的类型收窄 +- [ ] 善用工具类型(Partial、Pick、Omit 等) + +### 泛型 +- [ ] 泛型有适当的约束(extends) +- [ ] 泛型参数有合理的默认值 +- [ ] 避免过度泛型化(KISS 原则) + +### Strict 模式 +- [ ] tsconfig.json 启用了 strict: true +- [ ] 启用了 noUncheckedIndexedAccess +- [ ] 没有使用 @ts-ignore(改用 @ts-expect-error) + +### 异步代码 +- [ ] async 函数有错误处理 +- [ ] Promise rejection 被正确处理 +- [ ] 没有 floating promises(未处理的 Promise) +- [ ] 并发请求使用 Promise.all 或 Promise.allSettled +- [ ] 竞态条件使用 AbortController 处理 + +### 不可变性 +- [ ] 不直接修改函数参数 +- [ ] 使用 spread 操作符创建新对象/数组 +- [ ] 考虑使用 readonly 修饰符 + +### ESLint +- [ ] 使用 @typescript-eslint/recommended +- [ ] 没有 ESLint 警告或错误 +- [ ] 使用 consistent-type-imports diff --git a/examples/skills_code_review_agent/skills/code-review/reference/vue.md b/examples/skills_code_review_agent/skills/code-review/reference/vue.md new file mode 100644 index 000000000..cbd9165d3 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/vue.md @@ -0,0 +1,924 @@ +# Vue 3 Code Review Guide + +> Vue 3 Composition API 代码审查指南,覆盖响应性系统、Props/Emits、Watchers、Composables、Vue 3.5 新特性等核心主题。 + +## 目录 + +- [响应性系统](#响应性系统) +- [Props & Emits](#props--emits) +- [Vue 3.5 新特性](#vue-35-新特性) +- [Watchers](#watchers) +- [模板最佳实践](#模板最佳实践) +- [Composables](#composables) +- [性能优化](#性能优化) +- [Review Checklist](#review-checklist) + +--- + +## 响应性系统 + +### ref vs reactive 选择 + +```vue + + + + + + + + +``` + +### 解构 reactive 对象 + +```vue + + + + + +``` + +### computed 副作用 + +```vue + + + + + +``` + +### shallowRef 优化 + +```vue + + + + + +``` + +--- + +## Props & Emits + +### 直接修改 props + +```vue + + + + + +``` + +### defineProps 类型声明 + +```vue + + + + + +``` + +### defineEmits 类型安全 + +```vue + + + + + +``` + +--- + +## Vue 3.5 新特性 + +### Reactive Props Destructure (3.5+) + +```vue + + + + + + + + +``` + +### defineModel (3.4+) + +```vue + + + + + + + + + + + + + + + + +``` + +### useTemplateRef (3.5+) + +```vue + + + + + + + + + + +``` + +### useId (3.5+) + +```vue + + + + + + + + + + +``` + +### onWatcherCleanup (3.5+) + +```vue + + + + + +``` + +### Deferred Teleport (3.5+) + +```vue + + + + + +``` + +--- + +## Watchers + +### watch vs watchEffect + +```vue + +``` + +### watch 清理函数 + +```vue + + + + + +``` + +### watch 选项 + +```vue + +``` + +### 监听多个源 + +```vue + +``` + +--- + +## 模板最佳实践 + +### v-for 的 key + +```vue + + + + + + + + +``` + +### v-if 和 v-for 优先级 + +```vue + + + + + + + + + +``` + +### 事件处理 + +```vue + + + + + + + + + +``` + +--- + +## Composables + +### Composable 设计原则 + +```typescript +// ✅ 好的 composable 设计 +export function useCounter(initialValue = 0) { + const count = ref(initialValue) + + const increment = () => count.value++ + const decrement = () => count.value-- + const reset = () => count.value = initialValue + + // 返回响应式引用和方法 + return { + count: readonly(count), // 只读防止外部修改 + increment, + decrement, + reset + } +} + +// ❌ 不要返回 .value +export function useBadCounter() { + const count = ref(0) + return { + count: count.value // ❌ 丢失响应性! + } +} +``` + +### Props 传递给 composable + +```vue + + + + + +``` + +### 异步 Composable + +```typescript +// ✅ 异步 composable 模式 +export function useFetch(url: MaybeRefOrGetter) { + const data = ref(null) + const error = ref(null) + const loading = ref(false) + + const execute = async () => { + loading.value = true + error.value = null + + try { + const response = await fetch(toValue(url)) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + data.value = await response.json() + } catch (e) { + error.value = e as Error + } finally { + loading.value = false + } + } + + // 响应式 URL 时自动重新获取 + watchEffect(() => { + toValue(url) // 追踪依赖 + execute() + }) + + return { + data: readonly(data), + error: readonly(error), + loading: readonly(loading), + refetch: execute + } +} + +// 使用 +const { data, loading, error, refetch } = useFetch('/api/users') +``` + +### 生命周期与清理 + +```typescript +// ✅ Composable 中正确处理生命周期 +export function useEventListener( + target: MaybeRefOrGetter, + event: string, + handler: EventListener +) { + // 组件挂载后添加 + onMounted(() => { + toValue(target).addEventListener(event, handler) + }) + + // 组件卸载时移除 + onUnmounted(() => { + toValue(target).removeEventListener(event, handler) + }) +} + +// ✅ 使用 effectScope 管理副作用 +export function useFeature() { + const scope = effectScope() + + scope.run(() => { + // 所有响应式效果都在这个 scope 内 + const state = ref(0) + watch(state, () => { /* ... */ }) + watchEffect(() => { /* ... */ }) + }) + + // 清理所有效果 + onUnmounted(() => scope.stop()) + + return { /* ... */ } +} +``` + +--- + +## 性能优化 + +### v-memo + +```vue + + + + + +``` + +### defineAsyncComponent + +```vue + +``` + +### KeepAlive + +```vue + + + +``` + +### 虚拟列表 + +```vue + + + +``` + +--- + +## Review Checklist + +### 响应性系统 +- [ ] ref 用于基本类型,reactive 用于对象(或统一用 ref) +- [ ] 没有解构 reactive 对象(或使用了 toRefs) +- [ ] props 传递给 composable 时保持了响应性 +- [ ] shallowRef/shallowReactive 用于大型对象优化 +- [ ] computed 中没有副作用 + +### Props & Emits +- [ ] defineProps 使用 TypeScript 类型声明 +- [ ] 复杂默认值使用 withDefaults + 工厂函数 +- [ ] defineEmits 有完整的类型定义 +- [ ] 没有直接修改 props +- [ ] 考虑使用 defineModel 简化 v-model(Vue 3.4+) + +### Vue 3.5 新特性(如适用) +- [ ] 使用 Reactive Props Destructure 简化 props 访问 +- [ ] 使用 useTemplateRef 替代 ref 属性 +- [ ] 表单使用 useId 生成 SSR 安全的 ID +- [ ] 使用 onWatcherCleanup 处理复杂清理逻辑 + +### Watchers +- [ ] watch/watchEffect 有适当的清理函数 +- [ ] 异步 watch 处理了竞态条件 +- [ ] flush: 'post' 用于 DOM 操作的 watcher +- [ ] 避免过度使用 watcher(优先用 computed) +- [ ] 考虑 once: true 用于一次性监听 + +### 模板 +- [ ] v-for 使用唯一且稳定的 key +- [ ] v-if 和 v-for 没有在同一元素上 +- [ ] 事件处理使用方法而非内联复杂逻辑 +- [ ] 大型列表使用虚拟滚动 + +### Composables +- [ ] 相关逻辑提取到 composables +- [ ] composables 返回响应式引用(不是 .value) +- [ ] 纯函数不要包装成 composable +- [ ] 副作用在组件卸载时清理 +- [ ] 使用 effectScope 管理复杂副作用 + +### 性能 +- [ ] 大型组件拆分为小组件 +- [ ] 使用 defineAsyncComponent 懒加载 +- [ ] 避免不必要的响应式转换 +- [ ] v-memo 用于昂贵的列表渲染 +- [ ] KeepAlive 用于缓存动态组件 diff --git a/examples/skills_code_review_agent/skills/code-review/reference/zig.md b/examples/skills_code_review_agent/skills/code-review/reference/zig.md new file mode 100644 index 000000000..1e0855efa --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/reference/zig.md @@ -0,0 +1,440 @@ +# Zig Code Review Guide + +> Code review guidelines for Zig focusing on explicit memory ownership, error unions, defer/errdefer cleanup, comptime usage, safety-checked operations, C interop, and tests. + +## Table of Contents + +- [Memory & Allocators](#memory--allocators) +- [Errors & Cleanup](#errors--cleanup) +- [Pointers, Slices & Optionals](#pointers-slices--optionals) +- [Comptime & Generics](#comptime--generics) +- [Safety, Undefined Behavior & Casts](#safety-undefined-behavior--casts) +- [C Interop](#c-interop) +- [Testing](#testing) +- [Style & API Design](#style--api-design) +- [Review Checklist](#review-checklist) +- [References](#references) + +--- + +## Memory & Allocators + +### Make Allocator Ownership Explicit + +Zig code should make allocation policy visible. Libraries should usually accept an `std.mem.Allocator` from the caller rather than creating a global allocator internally. + +```zig +const std = @import("std"); + +// ❌ Bad: hides allocation policy and lifetime from callers. +fn readNamesBad() ![][]const u8 { + var gpa = std.heap.DebugAllocator(.{}){}; + const allocator = gpa.allocator(); + return try allocator.alloc([]const u8, 10); +} + +// ✅ Good: caller chooses allocator and owns the returned memory. +fn readNames(allocator: std.mem.Allocator) ![][]const u8 { + return try allocator.alloc([]const u8, 10); +} +``` + +Review questions: +- Does the caller know who owns allocated memory? +- Does the API document whether returned slices must be freed? +- Is the allocator parameter passed through instead of replaced by an internal global allocator? + +### Pair Allocations With Cleanup + +Every allocation path should have a visible cleanup path. Look for missing `defer`, missing `errdefer`, and containers that are initialized but never deinitialized. + +```zig +const std = @import("std"); + +fn collectBad(allocator: std.mem.Allocator) ![]u8 { + // ❌ Bad: returns a slice whose backing memory is freed as the function exits. + var bad_list: std.ArrayListUnmanaged(u8) = .empty; + defer bad_list.deinit(allocator); + try bad_list.append(allocator, 'a'); + return bad_list.items; +} + +fn collect(allocator: std.mem.Allocator) ![]u8 { + // ✅ Good: `errdefer` cleans up only on failure; success transfers ownership. + var list: std.ArrayListUnmanaged(u8) = .empty; + errdefer list.deinit(allocator); + + try list.append(allocator, 'a'); + try list.append(allocator, 'b'); + return try list.toOwnedSlice(allocator); +} +``` + +Review questions: +- Is cleanup registered immediately after acquisition? +- Is `errdefer` used when ownership transfers only on success? +- Are `deinit` calls paired with all container initializations? + +### Choose Allocators Deliberately + +Allocator choice is part of the design. A review should flag broad use of a debug/general-purpose allocator where a fixed buffer, arena, page allocator, or caller-provided allocator better matches the lifetime. + +```zig +// ❌ Bad: allocation lifetime is scattered across many individual frees. +const user = try allocator.create(User); +const events = try allocator.alloc(Event, event_count); + +// ✅ Good: request/frame-scoped allocations are freed together. +var arena = std.heap.ArenaAllocator.init(parent_allocator); +defer arena.deinit(); +const allocator = arena.allocator(); +``` + +Review questions: +- Are arena allocations freed at a clear lifetime boundary? +- Is a test using `std.testing.allocator` to catch leaks? +- Is a library avoiding policy decisions that belong to its caller? + +--- + +## Errors & Cleanup + +### Keep Error Sets Useful + +Avoid flattening meaningful errors into `anyerror` unless the boundary genuinely needs it. Specific error sets improve API contracts and make callers handle expected failures. + +```zig +// ❌ Bad: erases the expected parse failures behind `anyerror`. +fn parseDigitAny(input: []const u8) anyerror!u8 { + if (input.len == 0) return error.EmptyInput; + if (input[0] < '0' or input[0] > '9') return error.InvalidDigit; + return input[0] - '0'; +} + +// ✅ Good: names the domain failures that callers should handle. +const ParseError = error{ + EmptyInput, + InvalidDigit, +}; + +fn parseDigit(input: []const u8) ParseError!u8 { + if (input.len == 0) return error.EmptyInput; + if (input[0] < '0' or input[0] > '9') return error.InvalidDigit; + return input[0] - '0'; +} +``` + +Review questions: +- Are expected domain failures named explicitly? +- Is `anyerror` only used at integration boundaries? +- Does the caller preserve context when converting errors? + +### Use `try`, `catch`, and `errdefer` Intentionally + +Blind `catch unreachable` is a code smell unless the invariant is mechanically guaranteed. Prefer propagating errors with `try`, converting them at boundaries, or adding a comment for unreachable invariants. + +```zig +// ❌ Bad: hides a real allocation failure. +const buffer_bad = allocator.alloc(u8, size) catch unreachable; + +// ✅ Good: caller can handle OutOfMemory. +const buffer = try allocator.alloc(u8, size); +``` + +Review questions: +- Does `catch unreachable` mask I/O, allocation, parsing, or user-input errors? +- Are errors converted close to a boundary where the abstraction changes? +- Does `errdefer` undo partial state changes on failure? + +--- + +## Pointers, Slices & Optionals + +### Prefer Slices Over Pointer Plus Length + +Slices carry pointer and length together, improving bounds checking and API clarity. Raw pointer plus length should be reserved for FFI or very low-level code. + +```zig +// ❌ Bad: easy to mismatch pointer and length. +fn checksumRaw(ptr: [*]const u8, len: usize) u32 { + var sum: u32 = 0; + for (ptr[0..len]) |byte| sum += byte; + return sum; +} + +// ✅ Good: one value represents the buffer. +fn checksum(bytes: []const u8) u32 { + var sum: u32 = 0; + for (bytes) |byte| sum += byte; + return sum; +} +``` + +Review questions: +- Can a raw pointer API become a slice API? +- Is nullability modeled with `?T` instead of sentinel values? +- Are pointer lifetimes clear after returning from a function? + +### Avoid Returning Pointers to Stack Data + +Review returned slices and pointers carefully. Zig makes many lifetime issues visible, but reviewers should still check that returned data outlives the function. + +```zig +// ❌ Bad: returned slice points to stack memory. +fn labelStack() []const u8 { + var buf: [16]u8 = undefined; + _ = &buf; + return buf[0..]; +} + +// ✅ Good: caller owns the allocated result and can free it. +fn label(allocator: std.mem.Allocator) ![]u8 { + return try allocator.dupe(u8, "ready"); +} +``` + +Review questions: +- Does returned memory come from the caller, allocator, static storage, or a stable owner? +- Does a slice escape after its backing buffer is mutated or freed? +- Is aliasing intentional and documented for mutable slices? + +--- + +## Comptime & Generics + +### Keep Comptime Work Bounded and Readable + +`comptime` is powerful, but complex compile-time code can make error messages and build times worse. Prefer small generic helpers with clear type contracts. + +```zig +fn RingBuffer(comptime T: type, comptime capacity: usize) type { + // ✅ Good: invalid generic parameters fail with an actionable message. + if (capacity == 0) @compileError("capacity must be greater than zero"); + + return struct { + items: [capacity]T = undefined, + len: usize = 0, + }; +} +``` + +Review questions: +- Does `comptime` enforce a real invariant? +- Are compile errors explicit and actionable? +- Is reflection code isolated from ordinary runtime logic? + +### Avoid Overly Broad `anytype` + +`anytype` can make APIs flexible, but it can also hide required capabilities. Add comptime checks or prefer concrete interfaces when possible. + +```zig +// ✅ Good: the required writer capability is obvious at the call site. +fn writeAll(writer: anytype, bytes: []const u8) !void { + try writer.writeAll(bytes); +} +``` + +Review questions: +- Is the required shape of `anytype` clear from the function body or docs? +- Would a concrete type or smaller helper be easier to review? +- Are compile errors understandable when the wrong type is passed? + +--- + +## Safety, Undefined Behavior & Casts + +### Treat `undefined`, `unreachable`, and Casts as Review Hotspots + +Zig exposes low-level control directly. Review every `undefined`, `unreachable`, `@ptrCast`, `@alignCast`, `@intCast`, and pointer/int conversion. + +```zig +// ❌ Bad: assumes data layout, byte order, length, and alignment without proof. +const header: *const Header = @ptrCast(@alignCast(bytes.ptr)); + +// ✅ Good: parse fields explicitly and check length before reading. +if (bytes.len < 4) return error.ShortInput; +const magic = std.mem.readInt(u16, bytes[0..2], .little); +const flags = std.mem.readInt(u16, bytes[2..4], .little); +``` + +Review questions: +- Is `undefined` overwritten before being read? +- Is `unreachable` only used for impossible states, with a nearby explanation when non-obvious? +- Are casts preceded by checks for layout, range, alignment, size, byte order, and nullability? + +### Prefer Checked Arithmetic Unless Wrapping Is Intentional + +Wrapping operators such as `+%` and `-%` are useful, but they should communicate a deliberate modular arithmetic choice. + +```zig +// ❌ Bad: wrapping silences overflow that should expose a logic bug. +index = index +% 1; + +// ✅ Good: checked arithmetic traps on unexpected overflow. +sum += byte; + +// ✅ Good: wrapping is intentional for modular hash behavior. +hash = hash *% 16777619; +hash = hash +% byte; +``` + +Review questions: +- Is wrapping arithmetic required by an algorithm? +- Would overflow indicate invalid input or a bug? +- Are integer width changes explicit and tested around boundaries? + +--- + +## C Interop + +### Contain C Boundaries + +Keep `@cImport`, C pointer handling, and ABI assumptions close to a wrapper layer. Convert C data into Zig types before it spreads through the codebase. + +```zig +const std = @import("std"); + +// ❌ Bad: uses C strlen when no external C boundary is needed. +fn strlenC(input: [*:0]const u8) usize { + const c = @cImport({ + @cInclude("string.h"); + }); + + return c.strlen(input); +} + +// ✅ Good: use Zig's sentinel-aware standard library helper. +fn strlenZ(input: [*:0]const u8) usize { + return std.mem.len(input); +} +``` + +Review questions: +- Are C pointers represented with the correct Zig pointer type? +- Are sentinel-terminated strings modeled as sentinel pointers or slices? +- Are ownership and cleanup rules from the C library documented? +- Are C error codes converted into Zig error unions near the boundary? + +--- + +## Testing + +### Use `std.testing` Assertions and Leak Detection + +Tests that allocate should use `std.testing.allocator` where practical so leaks are reported by the test runner. + +```zig +const std = @import("std"); + +test "collect returns owned memory on success" { + const allocator = std.testing.allocator; + const names = try collect(allocator); + defer allocator.free(names); + + try std.testing.expectEqual(@as(usize, 2), names.len); +} + +test "collect handles allocation failures cleanly" { + var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ + .fail_index = 0, + }); + try std.testing.expectError(error.OutOfMemory, collect(failing.allocator())); +} +``` + +Review questions: +- Are allocation-heavy paths covered by tests? +- Are error paths tested with `std.testing.expectError`? +- Do tests cover boundary sizes such as empty input, one element, max capacity, and invalid encodings? + +### Test Build Modes and Targets When Relevant + +Behavior can differ across safety modes, targets, and ABIs. For low-level code, ask whether the PR was tested with the intended target and optimization mode. + +Review questions: +- Does code rely on debug-only safety checks? +- Does packed/aligned/extern layout code have target-aware tests? +- Are endian, pointer-width, and ABI assumptions tested or documented? + +--- + +## Style & API Design + +### Follow Zig Naming Conventions + +Use the official style guide as the baseline: `TitleCase` for types, `camelCase` for functions, and `snake_case` for variables. Avoid redundant words such as `Value`, `Data`, `Manager`, or `State` when the surrounding namespace already provides that meaning. + +```zig +// ❌ Bad: redundant namespace and vague type name. +pub const json_bad = struct { + pub const JsonValueManager = struct {}; +}; + +// ✅ Good: name is meaningful in its fully-qualified namespace. +pub const json = struct { + pub const Value = union(enum) {}; +}; +``` + +Review questions: +- Does the fully-qualified name read naturally? +- Are file and directory names consistent with the style guide? +- Are underscore-prefixed declarations avoided unless they come from an external convention? +- Are public APIs documented with doc comments where helpful? + +### Keep Public APIs Small + +Zig modules often expose declarations directly from files and structs. Review public declarations for accidental exports. + +Review questions: +- Should this declaration be `pub`? +- Is the public API stable enough to expose? +- Are implementation details hidden behind a smaller surface? + +--- + +## Review Checklist + +### Memory & Lifetime +- [ ] Allocator ownership is explicit. +- [ ] Allocations have matching `free`, `deinit`, `defer`, or `errdefer`. +- [ ] Returned slices and pointers outlive the function. +- [ ] Arena or temporary allocations have a clear lifetime boundary. + +### Errors & Cleanup +- [ ] Expected failures use specific error sets where practical. +- [ ] `catch unreachable` does not hide real runtime failures. +- [ ] Partial initialization is rolled back with `errdefer`. +- [ ] Error conversions happen at abstraction boundaries. + +### Pointers & Safety +- [ ] Raw pointers are justified; slices are used for ordinary buffers. +- [ ] Casts check size, range, alignment, and nullability. +- [ ] `undefined` is not read before initialization. +- [ ] Wrapping arithmetic is intentional and tested. + +### Comptime & API Design +- [ ] `comptime` logic enforces useful invariants. +- [ ] `anytype` usage has clear expectations. +- [ ] Public declarations are intentional. +- [ ] Names follow Zig style conventions. + +### C Interop & Portability +- [ ] C boundaries are isolated behind wrappers. +- [ ] C ownership and cleanup rules are documented. +- [ ] Target, endian, pointer-width, and ABI assumptions are tested or documented. + +### Tests +- [ ] Tests use `std.testing.allocator` for allocation-heavy code. +- [ ] Error paths use `std.testing.expectError`. +- [ ] Boundary cases are covered. +- [ ] Relevant build modes and targets are considered. + +--- + +## References + +- [Zig 0.16.0 Language Reference](https://ziglang.org/documentation/0.16.0/) +- [Zig 0.16.0 Standard Library documentation](https://ziglang.org/documentation/0.16.0/std/) +- [Zig 0.16.0 Style Guide](https://ziglang.org/documentation/0.16.0/#Style-Guide) +- [Choosing an Allocator](https://ziglang.org/documentation/0.16.0/#Choosing-an-Allocator) diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/pr-analyzer.py b/examples/skills_code_review_agent/skills/code-review/scripts/pr-analyzer.py new file mode 100644 index 000000000..d43a41876 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/pr-analyzer.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +""" +PR Analyzer - Analyze PR complexity and suggest review approach. + +Usage: + python pr-analyzer.py [--diff-file FILE] [--stats] + + Or pipe diff directly: + git diff main...HEAD | python pr-analyzer.py +""" + +import os +import sys +import re +import argparse +from collections import defaultdict +from dataclasses import dataclass +from typing import List, Dict, Optional + +RISK_NO_TESTS = "NO_TEST_CHANGES" + + +@dataclass +class FileStats: + """Statistics for a single file.""" + filename: str + additions: int = 0 + deletions: int = 0 + is_test: bool = False + is_config: bool = False + language: str = "unknown" + + +@dataclass +class PRAnalysis: + """Complete PR analysis results.""" + total_files: int + total_additions: int + total_deletions: int + files: List[FileStats] + complexity_score: float + size_category: str + estimated_review_time: int + risk_factors: List[str] + suggestions: List[str] + + +def detect_language(filename: str) -> str: + """Detect programming language from filename.""" + _, ext = os.path.splitext(filename) + extensions = { + '.py': 'Python', + '.js': 'JavaScript', + '.ts': 'TypeScript', + '.tsx': 'TypeScript/React', + '.jsx': 'JavaScript/React', + '.rs': 'Rust', + '.go': 'Go', + '.c': 'C', + '.h': 'C/C++', + '.cpp': 'C++', + '.hpp': 'C++', + '.cc': 'C++', + '.cxx': 'C++', + '.hh': 'C++', + '.hxx': 'C++', + '.java': 'Java', + '.kt': 'Kotlin', + '.swift': 'Swift', + '.rb': 'Ruby', + '.php': 'PHP', + '.cs': 'C#', + '.vue': 'Vue', + '.svelte': 'Svelte', + '.sql': 'SQL', + '.md': 'Markdown', + '.json': 'JSON', + '.yaml': 'YAML', + '.yml': 'YAML', + '.toml': 'TOML', + '.css': 'CSS', + '.scss': 'SCSS', + '.less': 'Less', + '.html': 'HTML', + '.zig': 'Zig', + '.ex': 'Elixir', + '.exs': 'Elixir', + '.erl': 'Erlang', + '.scala': 'Scala', + '.lua': 'Lua', + } + return extensions.get(ext.lower(), 'unknown') + + +def is_test_file(filename: str) -> bool: + """Check if file is a test file.""" + test_patterns = [ + r'(?:^|/)test_[^/]+\.py$', # Python: test_handler.py (anchored to path segment) + r'[^/]+_test\.', # *_test. — Rust, Go, etc. + r'[^/]+\.test\.(js|ts|tsx|jsx)$', # JS/TS: handler.test.ts + r'[^/]+\.spec\.(js|ts|tsx|jsx)$', # JS/TS: handler.spec.ts + r'(?:^|/)tests?/', # tests/ or test/ directory + r'(?:^|/)__tests__/', # __tests__/ directory + r'(?:^|/)spec/', # spec/ directory (Ruby, etc.) + ] + return any(re.search(p, filename) for p in test_patterns) + + +def is_config_file(filename: str) -> bool: + """Check if file is a configuration file. + + Uses explicit known-name matching to avoid flagging data files + like data.json, openapi.yaml, or swagger.json as config. + """ + basename = os.path.basename(filename) + + # Env files (.env, .env.local, .env.production, …) + if basename.startswith('.env'): + return True + + # Known config filenames (exact match) + known_config_names = { + 'package.json', 'package-lock.json', + 'tsconfig.json', 'jsconfig.json', + 'babel.config.json', 'babel.config.js', + 'webpack.config.js', 'webpack.config.ts', + 'rollup.config.js', 'vite.config.ts', 'vite.config.js', + '.eslintrc.json', '.eslintrc.js', '.eslintrc.yml', + '.prettierrc.json', '.prettierrc.yml', '.prettierrc.js', + 'jest.config.js', 'jest.config.ts', + 'vitest.config.ts', 'vitest.config.js', + 'tailwind.config.js', 'tailwind.config.ts', + 'postcss.config.js', + 'docker-compose.yml', 'docker-compose.yaml', + 'Dockerfile', + 'Makefile', 'CMakeLists.txt', + 'pyproject.toml', 'poetry.toml', 'Pipfile', + 'setup.cfg', 'setup.py', 'tox.ini', + 'Cargo.toml', 'Cargo.lock', + 'go.mod', 'go.sum', + 'Gemfile', 'Gemfile.lock', + 'composer.json', 'composer.lock', + 'Podfile', 'Package.swift', + '.gitignore', '.gitattributes', + 'gradle.properties', 'build.gradle', 'build.gradle.kts', + 'settings.gradle', 'settings.gradle.kts', + } + if basename in known_config_names: + return True + + # Known config path patterns + config_path_patterns = [ + r'\.github/workflows/[^/]+\.ya?ml$', + r'\.vscode/', + r'\.idea/', + ] + if any(re.search(p, filename) for p in config_path_patterns): + return True + + # Files with "config" in the name (e.g. app.config.ts, database_config.yml) + if re.search(r'config\.', basename): + return True + + # Files under a config/ directory + if re.search(r'(?:^|/)config/', filename): + return True + + return False + + +def parse_diff(diff_content: str) -> List[FileStats]: + """Parse git diff output and extract file statistics.""" + files = [] + current_file = None + + for line in diff_content.split('\n'): + # New file header + if line.startswith('diff --git'): + if current_file: + files.append(current_file) + # "diff --git a/ b/" — match the b/ side via a + # backreference so a literal "b/" inside paths like lib/, web/ or + # db/ can't be mistaken for the prefix. Renames have differing + # paths, so fall back to the b/ side after the separating space. + match = re.match(r'diff --git a/(.+?) b/\1', line) + if not match: + match = re.search(r' b/(.+)$', line) + if match: + filename = match.group(1) + current_file = FileStats( + filename=filename, + language=detect_language(filename), + is_test=is_test_file(filename), + is_config=is_config_file(filename), + ) + else: + current_file = None + elif current_file: + if line.startswith('+') and not line.startswith('+++'): + current_file.additions += 1 + elif line.startswith('-') and not line.startswith('---'): + current_file.deletions += 1 + + if current_file: + files.append(current_file) + + return files + + +def calculate_complexity(files: List[FileStats]) -> float: + """Calculate complexity score (0-1 scale).""" + if not files: + return 0.0 + + total_changes = sum(f.additions + f.deletions for f in files) + + # Base complexity from size + size_factor = min(total_changes / 1000, 1.0) + + # Factor for number of files + file_factor = min(len(files) / 20, 1.0) + + # Factor for non-test code ratio + test_lines = sum(f.additions + f.deletions for f in files if f.is_test) + non_test_ratio = 1 - (test_lines / max(total_changes, 1)) + + # Factor for language diversity + languages = set(f.language for f in files if f.language != 'unknown') + lang_factor = min(len(languages) / 5, 1.0) + + complexity = ( + size_factor * 0.4 + + file_factor * 0.2 + + non_test_ratio * 0.2 + + lang_factor * 0.2 + ) + + return round(complexity, 2) + + +def categorize_size(total_changes: int) -> str: + """Categorize PR size.""" + if total_changes < 50: + return "XS (Extra Small)" + elif total_changes < 200: + return "S (Small)" + elif total_changes < 400: + return "M (Medium)" + elif total_changes < 800: + return "L (Large)" + else: + return "XL (Extra Large) - Consider splitting" + + +def estimate_review_time(files: List[FileStats], complexity: float) -> int: + """Estimate review time in minutes.""" + total_changes = sum(f.additions + f.deletions for f in files) + + # Base time: ~1 minute per 20 lines + base_time = total_changes / 20 + + # Adjust for complexity + adjusted_time = base_time * (1 + complexity) + + # Minimum 5 minutes, maximum 120 minutes + return max(5, min(120, int(adjusted_time))) + + +def identify_risk_factors(files: List[FileStats]) -> List[str]: + """Identify potential risk factors in the PR.""" + risks = [] + + total_changes = sum(f.additions + f.deletions for f in files) + test_changes = sum(f.additions + f.deletions for f in files if f.is_test) + + if total_changes > 400: + risks.append("Large PR (>400 lines) - harder to review thoroughly") + + if test_changes == 0 and total_changes > 50: + risks.append(f"{RISK_NO_TESTS}: No test changes - verify test coverage") + + if total_changes > 100 and test_changes / max(total_changes, 1) < 0.2: + risks.append("Low test ratio (<20%) - consider adding more tests") + + # Security-sensitive files + security_patterns = ['.env', 'auth', 'security', 'password', 'token', 'secret'] + for f in files: + if any(p in f.filename.lower() for p in security_patterns): + risks.append(f"Security-sensitive file: {f.filename}") + break + + # Database changes + for f in files: + if 'migration' in f.filename.lower() or f.language == 'SQL': + risks.append("Database changes detected - review carefully") + break + + # Config changes + config_files = [f for f in files if f.is_config] + if config_files: + risks.append(f"Configuration changes in {len(config_files)} file(s)") + + return risks + + +def generate_suggestions(files: List[FileStats], complexity: float, risks: List[str]) -> List[str]: + """Generate review suggestions.""" + suggestions = [] + + total_changes = sum(f.additions + f.deletions for f in files) + + if total_changes > 800: + suggestions.append("Consider splitting this PR into smaller, focused changes") + + if complexity > 0.7: + suggestions.append("High complexity - allocate extra review time") + suggestions.append("Consider pair reviewing for critical sections") + + if any(RISK_NO_TESTS in r for r in risks): + suggestions.append("Request test additions before approval") + + # Language-specific suggestions + languages = set(f.language for f in files) + if 'TypeScript' in languages or 'TypeScript/React' in languages: + suggestions.append("Check for proper type usage (avoid 'any')") + if 'Rust' in languages: + suggestions.append("Check for unwrap() usage and error handling") + if 'C' in languages or 'C++' in languages or 'C/C++' in languages: + suggestions.append("Check for memory safety, bounds checks, and UB risks") + if 'SQL' in languages: + suggestions.append("Review for SQL injection and query performance") + + if not suggestions: + suggestions.append("Standard review process should suffice") + + return suggestions + + +def analyze_pr(diff_content: str) -> PRAnalysis: + """Perform complete PR analysis.""" + files = parse_diff(diff_content) + + total_additions = sum(f.additions for f in files) + total_deletions = sum(f.deletions for f in files) + total_changes = total_additions + total_deletions + + complexity = calculate_complexity(files) + risks = identify_risk_factors(files) + suggestions = generate_suggestions(files, complexity, risks) + + return PRAnalysis( + total_files=len(files), + total_additions=total_additions, + total_deletions=total_deletions, + files=files, + complexity_score=complexity, + size_category=categorize_size(total_changes), + estimated_review_time=estimate_review_time(files, complexity), + risk_factors=risks, + suggestions=suggestions, + ) + + +def print_analysis(analysis: PRAnalysis, show_files: bool = False): + """Print analysis results.""" + print("\n" + "=" * 60) + print("PR ANALYSIS REPORT") + print("=" * 60) + + print(f"\n📊 SUMMARY") + print(f" Files changed: {analysis.total_files}") + print(f" Additions: +{analysis.total_additions}") + print(f" Deletions: -{analysis.total_deletions}") + print(f" Total changes: {analysis.total_additions + analysis.total_deletions}") + + print(f"\n📏 SIZE: {analysis.size_category}") + print(f" Complexity score: {analysis.complexity_score}/1.0") + print(f" Estimated review time: ~{analysis.estimated_review_time} minutes") + + if analysis.risk_factors: + print(f"\n⚠️ RISK FACTORS:") + for risk in analysis.risk_factors: + print(f" • {risk}") + + print(f"\n💡 SUGGESTIONS:") + for suggestion in analysis.suggestions: + print(f" • {suggestion}") + + if show_files: + print(f"\n📁 FILES:") + # Group by language + by_lang: Dict[str, List[FileStats]] = defaultdict(list) + for f in analysis.files: + by_lang[f.language].append(f) + + for lang, lang_files in sorted(by_lang.items()): + print(f"\n [{lang}]") + for f in lang_files: + prefix = "🧪" if f.is_test else "⚙️" if f.is_config else "📄" + print(f" {prefix} {f.filename} (+{f.additions}/-{f.deletions})") + + print("\n" + "=" * 60) + + +def main(): + parser = argparse.ArgumentParser(description='Analyze PR complexity') + parser.add_argument('--diff-file', '-f', help='Path to diff file') + parser.add_argument('--stats', '-s', action='store_true', help='Show file details') + args = parser.parse_args() + + # Read diff from file or stdin + try: + if args.diff_file: + with open(args.diff_file, 'r', encoding='utf-8', errors='replace') as f: + diff_content = f.read() + elif not sys.stdin.isatty(): + diff_content = sys.stdin.buffer.read().decode('utf-8', errors='replace') + else: + print("Usage: git diff main...HEAD | python pr-analyzer.py") + print(" python pr-analyzer.py -f diff.txt") + sys.exit(1) + except OSError as e: + print(f"Error reading diff input: {e}", file=sys.stderr) + sys.exit(1) + + if not diff_content.strip(): + print("No diff content provided") + sys.exit(1) + + analysis = analyze_pr(diff_content) + print_analysis(analysis, show_files=args.stats) + + +if __name__ == '__main__': + main() diff --git a/examples/skills_code_review_agent/skills/code-review/scripts/test_pr_analyzer.py b/examples/skills_code_review_agent/skills/code-review/scripts/test_pr_analyzer.py new file mode 100644 index 000000000..e6f03fa14 --- /dev/null +++ b/examples/skills_code_review_agent/skills/code-review/scripts/test_pr_analyzer.py @@ -0,0 +1,380 @@ +#!/usr/bin/env python3 +"""Tests for pr-analyzer.py (stdlib unittest, no extra deps).""" + +import importlib.util +import os +import unittest + +# The script has a hyphen in its name, so load it by path. +_HERE = os.path.dirname(os.path.abspath(__file__)) +_spec = importlib.util.spec_from_file_location( + 'pr_analyzer', os.path.join(_HERE, 'pr-analyzer.py') +) +pr_analyzer = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(pr_analyzer) + +# Convenient aliases +FileStats = pr_analyzer.FileStats + + +# ═══════════════════════════════════════════════════════════════ +# parse_diff — filename extraction (existing tests) +# ═══════════════════════════════════════════════════════════════ + +class ParseDiffFilenameTest(unittest.TestCase): + def test_lib_prefixed_path(self): + # "lib/" embeds a literal "b/" that the old regex swallowed. + diff = ( + "diff --git a/lib/foo.py b/lib/foo.py\n" + "index 1234567..89abcde 100644\n" + "--- a/lib/foo.py\n" + "+++ b/lib/foo.py\n" + "@@ -1,2 +1,3 @@\n" + " unchanged\n" + "+added line\n" + "-removed line\n" + ) + files = pr_analyzer.parse_diff(diff) + self.assertEqual(len(files), 1) + self.assertEqual(files[0].filename, 'lib/foo.py') + self.assertEqual(files[0].additions, 1) + self.assertEqual(files[0].deletions, 1) + + def test_normal_path(self): + diff = ( + "diff --git a/src/main.py b/src/main.py\n" + "index 1111111..2222222 100644\n" + "--- a/src/main.py\n" + "+++ b/src/main.py\n" + "@@ -0,0 +1 @@\n" + "+print('hi')\n" + ) + files = pr_analyzer.parse_diff(diff) + self.assertEqual(len(files), 1) + self.assertEqual(files[0].filename, 'src/main.py') + + def test_other_embedded_b_slash_prefixes(self): + # web/ and db/ also contain a literal "b/". + diff = ( + "diff --git a/web/x.js b/web/x.js\n" + "+++ b/web/x.js\n" + "+console.log(1)\n" + "diff --git a/db/y.sql b/db/y.sql\n" + "+++ b/db/y.sql\n" + "+SELECT 1;\n" + ) + files = pr_analyzer.parse_diff(diff) + self.assertEqual([f.filename for f in files], ['web/x.js', 'db/y.sql']) + + def test_rename_falls_back_to_b_side(self): + diff = ( + "diff --git a/old/name.py b/new/name.py\n" + "similarity index 100%\n" + "rename from old/name.py\n" + "rename to new/name.py\n" + ) + files = pr_analyzer.parse_diff(diff) + self.assertEqual(len(files), 1) + self.assertEqual(files[0].filename, 'new/name.py') + + +# ═══════════════════════════════════════════════════════════════ +# detect_language +# ═══════════════════════════════════════════════════════════════ + +class DetectLanguageTest(unittest.TestCase): + def test_common_extensions(self): + cases = { + 'app.py': 'Python', + 'index.ts': 'TypeScript', + 'main.rs': 'Rust', + 'handler.go': 'Go', + 'App.java': 'Java', + 'Activity.kt': 'Kotlin', + 'ViewController.swift': 'Swift', + 'app.tsx': 'TypeScript/React', + 'style.css': 'CSS', + 'query.sql': 'SQL', + } + for filename, expected in cases.items(): + with self.subTest(filename=filename): + self.assertEqual(pr_analyzer.detect_language(filename), expected) + + def test_unknown_extension(self): + self.assertEqual(pr_analyzer.detect_language('data.xyz'), 'unknown') + self.assertEqual(pr_analyzer.detect_language('Makefile'), 'unknown') + + def test_cpp_variants(self): + for ext in ('.cpp', '.hpp', '.cc', '.cxx', '.hh', '.hxx'): + with self.subTest(ext=ext): + self.assertEqual(pr_analyzer.detect_language(f'file{ext}'), 'C++') + + +# ═══════════════════════════════════════════════════════════════ +# is_test_file +# ═══════════════════════════════════════════════════════════════ + +class IsTestFileTest(unittest.TestCase): + def test_python_test_prefix(self): + self.assertTrue(pr_analyzer.is_test_file('tests/test_handler.py')) + self.assertTrue(pr_analyzer.is_test_file('test_utils.py')) + + def test_python_test_suffix(self): + self.assertTrue(pr_analyzer.is_test_file('handler_test.py')) + + def test_rust_test_suffix(self): + self.assertTrue(pr_analyzer.is_test_file('src/my_module_test.rs')) + self.assertTrue(pr_analyzer.is_test_file('parser_test.rs')) + + def test_go_test_suffix(self): + self.assertTrue(pr_analyzer.is_test_file('handler_test.go')) + self.assertTrue(pr_analyzer.is_test_file('pkg/auth_test.go')) + + def test_js_ts_test_and_spec(self): + self.assertTrue(pr_analyzer.is_test_file('handler.test.ts')) + self.assertTrue(pr_analyzer.is_test_file('utils.spec.js')) + self.assertTrue(pr_analyzer.is_test_file('App.test.tsx')) + + def test_tests_directory(self): + self.assertTrue(pr_analyzer.is_test_file('tests/conftest.py')) + self.assertTrue(pr_analyzer.is_test_file('test/helpers.js')) + + def test_dunder_tests_directory(self): + self.assertTrue(pr_analyzer.is_test_file('__tests__/Button.test.tsx')) + + def test_non_test_files_rejected(self): + self.assertFalse(pr_analyzer.is_test_file('handler.go')) + self.assertFalse(pr_analyzer.is_test_file('module.rs')) + self.assertFalse(pr_analyzer.is_test_file('src/utils.py')) + self.assertFalse(pr_analyzer.is_test_file('lib/parser.js')) + self.assertFalse(pr_analyzer.is_test_file('contest.py')) + + def test_test_substring_not_matched(self): + """Files containing 'test_' as substring must NOT be flagged.""" + self.assertFalse(pr_analyzer.is_test_file('latest_report.py')) + self.assertFalse(pr_analyzer.is_test_file('contest_utils.py')) + self.assertFalse(pr_analyzer.is_test_file('src/latest_handler.py')) + + +# ═══════════════════════════════════════════════════════════════ +# is_config_file +# ═══════════════════════════════════════════════════════════════ + +class IsConfigFileTest(unittest.TestCase): + def test_known_json_configs(self): + self.assertTrue(pr_analyzer.is_config_file('package.json')) + self.assertTrue(pr_analyzer.is_config_file('tsconfig.json')) + self.assertTrue(pr_analyzer.is_config_file('.eslintrc.json')) + + def test_known_yaml_configs(self): + self.assertTrue(pr_analyzer.is_config_file('docker-compose.yml')) + self.assertTrue(pr_analyzer.is_config_file('.github/workflows/ci.yml')) + self.assertTrue(pr_analyzer.is_config_file('.prettierrc.yml')) + + def test_known_toml_configs(self): + self.assertTrue(pr_analyzer.is_config_file('Cargo.toml')) + self.assertTrue(pr_analyzer.is_config_file('pyproject.toml')) + + def test_env_files(self): + self.assertTrue(pr_analyzer.is_config_file('.env')) + self.assertTrue(pr_analyzer.is_config_file('.env.local')) + self.assertTrue(pr_analyzer.is_config_file('.env.production')) + + def test_config_in_filename(self): + self.assertTrue(pr_analyzer.is_config_file('app.config.ts')) + self.assertTrue(pr_analyzer.is_config_file('database_config.yml')) + + def test_data_files_rejected(self): + """Data files must NOT be flagged as config.""" + self.assertFalse(pr_analyzer.is_config_file('data.json')) + self.assertFalse(pr_analyzer.is_config_file('openapi.yaml')) + self.assertFalse(pr_analyzer.is_config_file('swagger.json')) + self.assertFalse(pr_analyzer.is_config_file('fixtures/sample.yml')) + self.assertFalse(pr_analyzer.is_config_file('translations.json')) + self.assertFalse(pr_analyzer.is_config_file('schema.toml')) + + def test_config_directory(self): + self.assertTrue(pr_analyzer.is_config_file('config/settings.yaml')) + self.assertTrue(pr_analyzer.is_config_file('config/database.yml')) + self.assertTrue(pr_analyzer.is_config_file('src/config/settings.json')) + + def test_source_files_rejected(self): + self.assertFalse(pr_analyzer.is_config_file('src/index.ts')) + self.assertFalse(pr_analyzer.is_config_file('lib/utils.py')) + + +# ═══════════════════════════════════════════════════════════════ +# calculate_complexity +# ═══════════════════════════════════════════════════════════════ + +class CalculateComplexityTest(unittest.TestCase): + def test_empty_files(self): + self.assertEqual(pr_analyzer.calculate_complexity([]), 0.0) + + def test_small_simple_change(self): + files = [FileStats(filename='app.py', additions=5, deletions=2, + language='Python')] + score = pr_analyzer.calculate_complexity(files) + self.assertLess(score, 0.3) + + def test_large_multi_language_change(self): + files = [ + FileStats(filename='app.py', additions=300, deletions=100, language='Python'), + FileStats(filename='main.rs', additions=200, deletions=50, language='Rust'), + FileStats(filename='index.ts', additions=150, deletions=80, language='TypeScript'), + FileStats(filename='handler.go', additions=100, deletions=30, language='Go'), + FileStats(filename='App.tsx', additions=50, deletions=20, language='TypeScript/React'), + ] + score = pr_analyzer.calculate_complexity(files) + self.assertGreater(score, 0.5) + + def test_test_heavy_change_is_lower(self): + """Changes with high test ratio should have lower complexity.""" + prod_files = [FileStats(filename='app.py', additions=100, deletions=50, + language='Python')] + test_files = [ + FileStats(filename='app.py', additions=100, deletions=50, + language='Python'), + FileStats(filename='tests/test_app.py', additions=100, deletions=0, + language='Python', is_test=True), + ] + score_prod = pr_analyzer.calculate_complexity(prod_files) + score_test = pr_analyzer.calculate_complexity(test_files) + self.assertLess(score_test, score_prod) + + +# ═══════════════════════════════════════════════════════════════ +# identify_risk_factors +# ═══════════════════════════════════════════════════════════════ + +class IdentifyRiskFactorsTest(unittest.TestCase): + def test_large_pr_flagged(self): + files = [FileStats(filename='big.py', additions=300, deletions=200, + language='Python')] + risks = pr_analyzer.identify_risk_factors(files) + self.assertTrue(any('Large PR' in r for r in risks)) + + def test_no_tests_flagged(self): + files = [FileStats(filename='app.py', additions=40, deletions=20, + language='Python')] + risks = pr_analyzer.identify_risk_factors(files) + self.assertTrue(any(pr_analyzer.RISK_NO_TESTS in r for r in risks)) + + def test_with_tests_not_flagged(self): + files = [ + FileStats(filename='app.py', additions=40, deletions=20, + language='Python'), + FileStats(filename='tests/test_app.py', additions=30, deletions=0, + language='Python', is_test=True), + ] + risks = pr_analyzer.identify_risk_factors(files) + self.assertFalse(any(pr_analyzer.RISK_NO_TESTS in r for r in risks)) + + def test_security_sensitive_file(self): + files = [FileStats(filename='src/auth/login.py', additions=10, deletions=5, + language='Python')] + risks = pr_analyzer.identify_risk_factors(files) + self.assertTrue(any('Security-sensitive' in r for r in risks)) + + def test_database_migration(self): + files = [FileStats(filename='migrations/001_init.sql', additions=20, deletions=0, + language='SQL')] + risks = pr_analyzer.identify_risk_factors(files) + self.assertTrue(any('Database' in r for r in risks)) + + def test_test_substring_files_still_flag_no_tests(self): + """Files like latest_report.py must not suppress NO_TEST_CHANGES.""" + files = [ + FileStats(filename='latest_report.py', additions=40, deletions=20, + language='Python'), + FileStats(filename='contest_utils.py', additions=30, deletions=10, + language='Python'), + ] + risks = pr_analyzer.identify_risk_factors(files) + self.assertTrue(any(pr_analyzer.RISK_NO_TESTS in r for r in risks)) + + +# ═══════════════════════════════════════════════════════════════ +# generate_suggestions +# ═══════════════════════════════════════════════════════════════ + +class GenerateSuggestionsTest(unittest.TestCase): + def test_returns_list(self): + files = [FileStats(filename='app.py', additions=10, deletions=5, + language='Python')] + result = pr_analyzer.generate_suggestions(files, 0.1, []) + self.assertIsInstance(result, list) + self.assertGreater(len(result), 0) + + def test_large_pr_split_suggestion(self): + files = [FileStats(filename='big.py', additions=600, deletions=300, + language='Python')] + result = pr_analyzer.generate_suggestions(files, 0.3, []) + self.assertTrue(any('splitting' in s.lower() for s in result)) + + def test_no_tests_suggestion(self): + files = [FileStats(filename='app.py', additions=40, deletions=20, + language='Python')] + risks = [f"{pr_analyzer.RISK_NO_TESTS}: no tests"] + result = pr_analyzer.generate_suggestions(files, 0.2, risks) + self.assertTrue(any('test' in s.lower() for s in result)) + + def test_rust_suggestion(self): + files = [FileStats(filename='lib.rs', additions=10, deletions=5, + language='Rust')] + result = pr_analyzer.generate_suggestions(files, 0.1, []) + self.assertTrue(any('unwrap' in s.lower() for s in result)) + + +# ═══════════════════════════════════════════════════════════════ +# analyze_pr — end-to-end integration +# ═══════════════════════════════════════════════════════════════ + +class AnalyzePRTest(unittest.TestCase): + def test_end_to_end(self): + diff = ( + "diff --git a/src/app.py b/src/app.py\n" + "index 1111111..2222222 100644\n" + "--- a/src/app.py\n" + "+++ b/src/app.py\n" + "@@ -1,3 +1,5 @@\n" + " import os\n" + "+import sys\n" + "+import json\n" + " def main():\n" + "+ print('hello')\n" + "+ return 0\n" + "diff --git a/tests/test_app.py b/tests/test_app.py\n" + "new file mode 100644\n" + "--- /dev/null\n" + "+++ b/tests/test_app.py\n" + "@@ -0,0 +1,3 @@\n" + "+from app import main\n" + "+def test_main():\n" + "+ assert main() == 0\n" + ) + analysis = pr_analyzer.analyze_pr(diff) + + self.assertEqual(analysis.total_files, 2) + self.assertEqual(analysis.total_additions, 7) # 4 + 3 + self.assertEqual(analysis.total_deletions, 0) + self.assertGreater(len(analysis.suggestions), 0) + self.assertIn('XS (Extra Small)', analysis.size_category) + + # Verify test file was detected + test_file = [f for f in analysis.files if 'test' in f.filename][0] + self.assertTrue(test_file.is_test) + + # Verify no "NO_TEST_CHANGES" risk since tests are present + self.assertFalse( + any(pr_analyzer.RISK_NO_TESTS in r for r in analysis.risk_factors) + ) + + def test_empty_diff(self): + analysis = pr_analyzer.analyze_pr("") + self.assertEqual(analysis.total_files, 0) + self.assertEqual(analysis.complexity_score, 0.0) + + +if __name__ == '__main__': + unittest.main() diff --git a/examples/skills_code_review_agent/tests/fixtures/01_clean/expected.json b/examples/skills_code_review_agent/tests/fixtures/01_clean/expected.json new file mode 100644 index 000000000..fcdfe5017 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/01_clean/expected.json @@ -0,0 +1 @@ +{"expected_status": "completed"} diff --git a/examples/skills_code_review_agent/tests/fixtures/01_clean/input.diff b/examples/skills_code_review_agent/tests/fixtures/01_clean/input.diff new file mode 100644 index 000000000..0e7b91245 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/01_clean/input.diff @@ -0,0 +1,20 @@ +diff --git a/demo/math_utils.py b/demo/math_utils.py +index 1111111..2222222 100644 +--- a/demo/math_utils.py ++++ b/demo/math_utils.py +@@ -1,2 +1,4 @@ + def add(left, right): + return left + right + +def subtract(left, right): + return left - right +diff --git a/tests/test_math_utils.py b/tests/test_math_utils.py +index 1111111..2222222 100644 +--- a/tests/test_math_utils.py ++++ b/tests/test_math_utils.py +@@ -1,2 +1,5 @@ + def test_add(): + assert add(1, 2) == 3 + +def test_subtract(): + assert subtract(3, 1) == 2 diff --git a/examples/skills_code_review_agent/tests/fixtures/02_hardcoded_token/input.diff b/examples/skills_code_review_agent/tests/fixtures/02_hardcoded_token/input.diff new file mode 100644 index 000000000..ee7daf1b0 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/02_hardcoded_token/input.diff @@ -0,0 +1,6 @@ +diff --git a/demo/client.py b/demo/client.py +--- a/demo/client.py ++++ b/demo/client.py +@@ -0,0 +1,2 @@ ++API_TOKEN = "sk-live-token-123456789" ++def call_model(): return API_TOKEN diff --git a/examples/skills_code_review_agent/tests/fixtures/03_async_leak/input.diff b/examples/skills_code_review_agent/tests/fixtures/03_async_leak/input.diff new file mode 100644 index 000000000..ceeeec35e --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/03_async_leak/input.diff @@ -0,0 +1,10 @@ +diff --git a/demo/fetch.py b/demo/fetch.py +--- a/demo/fetch.py ++++ b/demo/fetch.py +@@ -0,0 +1,6 @@ ++import aiohttp ++ ++async def fetch(url): ++ session = aiohttp.ClientSession() ++ response = await session.get(url) ++ return await response.text() diff --git a/examples/skills_code_review_agent/tests/fixtures/04_db_transaction_leak/input.diff b/examples/skills_code_review_agent/tests/fixtures/04_db_transaction_leak/input.diff new file mode 100644 index 000000000..3876a84c2 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/04_db_transaction_leak/input.diff @@ -0,0 +1,12 @@ +diff --git a/demo/repository.py b/demo/repository.py +--- a/demo/repository.py ++++ b/demo/repository.py +@@ -0,0 +1,8 @@ ++def create_user(engine, user): ++ conn = engine.connect() ++ tx = transaction.begin() ++ try: ++ conn.execute("INSERT INTO users VALUES (?)", [user]) ++ tx.commit() ++ except Exception: ++ raise diff --git a/examples/skills_code_review_agent/tests/fixtures/05_missing_test/input.diff b/examples/skills_code_review_agent/tests/fixtures/05_missing_test/input.diff new file mode 100644 index 000000000..eb7660eda --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/05_missing_test/input.diff @@ -0,0 +1,8 @@ +diff --git a/demo/price.py b/demo/price.py +--- a/demo/price.py ++++ b/demo/price.py +@@ -1,2 +1,4 @@ ++def discounted(price): ++ return price * 0.8 ++ + def full_price(price): return price diff --git a/examples/skills_code_review_agent/tests/fixtures/06_duplicate_finding/input.diff b/examples/skills_code_review_agent/tests/fixtures/06_duplicate_finding/input.diff new file mode 100644 index 000000000..e81df95e3 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/06_duplicate_finding/input.diff @@ -0,0 +1,6 @@ +diff --git a/demo/config.py b/demo/config.py +--- a/demo/config.py ++++ b/demo/config.py +@@ -0,0 +1,2 @@ ++API_TOKEN = "sk-live-duplicate-secret-123456" ++API_TOKEN = "sk-live-duplicate-secret-123456" diff --git a/examples/skills_code_review_agent/tests/fixtures/07_sandbox_failure/input.diff b/examples/skills_code_review_agent/tests/fixtures/07_sandbox_failure/input.diff new file mode 100644 index 000000000..699b75528 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/07_sandbox_failure/input.diff @@ -0,0 +1,6 @@ +diff --git a/tests/test_demo.py b/tests/test_demo.py +--- a/tests/test_demo.py ++++ b/tests/test_demo.py +@@ -0,0 +1,2 @@ ++def test_demo(): ++ assert True diff --git a/examples/skills_code_review_agent/tests/fixtures/08_secret_redaction/input.diff b/examples/skills_code_review_agent/tests/fixtures/08_secret_redaction/input.diff new file mode 100644 index 000000000..6b67fe526 --- /dev/null +++ b/examples/skills_code_review_agent/tests/fixtures/08_secret_redaction/input.diff @@ -0,0 +1,7 @@ +diff --git a/demo/settings.py b/demo/settings.py +--- a/demo/settings.py ++++ b/demo/settings.py +@@ -0,0 +1,3 @@ ++password = "open-sesame" ++API_TOKEN = "sk-live-redaction-123456" ++authorization = "Bearer private-token-123456" diff --git a/tests/examples/test_code_review_agent.py b/tests/examples/test_code_review_agent.py new file mode 100644 index 000000000..71867f9f0 --- /dev/null +++ b/tests/examples/test_code_review_agent.py @@ -0,0 +1,278 @@ +"""Tests for the native Skill code-review Agent's deterministic boundaries.""" +from __future__ import annotations + +import json +import sqlite3 +import subprocess +import sys +from datetime import datetime +from pathlib import Path + +import pytest + +from examples.skills_code_review_agent.agent.policy import evaluate_command +from examples.skills_code_review_agent.agent.storage import ReviewStorage +from examples.skills_code_review_agent.agent.prompts import INSTRUCTION +from examples.skills_code_review_agent.agent.tools import REVIEW_SKILL_TOOL_NAMES, plan_review_actions, parse_review_input, save_review_report +from examples.skills_code_review_agent.run_review import create_task_id + +FIXTURES = Path(__file__).parents[2] / "examples" / "skills_code_review_agent" / "tests" / "fixtures" + + +def test_parse_review_input_returns_redacted_hunks_and_changed_lines(): + result = parse_review_input(diff='+++ b/a.py\n@@ -0,0 +1 @@\n+token = "sk-secret"\n') + assert result["changed_files"] == ["a.py"] + assert "sk-secret" not in result["diff"] + assert result["hunks"][0]["file"] == "a.py" + + +def test_task_id_is_timestamped_readable_and_collision_safe(): + task_id = create_task_id( + diff_file=str(FIXTURES / "02_hardcoded_token" / "input.diff"), + now=datetime(2026, 7, 23, 22, 45, 30), suffix="a1b2c3d4", + ) + + assert task_id == "cr-20260723-224530-hardcoded-token-a1b2c3d4" + + +@pytest.mark.asyncio +async def test_cli_fake_model_uses_runner_without_model_api_key(tmp_path, monkeypatch): + from examples.skills_code_review_agent import run_review + from examples.skills_code_review_agent.agent import agent as review_agent + + fake_model = object() + captured: dict = {} + + async def fake_run(payload, runtime, model): + captured.update(payload=payload, runtime=runtime, model=model) + + monkeypatch.setattr(review_agent, "create_fake_model", lambda: fake_model) + monkeypatch.setattr(run_review, "_run_sdk_agent", fake_run) + monkeypatch.setattr(sys, "argv", [ + "run_review.py", "--diff-file", str(FIXTURES / "01_clean" / "input.diff"), + "--fake-model", "--output-dir", str(tmp_path), + ]) + + await run_review.main() + + assert captured["model"] is fake_model + assert captured["runtime"] == "docker" + assert captured["payload"]["task_id"].startswith("cr-") + + +def test_cli_rejects_dry_run_and_fake_model_together(): + completed = subprocess.run([ + sys.executable, "examples/skills_code_review_agent/run_review.py", + "--diff-file", str(FIXTURES / "01_clean" / "input.diff"), + "--dry-run", "--fake-model", + ], cwd=Path(__file__).parents[2], capture_output=True, text=True, check=False) + + assert completed.returncode == 2 + assert "not allowed with argument" in completed.stderr + + +def test_parse_review_input_stages_diff_and_changed_files(tmp_path): + repo = tmp_path / "repo" + source = repo / "src" / "example.py" + source.parent.mkdir(parents=True) + source.write_text("value = 1\n", encoding="utf-8") + patch = tmp_path / "input.diff" + patch.write_text("+++ b/src/example.py\n@@ -0,0 +1 @@\n+value = 1\n", encoding="utf-8") + result = parse_review_input( + diff_file=str(patch), repo_path=str(repo), staging_dir=str(tmp_path / "staging") + ) + assert [item["dst"] for item in result["workspace_inputs"]] == ["work/inputs/input.diff", "work/inputs/src/example.py"] + + +def test_parse_review_input_stages_generated_repo_diff(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + subprocess.run(["git", "-C", str(repo), "init"], check=True, capture_output=True) + source = repo / "example.py" + source.write_text("token = 'before'\n", encoding="utf-8") + subprocess.run(["git", "-C", str(repo), "add", "example.py"], check=True, capture_output=True) + source.write_text('token = "sk-secret"\n', encoding="utf-8") + result = parse_review_input(repo_path=str(repo), staging_dir=str(tmp_path / "staging")) + + staged_diff = tmp_path / "staging" / "input.diff" + assert staged_diff.read_text(encoding="utf-8") == result["diff"] + assert "sk-secret" not in staged_diff.read_text(encoding="utf-8") + assert result["workspace_inputs"][0]["dst"] == "work/inputs/input.diff" + + +def test_save_review_report_moves_low_confidence_to_human_review(tmp_path): + report = save_review_report(task_id="task-1", findings=[{ + "severity": "warning", "category": "semantic", "file": "a.py", "line": 1, + "title": "Review", "evidence": "value = 1", "recommendation": "Add a test.", + "confidence": 0.4, "source": "model", + }], evidence={"changed_lines": [{"file": "a.py", "line": 1, "content": "value = 1"}]}, output_dir=str(tmp_path)) + assert not any(item["category"] == "semantic" for item in report["findings"]) + assert report["needs_human_review"][0]["file"] == "a.py" + assert Path(report["json_path"]).exists() + + +@pytest.mark.parametrize("fixture_name,category", [ + ("01_clean", None), ("02_hardcoded_token", "secret"), ("03_async_leak", "async"), + ("04_db_transaction_leak", "database"), ("05_missing_test", "tests"), + ("06_duplicate_finding", "secret"), ("07_sandbox_failure", None), ("08_secret_redaction", "secret"), +]) +def test_public_fixtures_generate_redacted_persisted_reports(tmp_path, fixture_name, category): + payload = parse_review_input(diff=(FIXTURES / fixture_name / "input.diff").read_text(encoding="utf-8")) + report = save_review_report(task_id=fixture_name, findings=[], evidence={"changed_lines": payload["changed_lines"]}, output_dir=str(tmp_path)) + assert Path(report["json_path"]).exists() + if category: + assert category in {item["category"] for item in report["findings"]} + assert "sk-live" not in Path(report["json_path"]).read_text(encoding="utf-8") + + +def test_filter_denies_network_and_escalates_dynamic_python(): + assert evaluate_command(["curl", "https://example.invalid"], 30).decision == "deny" + assert evaluate_command(["python", "-c", "import socket"], 30).decision == "needs_human_review" + + +def test_sdk_agent_import_is_safe_without_loading_libmagic(): + from trpc_agent_sdk.agents import LlmAgent + + assert LlmAgent.__name__ == "LlmAgent" + + +async def test_agent_filter_uses_agent_context_metadata_not_missing_state(): + from trpc_agent_sdk.context import AgentContext + from trpc_agent_sdk.filter import FilterResult + from examples.skills_code_review_agent.agent.filter import CodeReviewAgentFilter + + context = AgentContext() + await CodeReviewAgentFilter()._before(context, None, FilterResult()) + + assert context.metadata["code_review_started"] is True + + +async def test_skill_run_filter_injects_workspace_inputs_from_context(): + from trpc_agent_sdk.context import AgentContext + from trpc_agent_sdk.filter import FilterResult + from examples.skills_code_review_agent.agent.filter import CodeReviewSkillRunFilter + + context = AgentContext() + context.metadata["code_review_workspace_inputs"] = [{"src": "host://C:/input.diff", "dst": "work/inputs/input.diff", "mode": "copy"}] + args = {"command": "python scripts/pr-analyzer.py --diff-file work/inputs/input.diff", "timeout": 30} + await CodeReviewSkillRunFilter()._before(context, args, FilterResult()) + + assert args["inputs"] == context.metadata["code_review_workspace_inputs"] + + +async def test_skill_run_filter_rejects_interactive_stdin(): + from trpc_agent_sdk.context import AgentContext + from trpc_agent_sdk.filter import FilterResult + from examples.skills_code_review_agent.agent.filter import CodeReviewSkillRunFilter + + context = AgentContext() + result = FilterResult() + args = {"command": "python", "stdin": "print('unsafe')", "timeout": 30} + await CodeReviewSkillRunFilter()._before( + context, + args, + result, + ) + + assert "needs_human_review" in str(result.error) + assert args["command"] == "python" + assert args["stdin"] == "print('unsafe')" + + +async def test_skill_run_filter_escalates_ruff_without_staged_source(): + from trpc_agent_sdk.context import AgentContext + from trpc_agent_sdk.filter import FilterResult + from examples.skills_code_review_agent.agent.filter import CodeReviewSkillRunFilter + + context = AgentContext() + context.metadata["code_review_workspace_inputs"] = [{"src": "host://C:/input.diff", "dst": "work/inputs/input.diff", "mode": "copy"}] + result = FilterResult() + await CodeReviewSkillRunFilter()._before(context, {"command": "ruff check demo/client.py", "timeout": 30}, result) + + assert "needs_human_review" in str(result.error) + +def test_review_agent_only_exposes_noninteractive_skill_tools(): + assert REVIEW_SKILL_TOOL_NAMES == ["skill_load", "skill_select_docs", "skill_list_docs"] + assert "never call\nparse_review_input" in INSTRUCTION + assert "Never use skill_exec" in INSTRUCTION + + +def test_plan_review_actions_uses_skill_relative_analyzer_command_without_stdin(): + actions = plan_review_actions(["analyze_pr"], [{"dst": "work/inputs/input.diff"}]) + + assert actions == [{ + "skill": "code-review", + "command": "python scripts/pr-analyzer.py --diff-file ../../work/inputs/input.diff", + "cwd": "", + "stdin": "", + "timeout": 30, + "inputs": [{"dst": "work/inputs/input.diff"}], + }] + + +def test_skill_plan_uses_workspace_relative_input_from_skill_cwd(): + skill = (FIXTURES.parents[1] / "skills" / "code-review" / "SKILL.md").read_text(encoding="utf-8") + + assert "../../work/inputs/input.diff" in skill + + +def test_sqlite_task_query_reads_report(tmp_path): + report = save_review_report(task_id="audit", findings=[], evidence={"changed_lines": []}, output_dir=str(tmp_path)) + task = ReviewStorage(tmp_path / "reviews.sqlite").get_task("audit") + assert task["task_id"] == report["task_id"] + assert task["metrics"]["finding_count"] == 0 + + +def test_report_persists_complete_skill_and_model_audit(tmp_path): + report = save_review_report( + task_id="audit-runs", findings=[], output_dir=str(tmp_path), + evidence={ + "changed_lines": [], + "skill_runs": [{"runtime": "docker", "command": "ruff check work/inputs/a.py", "stdout": "ok", "stderr": "", "exit_code": 0, "timed_out": False, "duration_seconds": 0.25, "output_files": [{"name": "out/result.txt", "content": "ok"}]}], + "model_runs": [{"model": "test-model", "input": {"diff": "[REDACTED]"}, "output": {"findings": []}, "duration_seconds": 0.1, "exception": ""}], + "filter_decisions": [{"decision": "allow", "reason": "allowed"}], + }, + ) + task = ReviewStorage(tmp_path / "reviews.sqlite").get_task(report["task_id"]) + assert task["skill_runs"][0]["stderr"] == "" + assert task["skill_runs"][0]["output_files"][0]["name"] == "out/result.txt" + assert task["model_runs"][0]["model"] == "test-model" + + +def test_report_compacts_audit_and_emits_monitoring_sections(tmp_path, monkeypatch): + monkeypatch.setenv("CODE_REVIEW_TOOL_OUTPUT_MAX_KIB", "1") + monkeypatch.setenv("CODE_REVIEW_MODEL_AUDIT_MAX_KIB", "1") + monkeypatch.setenv("CODE_REVIEW_MODEL_RUN_MAX_COUNT", "1") + report = save_review_report( + task_id="compact-audit", findings=[], output_dir=str(tmp_path), + evidence={ + "changed_lines": [], + "skill_runs": [{"runtime": "docker", "command": "python check.py", "status": "failed", "stdout": "x" * 3000, + "stderr": "boom", "exit_code": 1, "timed_out": False, "duration_seconds": 0.25, "output_files": []}], + "model_runs": [ + {"model": "first", "input": {"payload": "a" * 3000}, "output": {"text": "b" * 3000}, "duration_seconds": 0.1, "exception": ""}, + {"model": "second", "input": {}, "output": {}, "duration_seconds": 0.2, "exception": "RateLimitError"}, + ], + "filter_decisions": [{"decision": "allow", "reason": "ok"}], + }, + ) + run = report["skill_runs"][0] + assert run["stdout_truncated"] is True + assert len(report["model_runs"]) == 1 + assert report["model_runs"][0]["input_summary"]["truncated"] is True + assert report["metrics"]["severity_distribution"] == {} + assert report["metrics"]["sandbox_duration_seconds"] == 0.25 + assert report["metrics"]["exception_distribution"] == {"RateLimitError": 1} + markdown = Path(report["markdown_path"]).read_text(encoding="utf-8") + assert "## 监控指标" in markdown + assert "## 沙箱执行摘要" in markdown + assert "## 模型调用摘要" in markdown + + +def test_cli_dry_run_writes_task_scoped_report(tmp_path): + completed = subprocess.run([sys.executable, "examples/skills_code_review_agent/run_review.py", "--diff-file", + str(FIXTURES / "02_hardcoded_token" / "input.diff"), "--output-dir", str(tmp_path), "--dry-run"], + cwd=Path(__file__).parents[2], capture_output=True, text=True, check=False) + assert completed.returncode == 0, completed.stderr + report_path = Path(completed.stdout.strip().split(": ", 1)[1]) + assert report_path.exists() From cfcee87e1f72b0ae5518c907a3e4c1775d068a85 Mon Sep 17 00:00:00 2001 From: KeWang <1002245584@qq.com> Date: Sun, 26 Jul 2026 11:21:21 +0800 Subject: [PATCH 2/3] examples: fix code review agent audit and parsing --- examples/skills_code_review_agent/.env | 26 +-- examples/skills_code_review_agent/README.md | 22 +-- .../skills_code_review_agent/agent/agent.py | 23 ++- .../skills_code_review_agent/agent/filter.py | 32 ++-- .../skills_code_review_agent/agent/parser.py | 2 + .../skills_code_review_agent/agent/policy.py | 18 +- .../skills_code_review_agent/agent/rules.py | 14 +- .../skills_code_review_agent/agent/storage.py | 19 +- .../skills_code_review_agent/agent/tools.py | 61 ++++-- .../skills_code_review_agent/run_review.py | 9 +- tests/examples/test_code_review_agent.py | 178 +++++++++++++++++- 11 files changed, 329 insertions(+), 75 deletions(-) diff --git a/examples/skills_code_review_agent/.env b/examples/skills_code_review_agent/.env index db5f680af..a2ecb5280 100644 --- a/examples/skills_code_review_agent/.env +++ b/examples/skills_code_review_agent/.env @@ -1,20 +1,20 @@ -# OpenAI-compatible model used by the code-review Agent. -# Fill OPENAI_API_KEY locally; never commit a real key. -OPENAI_API_KEY= -OPENAI_BASE_URL=https://api.deepseek.com -OPENAI_MODEL=deepseek-v4-flash +# Set OPENAI_API_KEY, OPENAI_BASE_URL, OPENAI_MODEL for an OpenAI-compatible model. +# This tracked example file contains placeholders only. Do not commit real credentials. +OPENAI_API_KEY=your-api-key +OPENAI_BASE_URL=your-base-url +OPENAI_MODEL=your-model-name # Docker builds examples/skills_code_review_agent/Dockerfile once (with cache) # to provide the ruff and pytest commands used by the review Skill. -CODE_REVIEW_DOCKER_IMAGE=trpc-code-review:latest -CODE_REVIEW_DOCKER_BUILD_CONTEXT= +CODE_REVIEW_DOCKER_IMAGE=your-code-review-docker-image +CODE_REVIEW_DOCKER_BUILD_CONTEXT=your-code-review-docker-build-context # Audit/report size limits. Values are KiB except MODEL_RUN_MAX_COUNT. -CODE_REVIEW_TOOL_OUTPUT_MAX_KIB=8 -CODE_REVIEW_MODEL_AUDIT_MAX_KIB=16 -CODE_REVIEW_MODEL_RUN_MAX_COUNT=20 +CODE_REVIEW_TOOL_OUTPUT_MAX_KIB=your-tool-output-max-kib +CODE_REVIEW_MODEL_AUDIT_MAX_KIB=your-model-audit-max-kib +CODE_REVIEW_MODEL_RUN_MAX_COUNT=your-model-run-max-count # Optional official Cube/E2B workspace runtime configuration. -CUBE_TEMPLATE_ID= -E2B_API_URL= -E2B_API_KEY= +CUBE_TEMPLATE_ID=your-cube-template-id +E2B_API_URL=your-e2b-api-url +E2B_API_KEY=your-e2b-api-key diff --git a/examples/skills_code_review_agent/README.md b/examples/skills_code_review_agent/README.md index 3fb5c191a..5e2eedc11 100644 --- a/examples/skills_code_review_agent/README.md +++ b/examples/skills_code_review_agent/README.md @@ -84,27 +84,27 @@ docker build -t trpc-code-review:latest -f examples/skills_code_review_agent/Doc ### `.env` -编辑仓库提供的 `examples/skills_code_review_agent/.env` 模板,或通过 shell 环境变量覆盖配置。提交前必须确保其中没有真实密钥;若密钥曾进入 Git 历史,应立即在服务商侧轮换。 +本示例与仓库其他示例一样,提供受版本控制的 `examples/skills_code_review_agent/.env` 占位模板。先将其中所有 `your-xxx` 值替换为本地配置,或通过 shell 环境变量覆盖;该文件不得写入真实密钥。若密钥曾进入 Git 历史,应立即在服务商侧轮换。 ```dotenv # 真实模型;--fake-model 和 --dry-run 不需要 API Key。 OPENAI_API_KEY=your-api-key -OPENAI_BASE_URL=https://your-openai-compatible-endpoint/v1 +OPENAI_BASE_URL=your-base-url OPENAI_MODEL=your-model-name -# 镜像已手动构建时保持 BUILD_CONTEXT 为空,避免每次运行重建。 -CODE_REVIEW_DOCKER_IMAGE=trpc-code-review:latest -CODE_REVIEW_DOCKER_BUILD_CONTEXT= +# 填写非默认镜像/构建上下文;保留 your-xxx 时自动使用示例默认值。 +CODE_REVIEW_DOCKER_IMAGE=your-code-review-docker-image +CODE_REVIEW_DOCKER_BUILD_CONTEXT=your-code-review-docker-build-context # 单位 KiB;MODEL_RUN_MAX_COUNT 为条数。 -CODE_REVIEW_TOOL_OUTPUT_MAX_KIB=8 -CODE_REVIEW_MODEL_AUDIT_MAX_KIB=16 -CODE_REVIEW_MODEL_RUN_MAX_COUNT=20 +CODE_REVIEW_TOOL_OUTPUT_MAX_KIB=your-tool-output-max-kib +CODE_REVIEW_MODEL_AUDIT_MAX_KIB=your-model-audit-max-kib +CODE_REVIEW_MODEL_RUN_MAX_COUNT=your-model-run-max-count # Cube/E2B 预留配置。 -CUBE_TEMPLATE_ID= -E2B_API_URL= -E2B_API_KEY= +CUBE_TEMPLATE_ID=your-cube-template-id +E2B_API_URL=your-e2b-api-url +E2B_API_KEY=your-e2b-api-key ``` ## 运行命令 diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py index d79dfcf51..c4c97f76c 100644 --- a/examples/skills_code_review_agent/agent/agent.py +++ b/examples/skills_code_review_agent/agent/agent.py @@ -65,14 +65,14 @@ async def _generate_async_impl(self, request: Any, stream: bool = False, ctx: An return FakeReviewModel() -def create_agent(*, runtime: str = "docker", model: Any = None) -> Any: +def create_agent(*, runtime: str = "docker", model: Any = None, workspace_inputs: list[dict[str, str]] | None = None) -> Any: """Create the SDK-native Agent; call only in a supported SDK runtime.""" from trpc_agent_sdk.agents import LlmAgent from .tools import create_review_tools from .filter import before_model_audit, after_model_audit skill_tool_set, review_tools, repository = create_review_tools(runtime) - return LlmAgent( + agent = LlmAgent( name="code_review_agent", description="A policy-governed code review agent powered by loaded Skills.", model=model or create_fake_model(), instruction=INSTRUCTION, @@ -80,18 +80,25 @@ def create_agent(*, runtime: str = "docker", model: Any = None) -> Any: filters_name=["code_review_agent_filter"], before_model_callback=before_model_audit, after_model_callback=after_model_audit, ) + # Host paths are execution-only data. Never put them in the model message, + # session transcript, report, or audit payload. + agent._code_review_workspace_inputs = list(workspace_inputs or []) + return agent -async def create_agent_async(*, runtime: str = "docker", model: Any = None) -> Any: +async def create_agent_async(*, runtime: str = "docker", model: Any = None, + workspace_inputs: list[dict[str, str]] | None = None) -> Any: """Async variant used by Cube/E2B, whose SDK workspace starts asynchronously.""" if runtime not in {"cube", "e2b"}: - return create_agent(runtime=runtime, model=model) + return create_agent(runtime=runtime, model=model, workspace_inputs=workspace_inputs) from trpc_agent_sdk.agents import LlmAgent from .tools import create_review_tools_async from .filter import before_model_audit, after_model_audit skill_tool_set, review_tools, repository = await create_review_tools_async(runtime) - return LlmAgent(name="code_review_agent", description="A policy-governed code review agent powered by loaded Skills.", - model=model or create_fake_model(), instruction=INSTRUCTION, tools=[skill_tool_set, *review_tools], - skill_repository=repository, filters_name=["code_review_agent_filter"], - before_model_callback=before_model_audit, after_model_callback=after_model_audit) + agent = LlmAgent(name="code_review_agent", description="A policy-governed code review agent powered by loaded Skills.", + model=model or create_fake_model(), instruction=INSTRUCTION, tools=[skill_tool_set, *review_tools], + skill_repository=repository, filters_name=["code_review_agent_filter"], + before_model_callback=before_model_audit, after_model_callback=after_model_audit) + agent._code_review_workspace_inputs = list(workspace_inputs or []) + return agent diff --git a/examples/skills_code_review_agent/agent/filter.py b/examples/skills_code_review_agent/agent/filter.py index ad65b05f0..0d304594e 100644 --- a/examples/skills_code_review_agent/agent/filter.py +++ b/examples/skills_code_review_agent/agent/filter.py @@ -3,6 +3,7 @@ from typing import Any import json +import re import time from trpc_agent_sdk.context import AgentContext @@ -11,12 +12,14 @@ from .policy import FilterDecision, evaluate_command from .redactor import redact +_HOST_PATH = re.compile(r"host://[^\s\"']+") + def _safe(value: Any) -> Any: """Serialize SDK values for audit without storing credentials.""" if hasattr(value, "model_dump"): value = value.model_dump() if isinstance(value, str): - return redact(value) + return _HOST_PATH.sub("[HOST_PATH]", redact(value)) if isinstance(value, dict): return {str(key): _safe(item) for key, item in value.items()} if isinstance(value, (list, tuple)): @@ -40,7 +43,13 @@ class CodeReviewSkillRunFilter(BaseFilter): async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: args = getattr(req, "args", req if isinstance(req, dict) else {}) command = args.get("command", "") if isinstance(args, dict) else "" - timeout = int(args.get("timeout", 30)) if isinstance(args, dict) else 30 + timeout = 30 + decision: FilterDecision | None = None + if isinstance(args, dict): + try: + timeout = int(args.get("timeout", 30)) + except (TypeError, ValueError): + decision = FilterDecision("needs_human_review", "timeout must be a whole number of seconds") if isinstance(args, dict) and not args.get("inputs"): args["inputs"] = ctx.metadata.get("code_review_workspace_inputs", []) if isinstance(args, dict) and str(args.get("stdin", "")).strip(): @@ -48,9 +57,9 @@ async def _before(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: "needs_human_review", "stdin is not permitted; use the staged diff file path instead", ) - else: - decision = evaluate_command(command.split(), timeout) - if decision.decision == "allow" and command.split()[:1] == ["ruff"]: + elif decision is None: + decision = evaluate_command(command, timeout) + if decision.decision == "allow" and command.lstrip().startswith("ruff "): staged = {item.get("dst", "") for item in args.get("inputs", []) if isinstance(item, dict)} if not any(path != "work/inputs/input.diff" for path in staged): decision = type(decision)("needs_human_review", "ruff requires a staged source file, not only a diff") @@ -88,21 +97,22 @@ async def _after(self, ctx: AgentContext, req: Any, rsp: FilterResult) -> None: def before_model_audit(invocation_context: Any, request: Any) -> None: """Record the redacted model input and start time in InvocationContext state.""" state = invocation_context.agent_context.metadata - # The first user message already contains the host-backed input mappings. - # Capture them before the model requests a selected review action. + # The Agent receives trusted host-to-sandbox mappings out of band. The + # user/model message deliberately contains only sandbox-relative paths. if not state.get("code_review_workspace_inputs"): + trusted_inputs = getattr(invocation_context.agent, "_code_review_workspace_inputs", []) + if trusted_inputs: + state["code_review_workspace_inputs"] = trusted_inputs for content in getattr(request, "contents", []): for part in getattr(content, "parts", []): try: payload = json.loads(getattr(part, "text", "")) except (TypeError, json.JSONDecodeError): continue - inputs = payload.get("workspace_inputs") if isinstance(payload, dict) else None - if isinstance(inputs, list) and inputs: - state["code_review_workspace_inputs"] = inputs + if isinstance(payload, dict): state["code_review_changed_lines"] = payload.get("changed_lines", []) break - if state.get("code_review_workspace_inputs"): + if state.get("code_review_changed_lines"): break state["code_review_model_pending"] = { "model": getattr(invocation_context.agent.model, "_model_name", type(invocation_context.agent.model).__name__), diff --git a/examples/skills_code_review_agent/agent/parser.py b/examples/skills_code_review_agent/agent/parser.py index aac8f8310..d564a0465 100644 --- a/examples/skills_code_review_agent/agent/parser.py +++ b/examples/skills_code_review_agent/agent/parser.py @@ -60,6 +60,8 @@ def parse_unified_diff(diff: str) -> list[ChangedLine]: elif raw_line.startswith("+") and not raw_line.startswith("+++"): lines.append(ChangedLine(file_name, new_line, raw_line[1:])) new_line += 1 + elif raw_line.startswith("\\ No newline at end of file"): + continue elif raw_line.startswith(" ") or (raw_line and not raw_line.startswith("-")): new_line += 1 return lines diff --git a/examples/skills_code_review_agent/agent/policy.py b/examples/skills_code_review_agent/agent/policy.py index c815ba4d9..b794fbd0d 100644 --- a/examples/skills_code_review_agent/agent/policy.py +++ b/examples/skills_code_review_agent/agent/policy.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +import shlex @dataclass(frozen=True) @@ -13,12 +14,21 @@ class FilterDecision: class ReviewPolicyEngine: """Allow only bounded static checks; reject or escalate risky actions.""" - def decide(self, command: list[str], timeout_seconds: int = 30) -> FilterDecision: + def decide(self, command: list[str] | str, timeout_seconds: int = 30) -> FilterDecision: + if isinstance(command, str): + if any(marker in command for marker in (";", "|", "&", "`", "$", "<", ">", "\n", "\r")): + return FilterDecision("deny", "shell control syntax is not allowed in review commands") + try: + command = shlex.split(command, posix=True) + except ValueError: + return FilterDecision("deny", "command quoting could not be parsed safely") + if any(any(marker in token for marker in (";", "|", "&", "`", "$", "<", ">", "\n", "\r")) for token in command): + return FilterDecision("deny", "shell control syntax is not allowed in review commands") if not command or command[0] not in {"python", "pytest", "ruff"}: return FilterDecision("deny", "command executable is outside the review allowlist") text = " ".join(command).lower() - if command[:2] == ["python", "-c"] and any(token in text for token in ("subprocess", "os.system", "socket")): - return FilterDecision("needs_human_review", "dynamic Python code requests process or network access") + if command[0] == "python" and any(token == "-c" or token.startswith("-c") for token in command[1:]): + return FilterDecision("needs_human_review", "dynamic Python code is not permitted in review commands") if timeout_seconds > 120: return FilterDecision("deny", "command exceeds the 120 second review budget") if any(token in text for token in ("curl ", "wget ", "pip install", "npm install")): @@ -28,6 +38,6 @@ def decide(self, command: list[str], timeout_seconds: int = 30) -> FilterDecisio return FilterDecision("allow", "command is within the static-review allowlist") -def evaluate_command(command: list[str], timeout_seconds: int) -> FilterDecision: +def evaluate_command(command: list[str] | str, timeout_seconds: int) -> FilterDecision: """Compatibility helper for direct policy checks in tests and integrations.""" return ReviewPolicyEngine().decide(command, timeout_seconds) diff --git a/examples/skills_code_review_agent/agent/rules.py b/examples/skills_code_review_agent/agent/rules.py index b604334b3..04ac94b84 100644 --- a/examples/skills_code_review_agent/agent/rules.py +++ b/examples/skills_code_review_agent/agent/rules.py @@ -2,7 +2,6 @@ from __future__ import annotations -from dataclasses import replace import hashlib import re @@ -20,7 +19,7 @@ def _finding(line: ChangedLine, *, severity: str, category: str, title: str, rec def scan(lines: list[ChangedLine]) -> list[Finding]: findings: list[Finding] = [] - all_content = "\n".join(line.content for line in lines) + rollback_files = {line.file for line in lines if "rollback" in line.content.lower()} for line in lines: content = line.content if re.search(r"sk-[A-Za-z0-9_-]{8,}|(?:api[_-]?key|token|password)\s*=\s*[\"']", content, re.I): @@ -32,7 +31,7 @@ def scan(lines: list[ChangedLine]) -> list[Finding]: if re.search(r"\b(?:engine|conn|connection)\.connect\(", content): findings.append(_finding(line, severity="high", category="database", title="Database connection lifecycle is unsafe", recommendation="Use a context manager so connections close on both success and failure paths.")) - if re.search(r"\b(?:tx|transaction)\.begin\(", content) and "rollback" not in all_content: + if re.search(r"\b(?:tx|transaction)\.begin\(", content) and line.file not in rollback_files: findings.append(_finding(line, severity="high", category="database", title="Transaction has no rollback path", recommendation="Rollback in the exception path, or use an atomic transaction context manager.")) changed_code = any(not line.file.startswith("tests/") and line.file.endswith(".py") for line in lines) @@ -45,9 +44,8 @@ def scan(lines: list[ChangedLine]) -> list[Finding]: def deduplicate(findings: list[Finding]) -> list[Finding]: - unique: dict[tuple[str, str, str], Finding] = {} + unique: dict[tuple[str, str, int], Finding] = {} for finding in findings: - # One secret printed twice in the same changed file is one remediation task. - # Evidence has already been redacted, so it is safe to use as the stable key. - unique.setdefault((finding.category, finding.file, finding.evidence), finding) - return list(unique.values()) \ No newline at end of file + # Match report-level deduplication: one category per changed source line. + unique.setdefault((finding.category, finding.file, finding.line), finding) + return list(unique.values()) diff --git a/examples/skills_code_review_agent/agent/storage.py b/examples/skills_code_review_agent/agent/storage.py index 909644a3b..9e881aa48 100644 --- a/examples/skills_code_review_agent/agent/storage.py +++ b/examples/skills_code_review_agent/agent/storage.py @@ -5,6 +5,7 @@ import json from pathlib import Path import sqlite3 +from contextlib import contextmanager class ReviewStorage: @@ -28,8 +29,18 @@ def __init__(self, path: Path): if "metrics_json" not in columns: db.execute("ALTER TABLE review_metrics ADD COLUMN metrics_json TEXT") + @contextmanager def _connect(self): - return sqlite3.connect(self.path) + """Commit/rollback and close each short-lived SQLite connection.""" + db = sqlite3.connect(self.path) + try: + yield db + db.commit() + except Exception: + db.rollback() + raise + finally: + db.close() def save_native(self, task_id: str, report: dict, input_digest: str) -> None: """Persist the FunctionTool-owned report without depending on legacy models.""" @@ -88,7 +99,11 @@ def get_task(self, task_id: str) -> dict: "sandbox_runs": [dict(zip(("runtime", "status", "exit_code", "output"), row)) for row in runs], "filter_decisions": [dict(zip(("decision", "reason"), row)) for row in decisions], "findings": [dict(zip(("severity", "category", "file", "line", "evidence", "recommendation", "confidence"), row)) for row in findings], - "metrics": json.loads(metric[3]) if metric[3] else dict(zip(("finding_count", "sandbox_run_count", "blocked_count"), metric[:3])), + "metrics": ( + json.loads(metric[3]) if metric and metric[3] + else dict(zip(("finding_count", "sandbox_run_count", "blocked_count"), metric[:3])) + if metric else {"finding_count": 0, "sandbox_run_count": 0, "blocked_count": 0} + ), "skill_loads": [{"operation": row[0], "documents": json.loads(row[1]), "result": json.loads(row[2])} for row in skill_loads], "skill_runs": [{"runtime": row[0], "command": json.loads(row[1]), "status": row[2], "exit_code": row[3], "output": row[4], "stderr": row[5], "timed_out": bool(row[6]), "duration_seconds": row[7], "output_files": json.loads(row[8])} for row in skill_runs], "model_runs": [{"model": row[0], "duration_seconds": row[1], "result": json.loads(row[2])} for row in model_runs], diff --git a/examples/skills_code_review_agent/agent/tools.py b/examples/skills_code_review_agent/agent/tools.py index fcf1c8b19..4176bc7c1 100644 --- a/examples/skills_code_review_agent/agent/tools.py +++ b/examples/skills_code_review_agent/agent/tools.py @@ -4,6 +4,7 @@ import hashlib import json import os +import re import subprocess import time from dataclasses import dataclass @@ -12,6 +13,7 @@ REVIEW_SKILL_TOOL_NAMES = ["skill_load", "skill_select_docs", "skill_list_docs"] _ANALYZE_PR_COMMAND = "python scripts/pr-analyzer.py --diff-file ../../work/inputs/input.diff" +_HOST_PATH = re.compile(r"host://[^\s\"']+") def plan_review_actions(action_ids: list[str], workspace_inputs: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -32,6 +34,11 @@ class WorkspaceInputSpec: mode: str = "copy" def model_dump(self) -> dict[str, str]: + """Return the model-visible sandbox destination only.""" + return {"dst": self.dst, "mode": self.mode} + + def execution_dump(self) -> dict[str, str]: + """Return the trusted host-to-sandbox mapping for the workspace runtime.""" return {"src": self.src, "dst": self.dst, "mode": self.mode} @@ -39,6 +46,28 @@ def _stage(path: Path, dst: str) -> WorkspaceInputSpec: return WorkspaceInputSpec(f"host://{path.resolve().as_posix()}", dst) +def _redact_audit_text(value: Any) -> str: + """Redact secrets and execution-only host paths from persisted audit data.""" + from .redactor import redact + return _HOST_PATH.sub("[HOST_PATH]", redact(str(value or ""))) + + +def _redact_audit_value(value: Any) -> Any: + if isinstance(value, str): + return _redact_audit_text(value) + if isinstance(value, dict): + return {str(key): _redact_audit_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_redact_audit_value(item) for item in value] + return value + + +def _env_value_or_default(name: str, default: str) -> str: + """Treat committed ``your-...`` template values as unset configuration.""" + value = os.getenv(name, "").strip() + return default if not value or value.startswith("your-") else value + + def _env_positive_int(name: str, default: int) -> int: """Read a bounded audit limit from the environment.""" try: @@ -73,7 +102,7 @@ def _audit_summary(value: Any, limit_bytes: int, redact_text: Any) -> dict[str, def _compact_skill_runs(runs: list[dict], limit_bytes: int, redact_text: Any) -> list[dict]: compact: list[dict] = [] for run in runs: - item = dict(run) + item = _redact_audit_value(dict(run)) for field in ("stdout", "stderr"): value, truncated, original_bytes = _truncate_text(redact_text(item.get(field, "")), limit_bytes) item[field] = value @@ -103,7 +132,7 @@ def _review_metrics(findings: list[dict], human: list[dict], skill_runs: list[di statuses[run.get("status", "unknown")] = statuses.get(run.get("status", "unknown"), 0) + 1 exceptions: dict[str, int] = {} for run in model_runs: - exception = str(run.get("exception", "")).strip() + exception = _redact_audit_text(run.get("exception", "")).strip() if exception: exceptions[exception] = exceptions.get(exception, 0) + 1 return { @@ -176,27 +205,34 @@ def parse_review_input(*, diff: str = "", diff_file: str = "", repo_path: str = if root: for line in parsed.changed_lines: candidate = (root / line.file).resolve() - if candidate.is_file(): + # Diff paths are untrusted. Only stage files that remain inside the + # declared repository root after resolving ``..`` and symlinks. + if candidate.is_relative_to(root) and candidate.is_file(): sources.add(candidate) for source in sorted(sources): relative = source.relative_to(root).as_posix() if root and source.is_relative_to(root) else source.name staged.append(_stage(source, f"work/inputs/{relative}")) unique = {item.dst: item for item in staged} changed = [{"file": item.file, "line": item.line, "content": redact(item.content)} for item in parsed.changed_lines] + execution_inputs = [item.execution_dump() for item in unique.values()] result = { "diff": redacted_diff, "changed_files": sorted({item["file"] for item in changed if item["file"]}), "changed_lines": changed, "hunks": [{"file": item.file, "header": item.header, "context": [redact(text) for text in item.context]} for item in parsed.hunks], "workspace_inputs": [item.model_dump() for item in unique.values()], + # This private value is removed by the CLI before constructing the + # model message. It is retained only long enough to seed the SDK + # workspace runtime with its host-to-sandbox copy mappings. + "_execution_workspace_inputs": execution_inputs, } if tool_context is not None: state = tool_context.agent_context.metadata # A model often calls this tool with inline diff text. Such a call has # no host file to stage, so retain the input mappings pre-seeded from # the original CLI payload instead of accidentally discarding them. - if not result["workspace_inputs"] and state.get("code_review_workspace_inputs"): - result["workspace_inputs"] = state["code_review_workspace_inputs"] - state["code_review_workspace_inputs"] = result["workspace_inputs"] + if not execution_inputs and state.get("code_review_workspace_inputs"): + execution_inputs = state["code_review_workspace_inputs"] + state["code_review_workspace_inputs"] = execution_inputs state["code_review_changed_lines"] = changed return result @@ -221,6 +257,7 @@ def save_review_report(*, task_id: str, findings: list[dict], evidence: dict, ou } changed = {(item.get("file"), item.get("line")) for item in evidence.get("changed_lines", [])} evidence_text = "\n".join(str(item.get("content", "")) for item in evidence.get("changed_lines", [])) + redacted_evidence_text = redact(evidence_text) deterministic = [item.as_dict() for item in scan([ChangedLine(str(item.get("file", "")), int(item.get("line", 0)), str(item.get("content", ""))) for item in evidence.get("changed_lines", [])])] accepted, human, seen = [], [], set() for raw in [*deterministic, *findings]: @@ -231,7 +268,7 @@ def save_review_report(*, task_id: str, findings: list[dict], evidence: dict, ou continue item["evidence"] = redact(str(item["evidence"])) key = (item["category"], item["file"], item["line"]) - if key in seen or (item["file"], item["line"]) not in changed or (item["evidence"] and item["evidence"] not in redact(evidence_text)): + if key in seen or (item["file"], item["line"]) not in changed or (item["evidence"] and item["evidence"] not in redacted_evidence_text): continue seen.add(key) (human if item["confidence"] < 0.7 else accepted).append(item) @@ -242,9 +279,9 @@ def save_review_report(*, task_id: str, findings: list[dict], evidence: dict, ou model_limit = _env_positive_int("CODE_REVIEW_MODEL_AUDIT_MAX_KIB", 16) * 1024 model_count = _env_positive_int("CODE_REVIEW_MODEL_RUN_MAX_COUNT", 20) raw_model_runs = evidence.get("model_runs", []) - skill_runs = _compact_skill_runs(evidence.get("skill_runs", []), tool_limit, redact) - model_runs = _compact_model_runs(raw_model_runs, model_count, model_limit, redact) - decisions = evidence.get("filter_decisions", []) + skill_runs = _compact_skill_runs(evidence.get("skill_runs", []), tool_limit, _redact_audit_text) + model_runs = _compact_model_runs(raw_model_runs, model_count, model_limit, _redact_audit_text) + decisions = _redact_audit_value(evidence.get("filter_decisions", [])) metrics = _review_metrics(accepted, human, skill_runs, raw_model_runs, decisions, time.monotonic() - review_started) report = {"task_id": task_id, "status": "completed", "findings": accepted, "needs_human_review": human, "skill_runs": skill_runs, "model_runs": model_runs, "filter_decisions": decisions, @@ -287,8 +324,8 @@ def create_review_tools(runtime: str = "docker") -> tuple[Any, list[Any], Any]: from trpc_agent_sdk.code_executors.container import ContainerConfig example_root = Path(__file__).parents[1] workspace = create_container_workspace_runtime(container_config=ContainerConfig( - image=os.getenv("CODE_REVIEW_DOCKER_IMAGE", "trpc-code-review:latest"), - docker_path=os.getenv("CODE_REVIEW_DOCKER_BUILD_CONTEXT", str(example_root)), + image=_env_value_or_default("CODE_REVIEW_DOCKER_IMAGE", "trpc-code-review:latest"), + docker_path=_env_value_or_default("CODE_REVIEW_DOCKER_BUILD_CONTEXT", str(example_root)), )) else: raise RuntimeError("Cube/E2B runtime must be constructed asynchronously by the SDK deployment entry point") diff --git a/examples/skills_code_review_agent/run_review.py b/examples/skills_code_review_agent/run_review.py index c91aae382..1f4267bf9 100644 --- a/examples/skills_code_review_agent/run_review.py +++ b/examples/skills_code_review_agent/run_review.py @@ -39,7 +39,7 @@ def create_task_id(*, diff_file: str = "", repo_path: str = "", files: list[str] return f"cr-{timestamp}-{label}-{short_suffix}" -async def _run_sdk_agent(payload: dict, runtime: str, model: object) -> None: +async def _run_sdk_agent(payload: dict, runtime: str, model: object, workspace_inputs: list[dict]) -> None: """Use the repository Runner in a supported (normally Linux) SDK environment.""" from trpc_agent_sdk.runners import Runner from trpc_agent_sdk.sessions import InMemorySessionService @@ -47,7 +47,7 @@ async def _run_sdk_agent(payload: dict, runtime: str, model: object) -> None: from examples.skills_code_review_agent.agent.agent import create_agent_async session_service = InMemorySessionService() - agent = await create_agent_async(runtime=runtime, model=model) + agent = await create_agent_async(runtime=runtime, model=model, workspace_inputs=workspace_inputs) runner = Runner(app_name="skills_code_review_agent", agent=agent, session_service=session_service) await session_service.create_session(app_name="skills_code_review_agent", user_id="reviewer", session_id=payload["task_id"]) async for _ in runner.run_async(user_id="reviewer", session_id=payload["task_id"], @@ -78,6 +78,7 @@ async def main() -> None: diff_file=args.diff_file or "", repo_path=args.repo_path or "", files=args.files, staging_dir=str(staging_dir), ) + workspace_inputs = review_input.pop("_execution_workspace_inputs", []) payload = {**review_input, "task_id": task_id, "output_dir": args.output_dir} if args.dry_run: # Windows-safe fallback: same deterministic parse/report FunctionTool boundary, @@ -90,14 +91,14 @@ async def main() -> None: model = create_fake_model() else: api_key = os.environ.get(args.model_api_key_env, "") - if not api_key: + if not api_key or api_key.startswith("your-"): raise SystemExit(f"missing model API key: set {args.model_api_key_env} in .env or the environment") from examples.skills_code_review_agent.agent.agent import OpenAIReviewModel model = OpenAIReviewModel( api_key, args.model_base_url or os.getenv("OPENAI_BASE_URL", ""), args.model or os.getenv("OPENAI_MODEL", ""), ).create() - await _run_sdk_agent(payload, args.runtime, model) + await _run_sdk_agent(payload, args.runtime, model, workspace_inputs) print(f"submitted: {Path(args.output_dir) / payload['task_id'] / 'review_report.json'}") finally: shutil.rmtree(staging_dir, ignore_errors=True) diff --git a/tests/examples/test_code_review_agent.py b/tests/examples/test_code_review_agent.py index 71867f9f0..f86ea8cf4 100644 --- a/tests/examples/test_code_review_agent.py +++ b/tests/examples/test_code_review_agent.py @@ -26,6 +26,40 @@ def test_parse_review_input_returns_redacted_hunks_and_changed_lines(): assert result["hunks"][0]["file"] == "a.py" +def test_transaction_rollback_rule_is_scoped_to_the_changed_file(): + from examples.skills_code_review_agent.agent.parser import ChangedLine + from examples.skills_code_review_agent.agent.rules import scan + + findings = scan([ + ChangedLine("src/a.py", 10, "tx.begin()"), + ChangedLine("src/b.py", 4, "transaction.rollback()"), + ]) + + assert any(item.file == "src/a.py" and item.title == "Transaction has no rollback path" for item in findings) + + +def test_rule_deduplication_preserves_identical_findings_on_different_lines(): + from examples.skills_code_review_agent.agent.parser import ChangedLine + from examples.skills_code_review_agent.agent.rules import scan + + findings = scan([ + ChangedLine("src/client.py", 1, 'token = "same-secret"'), + ChangedLine("src/client.py", 2, 'token = "same-secret"'), + ]) + + assert {item.line for item in findings if item.category == "secret"} == {1, 2} + + +def test_diff_no_newline_marker_does_not_advance_new_file_line_number(): + from examples.skills_code_review_agent.agent.parser import parse_unified_diff + + changed = parse_unified_diff( + "+++ b/a.py\n@@ -1,2 +1,2 @@\n unchanged\n\\ No newline at end of file\n+added = True\n" + ) + + assert [(item.line, item.content) for item in changed] == [(2, "added = True")] + + def test_task_id_is_timestamped_readable_and_collision_safe(): task_id = create_task_id( diff_file=str(FIXTURES / "02_hardcoded_token" / "input.diff"), @@ -43,8 +77,8 @@ async def test_cli_fake_model_uses_runner_without_model_api_key(tmp_path, monkey fake_model = object() captured: dict = {} - async def fake_run(payload, runtime, model): - captured.update(payload=payload, runtime=runtime, model=model) + async def fake_run(payload, runtime, model, workspace_inputs): + captured.update(payload=payload, runtime=runtime, model=model, workspace_inputs=workspace_inputs) monkeypatch.setattr(review_agent, "create_fake_model", lambda: fake_model) monkeypatch.setattr(run_review, "_run_sdk_agent", fake_run) @@ -58,6 +92,7 @@ async def fake_run(payload, runtime, model): assert captured["model"] is fake_model assert captured["runtime"] == "docker" assert captured["payload"]["task_id"].startswith("cr-") + assert all("src" not in item for item in captured["payload"]["workspace_inputs"]) def test_cli_rejects_dry_run_and_fake_model_together(): @@ -84,6 +119,33 @@ def test_parse_review_input_stages_diff_and_changed_files(tmp_path): assert [item["dst"] for item in result["workspace_inputs"]] == ["work/inputs/input.diff", "work/inputs/src/example.py"] +def test_parse_review_input_skips_diff_paths_outside_repository_root(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + outside = tmp_path / "outside.py" + outside.write_text("secret = 'outside'\n", encoding="utf-8") + patch = tmp_path / "input.diff" + patch.write_text("+++ b/../../outside.py\n@@ -0,0 +1 @@\n+secret = 'outside'\n", encoding="utf-8") + + result = parse_review_input( + diff_file=str(patch), + repo_path=str(repo), + ) + + assert all(item["dst"] != "work/inputs/outside.py" for item in result["workspace_inputs"]) + assert all("outside.py" not in item["src"] for item in result["_execution_workspace_inputs"]) + + +def test_parse_review_input_exposes_only_sandbox_paths_to_model_payload(tmp_path): + patch = tmp_path / "input.diff" + patch.write_text("+++ b/a.py\n@@ -0,0 +1 @@\n+value = 1\n", encoding="utf-8") + + result = parse_review_input(diff_file=str(patch)) + + assert all("src" not in item for item in result["workspace_inputs"]) + assert any(item["src"].startswith("host://") for item in result["_execution_workspace_inputs"]) + + def test_parse_review_input_stages_generated_repo_diff(tmp_path): repo = tmp_path / "repo" repo.mkdir() @@ -128,8 +190,12 @@ def test_public_fixtures_generate_redacted_persisted_reports(tmp_path, fixture_n def test_filter_denies_network_and_escalates_dynamic_python(): assert evaluate_command(["curl", "https://example.invalid"], 30).decision == "deny" assert evaluate_command(["python", "-c", "import socket"], 30).decision == "needs_human_review" + assert evaluate_command('python -c"import socket"', 30).decision == "needs_human_review" + assert evaluate_command("python scripts/check.py; curl https://example.invalid", 30).decision == "deny" + assert evaluate_command(["python", "check.py\npytest"], 30).decision == "deny" +@pytest.mark.skipif(sys.platform == "win32", reason="SDK skills import may load python-magic unsafely on Windows") def test_sdk_agent_import_is_safe_without_loading_libmagic(): from trpc_agent_sdk.agents import LlmAgent @@ -160,6 +226,32 @@ async def test_skill_run_filter_injects_workspace_inputs_from_context(): assert args["inputs"] == context.metadata["code_review_workspace_inputs"] +def test_model_audit_uses_trusted_inputs_without_persisting_host_paths(): + from types import SimpleNamespace + from examples.skills_code_review_agent.agent.filter import before_model_audit + + host_path = "host://C:/private/repository/input.diff" + agent = SimpleNamespace( + model=SimpleNamespace(_model_name="test-model"), + _code_review_workspace_inputs=[{"src": host_path, "dst": "work/inputs/input.diff", "mode": "copy"}], + ) + context = SimpleNamespace(agent=agent, agent_context=SimpleNamespace(metadata={})) + class Request: + contents = [SimpleNamespace(parts=[SimpleNamespace(text=json.dumps({ + "workspace_inputs": [{"dst": "work/inputs/input.diff", "mode": "copy"}], + }))])] + + def model_dump(self): + return {"contents": [{"workspace_inputs": [{"dst": "work/inputs/input.diff", "mode": "copy"}]}]} + + request = Request() + + before_model_audit(context, request) + + assert context.agent_context.metadata["code_review_workspace_inputs"][0]["src"] == host_path + assert host_path not in json.dumps(context.agent_context.metadata["code_review_model_pending"]["input"]) + + async def test_skill_run_filter_rejects_interactive_stdin(): from trpc_agent_sdk.context import AgentContext from trpc_agent_sdk.filter import FilterResult @@ -179,6 +271,20 @@ async def test_skill_run_filter_rejects_interactive_stdin(): assert args["stdin"] == "print('unsafe')" +async def test_skill_run_filter_blocks_non_integer_timeout_without_raising(): + from trpc_agent_sdk.context import AgentContext + from trpc_agent_sdk.filter import FilterResult + from examples.skills_code_review_agent.agent.filter import CodeReviewSkillRunFilter + + result = FilterResult() + await CodeReviewSkillRunFilter()._before( + AgentContext(), {"command": "python scripts/check.py", "timeout": "30s"}, result + ) + + assert "needs_human_review" in str(result.error) + assert "timeout" in str(result.error) + + async def test_skill_run_filter_escalates_ruff_without_staged_source(): from trpc_agent_sdk.context import AgentContext from trpc_agent_sdk.filter import FilterResult @@ -223,6 +329,54 @@ def test_sqlite_task_query_reads_report(tmp_path): assert task["metrics"]["finding_count"] == 0 +def test_sqlite_task_query_tolerates_missing_legacy_metrics_row(tmp_path): + report = save_review_report(task_id="legacy-metrics", findings=[], evidence={"changed_lines": []}, output_dir=str(tmp_path)) + with sqlite3.connect(tmp_path / "reviews.sqlite") as db: + db.execute("DELETE FROM review_metrics WHERE task_id = ?", (report["task_id"],)) + + task = ReviewStorage(tmp_path / "reviews.sqlite").get_task(report["task_id"]) + + assert task["metrics"] == {"finding_count": 0, "sandbox_run_count": 0, "blocked_count": 0} + + +def test_sqlite_connections_are_closed_after_each_storage_operation(tmp_path, monkeypatch): + import examples.skills_code_review_agent.agent.storage as storage_module + + real_connect = sqlite3.connect + connections = [] + + class TrackingConnection: + def __init__(self, connection): + self.connection = connection + self.closed = False + + def close(self): + self.closed = True + self.connection.close() + + def __enter__(self): + return self + + def __exit__(self, *_): + return False + + def __getattr__(self, name): + return getattr(self.connection, name) + + def connect(path): + connection = TrackingConnection(real_connect(path)) + connections.append(connection) + return connection + + monkeypatch.setattr(storage_module.sqlite3, "connect", connect) + storage = ReviewStorage(tmp_path / "audit.sqlite") + storage.save_native("task", {"status": "completed", "metrics": {"finding_count": 0, "sandbox_run_count": 0, "blocked_count": 0}, + "findings": [], "filter_decisions": [], "skill_runs": [], "model_runs": []}, "digest") + storage.get_task("task") + + assert connections and all(connection.closed for connection in connections) + + def test_report_persists_complete_skill_and_model_audit(tmp_path): report = save_review_report( task_id="audit-runs", findings=[], output_dir=str(tmp_path), @@ -239,6 +393,26 @@ def test_report_persists_complete_skill_and_model_audit(tmp_path): assert task["model_runs"][0]["model"] == "test-model" +def test_report_and_sqlite_audit_redact_host_workspace_paths(tmp_path): + host_path = "host://C:/private/repository/input.diff" + report = save_review_report( + task_id="audit-host-path", findings=[], output_dir=str(tmp_path), + evidence={ + "changed_lines": [], + "skill_runs": [{"runtime": "docker", "command": "python check.py", "stdout": host_path, "stderr": "", + "exit_code": 0, "timed_out": False, "duration_seconds": 0, "output_files": []}], + "model_runs": [{"model": "test-model", "input": {"input": host_path}, "output": {}, + "duration_seconds": 0, "exception": host_path}], + "filter_decisions": [], + }, + ) + + report_text = Path(report["json_path"]).read_text(encoding="utf-8") + task_text = json.dumps(ReviewStorage(tmp_path / "reviews.sqlite").get_task("audit-host-path")) + assert host_path not in report_text + assert host_path not in task_text + + def test_report_compacts_audit_and_emits_monitoring_sections(tmp_path, monkeypatch): monkeypatch.setenv("CODE_REVIEW_TOOL_OUTPUT_MAX_KIB", "1") monkeypatch.setenv("CODE_REVIEW_MODEL_AUDIT_MAX_KIB", "1") From a12b0252c1833553e319aa17aab54d5a87ff6f64 Mon Sep 17 00:00:00 2001 From: KeWang <1002245584@qq.com> Date: Mon, 27 Jul 2026 14:54:38 +0800 Subject: [PATCH 3/3] examples: harden code review agent tool boundaries --- .../skills_code_review_agent/agent/agent.py | 15 +++- .../skills_code_review_agent/agent/filter.py | 6 ++ .../skills_code_review_agent/agent/tools.py | 33 +++++++- .../skills_code_review_agent/run_review.py | 10 ++- tests/examples/test_code_review_agent.py | 76 ++++++++++++++++++- 5 files changed, 128 insertions(+), 12 deletions(-) diff --git a/examples/skills_code_review_agent/agent/agent.py b/examples/skills_code_review_agent/agent/agent.py index c4c97f76c..6e96c1447 100644 --- a/examples/skills_code_review_agent/agent/agent.py +++ b/examples/skills_code_review_agent/agent/agent.py @@ -57,7 +57,7 @@ async def _generate_async_impl(self, request: Any, stream: bool = False, ctx: An args={"task_id": self.payload.get("task_id", "dry-run"), "findings": finding, "evidence": {"changed_lines": [{"file": item.file, "line": item.line, "content": item.content} for item in changed], "skill_runs": [], "filter_decisions": []}, - "output_dir": self.payload.get("output_dir", "")}, + }, ))])) return yield LlmResponse(content=Content(role="model", parts=[Part(text="Review report saved.")])) @@ -65,7 +65,8 @@ async def _generate_async_impl(self, request: Any, stream: bool = False, ctx: An return FakeReviewModel() -def create_agent(*, runtime: str = "docker", model: Any = None, workspace_inputs: list[dict[str, str]] | None = None) -> Any: +def create_agent(*, runtime: str = "docker", model: Any = None, workspace_inputs: list[dict[str, str]] | None = None, + task_id: str = "", output_dir: str = "") -> Any: """Create the SDK-native Agent; call only in a supported SDK runtime.""" from trpc_agent_sdk.agents import LlmAgent from .tools import create_review_tools @@ -83,14 +84,18 @@ def create_agent(*, runtime: str = "docker", model: Any = None, workspace_inputs # Host paths are execution-only data. Never put them in the model message, # session transcript, report, or audit payload. agent._code_review_workspace_inputs = list(workspace_inputs or []) + agent._code_review_task_id = task_id + agent._code_review_output_dir = output_dir return agent async def create_agent_async(*, runtime: str = "docker", model: Any = None, - workspace_inputs: list[dict[str, str]] | None = None) -> Any: + workspace_inputs: list[dict[str, str]] | None = None, task_id: str = "", + output_dir: str = "") -> Any: """Async variant used by Cube/E2B, whose SDK workspace starts asynchronously.""" if runtime not in {"cube", "e2b"}: - return create_agent(runtime=runtime, model=model, workspace_inputs=workspace_inputs) + return create_agent(runtime=runtime, model=model, workspace_inputs=workspace_inputs, + task_id=task_id, output_dir=output_dir) from trpc_agent_sdk.agents import LlmAgent from .tools import create_review_tools_async from .filter import before_model_audit, after_model_audit @@ -101,4 +106,6 @@ async def create_agent_async(*, runtime: str = "docker", model: Any = None, skill_repository=repository, filters_name=["code_review_agent_filter"], before_model_callback=before_model_audit, after_model_callback=after_model_audit) agent._code_review_workspace_inputs = list(workspace_inputs or []) + agent._code_review_task_id = task_id + agent._code_review_output_dir = output_dir return agent diff --git a/examples/skills_code_review_agent/agent/filter.py b/examples/skills_code_review_agent/agent/filter.py index 0d304594e..6e5cfd1b8 100644 --- a/examples/skills_code_review_agent/agent/filter.py +++ b/examples/skills_code_review_agent/agent/filter.py @@ -114,6 +114,12 @@ def before_model_audit(invocation_context: Any, request: Any) -> None: break if state.get("code_review_changed_lines"): break + trusted_task_id = getattr(invocation_context.agent, "_code_review_task_id", "") + trusted_output_dir = getattr(invocation_context.agent, "_code_review_output_dir", "") + if trusted_task_id: + state["code_review_task_id"] = trusted_task_id + if trusted_output_dir: + state["code_review_output_dir"] = trusted_output_dir state["code_review_model_pending"] = { "model": getattr(invocation_context.agent.model, "_model_name", type(invocation_context.agent.model).__name__), "input": _safe(request), "started": time.monotonic(), diff --git a/examples/skills_code_review_agent/agent/tools.py b/examples/skills_code_review_agent/agent/tools.py index 4176bc7c1..b20af32af 100644 --- a/examples/skills_code_review_agent/agent/tools.py +++ b/examples/skills_code_review_agent/agent/tools.py @@ -14,6 +14,7 @@ REVIEW_SKILL_TOOL_NAMES = ["skill_load", "skill_select_docs", "skill_list_docs"] _ANALYZE_PR_COMMAND = "python scripts/pr-analyzer.py --diff-file ../../work/inputs/input.diff" _HOST_PATH = re.compile(r"host://[^\s\"']+") +_TASK_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") def plan_review_actions(action_ids: list[str], workspace_inputs: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -76,6 +77,17 @@ def _env_positive_int(name: str, default: int) -> int: return default +def _resolve_report_task_root(task_id: str, output_dir: str) -> tuple[Path, Path]: + """Validate a task id and keep the report directory below its output root.""" + if not isinstance(task_id, str) or not _TASK_ID.fullmatch(task_id) or ".." in task_id: + raise ValueError("task_id must contain only safe letters, digits, dots, underscores, and hyphens") + root = Path(output_dir or Path(__file__).parents[1] / "review-output").resolve() + task_root = (root / task_id).resolve() + if not task_root.is_relative_to(root): + raise ValueError("task output path escapes the trusted output directory") + return root, task_root + + def _truncate_text(text: Any, limit_bytes: int) -> tuple[str, bool, int]: value = str(text or "") raw = value.encode("utf-8") @@ -247,6 +259,13 @@ def save_review_report(*, task_id: str, findings: list[dict], evidence: dict, ou review_started = time.monotonic() if tool_context is not None: state = tool_context.agent_context.metadata + trusted_task_id = state.get("code_review_task_id") + trusted_output_dir = state.get("code_review_output_dir") + if not trusted_task_id or not trusted_output_dir: + raise ValueError("trusted task configuration is required for model report persistence") + if task_id != trusted_task_id: + raise ValueError("model report task_id does not match the trusted task") + task_id, output_dir = trusted_task_id, trusted_output_dir review_started = state.get("code_review_started_at", review_started) evidence = { **evidence, @@ -272,8 +291,7 @@ def save_review_report(*, task_id: str, findings: list[dict], evidence: dict, ou continue seen.add(key) (human if item["confidence"] < 0.7 else accepted).append(item) - root, task_root = Path(output_dir or Path(__file__).parents[1] / "review-output"), None - task_root = root / task_id + root, task_root = _resolve_report_task_root(task_id, output_dir) task_root.mkdir(parents=True, exist_ok=True) tool_limit = _env_positive_int("CODE_REVIEW_TOOL_OUTPUT_MAX_KIB", 8) * 1024 model_limit = _env_positive_int("CODE_REVIEW_MODEL_AUDIT_MAX_KIB", 16) * 1024 @@ -290,6 +308,17 @@ def save_review_report(*, task_id: str, findings: list[dict], evidence: dict, ou json_path.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") markdown_path.write_text(_markdown_report(task_id, accepted, human, metrics, skill_runs, decisions, model_runs), encoding="utf-8") ReviewStorage(root / "reviews.sqlite").save_native(task_id, report, hashlib.sha256(redact(evidence_text).encode()).hexdigest()) + if tool_context is not None: + # FunctionTool results are added to the next model turn. Return only + # repository-independent completion data, never host output paths or + # the full persisted audit payload. + return { + "task_id": task_id, + "status": report["status"], + "finding_count": len(accepted), + "needs_human_review_count": len(human), + "report_files": ["review_report.json", "review_report.md"], + } return {**report, "json_path": str(json_path), "markdown_path": str(markdown_path)} diff --git a/examples/skills_code_review_agent/run_review.py b/examples/skills_code_review_agent/run_review.py index 1f4267bf9..833d6ac8f 100644 --- a/examples/skills_code_review_agent/run_review.py +++ b/examples/skills_code_review_agent/run_review.py @@ -39,7 +39,8 @@ def create_task_id(*, diff_file: str = "", repo_path: str = "", files: list[str] return f"cr-{timestamp}-{label}-{short_suffix}" -async def _run_sdk_agent(payload: dict, runtime: str, model: object, workspace_inputs: list[dict]) -> None: +async def _run_sdk_agent(payload: dict, runtime: str, model: object, workspace_inputs: list[dict], + task_id: str, output_dir: str) -> None: """Use the repository Runner in a supported (normally Linux) SDK environment.""" from trpc_agent_sdk.runners import Runner from trpc_agent_sdk.sessions import InMemorySessionService @@ -47,7 +48,8 @@ async def _run_sdk_agent(payload: dict, runtime: str, model: object, workspace_i from examples.skills_code_review_agent.agent.agent import create_agent_async session_service = InMemorySessionService() - agent = await create_agent_async(runtime=runtime, model=model, workspace_inputs=workspace_inputs) + agent = await create_agent_async(runtime=runtime, model=model, workspace_inputs=workspace_inputs, + task_id=task_id, output_dir=output_dir) runner = Runner(app_name="skills_code_review_agent", agent=agent, session_service=session_service) await session_service.create_session(app_name="skills_code_review_agent", user_id="reviewer", session_id=payload["task_id"]) async for _ in runner.run_async(user_id="reviewer", session_id=payload["task_id"], @@ -79,7 +81,7 @@ async def main() -> None: staging_dir=str(staging_dir), ) workspace_inputs = review_input.pop("_execution_workspace_inputs", []) - payload = {**review_input, "task_id": task_id, "output_dir": args.output_dir} + payload = {**review_input, "task_id": task_id} if args.dry_run: # Windows-safe fallback: same deterministic parse/report FunctionTool boundary, # without importing the SDK path that currently loads python-magic. @@ -98,7 +100,7 @@ async def main() -> None: api_key, args.model_base_url or os.getenv("OPENAI_BASE_URL", ""), args.model or os.getenv("OPENAI_MODEL", ""), ).create() - await _run_sdk_agent(payload, args.runtime, model, workspace_inputs) + await _run_sdk_agent(payload, args.runtime, model, workspace_inputs, task_id, args.output_dir) print(f"submitted: {Path(args.output_dir) / payload['task_id'] / 'review_report.json'}") finally: shutil.rmtree(staging_dir, ignore_errors=True) diff --git a/tests/examples/test_code_review_agent.py b/tests/examples/test_code_review_agent.py index f86ea8cf4..37120ef42 100644 --- a/tests/examples/test_code_review_agent.py +++ b/tests/examples/test_code_review_agent.py @@ -77,8 +77,9 @@ async def test_cli_fake_model_uses_runner_without_model_api_key(tmp_path, monkey fake_model = object() captured: dict = {} - async def fake_run(payload, runtime, model, workspace_inputs): - captured.update(payload=payload, runtime=runtime, model=model, workspace_inputs=workspace_inputs) + async def fake_run(payload, runtime, model, workspace_inputs, task_id, output_dir): + captured.update(payload=payload, runtime=runtime, model=model, workspace_inputs=workspace_inputs, + task_id=task_id, output_dir=output_dir) monkeypatch.setattr(review_agent, "create_fake_model", lambda: fake_model) monkeypatch.setattr(run_review, "_run_sdk_agent", fake_run) @@ -92,7 +93,9 @@ async def fake_run(payload, runtime, model, workspace_inputs): assert captured["model"] is fake_model assert captured["runtime"] == "docker" assert captured["payload"]["task_id"].startswith("cr-") + assert captured["task_id"] == captured["payload"]["task_id"] assert all("src" not in item for item in captured["payload"]["workspace_inputs"]) + assert "output_dir" not in captured["payload"] def test_cli_rejects_dry_run_and_fake_model_together(): @@ -173,6 +176,56 @@ def test_save_review_report_moves_low_confidence_to_human_review(tmp_path): assert Path(report["json_path"]).exists() +@pytest.mark.parametrize("task_id", ["../escape", "nested/task", "nested\\task", "/absolute", ".."]) +def test_save_review_report_rejects_unsafe_task_ids(tmp_path, task_id): + with pytest.raises(ValueError, match="task_id"): + save_review_report( + task_id=task_id, findings=[], evidence={"changed_lines": []}, output_dir=str(tmp_path) + ) + + +def test_save_review_report_rejects_model_forged_task_or_output_dir(tmp_path): + from types import SimpleNamespace + + context = SimpleNamespace(agent_context=SimpleNamespace(metadata={ + "code_review_task_id": "cr-trusted-task", + "code_review_output_dir": str(tmp_path / "trusted-output"), + })) + + with pytest.raises(ValueError, match="trusted task"): + save_review_report( + task_id="cr-forged-task", findings=[], evidence={"changed_lines": []}, + output_dir=str(tmp_path / "forged-output"), tool_context=context, + ) + + +def test_save_review_report_returns_safe_summary_to_model_tool_context(tmp_path): + from types import SimpleNamespace + + output_dir = tmp_path / "trusted-output" + context = SimpleNamespace(agent_context=SimpleNamespace(metadata={ + "code_review_task_id": "cr-trusted-task", + "code_review_output_dir": str(output_dir), + "code_review_changed_lines": [], + "code_review_skill_runs": [], + "code_review_model_runs": [], + "code_review_filter_decisions": [], + })) + + result = save_review_report( + task_id="cr-trusted-task", findings=[], evidence={"changed_lines": []}, tool_context=context, + ) + + assert result == { + "task_id": "cr-trusted-task", + "status": "completed", + "finding_count": 0, + "needs_human_review_count": 0, + "report_files": ["review_report.json", "review_report.md"], + } + assert str(output_dir) not in json.dumps(result) + + @pytest.mark.parametrize("fixture_name,category", [ ("01_clean", None), ("02_hardcoded_token", "secret"), ("03_async_leak", "async"), ("04_db_transaction_leak", "database"), ("05_missing_test", "tests"), @@ -252,6 +305,25 @@ def model_dump(self): assert host_path not in json.dumps(context.agent_context.metadata["code_review_model_pending"]["input"]) +def test_model_audit_seeds_trusted_task_output_configuration(): + from types import SimpleNamespace + from examples.skills_code_review_agent.agent.filter import before_model_audit + + agent = SimpleNamespace( + model=SimpleNamespace(_model_name="test-model"), + _code_review_workspace_inputs=[], + _code_review_task_id="cr-trusted-task", + _code_review_output_dir="C:/trusted/output", + ) + context = SimpleNamespace(agent=agent, agent_context=SimpleNamespace(metadata={})) + request = SimpleNamespace(contents=[]) + + before_model_audit(context, request) + + assert context.agent_context.metadata["code_review_task_id"] == "cr-trusted-task" + assert context.agent_context.metadata["code_review_output_dir"] == "C:/trusted/output" + + async def test_skill_run_filter_rejects_interactive_stdin(): from trpc_agent_sdk.context import AgentContext from trpc_agent_sdk.filter import FilterResult