diff --git a/.agents/skills/better-auth-best-practices/SKILL.md b/.agents/skills/better-auth-best-practices/SKILL.md new file mode 100644 index 0000000..74195ce --- /dev/null +++ b/.agents/skills/better-auth-best-practices/SKILL.md @@ -0,0 +1,192 @@ +--- +name: better-auth-best-practices +description: Configure Better Auth server and client, set up database adapters, manage sessions, add plugins, and handle environment variables. Use when users mention Better Auth, betterauth, auth.ts, or need to set up TypeScript authentication with email/password, OAuth, or plugin configuration. +--- + +# Better Auth Integration Guide + +## Documentation Version + +Use documentation that matches the Better Auth version installed in the project. APIs and plugin names can differ across maintained release lines. + +1. Prefer a version explicitly named by the user. +2. Otherwise, inspect the resolved `better-auth` version in the lockfile, falling back to the package manifest when no lockfile is available. +3. When the Better Auth MCP is available, call `get_doc` with `/llms.txt` to resolve that package version to a documentation identifier. Pass the identifier to every `search_docs` call and pass result paths to `get_doc` unchanged. +4. Without MCP, start at [better-auth.com/llms.txt](https://better-auth.com/llms.txt) and follow the matching version index. +5. Use the latest documentation only when the project version cannot be determined or the user explicitly asks about the latest release or an upgrade. + +When planning an upgrade, separate guidance for the currently installed version from guidance for the target version. + +--- + +## Setup Workflow + +1. Install: `npm install better-auth` +2. Set env vars: `BETTER_AUTH_SECRET` and `BETTER_AUTH_URL` +3. Create `auth.ts` with database + config +4. Create route handler for your framework +5. Run migrations: + - **Built-in adapter:** `npx auth@latest migrate` + - **Drizzle:** `npx auth@latest generate --output src/db/auth-schema.ts` then `npx drizzle-kit push` (dev) or `npx drizzle-kit generate && npx drizzle-kit migrate` (prod) + - **Prisma:** `npx auth@latest generate --output prisma/schema.prisma` then `npx prisma migrate dev` +6. Verify: call `GET /api/auth/ok` — should return `{ status: "ok" }` + +--- + +## Quick Reference + +### Environment Variables +- `BETTER_AUTH_SECRET` - Encryption secret (min 32 chars). Generate: `openssl rand -base64 32` +- `BETTER_AUTH_URL` - Base URL (e.g., `https://example.com`) + +Only define `baseURL`/`secret` in config if env vars are NOT set. + +### File Location +CLI looks for `auth.ts` in: `./`, `./lib`, `./utils`, or under `./src`. Use `--config` for custom path. + +### CLI Commands +- `npx auth@latest migrate` - Apply schema (built-in adapter) +- `npx auth@latest generate` - Generate schema for Prisma/Drizzle +- `npx auth@latest mcp --cursor` - Add MCP to AI tools + +**Re-run after adding/changing plugins.** + +--- + +## Core Config Options + +| Option | Notes | +|--------|-------| +| `appName` | Optional display name | +| `baseURL` | Only if `BETTER_AUTH_URL` not set | +| `basePath` | Default `/api/auth`. Set `/` for root. | +| `secret` | Only if `BETTER_AUTH_SECRET` not set | +| `database` | Required for most features. See adapters docs. | +| `secondaryStorage` | Redis/KV for sessions & rate limits | +| `emailAndPassword` | `{ enabled: true }` to activate | +| `socialProviders` | `{ google: { clientId, clientSecret }, ... }` | +| `plugins` | Array of plugins | +| `trustedOrigins` | CSRF whitelist | + +--- + +## Database + +**Direct connections:** Pass `pg.Pool`, `mysql2` pool, `better-sqlite3`, or `bun:sqlite` instance. For Postgres, also supports `postgres` (postgres.js) and `@neondatabase/serverless`. + +**ORM adapters:** Import from `better-auth/adapters/drizzle`, `better-auth/adapters/prisma`, `better-auth/adapters/mongodb`. + +**Drizzle provider values:** `"pg"` (PostgreSQL), `"mysql"` (MySQL), `"sqlite"` (SQLite). Must match the driver used. + +**Critical:** Better Auth uses adapter model names, NOT underlying table names. If Prisma model is `User` mapping to table `users`, use `modelName: "user"` (Prisma reference), not `"users"`. + +--- + +## Session Management + +**Storage priority:** +1. If `secondaryStorage` defined → sessions go there (not DB) +2. Set `session.storeSessionInDatabase: true` to also persist to DB +3. No database + `cookieCache` → fully stateless mode + +**Cookie cache strategies:** +- `compact` (default) - Base64url + HMAC. Smallest. +- `jwt` - Standard JWT. Readable but signed. +- `jwe` - Encrypted. Maximum security. + +**Key options:** `session.expiresIn` (default 7 days), `session.updateAge` (refresh interval), `session.cookieCache.maxAge`, `session.cookieCache.version` (change to invalidate all sessions). + +--- + +## User & Account Config + +**User:** `user.modelName`, `user.fields` (column mapping), `user.additionalFields`, `user.changeEmail.enabled` (disabled by default), `user.deleteUser.enabled` (disabled by default). + +**Account:** `account.modelName`, `account.accountLinking.enabled`, `account.storeAccountCookie` (for stateless OAuth). + +**Required for registration:** `email` and `name` fields. + +--- + +## Email Flows + +- `emailVerification.sendVerificationEmail` - Must be defined for verification to work +- `emailVerification.sendOnSignUp` / `sendOnSignIn` - Auto-send triggers +- `emailAndPassword.sendResetPassword` - Password reset email handler + +--- + +## Security + +**In `advanced`:** +- `useSecureCookies` - Force HTTPS cookies +- `disableCSRFCheck` - ⚠️ Security risk +- `disableOriginCheck` - ⚠️ Security risk +- `crossSubDomainCookies.enabled` - Share cookies across subdomains +- `ipAddress.ipAddressHeaders` - Custom IP headers for proxies +- `database.generateId` - Custom ID generation or `"serial"`/`"uuid"`/`false` + +**Rate limiting:** `rateLimit.enabled`, `rateLimit.window`, `rateLimit.max`, `rateLimit.storage` ("memory" | "database" | "secondary-storage"). + +--- + +## Hooks + +**Endpoint hooks:** `hooks.before` / `hooks.after` - Array of `{ matcher, handler }`. Use `createAuthMiddleware`. Access `ctx.path`, `ctx.context.returned` (after), `ctx.context.session`. + +**Database hooks:** `databaseHooks.user.create.before/after`, same for `session`, `account`. Useful for adding default values or post-creation actions. + +**Hook context (`ctx.context`):** `session`, `secret`, `authCookies`, `password.hash()`/`verify()`, `adapter`, `internalAdapter`, `generateId()`, `tables`, `baseURL`. + +--- + +## Plugins + +**Import from dedicated paths for tree-shaking:** +``` +import { twoFactor } from "better-auth/plugins/two-factor" +``` +NOT `from "better-auth/plugins"`. + +**Popular plugins:** `twoFactor`, `organization`, `passkey`, `magicLink`, `emailOtp`, `username`, `phoneNumber`, `admin`, `apiKey`, `bearer`, `jwt`, `multiSession`, `sso`, `oauthProvider`, `oidcProvider`, `openAPI`, `genericOAuth`. + +Client plugins go in `createAuthClient({ plugins: [...] })`. + +--- + +## Client + +Import from: `better-auth/client` (vanilla), `better-auth/react`, `better-auth/vue`, `better-auth/svelte`, `better-auth/solid`. + +Key methods: `signUp.email()`, `signIn.email()`, `signIn.social()`, `signOut()`, `useSession()`, `getSession()`, `revokeSession()`, `revokeSessions()`. + +--- + +## Type Safety + +Infer types: `typeof auth.$Infer.Session`, `typeof auth.$Infer.Session.user`. + +For separate client/server projects: `createAuthClient()`. + +--- + +## Common Gotchas + +1. **Model vs table name** - Config uses ORM model name, not DB table name +2. **Plugin schema** - Re-run CLI after adding plugins +3. **Secondary storage** - Sessions go there by default, not DB +4. **Cookie cache** - Custom session fields NOT cached, always re-fetched +5. **Stateless mode** - No DB = session in cookie only, logout on cache expiry +6. **Change email flow** - Sends to current email first, then new email +7. **Drizzle: db not initialized** - `drizzleAdapter(db, ...)` requires a `db` instance from `drizzle()`. See `create-auth` skill for setup examples (node-postgres, postgres.js, Neon). +8. **Drizzle: missing drizzle.config.ts** - `drizzle-kit` commands require a `drizzle.config.ts` pointing to the generated schema file and DB credentials. + +--- + +## Resources + +- [Docs](https://better-auth.com/docs) +- [Options Reference](https://better-auth.com/docs/reference/options) +- [LLMs.txt](https://better-auth.com/llms.txt) +- [GitHub](https://github.com/better-auth/better-auth) +- [Init Options Source](https://github.com/better-auth/better-auth/blob/main/packages/core/src/types/init-options.ts) diff --git a/.agents/skills/clean-code-principles/AGENTS.md b/.agents/skills/clean-code-principles/AGENTS.md new file mode 100644 index 0000000..b9b1035 --- /dev/null +++ b/.agents/skills/clean-code-principles/AGENTS.md @@ -0,0 +1,450 @@ +# Clean Code Principles - Agent Documentation + +**Version:** 1.0.2 +**Focus:** SOLID Principles, Core Principles (DRY, KISS, YAGNI), Design Patterns +**Rules:** 23 (10 SOLID + 12 Core + 1 Pattern); 4 categories planned +**License:** MIT + +--- + +This skill provides comprehensive clean code principles, SOLID guidelines, and design patterns for building maintainable, scalable software. + +## Overview + +The clean-code-principles skill offers language-agnostic software design principles organized into 7 categories, from CRITICAL (SOLID, Core Principles) to LOW priority (Comments). Each rule provides bad/good examples, explanations, and practical guidance. + +## When to Use This Skill + +Activate this skill when: +- Reviewing code architecture or design +- Refactoring existing code +- Making design decisions +- Establishing coding standards +- Teaching software design principles +- Addressing technical debt +- Improving code quality and maintainability + +## Trigger Phrases + +The skill activates on: +- "review architecture" +- "check code quality" +- "SOLID principles" +- "design patterns" +- "clean code" +- "refactoring advice" +- "code smells" +- "best practices" +- "DRY principle" +- "separation of concerns" + +## Skill Structure + +``` +clean-code-principles/ +├── SKILL.md # Main skill definition +├── AGENTS.md # This file - agent documentation +├── README.md # User-facing documentation +├── metadata.json # Structured metadata and references +└── rules/ + ├── _sections.md # Category definitions and organization + ├── _template.md # Template for new rules + ├── solid-*.md # SOLID principles (10 rules) + ├── core-*.md # Core principles (12 rules) + └── pattern-*.md # Design patterns (1 rule) +``` + +## Rule Categories + +### 1. SOLID Principles (CRITICAL - 10 rules) +**Prefix:** `solid-` + +Five fundamental object-oriented design principles: +- **S**ingle Responsibility: `solid-srp-class`, `solid-srp-function` +- **O**pen/Closed: `solid-ocp-extension`, `solid-ocp-abstraction` +- **L**iskov Substitution: `solid-lsp-contracts`, `solid-lsp-preconditions` +- **I**nterface Segregation: `solid-isp-clients`, `solid-isp-interfaces` +- **D**ependency Inversion: `solid-dip-abstractions`, `solid-dip-injection` + +**Use when:** Designing architecture, planning refactoring, discussing system design + +### 2. Core Principles (CRITICAL - 12 rules) +**Prefix:** `core-` + +Fundamental coding practices: +- **DRY** (Don't Repeat Yourself): 3 rules +- **KISS** (Keep It Simple): 2 rules +- **YAGNI** (You Aren't Gonna Need It): 2 rules +- **Other**: Separation of Concerns, Composition Over Inheritance, Law of Demeter, Fail Fast, Encapsulation + +**Use when:** Daily coding, code reviews, addressing duplication or complexity + +### 3. Design Patterns (HIGH - 1 rule) +**Prefix:** `pattern-` + +Common solutions to recurring problems: +- Repository Pattern (data access abstraction) + +**Use when:** Solving architectural problems, abstracting infrastructure concerns + +### 4-7. Future Categories +- **Code Organization** (`org-`): Module structure, boundaries +- **Naming & Readability** (`name-`): Identifier naming conventions +- **Functions & Methods** (`func-`): Function-level best practices +- **Comments & Documentation** (`doc-`): Documentation guidelines + +## How to Use Rules + +### Accessing Rules + +1. **By ID:** Reference specific rules using their ID + ``` + Check against solid-srp-class and core-dry + ``` + +2. **By Category:** Apply all rules in a category + ``` + Review this class against SOLID principles + ``` + +3. **By Scenario:** Choose relevant rules for the context + ``` + This has duplicated validation logic - check DRY rules + ``` + +### Rule Format + +Each rule follows a consistent structure: + +```markdown +--- +id: {rule-id} +title: {Full Title} +category: {category} +priority: {critical|high|medium|low} +tags: [{tags}] +related: [{related-rule-ids}] +--- + +# {Rule Title} + +{One-sentence summary} + +## Bad Example +{Anti-pattern code with problems listed} + +## Good Example +{Correct implementation with benefits} + +## Why +{5-7 benefits explaining the value} + +## When to Apply +{Practical scenarios} +``` + +### Output Format + +When identifying violations, use: + +``` +file:line - [rule-id] Description of issue +``` + +Example: +``` +src/services/UserService.ts:15 - [solid-srp-class] Class handles validation, persistence, and notifications +src/utils/helpers.ts:42 - [core-dry] Email validation duplicated from validators/email.ts +src/models/Order.ts:28 - [core-kiss-simplicity] Overly complex abstraction for simple use case +``` + +## Agent Strategies + +### Strategy 1: Architecture Review + +**Goal:** Assess overall system design + +**Approach:** +1. Start with SOLID principles (highest impact) +2. Identify violations of SRP, DIP, OCP +3. Check for proper separation of concerns +4. Evaluate composition vs inheritance +5. Assess interface design (ISP) + +**Output:** Prioritized list of architectural issues with rule references + +### Strategy 2: Code Quality Audit + +**Goal:** Find code quality issues in specific files + +**Approach:** +1. Scan for duplication (DRY rules) +2. Check complexity (KISS rules) +3. Look for overengineering (YAGNI rules) +4. Verify single responsibility +5. Assess encapsulation + +**Output:** File-by-file findings with specific line references + +### Strategy 3: Refactoring Guidance + +**Goal:** Provide actionable refactoring steps + +**Approach:** +1. Identify the primary issue (which rule violated) +2. Reference the good example from that rule +3. Suggest specific refactoring steps +4. Mention related rules that may also help +5. Prioritize changes by impact + +**Output:** Step-by-step refactoring plan with rule references + +### Strategy 4: Design Decision Support + +**Goal:** Help choose between design alternatives + +**Approach:** +1. Analyze each option against relevant principles +2. Consider YAGNI (simplest solution first) +3. Evaluate against SOLID principles +4. Check alignment with KISS +5. Recommend based on principle adherence + +**Output:** Comparative analysis with principle-based recommendation + +### Strategy 5: Pattern Application + +**Goal:** Suggest appropriate design patterns + +**Approach:** +1. Identify the problem being solved +2. Check if any patterns apply +3. Verify pattern aligns with SOLID principles +4. Ensure pattern isn't overengineering (YAGNI) +5. Provide implementation guidance + +**Output:** Pattern recommendation with integration guidance + +## Best Practices for Agents + +### Do: +- Start with CRITICAL rules (SOLID, Core) +- Provide specific line numbers when possible +- Reference rule IDs in all findings +- Explain WHY a rule matters, not just that it's violated +- Give actionable refactoring suggestions +- Consider the codebase context +- Prioritize by impact (CRITICAL > HIGH > MEDIUM > LOW) +- Show code examples from the rules +- Mention related rules that apply together + +### Don't: +- Don't cite every minor violation +- Don't recommend patterns without clear need (YAGNI) +- Don't ignore context (some violations may be acceptable) +- Don't focus on LOW priority rules when CRITICAL issues exist +- Don't provide fixes without explaining the principle +- Don't recommend complex solutions for simple problems (KISS) +- Don't suggest abstractions without concrete use cases (YAGNI) + +### Prioritization + +When multiple issues exist: +1. **CRITICAL - SOLID violations:** Architecture-level problems +2. **CRITICAL - Core violations:** Duplication, complexity, overengineering +3. **HIGH - Pattern opportunities:** Where patterns genuinely help +4. **MEDIUM - Naming/Functions:** Readability improvements +5. **LOW - Documentation:** Comment and doc issues + +## Example Interactions + +### Example 1: Architecture Review + +**User:** "Review the architecture of this OrderService class" + +**Agent Approach:** +1. Check class responsibilities against `solid-srp-class` +2. Evaluate dependencies against `solid-dip-injection` +3. Look for data access against `pattern-repository` +4. Check for proper separation against `core-separation-concerns` + +**Agent Output:** +``` +Architecture Review - OrderService + +CRITICAL Issues: +1. [solid-srp-class] Class has multiple responsibilities: + - Order validation (should be separate validator) + - Direct database access (should use repository) + - Email sending (should be separate service) + - Payment processing (should be separate service) + +2. [solid-dip-abstractions] Direct dependency on concrete implementations: + - Instantiates MySQLDatabase directly (line 15) + - Creates StripePaymentGateway in constructor (line 18) + +3. [pattern-repository] Direct SQL queries in service layer: + - Lines 45-50: Raw SQL should be in repository + +Recommendations: +1. Split into focused classes following SRP +2. Inject dependencies through constructor (DIP) +3. Create repository abstraction for data access +4. Extract email and payment to separate services + +See: solid-srp-class.md, solid-dip-injection.md, pattern-repository.md +``` + +### Example 2: Code Quality Check + +**User:** "Check this validation function for code quality issues" + +**Agent Approach:** +1. Check for duplication (`core-dry`) +2. Check complexity (`core-kiss-simplicity`) +3. Check function responsibility (`solid-srp-function`) + +**Agent Output:** +``` +Code Quality Issues: + +utils/validation.ts: + Line 23 - [core-dry] Email regex duplicated from line 45 and validators/email.ts + Line 30 - [core-dry-extraction] Password validation logic should be extracted to reusable function + Line 50 - [solid-srp-function] Function validates both email AND password - split into two functions + +Suggested refactoring: +1. Extract common regex patterns to constants file (core-dry-single-source) +2. Create separate validateEmail() and validatePassword() functions (solid-srp-function) +3. Import from shared validators module (core-dry-extraction) + +See: core-dry.md, core-dry-extraction.md, solid-srp-function.md +``` + +### Example 3: Design Decision + +**User:** "Should I use inheritance or composition for this User/Admin relationship?" + +**Agent Approach:** +1. Apply `core-composition` principle +2. Check LSP implications (`solid-lsp-contracts`) +3. Consider YAGNI (`core-yagni-abstractions`) + +**Agent Output:** +``` +Recommendation: Use Composition + +Analysis: +1. [core-composition] Composition is preferred over inheritance for flexibility + - Admins can have additional capabilities without inheritance constraints + - Easier to test each capability independently + - Can mix different permission sets dynamically + +2. [solid-lsp-contracts] Inheritance risks LSP violations + - If Admin overrides User methods differently + - If Admin has different behavior expectations + +3. [core-yagni-abstractions] Start simple + - Don't create inheritance hierarchy unless you need polymorphism + - Compose from Permission and Role objects instead + +Suggested approach: +```typescript +class User { + constructor( + private permissions: PermissionSet, + private roleManager: RoleManager + ) {} +} +``` + +See: core-composition.md, solid-lsp-contracts.md +``` + +## Integration with Other Skills + +This skill complements: +- **refactoring**: Provides principles for refactoring decisions +- **architecture-review**: Supplies SOLID and design principles +- **code-review**: Offers quality criteria for reviews +- **testing**: Promotes testable design (DIP, SRP) + +## Rule Reference Quick Guide + +### Most Common Rules + +**For duplication:** +- `core-dry` - Main DRY principle +- `core-dry-extraction` - How to extract duplicated code +- `core-dry-single-source` - Configuration and constants + +**For complex code:** +- `core-kiss-simplicity` - Avoid overengineering +- `core-kiss-readability` - Optimize for readability +- `core-yagni-features` - Don't build unused features +- `core-yagni-abstractions` - Don't abstract prematurely + +**For class design:** +- `solid-srp-class` - Single responsibility for classes +- `solid-dip-injection` - Dependency injection +- `core-separation-concerns` - Separate different concerns +- `core-composition` - Favor composition over inheritance + +**For function design:** +- `solid-srp-function` - Single responsibility for functions +- `core-kiss-readability` - Clear, readable functions + +**For interfaces:** +- `solid-isp-interfaces` - Small, focused interfaces +- `solid-isp-clients` - Client-specific interfaces + +**For extensibility:** +- `solid-ocp-extension` - Open for extension, closed for modification +- `solid-ocp-abstraction` - Use abstractions for extension points + +**For inheritance:** +- `solid-lsp-contracts` - Subtypes must honor contracts +- `solid-lsp-preconditions` - Pre/postcondition rules +- `core-composition` - Prefer composition + +**For data access:** +- `pattern-repository` - Abstract data persistence + +## Metadata + +**Version:** 1.0.2 +**Rules:** 23 (10 SOLID, 12 Core, 1 Pattern) +**Categories:** 7 (3 implemented, 4 planned) +**Languages:** Language-agnostic (examples in TypeScript) +**Last Updated:** 2026-03-07 + +## Resources + +### Books +- Clean Code (Robert C. Martin) +- Design Patterns (Gang of Four) +- Refactoring (Martin Fowler) +- The Pragmatic Programmer (Hunt & Thomas) + +### Online +- [Refactoring Guru](https://refactoring.guru/) - Design patterns and code smells +- [Martin Fowler's Catalog](https://refactoring.com/catalog/) - Refactoring techniques +- [Uncle Bob's Blog](https://blog.cleancoder.com/) - Software craftsmanship + +## Contributing New Rules + +When adding new rules: +1. Use `rules/_template.md` as starting point +2. Follow naming convention: `{prefix}-{concept}-{specificity}.md` +3. Include YAML frontmatter with all required fields +4. Provide clear bad/good examples +5. Explain 5-7 benefits in "Why" section +6. Add to `metadata.json` rules array +7. Update category counts in `_sections.md` +8. Reference related rules in frontmatter +9. Keep examples language-agnostic (TypeScript preferred) +10. Aim for 300-400 lines of content + +## License + +MIT License. This skill is provided as-is for educational and development purposes. diff --git a/.agents/skills/clean-code-principles/README.md b/.agents/skills/clean-code-principles/README.md new file mode 100644 index 0000000..5d982ff --- /dev/null +++ b/.agents/skills/clean-code-principles/README.md @@ -0,0 +1,88 @@ +# Clean Code Principles + +Fundamental software design principles for writing maintainable, scalable code. + +**Version:** 1.0.2 +**Rules:** 23 (10 SOLID + 12 Core + 1 Pattern); 4 categories planned +**License:** MIT + +--- + +## Overview + +Language-agnostic guidelines covering SOLID principles, core coding principles (DRY, KISS, YAGNI), and design patterns. Examples are written in TypeScript but apply to any object-oriented or functional language. + +## Categories (23 rules implemented) + +### 1. SOLID Principles (Critical) — 10 rules +Five fundamental object-oriented design principles: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. + +### 2. Core Principles (Critical) — 12 rules +DRY (3 rules), KISS (2 rules), YAGNI (2 rules), Separation of Concerns, Composition over Inheritance, Law of Demeter, Fail Fast, Encapsulation. + +### 3. Design Patterns (High) — 1 rule +Repository pattern for data access abstraction. + +### 4. Code Organization (High) — planned +Feature folders, module boundaries, layered architecture, package cohesion, circular dependency prevention. + +### 5. Naming & Readability (Medium) — planned +Meaningful names, consistent conventions, no magic numbers, domain language. + +### 6. Functions & Methods (Medium) — planned +Small functions, single purpose, limited parameters, pure functions, command-query separation. + +### 7. Comments & Documentation (Low) — planned +Self-documenting code, explain why not what, avoid noise, document public APIs. + +## Usage + +Ask Claude to: +- "Review architecture" — triggers SOLID + Separation of Concerns analysis +- "Check SOLID principles" — targeted SOLID review +- "Check code quality" — DRY, KISS, YAGNI audit +- "Suggest design patterns" — pattern recommendations +- "Refactoring advice" — actionable improvements with rule references + +## Key Principles + +### SOLID +| Principle | Rule | Summary | +|-----------|------|---------| +| **S**ingle Responsibility | `solid-srp-class`, `solid-srp-function` | One reason to change | +| **O**pen/Closed | `solid-ocp-extension`, `solid-ocp-abstraction` | Open for extension, closed for modification | +| **L**iskov Substitution | `solid-lsp-contracts`, `solid-lsp-preconditions` | Subtypes must be substitutable | +| **I**nterface Segregation | `solid-isp-clients`, `solid-isp-interfaces` | Small, focused interfaces | +| **D**ependency Inversion | `solid-dip-abstractions`, `solid-dip-injection` | Depend on abstractions | + +### Core + +| Principle | Rules | Summary | +|-----------|-------|---------| +| **DRY** | `core-dry`, `core-dry-extraction`, `core-dry-single-source` | Single source of truth | +| **KISS** | `core-kiss-simplicity`, `core-kiss-readability` | Simplest solution that works | +| **YAGNI** | `core-yagni-features`, `core-yagni-abstractions` | Build only what's needed | + +## Output Format + +When auditing code: + +``` +file:line - [rule-id] Description of issue +``` + +Example: +``` +src/services/UserService.ts:15 - [solid-srp-class] Class handles validation, persistence, and email +src/utils/helpers.ts:42 - [core-dry] Email validation duplicated from validators/email.ts +src/models/Order.ts:28 - [core-yagni-abstractions] Generic abstraction used in only one place +``` + +## References + +- **Clean Code** by Robert C. Martin — Foundation for clean code practices +- **Design Patterns** by Gang of Four — Classic design pattern catalog +- **Refactoring** by Martin Fowler — Improving code structure +- **The Pragmatic Programmer** by Hunt & Thomas — Practical software wisdom +- [Refactoring Guru](https://refactoring.guru/) — Design patterns and code smells +- [Martin Fowler's Refactoring Catalog](https://refactoring.com/catalog/) — Comprehensive techniques diff --git a/.agents/skills/clean-code-principles/SKILL.md b/.agents/skills/clean-code-principles/SKILL.md new file mode 100644 index 0000000..97a463c --- /dev/null +++ b/.agents/skills/clean-code-principles/SKILL.md @@ -0,0 +1,213 @@ +--- +name: clean-code-principles +description: SOLID principles, design patterns, DRY, KISS, and clean code fundamentals. Use when reviewing architecture, checking code quality, refactoring, or discussing design decisions. Triggers on "review architecture", "check code quality", "SOLID principles", "design patterns", or "clean code". +license: MIT +metadata: + author: AsyrafHussin + version: "1.0.2" +--- + +# Clean Code Principles + +Fundamental software design principles, SOLID, design patterns, and clean code practices. Language-agnostic guidelines for writing maintainable, scalable software. + +## When to Apply + +Reference these guidelines when: +- Designing new features or systems +- Reviewing code architecture +- Refactoring existing code +- Discussing design decisions +- Improving code quality + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | SOLID Principles | CRITICAL | `solid-` | +| 2 | Core Principles | CRITICAL | `core-` | +| 3 | Design Patterns | HIGH | `pattern-` | +| 4 | Code Organization | HIGH | `org-` | +| 5 | Naming & Readability | MEDIUM | `name-` | +| 6 | Functions & Methods | MEDIUM | `func-` | +| 7 | Comments & Documentation | LOW | `doc-` | + +## Quick Reference + +### 1. SOLID Principles (CRITICAL) + +- `solid-srp` - Single Responsibility Principle +- `solid-ocp` - Open/Closed Principle +- `solid-lsp` - Liskov Substitution Principle +- `solid-isp` - Interface Segregation Principle +- `solid-dip` - Dependency Inversion Principle + +### 2. Core Principles (CRITICAL) + +- `core-dry` - Don't Repeat Yourself +- `core-kiss` - Keep It Simple, Stupid +- `core-yagni` - You Aren't Gonna Need It +- `core-separation-of-concerns` - Separate different responsibilities +- `core-composition-over-inheritance` - Favor composition +- `core-law-of-demeter` - Principle of least knowledge +- `core-fail-fast` - Detect and report errors early +- `core-encapsulation` - Hide implementation details + +### 3. Design Patterns (HIGH) + +- `pattern-factory` - Factory pattern for object creation +- `pattern-strategy` - Strategy pattern for algorithms +- `pattern-repository` - Repository pattern for data access +- `pattern-decorator` - Decorator pattern for behavior extension +- `pattern-observer` - Observer pattern for event handling +- `pattern-adapter` - Adapter pattern for interface conversion +- `pattern-facade` - Facade pattern for simplified interfaces +- `pattern-dependency-injection` - DI for loose coupling + +### 4. Code Organization (HIGH) — planned + +- `org-feature-folders` - Organize by feature, not layer +- `org-module-boundaries` - Clear module boundaries +- `org-layered-architecture` - Proper layer separation +- `org-package-cohesion` - Related code together +- `org-circular-dependencies` - Avoid circular imports + +### 5. Naming & Readability (MEDIUM) — planned + +- `name-meaningful` - Use intention-revealing names +- `name-consistent` - Consistent naming conventions +- `name-searchable` - Avoid magic numbers/strings +- `name-avoid-encodings` - No Hungarian notation +- `name-domain-language` - Use domain terminology + +### 6. Functions & Methods (MEDIUM) — planned + +- `func-small` - Keep functions small +- `func-single-purpose` - Do one thing +- `func-few-arguments` - Limit parameters +- `func-no-side-effects` - Minimize side effects +- `func-command-query` - Separate commands and queries + +### 7. Comments & Documentation (LOW) — planned + +- `doc-self-documenting` - Code should explain itself +- `doc-why-not-what` - Explain why, not what +- `doc-avoid-noise` - No redundant comments +- `doc-api-docs` - Document public APIs + +## Essential Guidelines + +For detailed examples and explanations, see the rule files: + +- [core-dry.md](rules/core-dry.md) - Don't Repeat Yourself principle +- [pattern-repository.md](rules/pattern-repository.md) - Repository pattern for data access + +### SOLID Principles (Summary) + +| Principle | Definition | +|-----------|------------| +| **S**ingle Responsibility | A class should have only one reason to change | +| **O**pen/Closed | Open for extension, closed for modification | +| **L**iskov Substitution | Subtypes must be substitutable for base types | +| **I**nterface Segregation | Don't force clients to depend on unused interfaces | +| **D**ependency Inversion | Depend on abstractions, not concretions | + +### Core Principles (Summary) + +| Principle | Definition | +|-----------|------------| +| **DRY** | Don't Repeat Yourself - single source of truth | +| **KISS** | Keep It Simple - avoid over-engineering | +| **YAGNI** | You Aren't Gonna Need It - build only what's needed | + +### Quick Examples + +```typescript +// Single Responsibility - one class, one job +class UserService { + constructor( + private validator: UserValidator, + private repository: UserRepository, + ) {} + + createUser(data) { + this.validator.validate(data); + return this.repository.create(data); + } +} + +// Dependency Inversion - depend on abstractions +interface Repository { + find(id: string): Promise; + save(entity: T): Promise; +} + +class OrderService { + constructor(private repository: Repository) {} +} + +// DRY - single source of truth +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const isValidEmail = (email: string) => EMAIL_REGEX.test(email); + +// Meaningful names over magic numbers +const MINIMUM_AGE = 18; +if (user.age >= MINIMUM_AGE) { } +``` + +## Output Format + +When auditing code, output findings in this format: + +``` +file:line - [principle] Description of issue +``` + +Example: +``` +src/services/UserService.ts:15 - [solid-srp] Class handles validation, persistence, and notifications +src/utils/helpers.ts:42 - [core-dry] Email validation duplicated from validators/email.ts +src/models/Order.ts:28 - [name-meaningful] Variable 'x' should describe its purpose +``` + +## How to Use + +Read individual rule files for detailed explanations: + +``` +rules/solid-srp-class.md +rules/core-dry.md +rules/pattern-repository.md +``` + +## References + +This skill is built on established software engineering principles: + +### Core Books +- **Clean Code** by Robert C. Martin - Foundation for clean code practices +- **Design Patterns** by Gang of Four - Classic design pattern catalog +- **Refactoring** by Martin Fowler - Improving code structure +- **The Pragmatic Programmer** by Hunt & Thomas - Practical wisdom + +### Online Resources +- [Refactoring Guru](https://refactoring.guru/) - Design patterns and code smells +- [Martin Fowler's Refactoring Catalog](https://refactoring.com/catalog/) - Comprehensive refactoring techniques +- [Uncle Bob's Clean Coder Blog](https://blog.cleancoder.com/) - Software craftsmanship articles + +### Pattern Catalogs +- [Refactoring Guru - Design Patterns](https://refactoring.guru/design-patterns) +- [Martin Fowler - Enterprise Patterns](https://martinfowler.com/eaaCatalog/) + +## Metadata + +**Version:** 1.0.2 +**Status:** Active +**Coverage:** 23 rules across 3 implemented categories (SOLID, Core Principles, Design Patterns); 4 planned +**Last Updated:** 2026-03-07 + +### Rule Statistics +- SOLID Principles: 10 rules +- Core Principles: 12 rules +- Design Patterns: 1 rule + diff --git a/.agents/skills/clean-code-principles/rules/_sections.md b/.agents/skills/clean-code-principles/rules/_sections.md new file mode 100644 index 0000000..7aa7cf7 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/_sections.md @@ -0,0 +1,320 @@ +# Clean Code Principles - Rule Categories + +This document defines the organizational structure for clean code principles, ordered by priority and impact. + +## Category Overview + +| Priority | Category | Impact | Rule Count | Prefix | +|----------|----------|--------|------------|--------| +| 1 | SOLID Principles | CRITICAL | 10 | `solid-` | +| 2 | Core Principles | CRITICAL | 12 | `core-` | +| 3 | Design Patterns | HIGH | 1 | `pattern-` | +| 4 | Code Organization | HIGH | 0 | `org-` | +| 5 | Naming & Readability | MEDIUM | 0 | `name-` | +| 6 | Functions & Methods | MEDIUM | 0 | `func-` | +| 7 | Comments & Documentation | LOW | 0 | `doc-` | + +## 1. SOLID Principles (CRITICAL) + +**Priority:** CRITICAL +**Impact:** Architectural foundation, affects entire codebase structure +**Prefix:** `solid-` + +The five fundamental principles of object-oriented design that guide maintainable, scalable software architecture. + +### Rules + +#### Single Responsibility Principle (SRP) +- `solid-srp-class` - A class should have only one reason to change +- `solid-srp-function` - A function should do one thing and do it well + +#### Open/Closed Principle (OCP) +- `solid-ocp-extension` - Open for extension, closed for modification +- `solid-ocp-abstraction` - Use abstractions to enable extension + +#### Liskov Substitution Principle (LSP) +- `solid-lsp-contracts` - Subtypes must honor base type contracts +- `solid-lsp-preconditions` - Cannot strengthen preconditions or weaken postconditions + +#### Interface Segregation Principle (ISP) +- `solid-isp-clients` - Client-specific interfaces, not general-purpose +- `solid-isp-interfaces` - Small, cohesive interfaces + +#### Dependency Inversion Principle (DIP) +- `solid-dip-abstractions` - Depend on abstractions, not concretions +- `solid-dip-injection` - Inject dependencies from outside + +**Key Concepts:** +- Architectural soundness +- Maintainability at scale +- Testability through design +- Flexibility for change +- Reduced coupling + +**When to Apply:** +- Designing new features or systems +- Refactoring existing architecture +- Addressing technical debt +- Improving testability +- Planning for future extensibility + +--- + +## 2. Core Principles (CRITICAL) + +**Priority:** CRITICAL +**Impact:** Daily coding practices, code quality foundation +**Prefix:** `core-` + +Fundamental principles that apply to every line of code you write, regardless of paradigm or language. + +### Rules + +#### DRY (Don't Repeat Yourself) +- `core-dry` - Every piece of knowledge should have a single representation +- `core-dry-extraction` - Extract duplicated code into reusable functions +- `core-dry-single-source` - Single source of truth for configuration and data + +#### KISS (Keep It Simple, Stupid) +- `core-kiss-simplicity` - Choose the simplest solution that works +- `core-kiss-readability` - Optimize for readability over cleverness + +#### YAGNI (You Aren't Gonna Need It) +- `core-yagni-features` - Don't implement features before they're needed +- `core-yagni-abstractions` - Don't create abstractions prematurely + +#### Other Core Principles +- `core-separation-concerns` - Different concerns in different modules +- `core-composition` - Favor composition over inheritance +- `core-law-demeter` - Only talk to immediate friends +- `core-fail-fast` - Detect and report errors early +- `core-encapsulation` - Hide implementation details + +**Key Concepts:** +- Code duplication elimination +- Simplicity over complexity +- Lean development +- Modularity +- Information hiding + +**When to Apply:** +- Writing any new code +- Code reviews +- Refactoring sessions +- Bug fixes +- Performance optimization + +--- + +## 3. Design Patterns (HIGH) + +**Priority:** HIGH +**Impact:** Solves recurring problems with proven solutions +**Prefix:** `pattern-` + +Common design patterns that provide tested solutions to recurring software design problems. + +### Rules + +- `pattern-repository` - Abstraction for data access layer +- `pattern-factory` - Object creation without specifying exact class (planned) +- `pattern-strategy` - Encapsulate algorithms for runtime selection (planned) +- `pattern-decorator` - Add behavior without modifying objects (planned) +- `pattern-observer` - Define one-to-many dependencies (planned) +- `pattern-adapter` - Make incompatible interfaces work together (planned) +- `pattern-facade` - Simplified interface to complex subsystems (planned) + +**Key Concepts:** +- Proven solutions +- Common vocabulary +- Design reusability +- Best practices codified +- Language-agnostic approaches + +**When to Apply:** +- Solving common architectural problems +- Improving code structure +- Reducing coupling between components +- Making systems more testable +- Communicating design intent + +--- + +## 4. Code Organization (HIGH) + +**Priority:** HIGH +**Impact:** Project structure, module boundaries, discoverability +**Prefix:** `org-` + +Principles for organizing code into modules, packages, and directories for maintainability and scalability. + +### Rules (Planned) + +- `org-feature-folders` - Organize by feature, not by layer +- `org-module-boundaries` - Clear boundaries between modules +- `org-layered-architecture` - Proper separation of layers +- `org-package-cohesion` - Keep related code together +- `org-circular-dependencies` - Avoid circular imports + +**Key Concepts:** +- Feature-based organization +- Module boundaries +- Layer separation +- Dependency direction +- Discoverability + +**When to Apply:** +- Starting new projects +- Restructuring existing codebases +- Scaling applications +- Onboarding new team members +- Managing microservices + +--- + +## 5. Naming & Readability (MEDIUM) + +**Priority:** MEDIUM +**Impact:** Code comprehension, maintenance speed +**Prefix:** `name-` + +Conventions and principles for naming variables, functions, classes, and other identifiers. + +### Rules (Planned) + +- `name-meaningful` - Use intention-revealing names +- `name-consistent` - Follow consistent naming conventions +- `name-searchable` - Avoid magic numbers and strings +- `name-avoid-encodings` - No Hungarian notation +- `name-domain-language` - Use ubiquitous domain language + +**Key Concepts:** +- Intention revelation +- Consistency +- Searchability +- Domain terminology +- Avoid abbreviations + +**When to Apply:** +- Creating new identifiers +- Refactoring unclear names +- Code reviews +- Domain modeling +- API design + +--- + +## 6. Functions & Methods (MEDIUM) + +**Priority:** MEDIUM +**Impact:** Code readability, testability at function level +**Prefix:** `func-` + +Principles for writing clean, focused functions and methods. + +### Rules (Planned) + +- `func-small` - Keep functions small and focused +- `func-single-purpose` - Do one thing only +- `func-few-arguments` - Limit function parameters +- `func-no-side-effects` - Minimize or document side effects +- `func-command-query` - Separate commands from queries + +**Key Concepts:** +- Small functions +- Single purpose +- Few parameters +- Pure functions when possible +- Predictable behavior + +**When to Apply:** +- Writing new functions +- Refactoring long methods +- Improving testability +- Code reviews +- Performance optimization + +--- + +## 7. Comments & Documentation (LOW) + +**Priority:** LOW +**Impact:** Code maintainability, knowledge transfer +**Prefix:** `doc-` + +Guidelines for when and how to use comments and documentation effectively. + +### Rules (Planned) + +- `doc-self-documenting` - Write code that explains itself +- `doc-why-not-what` - Comments should explain why, not what +- `doc-avoid-noise` - No redundant or obvious comments +- `doc-api-docs` - Document public APIs and interfaces + +**Key Concepts:** +- Self-documenting code +- Intent over implementation +- Avoid redundancy +- Public API documentation +- Living documentation + +**When to Apply:** +- Complex business logic +- Non-obvious algorithms +- Public APIs +- Architectural decisions +- Workarounds and hacks + +--- + +## Rule Naming Convention + +All rules follow a consistent naming pattern: + +``` +{prefix}-{concept}-{specificity} +``` + +Examples: +- `solid-srp-class` - SOLID principle, SRP concept, class level +- `core-dry-extraction` - Core principle, DRY concept, extraction technique +- `pattern-repository` - Design pattern category, repository pattern + +## Priority Levels Explained + +- **CRITICAL**: Core architectural and coding principles. Violations significantly impact maintainability, testability, and scalability. +- **HIGH**: Important patterns and organizational principles. Violations complicate future development. +- **MEDIUM**: Best practices that improve code quality. Violations make code harder to read and maintain. +- **LOW**: Nice-to-have practices. Violations have minimal impact but reduce clarity. + +## Impact Assessment + +- **CRITICAL Impact**: Affects entire system architecture, multiple teams, long-term maintainability +- **HIGH Impact**: Affects module design, team productivity, medium-term maintainability +- **MEDIUM Impact**: Affects code readability, individual developer productivity +- **LOW Impact**: Affects code clarity, documentation quality + +## Usage Guidelines + +1. Start with SOLID and Core Principles - these are non-negotiable +2. Apply Design Patterns when solving specific architectural problems +3. Use Code Organization principles when structuring projects +4. Follow Naming & Readability guidelines for all new code +5. Apply Function principles during refactoring and new development +6. Add Comments only when necessary to explain complex logic + +## Cross-References + +Rules often relate to each other. The `related` field in each rule's frontmatter indicates: +- Rules that commonly apply together +- Rules that solve similar problems +- Rules that complement each other +- Rules that provide context or prerequisites + +## Evolution + +This categorization will evolve as: +- New rules are added +- Patterns emerge from practice +- Team feedback is incorporated +- Language-specific adaptations are needed diff --git a/.agents/skills/clean-code-principles/rules/_template.md b/.agents/skills/clean-code-principles/rules/_template.md new file mode 100644 index 0000000..d7c3c06 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/_template.md @@ -0,0 +1,211 @@ +--- +id: {prefix}-{concept}-{specificity} +title: {Full Descriptive Title} +category: {solid-principles|core-principles|design-patterns|code-organization|naming-readability|functions-methods|comments-documentation} +priority: {critical|high|medium|low} +tags: [{tag1}, {tag2}, {tag3}, {tag4}] +related: [{rule-id-1}, {rule-id-2}, {rule-id-3}] +--- + +# {Rule Title} + +{One or two sentence summary explaining the principle and why it matters. Should be clear and actionable.} + +## Bad Example + +```typescript +// Anti-pattern: {Brief description of what's wrong} + +{Code example demonstrating the violation} + +// Problems: +// 1. {Specific issue 1} +// 2. {Specific issue 2} +// 3. {Specific issue 3} +``` + +**Why This Is Wrong:** +- {Consequence 1} +- {Consequence 2} +- {Consequence 3} + +## Good Example + +```typescript +// Correct approach: {Brief description of the solution} + +{Code example demonstrating proper implementation} + +// Benefits: +// 1. {Benefit 1} +// 2. {Benefit 2} +// 3. {Benefit 3} +``` + +**Alternative Approach (Optional):** + +```typescript +// Another valid solution: {When this might be preferred} + +{Alternative code example if applicable} +``` + +## Why + +Explanation of the principle and its benefits: + +1. **{Benefit Category 1}**: {Detailed explanation} + +2. **{Benefit Category 2}**: {Detailed explanation} + +3. **{Benefit Category 3}**: {Detailed explanation} + +4. **{Benefit Category 4}**: {Detailed explanation} + +5. **{Benefit Category 5}**: {Detailed explanation} + +6. **{Benefit Category 6}**: {Detailed explanation} + +7. **{Benefit Category 7}**: {Detailed explanation} + +## When to Apply + +- {Situation 1} +- {Situation 2} +- {Situation 3} +- {Situation 4} + +## When NOT to Apply (Optional) + +```typescript +// Acceptable exception: {Scenario where the rule can be relaxed} + +{Code example of acceptable violation with clear reasoning} + +// This is acceptable because: +// - {Reason 1} +// - {Reason 2} +``` + +## Common Mistakes (Optional) + +### Mistake 1: {Common misunderstanding} + +```typescript +// ❌ Wrong +{Code showing mistake} + +// ✅ Correct +{Code showing correction} +``` + +### Mistake 2: {Another common issue} + +```typescript +// ❌ Wrong +{Code showing mistake} + +// ✅ Correct +{Code showing correction} +``` + +## Testing Implications (Optional) + +How this principle affects testing: + +```typescript +// Test example showing improved testability +{Test code demonstrating benefits} +``` + +## Real-World Example (Optional) + +{Brief description of how this applies in production scenarios} + +```typescript +// Production scenario: {Description} +{Realistic code example} +``` + +## Related Principles + +- **{Related Rule 1}**: {Brief explanation of relationship} +- **{Related Rule 2}**: {Brief explanation of relationship} +- **{Related Rule 3}**: {Brief explanation of relationship} + +## Further Reading (Optional) + +- {Resource title} - {URL or reference} +- {Resource title} - {URL or reference} + +## Language-Specific Notes (Optional) + +### TypeScript/JavaScript +{Language-specific considerations} + +### Python +{Language-specific considerations} + +### Java +{Language-specific considerations} + +### Go +{Language-specific considerations} + +--- + +## Template Guidelines + +### Frontmatter + +- **id**: Use format `{prefix}-{concept}-{specificity}`. Must be unique and match filename. +- **title**: Full descriptive title, human-readable +- **category**: One of the 7 defined categories +- **priority**: critical (SOLID, Core) | high (Patterns, Org) | medium (Naming, Functions) | low (Comments) +- **tags**: 3-5 relevant tags for searchability +- **related**: 2-4 related rule IDs that commonly apply together + +### Content Structure + +1. **Title & Summary**: Clear, one-sentence explanation +2. **Bad Example**: Show the anti-pattern with clear problems listed +3. **Good Example**: Show proper implementation with benefits +4. **Why**: 5-7 benefits explaining the value +5. **When to Apply**: Practical scenarios +6. **Optional Sections**: Add as needed for complex rules + +### Code Examples + +- Use TypeScript for primary examples (language-agnostic) +- Keep examples focused and minimal +- Show realistic scenarios, not toy examples +- Include comments explaining key points +- Use ❌ for bad examples, ✅ for good examples + +### Writing Style + +- Be direct and actionable +- Focus on "why" not just "what" +- Use active voice +- Keep explanations concise +- Provide context for decisions +- Assume intermediate developer knowledge + +### Length Guidelines + +- Minimum: 200 lines (simple rules) +- Target: 300-400 lines (most rules) +- Maximum: 600 lines (complex patterns) + +### Quality Checklist + +- [ ] Frontmatter complete and accurate +- [ ] Clear bad example with explained problems +- [ ] Clear good example with explained benefits +- [ ] At least 5 benefits in "Why" section +- [ ] Practical "When to Apply" scenarios +- [ ] Related rules referenced +- [ ] Code examples are realistic +- [ ] Comments explain key concepts +- [ ] Language-agnostic where possible +- [ ] Proofread for clarity and typos diff --git a/.agents/skills/clean-code-principles/rules/core-composition.md b/.agents/skills/clean-code-principles/rules/core-composition.md new file mode 100644 index 0000000..d0839c4 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-composition.md @@ -0,0 +1,317 @@ +--- +id: core-composition +title: Composition Over Inheritance +category: core-principles +priority: critical +tags: [composition, inheritance, flexibility, design] +related: [solid-srp-class, solid-dip-injection, core-encapsulation] +--- + +# Composition Over Inheritance + +Favor composing objects from smaller, focused pieces over building deep inheritance hierarchies. Composition provides more flexibility, better encapsulation, and avoids the fragile base class problem. + +## Bad Example + +```typescript +// Anti-pattern: Deep inheritance hierarchy + +class Animal { + protected name: string; + protected energy: number = 100; + + constructor(name: string) { + this.name = name; + } + + eat(amount: number): void { + this.energy += amount; + console.log(`${this.name} is eating. Energy: ${this.energy}`); + } + + sleep(hours: number): void { + this.energy += hours * 10; + console.log(`${this.name} slept for ${hours} hours. Energy: ${this.energy}`); + } +} + +class Bird extends Animal { + fly(): void { + this.energy -= 20; + console.log(`${this.name} is flying. Energy: ${this.energy}`); + } +} + +class Duck extends Bird { + swim(): void { + this.energy -= 5; + console.log(`${this.name} is swimming. Energy: ${this.energy}`); + } + + quack(): void { + console.log(`${this.name} says quack!`); + } +} + +class FlyingFish extends Animal { + // Problem: Can't inherit from both Bird and Fish + // Must duplicate flying code or create awkward hierarchy + + swim(): void { + this.energy -= 5; + console.log(`${this.name} is swimming. Energy: ${this.energy}`); + } + + // Duplicated from Bird class! + fly(): void { + this.energy -= 20; + console.log(`${this.name} is flying. Energy: ${this.energy}`); + } +} + +class Penguin extends Bird { + // Problem: Penguins can't fly but inherit fly() + // Must override to throw error - LSP violation + + fly(): void { + throw new Error('Penguins cannot fly!'); + } + + swim(): void { + this.energy -= 5; + console.log(`${this.name} is swimming. Energy: ${this.energy}`); + } +} + +// More problems: +// - What about a robot bird? It doesn't eat or sleep. +// - What about a bat? It flies but isn't a bird. +// - Every change to Animal affects all subclasses. +// - Testing requires understanding entire hierarchy. +``` + +## Good Example + +```typescript +// Correct approach: Composition with focused behaviors + +// Define behaviors as interfaces +interface Eater { + eat(amount: number): void; +} + +interface Sleeper { + sleep(hours: number): void; +} + +interface Flyer { + fly(): void; +} + +interface Swimmer { + swim(): void; +} + +interface Speaker { + speak(): void; +} + +// Implement behaviors as standalone classes +class StandardEater implements Eater { + constructor(private entity: { name: string; energy: number }) {} + + eat(amount: number): void { + this.entity.energy += amount; + console.log(`${this.entity.name} is eating. Energy: ${this.entity.energy}`); + } +} + +class StandardSleeper implements Sleeper { + constructor(private entity: { name: string; energy: number }) {} + + sleep(hours: number): void { + this.entity.energy += hours * 10; + console.log(`${this.entity.name} slept for ${hours} hours. Energy: ${this.entity.energy}`); + } +} + +class WingedFlyer implements Flyer { + constructor( + private entity: { name: string; energy: number }, + private energyCost: number = 20 + ) {} + + fly(): void { + this.entity.energy -= this.energyCost; + console.log(`${this.entity.name} is flying. Energy: ${this.entity.energy}`); + } +} + +class AquaticSwimmer implements Swimmer { + constructor( + private entity: { name: string; energy: number }, + private energyCost: number = 5 + ) {} + + swim(): void { + this.entity.energy -= this.energyCost; + console.log(`${this.entity.name} is swimming. Energy: ${this.entity.energy}`); + } +} + +// Compose animals from behaviors +class Duck implements Eater, Sleeper, Flyer, Swimmer, Speaker { + public name: string; + public energy: number = 100; + + private eater: Eater; + private sleeper: Sleeper; + private flyer: Flyer; + private swimmer: Swimmer; + + constructor(name: string) { + this.name = name; + this.eater = new StandardEater(this); + this.sleeper = new StandardSleeper(this); + this.flyer = new WingedFlyer(this); + this.swimmer = new AquaticSwimmer(this); + } + + eat(amount: number): void { + this.eater.eat(amount); + } + + sleep(hours: number): void { + this.sleeper.sleep(hours); + } + + fly(): void { + this.flyer.fly(); + } + + swim(): void { + this.swimmer.swim(); + } + + speak(): void { + console.log(`${this.name} says quack!`); + } +} + +// Penguin: swims but doesn't fly - no problem! +class Penguin implements Eater, Sleeper, Swimmer, Speaker { + public name: string; + public energy: number = 100; + + private eater: Eater; + private sleeper: Sleeper; + private swimmer: Swimmer; + + constructor(name: string) { + this.name = name; + this.eater = new StandardEater(this); + this.sleeper = new StandardSleeper(this); + this.swimmer = new AquaticSwimmer(this); + } + + eat(amount: number): void { + this.eater.eat(amount); + } + + sleep(hours: number): void { + this.sleeper.sleep(hours); + } + + swim(): void { + this.swimmer.swim(); + } + + speak(): void { + console.log(`${this.name} says squawk!`); + } +} + +// Flying fish: swims and flies - easy! +class FlyingFish implements Swimmer, Flyer { + public name: string; + public energy: number = 100; + + private swimmer: Swimmer; + private flyer: Flyer; + + constructor(name: string) { + this.name = name; + this.swimmer = new AquaticSwimmer(this); + this.flyer = new WingedFlyer(this, 30); // Different energy cost + } + + swim(): void { + this.swimmer.swim(); + } + + fly(): void { + this.flyer.fly(); + } +} + +// Robot bird: flies but doesn't eat or sleep +class RobotBird implements Flyer, Speaker { + public name: string; + public energy: number = 100; + + private flyer: Flyer; + + constructor(name: string) { + this.name = name; + this.flyer = new WingedFlyer(this, 10); // Efficient robot + } + + fly(): void { + this.flyer.fly(); + } + + speak(): void { + console.log(`${this.name} says BEEP BOOP!`); + } + + recharge(): void { + this.energy = 100; + console.log(`${this.name} recharged to full energy.`); + } +} + +// Functions work with any entity that has the required behavior +function makeEntityFly(flyer: Flyer): void { + flyer.fly(); +} + +function feedEntity(eater: Eater, amount: number): void { + eater.eat(amount); +} + +// Works with duck, flying fish, or robot bird +makeEntityFly(new Duck('Donald')); +makeEntityFly(new FlyingFish('Nemo')); +makeEntityFly(new RobotBird('R2D2')); + +// Works with duck or penguin, but not robot bird (correctly!) +feedEntity(new Duck('Donald'), 50); +feedEntity(new Penguin('Pingu'), 50); +// feedEntity(new RobotBird('R2D2'), 50); // Type error - RobotBird isn't an Eater +``` + +## Why + +1. **Flexibility**: Compose any combination of behaviors. No artificial hierarchy constraints. + +2. **Avoids Diamond Problem**: No multiple inheritance issues. Just implement multiple interfaces. + +3. **LSP Compliance**: No need to override methods to throw errors. Types only have methods they actually support. + +4. **Reusability**: Behaviors can be reused across unrelated types. + +5. **Testability**: Test behaviors in isolation. Mock specific behaviors easily. + +6. **Runtime Flexibility**: Can change behaviors at runtime by swapping implementations. + +7. **Stable Dependencies**: Behavior implementations are stable. Adding new composed types doesn't affect existing code. diff --git a/.agents/skills/clean-code-principles/rules/core-dry-extraction.md b/.agents/skills/clean-code-principles/rules/core-dry-extraction.md new file mode 100644 index 0000000..55b617d --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-dry-extraction.md @@ -0,0 +1,316 @@ +--- +id: core-dry-extraction +title: DRY - Code Extraction +category: core-principles +priority: critical +tags: [DRY, refactoring, extraction, code-reuse] +related: [core-dry, core-dry-single-source, solid-srp-function] +--- + +# DRY - Code Extraction + +Don't Repeat Yourself. When you find duplicated code, extract it into a reusable function, method, or module. Every piece of knowledge should have a single, unambiguous representation. + +## Bad Example + +```typescript +// Anti-pattern: Same validation logic repeated in multiple places + +class UserController { + async createUser(req: Request, res: Response): Promise { + const { email, password, name } = req.body; + + // Email validation - duplicated + if (!email) { + res.status(400).json({ error: 'Email is required' }); + return; + } + if (!email.includes('@') || !email.includes('.')) { + res.status(400).json({ error: 'Invalid email format' }); + return; + } + if (email.length > 255) { + res.status(400).json({ error: 'Email too long' }); + return; + } + + // Password validation - duplicated + if (!password) { + res.status(400).json({ error: 'Password is required' }); + return; + } + if (password.length < 8) { + res.status(400).json({ error: 'Password must be at least 8 characters' }); + return; + } + if (!/[A-Z]/.test(password)) { + res.status(400).json({ error: 'Password must contain uppercase letter' }); + return; + } + if (!/[0-9]/.test(password)) { + res.status(400).json({ error: 'Password must contain a number' }); + return; + } + + // Create user... + } + + async updateUser(req: Request, res: Response): Promise { + const { email, password } = req.body; + + // Same email validation repeated + if (email) { + if (!email.includes('@') || !email.includes('.')) { + res.status(400).json({ error: 'Invalid email format' }); + return; + } + if (email.length > 255) { + res.status(400).json({ error: 'Email too long' }); + return; + } + } + + // Same password validation repeated + if (password) { + if (password.length < 8) { + res.status(400).json({ error: 'Password must be at least 8 characters' }); + return; + } + if (!/[A-Z]/.test(password)) { + res.status(400).json({ error: 'Password must contain uppercase letter' }); + return; + } + if (!/[0-9]/.test(password)) { + res.status(400).json({ error: 'Password must contain a number' }); + return; + } + } + + // Update user... + } + + async resetPassword(req: Request, res: Response): Promise { + const { email, newPassword } = req.body; + + // Email validation repeated again + if (!email) { + res.status(400).json({ error: 'Email is required' }); + return; + } + if (!email.includes('@') || !email.includes('.')) { + res.status(400).json({ error: 'Invalid email format' }); + return; + } + + // Password validation repeated again + if (!newPassword) { + res.status(400).json({ error: 'New password is required' }); + return; + } + if (newPassword.length < 8) { + res.status(400).json({ error: 'Password must be at least 8 characters' }); + return; + } + if (!/[A-Z]/.test(newPassword)) { + res.status(400).json({ error: 'Password must contain uppercase letter' }); + return; + } + if (!/[0-9]/.test(newPassword)) { + res.status(400).json({ error: 'Password must contain a number' }); + return; + } + + // Reset password... + } +} +``` + +## Good Example + +```typescript +// Correct approach: Extract reusable validation functions + +// Validation result type +interface ValidationResult { + isValid: boolean; + errors: string[]; +} + +// Reusable validation functions +class Validators { + static email(email: string | undefined, options: { required?: boolean } = {}): ValidationResult { + const errors: string[] = []; + + if (!email) { + if (options.required) { + errors.push('Email is required'); + } + return { isValid: !options.required, errors }; + } + + if (!email.includes('@') || !email.includes('.')) { + errors.push('Invalid email format'); + } + + if (email.length > 255) { + errors.push('Email must be 255 characters or less'); + } + + return { isValid: errors.length === 0, errors }; + } + + static password(password: string | undefined, options: { required?: boolean } = {}): ValidationResult { + const errors: string[] = []; + + if (!password) { + if (options.required) { + errors.push('Password is required'); + } + return { isValid: !options.required, errors }; + } + + if (password.length < 8) { + errors.push('Password must be at least 8 characters'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/[0-9]/.test(password)) { + errors.push('Password must contain at least one number'); + } + + return { isValid: errors.length === 0, errors }; + } + + static combine(...results: ValidationResult[]): ValidationResult { + const errors = results.flatMap(r => r.errors); + return { isValid: errors.length === 0, errors }; + } +} + +// Reusable error response helper +function validationError(res: Response, errors: string[]): void { + res.status(400).json({ errors }); +} + +// Clean controller using extracted validations +class UserController { + async createUser(req: Request, res: Response): Promise { + const { email, password, name } = req.body; + + const validation = Validators.combine( + Validators.email(email, { required: true }), + Validators.password(password, { required: true }) + ); + + if (!validation.isValid) { + return validationError(res, validation.errors); + } + + // Create user... + } + + async updateUser(req: Request, res: Response): Promise { + const { email, password } = req.body; + + const validation = Validators.combine( + Validators.email(email), + Validators.password(password) + ); + + if (!validation.isValid) { + return validationError(res, validation.errors); + } + + // Update user... + } + + async resetPassword(req: Request, res: Response): Promise { + const { email, newPassword } = req.body; + + const validation = Validators.combine( + Validators.email(email, { required: true }), + Validators.password(newPassword, { required: true }) + ); + + if (!validation.isValid) { + return validationError(res, validation.errors); + } + + // Reset password... + } +} + +// Validators can be reused across the application +class AdminController { + async inviteUser(req: Request, res: Response): Promise { + const { email } = req.body; + + const validation = Validators.email(email, { required: true }); + + if (!validation.isValid) { + return validationError(res, validation.errors); + } + + // Send invite... + } +} + +// Easy to test in isolation +describe('Validators', () => { + describe('email', () => { + it('should reject invalid email format', () => { + const result = Validators.email('invalid'); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Invalid email format'); + }); + + it('should accept valid email', () => { + const result = Validators.email('user@example.com'); + expect(result.isValid).toBe(true); + }); + + it('should require email when required option is set', () => { + const result = Validators.email(undefined, { required: true }); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Email is required'); + }); + }); + + describe('password', () => { + it('should reject short passwords', () => { + const result = Validators.password('short'); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Password must be at least 8 characters'); + }); + + it('should require uppercase letter', () => { + const result = Validators.password('lowercase123'); + expect(result.isValid).toBe(false); + expect(result.errors).toContain('Password must contain at least one uppercase letter'); + }); + }); +}); +``` + +## Why + +1. **Single Source of Truth**: Password rules are defined once. Change them in one place, and all usages are updated. + +2. **Consistency**: All email validations behave the same way. No risk of inconsistent error messages or rules. + +3. **Easier Testing**: Test the validation logic once, thoroughly. No need to test the same logic in every controller. + +4. **Bug Fixes Propagate**: Fix a bug in `Validators.email()`, and it's fixed everywhere. + +5. **Reduced Code Size**: Less code means less to read, less to maintain, and fewer places for bugs. + +6. **Better Abstraction**: Controllers focus on HTTP concerns, validators focus on validation. + +7. **Reusability**: Same validators work in controllers, services, CLI tools, or anywhere else. diff --git a/.agents/skills/clean-code-principles/rules/core-dry-single-source.md b/.agents/skills/clean-code-principles/rules/core-dry-single-source.md new file mode 100644 index 0000000..63e5415 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-dry-single-source.md @@ -0,0 +1,282 @@ +--- +id: core-dry-single-source +title: DRY - Single Source of Truth +category: core-principles +priority: critical +tags: [DRY, single-source-of-truth, constants, configuration] +related: [core-dry, core-dry-extraction, core-encapsulation] +--- + +# DRY - Single Source of Truth + +Every piece of knowledge or configuration should exist in exactly one place. When data or logic needs to be referenced from multiple locations, use a single authoritative source. + +## Bad Example + +```typescript +// Anti-pattern: Same values defined in multiple places + +// In constants file +const API_BASE_URL = 'https://api.example.com/v1'; + +// In another file - duplicate! +const baseUrl = 'https://api.example.com/v1'; + +// In config.ts - another duplicate! +export const config = { + apiUrl: 'https://api.example.com/v1' +}; + +// In API client - yet another! +class ApiClient { + private baseUrl = 'https://api.example.com/v1'; // Duplicated! +} + +// Status codes defined in multiple places +class OrderService { + async getOrder(id: string): Promise { + const order = await this.repository.findById(id); + if (order.status === 'pending') { // Magic string + // ... + } + if (order.status === 'completed') { // Magic string + // ... + } + } +} + +class OrderController { + async listPendingOrders(): Promise { + return this.repository.findByStatus('pending'); // Same magic string + } +} + +// In frontend code +const isPending = order.status === 'pending'; // And again + +// Database seeds with duplicated data +const seedRoles = [ + { id: 1, name: 'admin', permissions: ['read', 'write', 'delete', 'admin'] }, + { id: 2, name: 'editor', permissions: ['read', 'write'] }, + { id: 3, name: 'viewer', permissions: ['read'] } +]; + +// In authorization middleware - duplicated permission logic +function checkPermission(user: User, action: string): boolean { + if (user.role === 'admin') { + return true; // Admin can do anything - duplicated knowledge + } + if (user.role === 'editor' && ['read', 'write'].includes(action)) { + return true; // Editor permissions - duplicated + } + if (user.role === 'viewer' && action === 'read') { + return true; // Viewer permissions - duplicated + } + return false; +} +``` + +## Good Example + +```typescript +// Correct approach: Single source of truth for all shared knowledge + +// Configuration - one authoritative source +// config/index.ts +export const Config = { + api: { + baseUrl: process.env.API_BASE_URL || 'https://api.example.com/v1', + timeout: Number(process.env.API_TIMEOUT) || 30000, + retries: Number(process.env.API_RETRIES) || 3 + }, + database: { + url: process.env.DATABASE_URL!, + poolSize: Number(process.env.DB_POOL_SIZE) || 10 + } +} as const; + +// All code references the single config +class ApiClient { + constructor(private baseUrl: string = Config.api.baseUrl) {} +} + +// Enums for finite sets of values - single source of truth +// domain/order/status.ts +export const OrderStatus = { + PENDING: 'pending', + PROCESSING: 'processing', + SHIPPED: 'shipped', + DELIVERED: 'delivered', + CANCELLED: 'cancelled' +} as const; + +export type OrderStatus = typeof OrderStatus[keyof typeof OrderStatus]; + +// All code uses the enum +class OrderService { + async getOrder(id: string): Promise { + const order = await this.repository.findById(id); + if (order.status === OrderStatus.PENDING) { + // Single source of truth + } + } +} + +class OrderController { + async listPendingOrders(): Promise { + return this.repository.findByStatus(OrderStatus.PENDING); // Same source + } +} + +// Roles and permissions - single authoritative definition +// domain/auth/roles.ts +export const Permission = { + READ: 'read', + WRITE: 'write', + DELETE: 'delete', + ADMIN: 'admin' +} as const; + +export type Permission = typeof Permission[keyof typeof Permission]; + +export const RoleDefinitions = { + admin: { + name: 'Administrator', + permissions: [Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN] + }, + editor: { + name: 'Editor', + permissions: [Permission.READ, Permission.WRITE] + }, + viewer: { + name: 'Viewer', + permissions: [Permission.READ] + } +} as const; + +export type RoleName = keyof typeof RoleDefinitions; + +// Permission checking uses the definitions +export function hasPermission(role: RoleName, permission: Permission): boolean { + const roleDef = RoleDefinitions[role]; + return roleDef.permissions.includes(permission); +} + +// Database seeds generated from the single source +export function generateRoleSeeds(): RoleSeed[] { + return Object.entries(RoleDefinitions).map(([key, def], index) => ({ + id: index + 1, + name: key, + displayName: def.name, + permissions: [...def.permissions] + })); +} + +// Validation rules - single source +// domain/user/validation.ts +export const UserValidationRules = { + email: { + maxLength: 255, + pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + }, + password: { + minLength: 8, + maxLength: 128, + requireUppercase: true, + requireLowercase: true, + requireNumber: true, + requireSpecial: false + }, + username: { + minLength: 3, + maxLength: 30, + pattern: /^[a-zA-Z0-9_]+$/ + } +} as const; + +// Validators use the rules +export function validateEmail(email: string): ValidationResult { + const rules = UserValidationRules.email; + + if (email.length > rules.maxLength) { + return { valid: false, error: `Email must be ${rules.maxLength} characters or less` }; + } + if (!rules.pattern.test(email)) { + return { valid: false, error: 'Invalid email format' }; + } + return { valid: true }; +} + +// Database schema uses the same rules +// migrations/001_create_users.ts +export const createUsersTable = ` + CREATE TABLE users ( + id UUID PRIMARY KEY, + email VARCHAR(${UserValidationRules.email.maxLength}) NOT NULL UNIQUE, + username VARCHAR(${UserValidationRules.username.maxLength}) NOT NULL UNIQUE, + -- ... + ) +`; + +// Frontend validation uses the same rules (shared module) +// shared/validation.ts (used by both frontend and backend) +export function getPasswordRequirements(): string[] { + const rules = UserValidationRules.password; + const requirements: string[] = []; + + requirements.push(`At least ${rules.minLength} characters`); + if (rules.requireUppercase) requirements.push('At least one uppercase letter'); + if (rules.requireLowercase) requirements.push('At least one lowercase letter'); + if (rules.requireNumber) requirements.push('At least one number'); + if (rules.requireSpecial) requirements.push('At least one special character'); + + return requirements; +} + +// Error messages - single source +// errors/messages.ts +export const ErrorMessages = { + user: { + notFound: 'User not found', + alreadyExists: 'A user with this email already exists', + invalidCredentials: 'Invalid email or password', + accountLocked: 'Account is locked. Please contact support.' + }, + order: { + notFound: 'Order not found', + alreadyCancelled: 'Order has already been cancelled', + cannotCancel: 'Order cannot be cancelled in its current state' + }, + auth: { + tokenExpired: 'Your session has expired. Please log in again.', + unauthorized: 'You do not have permission to perform this action' + } +} as const; + +// All code uses the same error messages +class UserService { + async findById(id: string): Promise { + const user = await this.repository.findById(id); + if (!user) { + throw new NotFoundError(ErrorMessages.user.notFound); + } + return user; + } +} +``` + +## Why + +1. **Consistency**: The same value is always the same everywhere. No "this worked yesterday" bugs. + +2. **Easy Updates**: Change a validation rule, config value, or status code in one place. + +3. **Prevents Drift**: Without a single source, values diverge over time as different developers make changes. + +4. **Documentation**: The source file serves as documentation for what values are valid. + +5. **Type Safety**: TypeScript can enforce that only valid values are used. + +6. **Searchability**: Easy to find all usages by searching for the constant name. + +7. **Refactoring**: Rename a status? Change it in one place and let the compiler find all usages. diff --git a/.agents/skills/clean-code-principles/rules/core-dry.md b/.agents/skills/clean-code-principles/rules/core-dry.md new file mode 100644 index 0000000..9ec1160 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-dry.md @@ -0,0 +1,320 @@ +--- +id: core-dry +title: Don't Repeat Yourself (DRY) +category: core-principles +priority: critical +tags: [DRY, duplication, single-source-of-truth, maintainability] +related: [core-dry-extraction, core-dry-single-source, solid-srp-class] +--- + +# Don't Repeat Yourself (DRY) + +## Why It Matters + +"Don't Repeat Yourself" means every piece of knowledge should have a single, authoritative representation. Duplication leads to inconsistencies, increases maintenance burden, and makes bugs harder to fix. When you change one copy but forget others, bugs creep in. + +## Incorrect + +```typescript +// ❌ Duplicated validation logic +class UserController { + createUser(data) { + if (!data.email || !data.email.includes('@')) { + throw new Error('Invalid email'); + } + if (!data.password || data.password.length < 8) { + throw new Error('Password too short'); + } + // create user... + } + + updateUser(id, data) { + if (!data.email || !data.email.includes('@')) { // Duplicated + throw new Error('Invalid email'); + } + if (!data.password || data.password.length < 8) { // Duplicated + throw new Error('Password too short'); + } + // update user... + } +} + +// ❌ Duplicated business rules +function calculateOrderTotal(items) { + let total = 0; + for (const item of items) { + total += item.price * item.quantity; + } + if (total > 100) { + total = total * 0.9; // 10% discount over $100 + } + return total; +} + +function calculateCartTotal(cartItems) { + let total = 0; + for (const item of cartItems) { + total += item.price * item.quantity; // Duplicated + } + if (total > 100) { + total = total * 0.9; // Duplicated discount logic + } + return total; +} + +// ❌ Duplicated constants +// file: checkout.ts +const TAX_RATE = 0.08; +const FREE_SHIPPING_THRESHOLD = 50; + +// file: cart.ts +const TAX_RATE = 0.08; // Duplicated +const FREE_SHIPPING_THRESHOLD = 50; // Duplicated +``` + +## Correct + +### Extract Shared Validation + +```typescript +// ✅ Single validation module +// validators/user.ts +export class UserValidator { + static validateEmail(email: string): void { + if (!email || !email.includes('@')) { + throw new ValidationError('Invalid email address'); + } + } + + static validatePassword(password: string): void { + if (!password || password.length < 8) { + throw new ValidationError('Password must be at least 8 characters'); + } + } + + static validate(data: UserData): void { + this.validateEmail(data.email); + this.validatePassword(data.password); + } +} + +// controller.ts +class UserController { + createUser(data) { + UserValidator.validate(data); + // create user... + } + + updateUser(id, data) { + UserValidator.validate(data); + // update user... + } +} +``` + +### Extract Shared Business Logic + +```typescript +// ✅ Single source of truth for pricing +// services/pricing.ts +export class PricingService { + private static readonly BULK_DISCOUNT_THRESHOLD = 100; + private static readonly BULK_DISCOUNT_RATE = 0.1; + + static calculateSubtotal(items: LineItem[]): number { + return items.reduce( + (sum, item) => sum + item.price * item.quantity, + 0 + ); + } + + static applyDiscount(subtotal: number): number { + if (subtotal > this.BULK_DISCOUNT_THRESHOLD) { + return subtotal * (1 - this.BULK_DISCOUNT_RATE); + } + return subtotal; + } + + static calculateTotal(items: LineItem[]): number { + const subtotal = this.calculateSubtotal(items); + return this.applyDiscount(subtotal); + } +} + +// Both order and cart use the same logic +const orderTotal = PricingService.calculateTotal(order.items); +const cartTotal = PricingService.calculateTotal(cart.items); +``` + +### Centralize Constants + +```typescript +// ✅ Single constants file +// constants/pricing.ts +export const PRICING = { + TAX_RATE: 0.08, + FREE_SHIPPING_THRESHOLD: 50, + BULK_DISCOUNT_THRESHOLD: 100, + BULK_DISCOUNT_RATE: 0.1, +} as const; + +// Used everywhere +import { PRICING } from '@/constants/pricing'; + +const tax = subtotal * PRICING.TAX_RATE; +const freeShipping = total >= PRICING.FREE_SHIPPING_THRESHOLD; +``` + +### Extract Shared Components + +```tsx +// ❌ Duplicated UI patterns +function UserCard({ user }) { + return ( +
+ +

{user.name}

+

{user.email}

+
+ ); +} + +function TeamMemberCard({ member }) { + return ( +
{/* Same styles */} + +

{member.name}

+

{member.role}

+
+ ); +} + +// ✅ Reusable component +function Card({ children, className }) { + return ( +
+ {children} +
+ ); +} + +function Avatar({ src, alt }) { + return {alt}; +} + +function UserCard({ user }) { + return ( + + +

{user.name}

+

{user.email}

+
+ ); +} + +function TeamMemberCard({ member }) { + return ( + + +

{member.name}

+

{member.role}

+
+ ); +} +``` + +### Extract Shared Hooks + +```typescript +// ❌ Duplicated fetch logic +function UserProfile() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch('/api/user') + .then(res => res.json()) + .then(setUser) + .catch(setError) + .finally(() => setLoading(false)); + }, []); + // ... +} + +function ProductList() { + const [products, setProducts] = useState(null); + const [loading, setLoading] = useState(true); // Duplicated + const [error, setError] = useState(null); // Duplicated + + useEffect(() => { + fetch('/api/products') // Same pattern + .then(res => res.json()) + .then(setProducts) + .catch(setError) + .finally(() => setLoading(false)); + }, []); + // ... +} + +// ✅ Custom hook (or use React Query) +function useFetch(url: string) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + fetch(url) + .then(res => res.json()) + .then(setData) + .catch(setError) + .finally(() => setLoading(false)); + }, [url]); + + return { data, loading, error }; +} + +function UserProfile() { + const { data: user, loading, error } = useFetch('/api/user'); + // ... +} + +function ProductList() { + const { data: products, loading, error } = useFetch('/api/products'); + // ... +} +``` + +## When NOT to DRY + +```typescript +// ⚠️ Don't extract coincidentally similar code +// These might look similar but serve different purposes + +function validateUserAge(age: number) { + return age >= 18; // Legal adult age +} + +function validateMinimumOrderQuantity(quantity: number) { + return quantity >= 18; // Business rule: minimum order +} + +// These should remain separate even though both check >= 18 +// Their reasons for change are different +``` + +## Rule of Three + +``` +Wait until you see duplication three times before extracting. +Two occurrences might be coincidental. Three indicates a pattern. +``` + +## Benefits + +- Single source of truth +- Fix bugs in one place +- Consistent behavior across codebase +- Easier refactoring +- Reduced code size +- Lower maintenance cost diff --git a/.agents/skills/clean-code-principles/rules/core-encapsulation.md b/.agents/skills/clean-code-principles/rules/core-encapsulation.md new file mode 100644 index 0000000..5c9bcea --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-encapsulation.md @@ -0,0 +1,335 @@ +--- +id: core-encapsulation +title: Encapsulation +category: core-principles +priority: critical +tags: [encapsulation, information-hiding, data-protection] +related: [solid-srp-class, core-law-demeter, solid-isp-interfaces] +--- + +# Encapsulation + +Hide internal implementation details and expose only what's necessary through a well-defined interface. Protect data integrity by controlling access to internal state. + +## Bad Example + +```typescript +// Anti-pattern: Exposed internals, no encapsulation + +class BankAccount { + // Public fields - anyone can modify directly + public accountNumber: string; + public balance: number; + public transactions: Transaction[]; + public overdraftLimit: number; + public isLocked: boolean; + + constructor(accountNumber: string, initialBalance: number) { + this.accountNumber = accountNumber; + this.balance = initialBalance; + this.transactions = []; + this.overdraftLimit = 0; + this.isLocked = false; + } +} + +// External code can violate business rules +const account = new BankAccount('12345', 1000); + +// Direct modification bypasses validation +account.balance = -999999; // Negative balance without check! +account.balance = account.balance + 1000; // No transaction record! + +// Can manipulate transaction history +account.transactions.push({ + id: 'fake', + amount: 1000000, + type: 'deposit' +}); // Fraudulent transaction! + +// Can unlock locked accounts +account.isLocked = false; // Bypasses security! + +// Can change overdraft without authorization +account.overdraftLimit = 100000; // Unauthorized overdraft! + +// Other classes depend on internal structure +class AccountReport { + generate(account: BankAccount): Report { + // Directly accesses internal array + const deposits = account.transactions.filter(t => t.type === 'deposit'); + const withdrawals = account.transactions.filter(t => t.type === 'withdrawal'); + + // Depends on internal structure of Transaction + const totalDeposits = deposits.reduce((sum, t) => sum + t.amount, 0); + + return { + balance: account.balance, + totalDeposits, + transactionCount: account.transactions.length + }; + } +} + +// Problems: +// 1. Anyone can modify balance without recording transaction +// 2. Business rules can be bypassed +// 3. No audit trail for changes +// 4. Internal structure changes break external code +// 5. No way to add validation later without breaking changes +``` + +## Good Example + +```typescript +// Correct approach: Proper encapsulation + +class BankAccount { + private readonly _accountNumber: string; + private _balance: number; + private readonly _transactions: Transaction[] = []; + private _overdraftLimit: number = 0; + private _isLocked: boolean = false; + private _lockReason: string | null = null; + + constructor(accountNumber: string, initialBalance: number) { + if (!accountNumber || accountNumber.length < 5) { + throw new Error('Invalid account number'); + } + if (initialBalance < 0) { + throw new Error('Initial balance cannot be negative'); + } + + this._accountNumber = accountNumber; + this._balance = initialBalance; + this._transactions.push( + Transaction.createInitial(initialBalance) + ); + } + + // Read-only access to account number + get accountNumber(): string { + return this._accountNumber; + } + + // Read-only access to balance + get balance(): number { + return this._balance; + } + + // Read-only access to lock status + get isLocked(): boolean { + return this._isLocked; + } + + // Controlled deposit with validation and audit trail + deposit(amount: number, description: string = 'Deposit'): Transaction { + this.ensureNotLocked(); + + if (amount <= 0) { + throw new InvalidAmountError('Deposit amount must be positive'); + } + + this._balance += amount; + + const transaction = Transaction.createDeposit(amount, description, this._balance); + this._transactions.push(transaction); + + return transaction; + } + + // Controlled withdrawal with business rules + withdraw(amount: number, description: string = 'Withdrawal'): Transaction { + this.ensureNotLocked(); + + if (amount <= 0) { + throw new InvalidAmountError('Withdrawal amount must be positive'); + } + + const availableBalance = this._balance + this._overdraftLimit; + if (amount > availableBalance) { + throw new InsufficientFundsError(amount, availableBalance); + } + + this._balance -= amount; + + const transaction = Transaction.createWithdrawal(amount, description, this._balance); + this._transactions.push(transaction); + + return transaction; + } + + // Transfer with proper validation + transferTo(recipient: BankAccount, amount: number): TransferResult { + this.ensureNotLocked(); + recipient.ensureNotLocked(); + + if (amount <= 0) { + throw new InvalidAmountError('Transfer amount must be positive'); + } + + const withdrawalTx = this.withdraw(amount, `Transfer to ${recipient.accountNumber}`); + const depositTx = recipient.deposit(amount, `Transfer from ${this._accountNumber}`); + + return { withdrawalTx, depositTx }; + } + + // Controlled overdraft limit modification + setOverdraftLimit(limit: number, authorizedBy: string): void { + if (limit < 0) { + throw new Error('Overdraft limit cannot be negative'); + } + if (limit > 10000) { + throw new Error('Overdraft limit exceeds maximum allowed'); + } + + this._overdraftLimit = limit; + + // Audit trail for limit changes + this._transactions.push( + Transaction.createAdministrative( + `Overdraft limit set to ${limit} by ${authorizedBy}` + ) + ); + } + + // Security control with audit + lock(reason: string): void { + this._isLocked = true; + this._lockReason = reason; + this._transactions.push( + Transaction.createAdministrative(`Account locked: ${reason}`) + ); + } + + unlock(authorizedBy: string): void { + this._isLocked = false; + this._lockReason = null; + this._transactions.push( + Transaction.createAdministrative(`Account unlocked by ${authorizedBy}`) + ); + } + + // Return copy of transactions, not the internal array + getTransactionHistory(): ReadonlyArray { + return [...this._transactions]; + } + + // Provide summary without exposing internals + getSummary(): AccountSummary { + const deposits = this._transactions.filter(t => t.type === 'deposit'); + const withdrawals = this._transactions.filter(t => t.type === 'withdrawal'); + + return { + accountNumber: this._accountNumber, + balance: this._balance, + isLocked: this._isLocked, + totalDeposits: deposits.reduce((sum, t) => sum + t.amount, 0), + totalWithdrawals: withdrawals.reduce((sum, t) => sum + t.amount, 0), + transactionCount: this._transactions.length + }; + } + + // Private helper method + private ensureNotLocked(): void { + if (this._isLocked) { + throw new AccountLockedError(this._lockReason || 'Account is locked'); + } + } +} + +// Transaction is also encapsulated +class Transaction { + private constructor( + public readonly id: string, + public readonly type: TransactionType, + public readonly amount: number, + public readonly description: string, + public readonly balanceAfter: number, + public readonly timestamp: Date + ) {} + + static createDeposit(amount: number, description: string, balanceAfter: number): Transaction { + return new Transaction( + generateId(), + 'deposit', + amount, + description, + balanceAfter, + new Date() + ); + } + + static createWithdrawal(amount: number, description: string, balanceAfter: number): Transaction { + return new Transaction( + generateId(), + 'withdrawal', + amount, + description, + balanceAfter, + new Date() + ); + } + + static createInitial(balance: number): Transaction { + return new Transaction( + generateId(), + 'initial', + balance, + 'Account opened', + balance, + new Date() + ); + } + + static createAdministrative(description: string): Transaction { + return new Transaction( + generateId(), + 'administrative', + 0, + description, + 0, + new Date() + ); + } +} + +// Usage - business rules are enforced +const account = new BankAccount('12345', 1000); + +// Proper deposit with audit trail +account.deposit(500, 'Paycheck'); + +// Withdrawal validates funds +try { + account.withdraw(2000); // Will throw InsufficientFundsError +} catch (error) { + console.log('Cannot withdraw more than available'); +} + +// Cannot manipulate balance directly +// account.balance = 999999; // Error: Property 'balance' is read-only + +// Cannot manipulate transactions +// account.getTransactionHistory().push(fakeTx); // Original array unaffected + +// Account summary provides what reports need +const summary = account.getSummary(); +console.log(`Balance: ${summary.balance}, Transactions: ${summary.transactionCount}`); +``` + +## Why + +1. **Data Integrity**: Balance can only change through proper deposit/withdraw methods that maintain consistency. + +2. **Business Rules**: All rules (positive amounts, sufficient funds, locked accounts) are enforced in one place. + +3. **Audit Trail**: Every change is recorded. Cannot modify history without proper methods. + +4. **Flexibility**: Internal representation can change without affecting clients. Switch from array to database later. + +5. **Security**: Cannot bypass validation or manipulate internal state directly. + +6. **Testing**: Can verify all edge cases through the public interface. Internal state is controlled. + +7. **Documentation**: The public interface documents what operations are allowed and how to use them. diff --git a/.agents/skills/clean-code-principles/rules/core-fail-fast.md b/.agents/skills/clean-code-principles/rules/core-fail-fast.md new file mode 100644 index 0000000..a131641 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-fail-fast.md @@ -0,0 +1,325 @@ +--- +id: core-fail-fast +title: Fail Fast Principle +category: core-principles +priority: critical +tags: [fail-fast, error-handling, validation] +related: [solid-lsp-preconditions, core-encapsulation] +--- + +# Fail Fast Principle + +Detect and report errors as early as possible. Validate inputs at system boundaries, check preconditions at the start of functions, and throw exceptions immediately when something is wrong. + +## Bad Example + +```typescript +// Anti-pattern: Delayed error detection + +class OrderProcessor { + async processOrder(order: any): Promise { + // No validation - problems will surface later + + // Proceeds even with potentially invalid data + const customer = await this.customerRepo.findById(order.customerId); + + // Customer might be null, but we keep going + const items = order.items; + + // Calculates total even if items might be undefined or empty + let total = 0; + if (items) { + for (const item of items) { + // item.productId might not exist + const product = await this.productRepo.findById(item.productId); + // product might be null + if (product) { + total += product.price * (item.quantity || 1); + } + } + } + + // Attempts payment even if customer is null + let paymentResult; + if (customer && customer.paymentMethod) { + paymentResult = await this.paymentService.charge( + customer.paymentMethod, + total + ); + } + + // Creates order record even if payment failed + const orderRecord = await this.orderRepo.create({ + customerId: order.customerId, + total, + status: paymentResult?.success ? 'paid' : 'pending' + }); + + // Sends email even if we don't have a valid email + if (customer?.email) { + await this.emailService.send(customer.email, 'Order confirmation'); + } + + // Returns "success" even though many things might have gone wrong + return { success: true, orderId: orderRecord.id }; + } +} + +// Problems: +// 1. Null customer leads to silent failures +// 2. Empty order goes through system doing nothing useful +// 3. Payment failure creates orphaned order records +// 4. No indication of what went wrong +// 5. Database in inconsistent state +``` + +## Good Example + +```typescript +// Correct approach: Fail fast with immediate validation + +// Custom error types for clear failure reasons +class ValidationError extends Error { + constructor( + message: string, + public readonly field: string, + public readonly value: unknown + ) { + super(message); + this.name = 'ValidationError'; + } +} + +class NotFoundError extends Error { + constructor( + public readonly entity: string, + public readonly id: string + ) { + super(`${entity} not found: ${id}`); + this.name = 'NotFoundError'; + } +} + +class PaymentError extends Error { + constructor( + message: string, + public readonly code: string + ) { + super(message); + this.name = 'PaymentError'; + } +} + +// Input validation schema +interface CreateOrderInput { + customerId: string; + items: OrderItemInput[]; +} + +interface OrderItemInput { + productId: string; + quantity: number; +} + +class OrderProcessor { + async processOrder(input: unknown): Promise { + // STEP 1: Validate input immediately + const validatedInput = this.validateInput(input); + + // STEP 2: Verify customer exists before proceeding + const customer = await this.loadCustomer(validatedInput.customerId); + + // STEP 3: Verify all products exist before any processing + const products = await this.loadProducts(validatedInput.items); + + // STEP 4: Verify payment method before creating order + this.verifyPaymentMethod(customer); + + // STEP 5: Calculate total (now safe - all data validated) + const total = this.calculateTotal(validatedInput.items, products); + + // STEP 6: Verify sufficient inventory before payment + await this.verifyInventory(validatedInput.items, products); + + // STEP 7: Process payment before creating order + const payment = await this.processPayment(customer, total); + + // STEP 8: Create order only after successful payment + const order = await this.createOrder(validatedInput, customer, total, payment); + + // STEP 9: Send confirmation (non-critical, can fail gracefully) + await this.sendConfirmation(customer, order); + + return { success: true, orderId: order.id }; + } + + private validateInput(input: unknown): CreateOrderInput { + if (!input || typeof input !== 'object') { + throw new ValidationError('Input must be an object', 'input', input); + } + + const obj = input as Record; + + if (!obj.customerId || typeof obj.customerId !== 'string') { + throw new ValidationError('customerId is required and must be a string', 'customerId', obj.customerId); + } + + if (!Array.isArray(obj.items) || obj.items.length === 0) { + throw new ValidationError('items must be a non-empty array', 'items', obj.items); + } + + const validatedItems: OrderItemInput[] = []; + for (let i = 0; i < obj.items.length; i++) { + const item = obj.items[i]; + if (!item || typeof item !== 'object') { + throw new ValidationError(`Item at index ${i} must be an object`, `items[${i}]`, item); + } + + const itemObj = item as Record; + + if (!itemObj.productId || typeof itemObj.productId !== 'string') { + throw new ValidationError( + `productId is required at index ${i}`, + `items[${i}].productId`, + itemObj.productId + ); + } + + if (typeof itemObj.quantity !== 'number' || itemObj.quantity < 1) { + throw new ValidationError( + `quantity must be a positive number at index ${i}`, + `items[${i}].quantity`, + itemObj.quantity + ); + } + + validatedItems.push({ + productId: itemObj.productId, + quantity: itemObj.quantity + }); + } + + return { + customerId: obj.customerId, + items: validatedItems + }; + } + + private async loadCustomer(customerId: string): Promise { + const customer = await this.customerRepo.findById(customerId); + + if (!customer) { + throw new NotFoundError('Customer', customerId); + } + + if (!customer.isActive) { + throw new ValidationError('Customer account is inactive', 'customerId', customerId); + } + + return customer; + } + + private async loadProducts(items: OrderItemInput[]): Promise> { + const productIds = items.map(item => item.productId); + const products = await this.productRepo.findByIds(productIds); + + const productMap = new Map(); + for (const product of products) { + productMap.set(product.id, product); + } + + // Verify all products were found + for (const item of items) { + if (!productMap.has(item.productId)) { + throw new NotFoundError('Product', item.productId); + } + } + + return productMap; + } + + private verifyPaymentMethod(customer: Customer): void { + if (!customer.paymentMethodId) { + throw new ValidationError( + 'Customer has no payment method configured', + 'paymentMethod', + null + ); + } + } + + private calculateTotal(items: OrderItemInput[], products: Map): number { + return items.reduce((total, item) => { + const product = products.get(item.productId)!; // Safe - already validated + return total + product.price * item.quantity; + }, 0); + } + + private async verifyInventory( + items: OrderItemInput[], + products: Map + ): Promise { + for (const item of items) { + const product = products.get(item.productId)!; + if (product.stock < item.quantity) { + throw new ValidationError( + `Insufficient stock for product ${product.name}. Available: ${product.stock}, Requested: ${item.quantity}`, + 'quantity', + item.quantity + ); + } + } + } + + private async processPayment(customer: Customer, amount: number): Promise { + try { + return await this.paymentService.charge(customer.paymentMethodId, amount); + } catch (error) { + throw new PaymentError( + `Payment failed: ${error.message}`, + error.code || 'UNKNOWN' + ); + } + } + + private async createOrder( + input: CreateOrderInput, + customer: Customer, + total: number, + payment: Payment + ): Promise { + return this.orderRepo.create({ + customerId: customer.id, + items: input.items, + total, + paymentId: payment.id, + status: 'paid' + }); + } + + private async sendConfirmation(customer: Customer, order: Order): Promise { + try { + await this.emailService.sendOrderConfirmation(customer.email, order); + } catch (error) { + // Log but don't fail - email is non-critical + this.logger.error('Failed to send order confirmation', { orderId: order.id, error }); + } + } +} +``` + +## Why + +1. **Clear Error Messages**: When validation fails, you know exactly what's wrong and where. + +2. **No Wasted Work**: Invalid requests fail immediately, not after expensive operations. + +3. **Data Integrity**: The database never enters an inconsistent state because we validate before mutating. + +4. **Debugging**: Stack traces point to the actual problem, not to a downstream symptom. + +5. **Predictability**: Either the operation fully succeeds or it cleanly fails with a clear reason. + +6. **Security**: Invalid inputs are rejected at the boundary, not passed through the system. + +7. **Recovery**: Callers can handle specific error types appropriately (retry, report, fallback). diff --git a/.agents/skills/clean-code-principles/rules/core-kiss-readability.md b/.agents/skills/clean-code-principles/rules/core-kiss-readability.md new file mode 100644 index 0000000..c302e61 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-kiss-readability.md @@ -0,0 +1,202 @@ +--- +id: core-kiss-readability +title: KISS - Readability +category: core-principles +priority: critical +tags: [KISS, readability, clear-code, maintainability] +related: [core-kiss-simplicity, solid-srp-function] +--- + +# KISS - Readability + +Code is read far more often than it is written. Optimize for readability by using clear names, straightforward logic, and avoiding clever tricks that obscure intent. + +## Bad Example + +```typescript +// Anti-pattern: Clever but cryptic code + +// One-liner that's hard to understand +const r = d.filter(x => x.s === 'a' && x.t > Date.now() - 864e5).reduce((a, x) => ({ ...a, [x.c]: (a[x.c] || 0) + x.v }), {}); + +// Nested ternaries +const status = x > 100 ? 'high' : x > 50 ? 'medium' : x > 20 ? 'low' : x > 0 ? 'minimal' : 'none'; + +// Bitwise operations for boolean logic +const isValid = !!(flags & 0x1) && !!(flags & 0x2) || !!(flags & 0x4); + +// Regex that nobody can read +const isValidEmail = /^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])$/i.test(email); + +// Clever use of || and && for control flow +user && user.isActive && user.hasPermission('admin') && doAdminStuff() || showError(); + +// Abusing array methods +const result = [...Array(10)].map((_, i) => i * 2).filter(Boolean).reduce((a, b) => a + b, 0); + +// Short variable names that save typing but cost understanding +function p(d, o) { + return d.map(i => ({ ...i, t: i.t * o.r, s: o.s ? i.s + o.s : i.s })).filter(i => i.t > o.m); +} +``` + +## Good Example + +```typescript +// Correct approach: Clear, self-documenting code + +// Break complex operations into named steps +function getActiveOrdersSummaryByCategory(orders: Order[]): Record { + const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000; + + const activeRecentOrders = orders.filter(order => + order.status === 'active' && order.timestamp > oneDayAgo + ); + + const summaryByCategory: Record = {}; + + for (const order of activeRecentOrders) { + const currentTotal = summaryByCategory[order.category] || 0; + summaryByCategory[order.category] = currentTotal + order.value; + } + + return summaryByCategory; +} + +// Use clear conditional logic +function getAlertLevel(value: number): AlertLevel { + if (value > 100) { + return 'high'; + } + if (value > 50) { + return 'medium'; + } + if (value > 20) { + return 'low'; + } + if (value > 0) { + return 'minimal'; + } + return 'none'; +} + +// Use named constants for flags +const UserFlags = { + IS_VERIFIED: 0x1, + IS_PREMIUM: 0x2, + IS_ADMIN: 0x4 +} as const; + +function hasUserFlag(flags: number, flag: number): boolean { + return (flags & flag) !== 0; +} + +function isValidPremiumAdmin(flags: number): boolean { + const isVerified = hasUserFlag(flags, UserFlags.IS_VERIFIED); + const isPremium = hasUserFlag(flags, UserFlags.IS_PREMIUM); + const isAdmin = hasUserFlag(flags, UserFlags.IS_ADMIN); + + return (isVerified && isPremium) || isAdmin; +} + +// Use a simple email validation +function isValidEmail(email: string): boolean { + if (!email || email.length > 254) { + return false; + } + + const atIndex = email.indexOf('@'); + if (atIndex < 1) { + return false; + } + + const domain = email.slice(atIndex + 1); + if (!domain || !domain.includes('.')) { + return false; + } + + return true; +} + +// Or use a well-tested library with clear intent +import { isEmail } from 'validator'; +const isValidEmail = isEmail(email); + +// Use explicit control flow +function handleUserAction(user: User | null): void { + if (!user) { + showError('User not found'); + return; + } + + if (!user.isActive) { + showError('User account is inactive'); + return; + } + + if (!user.hasPermission('admin')) { + showError('Admin permission required'); + return; + } + + doAdminStuff(); +} + +// Use descriptive variable and function names +function generateEvenNumbersSum(count: number): number { + const evenNumbers: number[] = []; + + for (let i = 0; i < count; i++) { + evenNumbers.push(i * 2); + } + + const sum = evenNumbers.reduce((total, num) => total + num, 0); + + return sum; +} + +// Use full, descriptive parameter names +interface PriceAdjustmentOptions { + rateMultiplier: number; + shippingSurcharge?: number; + minimumThreshold: number; +} + +function adjustProductPrices( + products: Product[], + options: PriceAdjustmentOptions +): Product[] { + return products + .map(product => ({ + ...product, + totalPrice: product.basePrice * options.rateMultiplier, + shippingCost: options.shippingSurcharge + ? product.shippingCost + options.shippingSurcharge + : product.shippingCost + })) + .filter(product => product.totalPrice > options.minimumThreshold); +} + +// Usage is self-documenting +const adjustedProducts = adjustProductPrices(products, { + rateMultiplier: 1.1, + shippingSurcharge: 5, + minimumThreshold: 20 +}); +``` + +## Why + +1. **Comprehension Speed**: Readable code is understood quickly. Clever code requires deciphering. + +2. **Fewer Bugs**: When code clearly expresses intent, mistakes are obvious. + +3. **Onboarding**: New team members can contribute faster with readable code. + +4. **Code Reviews**: Reviewers can focus on logic, not translation. + +5. **Future You**: Code you wrote 6 months ago might as well have been written by someone else. + +6. **Maintenance Cost**: Most of a codebase's lifetime is spent in maintenance, not initial development. + +7. **Collaboration**: Teams work better when everyone can understand everyone's code. diff --git a/.agents/skills/clean-code-principles/rules/core-kiss-simplicity.md b/.agents/skills/clean-code-principles/rules/core-kiss-simplicity.md new file mode 100644 index 0000000..376c846 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-kiss-simplicity.md @@ -0,0 +1,255 @@ +--- +id: core-kiss-simplicity +title: KISS - Simplicity +category: core-principles +priority: critical +tags: [KISS, simplicity, over-engineering, maintainability] +related: [core-kiss-readability, core-yagni-abstractions, solid-srp-function] +--- + +# KISS Principle - Simplicity + +Keep It Simple, Stupid. Choose the simplest solution that solves the problem. Avoid unnecessary complexity, over-engineering, and clever code that's hard to understand. + +## Bad Example + +```typescript +// Anti-pattern: Over-engineered solution for a simple problem + +// Simple task: Check if a user can access a resource +// Over-engineered approach with unnecessary abstractions + +interface AccessControlStrategy { + evaluate(context: AccessContext): AccessDecision; +} + +interface AccessContext { + subject: Subject; + resource: Resource; + action: Action; + environment: Environment; +} + +interface Subject { + id: string; + attributes: Map; +} + +interface Resource { + id: string; + type: string; + attributes: Map; +} + +interface Action { + id: string; + attributes: Map; +} + +interface Environment { + currentTime: Date; + ipAddress: string; + attributes: Map; +} + +type AttributeValue = string | number | boolean | string[]; + +interface AccessDecision { + decision: 'permit' | 'deny' | 'indeterminate' | 'not_applicable'; + obligations?: Obligation[]; + advice?: Advice[]; +} + +interface Obligation { + id: string; + fulfillOn: 'permit' | 'deny'; + attributes: Map; +} + +interface Advice { + id: string; + appliesTo: 'permit' | 'deny'; + attributes: Map; +} + +class AttributeBasedAccessControl { + private strategies: AccessControlStrategy[] = []; + private combiningAlgorithm: CombiningAlgorithm; + + constructor(combiningAlgorithm: CombiningAlgorithm) { + this.combiningAlgorithm = combiningAlgorithm; + } + + addStrategy(strategy: AccessControlStrategy): void { + this.strategies.push(strategy); + } + + evaluate(context: AccessContext): AccessDecision { + const decisions = this.strategies.map(s => s.evaluate(context)); + return this.combiningAlgorithm.combine(decisions); + } +} + +// 500+ more lines of abstraction layers... + +// Actually usage for a simple check: +const context: AccessContext = { + subject: { + id: user.id, + attributes: new Map([['role', user.role]]) + }, + resource: { + id: document.id, + type: 'document', + attributes: new Map([['ownerId', document.ownerId]]) + }, + action: { + id: 'read', + attributes: new Map() + }, + environment: { + currentTime: new Date(), + ipAddress: request.ip, + attributes: new Map() + } +}; + +const decision = accessControl.evaluate(context); +if (decision.decision === 'permit') { + // Allow access +} +``` + +## Good Example + +```typescript +// Correct approach: Simple, direct solution + +// Simple function that does what's needed +function canUserAccessDocument(user: User, document: Document, action: 'read' | 'write' | 'delete'): boolean { + // Admin can do anything + if (user.role === 'admin') { + return true; + } + + // Owner can do anything with their document + if (document.ownerId === user.id) { + return true; + } + + // Check explicit permissions + const permission = document.permissions.find(p => p.userId === user.id); + if (!permission) { + return false; + } + + // Check if permission level is sufficient + switch (action) { + case 'read': + return ['read', 'write', 'admin'].includes(permission.level); + case 'write': + return ['write', 'admin'].includes(permission.level); + case 'delete': + return permission.level === 'admin'; + default: + return false; + } +} + +// Usage is straightforward +if (canUserAccessDocument(user, document, 'read')) { + // Allow access +} + +// If requirements grow, evolve the solution incrementally +// Add time-based access when actually needed +function canUserAccessDocument( + user: User, + document: Document, + action: 'read' | 'write' | 'delete' +): boolean { + // Admin can do anything + if (user.role === 'admin') { + return true; + } + + // Owner can do anything with their document + if (document.ownerId === user.id) { + return true; + } + + // Check explicit permissions + const permission = document.permissions.find(p => p.userId === user.id); + if (!permission) { + return false; + } + + // Check expiration (added when actually needed) + if (permission.expiresAt && permission.expiresAt < new Date()) { + return false; + } + + // Check if permission level is sufficient + const requiredLevel = getRequiredLevel(action); + return hasRequiredLevel(permission.level, requiredLevel); +} + +function getRequiredLevel(action: 'read' | 'write' | 'delete'): PermissionLevel { + const levels: Record = { + read: 'read', + write: 'write', + delete: 'admin' + }; + return levels[action]; +} + +function hasRequiredLevel(userLevel: PermissionLevel, required: PermissionLevel): boolean { + const hierarchy: PermissionLevel[] = ['read', 'write', 'admin']; + return hierarchy.indexOf(userLevel) >= hierarchy.indexOf(required); +} + +// Still simple, still readable, handles new requirement + +// For multiple resources, create focused helper +class DocumentAccessChecker { + canRead(user: User, document: Document): boolean { + return canUserAccessDocument(user, document, 'read'); + } + + canWrite(user: User, document: Document): boolean { + return canUserAccessDocument(user, document, 'write'); + } + + canDelete(user: User, document: Document): boolean { + return canUserAccessDocument(user, document, 'delete'); + } + + // Filter a list of documents to only accessible ones + filterReadable(user: User, documents: Document[]): Document[] { + return documents.filter(doc => this.canRead(user, doc)); + } +} + +// Usage remains simple +const checker = new DocumentAccessChecker(); +if (checker.canRead(user, document)) { + // Show document +} +const accessibleDocs = checker.filterReadable(user, allDocuments); +``` + +## Why + +1. **Readability**: Simple code can be understood in seconds. Complex abstractions require studying. + +2. **Maintainability**: New developers can work with simple code immediately. Complex frameworks need training. + +3. **Debugging**: When something breaks, simple code has obvious failure points. + +4. **Performance**: Simple code is often faster - fewer layers, fewer allocations, less indirection. + +5. **Time to Market**: Simple solutions are built and shipped faster. + +6. **YAGNI Alignment**: The complex solution solves problems you don't have (and may never have). + +7. **Incremental Complexity**: Start simple, add complexity only when real requirements demand it. The simple solution can evolve. diff --git a/.agents/skills/clean-code-principles/rules/core-law-demeter.md b/.agents/skills/clean-code-principles/rules/core-law-demeter.md new file mode 100644 index 0000000..f844ce1 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-law-demeter.md @@ -0,0 +1,321 @@ +--- +id: core-law-demeter +title: Law of Demeter +category: core-principles +priority: critical +tags: [law-of-demeter, coupling, encapsulation] +related: [core-encapsulation, solid-srp-class, core-separation-concerns] +--- + +# Law of Demeter + +A method should only talk to its immediate friends, not to strangers. Don't reach through objects to access their internal structure. This reduces coupling and makes code more maintainable. + +## Bad Example + +```typescript +// Anti-pattern: Reaching through object chains + +class Address { + street: string; + city: string; + country: Country; +} + +class Country { + name: string; + code: string; + taxRules: TaxRules; +} + +class TaxRules { + vatRate: number; + calculateTax(amount: number): number { + return amount * this.vatRate; + } +} + +class Customer { + name: string; + address: Address; + wallet: Wallet; +} + +class Wallet { + balance: number; + currency: Currency; + + deduct(amount: number): void { + this.balance -= amount; + } +} + +class Currency { + code: string; + exchangeRate: number; +} + +class Order { + customer: Customer; + items: OrderItem[]; + + // Violation: Reaching deep into customer's structure + getCustomerCountry(): string { + return this.customer.address.country.name; // 4 levels deep! + } + + // Violation: Reaching into customer's wallet + calculateTax(): number { + const amount = this.getTotal(); + // Reaching through customer -> address -> country -> taxRules + return this.customer.address.country.taxRules.calculateTax(amount); + } + + // Violation: Manipulating customer's wallet directly + processPayment(): void { + const total = this.calculateTax() + this.getTotal(); + + // Reaching into wallet to check and modify + if (this.customer.wallet.balance < total) { + throw new Error('Insufficient funds'); + } + + // Reaching into wallet's currency for conversion + const exchangeRate = this.customer.wallet.currency.exchangeRate; + const convertedAmount = total * exchangeRate; + + this.customer.wallet.deduct(convertedAmount); + } + + getTotal(): number { + return this.items.reduce((sum, item) => sum + item.price, 0); + } +} + +// Problems with this approach: +// 1. Order knows too much about Customer's internal structure +// 2. Changes to Address, Country, or Wallet break Order +// 3. Hard to test - must mock entire object graph +// 4. Tight coupling between unrelated classes +``` + +## Good Example + +```typescript +// Correct approach: Talk only to immediate friends + +class Address { + private street: string; + private city: string; + private country: Country; + + constructor(street: string, city: string, country: Country) { + this.street = street; + this.city = city; + this.country = country; + } + + getCountryName(): string { + return this.country.getName(); + } + + calculateTax(amount: number): number { + return this.country.calculateTax(amount); + } +} + +class Country { + private name: string; + private code: string; + private taxRules: TaxRules; + + constructor(name: string, code: string, taxRules: TaxRules) { + this.name = name; + this.code = code; + this.taxRules = taxRules; + } + + getName(): string { + return this.name; + } + + getCode(): string { + return this.code; + } + + calculateTax(amount: number): number { + return this.taxRules.calculate(amount); + } +} + +class TaxRules { + private vatRate: number; + + constructor(vatRate: number) { + this.vatRate = vatRate; + } + + calculate(amount: number): number { + return amount * this.vatRate; + } +} + +class Wallet { + private balance: number; + private currency: Currency; + + constructor(balance: number, currency: Currency) { + this.balance = balance; + this.currency = currency; + } + + canAfford(amount: number): boolean { + const convertedAmount = this.currency.convert(amount); + return this.balance >= convertedAmount; + } + + pay(amount: number): PaymentResult { + const convertedAmount = this.currency.convert(amount); + + if (!this.canAfford(amount)) { + return { success: false, error: 'Insufficient funds' }; + } + + this.balance -= convertedAmount; + return { success: true, amountPaid: convertedAmount }; + } + + getBalance(): number { + return this.balance; + } +} + +class Currency { + private code: string; + private exchangeRate: number; + + constructor(code: string, exchangeRate: number) { + this.code = code; + this.exchangeRate = exchangeRate; + } + + convert(amount: number): number { + return amount * this.exchangeRate; + } +} + +class Customer { + private name: string; + private address: Address; + private wallet: Wallet; + + constructor(name: string, address: Address, wallet: Wallet) { + this.name = name; + this.address = address; + this.wallet = wallet; + } + + getName(): string { + return this.name; + } + + getCountryName(): string { + return this.address.getCountryName(); + } + + calculateTaxFor(amount: number): number { + return this.address.calculateTax(amount); + } + + canAfford(amount: number): boolean { + return this.wallet.canAfford(amount); + } + + pay(amount: number): PaymentResult { + return this.wallet.pay(amount); + } +} + +class Order { + private customer: Customer; + private items: OrderItem[]; + + constructor(customer: Customer, items: OrderItem[]) { + this.customer = customer; + this.items = items; + } + + // Only talks to immediate friend (customer) + getCustomerCountry(): string { + return this.customer.getCountryName(); + } + + getTotal(): number { + return this.items.reduce((sum, item) => sum + item.getPrice(), 0); + } + + // Asks customer to calculate tax (customer knows how) + calculateTax(): number { + return this.customer.calculateTaxFor(this.getTotal()); + } + + getTotalWithTax(): number { + return this.getTotal() + this.calculateTax(); + } + + // Asks customer to pay (customer handles wallet) + processPayment(): PaymentResult { + const total = this.getTotalWithTax(); + + if (!this.customer.canAfford(total)) { + return { success: false, error: 'Insufficient funds' }; + } + + return this.customer.pay(total); + } +} + +// Usage +const order = new Order(customer, items); +const result = order.processPayment(); + +if (result.success) { + console.log(`Payment successful: ${result.amountPaid}`); +} else { + console.log(`Payment failed: ${result.error}`); +} + +// Testing is now easy - only mock the immediate friend +describe('Order', () => { + it('should process payment through customer', () => { + const mockCustomer: Customer = { + getCountryName: () => 'USA', + calculateTaxFor: (amount: number) => amount * 0.1, + canAfford: () => true, + pay: jest.fn().mockReturnValue({ success: true, amountPaid: 110 }) + } as any; + + const order = new Order(mockCustomer, [{ getPrice: () => 100 }]); + const result = order.processPayment(); + + expect(result.success).toBe(true); + expect(mockCustomer.pay).toHaveBeenCalledWith(110); + }); +}); +``` + +## Why + +1. **Reduced Coupling**: Order only knows about Customer. Changes to Address, Country, or Wallet don't affect Order. + +2. **Encapsulation**: Internal structure is hidden. Customer can change how it stores address without affecting clients. + +3. **Easier Testing**: Mock only the immediate friend. No need to construct deep object graphs. + +4. **Better Abstraction**: Customer is responsible for customer things. Order doesn't need to know about wallets. + +5. **Maintainability**: When requirements change, changes are localized to the responsible class. + +6. **Readability**: `customer.calculateTaxFor(amount)` is clearer than `customer.address.country.taxRules.calculateTax(amount)`. + +7. **Flexibility**: Can change internal implementations without affecting clients. Customer could switch from wallet to payment service. diff --git a/.agents/skills/clean-code-principles/rules/core-separation-concerns.md b/.agents/skills/clean-code-principles/rules/core-separation-concerns.md new file mode 100644 index 0000000..96abbba --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-separation-concerns.md @@ -0,0 +1,337 @@ +--- +id: core-separation-concerns +title: Separation of Concerns +category: core-principles +priority: critical +tags: [separation-of-concerns, modularity, cohesion] +related: [solid-srp-class, solid-srp-function, core-law-demeter] +--- + +# Separation of Concerns + +Different concerns should be handled by different parts of the system. Each module, class, or function should address a single concern, making the code easier to understand, test, and modify. + +## Bad Example + +```typescript +// Anti-pattern: Multiple concerns mixed together + +class OrderProcessor { + async processOrder(orderData: any, request: Request): Promise { + // Concern 1: HTTP request parsing + const authHeader = request.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401 }); + } + const token = authHeader.slice(7); + + // Concern 2: Authentication + let userId: string; + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { userId: string }; + userId = decoded.userId; + } catch { + return new Response(JSON.stringify({ error: 'Invalid token' }), { status: 401 }); + } + + // Concern 3: Validation + if (!orderData.items || orderData.items.length === 0) { + return new Response(JSON.stringify({ error: 'Order must have items' }), { status: 400 }); + } + for (const item of orderData.items) { + if (!item.productId || item.quantity < 1) { + return new Response(JSON.stringify({ error: 'Invalid item' }), { status: 400 }); + } + } + + // Concern 4: Database access + const connection = await mysql.createConnection(process.env.DATABASE_URL!); + + try { + // Concern 5: Business logic - inventory check + for (const item of orderData.items) { + const [rows] = await connection.execute( + 'SELECT stock FROM products WHERE id = ?', + [item.productId] + ); + if (rows[0].stock < item.quantity) { + return new Response( + JSON.stringify({ error: `Insufficient stock for ${item.productId}` }), + { status: 400 } + ); + } + } + + // Concern 6: Business logic - pricing + let total = 0; + for (const item of orderData.items) { + const [rows] = await connection.execute( + 'SELECT price FROM products WHERE id = ?', + [item.productId] + ); + total += rows[0].price * item.quantity; + } + + // Concern 7: Business logic - create order + const orderId = crypto.randomUUID(); + await connection.execute( + 'INSERT INTO orders (id, user_id, total, status) VALUES (?, ?, ?, ?)', + [orderId, userId, total, 'pending'] + ); + + // Concern 8: Payment processing + const stripe = new Stripe(process.env.STRIPE_KEY!); + const paymentIntent = await stripe.paymentIntents.create({ + amount: Math.round(total * 100), + currency: 'usd', + customer: userId + }); + + // Concern 9: Inventory update + for (const item of orderData.items) { + await connection.execute( + 'UPDATE products SET stock = stock - ? WHERE id = ?', + [item.quantity, item.productId] + ); + } + + // Concern 10: Notification + const transporter = nodemailer.createTransport({ /* SMTP config */ }); + await transporter.sendMail({ + to: orderData.email, + subject: 'Order Confirmation', + html: `

Order ${orderId} confirmed

Total: $${total}

` + }); + + // Concern 11: Logging + console.log(`Order ${orderId} created for user ${userId}`); + + // Concern 12: HTTP response formatting + return new Response( + JSON.stringify({ orderId, total, paymentIntentId: paymentIntent.id }), + { status: 201, headers: { 'Content-Type': 'application/json' } } + ); + + } finally { + await connection.end(); + } + } +} +``` + +## Good Example + +```typescript +// Correct approach: Each concern in its own module + +// Concern: HTTP handling +class OrderController { + constructor(private orderService: OrderService) {} + + async createOrder(req: Request, res: Response): Promise { + try { + const userId = req.user.id; // Auth middleware already handled this + const orderData = req.body; // Validation middleware already validated + + const order = await this.orderService.createOrder(userId, orderData); + + res.status(201).json({ + orderId: order.id, + total: order.total, + status: order.status + }); + } catch (error) { + if (error instanceof InsufficientStockError) { + res.status(400).json({ error: error.message }); + } else if (error instanceof PaymentFailedError) { + res.status(402).json({ error: 'Payment failed' }); + } else { + throw error; // Let error middleware handle + } + } + } +} + +// Concern: Authentication (middleware) +class AuthMiddleware { + constructor(private authService: AuthService) {} + + async handle(req: Request, res: Response, next: NextFunction): Promise { + const token = this.extractToken(req); + if (!token) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + const user = await this.authService.validateToken(token); + if (!user) { + res.status(401).json({ error: 'Invalid token' }); + return; + } + + req.user = user; + next(); + } + + private extractToken(req: Request): string | null { + const header = req.headers.authorization; + return header?.startsWith('Bearer ') ? header.slice(7) : null; + } +} + +// Concern: Validation (separate validator) +class OrderValidator { + validate(data: unknown): CreateOrderData { + const schema = z.object({ + items: z.array(z.object({ + productId: z.string().uuid(), + quantity: z.number().int().positive() + })).min(1) + }); + + return schema.parse(data); + } +} + +// Concern: Business logic orchestration +class OrderService { + constructor( + private orderRepository: OrderRepository, + private inventoryService: InventoryService, + private pricingService: PricingService, + private paymentService: PaymentService, + private notificationService: NotificationService, + private logger: Logger + ) {} + + async createOrder(userId: string, data: CreateOrderData): Promise { + // Check inventory + await this.inventoryService.checkAvailability(data.items); + + // Calculate pricing + const pricing = await this.pricingService.calculateTotal(data.items); + + // Create order + const order = await this.orderRepository.create({ + userId, + items: data.items, + total: pricing.total, + status: 'pending' + }); + + // Process payment + await this.paymentService.charge(userId, pricing.total, order.id); + + // Reserve inventory + await this.inventoryService.reserve(data.items); + + // Send confirmation + await this.notificationService.sendOrderConfirmation(order); + + // Log + this.logger.info('Order created', { orderId: order.id, userId }); + + return order; + } +} + +// Concern: Inventory management +class InventoryService { + constructor(private productRepository: ProductRepository) {} + + async checkAvailability(items: OrderItem[]): Promise { + for (const item of items) { + const product = await this.productRepository.findById(item.productId); + if (product.stock < item.quantity) { + throw new InsufficientStockError(item.productId, product.stock, item.quantity); + } + } + } + + async reserve(items: OrderItem[]): Promise { + for (const item of items) { + await this.productRepository.decrementStock(item.productId, item.quantity); + } + } +} + +// Concern: Pricing calculation +class PricingService { + constructor(private productRepository: ProductRepository) {} + + async calculateTotal(items: OrderItem[]): Promise { + let subtotal = 0; + + for (const item of items) { + const product = await this.productRepository.findById(item.productId); + subtotal += product.price * item.quantity; + } + + const tax = this.calculateTax(subtotal); + const total = subtotal + tax; + + return { subtotal, tax, total }; + } + + private calculateTax(amount: number): number { + return amount * 0.1; // 10% tax + } +} + +// Concern: Payment processing +class PaymentService { + constructor(private stripeClient: Stripe) {} + + async charge(userId: string, amount: number, orderId: string): Promise { + const intent = await this.stripeClient.paymentIntents.create({ + amount: Math.round(amount * 100), + currency: 'usd', + metadata: { orderId } + }); + + return { id: intent.id, status: 'pending' }; + } +} + +// Concern: Notifications +class NotificationService { + constructor(private emailService: EmailService) {} + + async sendOrderConfirmation(order: Order): Promise { + await this.emailService.send({ + to: order.userEmail, + template: 'order-confirmation', + data: { orderId: order.id, total: order.total } + }); + } +} + +// Concern: Data access +class OrderRepository { + constructor(private db: Database) {} + + async create(data: CreateOrderInput): Promise { + return this.db.orders.create({ data }); + } + + async findById(id: string): Promise { + return this.db.orders.findUnique({ where: { id } }); + } +} +``` + +## Why + +1. **Understandability**: Each module has one job. Easy to understand what it does. + +2. **Testability**: Test inventory logic without payment, test pricing without database. + +3. **Reusability**: `PricingService` can be used for quotes, carts, or invoices. + +4. **Maintainability**: Change email provider? Only touch `EmailService`. + +5. **Team Scaling**: Different developers can work on different concerns without conflicts. + +6. **Flexibility**: Swap Stripe for another payment provider by changing only `PaymentService`. + +7. **Debugging**: Error in pricing? Look at `PricingService`. Clear boundaries help locate issues. diff --git a/.agents/skills/clean-code-principles/rules/core-yagni-abstractions.md b/.agents/skills/clean-code-principles/rules/core-yagni-abstractions.md new file mode 100644 index 0000000..ecd5c15 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-yagni-abstractions.md @@ -0,0 +1,252 @@ +--- +id: core-yagni-abstractions +title: YAGNI - Abstractions +category: core-principles +priority: critical +tags: [YAGNI, premature-abstraction, simplicity] +related: [core-yagni-features, core-kiss-simplicity, solid-ocp-abstraction] +--- + +# YAGNI Principle - Abstractions + +Don't create abstractions until you have concrete evidence they're needed. Premature abstraction leads to wrong abstractions that are worse than no abstraction. + +## Bad Example + +```typescript +// Anti-pattern: Creating abstractions before understanding the problem + +// Task: Send an email notification +// Over-abstracted solution based on imagined future needs + +// "We might need different notification channels someday" +interface NotificationChannel { + send(notification: Notification): Promise; + getCapabilities(): ChannelCapabilities; + isAvailable(): Promise; +} + +// "We might need different notification types" +interface Notification { + id: string; + type: NotificationType; + priority: NotificationPriority; + payload: NotificationPayload; + metadata: NotificationMetadata; +} + +// "We might need complex routing logic" +interface NotificationRouter { + route(notification: Notification): Promise; + registerChannel(channel: NotificationChannel): void; + setRoutingRules(rules: RoutingRule[]): void; +} + +// "We might need to transform notifications per channel" +interface NotificationTransformer { + transform(notification: Notification, channel: NotificationChannel): TransformedNotification; +} + +// "We might need retry logic" +interface NotificationRetryPolicy { + shouldRetry(attempt: number, error: Error): boolean; + getDelay(attempt: number): number; +} + +// "We might need to track delivery" +interface NotificationTracker { + trackSent(notification: Notification, channel: NotificationChannel): Promise; + trackDelivered(notificationId: string): Promise; + trackFailed(notificationId: string, error: Error): Promise; +} + +// "We might need a notification queue" +interface NotificationQueue { + enqueue(notification: Notification): Promise; + process(): Promise; + getStatus(notificationId: string): Promise; +} + +// Orchestrator that ties it all together +class NotificationOrchestrator { + constructor( + private router: NotificationRouter, + private transformer: NotificationTransformer, + private retryPolicy: NotificationRetryPolicy, + private tracker: NotificationTracker, + private queue: NotificationQueue + ) {} + + async notify(notification: Notification): Promise { + await this.queue.enqueue(notification); + // 200+ lines of orchestration logic + } +} + +// But all we actually needed was: +// Send an email when a user registers + +// Result: 1000+ lines of abstraction, weeks of work, for sending one email +``` + +## Good Example + +```typescript +// Correct approach: Start concrete, abstract when patterns emerge + +// Task: Send an email notification +// Simple, direct solution + +interface EmailOptions { + to: string; + subject: string; + body: string; +} + +class EmailService { + constructor(private smtpClient: SmtpClient) {} + + async send(options: EmailOptions): Promise { + await this.smtpClient.send({ + from: 'noreply@example.com', + to: options.to, + subject: options.subject, + html: options.body + }); + } +} + +// Usage +const emailService = new EmailService(smtpClient); +await emailService.send({ + to: user.email, + subject: 'Welcome!', + body: '

Welcome to our app!

' +}); + +// Later, when we actually need SMS (not "might need"): +class SmsService { + constructor(private twilioClient: TwilioClient) {} + + async send(phone: string, message: string): Promise { + await this.twilioClient.messages.create({ + to: phone, + from: process.env.TWILIO_NUMBER, + body: message + }); + } +} + +// Now we have TWO concrete implementations +// We can see what they have in common + +// Abstract AFTER seeing the pattern (Rule of Three) +// After email, SMS, and push notifications exist: + +interface NotificationSender { + send(recipient: string, message: NotificationMessage): Promise; +} + +interface NotificationMessage { + subject?: string; + body: string; +} + +class EmailNotificationSender implements NotificationSender { + constructor(private emailService: EmailService) {} + + async send(recipient: string, message: NotificationMessage): Promise { + await this.emailService.send({ + to: recipient, + subject: message.subject || 'Notification', + body: message.body + }); + } +} + +class SmsNotificationSender implements NotificationSender { + constructor(private smsService: SmsService) {} + + async send(recipient: string, message: NotificationMessage): Promise { + // SMS doesn't support subject, so we just use body + await this.smsService.send(recipient, message.body); + } +} + +class PushNotificationSender implements NotificationSender { + constructor(private pushService: PushService) {} + + async send(recipient: string, message: NotificationMessage): Promise { + await this.pushService.send(recipient, { + title: message.subject, + body: message.body + }); + } +} + +// Simple notification service that uses the abstraction +class NotificationService { + constructor(private senders: Map) {} + + async notify( + channel: 'email' | 'sms' | 'push', + recipient: string, + message: NotificationMessage + ): Promise { + const sender = this.senders.get(channel); + if (!sender) { + throw new Error(`Unknown notification channel: ${channel}`); + } + await sender.send(recipient, message); + } +} + +// The abstraction fits because it was derived from concrete implementations +// It's minimal - just what's needed, nothing speculative + +// If we later need retry logic, we add it when we have concrete requirements: +class RetryingNotificationSender implements NotificationSender { + constructor( + private sender: NotificationSender, + private maxAttempts: number = 3 + ) {} + + async send(recipient: string, message: NotificationMessage): Promise { + let lastError: Error | undefined; + + for (let attempt = 1; attempt <= this.maxAttempts; attempt++) { + try { + await this.sender.send(recipient, message); + return; + } catch (error) { + lastError = error as Error; + if (attempt < this.maxAttempts) { + await this.delay(attempt * 1000); + } + } + } + + throw lastError; + } + + private delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); + } +} +``` + +## Why + +1. **Wrong Abstractions Are Costly**: Premature abstractions are often wrong because you don't understand the problem yet. Wrong abstractions are harder to change than no abstractions. + +2. **Rule of Three**: Wait until you have three concrete examples before abstracting. Two is often coincidence; three reveals the pattern. + +3. **Duplication Is Cheaper Than Wrong Abstraction**: It's easier to extract a correct abstraction from duplicated code than to fix a wrong abstraction. + +4. **Context Matters**: Abstractions made with real requirements fit better than those made speculatively. + +5. **Simplicity**: Concrete code is simpler to understand, debug, and modify. + +6. **Evolutionary Design**: Let the design emerge from actual needs rather than imagined ones. + +7. **Time Value**: The time spent on speculative abstractions could be spent on real features. diff --git a/.agents/skills/clean-code-principles/rules/core-yagni-features.md b/.agents/skills/clean-code-principles/rules/core-yagni-features.md new file mode 100644 index 0000000..acac28c --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/core-yagni-features.md @@ -0,0 +1,205 @@ +--- +id: core-yagni-features +title: YAGNI - Features +category: core-principles +priority: critical +tags: [YAGNI, speculative-features, lean-development] +related: [core-yagni-abstractions, core-kiss-simplicity] +--- + +# YAGNI Principle - Features + +You Aren't Gonna Need It. Don't implement features until they are actually required. Building speculative features wastes time and adds unnecessary complexity. + +## Bad Example + +```typescript +// Anti-pattern: Building features "just in case" + +// Task: Create a simple user registration +// Over-built solution with features nobody asked for + +interface User { + id: string; + email: string; + password: string; + // Speculative fields - nobody asked for these + phone?: string; + address?: Address; + preferences?: UserPreferences; + socialProfiles?: SocialProfile[]; + twoFactorEnabled?: boolean; + twoFactorSecret?: string; + backupCodes?: string[]; + apiKeys?: ApiKey[]; + loginHistory?: LoginAttempt[]; + sessions?: Session[]; + subscriptionTier?: 'free' | 'basic' | 'premium' | 'enterprise'; + billingInfo?: BillingInfo; + teamMemberships?: TeamMembership[]; + referralCode?: string; + referredBy?: string; + loyaltyPoints?: number; +} + +class UserService { + // Basic registration - actually needed + async register(email: string, password: string): Promise { + // ... + } + + // These were built "just in case" - never used + + async enableTwoFactor(userId: string): Promise { + // 200 lines of 2FA implementation + // Product never asked for this feature + } + + async generateApiKey(userId: string, scopes: string[]): Promise { + // 150 lines of API key management + // No API exists for external developers + } + + async trackLoginAttempt(userId: string, success: boolean, ip: string): Promise { + // 100 lines of login tracking + // No dashboard to view this data + } + + async manageSessions(userId: string): Promise { + // 180 lines of session management + // Users can't actually see or manage sessions + } + + async upgradeSubscription(userId: string, tier: string): Promise { + // 300 lines of subscription logic + // App is free, no paid tiers planned + } + + async processReferral(referrerCode: string, newUserId: string): Promise { + // 120 lines of referral logic + // Marketing hasn't planned a referral program + } + + async awardLoyaltyPoints(userId: string, action: string): Promise { + // 100 lines of loyalty point logic + // No loyalty program exists + } + + // 1500+ lines of code for features that may never be used +} + +// Result: +// - 3 weeks spent building speculative features +// - 1500+ lines to maintain +// - Bugs introduced in code that's never executed +// - Actual requirements delayed +``` + +## Good Example + +```typescript +// Correct approach: Build only what's needed now + +// Task: Create a simple user registration +// Focused solution that meets actual requirements + +interface User { + id: string; + email: string; + passwordHash: string; + createdAt: Date; + updatedAt: Date; +} + +interface CreateUserData { + email: string; + password: string; +} + +class UserService { + constructor( + private userRepository: UserRepository, + private passwordHasher: PasswordHasher, + private emailService: EmailService + ) {} + + async register(data: CreateUserData): Promise { + // Validate email + const existingUser = await this.userRepository.findByEmail(data.email); + if (existingUser) { + throw new UserAlreadyExistsError(data.email); + } + + // Create user + const passwordHash = await this.passwordHasher.hash(data.password); + const user = await this.userRepository.create({ + email: data.email, + passwordHash, + createdAt: new Date(), + updatedAt: new Date() + }); + + // Send welcome email + await this.emailService.sendWelcome(user.email); + + return user; + } + + async findById(id: string): Promise { + return this.userRepository.findById(id); + } + + async findByEmail(email: string): Promise { + return this.userRepository.findByEmail(email); + } +} + +// Result: +// - Built in 2 days +// - ~100 lines of focused code +// - Easy to understand and maintain +// - Shipped to users quickly +// - Can add features when actually needed + +// When 2FA is actually requested (6 months later): +// Add it then with full context of actual requirements + +interface TwoFactorService { + enable(userId: string): Promise; + verify(userId: string, code: string): Promise; + disable(userId: string): Promise; +} + +// When subscription tiers are planned (1 year later): +// Add with actual business requirements, pricing, and features defined + +interface SubscriptionService { + getCurrentPlan(userId: string): Promise; + upgrade(userId: string, planId: string): Promise; + downgrade(userId: string, planId: string): Promise; + cancel(userId: string): Promise; +} + +// Benefits of waiting: +// 1. Requirements are clearer +// 2. You know the actual use cases +// 3. Technology may have improved +// 4. You didn't maintain unused code for months +// 5. You might not need it at all (50%+ of speculative features are never used) +``` + +## Why + +1. **Waste Prevention**: Speculative features consume development time that could be spent on actual needs. + +2. **Maintenance Burden**: Every line of code must be maintained, tested, and understood - even unused code. + +3. **Complexity Cost**: Unused features add cognitive load when reading and modifying the codebase. + +4. **Requirements Clarity**: Future requirements are clearer when you're actually implementing them. + +5. **Flexibility**: Code without speculative features is easier to refactor and evolve. + +6. **Faster Delivery**: Ship what's needed now, iterate based on real feedback. + +7. **Better Design**: Features designed with real requirements and user feedback are better than guessed features. diff --git a/.agents/skills/clean-code-principles/rules/pattern-repository.md b/.agents/skills/clean-code-principles/rules/pattern-repository.md new file mode 100644 index 0000000..63ef16e --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/pattern-repository.md @@ -0,0 +1,359 @@ +--- +id: pattern-repository +title: Design Pattern - Repository +category: design-patterns +priority: high +tags: [design-patterns, repository, data-access, separation-of-concerns] +related: [solid-dip-abstractions, solid-srp-class, core-separation-concerns] +--- + +# Repository Pattern + +The Repository pattern abstracts data persistence, providing a collection-like interface for accessing domain objects. It separates business logic from data access, makes code testable, and allows swapping storage implementations without changing application code. + +## Bad Example + +```typescript +// ❌ Data access mixed with business logic +class OrderService { + async createOrder(userId: string, items: CartItem[]) { + // Business logic + const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0); + + // Direct database access - tightly coupled + const result = await pool.query( + `INSERT INTO orders (user_id, total, status, created_at) + VALUES ($1, $2, $3, NOW()) RETURNING *`, + [userId, total, 'pending'] + ); + + const orderId = result.rows[0].id; + + // More SQL scattered in business logic + for (const item of items) { + await pool.query( + `INSERT INTO order_items (order_id, product_id, quantity, price) + VALUES ($1, $2, $3, $4)`, + [orderId, item.productId, item.quantity, item.price] + ); + } + + return result.rows[0]; + } + + async getOrdersByUser(userId: string) { + // SQL everywhere + const result = await pool.query( + `SELECT o.*, json_agg(oi.*) as items + FROM orders o + LEFT JOIN order_items oi ON o.id = oi.order_id + WHERE o.user_id = $1 + GROUP BY o.id`, + [userId] + ); + return result.rows; + } +} +``` + +**Problems:** +- Business logic mixed with SQL +- Hard to test without real database +- Changing database requires rewriting service +- SQL scattered across codebase + +## Good Example + +### Define Repository Interface + +```typescript +// ✅ Repository interface - contract for data access +interface OrderRepository { + find(id: string): Promise; + findByUser(userId: string): Promise; + findByStatus(status: OrderStatus): Promise; + save(order: Order): Promise; + delete(id: string): Promise; +} + +interface OrderItemRepository { + findByOrder(orderId: string): Promise; + saveMany(items: OrderItem[]): Promise; + deleteByOrder(orderId: string): Promise; +} +``` + +### Implement Repository + +```typescript +// ✅ PostgreSQL implementation +class PostgresOrderRepository implements OrderRepository { + constructor(private db: Pool) {} + + async find(id: string): Promise { + const result = await this.db.query( + 'SELECT * FROM orders WHERE id = $1', + [id] + ); + return result.rows[0] ? this.mapToOrder(result.rows[0]) : null; + } + + async findByUser(userId: string): Promise { + const result = await this.db.query( + 'SELECT * FROM orders WHERE user_id = $1 ORDER BY created_at DESC', + [userId] + ); + return result.rows.map(this.mapToOrder); + } + + async findByStatus(status: OrderStatus): Promise { + const result = await this.db.query( + 'SELECT * FROM orders WHERE status = $1', + [status] + ); + return result.rows.map(this.mapToOrder); + } + + async save(order: Order): Promise { + if (order.id) { + return this.update(order); + } + return this.insert(order); + } + + private async insert(order: Order): Promise { + const result = await this.db.query( + `INSERT INTO orders (user_id, total, status, created_at) + VALUES ($1, $2, $3, NOW()) RETURNING *`, + [order.userId, order.total, order.status] + ); + return this.mapToOrder(result.rows[0]); + } + + private async update(order: Order): Promise { + const result = await this.db.query( + `UPDATE orders SET total = $1, status = $2, updated_at = NOW() + WHERE id = $3 RETURNING *`, + [order.total, order.status, order.id] + ); + return this.mapToOrder(result.rows[0]); + } + + async delete(id: string): Promise { + await this.db.query('DELETE FROM orders WHERE id = $1', [id]); + } + + private mapToOrder(row: any): Order { + return new Order({ + id: row.id, + userId: row.user_id, + total: parseFloat(row.total), + status: row.status as OrderStatus, + createdAt: row.created_at, + updatedAt: row.updated_at, + }); + } +} +``` + +### Clean Service Layer + +```typescript +// ✅ Service focuses on business logic only +class OrderService { + constructor( + private orderRepository: OrderRepository, + private orderItemRepository: OrderItemRepository, + private productRepository: ProductRepository, + ) {} + + async createOrder(userId: string, items: CartItem[]): Promise { + // Pure business logic + const total = this.calculateTotal(items); + + const order = new Order({ + userId, + total, + status: OrderStatus.Pending, + }); + + // Repository handles persistence + const savedOrder = await this.orderRepository.save(order); + + const orderItems = items.map(item => new OrderItem({ + orderId: savedOrder.id, + productId: item.productId, + quantity: item.quantity, + price: item.price, + })); + + await this.orderItemRepository.saveMany(orderItems); + + return savedOrder; + } + + async cancelOrder(orderId: string): Promise { + const order = await this.orderRepository.find(orderId); + + if (!order) { + throw new OrderNotFoundException(orderId); + } + + if (!order.canBeCancelled()) { + throw new InvalidOrderStateException('Order cannot be cancelled'); + } + + order.cancel(); + return this.orderRepository.save(order); + } + + private calculateTotal(items: CartItem[]): number { + return items.reduce((sum, item) => sum + item.price * item.quantity, 0); + } +} +``` + +### In-Memory Repository for Testing + +```typescript +// ✅ In-memory implementation for tests +class InMemoryOrderRepository implements OrderRepository { + private orders: Map = new Map(); + private idCounter = 1; + + async find(id: string): Promise { + return this.orders.get(id) ?? null; + } + + async findByUser(userId: string): Promise { + return Array.from(this.orders.values()) + .filter(order => order.userId === userId); + } + + async findByStatus(status: OrderStatus): Promise { + return Array.from(this.orders.values()) + .filter(order => order.status === status); + } + + async save(order: Order): Promise { + if (!order.id) { + order.id = String(this.idCounter++); + } + this.orders.set(order.id, order); + return order; + } + + async delete(id: string): Promise { + this.orders.delete(id); + } + + // Test helper methods + clear(): void { + this.orders.clear(); + } + + seed(orders: Order[]): void { + orders.forEach(order => this.orders.set(order.id, order)); + } +} +``` + +### Easy Testing + +```typescript +// ✅ Unit tests without database +describe('OrderService', () => { + let orderService: OrderService; + let orderRepository: InMemoryOrderRepository; + let orderItemRepository: InMemoryOrderItemRepository; + + beforeEach(() => { + orderRepository = new InMemoryOrderRepository(); + orderItemRepository = new InMemoryOrderItemRepository(); + orderService = new OrderService( + orderRepository, + orderItemRepository, + new InMemoryProductRepository(), + ); + }); + + it('creates order with calculated total', async () => { + const items = [ + { productId: '1', quantity: 2, price: 10 }, + { productId: '2', quantity: 1, price: 25 }, + ]; + + const order = await orderService.createOrder('user-1', items); + + expect(order.total).toBe(45); + expect(order.status).toBe(OrderStatus.Pending); + }); + + it('cancels pending order', async () => { + orderRepository.seed([ + new Order({ id: '1', userId: 'user-1', status: OrderStatus.Pending }), + ]); + + const order = await orderService.cancelOrder('1'); + + expect(order.status).toBe(OrderStatus.Cancelled); + }); + + it('throws when cancelling shipped order', async () => { + orderRepository.seed([ + new Order({ id: '1', userId: 'user-1', status: OrderStatus.Shipped }), + ]); + + await expect(orderService.cancelOrder('1')) + .rejects.toThrow(InvalidOrderStateException); + }); +}); +``` + +### Generic Repository Base + +```typescript +// ✅ Generic base for common operations +interface Repository { + find(id: ID): Promise; + findAll(): Promise; + save(entity: T): Promise; + delete(id: ID): Promise; + exists(id: ID): Promise; +} + +abstract class BasePostgresRepository implements Repository { + constructor( + protected db: Pool, + protected tableName: string, + ) {} + + async find(id: ID): Promise { + const result = await this.db.query( + `SELECT * FROM ${this.tableName} WHERE id = $1`, + [id] + ); + return result.rows[0] ? this.mapToEntity(result.rows[0]) : null; + } + + async exists(id: ID): Promise { + const result = await this.db.query( + `SELECT 1 FROM ${this.tableName} WHERE id = $1`, + [id] + ); + return result.rows.length > 0; + } + + protected abstract mapToEntity(row: any): T; +} +``` + +## Why + +- Separation of concerns +- Business logic free of persistence details +- Easy to test with in-memory implementations +- Swap databases without changing services +- Centralized query logic +- Consistent data access patterns +- Single place to add caching, logging diff --git a/.agents/skills/clean-code-principles/rules/solid-dip-abstractions.md b/.agents/skills/clean-code-principles/rules/solid-dip-abstractions.md new file mode 100644 index 0000000..feb7eaf --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-dip-abstractions.md @@ -0,0 +1,336 @@ +--- +id: solid-dip-abstractions +title: SOLID - Dependency Inversion (Abstractions) +category: solid-principles +priority: critical +tags: [SOLID, DIP, dependency-inversion, abstractions] +related: [solid-dip-injection, solid-ocp-abstraction, pattern-repository] +--- + +# Dependency Inversion Principle - Depend on Abstractions + +High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. + +## Bad Example + +```typescript +// Anti-pattern: High-level module depends on low-level implementation details + +// Low-level modules (concrete implementations) +class MySQLDatabase { + connect(): void { + console.log('Connecting to MySQL...'); + } + + query(sql: string): any[] { + console.log(`Executing MySQL query: ${sql}`); + return []; + } + + close(): void { + console.log('Closing MySQL connection'); + } +} + +class SmtpEmailSender { + send(to: string, subject: string, body: string): void { + console.log(`Sending SMTP email to ${to}`); + } +} + +class StripePaymentGateway { + charge(amount: number, cardToken: string): string { + console.log(`Charging $${amount} via Stripe`); + return 'stripe_txn_123'; + } +} + +// High-level module directly depends on low-level implementations +class OrderService { + private database: MySQLDatabase; + private emailSender: SmtpEmailSender; + private paymentGateway: StripePaymentGateway; + + constructor() { + // Direct instantiation creates tight coupling + this.database = new MySQLDatabase(); + this.emailSender = new SmtpEmailSender(); + this.paymentGateway = new StripePaymentGateway(); + } + + async createOrder(orderData: OrderData): Promise { + this.database.connect(); + + // MySQL-specific query + const result = this.database.query( + `INSERT INTO orders (customer_id, total) VALUES (${orderData.customerId}, ${orderData.total})` + ); + + // Stripe-specific charging + const txnId = this.paymentGateway.charge(orderData.total, orderData.cardToken); + + // SMTP-specific sending + this.emailSender.send( + orderData.customerEmail, + 'Order Confirmation', + `Your order has been placed. Transaction: ${txnId}` + ); + + this.database.close(); + + return { id: 'order_1', ...orderData }; + } +} + +// Problems: +// 1. Cannot switch to PostgreSQL without modifying OrderService +// 2. Cannot use SendGrid for emails without modifying OrderService +// 3. Cannot test without real MySQL, SMTP, and Stripe +// 4. OrderService knows implementation details it shouldn't +``` + +## Good Example + +```typescript +// Correct approach: Depend on abstractions, not concretions + +// Abstractions (interfaces) - owned by the high-level module +interface Database { + connect(): Promise; + query(query: QueryBuilder): Promise; + execute(command: Command): Promise; + disconnect(): Promise; +} + +interface QueryBuilder { + table: string; + select?: string[]; + where?: Record; + orderBy?: string; + limit?: number; +} + +interface Command { + table: string; + operation: 'insert' | 'update' | 'delete'; + data?: Record; + where?: Record; +} + +interface EmailSender { + send(options: EmailOptions): Promise; +} + +interface EmailOptions { + to: string; + subject: string; + body: string; + html?: string; +} + +interface PaymentGateway { + charge(request: ChargeRequest): Promise; + refund(transactionId: string, amount?: number): Promise; +} + +interface ChargeRequest { + amount: number; + currency: string; + paymentMethodId: string; + metadata?: Record; +} + +interface ChargeResult { + transactionId: string; + status: 'success' | 'pending' | 'failed'; + amount: number; +} + +// High-level module depends only on abstractions +class OrderService { + constructor( + private database: Database, + private emailSender: EmailSender, + private paymentGateway: PaymentGateway + ) {} + + async createOrder(orderData: OrderData): Promise { + await this.database.connect(); + + try { + // Uses abstraction - no SQL dialect specifics + await this.database.execute({ + table: 'orders', + operation: 'insert', + data: { + customerId: orderData.customerId, + total: orderData.total, + status: 'pending' + } + }); + + // Uses abstraction - no payment provider specifics + const chargeResult = await this.paymentGateway.charge({ + amount: orderData.total, + currency: 'USD', + paymentMethodId: orderData.paymentMethodId, + metadata: { orderId: orderData.id } + }); + + // Uses abstraction - no email provider specifics + await this.emailSender.send({ + to: orderData.customerEmail, + subject: 'Order Confirmation', + body: `Your order has been placed. Transaction: ${chargeResult.transactionId}` + }); + + return { id: orderData.id, ...orderData, status: 'completed' }; + } finally { + await this.database.disconnect(); + } + } +} + +// Low-level implementations depend on abstractions +class MySQLDatabase implements Database { + private connection: any; + + async connect(): Promise { + this.connection = await mysql.createConnection(config); + } + + async query(query: QueryBuilder): Promise { + const sql = this.buildSelectQuery(query); + const [rows] = await this.connection.execute(sql); + return rows as T[]; + } + + async execute(command: Command): Promise { + const sql = this.buildCommandQuery(command); + await this.connection.execute(sql); + } + + async disconnect(): Promise { + await this.connection.end(); + } + + private buildSelectQuery(query: QueryBuilder): string { + // MySQL-specific query building + return `SELECT ${query.select?.join(', ') ?? '*'} FROM ${query.table}`; + } + + private buildCommandQuery(command: Command): string { + // MySQL-specific command building + return `INSERT INTO ${command.table} ...`; + } +} + +class PostgreSQLDatabase implements Database { + // PostgreSQL-specific implementation of the same interface + async connect(): Promise { /* PostgreSQL connection */ } + async query(query: QueryBuilder): Promise { /* PostgreSQL query */ } + async execute(command: Command): Promise { /* PostgreSQL execute */ } + async disconnect(): Promise { /* PostgreSQL disconnect */ } +} + +class SendGridEmailSender implements EmailSender { + constructor(private apiKey: string) {} + + async send(options: EmailOptions): Promise { + await sendgrid.send({ + to: options.to, + from: 'noreply@example.com', + subject: options.subject, + text: options.body, + html: options.html + }); + } +} + +class StripePaymentGateway implements PaymentGateway { + constructor(private stripe: Stripe) {} + + async charge(request: ChargeRequest): Promise { + const intent = await this.stripe.paymentIntents.create({ + amount: request.amount * 100, + currency: request.currency, + payment_method: request.paymentMethodId, + confirm: true + }); + + return { + transactionId: intent.id, + status: intent.status === 'succeeded' ? 'success' : 'pending', + amount: request.amount + }; + } + + async refund(transactionId: string, amount?: number): Promise { + const refund = await this.stripe.refunds.create({ + payment_intent: transactionId, + amount: amount ? amount * 100 : undefined + }); + return { refundId: refund.id, status: 'success' }; + } +} + +// Composition root - where we wire everything together +function createOrderService(): OrderService { + const database = new PostgreSQLDatabase(); + const emailSender = new SendGridEmailSender(process.env.SENDGRID_API_KEY!); + const paymentGateway = new StripePaymentGateway(new Stripe(process.env.STRIPE_KEY)); + + return new OrderService(database, emailSender, paymentGateway); +} + +// Testing with mock implementations +class MockDatabase implements Database { + public queries: QueryBuilder[] = []; + public commands: Command[] = []; + + async connect(): Promise {} + async query(query: QueryBuilder): Promise { + this.queries.push(query); + return []; + } + async execute(command: Command): Promise { + this.commands.push(command); + } + async disconnect(): Promise {} +} + +// Test without real databases, email servers, or payment gateways +describe('OrderService', () => { + it('should create order', async () => { + const mockDb = new MockDatabase(); + const mockEmail: EmailSender = { send: jest.fn() }; + const mockPayment: PaymentGateway = { + charge: jest.fn().mockResolvedValue({ transactionId: 'test_txn', status: 'success', amount: 100 }), + refund: jest.fn() + }; + + const service = new OrderService(mockDb, mockEmail, mockPayment); + await service.createOrder(testOrderData); + + expect(mockDb.commands).toHaveLength(1); + expect(mockPayment.charge).toHaveBeenCalled(); + expect(mockEmail.send).toHaveBeenCalled(); + }); +}); +``` + +## Why + +1. **Loose Coupling**: `OrderService` doesn't know or care about MySQL, Stripe, or SendGrid. It works with any implementation. + +2. **Easy Swapping**: Switch from MySQL to PostgreSQL by providing a different implementation. No changes to `OrderService`. + +3. **Testability**: Test with mock implementations - no real databases or external services needed. + +4. **Parallel Development**: Teams can work on different implementations simultaneously against the same interface. + +5. **Policy/Detail Separation**: Business rules (OrderService) are separate from infrastructure details (MySQLDatabase). + +6. **Stable Architecture**: Abstractions change less frequently than implementations. The core is protected from change. + +7. **Plugin Architecture**: New implementations can be added without modifying existing code. diff --git a/.agents/skills/clean-code-principles/rules/solid-dip-injection.md b/.agents/skills/clean-code-principles/rules/solid-dip-injection.md new file mode 100644 index 0000000..1bbd78d --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-dip-injection.md @@ -0,0 +1,349 @@ +--- +id: solid-dip-injection +title: SOLID - Dependency Inversion (Injection) +category: solid-principles +priority: critical +tags: [SOLID, DIP, dependency-injection, testability] +related: [solid-dip-abstractions, solid-srp-class, core-composition] +--- + +# Dependency Inversion Principle - Dependency Injection + +Dependencies should be injected from outside rather than created inside a class. This enables loose coupling, testability, and flexibility in how dependencies are provided. + +## Bad Example + +```typescript +// Anti-pattern: Creating dependencies inside the class + +class UserRegistrationService { + private userRepository: UserRepository; + private passwordHasher: PasswordHasher; + private emailService: EmailService; + private logger: Logger; + private analyticsService: AnalyticsService; + + constructor() { + // Dependencies created inside - tight coupling + this.userRepository = new PostgresUserRepository( + new PostgresConnection('localhost', 5432, 'mydb') + ); + this.passwordHasher = new BcryptPasswordHasher(10); + this.emailService = new SendGridEmailService(process.env.SENDGRID_KEY!); + this.logger = new WinstonLogger('UserRegistration'); + this.analyticsService = new MixpanelAnalytics(process.env.MIXPANEL_TOKEN!); + } + + async register(email: string, password: string): Promise { + this.logger.info(`Registering user: ${email}`); + + const existingUser = await this.userRepository.findByEmail(email); + if (existingUser) { + throw new Error('User already exists'); + } + + const hashedPassword = await this.passwordHasher.hash(password); + const user = await this.userRepository.create({ email, password: hashedPassword }); + + await this.emailService.send({ + to: email, + subject: 'Welcome!', + body: 'Thanks for registering' + }); + + await this.analyticsService.track('user_registered', { userId: user.id }); + + return user; + } +} + +// Problems: +// 1. Cannot test without real Postgres, SendGrid, Mixpanel +// 2. Cannot reuse with different implementations +// 3. Hard-coded configuration scattered through constructors +// 4. Class responsible for creating its dependencies +``` + +## Good Example + +```typescript +// Correct approach: Dependencies injected from outside + +// Interfaces for all dependencies +interface UserRepository { + findByEmail(email: string): Promise; + findById(id: string): Promise; + create(data: CreateUserData): Promise; + update(id: string, data: Partial): Promise; +} + +interface PasswordHasher { + hash(password: string): Promise; + verify(password: string, hash: string): Promise; +} + +interface EmailService { + send(options: EmailOptions): Promise; +} + +interface Logger { + info(message: string, meta?: Record): void; + error(message: string, error?: Error, meta?: Record): void; + warn(message: string, meta?: Record): void; +} + +interface AnalyticsService { + track(event: string, properties?: Record): Promise; + identify(userId: string, traits?: Record): Promise; +} + +// Service with constructor injection +class UserRegistrationService { + constructor( + private readonly userRepository: UserRepository, + private readonly passwordHasher: PasswordHasher, + private readonly emailService: EmailService, + private readonly logger: Logger, + private readonly analyticsService: AnalyticsService + ) {} + + async register(email: string, password: string): Promise { + this.logger.info('Registering user', { email }); + + const existingUser = await this.userRepository.findByEmail(email); + if (existingUser) { + throw new UserAlreadyExistsError(email); + } + + const hashedPassword = await this.passwordHasher.hash(password); + const user = await this.userRepository.create({ + email, + password: hashedPassword, + createdAt: new Date() + }); + + await this.sendWelcomeEmail(user); + await this.trackRegistration(user); + + this.logger.info('User registered successfully', { userId: user.id }); + return user; + } + + private async sendWelcomeEmail(user: User): Promise { + try { + await this.emailService.send({ + to: user.email, + subject: 'Welcome!', + body: 'Thanks for registering' + }); + } catch (error) { + this.logger.error('Failed to send welcome email', error as Error, { userId: user.id }); + // Don't fail registration if email fails + } + } + + private async trackRegistration(user: User): Promise { + try { + await this.analyticsService.identify(user.id, { email: user.email }); + await this.analyticsService.track('user_registered', { userId: user.id }); + } catch (error) { + this.logger.error('Failed to track registration', error as Error, { userId: user.id }); + // Don't fail registration if analytics fails + } + } +} + +// Concrete implementations +class PostgresUserRepository implements UserRepository { + constructor(private db: DatabaseConnection) {} + + async findByEmail(email: string): Promise { + const result = await this.db.query('SELECT * FROM users WHERE email = $1', [email]); + return result.rows[0] || null; + } + + async findById(id: string): Promise { + const result = await this.db.query('SELECT * FROM users WHERE id = $1', [id]); + return result.rows[0] || null; + } + + async create(data: CreateUserData): Promise { + const result = await this.db.query( + 'INSERT INTO users (email, password, created_at) VALUES ($1, $2, $3) RETURNING *', + [data.email, data.password, data.createdAt] + ); + return result.rows[0]; + } + + async update(id: string, data: Partial): Promise { + // Implementation + } +} + +class BcryptPasswordHasher implements PasswordHasher { + constructor(private rounds: number = 10) {} + + async hash(password: string): Promise { + return bcrypt.hash(password, this.rounds); + } + + async verify(password: string, hash: string): Promise { + return bcrypt.compare(password, hash); + } +} + +// Dependency Injection Container (manual) +class Container { + private services: Map = new Map(); + + register(key: string, factory: () => T): void { + this.services.set(key, factory); + } + + resolve(key: string): T { + const factory = this.services.get(key); + if (!factory) { + throw new Error(`Service not registered: ${key}`); + } + return factory(); + } +} + +// Composition root - wire up all dependencies +function configureContainer(): Container { + const container = new Container(); + + // Register infrastructure + container.register('DatabaseConnection', () => + new PostgresConnection(process.env.DATABASE_URL!) + ); + + container.register('Logger', () => + new WinstonLogger({ level: process.env.LOG_LEVEL || 'info' }) + ); + + // Register repositories + container.register('UserRepository', () => + new PostgresUserRepository(container.resolve('DatabaseConnection')) + ); + + // Register services + container.register('PasswordHasher', () => + new BcryptPasswordHasher(12) + ); + + container.register('EmailService', () => + new SendGridEmailService(process.env.SENDGRID_API_KEY!) + ); + + container.register('AnalyticsService', () => + new MixpanelAnalytics(process.env.MIXPANEL_TOKEN!) + ); + + // Register application services + container.register('UserRegistrationService', () => + new UserRegistrationService( + container.resolve('UserRepository'), + container.resolve('PasswordHasher'), + container.resolve('EmailService'), + container.resolve('Logger'), + container.resolve('AnalyticsService') + ) + ); + + return container; +} + +// Application startup +const container = configureContainer(); +const registrationService = container.resolve('UserRegistrationService'); + +// Testing is now trivial with mock implementations +describe('UserRegistrationService', () => { + let service: UserRegistrationService; + let mockUserRepository: jest.Mocked; + let mockPasswordHasher: jest.Mocked; + let mockEmailService: jest.Mocked; + let mockLogger: jest.Mocked; + let mockAnalytics: jest.Mocked; + + beforeEach(() => { + mockUserRepository = { + findByEmail: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn() + }; + mockPasswordHasher = { + hash: jest.fn(), + verify: jest.fn() + }; + mockEmailService = { send: jest.fn() }; + mockLogger = { info: jest.fn(), error: jest.fn(), warn: jest.fn() }; + mockAnalytics = { track: jest.fn(), identify: jest.fn() }; + + service = new UserRegistrationService( + mockUserRepository, + mockPasswordHasher, + mockEmailService, + mockLogger, + mockAnalytics + ); + }); + + it('should register a new user', async () => { + mockUserRepository.findByEmail.mockResolvedValue(null); + mockPasswordHasher.hash.mockResolvedValue('hashed_password'); + mockUserRepository.create.mockResolvedValue({ + id: '1', + email: 'test@example.com', + password: 'hashed_password', + createdAt: new Date() + }); + + const user = await service.register('test@example.com', 'password123'); + + expect(user.id).toBe('1'); + expect(mockPasswordHasher.hash).toHaveBeenCalledWith('password123'); + expect(mockEmailService.send).toHaveBeenCalled(); + expect(mockAnalytics.track).toHaveBeenCalledWith('user_registered', { userId: '1' }); + }); + + it('should throw error if user already exists', async () => { + mockUserRepository.findByEmail.mockResolvedValue({ id: '1', email: 'test@example.com' } as User); + + await expect(service.register('test@example.com', 'password123')) + .rejects.toThrow(UserAlreadyExistsError); + + expect(mockUserRepository.create).not.toHaveBeenCalled(); + }); + + it('should not fail registration if email sending fails', async () => { + mockUserRepository.findByEmail.mockResolvedValue(null); + mockPasswordHasher.hash.mockResolvedValue('hashed_password'); + mockUserRepository.create.mockResolvedValue({ id: '1', email: 'test@example.com' } as User); + mockEmailService.send.mockRejectedValue(new Error('SMTP error')); + + const user = await service.register('test@example.com', 'password123'); + + expect(user.id).toBe('1'); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); +``` + +## Why + +1. **Testability**: All dependencies can be mocked. Tests run fast without external services. + +2. **Flexibility**: Switch implementations at configuration time, not code change time. + +3. **Single Responsibility**: Classes focus on business logic, not on constructing dependencies. + +4. **Configuration Centralization**: All wiring happens in one place (composition root), making it easy to see and change. + +5. **Lifetime Management**: The container can manage singleton vs. transient instances. + +6. **Environment Adaptation**: Easy to provide different implementations for dev, test, staging, and production. + +7. **Explicit Dependencies**: Looking at the constructor tells you exactly what a class needs to function. diff --git a/.agents/skills/clean-code-principles/rules/solid-isp-clients.md b/.agents/skills/clean-code-principles/rules/solid-isp-clients.md new file mode 100644 index 0000000..490e054 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-isp-clients.md @@ -0,0 +1,246 @@ +--- +id: solid-isp-clients +title: SOLID - Interface Segregation (Client-Specific) +category: solid-principles +priority: critical +tags: [SOLID, ISP, interface-segregation, client-design] +related: [solid-isp-interfaces, solid-srp-class, solid-lsp-contracts] +--- + +# Interface Segregation Principle - Client-Specific Interfaces + +Clients should not be forced to depend on interfaces they do not use. Design interfaces from the client's perspective, not the implementation's. + +## Bad Example + +```typescript +// Anti-pattern: Fat interface that forces clients to depend on methods they don't use + +interface UserService { + // Authentication + login(email: string, password: string): Promise; + logout(userId: string): Promise; + refreshToken(token: string): Promise; + validateToken(token: string): Promise; + + // User management + createUser(data: CreateUserData): Promise; + updateUser(id: string, data: UpdateUserData): Promise; + deleteUser(id: string): Promise; + getUser(id: string): Promise; + listUsers(filters: UserFilters): Promise; + + // Profile + updateProfile(userId: string, profile: ProfileData): Promise; + uploadAvatar(userId: string, image: Buffer): Promise; + getProfile(userId: string): Promise; + + // Notifications + sendNotification(userId: string, notification: Notification): Promise; + getNotificationPreferences(userId: string): Promise; + updateNotificationPreferences(userId: string, prefs: NotificationPrefs): Promise; + + // Analytics + trackUserEvent(userId: string, event: AnalyticsEvent): Promise; + getUserAnalytics(userId: string): Promise; +} + +// Login page only needs authentication but must depend on entire interface +class LoginPage { + constructor(private userService: UserService) {} // Depends on 15+ methods! + + async handleLogin(email: string, password: string): Promise { + // Only uses 1 method + const token = await this.userService.login(email, password); + this.storeToken(token); + } +} + +// Profile component forced to depend on authentication and analytics +class ProfileEditor { + constructor(private userService: UserService) {} // Depends on 15+ methods! + + async saveProfile(userId: string, data: ProfileData): Promise { + // Only uses 2 methods + await this.userService.updateProfile(userId, data); + const profile = await this.userService.getProfile(userId); + } +} + +// Mock implementation nightmare for testing +class MockUserService implements UserService { + // Must implement ALL methods even for simple tests + login = jest.fn(); + logout = jest.fn(); + refreshToken = jest.fn(); + validateToken = jest.fn(); + createUser = jest.fn(); + updateUser = jest.fn(); + deleteUser = jest.fn(); + getUser = jest.fn(); + listUsers = jest.fn(); + updateProfile = jest.fn(); + uploadAvatar = jest.fn(); + getProfile = jest.fn(); + sendNotification = jest.fn(); + getNotificationPreferences = jest.fn(); + updateNotificationPreferences = jest.fn(); + trackUserEvent = jest.fn(); + getUserAnalytics = jest.fn(); +} +``` + +## Good Example + +```typescript +// Correct approach: Client-specific interfaces + +// Authentication client interface +interface Authenticator { + login(email: string, password: string): Promise; + logout(userId: string): Promise; + refreshToken(token: string): Promise; + validateToken(token: string): Promise; +} + +// User management client interface +interface UserManager { + createUser(data: CreateUserData): Promise; + updateUser(id: string, data: UpdateUserData): Promise; + deleteUser(id: string): Promise; + getUser(id: string): Promise; + listUsers(filters: UserFilters): Promise; +} + +// Profile client interface +interface ProfileManager { + updateProfile(userId: string, profile: ProfileData): Promise; + uploadAvatar(userId: string, image: Buffer): Promise; + getProfile(userId: string): Promise; +} + +// Notification client interface +interface NotificationManager { + sendNotification(userId: string, notification: Notification): Promise; + getNotificationPreferences(userId: string): Promise; + updateNotificationPreferences(userId: string, prefs: NotificationPrefs): Promise; +} + +// Analytics client interface +interface UserAnalyticsTracker { + trackUserEvent(userId: string, event: AnalyticsEvent): Promise; + getUserAnalytics(userId: string): Promise; +} + +// Read-only user lookup for components that only need to fetch +interface UserLookup { + getUser(id: string): Promise; + getProfile(userId: string): Promise; +} + +// Login page depends only on what it needs +class LoginPage { + constructor(private auth: Authenticator) {} // Only 4 methods + + async handleLogin(email: string, password: string): Promise { + const token = await this.auth.login(email, password); + this.storeToken(token); + } +} + +// Profile editor depends only on profile operations +class ProfileEditor { + constructor(private profileManager: ProfileManager) {} // Only 3 methods + + async saveProfile(userId: string, data: ProfileData): Promise { + await this.profileManager.updateProfile(userId, data); + } + + async loadProfile(userId: string): Promise { + return this.profileManager.getProfile(userId); + } +} + +// User display component only needs read access +class UserCard { + constructor(private userLookup: UserLookup) {} // Only 2 methods + + async render(userId: string): Promise { + const user = await this.userLookup.getUser(userId); + const profile = await this.userLookup.getProfile(userId); + // Render user card + } +} + +// Admin panel needs user management +class AdminUserPanel { + constructor(private userManager: UserManager) {} // Only 5 methods + + async deleteUserAccount(id: string): Promise { + await this.userManager.deleteUser(id); + } +} + +// Implementation can still implement multiple interfaces +class UserServiceImpl implements + Authenticator, + UserManager, + ProfileManager, + NotificationManager, + UserAnalyticsTracker, + UserLookup +{ + // Implement all methods... + async login(email: string, password: string): Promise { /* ... */ } + async logout(userId: string): Promise { /* ... */ } + // ... rest of implementation +} + +// Testing is now simple - only mock what you need +describe('LoginPage', () => { + it('should login user', async () => { + const mockAuth: Authenticator = { + login: jest.fn().mockResolvedValue({ token: 'abc123' }), + logout: jest.fn(), + refreshToken: jest.fn(), + validateToken: jest.fn() + }; + + const loginPage = new LoginPage(mockAuth); + await loginPage.handleLogin('test@example.com', 'password'); + + expect(mockAuth.login).toHaveBeenCalledWith('test@example.com', 'password'); + }); +}); + +describe('ProfileEditor', () => { + it('should save profile', async () => { + const mockProfileManager: ProfileManager = { + updateProfile: jest.fn().mockResolvedValue({}), + uploadAvatar: jest.fn(), + getProfile: jest.fn() + }; + + const editor = new ProfileEditor(mockProfileManager); + await editor.saveProfile('user1', { name: 'John' }); + + expect(mockProfileManager.updateProfile).toHaveBeenCalled(); + }); +}); +``` + +## Why + +1. **Minimal Dependencies**: Each client depends only on the methods it actually uses, reducing coupling. + +2. **Easier Testing**: Mocks are small and focused. Testing `LoginPage` requires mocking 4 methods, not 15+. + +3. **Better Encapsulation**: Clients can't accidentally call methods they shouldn't have access to. + +4. **Clearer Intent**: Interface names describe what clients need: `Authenticator`, `ProfileManager`, `UserLookup`. + +5. **Independent Evolution**: Changes to analytics don't affect authentication clients. Interfaces can evolve separately. + +6. **Flexible Composition**: Different implementations can provide different subsets of functionality. + +7. **Single Responsibility**: Each interface has a focused responsibility, making the system easier to understand. diff --git a/.agents/skills/clean-code-principles/rules/solid-isp-interfaces.md b/.agents/skills/clean-code-principles/rules/solid-isp-interfaces.md new file mode 100644 index 0000000..be92a05 --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-isp-interfaces.md @@ -0,0 +1,286 @@ +--- +id: solid-isp-interfaces +title: SOLID - Interface Segregation (Small Interfaces) +category: solid-principles +priority: critical +tags: [SOLID, ISP, interface-segregation, cohesion] +related: [solid-isp-clients, solid-srp-class, core-separation-concerns] +--- + +# Interface Segregation Principle - Small Cohesive Interfaces + +Interfaces should be small and cohesive, grouping only closely related methods. Split large interfaces into smaller, more focused ones. + +## Bad Example + +```typescript +// Anti-pattern: Large interface with unrelated methods grouped together + +interface Document { + // Content operations + getContent(): string; + setContent(content: string): void; + appendContent(content: string): void; + + // Persistence operations + save(): Promise; + load(id: string): Promise; + delete(): Promise; + + // Export operations + exportToPdf(): Promise; + exportToWord(): Promise; + exportToHtml(): Promise; + + // Collaboration operations + share(userId: string): Promise; + getCollaborators(): Promise; + addComment(comment: Comment): Promise; + getComments(): Promise; + + // Version control + createVersion(): Promise; + getVersionHistory(): Promise; + revertToVersion(versionId: string): Promise; + + // Permissions + setPermissions(permissions: Permissions): Promise; + getPermissions(): Promise; + checkPermission(userId: string, action: string): Promise; +} + +// Simple note-taking app forced to implement everything +class SimpleNote implements Document { + private content: string = ''; + + getContent(): string { return this.content; } + setContent(content: string): void { this.content = content; } + appendContent(content: string): void { this.content += content; } + + // Forced to implement methods it doesn't need + async save(): Promise { throw new Error('Not supported'); } + async load(id: string): Promise { throw new Error('Not supported'); } + async delete(): Promise { throw new Error('Not supported'); } + + async exportToPdf(): Promise { throw new Error('Not supported'); } + async exportToWord(): Promise { throw new Error('Not supported'); } + async exportToHtml(): Promise { throw new Error('Not supported'); } + + async share(userId: string): Promise { throw new Error('Not supported'); } + async getCollaborators(): Promise { throw new Error('Not supported'); } + async addComment(comment: Comment): Promise { throw new Error('Not supported'); } + async getComments(): Promise { throw new Error('Not supported'); } + + async createVersion(): Promise { throw new Error('Not supported'); } + async getVersionHistory(): Promise { throw new Error('Not supported'); } + async revertToVersion(versionId: string): Promise { throw new Error('Not supported'); } + + async setPermissions(permissions: Permissions): Promise { throw new Error('Not supported'); } + async getPermissions(): Promise { throw new Error('Not supported'); } + async checkPermission(userId: string, action: string): Promise { throw new Error('Not supported'); } +} +``` + +## Good Example + +```typescript +// Correct approach: Small, cohesive interfaces + +// Core content interface - the essence of a document +interface DocumentContent { + getContent(): string; + setContent(content: string): void; +} + +// Extended content operations +interface EditableContent extends DocumentContent { + appendContent(content: string): void; + insertContent(position: number, content: string): void; + deleteRange(start: number, end: number): void; +} + +// Persistence operations +interface Persistable { + save(): Promise; + load(id: string): Promise; +} + +interface Deletable { + delete(): Promise; +} + +// Export capabilities +interface PdfExportable { + exportToPdf(): Promise; +} + +interface WordExportable { + exportToWord(): Promise; +} + +interface HtmlExportable { + exportToHtml(): Promise; +} + +// Combine export interfaces when needed +interface FullyExportable extends PdfExportable, WordExportable, HtmlExportable {} + +// Collaboration interfaces +interface Shareable { + share(userId: string, permission: Permission): Promise; + unshare(userId: string): Promise; + getCollaborators(): Promise; +} + +interface Commentable { + addComment(comment: Comment): Promise; + removeComment(commentId: string): Promise; + getComments(): Promise; +} + +// Version control +interface Versionable { + createVersion(message?: string): Promise; + getVersionHistory(): Promise; + revertToVersion(versionId: string): Promise; +} + +// Permissions +interface PermissionControlled { + setPermissions(permissions: Permissions): Promise; + getPermissions(): Promise; + checkPermission(userId: string, action: string): Promise; +} + +// Simple note only implements what it needs +class SimpleNote implements DocumentContent { + private content: string = ''; + + getContent(): string { + return this.content; + } + + setContent(content: string): void { + this.content = content; + } +} + +// Persistent note adds storage +class PersistentNote implements EditableContent, Persistable { + private content: string = ''; + private id: string | null = null; + + constructor(private storage: StorageService) {} + + getContent(): string { return this.content; } + setContent(content: string): void { this.content = content; } + appendContent(content: string): void { this.content += content; } + insertContent(position: number, content: string): void { + this.content = this.content.slice(0, position) + content + this.content.slice(position); + } + deleteRange(start: number, end: number): void { + this.content = this.content.slice(0, start) + this.content.slice(end); + } + + async save(): Promise { + this.id = await this.storage.save(this.content); + } + + async load(id: string): Promise { + this.content = await this.storage.load(id); + this.id = id; + } +} + +// Full-featured collaborative document +class CollaborativeDocument implements + EditableContent, + Persistable, + Deletable, + FullyExportable, + Shareable, + Commentable, + Versionable, + PermissionControlled +{ + constructor( + private storage: StorageService, + private exportService: ExportService, + private collaborationService: CollaborationService, + private versionService: VersionService, + private permissionService: PermissionService + ) {} + + // Implement all methods with actual functionality + // Each service handles its domain + + getContent(): string { /* ... */ } + setContent(content: string): void { /* ... */ } + appendContent(content: string): void { /* ... */ } + insertContent(position: number, content: string): void { /* ... */ } + deleteRange(start: number, end: number): void { /* ... */ } + + async save(): Promise { /* delegate to storage */ } + async load(id: string): Promise { /* delegate to storage */ } + async delete(): Promise { /* delegate to storage */ } + + async exportToPdf(): Promise { return this.exportService.toPdf(this); } + async exportToWord(): Promise { return this.exportService.toWord(this); } + async exportToHtml(): Promise { return this.exportService.toHtml(this); } + + async share(userId: string, permission: Permission): Promise { /* ... */ } + async unshare(userId: string): Promise { /* ... */ } + async getCollaborators(): Promise { /* ... */ } + + async addComment(comment: Comment): Promise { /* ... */ } + async removeComment(commentId: string): Promise { /* ... */ } + async getComments(): Promise { /* ... */ } + + async createVersion(message?: string): Promise { /* ... */ } + async getVersionHistory(): Promise { /* ... */ } + async revertToVersion(versionId: string): Promise { /* ... */ } + + async setPermissions(permissions: Permissions): Promise { /* ... */ } + async getPermissions(): Promise { /* ... */ } + async checkPermission(userId: string, action: string): Promise { /* ... */ } +} + +// Functions can accept only the interfaces they need +function renderDocument(doc: DocumentContent): void { + console.log(doc.getContent()); +} + +async function exportToPdf(doc: PdfExportable): Promise { + const pdf = await doc.exportToPdf(); + // Send pdf... +} + +function canUserEdit(doc: PermissionControlled, userId: string): Promise { + return doc.checkPermission(userId, 'edit'); +} + +// All document types work where their interfaces are expected +const simpleNote = new SimpleNote(); +renderDocument(simpleNote); // Works! + +const collabDoc = new CollaborativeDocument(/* ... */); +renderDocument(collabDoc); // Works! +await exportToPdf(collabDoc); // Works! +await canUserEdit(collabDoc, 'user123'); // Works! +``` + +## Why + +1. **Cohesion**: Each interface groups related methods. `Commentable` is about comments, `Versionable` about versions. + +2. **Flexibility**: Classes implement only the interfaces that match their capabilities. + +3. **Composability**: Complex types are built by combining simple interfaces: `EditableContent & Persistable & Shareable`. + +4. **No Empty Implementations**: No need for `throw new Error('Not supported')` - just don't implement the interface. + +5. **Clear Capabilities**: Looking at a class's implemented interfaces tells you exactly what it can do. + +6. **Easier Evolution**: Add new capabilities by creating new interfaces without modifying existing ones. + +7. **Better Typing**: Functions declare exactly what they need. `exportToPdf(doc: PdfExportable)` is self-documenting. diff --git a/.agents/skills/clean-code-principles/rules/solid-lsp-contracts.md b/.agents/skills/clean-code-principles/rules/solid-lsp-contracts.md new file mode 100644 index 0000000..38a3c8d --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-lsp-contracts.md @@ -0,0 +1,203 @@ +--- +id: solid-lsp-contracts +title: SOLID - Liskov Substitution (Contracts) +category: solid-principles +priority: critical +tags: [SOLID, LSP, liskov-substitution, contracts] +related: [solid-lsp-preconditions, solid-ocp-abstraction, core-composition] +--- + +# Liskov Substitution Principle - Contracts + +Subtypes must be substitutable for their base types without altering the correctness of the program. Derived classes must honor the contracts established by their base classes. + +## Bad Example + +```typescript +// Anti-pattern: Subclass violates the contract of the base class + +class Rectangle { + protected _width: number; + protected _height: number; + + constructor(width: number, height: number) { + this._width = width; + this._height = height; + } + + get width(): number { + return this._width; + } + + set width(value: number) { + this._width = value; + } + + get height(): number { + return this._height; + } + + set height(value: number) { + this._height = value; + } + + getArea(): number { + return this._width * this._height; + } +} + +// Square violates LSP - it changes the behavior of setters +class Square extends Rectangle { + constructor(side: number) { + super(side, side); + } + + // Violates LSP: setter has different behavior than parent + set width(value: number) { + this._width = value; + this._height = value; // Unexpected side effect! + } + + set height(value: number) { + this._width = value; // Unexpected side effect! + this._height = value; + } +} + +// This code works with Rectangle but breaks with Square +function resizeRectangle(rect: Rectangle): void { + rect.width = 10; + rect.height = 5; + + // Expects area to be 50, but Square gives 25! + console.assert(rect.getArea() === 50, 'Area should be 50'); +} + +const rectangle = new Rectangle(4, 4); +resizeRectangle(rectangle); // Works: area is 50 + +const square = new Square(4); +resizeRectangle(square); // Fails: area is 25, not 50 +``` + +## Good Example + +```typescript +// Correct approach: Use composition and proper abstractions + +// Define what shapes can do +interface Shape { + getArea(): number; + getPerimeter(): number; +} + +// Define what resizable shapes can do +interface ResizableShape extends Shape { + scale(factor: number): void; +} + +// Immutable rectangle - no setters that could be violated +class Rectangle implements Shape { + constructor( + readonly width: number, + readonly height: number + ) { + if (width <= 0 || height <= 0) { + throw new Error('Dimensions must be positive'); + } + } + + getArea(): number { + return this.width * this.height; + } + + getPerimeter(): number { + return 2 * (this.width + this.height); + } + + // Return new instance instead of mutating + withWidth(width: number): Rectangle { + return new Rectangle(width, this.height); + } + + withHeight(height: number): Rectangle { + return new Rectangle(this.width, height); + } + + scale(factor: number): Rectangle { + return new Rectangle(this.width * factor, this.height * factor); + } +} + +// Square is its own shape, not a subtype of Rectangle +class Square implements Shape { + constructor(readonly side: number) { + if (side <= 0) { + throw new Error('Side must be positive'); + } + } + + getArea(): number { + return this.side * this.side; + } + + getPerimeter(): number { + return 4 * this.side; + } + + withSide(side: number): Square { + return new Square(side); + } + + scale(factor: number): Square { + return new Square(this.side * factor); + } +} + +// Functions work with the Shape interface +function printShapeInfo(shape: Shape): void { + console.log(`Area: ${shape.getArea()}`); + console.log(`Perimeter: ${shape.getPerimeter()}`); +} + +// This works correctly with both Rectangle and Square +const rect = new Rectangle(10, 5); +printShapeInfo(rect); // Area: 50, Perimeter: 30 + +const square = new Square(5); +printShapeInfo(square); // Area: 25, Perimeter: 20 + +// For operations specific to rectangles, use Rectangle type +function createBanner(width: number, height: number): Rectangle { + return new Rectangle(width, height); +} + +// For operations that work with any shape, use Shape interface +function calculateTotalArea(shapes: Shape[]): number { + return shapes.reduce((total, shape) => total + shape.getArea(), 0); +} + +const shapes: Shape[] = [ + new Rectangle(10, 5), + new Square(4), + new Rectangle(3, 7) +]; + +console.log(calculateTotalArea(shapes)); // 50 + 16 + 21 = 87 +``` + +## Why + +1. **Predictable Behavior**: Code using the base type works correctly with any subtype. No surprises. + +2. **Safe Polymorphism**: You can pass any `Shape` to functions expecting `Shape` without checking the concrete type. + +3. **Contract Honoring**: Each class fully honors its interface contract - `getArea()` always returns the correct area. + +4. **Immutability Benefits**: By making shapes immutable, we avoid the setter problem entirely. + +5. **Proper Modeling**: Square and Rectangle are separate concepts that happen to share behavior (Shape), not a parent-child relationship. + +6. **Easier Testing**: Tests for `Shape` work for all implementations. No special cases needed. + +7. **Design Clarity**: The inheritance hierarchy reflects true "is-a" relationships, not just code reuse. diff --git a/.agents/skills/clean-code-principles/rules/solid-lsp-preconditions.md b/.agents/skills/clean-code-principles/rules/solid-lsp-preconditions.md new file mode 100644 index 0000000..aeb950a --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-lsp-preconditions.md @@ -0,0 +1,256 @@ +--- +id: solid-lsp-preconditions +title: SOLID - Liskov Substitution (Preconditions) +category: solid-principles +priority: critical +tags: [SOLID, LSP, liskov-substitution, preconditions, postconditions] +related: [solid-lsp-contracts, core-fail-fast, solid-ocp-abstraction] +--- + +# Liskov Substitution Principle - Preconditions and Postconditions + +Subtypes cannot strengthen preconditions (require more) or weaken postconditions (guarantee less) compared to their base types. + +## Bad Example + +```typescript +// Anti-pattern: Subclass strengthens preconditions and weakens postconditions + +interface PaymentProcessor { + // Contract: accepts any amount > 0, returns transaction ID + processPayment(amount: number): Promise; +} + +class StandardPaymentProcessor implements PaymentProcessor { + async processPayment(amount: number): Promise { + if (amount <= 0) { + throw new Error('Amount must be positive'); + } + // Process payment and return transaction ID + return `TXN-${Date.now()}`; + } +} + +// Violates LSP: Strengthens preconditions +class PremiumPaymentProcessor implements PaymentProcessor { + private readonly minimumAmount = 100; // Stronger precondition! + private readonly maximumAmount = 10000; // Stronger precondition! + + async processPayment(amount: number): Promise { + if (amount <= 0) { + throw new Error('Amount must be positive'); + } + // Additional restrictions not in base contract + if (amount < this.minimumAmount) { + throw new Error(`Minimum amount is ${this.minimumAmount}`); // Violates LSP! + } + if (amount > this.maximumAmount) { + throw new Error(`Maximum amount is ${this.maximumAmount}`); // Violates LSP! + } + return `PREM-TXN-${Date.now()}`; + } +} + +// Violates LSP: Weakens postconditions +class UnreliablePaymentProcessor implements PaymentProcessor { + async processPayment(amount: number): Promise { + if (amount <= 0) { + throw new Error('Amount must be positive'); + } + + // Sometimes returns null instead of transaction ID - weaker postcondition! + if (Math.random() > 0.5) { + return null as any; // Violates the guarantee of returning a string + } + + return `TXN-${Date.now()}`; + } +} + +// Client code breaks when substituting implementations +async function checkout(processor: PaymentProcessor, amount: number): Promise { + const txnId = await processor.processPayment(amount); + // Assumes txnId is always a valid string (as per contract) + console.log(`Payment complete: ${txnId.toUpperCase()}`); // Crashes with null! +} + +// Works with StandardPaymentProcessor +await checkout(new StandardPaymentProcessor(), 50); + +// Fails with PremiumPaymentProcessor - amount too low +await checkout(new PremiumPaymentProcessor(), 50); // Error: Minimum amount is 100 + +// Fails with UnreliablePaymentProcessor - null txnId +await checkout(new UnreliablePaymentProcessor(), 50); // Error: Cannot read toUpperCase of null +``` + +## Good Example + +```typescript +// Correct approach: Subtypes honor preconditions and postconditions + +// Clear contract with documented preconditions and postconditions +interface PaymentProcessor { + /** + * Process a payment transaction. + * + * @precondition amount > 0 + * @postcondition returns a non-empty transaction ID string + * @throws PaymentError if payment fails (not for invalid preconditions) + */ + processPayment(amount: number): Promise; + + /** + * Check if this processor can handle the given amount. + * Allows clients to check before attempting payment. + */ + canProcess(amount: number): boolean; + + /** + * Get the constraints of this processor. + */ + getConstraints(): ProcessorConstraints; +} + +interface PaymentResult { + readonly transactionId: string; + readonly processedAt: Date; + readonly amount: number; + readonly status: 'success' | 'pending'; +} + +interface ProcessorConstraints { + readonly minAmount: number; + readonly maxAmount: number; + readonly supportedCurrencies: string[]; +} + +// Base implementation with standard constraints +class StandardPaymentProcessor implements PaymentProcessor { + private readonly constraints: ProcessorConstraints = { + minAmount: 0.01, + maxAmount: Infinity, + supportedCurrencies: ['USD', 'EUR', 'GBP'] + }; + + getConstraints(): ProcessorConstraints { + return this.constraints; + } + + canProcess(amount: number): boolean { + return amount >= this.constraints.minAmount && + amount <= this.constraints.maxAmount; + } + + async processPayment(amount: number): Promise { + // Precondition check (same as interface contract) + if (amount <= 0) { + throw new InvalidAmountError('Amount must be positive'); + } + + // Implementation + const transactionId = `TXN-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + + // Postcondition: always returns valid PaymentResult + return { + transactionId, + processedAt: new Date(), + amount, + status: 'success' + }; + } +} + +// Premium processor with different constraints but same contract behavior +class PremiumPaymentProcessor implements PaymentProcessor { + private readonly constraints: ProcessorConstraints = { + minAmount: 100, + maxAmount: 100000, + supportedCurrencies: ['USD', 'EUR', 'GBP', 'JPY', 'CHF'] + }; + + getConstraints(): ProcessorConstraints { + return this.constraints; + } + + // Clients can check constraints before calling processPayment + canProcess(amount: number): boolean { + return amount >= this.constraints.minAmount && + amount <= this.constraints.maxAmount; + } + + async processPayment(amount: number): Promise { + // Same precondition as interface - not strengthened + if (amount <= 0) { + throw new InvalidAmountError('Amount must be positive'); + } + + // Business logic can still reject, but with proper error handling + if (!this.canProcess(amount)) { + throw new PaymentError( + `Amount ${amount} outside processor range [${this.constraints.minAmount}, ${this.constraints.maxAmount}]` + ); + } + + // Premium processing logic + const transactionId = `PREM-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`; + + // Postcondition: always returns valid PaymentResult (same guarantee) + return { + transactionId, + processedAt: new Date(), + amount, + status: 'success' + }; + } +} + +// Factory that selects appropriate processor +class PaymentProcessorFactory { + private processors: PaymentProcessor[] = [ + new StandardPaymentProcessor(), + new PremiumPaymentProcessor() + ]; + + getProcessorFor(amount: number): PaymentProcessor { + const suitable = this.processors.find(p => p.canProcess(amount)); + if (!suitable) { + throw new NoSuitableProcessorError(amount); + } + return suitable; + } +} + +// Client code works correctly with any processor +async function checkout(amount: number, factory: PaymentProcessorFactory): Promise { + const processor = factory.getProcessorFor(amount); + const result = await processor.processPayment(amount); + + // Postcondition guarantees result is valid + console.log(`Payment complete: ${result.transactionId}`); + console.log(`Processed at: ${result.processedAt.toISOString()}`); +} + +// Usage +const factory = new PaymentProcessorFactory(); + +await checkout(50, factory); // Uses StandardPaymentProcessor +await checkout(500, factory); // Uses PremiumPaymentProcessor +await checkout(5000, factory); // Uses PremiumPaymentProcessor +``` + +## Why + +1. **Substitutability**: Any `PaymentProcessor` can be used interchangeably where `PaymentProcessor` is expected. + +2. **Client Safety**: Clients can rely on the contract. If preconditions are met, postconditions are guaranteed. + +3. **Explicit Constraints**: Instead of silently strengthening preconditions, processors expose their constraints through `getConstraints()` and `canProcess()`. + +4. **Proper Error Handling**: Constraint violations throw appropriate errors that clients can handle, rather than unexpected failures. + +5. **Factory Pattern**: The factory selects the appropriate processor, keeping constraint logic out of client code. + +6. **Design by Contract**: Clear documentation of preconditions and postconditions makes the contract explicit. + +7. **Defensive Programming**: Clients can check `canProcess()` before attempting payment, avoiding errors entirely. diff --git a/.agents/skills/clean-code-principles/rules/solid-ocp-abstraction.md b/.agents/skills/clean-code-principles/rules/solid-ocp-abstraction.md new file mode 100644 index 0000000..2d1f57a --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-ocp-abstraction.md @@ -0,0 +1,265 @@ +--- +id: solid-ocp-abstraction +title: SOLID - Open/Closed (Abstraction) +category: solid-principles +priority: critical +tags: [SOLID, OCP, open-closed, abstraction] +related: [solid-ocp-extension, solid-dip-abstractions, pattern-repository] +--- + +# Open/Closed Principle - Abstraction + +Use abstractions (interfaces and abstract classes) to define stable contracts that allow new implementations without modifying existing code. + +## Bad Example + +```typescript +// Anti-pattern: Concrete dependencies that require modification for new requirements +class ReportGenerator { + generateReport(data: SalesData[], format: string): string { + let report = ''; + + // Calculate totals + const totalSales = data.reduce((sum, d) => sum + d.amount, 0); + const averageSale = totalSales / data.length; + + if (format === 'html') { + report = ` + + +

Sales Report

+

Total Sales: $${totalSales}

+

Average Sale: $${averageSale.toFixed(2)}

+ + ${data.map(d => ``).join('')} +
${d.date}$${d.amount}
+ + + `; + } else if (format === 'pdf') { + // PDF generation logic mixed in + const pdf = new PDFDocument(); + pdf.text('Sales Report'); + pdf.text(`Total: $${totalSales}`); + // ... more PDF logic + report = pdf.output(); + } else if (format === 'csv') { + report = 'Date,Amount\n'; + report += data.map(d => `${d.date},${d.amount}`).join('\n'); + } else if (format === 'excel') { + // Excel generation logic + // Adding new format requires modifying this class + } + + return report; + } +} + +// Problems: +// 1. Adding JSON format requires modifying ReportGenerator +// 2. All format logic is tightly coupled +// 3. Testing is difficult +// 4. No way to extend without modification +``` + +## Good Example + +```typescript +// Correct approach: Abstractions enable extension without modification + +// Stable abstraction for report data +interface ReportData { + readonly title: string; + readonly generatedAt: Date; + readonly summary: ReportSummary; + readonly items: ReportItem[]; +} + +interface ReportSummary { + readonly totalSales: number; + readonly averageSale: number; + readonly itemCount: number; +} + +interface ReportItem { + readonly date: string; + readonly amount: number; + readonly description?: string; +} + +// Stable abstraction for formatters +interface ReportFormatter { + readonly format: string; + readonly mimeType: string; + render(data: ReportData): string | Buffer | Promise; +} + +// Stable abstraction for data sources +interface ReportDataSource { + fetch(criteria: ReportCriteria): Promise; + transform(rawData: T[]): ReportData; +} + +// Report generator depends only on abstractions +class ReportGenerator { + constructor( + private formatters: Map = new Map() + ) {} + + registerFormatter(formatter: ReportFormatter): void { + this.formatters.set(formatter.format, formatter); + } + + async generate( + dataSource: ReportDataSource, + criteria: ReportCriteria, + format: string + ): Promise { + const formatter = this.formatters.get(format); + if (!formatter) { + throw new UnsupportedFormatError(format, this.getSupportedFormats()); + } + + const rawData = await dataSource.fetch(criteria); + const reportData = dataSource.transform(rawData); + const content = await Promise.resolve(formatter.render(reportData)); + + return { + content, + mimeType: formatter.mimeType, + format, + generatedAt: new Date() + }; + } + + getSupportedFormats(): string[] { + return Array.from(this.formatters.keys()); + } +} + +// Concrete formatters - each can be added without modifying ReportGenerator + +class HtmlReportFormatter implements ReportFormatter { + readonly format = 'html'; + readonly mimeType = 'text/html'; + + render(data: ReportData): string { + return ` + + + ${data.title} + +

${data.title}

+

Generated: ${data.generatedAt.toISOString()}

+
+

Total Sales: $${data.summary.totalSales.toFixed(2)}

+

Average Sale: $${data.summary.averageSale.toFixed(2)}

+
+ + + + ${data.items.map(item => + `` + ).join('')} + +
DateAmount
${item.date}$${item.amount}
+ + + `; + } +} + +class CsvReportFormatter implements ReportFormatter { + readonly format = 'csv'; + readonly mimeType = 'text/csv'; + + render(data: ReportData): string { + const header = 'Date,Amount,Description'; + const rows = data.items.map(item => + `${item.date},${item.amount},"${item.description || ''}"` + ); + return [header, ...rows].join('\n'); + } +} + +class JsonReportFormatter implements ReportFormatter { + readonly format = 'json'; + readonly mimeType = 'application/json'; + + render(data: ReportData): string { + return JSON.stringify(data, null, 2); + } +} + +// Adding Excel format - no modification to existing code +class ExcelReportFormatter implements ReportFormatter { + readonly format = 'xlsx'; + readonly mimeType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + + render(data: ReportData): Buffer { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(data.title); + + sheet.addRow(['Date', 'Amount', 'Description']); + data.items.forEach(item => { + sheet.addRow([item.date, item.amount, item.description || '']); + }); + + return workbook.xlsx.writeBuffer(); + } +} + +// Concrete data source - can create new sources without modification +class SalesReportDataSource implements ReportDataSource { + constructor(private salesRepository: SalesRepository) {} + + async fetch(criteria: ReportCriteria): Promise { + return this.salesRepository.findByCriteria(criteria); + } + + transform(rawData: SalesData[]): ReportData { + const totalSales = rawData.reduce((sum, d) => sum + d.amount, 0); + + return { + title: 'Sales Report', + generatedAt: new Date(), + summary: { + totalSales, + averageSale: rawData.length > 0 ? totalSales / rawData.length : 0, + itemCount: rawData.length + }, + items: rawData.map(d => ({ + date: d.date, + amount: d.amount, + description: d.productName + })) + }; + } +} + +// Usage +const generator = new ReportGenerator(); +generator.registerFormatter(new HtmlReportFormatter()); +generator.registerFormatter(new CsvReportFormatter()); +generator.registerFormatter(new JsonReportFormatter()); +generator.registerFormatter(new ExcelReportFormatter()); + +const salesDataSource = new SalesReportDataSource(salesRepository); +const report = await generator.generate(salesDataSource, criteria, 'xlsx'); +``` + +## Why + +1. **Stable Core**: The `ReportGenerator` class and interfaces form a stable core that rarely changes. + +2. **Independent Development**: Teams can develop new formatters or data sources independently. + +3. **Composition Over Inheritance**: New functionality is added through composition (registering new implementations) rather than inheritance hierarchies. + +4. **Testing**: Each component can be tested in isolation with mock implementations of interfaces. + +5. **Flexibility**: The same generator works with any combination of data sources and formatters. + +6. **Dependency Inversion**: High-level modules (ReportGenerator) don't depend on low-level modules (specific formatters), both depend on abstractions. + +7. **Plugin System**: Natural foundation for a plugin architecture where third parties can add formatters. diff --git a/.agents/skills/clean-code-principles/rules/solid-ocp-extension.md b/.agents/skills/clean-code-principles/rules/solid-ocp-extension.md new file mode 100644 index 0000000..88515ba --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-ocp-extension.md @@ -0,0 +1,205 @@ +--- +id: solid-ocp-extension +title: SOLID - Open/Closed Principle (Extension) +category: solid-principles +priority: critical +tags: [SOLID, OCP, open-closed, extensibility, design-patterns] +related: [solid-ocp-abstraction, pattern-repository, solid-dip-abstractions] +--- + +# Open/Closed Principle - Extension + +Software entities should be open for extension but closed for modification. Add new functionality by adding new code, not by changing existing code. + +## Bad Example + +```typescript +// Anti-pattern: Modifying existing code to add new functionality +class PaymentProcessor { + processPayment(payment: Payment): PaymentResult { + switch (payment.method) { + case 'credit_card': + return this.processCreditCard(payment); + case 'paypal': + return this.processPayPal(payment); + case 'stripe': + return this.processStripe(payment); + // Every new payment method requires modifying this class + case 'apple_pay': + return this.processApplePay(payment); + case 'google_pay': + return this.processGooglePay(payment); + case 'crypto': + return this.processCrypto(payment); + default: + throw new Error(`Unknown payment method: ${payment.method}`); + } + } + + private processCreditCard(payment: Payment): PaymentResult { + // Credit card logic + } + + private processPayPal(payment: Payment): PaymentResult { + // PayPal logic + } + + private processStripe(payment: Payment): PaymentResult { + // Stripe logic + } + + private processApplePay(payment: Payment): PaymentResult { + // Apple Pay logic + } + + private processGooglePay(payment: Payment): PaymentResult { + // Google Pay logic + } + + private processCrypto(payment: Payment): PaymentResult { + // Crypto logic + } +} + +// Adding a new payment method requires: +// 1. Adding a new case to the switch statement +// 2. Adding a new private method +// 3. Testing the entire class again +``` + +## Good Example + +```typescript +// Correct approach: Open for extension, closed for modification + +// Define the contract for payment handlers +interface PaymentHandler { + readonly methodType: string; + canHandle(payment: Payment): boolean; + process(payment: Payment): Promise; + validate(payment: Payment): ValidationResult; +} + +// Payment processor that never needs modification +class PaymentProcessor { + private handlers: Map = new Map(); + + registerHandler(handler: PaymentHandler): void { + this.handlers.set(handler.methodType, handler); + } + + async processPayment(payment: Payment): Promise { + const handler = this.handlers.get(payment.method); + + if (!handler) { + throw new UnsupportedPaymentMethodError(payment.method); + } + + const validation = handler.validate(payment); + if (!validation.isValid) { + throw new PaymentValidationError(validation.errors); + } + + return handler.process(payment); + } + + getSupportedMethods(): string[] { + return Array.from(this.handlers.keys()); + } +} + +// Each payment method is a separate class that can be added without modifying PaymentProcessor + +class CreditCardPaymentHandler implements PaymentHandler { + readonly methodType = 'credit_card'; + + canHandle(payment: Payment): boolean { + return payment.method === this.methodType; + } + + validate(payment: Payment): ValidationResult { + const errors: string[] = []; + if (!payment.cardNumber || payment.cardNumber.length !== 16) { + errors.push('Invalid card number'); + } + if (!payment.cvv || payment.cvv.length !== 3) { + errors.push('Invalid CVV'); + } + return { isValid: errors.length === 0, errors }; + } + + async process(payment: Payment): Promise { + // Credit card processing logic + return { success: true, transactionId: generateId() }; + } +} + +class PayPalPaymentHandler implements PaymentHandler { + readonly methodType = 'paypal'; + + canHandle(payment: Payment): boolean { + return payment.method === this.methodType; + } + + validate(payment: Payment): ValidationResult { + const errors: string[] = []; + if (!payment.paypalEmail) { + errors.push('PayPal email required'); + } + return { isValid: errors.length === 0, errors }; + } + + async process(payment: Payment): Promise { + // PayPal processing logic + return { success: true, transactionId: generateId() }; + } +} + +// Adding a new payment method - no modification to existing code! +class CryptoPaymentHandler implements PaymentHandler { + readonly methodType = 'crypto'; + + canHandle(payment: Payment): boolean { + return payment.method === this.methodType; + } + + validate(payment: Payment): ValidationResult { + const errors: string[] = []; + if (!payment.walletAddress) { + errors.push('Wallet address required'); + } + if (!payment.cryptoCurrency) { + errors.push('Cryptocurrency type required'); + } + return { isValid: errors.length === 0, errors }; + } + + async process(payment: Payment): Promise { + // Crypto processing logic + return { success: true, transactionId: generateId() }; + } +} + +// Application setup - register handlers +const processor = new PaymentProcessor(); +processor.registerHandler(new CreditCardPaymentHandler()); +processor.registerHandler(new PayPalPaymentHandler()); +processor.registerHandler(new CryptoPaymentHandler()); +// Add new payment methods by simply registering new handlers +``` + +## Why + +1. **Stability**: The `PaymentProcessor` class is stable and tested. Adding new payment methods doesn't risk breaking existing functionality. + +2. **Team Scalability**: Different developers can work on different payment handlers simultaneously without merge conflicts. + +3. **Plugin Architecture**: Payment handlers can be loaded dynamically, even from external packages or plugins. + +4. **Testing Isolation**: Each handler is tested independently. New handlers don't require retesting old ones. + +5. **Feature Flags**: New payment methods can be enabled/disabled by simply registering or not registering the handler. + +6. **Compliance**: When regulations change for one payment method, only that handler needs updating. + +7. **Rollback Safety**: If a new payment handler has issues, remove it without touching proven code. diff --git a/.agents/skills/clean-code-principles/rules/solid-srp-class.md b/.agents/skills/clean-code-principles/rules/solid-srp-class.md new file mode 100644 index 0000000..765328d --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-srp-class.md @@ -0,0 +1,149 @@ +--- +id: solid-srp-class +title: SOLID - Single Responsibility Principle (Class Level) +category: solid-principles +priority: critical +tags: [SOLID, SRP, single-responsibility, class-design] +related: [solid-srp-function, core-separation-concerns, solid-isp-interfaces] +--- + +# Single Responsibility Principle - Class Level + +A class should have only one reason to change, meaning it should have only one job or responsibility. + +## Bad Example + +```typescript +// Anti-pattern: Class handles multiple responsibilities +class UserManager { + private db: Database; + private mailer: EmailService; + + constructor() { + this.db = new Database(); + this.mailer = new EmailService(); + } + + createUser(userData: UserData): User { + // Validation logic + if (!userData.email.includes('@')) { + throw new Error('Invalid email'); + } + if (userData.password.length < 8) { + throw new Error('Password too short'); + } + + // Database logic + const user = this.db.insert('users', userData); + + // Email logic + const htmlContent = `

Welcome ${user.name}!

`; + this.mailer.send(user.email, 'Welcome', htmlContent); + + // Logging logic + console.log(`User created: ${user.id} at ${new Date()}`); + this.writeToFile(`/logs/users.log`, `Created: ${user.id}`); + + return user; + } + + private writeToFile(path: string, content: string): void { + // File writing implementation + } +} +``` + +## Good Example + +```typescript +// Correct approach: Each class has a single responsibility + +// Handles user validation only +class UserValidator { + validate(userData: UserData): ValidationResult { + const errors: string[] = []; + + if (!userData.email.includes('@')) { + errors.push('Invalid email format'); + } + if (userData.password.length < 8) { + errors.push('Password must be at least 8 characters'); + } + + return { isValid: errors.length === 0, errors }; + } +} + +// Handles user persistence only +class UserRepository { + constructor(private db: Database) {} + + save(userData: UserData): User { + return this.db.insert('users', userData); + } + + findById(id: string): User | null { + return this.db.findOne('users', { id }); + } +} + +// Handles welcome email only +class WelcomeEmailService { + constructor(private mailer: EmailService) {} + + sendWelcomeEmail(user: User): void { + const htmlContent = this.buildTemplate(user); + this.mailer.send(user.email, 'Welcome', htmlContent); + } + + private buildTemplate(user: User): string { + return `

Welcome ${user.name}!

`; + } +} + +// Handles user activity logging only +class UserActivityLogger { + constructor(private logger: Logger) {} + + logCreation(user: User): void { + this.logger.info(`User created: ${user.id}`, { userId: user.id }); + } +} + +// Orchestrates the user creation process +class UserService { + constructor( + private validator: UserValidator, + private repository: UserRepository, + private welcomeEmail: WelcomeEmailService, + private activityLogger: UserActivityLogger + ) {} + + createUser(userData: UserData): User { + const validation = this.validator.validate(userData); + if (!validation.isValid) { + throw new ValidationError(validation.errors); + } + + const user = this.repository.save(userData); + this.welcomeEmail.sendWelcomeEmail(user); + this.activityLogger.logCreation(user); + + return user; + } +} +``` + +## Why + +1. **Easier Testing**: Each class can be unit tested in isolation without mocking unrelated dependencies. + +2. **Reduced Coupling**: Changes to email templates don't affect database logic or validation rules. + +3. **Better Reusability**: The `UserValidator` can be reused for profile updates, the `WelcomeEmailService` can be triggered from different flows. + +4. **Clearer Ownership**: Teams can own specific classes without stepping on each other's work. + +5. **Simpler Maintenance**: Bug in email formatting? Look only at `WelcomeEmailService`. Validation issue? Check `UserValidator`. + +6. **Flexible Composition**: Easy to add features like async email sending or different logging strategies without touching core logic. diff --git a/.agents/skills/clean-code-principles/rules/solid-srp-function.md b/.agents/skills/clean-code-principles/rules/solid-srp-function.md new file mode 100644 index 0000000..842888e --- /dev/null +++ b/.agents/skills/clean-code-principles/rules/solid-srp-function.md @@ -0,0 +1,208 @@ +--- +id: solid-srp-function +title: SOLID - Single Responsibility Principle (Function Level) +category: solid-principles +priority: critical +tags: [SOLID, SRP, single-responsibility, function-design] +related: [solid-srp-class, core-dry-extraction, core-kiss-simplicity] +--- + +# Single Responsibility Principle - Function Level + +A function should do one thing, do it well, and do it only. It should have a single, clear purpose that can be described without using conjunctions like "and" or "or". + +## Bad Example + +```typescript +// Anti-pattern: Function does multiple things +async function processOrder(orderId: string): Promise { + // Fetch order + const order = await db.query('SELECT * FROM orders WHERE id = ?', [orderId]); + + // Validate order + if (!order) throw new Error('Order not found'); + if (order.status !== 'pending') throw new Error('Order already processed'); + if (order.items.length === 0) throw new Error('Order has no items'); + + // Calculate totals + let subtotal = 0; + for (const item of order.items) { + const product = await db.query('SELECT price FROM products WHERE id = ?', [item.productId]); + subtotal += product.price * item.quantity; + } + const tax = subtotal * 0.1; + const shipping = subtotal > 100 ? 0 : 10; + const total = subtotal + tax + shipping; + + // Update inventory + for (const item of order.items) { + await db.query('UPDATE products SET stock = stock - ? WHERE id = ?', + [item.quantity, item.productId]); + } + + // Process payment + const paymentResult = await stripe.charges.create({ + amount: Math.round(total * 100), + currency: 'usd', + customer: order.customerId + }); + + // Update order status + await db.query('UPDATE orders SET status = ?, total = ?, payment_id = ? WHERE id = ?', + ['completed', total, paymentResult.id, orderId]); + + // Send confirmation email + const emailHtml = `

Order Confirmed

Total: $${total}

`; + await sendgrid.send({ + to: order.customerEmail, + subject: 'Order Confirmation', + html: emailHtml + }); + + // Log analytics + await analytics.track('order_completed', { orderId, total, itemCount: order.items.length }); +} +``` + +## Good Example + +```typescript +// Correct approach: Each function has a single purpose + +async function processOrder(orderId: string): Promise { + const order = await fetchAndValidateOrder(orderId); + const pricing = await calculateOrderPricing(order); + + await reserveInventory(order.items); + + try { + const payment = await processPayment(order.customerId, pricing.total); + const completedOrder = await finalizeOrder(order, pricing, payment); + + await sendOrderConfirmation(completedOrder); + await trackOrderCompletion(completedOrder); + + return completedOrder; + } catch (error) { + await releaseInventory(order.items); + throw error; + } +} + +async function fetchAndValidateOrder(orderId: string): Promise { + const order = await orderRepository.findById(orderId); + + if (!order) { + throw new OrderNotFoundError(orderId); + } + + validateOrderStatus(order); + validateOrderItems(order); + + return order; +} + +function validateOrderStatus(order: Order): void { + if (order.status !== 'pending') { + throw new InvalidOrderStatusError(order.id, order.status); + } +} + +function validateOrderItems(order: Order): void { + if (order.items.length === 0) { + throw new EmptyOrderError(order.id); + } +} + +async function calculateOrderPricing(order: Order): Promise { + const subtotal = await calculateSubtotal(order.items); + const tax = calculateTax(subtotal); + const shipping = calculateShipping(subtotal); + + return { + subtotal, + tax, + shipping, + total: subtotal + tax + shipping + }; +} + +async function calculateSubtotal(items: OrderItem[]): Promise { + const itemTotals = await Promise.all( + items.map(item => calculateItemTotal(item)) + ); + return itemTotals.reduce((sum, total) => sum + total, 0); +} + +async function calculateItemTotal(item: OrderItem): Promise { + const product = await productRepository.findById(item.productId); + return product.price * item.quantity; +} + +function calculateTax(subtotal: number): number { + const TAX_RATE = 0.1; + return subtotal * TAX_RATE; +} + +function calculateShipping(subtotal: number): number { + const FREE_SHIPPING_THRESHOLD = 100; + const STANDARD_SHIPPING = 10; + return subtotal > FREE_SHIPPING_THRESHOLD ? 0 : STANDARD_SHIPPING; +} + +async function reserveInventory(items: OrderItem[]): Promise { + await Promise.all( + items.map(item => inventoryService.reserve(item.productId, item.quantity)) + ); +} + +async function releaseInventory(items: OrderItem[]): Promise { + await Promise.all( + items.map(item => inventoryService.release(item.productId, item.quantity)) + ); +} + +async function processPayment(customerId: string, amount: number): Promise { + return paymentService.charge(customerId, amount); +} + +async function finalizeOrder( + order: Order, + pricing: OrderPricing, + payment: Payment +): Promise { + return orderRepository.updateStatus(order.id, { + status: 'completed', + total: pricing.total, + paymentId: payment.id + }); +} + +async function sendOrderConfirmation(order: ProcessedOrder): Promise { + await emailService.sendOrderConfirmation(order); +} + +async function trackOrderCompletion(order: ProcessedOrder): Promise { + await analyticsService.track('order_completed', { + orderId: order.id, + total: order.total, + itemCount: order.items.length + }); +} +``` + +## Why + +1. **Readability**: The main function reads like a story - fetch, validate, calculate, reserve, charge, finalize, notify. + +2. **Testability**: Each function can be unit tested independently. Testing `calculateTax` doesn't require mocking a database. + +3. **Reusability**: `calculateShipping` can be reused in a shipping estimate feature. `validateOrderStatus` can be used in other order operations. + +4. **Debugging**: Stack traces point to specific functions. "Error in calculateSubtotal" is more helpful than "Error in processOrder at line 47". + +5. **Modification**: Changing tax calculation only touches `calculateTax`. Adding a discount feature can be inserted cleanly between subtotal and tax. + +6. **Error Handling**: Each function can have appropriate error handling. The main function can orchestrate rollback on failure. + +7. **Documentation**: Function names serve as documentation. The code is self-explanatory without comments. diff --git a/.agents/skills/clean-code/SKILL.md b/.agents/skills/clean-code/SKILL.md new file mode 100644 index 0000000..ac11a9e --- /dev/null +++ b/.agents/skills/clean-code/SKILL.md @@ -0,0 +1,99 @@ +--- +name: clean-code +description: "This skill embodies the principles of \"Clean Code\" by Robert C. Martin (Uncle Bob). Use it to transform \"code that works\" into \"code that is clean.\"" +risk: safe +source: "ClawForge (https://github.com/jackjin1997/ClawForge)" +date_added: "2026-02-27" +--- + +# Clean Code Skill + +This skill embodies the principles of "Clean Code" by Robert C. Martin (Uncle Bob). Use it to transform "code that works" into "code that is clean." + +## 🧠 Core Philosophy +> "Code is clean if it can be read, and enhanced by a developer other than its original author." — Grady Booch + +## When to Use +Use this skill when: +- **Writing new code**: To ensure high quality from the start. +- **Reviewing Pull Requests**: To provide constructive, principle-based feedback. +- **Refactoring legacy code**: To identify and remove code smells. +- **Improving team standards**: To align on industry-standard best practices. + +## 1. Meaningful Names +- **Use Intention-Revealing Names**: `elapsedTimeInDays` instead of `d`. +- **Avoid Disinformation**: Don't use `accountList` if it's actually a `Map`. +- **Make Meaningful Distinctions**: Avoid `ProductData` vs `ProductInfo`. +- **Use Pronounceable/Searchable Names**: Avoid `genymdhms`. +- **Class Names**: Use nouns (`Customer`, `WikiPage`). Avoid `Manager`, `Data`. +- **Method Names**: Use verbs (`postPayment`, `deletePage`). + +## 2. Functions +- **Small!**: Functions should be shorter than you think. +- **Do One Thing**: A function should do only one thing, and do it well. +- **One Level of Abstraction**: Don't mix high-level business logic with low-level details (like regex). +- **Descriptive Names**: `isPasswordValid` is better than `check`. +- **Arguments**: 0 is ideal, 1-2 is okay, 3+ requires a very strong justification. +- **No Side Effects**: Functions shouldn't secretly change global state. + +## 3. Comments +- **Don't Comment Bad Code—Rewrite It**: Most comments are a sign of failure to express ourselves in code. +- **Explain Yourself in Code**: + ```python + # Check if employee is eligible for full benefits + if employee.flags & HOURLY and employee.age > 65: + ``` + vs + ```python + if employee.isEligibleForFullBenefits(): + ``` +- **Good Comments**: Legal, Informative (regex intent), Clarification (external libraries), TODOs. +- **Bad Comments**: Mumbling, Redundant, Misleading, Mandated, Noise, Position Markers. + +## 4. Formatting +- **The Newspaper Metaphor**: High-level concepts at the top, details at the bottom. +- **Vertical Density**: Related lines should be close to each other. +- **Distance**: Variables should be declared near their usage. +- **Indentation**: Essential for structural readability. + +## 5. Objects and Data Structures +- **Data Abstraction**: Hide the implementation behind interfaces. +- **The Law of Demeter**: A module should not know about the innards of the objects it manipulates. Avoid `a.getB().getC().doSomething()`. +- **Data Transfer Objects (DTO)**: Classes with public variables and no functions. + +## 6. Error Handling +- **Use Exceptions instead of Return Codes**: Keeps logic clean. +- **Write Try-Catch-Finally First**: Defines the scope of the operation. +- **Don't Return Null**: It forces the caller to check for null every time. +- **Don't Pass Null**: Leads to `NullPointerException`. + +## 7. Unit Tests +- **The Three Laws of TDD**: + 1. Don't write production code until you have a failing unit test. + 2. Don't write more of a unit test than is sufficient to fail. + 3. Don't write more production code than is sufficient to pass the failing test. +- **F.I.R.S.T. Principles**: Fast, Independent, Repeatable, Self-Validating, Timely. + +## 8. Classes +- **Small!**: Classes should have a single responsibility (SRP). +- **The Stepdown Rule**: We want the code to read like a top-down narrative. + +## 9. Smells and Heuristics +- **Rigidity**: Hard to change. +- **Fragility**: Breaks in many places. +- **Immobility**: Hard to reuse. +- **Viscosity**: Hard to do the right thing. +- **Needless Complexity/Repetition**. + +## 🛠️ Implementation Checklist +- [ ] Is this function smaller than 20 lines? +- [ ] Does this function do exactly one thing? +- [ ] Are all names searchable and intention-revealing? +- [ ] Have I avoided comments by making the code clearer? +- [ ] Am I passing too many arguments? +- [ ] Is there a failing test for this change? + +## Limitations +- Use this skill only when the task clearly matches the scope described above. +- Do not treat the output as a substitute for environment-specific validation, testing, or expert review. +- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing. diff --git a/.agents/skills/hono/SKILL.md b/.agents/skills/hono/SKILL.md new file mode 100644 index 0000000..857453f --- /dev/null +++ b/.agents/skills/hono/SKILL.md @@ -0,0 +1,598 @@ +--- +name: hono +description: Use when building Hono web applications or when the user asks about Hono APIs, routing, middleware, JSX, validation, testing, or streaming. TRIGGER when code imports from 'hono' or 'hono/*', or user mentions Hono. Use Hono CLI to inspect and test the app. +--- + +# Hono Skill + +Build Hono web applications. This skill provides inline API knowledge for AI. Use Hono CLI to inspect and test the app. + +## Latest Documentation + +For details beyond this inline reference, fetch the latest documentation from https://hono.dev. Get the index of doc pages from `https://hono.dev/llms.txt`, then fetch a page with the `Accept: text/markdown` header to receive it as Markdown: + +```bash +curl -H "Accept: text/markdown" https://hono.dev/docs/helpers/cookie +``` + +## Hono CLI + +Use [Hono CLI](https://github.com/honojs/cli) to inspect and test the app. Install it in the project, then let the CLI explain itself: + + + +```bash +npm install -D @hono/cli@next +npx hono agent-context +``` + +Follow the output. It explains every command (`routes`, `request`, `benchmark`, `optimize`, `ssg`), the JSON output contract, and the workflow. + +Notes: + +- `hono request` sends a request with `app.request()` — no server needed. Do not pass credentials directly in CLI arguments; use environment variables for sensitive values. +- For Cloudflare Workers bindings (KV, D1, R2, etc.), use `hono request /path --runtime workerd`. It starts the app with the wrangler config of the project, so the local bindings (`c.env`) are real. wrangler must be installed in the project. +- For several requests, or a flow that keeps state (POST, then use the returned id), use one `hono request --batch -` call. One JSON object per line; `save` a value and use it as `{{id}}` in later steps. The steps share one app instance: + + ```bash + npx hono request --batch - <<'EOF' + {"method":"POST","path":"/users","body":{"name":"Alice"},"save":{"id":".id"}} + {"path":"/users/{{id}}"} + EOF + ``` + +--- + +## Hono API Reference + +### App Constructor + +```ts +import { Hono } from 'hono' + +const app = new Hono() + +// With TypeScript generics +type Env = { + Bindings: { DATABASE: D1Database; KV: KVNamespace } + Variables: { user: User } +} +const app = new Hono() +``` + +### Routing Methods + +```ts +app.get('/path', handler) +app.post('/path', handler) +app.put('/path', handler) +app.delete('/path', handler) +app.patch('/path', handler) +app.options('/path', handler) +app.all('/path', handler) // all HTTP methods +app.on('PURGE', '/path', handler) // custom method +app.on(['PUT', 'DELETE'], '/path', handler) // multiple methods +``` + +### Routing Patterns + +```ts +// Path parameters +app.get('/user/:name', (c) => { + const name = c.req.param('name') + return c.json({ name }) +}) + +// Multiple params +app.get('/posts/:id/comments/:commentId', (c) => { + const { id, commentId } = c.req.param() +}) + +// Optional parameters +app.get('/api/animal/:type?', (c) => c.text('Animal!')) + +// Wildcards +app.get('/wild/*/card', (c) => c.text('Wildcard')) + +// Regexp constraints +app.get('/post/:date{[0-9]+}/:title{[a-z]+}', (c) => { + const { date, title } = c.req.param() +}) + +// Chained routes +app + .get('/endpoint', (c) => c.text('GET')) + .post((c) => c.text('POST')) + .delete((c) => c.text('DELETE')) +``` + +### Route Grouping + +```ts +// Using route() +const api = new Hono() +api.get('/users', (c) => c.json([])) + +const app = new Hono() +app.route('/api', api) // mounts at /api/users + +// Using basePath() +const app = new Hono().basePath('/api') +app.get('/users', (c) => c.json([])) // GET /api/users +``` + +### Error Handling + +```ts +app.notFound((c) => c.json({ message: 'Not Found' }, 404)) + +app.onError((err, c) => { + console.error(err) + return c.json({ message: 'Internal Server Error' }, 500) +}) +``` + +--- + +## Context (c) + +### Response Methods + +```ts +c.text('Hello') // text/plain +c.json({ message: 'Hello' }) // application/json +c.html('

Hello

') // text/html +c.redirect('/new-path') // 302 redirect +c.redirect('/new-path', 301) // 301 redirect +c.body('raw body', 200, headers) // raw response +c.notFound() // 404 response +``` + +### Headers & Status + +```ts +c.status(201) +c.header('X-Custom', 'value') +c.header('Cache-Control', 'no-store') +``` + +### Variables (request-scoped data) + +```ts +// In middleware +c.set('user', { id: 1, name: 'Alice' }) + +// In handler +const user = c.get('user') +// or +const user = c.var.user +``` + +### Environment (Cloudflare Workers) + +```ts +const value = await c.env.KV.get('key') +const db = c.env.DATABASE +c.executionCtx.waitUntil(promise) +``` + +### Renderer + +```ts +app.use(async (c, next) => { + c.setRenderer((content) => + c.html( + {content} + ) + ) + await next() +}) + +app.get('/', (c) => c.render(

Hello

)) +``` + +--- + +## HonoRequest (c.req) + +```ts +c.req.param('id') // path parameter +c.req.param() // all path params as object +c.req.query('page') // query string parameter +c.req.query() // all query params as object +c.req.queries('tags') // multiple values: ?tags=A&tags=B → ['A', 'B'] +c.req.header('Authorization') // request header +c.req.header() // all headers (keys are lowercase) + +// Body parsing +await c.req.json() // parse JSON body +await c.req.text() // parse text body +await c.req.formData() // parse as FormData +await c.req.parseBody() // parse multipart/form-data or urlencoded +await c.req.arrayBuffer() // parse as ArrayBuffer +await c.req.blob() // parse as Blob + +// Validated data (used with validator middleware) +c.req.valid('json') +c.req.valid('query') +c.req.valid('form') +c.req.valid('param') + +// Properties +c.req.url // full URL string +c.req.path // pathname +c.req.method // HTTP method +c.req.raw // underlying Request object +``` + +--- + +## Middleware + +### Using Built-in Middleware + +```ts +import { cors } from 'hono/cors' +import { logger } from 'hono/logger' +import { basicAuth } from 'hono/basic-auth' +import { prettyJSON } from 'hono/pretty-json' +import { secureHeaders } from 'hono/secure-headers' +import { etag } from 'hono/etag' +import { compress } from 'hono/compress' +import { poweredBy } from 'hono/powered-by' +import { timing } from 'hono/timing' +import { cache } from 'hono/cache' +import { bearerAuth } from 'hono/bearer-auth' +import { jwt } from 'hono/jwt' +import { jwk } from 'hono/jwk' +import { csrf } from 'hono/csrf' +import { ipRestriction } from 'hono/ip-restriction' +import { bodyLimit } from 'hono/body-limit' +import { timeout } from 'hono/timeout' +import { requestId } from 'hono/request-id' +import { methodOverride } from 'hono/method-override' +import { methodNotAllowed } from 'hono/method-not-allowed' +import { languageDetector } from 'hono/language' +import { some, every, except } from 'hono/combine' +import { contextStorage, getContext } from 'hono/context-storage' +import { trailingSlash, trimTrailingSlash } from 'hono/trailing-slash' + +// Registration +app.use(logger()) // all routes +app.use('/api/*', cors()) // specific path +app.post('/api/*', basicAuth({ username: 'admin', password: 'secret' })) +``` + +### Custom Middleware + +```ts +// Inline +app.use(async (c, next) => { + const start = Date.now() + await next() + const elapsed = Date.now() - start + c.res.headers.set('X-Response-Time', `${elapsed}ms`) +}) + +// Reusable with createMiddleware +import { createMiddleware } from 'hono/factory' + +const auth = createMiddleware(async (c, next) => { + const token = c.req.header('Authorization') + if (!token) return c.json({ error: 'Unauthorized' }, 401) + await next() +}) + +app.use('/api/*', auth) +``` + +### Middleware Execution Order + +Middleware executes in registration order. `await next()` calls the next middleware/handler, and code after `next()` runs on the way back: + +``` +Request → mw1 before → mw2 before → handler → mw2 after → mw1 after → Response +``` + +```ts +app.use(async (c, next) => { + // before handler + await next() + // after handler +}) +``` + +--- + +## Validation + +Validation targets: `json`, `form`, `query`, `header`, `param`, `cookie`. + +### Zod Validator + +```ts +import { zValidator } from '@hono/zod-validator' +import { z } from 'zod' + +const schema = z.object({ + title: z.string().min(1), + body: z.string() +}) + +app.post('/posts', zValidator('json', schema), (c) => { + const data = c.req.valid('json') // fully typed + return c.json(data, 201) +}) +``` + +### Valibot / Standard Schema Validator + +```ts +import { sValidator } from '@hono/standard-validator' +import * as v from 'valibot' + +const schema = v.object({ name: v.string(), age: v.number() }) + +app.post('/users', sValidator('json', schema), (c) => { + const data = c.req.valid('json') + return c.json(data, 201) +}) +``` + +--- + +## JSX + +### Setup + +In `tsconfig.json`: + +```json +{ + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx" + } +} +``` + +Or use pragma: `/** @jsxImportSource hono/jsx */` + +**Important:** Files using JSX must have a `.tsx` extension. Rename `.ts` to `.tsx` or the compiler will fail. + +### Components + +```tsx +import type { PropsWithChildren } from 'hono/jsx' + +const Layout = (props: PropsWithChildren) => ( + + + My App + + {props.children} + +) + +const UserCard = ({ name }: { name: string }) => ( +
+

{name}

+
+) + +app.get('/', (c) => { + return c.html( + + + + ) +}) +``` + +### jsxRenderer Middleware + +Use `jsxRenderer` middleware for layouts. For details, see https://hono.dev/docs/middleware/builtin/jsx-renderer + +### Async Components + +```tsx +const UserList = async () => { + const users = await fetchUsers() + return ( +
    + {users.map((u) => ( +
  • {u.name}
  • + ))} +
+ ) +} +``` + +### Fragments + +```tsx +const Items = () => ( + <> +
  • Item 1
  • +
  • Item 2
  • + +) +``` + +--- + +## Streaming + +```ts +import { stream, streamText, streamSSE } from 'hono/streaming' + +// Basic stream +app.get('/stream', (c) => { + return stream(c, async (stream) => { + stream.onAbort(() => console.log('Aborted')) + await stream.write(new Uint8Array([0x48, 0x65])) + await stream.pipe(readableStream) + }) +}) + +// Text stream +app.get('/stream-text', (c) => { + return streamText(c, async (stream) => { + await stream.writeln('Hello') + await stream.sleep(1000) + await stream.write('World') + }) +}) + +// Server-Sent Events +app.get('/sse', (c) => { + return streamSSE(c, async (stream) => { + let id = 0 + while (true) { + await stream.writeSSE({ + data: JSON.stringify({ time: new Date().toISOString() }), + event: 'time-update', + id: String(id++) + }) + await stream.sleep(1000) + } + }) +}) +``` + +--- + +## Testing with app.request() + +Test endpoints without starting an HTTP server: + +```ts +// GET +const res = await app.request('/posts') +expect(res.status).toBe(200) +expect(await res.json()).toEqual({ posts: [] }) + +// POST with JSON +const res = await app.request('/posts', { + method: 'POST', + body: JSON.stringify({ title: 'Hello' }), + headers: { 'Content-Type': 'application/json' } +}) + +// POST with FormData +const formData = new FormData() +formData.append('name', 'Alice') +const res = await app.request('/users', { method: 'POST', body: formData }) + +// With mock env (Cloudflare Workers bindings) +const res = await app.request('/api/data', {}, { KV: mockKV, DATABASE: mockDB }) + +// Using Request object +const req = new Request('http://localhost/api', { method: 'DELETE' }) +const res = await app.request(req) +``` + +--- + +## Hono Client (RPC) + +Type-safe API client using shared types between server and client. + +**IMPORTANT: Routes MUST be chained for type inference to work. Without chaining, the client cannot infer route types.** + +```ts +// Server: routes MUST be chained to preserve types +const route = app + .post('/posts', zValidator('json', schema), (c) => { + return c.json({ ok: true }, 201) + }) + .get('/posts', (c) => { + return c.json({ posts: [] }) + }) +export type AppType = typeof route + +// Client: use hc() with the exported type +import { hc } from 'hono/client' +import type { AppType } from './server' + +const client = hc('http://localhost:8787/') +const res = await client.posts.$post({ json: { title: 'Hello' } }) +const data = await res.json() // fully typed +``` + +Type utilities: + +```ts +import type { InferRequestType, InferResponseType } from 'hono/client' + +type ReqType = InferRequestType +type ResType = InferResponseType +``` + +--- + +## Helpers + +Helpers are utility functions imported from `hono/`: + +```ts +import { getConnInfo } from 'hono/conninfo' +import { getCookie, setCookie, deleteCookie } from 'hono/cookie' +import { css, Style } from 'hono/css' +import { createFactory } from 'hono/factory' +import { html, raw } from 'hono/html' +import { stream, streamText, streamSSE } from 'hono/streaming' +import { testClient } from 'hono/testing' +import { upgradeWebSocket } from 'hono/cloudflare-workers' // or other adapter +``` + +Available helpers: Accepts, Adapter, ConnInfo, Cookie, css, Dev, Factory, html, JWT, Proxy, Route, SSG, Streaming, Testing, WebSocket. + +For details, see `https://hono.dev/docs/helpers/` (fetch with `Accept: text/markdown`). + +### Factory + +Use `createFactory` to define `Env` once and share it across app, middleware, and handlers: + +```ts +import { createFactory } from 'hono/factory' + +const factory = createFactory() + +// Create app (Env type is inherited) +const app = factory.createApp() + +// Create middleware (Env type is inherited, no need to pass generics) +const mw = factory.createMiddleware(async (c, next) => { + await next() +}) + +// Create handlers separately (preserves type inference) +const handlers = factory.createHandlers(logger(), (c) => c.json({ message: 'Hello' })) +app.get('/api', ...handlers) +``` + +--- + +## Best Practices + +- Write handlers inline in route definitions for proper type inference of path params. +- Use `app.route()` to organize large apps by feature, not Rails-style controllers. +- Use `createFactory()` to share Env type across app, middleware, and handlers. +- Use `c.set()`/`c.get()` to pass data between middleware and handlers. +- Chain validators for multiple request parts (param + query + json). +- Export app type for RPC: `export type AppType = typeof routes` +- Use `app.request()` for testing — no server startup needed. + +## Adapters + +Hono runs on multiple runtimes. The default export works for Cloudflare Workers, Deno, and Bun. For Node.js, use the Node adapter: + +```ts +// Cloudflare Workers / Deno / Bun +export default app + +// Node.js +import { serve } from '@hono/node-server' +serve(app) +``` diff --git a/.agents/skills/interface-design/SKILL.md b/.agents/skills/interface-design/SKILL.md new file mode 100644 index 0000000..92e1df4 --- /dev/null +++ b/.agents/skills/interface-design/SKILL.md @@ -0,0 +1,320 @@ +--- +name: interface-design +description: Craft-first interface design for dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces, and interactive products. Use when designing, building, reviewing, auditing, or refining product UI where visual craft, layout hierarchy, tokens, states, visual direction, or design-system consistency matter. Not for marketing pages, landing pages, campaigns, or brand-only work. +--- + +# Interface Design + +Build product interfaces with the craft of a top design team — Linear, Vercel, Stripe, Apple. The difference between those and generic output is not talent. It is that every decision was *decided*, the hierarchy is unmistakable, and a hundred small details are correct at once. This skill is how you get there. + +## Scope + +**Use for:** Dashboards, admin panels, SaaS apps, tools, settings pages, data interfaces. + +**Not for:** Landing pages, marketing sites, campaigns, brand-only work. Use a marketing/frontend design skill for those. + +This skill is self-contained: direction, visual hierarchy, design-system architecture, and the polish and motion essentials needed to ship production-grade UI all live here. + +--- + +# The Problem + +You will generate generic output. Your training has seen thousands of dashboards, and the patterns are strong. You can follow this entire process — explore the domain, name a signature, state your intent — and still produce a template: warm colors on cold structures, friendly fonts on generic layouts. + +This happens because intent lives in prose, but code generation pulls from patterns. The gap between them is where defaults win. Process helps, but it doesn't guarantee craft. You have to catch yourself, and you have to know the concrete moves that defaults don't. + +**The bar:** If another AI, given a similar prompt, would produce substantially the same output, you have failed. Not different for its own sake — different because the interface emerged from *this* user, *this* task, *this* world. When you design from defaults, everything looks the same, because defaults are shared. + +--- + +# Where Defaults Hide + +Defaults disguise themselves as infrastructure — the parts that feel like they just need to work, not be designed. + +- **Typography feels like a container.** But type isn't holding your design, it *is* your design. The weight of a headline, the personality of a label, the texture of a paragraph shape how the product feels before anyone reads a word. Reaching for your usual font means you're not designing. +- **Navigation feels like scaffolding.** But navigation *is* the product — where you are, where you can go, what matters. A page floating in space is a component demo, not software. +- **Data feels like presentation.** But a number on screen is not design. What does it *mean* to the person looking? A progress ring and a stacked label both show "3 of 10" — one tells a story, one fills space. +- **Token names feel like implementation detail.** But `--ink` and `--parchment` evoke a world; `--gray-700` and `--surface-2` evoke a template. Someone reading only your tokens should guess what product this is. + +There are no structural decisions. Everything is design. The moment you stop asking "why this?" is the moment defaults take over. + +--- + +# Intent First + +Before touching code, answer these. Keep it a compact working brief unless the direction needs user confirmation. + +- **Who is this human?** Not "users." The actual person. Where are they when they open this? What did they do 5 minutes ago, what will they do 5 minutes after? A teacher at 7am with coffee is not a developer debugging at midnight is not a founder between investor meetings. +- **What must they accomplish?** The verb. Grade these submissions. Find the broken deployment. Approve the payment. The answer determines what leads, what follows, what hides. +- **What should this feel like?** In words that mean something. "Clean and modern" means nothing — every AI says that. Warm like a notebook? Cold like a terminal? Dense like a trading floor? Calm like a reading app? This shapes color, type, spacing, density — everything. + +If the prompt is too vague to identify the human, task, and feel, ask one concise question. If context allows a responsible assumption, state it briefly and proceed. + +**Intent must be systemic.** Saying "warm" then using cold colors is not following through. If the intent is warm: surfaces, text, borders, accents, semantic colors, type — all warm. If dense: spacing, type size, information architecture — all dense. Check every token against the stated intent. For every choice — layout, color temperature, typeface, spacing scale, hierarchy — you must be able to say *why*. "It's common" or "it works" means you defaulted. + +--- + +# Product Domain Exploration + +This is where defaults get caught — or don't. Generic path: Task type → visual template → theme. Crafted path: Task type → product domain → signature → structure + expression. The difference is time spent in the product's world before any visual thinking. + +**Produce all four before proposing any direction:** + +- **Domain** — concepts, metaphors, vocabulary from this product's world. Not features — territory. Minimum 5. +- **Color world** — what colors exist *naturally* here? Not "warm" or "cool" — go to the actual world. If this product were a physical space, what would you see? List 5+. +- **Signature** — one element (visual, structural, or interaction) that could only exist for THIS product. If you can't name one, keep exploring. +- **Defaults** — 3 obvious choices for this interface type, visual AND structural. You can't avoid patterns you haven't named. + +**The test:** Read your proposal with the product name removed. Could someone identify what it's for? If not, explore deeper. + +--- + +# Render It When You Can + +If an inline visual-rendering tool is available in the session (e.g. a `show_widget` / `visualize` tool that renders HTML or SVG inline in the conversation), prefer **showing** the design over describing it. A direction trapped in prose is a fraction as useful as one the person can look at. This is conditional: when no such tool is present (CI, headless agents, plain terminals), fall back to code, tokens, and a written proposal. Never assume the tool exists; check, then use it. + +Render at three moments: + +1. **Proposing a direction.** Alongside the Suggest + Ask block, render a small live specimen: the palette as actual swatches, the type scale in the real typeface, the surface-elevation steps as stacked cards, the signature element as a real component. The person should *see* "warm like a notebook," not read the words. +2. **Designing a component.** Render the actual component (or a tight before/after, both variants side by side) so the craft decisions — spacing, borders, hierarchy, states — are visible, not asserted. Render the real states (default, hover, empty, error) where they matter. +3. **Critiquing or auditing.** Render the current version and the improved version together so the gap is shown, not narrated. + +Rules when rendering: + +- The widget shows the **visual only**. All reasoning, the domain exploration, the rejected-defaults list, and the recommendation stay in your response text — never paste prose into the widget. +- Match the rendering tool's own design-system contract (load its `read_me`/guidance if it has one). Use its theme variables so the specimen inherits light/dark mode and sits native in the host. Don't fight the host chrome. +- The specimen must still pass the checks below. A rendered default is still a default — rendering is how craft gets *seen*, not a substitute for it. +- This renders to the *conversation*, not the project. The actual implementation still lands in the codebase through normal edits. + +The point: collapse the loop between "here's my thinking" and "here's what it looks like" into a single message the person can react to. + +--- + +# Visual Hierarchy & Composition + +The single biggest driver of "this looks designed" versus "this looks generated." Defaults produce *flatness* — everything the same size, weight, and spacing, so nothing leads and the eye has nowhere to go. Craft produces *hierarchy* — the eye knows instantly what matters. These are concrete moves, not vibes. + +## One focal point per view + +Every screen has one thing the user came to do. That thing dominates — through size, contrast, position, or the space around it. When everything competes equally, nothing wins and the interface reads like a parking lot. Before building, name the focal element out loud. Then make it win: bigger, higher-contrast, or ringed in whitespace. Demote everything else deliberately. + +## Type scale is a ratio, and weight beats size + +Don't pick sizes by feel. Pick a ratio and step it: ~1.2 (minor third) for dense/calm UI, ~1.25 for most product UI, ~1.333 for expressive. From a 14–16px body that yields a *visibly* distinct scale, not 15/16/17 mush. A 14px base at 1.25: `caption 11 · body 14 · h4 16 · h3 18 · h2 22 · h1 28 · display 44+`. Round to whole pixels and to your spacing grid. + +The Apple/Linear move: **weight and color do more hierarchy work than size.** A single 14px size holds three tiers through weight + opacity alone — `value: 600 / primary`, `label: 500 / secondary`, `meta: 400 / muted` — separating more cleanly than two regular weights two points apart. Build from three levers together (size, weight, color/opacity), never size alone. If you squint and can't tell headline from body from label, the hierarchy is too weak. + +Worked example — a metric, flat vs decided. *Flat:* `Revenue` / `$48,200` both 14px regular gray, three identical boxes, no focal point. *Decided:* `REVENUE` 11px/500/muted/tracked · `$48,200` 28px/600/primary/tabular-nums (the hero) · `↑12%` 12px/500/success. Same data, opposite legibility — the figure leads through size+weight+one accent, the label is demoted, secondary metrics drop to a lower tier. + +## Density is a decision, expressed in px + +Linear is tight; Stripe is airy. Neither is default — both are *chosen*, and the choice is the same number repeated everywhere. Decide the density up front and name the values: a tool panel at 12–16px padding feels workbench-tight; the same card at 24px feels like a brochure. The same number can be right in one context and lazy in another. Pick deliberately, then hold it. + +## Spatial rhythm — breathe unevenly + +Great interfaces don't space everything equally. Dense control zones give way to open content; heavy elements balance against light ones; the eye travels with purpose. Monotone layouts — same card size, same gap, same density everywhere — are the sound of no one deciding. Vary the rhythm on purpose: group tightly-related things, then put real air between groups. + +## Proportions speak + +A 280px sidebar next to full-width content says "navigation serves content." A 360px sidebar says "these are peers." The specific number declares what matters. If you can't articulate what a proportion is saying, it isn't saying anything. Choose widths and ratios that state a relationship. + +## Distribution and restraint (the "expensive" look) + +- **~60/30/10**: a dominant neutral surface, a secondary tone, and ~10% accent. Color is a scarce resource — most of the screen is structure. +- **One accent, used with intention**, beats five colors used without thought. Gray builds structure; color *communicates* (status, action, identity). Unmotivated color is noise. +- **Hierarchy through space and weight, not lines.** Reach for whitespace and tonal shift before borders and dividers. The most premium interfaces are mostly invisible structure. +- **Optical sizing on large type**: tighten letter-spacing as type gets bigger (headings slightly negative tracking); loosen line-height on body for readability (~1.5). Tight type reads as crafted; default tracking on a 32px heading reads as a document. + +--- + +# Craft Foundations + +## Subtle Layering (the backbone) + +Regardless of direction, this applies to everything. You should *barely notice the system working* — when you look at Vercel's dashboard you don't think "nice borders," you just understand the structure. Invisible craft is working craft. + +**Surface elevation.** Surfaces stack: a dropdown sits above a card sits above the page. Build a numbered system — base, then increasing levels. Each jump is only a few percentage points of lightness — e.g. dark mode base → +7% → +9% → +12%; light mode stays light and adds shadow instead. You can barely see one step in isolation, but stacked, the hierarchy emerges. Whisper-quiet shifts you feel rather than see. + +- **Sidebars:** same background as canvas, not a different color. Different colors fragment the space into "sidebar world" and "content world." A subtle border is enough. +- **Dropdowns/popovers:** one level above their parent surface, or they blend in and layering is lost. +- **Inputs:** slightly *darker* than surroundings, not lighter. Inputs are inset — they receive content. A darker fill signals "type here" without heavy borders. + +**Borders.** Should disappear when you're not looking for them, but be findable when you need structure. Low-opacity rgba blends with the background and defines an edge without demanding attention; solid hex borders look harsh by comparison. Dark mode lives around `rgba(255,255,255,0.06–0.12)`, light mode slightly higher. Build a progression — standard, softer separation, emphasis, focus-ring — and match intensity to the importance of the boundary. + +**The squint test:** blur your eyes at the interface. You should still perceive hierarchy — what's above what, where sections divide — but nothing should jump out. No harsh lines, no jarring shifts. Just quiet structure. Get this wrong and nothing else matters. + +## Infinite Expression + +Every pattern has infinite expressions — **no two interfaces should look the same.** A metric display could be a hero number, inline stat, sparkline, gauge, progress bar, comparison delta, or trend badge. Same sidebar width, same card grid, same icon-left-number-big-label-small metric boxes every time *signals AI-generated immediately* and is forgettable. Linear's cards don't look like Notion's; Vercel's metrics don't look like Stripe's. Same concepts, infinite expressions. Before building, ask: what's the ONE thing users do here, and what product solves a similar problem brilliantly? + +## Color Lives Somewhere + +Every product exists in a world, and that world has colors. Before reaching for a palette, walk into the physical version of this space — what materials, what light, what objects? Your palette should feel like it came FROM somewhere, not applied TO something. Temperature is one axis; also ask quiet or loud, dense or spacious, serious or playful, geometric or organic. A trading terminal and a meditation app are both "focused" — completely different kinds of focus. + +--- + +# Before Writing Each Component + +**Every time** you write UI code — even small additions — state: + +``` +Intent: [who is this human, what must they do, how should it feel] +Hierarchy: [the focal element, and how it wins — size / weight / contrast / space] +Palette: [colors from your exploration — and WHY they fit this world] +Depth: [borders / subtle shadows / layered — and WHY it fits the intent] +Surfaces: [your elevation scale — and WHY this temperature] +Typography: [typeface + the size/weight/color levers — and WHY] +Spacing: [base unit + chosen density] +``` + +This checkpoint is mandatory. If you can't explain WHY for each, you're defaulting — stop and think. + +--- + +# Use What Exists + +The most common way AI degrades a codebase: it hand-rolls what already exists. A bespoke `
    ` "button" beside the project's real `Button`. A from-scratch dropdown with no keyboard support beside an installed primitive that has it. A 14-class Tailwind string copy-pasted onto every card instead of the component or token that's right there. Every one of these is the same failure — generating new instead of using what's present — and the result is inconsistent, inaccessible, and unmaintainable. **Before you build a control or style an element, look at what the project already gives you.** + +## Controls: native → primitive → hand-roll + +1. **Native HTML first** where it works. A ` + + + ); +} +``` + +### CSS-Only Stagger + +```css +.stagger-item { + opacity: 0; + transform: translateY(12px); + filter: blur(4px); + animation: fadeInUp 400ms ease-out forwards; +} + +.stagger-item:nth-child(1) { animation-delay: 0ms; } +.stagger-item:nth-child(2) { animation-delay: 100ms; } +.stagger-item:nth-child(3) { animation-delay: 200ms; } + +@keyframes fadeInUp { + to { + opacity: 1; + transform: translateY(0); + filter: blur(0); + } +} +``` + +## Exit Animations + +Exit animations should be softer and less attention-grabbing than enter animations. The user's focus is moving to the next thing — don't fight for attention. + +### Subtle Exit (Recommended) + +```tsx +// Small fixed translateY — indicates direction without drama + + {content} + +``` + +### Full Exit (When Context Matters) + +```tsx +// Slide fully out — use when spatial context is important +// (e.g., a card returning to a list, a drawer closing) + + {content} + +``` + +### Good vs. Bad + +```css +/* Good — subtle exit */ +.item-exit { + opacity: 0; + transform: translateY(-12px); + transition: opacity 150ms ease-out, transform 150ms ease-out; +} + +/* Bad — dramatic exit that steals focus */ +.item-exit { + opacity: 0; + transform: translateY(-100%) scale(0.5); + transition: all 400ms ease-out; +} + +/* Sometimes correct — remove immediately when motion adds no context */ +.item-exit { + display: none; +} +``` + +**Key points:** +- Use a small fixed `translateY` (e.g., `-12px`) instead of the full container height +- Keep some directional movement to indicate where the element went +- Exit duration should be shorter than enter duration (150ms vs 300ms) +- Use a subtle exit when it preserves spatial context. Remove immediately when motion adds no information, the interaction repeats frequently, or reduced motion is requested. + +## Contextual Icon Animations + +When icons appear or disappear contextually (on hover, on state change), animate them with `opacity`, `scale`, and `blur` rather than just toggling visibility. + +### Motion Example + +This example uses the `motion` package. If the project instead has `framer-motion`, import the same APIs from `"framer-motion"`; never mix an installed package with the other package's import path. + +```tsx +import { AnimatePresence, motion } from "motion/react"; + +function IconButton({ isActive, icon: Icon }) { + return ( + + ); +} +``` + +### CSS Transition Approach (No Motion) + +If the project doesn't use Motion (Framer Motion), keep both icons in the DOM and cross-fade them with CSS transitions. Because neither icon unmounts, both enter and exit animate smoothly. + +The trick: one icon is absolutely positioned on top of the other. Toggling state cross-fades them — the entering icon scales up from `0.25` while the exiting icon scales down to `0.25`, both with opacity and blur. + +```tsx +function IconButton({ isActive, ActiveIcon, InactiveIcon }) { + return ( + + ); +} +``` + +The non-absolute icon (InactiveIcon) defines the layout size. The absolute icon (ActiveIcon) overlays it without affecting flow. + +### Choosing Between Motion and CSS + +| | Motion (Framer Motion) | CSS transitions (both icons in DOM) | +| --- | --- | --- | +| **Enter animation** | Yes | Yes | +| **Exit animation** | Yes (via `AnimatePresence`) | Yes (cross-fade — icon never unmounts) | +| **Spring physics** | Yes | No — use `cubic-bezier(0.2, 0, 0, 1)` as approximation | +| **When to use** | Project already uses `motion` or `framer-motion` | No motion dependency, or keeping bundle small | + +**Rule:** Check the project's `package.json`. Import from `"motion/react"` when `motion` is installed, or from `"framer-motion"` when `framer-motion` is installed. If both exist, follow the imports already used by the component or its nearest peers. If neither is present, use the CSS cross-fade pattern — don't add a dependency just for icon transitions. + +### When to Animate Icons + +| Animate | Don't animate | +| --- | --- | +| Icons that appear on hover (action buttons) | Static navigation icons | +| State change icons (play → pause, like → liked) | Decorative icons | +| Icons in contextual toolbars | Icons that are always visible | +| Loading/success state indicators | Icon labels (text next to icon) | + +**Important:** Always use exactly these values for contextual icon animations — do not deviate: +- `scale`: `0.25` → `1` (never use `0.5` or `0.6`) +- `opacity`: `0` → `1` +- `filter`: `"blur(4px)"` → `"blur(0px)"` +- `transition`: `{ type: "spring", duration: 0.3, bounce: 0 }` — **bounce must always be `0`**, never `0.1` or any other value + +## Scale on Press + +A subtle scale-down on click gives buttons tactile feedback. Always use `scale(0.96)`. Never use a value smaller than `0.95` — anything below feels exaggerated. Use CSS transitions for interruptibility — if the user releases mid-press, it should smoothly return. + +Not every button needs this. Add a `static` prop to your button component that disables the scale effect when the motion would be distracting. + +### CSS Example + +```css +.button { + transition-property: scale; + transition-duration: 150ms; + transition-timing-function: ease-out; +} + +.button:active { + scale: 0.96; +} +``` + +### Tailwind Example + +```tsx + +``` + +### Motion Example + +```tsx + + Click me + +``` + +### Static Prop Pattern + +Extract the scale class into a variable and conditionally apply it based on a `static` prop: + +```tsx +const tapScale = "active:not-disabled:scale-[0.96]"; + +function Button({ static: isStatic, className, children, ...props }) { + return ( + + ); +} + +// Usage + {/* scales on press */} + {/* no scale */} +``` + +## Skip Animation on Page Load + +Use `initial={false}` on `AnimatePresence` to prevent enter animations from firing on first render. Elements that are already in their default state shouldn't animate in on page load — only on subsequent state changes. + +### When It Works + +```tsx +// Good — icon doesn't animate in on mount, only on state change + + + + + +``` + +Works well for: icon swaps, toggles, tabs, segmented controls — anything that has a default state on page load. + +### When It Breaks + +Don't use `initial={false}` when the component relies on its `initial` prop to set up a first-time enter animation, like a staggered page hero or a loading state. In those cases, removing the initial animation skips the entire entrance. + +```tsx +// Bad — initial={false} would skip the staggered page enter entirely + + + ... + + +``` + +Verify the component still looks right on a full page refresh before applying this. + +## Motion Restraint + +Motion is a budget, not a garnish: + +- **No custom animation on high-frequency interactions.** Repeated interactions get instant feedback or a minimal `opacity` or `background-color` transition at ≤150ms. +- **Motion is never the only feedback channel.** Every animated state change also needs a static cue such as color, icon, or label. +- **Brief and precise beats prominent.** If a shorter, smaller animation communicates the same thing, use it. +- **Honor reduced-motion preferences.** Preserve the static cue and remove unnecessary movement. + +```css +/* Good: high-frequency hover gets a minimal transition */ +.row:hover { + background-color: var(--surface-hover); + transition: background-color 100ms ease-out; +} + +/* Bad: every hover replays a full entrance */ +.row:hover .row-icon { + animation: bounceIn 500ms; +} +``` diff --git a/.agents/skills/make-interfaces-feel-better/icons.md b/.agents/skills/make-interfaces-feel-better/icons.md new file mode 100644 index 0000000..6bdc007 --- /dev/null +++ b/.agents/skills/make-interfaces-feel-better/icons.md @@ -0,0 +1,63 @@ +# Icons + +Icon weight, states, sizing, and direction: the details that make icons sit naturally in an interface. + +## Match Icon Stroke to Text Weight + +An icon next to text should carry the same optical weight as the text. + +| Adjacent text | Icon stroke width (24px grid) | +| --- | --- | +| Regular (400), 14–16px | `1.5px` | +| Medium/Semibold (500–600) | `2px` | +| Bold (700), or emphasized standalone | `2.5px` | + +Use one stroke weight per icon set on a surface. Size inline icons relative to the text's cap height, typically `1em`–`1.25em`. + +## One SVG, Recolored per State + +Use one SVG drawn with `currentColor`; let CSS drive hover, selected, and disabled states. Strip hardcoded `fill` and `stroke` colors when importing icons. + +```html +… +``` + +```css +.icon-button { color: oklch(0.552 0.016 285.938); } +.icon-button:hover { color: oklch(0.21 0.006 285.885); } +.icon-button[aria-pressed="true"] { color: oklch(0.623 0.188 259.815); } +.icon-button:disabled { opacity: 0.4; } +``` + +## Outline Default, Fill Active + +| Variant | Use for | +| --- | --- | +| Outline | Default state: toolbars, list rows, inline with text | +| Fill | Selected or active state: active tab, toggled bookmark, liked heart | + +The swap between variants is a contextual icon animation; use the exact cross-fade values in [animations.md](animations.md). + +## Design at Render Size + +- Test every icon at the smallest size it will render, often `16px`. +- Prefer simplified glyphs for small contexts over scaled-down detailed artwork. +- Use the icon set's native grid sizes (`16`, `20`, `24`) rather than arbitrary fractional scales. +- Use SVG rather than raster assets. + +## Icons in RTL + +| Flip | Don't flip | +| --- | --- | +| Back/forward arrows, navigation chevrons | Logos and brand marks | +| Text alignment, lists, indent | Checkmarks | +| Directional send glyphs | Clocks, cups, pencils | +| Speaker waves tied to reading direction | Media playback controls | + +```css +[dir="rtl"] .icon-directional { + scale: -1 1; +} +``` + +Analyze composite icons part by part: an overlay may keep its position even when the base glyph flips. Give every icon-only control an accessible name and mark purely decorative icons hidden from assistive technology. diff --git a/.agents/skills/make-interfaces-feel-better/performance.md b/.agents/skills/make-interfaces-feel-better/performance.md new file mode 100644 index 0000000..c12257a --- /dev/null +++ b/.agents/skills/make-interfaces-feel-better/performance.md @@ -0,0 +1,88 @@ +# Performance + +Transition specificity and GPU compositing hints. + +## Transition Only What Changes + +Never use `transition: all` or Tailwind's `transition-all`. Always specify the exact properties that change. Tailwind's bare `transition` maps to a curated default list of colors, opacity, shadow, and transforms, not to `all`; still prefer naming exactly what changes. + +### Why + +- `transition: all` forces the browser to watch every property for changes +- Causes unexpected transitions on properties you didn't intend to animate (colors, padding, shadows) +- Prevents browser optimizations + +### CSS Example + +```css +/* Good — only transition what changes */ +.button { + transition-property: scale, background-color; + transition-duration: 150ms; + transition-timing-function: ease-out; +} + +/* Bad — transition everything */ +.button { + transition: all 150ms ease-out; +} +``` + +### Tailwind + +```tsx +// Good — explicit properties + +``` + +### Play Button Triangles + +Play icons are triangular and their geometric center is not their visual center. Shift slightly right: + +```css +/* Good — optically centered */ +.play-button svg { + margin-left: 2px; /* shift right to account for triangle shape */ +} + +/* Bad — geometrically centered but looks off */ +.play-button svg { + /* no adjustment */ +} +``` + +### Asymmetric Icons (Stars, Arrows, Carets) + +Some icons have uneven visual weight. The best fix is adjusting the SVG directly so no extra margin/padding is needed in the component code. + +```tsx +// Best — fix in the SVG itself +// Adjust the viewBox or path to visually center the icon + +// Fallback — adjust with margin + + + +``` + +## Shadows Instead of Borders + +For **buttons, cards, and containers** that use a border for depth or elevation, prefer replacing it with a subtle `box-shadow`. Shadows adapt to any background since they use transparency; solid borders don't. This also helps when using images or multiple colors as backgrounds — solid border colors don't work well on backgrounds other than the ones they were designed for. + +**Do not apply this to dividers** (`border-b`, `border-t`, side borders) or any border whose purpose is layout separation rather than element depth. Those should stay as borders. + +### Shadow as Border (Light Mode) + +The shadow is comprised of three layers. The first acts as a 1px border ring, the second adds subtle lift, and the third provides ambient depth: + +```css +:root { + --shadow-border: + 0px 0px 0px 1px oklch(0 0 0 / 0.06), + 0px 1px 2px -1px oklch(0 0 0 / 0.06), + 0px 2px 4px 0px oklch(0 0 0 / 0.04); + --shadow-border-hover: + 0px 0px 0px 1px oklch(0 0 0 / 0.08), + 0px 1px 2px -1px oklch(0 0 0 / 0.08), + 0px 2px 4px 0px oklch(0 0 0 / 0.06); +} +``` + +### Shadow as Border (Dark Mode) + +In dark mode, simplify to a single white ring — layered depth shadows aren't visible on dark backgrounds: + +```css +/* Dark mode — adapt to whatever setup the project uses + (prefers-color-scheme, class, data attribute, etc.) */ +--shadow-border: 0 0 0 1px oklch(1 0 0 / 0.08); +--shadow-border-hover: 0 0 0 1px oklch(1 0 0 / 0.13); +``` + +### Usage with Hover Transition + +Apply the variable and add `transition-[box-shadow]` for a smooth hover: + +```css +.card { + box-shadow: var(--shadow-border); + transition-property: box-shadow; + transition-duration: 150ms; + transition-timing-function: ease-out; +} + +.card:hover { + box-shadow: var(--shadow-border-hover); +} +``` + +### When to Use Shadows vs. Borders + +| Use shadows | Use borders | +| --- | --- | +| Cards, containers with depth | Dividers between list items | +| Buttons with bordered styles | Table cell boundaries | +| Elevated elements (dropdowns, modals) | Form input outlines (for accessibility) | +| Elements on varied backgrounds | Hairline separators in dense UI | +| Hover/focus states for lift effect | | + +## Image Outlines + +Add a subtle `1px` outline with low opacity to images. This creates consistent depth, especially in design systems where other elements use borders or shadows. + +### Color rules (non-negotiable) + +- **Light mode**: pure black, `oklch(0 0 0 / 0.1)`. +- **Dark mode**: pure white, `oklch(1 0 0 / 0.1)`. +- Never use a near-black or near-white from the project palette (e.g. slate-900, zinc-900, `#0a0a0a`, `#111827`, `#f5f5f7`). Tinted outlines pick up the surrounding surface color and read as dirt on the image edge. +- Never match the outline to the project's accent or ink color. The outline is a neutral separator, not a themed element. + +### Light Mode + +```css +img { + outline: 1px solid oklch(0 0 0 / 0.1); + outline-offset: -1px; /* inset so it doesn't add to layout */ +} +``` + +### Dark Mode + +```css +img { + outline: 1px solid oklch(1 0 0 / 0.1); + outline-offset: -1px; +} +``` + +### Tailwind with Dark Mode + +```tsx +{alt} +``` + +Use `outline-black/10` and `outline-white/10` specifically — not `outline-slate-*`, `outline-zinc-*`, `outline-neutral-*`, or any tinted scale. + +**Why outline instead of border?** `outline` doesn't affect layout (no added width/height), and `outline-offset: -1px` keeps it inset so images stay their intended size. + +## Minimum Hit Area + +Interactive elements should prefer a 44×44px hit area for touch or mobile contexts. In dense desktop interfaces, use at least 40×40px. If the visible element is smaller (e.g., a 20×20 checkbox), extend the hit area with a pseudo-element. + +### CSS Example + +```css +/* Small checkbox with expanded 44px hit area */ +.checkbox { + position: relative; + width: 20px; + height: 20px; +} + +.checkbox::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 44px; + height: 44px; +} +``` + +### Tailwind Example + +```tsx + +``` + +### Collision Rule + +If the extended hit area overlaps another interactive element, shrink the pseudo-element — but make it as large as possible without colliding. Two interactive elements should never have overlapping hit areas. diff --git a/.agents/skills/make-interfaces-feel-better/typography.md b/.agents/skills/make-interfaces-feel-better/typography.md new file mode 100644 index 0000000..a950535 --- /dev/null +++ b/.agents/skills/make-interfaces-feel-better/typography.md @@ -0,0 +1,157 @@ +# Typography + +Typography rendering details that make interfaces feel better. + +## Text Wrapping + +### text-wrap: balance + +Distributes text evenly across lines, preventing orphaned words on headings and short text blocks. **Only works on blocks of 6 lines or fewer** (Chromium) or 10 lines or fewer (Firefox) — the balancing algorithm is computationally expensive, so browsers limit it to short text. + +```css +/* Good — even line lengths on short text */ +h1, h2, h3 { + text-wrap: balance; +} +``` + +```css +/* Bad — default wrapping leaves orphans */ +h1 { + /* no text-wrap rule → "Read our + blog" instead of balanced lines */ +} +``` + +```css +/* Bad — balance on long paragraphs (silently ignored, wastes intent) */ +.article-body p { + text-wrap: balance; +} +``` + +**Tailwind:** `text-balance` + +### text-wrap: pretty + +Prevents orphaned words (a single word dangling on the last line) by adjusting line breaks throughout the paragraph. Unlike `balance`, it doesn't try to equalize line lengths — it just ensures the last line isn't embarrassingly short. Works on text of any length with no line-count limit. + +This should be your **default for short-to-medium text** — paragraphs, descriptions, captions, list items, card text. For very long text (10+ lines), skip both `pretty` and `balance` — the browser's default wrapping is fine and you avoid unnecessary layout cost. + +```css +/* Good — descriptions, captions, short paragraphs */ +p, li, figcaption, blockquote { + text-wrap: pretty; +} +``` + +```tsx +// Tailwind +

    + A short paragraph that won't leave an orphan on the last line. +

    +``` + +**Tailwind:** `text-pretty` + +### When to Use Which + +| Scenario | Use | +| --- | --- | +| Headings, titles where even distribution matters | `text-wrap: balance` | +| Short-to-medium text — paragraphs, descriptions, captions, UI text | `text-wrap: pretty` | +| Long text (10+ lines), code blocks, pre-formatted text | Neither — leave default | + +## Font Smoothing (macOS) + +On macOS, text renders heavier than intended by default. Apply antialiased smoothing to the root layout so all text renders crisper and thinner. + +```css +/* CSS */ +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +``` + +```tsx +// Tailwind — apply to root layout + +``` + +### Good vs. Bad + +```css +/* Good — applied once at the root */ +html { + -webkit-font-smoothing: antialiased; +} + +/* Bad — applied per-element, inconsistent */ +.heading { + -webkit-font-smoothing: antialiased; +} +.body { + /* no smoothing → heavier than heading */ +} +``` + +**Note:** This only affects macOS rendering. Other platforms ignore these properties, so it's safe to apply universally. + +## Font Family Scope + +This skill does not require a specific font family. Do not introduce a paid or proprietary typeface just to satisfy the polish checklist. + +Use the product's existing type system unless the task explicitly asks for a type change. If the design calls for a system-native macOS feel, use the system font stack. If the design calls for a commercial face such as Helvetica Now, treat it as an optional brand decision and keep a practical fallback stack. + +```css +/* System-native macOS/iOS feel */ +html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} +``` + +```css +/* Commercial brand face with safe fallbacks */ +html { + font-family: "Helvetica Now", "Helvetica Neue", Arial, sans-serif; +} +``` + +**Rule:** font smoothing, text wrapping, and tabular numbers are rendering details. They do not override the project's chosen font family. + +## Tabular Numbers + +When numbers update dynamically (counters, prices, timers, table columns), use tabular-nums to make all digits equal width. This prevents layout shift as values change. + +```css +/* CSS */ +.counter { + font-variant-numeric: tabular-nums; +} +``` + +```tsx +// Tailwind +{count} +``` + +### When to Use + +| Use tabular-nums | Don't use tabular-nums | +| --- | --- | +| Counters and timers | Static display numbers | +| Prices that update | Decorative large numbers | +| Table columns with numbers | Phone numbers, zip codes | +| Animated number transitions | Version numbers (v2.1.0) | +| Scoreboards, dashboards | | + +### Caveat + +Some fonts (like Inter) change the visual appearance of numerals with this property — specifically, the digit `1` becomes wider and centered. This is expected behavior and usually desirable for alignment, but verify it looks right in your specific font. + +```css +/* With Inter font: + Default: 1234 → proportional, "1" is narrow + Tabular: 1234 → all digits equal width, "1" centered */ +``` diff --git a/.agents/skills/prisma-client-api/SKILL.md b/.agents/skills/prisma-client-api/SKILL.md new file mode 100644 index 0000000..57aa8a5 --- /dev/null +++ b/.agents/skills/prisma-client-api/SKILL.md @@ -0,0 +1,216 @@ +--- +name: prisma-client-api +description: Prisma Client API reference covering model queries, filters, operators, and client methods. Use when writing database queries, using CRUD operations, filtering data, or configuring Prisma Client. Triggers on "prisma query", "findMany", "create", "update", "delete", "$transaction". +license: MIT +metadata: + author: prisma + version: "7.9.1" +--- + +# Prisma Client API Reference + +Complete API reference for Prisma Client. This skill provides guidance on model queries, filtering, relations, and client methods for current Prisma projects. + +## When to Apply + +Reference this skill when: +- Writing database queries with Prisma Client +- Performing CRUD operations (create, read, update, delete) +- Filtering and sorting data +- Working with relations +- Using transactions +- Configuring client options + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Client Construction | HIGH | `constructor` | +| 2 | Model Queries | CRITICAL | `model-queries` | +| 3 | Query Shape | HIGH | `query-options` | +| 4 | Filtering | HIGH | `filters` | +| 5 | Relations | HIGH | `relations` | +| 6 | Transactions | CRITICAL | `transactions` | +| 7 | Raw SQL | CRITICAL | `raw-queries` | +| 8 | Client Methods | MEDIUM | `client-methods` | + +## Quick Reference + +- `constructor` - `PrismaClient` setup, adapter wiring, logging, and SQL commenter plugins +- `model-queries` - CRUD operations and bulk operations +- `query-options` - `select`, `include`, `omit`, sort, pagination +- `filters` - scalar and logical filter operators +- `relations` - relation reads and nested writes +- `transactions` - array and interactive transaction patterns +- `raw-queries` - `$queryRaw` and `$executeRaw` safety +- `client-methods` - lifecycle methods, extensions, and `satisfies` patterns for `prisma-client` + +## Client Instantiation + +```typescript +import { PrismaClient } from '../generated/client' +import { PrismaPg } from '@prisma/adapter-pg' + +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL +}) + +const prisma = new PrismaClient({ adapter }) +``` + +## Model Query Methods + +| Method | Description | +|--------|-------------| +| `findUnique()` | Find one record by unique field | +| `findUniqueOrThrow()` | Find one or throw error | +| `findFirst()` | Find first matching record | +| `findFirstOrThrow()` | Find first or throw error | +| `findMany()` | Find multiple records | +| `create()` | Create a new record | +| `createMany()` | Create multiple records | +| `createManyAndReturn()` | Create multiple and return them | +| `update()` | Update one record | +| `updateMany()` | Update multiple records | +| `updateManyAndReturn()` | Update multiple and return them | +| `upsert()` | Update or create record | +| `delete()` | Delete one record | +| `deleteMany()` | Delete multiple records | +| `count()` | Count matching records | +| `aggregate()` | Aggregate values (sum, avg, etc.) | +| `groupBy()` | Group and aggregate | + +## Query Options + +| Option | Description | +|--------|-------------| +| `where` | Filter conditions | +| `select` | Fields to include | +| `include` | Relations to load | +| `omit` | Fields to exclude | +| `orderBy` | Sort order | +| `take` | Limit results | +| `skip` | Skip results (pagination) | +| `cursor` | Cursor-based pagination | +| `distinct` | Unique values only | + +## Client Methods + +| Method | Description | +|--------|-------------| +| `$connect()` | Explicitly connect to database | +| `$disconnect()` | Disconnect from database | +| `$transaction()` | Execute transaction | +| `$queryRaw()` | Execute raw SQL query | +| `$executeRaw()` | Execute raw SQL command | +| `$on()` | Subscribe to events | +| `$extends()` | Add extensions | + +## Quick Examples + +### Find records + +```typescript +// Find by unique field +const user = await prisma.user.findUnique({ + where: { email: 'alice@prisma.io' } +}) + +// Find with filter +const users = await prisma.user.findMany({ + where: { role: 'ADMIN' }, + orderBy: { createdAt: 'desc' }, + take: 10 +}) +``` + +### Create records + +```typescript +const user = await prisma.user.create({ + data: { + email: 'alice@prisma.io', + name: 'Alice', + posts: { + create: { title: 'Hello World' } + } + }, + include: { posts: true } +}) +``` + +### Update records + +```typescript +const user = await prisma.user.update({ + where: { id: 1 }, + data: { name: 'Alice Smith' } +}) +``` + +### Delete records + +```typescript +await prisma.user.delete({ + where: { id: 1 } +}) +``` + +### Transactions + +```typescript +const [user, post] = await prisma.$transaction([ + prisma.user.create({ data: { email: 'alice@prisma.io' } }), + prisma.post.create({ data: { title: 'Hello', authorId: 1 } }) +]) +``` + +## Rule Files + +Detailed API documentation: + +``` +references/constructor.md - PrismaClient constructor options +references/model-queries.md - CRUD operations +references/query-options.md - select, include, omit, where, orderBy +references/filters.md - Filter conditions and operators +references/relations.md - Relation queries and nested operations +references/transactions.md - Transaction API +references/raw-queries.md - $queryRaw, $executeRaw +references/client-methods.md - $connect, $disconnect, $on, $extends +``` + +## Filter Operators + +| Operator | Description | +|----------|-------------| +| `equals` | Exact match | +| `not` | Not equal | +| `in` | In array | +| `notIn` | Not in array | +| `lt`, `lte` | Less than | +| `gt`, `gte` | Greater than | +| `contains` | String contains | +| `startsWith` | String starts with | +| `endsWith` | String ends with | +| `mode` | Case sensitivity | + +## Relation Filters + +| Operator | Description | +|----------|-------------| +| `some` | At least one related record matches | +| `every` | All related records match | +| `none` | No related records match | +| `is` | Related record matches (1-to-1) | +| `isNot` | Related record doesn't match | + +## Resources + +- [Prisma Client API Reference](https://www.prisma.io/docs/orm/reference/prisma-client-reference) +- [CRUD Operations](https://www.prisma.io/docs/orm/prisma-client/queries/crud) +- [Filtering and Sorting](https://www.prisma.io/docs/orm/prisma-client/queries/filtering-and-sorting) + +## How to Use + +Pick the category from the table above, then open the matching reference file for implementation details and examples. diff --git a/.agents/skills/prisma-client-api/references/client-methods.md b/.agents/skills/prisma-client-api/references/client-methods.md new file mode 100644 index 0000000..17beb1f --- /dev/null +++ b/.agents/skills/prisma-client-api/references/client-methods.md @@ -0,0 +1,223 @@ +# Client Methods + +Prisma Client instance methods. + +## $connect() + +Explicitly connect to the database: + +```typescript +const prisma = new PrismaClient({ adapter }) + +// Explicit connection +await prisma.$connect() +``` + +### When to use + +Usually not needed - Prisma connects automatically on first query. Use for: +- Fail fast on startup +- Health checks +- Pre-warming connections + +```typescript +async function main() { + try { + await prisma.$connect() + console.log('Database connected') + } catch (e) { + console.error('Failed to connect:', e) + process.exit(1) + } +} +``` + +## $disconnect() + +Close database connection: + +```typescript +await prisma.$disconnect() +``` + +### Graceful shutdown + +```typescript +process.on('beforeExit', async () => { + await prisma.$disconnect() +}) + +// Or with SIGTERM +process.on('SIGTERM', async () => { + await prisma.$disconnect() + process.exit(0) +}) +``` + +### In tests + +```typescript +afterAll(async () => { + await prisma.$disconnect() +}) +``` + +## $on() + +Subscribe to events: + +### Query events + +```typescript +const prisma = new PrismaClient({ + adapter, + log: [{ level: 'query', emit: 'event' }] +}) + +prisma.$on('query', (e) => { + console.log('Query:', e.query) + console.log('Params:', e.params) + console.log('Duration:', e.duration, 'ms') +}) +``` + +### Log events + +```typescript +const prisma = new PrismaClient({ + adapter, + log: [ + { level: 'info', emit: 'event' }, + { level: 'warn', emit: 'event' }, + { level: 'error', emit: 'event' } + ] +}) + +prisma.$on('info', (e) => console.log(e.message)) +prisma.$on('warn', (e) => console.warn(e.message)) +prisma.$on('error', (e) => console.error(e.message)) +``` + +## $extends() + +Add extensions for custom behavior: + +### Add custom methods + +```typescript +const prisma = new PrismaClient({ adapter }).$extends({ + client: { + $log: (message: string) => console.log(message) + } +}) + +prisma.$log('Hello!') +``` + +### Add model methods + +```typescript +const prisma = new PrismaClient({ adapter }).$extends({ + model: { + user: { + async findByEmail(email: string) { + return prisma.user.findUnique({ where: { email } }) + } + } + } +}) + +const user = await prisma.user.findByEmail('alice@prisma.io') +``` + +### Query extensions + +```typescript +const prisma = new PrismaClient({ adapter }).$extends({ + query: { + user: { + async findMany({ args, query }) { + // Add default filter + args.where = { ...args.where, deletedAt: null } + return query(args) + } + } + } +}) +``` + +### Result extensions + +```typescript +const prisma = new PrismaClient({ adapter }).$extends({ + result: { + user: { + fullName: { + needs: { firstName: true, lastName: true }, + compute(user) { + return `${user.firstName} ${user.lastName}` + } + } + } + } +}) + +const user = await prisma.user.findFirst() +console.log(user.fullName) // Computed field +``` + +### Chain extensions + +```typescript +const prisma = new PrismaClient({ adapter }) + .$extends(loggingExtension) + .$extends(softDeleteExtension) + .$extends(computedFieldsExtension) +``` + +## $transaction() + +See `transactions.md` for details. + +## $queryRaw() / $executeRaw() + +See `raw-queries.md` for details. + +## Type utilities + +### Prisma namespace + +```typescript +import { Prisma } from '../generated/client' + +// Input types +type UserCreateInput = Prisma.UserCreateInput +type UserWhereInput = Prisma.UserWhereInput + +// Output types +type User = Prisma.UserGetPayload<{}> +type UserWithPosts = Prisma.UserGetPayload<{ + include: { posts: true } +}> +``` + +### Type-safe query fragments with satisfies + +Type-safe query fragments: + +```typescript +import { Prisma } from '../generated/client' + +const userSelect = { + id: true, + email: true, + name: true +} satisfies Prisma.UserSelect + +const user = await prisma.user.findUnique({ + where: { id: 1 }, + select: userSelect +}) +``` + +With the `prisma-client` generator, use TypeScript `satisfies` for typed query fragments. You may still see older examples that use `Prisma.validator()` with `prisma-client-js`. diff --git a/.agents/skills/prisma-client-api/references/constructor.md b/.agents/skills/prisma-client-api/references/constructor.md new file mode 100644 index 0000000..a9fb5e8 --- /dev/null +++ b/.agents/skills/prisma-client-api/references/constructor.md @@ -0,0 +1,221 @@ +# PrismaClient Constructor + +Configure Prisma Client when instantiating. + +## Basic Instantiation + +```typescript +import { PrismaClient } from '../generated/client' +import { PrismaPg } from '@prisma/adapter-pg' + +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL +}) + +const prisma = new PrismaClient({ adapter }) +``` + +## Constructor Options + +### adapter (Required for the SQL provider workflow) + +Driver adapter instance: + +```typescript +import { PrismaPg } from '@prisma/adapter-pg' + +const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL +}) + +const prisma = new PrismaClient({ adapter }) +``` + +### accelerateUrl (For Accelerate users) + +```typescript +import { withAccelerate } from '@prisma/extension-accelerate' + +const prisma = new PrismaClient({ + accelerateUrl: process.env.DATABASE_URL, // prisma:// URL +}).$extends(withAccelerate()) +``` + +### log + +Configure logging: + +```typescript +const prisma = new PrismaClient({ + adapter, + log: ['query', 'info', 'warn', 'error'], +}) +``` + +#### Log levels + +| Level | Description | +|-------|-------------| +| `query` | All SQL queries | +| `info` | Informational messages | +| `warn` | Warnings | +| `error` | Errors | + +#### Log to events + +```typescript +const prisma = new PrismaClient({ + adapter, + log: [ + { level: 'query', emit: 'event' }, + { level: 'error', emit: 'stdout' }, + ], +}) + +prisma.$on('query', (e) => { + console.log('Query:', e.query) + console.log('Duration:', e.duration, 'ms') +}) +``` + +### errorFormat + +Control error formatting: + +```typescript +const prisma = new PrismaClient({ + adapter, + errorFormat: 'pretty', // 'pretty' | 'colorless' | 'minimal' +}) +``` + +### comments + +Attach SQL commenter plugins for observability, tracing, or query insights: + +```typescript +import { PrismaClient } from '../generated/client' +import { PrismaPg } from '@prisma/adapter-pg' +import { prismaQueryInsights } from '@prisma/sqlcommenter-query-insights' +import { queryTags, withQueryTags } from '@prisma/sqlcommenter-query-tags' +import { traceContext } from '@prisma/sqlcommenter-trace-context' + +const prisma = new PrismaClient({ + adapter: new PrismaPg(process.env.DATABASE_URL!), + comments: [prismaQueryInsights(), traceContext(), queryTags()], +}) + +await withQueryTags({ route: '/api/users', requestId: 'req-123' }, () => + prisma.user.findMany(), +) +``` + +Use `comments` only for SQL providers. This is the clean way to add trace or query-shape metadata without changing your query calls. + +### transactionOptions + +Default transaction settings: + +```typescript +const prisma = new PrismaClient({ + adapter, + transactionOptions: { + maxWait: 5000, // Max wait to acquire transaction (ms) + timeout: 10000, // Max transaction duration (ms) + isolationLevel: 'Serializable', + }, +}) +``` + +### queryPlanCacheMaxSize + +Use `queryPlanCacheMaxSize` to limit the in-memory query-plan cache: + +```typescript +const prisma = new PrismaClient({ + adapter, + queryPlanCacheMaxSize: 2_000, +}) +``` + +The value must be a non-negative integer. Set it to `0` to disable query-plan caching; omit it to use Prisma's default. Treat this as a process-local memory/performance control, not a database prepared-statement setting. + +## Singleton Pattern + +Prevent multiple client instances in development: + +```typescript +// lib/prisma.ts +import { PrismaClient } from '../generated/client' +import { PrismaPg } from '@prisma/adapter-pg' + +const globalForPrisma = globalThis as unknown as { + prisma: PrismaClient | undefined +} + +function createPrismaClient() { + const adapter = new PrismaPg({ + connectionString: process.env.DATABASE_URL! + }) + return new PrismaClient({ adapter }) +} + +export const prisma = globalForPrisma.prisma ?? createPrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +} +``` + +## Next.js Pattern + +```typescript +// lib/prisma.ts +import { PrismaClient } from '@/generated/client' +import { PrismaPg } from '@prisma/adapter-pg' + +const createAdapter = () => new PrismaPg({ + connectionString: process.env.DATABASE_URL! +}) + +const prismaClientSingleton = () => { + return new PrismaClient({ adapter: createAdapter() }) +} + +declare const globalThis: { + prismaGlobal: ReturnType +} & typeof global + +const prisma = globalThis.prismaGlobal ?? prismaClientSingleton() + +export default prisma + +if (process.env.NODE_ENV !== 'production') { + globalThis.prismaGlobal = prisma +} +``` + +## Query Events + +Listen to query events: + +```typescript +const prisma = new PrismaClient({ + adapter, + log: [{ level: 'query', emit: 'event' }], +}) + +prisma.$on('query', (e) => { + console.log('Query:', e.query) + console.log('Params:', e.params) + console.log('Duration:', e.duration) +}) +``` + +## Log Events + +```typescript +prisma.$on('info', (e) => console.log(e.message)) +prisma.$on('warn', (e) => console.warn(e.message)) +prisma.$on('error', (e) => console.error(e.message)) +``` diff --git a/.agents/skills/prisma-client-api/references/filters.md b/.agents/skills/prisma-client-api/references/filters.md new file mode 100644 index 0000000..a9b7eea --- /dev/null +++ b/.agents/skills/prisma-client-api/references/filters.md @@ -0,0 +1,256 @@ +# Filter Conditions and Operators + +Filter operators for the `where` clause. + +## Equality + +```typescript +// Exact match (implicit) +where: { email: 'alice@prisma.io' } + +// Explicit equals +where: { email: { equals: 'alice@prisma.io' } } + +// Not equal +where: { email: { not: 'alice@prisma.io' } } +``` + +## Comparison + +```typescript +// Greater than +where: { age: { gt: 18 } } + +// Greater than or equal +where: { age: { gte: 18 } } + +// Less than +where: { age: { lt: 65 } } + +// Less than or equal +where: { age: { lte: 65 } } + +// Combined +where: { age: { gte: 18, lte: 65 } } +``` + +## Lists + +```typescript +// In array +where: { role: { in: ['ADMIN', 'MODERATOR'] } } + +// Not in array +where: { role: { notIn: ['GUEST', 'BANNED'] } } +``` + +## String Filters + +```typescript +// Contains +where: { email: { contains: 'prisma' } } + +// Starts with +where: { email: { startsWith: 'alice' } } + +// Ends with +where: { email: { endsWith: '@prisma.io' } } + +// Case-insensitive (default for some databases) +where: { + email: { + contains: 'PRISMA', + mode: 'insensitive' + } +} +``` + +## Null Checks + +```typescript +// Is null +where: { deletedAt: null } + +// Is not null +where: { deletedAt: { not: null } } + +// Using isSet (for optional fields) +where: { middleName: { isSet: true } } +``` + +## Logical Operators + +### AND (implicit) + +```typescript +// Multiple conditions = AND +where: { + email: { contains: '@prisma.io' }, + role: 'ADMIN' +} +``` + +### AND (explicit) + +```typescript +where: { + AND: [ + { email: { contains: '@prisma.io' } }, + { role: 'ADMIN' } + ] +} +``` + +### OR + +```typescript +where: { + OR: [ + { email: { contains: '@gmail.com' } }, + { email: { contains: '@prisma.io' } } + ] +} +``` + +### NOT + +```typescript +where: { + NOT: { + role: 'GUEST' + } +} + +// Multiple NOT conditions +where: { + NOT: [ + { role: 'GUEST' }, + { verified: false } + ] +} +``` + +### Combined + +```typescript +where: { + AND: [ + { verified: true }, + { + OR: [ + { role: 'ADMIN' }, + { role: 'MODERATOR' } + ] + } + ], + NOT: { deletedAt: { not: null } } +} +``` + +## Relation Filters + +### some + +At least one related record matches: + +```typescript +// Users with at least one published post +where: { + posts: { + some: { published: true } + } +} +``` + +### every + +All related records match: + +```typescript +// Users where all posts are published +where: { + posts: { + every: { published: true } + } +} +``` + +### none + +No related records match: + +```typescript +// Users with no published posts +where: { + posts: { + none: { published: true } + } +} +``` + +### is / isNot (1-to-1) + +```typescript +// Users with profile in specific country +where: { + profile: { + is: { country: 'USA' } + } +} + +// Users without profile +where: { + profile: { + isNot: null + } +} +``` + +## Array Field Filters + +For fields like `String[]`: + +```typescript +// Has element +where: { tags: { has: 'typescript' } } + +// Has some elements +where: { tags: { hasSome: ['typescript', 'javascript'] } } + +// Has every element +where: { tags: { hasEvery: ['typescript', 'prisma'] } } + +// Is empty +where: { tags: { isEmpty: true } } +``` + +## JSON Filters + +```typescript +// Path-based filter +where: { + metadata: { + path: ['settings', 'theme'], + equals: 'dark' + } +} + +// String contains in JSON +where: { + metadata: { + path: ['bio'], + string_contains: 'developer' + } +} +``` + +## Full-Text Search + +```typescript +// Requires @@fulltext index +where: { + content: { + search: 'prisma database' + } +} +``` diff --git a/.agents/skills/prisma-client-api/references/model-queries.md b/.agents/skills/prisma-client-api/references/model-queries.md new file mode 100644 index 0000000..0687ef5 --- /dev/null +++ b/.agents/skills/prisma-client-api/references/model-queries.md @@ -0,0 +1,281 @@ +# Model Queries + +CRUD operations for your Prisma models. + +## Read Operations + +### findUnique + +Find a single record by unique field: + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 } +}) + +const user = await prisma.user.findUnique({ + where: { email: 'alice@prisma.io' } +}) +``` + +#### With composite unique key + +```typescript +// Model with @@unique([firstName, lastName]) +const user = await prisma.user.findUnique({ + where: { + firstName_lastName: { + firstName: 'Alice', + lastName: 'Smith' + } + } +}) +``` + +### findUniqueOrThrow + +Same as findUnique but throws if not found: + +```typescript +const user = await prisma.user.findUniqueOrThrow({ + where: { id: 1 } +}) +// Throws PrismaClientKnownRequestError if not found +``` + +### findFirst + +Find first matching record: + +```typescript +const user = await prisma.user.findFirst({ + where: { role: 'ADMIN' }, + orderBy: { createdAt: 'desc' } +}) +``` + +### findFirstOrThrow + +```typescript +const user = await prisma.user.findFirstOrThrow({ + where: { role: 'ADMIN' } +}) +``` + +### findMany + +Find multiple records: + +```typescript +const users = await prisma.user.findMany({ + where: { role: 'USER' }, + orderBy: { name: 'asc' }, + take: 10, + skip: 0 +}) +``` + +## Create Operations + +### create + +Create a single record: + +```typescript +const user = await prisma.user.create({ + data: { + email: 'alice@prisma.io', + name: 'Alice' + } +}) +``` + +#### With relations + +```typescript +const user = await prisma.user.create({ + data: { + email: 'alice@prisma.io', + posts: { + create: [ + { title: 'First Post' }, + { title: 'Second Post' } + ] + } + }, + include: { posts: true } +}) +``` + +### createMany + +Create multiple records: + +```typescript +const result = await prisma.user.createMany({ + data: [ + { email: 'alice@prisma.io', name: 'Alice' }, + { email: 'bob@prisma.io', name: 'Bob' } + ], + skipDuplicates: true // Skip records with duplicate unique fields +}) +// Returns { count: 2 } +``` + +### createManyAndReturn + +Create multiple and return them: + +```typescript +const users = await prisma.user.createManyAndReturn({ + data: [ + { email: 'alice@prisma.io', name: 'Alice' }, + { email: 'bob@prisma.io', name: 'Bob' } + ] +}) +// Returns array of created users +``` + +## Update Operations + +### update + +Update a single record: + +```typescript +const user = await prisma.user.update({ + where: { id: 1 }, + data: { name: 'Alice Smith' } +}) +``` + +#### Atomic operations + +```typescript +const post = await prisma.post.update({ + where: { id: 1 }, + data: { + views: { increment: 1 }, + likes: { decrement: 1 }, + score: { multiply: 2 }, + rating: { divide: 2 }, + version: { set: 5 } + } +}) +``` + +### updateMany + +Update multiple records: + +```typescript +const result = await prisma.user.updateMany({ + where: { role: 'USER' }, + data: { verified: true } +}) +// Returns { count: 42 } +``` + +### updateManyAndReturn + +```typescript +const users = await prisma.user.updateManyAndReturn({ + where: { role: 'USER' }, + data: { verified: true } +}) +// Returns array of updated users +``` + +### upsert + +Update or create: + +```typescript +const user = await prisma.user.upsert({ + where: { email: 'alice@prisma.io' }, + update: { name: 'Alice Smith' }, + create: { email: 'alice@prisma.io', name: 'Alice' } +}) +``` + +## Delete Operations + +### delete + +Delete a single record: + +```typescript +const user = await prisma.user.delete({ + where: { id: 1 } +}) +// Returns deleted record +``` + +### deleteMany + +Delete multiple records: + +```typescript +const result = await prisma.user.deleteMany({ + where: { role: 'GUEST' } +}) +// Returns { count: 5 } + +// Delete all +const result = await prisma.user.deleteMany({}) +``` + +## Aggregation Operations + +### count + +```typescript +const count = await prisma.user.count({ + where: { role: 'ADMIN' } +}) +``` + +### aggregate + +```typescript +const result = await prisma.post.aggregate({ + _avg: { views: true }, + _sum: { views: true }, + _min: { views: true }, + _max: { views: true }, + _count: { _all: true } +}) +``` + +### groupBy + +```typescript +const groups = await prisma.user.groupBy({ + by: ['country'], + _count: { _all: true }, + _avg: { age: true }, + having: { + age: { _avg: { gt: 30 } } + } +}) +``` + +## Return Types + +| Method | Returns | +|--------|---------| +| `findUnique` | Record \| null | +| `findUniqueOrThrow` | Record (throws if not found) | +| `findFirst` | Record \| null | +| `findFirstOrThrow` | Record (throws if not found) | +| `findMany` | Record[] | +| `create` | Record | +| `createMany` | { count: number } | +| `createManyAndReturn` | Record[] | +| `update` | Record | +| `updateMany` | { count: number } | +| `delete` | Record | +| `deleteMany` | { count: number } | +| `count` | number | +| `aggregate` | Aggregate result | +| `groupBy` | Group result[] | diff --git a/.agents/skills/prisma-client-api/references/query-options.md b/.agents/skills/prisma-client-api/references/query-options.md new file mode 100644 index 0000000..25864a4 --- /dev/null +++ b/.agents/skills/prisma-client-api/references/query-options.md @@ -0,0 +1,276 @@ +# Query Options + +Options for controlling query behavior. + +## select + +Choose specific fields to return: + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + select: { + id: true, + name: true, + email: true, + // password: false (excluded by not including) + } +}) +// Returns: { id: 1, name: 'Alice', email: 'alice@prisma.io' } +``` + +### Select relations + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + select: { + name: true, + posts: { + select: { + title: true, + published: true + } + } + } +}) +``` + +### Select with include inside + +```typescript +const user = await prisma.user.findMany({ + select: { + name: true, + posts: { + include: { + comments: true + } + } + } +}) +``` + +### Select relation count + +```typescript +const users = await prisma.user.findMany({ + select: { + name: true, + _count: { + select: { posts: true } + } + } +}) +// Returns: { name: 'Alice', _count: { posts: 5 } } +``` + +## include + +Include related records: + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + include: { + posts: true, + profile: true + } +}) +``` + +### Filtered include + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + include: { + posts: { + where: { published: true }, + orderBy: { createdAt: 'desc' }, + take: 5 + } + } +}) +``` + +### Nested include + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + include: { + posts: { + include: { + comments: { + include: { + author: true + } + } + } + } + } +}) +``` + +### Include relation count + +```typescript +const users = await prisma.user.findMany({ + include: { + _count: { + select: { posts: true, followers: true } + } + } +}) +``` + +## omit + +Exclude specific fields: + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + omit: { + password: true + } +}) +// Returns all fields except password +``` + +### Omit in relations + +```typescript +const users = await prisma.user.findMany({ + omit: { password: true }, + include: { + posts: { + omit: { content: true } + } + } +}) +``` + +**Note:** Cannot use `select` and `omit` together. + +## where + +Filter records: + +```typescript +const users = await prisma.user.findMany({ + where: { + email: { contains: '@prisma.io' }, + role: 'ADMIN' + } +}) +``` + +See `filters.md` for detailed filter operators. + +## orderBy + +Sort results: + +```typescript +// Single field +const users = await prisma.user.findMany({ + orderBy: { name: 'asc' } +}) + +// Multiple fields +const users = await prisma.user.findMany({ + orderBy: [ + { role: 'desc' }, + { name: 'asc' } + ] +}) +``` + +### Order by relation + +```typescript +const users = await prisma.user.findMany({ + orderBy: { + posts: { _count: 'desc' } + } +}) +``` + +### Null handling + +```typescript +const users = await prisma.user.findMany({ + orderBy: { + name: { sort: 'asc', nulls: 'last' } + } +}) +``` + +## take & skip + +Pagination: + +```typescript +// First page +const users = await prisma.user.findMany({ + take: 10, + skip: 0 +}) + +// Second page +const users = await prisma.user.findMany({ + take: 10, + skip: 10 +}) +``` + +### Negative take (reverse) + +```typescript +const lastUsers = await prisma.user.findMany({ + take: -10, + orderBy: { id: 'asc' } +}) +// Returns last 10 users +``` + +## cursor + +Cursor-based pagination: + +```typescript +// First page +const firstPage = await prisma.user.findMany({ + take: 10, + orderBy: { id: 'asc' } +}) + +// Next page using cursor +const nextPage = await prisma.user.findMany({ + take: 10, + skip: 1, // Skip the cursor record + cursor: { id: firstPage[firstPage.length - 1].id }, + orderBy: { id: 'asc' } +}) +``` + +## distinct + +Return unique values: + +```typescript +const cities = await prisma.user.findMany({ + distinct: ['city'], + select: { city: true } +}) +``` + +### Multiple distinct fields + +```typescript +const locations = await prisma.user.findMany({ + distinct: ['city', 'country'] +}) +``` diff --git a/.agents/skills/prisma-client-api/references/raw-queries.md b/.agents/skills/prisma-client-api/references/raw-queries.md new file mode 100644 index 0000000..e444ce2 --- /dev/null +++ b/.agents/skills/prisma-client-api/references/raw-queries.md @@ -0,0 +1,198 @@ +# Raw Queries + +Execute raw SQL when Prisma's query API isn't sufficient. + +## $queryRaw + +Execute SELECT queries and get typed results: + +```typescript +const users = await prisma.$queryRaw` + SELECT * FROM "User" WHERE email LIKE ${'%@prisma.io'} +` +``` + +### With type + +```typescript +type User = { id: number; email: string; name: string | null } + +const users = await prisma.$queryRaw` + SELECT id, email, name FROM "User" WHERE role = ${'ADMIN'} +` +``` + +### Dynamic table/column names + +Use `Prisma.raw()` for identifiers (not safe for user input): + +```typescript +import { Prisma } from '../generated/client' + +const column = 'email' +const users = await prisma.$queryRaw` + SELECT ${Prisma.raw(column)} FROM "User" +` +``` + +### With Prisma.sql + +Build queries dynamically: + +```typescript +import { Prisma } from '../generated/client' + +const email = 'alice@prisma.io' +const query = Prisma.sql`SELECT * FROM "User" WHERE email = ${email}` +const users = await prisma.$queryRaw(query) +``` + +### Join multiple SQL fragments + +```typescript +import { Prisma } from '../generated/client' + +const conditions = [ + Prisma.sql`role = ${'ADMIN'}`, + Prisma.sql`verified = ${true}` +] + +const users = await prisma.$queryRaw` + SELECT * FROM "User" + WHERE ${Prisma.join(conditions, ' AND ')} +` +``` + +## $executeRaw + +Execute INSERT, UPDATE, DELETE (returns affected count): + +```typescript +const count = await prisma.$executeRaw` + UPDATE "User" SET verified = true WHERE email LIKE ${'%@prisma.io'} +` +console.log(`Updated ${count} users`) +``` + +### Delete example + +```typescript +const deleted = await prisma.$executeRaw` + DELETE FROM "User" WHERE "deletedAt" < ${thirtyDaysAgo} +` +``` + +### Insert example + +```typescript +const inserted = await prisma.$executeRaw` + INSERT INTO "Log" (message, level, timestamp) + VALUES (${message}, ${level}, ${new Date()}) +` +``` + +## $queryRawUnsafe / $executeRawUnsafe + +For fully dynamic queries (use with caution!): + +```typescript +// ⚠️ SQL injection risk - only use with trusted input +const table = 'User' +const users = await prisma.$queryRawUnsafe( + `SELECT * FROM "${table}" WHERE id = $1`, + userId +) +``` + +### Parameterized unsafe query + +```typescript +const result = await prisma.$executeRawUnsafe( + 'UPDATE "User" SET name = $1 WHERE id = $2', + 'Alice', + 1 +) +``` + +## SQL Injection Prevention + +### Safe (parameterized) + +```typescript +// ✅ User input is parameterized +const email = userInput +const users = await prisma.$queryRaw` + SELECT * FROM "User" WHERE email = ${email} +` +``` + +### Unsafe (concatenation) + +```typescript +// ❌ SQL injection vulnerability! +const email = userInput +const users = await prisma.$queryRawUnsafe( + `SELECT * FROM "User" WHERE email = '${email}'` +) +``` + +## Database-Specific Features + +### PostgreSQL + +```typescript +// Array operations +const users = await prisma.$queryRaw` + SELECT * FROM "User" WHERE 'admin' = ANY(roles) +` + +// JSON operations +const users = await prisma.$queryRaw` + SELECT * FROM "User" WHERE metadata->>'theme' = 'dark' +` +``` + +### MySQL + +```typescript +// Full-text search +const posts = await prisma.$queryRaw` + SELECT * FROM Post WHERE MATCH(title, content) AGAINST(${searchTerm}) +` +``` + +## Transactions with Raw Queries + +```typescript +await prisma.$transaction(async (tx) => { + await tx.$executeRaw`UPDATE "Account" SET balance = balance - ${amount} WHERE id = ${senderId}` + await tx.$executeRaw`UPDATE "Account" SET balance = balance + ${amount} WHERE id = ${recipientId}` +}) +``` + +## Handling Results + +### BigInt handling + +PostgreSQL returns BigInt for COUNT: + +```typescript +const result = await prisma.$queryRaw<[{ count: bigint }]>` + SELECT COUNT(*) as count FROM "User" +` +const count = Number(result[0].count) +``` + +### Date handling + +```typescript +type Result = { createdAt: Date } +const users = await prisma.$queryRaw` + SELECT "createdAt" FROM "User" +` +// createdAt is already a Date object +``` + +Invalid JavaScript `Date` values passed to raw queries fail validation instead of being silently serialized as `null`. Validate date input at the application boundary; do not rely on `new Date(badValue)` reaching the database. + +When a driver adapter returns an unmapped database-specific error, Prisma surfaces `P2039` with the adapter's preserved original code/message. If those details are missing, fix the adapter mapping rather than parsing rendered error text. diff --git a/.agents/skills/prisma-client-api/references/relations.md b/.agents/skills/prisma-client-api/references/relations.md new file mode 100644 index 0000000..001c448 --- /dev/null +++ b/.agents/skills/prisma-client-api/references/relations.md @@ -0,0 +1,308 @@ +# Relation Queries + +Query and modify related records. + +## Include Relations + +Load related records: + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + include: { + posts: true, + profile: true + } +}) +``` + +### Filtered include + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + include: { + posts: { + where: { published: true }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { id: true, title: true } + } + } +}) +``` + +### Nested include + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + include: { + posts: { + include: { + comments: { + include: { author: true } + } + } + } + } +}) +``` + +## Select Relations + +```typescript +const user = await prisma.user.findUnique({ + where: { id: 1 }, + select: { + name: true, + posts: { + select: { title: true } + } + } +}) +``` + +## Nested Writes + +### Create with relations + +```typescript +const user = await prisma.user.create({ + data: { + email: 'alice@prisma.io', + posts: { + create: [ + { title: 'Post 1' }, + { title: 'Post 2' } + ] + }, + profile: { + create: { bio: 'Hello!' } + } + } +}) +``` + +### Create or connect + +```typescript +const post = await prisma.post.create({ + data: { + title: 'New Post', + author: { + connectOrCreate: { + where: { email: 'alice@prisma.io' }, + create: { email: 'alice@prisma.io', name: 'Alice' } + } + } + } +}) +``` + +### Connect existing + +```typescript +const post = await prisma.post.create({ + data: { + title: 'New Post', + author: { + connect: { id: 1 } + } + } +}) + +// Shorthand for foreign key +const post = await prisma.post.create({ + data: { + title: 'New Post', + authorId: 1 + } +}) +``` + +## Update Relations + +### Update related records + +```typescript +const user = await prisma.user.update({ + where: { id: 1 }, + data: { + posts: { + update: { + where: { id: 1 }, + data: { title: 'Updated Title' } + } + } + } +}) +``` + +### Update many related + +```typescript +const user = await prisma.user.update({ + where: { id: 1 }, + data: { + posts: { + updateMany: { + where: { published: false }, + data: { published: true } + } + } + } +}) +``` + +### Upsert related + +```typescript +const user = await prisma.user.update({ + where: { id: 1 }, + data: { + profile: { + upsert: { + create: { bio: 'New bio' }, + update: { bio: 'Updated bio' } + } + } + } +}) +``` + +### Disconnect + +```typescript +// 1-to-1 optional +const user = await prisma.user.update({ + where: { id: 1 }, + data: { + profile: { disconnect: true } + } +}) + +// Many-to-many +const post = await prisma.post.update({ + where: { id: 1 }, + data: { + tags: { + disconnect: [{ id: 1 }, { id: 2 }] + } + } +}) +``` + +### Delete related + +```typescript +const user = await prisma.user.update({ + where: { id: 1 }, + data: { + posts: { + delete: { id: 1 } + } + } +}) + +// Delete many +const user = await prisma.user.update({ + where: { id: 1 }, + data: { + posts: { + deleteMany: { published: false } + } + } +}) +``` + +### Set (replace all) + +```typescript +// Replace all related records +const post = await prisma.post.update({ + where: { id: 1 }, + data: { + tags: { + set: [{ id: 1 }, { id: 2 }] + } + } +}) +``` + +## Relation Filters + +### some + +At least one matches: + +```typescript +const users = await prisma.user.findMany({ + where: { + posts: { some: { published: true } } + } +}) +``` + +### every + +All match: + +```typescript +const users = await prisma.user.findMany({ + where: { + posts: { every: { published: true } } + } +}) +``` + +### none + +None match: + +```typescript +const users = await prisma.user.findMany({ + where: { + posts: { none: { published: true } } + } +}) +``` + +### is / isNot (1-to-1) + +```typescript +const users = await prisma.user.findMany({ + where: { + profile: { is: { country: 'USA' } } + } +}) +``` + +## Count Relations + +```typescript +const users = await prisma.user.findMany({ + select: { + name: true, + _count: { + select: { posts: true, followers: true } + } + } +}) +// { name: 'Alice', _count: { posts: 5, followers: 100 } } +``` + +### Filter counted relations + +```typescript +const users = await prisma.user.findMany({ + select: { + name: true, + _count: { + select: { + posts: { where: { published: true } } + } + } + } +}) +``` diff --git a/.agents/skills/prisma-client-api/references/transactions.md b/.agents/skills/prisma-client-api/references/transactions.md new file mode 100644 index 0000000..c2a981a --- /dev/null +++ b/.agents/skills/prisma-client-api/references/transactions.md @@ -0,0 +1,184 @@ +# Transactions + +Execute multiple operations atomically. + +## Sequential Transactions + +Array of operations executed in order: + +```typescript +const [user, post] = await prisma.$transaction([ + prisma.user.create({ data: { email: 'alice@prisma.io' } }), + prisma.post.create({ data: { title: 'Hello', authorId: 1 } }) +]) +``` + +### All or nothing + +If any operation fails, all are rolled back: + +```typescript +try { + await prisma.$transaction([ + prisma.user.create({ data: { email: 'alice@prisma.io' } }), + prisma.user.create({ data: { email: 'alice@prisma.io' } }) // Duplicate! + ]) +} catch (e) { + // Both operations rolled back +} +``` + +## Interactive Transactions + +For complex logic and dependent operations: + +```typescript +await prisma.$transaction(async (tx) => { + // Decrement sender balance + const sender = await tx.account.update({ + where: { id: senderId }, + data: { balance: { decrement: amount } } + }) + + // Check balance + if (sender.balance < 0) { + throw new Error('Insufficient funds') + } + + // Increment recipient balance + await tx.account.update({ + where: { id: recipientId }, + data: { balance: { increment: amount } } + }) +}) +``` + +### Transaction options + +```typescript +await prisma.$transaction( + async (tx) => { + // operations + }, + { + maxWait: 5000, // Max wait to acquire lock (ms) + timeout: 10000, // Max transaction duration (ms) + isolationLevel: 'Serializable' // Isolation level + } +) +``` + +### Isolation levels + +| Level | Description | +|-------|-------------| +| `ReadUncommitted` | Lowest isolation, can read uncommitted changes | +| `ReadCommitted` | Only read committed changes | +| `RepeatableRead` | Consistent reads within transaction | +| `Serializable` | Highest isolation, serialized execution | + +## Nested Writes + +Automatic transactions for nested operations: + +```typescript +// This is automatically a transaction +const user = await prisma.user.create({ + data: { + email: 'alice@prisma.io', + posts: { + create: [ + { title: 'Post 1' }, + { title: 'Post 2' } + ] + }, + profile: { + create: { bio: 'Hello!' } + } + } +}) +``` + +## Transaction Client + +The `tx` parameter is a Prisma Client scoped to the transaction: + +```typescript +await prisma.$transaction(async (tx) => { + // Use tx instead of prisma + await tx.user.create({ ... }) + await tx.post.create({ ... }) + + // Can call methods + const count = await tx.user.count() +}) +``` + +## OrThrow in Transactions + +Use with interactive transactions: + +```typescript +await prisma.$transaction(async (tx) => { + // If not found, throws and rolls back entire transaction + const user = await tx.user.findUniqueOrThrow({ + where: { id: 1 } + }) + + await tx.post.create({ + data: { title: 'New Post', authorId: user.id } + }) +}) +``` + +## Best Practices + +### Keep transactions short + +```typescript +// Good - only DB operations in transaction +const data = prepareData() // Outside transaction +await prisma.$transaction(async (tx) => { + await tx.user.create({ data }) +}) +``` + +### Handle errors + +```typescript +try { + await prisma.$transaction(async (tx) => { + // operations + }) +} catch (e) { + if (e.code === 'P2002') { + // Handle unique constraint violation + } + throw e +} +``` + +### Use appropriate isolation + +```typescript +// Default is fine for most cases +await prisma.$transaction(async (tx) => { + // operations +}) + +// Use Serializable for strict consistency +await prisma.$transaction( + async (tx) => { /* operations */ }, + { isolationLevel: 'Serializable' } +) +``` + +## Sequential vs Interactive + +| Feature | Sequential | Interactive | +|---------|------------|-------------| +| Syntax | Array | Async function | +| Dependent ops | No | Yes | +| Conditional logic | No | Yes | +| Performance | Better | More flexible | +| Use case | Simple batch | Complex logic | diff --git a/.agents/skills/prisma-postgres/SKILL.md b/.agents/skills/prisma-postgres/SKILL.md new file mode 100644 index 0000000..568543f --- /dev/null +++ b/.agents/skills/prisma-postgres/SKILL.md @@ -0,0 +1,145 @@ +--- +name: prisma-postgres +description: Prisma Postgres setup and operations guidance across Console, create-db CLI, Management API, and Management API SDK. Use when creating Prisma Postgres databases, working in Prisma Console, provisioning with create-db/create-pg/create-postgres, or integrating programmatic provisioning with service tokens or OAuth. +license: MIT +metadata: + author: prisma + version: "7.9.1" +--- + +# Prisma Postgres + +Guidance for creating, managing, and integrating Prisma Postgres across interactive and programmatic workflows. + +## When to Apply + +Reference this skill when: +- Setting up Prisma Postgres from Prisma Console +- Provisioning instant temporary databases with `create-db` +- Linking an existing local project with `prisma postgres link` +- Managing Prisma Postgres resources via Management API +- Using `@prisma/management-api-sdk` in TypeScript/JavaScript +- Handling claim URLs, connection strings, regions, and auth flows + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | CLI Provisioning | CRITICAL | `create-db-cli` | +| 2 | Management API | CRITICAL | `management-api` | +| 3 | Management API SDK | HIGH | `management-api-sdk` | +| 4 | Console and Connections | HIGH | `console-and-connections` | + +## Quick Reference + +- `create-db-cli` - instant databases and current CLI flags (`--ttl`, `--copy`, `--quiet`, `--open`) +- `management-api` - service token and OAuth API workflows +- `management-api-sdk` - typed SDK usage with token storage +- `console-and-connections` - Console operations, `prisma postgres link`, direct TCP connections, and serverless-driver choices + +## Core Workflows + +### 1. Console-first workflow + +Use Prisma Console for manual setup and operations: + +- Open `https://console.prisma.io` +- Create/select workspace and project +- Use Studio in the project sidebar to view/edit data +- Retrieve direct connection details from the project UI + +### 2. Quick provisioning with create-db + +Use `create-db` when you need a database immediately: + +```bash +npx create-db@latest +``` + +Aliases: + +```bash +npx create-pg@latest +npx create-postgres@latest +``` + +For app integrations, you can also use the programmatic API (`create()` / `regions()`) from the `create-db` npm package. + +Temporary databases auto-delete after ~24 hours unless claimed. + +### 2b. Persistent databases with the Platform CLI + +For databases that belong to a Project (not throwaway `create-db` databases), use `@prisma/cli`: + +```bash +npx -y @prisma/cli@latest database create --help +npx -y @prisma/cli@latest database list --json +npx -y @prisma/cli@latest database connection create db_123 +npx -y @prisma/cli@latest database usage db_123 +npx -y @prisma/cli@latest database backup list db_123 +``` + +`database create` and `database connection create` print a one-time connection URL; store it immediately. Destructive commands (`remove`, `restore`) require exact `--confirm `. + +For automation, prefer `--json --no-interactive`, resolve ids before mutations, and verify the installed command's help because this CLI is beta. + +### 3. Link an existing local project + +Use `prisma postgres link` when the database already exists and you want to wire a local project to it: + +```bash +prisma postgres link +``` + +For CI or other non-interactive environments: + +```bash +prisma postgres link --api-key "" --database "db_..." +``` + +This flow updates your local `.env` with `DATABASE_URL`, then you can run `prisma generate` and `prisma migrate dev`. + +### 4. Programmatic provisioning with Management API + +Use API endpoints on: + +```text +https://api.prisma.io/v1 +``` + +Explore the schema and endpoints using: + +- OpenAPI docs: `https://api.prisma.io/v1/doc` +- Swagger Editor: `https://api.prisma.io/v1/swagger-editor` + +Auth options: + +- Service token (workspace server-to-server) +- OAuth 2.0 (act on behalf of users) + +### 5. Type-safe integration with Management API SDK + +Install and use: + +```bash +npm install @prisma/management-api-sdk +``` + +Use `createManagementApiClient` for existing tokens, or `createManagementApiSdk` for OAuth + token refresh. + +The SDK exposes typed workspace service-token list, create, and revoke routes. A newly created token value is returned exactly once. Let the installed SDK types or OpenAPI document settle exact beta endpoint shapes. + +## Rule Files + +Detailed guidance lives in: + +``` +references/console-and-connections.md +references/create-db-cli.md +references/management-api.md +references/management-api-sdk.md +``` + +## How to Use + +Start with `references/create-db-cli.md` for fast setup, then switch to `references/management-api.md` or `references/management-api-sdk.md` when you need programmatic provisioning. diff --git a/.agents/skills/prisma-postgres/references/console-and-connections.md b/.agents/skills/prisma-postgres/references/console-and-connections.md new file mode 100644 index 0000000..4025d12 --- /dev/null +++ b/.agents/skills/prisma-postgres/references/console-and-connections.md @@ -0,0 +1,69 @@ +# console-and-connections + +Use Prisma Console workflows for project visibility, data inspection, and connection setup. + +## Priority + +HIGH + +## Why It Matters + +Many Prisma Postgres tasks are quickest in the Console: viewing Studio data, checking metrics, and retrieving connection details. This avoids unnecessary API or CLI work for simple operational tasks. + +## Console workflow + +1. Open `https://console.prisma.io`. +2. Select workspace and project. +3. Use dashboard metrics for usage and billing visibility. +4. Open the **Studio** tab in the sidebar to inspect and edit data. + +## Local Studio + +You can also inspect data locally: + +```bash +npx prisma studio +``` + +## Linking an existing project + +If the Prisma Postgres database already exists, link the local project instead of provisioning a new one: + +```bash +prisma postgres link +``` + +For CI or non-interactive usage: + +```bash +prisma postgres link --api-key "" --database "db_..." +``` + +This command updates or creates `.env` with `DATABASE_URL`. If the project is already linked, use `--force` to re-link. After linking, run `prisma generate`, then `prisma migrate dev` if you need to apply the schema. + +## Connection setup + +For direct PostgreSQL tools and drivers: + +- Generate/copy direct connection credentials from the project connection UI. +- Use the resulting PostgreSQL URL as `DATABASE_URL` for `pg` and `@prisma/adapter-pg`. +- For Prisma Postgres direct TCP, include `sslmode=require`. + +Typical direct TCP format: + +```env +DATABASE_URL="postgres://identifier:key@db.prisma.io:5432/postgres?sslmode=require" +``` + +Management API connection responses expose both `endpoints.direct` (`db.prisma.io:5432`) and `endpoints.pooled` (`pooled.db.prisma.io:5432`); prefer those fields over the deprecated flat `connectionString`. Connection secrets are shown once at creation (one-time view); store them immediately. + +## Adapter choices + +- Standard Node.js apps: prefer `@prisma/adapter-pg` with the direct TCP URL above. +- Edge/serverless runtimes: use `@prisma/adapter-ppg` with `@prisma/ppg` only when you specifically need the Prisma Postgres serverless driver. + +## References + +- [Prisma Postgres overview](https://www.prisma.io/docs/postgres/introduction/overview) +- [Viewing data](https://www.prisma.io/docs/postgres/integrations/viewing-data) +- [Direct connections](https://www.prisma.io/docs/postgres/database/direct-connections) diff --git a/.agents/skills/prisma-postgres/references/create-db-cli.md b/.agents/skills/prisma-postgres/references/create-db-cli.md new file mode 100644 index 0000000..7359b78 --- /dev/null +++ b/.agents/skills/prisma-postgres/references/create-db-cli.md @@ -0,0 +1,136 @@ +# create-db-cli + +Use `create-db` for instant Prisma Postgres provisioning from the terminal. + +## Priority + +CRITICAL + +## Why It Matters + +`create-db` is the fastest way to get a working Prisma Postgres instance for development, demos, and CI previews. It can also emit machine-readable output and write env variables directly. + +## Commands + +```bash +npx create-db@latest +npx create-db@latest create [options] +npx create-db@latest regions +``` + +Aliases: + +```bash +npx create-pg@latest +npx create-postgres@latest +``` + +## Command discovery (`--help`) + +Always use `--help` first when integrating CLI commands: + +```bash +npx create-db@latest --help +npx create-db@latest create --help +npx create-db@latest regions --help +``` + +Top-level commands currently exposed: + +- `create` (default) to provision a database +- `regions` to list available regions + +## `create` options + +| Flag | Shorthand | Description | +|---|---|---| +| `--region [string]` | `-r` | Region choice: `ap-southeast-1`, `ap-northeast-1`, `eu-central-1`, `eu-west-3`, `us-east-1`, `us-west-1` | +| `--interactive [boolean]` | `-i` | Open region selector | +| `--json [boolean]` | `-j` | Output machine-readable JSON | +| `--env [string]` | `-e` | Write `DATABASE_URL` and `CLAIM_URL` into a target `.env` | +| `--ttl [string]` | `-t` | Auto-delete after a TTL like `30m` or `1h-24h` | +| `--copy [boolean]` | `-c` | Copy the connection string to the clipboard | +| `--quiet [boolean]` | `-q` | Only print the connection string | +| `--open [boolean]` | `-o` | Open the claim URL in your browser | + +## Lifecycle and claim flow + +- Databases are temporary by default. +- Unclaimed databases are auto-deleted after ~24 hours. +- Claim the database using the URL shown in command output to keep it permanently. + +## Programmatic usage (library API) + +You can also use `create-db` programmatically in Node.js/Bun instead of shelling out to the CLI. + +Install: + +```bash +npm install create-db +# or +bun add create-db +``` + +Create a database: + +```ts +import { create, isDatabaseSuccess, isDatabaseError } from "create-db"; + +const result = await create({ + region: "us-east-1", + userAgent: "my-app/1.0.0", +}); + +if (isDatabaseSuccess(result)) { + console.log(result.connectionString); + console.log(result.claimUrl); + console.log(result.deletionDate); +} + +if (isDatabaseError(result)) { + console.error(result.error, result.message); +} +``` + +List regions programmatically: + +```ts +import { regions } from "create-db"; + +const available = await regions(); +console.log(available); +``` + +Programmatic `create()` defaults to `us-east-1` if no region is passed. + +## Common patterns + +```bash +# quick database +npx create-db@latest + +# region-specific database +npx create-db@latest --region eu-central-1 + +# interactive region selection +npx create-db@latest --interactive + +# write env vars for app bootstrap +npx create-db@latest --env .env + +# auto-delete sooner +npx create-db@latest --ttl 2h + +# copy connection string to clipboard +npx create-db@latest --copy + +# print only the connection string +npx create-db@latest --quiet + +# CI-friendly output +npx create-db@latest --json +``` + +## References + +- [npx create-db docs](https://www.prisma.io/docs/postgres/introduction/npx-create-db) diff --git a/.agents/skills/prisma-postgres/references/management-api-sdk.md b/.agents/skills/prisma-postgres/references/management-api-sdk.md new file mode 100644 index 0000000..026aa5f --- /dev/null +++ b/.agents/skills/prisma-postgres/references/management-api-sdk.md @@ -0,0 +1,70 @@ +# management-api-sdk + +Use `@prisma/management-api-sdk` for typed API integration with optional OAuth and token refresh. + +The Platform API evolves independently from Prisma ORM. Inspect the installed package's generated `api.d.ts` for exact paths and request/response shapes. + +## Priority + +HIGH + +## Why It Matters + +The SDK provides typed endpoint methods and removes boilerplate around auth and refresh handling, which reduces errors in production provisioning flows. + +## Install + +```bash +npm install @prisma/management-api-sdk +``` + +## Simple client (existing token) + +```typescript +import { createManagementApiClient } from '@prisma/management-api-sdk' + +const client = createManagementApiClient({ token: process.env.PRISMA_SERVICE_TOKEN! }) +const { data: workspaces } = await client.GET('/v1/workspaces') +``` + +Check the generated client result before using `data`; typed clients surface HTTP failures separately. Never log a full response from connection/key creation because it may contain one-time credentials. + +## Workspace service tokens + +The typed client exposes routes to list, create, and revoke workspace service tokens: + +- `GET /v1/workspaces/{workspaceId}/service-tokens` +- `POST /v1/workspaces/{workspaceId}/service-tokens` +- `DELETE /v1/workspaces/{workspaceId}/service-tokens/{serviceTokenId}` + +Creation accepts a display `name`. The response's `data.value` is the complete token and is returned exactly once; transfer it directly to the intended secret store without logging the response. Later list calls return metadata and `valueHint`, not the token value. Treat revocation as destructive and resolve both ids explicitly. + +## Full SDK (OAuth + refresh) + +```typescript +import { createManagementApiSdk, type TokenStorage } from '@prisma/management-api-sdk' + +const tokenStorage: TokenStorage = { + async getTokens() { return null }, + async setTokens(tokens) {}, + async clearTokens() {}, +} + +const api = createManagementApiSdk({ + clientId: process.env.PRISMA_CLIENT_ID!, + redirectUri: 'https://your-app.com/auth/callback', + tokenStorage, +}) +``` + +## OAuth SDK flow + +1. Call `getLoginUrl()` and persist `state` + `verifier`. +2. Redirect user to login URL. +3. Handle callback with `handleCallback()`. +4. Use `api.client` for typed endpoint calls. +5. Call `logout()` when needed. + +## References + +- [Management API SDK docs](https://www.prisma.io/docs/postgres/introduction/management-api-sdk) diff --git a/.agents/skills/prisma-postgres/references/management-api.md b/.agents/skills/prisma-postgres/references/management-api.md new file mode 100644 index 0000000..4e4e76f --- /dev/null +++ b/.agents/skills/prisma-postgres/references/management-api.md @@ -0,0 +1,79 @@ +# management-api + +Use Prisma Management API for programmatic provisioning and workspace/project/database management. + +## Priority + +CRITICAL + +## Why It Matters + +When you need backend automation, multi-tenant onboarding flows, or controlled resource provisioning, the Management API is the source of truth and is more reliable than interactive workflows. + +## Base URL + +```text +https://api.prisma.io/v1 +``` + +## API exploration + +- OpenAPI docs: `https://api.prisma.io/v1/doc` +- Swagger Editor: `https://api.prisma.io/v1/swagger-editor` + +## Authentication methods + +- Service token: best for server-to-server operations in your own workspace +- OAuth 2.0: best for acting on behalf of users across workspaces + +## Service token flow + +1. Create token in Prisma Console workspace settings. +2. Send token as Bearer auth: + +```text +Authorization: Bearer $TOKEN +``` + +## OAuth flow summary + +1. Redirect user to `https://auth.prisma.io/authorize` with `client_id`, `redirect_uri`, `response_type=code`, and scopes. +2. Receive `code` on callback. +3. Exchange code at `https://auth.prisma.io/token`. +4. Use returned access token in Management API requests. + +## Resource model + +Workspace -> Project -> Branch -> Database. Branches are a first-class resource: databases attach to a Branch, and branch-scoped env/databases are how preview isolation works. + +## Current resource inventory + +The 1.55 OpenAPI surface includes: + +- workspaces, subscriptions, workspace integrations, workspace service tokens, and current-user metadata +- projects, transfers, project databases, and project/branch environment variables +- branches under a project plus branch get/update/delete operations +- databases, usage, backups, restore, connections, and connection rotation +- apps, deployments, promotion/rollback, runtime logs, domains, and build logs +- buckets and bucket keys +- source repositories, SCM installations/install intents, and repositories +- integrations and regions + +App/deployment, branch mutation, SCM, and bucket routes include experimental surfaces. Read the installed SDK types or live OpenAPI before building durable automation around them. + +Connection create/rotate responses reveal credentials once. Later reads redact or omit the secret, so store the URL immediately. Use the structured direct/pooled endpoint returned by the concrete operation; do not assume a historical flat response shape. + +Workspace service-token creation also returns the complete token value exactly once. List calls expose only metadata and a `valueHint`; delete revokes the token. Keep workspace and token ids opaque, and never log a create response. + +Database create supports explicit project, region, branch, and source context. A source may be empty, a backup, or another database. Backup records are incremental; rely on current fields and documented units rather than old full-backup examples. + +## Notes + +- Management API mutation responses may include direct connection credentials; treat the entire response as secret until redacted. +- Prefer an API-provided connection string over manually assembling one from fields. + +## References + +- [Management API docs](https://www.prisma.io/docs/postgres/introduction/management-api) +- [OpenAPI docs](https://api.prisma.io/v1/doc) +- [Swagger Editor](https://api.prisma.io/v1/swagger-editor) diff --git a/.agents/skills/react-hook-form/AGENTS.md b/.agents/skills/react-hook-form/AGENTS.md new file mode 100644 index 0000000..a1e82a3 --- /dev/null +++ b/.agents/skills/react-hook-form/AGENTS.md @@ -0,0 +1,96 @@ +# React Hook Form + +**Version 2.1.0** +Community +July 2026 + +> **Note:** This document targets React Hook Form codebases. +> It is mainly for agents and LLMs to follow when maintaining, generating, or refactoring forms. +> Humans may also find it useful, but guidance here is optimized for automation and consistency +> by AI-assisted workflows. + +--- + +## Abstract + +Focused guide to the React Hook Form decisions a capable model gets wrong. Contains 35 rules across 7 categories, verified against react-hook-form 7.82.0 by diffing the shipped type definitions and type-checking every code block under tsc --strict against the real package types. Covers where a subscription must live to isolate re-renders, the third useForm generic that transforming resolvers require, the options that silently drop data from the submitted payload (register disabled, shouldUnregister, useFieldArray disabled), the NaN that valueAsNumber produces for an empty input, server error handling via setError('root.*'), resetDefaultValues() for rebasing the dirty baseline after a save, and the Watch / FormStateSubscribe / FieldArray render-prop components. Rules that merely restate what the library already does correctly have been removed. + +--- + +## Table of Contents + +1. [Form Configuration](references/_sections.md#1-form-configuration) — **CRITICAL** + - 1.1 [Always Provide defaultValues for Form Initialization](references/formcfg-default-values.md) — CRITICAL (prevents uncontrolled-to-controlled input warnings and a reset() with nothing to restore) + - 1.2 [Depend on formState Slices, Not on formState Itself](references/formcfg-useeffect-dependency.md) — HIGH (prevents effects that re-run on every keystroke) + - 1.3 [Justify Any mode Other Than the Default onSubmit](references/formcfg-validation-mode.md) — CRITICAL (prevents a full validation pass and re-render on every keystroke) + - 1.4 [Keep Default reValidateMode Unless Validation Is Expensive](references/formcfg-revalidate-mode.md) — MEDIUM (maintains immediate corrective feedback after first submit) + - 1.5 [Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload](references/formcfg-should-unregister.md) — HIGH (prevents silently dropping values the user already entered) + - 1.6 [Pass the Third useForm Generic When the Resolver Transforms Values](references/formcfg-transformed-values-generic.md) — CRITICAL (makes handleSubmit receive the schema's output type instead of its input type) + - 1.7 [Use Async defaultValues for Server Data](references/formcfg-async-default-values.md) — CRITICAL (eliminates manual useEffect reset patterns) + - 1.8 [Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option](references/formcfg-disabled-prop.md) — MEDIUM (prevents fields silently missing from submission and skipped validation) + - 1.9 [Use the values Prop to Keep a Form in Sync with Server Data](references/formcfg-values-prop.md) — HIGH (replaces a useEffect+reset that overwrites edits whenever the query refetches) +2. [Field Subscription](references/_sections.md#2-field-subscription) — **CRITICAL** + - 2.1 [Avoid Calling watch() in Render for One-Time Reads](references/sub-avoid-watch-in-render.md) — HIGH (prevents unnecessary subscriptions and re-renders) + - 2.2 [React.memo Cannot Stop Context-Driven Re-renders Under FormProvider](references/sub-memo-cannot-beat-context.md) — MEDIUM (replaces a memo pass that has no effect with isolation that does) + - 2.3 [Use subscribe() to React to Form Changes Outside the React Lifecycle](references/sub-subscribe-outside-react.md) — HIGH (eliminates re-renders for non-UI consumers like analytics, autosave, telemetry) + - 2.4 [Use the Render-Prop Components to Isolate Re-renders Without a Child Component](references/sub-render-prop-components.md) — HIGH (confines a subscription to one subtree without authoring a wrapper component) + - 2.5 [Use useFormContext Sparingly for Deep Nesting](references/sub-useformcontext-sparingly.md) — MEDIUM (reduces prop drilling but increases implicit dependencies) + - 2.6 [Use useWatch Instead of watch for Isolated Re-renders](references/sub-usewatch-over-watch.md) — CRITICAL (confines value-change re-renders to the subscribing component) + - 2.7 [Watch Specific Fields Instead of Entire Form](references/sub-watch-specific-fields.md) — CRITICAL (reduces re-renders from N fields to 1 field change) +3. [Controlled Components](references/_sections.md#3-controlled-components) — **HIGH** + - 3.1 [Isolate Controlled Inputs in Dedicated Child Components](references/ctrl-usecontroller-isolation.md) — HIGH (re-renders only the changed field instead of the whole form) + - 3.2 [Wire Controller Field Props Correctly for UI Libraries](references/ctrl-controller-field-props.md) — HIGH (prevents a control that renders correctly but never writes back to the form) +4. [Validation Patterns](references/_sections.md#4-validation-patterns) — **HIGH** + - 4.1 [Build the Validation Schema Once, Outside the Render Path](references/valid-resolver-caching.md) — HIGH (stops rebuilding the whole schema object on every keystroke) + - 4.2 [Handle the NaN valueAsNumber Produces for an Empty Input](references/valid-valueasnumber-empty-nan.md) — HIGH (prevents an optional number field that can never be left blank) + - 4.3 [Surface Server Errors via setError('root.serverError', ...)](references/valid-server-errors.md) — HIGH (prevents lost server-side validation errors and unrecoverable form state) + - 4.4 [Use delayError to Debounce Rapid Error Display](references/valid-delay-error.md) — MEDIUM (reduces UI flicker during fast typing validation) +5. [State Management](references/_sections.md#5-state-management) — **MEDIUM-HIGH** + - 5.1 [Avoid isValid with onSubmit Mode for Button State](references/formstate-avoid-isvalid-with-onsubmit.md) — MEDIUM (prevents whole-form validation on every change under a deferred-validation mode) + - 5.2 [Read Every formState Property You Depend On During Render](references/formstate-destructure-formstate.md) — MEDIUM (prevents a component that never re-renders when the state it shows changes) + - 5.3 [Rebase Defaults with resetDefaultValues After a Successful Save](references/formstate-reset-default-values.md) — HIGH (clears isDirty without discarding edits made during the in-flight request) + - 5.4 [Use handleSubmit's Second Argument to Handle a Rejected Submit](references/formstate-handlesubmit-oninvalid.md) — MEDIUM (gives a failed submit somewhere to go instead of silently doing nothing) + - 5.5 [Use useFormState for Isolated State Subscriptions](references/formstate-useformstate-isolation.md) — MEDIUM (prevents parent re-renders from state access in children) + - 5.6 [Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful](references/formstate-async-submit-lifecycle.md) — HIGH (prevents stuck isSubmitting state and missing post-success reset) +6. [Field Arrays](references/_sections.md#6-field-arrays) — **MEDIUM-HIGH** + - 6.1 [Separate Sequential Field Array Operations](references/array-separate-crud-operations.md) — MEDIUM-HIGH (prevents state corruption from batched mutations) + - 6.2 [Use field.id as Key in useFieldArray Maps](references/array-use-field-id-as-key.md) — MEDIUM-HIGH (prevents state corruption and unnecessary re-renders) + - 6.3 [Use Single useFieldArray Instance Per Field Name](references/array-unique-fieldarray-per-name.md) — MEDIUM-HIGH (prevents state conflicts from duplicate subscriptions) + - 6.4 [useFieldArray's disabled Option Makes Every Mutation a Silent No-op](references/array-disabled-silently-noops.md) — MEDIUM-HIGH (prevents append/remove calls that vanish with no error or warning) +7. [Integration Patterns](references/_sections.md#7-integration-patterns) — **MEDIUM** + - 7.1 [Transform Values at Controller Level for Type Coercion](references/integ-value-transform.md) — MEDIUM (stops string input values reaching a number- or date-typed schema) + - 7.2 [Verify shadcn Form Component Import Source](references/integ-shadcn-form-import.md) — MEDIUM (prevents silent component mismatch bugs) + - 7.3 [Wire shadcn Select with onValueChange Instead of Spread](references/integ-shadcn-select-wiring.md) — MEDIUM (prevents a Radix Select that renders but never writes to the form) + +--- + +## References + +1. [https://react-hook-form.com/docs](https://react-hook-form.com/docs) +2. [https://react-hook-form.com/advanced-usage](https://react-hook-form.com/advanced-usage) +3. [https://react-hook-form.com/docs/useform](https://react-hook-form.com/docs/useform) +4. [https://react-hook-form.com/docs/useform/subscribe](https://react-hook-form.com/docs/useform/subscribe) +5. [https://react-hook-form.com/docs/useform/seterror](https://react-hook-form.com/docs/useform/seterror) +6. [https://react-hook-form.com/docs/useform/setvalue](https://react-hook-form.com/docs/useform/setvalue) +7. [https://react-hook-form.com/docs/useform/resetdefaultvalues](https://react-hook-form.com/docs/useform/resetdefaultvalues) +8. [https://react-hook-form.com/docs/useform/formstate](https://react-hook-form.com/docs/useform/formstate) +9. [https://react-hook-form.com/docs/usewatch](https://react-hook-form.com/docs/usewatch) +10. [https://react-hook-form.com/docs/usecontroller](https://react-hook-form.com/docs/usecontroller) +11. [https://react-hook-form.com/docs/usefieldarray](https://react-hook-form.com/docs/usefieldarray) +12. [https://react-hook-form.com/docs/useformstate](https://react-hook-form.com/docs/useformstate) +13. [https://github.com/react-hook-form/react-hook-form/releases](https://github.com/react-hook-form/react-hook-form/releases) +14. [https://github.com/react-hook-form/resolvers](https://github.com/react-hook-form/resolvers) +15. [https://ui.shadcn.com/docs/components/form](https://ui.shadcn.com/docs/components/form) + +--- + +## Source Files + +This document was compiled from individual reference files. For detailed editing or extension: + +| File | Description | +|------|-------------| +| [references/_sections.md](references/_sections.md) | Category definitions and impact ordering | +| [assets/templates/_template.md](assets/templates/_template.md) | Template for creating new rules | +| [SKILL.md](SKILL.md) | Quick reference entry point | +| [metadata.json](metadata.json) | Version and reference URLs | \ No newline at end of file diff --git a/.agents/skills/react-hook-form/README.md b/.agents/skills/react-hook-form/README.md new file mode 100644 index 0000000..be038dc --- /dev/null +++ b/.agents/skills/react-hook-form/README.md @@ -0,0 +1,126 @@ +# React Hook Form Best Practices Skill + +Corrective guidance for React Hook Form — the decisions that go wrong by default. + +## Overview + +This skill provides 35 rules across 7 categories. Every rule names a decision that a capable model gets wrong by default; rules that merely restated correct library behaviour were removed in 2.0.0. Verified against **react-hook-form 7.82.0** (July 2026), with every code block type-checked under `tsc --strict` against the real package types. + +### Directory Structure + +``` +react-hook-form/ +├── SKILL.md # Entry point with quick reference +├── AGENTS.md # Compiled comprehensive guide (TOC over references/) +├── metadata.json # Version, org, references +├── README.md # This file +├── assets/ +│ └── templates/ +│ └── _template.md # Rule template +└── references/ + ├── _sections.md # Category definitions and impact ordering + ├── formcfg-*.md # Form configuration rules (9) + ├── sub-*.md # Field subscription rules (7) + ├── ctrl-*.md # Controlled component rules (2) + ├── valid-*.md # Validation pattern rules (4) + ├── formstate-*.md # State management rules (6) + ├── array-*.md # Field array rules (4) + └── integ-*.md # Integration pattern rules (3) +``` + +## Validating + +This skill is validated by the repo-level tooling, not by per-skill scripts: + +```bash +npm run validate # structural validation of every skill +node /path/to/dev-skill/scripts/validate-skill.js . # discipline-aware validation of this skill +``` + +## Creating a New Rule + +1. Choose the appropriate category prefix: + +| Category | Prefix | Impact | +|----------|--------|--------| +| Form Configuration | `formcfg-` | CRITICAL | +| Field Subscription | `sub-` | CRITICAL | +| Controlled Components | `ctrl-` | HIGH | +| Validation Patterns | `valid-` | HIGH | +| State Management | `formstate-` | MEDIUM-HIGH | +| Field Arrays | `array-` | MEDIUM-HIGH | +| Integration Patterns | `integ-` | MEDIUM | + +2. Create a new file: `references/{prefix}-{slug}.md` + +3. Use the rule template from `assets/templates/_template.md` + +4. Add the rule to the SKILL.md quick reference and to the AGENTS.md table of contents (entries are ordered alphabetically by title within each section). + +## Rule File Structure + +```markdown +--- +title: Rule Title Here +impact: CRITICAL|HIGH|MEDIUM-HIGH|MEDIUM|LOW-MEDIUM|LOW +impactDescription: What the rule prevents or enables +tags: prefix, keyword1, keyword2 +--- + +## Rule Title Here + +Brief explanation of WHY this matters (1-3 sentences). + +**Incorrect (description of problem):** + +\`\`\`typescript +// Bad code with comment on key line +\`\`\` + +**Correct (description of solution):** + +\`\`\`typescript +// Good code with minimal diff from incorrect +\`\`\` + +Reference: [Documentation Link](https://example.com) +``` + +## File Naming Convention + +Rules follow the pattern: `{prefix}-{slug}.md` + +- **prefix**: Category identifier from `_sections.md` (lowercase letters only) +- **slug**: Kebab-case description of the rule + +Examples: +- `formcfg-validation-mode.md` +- `sub-usewatch-over-watch.md` +- `ctrl-usecontroller-isolation.md` + +## Impact Levels + +| Level | Description | +|-------|-------------| +| CRITICAL | Cascade effect on entire form performance | +| HIGH | Significant impact on specific operations | +| MEDIUM-HIGH | Notable improvement for common patterns | +| MEDIUM | Measurable improvement in specific scenarios | +| LOW-MEDIUM | Minor optimization for edge cases | +| LOW | Best practice with minimal performance impact | + +## Companion Skill + +`react-hook-form-audit` is a static-analysis skill that detects violations of these rules in a Next.js App Router codebase and links each finding back to the corresponding file in `references/`. + +## Contributing + +1. Read existing rules in the same category for style consistency +2. Ensure incorrect/correct examples have minimal diff +3. Typecheck every example against the pinned react-hook-form version before committing +4. Include authoritative reference links +5. Run validation before submitting + +## Acknowledgments + +Based on official React Hook Form documentation, the library's shipped type definitions, and community best practices. diff --git a/.agents/skills/react-hook-form/SKILL.md b/.agents/skills/react-hook-form/SKILL.md new file mode 100644 index 0000000..dcf50ed --- /dev/null +++ b/.agents/skills/react-hook-form/SKILL.md @@ -0,0 +1,113 @@ +--- +name: react-hook-form +description: React Hook Form performance optimization for client-side form validation using useForm, useWatch, useController, useFieldArray, the subscribe() API, and the Watch / FormStateSubscribe / FieldArray render-prop components. Covers RHF 7.82 additions including resetDefaultValues() and the disabled field-array option. This skill should be used when building client-side controlled forms with React Hook Form library. This skill does NOT cover React 19 Server Actions, useActionState, or server-side form handling (use react-19 skill for those). +--- + +# React Hook Form Best Practices by Community + +Comprehensive performance optimization guide for React Hook Form applications. Contains 35 rules across 7 categories, each naming a decision that goes wrong by default. Verified against react-hook-form **7.82.0**. + +## When to Apply + +Reference these guidelines when: +- Writing new forms with React Hook Form +- Configuring useForm options (mode, defaultValues, validation) +- Subscribing to form values with watch / useWatch / subscribe +- Integrating controlled UI components (MUI, shadcn, Ant Design) +- Managing dynamic field arrays with useFieldArray +- Handling async submit, server errors, and submit lifecycle state +- Reviewing forms for performance issues + +## When NOT to Use This Skill + +- **React 19 Server Actions / `useActionState`** — use the `react-19` skill instead +- **Deeply nested, fully type-safe forms** — TanStack Form may be a better fit for forms with complex nested schemas; this skill assumes you've already chosen RHF +- **Single-input or trivial forms** — uncontrolled `
    ` + `FormData` is often simpler than pulling in any library + +## Rule Categories by Priority + +| Priority | Category | Impact | Prefix | +|----------|----------|--------|--------| +| 1 | Form Configuration | CRITICAL | `formcfg-` | +| 2 | Field Subscription | CRITICAL | `sub-` | +| 3 | Controlled Components | HIGH | `ctrl-` | +| 4 | Validation Patterns | HIGH | `valid-` | +| 5 | State Management | MEDIUM-HIGH | `formstate-` | +| 6 | Field Arrays | MEDIUM-HIGH | `array-` | +| 7 | Integration Patterns | MEDIUM | `integ-` | + +## Quick Reference + +### 1. Form Configuration (CRITICAL) + +- `formcfg-default-values` - Always Provide defaultValues for Form Initialization +- `formcfg-useeffect-dependency` - Depend on formState Slices, Not on formState Itself +- `formcfg-validation-mode` - Justify Any mode Other Than the Default onSubmit +- `formcfg-revalidate-mode` - Keep Default reValidateMode Unless Validation Is Expensive +- `formcfg-should-unregister` - Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload +- `formcfg-transformed-values-generic` - Pass the Third useForm Generic When the Resolver Transforms Values +- `formcfg-async-default-values` - Use Async defaultValues for Server Data +- `formcfg-disabled-prop` - Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option +- `formcfg-values-prop` - Use the values Prop to Keep a Form in Sync with Server Data + +### 2. Field Subscription (CRITICAL) + +- `sub-avoid-watch-in-render` - Avoid Calling watch() in Render for One-Time Reads +- `sub-memo-cannot-beat-context` - React.memo Cannot Stop Context-Driven Re-renders Under FormProvider +- `sub-subscribe-outside-react` - Use subscribe() to React to Form Changes Outside the React Lifecycle +- `sub-render-prop-components` - Use the Render-Prop Components to Isolate Re-renders Without a Child Component +- `sub-useformcontext-sparingly` - Use useFormContext Sparingly for Deep Nesting +- `sub-usewatch-over-watch` - Use useWatch Instead of watch for Isolated Re-renders +- `sub-watch-specific-fields` - Watch Specific Fields Instead of Entire Form + +### 3. Controlled Components (HIGH) + +- `ctrl-usecontroller-isolation` - Isolate Controlled Inputs in Dedicated Child Components +- `ctrl-controller-field-props` - Wire Controller Field Props Correctly for UI Libraries + +### 4. Validation Patterns (HIGH) + +- `valid-resolver-caching` - Build the Validation Schema Once, Outside the Render Path +- `valid-valueasnumber-empty-nan` - Handle the NaN valueAsNumber Produces for an Empty Input +- `valid-server-errors` - Surface Server Errors via setError('root.serverError', ...) +- `valid-delay-error` - Use delayError to Debounce Rapid Error Display + +### 5. State Management (MEDIUM-HIGH) + +- `formstate-avoid-isvalid-with-onsubmit` - Avoid isValid with onSubmit Mode for Button State +- `formstate-destructure-formstate` - Read Every formState Property You Depend On During Render +- `formstate-reset-default-values` - Rebase Defaults with resetDefaultValues After a Successful Save +- `formstate-handlesubmit-oninvalid` - Use handleSubmit's Second Argument to Handle a Rejected Submit +- `formstate-useformstate-isolation` - Use useFormState for Isolated State Subscriptions +- `formstate-async-submit-lifecycle` - Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful + +### 6. Field Arrays (MEDIUM-HIGH) + +- `array-separate-crud-operations` - Separate Sequential Field Array Operations +- `array-use-field-id-as-key` - Use field.id as Key in useFieldArray Maps +- `array-unique-fieldarray-per-name` - Use Single useFieldArray Instance Per Field Name +- `array-disabled-silently-noops` - useFieldArray's disabled Option Makes Every Mutation a Silent No-op + +### 7. Integration Patterns (MEDIUM) + +- `integ-value-transform` - Transform Values at Controller Level for Type Coercion +- `integ-shadcn-form-import` - Verify shadcn Form Component Import Source +- `integ-shadcn-select-wiring` - Wire shadcn Select with onValueChange Instead of Spread + +## How to Use + +Read individual reference files for detailed explanations and code examples: + +- [Section definitions](references/_sections.md) - Category structure and impact levels +- [Rule template](assets/templates/_template.md) - Template for adding new rules +- Reference files: `references/{prefix}-{slug}.md` + +## Related Skills + +- For schema validation with Zod resolver, see `zod` skill +- For React 19 server actions, see `react-19` skill +- For UI/UX form design, see `frontend-design` skill + +## Full Compiled Document + +For the complete guide with all rules expanded: `AGENTS.md` diff --git a/.agents/skills/react-hook-form/assets/templates/_template.md b/.agents/skills/react-hook-form/assets/templates/_template.md new file mode 100644 index 0000000..4d0a698 --- /dev/null +++ b/.agents/skills/react-hook-form/assets/templates/_template.md @@ -0,0 +1,26 @@ +--- +title: Rule Title Here +impact: MEDIUM +impactDescription: Optional description of impact (e.g., "20-50% improvement") +tags: prefix, tag1, tag2 +--- + +## Rule Title Here + +Brief explanation of the rule and why it matters. This should be clear and concise, explaining the performance implications. + +**Incorrect (description of what's wrong):** + +```typescript +// Bad code example here +const bad = example() +``` + +**Correct (description of what's right):** + +```typescript +// Good code example here +const good = example() +``` + +Reference: [Link to documentation or resource](https://example.com) diff --git a/.agents/skills/react-hook-form/references/_sections.md b/.agents/skills/react-hook-form/references/_sections.md new file mode 100644 index 0000000..0d9d723 --- /dev/null +++ b/.agents/skills/react-hook-form/references/_sections.md @@ -0,0 +1,41 @@ +# Sections + +This file defines all sections, their ordering, impact levels, and descriptions. +The section ID (in parentheses) is the filename prefix used to group rules. + +--- + +## 1. Form Configuration (formcfg) + +**Impact:** CRITICAL +**Description:** Initial useForm setup determines validation timing, re-render boundaries, and what ends up in the submitted payload. The wrong mode validates on every keystroke; the wrong disabled or shouldUnregister setting silently drops data. + +## 2. Field Subscription (sub) + +**Impact:** CRITICAL +**Description:** Where a subscription lives decides how much of the tree re-renders. Reading a value at the form root instead of in the leaf that displays it is the difference between re-rendering the whole form and re-rendering one field. + +## 3. Controlled Components (ctrl) + +**Impact:** HIGH +**Description:** Controller and useController isolate re-renders only when they sit in a child component. Wiring their field props to a third-party control also has to match that library's prop names, or the input renders but never writes back. + +## 4. Validation Patterns (valid) + +**Impact:** HIGH +**Description:** Where the schema is constructed, how server-side failures re-enter the form, and how error display is paced. Building a schema inside render pays the construction cost on every keystroke. + +## 5. State Management (formstate) + +**Impact:** MEDIUM-HIGH +**Description:** formState is a per-property Proxy, so a value you never read during render is a value you never re-render for. Submit lifecycle belongs here too: an async handler that throws without a catch strands isSubmitting forever. + +## 6. Field Arrays (array) + +**Impact:** MEDIUM-HIGH +**Description:** Dynamic field management requires stable keys and one owner per field name. Some options — notably disabled — fail silently rather than loudly. + +## 7. Integration Patterns (integ) + +**Impact:** MEDIUM +**Description:** Third-party UI library integration (shadcn/Radix, MUI) requires specific wiring, and native inputs need explicit type coercion before values reach a typed schema. diff --git a/.agents/skills/react-hook-form/references/array-disabled-silently-noops.md b/.agents/skills/react-hook-form/references/array-disabled-silently-noops.md new file mode 100644 index 0000000..179ff73 --- /dev/null +++ b/.agents/skills/react-hook-form/references/array-disabled-silently-noops.md @@ -0,0 +1,56 @@ +--- +title: useFieldArray's disabled Option Makes Every Mutation a Silent No-op +impact: MEDIUM-HIGH +impactDescription: prevents append/remove calls that vanish with no error or warning +tags: array, useFieldArray, disabled, read-only, no-op +--- + +## useFieldArray's disabled Option Makes Every Mutation a Silent No-op + +`useFieldArray({ disabled })` (RHF 7.79+) is not a UI hint. When `disabled` is truthy, `append`, `prepend`, `insert`, `remove`, `swap`, `move`, `update`, and `replace` each return immediately — no mutation, no error, no console warning. Reaching for it as "grey the rows out while saving" produces a form where clicking Add does nothing and there is nothing in the console to explain why. + +**Incorrect (`disabled` tied to submit state — the append silently disappears):** + +```typescript +function TeamMembersFields({ control }: { control: Control }) { + const { isSubmitting } = useFormState({ control }) + const { fields, append, remove } = useFieldArray({ + control, + name: 'members', + disabled: isSubmitting, // Also kills append/remove, not just the visuals + }) + + return ( + <> + {fields.map((field, index) => ( + remove(index)} /> + ))} + + + ) +} +``` + +**Correct (disable the controls; reserve the option for genuinely read-only arrays):** + +```typescript +function TeamMembersFields({ control }: { control: Control }) { + const { isSubmitting } = useFormState({ control }) + const { fields, append, remove } = useFieldArray({ control, name: 'members' }) + + return ( + <> + {fields.map((field, index) => ( + remove(index)} disabled={isSubmitting} /> + ))} + + + ) +} +``` + +Use `disabled: true` when the array is structurally immutable for this user — a locked invoice, a plan the current role may not edit — where a mutation slipping through would be a bug. In that case `fields[index].disabled` (7.80+) carries the flag down to each row so the inputs can render disabled from the same source of truth. + +Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray) diff --git a/.agents/skills/react-hook-form/references/array-separate-crud-operations.md b/.agents/skills/react-hook-form/references/array-separate-crud-operations.md new file mode 100644 index 0000000..9182fb2 --- /dev/null +++ b/.agents/skills/react-hook-form/references/array-separate-crud-operations.md @@ -0,0 +1,76 @@ +--- +title: Separate Sequential Field Array Operations +impact: MEDIUM-HIGH +impactDescription: prevents state corruption from batched mutations +tags: array, useFieldArray, append, remove, sequential +--- + +## Separate Sequential Field Array Operations + +Chaining `append()` and `remove()` in the same handler can cause state corruption. Defer removals to a useEffect or separate user action to allow React to process renders between operations. + +**Incorrect (stacked operations cause state issues):** + +```typescript +function ReplaceItemForm() { + const { control } = useForm() + const { fields, append, remove } = useFieldArray({ control, name: 'items' }) + + const replaceItem = (indexToReplace: number, newItem: Item) => { + remove(indexToReplace) // Remove old item + append(newItem) // Immediately add new - state may be stale + } + + return ( +
    + {fields.map((field, index) => ( + replaceItem(index, newItem)} + /> + ))} +
    + ) +} +``` + +**Correct (use update for replacements, or defer operations):** + +```typescript +function ReplaceItemForm() { + const { control } = useForm() + const { fields, update } = useFieldArray({ control, name: 'items' }) + + const replaceItem = (indexToReplace: number, newItem: Item) => { + update(indexToReplace, newItem) // Single atomic operation + } + + return ( +
    + {fields.map((field, index) => ( + replaceItem(index, newItem)} + /> + ))} +
    + ) +} +``` + +**Alternative (defer removal with useEffect):** + +```typescript +const [pendingRemoval, setPendingRemoval] = useState(null) + +useEffect(() => { + if (pendingRemoval !== null) { + remove(pendingRemoval) + setPendingRemoval(null) + } +}, [pendingRemoval, remove]) +``` + +Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray) diff --git a/.agents/skills/react-hook-form/references/array-unique-fieldarray-per-name.md b/.agents/skills/react-hook-form/references/array-unique-fieldarray-per-name.md new file mode 100644 index 0000000..db00867 --- /dev/null +++ b/.agents/skills/react-hook-form/references/array-unique-fieldarray-per-name.md @@ -0,0 +1,58 @@ +--- +title: Use Single useFieldArray Instance Per Field Name +impact: MEDIUM-HIGH +impactDescription: prevents state conflicts from duplicate subscriptions +tags: array, useFieldArray, instance, state-management +--- + +## Use Single useFieldArray Instance Per Field Name + +Each field name should have only one useFieldArray instance. Multiple instances managing the same field name cause state conflicts and unpredictable behavior. + +**Incorrect (multiple instances for same field):** + +```typescript +function OrderForm() { + const { control } = useForm() + + return ( +
    + + +
    + ) +} + +function ItemsList({ control }: { control: Control }) { + const { fields, append } = useFieldArray({ control, name: 'items' }) // Instance 1 + return
    {/* render items */}
    +} + +function ItemsSummary({ control }: { control: Control }) { + const { fields } = useFieldArray({ control, name: 'items' }) // Instance 2 - conflicts! + return
    Total items: {fields.length}
    +} +``` + +**Correct (single instance, pass fields down or use useWatch):** + +```typescript +function OrderForm() { + const { control } = useForm() + const { fields, append, remove } = useFieldArray({ control, name: 'items' }) + + return ( +
    + + {/* Uses useWatch, not useFieldArray */} +
    + ) +} + +function ItemsSummary({ control }: { control: Control }) { + const items = useWatch({ control, name: 'items' }) // Read-only subscription + return
    Total items: {items?.length ?? 0}
    +} +``` + +Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray) diff --git a/.agents/skills/react-hook-form/references/array-use-field-id-as-key.md b/.agents/skills/react-hook-form/references/array-use-field-id-as-key.md new file mode 100644 index 0000000..f9241df --- /dev/null +++ b/.agents/skills/react-hook-form/references/array-use-field-id-as-key.md @@ -0,0 +1,56 @@ +--- +title: Use field.id as Key in useFieldArray Maps +impact: MEDIUM-HIGH +impactDescription: prevents state corruption and unnecessary re-renders +tags: array, useFieldArray, key, react-key +--- + +## Use field.id as Key in useFieldArray Maps + +useFieldArray generates a unique `id` for each field. Using array index as key causes React to lose track of component identity when items are reordered, removed, or inserted. + +**Incorrect (index as key causes state corruption):** + +```typescript +function IngredientsForm() { + const { control, register } = useForm() + const { fields, append, remove } = useFieldArray({ control, name: 'ingredients' }) + + return ( +
    + {fields.map((field, index) => ( +
    {/* Index key causes re-render issues */} + + +
    + ))} + +
    + ) +} +``` + +**Correct (field.id ensures stable identity):** + +```typescript +function IngredientsForm() { + const { control, register } = useForm() + const { fields, append, remove } = useFieldArray({ control, name: 'ingredients' }) + + return ( +
    + {fields.map((field, index) => ( +
    {/* Stable identity across operations */} + + +
    + ))} + +
    + ) +} +``` + +**Forward compatibility:** `field.id` is correct for all of v7. The v8 beta line renames the generated render key to `field.key` and drops the `keyName` option, so `id` becomes an ordinary data property that no longer guarantees uniqueness. Do not pre-emptively switch on v7 — but if you set `keyName` to something custom today, that is the piece with no v8 equivalent. + +Reference: [useFieldArray](https://react-hook-form.com/docs/usefieldarray) diff --git a/.agents/skills/react-hook-form/references/ctrl-controller-field-props.md b/.agents/skills/react-hook-form/references/ctrl-controller-field-props.md new file mode 100644 index 0000000..5b5b349 --- /dev/null +++ b/.agents/skills/react-hook-form/references/ctrl-controller-field-props.md @@ -0,0 +1,56 @@ +--- +title: Wire Controller Field Props Correctly for UI Libraries +impact: HIGH +impactDescription: prevents a control that renders correctly but never writes back to the form +tags: ctrl, Controller, field-props, ui-libraries +--- + +## Wire Controller Field Props Correctly for UI Libraries + +Different UI libraries expect different prop names. Map Controller's field props correctly: `onChange` sends data back, `onBlur` reports interaction, `value` sets the display, `ref` enables focus on error. + +**Incorrect (spreading field on incompatible component):** + +```typescript +function FormWithSelect({ control }: { control: Control }) { + return ( + ( + + United States + United Kingdom + + )} + /> + ) +} +``` + +**Common mappings by library:** +- MUI Select: `value`, `onChange` (receives event) +- Radix/shadcn Select: `value`, `onValueChange` (receives value directly) +- React Select: `value`, `onChange` (receives option object) + +Reference: [useController](https://react-hook-form.com/docs/usecontroller) diff --git a/.agents/skills/react-hook-form/references/ctrl-usecontroller-isolation.md b/.agents/skills/react-hook-form/references/ctrl-usecontroller-isolation.md new file mode 100644 index 0000000..e5e7ae2 --- /dev/null +++ b/.agents/skills/react-hook-form/references/ctrl-usecontroller-isolation.md @@ -0,0 +1,80 @@ +--- +title: Isolate Controlled Inputs in Dedicated Child Components +impact: HIGH +impactDescription: re-renders only the changed field instead of the whole form +tags: ctrl, useController, Controller, controlled-components, re-renders +--- + +## Isolate Controlled Inputs in Dedicated Child Components + +`Controller` and `useController` are equivalent — `Controller` is a thin component wrapper around `useController`. Re-render isolation does **not** come from picking one over the other. It comes from putting the subscription in a **child component**, so that when the field value changes, only the child re-renders. Inlining `Controller` (or `useController`) in the parent form makes every parent re-render flow through every controlled input. + +**Incorrect (Controllers inlined in parent — every parent re-render re-renders all controlled inputs):** + +```typescript +function PaymentForm() { + const { control, handleSubmit } = useForm() + + return ( + + } + /> + } + /> + + ) +} +``` + +**Correct (subscription moved into dedicated child components, isolating re-renders to the changed field):** + +```typescript +function PaymentForm() { + const { control, handleSubmit } = useForm() + + return ( +
    + + + + ) +} + +function AmountInput({ control }: { control: Control }) { + const { field } = useController({ name: 'amount', control }) + return +} + +function CurrencySelectField({ control }: { control: Control }) { + const { field } = useController({ name: 'currency', control }) + return +} +``` + +**Equivalent with `Controller` (also correct — same isolation):** + +```typescript +function AmountField({ control }: { control: Control }) { + return ( + } + /> + ) +} +``` + +**When to prefer one API over the other:** +- `useController` — when you also need `fieldState`/`formState` in the same component, or want to compose with custom logic +- `Controller` — when you want a single JSX-only declaration and don't need to read state in the surrounding component + +Both achieve the same re-render isolation when placed in a child component. + +Reference: [useController](https://react-hook-form.com/docs/usecontroller) · [Controller](https://react-hook-form.com/docs/usecontroller/controller) diff --git a/.agents/skills/react-hook-form/references/formcfg-async-default-values.md b/.agents/skills/react-hook-form/references/formcfg-async-default-values.md new file mode 100644 index 0000000..edd42cd --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-async-default-values.md @@ -0,0 +1,69 @@ +--- +title: Use Async defaultValues for Server Data +impact: CRITICAL +impactDescription: eliminates manual useEffect reset patterns +tags: formcfg, async, default-values, data-fetching +--- + +## Use Async defaultValues for Server Data + +React Hook Form supports async functions for `defaultValues`, eliminating the need for manual useEffect + reset() patterns when loading initial data from an API. + +**Incorrect (manual useEffect reset pattern):** + +```typescript +function EditUserForm({ userId }: { userId: string }) { + const { register, reset, handleSubmit, formState: { isLoading } } = useForm({ + defaultValues: { + email: '', + name: '', + }, + }) + + useEffect(() => { + async function loadUser() { + const user = await fetchUser(userId) + reset(user) // Manual reset required + } + loadUser() + }, [userId, reset]) + + if (isLoading) return + + return ( +
    + + +
    + ) +} +``` + +**Correct (async defaultValues handles loading automatically):** + +```typescript +function EditUserForm({ userId }: { userId: string }) { + const { register, handleSubmit, formState: { isLoading } } = useForm({ + defaultValues: async () => { + const user = await fetchUser(userId) + return { + email: user.email, + name: user.name, + } + }, + }) + + if (isLoading) return + + return ( +
    + + +
    + ) +} +``` + +**Note:** defaultValues are cached after initial load. Use `reset()` with new values if you need to refresh data. + +Reference: [useForm - defaultValues](https://react-hook-form.com/docs/useform) diff --git a/.agents/skills/react-hook-form/references/formcfg-default-values.md b/.agents/skills/react-hook-form/references/formcfg-default-values.md new file mode 100644 index 0000000..7e6f5e4 --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-default-values.md @@ -0,0 +1,58 @@ +--- +title: Always Provide defaultValues for Form Initialization +impact: CRITICAL +impactDescription: prevents uncontrolled-to-controlled input warnings and a reset() with nothing to restore +tags: formcfg, default-values, initialization, useForm +--- + +## Always Provide defaultValues for Form Initialization + +`useForm()` with no `defaultValues` starts every field as `undefined`. Three things break at once: any controlled input flips from uncontrolled to controlled on first keystroke (React logs a warning and can lose the value), `reset()` with no arguments has no baseline to restore to, and `isDirty`/`dirtyFields` compare against nothing so the form reads as dirty the moment anything is touched. Provide the full shape up front, using empty strings rather than `undefined`. + +**Incorrect (no defaultValues — reset() has no baseline, inputs start uncontrolled):** + +```typescript +function ProfileForm({ user }: { user: User }) { + const { register, reset, handleSubmit } = useForm() + + useEffect(() => { + reset(user) + }, [user, reset]) + + return ( +
    + + + +
    + ) +} +``` + +**Correct (explicit defaults — reset() restores them, isDirty is meaningful):** + +```typescript +function ProfileForm({ user }: { user: User }) { + const { register, reset, handleSubmit } = useForm({ + defaultValues: { firstName: '', lastName: '' }, + }) + + useEffect(() => { + reset(user) + }, [user, reset]) + + return ( +
    + + + +
    + ) +} +``` + +When the defaults come from the server, pass them directly rather than defaulting-then-resetting — see `formcfg-async-default-values`. After a successful save, move the baseline with `resetDefaultValues` rather than `reset` (see `formstate-reset-default-values`). + +**Note:** Avoid custom objects with prototype methods (Moment, Luxon) as defaultValues — RHF deep-clones them. Use plain objects or primitives. + +Reference: [useForm - defaultValues](https://react-hook-form.com/docs/useform) diff --git a/.agents/skills/react-hook-form/references/formcfg-disabled-prop.md b/.agents/skills/react-hook-form/references/formcfg-disabled-prop.md new file mode 100644 index 0000000..606c34e --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-disabled-prop.md @@ -0,0 +1,83 @@ +--- +title: Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option +impact: MEDIUM +impactDescription: prevents fields silently missing from submission and skipped validation +tags: formcfg, register, disabled, validation, footgun +--- + +## Use the HTML disabled Attribute for Visual Disabling, Not register's disabled Option + +Passing `disabled: true` to `register` (or to `useController`/`Controller`) tells RHF the field is "not part of submission": `handleSubmit` deletes the field from the values object it hands your handler, and validation for that field is skipped. It is **not** the same as `` for purely visual disabling. If you only want the input greyed out, use the plain HTML attribute. + +The value itself is **not** destroyed — `handleSubmit` unsets disabled names from a *clone* of the form values, so `getValues('promoCode')` still returns what the user typed and re-enabling the field brings it back into the payload. The bug this causes is a field that quietly vanishes from your submit handler while the UI still shows a value in it. + +**Incorrect (using register's disabled option for visual disabling — promoCode silently disappears from the submitted payload):** + +```typescript +function CheckoutForm() { + const [usingGiftCard, setUsingGiftCard] = useState(false) + const { register, handleSubmit } = useForm({ + defaultValues: { promoCode: '', giftCardCode: '' }, + }) + + return ( +
    + + + +
    + ) +} +``` + +**Correct (use HTML disabled for visual-only disable; use register's disabled only when intentionally excluding the field):** + +```typescript +function CheckoutForm() { + const [usingGiftCard, setUsingGiftCard] = useState(false) + const { register, handleSubmit, watch } = useForm({ + defaultValues: { promoCode: '', giftCardCode: '', useShippingForBilling: true, billingAddress: '' }, + }) + const useShippingForBilling = watch('useShippingForBilling') + + return ( +
    + + + {/* Visual disable only: value stays in form state, validation still runs */} + + + + {/* Intentional exclusion: when checked, billingAddress is omitted from submission */} + + +
    + ) +} +``` + +**Rule of thumb:** +- Want the field greyed out but still submitted/validated → use the HTML `disabled` attribute directly on the input +- Want the field excluded from submission and validation → use `register('name', { disabled: true })` + +If a value is disappearing from your submit handler, check for this option before suspecting `getValues()` — the two disagree by design. + +Reference: [register - disabled](https://react-hook-form.com/docs/useform/register) diff --git a/.agents/skills/react-hook-form/references/formcfg-revalidate-mode.md b/.agents/skills/react-hook-form/references/formcfg-revalidate-mode.md new file mode 100644 index 0000000..9b917cb --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-revalidate-mode.md @@ -0,0 +1,58 @@ +--- +title: Keep Default reValidateMode Unless Validation Is Expensive +impact: MEDIUM +impactDescription: maintains immediate corrective feedback after first submit +tags: formcfg, revalidate-mode, validation, useForm +--- + +## Keep Default reValidateMode Unless Validation Is Expensive + +After the first submit, `reValidateMode` controls when fields re-validate. The default is `onChange`, which gives users immediate positive feedback the moment they fix an error — this is the recommended UX in most cases ("don't eagerly scold, but eagerly reward"). Only switch to `onBlur` or `onSubmit` when validation is genuinely expensive (async checks, large schemas, heavy regex on long inputs). + +**Incorrect (switching reValidateMode to onBlur for a cheap synchronous schema):** + +```typescript +function CheckoutForm() { + const { register, handleSubmit } = useForm({ + mode: 'onSubmit', + reValidateMode: 'onBlur', // Hurts UX: user fixes a wrong CVV and gets no feedback until blur + resolver: zodResolver(cheapSyncSchema), + }) + + return ( +
    + + +
    + ) +} +``` + +**Correct (default onChange revalidation; switch only when validation is genuinely expensive):** + +```typescript +function CheckoutForm() { + const { register, handleSubmit } = useForm({ + mode: 'onSubmit', + // reValidateMode: 'onChange' is the default — leave it for immediate feedback on correction. + // Switch to 'onBlur' only if you have an async check or >16ms-per-keystroke validation cost. + resolver: zodResolver(cheapSyncSchema), + }) + + return ( +
    + + +
    + ) +} +``` + +**When to deviate from the default:** +- Validation involves a network call or expensive computation (>16ms per keystroke) +- The form has dozens of fields and post-submit re-render cost is measurable in profiling +- The error message is purely informational, not correctable in real time + +Otherwise keep `onChange` — users who just fixed an error get instant validation success, which is the UX the RHF defaults are tuned for. + +Reference: [useForm - reValidateMode](https://react-hook-form.com/docs/useform) diff --git a/.agents/skills/react-hook-form/references/formcfg-should-unregister.md b/.agents/skills/react-hook-form/references/formcfg-should-unregister.md new file mode 100644 index 0000000..0ae5402 --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-should-unregister.md @@ -0,0 +1,56 @@ +--- +title: Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload +impact: HIGH +impactDescription: prevents silently dropping values the user already entered +tags: formcfg, should-unregister, dynamic-forms, conditional-fields +--- + +## Keep shouldUnregister Off Unless Hidden Fields Must Leave the Payload + +The default (`shouldUnregister: false`) keeps a field's value in form state after its input unmounts. That is the right default and it is not a memory problem — the retained data is a few strings per field. `shouldUnregister: true` is a **submission-shape** option: it removes unmounted fields from the values object entirely. Reaching for it to "clean up" a multi-step wizard silently deletes everything the user entered on step 1 the moment they advance to step 2. + +**Incorrect (wizard loses step 1 the moment step 2 renders):** + +```typescript +function OnboardingWizard() { + const [step, setStep] = useState(1) + const { register, handleSubmit } = useForm({ + shouldUnregister: true, // personalName is dropped as soon as step 1 unmounts + defaultValues: { personalName: '', companyName: '' }, + }) + + return ( +
    + {step === 1 && } + {step === 2 && } + +
    + ) +} +``` + +**Correct (default retention — every step survives to submit):** + +```typescript +function OnboardingWizard() { + const [step, setStep] = useState(1) + const { register, handleSubmit } = useForm({ + defaultValues: { personalName: '', companyName: '' }, + }) + + return ( +
    + {step === 1 && } + {step === 2 && } + +
    + ) +} +``` + +**When `shouldUnregister: true` is the right call:** +- A discriminated payload where the hidden branch's keys must be absent, not empty — e.g. a "Business" account sends `taxId` and a "Personal" one must not send the key at all +- A backend that treats a present-but-empty key differently from an absent one +- Set it per field via `register('taxId', { shouldUnregister: true })` rather than form-wide, so the rest of the form keeps the safe default + +Reference: [useForm - shouldUnregister](https://react-hook-form.com/docs/useform) diff --git a/.agents/skills/react-hook-form/references/formcfg-transformed-values-generic.md b/.agents/skills/react-hook-form/references/formcfg-transformed-values-generic.md new file mode 100644 index 0000000..a291b5b --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-transformed-values-generic.md @@ -0,0 +1,70 @@ +--- +title: Pass the Third useForm Generic When the Resolver Transforms Values +impact: CRITICAL +impactDescription: makes handleSubmit receive the schema's output type instead of its input type +tags: formcfg, generics, resolver, zod, transform, typescript +--- + +## Pass the Third useForm Generic When the Resolver Transforms Values + +`useForm` takes three generics: `useForm`. The first is what lives in the form (what inputs produce, before validation); the third is what `handleSubmit` hands your success callback. They are the same type only when the schema does no transformation. + +The moment a schema uses `z.coerce`, `.transform()`, or a `.default()`, input and output diverge — the form holds a string, the schema yields a `Date` or a `number`. Omit the third generic and TypeScript pins the output to the input type, which the resolver then contradicts. With `@hookform/resolvers` v5 the error lands on the `resolver:` property and reads like this: + +```text +Type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: Date; … }>' is not assignable to +type 'Resolver<{ arrivesOn: string; … }, any, { arrivesOn: string; … }>'. + Types of property 'arrivesOn' are incompatible. + Type 'Date' is not assignable to type 'string'. +``` + +Nothing in that message mentions a missing generic, so the usual reactions are to cast the resolver, widen the schema until the transform is gone, or drop the resolver's type entirely — all of which trade a correct error for silently wrong types. This is the most common typing failure in RHF + Zod and the fix is one type argument. + +**Incorrect (one generic — the resolver's output type contradicts the form's, and handleSubmit is typed with the pre-validation input):** + +```typescript +const bookingSchema = z.object({ + guests: z.coerce.number().int().min(1), + arrivesOn: z.iso.date().transform((value) => new Date(value)), +}) + +type BookingInput = z.input + +function BookingForm() { + const { register, handleSubmit } = useForm({ + resolver: zodResolver(bookingSchema), + defaultValues: { guests: '1', arrivesOn: '' }, + }) + + // The resolver above fails to typecheck; values.arrivesOn is string, but is a Date at runtime + return
    createBooking(values))} /> +} +``` + +**Correct (three generics — the callback is typed with the schema's output):** + +```typescript +const bookingSchema = z.object({ + guests: z.coerce.number().int().min(1), + arrivesOn: z.iso.date().transform((value) => new Date(value)), +}) + +type BookingInput = z.input +type BookingOutput = z.output + +function BookingForm() { + const { register, handleSubmit } = useForm({ + resolver: zodResolver(bookingSchema), + defaultValues: { guests: '1', arrivesOn: '' }, + }) + + // values.arrivesOn is Date, values.guests is number — matching runtime + return createBooking(values))} /> +} +``` + +The middle generic is the resolver context; pass `unknown` when you don't use one. Derive both types from the schema (`z.input` / `z.output`) rather than hand-writing them, so they can't drift. + +If `handleSubmit` is fighting you about a field type, check this generic before reaching for `as`. + +Reference: [useForm](https://react-hook-form.com/docs/useform) · [React Hook Form Resolvers](https://github.com/react-hook-form/resolvers) diff --git a/.agents/skills/react-hook-form/references/formcfg-useeffect-dependency.md b/.agents/skills/react-hook-form/references/formcfg-useeffect-dependency.md new file mode 100644 index 0000000..408db3b --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-useeffect-dependency.md @@ -0,0 +1,62 @@ +--- +title: Depend on formState Slices, Not on formState Itself +impact: HIGH +impactDescription: prevents effects that re-run on every keystroke +tags: formcfg, useEffect, dependencies, formState +--- + +## Depend on formState Slices, Not on formState Itself + +The `useForm()` return object is **stable**. `useForm` keeps it in a `useRef` and returns the same object on every render, mutating `formState` onto it — so `useEffect(fn, [form])` runs once, and `register`, `reset`, `control`, `setValue` and friends are all safe dependencies. (Widely repeated advice says the form object is a fresh reference each render and loops; that is not true of any v7 release.) + +The `formState` **proxy** is the part that changes. It is rebuilt via `useMemo` keyed on the underlying state, so it gets a new identity on every form-state update — every keystroke, in `onChange` mode. Depending on it re-runs the effect that often. Depend on the specific boolean you care about. + +**Incorrect (effect re-runs on every form-state update, not just on success):** + +```typescript +function ContactForm({ onSaved }: { onSaved: () => void }) { + const { register, handleSubmit, reset, formState } = useForm({ + defaultValues: { email: '' }, + }) + + useEffect(() => { + if (formState.isSubmitSuccessful) { + reset() + onSaved() + } + }, [formState, reset, onSaved]) // New proxy identity on every keystroke + + return ( + + + + ) +} +``` + +**Correct (depend on the slice that actually gates the effect):** + +```typescript +function ContactForm({ onSaved }: { onSaved: () => void }) { + const { register, handleSubmit, reset, formState: { isSubmitSuccessful } } = useForm({ + defaultValues: { email: '' }, + }) + + useEffect(() => { + if (isSubmitSuccessful) { + reset() + onSaved() + } + }, [isSubmitSuccessful, reset, onSaved]) // Only flips once per successful submit + + return ( +
    + +
    + ) +} +``` + +Destructuring also matters for a second reason: reading `formState.isSubmitSuccessful` is what registers the Proxy subscription in the first place — see `formstate-destructure-formstate`. + +Reference: [useForm](https://react-hook-form.com/docs/useform) · [formState](https://react-hook-form.com/docs/useform/formstate) diff --git a/.agents/skills/react-hook-form/references/formcfg-validation-mode.md b/.agents/skills/react-hook-form/references/formcfg-validation-mode.md new file mode 100644 index 0000000..903711f --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-validation-mode.md @@ -0,0 +1,55 @@ +--- +title: Justify Any mode Other Than the Default onSubmit +impact: CRITICAL +impactDescription: prevents a full validation pass and re-render on every keystroke +tags: formcfg, validation-mode, re-renders, useForm +--- + +## Justify Any mode Other Than the Default onSubmit + +`mode` decides when RHF validates. The default is `'onSubmit'`, and you should have to argue your way off it: `'onChange'` runs the field's validation — the whole resolver schema, if you use one — and re-renders on every keystroke. It gets reached for reflexively because "validate as they type" sounds like better UX, when in practice it means showing someone an "invalid email" error while they are still on the third character. + +**Incorrect (onChange chosen by default — errors fire mid-word, every keystroke re-validates):** + +```typescript +function RegistrationForm() { + const { register, handleSubmit, formState: { errors } } = useForm({ + mode: 'onChange', + defaultValues: { email: '' }, + }) + + return ( +
    + + {errors.email && {errors.email.message}} +
    + ) +} +``` + +**Correct (leave the default; escalate only where it earns its keep):** + +```typescript +function RegistrationForm() { + const { register, handleSubmit, formState: { errors } } = useForm({ + defaultValues: { email: '' }, + }) + + return ( +
    + + {errors.email && {errors.email.message}} +
    + ) +} +``` + +`reValidateMode` already defaults to `'onChange'`, so a field that has *failed* validation does give immediate feedback as the user corrects it — which is what people usually think they need `mode: 'onChange'` for. + +**Modes worth the escalation:** +- `onTouched` — validate after the first blur, then on change. The usual right answer when submit-time errors feel too late. +- `onBlur` — validate on blur only; quieter than `onTouched` while correcting. +- `onChange` — password-strength meters, "username is available" checks, live-computed totals. Add a comment saying which. +- `all` — `onBlur` and `onChange` together; rarely justified. + +Reference: [useForm - mode](https://react-hook-form.com/docs/useform) diff --git a/.agents/skills/react-hook-form/references/formcfg-values-prop.md b/.agents/skills/react-hook-form/references/formcfg-values-prop.md new file mode 100644 index 0000000..4f055ec --- /dev/null +++ b/.agents/skills/react-hook-form/references/formcfg-values-prop.md @@ -0,0 +1,61 @@ +--- +title: Use the values Prop to Keep a Form in Sync with Server Data +impact: HIGH +impactDescription: replaces a useEffect+reset that overwrites edits whenever the query refetches +tags: formcfg, values, resetOptions, react-query, server-state +--- + +## Use the values Prop to Keep a Form in Sync with Server Data + +When the initial data arrives from a query, the reflex is `useEffect(() => reset(data), [data])`. That works until the query refetches — on window focus, on interval, after an unrelated mutation — and the effect fires again mid-edit, wiping whatever the user had typed. + +`useForm({ values })` is the built-in answer. RHF re-syncs the form when the `values` reference changes, and `resetOptions: { keepDirtyValues: true }` tells it to leave fields the user has touched alone while updating the ones they haven't. `defaultValues` still supplies the shape before the first response lands. + +**Incorrect (effect-driven reset — a background refetch discards in-progress edits):** + +```typescript +function ProfileForm({ userId }: { userId: string }) { + const { data: profile } = useQuery({ queryKey: ['profile', userId], queryFn: fetchProfile }) + const { register, reset, handleSubmit } = useForm({ + defaultValues: { displayName: '', bio: '' }, + }) + + useEffect(() => { + if (profile) reset(profile) // Fires again on every refetch, mid-edit + }, [profile, reset]) + + return ( +
    + +
    + ) +} +``` + +**Correct (declarative sync that preserves what the user has touched):** + +```typescript +function ProfileForm({ userId }: { userId: string }) { + const { data: profile } = useQuery({ queryKey: ['profile', userId], queryFn: fetchProfile }) + const { register, handleSubmit } = useForm({ + defaultValues: { displayName: '', bio: '' }, + values: profile, + resetOptions: { keepDirtyValues: true }, + }) + + return ( +
    + +
    + ) +} +``` + +**Which initialiser to reach for:** +- `defaultValues` — the shape and the baseline; required regardless (see `formcfg-default-values`) +- `values` — the record is fetched and may change while the form is open +- async `defaultValues` — the record is fetched once and will not change under the form (see `formcfg-async-default-values`) + +Drop `keepDirtyValues` only when a server change should win over the user's edit — a record another person may be editing concurrently, for instance. + +Reference: [useForm - values](https://react-hook-form.com/docs/useform) diff --git a/.agents/skills/react-hook-form/references/formstate-async-submit-lifecycle.md b/.agents/skills/react-hook-form/references/formstate-async-submit-lifecycle.md new file mode 100644 index 0000000..806656e --- /dev/null +++ b/.agents/skills/react-hook-form/references/formstate-async-submit-lifecycle.md @@ -0,0 +1,79 @@ +--- +title: Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful +impact: HIGH +impactDescription: prevents stuck isSubmitting state and missing post-success reset +tags: formstate, isSubmitting, isSubmitSuccessful, async, submit, reset +--- + +## Wrap Async Submit Handlers in try/catch and Reset on isSubmitSuccessful + +`isSubmitting` is the canonical way to disable the submit button while a request is in flight, but it has a well-known footgun: if your submit handler **throws**, `isSubmitting` stays `true` and the form becomes unrecoverable. Always `try/catch` inside the async handler. Pair this with `isSubmitSuccessful` + `useEffect(reset)` to clear the form after a successful submit (resetting inside the handler races with the success state transition). + +**Incorrect (throw leaves isSubmitting stuck; manual reset races):** + +```typescript +function CreatePostForm() { + const { register, handleSubmit, reset, formState: { isSubmitting } } = useForm() + + const onSubmit = async (data: PostFormData) => { + const res = await fetch('/api/posts', { method: 'POST', body: JSON.stringify(data) }) + if (!res.ok) throw new Error('Save failed') // isSubmitting will stay true forever + reset() // Races with the form's success state + } + + return ( +
    + +