diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..c232f3d --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,46 @@ +--- +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# Codacy reads this file from the default branch for every analysis, and a pull request's +# changes to it apply to that pull request's analysis too +# (https://docs.codacy.com/repositories-configure/codacy-configuration-file/, read 2026-11-01). +# Once it exists, ignored-files settings in the Codacy UI no longer apply: every exclusion +# lives here, one narrow pattern per judged false positive, each naming its verdict. +engines: + markdownlint: + exclude_paths: + # MD024 (no duplicate headings): this repository lints Markdown with + # tools/markdownlint/markdownlint-cli2.yaml, where MD024 is siblings_only: true, so a + # heading repeated under a different parent is the declared style. The adopted reference + # guides repeat section names such as checklists and examples under each topic (28 + # findings on pull request 27). The repository's own Markdown gate still lints these files. + # Verdict: FACT peritia-decided-codacy-trial-verdict-27-md024. + - "skills/*/reference/**" + agentlinter: + exclude_paths: + # skill-safety dangerous-commands: the XSS prevention guide names eval(), new Function() + # and 'unsafe-eval' as patterns a reviewer refuses (5 findings on pull request 27); the + # guide runs nothing. Verdict: FACT peritia-decided-codacy-trial-verdict-27-xss-eval. + - "skills/code-review-skill/reference/cross-cutting/xss-prevention.md" + opengrep: + exclude_paths: + # javascript path traversal (non-literal fs filename): a pattern rule that fires on every + # variable path. normalize.mjs resolves each path and refuses anything outside the root + # (exit 2); test_normalize.mjs proves both sides and runs in make test (17 findings on + # pull request 27). Verdict: FACT peritia-decided-codacy-trial-verdict-27-path-traversal. + - "tools/markdownlint/normalize.mjs" + - "tools/markdownlint/test_normalize.mjs" + # python dangerous subprocess use: the adoption tests start node and git with a fixed + # argument list, by full path from shutil.which, on test input (2 findings). + # Verdict: FACT peritia-decided-codacy-trial-verdict-27-test-subprocess. + - "tools/test_adopt_skills.py" + bandit: + exclude_paths: + # B603 subprocess without shell: same calls as the opengrep entry above (2 findings). + # Verdict: FACT peritia-decided-codacy-trial-verdict-27-test-subprocess. + - "tools/test_adopt_skills.py" + prospector: + exclude_paths: + # Prospector's bandit plugin repeats the B603 finding above (2 findings); Ruff still lints + # this file. Verdict: FACT peritia-decided-codacy-trial-verdict-27-test-subprocess. + - "tools/test_adopt_skills.py" diff --git a/CREDITS.md b/CREDITS.md index bc9a026..640b618 100644 --- a/CREDITS.md +++ b/CREDITS.md @@ -26,3 +26,4 @@ names the exact path and commit. | | MIT | `applicationinsights-web-ts`, `azure-advisor-actionability`, `azure-eventhub-java`, `azure-local`, `azure-local-multi-rack`, `azure-storage-blob-java`, `wiki-changelog`, `wiki-qa` | | | Apache-2.0, CC0-1.0, MIT, MIT-0 | `aas-compose-stack`, `aas-discover`, `aas-review-stack`, `agenttrace-session-audit`, `ai-logo-maker`, `ai-multilingual-dubbing`, `ai-photo-restyler`, `ai-podcast-voiceover`, `api-contract-testing`, `artifact-yylo`, `atlas-cloud-media`, `audit-logging`, `aws-cloudtrail`, `azure-monitor-audit`, `beatra`, `beatra-ai-video-studio`, `before-you-build`, `brain-to-docs`, `browser-extension-launch`, `business-continuity`, `buywhere-product-catalog`, `codex-subagent`, `compile-knowledge`, `deep-research-framework`, `define-my-content-pillars`, `delegating-to-agents`, `diagnose-android-overheating`, `ditto`, `draft-my-comments`, `dsh-deepread`, `ecommerce-listing-image-set`, `equibles`, `famulor-skill`, `feature-tracking`, `fedramp-compliance`, `five-axis-code-review`, `git-commit-message`, `glasser`, `graceful-shutdown`, `hipaa-compliance-sickn33`, `idea-autopsy`, `incident-management`, `invokely-statistics`, `iso27001-compliance`, `jet-browser`, `laravel-development-workflow`, `markstream-angular`, `markstream-migration`, `markstream-nuxt`, `markstream-react`, `markstream-svelte`, `markstream-vue`, `markstream-vue2`, `markstream-vue2-cli`, `markstream-vue2-vite`, `md2video-audio`, `mdpr-skill`, `meeting-notes`, `muapi-media`, `music-generation-studio`, `nexrad-mosaic-access`, `nexrad-product-access`, `noaa-radar-satellite-fetching`, `openclaw-deployment-hardening`, `optim-agent`, `orchestrate`, `outreachagent`, `parallel-search-mcp`, `pci-dss-compliance`, `pi-web-search`, `plan-ledger-tasks-yylo`, `poster-design-studio`, `pre-release-review`, `pre-ship-gate`, `product-photo-studio`, `production-runtime-certification`, `radar-satellite-analysis`, `repo-maintainer`, `repurpose-into-a-post`, `research-prompt`, `resumable-implementation-contracts`, `same-idea-both-platforms`, `security-automation`, `seo-drift`, `serply-search-mcp`, `setup-help`, `shopify-review-triage`, `skeleton-first`, `skill-porter`, `skill-security-audit`, `soc2-compliance`, `spec-driven-loop`, `sshepherd`, `suno-lyrics-to-song`, `taisly-social-media-posting`, `talking-avatar-video`, `tech-writing-proofread`, `tokenleak`, `trading-ledger`, `tree-ring-memory`, `turn-this-into-a-thread`, `understand-project-yylo`, `unslop`, `unsloth-finetuning`, `upstash-redis`, `video-router`, `viral-video-teardown-remake`, `voice-cloning-studio`, `voiceover-narration-studio`, `warehouse`, `weather-data-lifecycle-management`, `weather-data-reproducibility`, `weather-model-data-fetching`, `weather-model-run-discovery`, `weather-observation-fetching`, `weather-pipeline-performance-diagnosis`, `what-could-break`, `why-did-this-post-flop`, `wiki-builder`, `wiki-yylo`, `workflow-yylo`, `write-in-my-voice`, `youtube-transcript-skills` | | | MIT | `pr-labeling`, `triage` | +| | MIT | `code-review-skill` | diff --git a/Makefile b/Makefile index fffd935..17aecd5 100644 --- a/Makefile +++ b/Makefile @@ -35,6 +35,7 @@ tools: # carries a licence (REUSE). The selftest plants one defect per rule and must see each refused. test: tools build @$(BIN)/python -B tools/check_skills.py selftest + @node --test tools/markdownlint/test_normalize.mjs @python3 -B tools/install_user_skills.py selftest @$(BIN)/python -B tools/test_census.py @$(BIN)/python -B tools/test_classify.py diff --git a/REUSE.toml b/REUSE.toml index d2e8ac1..b726c12 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -1668,3 +1668,9 @@ path = ["skills/sshepherd/**"] precedence = "override" SPDX-FileCopyrightText = "2026 sickn33 and contributors" SPDX-License-Identifier = "MIT" + +[[annotations]] +path = ["skills/code-review-skill/**"] +precedence = "override" +SPDX-FileCopyrightText = "2026 awesome-skills and contributors" +SPDX-License-Identifier = "MIT" diff --git a/requirements/tools.in b/requirements/tools.in index a790a39..65add83 100644 --- a/requirements/tools.in +++ b/requirements/tools.in @@ -1,3 +1,4 @@ # SPDX-FileCopyrightText: 2026 Lusoris # SPDX-License-Identifier: EUPL-1.2 pyyaml +markdown-it-py diff --git a/requirements/tools.txt b/requirements/tools.txt index 7673df4..bf72b68 100644 --- a/requirements/tools.txt +++ b/requirements/tools.txt @@ -1,5 +1,13 @@ # This file was autogenerated by uv via the following command: # uv pip compile --universal --python-version=3.12 --generate-hashes --exclude-newer=P3D requirements/tools.in --output-file=requirements/tools.txt +markdown-it-py==4.2.0 \ + --hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \ + --hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a + # via -r requirements/tools.in +mdurl==0.1.2 \ + --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ + --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba + # via markdown-it-py pyyaml==6.0.3 \ --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ diff --git a/skills/INDEX.tsv b/skills/INDEX.tsv index b9f1c4d..e515e74 100644 --- a/skills/INDEX.tsv +++ b/skills/INDEX.tsv @@ -40,7 +40,7 @@ bgpt-paper-search K-Dense-AI/scientific-agent-skills brain-to-docs sickn33/agentic-awesome-skills docs-writing;planning-fleet-mgmt other 126 brand-voice affaan-m/ecc brendangregg-use-tsa thecsdoctor/brendangregg-use-tsa-skill -brooks-review hyhmrright/brooks-lint +brooks-review hyhmrright/brooks-lint 106 browser-extension-launch sickn33/agentic-awesome-skills business-continuity sickn33/agentic-awesome-skills buywhere-product-catalog sickn33/agentic-awesome-skills @@ -48,6 +48,7 @@ carrier-relationship-management affaan-m/ecc research;planning-fleet-mgmt other cellprofiler K-Dense-AI/scientific-agent-skills change-impact affaan-m/ecc cisco-ios-patterns affaan-m/ecc review-audit kubernetes-hosts-networking 47 +code-review-skill awesome-skills/code-review-skill review-audit other 106 codebase-recon yujiachen-y/codebase-recon-skill codex-subagent sickn33/agentic-awesome-skills coding;review-audit;testing other 110 compile-knowledge sickn33/agentic-awesome-skills docs-writing;research other 48 diff --git a/skills/code-review-skill/LICENSE b/skills/code-review-skill/LICENSE new file mode 100644 index 0000000..636215f --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/ORIGIN.md b/skills/code-review-skill/ORIGIN.md new file mode 100644 index 0000000..64ebf9e --- /dev/null +++ b/skills/code-review-skill/ORIGIN.md @@ -0,0 +1,5 @@ +upstream: +path: . +commit: 4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb +licence: MIT +changes: code fences without a language marked text; omitted repository furniture (.gitignore, .nojekyll, CONTRIBUTING.md, README.md, index.en.html, index.html); omitted agent workbench files (.hive/PROTOCOL.md, .hive/memory.md, .hive/tasks.md); normalized Markdown whitespace (SKILL.md (MD032, MD060), assets/review-checklist.md (MD060), reference/angular.md (MD060), reference/c.md (MD022, MD032, MD060), reference/code-quality-universal.md (MD032), reference/code-review-best-practices.md (MD022, MD032, MD060), reference/common-bugs-checklist.md (MD022, MD032), reference/cpp.md (MD022, MD032, MD060), reference/cross-cutting/async-concurrency-patterns.md (MD022, MD032, MD060), reference/cross-cutting/n-plus-one-queries.md (MD022, MD032, MD060), reference/cross-cutting/sql-injection-prevention.md (MD047, MD060), reference/cross-cutting/xss-prevention.md (MD032, MD047, MD060), reference/css-less-sass.md (MD060), reference/dart.md (MD022, MD032), reference/fastapi.md (MD060), reference/go.md (MD022, MD032), reference/java.md (MD022, MD032), reference/java8.md (MD022, MD032, MD060), reference/performance-review-guide.md (MD032, MD060), reference/python.md (MD022, MD032), reference/qt.md (MD022, MD032, MD060), reference/react.md (MD060), reference/ruby.md (MD022, MD032), reference/swift.md (MD022, MD032), reference/typescript.md (MD022, MD032), reference/vue.md (MD022, MD032), reference/zig.md (MD022, MD032)); applied review patches (SKILL.md (PRRT_kwDOVLPOVM6yVpUJ), reference/c.md (PRRT_kwDOVLPOVM6yVs6w, PRRT_kwDOVLPOVM6yVs62, PRRT_kwDOVLPOVM6yVs6y, PRRT_kwDOVLPOVM6yVs65), reference/performance-review-guide.md (PRRT_kwDOVLPOVM6yVs6_), reference/react.md (PRRT_kwDOVLPOVM6yVs7C, PRRT_kwDOVLPOVM6yWCLe, PRRT_kwDOVLPOVM6yWQoT, PRRT_kwDOVLPOVM6yWQoi), reference/svelte.md (PRRT_kwDOVLPOVM6yVpT-, PRRT_kwDOVLPOVM6yVs7I, PRRT_kwDOVLPOVM6yWCLh, PRRT_kwDOVLPOVM6yWQob), reference/typescript.md (PRRT_kwDOVLPOVM6yVs7Q, PRRT_kwDOVLPOVM6yWCLj), reference/vue.md (PRRT_kwDOVLPOVM6yVs7T), reference/zig.md (PRRT_kwDOVLPOVM6yVs7X)); omitted files refused by markdown gate (assets/pr-review-template.md (MD036), reference/architecture-review-guide.md (MD036), reference/cross-cutting/error-handling-principles.md (MD003), reference/kotlin.md (MD026), reference/rust.md (MD036), reference/security-review-guide.md (MD036)); added provenance and licence attribution diff --git a/skills/code-review-skill/SKILL.md b/skills/code-review-skill/SKILL.md new file mode 100644 index 0000000..3af7352 --- /dev/null +++ b/skills/code-review-skill/SKILL.md @@ -0,0 +1,240 @@ +--- +name: code-review-skill +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, + Dart, Flutter, 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 + - WebFetch +author: awesome-skills +--- + +# Code Review Skill + +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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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 | +| **Dart / Flutter** | [Dart Guide](reference/dart.md) | Widget rebuilds, const constructors, null safety, isolates, async in build, Riverpod/Bloc, platform channels, keys, disposal | +| **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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/assets/pr-review-template.md) - PR 审查评论模板 +- [Review Checklist](assets/review-checklist.md) - 快速参考清单 diff --git a/skills/code-review-skill/assets/review-checklist.md b/skills/code-review-skill/assets/review-checklist.md new file mode 100644 index 0000000..f7d1768 --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/reference/angular.md b/skills/code-review-skill/reference/angular.md new file mode 100644 index 0000000..c9cdd68 --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/reference/c.md b/skills/code-review-skill/reference/c.md new file mode 100644 index 0000000..8e9555d --- /dev/null +++ b/skills/code-review-skill/reference/c.md @@ -0,0 +1,900 @@ +# 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) +_Static_assert(sizeof(uint32_t) == sizeof(float), "size mismatch"); +uint32_t i; +memcpy(&i, &f, sizeof(i)); + +// ✅ Also acceptable in C11: +union { float f; uint32_t i; } u; +u.f = 3.14f; +uint32_t 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 +int handle(struct Msg *m, size_t total_size) { + if (total_size < sizeof(struct Msg) || 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); + return 0; +} +``` + +### 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: +#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L + #define TYPEOF(x) typeof(x) +#elif defined(__GNUC__) || defined(__clang__) + #define TYPEOF(x) __typeof__(x) +#else + #error "TYPEOF requires C23 or a GNU-compatible compiler" +#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/skills/code-review-skill/reference/code-quality-universal.md b/skills/code-review-skill/reference/code-quality-universal.md new file mode 100644 index 0000000..c600062 --- /dev/null +++ b/skills/code-review-skill/reference/code-quality-universal.md @@ -0,0 +1,498 @@ +# 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/skills/code-review-skill/reference/code-review-best-practices.md b/skills/code-review-skill/reference/code-review-best-practices.md new file mode 100644 index 0000000..eab0f22 --- /dev/null +++ b/skills/code-review-skill/reference/code-review-best-practices.md @@ -0,0 +1,150 @@ +# 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/skills/code-review-skill/reference/common-bugs-checklist.md b/skills/code-review-skill/reference/common-bugs-checklist.md new file mode 100644 index 0000000..ebb0d3c --- /dev/null +++ b/skills/code-review-skill/reference/common-bugs-checklist.md @@ -0,0 +1,312 @@ +# 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) + +## Dart / Flutter + +- [ ] Missing `const` on static widget subtrees, or `_buildFoo()` helpers instead of extracted widgets +- [ ] `!` / `as` / unconstrained `late` used to silence null safety +- [ ] Heavy `jsonDecode` / image / crypto work on the UI isolate +- [ ] `Future` or `Stream` created inside `build` (new instance every rebuild) +- [ ] `setState` / `BuildContext` used after `await` without `mounted` / `context.mounted` +- [ ] `ref.watch` / `context.watch` in a callback; `read` used in `build` (or the reverse) +- [ ] `BlocProvider.value` / `ChangeNotifierProvider.value` given a new instance constructed in `build` (use `create`; `value` does not dispose) +- [ ] `BlocProvider(create: ...)` captures a stale `id` — missing `ValueKey(id)` remount or `didUpdateWidget` reload (`UniqueKey()` remounts every rebuild) +- [ ] Platform channel `invokeMethod` without `PlatformException` handling +- [ ] List children holding `State` missing a stable `ValueKey` (or using `UniqueKey()` in `build`) +- [ ] `TextEditingController` / `AnimationController` / `StreamSubscription` not disposed + +**Full guide:** [Dart / Flutter Review Guide](dart.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/skills/code-review-skill/reference/cpp.md b/skills/code-review-skill/reference/cpp.md new file mode 100644 index 0000000..5758415 --- /dev/null +++ b/skills/code-review-skill/reference/cpp.md @@ -0,0 +1,899 @@ +# 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/skills/code-review-skill/reference/cross-cutting/async-concurrency-patterns.md b/skills/code-review-skill/reference/cross-cutting/async-concurrency-patterns.md new file mode 100644 index 0000000..bd45cde --- /dev/null +++ b/skills/code-review-skill/reference/cross-cutting/async-concurrency-patterns.md @@ -0,0 +1,520 @@ +# 异步与并发模式 — 跨语言通用指南 + +> 本文档覆盖并发模型对比、常见陷阱、跨语言最佳实践和结构化并发模式。 + +## 目录 + +- [并发模型对比](#并发模型对比) +- [常见陷阱](#常见陷阱) +- [最佳实践](#最佳实践) +- [跨语言代码示例](#跨语言代码示例) +- [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 线程 + 共享内存 | 真正并行 | 锁管理复杂、死锁风险 | + +### 何时选择什么 + +```text +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) + +多个并发任务读写共享状态,结果依赖执行顺序。 + +```text +// 通用伪代码 +counter = 0 + +task1: counter += 1 // 读 counter=0, 写 counter=1 +task2: counter += 1 // 读 counter=0, 写 counter=1 +// 期望 counter=2, 实际 counter=1 +``` + +**解决方案**:互斥锁、原子操作、或将共享状态封装在 Actor 中。 + +### 陷阱 2: 死锁(Deadlock) + +两个或多个任务互相等待对方持有的锁。 + +```text +task1: lock(A); lock(B); // 持有 A,等待 B +task2: lock(B); lock(A); // 持有 B,等待 A +// 两者永远等待 +``` + +**解决方案**: + +- 一致的锁获取顺序 +- 超时锁(tryLock with timeout) +- 避免嵌套锁 + +### 陷阱 3: Starvation + +低优先级任务永远得不到执行机会。 + +```text +// 高优先级任务持续到达,低优先级任务永远排队 +``` + +**解决方案**:公平锁、任务优先级队列、限制并发数。 + +### 陷阱 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/skills/code-review-skill/reference/cross-cutting/n-plus-one-queries.md b/skills/code-review-skill/reference/cross-cutting/n-plus-one-queries.md new file mode 100644 index 0000000..b4356c3 --- /dev/null +++ b/skills/code-review-skill/reference/cross-cutting/n-plus-one-queries.md @@ -0,0 +1,312 @@ +# N+1 查询问题 — 跨语言通用指南 + +> N+1 查询是 ORM 和数据库访问层最常见的性能反模式。本文档覆盖问题定义、检测方法、通用解决方案和跨语言代码示例。 + +## 目录 + +- [问题定义](#问题定义) +- [性能影响](#性能影响) +- [检测方法](#检测方法) +- [通用解决方案](#通用解决方案) +- [语言特定实现](#语言特定实现) +- [Review Checklist](#review-checklist) + +--- + +## 问题定义 + +N+1 查询是指:**1 次查询获取 N 条记录,随后在循环中触发 N 次额外查询**来获取关联数据。 + +```text +请求流程: + 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,合并为一次批量查询。 + +```text +// 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/skills/code-review-skill/reference/cross-cutting/sql-injection-prevention.md b/skills/code-review-skill/reference/cross-cutting/sql-injection-prevention.md new file mode 100644 index 0000000..926977e --- /dev/null +++ b/skills/code-review-skill/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 diff --git a/skills/code-review-skill/reference/cross-cutting/xss-prevention.md b/skills/code-review-skill/reference/cross-cutting/xss-prevention.md new file mode 100644 index 0000000..5bcf899 --- /dev/null +++ b/skills/code-review-skill/reference/cross-cutting/xss-prevention.md @@ -0,0 +1,265 @@ +# 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`) diff --git a/skills/code-review-skill/reference/csharp.md b/skills/code-review-skill/reference/csharp.md new file mode 100644 index 0000000..1e04393 --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/reference/css-less-sass.md b/skills/code-review-skill/reference/css-less-sass.md new file mode 100644 index 0000000..8b403db --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/reference/dart.md b/skills/code-review-skill/reference/dart.md new file mode 100644 index 0000000..718df17 --- /dev/null +++ b/skills/code-review-skill/reference/dart.md @@ -0,0 +1,685 @@ +# Dart / Flutter Code Review Guide + +> Code review guidelines for Dart 3 and Flutter focusing on widget rebuilds, const constructors, null safety, isolates, async in `build`, Riverpod/Bloc state pitfalls, platform channels, keys, disposal, and testability. Not a language tutorial. + +## Table of Contents + +- [Widget Rebuilds & Const Constructors](#widget-rebuilds--const-constructors) +- [Null Safety & `late`](#null-safety--late) +- [Isolates](#isolates) +- [Async in `build`](#async-in-build) +- [State Management: Riverpod & Bloc](#state-management-riverpod--bloc) +- [Platform Channels](#platform-channels) +- [Keys](#keys) +- [Disposal & Lifecycle](#disposal--lifecycle) +- [Testability](#testability) +- [Review Checklist](#review-checklist) +- [References](#references) + +--- + +## Widget Rebuilds & Const Constructors + +Flutter rebuilds are cheap only when the Element tree can skip work. A review should flag widgets that allocate new subtrees, closures, or Theme lookups on every frame when a `const` constructor or an extracted widget would keep the child Element. + +### Prefer `const` constructors where the subtree is static + +```dart +// ❌ Bad: every parent rebuild allocates a new Text and Icon. +Widget build(BuildContext context) { + return Row( + children: [ + Icon(Icons.star), + Text('Favorites'), + ], + ); +} + +// ✅ Good: const widgets can be canonicalized and skipped on rebuild. +Widget build(BuildContext context) { + return const Row( + children: [ + Icon(Icons.star), + Text('Favorites'), + ], + ); +} +``` + +Review questions: + +- Are leaf widgets (`Text`, `Icon`, `SizedBox`, `Padding` with constant insets) `const`? +- Does a missing `const` on a parent block `const` on an entire subtree? +- Is `const` omitted because a single non-const argument (color from `Theme.of`, a closure) poisons the constructor? + +### Extract widgets, not helper methods, when state should be isolated + +A private method that returns a `Widget` is inlined into the caller's `build`. The returned widgets have no Element identity of their own, so they rebuild whenever the caller rebuilds. + +```dart +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key, required this.user}); + final User user; + + // ❌ Bad: `_header()` rebuilds with ProfilePage even when `user` is unchanged. + Widget _header() => Header(title: user.name); + + @override + Widget build(BuildContext context) { + return Column(children: [_header(), const Feed()]); + } +} + +// ✅ Good: a separate widget gets its own Element; Feed stays const. +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key, required this.user}); + final User user; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Header(title: user.name), + const Feed(), + ], + ); + } +} +``` + +### Do not call `setState` for derived values + +```dart +// ❌ Bad: derived data stored in State, forcing a rebuild to recompute a getter. +class CartBadge extends StatefulWidget { /* ... */ } + +class _CartBadgeState extends State { + int _count = 0; + + void didUpdateWidget(CartBadge old) { + super.didUpdateWidget(old); + setState(() => _count = widget.items.length); + } +} + +// ✅ Good: derive in build; setState only when the source data changes. +class CartBadge extends StatelessWidget { + const CartBadge({super.key, required this.items}); + final List items; + + @override + Widget build(BuildContext context) { + return Text('${items.length}'); + } +} +``` + +### Rebuild scope + +- `setState` on a high `State` object rebuilds that subtree. Prefer lifting only the state that must be shared, and wrapping expensive children in `RepaintBoundary` or extracting them. +- `ListView(children: [...])` builds every child. Prefer `ListView.builder` / `SliverList` for long lists. +- Passing a newly allocated `List`/`Map` or a new callback instance into a child that is otherwise `const`-eligible defeats child skip. Capture stable callbacks (`void Function()` stored on State) or use `Widget.canUpdate` identity via `const` / extracted widgets. + +For general UI performance patterns see [Performance Review Guide](performance-review-guide.md). + +--- + +## Null Safety & `late` + +Dart's null safety is only as strong as the holes the code leaves (`!`, `late`, `as`). Treat those as review signals, not as style. + +### Avoid `!` on values the compiler cannot prove + +```dart +// ❌ Bad: bang operator hides a possible null and crashes at runtime. +void openProfile(User? user) { + Navigator.pushNamed(context, '/user/${user!.id}'); +} + +// ✅ Good: promote with a local or return early. +void openProfile(User? user) { + final id = user?.id; + if (id == null) return; + Navigator.pushNamed(context, '/user/$id'); +} +``` + +### `late` is a delayed crash, not a type + +`late` without an initializer throws `LateInitializationError` on first read. `late final` with an initializer is lazy and is the usual legitimate case (expensive, once). + +```dart +class Session { + // ❌ Bad: `late` field is read from another method with no constructor guarantee. + late String token; + + bool get isReady => token.isNotEmpty; +} + +class Session { + // ✅ Good: nullable until assigned; API makes absence visible. + String? token; + + bool get isReady => token != null && token!.isNotEmpty; +} + +class ThemeCache { + // ✅ Good: lazy `late final` with initializer; first read computes once. + late final Map _colors = _loadColors(); +} +``` + +Review questions: + +- Is `late` used because the author did not want `?` / a constructor argument? +- Is a `late` field assigned in `initState` but read from a listener that can fire earlier (animation, platform callback)? +- Are `as Foo` casts used where a pattern (`if (x case final Foo foo)`) or `is` promotion would fail loudly and locally? + +### Fields do not promote + +```dart +class ProfileTile extends StatelessWidget { + const ProfileTile({super.key, required this.user}); + final User? user; + + @override + Widget build(BuildContext context) { + // ❌ Bad: field promotion does not apply; this is a lint (`unchecked_use_of_nullable_value`). + // return Text(user.name); + + // ✅ Good: promote a local. + final user = this.user; + if (user == null) return const SizedBox.shrink(); + return Text(user.name); + } +} +``` + +Null-related crashes are still crashes. See [Error Handling Guide](cross-cutting/error-handling-principles.md) for fail-fast vs. swallowing. + +--- + +## Isolates + +> 📖 Cross-language concurrency patterns: [Async & Concurrency Guide](cross-cutting/async-concurrency-patterns.md) + +Flutter's UI isolate must stay free of heavy JSON, image, and crypto work. Dart isolates do not share memory; messages must be sendable. + +### Do not block the UI isolate + +```dart +// ❌ Bad: large JSON decode on the UI isolate janks frames. +final users = (jsonDecode(raw) as List) + .map((e) => User.fromJson(e as Map)) + .toList(); + +// ✅ Good: `Isolate.run` (Dart 2.19+) / `compute` for one-shot work. +final users = await Isolate.run(() { + return (jsonDecode(raw) as List) + .map((e) => User.fromJson(e as Map)) + .toList(); +}); +``` + +### Message restrictions + +```dart +// ❌ Bad: closures, ReceivePorts, and many plugin types are not sendable. +await Isolate.run(() => widget.onParsed()); + +// ✅ Good: send plain data; map back to UI types on the main isolate. +final dto = await Isolate.run(() => parseReport(bytes)); +setState(() => report = Report.fromDto(dto)); +``` + +Review questions: + +- Is `jsonDecode` / image decode / encryption of non-trivial payloads on the UI isolate? +- Does the isolate callback capture `BuildContext`, `State`, or plugin controllers? +- If a long-lived isolate is spawned, is `kill` / `pause` paired with widget disposal? +- Are isolate failures surfaced, or does `await Isolate.run` lack error handling? + +`compute` from `flutter/foundation.dart` is a thin wrapper; prefer `Isolate.run` in Dart-only code. Neither is a thread pool — spawning per tiny call is more expensive than doing the work inline. + +--- + +## Async in `build` + +`build` must stay synchronous and side-effect free. Futures created in `build` restart on every rebuild. + +### Do not start I/O or create a new `Future` in `build` + +```dart +// ❌ Bad: new Future every rebuild; the previous request is abandoned. +Widget build(BuildContext context) { + return FutureBuilder( + future: api.fetchProfile(id), + builder: (context, snap) { /* ... */ }, + ); +} + +// ✅ Good: cache the Future in State; refetch when `id` changes. +class ProfileView extends StatefulWidget { + const ProfileView({super.key, required this.id}); + final String id; + @override + State createState() => _ProfileViewState(); +} + +class _ProfileViewState extends State { + late Future _future = api.fetchProfile(widget.id); + + @override + void didUpdateWidget(ProfileView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.id != widget.id) { + _future = api.fetchProfile(widget.id); + } + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _future, + builder: (context, snap) { /* ... */ }, + ); + } +} +``` + +Keying `ProfileView` with `ValueKey(id)` also remounts State and starts a new fetch. Either approach is fine; a cached `late final` future with no `didUpdateWidget` goes stale when `id` changes. + +The same rule applies to `StreamBuilder`: do not open a new stream in `build`. Create it once (`initState`, a provider, a Bloc) and cancel on dispose. + +### `setState` after `await` must check `mounted` + +```dart +Future _load() async { + final data = await api.fetch(); + // ❌ Bad: widget may have been disposed during the await. + setState(() => _data = data); +} + +Future _load() async { + final data = await api.fetch(); + if (!mounted) return; + setState(() => _data = data); +} +``` + +Use `context.mounted` (Flutter 3.7+) before using `BuildContext` after an async gap (`Navigator`, `ScaffoldMessenger`, `showDialog`). The `use_build_context_synchronously` lint exists because this is a crash class, not a style issue. + +### Do not mark `build` `async` + +```dart +// ❌ Bad: build cannot be async; this does not compile, or a helper is abused. +Widget build(BuildContext context) async { + final user = await repo.user(); + return Text(user.name); +} + +// ✅ Good: hold async state in State / a notifier; build only reads it. +``` + +Fire-and-forget `unawaited(load())` inside `build` is the same bug with extra steps. + +--- + +## State Management: Riverpod & Bloc + +These examples use Riverpod and Bloc because they dominate Flutter reviews. The same pitfalls apply to Provider, GetX, Signals, and raw `InheritedWidget`: rebuild scope, side effects in build, and lifetime. + +### Riverpod: `watch` vs `read`, and rebuild scope + +```dart +class CartButton extends ConsumerWidget { + const CartButton({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + // ❌ Bad: watches the whole cart; button rebuilds on every item mutation. + final cart = ref.watch(cartProvider); + + // ✅ Good: watch only the field this widget needs. + final count = ref.watch(cartProvider.select((c) => c.items.length)); + return Badge(label: Text('$count')); + } +} +``` + +```dart +void onPressed() { + // ❌ Bad: `watch` in a callback / listener; it is not a rebuild subscription. + // ref.watch(cartProvider).add(item); + + // ✅ Good: `read` for one-shot, `watch`/`select` only in `build` / `build` of a listen widget. + ref.read(cartProvider.notifier).add(item); +} +``` + +Review questions: + +- Is `ref.watch` used in `initState`, a gesture handler, or a `Provider`'s constructor? +- Does a widget `watch` a large object when `select` / a derived provider would do? +- Are providers that capture `ref` after `dispose` (`ref.onDispose` missing for controllers, streams)? +- Is `autoDispose` omitted on screen-scoped providers so they leak after pop? + +### Bloc: `create` vs `value`, `buildWhen`, closed cubits + +`BlocProvider(create: ...)` inside `build` is the documented pattern: `create` runs once per Element, not on every rebuild. `updateShouldNotify` does not control `create`. The bug is constructing a new bloc instance in `build` and handing it to `BlocProvider.value` (no ownership, no dispose, new object every rebuild). Because `create` captures `id` once, a parent that later passes a new `id` will not reload unless the Element remounts or you dispatch an update. + +```dart +// ❌ Bad: new ProfileBloc() every rebuild; `value` does not dispose it. +Widget build(BuildContext context) { + return BlocProvider.value( + value: ProfileBloc()..add(ProfileStarted(id)), + child: const ProfileBody(), + ); +} + +// ❌ Bad: UniqueKey remounts every rebuild — `create` runs again each time. +Widget build(BuildContext context) { + return BlocProvider( + key: UniqueKey(), + create: (_) => ProfileBloc()..add(ProfileStarted(id)), + child: const ProfileBody(), + ); +} + +// ✅ Good: `create` owns one instance; ValueKey(id) remounts only when id changes. +class ProfilePage extends StatelessWidget { + const ProfilePage({super.key, required this.id}); + final String id; + + @override + Widget build(BuildContext context) { + return BlocProvider( + key: ValueKey(id), + create: (_) => ProfileBloc()..add(ProfileStarted(id)), + child: const ProfileBody(), + ); + } +} +``` + +Alternatively, keep the same bloc and in `didUpdateWidget` dispatch `ProfileRequested(id)` when `id` changes — do not construct a replacement `ProfileBloc()` there. + +```dart +// ❌ Bad: listener work inside builder; also rebuilds on every state. +BlocBuilder( + builder: (context, state) { + if (state is ProfileFailure) { + WidgetsBinding.instance.addPostFrameCallback((_) { + ScaffoldMessenger.of(context).showSnackBar(/* ... */); + }); + } + return ProfileBody(state: state); + }, +) + +// ✅ Good: side effects in BlocListener; rebuilds filtered with buildWhen. +BlocConsumer( + listenWhen: (p, c) => c is ProfileFailure && p is! ProfileFailure, + listener: (context, state) { + final message = (state as ProfileFailure).message; + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message))); + }, + buildWhen: (p, c) => p.user != c.user || p.loading != c.loading, + builder: (context, state) => ProfileBody(state: state), +) +``` + +Review questions: + +- Is a `Cubit`/`Bloc` closed (`BlocProvider` does this if `lazy`/`create` owns it; a manually constructed bloc must `close`)? +- Are events dispatched from `build`? +- Is `Equatable` / `==` missing on state so every `emit` rebuilds even when fields did not change? +- Is business logic in the widget (`context.read().add` mixed with parsing, I/O) instead of the bloc? + +Do not treat GetX `Obx` / `GetBuilder` as exempt: the same "who owns the controller, who rebuilds, who disposes" questions apply. + +--- + +## Platform Channels + +Method channels, event channels, and Pigeon are a trust and lifetime boundary. Failures look like "missing plugin" at runtime, not at compile time. + +```dart +// ❌ Bad: untyped maps, ignored errors, UI isolate blocked on a heavy native call. +final result = await MethodChannel('app.wallet').invokeMethod('sign', {'tx': raw}); +final signature = result as String; + +// ✅ Good: typed API (Pigeon or a wrapper), errors handled, no UI-thread assumption. +try { + final signature = await walletHost.sign(SignRequest(tx: raw)); + if (!mounted) return; + onSigned(signature); +} on PlatformException catch (e) { + onSignFailed(e.code, e.message); +} +``` + +Review questions: + +- Is the channel name a collision-prone generic string (`app`, `native`) without a domain prefix? +- Are Android/iOS implementations of the same method covering all argument types and null? +- Does the Dart side assume the plugin is registered (tests, background isolates, and add-to-app engines often are not)? +- Is binary data passed as `List` copies instead of `Uint8List` / Pigeon bytes? +- Are EventChannel subscriptions cancelled in `dispose`? +- Does native code do disk/network work on the platform main thread? + +Platform data is untrusted input: validate it the same way you would a network payload. See [Security Review Guide](security-review-guide.md). + +--- + +## Keys + +Keys preserve `State` / `Element` identity across rebuilds. Wrong keys cause state to attach to the wrong row; missing keys cause state to reset when the list reorders. + +```dart +// ❌ Bad: no keys; after delete/reorder, TextField state sticks to the index. +ListView( + children: items.map((item) => TodoRow(item: item)).toList(), +) + +// ❌ Bad: UniqueKey() in build: every rebuild is a new identity; state is always reset. +TodoRow(key: UniqueKey(), item: item) + +// ✅ Good: a stable id from the model. +ListView( + children: [ + for (final item in items) + TodoRow(key: ValueKey(item.id), item: item), + ], +) +``` + +```dart +// ❌ Bad: GlobalKey created in `build` — a new key every rebuild, plus GlobalKey cost. +Widget build(BuildContext context) { + final key = GlobalKey(); + return Form(key: key, child: /* ... */); +} + +// ✅ Good: GlobalKey is rare; hold it on State when FormState is actually needed. +class _EditorState extends State { + final _formKey = GlobalKey(); + @override + Widget build(BuildContext context) => Form(key: _formKey, child: /* ... */); +} +``` + +Review questions: + +- Do reorderable / dismissible / animated lists use `ValueKey` / `ObjectKey` from a stable model id? +- Is `GlobalKey` used to reach into a child's `State` when a callback / `ValueNotifier` would do? +- Are keys on widgets whose `runtimeType` already uniquely identifies them (usually unnecessary)? + +--- + +## Disposal & Lifecycle + +Anything with `addListener`, a subscription, a ticker, or a native peer needs a matching `dispose`. Flutter will not save you. + +```dart +class SearchField extends StatefulWidget { + const SearchField({super.key}); + @override + State createState() => _SearchFieldState(); +} + +class _SearchFieldState extends State with SingleTickerProviderStateMixin { + late final TextEditingController _controller; + late final AnimationController _anim; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(); + _anim = AnimationController(vsync: this, duration: const Duration(milliseconds: 200)); + _sub = queryStream.listen((q) { + if (mounted) _controller.text = q.text; + }); + } + + @override + void dispose() { + _sub?.cancel(); + _anim.dispose(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => TextField(controller: _controller); +} +``` + +Review questions: + +- `TextEditingController`, `ScrollController`, `FocusNode`, `AnimationController`, `TabController`, `PageController` — created? disposed? +- `StreamSubscription`, `Timer`, `ChangeNotifier` listeners, `WidgetsBindingObserver` — removed? +- `AnimationController` uses a `TickerProvider` that dies with the `State` (`SingleTickerProviderStateMixin`), not a leaked vsync? +- Route-level controllers created in `BlocProvider`/`Provider` — `dispose` callback set? +- `Image.network` / video / camera controllers stopped when the route is covered, not only when popped? + +`dispose` must not use `BuildContext` (the element is unmounted). Do not call `setState` there. + +--- + +## Testability + +Review the tests the same way as the product code. Flutter tests that pump the whole app to assert a string are a smell that logic is trapped in widgets. + +```dart +// ❌ Bad: parsing and I/O live in the widget; tests must pump and mock everything. +class PriceLabel extends StatelessWidget { + const PriceLabel({super.key, required this.raw}); + final String raw; + + @override + Widget build(BuildContext context) { + final n = NumberFormat.simpleCurrency().parse(raw); + return Text(n.toString()); + } +} + +// ✅ Good: pure function / mapper is unit-tested; widget only renders. +String formatPrice(String raw, NumberFormat format) => format.format(format.parse(raw)); + +class PriceLabel extends StatelessWidget { + const PriceLabel({super.key, required this.text}); + final String text; + + @override + Widget build(BuildContext context) => Text(text); +} +``` + +```dart +// ❌ Bad: test depends on real time and network. +testWidgets('loads profile', (tester) async { + await tester.pumpWidget(const ProfilePage(id: '1')); + await tester.pumpAndSettle(); + expect(find.text('Ada'), findsOneWidget); +}); + +// ✅ Good: inject a fake; pump until the Future you control completes. +testWidgets('loads profile', (tester) async { + final api = FakeProfileApi()..completeWith(Profile(name: 'Ada')); + await tester.pumpWidget(ProfilePage(id: '1', api: api)); + await tester.pump(); + expect(find.text('Ada'), findsOneWidget); +}); +``` + +Review questions: + +- Can the new logic be tested with `flutter test` without `IntegrationTestWidgetsFlutterBinding`? +- Are `Key`s present on interactive widgets the test (and the reviewer) needs to find, without painting `UniqueKey` in `build`? +- Are platform channels mocked (`TestDefaultBinaryMessengerBinding`)? +- Does `pumpAndSettle` hang because an infinite animation / polling stream never idles? +- Is `late` in tests hiding missing setup? + +--- + +## Review Checklist + +### Widgets & rebuilds + +- [ ] Static subtrees use `const` constructors +- [ ] Expensive children are extracted widgets, not `_buildFoo()` methods +- [ ] Long lists use lazy builders, not a fully-materialized `children:` list +- [ ] `setState` is not used to store values that can be derived in `build` + +### Null safety & `late` + +- [ ] `!` and `as` are not used to silence the type system +- [ ] `late` is lazy-init (`late final x = ...`) or truly guaranteed before first read +- [ ] Nullable fields are promoted via locals, not assumed non-null + +### Isolates & async + +- [ ] Heavy JSON / image / crypto work is off the UI isolate +- [ ] Isolate messages are sendable plain data +- [ ] `build` does not create Futures/Streams or start I/O +- [ ] `FutureBuilder`/`StreamBuilder` reuse a cached future/stream; refetch when the id/input changes +- [ ] `setState` / `BuildContext` after `await` check `mounted` / `context.mounted` + +### State management + +- [ ] `ref.watch` / `context.watch` only in `build`; `read` in callbacks +- [ ] Rebuilds are narrowed (`select`, `buildWhen`) +- [ ] Do not construct a `Bloc`/`Cubit`/`ChangeNotifier` in `build` and pass it to `*.value` — use `create`; key with `ValueKey(id)` (not `UniqueKey()`) or `didUpdateWidget` so a new id reloads +- [ ] Providers/blocs that own controllers are disposed (`autoDispose`, `close`) + +### Platform, keys, disposal + +- [ ] Platform channel calls handle `PlatformException` and missing plugins +- [ ] EventChannel / native subscriptions are cancelled +- [ ] List children that hold `State` have stable `ValueKey`s (not `UniqueKey()` in `build`) +- [ ] Controllers, tickers, and subscriptions are disposed; `dispose` does not use `context` + +### Tests + +- [ ] Domain logic is unit-tested without pumping widgets +- [ ] Widget tests inject fakes; they do not hit network or real platform channels +- [ ] `pumpAndSettle` is not used on never-idle animations + +--- + +## References + +- [Dart language tour (null safety)](https://dart.dev/null-safety) +- [Effective Dart](https://dart.dev/effective-dart) +- [Flutter performance: best practices](https://docs.flutter.dev/perf/best-practices) +- [FutureBuilder class](https://api.flutter.dev/flutter/widgets/FutureBuilder-class.html) +- [Isolate.run](https://api.dart.dev/stable/dart-isolate/Isolate/run.html) +- [Riverpod: Refs (`watch` vs `read`)](https://riverpod.dev/docs/concepts2/refs) +- [Bloc: BlocListener vs BlocBuilder](https://bloclibrary.dev/bloc-concepts/) +- [Key class](https://api.flutter.dev/flutter/foundation/Key-class.html) +- [Error Handling Guide](cross-cutting/error-handling-principles.md) +- [Async & Concurrency Guide](cross-cutting/async-concurrency-patterns.md) +- [Performance Review Guide](performance-review-guide.md) +- [Security Review Guide](security-review-guide.md) diff --git a/skills/code-review-skill/reference/django.md b/skills/code-review-skill/reference/django.md new file mode 100644 index 0000000..55b8109 --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/reference/fastapi.md b/skills/code-review-skill/reference/fastapi.md new file mode 100644 index 0000000..64030a4 --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/reference/go.md b/skills/code-review-skill/reference/go.md new file mode 100644 index 0000000..97eda6d --- /dev/null +++ b/skills/code-review-skill/reference/go.md @@ -0,0 +1,995 @@ +# 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/skills/code-review-skill/reference/java.md b/skills/code-review-skill/reference/java.md new file mode 100644 index 0000000..9504eba --- /dev/null +++ b/skills/code-review-skill/reference/java.md @@ -0,0 +1,414 @@ +# 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/skills/code-review-skill/reference/java8.md b/skills/code-review-skill/reference/java8.md new file mode 100644 index 0000000..097040c --- /dev/null +++ b/skills/code-review-skill/reference/java8.md @@ -0,0 +1,591 @@ +# 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/skills/code-review-skill/reference/nestjs.md b/skills/code-review-skill/reference/nestjs.md new file mode 100644 index 0000000..36cbed2 --- /dev/null +++ b/skills/code-review-skill/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) {} +} +``` + +--- + +## 模块组织 + +### 推荐四层结构 + +```text +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/skills/code-review-skill/reference/performance-review-guide.md b/skills/code-review-skill/reference/performance-review-guide.md new file mode 100644 index 0000000..52cccc6 --- /dev/null +++ b/skills/code-review-skill/reference/performance-review-guide.md @@ -0,0 +1,828 @@ +# 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 +const ALLOWED_FIELDS = new Set(['id', 'name', 'email']); +app.get('/users', async (req, res) => { + const requested = typeof req.query.fields === 'string' ? req.query.fields.split(',') : []; + const fields = requested.filter((f) => ALLOWED_FIELDS.has(f)); + const users = await User.findAll({ + attributes: fields.length ? fields : ['id', 'name'], + }); + 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/skills/code-review-skill/reference/php.md b/skills/code-review-skill/reference/php.md new file mode 100644 index 0000000..2f0c702 --- /dev/null +++ b/skills/code-review-skill/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/skills/code-review-skill/reference/python.md b/skills/code-review-skill/reference/python.md new file mode 100644 index 0000000..7e306cf --- /dev/null +++ b/skills/code-review-skill/reference/python.md @@ -0,0 +1,1080 @@ +# 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/skills/code-review-skill/reference/qt.md b/skills/code-review-skill/reference/qt.md new file mode 100644 index 0000000..490c6f1 --- /dev/null +++ b/skills/code-review-skill/reference/qt.md @@ -0,0 +1,782 @@ +# 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/skills/code-review-skill/reference/react.md b/skills/code-review-skill/reference/react.md new file mode 100644 index 0000000..28997db --- /dev/null +++ b/skills/code-review-skill/reference/react.md @@ -0,0 +1,873 @@ +# 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 { startTransition, useActionState, useOptimistic } from 'react'; + +function FastLike({ postId, likes }) { // 父组件渲染 ,让新文章从各自点赞数开始 + const [likeCount, likeAction] = useActionState(async (currentCount) => { + try { return await likePost(postId); } // likePost 返回服务端最新的点赞数 + catch { return currentCount; } // 保持原计数;React 自动回滚丢弃乐观值 + }, likes); + const [optimisticLikes, addOptimisticLike] = useOptimistic( + likeCount, + (current, increment: number) => current + increment + ); + + const handleLike = () => { + startTransition(() => { + addOptimisticLike(1); + likeAction(); + }); + }; + + 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/skills/code-review-skill/reference/ruby.md b/skills/code-review-skill/reference/ruby.md new file mode 100644 index 0000000..753412c --- /dev/null +++ b/skills/code-review-skill/reference/ruby.md @@ -0,0 +1,1011 @@ +# 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/skills/code-review-skill/reference/svelte.md b/skills/code-review-skill/reference/svelte.md new file mode 100644 index 0000000..1ab30a9 --- /dev/null +++ b/skills/code-review-skill/reference/svelte.md @@ -0,0 +1,1075 @@ +# 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 }) => { + if (!locals.user) return fail(401, { message: 'Unauthorized' }); + const formData = await request.formData(); + const id = formData.get('id'); + + if (typeof id !== 'string' || !id) return fail(400, { message: 'Missing id' }); + if (!locals.user.isAdmin && locals.user.id !== id) return fail(403, { message: 'Forbidden' }); + + 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) +import { error } from '@sveltejs/kit'; +import { SECRET_API_KEY } from '$env/static/private'; + +export async function load({ fetch, locals }) { + // locals.user is set by the app's handle hook + if (!locals.user?.isAdmin) error(403, 'Forbidden'); + + // secrets stay on server + const res = await fetch('https://api.example.com/admin', { + headers: { Authorization: `Bearer ${SECRET_API_KEY}` }, + }); + const data = await res.json(); + 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/skills/code-review-skill/reference/swift.md b/skills/code-review-skill/reference/swift.md new file mode 100644 index 0000000..75b0618 --- /dev/null +++ b/skills/code-review-skill/reference/swift.md @@ -0,0 +1,938 @@ +# 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/skills/code-review-skill/reference/typescript.md b/skills/code-review-skill/reference/typescript.md new file mode 100644 index 0000000..232e35e --- /dev/null +++ b/skills/code-review-skill/reference/typescript.md @@ -0,0 +1,1027 @@ +# 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 | null = null; + + async connect() { + this.db = new sqlite3.Database(':memory:'); + } + + async [Symbol.asyncDispose]() { + const db = this.db; + if (!db) return; + this.db = null; + await new Promise((resolve, reject) => { + db.close((err) => (err ? reject(err) : resolve())); + }); + } +} + +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/skills/code-review-skill/reference/vue.md b/skills/code-review-skill/reference/vue.md new file mode 100644 index 0000000..c4b67be --- /dev/null +++ b/skills/code-review-skill/reference/vue.md @@ -0,0 +1,939 @@ +# 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) + let controller: AbortController | null = null + + const execute = async () => { + controller?.abort() + const current = new AbortController() + controller = current + loading.value = true + error.value = null + + try { + const response = await fetch(toValue(url), { signal: current.signal }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + data.value = await response.json() + } catch (e) { + if ((e as Error).name !== 'AbortError') { + error.value = e as Error + } + } finally { + if (controller === current) { + loading.value = false + } + } + } + + // 响应式 URL 时自动重新获取 + watchEffect((onCleanup) => { + onCleanup(() => controller?.abort()) + 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/skills/code-review-skill/reference/zig.md b/skills/code-review-skill/reference/zig.md new file mode 100644 index 0000000..3a9564c --- /dev/null +++ b/skills/code-review-skill/reference/zig.md @@ -0,0 +1,464 @@ +# 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 { + const names = try allocator.alloc([]const u8, 10); + @memset(names, ""); + return names; +} +``` + +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/skills/code-review-skill/scripts/pr-analyzer.py b/skills/code-review-skill/scripts/pr-analyzer.py new file mode 100644 index 0000000..b0a7204 --- /dev/null +++ b/skills/code-review-skill/scripts/pr-analyzer.py @@ -0,0 +1,430 @@ +""" +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 argparse +import os +import re +import sys +from collections import defaultdict +from dataclasses import dataclass + +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 + return bool(re.search(r'(?:^|/)config/', filename)) + + +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 = {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 = {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("\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("\n⚠️ RISK FACTORS:") + for risk in analysis.risk_factors: + print(f" • {risk}") + + print("\n💡 SUGGESTIONS:") + for suggestion in analysis.suggestions: + print(f" • {suggestion}") + + if show_files: + print("\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/skills/code-review-skill/scripts/test_pr_analyzer.py b/skills/code-review-skill/scripts/test_pr_analyzer.py new file mode 100644 index 0000000..e6f03fa --- /dev/null +++ b/skills/code-review-skill/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/tools/adopt_skills.py b/tools/adopt_skills.py index 4fd4cec..1c60527 100644 --- a/tools/adopt_skills.py +++ b/tools/adopt_skills.py @@ -11,6 +11,7 @@ import argparse import base64 import csv +from dataclasses import dataclass import io import json import os @@ -51,6 +52,8 @@ r'^\s*\[(?P[^\]]+)\]\s+(?P\S+?):\d+', re.M ) +THREAD_HEADER = re.compile(r'^#?\s*threads:\s*(.+)\Z', re.IGNORECASE) +THREAD_ID = re.compile(r'^[A-Za-z0-9_-]+\Z') class Refused(ValueError): @@ -127,6 +130,11 @@ def safe_path(raw): return path +def is_licence_name(name: str) -> bool: + return (name in LICENSE_FILENAMES or + bool(re.fullmatch(r'(LICEN[SC]E|COPYING)(?:\.[A-Za-z0-9_-]+)?', name, re.IGNORECASE))) + + def checkout_for(checkouts, row, pin): source = row['source'] if not REPOSITORY.fullmatch(source): @@ -137,6 +145,8 @@ def checkout_for(checkouts, row, pin): checkout = checkouts / source.replace('/', '__') if checkout.is_symlink() or checkout.resolve().parent != checkouts.resolve(): raise Refused('checkout-outside-folder') + if not (checkout / '.git').exists(): + raise Refused('missing-git-checkout') if git(checkout, 'rev-parse', 'HEAD').decode().strip() != pin: raise Refused('checkout-pin-mismatch') if git(checkout, 'status', '--porcelain', '--untracked-files=all'): @@ -162,6 +172,28 @@ def tree_files(checkout, pin, folder, recursive=True): return entries +def filter_entries(entries, folder): + root_skill = str(folder) == '.' + kept, furniture, workbench = [], [], [] + for entry in entries[:MAX_ROWS]: + path = entry[0] + if root_skill and len(path.parts) == 1: + if path.name == 'SKILL.md' or is_licence_name(path.name): + kept.append(entry) + else: + furniture.append(str(path)) + elif any(part.startswith('.') for part in path.parts) or path.name.lower() in check_skills.AGENT_CONTROL_FILES: + workbench.append(str(path)) + else: + kept.append(entry) + notes = [] + if furniture: + notes.append(f"omitted repository furniture ({', '.join(sorted(furniture))})") + if workbench and root_skill: + notes.append(f"omitted agent workbench files ({', '.join(sorted(workbench))})") + return kept, notes + + def validate_files(entries, allow_missing=False): if len(entries) > MAX_FILES: raise Refused('file-limit: more than 200 files') @@ -181,8 +213,10 @@ def validate_files(entries, allow_missing=False): def expand_sparse(checkout, path, *, file=False): + if git(checkout, 'config', '--default', 'false', '--get', 'core.sparseCheckout').strip() != b'true': + return # Cone mode already includes root files; non-cone mode needs a literal pattern. - cone = git(checkout, 'config', '--get', 'core.sparseCheckoutCone').strip() == b'true' + cone = git(checkout, 'config', '--default', 'false', '--get', 'core.sparseCheckoutCone').strip() == b'true' if cone: git(checkout, 'sparse-checkout', 'add', '--', '.' if file else str(path), allow_fetch=True) else: @@ -216,50 +250,33 @@ def copy_blobs(checkout, entries, destination, allow_fetch=False): def rewrite_fences(content: str) -> tuple[str, bool]: lines = content.splitlines(keepends=True) - out = [] - in_fence = False - fence_char = None - fence_len = 0 + tokens = check_skills.COMMONMARK.parse(content) changed = False + for token in tokens: + if token.type == 'fence' and not token.info.strip() and token.map: + idx = token.map[0] + line = lines[idx] + pos = line.find(token.markup) + if pos != -1: + indent = line[:pos] + newline = '\r\n' if line.endswith('\r\n') else ('\n' if line.endswith('\n') else '') + new_line = f'{indent}{token.markup}text{newline}' + if new_line != line: + lines[idx] = new_line + changed = True + return ''.join(lines), changed - fence_re = re.compile(r'^([ \t]*)(`{3,}|~{3,})([^\n\r]*)([\r\n]*)$') - for line in lines: - m = fence_re.match(line) - if not in_fence: - if m: - indent, fence, rest, newline = m.groups() - info = rest.strip() - in_fence = True - fence_char = fence[0] - fence_len = len(fence) - if not info: - out.append(f'{indent}{fence}text{newline}') - changed = True - continue - out.append(line) - else: - if m: - indent, fence, rest, newline = m.groups() - info = rest.strip() - if not info and fence[0] == fence_char and len(fence) >= fence_len: - in_fence = False - fence_char = None - fence_len = 0 - out.append(line) - return ''.join(out), changed - - -def rewrite_skill_fences(skill: Path) -> bool: - any_changed = False - for path in sorted(skill.rglob('*')): +def rewrite_skill_fences(skill: Path) -> set[str]: + changed_files: set[str] = set() + for path in sorted(skill.rglob('*'))[:MAX_ROWS]: if path.is_file() and path.name != 'ORIGIN.md' and path.suffix.lower() in MARKDOWN_EXTENSIONS: text = path.read_text(encoding='utf-8') new_text, changed = rewrite_fences(text) if changed: path.write_text(new_text, encoding='utf-8') - any_changed = True - return any_changed + changed_files.add(path.relative_to(skill).as_posix()) + return changed_files default_reader = LiveReader().gh_json @@ -435,24 +452,232 @@ def hiss_gate(root, staging, skill): raise Refused('hiss') -def stage_one(row, pin, checkouts, staging, occupied, apply, reader): +def drop_file(skill: Path, rel: str) -> None: + """Remove one staged file and the folders it leaves empty, bounded.""" + target = skill / rel + target.unlink() + parent = target.parent + for _ in range(16): + if parent == skill or not parent.is_dir() or any(parent.iterdir()): + break + parent.rmdir() + parent = parent.parent + + +def patch_threads(patch_text: str, rel: str) -> list[str]: + """Review thread ids named in the header lines before a patch's first diff line; a patch without any + is refused, so every content edit stays tied to the finding it answers.""" + threads: list[str] = [] + for line in patch_text.splitlines()[:MAX_ROWS]: + if line.startswith('--- '): + break + m = THREAD_HEADER.match(line.strip()) + if m: + threads.extend(t.strip() for t in m.group(1).split(',')[:64] if t.strip()) + if not threads or not all(THREAD_ID.fullmatch(t) for t in threads): + raise Refused(f'patch: {rel}') + return threads + + +def apply_patch_text(text: str, patch_text: str, rel: str) -> tuple[str, list[str]]: + """Apply one unified diff to text with git apply (exact context, no fuzz) in a scratch folder and + return the result and the patch's thread ids; a patch that does not apply is refused by name.""" + threads = patch_threads(patch_text, rel) + with tempfile.TemporaryDirectory(prefix='adopt-patch-') as tmp: + target = Path(tmp) / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(text, encoding='utf-8') + try: + command(['git', 'apply', '--whitespace=nowarn', '-'], cwd=tmp, data=patch_text.encode('utf-8')) + return target.read_text(encoding='utf-8'), threads + except (Refused, OSError, UnicodeDecodeError, subprocess.SubprocessError) as error: + raise Refused(f'patch: {rel}') from error + + +def apply_skill_patches(skill: Path, patch_dir: Path | None) -> dict[str, list[str]]: + if patch_dir is None or not patch_dir.exists(): + return {} + if patch_dir.is_symlink() or not patch_dir.is_dir(): + raise Refused(f'patch: {skill.name}') + patched: dict[str, list[str]] = {} + for entry in sorted(patch_dir.rglob('*'))[:MAX_ROWS]: + rel_entry = entry.relative_to(patch_dir).as_posix() + if entry.is_symlink(): + raise Refused(f'patch: {rel_entry}') + if entry.is_dir(): + continue + if not rel_entry.endswith('.patch'): + raise Refused(f'patch: {rel_entry}') + rel = str(safe_path(rel_entry.removesuffix('.patch'))) + target = skill / rel + if not target.is_file(): + raise Refused(f'patch: {rel}') + try: + src_text = target.read_text(encoding='utf-8') + patch_text = entry.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError) as error: + raise Refused(f'patch: {rel}') from error + new_text, threads = apply_patch_text(src_text, patch_text, rel) + target.write_text(new_text, encoding='utf-8') + patched[rel] = threads + return patched + + +def _allowed_by_patch(rel: str, pin_text: str, norm_text: str, after: str, patch_dir: Path | None) -> bool: + if patch_dir is None or ''.join(pin_text.split()) != ''.join(norm_text.split()): + return False + patch_file = patch_dir / f'{rel}.patch' + if patch_file.is_symlink() or not patch_file.is_file(): + return False + try: + expected, _ = apply_patch_text(norm_text, patch_file.read_text(encoding='utf-8'), rel) + except (OSError, UnicodeDecodeError, Refused): + return False + return after == expected + + +def whitespace_arbiter(skill: Path, before: dict[str, str], patch_dir: Path | None = None, + normalized_pin: dict[str, str] | None = None) -> list[str]: + """Files the normalizer changed beyond whitespace (ruling peritia-code-review-skill-markdown-refinement-0013): each is + dropped unless a listed patch in patch_dir exists and the file equals normalized-pin plus that patch.""" + dropped = [] + norm_map = normalized_pin if normalized_pin is not None else before + for rel, text in sorted(before.items())[:MAX_FILES]: + target = skill / rel + after = target.read_text(encoding='utf-8') if target.is_file() else '' + if ''.join(text.split()) == ''.join(after.split()): + continue + if _allowed_by_patch(rel, text, norm_map.get(rel, text), after, patch_dir): + continue + drop_file(skill, rel) + dropped.append(rel) + return dropped + + +def normalize_skill_markdown(root: Path, skill: Path) -> tuple[dict[str, list[str]], dict[str, list[str]]]: + tool_dir = root / 'tools/markdownlint' if root is not None else Path(__file__).resolve().parent / 'markdownlint' + if not tool_dir.exists(): + tool_dir = Path(__file__).resolve().parent / 'markdownlint' + script = tool_dir / 'normalize.mjs' + if not script.is_file(): + raise Refused('markdown-normalize') + patch_dir = (root / 'tools/skill-patches' / skill.name) if root is not None else None + files = [ + path.relative_to(skill).as_posix() + for path in sorted(skill.rglob('*'))[:MAX_ROWS] + if path.is_file() and path.name != 'ORIGIN.md' and path.suffix.lower() in MARKDOWN_EXTENSIONS + ] + if len(files) > MAX_FILES: + raise Refused('file-limit: more than 200 files') + if not files: + patched = apply_skill_patches(skill, patch_dir) + return {}, patched + before = {rel: (skill / rel).read_text(encoding='utf-8') for rel in files} + try: + raw = command(['node', str(script), *files], cwd=skill, timeout=TIMEOUT) + parsed = json.loads(raw.decode('utf-8')) + except (OSError, ValueError, subprocess.SubprocessError) as error: + raise Refused('markdown-normalize') from error + if not isinstance(parsed, dict): + raise Refused('markdown-normalize') + normalized = {str(k): [str(r) for r in v] for k, v in parsed.items() if isinstance(v, list) and v} + normalized_pin = {rel: (skill / rel).read_text(encoding='utf-8') for rel in files} + patched = apply_skill_patches(skill, patch_dir) + for rel in whitespace_arbiter(skill, before, patch_dir=patch_dir, normalized_pin=normalized_pin): + normalized[rel] = ['changed beyond whitespace, omitted'] + return normalized, patched + + +def drop_gate_refused(skill: Path, message: str, dropped: dict[str, str]) -> bool: + pairs = re.findall(r'([A-Za-z0-9_,\s-]+) in (\S+)', message) + if not pairs: + return False + for rules, relpath in pairs[:MAX_FILES]: + rel = relpath.rstrip(';') + if rel == 'SKILL.md': + return False + if not (skill / rel).is_file(): + return False + drop_file(skill, rel) + dropped[rel] = rules.strip() + return True + + +def build_notes(skill: Path, filter_notes: list[str], normalized: dict[str, list[str]], + dropped_md: dict[str, str], dropped_hiss: dict[str, str], lic_note: str, + patched: dict[str, list[str]] | None = None) -> str: + notes = list(filter_notes) + active_norm = {rel: rules for rel, rules in sorted(normalized.items()) if (skill / rel).is_file()} + beyond = sorted(rel for rel, rules in normalized.items() if rules == ['changed beyond whitespace, omitted']) + if beyond: + notes.append(f"omitted files the whitespace normalizer would change beyond whitespace ({', '.join(beyond)})") + if active_norm: + items = ', '.join(f"{rel} ({', '.join(rules)})" for rel, rules in active_norm.items()) + notes.append(f'normalized Markdown whitespace ({items})') + active_patched = {rel: threads for rel, threads in sorted((patched or {}).items()) if (skill / rel).is_file()} + if active_patched: + items = ', '.join(f"{rel} ({', '.join(threads)})" for rel, threads in active_patched.items()) + notes.append(f'applied review patches ({items})') + if dropped_md: + items = ', '.join(f'{f} ({r})' for f, r in sorted(dropped_md.items())) + notes.append(f'omitted files refused by markdown gate ({items})') + if dropped_hiss: + items = ', '.join(f'{f} ({r})' for f, r in sorted(dropped_hiss.items())) + notes.append(f'omitted files refused by HISS gate ({items})') + if lic_note: + notes.append(lic_note) + return '; '.join(notes) + + +@dataclass(frozen=True) +class GateOptions: + """Configured gate callbacks for staged skill validation.""" + gate: object = None + hiss_gate: object = None + + +def run_gates(root, staging, skill, row, folder, pin, state, gates: GateOptions): + filter_notes, normalized, lic_note, fenced_files, patched = state + dropped_md, dropped_hiss = {}, {} + for step in range(8): + try: + if gates.gate: + gates.gate(root, staging, skill) + if gates.hiss_gate: + gates.hiss_gate(root, staging, skill) + break + except Refused as error: + msg = str(error) + target_map = dropped_md if msg.startswith('markdown:') else dropped_hiss + if str(folder) != '.' or step == 7 or not drop_gate_refused(skill, msg, target_map): + raise + note = build_notes(skill, filter_notes, normalized, dropped_md, dropped_hiss, lic_note, patched=patched) + fence_changed = any((skill / rel).is_file() for rel in fenced_files) + attribution(skill, row, folder, pin, note, fence_changed=fence_changed) + git(staging, 'add', '-A', '--', 'skills') + + +def stage_one(row, pin, checkouts, root, staging, occupied, apply, reader): checkout, folder = checkout_for(checkouts, row, pin) name = choose_name(row, occupied) if apply: expand_sparse(checkout, folder) - entries = tree_files(checkout, pin, folder) + raw_entries = tree_files(checkout, pin, folder) + entries, filter_notes = filter_entries(raw_entries, folder) validate_files(entries, allow_missing=not apply) skill = staging / 'skills' / name skill.mkdir(parents=True) copy_blobs(checkout, entries, skill, allow_fetch=not apply) - fence_changed = rewrite_skill_fences(skill) - note = licence_text(row, checkout, pin, skill, apply, reader) - attribution(skill, row, folder, pin, note, fence_changed=fence_changed) + fenced_files = rewrite_skill_fences(skill) + normalized, patched = normalize_skill_markdown(root, skill) + lic_note = licence_text(row, checkout, pin, skill, apply, reader) + note = build_notes(skill, filter_notes, normalized, {}, {}, lic_note, patched=patched) + attribution(skill, row, folder, pin, note, fence_changed=bool(fenced_files)) problems = check_skills.skill_problems(skill) if problems: raise Refused('skill-check: ' + '; '.join(problems)) year = git(checkout, 'show', '-s', '--format=%cs', pin).decode().strip()[:4] - return skill, year + return skill, year, folder, (filter_notes, normalized, lic_note, fenced_files, patched) def initial_metadata(root): @@ -463,10 +688,13 @@ def initial_metadata(root): def add_metadata(reuse, credits, row, name, year): - annotation = ('\n[[annotations]]\n' + f'path = ["skills/{name}/**"]\n' + path_line = f'path = ["skills/{name}/**"]\n' + annotation = ('\n[[annotations]]\n' + path_line + 'precedence = "override"\n' + f'SPDX-FileCopyrightText = {json.dumps(year + " " + row["source"].split("/")[0] + " and contributors")}\n' + f'SPDX-License-Identifier = {json.dumps(row["licence"])}\n') + if path_line not in reuse: + reuse = reuse + annotation url = f'https://github.com/{row["source"]}' lines = credits.splitlines() found = False @@ -480,7 +708,7 @@ def add_metadata(reuse, credits, row, name, year): break if not found: lines.append(f'| <{url}> | {row["licence"]} | `{name}` |') - return reuse + annotation, '\n'.join(lines) + '\n' + return reuse, '\n'.join(lines) + '\n' def report_text(rows): @@ -744,11 +972,10 @@ def evaluate(row, grouped, order, pins, checkouts, root, staging, occupied, appl return result, None, '' if len(occupied) >= check_skills.MAX_SKILLS: raise Refused('skill-count-limit') - skill, year = stage_one(row, pins[row['source']], checkouts, staging, occupied, apply, reader) - if gate: - gate(root, staging, skill) - if hiss_gate: - hiss_gate(root, staging, skill) + pin = pins[row['source']] + skill, year, folder, state = stage_one( + row, pin, checkouts, root, staging, occupied, apply, reader) + run_gates(root, staging, skill, row, folder, pin, state, GateOptions(gate=gate, hiss_gate=hiss_gate)) # A future gate fixer must preserve the checker contract too. problems = check_skills.skill_problems(skill) if problems: diff --git a/tools/census-sources.json b/tools/census-sources.json index 9158a0a..63648a0 100644 --- a/tools/census-sources.json +++ b/tools/census-sources.json @@ -24,6 +24,12 @@ "found_at": "https://github.com/anthropics/skills", "rank": 1 }, + { + "repository": "awesome-skills/code-review-skill", + "note": "Operator-pointed intake (order peritia-intake-awesome-skills-code-review-20261031).", + "found_at": "https://github.com/awesome-skills/code-review-skill", + "rank": 5 + }, { "repository": "bergside/awesome-design-skills", "note": "Public awesome list found by fresh GitHub repository search for SKILL.md.", diff --git a/tools/check_skills.py b/tools/check_skills.py index a121b1e..b9212af 100644 --- a/tools/check_skills.py +++ b/tools/check_skills.py @@ -12,6 +12,7 @@ import tempfile from pathlib import Path +from markdown_it import MarkdownIt import yaml try: @@ -19,10 +20,19 @@ except ImportError: from tools.install_user_skills import read_install_set +COMMONMARK = MarkdownIt("commonmark") MAX_SKILLS = 5000 # bounds the directories read (HISS-02) +MAX_SKILL_ENTRIES = 1000 # bounds the entries walked in one skill (HISS-02) +AGENT_CONTROL_FILES = frozenset({ + "protocol.md", "agents.md", "claude.md", "gemini.md", + ".cursorrules", ".windsurfrules", "copilot-instructions.md", +}) FRONT = re.compile(r"\A---\n(.*?)\n---\n", re.S) FIELD = re.compile(r"^(?P[a-z_]+):\s*(?P.+)$", re.M) COMMIT = re.compile(r"^[0-9a-f]{40}$") +INLINE_CODE = re.compile(r"`[^`]*`") +MD_LINK = re.compile(r"(? dict[str, str]: return {} +def _workbench_problems(skill: Path) -> list[str]: + entries: list[Path] = [] + for i, entry in enumerate(skill.rglob("*")): + if i >= MAX_SKILL_ENTRIES: + return [f"{skill.name}: more than {MAX_SKILL_ENTRIES} entries"] + entries.append(entry) + out: list[str] = [] + for entry in sorted(entries): + if entry.name.startswith(".") or (entry.is_file() and entry.name.lower() in AGENT_CONTROL_FILES): + rel = entry.relative_to(skill).as_posix() + out.append(f"{skill.name}/{rel}: agent workbench or agent-control content is not allowed") + return out + + +def _skill_md_link_problems(skill: Path, text: str) -> list[str]: + out: list[str] = [] + skill_root = skill.resolve() + tokens = COMMONMARK.parse(text) + skipped: set[int] = set() + for token in tokens: + if token.type in ("fence", "code_block") and token.map: + skipped.update(range(token.map[0], token.map[1])) + + for lineno, line in enumerate(text.splitlines()[:MAX_SKILL_ENTRIES * 10], 1): + if (lineno - 1) in skipped: + continue + stripped = INLINE_CODE.sub("", line) + for match in MD_LINK.finditer(stripped): + target = match.group(1).strip("<>") + path_part = target.split("#", 1)[0].split("?", 1)[0] + if not path_part or ":" in path_part or path_part.startswith(("/", "../")): + continue + if not path_part.lower().endswith(CHECKED_LINK_SUFFIXES): + continue + resolved = (skill / path_part).resolve() + if not resolved.is_file() or not resolved.is_relative_to(skill_root): + out.append(f"{skill.name}/SKILL.md:{lineno}: links to missing file {path_part}") + return out + + def skill_problems(skill): """Problems of one skill directory, each naming the file and the rule.""" out = [] @@ -81,6 +131,8 @@ def skill_problems(skill): for n, line in enumerate(text.splitlines(), 1): if FETCH.search(line): out.append(f"{skill.name}/SKILL.md:{n}: installs or fetches code at run time") + out.extend(_skill_md_link_problems(skill, text)) + out.extend(_workbench_problems(skill)) if not origin.is_file(): return out + [f"{skill.name}: ORIGIN.md missing"] rec = fields(origin.read_text(encoding="utf-8")) @@ -239,13 +291,55 @@ def _selftest_cases(tmp, good_md, good_origin): "installs": (good_md.format(n="installs") + "\n uv pip install numpy==2.5.3\n", good_origin, True, "installs or fetches"), "empty-desc": ("---\nname: empty-desc\ndescription: >\n---\n\n# x\n", good_origin, True, "needs a description"), "bad-yaml": ("---\nname: [\n---\n\n# x\n", good_origin, True, "not valid YAML"), + "dangling-link": (good_md.format(n="dangling-link") + "\nSee [Guide](reference/missing.md).\n", good_origin, True, "links to missing file reference/missing.md"), + "tilde-fence-link": ( + good_md.format(n="tilde-fence-link") + + "\n~~~\n```\n[Inside](reference/inside-tilde.md)\n~~~\nSee [After](reference/after-tilde.md).\n", + good_origin, True, "links to missing file reference/after-tilde.md", + ), + "quad-fence-link": ( + good_md.format(n="quad-fence-link") + + "\n````markdown\n```python\n[Inside](reference/inside-quad.md)\n````\nSee [After](reference/after-quad.md).\n", + good_origin, True, "links to missing file reference/after-quad.md", + ), + "indented-backtick-link": ( + good_md.format(n="indented-backtick-link") + + "\n ```\n[After](reference/after-indented.md)\n", + good_origin, True, "links to missing file reference/after-indented.md", + ), } for name, (md, origin, licence, want) in cases.items(): planted = tmp / name write(planted, name, md, origin, licence) problems = skill_problems(planted / "skills" / name) - assert any(want in p for p in problems), (name, problems) - print(f"selftest ok: 2 clean skills pass, {len(cases)} planted defects refused, install-set validated") + if not (any(want in p for p in problems) and not any("inside-" in p for p in problems)): + raise AssertionError((name, problems)) + _selftest_workbench(tmp, good_md, good_origin) + print(f"selftest ok: 2 clean skills pass, {len(cases) + 1} planted defects refused, install-set validated") + + +def _selftest_workbench(tmp, good_md, good_origin): + hive_root = tmp / "hive-protocol" + write(hive_root, "hive-protocol", good_md.format(n="hive-protocol"), good_origin) + hive_skill = hive_root / "skills" / "hive-protocol" + (hive_skill / ".hive").mkdir() + (hive_skill / ".hive" / "PROTOCOL.md").write_text("# Protocol\n", encoding="utf-8") + problems = skill_problems(hive_skill) + if not any(".hive/PROTOCOL.md" in p for p in problems): + raise AssertionError(f"planted .hive/PROTOCOL.md not refused: {problems}") + (hive_skill / ".hive" / "PROTOCOL.md").unlink() + (hive_skill / ".hive").rmdir() + left = skill_problems(hive_skill) + if left: + raise AssertionError(f"skill without .hive still refused: {left}") + + prose_root = tmp / "hive-prose" + prose_md = good_md.format(n="hive-prose") + "\nNever copy a .hive workbench folder.\n" + write(prose_root, "hive-prose", prose_md, good_origin) + (prose_root / "skills" / "hive-prose" / "notes.md").write_text("Mentions .hive in prose.\n", encoding="utf-8") + prose = skill_problems(prose_root / "skills" / "hive-prose") + if prose: + raise AssertionError(f"prose naming .hive refused: {prose}") if __name__ == "__main__": diff --git a/tools/markdownlint/normalize.mjs b/tools/markdownlint/normalize.mjs new file mode 100644 index 0000000..5bd72f1 --- /dev/null +++ b/tools/markdownlint/normalize.mjs @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: 2026 lusoris +// +// SPDX-License-Identifier: EUPL-1.2 + +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const MAX_FILES = 200; +const MAX_FILE_BYTES_CEILING = 4_194_304; +const MAX_CAPTURE_BYTES = 1_048_576; +const MAX_MANIFEST_ALIASES = 1_024; +const MAX_MANIFEST_DEPTH = 64; +const MAX_FIX_PASSES = 4; +const INSTALL_TIMEOUT_MS = 300_000; +const NPM_CI_ARGS = Object.freeze(["ci", "--ignore-scripts", "--no-audit", "--no-fund"]); +const TOOL_FILES = Object.freeze(["package.json", "package-lock.json"]); +const MARKDOWNLINT_ROOT_ENTRY = "./lib/exports.mjs"; +const MARKDOWNLINT_SYNC_ENTRY = "./lib/exports-sync.mjs"; +const NORMALIZE_CONFIG = Object.freeze({ + default: false, + MD022: true, + MD031: true, + MD032: true, + MD047: true, + MD060: true, + MD020: true, +}); +const COMPACT_TABLE_CONFIG = Object.freeze({ + default: false, + MD060: { style: "compact" }, +}); + +function fail(message) { + throw new Error(message); +} + +function command(commandName, args, options = {}) { + const result = spawnSync(commandName, args, { + cwd: options.cwd, + encoding: "utf8", + env: { ...process.env, ...options.env }, + maxBuffer: MAX_CAPTURE_BYTES, + stdio: "pipe", + timeout: options.timeout ?? INSTALL_TIMEOUT_MS, + windowsHide: true, + }); + if (result.error) { + fail(`${commandName} failed: ${result.error.message}`); + } + if (result.signal) { + fail(`${commandName} terminated by ${result.signal}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim().slice(0, 500); + fail(`${commandName} exited ${result.status}${detail ? `: ${detail}` : ""}`); + } + return result; +} + +function npmInvocation(platform, execPath, args) { + if (platform !== "win32") { + return { file: "npm", args: [...args] }; + } + const cli = path.resolve(path.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js"); + if (fs.statSync(cli, { throwIfNoEntry: false })?.isFile() !== true) { + fail(`npm CLI not found beside ${execPath} at ${cli}`); + } + return { file: execPath, args: [cli, ...args] }; +} + +function install(toolDir, temporary) { + for (let index = 0; index < TOOL_FILES.length; index += 1) { + fs.copyFileSync(path.join(toolDir, TOOL_FILES[index]), path.join(temporary, TOOL_FILES[index])); + } + const npm = npmInvocation(process.platform, process.execPath, NPM_CI_ARGS); + command(npm.file, npm.args, { cwd: temporary, timeout: INSTALL_TIMEOUT_MS }); +} + +function loadDependency(temporary, name) { + return createRequire(path.join(temporary, "package.json"))(name); +} + +function markdownlintEntries(temporary) { + const realTemp = fs.realpathSync(path.resolve(temporary)); + const lockPath = path.resolve(realTemp, "package-lock.json"); + const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")); + const locked = lock?.packages?.["node_modules/markdownlint"]?.version; + if (typeof locked !== "string" || locked === "") { + fail("package-lock.json pins no markdownlint version"); + } + const packageDir = fs.realpathSync(path.resolve(realTemp, "node_modules", "markdownlint")); + const metadataPath = path.resolve(packageDir, "package.json"); + const metadata = JSON.parse(fs.readFileSync(metadataPath, "utf8")); + if ( + metadata?.version !== locked || + metadata?.exports?.["."] !== MARKDOWNLINT_ROOT_ENTRY || + metadata?.exports?.["./sync"] !== MARKDOWNLINT_SYNC_ENTRY + ) { + fail(`installed markdownlint package does not match locked ${locked} library contract`); + } + return { + rootEntry: path.join(packageDir, MARKDOWNLINT_ROOT_ENTRY), + syncEntry: path.join(packageDir, MARKDOWNLINT_SYNC_ENTRY), + }; +} + +function configurationParsers(temporary) { + const yaml = loadDependency(temporary, "js-yaml"); + const jsonc = loadDependency(temporary, "jsonc-parser"); + const toml = loadDependency(temporary, "smol-toml"); + const parseJSONC = (text) => { + const errors = []; + const result = jsonc.parse(text, errors, { allowTrailingComma: true }); + if (errors.length > 0) { + throw new Error(`Unable to parse JSONC content: ${errors.length} errors`); + } + return result; + }; + return [ + parseJSONC, + (text) => toml.parse(text), + (text) => yaml.load(text, { maxAliases: MAX_MANIFEST_ALIASES, maxDepth: MAX_MANIFEST_DEPTH }), + ]; +} + +function resolveRoot(root) { + try { + return fs.realpathSync(path.resolve(root)); + } catch { + fail(`refusing root folder that does not resolve: ${root}`); + } +} + +// Refuses an input that is absolute, climbs with "..", or resolves outside the folder it is run from (the staged +// skill), before any install, read or write. +function checkedInput(file, realRoot) { + if (typeof file !== "string" || file === "" || path.isAbsolute(file) || file.split(/[\\/]+/).includes("..")) { + fail(`refusing Markdown input outside the skill folder: ${file}`); + } + const candidate = path.resolve(realRoot, file); + let resolved; + try { + resolved = fs.realpathSync(candidate); + } catch { + fail(`refusing Markdown input that does not resolve: ${file}`); + } + const relative = path.relative(realRoot, resolved); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + fail(`refusing Markdown input outside the skill folder: ${file}`); + } + return { file, resolved }; +} + +function readLintSource(resolvedPath) { + let descriptor; + try { + descriptor = fs.openSync(resolvedPath, "r"); + } catch { + fail(`refusing to normalize non-file Markdown source ${resolvedPath}`); + } + try { + const lstat = fs.lstatSync(resolvedPath); + if (lstat.isSymbolicLink()) { + fail(`refusing to normalize non-file Markdown source ${resolvedPath}`); + } + const stat = fs.fstatSync(descriptor); + if (!stat.isFile()) { + fail(`refusing to normalize non-file Markdown source ${resolvedPath}`); + } + if (stat.size > MAX_FILE_BYTES_CEILING) { + fail(`${resolvedPath} is ${stat.size} bytes; per-file ceiling is ${MAX_FILE_BYTES_CEILING}`); + } + return fs.readFileSync(descriptor, "utf8"); + } finally { + fs.closeSync(descriptor); + } +} + +function normalizeOne(entry, lint, applyFixes, baseOptions) { + const original = readLintSource(entry.resolved); + const initial = lint({ ...baseOptions, config: NORMALIZE_CONFIG, strings: { [entry.file]: original } })[entry.file] ?? []; + if (initial.length === 0) { + return []; + } + const rules = [...new Set(initial.map((item) => item.ruleNames[0]))].sort(); + let current = applyFixes(original, initial); + for (let pass = 0; pass < MAX_FIX_PASSES; pass += 1) { + const remaining = lint({ ...baseOptions, config: NORMALIZE_CONFIG, strings: { [entry.file]: current } })[entry.file] ?? []; + if (remaining.length === 0) { + break; + } + if (remaining.some((item) => item.ruleNames[0] === "MD060" && !item.fixInfo)) { + const compact = lint({ ...baseOptions, config: COMPACT_TABLE_CONFIG, strings: { [entry.file]: current } })[entry.file] ?? []; + current = applyFixes(current, compact); + } else { + current = applyFixes(current, remaining); + } + } + if (current === original) { + return []; + } + fs.writeFileSync(entry.resolved, current, "utf8"); + return rules; +} + +function parseArgs(args) { + let root = process.cwd(); + const files = []; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--root" && index + 1 < args.length) { + index += 1; + root = args[index]; + } else if (arg.startsWith("--root=")) { + root = arg.slice("--root=".length); + } else { + files.push(arg); + } + } + return { root, files }; +} + +async function main() { + const { root, files } = parseArgs(process.argv.slice(2)); + if (files.length === 0) { + process.stdout.write("{}\n"); + return; + } + if (files.length > MAX_FILES) { + fail(`normalize batch has ${files.length} files; maximum is ${MAX_FILES}`); + } + const realRoot = resolveRoot(root); + const entries = []; + for (let index = 0; index < files.length; index += 1) { + entries.push(checkedInput(files[index], realRoot)); + } + const toolDir = path.dirname(fileURLToPath(import.meta.url)); + const temporary = fs.mkdtempSync(path.join(os.tmpdir(), "praetor-markdownlint-normalize-")); + try { + install(toolDir, temporary); + const { rootEntry, syncEntry } = markdownlintEntries(temporary); + const { lint } = await import(pathToFileURL(syncEntry).href); + const { applyFixes } = await import(pathToFileURL(rootEntry).href); + const baseOptions = { + configParsers: configurationParsers(temporary), + handleRuleFailures: true, + noInlineConfig: false, + }; + const changed = {}; + for (let index = 0; index < entries.length && index < MAX_FILES; index += 1) { + const entry = entries[index]; + const rules = normalizeOne(entry, lint, applyFixes, baseOptions); + if (rules.length > 0) { + changed[entry.file] = rules; + } + } + process.stdout.write(`${JSON.stringify(changed)}\n`); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +main().catch((error) => { + process.stderr.write(`markdown-normalize: ${error.message}\n`); + process.exitCode = 2; +}); diff --git a/tools/markdownlint/test_normalize.mjs b/tools/markdownlint/test_normalize.mjs new file mode 100644 index 0000000..0e5ff47 --- /dev/null +++ b/tools/markdownlint/test_normalize.mjs @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2026 Lusoris +// +// SPDX-License-Identifier: EUPL-1.2 + +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const toolDir = path.dirname(fileURLToPath(import.meta.url)); +const normalizeScript = path.join(toolDir, "normalize.mjs"); + +test("normalize.mjs refuses a path outside the root", () => { + const tempBase = fs.mkdtempSync(path.join(os.tmpdir(), "normalize-test-outside-")); + try { + const rootFolder = path.join(tempBase, "root"); + fs.mkdirSync(rootFolder); + const outsideFile = path.join(tempBase, "outside.md"); + fs.writeFileSync(outsideFile, "# Outside\n", "utf8"); + + // 1. Absolute path outside root + const absRes = spawnSync(process.execPath, [normalizeScript, outsideFile], { + cwd: rootFolder, + encoding: "utf8", + }); + assert.notEqual(absRes.status, 0); + assert.match(absRes.stderr, /refusing Markdown input outside the skill folder/u); + + // 2. Relative climbing path outside root + const climbRes = spawnSync(process.execPath, [normalizeScript, "../outside.md"], { + cwd: rootFolder, + encoding: "utf8", + }); + assert.notEqual(climbRes.status, 0); + assert.match(climbRes.stderr, /refusing Markdown input outside the skill folder/u); + + // 3. Symlink pointing outside root + const symlinkFile = path.join(rootFolder, "symlink.md"); + fs.symlinkSync(outsideFile, symlinkFile); + const symlinkRes = spawnSync(process.execPath, [normalizeScript, "symlink.md"], { + cwd: rootFolder, + encoding: "utf8", + }); + assert.notEqual(symlinkRes.status, 0); + assert.match(symlinkRes.stderr, /refusing Markdown input outside the skill folder/u); + + // 4. Using explicit --root flag + const explicitRes = spawnSync(process.execPath, [normalizeScript, "--root", rootFolder, outsideFile], { + cwd: tempBase, + encoding: "utf8", + }); + assert.notEqual(explicitRes.status, 0); + assert.match(explicitRes.stderr, /refusing Markdown input outside the skill folder/u); + + // Outside file must remain untouched + assert.equal(fs.readFileSync(outsideFile, "utf8"), "# Outside\n"); + } finally { + fs.rmSync(tempBase, { recursive: true, force: true }); + } +}); + +test("normalize.mjs processes a path inside the root", () => { + const tempBase = fs.mkdtempSync(path.join(os.tmpdir(), "normalize-test-inside-")); + try { + const rootFolder = path.join(tempBase, "root"); + fs.mkdirSync(rootFolder); + const insideFile = path.join(rootFolder, "inside.md"); + // Markdown with heading spacing (MD022), list spacing (MD032), table (MD060), and EOF newline (MD047) + const unalignedMd = + "# Reference\n" + + "## Tight Heading\n" + + "Intro paragraph:\n" + + "- item one\n" + + "```python\n" + + "x = 1\n" + + "```\n" + + "After fence.\n\n" + + "| Header One | Header Two |\n" + + "|---|---|\n" + + "| 1 | 2 |"; + fs.writeFileSync(insideFile, unalignedMd, "utf8"); + + const res = spawnSync(process.execPath, [normalizeScript, "inside.md"], { + cwd: rootFolder, + encoding: "utf8", + }); + assert.equal(res.status, 0, res.stderr); + + const changed = JSON.parse(res.stdout); + assert.ok(Array.isArray(changed["inside.md"]), `expected rules for inside.md, got ${res.stdout}`); + assert.ok(changed["inside.md"].includes("MD022"), `expected MD022 rule, got ${JSON.stringify(changed)}`); + assert.ok(changed["inside.md"].includes("MD047"), `expected MD047 rule, got ${JSON.stringify(changed)}`); + + const normalizedContent = fs.readFileSync(insideFile, "utf8"); + assert.notEqual(normalizedContent, unalignedMd); + assert.ok(normalizedContent.endsWith("\n")); + } finally { + fs.rmSync(tempBase, { recursive: true, force: true }); + } +}); diff --git a/tools/skill-patches/code-review-skill/SKILL.md.patch b/tools/skill-patches/code-review-skill/SKILL.md.patch new file mode 100644 index 0000000..06998f4 --- /dev/null +++ b/tools/skill-patches/code-review-skill/SKILL.md.patch @@ -0,0 +1,71 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVpUJ, PRRT_kwDOVLPOVM6yVpUI +--- a/SKILL.md ++++ b/SKILL.md +@@ -15,4 +15,5 @@ allowed-tools: + - Glob + - Bash + - WebFetch ++author: awesome-skills + --- +@@ -113,7 +114,7 @@ Before diving into code, understand: + ### 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) ++ - For significant changes, consult [Architecture Review Guide](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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) +@@ -145,7 +146,7 @@ For each file, check: + + ### Technique 1: The Checklist Method + +-Use checklists for consistent reviews. See [Security Review Guide](reference/security-review-guide.md) for comprehensive security checklist. ++Use checklists for consistent reviews. See [Security Review Guide](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/reference/security-review-guide.md) for comprehensive security checklist. + + ### Technique 2: The Question Approach + +@@ -193,7 +194,7 @@ Use labels to indicate priority: + | **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 审查, 异步代码, 取消安全性, 错误处理 | ++| **Rust** | [Rust Guide](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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, 异步视图 | +@@ -204,7 +205,7 @@ Use labels to indicate priority: + | **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, 空安全, 内存泄漏, 架构模式 | ++| **Kotlin / Android** | [Kotlin Guide](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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 | + | **Dart / Flutter** | [Dart Guide](reference/dart.md) | Widget rebuilds, const constructors, null safety, isolates, async in build, Riverpod/Bloc, platform channels, keys, disposal | + | **NestJS** | [NestJS Guide](reference/nestjs.md) | 依赖注入, 分层架构, DTO 验证, Guard/Interceptor, 循环依赖 | +@@ -221,19 +222,19 @@ 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 | ++| **Architecture Review** | [Architecture Review Guide](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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, 命令注入, 跨语言示例 | ++| **Security Review** | [Security Review Guide](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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 | ++| **Error Handling** | [Error Handling Guide](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/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 审查评论模板 ++- [PR Review Template](https://github.com/awesome-skills/code-review-skill/blob/4850184dfd7765ea7b3c5c4e9b6cf8e0815ee7cb/assets/pr-review-template.md) - PR 审查评论模板 + - [Review Checklist](assets/review-checklist.md) - 快速参考清单 diff --git a/tools/skill-patches/code-review-skill/reference/c.md.patch b/tools/skill-patches/code-review-skill/reference/c.md.patch new file mode 100644 index 0000000..f09a224 --- /dev/null +++ b/tools/skill-patches/code-review-skill/reference/c.md.patch @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVs6w, PRRT_kwDOVLPOVM6yVs62, PRRT_kwDOVLPOVM6yVs6y, PRRT_kwDOVLPOVM6yVs65 +--- a/reference/c.md ++++ b/reference/c.md +@@ -241,13 +241,14 @@ float f = 3.14f; + int i = *(int *)&f; // UB + + // ✅ Good: use memcpy or union (C11 allows type-punning via union) +-int i; ++_Static_assert(sizeof(uint32_t) == sizeof(float), "size mismatch"); ++uint32_t i; + memcpy(&i, &f, sizeof(i)); + + // ✅ Also acceptable in C11: +-union { float f; int i; } u; ++union { float f; uint32_t i; } u; + u.f = 3.14f; +-int i = u.i; ++uint32_t i = u.i; + ``` + + ### Shift operations +@@ -554,8 +555,8 @@ void handle(struct Msg *m) { + } + + // ✅ Good: validate before use +-void handle(struct Msg *m, size_t total_size) { +- if (m->len > total_size - sizeof(struct Msg)) { ++int handle(struct Msg *m, size_t total_size) { ++ if (total_size < sizeof(struct Msg) || m->len > total_size - sizeof(struct Msg)) { + return -EINVAL; + } + char buf[256]; +@@ -563,6 +564,7 @@ void handle(struct Msg *m, size_t total_size) { + return -E2BIG; + } + memcpy(buf, m->data, m->len); ++ return 0; + } + ``` + +@@ -670,10 +672,12 @@ typeof(x) y = x; + + // ✅ Good: use standard C or isolate extensions + // In a platform-specific header: +-#ifdef __GNUC__ ++#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L + #define TYPEOF(x) typeof(x) ++#elif defined(__GNUC__) || defined(__clang__) ++ #define TYPEOF(x) __typeof__(x) + #else +- #define TYPEOF(x) decltype(x) /* C++23 or compiler-specific */ ++ #error "TYPEOF requires C23 or a GNU-compatible compiler" + #endif + ``` + diff --git a/tools/skill-patches/code-review-skill/reference/performance-review-guide.md.patch b/tools/skill-patches/code-review-skill/reference/performance-review-guide.md.patch new file mode 100644 index 0000000..fd95eb6 --- /dev/null +++ b/tools/skill-patches/code-review-skill/reference/performance-review-guide.md.patch @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVs6_ +--- a/reference/performance-review-guide.md ++++ b/reference/performance-review-guide.md +@@ -497,12 +497,14 @@ app.get('/static-data', (req, res) => { + const compression = require('compression'); + app.use(compression()); + +-// ✅ 只返回必要字段 ++// ✅ 只返回必要字段(白名单过滤) + // 请求: GET /users?fields=id,name,email ++const ALLOWED_FIELDS = new Set(['id', 'name', 'email']); + app.get('/users', async (req, res) => { +- const fields = req.query.fields?.split(',') || ['id', 'name']; ++ const requested = typeof req.query.fields === 'string' ? req.query.fields.split(',') : []; ++ const fields = requested.filter((f) => ALLOWED_FIELDS.has(f)); + const users = await User.findAll({ +- attributes: fields, ++ attributes: fields.length ? fields : ['id', 'name'], + }); + res.json(users); + }); diff --git a/tools/skill-patches/code-review-skill/reference/react.md.patch b/tools/skill-patches/code-review-skill/reference/react.md.patch new file mode 100644 index 0000000..1e61c7f --- /dev/null +++ b/tools/skill-patches/code-review-skill/reference/react.md.patch @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVs7C, PRRT_kwDOVLPOVM6yWCLe, PRRT_kwDOVLPOVM6yWQoT, PRRT_kwDOVLPOVM6yWQoi +--- a/reference/react.md ++++ b/reference/react.md +@@ -360,21 +360,23 @@ function SlowLike({ postId, likes }) { + } + + // ✅ useOptimistic 即时反馈,失败自动回滚 +-import { useOptimistic } from 'react'; ++import { startTransition, useActionState, useOptimistic } from 'react'; + +-function FastLike({ postId, likes }) { ++function FastLike({ postId, likes }) { // 父组件渲染 ,让新文章从各自点赞数开始 ++ const [likeCount, likeAction] = useActionState(async (currentCount) => { ++ try { return await likePost(postId); } // likePost 返回服务端最新的点赞数 ++ catch { return currentCount; } // 保持原计数;React 自动回滚丢弃乐观值 ++ }, likes); + const [optimisticLikes, addOptimisticLike] = useOptimistic( +- likes, +- (currentLikes, increment: number) => currentLikes + increment ++ likeCount, ++ (current, increment: number) => current + increment + ); + +- const handleLike = async () => { +- addOptimisticLike(1); // 立即更新 UI +- try { +- await likePost(postId); // 后台同步 +- } catch { +- // React 自动回滚到 likes 原值 +- } ++ const handleLike = () => { ++ startTransition(() => { ++ addOptimisticLike(1); ++ likeAction(); ++ }); + }; + + return ; diff --git a/tools/skill-patches/code-review-skill/reference/svelte.md.patch b/tools/skill-patches/code-review-skill/reference/svelte.md.patch new file mode 100644 index 0000000..26ed4a0 --- /dev/null +++ b/tools/skill-patches/code-review-skill/reference/svelte.md.patch @@ -0,0 +1,40 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVpT-, PRRT_kwDOVLPOVM6yVs7I, PRRT_kwDOVLPOVM6yWCLh, PRRT_kwDOVLPOVM6yWQob +--- a/reference/svelte.md ++++ b/reference/svelte.md +@@ -422,10 +422,12 @@ import { fail, redirect } from '@sveltejs/kit'; + + export const actions = { + delete: async ({ request, locals }) => { ++ if (!locals.user) return fail(401, { message: 'Unauthorized' }); + const formData = await request.formData(); + const id = formData.get('id'); + +- if (!id) return fail(400, { message: 'Missing id' }); ++ if (typeof id !== 'string' || !id) return fail(400, { message: 'Missing id' }); ++ if (!locals.user.isAdmin && locals.user.id !== id) return fail(403, { message: 'Forbidden' }); + + await locals.db.users.delete(id); + throw redirect(303, '/users'); +@@ -923,9 +925,18 @@ export async function load() { + + // ✅ 私有环境变量只在 server load 中使用 + // src/routes/admin/+page.server.js (server-only) +-export async function load({ locals }) { ++import { error } from '@sveltejs/kit'; ++import { SECRET_API_KEY } from '$env/static/private'; ++ ++export async function load({ fetch, locals }) { ++ // locals.user is set by the app's handle hook ++ if (!locals.user?.isAdmin) error(403, 'Forbidden'); ++ + // secrets stay on server +- const data = await fetch(process.env.SECRET_API_KEY + '/admin'); ++ const res = await fetch('https://api.example.com/admin', { ++ headers: { Authorization: `Bearer ${SECRET_API_KEY}` }, ++ }); ++ const data = await res.json(); + return { data }; // only derived data is sent to client + } + diff --git a/tools/skill-patches/code-review-skill/reference/typescript.md.patch b/tools/skill-patches/code-review-skill/reference/typescript.md.patch new file mode 100644 index 0000000..e81c863 --- /dev/null +++ b/tools/skill-patches/code-review-skill/reference/typescript.md.patch @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVs7Q, PRRT_kwDOVLPOVM6yWCLj +--- a/reference/typescript.md ++++ b/reference/typescript.md +@@ -947,14 +947,19 @@ function processFile() { + ```typescript + // ✅ AsyncDisposable 用于异步资源(TS 5.2+) + class DatabaseConnection implements AsyncDisposable { +- private db: sqlite3.Database; ++ private db: sqlite3.Database | null = null; + + async connect() { + this.db = new sqlite3.Database(':memory:'); + } + + async [Symbol.asyncDispose]() { +- await this.db.close(); ++ const db = this.db; ++ if (!db) return; ++ this.db = null; ++ await new Promise((resolve, reject) => { ++ db.close((err) => (err ? reject(err) : resolve())); ++ }); + } + } + diff --git a/tools/skill-patches/code-review-skill/reference/vue.md.patch b/tools/skill-patches/code-review-skill/reference/vue.md.patch new file mode 100644 index 0000000..e372dcb --- /dev/null +++ b/tools/skill-patches/code-review-skill/reference/vue.md.patch @@ -0,0 +1,46 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVs7T +--- a/reference/vue.md ++++ b/reference/vue.md +@@ -692,27 +692,35 @@ export function useFetch(url: MaybeRefOrGetter) { + const data = ref(null) + const error = ref(null) + const loading = ref(false) ++ let controller: AbortController | null = null + + const execute = async () => { ++ controller?.abort() ++ const current = new AbortController() ++ controller = current + loading.value = true + error.value = null + + try { +- const response = await fetch(toValue(url)) ++ const response = await fetch(toValue(url), { signal: current.signal }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + data.value = await response.json() + } catch (e) { +- error.value = e as Error ++ if ((e as Error).name !== 'AbortError') { ++ error.value = e as Error ++ } + } finally { +- loading.value = false ++ if (controller === current) { ++ loading.value = false ++ } + } + } + + // 响应式 URL 时自动重新获取 +- watchEffect(() => { +- toValue(url) // 追踪依赖 ++ watchEffect((onCleanup) => { ++ onCleanup(() => controller?.abort()) + execute() + }) + diff --git a/tools/skill-patches/code-review-skill/reference/zig.md.patch b/tools/skill-patches/code-review-skill/reference/zig.md.patch new file mode 100644 index 0000000..f989efa --- /dev/null +++ b/tools/skill-patches/code-review-skill/reference/zig.md.patch @@ -0,0 +1,16 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVs7X +--- a/reference/zig.md ++++ b/reference/zig.md +@@ -35,7 +35,9 @@ fn readNamesBad() ![][]const u8 { + + // ✅ Good: caller chooses allocator and owns the returned memory. + fn readNames(allocator: std.mem.Allocator) ![][]const u8 { +- return try allocator.alloc([]const u8, 10); ++ const names = try allocator.alloc([]const u8, 10); ++ @memset(names, ""); ++ return names; + } + ``` + diff --git a/tools/skill-patches/code-review-skill/scripts/pr-analyzer.py.patch b/tools/skill-patches/code-review-skill/scripts/pr-analyzer.py.patch new file mode 100644 index 0000000..36ba1b1 --- /dev/null +++ b/tools/skill-patches/code-review-skill/scripts/pr-analyzer.py.patch @@ -0,0 +1,144 @@ +# SPDX-FileCopyrightText: 2026 Lusoris +# SPDX-License-Identifier: EUPL-1.2 +# threads: PRRT_kwDOVLPOVM6yVpUJ +--- a/scripts/pr-analyzer.py ++++ b/scripts/pr-analyzer.py +@@ -1,4 +1,3 @@ +-#!/usr/bin/env python3 + """ + PR Analyzer - Analyze PR complexity and suggest review approach. + +@@ -9,13 +8,12 @@ + git diff main...HEAD | python pr-analyzer.py + """ + ++import argparse + import os +-import sys + import re +-import argparse ++import sys + from collections import defaultdict + from dataclasses import dataclass +-from typing import List, Dict, Optional + + RISK_NO_TESTS = "NO_TEST_CHANGES" + +@@ -37,12 +35,12 @@ + total_files: int + total_additions: int + total_deletions: int +- files: List[FileStats] ++ files: list[FileStats] + complexity_score: float + size_category: str + estimated_review_time: int +- risk_factors: List[str] +- suggestions: List[str] ++ risk_factors: list[str] ++ suggestions: list[str] + + + def detect_language(filename: str) -> str: +@@ -162,13 +160,10 @@ + return True + + # Files under a config/ directory +- if re.search(r'(?:^|/)config/', filename): +- return True +- +- return False ++ return bool(re.search(r'(?:^|/)config/', filename)) + + +-def parse_diff(diff_content: str) -> List[FileStats]: ++def parse_diff(diff_content: str) -> list[FileStats]: + """Parse git diff output and extract file statistics.""" + files = [] + current_file = None +@@ -207,7 +202,7 @@ + return files + + +-def calculate_complexity(files: List[FileStats]) -> float: ++def calculate_complexity(files: list[FileStats]) -> float: + """Calculate complexity score (0-1 scale).""" + if not files: + return 0.0 +@@ -225,7 +220,7 @@ + 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') ++ languages = {f.language for f in files if f.language != 'unknown'} + lang_factor = min(len(languages) / 5, 1.0) + + complexity = ( +@@ -252,7 +247,7 @@ + return "XL (Extra Large) - Consider splitting" + + +-def estimate_review_time(files: List[FileStats], complexity: float) -> int: ++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) + +@@ -266,7 +261,7 @@ + return max(5, min(120, int(adjusted_time))) + + +-def identify_risk_factors(files: List[FileStats]) -> List[str]: ++def identify_risk_factors(files: list[FileStats]) -> list[str]: + """Identify potential risk factors in the PR.""" + risks = [] + +@@ -303,7 +298,7 @@ + return risks + + +-def generate_suggestions(files: List[FileStats], complexity: float, risks: List[str]) -> List[str]: ++def generate_suggestions(files: list[FileStats], complexity: float, risks: list[str]) -> list[str]: + """Generate review suggestions.""" + suggestions = [] + +@@ -320,7 +315,7 @@ + suggestions.append("Request test additions before approval") + + # Language-specific suggestions +- languages = set(f.language for f in files) ++ languages = {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: +@@ -367,7 +362,7 @@ + print("PR ANALYSIS REPORT") + print("=" * 60) + +- print(f"\n📊 SUMMARY") ++ print("\n📊 SUMMARY") + print(f" Files changed: {analysis.total_files}") + print(f" Additions: +{analysis.total_additions}") + print(f" Deletions: -{analysis.total_deletions}") +@@ -378,18 +373,18 @@ + print(f" Estimated review time: ~{analysis.estimated_review_time} minutes") + + if analysis.risk_factors: +- print(f"\n⚠️ RISK FACTORS:") ++ print("\n⚠️ RISK FACTORS:") + for risk in analysis.risk_factors: + print(f" • {risk}") + +- print(f"\n💡 SUGGESTIONS:") ++ print("\n💡 SUGGESTIONS:") + for suggestion in analysis.suggestions: + print(f" • {suggestion}") + + if show_files: +- print(f"\n📁 FILES:") ++ print("\n📁 FILES:") + # Group by language +- by_lang: Dict[str, List[FileStats]] = defaultdict(list) ++ by_lang: dict[str, list[FileStats]] = defaultdict(list) + for f in analysis.files: + by_lang[f.language].append(f) + diff --git a/tools/test_adopt_skills.py b/tools/test_adopt_skills.py index ff1e3a5..0a7ec44 100644 --- a/tools/test_adopt_skills.py +++ b/tools/test_adopt_skills.py @@ -704,6 +704,286 @@ def test_fresh_candidate_with_colliding_name_gets_owner_suffix(self): self.assertIn('name changed from demo to demo-owner', (skill / 'ORIGIN.md').read_text()) self.assertEqual(check_skills.check(self.root), 0) + def test_checkout_without_git_is_refused(self): + checkout = self.checkouts / 'owner__nogit' + checkout.mkdir() + (checkout / 'SKILL.md').write_text(MD.format(name='nogit'), encoding='utf-8') + (checkout / 'LICENSE').write_text(LICENCE, encoding='utf-8') + self.candidate(name='nogit', folder='', source='owner/nogit', pin='4' * 40) + rows, _ = self.invoke(apply=True) + self.assertEqual(rows[0]['verdict'], 'refused', rows) + self.assertEqual(rows[0]['reason'], 'missing-git-checkout') + self.assertFalse((self.root / 'skills/nogit').exists()) + + def test_normalizer_refuses_inputs_outside_the_skill_folder(self): + """An absolute path, a .. segment or a link out of the folder is refused before any install or write.""" + folder = self.base / 'norm-inputs' + folder.mkdir() + (folder / 'ok.md').write_text('# Ok\n', encoding='utf-8') + outside = self.base / 'outside.md' + outside.write_text('# Outside\n', encoding='utf-8') + (folder / 'link.md').symlink_to(outside) + script = REPO / 'tools/markdownlint/normalize.mjs' + node_bin = shutil.which('node') + if not node_bin: + self.fail('node executable not found on PATH') + for bad in (str(outside), '../outside.md', 'link.md'): + proc = subprocess.run([node_bin, str(script), bad], cwd=folder, capture_output=True, + check=False, timeout=60) + self.assertNotEqual(proc.returncode, 0, bad) + self.assertIn(b'refusing Markdown input', proc.stderr, (bad, proc.stderr[:300])) + self.assertEqual(outside.read_text(encoding='utf-8'), '# Outside\n') + + def test_whitespace_arbiter_omits_a_file_the_normalizer_changes_beyond_whitespace(self): + """Ruling peritia-code-review-skill-markdown-refinement-0013: a file whose text changes beyond whitespace + (fence tags aside) stays out and ORIGIN.md lists it; the other files stay normalized.""" + pin = self.source({ + 'pack/demo/SKILL.md': MD.format(name='demo'), + 'pack/demo/LICENSE': LICENCE, + 'pack/demo/reference/guide.md': '# Guide\n\nIntro paragraph.\n', + 'pack/demo/reference/keep.md': '# Keep\n\nStays as is.\n', + }) + self.candidate(pin=pin) + shutil.copytree(REPO / 'tools/markdownlint', self.root / 'tools/markdownlint') + fake = self.root / 'tools/markdownlint/normalize.mjs' + fake.write_text( + 'import fs from "node:fs";\n' + 'const out = {};\n' + 'for (const f of process.argv.slice(2)) {\n' + ' if (f.endsWith("guide.md")) {\n' + ' fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace("Intro", "Introduction"));\n' + ' out[f] = ["MD022"];\n' + ' }\n' + '}\n' + 'process.stdout.write(JSON.stringify(out));\n', + encoding='utf-8') + rows, _ = self.invoke(apply=True, gate=adopt.markdown_gate) + self.assertEqual(rows[0]['verdict'], 'adopted', rows) + skill = self.root / 'skills/demo' + self.assertFalse((skill / 'reference/guide.md').exists()) + self.assertTrue((skill / 'reference/keep.md').is_file()) + origin = (skill / 'ORIGIN.md').read_text(encoding='utf-8') + self.assertIn('omitted files the whitespace normalizer would change beyond whitespace (reference/guide.md)', origin) + + def test_markdown_normalizer_fixes_supporting_files_and_records_rules_and_fails_closed(self): + unnormalized_ref = ( + '# Reference\n' + '## Tight Heading\n' + 'Intro paragraph:\n' + '- item one\n' + '```python\n' + 'x = 1\n' + '```\n' + 'After fence.\n\n' + '| Header One | Header Two |\n' + '|---|---|\n' + '| 1 | 2 |' + ) + pin = self.source({ + 'pack/demo/SKILL.md': MD.format(name='demo'), + 'pack/demo/LICENSE': LICENCE, + 'pack/demo/reference/guide.md': unnormalized_ref, + }) + self.candidate(pin=pin) + shutil.copytree(REPO / 'tools/markdownlint', self.root / 'tools/markdownlint') + rows, _ = self.invoke(apply=True, gate=adopt.markdown_gate) + self.assertEqual(rows[0]['verdict'], 'adopted', rows) + skill = self.root / 'skills/demo' + self.assertTrue((skill / 'reference/guide.md').is_file()) + origin = (skill / 'ORIGIN.md').read_text(encoding='utf-8') + self.assertIn( + 'normalized Markdown whitespace (reference/guide.md (MD022, MD031, MD032, MD047, MD060))', + origin, + ) + fenced_upstream = self.base / 'fenced-upstream.md' + fenced_text, _ = adopt.rewrite_fences(unnormalized_ref) + fenced_upstream.write_text(fenced_text, encoding='utf-8') + git_bin = shutil.which('git') + if not git_bin: + self.fail('git executable not found on PATH') + diff_proc = subprocess.run( + [git_bin, 'diff', '--no-index', '-w', '--ignore-blank-lines', + str(fenced_upstream), str(skill / 'reference/guide.md')], + capture_output=True, check=False, + ) + self.assertEqual(diff_proc.returncode, 0, diff_proc.stdout.decode('utf-8', errors='replace')) + self.assertEqual(diff_proc.stdout, b'') + # Fail-closed when normalizer script is broken or absent + shutil.rmtree(skill) + (self.root / 'tools/markdownlint/normalize.mjs').unlink() + rows_fail, _ = self.invoke(apply=True, gate=adopt.markdown_gate) + self.assertEqual(rows_fail[0]['verdict'], 'refused', rows_fail) + self.assertEqual(rows_fail[0]['reason'], 'markdown-normalize') + self.assertFalse(skill.exists()) + + def test_root_skill_omits_furniture_workbench_and_gate_refused_files(self): + files = { + 'SKILL.md': MD.format(name='root-skill'), + 'LICENSE': LICENCE, + 'README.md': '# Repo readme\n', + 'index.html': '\n', + '.gitignore': '*.pyc\n', + '.hive/PROTOCOL.md': '# Hive\n', + 'reference/good.md': '# Good\n\nValid reference text.\n', + 'reference/bad.md': '#BadHeading\n', + } + pin = self.source(files, source='owner/root-skill') + self.candidate(name='root-skill', folder='', source='owner/root-skill', pin=pin) + shutil.copytree(REPO / 'tools/markdownlint', self.root / 'tools/markdownlint') + rows, _ = self.invoke(apply=True, gate=adopt.markdown_gate) + self.assertEqual(rows[0]['verdict'], 'adopted', rows) + skill = self.root / 'skills/root-skill' + self.assertTrue((skill / 'SKILL.md').is_file()) + self.assertTrue((skill / 'reference/good.md').is_file()) + self.assertFalse((skill / 'README.md').exists()) + self.assertFalse((skill / 'index.html').exists()) + self.assertFalse((skill / '.gitignore').exists()) + self.assertFalse((skill / '.hive').exists()) + self.assertFalse((skill / 'reference/bad.md').exists()) + origin = (skill / 'ORIGIN.md').read_text(encoding='utf-8') + self.assertIn('path: .', origin) + self.assertIn('omitted repository furniture (.gitignore, README.md, index.html)', origin) + self.assertIn('omitted agent workbench files (.hive/PROTOCOL.md)', origin) + self.assertIn('omitted files refused by markdown gate (reference/bad.md (MD018))', origin) + self.assertEqual(check_skills.check(self.root), 0) + + def test_arbiter_planted_listed_patch_passes(self): + pin = self.source({ + 'pack/demo/SKILL.md': MD.format(name='demo'), + 'pack/demo/LICENSE': LICENCE, + 'pack/demo/reference/guide.md': '# Guide\n## Tight\nOld line.\n', + }) + self.candidate(pin=pin) + shutil.copytree(REPO / 'tools/markdownlint', self.root / 'tools/markdownlint') + patch_file = self.root / 'tools/skill-patches/demo/reference/guide.md.patch' + patch_file.parent.mkdir(parents=True, exist_ok=True) + patch_file.write_text( + '# threads: PRRT_test1, PRRT_test2\n' + '--- a/reference/guide.md\n' + '+++ b/reference/guide.md\n' + '@@ -3,3 +3,3 @@\n' + ' ## Tight\n' + ' \n' + '-Old line.\n' + '+New line.\n', + encoding='utf-8', + ) + rows, _ = self.invoke(apply=True, gate=adopt.markdown_gate) + self.assertEqual(rows[0]['verdict'], 'adopted', rows) + skill = self.root / 'skills/demo' + self.assertEqual((skill / 'reference/guide.md').read_text(encoding='utf-8'), '# Guide\n\n## Tight\n\nNew line.\n') + origin = (skill / 'ORIGIN.md').read_text(encoding='utf-8') + self.assertIn('applied review patches (reference/guide.md (PRRT_test1, PRRT_test2))', origin) + + def test_arbiter_planted_unlisted_edit_is_refused(self): + pin = self.source({ + 'pack/demo/SKILL.md': MD.format(name='demo'), + 'pack/demo/LICENSE': LICENCE, + 'pack/demo/reference/listed.md': '# Listed\n\nOld text.\n', + 'pack/demo/reference/unlisted.md': '# Unlisted\n\nOriginal text.\n', + }) + self.candidate(pin=pin) + shutil.copytree(REPO / 'tools/markdownlint', self.root / 'tools/markdownlint') + patch_file = self.root / 'tools/skill-patches/demo/reference/listed.md.patch' + patch_file.parent.mkdir(parents=True, exist_ok=True) + patch_file.write_text( + '# threads: PRRT_listed\n' + '--- a/reference/listed.md\n' + '+++ b/reference/listed.md\n' + '@@ -1,3 +1,3 @@\n' + ' # Listed\n' + ' \n' + '-Old text.\n' + '+Patched text.\n', + encoding='utf-8', + ) + fake = self.root / 'tools/markdownlint/normalize.mjs' + fake.write_text( + 'import fs from "node:fs";\n' + 'const out = {};\n' + 'for (const f of process.argv.slice(2)) {\n' + ' if (f.endsWith("unlisted.md")) {\n' + ' fs.writeFileSync(f, fs.readFileSync(f, "utf8").replace("Original", "Tampered"));\n' + ' out[f] = ["MD022"];\n' + ' }\n' + '}\n' + 'process.stdout.write(JSON.stringify(out));\n', + encoding='utf-8', + ) + rows, _ = self.invoke(apply=True, gate=adopt.markdown_gate) + self.assertEqual(rows[0]['verdict'], 'adopted', rows) + skill = self.root / 'skills/demo' + self.assertTrue((skill / 'reference/listed.md').is_file()) + self.assertEqual((skill / 'reference/listed.md').read_text(encoding='utf-8'), '# Listed\n\nPatched text.\n') + self.assertFalse((skill / 'reference/unlisted.md').exists()) + # Direct arbiter check: even a file with a patch is dropped if an extra unlisted edit is present. + (skill / 'reference/listed.md').write_text('# Listed\n\nPatched text plus unlisted edit.\n', encoding='utf-8') + dropped = adopt.whitespace_arbiter( + skill, + {'reference/listed.md': '# Listed\n\nOld text.\n'}, + patch_dir=self.root / 'tools/skill-patches/demo', + ) + self.assertEqual(dropped, ['reference/listed.md']) + self.assertFalse((skill / 'reference/listed.md').exists()) + + def test_arbiter_planted_unapplicable_patch_fails_adoption(self): + pin = self.source({ + 'pack/demo/SKILL.md': MD.format(name='demo'), + 'pack/demo/LICENSE': LICENCE, + 'pack/demo/reference/guide.md': '# Guide\n\nUpstream bumped this line.\n', + }) + self.candidate(pin=pin) + shutil.copytree(REPO / 'tools/markdownlint', self.root / 'tools/markdownlint') + patch_file = self.root / 'tools/skill-patches/demo/reference/guide.md.patch' + patch_file.parent.mkdir(parents=True, exist_ok=True) + patch_file.write_text( + '# threads: PRRT_stale\n' + '--- a/reference/guide.md\n' + '+++ b/reference/guide.md\n' + '@@ -1,3 +1,3 @@\n' + ' # Guide\n' + ' \n' + '-Old line that no longer exists.\n' + '+Patched line.\n', + encoding='utf-8', + ) + rows, _ = self.invoke(apply=True, gate=adopt.markdown_gate) + self.assertEqual(rows[0]['verdict'], 'refused', rows) + self.assertIn('reference/guide.md', rows[0]['reason']) + self.assertFalse((self.root / 'skills/demo').exists()) + + +class RewriteFencesTests(unittest.TestCase): + def test_top_level_four_space_backticks_unchanged(self): + text = " ```\n code\n ```\n" + new_text, changed = adopt.rewrite_fences(text) + self.assertFalse(changed) + self.assertEqual(new_text, text) + + def test_nested_list_four_space_fence_rewritten(self): + text = "* item\n ```\n code\n ```\n" + new_text, changed = adopt.rewrite_fences(text) + self.assertTrue(changed) + self.assertEqual(new_text, "* item\n ```text\n code\n ```\n") + + def test_tilde_fence_rewritten(self): + text = "~~~\ncode\n~~~\n" + new_text, changed = adopt.rewrite_fences(text) + self.assertTrue(changed) + self.assertEqual(new_text, "~~~text\ncode\n~~~\n") + + def test_unclosed_fence_rewritten(self): + text = "```\nunclosed code\n" + new_text, changed = adopt.rewrite_fences(text) + self.assertTrue(changed) + self.assertEqual(new_text, "```text\nunclosed code\n") + + def test_fence_with_info_unchanged(self): + text = "```python\nprint('hello')\n```\n" + new_text, changed = adopt.rewrite_fences(text) + self.assertFalse(changed) + self.assertEqual(new_text, text) + if __name__ == '__main__': unittest.main() diff --git a/tools/test_check_skills.py b/tools/test_check_skills.py index 160bba0..867eba7 100644 --- a/tools/test_check_skills.py +++ b/tools/test_check_skills.py @@ -68,6 +68,97 @@ def test_duplicate_row_refused(self): problems = check_skills.index_problems(self.root) self.assertTrue(any("duplicate row for alpha" in p for p in problems), problems) + def test_workbench_and_agent_control_files_refused(self): + alpha = self.root / "skills" / "alpha" + (alpha / ".hive").mkdir() + (alpha / ".hive" / "PROTOCOL.md").write_text("# Protocol\n", encoding="utf-8") + problems = check_skills.skill_problems(alpha) + self.assertTrue(any("alpha/.hive/PROTOCOL.md" in p for p in problems), problems) + (alpha / ".hive" / "PROTOCOL.md").unlink() + (alpha / ".hive").rmdir() + self.assertEqual(check_skills.skill_problems(alpha), []) + + (alpha / "notes.md").write_text("Prose mentioning .hive and PROTOCOL.md stays valid.\n", encoding="utf-8") + self.assertEqual(check_skills.skill_problems(alpha), []) + + def test_dangling_skill_md_link_refused(self): + alpha = self.root / "skills" / "alpha" + (alpha / "SKILL.md").write_text( + MD.format(name="alpha") + "\nSee [Guide](reference/missing.md) and [Present](notes.md).\n", + encoding="utf-8", + ) + (alpha / "notes.md").write_text("# Present\n", encoding="utf-8") + problems = check_skills.skill_problems(alpha) + self.assertTrue(any("links to missing file reference/missing.md" in p for p in problems), problems) + self.assertFalse(any("notes.md" in p for p in problems), problems) + + def test_planted_tilde_fence_containing_backtick_fence(self): + alpha = self.root / "skills" / "alpha" + (alpha / "SKILL.md").write_text( + MD.format(name="alpha") + + "\n~~~\n```\n[Inside](reference/inside-tilde.md)\n~~~\n" + + "See [After](reference/after-tilde.md).\n", + encoding="utf-8", + ) + problems = check_skills.skill_problems(alpha) + self.assertFalse(any("inside-tilde.md" in p for p in problems), problems) + self.assertTrue(any("links to missing file reference/after-tilde.md" in p for p in problems), problems) + + def test_planted_quad_fence_containing_triple_python_fence(self): + alpha = self.root / "skills" / "alpha" + (alpha / "SKILL.md").write_text( + MD.format(name="alpha") + + "\n````markdown\n```python\n[Inside](reference/inside-quad.md)\n````\n" + + "See [After](reference/after-quad.md).\n", + encoding="utf-8", + ) + problems = check_skills.skill_problems(alpha) + self.assertFalse(any("inside-quad.md" in p for p in problems), problems) + self.assertTrue(any("links to missing file reference/after-quad.md" in p for p in problems), problems) + + def test_top_level_four_space_backticks_is_no_fence_and_link_after_checked(self): + alpha = self.root / "skills" / "alpha" + (alpha / "SKILL.md").write_text( + MD.format(name="alpha") + + "\n ```\n[Missing](reference/missing.md)\n", + encoding="utf-8", + ) + problems = check_skills.skill_problems(alpha) + self.assertTrue(any("links to missing file reference/missing.md" in p for p in problems), problems) + + def test_nested_list_four_space_fence_skips_link_inside(self): + alpha = self.root / "skills" / "alpha" + (alpha / "SKILL.md").write_text( + MD.format(name="alpha") + + "\n* item\n ```\n [Inside](reference/inside.md)\n ```\nSee [After](reference/after.md).\n", + encoding="utf-8", + ) + problems = check_skills.skill_problems(alpha) + self.assertFalse(any("inside.md" in p for p in problems), problems) + self.assertTrue(any("links to missing file reference/after.md" in p for p in problems), problems) + + def test_tilde_fence_skips_link_inside(self): + alpha = self.root / "skills" / "alpha" + (alpha / "SKILL.md").write_text( + MD.format(name="alpha") + + "\n~~~\n[Inside](reference/inside.md)\n~~~\nSee [After](reference/after.md).\n", + encoding="utf-8", + ) + problems = check_skills.skill_problems(alpha) + self.assertFalse(any("inside.md" in p for p in problems), problems) + self.assertTrue(any("links to missing file reference/after.md" in p for p in problems), problems) + + def test_unclosed_fence_runs_to_end_and_skips_links(self): + alpha = self.root / "skills" / "alpha" + (alpha / "SKILL.md").write_text( + MD.format(name="alpha") + + "\n```\n[Inside](reference/inside.md)\n[Still Inside](reference/still-inside.md)\n", + encoding="utf-8", + ) + problems = check_skills.skill_problems(alpha) + self.assertFalse(any("inside.md" in p for p in problems), problems) + self.assertFalse(any("still-inside.md" in p for p in problems), problems) + if __name__ == "__main__": unittest.main()