diff --git a/.claude/rules/ecc/angular/coding-style.md b/.claude/rules/ecc/angular/coding-style.md new file mode 100644 index 0000000..bed3986 --- /dev/null +++ b/.claude/rules/ecc/angular/coding-style.md @@ -0,0 +1,182 @@ +--- +paths: + - "**/*.component.ts" + - "**/*.component.html" + - "**/*.service.ts" + - "**/*.directive.ts" + - "**/*.pipe.ts" + - "**/*.guard.ts" + - "**/*.resolver.ts" + - "**/*.module.ts" +--- +# Angular Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Angular specific content. + +## Version Awareness + +Always check the project's Angular version before writing code — features differ significantly between versions. Run `ng version` or inspect `package.json`. When creating a new project, do not pin a version unless the user specifies one. + +After generating or modifying Angular code, always run `ng build` to catch errors before finishing. + +## File Naming + +Follow Angular CLI conventions — one artifact per file: + +- `user-profile.component.ts` + `user-profile.component.html` + `user-profile.component.spec.ts` +- `user.service.ts`, `auth.guard.ts`, `date-format.pipe.ts` +- Feature folders: `features/users/`, `features/auth/` +- Generate with the CLI: `ng generate component features/users/user-card` + +## Components + +Prefer standalone components (v17+ default). Use `OnPush` change detection on all new components. + +```typescript +@Component({ + selector: 'app-user-card', + standalone: true, + imports: [RouterModule], + templateUrl: './user-card.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UserCardComponent { + user = input.required(); + select = output(); +} +``` + +## Dependency Injection + +Use `inject()` over constructor injection. Keep constructors empty or remove them entirely. + +```typescript +// CORRECT +@Injectable({ providedIn: 'root' }) +export class UserService { + private http = inject(HttpClient); + private router = inject(Router); +} + +// WRONG: Constructor injection is verbose and harder to tree-shake +constructor(private http: HttpClient, private router: Router) {} +``` + +Use `InjectionToken` for non-class dependencies: + +```typescript +const API_URL = new InjectionToken('API_URL'); + +// Provide: +{ provide: API_URL, useValue: 'https://api.example.com' } + +// Consume: +private apiUrl = inject(API_URL); +``` + +## Signals + +### Core Primitives + +```typescript +count = signal(0); +doubled = computed(() => this.count() * 2); + +increment() { + this.count.update(n => n + 1); +} +``` + +### `linkedSignal` — Writable Derived State + +Use `linkedSignal` when a signal must reset or adapt when a source changes, but also be independently writable: + +```typescript +selectedOption = linkedSignal(() => this.options()[0]); +// Resets to first option when options changes, but user can override +``` + +### `resource` — Async Data into Signals + +Use `resource()` to fetch async data reactively without manual subscriptions: + +```typescript +userResource = resource({ + request: () => ({ id: this.userId() }), + loader: ({ request }) => fetch(`/api/users/${request.id}`).then(r => r.json()), +}); + +// Access: userResource.value(), userResource.isLoading(), userResource.error() +``` + +### `effect` Usage + +Use `effect()` only for side effects that must react to signal changes (logging, third-party DOM manipulation). Never use effects to synchronize signals — use `computed` or `linkedSignal` instead. For DOM work after render, use `afterRenderEffect`. + +```typescript +// CORRECT: Side effect +effect(() => console.log('User changed:', this.user())); + +// WRONG: Use computed instead +effect(() => { this.fullName.set(`${this.first()} ${this.last()}`); }); +``` + +## Templates + +Use v17+ block syntax. Always provide `track` in `@for`: + +```html +@for (item of items(); track item.id) { + +} + +@if (isLoading()) { + +} @else if (error()) { + +} @else { + +} +``` + +No logic in templates beyond simple conditionals — move to component methods or pipes. + +## Forms + +Choose the form strategy that matches the project's existing approach: + +- **Signal Forms** (v21+): Preferred for new projects on v21+. Signal-based form state. +- **Reactive Forms**: `FormBuilder` + `FormGroup` + `FormControl`. Best for complex forms with dynamic validation. +- **Template-Driven Forms**: `ngModel`. Suitable for simple forms only. + +```typescript +// Reactive Forms — standard approach for most apps +export class LoginComponent { + private fb = inject(FormBuilder); + + form = this.fb.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', [Validators.required, Validators.minLength(8)]], + }); + + submit() { + if (this.form.valid) { + // use this.form.value + } + } +} +``` + +## Component Styles + +Use component-level styles with `ViewEncapsulation.Emulated` (default). Avoid `ViewEncapsulation.None` unless building a design system that intentionally bleeds styles. + +- Scope styles to the component — do not use global class names inside component stylesheets +- Use `:host` for host element styling +- Prefer CSS custom properties for themeable values + +## Change Detection + +- Default to `ChangeDetectionStrategy.OnPush` on all new components +- Signals and `async` pipe handle detection automatically — avoid `markForCheck()` and `detectChanges()` +- Never mutate `@Input()` objects in place when using OnPush diff --git a/.claude/rules/ecc/angular/hooks.md b/.claude/rules/ecc/angular/hooks.md new file mode 100644 index 0000000..987a498 --- /dev/null +++ b/.claude/rules/ecc/angular/hooks.md @@ -0,0 +1,25 @@ +--- +paths: + - "**/*.component.ts" + - "**/*.component.html" + - "**/*.service.ts" + - "**/*.directive.ts" + - "**/*.pipe.ts" + - "**/*.spec.ts" +--- +# Angular Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Angular specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **Prettier**: Auto-format `.ts` and `.html` files after edit +- **ESLint / ng lint**: Run `ng lint` after editing Angular source files to catch decorator misuse, template errors, and style violations +- **TypeScript check**: Run `tsc --noEmit` after editing `.ts` files +- **Build check**: Run `ng build` after generating or significantly changing Angular code to catch template and type errors early + +## Stop Hooks + +- **Lint audit**: Run `ng lint` across modified files before session ends to catch any outstanding violations diff --git a/.claude/rules/ecc/angular/patterns.md b/.claude/rules/ecc/angular/patterns.md new file mode 100644 index 0000000..c703531 --- /dev/null +++ b/.claude/rules/ecc/angular/patterns.md @@ -0,0 +1,249 @@ +--- +paths: + - "**/*.component.ts" + - "**/*.component.html" + - "**/*.service.ts" + - "**/*.store.ts" + - "**/*.routes.ts" +--- +# Angular Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Angular specific content. + +## Smart / Dumb Component Split + +Smart (container) components own data fetching and state. Dumb (presentational) components receive inputs and emit outputs only — no service injection. + +```typescript +// Smart — owns data +@Component({ standalone: true, changeDetection: ChangeDetectionStrategy.OnPush }) +export class UserPageComponent { + private userService = inject(UserService); + user = toSignal(this.userService.getUser(this.userId)); +} +``` + +```html + + +``` + +## Service Layer + +Services own all data access and business logic. Components delegate — no `HttpClient` in components. + +```typescript +@Injectable({ providedIn: 'root' }) +export class UserService { + private http = inject(HttpClient); + + getUsers(): Observable { + return this.http.get('/api/users'); + } +} +``` + +## Async Data with `resource` + +Use `resource()` for reactive async fetching. Prefer over manual RxJS pipelines for simple data loading: + +```typescript +export class UserDetailComponent { + userId = input.required(); + + userResource = resource({ + request: () => ({ id: this.userId() }), + loader: ({ request }) => + firstValueFrom(inject(UserService).getUser(request.id)), + }); +} +``` + +Access state: `userResource.value()`, `userResource.isLoading()`, `userResource.error()`, `userResource.reload()`. + +## Signal State Patterns + +```typescript +// Local mutable state +count = signal(0); + +// Derived (never duplicated) +doubled = computed(() => this.count() * 2); + +// Writable derived state that resets with source +selectedItem = linkedSignal(() => this.items()[0]); + +// Bridge Observable to signal +users = toSignal(this.userService.getUsers(), { initialValue: [] }); +``` + +Never store derived values in separate signals — use `computed`. Never use `effect` to sync signals — use `computed` or `linkedSignal`. + +## Subscription Cleanup + +Use `takeUntilDestroyed()` for all manual subscriptions. Never use manual `ngOnDestroy` + `Subject` + `takeUntil` on new code. + +```typescript +export class UserComponent { + private destroyRef = inject(DestroyRef); + + ngOnInit() { + this.userService.updates$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(update => this.handleUpdate(update)); + } +} +``` + +## Routing + +### Route Definition + +```typescript +// app.routes.ts +export const routes: Routes = [ + { path: '', component: HomeComponent }, + { + path: 'admin', + canMatch: [authGuard], // CanMatch prevents loading the chunk at all + loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES), + }, + { + path: 'users/:id', + resolve: { user: userResolver }, + component: UserDetailComponent, + }, +]; +``` + +- Use `canMatch` over `canActivate` when the route module should not load for unauthorized users +- Lazy-load all feature modules with `loadChildren` +- Pre-fetch data with `resolve` to avoid loading states in components + +### Functional Guards + +```typescript +export const authGuard: CanActivateFn = () => { + const auth = inject(AuthService); + return auth.isAuthenticated() + ? true + : inject(Router).createUrlTree(['/login']); +}; +``` + +### Data Resolvers + +```typescript +export const userResolver: ResolveFn = (route) => { + return inject(UserService).getUser(route.paramMap.get('id')!); +}; +``` + +### View Transitions + +Enable smooth route transitions with the View Transitions API: + +```typescript +// app.config.ts +provideRouter(routes, withViewTransitions()) +``` + +## Dependency Injection Patterns + +### Scoped Providers + +Provide services at component or route level when they should not be singletons: + +```typescript +@Component({ + providers: [UserEditService], // scoped to this component subtree +}) +export class UserEditComponent {} +``` + +### `InjectionToken` + +```typescript +export const CONFIG = new InjectionToken('APP_CONFIG'); + +// In providers: +{ provide: CONFIG, useValue: appConfig } +{ provide: CONFIG, useFactory: () => loadConfig(), deps: [] } + +// Consume: +private config = inject(CONFIG); +``` + +### `viewProviders` vs `providers` + +- `providers`: Available to the component and all its content children +- `viewProviders`: Available only to the component's own view (not projected content) + +## HTTP Interceptors + +Use functional interceptors (v15+) for auth, error handling, and retries: + +```typescript +export const authInterceptor: HttpInterceptorFn = (req, next) => { + const token = inject(AuthService).token(); + if (!token) return next(req); + return next(req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })); +}; +``` + +Register in `app.config.ts`: + +```typescript +provideHttpClient(withInterceptors([authInterceptor, errorInterceptor])) +``` + +## RxJS Operators + +- `switchMap` — search, navigation (cancels previous) +- `mergeMap` — independent parallel requests +- `exhaustMap` — form submissions (ignores until complete) +- Always handle errors with `catchError` — never let streams die silently + +```typescript +search$ = this.query$.pipe( + debounceTime(300), + distinctUntilChanged(), + switchMap(q => this.service.search(q).pipe(catchError(() => of([])))), +); +``` + +## Forms + +Match the project's existing form strategy. For new v21+ apps, prefer signal forms. + +```typescript +// Reactive Forms — standard for complex forms +export class UserFormComponent { + private fb = inject(FormBuilder); + + form = this.fb.group({ + name: ['', Validators.required], + email: ['', [Validators.required, Validators.email]], + }); +} +``` + +## Rendering Strategies + +- **CSR** (default): Standard SPA +- **SSR + Hydration**: `ng add @angular/ssr` — improves FCP and SEO +- **SSG (Prerendering)**: Static pages at build time for content-heavy routes + +When using SSR, avoid `window`, `document`, `localStorage` directly — use `isPlatformBrowser` or `DOCUMENT` token. + +## Accessibility + +Use Angular CDK for headless, accessible components (Accordion, Listbox, Combobox, Menu, Tabs, Toolbar, Tree, Grid). Style ARIA attributes rather than managing them manually: + +```css +[aria-selected="true"] { background: var(--color-selected); } +``` + +## Skill Reference + +See skill: `angular-developer` for deep guidance on signals, forms, routing, DI, SSR, and accessibility patterns. diff --git a/.claude/rules/ecc/angular/security.md b/.claude/rules/ecc/angular/security.md new file mode 100644 index 0000000..167e5eb --- /dev/null +++ b/.claude/rules/ecc/angular/security.md @@ -0,0 +1,87 @@ +--- +paths: + - "**/*.component.ts" + - "**/*.component.html" + - "**/*.service.ts" + - "**/*.interceptor.ts" +--- +# Angular Security + +> This file extends [common/security.md](../common/security.md) with Angular specific content. + +## XSS Prevention + +Angular auto-sanitizes bound values. Never bypass the sanitizer on user-controlled input. + +```typescript +// WRONG: Bypasses sanitization — XSS risk +this.safeHtml = this.sanitizer.bypassSecurityTrustHtml(userInput); + +// CORRECT: Sanitize explicitly before trusting +this.safeHtml = this.sanitizer.sanitize(SecurityContext.HTML, userInput); +``` + +- Never use `bypassSecurityTrust*` methods without a documented, reviewed reason +- Avoid `[innerHTML]` with untrusted content — use `innerText` or a sanitizing pipe +- Never bind `[href]` to user input — Angular does not block `javascript:` URLs in all contexts +- Never construct template strings from user data + +## HTTP Security + +Use `HttpClient` exclusively — never raw `fetch()` or `XHR` unless no alternative exists. + +```typescript +// WRONG: Bypasses interceptors (auth headers, error handling, logging) +const res = await fetch('/api/users'); + +// CORRECT +users$ = this.http.get('/api/users'); +``` + +- Attach auth tokens via interceptors — never hardcode in individual service calls +- Type and validate API responses — treat external data as `unknown` at the boundary +- Never log HTTP responses that may contain tokens, PII, or credentials + +## Secret Management + +```typescript +// WRONG: Hardcoded secret in source +const apiKey = 'sk-live-xxxx'; + +// CORRECT: Injected via environment +import { environment } from '../environments/environment'; +const apiKey = environment.apiKey; +``` + +- Treat `environment.ts` as a config shape — never store real secrets in source-controlled environment files +- Inject production secrets via CI/CD (environment variables, secret managers) + +## Route Guards + +Every authenticated or role-restricted route must have a guard. Never rely on hiding UI elements alone. + +```typescript +{ + path: 'admin', + canMatch: [authGuard, roleGuard('admin')], + loadChildren: () => import('./admin/admin.routes'), +} +``` + +Use `canMatch` for sensitive routes — it prevents the route module from loading at all for unauthorized users. + +## SSR Security + +When using Angular SSR: + +- Never expose server-side environment variables to the client via `TransferState` unless they are intentionally public +- Sanitize all inputs before server-side rendering — DOM-based XSS can occur server-side too +- Avoid `window`, `document`, `localStorage` on the server — gate with `isPlatformBrowser` or inject via `DOCUMENT` token + +## Content Security Policy + +Configure CSP headers server-side. Avoid `unsafe-inline` in `script-src`. When using SSR with inline scripts, use nonces via Angular's CSP support. + +## Agent Support + +- Use **security-reviewer** skill for comprehensive security audits diff --git a/.claude/rules/ecc/angular/testing.md b/.claude/rules/ecc/angular/testing.md new file mode 100644 index 0000000..f2f4d93 --- /dev/null +++ b/.claude/rules/ecc/angular/testing.md @@ -0,0 +1,164 @@ +--- +paths: + - "**/*.spec.ts" + - "**/*.test.ts" +--- +# Angular Testing + +> This file extends [common/testing.md](../common/testing.md) with Angular specific content. + +## Test Runner + +Use the test runner configured by the project. Check `angular.json` and `package.json`; Angular projects commonly use Vitest, Jest, or Jasmine + Karma. + +```bash +ng test # watch mode +ng test --no-watch # CI mode +``` + +## TestBed Setup + +For standalone components, import the component directly. Call `compileComponents()` for components with external templates. + +```typescript +describe('UserCardComponent', () => { + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [UserCardComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(UserCardComponent); + }); +}); +``` + +## Signal Inputs + +Set signal-based inputs via `fixture.componentRef.setInput()`: + +```typescript +fixture.componentRef.setInput('user', mockUser); +fixture.detectChanges(); +``` + +## Component Harnesses + +Prefer Angular CDK component harnesses over direct DOM queries for UI interaction. Harnesses are more resilient to markup changes. + +```typescript +import { HarnessLoader } from '@angular/cdk/testing'; +import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed'; +import { MatButtonHarness } from '@angular/material/button/testing'; + +let loader: HarnessLoader; + +beforeEach(() => { + loader = TestbedHarnessEnvironment.loader(fixture); +}); + +it('triggers save on button click', async () => { + const button = await loader.getHarness(MatButtonHarness.with({ text: 'Save' })); + await button.click(); + expect(saveSpy).toHaveBeenCalled(); +}); +``` + +## Router Testing + +Use `RouterTestingHarness` for components that depend on the router: + +```typescript +import { RouterTestingHarness } from '@angular/router/testing'; + +it('renders user on navigation', async () => { + const harness = await RouterTestingHarness.create(); + const component = await harness.navigateByUrl('/users/1', UserDetailComponent); + expect(component.userId()).toBe('1'); +}); +``` + +## Async Testing + +Use `fakeAsync` + `tick` for controlled async. Use `waitForAsync` for real async with `fixture.whenStable()`. + +```typescript +it('loads user after delay', fakeAsync(() => { + const service = TestBed.inject(UserService); + vi.spyOn(service, 'getUser').mockReturnValue(of(mockUser)); + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(fixture.nativeElement.querySelector('.name').textContent).toBe(mockUser.name); +})); +``` + +## HTTP Testing + +```typescript +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { HttpTestingController } from '@angular/common/http/testing'; + +beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + httpMock = TestBed.inject(HttpTestingController); +}); + +afterEach(() => httpMock.verify()); +``` + +## Service Testing + +Inject services directly without a component fixture: + +```typescript +describe('UserService', () => { + let service: UserService; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [provideHttpClient(), provideHttpClientTesting()], + }); + service = TestBed.inject(UserService); + }); +}); +``` + +## What to Test + +- **Services**: All public methods, error paths, HTTP interactions +- **Components**: Input/output bindings, rendered output for key states, user interactions via harnesses +- **Pipes**: Pure transformation — plain unit tests, no TestBed needed +- **Guards/Resolvers**: Return values for allowed and denied states using `RouterTestingHarness` + +## E2E Testing + +Use the project's configured E2E framework, such as Cypress or Playwright, for critical user flows. + +```typescript +describe('Login flow', () => { + it('redirects to dashboard on valid credentials', () => { + cy.visit('/login'); + cy.get('[data-cy=email]').type('user@example.com'); + cy.get('[data-cy=password]').type('password123'); + cy.get('[data-cy=submit]').click(); + cy.url().should('include', '/dashboard'); + }); +}); +``` + +- Add `data-cy` attributes to interactive elements for stable selectors +- Do not rely on CSS classes or text content for selectors in E2E tests + +## Coverage + +Target ≥80% for services and pipes. Components: test behaviour, not implementation details. + +## Skill Reference + +See skill: `angular-developer` for comprehensive testing patterns, harness usage, and async best practices. diff --git a/.claude/rules/ecc/arkts/coding-style.md b/.claude/rules/ecc/arkts/coding-style.md new file mode 100644 index 0000000..5044ced --- /dev/null +++ b/.claude/rules/ecc/arkts/coding-style.md @@ -0,0 +1,153 @@ +--- +paths: + - "**/*.ets" + - "**/*.ts" + - "**/module.json5" + - "**/oh-package.json5" + - "**/build-profile.json5" +--- +# HarmonyOS / ArkTS Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with HarmonyOS and ArkTS-specific content. + +## ArkTS Language Constraints + +ArkTS is a strict, statically-typed subset of TypeScript. Violating these constraints causes **compilation failures**. + +### Type System + +- No `any` or `unknown` types - always use explicit types +- No index access types - use type names directly +- No conditional type aliases or `infer` keyword +- No intersection types - use inheritance +- No mapped types - use classes and regular idioms +- No `typeof` for type annotations - use explicit type declarations +- No `as const` assertions - use explicit type annotations +- No structural typing - use inheritance, interfaces, or type aliases +- No TypeScript utility types except `Partial`, `Required`, `Readonly`, `Record` +- For `Record`, index expression type is `V | undefined` +- Omit type annotations in `catch` clauses (ArkTS does not support `any`/`unknown`) + +### Functions & Classes + +- No function expressions - use arrow functions +- No nested functions - use lambdas +- No generator functions - use `async`/`await` for multitasking +- No `Function.apply`, `Function.call`, `Function.bind` - follow traditional OOP for `this` +- No constructor type expressions - use lambdas +- No constructor signatures in interfaces or object types - use methods or classes +- No declaring class fields in constructors - declare in class body +- No `this` in standalone functions or static methods - only in instance methods +- No `new.target` +- No definite assignment assertions (`let v!: T`) - use initialized declarations +- No class literals - introduce named class types +- No using classes as objects (assigning to variables) - class declarations introduce types, not values +- Only one static block per class - merge all static statements + +### Object & Property Access + +- No dynamic field declaration or `obj["field"]` access - use `obj.field` syntax +- No `delete` operator - use nullable type with `null` to mark absence +- No prototype assignment - use classes and interfaces +- No `in` operator - use `instanceof` +- No reassigning object methods - use wrapper functions or inheritance +- No `Symbol()` API (except `Symbol.iterator`) +- No `globalThis` or global scope - use explicit module exports/imports +- No namespaces as objects - use classes or modules +- No statements inside namespaces - use functions + +### Destructuring & Spread + +- No destructuring assignments or variable declarations - use intermediate objects and field-by-field access +- No destructuring parameter declarations - pass parameters directly, assign local names manually +- Spread operator only for expanding arrays (or array-derived classes) into rest parameters or array literals + +### Modules & Imports + +- No `require()` - use regular `import` syntax +- No `export = ...` - use normal export/import +- No import assertions - imports are compile-time in ArkTS +- No UMD modules +- No wildcards in module names +- All `import` statements must appear before all other statements +- TypeScript codebases must not depend on ArkTS codebases via import (reverse is supported) + +### Other Restrictions + +- No `var` - use `let` +- No `for...in` loops - use regular `for` loops for arrays +- No `with` statements +- No JSX expressions +- No `#` private identifiers - use `private` keyword +- No declaration merging (classes, interfaces, enums) - keep definitions compact +- No index signatures - use arrays +- Comma operator only in `for` loops +- Unary operators `+`, `-`, `~` only for numeric types (no implicit string conversion) +- Enum members: only same-type compile-time expressions for explicit initializers +- Function return type inference is limited - specify return types explicitly when calling functions with omitted return types + +### Object Literals + +- Supported only when compiler can infer the corresponding class or interface +- NOT supported for: `any`/`Object`/`object` types, classes/interfaces with methods, classes with parameterized constructors, classes with `readonly` fields + +## Naming Conventions + +- Variables / functions: `camelCase` (e.g., `getUserInfo`, `goodsList`) +- Classes / interfaces: `PascalCase` (e.g., `UserViewModel`, `IGoodsModel`) +- Constants: `UPPER_SNAKE_CASE` (e.g., `MAX_PAGE_SIZE`, `COLOR_PRIMARY`) +- File names: `PascalCase` for components (e.g., `HomePage.ets`), `camelCase` for utilities + +## Formatting + +- Prefer double quotes for strings +- Semicolons at end of statements +- Never use `var` - prefer `const`, then `let` +- All methods, parameters, return values must have complete type annotations + +## File Organization + +- Component files (`.ets`): one `@ComponentV2` per file +- ViewModel files: one ViewModel class per file +- Model files: related data models may share a file +- Keep files under 400 lines; extract helpers for files approaching 800 lines + +## Comments + +- File header: `@file` (file purpose) + `@author` (developer), if the project already uses file headers +- Public methods: JSDoc with `@param`, `@returns`; add `@example` for complex methods +- Match the project's existing documentation language; use English unless the repository has already standardized on Chinese comments + +## Error Handling + +```typescript +// Use try/catch with proper error handling +try { + const result = await riskyOperation() + return result +} catch (error) { + hilog.error(0x0000, 'TAG', 'Operation failed: %{public}s', error) + throw new Error('User-friendly error message') +} +``` + +## Immutability + +Follow the common immutability principles - create new objects instead of mutating: + +```typescript +// BAD: mutation +function updateUser(user: UserModel, name: string): UserModel { + user.name = name // direct mutation + return user +} + +// GOOD: immutable - create new instance +function updateUser(user: UserModel, name: string): UserModel { + const updated = new UserModel() + updated.id = user.id + updated.name = name + updated.email = user.email + return updated +} +``` diff --git a/.claude/rules/ecc/arkts/hooks.md b/.claude/rules/ecc/arkts/hooks.md new file mode 100644 index 0000000..f870d91 --- /dev/null +++ b/.claude/rules/ecc/arkts/hooks.md @@ -0,0 +1,135 @@ +--- +paths: + - "**/*.ets" + - "**/*.ts" + - "**/module.json5" + - "**/oh-package.json5" +--- +# HarmonyOS / ArkTS Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with HarmonyOS-specific build and validation hooks. + +## Build Commands + +### HAP Package Build + +```bash +# Build HAP package (global hvigor environment) +hvigorw assembleHap -p product=default + +# Build with specific module +hvigorw assembleHap -p module=entry -p product=default + +# Clean build +hvigorw clean +``` + +### DevEco Studio CLI + +```bash +# Check project structure +hvigorw --version + +# Install dependencies +ohpm install + +# Update dependencies +ohpm update +``` + +## Recommended PostToolUse Hooks + +### After Editing .ets/.ts Files + +Run hvigor build to check for ArkTS compilation errors: + +```json +{ + "type": "PostToolUse", + "matcher": { + "tool": ["Edit", "Write"], + "filePath": ["**/*.ets", "**/*.ts"] + }, + "hooks": [ + { + "command": "hvigorw assembleHap -p product=default 2>&1 | tail -20", + "async": true, + "timeout": 60000 + } + ] +} +``` + +### After Editing module.json5 + +Validate permission and ability declarations: + +```json +{ + "type": "PostToolUse", + "matcher": { + "tool": "Edit", + "filePath": "**/module.json5" + }, + "hooks": [ + { + "command": "echo '[HarmonyOS] module.json5 modified - verify permissions and abilities'", + "async": false + } + ] +} +``` + +### After Editing oh-package.json5 + +Reinstall dependencies: + +```json +{ + "type": "PostToolUse", + "matcher": { + "tool": "Edit", + "filePath": "**/oh-package.json5" + }, + "hooks": [ + { + "command": "ohpm install 2>&1 | tail -10", + "async": true, + "timeout": 30000 + } + ] +} +``` + +## PreToolUse Hooks + +### V1 Decorator Guard + +Warn when code contains V1 state management decorators: + +```json +{ + "type": "PreToolUse", + "matcher": { + "tool": ["Write", "Edit"], + "filePath": "**/*.ets" + }, + "hooks": [ + { + "command": "echo '[HarmonyOS] Reminder: Use @ComponentV2 / @Local / @Param - V1 decorators (@State, @Prop, @Link) are prohibited'" + } + ] +} +``` + +## Validation Checklist + +After each implementation cycle, verify: + +- [ ] `hvigorw assembleHap` completes without errors +- [ ] No V1 decorators in new or modified `.ets` files +- [ ] No `@ohos.router` imports in new or modified files +- [ ] All API permissions declared in `module.json5` +- [ ] All dependencies listed in `oh-package.json5` +- [ ] Resource strings added to all i18n directories +- [ ] Dark theme colors provided for new color resources diff --git a/.claude/rules/ecc/arkts/patterns.md b/.claude/rules/ecc/arkts/patterns.md new file mode 100644 index 0000000..549a3d1 --- /dev/null +++ b/.claude/rules/ecc/arkts/patterns.md @@ -0,0 +1,236 @@ +--- +paths: + - "**/*.ets" + - "**/*.ts" +--- +# HarmonyOS / ArkTS Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with HarmonyOS and ArkTS-specific patterns. + +## State Management: V2 Only + +**MUST use** ArkUI State Management V2. V1 decorators are deprecated and must not be used. + +### V2 Decorators + +| Decorator | Purpose | +|-----------|---------| +| `@ComponentV2` | Marks a struct as a V2 component | +| `@Local` | Local state within a component | +| `@Param` | Props received from parent (read-only) | +| `@Event` | Callback events from child to parent | +| `@Provider` | Provides state to descendant components | +| `@Consumer` | Consumes state from ancestor `@Provider` | +| `@Monitor` | Watches for state changes (replaces V1 `@Watch`) | +| `@Computed` | Derived/computed values | +| `@ObservedV2` | Makes a class observable for V2 state management | +| `@Trace` | Marks observable properties in `@ObservedV2` classes | + +### Prohibited V1 Decorators + +Never use: `@State`, `@Prop`, `@Link`, `@ObjectLink`, `@Observed`, `@Provide`, `@Consume`, `@Watch`, `@Component` (use `@ComponentV2` instead). + +### V2 Component Example + +```typescript +@ObservedV2 +class UserModel { + @Trace name: string = '' + @Trace age: number = 0 +} + +@ComponentV2 +struct UserCard { + @Param user: UserModel = new UserModel() + @Event onDelete: () => void = () => {} + + build() { + Column() { + Text(this.user.name) + .fontSize($r('app.float.font_size_title')) + Text(`${this.user.age}`) + .fontSize($r('app.float.font_size_body')) + Button($r('app.string.delete')) + .onClick(() => this.onDelete()) + } + } +} +``` + +### State Synchronization + +```typescript +@ComponentV2 +struct ParentPage { + @Provider('userState') userModel: UserModel = new UserModel() + + build() { + Column() { + ChildComponent() // automatically receives @Consumer('userState') + } + } +} + +@ComponentV2 +struct ChildComponent { + @Consumer('userState') userModel: UserModel = new UserModel() + + build() { + Text(this.userModel.name) + } +} +``` + +## Routing: Navigation Only + +**MUST use** `Navigation` component with `NavPathStack`. Never use `@ohos.router`. + +### Navigation Setup + +```typescript +@ComponentV2 +struct MainPage { + @Local navPathStack: NavPathStack = new NavPathStack() + + build() { + Navigation(this.navPathStack) { + // Home content + } + .navDestination(this.routerMap) + } + + @Builder + routerMap(name: string, param: ESObject) { + if (name === 'detail') { + DetailPage() + } else if (name === 'settings') { + SettingsPage() + } + } +} +``` + +### Page Navigation + +```typescript +// Push a new page +this.navPathStack.pushPath({ name: 'detail', param: { id: '123' } }) + +// Replace current page +this.navPathStack.replacePath({ name: 'settings' }) + +// Pop back +this.navPathStack.pop() + +// Pop to root +this.navPathStack.clear() +``` + +### NavDestination Sub-page + +```typescript +@ComponentV2 +struct DetailPage { + build() { + NavDestination() { + Column() { + Text($r('app.string.detail_title')) + } + } + .title($r('app.string.detail_nav_title')) + } +} +``` + +## Architecture Pattern: MVVM + +Recommended architecture for HarmonyOS applications: + +``` +feature/ + |-- model/ # Data models (@ObservedV2 classes) + |-- viewmodel/ # Business logic (ViewModel classes) + |-- view/ # UI components (@ComponentV2 structs) + |-- service/ # API calls, data access +``` + +- **View**: Only rendering logic, no business logic in `build()` +- **ViewModel**: All business logic encapsulated here +- **Model**: Pure data classes with `@ObservedV2` and `@Trace` +- **Service**: Network requests, database operations, file I/O + +## ArkUI Animation Patterns + +### State-Driven Animation + +```typescript +@ComponentV2 +struct AnimatedCard { + @Local isExpanded: boolean = false + @Local cardScale: number = 0.8 + + build() { + Column() { + // Content + } + .scale({ x: this.cardScale, y: this.cardScale }) + .animation({ duration: 300, curve: Curve.EaseInOut }) + .onClick(() => { + this.isExpanded = !this.isExpanded + this.cardScale = this.isExpanded ? 1.0 : 0.8 + }) + } +} +``` + +### Animation Rules + +- Prefer native HarmonyOS animation APIs and advanced templates +- Use declarative UI with state-driven animations (change state variables to trigger animations) +- Set `renderGroup(true)` for complex sub-component animations to reduce render batches +- **NEVER** frequently change `width`, `height`, `padding`, `margin` during animations - severe performance impact +- Use `animateTo` for explicit animation control +- Prefer `transform` (translate, scale, rotate) and `opacity` for performant animations + +## Performance Patterns + +### LazyForEach for Large Lists + +```typescript +@ComponentV2 +struct LargeList { + @Local dataSource: MyDataSource = new MyDataSource() + + build() { + List() { + LazyForEach(this.dataSource, (item: ItemModel) => { + ListItem() { + ItemComponent({ item: item }) + } + }, (item: ItemModel) => item.id) + } + } +} +``` + +### Component Reuse + +- Extract reusable components into separate files +- Use `@Builder` for lightweight UI fragments within a component +- Use `@Param` for configurable components + +## Resource References + +Always define UI constants as resources and reference via `$r()`: + +```typescript +// BAD: hardcoded values +Text('Hello') + .fontSize(16) + .fontColor('#333333') + +// GOOD: resource references +Text($r('app.string.greeting')) + .fontSize($r('app.float.font_size_body')) + .fontColor($r('app.color.text_primary')) +``` diff --git a/.claude/rules/ecc/arkts/security.md b/.claude/rules/ecc/arkts/security.md new file mode 100644 index 0000000..f767171 --- /dev/null +++ b/.claude/rules/ecc/arkts/security.md @@ -0,0 +1,141 @@ +--- +paths: + - "**/*.ets" + - "**/*.ts" + - "**/module.json5" +--- +# HarmonyOS / ArkTS Security + +> This file extends [common/security.md](../common/security.md) with HarmonyOS-specific security practices. + +## Permission Management + +### Declare Permissions in module.json5 + +All system API calls requiring permissions must be declared: + +```json5 +{ + "module": { + "requestPermissions": [ + { + "name": "ohos.permission.INTERNET", + "reason": "$string:internet_permission_reason", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "always" + } + } + ] + } +} +``` + +### Permission Checklist + +Before calling system APIs, verify: + +- [ ] Permission declared in `module.json5` +- [ ] Permission reason string defined in resources (for user-facing permissions) +- [ ] Runtime permission request implemented for sensitive permissions (camera, location, etc.) +- [ ] Permission check before API call with graceful fallback on denial + +### Runtime Permission Request + +```typescript +import { abilityAccessCtrl, bundleManager, Permissions } from '@kit.AbilityKit'; + +async function checkAndRequestPermission(permission: Permissions): Promise { + const atManager = abilityAccessCtrl.createAtManager(); + const bundleInfo = await bundleManager.getBundleInfoForSelf( + bundleManager.BundleFlag.GET_BUNDLE_INFO_WITH_APPLICATION + ); + const tokenId = bundleInfo.appInfo.accessTokenId; + const grantStatus = await atManager.checkAccessToken(tokenId, permission); + + if (grantStatus === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { + return true; + } + + const result = await atManager.requestPermissionsFromUser(getContext(), [permission]); + return result.authResults[0] === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED; +} +``` + +## Secret Management + +- **NEVER** hardcode API keys, tokens, or passwords in `.ets`/`.ts` source files +- Use HarmonyOS Preferences API for non-sensitive configuration +- Use HarmonyOS Keystore for sensitive credentials +- Environment-specific configs should be managed via build profiles + +```typescript +// BAD: hardcoded secret +const API_KEY: string = 'sk-xxxxxxxxxxxx'; + +// GOOD: from build profile config (non-sensitive) +import { BuildProfile } from 'BuildProfile'; +const endpoint = BuildProfile.API_ENDPOINT; + +// GOOD: use HUKS to encrypt/decrypt data without exposing key material +import { huks } from '@kit.UniversalKeystoreKit'; +async function decryptWithKeystore(alias: string, nonce: Uint8Array, aad: Uint8Array, cipherData: Uint8Array): Promise { + const options: huks.HuksOptions = { + properties: [ + { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_AES }, + { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_DECRYPT }, + { tag: huks.HuksTag.HUKS_TAG_BLOCK_MODE, value: huks.HuksCipherMode.HUKS_MODE_GCM }, + { tag: huks.HuksTag.HUKS_TAG_PADDING, value: huks.HuksKeyPadding.HUKS_PADDING_NONE }, + { tag: huks.HuksTag.HUKS_TAG_NONCE, value: nonce }, + { tag: huks.HuksTag.HUKS_TAG_ASSOCIATED_DATA, value: aad } + ], + inData: cipherData + }; + const handle = await huks.initSession(alias, options); + const result = await huks.finishSession(handle.handle, options); + return result.outData; +} +``` + +## Input Validation + +- Validate all user input before processing +- Sanitize data before displaying in UI to prevent injection +- Validate deep link parameters before navigation + +```typescript +// Validate before navigation +function handleDeepLink(uri: string): void { + const allowedPaths: string[] = ['detail', 'settings', 'profile']; + const parsed = new URL(uri); + const path = parsed.pathname.replace('/', ''); + + if (!allowedPaths.includes(path)) { + hilog.warn(0x0000, 'DeepLink', 'Invalid deep link path: %{public}s', path); + return; + } + + navPathStack.pushPath({ name: path }); +} +``` + +## Network Security + +- Always use HTTPS for network requests +- Validate server certificates +- Implement request timeout and retry policies +- Never log sensitive data (tokens, user credentials) in network request/response logs + +## Data Storage Security + +- Use encrypted preferences for sensitive local data +- Clear sensitive data from memory when no longer needed +- Implement proper data lifecycle management +- Consider data classification (public, internal, confidential) when choosing storage mechanisms + +## Dependency Security + +- Only use dependencies from trusted sources (official ohpm registry) +- Verify dependency versions in `oh-package.json5` +- Regularly check for known vulnerabilities in third-party libraries +- Pin dependency versions to avoid unexpected updates diff --git a/.claude/rules/ecc/arkts/testing.md b/.claude/rules/ecc/arkts/testing.md new file mode 100644 index 0000000..a32cfa8 --- /dev/null +++ b/.claude/rules/ecc/arkts/testing.md @@ -0,0 +1,126 @@ +--- +paths: + - "**/*.ets" + - "**/*.ts" + - "**/ohosTest/**" +--- +# HarmonyOS / ArkTS Testing + +> This file extends [common/testing.md](../common/testing.md) with HarmonyOS-specific testing practices. + +## Test Framework + +HarmonyOS uses the built-in test framework with `@ohos.test` capabilities: + +- **Unit tests**: Located in `src/ohosTest/ets/test/` +- **UI tests**: Use `@ohos.UiTest` for component testing +- **Instrument tests**: Run on device/emulator + +## Test Directory Structure + +``` +module/ + |-- src/ + | |-- main/ets/ # Production code + | |-- ohosTest/ets/ # Test code + | |-- test/ + | | |-- Ability.test.ets + | | |-- List.test.ets + | |-- TestAbility.ets + | |-- TestRunner.ets +``` + +## Running Tests + +```bash +# Run all tests for a module +hvigorw testHap -p product=default + +# Run tests on connected device +hdc shell aa test -b com.example.app -m entry_test -s unittest /ets/TestRunner/OpenHarmonyTestRunner +``` + +## Unit Test Example + +```typescript +import { describe, it, expect } from '@ohos/hypium'; + +export default function UserViewModelTest() { + describe('UserViewModel', () => { + it('should_initialize_with_empty_state', 0, () => { + const vm = new UserViewModel(); + expect(vm.userName).assertEqual(''); + expect(vm.isLoading).assertFalse(); + }); + + it('should_update_user_name', 0, () => { + const vm = new UserViewModel(); + vm.updateUserName('Alice'); + expect(vm.userName).assertEqual('Alice'); + }); + + it('should_handle_empty_input', 0, () => { + const vm = new UserViewModel(); + vm.updateUserName(''); + expect(vm.userName).assertEqual(''); + expect(vm.hasError).assertFalse(); + }); + }); +} +``` + +## UI Test Example + +```typescript +import { describe, it, expect } from '@ohos/hypium'; +import { Driver, ON } from '@ohos.UiTest'; + +export default function HomePageUITest() { + describe('HomePage_UI', () => { + it('should_display_title', 0, async () => { + const driver = Driver.create(); + await driver.delayMs(1000); + + const title = await driver.findComponent(ON.text('Home')); + expect(title !== null).assertTrue(); + }); + + it('should_navigate_to_detail_on_click', 0, async () => { + const driver = Driver.create(); + const button = await driver.findComponent(ON.id('detailButton')); + await button.click(); + await driver.delayMs(500); + + const detailTitle = await driver.findComponent(ON.text('Detail')); + expect(detailTitle !== null).assertTrue(); + }); + }); +} +``` + +## TDD Workflow for HarmonyOS + +Follow the standard TDD cycle adapted for HarmonyOS: + +1. **RED**: Write a failing test in `ohosTest/ets/test/` +2. **GREEN**: Implement minimal code in `main/ets/` to pass +3. **REFACTOR**: Clean up while keeping tests green +4. **BUILD**: Run `hvigorw assembleHap` to verify compilation +5. **VERIFY**: Run tests on device/emulator + +## Test Coverage Requirements + +- Minimum 80% coverage for all critical application code (ViewModels, services, utilities) +- **Unit tests**: All utility functions, ViewModel logic, data models +- **Integration tests**: API calls, database operations, cross-module interactions +- **E2E / UI tests**: Critical user flows (login, navigation, data submission) +- Test edge cases: empty data, network errors, permission denials + +## Testing Best Practices + +- Keep tests independent - no shared mutable state between tests +- Mock network calls and system APIs in unit tests +- Use meaningful test names: `should_[expected_behavior]_when_[condition]` +- Test V2 state management reactivity: verify `@Trace` properties trigger UI updates +- Test Navigation flows: verify `NavPathStack` push/pop/replace operations +- Avoid testing framework internals - focus on business logic and user-visible behavior diff --git a/.claude/rules/ecc/common/agents.md b/.claude/rules/ecc/common/agents.md new file mode 100644 index 0000000..d7dd1be --- /dev/null +++ b/.claude/rules/ecc/common/agents.md @@ -0,0 +1,51 @@ +# Agent Orchestration + +## Available Agents + +Located in `~/.claude/agents/`: + +| Agent | Purpose | When to Use | +|-------|---------|-------------| +| planner | Implementation planning | Complex features, refactoring | +| architect | System design | Architectural decisions | +| tdd-guide | Test-driven development | New features, bug fixes | +| code-reviewer | Code review | After writing code | +| security-reviewer | Security analysis | Before commits | +| build-error-resolver | Fix build errors | When build fails | +| e2e-runner | E2E testing | Critical user flows | +| refactor-cleaner | Dead code cleanup | Code maintenance | +| doc-updater | Documentation | Updating docs | +| rust-reviewer | Rust code review | Rust projects | +| harmonyos-app-resolver | HarmonyOS app development | HarmonyOS/ArkTS projects | + +## Immediate Agent Usage + +No user prompt needed: +1. Complex feature requests - Use **planner** agent +2. Code just written/modified - Use **code-reviewer** agent +3. Bug fix or new feature - Use **tdd-guide** agent +4. Architectural decision - Use **architect** agent + +## Parallel Task Execution + +ALWAYS use parallel Task execution for independent operations: + +```markdown +# GOOD: Parallel execution +Launch 3 agents in parallel: +1. Agent 1: Security analysis of auth module +2. Agent 2: Performance review of cache system +3. Agent 3: Type checking of utilities + +# BAD: Sequential when unnecessary +First agent 1, then agent 2, then agent 3 +``` + +## Multi-Perspective Analysis + +For complex problems, use split role sub-agents: +- Factual reviewer +- Senior engineer +- Security expert +- Consistency reviewer +- Redundancy checker diff --git a/.claude/rules/ecc/common/code-review.md b/.claude/rules/ecc/common/code-review.md new file mode 100644 index 0000000..d79ba9b --- /dev/null +++ b/.claude/rules/ecc/common/code-review.md @@ -0,0 +1,124 @@ +# Code Review Standards + +## Purpose + +Code review ensures quality, security, and maintainability before code is merged. This rule defines when and how to conduct code reviews. + +## When to Review + +**MANDATORY review triggers:** + +- After writing or modifying code +- Before any commit to shared branches +- When security-sensitive code is changed (auth, payments, user data) +- When architectural changes are made +- Before merging pull requests + +**Pre-Review Requirements:** + +Before requesting review, ensure: + +- All automated checks (CI/CD) are passing +- Merge conflicts are resolved +- Branch is up to date with target branch + +## Review Checklist + +Before marking code complete: + +- [ ] Code is readable and well-named +- [ ] Functions are focused (<50 lines) +- [ ] Files are cohesive (<800 lines) +- [ ] No deep nesting (>4 levels) +- [ ] Errors are handled explicitly +- [ ] No hardcoded secrets or credentials +- [ ] No console.log or debug statements +- [ ] Tests exist for new functionality +- [ ] Test coverage meets 80% minimum + +## Security Review Triggers + +**STOP and use security-reviewer agent when:** + +- Authentication or authorization code +- User input handling +- Database queries +- File system operations +- External API calls +- Cryptographic operations +- Payment or financial code + +## Review Severity Levels + +| Level | Meaning | Action | +|-------|---------|--------| +| CRITICAL | Security vulnerability or data loss risk | **BLOCK** - Must fix before merge | +| HIGH | Bug or significant quality issue | **WARN** - Should fix before merge | +| MEDIUM | Maintainability concern | **INFO** - Consider fixing | +| LOW | Style or minor suggestion | **NOTE** - Optional | + +## Agent Usage + +Use these agents for code review: + +| Agent | Purpose | +|-------|---------| +| **code-reviewer** | General code quality, patterns, best practices | +| **security-reviewer** | Security vulnerabilities, OWASP Top 10 | +| **typescript-reviewer** | TypeScript/JavaScript specific issues | +| **python-reviewer** | Python specific issues | +| **go-reviewer** | Go specific issues | +| **rust-reviewer** | Rust specific issues | + +## Review Workflow + +``` +1. Run git diff to understand changes +2. Check security checklist first +3. Review code quality checklist +4. Run relevant tests +5. Verify coverage >= 80% +6. Use appropriate agent for detailed review +``` + +## Common Issues to Catch + +### Security + +- Hardcoded credentials (API keys, passwords, tokens) +- SQL injection (string concatenation in queries) +- XSS vulnerabilities (unescaped user input) +- Path traversal (unsanitized file paths) +- CSRF protection missing +- Authentication bypasses + +### Code Quality + +- Large functions (>50 lines) - split into smaller +- Large files (>800 lines) - extract modules +- Deep nesting (>4 levels) - use early returns +- Missing error handling - handle explicitly +- Mutation patterns - prefer immutable operations +- Missing tests - add test coverage + +### Performance + +- N+1 queries - use JOINs or batching +- Missing pagination - add LIMIT to queries +- Unbounded queries - add constraints +- Missing caching - cache expensive operations + +## Approval Criteria + +- **Approve**: No CRITICAL or HIGH issues +- **Warning**: Only HIGH issues (merge with caution) +- **Block**: CRITICAL issues found + +## Integration with Other Rules + +This rule works with: + +- [testing.md](testing.md) - Test coverage requirements +- [security.md](security.md) - Security checklist +- [git-workflow.md](git-workflow.md) - Commit standards +- [agents.md](agents.md) - Agent delegation diff --git a/.claude/rules/ecc/common/coding-style.md b/.claude/rules/ecc/common/coding-style.md new file mode 100644 index 0000000..e72f3f1 --- /dev/null +++ b/.claude/rules/ecc/common/coding-style.md @@ -0,0 +1,90 @@ +# Coding Style + +## Immutability (CRITICAL) + +ALWAYS create new objects, NEVER mutate existing ones: + +``` +// Pseudocode +WRONG: modify(original, field, value) → changes original in-place +CORRECT: update(original, field, value) → returns new copy with change +``` + +Rationale: Immutable data prevents hidden side effects, makes debugging easier, and enables safe concurrency. + +## Core Principles + +### KISS (Keep It Simple) + +- Prefer the simplest solution that actually works +- Avoid premature optimization +- Optimize for clarity over cleverness + +### DRY (Don't Repeat Yourself) + +- Extract repeated logic into shared functions or utilities +- Avoid copy-paste implementation drift +- Introduce abstractions when repetition is real, not speculative + +### YAGNI (You Aren't Gonna Need It) + +- Do not build features or abstractions before they are needed +- Avoid speculative generality +- Start simple, then refactor when the pressure is real + +## File Organization + +MANY SMALL FILES > FEW LARGE FILES: +- High cohesion, low coupling +- 200-400 lines typical, 800 max +- Extract utilities from large modules +- Organize by feature/domain, not by type + +## Error Handling + +ALWAYS handle errors comprehensively: +- Handle errors explicitly at every level +- Provide user-friendly error messages in UI-facing code +- Log detailed error context on the server side +- Never silently swallow errors + +## Input Validation + +ALWAYS validate at system boundaries: +- Validate all user input before processing +- Use schema-based validation where available +- Fail fast with clear error messages +- Never trust external data (API responses, user input, file content) + +## Naming Conventions + +- Variables and functions: `camelCase` with descriptive names +- Booleans: prefer `is`, `has`, `should`, or `can` prefixes +- Interfaces, types, and components: `PascalCase` +- Constants: `UPPER_SNAKE_CASE` +- Custom hooks: `camelCase` with a `use` prefix + +## Code Smells to Avoid + +### Deep Nesting + +Prefer early returns over nested conditionals once the logic starts stacking. + +### Magic Numbers + +Use named constants for meaningful thresholds, delays, and limits. + +### Long Functions + +Split large functions into focused pieces with clear responsibilities. + +## Code Quality Checklist + +Before marking work complete: +- [ ] Code is readable and well-named +- [ ] Functions are small (<50 lines) +- [ ] Files are focused (<800 lines) +- [ ] No deep nesting (>4 levels) +- [ ] Proper error handling +- [ ] No hardcoded values (use constants or config) +- [ ] No mutation (immutable patterns used) diff --git a/.claude/rules/ecc/common/development-workflow.md b/.claude/rules/ecc/common/development-workflow.md new file mode 100644 index 0000000..ae070be --- /dev/null +++ b/.claude/rules/ecc/common/development-workflow.md @@ -0,0 +1,44 @@ +# Development Workflow + +> This file extends [common/git-workflow.md](./git-workflow.md) with the full feature development process that happens before git operations. + +The Feature Implementation Workflow describes the development pipeline: research, planning, TDD, code review, and then committing to git. + +## Feature Implementation Workflow + +0. **Research & Reuse** _(mandatory before any new implementation)_ + - **GitHub code search first:** Run `gh search repos` and `gh search code` to find existing implementations, templates, and patterns before writing anything new. + - **Library docs second:** Use Context7 or primary vendor docs to confirm API behavior, package usage, and version-specific details before implementing. + - **Exa only when the first two are insufficient:** Use Exa for broader web research or discovery after GitHub search and primary docs. + - **Check package registries:** Search npm, PyPI, crates.io, and other registries before writing utility code. Prefer battle-tested libraries over hand-rolled solutions. + - **Search for adaptable implementations:** Look for open-source projects that solve 80%+ of the problem and can be forked, ported, or wrapped. + - Prefer adopting or porting a proven approach over writing net-new code when it meets the requirement. + +1. **Plan First** + - Use **planner** agent to create implementation plan + - Generate planning docs before coding: PRD, architecture, system_design, tech_doc, task_list + - Identify dependencies and risks + - Break down into phases + +2. **TDD Approach** + - Use **tdd-guide** agent + - Write tests first (RED) + - Implement to pass tests (GREEN) + - Refactor (IMPROVE) + - Verify 80%+ coverage + +3. **Code Review** + - Use **code-reviewer** agent immediately after writing code + - Address CRITICAL and HIGH issues + - Fix MEDIUM issues when possible + +4. **Commit & Push** + - Detailed commit messages + - Follow conventional commits format + - See [git-workflow.md](./git-workflow.md) for commit message format and PR process + +5. **Pre-Review Checks** + - Verify all automated checks (CI/CD) are passing + - Resolve any merge conflicts + - Ensure branch is up to date with target branch + - Only request review after these checks pass diff --git a/.claude/rules/ecc/common/git-workflow.md b/.claude/rules/ecc/common/git-workflow.md new file mode 100644 index 0000000..d57d9e2 --- /dev/null +++ b/.claude/rules/ecc/common/git-workflow.md @@ -0,0 +1,24 @@ +# Git Workflow + +## Commit Message Format +``` +: + + +``` + +Types: feat, fix, refactor, docs, test, chore, perf, ci + +Note: Attribution disabled globally via ~/.claude/settings.json. + +## Pull Request Workflow + +When creating PRs: +1. Analyze full commit history (not just latest commit) +2. Use `git diff [base-branch]...HEAD` to see all changes +3. Draft comprehensive PR summary +4. Include test plan with TODOs +5. Push with `-u` flag if new branch + +> For the full development process (planning, TDD, code review) before git operations, +> see [development-workflow.md](./development-workflow.md). diff --git a/.claude/rules/ecc/common/hooks.md b/.claude/rules/ecc/common/hooks.md new file mode 100644 index 0000000..5439408 --- /dev/null +++ b/.claude/rules/ecc/common/hooks.md @@ -0,0 +1,30 @@ +# Hooks System + +## Hook Types + +- **PreToolUse**: Before tool execution (validation, parameter modification) +- **PostToolUse**: After tool execution (auto-format, checks) +- **Stop**: When session ends (final verification) + +## Auto-Accept Permissions + +Use with caution: +- Enable for trusted, well-defined plans +- Disable for exploratory work +- Never use dangerously-skip-permissions flag +- Configure `allowedTools` in `~/.claude.json` instead + +## TodoWrite Best Practices + +Use TodoWrite tool to: +- Track progress on multi-step tasks +- Verify understanding of instructions +- Enable real-time steering +- Show granular implementation steps + +Todo list reveals: +- Out of order steps +- Missing items +- Extra unnecessary items +- Wrong granularity +- Misinterpreted requirements diff --git a/.claude/rules/ecc/common/patterns.md b/.claude/rules/ecc/common/patterns.md new file mode 100644 index 0000000..959939f --- /dev/null +++ b/.claude/rules/ecc/common/patterns.md @@ -0,0 +1,31 @@ +# Common Patterns + +## Skeleton Projects + +When implementing new functionality: +1. Search for battle-tested skeleton projects +2. Use parallel agents to evaluate options: + - Security assessment + - Extensibility analysis + - Relevance scoring + - Implementation planning +3. Clone best match as foundation +4. Iterate within proven structure + +## Design Patterns + +### Repository Pattern + +Encapsulate data access behind a consistent interface: +- Define standard operations: findAll, findById, create, update, delete +- Concrete implementations handle storage details (database, API, file, etc.) +- Business logic depends on the abstract interface, not the storage mechanism +- Enables easy swapping of data sources and simplifies testing with mocks + +### API Response Format + +Use a consistent envelope for all API responses: +- Include a success/status indicator +- Include the data payload (nullable on error) +- Include an error message field (nullable on success) +- Include metadata for paginated responses (total, page, limit) diff --git a/.claude/rules/ecc/common/performance.md b/.claude/rules/ecc/common/performance.md new file mode 100644 index 0000000..0a2f229 --- /dev/null +++ b/.claude/rules/ecc/common/performance.md @@ -0,0 +1,55 @@ +# Performance Optimization + +## Model Selection Strategy + +**Haiku** (90% of Sonnet capability, 3x cost savings): +- Lightweight agents with frequent invocation +- Pair programming and code generation +- Worker agents in multi-agent systems + +**Sonnet** (Best coding model): +- Main development work +- Orchestrating multi-agent workflows +- Complex coding tasks + +**Opus** (Deepest reasoning): +- Complex architectural decisions +- Maximum reasoning requirements +- Research and analysis tasks + +## Context Window Management + +Avoid last 20% of context window for: +- Large-scale refactoring +- Feature implementation spanning multiple files +- Debugging complex interactions + +Lower context sensitivity tasks: +- Single-file edits +- Independent utility creation +- Documentation updates +- Simple bug fixes + +## Extended Thinking + Plan Mode + +Extended thinking is enabled by default, reserving up to 31,999 tokens for internal reasoning. + +Control extended thinking via: +- **Toggle**: Option+T (macOS) / Alt+T (Windows/Linux) +- **Config**: Set `alwaysThinkingEnabled` in `~/.claude/settings.json` +- **Budget cap**: `export MAX_THINKING_TOKENS=10000` (bash) or `$env:MAX_THINKING_TOKENS = "10000"` (PowerShell) +- **Verbose mode**: Ctrl+O to see thinking output + +For complex tasks requiring deep reasoning: +1. Ensure extended thinking is enabled (on by default) +2. Enable **Plan Mode** for structured approach +3. Use multiple critique rounds for thorough analysis +4. Use split role sub-agents for diverse perspectives + +## Build Troubleshooting + +If build fails: +1. Use **build-error-resolver** agent +2. Analyze error messages +3. Fix incrementally +4. Verify after each fix diff --git a/.claude/rules/ecc/common/security.md b/.claude/rules/ecc/common/security.md new file mode 100644 index 0000000..49624c0 --- /dev/null +++ b/.claude/rules/ecc/common/security.md @@ -0,0 +1,29 @@ +# Security Guidelines + +## Mandatory Security Checks + +Before ANY commit: +- [ ] No hardcoded secrets (API keys, passwords, tokens) +- [ ] All user inputs validated +- [ ] SQL injection prevention (parameterized queries) +- [ ] XSS prevention (sanitized HTML) +- [ ] CSRF protection enabled +- [ ] Authentication/authorization verified +- [ ] Rate limiting on all endpoints +- [ ] Error messages don't leak sensitive data + +## Secret Management + +- NEVER hardcode secrets in source code +- ALWAYS use environment variables or a secret manager +- Validate that required secrets are present at startup +- Rotate any secrets that may have been exposed + +## Security Response Protocol + +If security issue found: +1. STOP immediately +2. Use **security-reviewer** agent +3. Fix CRITICAL issues before continuing +4. Rotate any exposed secrets +5. Review entire codebase for similar issues diff --git a/.claude/rules/ecc/common/testing.md b/.claude/rules/ecc/common/testing.md new file mode 100644 index 0000000..416c1c2 --- /dev/null +++ b/.claude/rules/ecc/common/testing.md @@ -0,0 +1,57 @@ +# Testing Requirements + +## Minimum Test Coverage: 80% + +Test Types (ALL required): +1. **Unit Tests** - Individual functions, utilities, components +2. **Integration Tests** - API endpoints, database operations +3. **E2E Tests** - Critical user flows (framework chosen per language) + +## Test-Driven Development + +MANDATORY workflow: +1. Write test first (RED) +2. Run test - it should FAIL +3. Write minimal implementation (GREEN) +4. Run test - it should PASS +5. Refactor (IMPROVE) +6. Verify coverage (80%+) + +## Troubleshooting Test Failures + +1. Use **tdd-guide** agent +2. Check test isolation +3. Verify mocks are correct +4. Fix implementation, not tests (unless tests are wrong) + +## Agent Support + +- **tdd-guide** - Use PROACTIVELY for new features, enforces write-tests-first + +## Test Structure (AAA Pattern) + +Prefer Arrange-Act-Assert structure for tests: + +```typescript +test('calculates similarity correctly', () => { + // Arrange + const vector1 = [1, 0, 0] + const vector2 = [0, 1, 0] + + // Act + const similarity = calculateCosineSimilarity(vector1, vector2) + + // Assert + expect(similarity).toBe(0) +}) +``` + +### Test Naming + +Use descriptive names that explain the behavior under test: + +```typescript +test('returns empty array when no markets match query', () => {}) +test('throws error when API key is missing', () => {}) +test('falls back to substring search when Redis is unavailable', () => {}) +``` diff --git a/.claude/rules/ecc/cpp/coding-style.md b/.claude/rules/ecc/cpp/coding-style.md new file mode 100644 index 0000000..3550077 --- /dev/null +++ b/.claude/rules/ecc/cpp/coding-style.md @@ -0,0 +1,44 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with C++ specific content. + +## Modern C++ (C++17/20/23) + +- Prefer **modern C++ features** over C-style constructs +- Use `auto` when the type is obvious from context +- Use `constexpr` for compile-time constants +- Use structured bindings: `auto [key, value] = map_entry;` + +## Resource Management + +- **RAII everywhere** — no manual `new`/`delete` +- Use `std::unique_ptr` for exclusive ownership +- Use `std::shared_ptr` only when shared ownership is truly needed +- Use `std::make_unique` / `std::make_shared` over raw `new` + +## Naming Conventions + +- Types/Classes: `PascalCase` +- Functions/Methods: `snake_case` or `camelCase` (follow project convention) +- Constants: `kPascalCase` or `UPPER_SNAKE_CASE` +- Namespaces: `lowercase` +- Member variables: `snake_case_` (trailing underscore) or `m_` prefix + +## Formatting + +- Use **clang-format** — no style debates +- Run `clang-format -i ` before committing + +## Reference + +See skill: `cpp-coding-standards` for comprehensive C++ coding standards and guidelines. diff --git a/.claude/rules/ecc/cpp/hooks.md b/.claude/rules/ecc/cpp/hooks.md new file mode 100644 index 0000000..4ab677a --- /dev/null +++ b/.claude/rules/ecc/cpp/hooks.md @@ -0,0 +1,39 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with C++ specific content. + +## Build Hooks + +Run these checks before committing C++ changes: + +```bash +# Format check +clang-format --dry-run --Werror src/*.cpp src/*.hpp + +# Static analysis +clang-tidy src/*.cpp -- -std=c++17 + +# Build +cmake --build build + +# Tests +ctest --test-dir build --output-on-failure +``` + +## Recommended CI Pipeline + +1. **clang-format** — formatting check +2. **clang-tidy** — static analysis +3. **cppcheck** — additional analysis +4. **cmake build** — compilation +5. **ctest** — test execution with sanitizers diff --git a/.claude/rules/ecc/cpp/patterns.md b/.claude/rules/ecc/cpp/patterns.md new file mode 100644 index 0000000..0c156e8 --- /dev/null +++ b/.claude/rules/ecc/cpp/patterns.md @@ -0,0 +1,51 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with C++ specific content. + +## RAII (Resource Acquisition Is Initialization) + +Tie resource lifetime to object lifetime: + +```cpp +class FileHandle { +public: + explicit FileHandle(const std::string& path) : file_(std::fopen(path.c_str(), "r")) {} + ~FileHandle() { if (file_) std::fclose(file_); } + FileHandle(const FileHandle&) = delete; + FileHandle& operator=(const FileHandle&) = delete; +private: + std::FILE* file_; +}; +``` + +## Rule of Five/Zero + +- **Rule of Zero**: Prefer classes that need no custom destructor, copy/move constructors, or assignments +- **Rule of Five**: If you define any of destructor/copy-ctor/copy-assign/move-ctor/move-assign, define all five + +## Value Semantics + +- Pass small/trivial types by value +- Pass large types by `const&` +- Return by value (rely on RVO/NRVO) +- Use move semantics for sink parameters + +## Error Handling + +- Use exceptions for exceptional conditions +- Use `std::optional` for values that may not exist +- Use `std::expected` (C++23) or result types for expected failures + +## Reference + +See skill: `cpp-coding-standards` for comprehensive C++ patterns and anti-patterns. diff --git a/.claude/rules/ecc/cpp/security.md b/.claude/rules/ecc/cpp/security.md new file mode 100644 index 0000000..0ee9f5f --- /dev/null +++ b/.claude/rules/ecc/cpp/security.md @@ -0,0 +1,51 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Security + +> This file extends [common/security.md](../common/security.md) with C++ specific content. + +## Memory Safety + +- Never use raw `new`/`delete` — use smart pointers +- Never use C-style arrays — use `std::array` or `std::vector` +- Never use `malloc`/`free` — use C++ allocation +- Avoid `reinterpret_cast` unless absolutely necessary + +## Buffer Overflows + +- Use `std::string` over `char*` +- Use `.at()` for bounds-checked access when safety matters +- Never use `strcpy`, `strcat`, `sprintf` — use `std::string` or `fmt::format` + +## Undefined Behavior + +- Always initialize variables +- Avoid signed integer overflow +- Never dereference null or dangling pointers +- Use sanitizers in CI: + ```bash + cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" .. + ``` + +## Static Analysis + +- Use **clang-tidy** for automated checks: + ```bash + clang-tidy --checks='*' src/*.cpp + ``` +- Use **cppcheck** for additional analysis: + ```bash + cppcheck --enable=all src/ + ``` + +## Reference + +See skill: `cpp-coding-standards` for detailed security guidelines. diff --git a/.claude/rules/ecc/cpp/testing.md b/.claude/rules/ecc/cpp/testing.md new file mode 100644 index 0000000..7c28355 --- /dev/null +++ b/.claude/rules/ecc/cpp/testing.md @@ -0,0 +1,44 @@ +--- +paths: + - "**/*.cpp" + - "**/*.hpp" + - "**/*.cc" + - "**/*.hh" + - "**/*.cxx" + - "**/*.h" + - "**/CMakeLists.txt" +--- +# C++ Testing + +> This file extends [common/testing.md](../common/testing.md) with C++ specific content. + +## Framework + +Use **GoogleTest** (gtest/gmock) with **CMake/CTest**. + +## Running Tests + +```bash +cmake --build build && ctest --test-dir build --output-on-failure +``` + +## Coverage + +```bash +cmake -DCMAKE_CXX_FLAGS="--coverage" -DCMAKE_EXE_LINKER_FLAGS="--coverage" .. +cmake --build . +ctest --output-on-failure +lcov --capture --directory . --output-file coverage.info +``` + +## Sanitizers + +Always run tests with sanitizers in CI: + +```bash +cmake -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined" .. +``` + +## Reference + +See skill: `cpp-testing` for detailed C++ testing patterns, TDD workflow, and GoogleTest/GMock usage. diff --git a/.claude/rules/ecc/csharp/coding-style.md b/.claude/rules/ecc/csharp/coding-style.md new file mode 100644 index 0000000..d97aaad --- /dev/null +++ b/.claude/rules/ecc/csharp/coding-style.md @@ -0,0 +1,72 @@ +--- +paths: + - "**/*.cs" + - "**/*.csx" +--- +# C# Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with C#-specific content. + +## Standards + +- Follow current .NET conventions and enable nullable reference types +- Prefer explicit access modifiers on public and internal APIs +- Keep files aligned with the primary type they define + +## Types and Models + +- Prefer `record` or `record struct` for immutable value-like models +- Use `class` for entities or types with identity and lifecycle +- Use `interface` for service boundaries and abstractions +- Avoid `dynamic` in application code; prefer generics or explicit models + +```csharp +public sealed record UserDto(Guid Id, string Email); + +public interface IUserRepository +{ + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); +} +``` + +## Immutability + +- Prefer `init` setters, constructor parameters, and immutable collections for shared state +- Do not mutate input models in-place when producing updated state + +```csharp +public sealed record UserProfile(string Name, string Email); + +public static UserProfile Rename(UserProfile profile, string name) => + profile with { Name = name }; +``` + +## Async and Error Handling + +- Prefer `async`/`await` over blocking calls like `.Result` or `.Wait()` +- Pass `CancellationToken` through public async APIs +- Throw specific exceptions and log with structured properties + +```csharp +public async Task LoadOrderAsync( + Guid orderId, + CancellationToken cancellationToken) +{ + try + { + return await repository.FindAsync(orderId, cancellationToken) + ?? throw new InvalidOperationException($"Order {orderId} was not found."); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to load order {OrderId}", orderId); + throw; + } +} +``` + +## Formatting + +- Use `dotnet format` for formatting and analyzer fixes +- Keep `using` directives organized and remove unused imports +- Prefer expression-bodied members only when they stay readable diff --git a/.claude/rules/ecc/csharp/hooks.md b/.claude/rules/ecc/csharp/hooks.md new file mode 100644 index 0000000..f7a46ef --- /dev/null +++ b/.claude/rules/ecc/csharp/hooks.md @@ -0,0 +1,25 @@ +--- +paths: + - "**/*.cs" + - "**/*.csx" + - "**/*.csproj" + - "**/*.sln" + - "**/Directory.Build.props" + - "**/Directory.Build.targets" +--- +# C# Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with C#-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **dotnet format**: Auto-format edited C# files and apply analyzer fixes +- **dotnet build**: Verify the solution or project still compiles after edits +- **dotnet test --no-build**: Re-run the nearest relevant test project after behavior changes + +## Stop Hooks + +- Run a final `dotnet build` before ending a session with broad C# changes +- Warn on modified `appsettings*.json` files so secrets do not get committed diff --git a/.claude/rules/ecc/csharp/patterns.md b/.claude/rules/ecc/csharp/patterns.md new file mode 100644 index 0000000..a94aba7 --- /dev/null +++ b/.claude/rules/ecc/csharp/patterns.md @@ -0,0 +1,50 @@ +--- +paths: + - "**/*.cs" + - "**/*.csx" +--- +# C# Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with C#-specific content. + +## API Response Pattern + +```csharp +public sealed record ApiResponse( + bool Success, + T? Data = default, + string? Error = null, + object? Meta = null); +``` + +## Repository Pattern + +```csharp +public interface IRepository +{ + Task> FindAllAsync(CancellationToken cancellationToken); + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); + Task CreateAsync(T entity, CancellationToken cancellationToken); + Task UpdateAsync(T entity, CancellationToken cancellationToken); + Task DeleteAsync(Guid id, CancellationToken cancellationToken); +} +``` + +## Options Pattern + +Use strongly typed options for config instead of reading raw strings throughout the codebase. + +```csharp +public sealed class PaymentsOptions +{ + public const string SectionName = "Payments"; + public required string BaseUrl { get; init; } + public required string ApiKeySecretName { get; init; } +} +``` + +## Dependency Injection + +- Depend on interfaces at service boundaries +- Keep constructors focused; if a service needs too many dependencies, split responsibilities +- Register lifetimes intentionally: singleton for stateless/shared services, scoped for request data, transient for lightweight pure workers diff --git a/.claude/rules/ecc/csharp/security.md b/.claude/rules/ecc/csharp/security.md new file mode 100644 index 0000000..9eb7c23 --- /dev/null +++ b/.claude/rules/ecc/csharp/security.md @@ -0,0 +1,58 @@ +--- +paths: + - "**/*.cs" + - "**/*.csx" + - "**/*.csproj" + - "**/appsettings*.json" +--- +# C# Security + +> This file extends [common/security.md](../common/security.md) with C#-specific content. + +## Secret Management + +- Never hardcode API keys, tokens, or connection strings in source code +- Use environment variables, user secrets for local development, and a secret manager in production +- Keep `appsettings.*.json` free of real credentials + +```csharp +// BAD +const string ApiKey = "sk-live-123"; + +// GOOD +var apiKey = builder.Configuration["OpenAI:ApiKey"] + ?? throw new InvalidOperationException("OpenAI:ApiKey is not configured."); +``` + +## SQL Injection Prevention + +- Always use parameterized queries with ADO.NET, Dapper, or EF Core +- Never concatenate user input into SQL strings +- Validate sort fields and filter operators before using dynamic query composition + +```csharp +const string sql = "SELECT * FROM Orders WHERE CustomerId = @customerId"; +await connection.QueryAsync(sql, new { customerId }); +``` + +## Input Validation + +- Validate DTOs at the application boundary +- Use data annotations, FluentValidation, or explicit guard clauses +- Reject invalid model state before running business logic + +## Authentication and Authorization + +- Prefer framework auth handlers instead of custom token parsing +- Enforce authorization policies at endpoint or handler boundaries +- Never log raw tokens, passwords, or PII + +## Error Handling + +- Return safe client-facing messages +- Log detailed exceptions with structured context server-side +- Do not expose stack traces, SQL text, or filesystem paths in API responses + +## References + +See skill: `security-review` for broader application security review checklists. diff --git a/.claude/rules/ecc/csharp/testing.md b/.claude/rules/ecc/csharp/testing.md new file mode 100644 index 0000000..a00f012 --- /dev/null +++ b/.claude/rules/ecc/csharp/testing.md @@ -0,0 +1,46 @@ +--- +paths: + - "**/*.cs" + - "**/*.csx" + - "**/*.csproj" +--- +# C# Testing + +> This file extends [common/testing.md](../common/testing.md) with C#-specific content. + +## Test Framework + +- Prefer **xUnit** for unit and integration tests +- Use **FluentAssertions** for readable assertions +- Use **Moq** or **NSubstitute** for mocking dependencies +- Use **Testcontainers** when integration tests need real infrastructure + +## Test Organization + +- Mirror `src/` structure under `tests/` +- Separate unit, integration, and end-to-end coverage clearly +- Name tests by behavior, not implementation details + +```csharp +public sealed class OrderServiceTests +{ + [Fact] + public async Task FindByIdAsync_ReturnsOrder_WhenOrderExists() + { + // Arrange + // Act + // Assert + } +} +``` + +## ASP.NET Core Integration Tests + +- Use `WebApplicationFactory` for API integration coverage +- Test auth, validation, and serialization through HTTP, not by bypassing middleware + +## Coverage + +- Target 80%+ line coverage +- Focus coverage on domain logic, validation, auth, and failure paths +- Run `dotnet test` in CI with coverage collection enabled where available diff --git a/.claude/rules/ecc/dart/coding-style.md b/.claude/rules/ecc/dart/coding-style.md new file mode 100644 index 0000000..f79c1fc --- /dev/null +++ b/.claude/rules/ecc/dart/coding-style.md @@ -0,0 +1,159 @@ +--- +paths: + - "**/*.dart" + - "**/pubspec.yaml" + - "**/analysis_options.yaml" +--- +# Dart/Flutter Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Dart and Flutter-specific content. + +## Formatting + +- **dart format** for all `.dart` files — enforced in CI (`dart format --set-exit-if-changed .`) +- Line length: 80 characters (dart format default) +- Trailing commas on multi-line argument/parameter lists to improve diffs and formatting + +## Immutability + +- Prefer `final` for local variables and `const` for compile-time constants +- Use `const` constructors wherever all fields are `final` +- Return unmodifiable collections from public APIs (`List.unmodifiable`, `Map.unmodifiable`) +- Use `copyWith()` for state mutations in immutable state classes + +```dart +// BAD +var count = 0; +List items = ['a', 'b']; + +// GOOD +final count = 0; +const items = ['a', 'b']; +``` + +## Naming + +Follow Dart conventions: +- `camelCase` for variables, parameters, and named constructors +- `PascalCase` for classes, enums, typedefs, and extensions +- `snake_case` for file names and library names +- `SCREAMING_SNAKE_CASE` for constants declared with `const` at top level +- Prefix private members with `_` +- Extension names describe the type they extend: `StringExtensions`, not `MyHelpers` + +## Null Safety + +- Avoid `!` (bang operator) — prefer `?.`, `??`, `if (x != null)`, or Dart 3 pattern matching; reserve `!` only where a null value is a programming error and crashing is the right behaviour +- Avoid `late` unless initialization is guaranteed before first use (prefer nullable or constructor init) +- Use `required` for constructor parameters that must always be provided + +```dart +// BAD — crashes at runtime if user is null +final name = user!.name; + +// GOOD — null-aware operators +final name = user?.name ?? 'Unknown'; + +// GOOD — Dart 3 pattern matching (exhaustive, compiler-checked) +final name = switch (user) { + User(:final name) => name, + null => 'Unknown', +}; + +// GOOD — early-return null guard +String getUserName(User? user) { + if (user == null) return 'Unknown'; + return user.name; // promoted to non-null after the guard +} +``` + +## Sealed Types and Pattern Matching (Dart 3+) + +Use sealed classes to model closed state hierarchies: + +```dart +sealed class AsyncState { + const AsyncState(); +} + +final class Loading extends AsyncState { + const Loading(); +} + +final class Success extends AsyncState { + const Success(this.data); + final T data; +} + +final class Failure extends AsyncState { + const Failure(this.error); + final Object error; +} +``` + +Always use exhaustive `switch` with sealed types — no default/wildcard: + +```dart +// BAD +if (state is Loading) { ... } + +// GOOD +return switch (state) { + Loading() => const CircularProgressIndicator(), + Success(:final data) => DataWidget(data), + Failure(:final error) => ErrorWidget(error.toString()), +}; +``` + +## Error Handling + +- Specify exception types in `on` clauses — never use bare `catch (e)` +- Never catch `Error` subtypes — they indicate programming bugs +- Use `Result`-style types or sealed classes for recoverable errors +- Avoid using exceptions for control flow + +```dart +// BAD +try { + await fetchUser(); +} catch (e) { + log(e.toString()); +} + +// GOOD +try { + await fetchUser(); +} on NetworkException catch (e) { + log('Network error: ${e.message}'); +} on NotFoundException { + handleNotFound(); +} +``` + +## Async / Futures + +- Always `await` Futures or explicitly call `unawaited()` to signal intentional fire-and-forget +- Never mark a function `async` if it never `await`s anything +- Use `Future.wait` / `Future.any` for concurrent operations +- Check `context.mounted` before using `BuildContext` after any `await` (Flutter 3.7+) + +```dart +// BAD — ignoring Future +fetchData(); // fire-and-forget without marking intent + +// GOOD +unawaited(fetchData()); // explicit fire-and-forget +await fetchData(); // or properly awaited +``` + +## Imports + +- Use `package:` imports throughout — never relative imports (`../`) for cross-feature or cross-layer code +- Order: `dart:` → external `package:` → internal `package:` (same package) +- No unused imports — `dart analyze` enforces this with `unused_import` + +## Code Generation + +- Generated files (`.g.dart`, `.freezed.dart`, `.gr.dart`) must be committed or gitignored consistently — pick one strategy per project +- Never manually edit generated files +- Keep generator annotations (`@JsonSerializable`, `@freezed`, `@riverpod`, etc.) on the canonical source file only diff --git a/.claude/rules/ecc/dart/hooks.md b/.claude/rules/ecc/dart/hooks.md new file mode 100644 index 0000000..d120efb --- /dev/null +++ b/.claude/rules/ecc/dart/hooks.md @@ -0,0 +1,66 @@ +--- +paths: + - "**/*.dart" + - "**/pubspec.yaml" + - "**/analysis_options.yaml" +--- +# Dart/Flutter Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Dart and Flutter-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **dart format**: Auto-format `.dart` files after edit +- **dart analyze**: Run static analysis after editing Dart files and surface warnings +- **flutter test**: Optionally run affected tests after significant changes + +## Recommended Hook Configuration + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": { "tool_name": "Edit", "file_paths": ["**/*.dart"] }, + "hooks": [ + { "type": "command", "command": "dart format $CLAUDE_FILE_PATHS" } + ] + } + ] + } +} +``` + +## Pre-commit Checks + +Run before committing Dart/Flutter changes: + +```bash +dart format --set-exit-if-changed . +dart analyze --fatal-infos +flutter test +``` + +## Useful One-liners + +```bash +# Format all Dart files +dart format . + +# Analyze and report issues +dart analyze + +# Run all tests with coverage +flutter test --coverage + +# Regenerate code-gen files +dart run build_runner build --delete-conflicting-outputs + +# Check for outdated packages +flutter pub outdated + +# Upgrade packages within constraints +flutter pub upgrade +``` diff --git a/.claude/rules/ecc/dart/patterns.md b/.claude/rules/ecc/dart/patterns.md new file mode 100644 index 0000000..94bf41b --- /dev/null +++ b/.claude/rules/ecc/dart/patterns.md @@ -0,0 +1,261 @@ +--- +paths: + - "**/*.dart" + - "**/pubspec.yaml" +--- +# Dart/Flutter Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Dart, Flutter, and common ecosystem-specific content. + +## Repository Pattern + +```dart +abstract interface class UserRepository { + Future getById(String id); + Future> getAll(); + Stream> watchAll(); + Future save(User user); + Future delete(String id); +} + +class UserRepositoryImpl implements UserRepository { + const UserRepositoryImpl(this._remote, this._local); + + final UserRemoteDataSource _remote; + final UserLocalDataSource _local; + + @override + Future getById(String id) async { + final local = await _local.getById(id); + if (local != null) return local; + final remote = await _remote.getById(id); + if (remote != null) await _local.save(remote); + return remote; + } + + @override + Future> getAll() async { + final remote = await _remote.getAll(); + for (final user in remote) { + await _local.save(user); + } + return remote; + } + + @override + Stream> watchAll() => _local.watchAll(); + + @override + Future save(User user) => _local.save(user); + + @override + Future delete(String id) async { + await _remote.delete(id); + await _local.delete(id); + } +} +``` + +## State Management: BLoC/Cubit + +```dart +// Cubit — simple state transitions +class CounterCubit extends Cubit { + CounterCubit() : super(0); + + void increment() => emit(state + 1); + void decrement() => emit(state - 1); +} + +// BLoC — event-driven +@immutable +sealed class CartEvent {} +class CartItemAdded extends CartEvent { CartItemAdded(this.item); final Item item; } +class CartItemRemoved extends CartEvent { CartItemRemoved(this.id); final String id; } +class CartCleared extends CartEvent {} + +@immutable +class CartState { + const CartState({this.items = const []}); + final List items; + CartState copyWith({List? items}) => CartState(items: items ?? this.items); +} + +class CartBloc extends Bloc { + CartBloc() : super(const CartState()) { + on((event, emit) => + emit(state.copyWith(items: [...state.items, event.item]))); + on((event, emit) => + emit(state.copyWith(items: state.items.where((i) => i.id != event.id).toList()))); + on((_, emit) => emit(const CartState())); + } +} +``` + +## State Management: Riverpod + +```dart +// Simple provider +@riverpod +Future> users(Ref ref) async { + final repo = ref.watch(userRepositoryProvider); + return repo.getAll(); +} + +// Notifier for mutable state +@riverpod +class CartNotifier extends _$CartNotifier { + @override + List build() => []; + + void add(Item item) => state = [...state, item]; + void remove(String id) => state = state.where((i) => i.id != id).toList(); + void clear() => state = []; +} + +// ConsumerWidget +class CartPage extends ConsumerWidget { + const CartPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final items = ref.watch(cartNotifierProvider); + return ListView( + children: items.map((item) => CartItemTile(item: item)).toList(), + ); + } +} +``` + +## Dependency Injection + +Constructor injection is preferred. Use `get_it` or Riverpod providers at composition root: + +```dart +// get_it registration (in a setup file) +void setupDependencies() { + final di = GetIt.instance; + di.registerSingleton(ApiClient(baseUrl: Env.apiUrl)); + di.registerSingleton( + UserRepositoryImpl(di(), di()), + ); + di.registerFactory(() => UserListViewModel(di())); +} +``` + +## ViewModel Pattern (without BLoC/Riverpod) + +```dart +class UserListViewModel extends ChangeNotifier { + UserListViewModel(this._repository); + + final UserRepository _repository; + + AsyncState> _state = const Loading(); + AsyncState> get state => _state; + + Future load() async { + _state = const Loading(); + notifyListeners(); + try { + final users = await _repository.getAll(); + _state = Success(users); + } on Exception catch (e) { + _state = Failure(e); + } + notifyListeners(); + } +} +``` + +## UseCase Pattern + +```dart +class GetUserUseCase { + const GetUserUseCase(this._repository); + final UserRepository _repository; + + Future call(String id) => _repository.getById(id); +} + +class CreateUserUseCase { + const CreateUserUseCase(this._repository, this._idGenerator); + final UserRepository _repository; + final IdGenerator _idGenerator; // injected — domain layer must not depend on uuid package directly + + Future call(CreateUserInput input) async { + // Validate, apply business rules, then persist + final user = User(id: _idGenerator.generate(), name: input.name, email: input.email); + await _repository.save(user); + } +} +``` + +## Immutable State with freezed + +```dart +@freezed +class UserState with _$UserState { + const factory UserState({ + @Default([]) List users, + @Default(false) bool isLoading, + String? errorMessage, + }) = _UserState; +} +``` + +## Clean Architecture Layer Boundaries + +``` +lib/ +├── domain/ # Pure Dart — no Flutter, no external packages +│ ├── entities/ +│ ├── repositories/ # Abstract interfaces +│ └── usecases/ +├── data/ # Implements domain interfaces +│ ├── datasources/ +│ ├── models/ # DTOs with fromJson/toJson +│ └── repositories/ +└── presentation/ # Flutter widgets + state management + ├── pages/ + ├── widgets/ + └── providers/ (or blocs/ or viewmodels/) +``` + +- Domain must not import `package:flutter` or any data-layer package +- Data layer maps DTOs to domain entities at repository boundaries +- Presentation calls use cases, not repositories directly + +## Navigation (GoRouter) + +```dart +final router = GoRouter( + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const HomePage(), + ), + GoRoute( + path: '/users/:id', + builder: (context, state) { + final id = state.pathParameters['id']!; + return UserDetailPage(userId: id); + }, + ), + ], + // refreshListenable re-evaluates redirect whenever auth state changes + refreshListenable: GoRouterRefreshStream(authCubit.stream), + redirect: (context, state) { + final isLoggedIn = context.read().state is AuthAuthenticated; + if (!isLoggedIn && !state.matchedLocation.startsWith('/login')) { + return '/login'; + } + return null; + }, +); +``` + +## References + +See skill: `flutter-dart-code-review` for the comprehensive review checklist. +See skill: `compose-multiplatform-patterns` for Kotlin Multiplatform/Flutter interop patterns. diff --git a/.claude/rules/ecc/dart/security.md b/.claude/rules/ecc/dart/security.md new file mode 100644 index 0000000..74d9643 --- /dev/null +++ b/.claude/rules/ecc/dart/security.md @@ -0,0 +1,135 @@ +--- +paths: + - "**/*.dart" + - "**/pubspec.yaml" + - "**/AndroidManifest.xml" + - "**/Info.plist" +--- +# Dart/Flutter Security + +> This file extends [common/security.md](../common/security.md) with Dart, Flutter, and mobile-specific content. + +## Secrets Management + +- Never hardcode API keys, tokens, or credentials in Dart source +- Use `--dart-define` or `--dart-define-from-file` for compile-time config (values are not truly secret — use a backend proxy for server-side secrets) +- Use `flutter_dotenv` or equivalent, with `.env` files listed in `.gitignore` +- Store runtime secrets in platform-secure storage: `flutter_secure_storage` (Keychain on iOS, EncryptedSharedPreferences on Android) + +```dart +// BAD +const apiKey = 'sk-abc123...'; + +// GOOD — compile-time config (not secret, just configurable) +const apiKey = String.fromEnvironment('API_KEY'); + +// GOOD — runtime secret from secure storage +final token = await secureStorage.read(key: 'auth_token'); +``` + +## Network Security + +- Enforce HTTPS — no `http://` calls in production +- Configure Android `network_security_config.xml` to block cleartext traffic +- Set `NSAppTransportSecurity` in `Info.plist` to disallow arbitrary loads +- Set request timeouts on all HTTP clients — never leave defaults +- Consider certificate pinning for high-security endpoints + +```dart +// Dio with timeout and HTTPS enforcement +final dio = Dio(BaseOptions( + baseUrl: 'https://api.example.com', + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), +)); +``` + +## Input Validation + +- Validate and sanitize all user input before sending to API or storage +- Never pass unsanitized input to SQL queries — use parameterized queries (sqflite, drift) +- Sanitize deep link URLs before navigation — validate scheme, host, and path parameters +- Use `Uri.tryParse` and validate before navigating + +```dart +// BAD — SQL injection +await db.rawQuery("SELECT * FROM users WHERE email = '$userInput'"); + +// GOOD — parameterized +await db.query('users', where: 'email = ?', whereArgs: [userInput]); + +// BAD — unvalidated deep link +final uri = Uri.parse(incomingLink); +context.go(uri.path); // could navigate to any route + +// GOOD — validated deep link +final uri = Uri.tryParse(incomingLink); +if (uri != null && uri.host == 'myapp.com' && _allowedPaths.contains(uri.path)) { + context.go(uri.path); +} +``` + +## Data Protection + +- Store tokens, PII, and credentials only in `flutter_secure_storage` +- Never write sensitive data to `SharedPreferences` or local files in plaintext +- Clear auth state on logout: tokens, cached user data, cookies +- Use biometric authentication (`local_auth`) for sensitive operations +- Avoid logging sensitive data — no `print(token)` or `debugPrint(password)` + +## Android-Specific + +- Declare only required permissions in `AndroidManifest.xml` +- Export Android components (`Activity`, `Service`, `BroadcastReceiver`) only when necessary; add `android:exported="false"` where not needed +- Review intent filters — exported components with implicit intent filters are accessible by any app +- Use `FLAG_SECURE` for screens displaying sensitive data (prevents screenshots) + +```xml + + + + + +``` + +## iOS-Specific + +- Declare only required usage descriptions in `Info.plist` (`NSCameraUsageDescription`, etc.) +- Store secrets in Keychain — `flutter_secure_storage` uses Keychain on iOS +- Use App Transport Security (ATS) — disallow arbitrary loads +- Enable data protection entitlement for sensitive files + +## WebView Security + +- Use `webview_flutter` v4+ (`WebViewController` / `WebViewWidget`) — the legacy `WebView` widget is removed +- Disable JavaScript unless explicitly required (`JavaScriptMode.disabled`) +- Validate URLs before loading — never load arbitrary URLs from deep links +- Never expose Dart callbacks to JavaScript unless absolutely needed and carefully sandboxed +- Use `NavigationDelegate.onNavigationRequest` to intercept and validate navigation requests + +```dart +// webview_flutter v4+ API (WebViewController + WebViewWidget) +final controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.disabled) // disabled unless required + ..setNavigationDelegate( + NavigationDelegate( + onNavigationRequest: (request) { + final uri = Uri.tryParse(request.url); + if (uri == null || uri.host != 'trusted.example.com') { + return NavigationDecision.prevent; + } + return NavigationDecision.navigate; + }, + ), + ); + +// In your widget tree: +WebViewWidget(controller: controller) +``` + +## Obfuscation and Build Security + +- Enable obfuscation in release builds: `flutter build apk --obfuscate --split-debug-info=./debug-info/` +- Keep `--split-debug-info` output out of version control (used for crash symbolication only) +- Ensure ProGuard/R8 rules don't inadvertently expose serialized classes +- Run `flutter analyze` and address all warnings before release diff --git a/.claude/rules/ecc/dart/testing.md b/.claude/rules/ecc/dart/testing.md new file mode 100644 index 0000000..bd6ff62 --- /dev/null +++ b/.claude/rules/ecc/dart/testing.md @@ -0,0 +1,215 @@ +--- +paths: + - "**/*.dart" + - "**/pubspec.yaml" + - "**/analysis_options.yaml" +--- +# Dart/Flutter Testing + +> This file extends [common/testing.md](../common/testing.md) with Dart and Flutter-specific content. + +## Test Framework + +- **flutter_test** / **dart:test** — built-in test runner +- **mockito** (with `@GenerateMocks`) or **mocktail** (no codegen) for mocking +- **bloc_test** for BLoC/Cubit unit tests +- **fake_async** for controlling time in unit tests +- **integration_test** for end-to-end device tests + +## Test Types + +| Type | Tool | Location | When to Write | +|------|------|----------|---------------| +| Unit | `dart:test` | `test/unit/` | All domain logic, state managers, repositories | +| Widget | `flutter_test` | `test/widget/` | All widgets with meaningful behavior | +| Golden | `flutter_test` | `test/golden/` | Design-critical UI components | +| Integration | `integration_test` | `integration_test/` | Critical user flows on real device/emulator | + +## Unit Tests: State Managers + +### BLoC with `bloc_test` + +```dart +group('CartBloc', () { + late CartBloc bloc; + late MockCartRepository repository; + + setUp(() { + repository = MockCartRepository(); + bloc = CartBloc(repository); + }); + + tearDown(() => bloc.close()); + + blocTest( + 'emits updated items when CartItemAdded', + build: () => bloc, + act: (b) => b.add(CartItemAdded(testItem)), + expect: () => [CartState(items: [testItem])], + ); + + blocTest( + 'emits empty cart when CartCleared', + seed: () => CartState(items: [testItem]), + build: () => bloc, + act: (b) => b.add(CartCleared()), + expect: () => [const CartState()], + ); +}); +``` + +### Riverpod with `ProviderContainer` + +```dart +test('usersProvider loads users from repository', () async { + final container = ProviderContainer( + overrides: [userRepositoryProvider.overrideWithValue(FakeUserRepository())], + ); + addTearDown(container.dispose); + + final result = await container.read(usersProvider.future); + expect(result, isNotEmpty); +}); +``` + +## Widget Tests + +```dart +testWidgets('CartPage shows item count badge', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + cartNotifierProvider.overrideWith(() => FakeCartNotifier([testItem])), + ], + child: const MaterialApp(home: CartPage()), + ), + ); + + await tester.pump(); + expect(find.text('1'), findsOneWidget); + expect(find.byType(CartItemTile), findsOneWidget); +}); + +testWidgets('shows empty state when cart is empty', (tester) async { + await tester.pumpWidget( + ProviderScope( + overrides: [cartNotifierProvider.overrideWith(() => FakeCartNotifier([]))], + child: const MaterialApp(home: CartPage()), + ), + ); + + await tester.pump(); + expect(find.text('Your cart is empty'), findsOneWidget); +}); +``` + +## Fakes Over Mocks + +Prefer hand-written fakes for complex dependencies: + +```dart +class FakeUserRepository implements UserRepository { + final _users = {}; + Object? fetchError; + + @override + Future getById(String id) async { + if (fetchError != null) throw fetchError!; + return _users[id]; + } + + @override + Future> getAll() async { + if (fetchError != null) throw fetchError!; + return _users.values.toList(); + } + + @override + Stream> watchAll() => Stream.value(_users.values.toList()); + + @override + Future save(User user) async { + _users[user.id] = user; + } + + @override + Future delete(String id) async { + _users.remove(id); + } + + void addUser(User user) => _users[user.id] = user; +} +``` + +## Async Testing + +```dart +// Use fake_async for controlling timers and Futures +test('debounce triggers after 300ms', () { + fakeAsync((async) { + final debouncer = Debouncer(delay: const Duration(milliseconds: 300)); + var callCount = 0; + debouncer.run(() => callCount++); + expect(callCount, 0); + async.elapse(const Duration(milliseconds: 200)); + expect(callCount, 0); + async.elapse(const Duration(milliseconds: 200)); + expect(callCount, 1); + }); +}); +``` + +## Golden Tests + +```dart +testWidgets('UserCard golden test', (tester) async { + await tester.pumpWidget( + MaterialApp(home: UserCard(user: testUser)), + ); + + await expectLater( + find.byType(UserCard), + matchesGoldenFile('goldens/user_card.png'), + ); +}); +``` + +Run `flutter test --update-goldens` when intentional visual changes are made. + +## Test Naming + +Use descriptive, behavior-focused names: + +```dart +test('returns null when user does not exist', () { ... }); +test('throws NotFoundException when id is empty string', () { ... }); +testWidgets('disables submit button while form is invalid', (tester) async { ... }); +``` + +## Test Organization + +``` +test/ +├── unit/ +│ ├── domain/ +│ │ └── usecases/ +│ └── data/ +│ └── repositories/ +├── widget/ +│ └── presentation/ +│ └── pages/ +└── golden/ + └── widgets/ + +integration_test/ +└── flows/ + ├── login_flow_test.dart + └── checkout_flow_test.dart +``` + +## Coverage + +- Target 80%+ line coverage for business logic (domain + state managers) +- All state transitions must have tests: loading → success, loading → error, retry +- Run `flutter test --coverage` and inspect `lcov.info` with a coverage reporter +- Coverage failures should block CI when below threshold diff --git a/.claude/rules/ecc/fsharp/coding-style.md b/.claude/rules/ecc/fsharp/coding-style.md new file mode 100644 index 0000000..89e2977 --- /dev/null +++ b/.claude/rules/ecc/fsharp/coding-style.md @@ -0,0 +1,112 @@ +--- +paths: + - "**/*.fs" + - "**/*.fsx" +--- +# F# Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with F#-specific content. + +## Standards + +- Follow standard F# conventions and leverage the type system for correctness +- Prefer immutability by default; use `mutable` only when justified by performance +- Keep modules focused and cohesive + +## Types and Models + +- Prefer discriminated unions for domain modeling over class hierarchies +- Use records for data with named fields +- Use single-case unions for type-safe wrappers around primitives +- Avoid classes unless interop or mutable state requires them + +```fsharp +type EmailAddress = EmailAddress of string + +type OrderStatus = + | Pending + | Confirmed of confirmedAt: DateTimeOffset + | Shipped of trackingNumber: string + | Cancelled of reason: string + +type Order = + { Id: Guid + CustomerId: string + Status: OrderStatus + Items: OrderItem list } +``` + +## Immutability + +- Records are immutable by default; use `with` expressions for updates +- Prefer `list`, `map`, `set` over mutable collections +- Avoid `ref` cells and mutable fields in domain logic + +```fsharp +let rename (profile: UserProfile) newName = + { profile with Name = newName } +``` + +## Function Style + +- Prefer small, composable functions over large methods +- Use the pipe operator `|>` to build readable data pipelines +- Prefer pattern matching over if/else chains +- Use `Option` instead of null; use `Result` for operations that can fail + +```fsharp +let processOrder order = + order + |> validateItems + |> Result.bind calculateTotal + |> Result.map applyDiscount + |> Result.mapError OrderError +``` + +## Async and Error Handling + +- Use `task { }` for interop with .NET async APIs +- Use `async { }` for F#-native async workflows +- Propagate `CancellationToken` through public async APIs +- Prefer `Result` and railway-oriented programming over exceptions for expected failures + +```fsharp +let loadOrderAsync (orderId: Guid) (ct: CancellationToken) = + task { + let! order = repository.FindAsync(orderId, ct) + return + order + |> Option.defaultWith (fun () -> + failwith $"Order {orderId} was not found.") + } +``` + +## Formatting + +- Use `fantomas` for automatic formatting +- Prefer significant whitespace; avoid unnecessary parentheses +- Remove unused `open` declarations + +### Open Declaration Order + +Group `open` statements into four sections separated by a blank line, each section sorted lexically within itself: + +1. `System.*` +2. `Microsoft.*` +3. Third-party namespaces +4. First-party / project namespaces + +```fsharp +open System +open System.Collections.Generic +open System.Threading.Tasks + +open Microsoft.AspNetCore.Http +open Microsoft.Extensions.Logging + +open FsCheck.Xunit +open Swensen.Unquote + +open MyApp.Domain +open MyApp.Infrastructure +``` diff --git a/.claude/rules/ecc/fsharp/hooks.md b/.claude/rules/ecc/fsharp/hooks.md new file mode 100644 index 0000000..9108d20 --- /dev/null +++ b/.claude/rules/ecc/fsharp/hooks.md @@ -0,0 +1,26 @@ +--- +paths: + - "**/*.fs" + - "**/*.fsx" + - "**/*.fsproj" + - "**/*.sln" + - "**/*.slnx" + - "**/Directory.Build.props" + - "**/Directory.Build.targets" +--- +# F# Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with F#-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **fantomas**: Auto-format edited F# files +- **dotnet build**: Verify the solution or project still compiles after edits +- **dotnet test --no-build**: Re-run the nearest relevant test project after behavior changes + +## Stop Hooks + +- Run a final `dotnet build` before ending a session with broad F# changes +- Warn on modified `appsettings*.json` files so secrets do not get committed diff --git a/.claude/rules/ecc/fsharp/patterns.md b/.claude/rules/ecc/fsharp/patterns.md new file mode 100644 index 0000000..490b995 --- /dev/null +++ b/.claude/rules/ecc/fsharp/patterns.md @@ -0,0 +1,111 @@ +--- +paths: + - "**/*.fs" + - "**/*.fsx" +--- +# F# Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with F#-specific content. + +## Result Type for Error Handling + +Use `Result<'T, 'TError>` with railway-oriented programming instead of exceptions for expected failures. + +```fsharp +type OrderError = + | InvalidCustomer of string + | EmptyItems + | ItemOutOfStock of sku: string + +let validateOrder (request: CreateOrderRequest) : Result = + if String.IsNullOrWhiteSpace request.CustomerId then + Error(InvalidCustomer "CustomerId is required") + elif request.Items |> List.isEmpty then + Error EmptyItems + else + Ok { CustomerId = request.CustomerId; Items = request.Items } +``` + +## Option for Missing Values + +Prefer `Option<'T>` over null. Use `Option.map`, `Option.bind`, and `Option.defaultValue` to transform. + +```fsharp +let findUser (id: Guid) : User option = + users |> Map.tryFind id + +let getUserEmail userId = + findUser userId + |> Option.map (fun u -> u.Email) + |> Option.defaultValue "unknown@example.com" +``` + +## Discriminated Unions for Domain Modeling + +Model business states explicitly. The compiler enforces exhaustive handling. + +```fsharp +type PaymentState = + | AwaitingPayment of amount: decimal + | Paid of paidAt: DateTimeOffset * transactionId: string + | Refunded of refundedAt: DateTimeOffset * reason: string + | Failed of error: string + +let describePayment = function + | AwaitingPayment amount -> $"Awaiting payment of {amount:C}" + | Paid (at, txn) -> $"Paid at {at} (txn: {txn})" + | Refunded (at, reason) -> $"Refunded at {at}: {reason}" + | Failed error -> $"Payment failed: {error}" +``` + +## Computation Expressions + +Use computation expressions to simplify sequential operations that may fail. + +```fsharp +let placeOrder request = + result { + let! validated = validateOrder request + let! inventory = checkInventory validated.Items + let! order = createOrder validated inventory + return order + } +``` + +## Module Organization + +- Group related functions in modules rather than classes +- Use `[]` to prevent name collisions +- Keep modules small and focused on a single responsibility + +```fsharp +[] +module Order = + let create customerId items = { Id = Guid.NewGuid(); CustomerId = customerId; Items = items; Status = Pending } + let confirm order = { order with Status = Confirmed(DateTimeOffset.UtcNow) } + let cancel reason order = { order with Status = Cancelled reason } +``` + +## Dependency Injection + +- Define dependencies as function parameters or record-of-functions +- Use interfaces sparingly, primarily at the boundary with .NET libraries +- Prefer partial application for injecting dependencies into pipelines + +```fsharp +type OrderDeps = + { FindOrder: Guid -> Task + SaveOrder: Order -> Task + SendNotification: Order -> Task } + +let processOrder (deps: OrderDeps) orderId = + task { + match! deps.FindOrder orderId with + | None -> return Error "Order not found" + | Some order -> + let confirmed = Order.confirm order + do! deps.SaveOrder confirmed + do! deps.SendNotification confirmed + return Ok confirmed + } +``` diff --git a/.claude/rules/ecc/fsharp/security.md b/.claude/rules/ecc/fsharp/security.md new file mode 100644 index 0000000..86801c0 --- /dev/null +++ b/.claude/rules/ecc/fsharp/security.md @@ -0,0 +1,76 @@ +--- +paths: + - "**/*.fs" + - "**/*.fsx" + - "**/*.fsproj" + - "**/appsettings*.json" +--- +# F# Security + +> This file extends [common/security.md](../common/security.md) with F#-specific content. + +## Secret Management + +- Never hardcode API keys, tokens, or connection strings in source code +- Use environment variables, user secrets for local development, and a secret manager in production +- Keep `appsettings.*.json` free of real credentials + +```fsharp +// BAD +let apiKey = "sk-live-123" + +// GOOD +let apiKey = + configuration["OpenAI:ApiKey"] + |> Option.ofObj + |> Option.defaultWith (fun () -> failwith "OpenAI:ApiKey is not configured.") +``` + +## SQL Injection Prevention + +- Always use parameterized queries with ADO.NET, Dapper, or EF Core +- Never concatenate user input into SQL strings +- Validate sort fields and filter operators before using dynamic query composition + +```fsharp +let findByCustomer (connection: IDbConnection) customerId = + task { + let sql = "SELECT * FROM Orders WHERE CustomerId = @customerId" + return! connection.QueryAsync(sql, {| customerId = customerId |}) + } +``` + +## Input Validation + +- Validate inputs at the application boundary using types +- Use single-case discriminated unions for validated values +- Reject invalid input before it enters domain logic + +```fsharp +type ValidatedEmail = private ValidatedEmail of string + +module ValidatedEmail = + let create (input: string) = + if System.Text.RegularExpressions.Regex.IsMatch(input, @"^[^@]+@[^@]+\.[^@]+$") then + Ok(ValidatedEmail input) + else + Error "Invalid email address" + + let value (ValidatedEmail v) = v +``` + +## Authentication and Authorization + +- Prefer framework auth handlers instead of custom token parsing +- Enforce authorization policies at endpoint or handler boundaries +- Never log raw tokens, passwords, or PII + +## Error Handling + +- Return safe client-facing messages +- Log detailed exceptions with structured context server-side +- Do not expose stack traces, SQL text, or filesystem paths in API responses + +## References + +See skill: `security-review` for broader application security review checklists. diff --git a/.claude/rules/ecc/fsharp/testing.md b/.claude/rules/ecc/fsharp/testing.md new file mode 100644 index 0000000..8dbc7f9 --- /dev/null +++ b/.claude/rules/ecc/fsharp/testing.md @@ -0,0 +1,62 @@ +--- +paths: + - "**/*.fs" + - "**/*.fsx" + - "**/*.fsproj" +--- +# F# Testing + +> This file extends [common/testing.md](../common/testing.md) with F#-specific content. + +## Test Framework + +- Prefer **xUnit** with **FsUnit.xUnit** for F#-friendly assertions +- Use **Unquote** for quotation-based assertions with clear failure messages +- Use **FsCheck.xUnit** for property-based testing +- Use **NSubstitute** or function stubs for mocking dependencies +- Use **Testcontainers** when integration tests need real infrastructure + +## Test Organization + +- Mirror `src/` structure under `tests/` +- Separate unit, integration, and end-to-end coverage clearly +- Name tests by behavior, not implementation details + +```fsharp +open Xunit +open Swensen.Unquote + +[] +let ``PlaceOrder returns success when request is valid`` () = + let request = { CustomerId = "cust-123"; Items = [ validItem ] } + let result = OrderService.placeOrder request + test <@ Result.isOk result @> + +[] +let ``PlaceOrder returns error when items are empty`` () = + let request = { CustomerId = "cust-123"; Items = [] } + let result = OrderService.placeOrder request + test <@ Result.isError result @> +``` + +## Property-Based Testing with FsCheck + +```fsharp +open FsCheck.Xunit + +[] +let ``order total is never negative`` (items: OrderItem list) = + let total = Order.calculateTotal items + total >= 0m +``` + +## ASP.NET Core Integration Tests + +- Use `WebApplicationFactory` for API integration coverage +- Test auth, validation, and serialization through HTTP, not by bypassing middleware + +## Coverage + +- Target 80%+ line coverage +- Focus coverage on domain logic, validation, auth, and failure paths +- Run `dotnet test` in CI with coverage collection enabled where available diff --git a/.claude/rules/ecc/golang/coding-style.md b/.claude/rules/ecc/golang/coding-style.md new file mode 100644 index 0000000..d7d6c31 --- /dev/null +++ b/.claude/rules/ecc/golang/coding-style.md @@ -0,0 +1,32 @@ +--- +paths: + - "**/*.go" + - "**/go.mod" + - "**/go.sum" +--- +# Go Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Go specific content. + +## Formatting + +- **gofmt** and **goimports** are mandatory — no style debates + +## Design Principles + +- Accept interfaces, return structs +- Keep interfaces small (1-3 methods) + +## Error Handling + +Always wrap errors with context: + +```go +if err != nil { + return fmt.Errorf("failed to create user: %w", err) +} +``` + +## Reference + +See skill: `golang-patterns` for comprehensive Go idioms and patterns. diff --git a/.claude/rules/ecc/golang/hooks.md b/.claude/rules/ecc/golang/hooks.md new file mode 100644 index 0000000..f05e4ad --- /dev/null +++ b/.claude/rules/ecc/golang/hooks.md @@ -0,0 +1,17 @@ +--- +paths: + - "**/*.go" + - "**/go.mod" + - "**/go.sum" +--- +# Go Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Go specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **gofmt/goimports**: Auto-format `.go` files after edit +- **go vet**: Run static analysis after editing `.go` files +- **staticcheck**: Run extended static checks on modified packages diff --git a/.claude/rules/ecc/golang/patterns.md b/.claude/rules/ecc/golang/patterns.md new file mode 100644 index 0000000..ba28dba --- /dev/null +++ b/.claude/rules/ecc/golang/patterns.md @@ -0,0 +1,45 @@ +--- +paths: + - "**/*.go" + - "**/go.mod" + - "**/go.sum" +--- +# Go Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Go specific content. + +## Functional Options + +```go +type Option func(*Server) + +func WithPort(port int) Option { + return func(s *Server) { s.port = port } +} + +func NewServer(opts ...Option) *Server { + s := &Server{port: 8080} + for _, opt := range opts { + opt(s) + } + return s +} +``` + +## Small Interfaces + +Define interfaces where they are used, not where they are implemented. + +## Dependency Injection + +Use constructor functions to inject dependencies: + +```go +func NewUserService(repo UserRepository, logger Logger) *UserService { + return &UserService{repo: repo, logger: logger} +} +``` + +## Reference + +See skill: `golang-patterns` for comprehensive Go patterns including concurrency, error handling, and package organization. diff --git a/.claude/rules/ecc/golang/security.md b/.claude/rules/ecc/golang/security.md new file mode 100644 index 0000000..372b754 --- /dev/null +++ b/.claude/rules/ecc/golang/security.md @@ -0,0 +1,34 @@ +--- +paths: + - "**/*.go" + - "**/go.mod" + - "**/go.sum" +--- +# Go Security + +> This file extends [common/security.md](../common/security.md) with Go specific content. + +## Secret Management + +```go +apiKey := os.Getenv("OPENAI_API_KEY") +if apiKey == "" { + log.Fatal("OPENAI_API_KEY not configured") +} +``` + +## Security Scanning + +- Use **gosec** for static security analysis: + ```bash + gosec ./... + ``` + +## Context & Timeouts + +Always use `context.Context` for timeout control: + +```go +ctx, cancel := context.WithTimeout(ctx, 5*time.Second) +defer cancel() +``` diff --git a/.claude/rules/ecc/golang/testing.md b/.claude/rules/ecc/golang/testing.md new file mode 100644 index 0000000..6b80022 --- /dev/null +++ b/.claude/rules/ecc/golang/testing.md @@ -0,0 +1,31 @@ +--- +paths: + - "**/*.go" + - "**/go.mod" + - "**/go.sum" +--- +# Go Testing + +> This file extends [common/testing.md](../common/testing.md) with Go specific content. + +## Framework + +Use the standard `go test` with **table-driven tests**. + +## Race Detection + +Always run with the `-race` flag: + +```bash +go test -race ./... +``` + +## Coverage + +```bash +go test -cover ./... +``` + +## Reference + +See skill: `golang-testing` for detailed Go testing patterns and helpers. diff --git a/.claude/rules/ecc/java/coding-style.md b/.claude/rules/ecc/java/coding-style.md new file mode 100644 index 0000000..d20d5ab --- /dev/null +++ b/.claude/rules/ecc/java/coding-style.md @@ -0,0 +1,114 @@ +--- +paths: + - "**/*.java" +--- +# Java Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Java-specific content. + +## Formatting + +- **google-java-format** or **Checkstyle** (Google or Sun style) for enforcement +- One public top-level type per file +- Consistent indent: 2 or 4 spaces (match project standard) +- Member order: constants, fields, constructors, public methods, protected, private + +## Immutability + +- Prefer `record` for value types (Java 16+) +- Mark fields `final` by default — use mutable state only when required +- Return defensive copies from public APIs: `List.copyOf()`, `Map.copyOf()`, `Set.copyOf()` +- Copy-on-write: return new instances rather than mutating existing ones + +```java +// GOOD — immutable value type +public record OrderSummary(Long id, String customerName, BigDecimal total) {} + +// GOOD — final fields, no setters +public class Order { + private final Long id; + private final List items; + + public List getItems() { + return List.copyOf(items); + } +} +``` + +## Naming + +Follow standard Java conventions: +- `PascalCase` for classes, interfaces, records, enums +- `camelCase` for methods, fields, parameters, local variables +- `SCREAMING_SNAKE_CASE` for `static final` constants +- Packages: all lowercase, reverse domain (`com.example.app.service`) + +## Modern Java Features + +Use modern language features where they improve clarity: +- **Records** for DTOs and value types (Java 16+) +- **Sealed classes** for closed type hierarchies (Java 17+) +- **Pattern matching** with `instanceof` — no explicit cast (Java 16+) +- **Text blocks** for multi-line strings — SQL, JSON templates (Java 15+) +- **Switch expressions** with arrow syntax (Java 14+) +- **Pattern matching in switch** — exhaustive sealed type handling (Java 21+) + +```java +// Pattern matching instanceof +if (shape instanceof Circle c) { + return Math.PI * c.radius() * c.radius(); +} + +// Sealed type hierarchy +public sealed interface PaymentMethod permits CreditCard, BankTransfer, Wallet {} + +// Switch expression +String label = switch (status) { + case ACTIVE -> "Active"; + case SUSPENDED -> "Suspended"; + case CLOSED -> "Closed"; +}; +``` + +## Optional Usage + +- Return `Optional` from finder methods that may have no result +- Use `map()`, `flatMap()`, `orElseThrow()` — never call `get()` without `isPresent()` +- Never use `Optional` as a field type or method parameter + +```java +// GOOD +return repository.findById(id) + .map(ResponseDto::from) + .orElseThrow(() -> new OrderNotFoundException(id)); + +// BAD — Optional as parameter +public void process(Optional name) {} +``` + +## Error Handling + +- Prefer unchecked exceptions for domain errors +- Create domain-specific exceptions extending `RuntimeException` +- Avoid broad `catch (Exception e)` unless at top-level handlers +- Include context in exception messages + +```java +public class OrderNotFoundException extends RuntimeException { + public OrderNotFoundException(Long id) { + super("Order not found: id=" + id); + } +} +``` + +## Streams + +- Use streams for transformations; keep pipelines short (3-4 operations max) +- Prefer method references when readable: `.map(Order::getTotal)` +- Avoid side effects in stream operations +- For complex logic, prefer a loop over a convoluted stream pipeline + +## References + +See skill: `java-coding-standards` for full coding standards with examples. +See skill: `jpa-patterns` for JPA/Hibernate entity design patterns. diff --git a/.claude/rules/ecc/java/hooks.md b/.claude/rules/ecc/java/hooks.md new file mode 100644 index 0000000..9dd33b3 --- /dev/null +++ b/.claude/rules/ecc/java/hooks.md @@ -0,0 +1,18 @@ +--- +paths: + - "**/*.java" + - "**/pom.xml" + - "**/build.gradle" + - "**/build.gradle.kts" +--- +# Java Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Java-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **google-java-format**: Auto-format `.java` files after edit +- **checkstyle**: Run style checks after editing Java files +- **./mvnw compile** or **./gradlew compileJava**: Verify compilation after changes diff --git a/.claude/rules/ecc/java/patterns.md b/.claude/rules/ecc/java/patterns.md new file mode 100644 index 0000000..44ed3f2 --- /dev/null +++ b/.claude/rules/ecc/java/patterns.md @@ -0,0 +1,147 @@ +--- +paths: + - "**/*.java" +--- +# Java Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Java-specific content. + +## Repository Pattern + +Encapsulate data access behind an interface: + +```java +public interface OrderRepository { + Optional findById(Long id); + List findAll(); + Order save(Order order); + void deleteById(Long id); +} +``` + +Concrete implementations handle storage details (JPA, JDBC, in-memory for tests). + +## Service Layer + +Business logic in service classes; keep controllers and repositories thin: + +```java +public class OrderService { + private final OrderRepository orderRepository; + private final PaymentGateway paymentGateway; + + public OrderService(OrderRepository orderRepository, PaymentGateway paymentGateway) { + this.orderRepository = orderRepository; + this.paymentGateway = paymentGateway; + } + + public OrderSummary placeOrder(CreateOrderRequest request) { + var order = Order.from(request); + paymentGateway.charge(order.total()); + var saved = orderRepository.save(order); + return OrderSummary.from(saved); + } +} +``` + +## Constructor Injection + +Always use constructor injection — never field injection: + +```java +// GOOD — constructor injection (testable, immutable) +public class NotificationService { + private final EmailSender emailSender; + + public NotificationService(EmailSender emailSender) { + this.emailSender = emailSender; + } +} + +// BAD — field injection (untestable without reflection, requires framework magic) +public class NotificationService { + @Inject // or @Autowired + private EmailSender emailSender; +} +``` + +## DTO Mapping + +Use records for DTOs. Map at service/controller boundaries: + +```java +public record OrderResponse(Long id, String customer, BigDecimal total) { + public static OrderResponse from(Order order) { + return new OrderResponse(order.getId(), order.getCustomerName(), order.getTotal()); + } +} +``` + +## Builder Pattern + +Use for objects with many optional parameters: + +```java +public class SearchCriteria { + private final String query; + private final int page; + private final int size; + private final String sortBy; + + private SearchCriteria(Builder builder) { + this.query = builder.query; + this.page = builder.page; + this.size = builder.size; + this.sortBy = builder.sortBy; + } + + public static class Builder { + private String query = ""; + private int page = 0; + private int size = 20; + private String sortBy = "id"; + + public Builder query(String query) { this.query = query; return this; } + public Builder page(int page) { this.page = page; return this; } + public Builder size(int size) { this.size = size; return this; } + public Builder sortBy(String sortBy) { this.sortBy = sortBy; return this; } + public SearchCriteria build() { return new SearchCriteria(this); } + } +} +``` + +## Sealed Types for Domain Models + +```java +public sealed interface PaymentResult permits PaymentSuccess, PaymentFailure { + record PaymentSuccess(String transactionId, BigDecimal amount) implements PaymentResult {} + record PaymentFailure(String errorCode, String message) implements PaymentResult {} +} + +// Exhaustive handling (Java 21+) +String message = switch (result) { + case PaymentSuccess s -> "Paid: " + s.transactionId(); + case PaymentFailure f -> "Failed: " + f.errorCode(); +}; +``` + +## API Response Envelope + +Consistent API responses: + +```java +public record ApiResponse(boolean success, T data, String error) { + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, data, null); + } + public static ApiResponse error(String message) { + return new ApiResponse<>(false, null, message); + } +} +``` + +## References + +See skill: `springboot-patterns` for Spring Boot architecture patterns. +See skill: `quarkus-patterns` for Quarkus architecture patterns with REST, Panache, and messaging. +See skill: `jpa-patterns` for entity design and query optimization. diff --git a/.claude/rules/ecc/java/security.md b/.claude/rules/ecc/java/security.md new file mode 100644 index 0000000..cbedc12 --- /dev/null +++ b/.claude/rules/ecc/java/security.md @@ -0,0 +1,101 @@ +--- +paths: + - "**/*.java" +--- +# Java Security + +> This file extends [common/security.md](../common/security.md) with Java-specific content. + +## Secrets Management + +- Never hardcode API keys, tokens, or credentials in source code +- Use environment variables: `System.getenv("API_KEY")` +- Use a secret manager (Vault, AWS Secrets Manager) for production secrets +- Keep local config files with secrets in `.gitignore` + +```java +// BAD +private static final String API_KEY = "sk-abc123..."; + +// GOOD — environment variable +String apiKey = System.getenv("PAYMENT_API_KEY"); +Objects.requireNonNull(apiKey, "PAYMENT_API_KEY must be set"); +``` + +## SQL Injection Prevention + +- Always use parameterized queries — never concatenate user input into SQL +- Use `PreparedStatement` or your framework's parameterized query API +- Validate and sanitize any input used in native queries + +```java +// BAD — SQL injection via string concatenation +Statement stmt = conn.createStatement(); +String sql = "SELECT * FROM orders WHERE name = '" + name + "'"; +stmt.executeQuery(sql); + +// GOOD — PreparedStatement with parameterized query +PreparedStatement ps = conn.prepareStatement("SELECT * FROM orders WHERE name = ?"); +ps.setString(1, name); + +// GOOD — JDBC template +jdbcTemplate.query("SELECT * FROM orders WHERE name = ?", mapper, name); +``` + +## Input Validation + +- Validate all user input at system boundaries before processing +- Use Bean Validation (`@NotNull`, `@NotBlank`, `@Size`) on DTOs when using a validation framework +- Sanitize file paths and user-provided strings before use +- Reject input that fails validation with clear error messages + +```java +// Validate manually in plain Java +public Order createOrder(String customerName, BigDecimal amount) { + if (customerName == null || customerName.isBlank()) { + throw new IllegalArgumentException("Customer name is required"); + } + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("Amount must be positive"); + } + return new Order(customerName, amount); +} +``` + +## Authentication and Authorization + +- Never implement custom auth crypto — use established libraries +- Store passwords with bcrypt or Argon2, never MD5/SHA1 +- Enforce authorization checks at service boundaries +- Clear sensitive data from logs — never log passwords, tokens, or PII + +## Dependency Security + +- Run `mvn dependency:tree` or `./gradlew dependencies` to audit transitive dependencies +- Use OWASP Dependency-Check or Snyk to scan for known CVEs +- Keep dependencies updated — set up Dependabot or Renovate + +## Error Messages + +- Never expose stack traces, internal paths, or SQL errors in API responses +- Map exceptions to safe, generic client messages at handler boundaries +- Log detailed errors server-side; return generic messages to clients + +```java +// Log the detail, return a generic message +try { + return orderService.findById(id); +} catch (OrderNotFoundException ex) { + log.warn("Order not found: id={}", id); + return ApiResponse.error("Resource not found"); // generic, no internals +} catch (Exception ex) { + log.error("Unexpected error processing order id={}", id, ex); + return ApiResponse.error("Internal server error"); // never expose ex.getMessage() +} +``` + +## References + +See skill: `springboot-security` for Spring Security authentication and authorization patterns. +See skill: `quarkus-security` for Quarkus security with JWT/OIDC, RBAC, and CDI. +See skill: `security-review` for general security checklists. diff --git a/.claude/rules/ecc/java/testing.md b/.claude/rules/ecc/java/testing.md new file mode 100644 index 0000000..177b264 --- /dev/null +++ b/.claude/rules/ecc/java/testing.md @@ -0,0 +1,133 @@ +--- +paths: + - "**/*.java" +--- +# Java Testing + +> This file extends [common/testing.md](../common/testing.md) with Java-specific content. + +## Test Framework + +- **JUnit 5** (`@Test`, `@ParameterizedTest`, `@Nested`, `@DisplayName`) +- **AssertJ** for fluent assertions (`assertThat(result).isEqualTo(expected)`) +- **Mockito** for mocking dependencies +- **Testcontainers** for integration tests requiring databases or services + +## Test Organization + +``` +src/test/java/com/example/app/ + service/ # Unit tests for service layer + controller/ # Web layer / API tests + repository/ # Data access tests + integration/ # Cross-layer integration tests +``` + +Mirror the `src/main/java` package structure in `src/test/java`. + +## Unit Test Pattern + +```java +@ExtendWith(MockitoExtension.class) +class OrderServiceTest { + + @Mock + private OrderRepository orderRepository; + + private OrderService orderService; + + @BeforeEach + void setUp() { + orderService = new OrderService(orderRepository); + } + + @Test + @DisplayName("findById returns order when exists") + void findById_existingOrder_returnsOrder() { + var order = new Order(1L, "Alice", BigDecimal.TEN); + when(orderRepository.findById(1L)).thenReturn(Optional.of(order)); + + var result = orderService.findById(1L); + + assertThat(result.customerName()).isEqualTo("Alice"); + verify(orderRepository).findById(1L); + } + + @Test + @DisplayName("findById throws when order not found") + void findById_missingOrder_throws() { + when(orderRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> orderService.findById(99L)) + .isInstanceOf(OrderNotFoundException.class) + .hasMessageContaining("99"); + } +} +``` + +## Parameterized Tests + +```java +@ParameterizedTest +@CsvSource({ + "100.00, 10, 90.00", + "50.00, 0, 50.00", + "200.00, 25, 150.00" +}) +@DisplayName("discount applied correctly") +void applyDiscount(BigDecimal price, int pct, BigDecimal expected) { + assertThat(PricingUtils.discount(price, pct)).isEqualByComparingTo(expected); +} +``` + +## Integration Tests + +Use Testcontainers for real database integration: + +```java +@Testcontainers +class OrderRepositoryIT { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16"); + + private OrderRepository repository; + + @BeforeEach + void setUp() { + var dataSource = new PGSimpleDataSource(); + dataSource.setUrl(postgres.getJdbcUrl()); + dataSource.setUser(postgres.getUsername()); + dataSource.setPassword(postgres.getPassword()); + repository = new JdbcOrderRepository(dataSource); + } + + @Test + void save_and_findById() { + var saved = repository.save(new Order(null, "Bob", BigDecimal.ONE)); + var found = repository.findById(saved.getId()); + assertThat(found).isPresent(); + } +} +``` + +For Spring Boot integration tests, see skill: `springboot-tdd`. +For Quarkus integration tests, see skill: `quarkus-tdd`. + +## Test Naming + +Use descriptive names with `@DisplayName`: +- `methodName_scenario_expectedBehavior()` for method names +- `@DisplayName("human-readable description")` for reports + +## Coverage + +- Target 80%+ line coverage +- Use JaCoCo for coverage reporting +- Focus on service and domain logic — skip trivial getters/config classes + +## References + +See skill: `springboot-tdd` for Spring Boot TDD patterns with MockMvc and Testcontainers. +See skill: `quarkus-tdd` for Quarkus TDD patterns with REST Assured and Dev Services. +See skill: `java-coding-standards` for testing expectations. diff --git a/.claude/rules/ecc/kotlin/coding-style.md b/.claude/rules/ecc/kotlin/coding-style.md new file mode 100644 index 0000000..5c5ee30 --- /dev/null +++ b/.claude/rules/ecc/kotlin/coding-style.md @@ -0,0 +1,86 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Kotlin-specific content. + +## Formatting + +- **ktlint** or **Detekt** for style enforcement +- Official Kotlin code style (`kotlin.code.style=official` in `gradle.properties`) + +## Immutability + +- Prefer `val` over `var` — default to `val` and only use `var` when mutation is required +- Use `data class` for value types; use immutable collections (`List`, `Map`, `Set`) in public APIs +- Copy-on-write for state updates: `state.copy(field = newValue)` + +## Naming + +Follow Kotlin conventions: +- `camelCase` for functions and properties +- `PascalCase` for classes, interfaces, objects, and type aliases +- `SCREAMING_SNAKE_CASE` for constants (`const val` or `@JvmStatic`) +- Prefix interfaces with behavior, not `I`: `Clickable` not `IClickable` + +## Null Safety + +- Never use `!!` — prefer `?.`, `?:`, `requireNotNull()`, or `checkNotNull()` +- Use `?.let {}` for scoped null-safe operations +- Return nullable types from functions that can legitimately have no result + +```kotlin +// BAD +val name = user!!.name + +// GOOD +val name = user?.name ?: "Unknown" +val name = requireNotNull(user) { "User must be set before accessing name" }.name +``` + +## Sealed Types + +Use sealed classes/interfaces to model closed state hierarchies: + +```kotlin +sealed interface UiState { + data object Loading : UiState + data class Success(val data: T) : UiState + data class Error(val message: String) : UiState +} +``` + +Always use exhaustive `when` with sealed types — no `else` branch. + +## Extension Functions + +Use extension functions for utility operations, but keep them discoverable: +- Place in a file named after the receiver type (`StringExt.kt`, `FlowExt.kt`) +- Keep scope limited — don't add extensions to `Any` or overly generic types + +## Scope Functions + +Use the right scope function: +- `let` — null check + transform: `user?.let { greet(it) }` +- `run` — compute a result using receiver: `service.run { fetch(config) }` +- `apply` — configure an object: `builder.apply { timeout = 30 }` +- `also` — side effects: `result.also { log(it) }` +- Avoid deep nesting of scope functions (max 2 levels) + +## Error Handling + +- Use `Result` or custom sealed types +- Use `runCatching {}` for wrapping throwable code +- Never catch `CancellationException` — always rethrow it +- Avoid `try-catch` for control flow + +```kotlin +// BAD — using exceptions for control flow +val user = try { repository.getUser(id) } catch (e: NotFoundException) { null } + +// GOOD — nullable return +val user: User? = repository.findUser(id) +``` diff --git a/.claude/rules/ecc/kotlin/hooks.md b/.claude/rules/ecc/kotlin/hooks.md new file mode 100644 index 0000000..28bb02f --- /dev/null +++ b/.claude/rules/ecc/kotlin/hooks.md @@ -0,0 +1,17 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" + - "**/build.gradle.kts" +--- +# Kotlin Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Kotlin-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **ktfmt/ktlint**: Auto-format `.kt` and `.kts` files after edit +- **detekt**: Run static analysis after editing Kotlin files +- **./gradlew build**: Verify compilation after changes diff --git a/.claude/rules/ecc/kotlin/patterns.md b/.claude/rules/ecc/kotlin/patterns.md new file mode 100644 index 0000000..1a09e6b --- /dev/null +++ b/.claude/rules/ecc/kotlin/patterns.md @@ -0,0 +1,146 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Kotlin and Android/KMP-specific content. + +## Dependency Injection + +Prefer constructor injection. Use Koin (KMP) or Hilt (Android-only): + +```kotlin +// Koin — declare modules +val dataModule = module { + single { ItemRepositoryImpl(get(), get()) } + factory { GetItemsUseCase(get()) } + viewModelOf(::ItemListViewModel) +} + +// Hilt — annotations +@HiltViewModel +class ItemListViewModel @Inject constructor( + private val getItems: GetItemsUseCase +) : ViewModel() +``` + +## ViewModel Pattern + +Single state object, event sink, one-way data flow: + +```kotlin +data class ScreenState( + val items: List = emptyList(), + val isLoading: Boolean = false +) + +class ScreenViewModel(private val useCase: GetItemsUseCase) : ViewModel() { + private val _state = MutableStateFlow(ScreenState()) + val state = _state.asStateFlow() + + fun onEvent(event: ScreenEvent) { + when (event) { + is ScreenEvent.Load -> load() + is ScreenEvent.Delete -> delete(event.id) + } + } +} +``` + +## Repository Pattern + +- `suspend` functions return `Result` or custom error type +- `Flow` for reactive streams +- Coordinate local + remote data sources + +```kotlin +interface ItemRepository { + suspend fun getById(id: String): Result + suspend fun getAll(): Result> + fun observeAll(): Flow> +} +``` + +## UseCase Pattern + +Single responsibility, `operator fun invoke`: + +```kotlin +class GetItemUseCase(private val repository: ItemRepository) { + suspend operator fun invoke(id: String): Result { + return repository.getById(id) + } +} + +class GetItemsUseCase(private val repository: ItemRepository) { + suspend operator fun invoke(): Result> { + return repository.getAll() + } +} +``` + +## expect/actual (KMP) + +Use for platform-specific implementations: + +```kotlin +// commonMain +expect fun platformName(): String +expect class SecureStorage { + fun save(key: String, value: String) + fun get(key: String): String? +} + +// androidMain +actual fun platformName(): String = "Android" +actual class SecureStorage { + actual fun save(key: String, value: String) { /* EncryptedSharedPreferences */ } + actual fun get(key: String): String? = null /* ... */ +} + +// iosMain +actual fun platformName(): String = "iOS" +actual class SecureStorage { + actual fun save(key: String, value: String) { /* Keychain */ } + actual fun get(key: String): String? = null /* ... */ +} +``` + +## Coroutine Patterns + +- Use `viewModelScope` in ViewModels, `coroutineScope` for structured child work +- Use `stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), initialValue)` for StateFlow from cold Flows +- Use `supervisorScope` when child failures should be independent + +## Builder Pattern with DSL + +```kotlin +class HttpClientConfig { + var baseUrl: String = "" + var timeout: Long = 30_000 + private val interceptors = mutableListOf() + + fun interceptor(block: () -> Interceptor) { + interceptors.add(block()) + } +} + +fun httpClient(block: HttpClientConfig.() -> Unit): HttpClient { + val config = HttpClientConfig().apply(block) + return HttpClient(config) +} + +// Usage +val client = httpClient { + baseUrl = "https://api.example.com" + timeout = 15_000 + interceptor { AuthInterceptor(tokenProvider) } +} +``` + +## References + +See skill: `kotlin-coroutines-flows` for detailed coroutine patterns. +See skill: `android-clean-architecture` for module and layer patterns. diff --git a/.claude/rules/ecc/kotlin/security.md b/.claude/rules/ecc/kotlin/security.md new file mode 100644 index 0000000..a212211 --- /dev/null +++ b/.claude/rules/ecc/kotlin/security.md @@ -0,0 +1,82 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Security + +> This file extends [common/security.md](../common/security.md) with Kotlin and Android/KMP-specific content. + +## Secrets Management + +- Never hardcode API keys, tokens, or credentials in source code +- Use `local.properties` (git-ignored) for local development secrets +- Use `BuildConfig` fields generated from CI secrets for release builds +- Use `EncryptedSharedPreferences` (Android) or Keychain (iOS) for runtime secret storage + +```kotlin +// BAD +val apiKey = "sk-abc123..." + +// GOOD — from BuildConfig (generated at build time) +val apiKey = BuildConfig.API_KEY + +// GOOD — from secure storage at runtime +val token = secureStorage.get("auth_token") +``` + +## Network Security + +- Use HTTPS exclusively — configure `network_security_config.xml` to block cleartext +- Pin certificates for sensitive endpoints using OkHttp `CertificatePinner` or Ktor equivalent +- Set timeouts on all HTTP clients — never leave defaults (which may be infinite) +- Validate and sanitize all server responses before use + +```xml + + + + +``` + +## Input Validation + +- Validate all user input before processing or sending to API +- Use parameterized queries for Room/SQLDelight — never concatenate user input into SQL +- Sanitize file paths from user input to prevent path traversal + +```kotlin +// BAD — SQL injection +@Query("SELECT * FROM items WHERE name = '$input'") + +// GOOD — parameterized +@Query("SELECT * FROM items WHERE name = :input") +fun findByName(input: String): List +``` + +## Data Protection + +- Use `EncryptedSharedPreferences` for sensitive key-value data on Android +- Use `@Serializable` with explicit field names — don't leak internal property names +- Clear sensitive data from memory when no longer needed +- Use `@Keep` or ProGuard rules for serialized classes to prevent name mangling + +## Authentication + +- Store tokens in secure storage, not in plain SharedPreferences +- Implement token refresh with proper 401/403 handling +- Clear all auth state on logout (tokens, cached user data, cookies) +- Use biometric authentication (`BiometricPrompt`) for sensitive operations + +## ProGuard / R8 + +- Keep rules for all serialized models (`@Serializable`, Gson, Moshi) +- Keep rules for reflection-based libraries (Koin, Retrofit) +- Test release builds — obfuscation can break serialization silently + +## WebView Security + +- Disable JavaScript unless explicitly needed: `settings.javaScriptEnabled = false` +- Validate URLs before loading in WebView +- Never expose `@JavascriptInterface` methods that access sensitive data +- Use `WebViewClient.shouldOverrideUrlLoading()` to control navigation diff --git a/.claude/rules/ecc/kotlin/testing.md b/.claude/rules/ecc/kotlin/testing.md new file mode 100644 index 0000000..cdf9733 --- /dev/null +++ b/.claude/rules/ecc/kotlin/testing.md @@ -0,0 +1,128 @@ +--- +paths: + - "**/*.kt" + - "**/*.kts" +--- +# Kotlin Testing + +> This file extends [common/testing.md](../common/testing.md) with Kotlin and Android/KMP-specific content. + +## Test Framework + +- **kotlin.test** for multiplatform (KMP) — `@Test`, `assertEquals`, `assertTrue` +- **JUnit 4/5** for Android-specific tests +- **Turbine** for testing Flows and StateFlow +- **kotlinx-coroutines-test** for coroutine testing (`runTest`, `TestDispatcher`) + +## ViewModel Testing with Turbine + +```kotlin +@Test +fun `loading state emitted then data`() = runTest { + val repo = FakeItemRepository() + repo.addItem(testItem) + val viewModel = ItemListViewModel(GetItemsUseCase(repo)) + + viewModel.state.test { + assertEquals(ItemListState(), awaitItem()) // initial state + viewModel.onEvent(ItemListEvent.Load) + assertTrue(awaitItem().isLoading) // loading + assertEquals(listOf(testItem), awaitItem().items) // loaded + } +} +``` + +## Fakes Over Mocks + +Prefer hand-written fakes over mocking frameworks: + +```kotlin +class FakeItemRepository : ItemRepository { + private val items = mutableListOf() + var fetchError: Throwable? = null + + override suspend fun getAll(): Result> { + fetchError?.let { return Result.failure(it) } + return Result.success(items.toList()) + } + + override fun observeAll(): Flow> = flowOf(items.toList()) + + fun addItem(item: Item) { items.add(item) } +} +``` + +## Coroutine Testing + +```kotlin +@Test +fun `parallel operations complete`() = runTest { + val repo = FakeRepository() + val result = loadDashboard(repo) + advanceUntilIdle() + assertNotNull(result.items) + assertNotNull(result.stats) +} +``` + +Use `runTest` — it auto-advances virtual time and provides `TestScope`. + +## Ktor MockEngine + +```kotlin +val mockEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/api/items" -> respond( + content = Json.encodeToString(testItems), + headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString()) + ) + else -> respondError(HttpStatusCode.NotFound) + } +} + +val client = HttpClient(mockEngine) { + install(ContentNegotiation) { json() } +} +``` + +## Room/SQLDelight Testing + +- Room: Use `Room.inMemoryDatabaseBuilder()` for in-memory testing +- SQLDelight: Use `JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)` for JVM tests + +```kotlin +@Test +fun `insert and query items`() = runTest { + val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) + Database.Schema.create(driver) + val db = Database(driver) + + db.itemQueries.insert("1", "Sample Item", "description") + val items = db.itemQueries.getAll().executeAsList() + assertEquals(1, items.size) +} +``` + +## Test Naming + +Use backtick-quoted descriptive names: + +```kotlin +@Test +fun `search with empty query returns all items`() = runTest { } + +@Test +fun `delete item emits updated list without deleted item`() = runTest { } +``` + +## Test Organization + +``` +src/ +├── commonTest/kotlin/ # Shared tests (ViewModel, UseCase, Repository) +├── androidUnitTest/kotlin/ # Android unit tests (JUnit) +├── androidInstrumentedTest/kotlin/ # Instrumented tests (Room, UI) +└── iosTest/kotlin/ # iOS-specific tests +``` + +Minimum test coverage: ViewModel + UseCase for every feature. diff --git a/.claude/rules/ecc/nuxt/coding-style.md b/.claude/rules/ecc/nuxt/coding-style.md new file mode 100644 index 0000000..b8a938a --- /dev/null +++ b/.claude/rules/ecc/nuxt/coding-style.md @@ -0,0 +1,47 @@ +--- +paths: + - "**/nuxt.config.*" + - "**/app.config.*" + - "**/app.vue" + - "**/pages/**" + - "**/layouts/**" + - "**/middleware/**" +--- + +# Nuxt Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Nuxt specific content. + +## Directory layout + +- Default `srcDir` is `app/`. Framework files live at `app/pages/`, `app/layouts/`, `app/middleware/`, `app/plugins/`, `app/app.config.ts`. `nuxt.config.ts` and `server/` stay at project root. +- Some projects override `srcDir` to `src/` for a Feature-Sliced Design layout, remapping `dir.pages` (for example to `src/app/routes`), `dir.layouts`, and the `@`/`~` aliases. Always check `nuxt.config.ts` before assuming a path. + +## Auto-imports discipline + +- Composables in `app/composables/` and `server/utils/` auto-import. Do NOT manually import Nuxt composables (`useFetch`, `useState`, `navigateTo`) or `defineStore` / `storeToRefs`. +- Do NOT add a standalone `vue-router` dep (Nuxt bundles v5) or hand-mount `createApp` / `createPinia` / `createRouter`. The framework wires these. + +## Compiler macros + +- `definePageMeta` is a compile-time macro. Static values only, no reactive data and no side-effect calls inside it. +- Augment typed `PageMeta` via `declare module '#app'` rather than casting. + +## Config file separation + +Three distinct files, do not conflate. + +- `nuxt.config.ts` = build-time only (`routeRules`, `modules`, `nitro`, `ssr` flag). Not reactive. +- `runtimeConfig` (inside nuxt.config) = per-env runtime values, env-overridable via `NUXT_*`. Root keys are server-only, `public` keys are client-visible. +- `app/app.config.ts` = public build-fixed reactive settings (theme tokens, feature flags). No env override. NEVER secrets. + +## Head and meta + +- `app.head` in `nuxt.config.ts` takes static values only. +- Reactive meta goes through `useHead` / `useSeoMeta` in component setup, never via `app.head`. + +## Reference + +- ECC skills: `nuxt4-patterns`, `vite-patterns`, `frontend-patterns`. +- [Nuxt directory structure](https://nuxt.com/docs/guide/directory-structure/app) +- [Nuxt configuration](https://nuxt.com/docs/api/nuxt-config) diff --git a/.claude/rules/ecc/nuxt/hooks.md b/.claude/rules/ecc/nuxt/hooks.md new file mode 100644 index 0000000..a561c9d --- /dev/null +++ b/.claude/rules/ecc/nuxt/hooks.md @@ -0,0 +1,39 @@ +--- +paths: + - "**/nuxt.config.*" + - "**/app.config.*" + - "**/server/**/*.ts" + - "**/*.vue" +--- + +# Nuxt Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Nuxt specific content. + +These are Claude Code harness hooks for Nuxt work. They run via the harness, not Claude. + +## Typecheck + +- `nuxi typecheck` wraps `vue-tsc`. Requires `vue-tsc` + `typescript` dev deps. +- Run on `.vue` / `.ts` edit or pre-commit. Typecheck is project-wide, so debounce it and wrap it in a timeout (mirror `web/hooks.md`, for example `timeout 60 nuxi typecheck`) so a hung type-check is reaped instead of accumulating across fast edits. + +## Lint + +- Use the `@nuxt/eslint` module (flat-config, project-aware, generates `.nuxt/eslint.config.mjs`). +- Run `eslint .` or `eslint --fix`. This is the Nuxt-official ESLint integration, prefer it over hand-rolled configs. + +## Format + +- `prettier --write`, or enable stylistic rules in `@nuxt/eslint` to avoid a Prettier/ESLint conflict. +- Pick one formatting authority. Do not run both Prettier and ESLint stylistic at once. + +## Suggested PostToolUse chain + +- On Edit to `app/**` and `server/**`: run `eslint --fix` then `timeout 60 nuxi typecheck`. +- Order matters: lint-fix first (mutates the file), the timed typecheck second (verifies the result). Debouncing still applies. + +## Reference + +- ECC skills: `nuxt4-patterns`, `vite-patterns`. +- [@nuxt/eslint module](https://eslint.nuxt.com/) +- [nuxi typecheck](https://nuxt.com/docs/api/commands/typecheck) diff --git a/.claude/rules/ecc/nuxt/patterns.md b/.claude/rules/ecc/nuxt/patterns.md new file mode 100644 index 0000000..cb41be4 --- /dev/null +++ b/.claude/rules/ecc/nuxt/patterns.md @@ -0,0 +1,54 @@ +--- +paths: + - "**/nuxt.config.*" + - "**/app.config.*" + - "**/app.vue" + - "**/server/**/*.ts" + - "**/pages/**" + - "**/middleware/**" +--- + +# Nuxt Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Nuxt specific content. + +## Data-fetch selection + +Load-bearing. Pick by render timing, not habit. + +- `useFetch(url)` = SSR-safe, URL-first initial/first-paint data. The default. Forwards the server result through the payload so there is no hydration double-fetch. +- `useAsyncData(key, fn)` = SSR-safe, custom async logic (SDK / GraphQL / combined calls). The explicit key shares the result across components. +- `$fetch` = client interactions only (form submit, button click, POST/PUT/DELETE). NOT SSR-safe, double-fetches if used for first paint. +- Rule: `useFetch` / `useAsyncData` for anything rendered on first paint, `$fetch` only for event-driven mutations. + +## Shared state + +- `useState('key', () => init)` for SSR-safe shared state. Values must be JSON-serializable. +- NEVER `export const x = ref()` at module scope. One shared instance leaks across concurrent SSR requests and causes a memory leak. +- With `@pinia/nuxt`: Pinia for domain state, `useState` for small cross-component primitives. +- Async server-side init goes in `callOnce(async () => {...})`, not as a side effect inside `useAsyncData`. + +## Nitro server routes + +- `server/api/*.{get,post}.ts` auto-register by path + method. Handler is `defineEventHandler((event) => ...)`. +- Errors via `throw createError({ status, statusText })`. Prefer the Web-API `status` / `statusText` over deprecated `statusCode` / `statusMessage`. +- `server/middleware/` must NOT return a response. Only mutate `event.context` or set headers. + +## Route middleware + +- `app/middleware/*.ts` with `defineNuxtRouteMiddleware((to, from) => ...)`. +- Use the `to` / `from` args. Do NOT call `useRoute()` inside middleware. +- `.global` suffix runs on every route. Return `navigateTo()` to redirect, `abortNavigation()` to stop. + +## Hydration-safe rendering + +- Route off `status` (`idle | pending | success | error`) for lazy fetches. +- `useAsyncData` payload uses `devalue` (Date/Map/Set/refs survive). A `server/api` response is `JSON.stringify`-only, so define `toJSON()` for non-JSON types. +- Shrink payload with `pick` / `transform`. This reduces serialized size, it does not skip the fetch. + +## Reference + +- ECC skills: `nuxt4-patterns`, `vite-patterns`, `frontend-patterns`. +- [Nuxt data fetching](https://nuxt.com/docs/getting-started/data-fetching) +- [Nuxt state management](https://nuxt.com/docs/getting-started/state-management) +- [Nuxt server engine (Nitro)](https://nuxt.com/docs/guide/directory-structure/server) diff --git a/.claude/rules/ecc/nuxt/security.md b/.claude/rules/ecc/nuxt/security.md new file mode 100644 index 0000000..37f45bb --- /dev/null +++ b/.claude/rules/ecc/nuxt/security.md @@ -0,0 +1,48 @@ +--- +paths: + - "**/nuxt.config.*" + - "**/app.config.*" + - "**/server/**/*.ts" +--- + +# Nuxt Security + +> This file extends [common/security.md](../common/security.md) with Nuxt specific content. + +## runtimeConfig public vs private + +- Root `runtimeConfig` keys are server-only. `runtimeConfig.public` serializes into EVERY page payload (client-visible). +- Secrets go at root only. Never put secrets in `app.config.ts` or `runtimeConfig.public`, both ship to the client bundle. +- Official warning: "Be careful not to expose runtime config keys to the client-side by either rendering them or passing them to `useState`." + +## Server-route input validation + +- Use h3 validating readers. Do NOT trust raw `readBody` / `getQuery` / `getRouterParam`. + - `readValidatedBody(event, schema)` validates the body. + - `getValidatedQuery(event, schema)` validates the query. + - `getValidatedRouterParams(event, schema)` validates route params. +- All accept a validation function or a Zod schema and throw on failure. + +## SSR payload leakage + +- Anything in `useState`, `useFetch` / `useAsyncData` results, or `runtimeConfig.public` is serialized into the client payload. Never write a secret into those. +- Use `useServerSeoMeta` for server-only meta with no client cost. + +## Cookie and auth passthrough on SSR + +- Nuxt does NOT auto-attach the incoming user's cookies to outbound server-side `$fetch`. +- Forward explicitly with `useRequestFetch()` (cleanest, pre-bound to request headers) or `useRequestHeaders(['cookie'])`. +- Relay a backend `Set-Cookie` to the browser via `$fetch.raw` + `appendResponseHeader(event, 'set-cookie', ...)`. +- socket.io is client-only (`.client.ts` plugin), never SSR. + +## SSRF on server $fetch + +- Server routes run with full network egress. Never pass user-controlled input directly into a server-side `$fetch` URL or host. +- Validate the param first (h3 utilities above), allowlist the target, pin to `runtimeConfig.public.apiBase`, reject user-supplied absolute URLs. +- Auto-trigger `/security-review` only for routes that make external network requests (server `$fetch`), handle auth tokens or credentials, or perform sensitive mutations or authorization checks. Examples: SSRF-prone proxy endpoints, token exchange or password reset, admin actions. Skip benign read-only routes that only accept validated query params. + +## Reference + +- ECC skills: `security-review`, `nuxt4-patterns`. +- [Nuxt runtime config](https://nuxt.com/docs/guide/going-further/runtime-config) +- [h3 request utils](https://v1.h3.dev/utils/request) diff --git a/.claude/rules/ecc/nuxt/testing.md b/.claude/rules/ecc/nuxt/testing.md new file mode 100644 index 0000000..ac3744f --- /dev/null +++ b/.claude/rules/ecc/nuxt/testing.md @@ -0,0 +1,49 @@ +--- +paths: + - "**/nuxt.config.*" + - "**/server/**/*.ts" + - "**/pages/**" + - "**/layouts/**" + - "**/middleware/**" +--- + +# Nuxt Testing + +> This file extends [common/testing.md](../common/testing.md) with Nuxt specific content. + +Package: `@nuxt/test-utils`. Vitest-first for unit and component tests, with built-in Playwright browser E2E support. nuxt-vitest and vitest-environment-nuxt are superseded and folded into it. + +## Setup + +- Install dev deps: `@nuxt/test-utils vitest @vue/test-utils happy-dom playwright-core`. +- Config: `defineVitestConfig({ test: { environment: 'nuxt' } })` from `@nuxt/test-utils/config`. Use `defineVitestProject` for multi-project (separate unit / nuxt / e2e environments). +- Add `@nuxt/test-utils/module` to `nuxt.config`. Per-file opt-in via `// @vitest-environment nuxt`. + +## Runtime helpers + +Import from `@nuxt/test-utils/runtime`. + +- `mountSuspended(component, opts)` mounts in the Nuxt env with async setup + plugin injection (accepts `@vue/test-utils` mount options + `route`). +- `renderSuspended(component, opts)` is the Testing Library variant (needs `@testing-library/vue`). +- `mockNuxtImport(name, factory)` mocks auto-imports (e.g. `useState`). Once per import per file, use `vi.hoisted()`. +- `mockComponent(name, factory)` mocks by PascalCase name or path. +- `registerEndpoint(path, handler|opts)` mocks a Nitro endpoint to test server routes or stub the backend. Supports method + `once`. + +## E2E helpers + +Import from `@nuxt/test-utils/e2e`. + +- `await setup({ rootDir, server, browser, ... })` inside the describe block (manages beforeAll/afterAll). +- Then `$fetch(url)` (rendered HTML), `fetch(url)` (response object), `url(path)` (full URL with port), `createPage(url)` (Playwright). +- Playwright integration: import `expect` / `test` from `@nuxt/test-utils/playwright`. + +## What to test how + +- Composables: mock auto-imports with `mockNuxtImport`, mount a host component via `mountSuspended` to exercise `useState` / `useFetch` in the Nuxt runtime. +- Server routes: `registerEndpoint` to stub, or e2e `$fetch` / `fetch` against the real Nitro server. + +## Reference + +- ECC skills: `nuxt4-patterns`, `e2e-testing`, `vite-patterns`. +- [Nuxt testing docs](https://nuxt.com/docs/getting-started/testing) +- [@nuxt/test-utils npm](https://www.npmjs.com/package/@nuxt/test-utils) diff --git a/.claude/rules/ecc/perl/coding-style.md b/.claude/rules/ecc/perl/coding-style.md new file mode 100644 index 0000000..9c7fbb2 --- /dev/null +++ b/.claude/rules/ecc/perl/coding-style.md @@ -0,0 +1,46 @@ +--- +paths: + - "**/*.pl" + - "**/*.pm" + - "**/*.t" + - "**/*.psgi" + - "**/*.cgi" +--- +# Perl Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Perl-specific content. + +## Standards + +- Always `use v5.36` (enables `strict`, `warnings`, `say`, subroutine signatures) +- Use subroutine signatures — never unpack `@_` manually +- Prefer `say` over `print` with explicit newlines + +## Immutability + +- Use **Moo** with `is => 'ro'` and `Types::Standard` for all attributes +- Never use blessed hashrefs directly — always use Moo/Moose accessors +- **OO override note**: Moo `has` attributes with `builder` or `default` are acceptable for computed read-only values + +## Formatting + +Use **perltidy** with these settings: + +``` +-i=4 # 4-space indent +-l=100 # 100 char line length +-ce # cuddled else +-bar # opening brace always right +``` + +## Linting + +Use **perlcritic** at severity 3 with themes: `core`, `pbp`, `security`. + +```bash +perlcritic --severity 3 --theme 'core || pbp || security' lib/ +``` + +## Reference + +See skill: `perl-patterns` for comprehensive modern Perl idioms and best practices. diff --git a/.claude/rules/ecc/perl/hooks.md b/.claude/rules/ecc/perl/hooks.md new file mode 100644 index 0000000..0b6daad --- /dev/null +++ b/.claude/rules/ecc/perl/hooks.md @@ -0,0 +1,22 @@ +--- +paths: + - "**/*.pl" + - "**/*.pm" + - "**/*.t" + - "**/*.psgi" + - "**/*.cgi" +--- +# Perl Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Perl-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **perltidy**: Auto-format `.pl` and `.pm` files after edit +- **perlcritic**: Run lint check after editing `.pm` files + +## Warnings + +- Warn about `print` in non-script `.pm` files — use `say` or a logging module (e.g., `Log::Any`) diff --git a/.claude/rules/ecc/perl/patterns.md b/.claude/rules/ecc/perl/patterns.md new file mode 100644 index 0000000..a2f7b4f --- /dev/null +++ b/.claude/rules/ecc/perl/patterns.md @@ -0,0 +1,76 @@ +--- +paths: + - "**/*.pl" + - "**/*.pm" + - "**/*.t" + - "**/*.psgi" + - "**/*.cgi" +--- +# Perl Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Perl-specific content. + +## Repository Pattern + +Use **DBI** or **DBIx::Class** behind an interface: + +```perl +package MyApp::Repo::User; +use Moo; + +has dbh => (is => 'ro', required => 1); + +sub find_by_id ($self, $id) { + my $sth = $self->dbh->prepare('SELECT * FROM users WHERE id = ?'); + $sth->execute($id); + return $sth->fetchrow_hashref; +} +``` + +## DTOs / Value Objects + +Use **Moo** classes with **Types::Standard** (equivalent to Python dataclasses): + +```perl +package MyApp::DTO::User; +use Moo; +use Types::Standard qw(Str Int); + +has name => (is => 'ro', isa => Str, required => 1); +has email => (is => 'ro', isa => Str, required => 1); +has age => (is => 'ro', isa => Int); +``` + +## Resource Management + +- Always use **three-arg open** with `autodie` +- Use **Path::Tiny** for file operations + +```perl +use autodie; +use Path::Tiny; + +my $content = path('config.json')->slurp_utf8; +``` + +## Module Interface + +Use `Exporter 'import'` with `@EXPORT_OK` — never `@EXPORT`: + +```perl +use Exporter 'import'; +our @EXPORT_OK = qw(parse_config validate_input); +``` + +## Dependency Management + +Use **cpanfile** + **carton** for reproducible installs: + +```bash +carton install +carton exec prove -lr t/ +``` + +## Reference + +See skill: `perl-patterns` for comprehensive modern Perl patterns and idioms. diff --git a/.claude/rules/ecc/perl/security.md b/.claude/rules/ecc/perl/security.md new file mode 100644 index 0000000..c87fefc --- /dev/null +++ b/.claude/rules/ecc/perl/security.md @@ -0,0 +1,69 @@ +--- +paths: + - "**/*.pl" + - "**/*.pm" + - "**/*.t" + - "**/*.psgi" + - "**/*.cgi" +--- +# Perl Security + +> This file extends [common/security.md](../common/security.md) with Perl-specific content. + +## Taint Mode + +- Use `-T` flag on all CGI/web-facing scripts +- Sanitize `%ENV` (`$ENV{PATH}`, `$ENV{CDPATH}`, etc.) before any external command + +## Input Validation + +- Use allowlist regex for untainting — never `/(.*)/s` +- Validate all user input with explicit patterns: + +```perl +if ($input =~ /\A([a-zA-Z0-9_-]+)\z/) { + my $clean = $1; +} +``` + +## File I/O + +- **Three-arg open only** — never two-arg open +- Prevent path traversal with `Cwd::realpath`: + +```perl +use Cwd 'realpath'; +my $safe_path = realpath($user_path); +die "Path traversal" unless $safe_path =~ m{\A/allowed/directory/}; +``` + +## Process Execution + +- Use **list-form `system()`** — never single-string form +- Use **IPC::Run3** for capturing output +- Never use backticks with variable interpolation + +```perl +system('grep', '-r', $pattern, $directory); # safe +``` + +## SQL Injection Prevention + +Always use DBI placeholders — never interpolate into SQL: + +```perl +my $sth = $dbh->prepare('SELECT * FROM users WHERE email = ?'); +$sth->execute($email); +``` + +## Security Scanning + +Run **perlcritic** with the security theme at severity 4+: + +```bash +perlcritic --severity 4 --theme security lib/ +``` + +## Reference + +See skill: `perl-security` for comprehensive Perl security patterns, taint mode, and safe I/O. diff --git a/.claude/rules/ecc/perl/testing.md b/.claude/rules/ecc/perl/testing.md new file mode 100644 index 0000000..d451699 --- /dev/null +++ b/.claude/rules/ecc/perl/testing.md @@ -0,0 +1,54 @@ +--- +paths: + - "**/*.pl" + - "**/*.pm" + - "**/*.t" + - "**/*.psgi" + - "**/*.cgi" +--- +# Perl Testing + +> This file extends [common/testing.md](../common/testing.md) with Perl-specific content. + +## Framework + +Use **Test2::V0** for new projects (not Test::More): + +```perl +use Test2::V0; + +is($result, 42, 'answer is correct'); + +done_testing; +``` + +## Runner + +```bash +prove -l t/ # adds lib/ to @INC +prove -lr -j8 t/ # recursive, 8 parallel jobs +``` + +Always use `-l` to ensure `lib/` is on `@INC`. + +## Coverage + +Use **Devel::Cover** — target 80%+: + +```bash +cover -test +``` + +## Mocking + +- **Test::MockModule** — mock methods on existing modules +- **Test::MockObject** — create test doubles from scratch + +## Pitfalls + +- Always end test files with `done_testing` +- Never forget the `-l` flag with `prove` + +## Reference + +See skill: `perl-testing` for detailed Perl TDD patterns with Test2::V0, prove, and Devel::Cover. diff --git a/.claude/rules/ecc/php/coding-style.md b/.claude/rules/ecc/php/coding-style.md new file mode 100644 index 0000000..382ea9b --- /dev/null +++ b/.claude/rules/ecc/php/coding-style.md @@ -0,0 +1,40 @@ +--- +paths: + - "**/*.php" + - "**/composer.json" +--- +# PHP Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with PHP specific content. + +## Standards + +- Follow **PSR-12** formatting and naming conventions. +- Prefer `declare(strict_types=1);` in application code. +- Use scalar type hints, return types, and typed properties everywhere new code permits. + +## Immutability + +- Prefer immutable DTOs and value objects for data crossing service boundaries. +- Use `readonly` properties or immutable constructors for request/response payloads where possible. +- Keep arrays for simple maps; promote business-critical structures into explicit classes. + +## Formatting + +- Use **PHP-CS-Fixer** or **Laravel Pint** for formatting. +- Use **PHPStan** or **Psalm** for static analysis. +- Keep Composer scripts checked in so the same commands run locally and in CI. + +## Imports + +- Add `use` statements for all referenced classes, interfaces, and traits. +- Avoid relying on the global namespace unless the project explicitly prefers fully qualified names. + +## Error Handling + +- Throw exceptions for exceptional states; avoid returning `false`/`null` as hidden error channels in new code. +- Convert framework/request input into validated DTOs before it reaches domain logic. + +## Reference + +See skill: `backend-patterns` for broader service/repository layering guidance. diff --git a/.claude/rules/ecc/php/hooks.md b/.claude/rules/ecc/php/hooks.md new file mode 100644 index 0000000..10dd3c9 --- /dev/null +++ b/.claude/rules/ecc/php/hooks.md @@ -0,0 +1,24 @@ +--- +paths: + - "**/*.php" + - "**/composer.json" + - "**/phpstan.neon" + - "**/phpstan.neon.dist" + - "**/psalm.xml" +--- +# PHP Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with PHP specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **Pint / PHP-CS-Fixer**: Auto-format edited `.php` files. +- **PHPStan / Psalm**: Run static analysis after PHP edits in typed codebases. +- **PHPUnit / Pest**: Run targeted tests for touched files or modules when edits affect behavior. + +## Warnings + +- Warn on `var_dump`, `dd`, `dump`, or `die()` left in edited files. +- Warn when edited PHP files add raw SQL or disable CSRF/session protections. diff --git a/.claude/rules/ecc/php/patterns.md b/.claude/rules/ecc/php/patterns.md new file mode 100644 index 0000000..b914474 --- /dev/null +++ b/.claude/rules/ecc/php/patterns.md @@ -0,0 +1,33 @@ +--- +paths: + - "**/*.php" + - "**/composer.json" +--- +# PHP Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with PHP specific content. + +## Thin Controllers, Explicit Services + +- Keep controllers focused on transport: auth, validation, serialization, status codes. +- Move business rules into application/domain services that are easy to test without HTTP bootstrapping. + +## DTOs and Value Objects + +- Replace shape-heavy associative arrays with DTOs for requests, commands, and external API payloads. +- Use value objects for money, identifiers, date ranges, and other constrained concepts. + +## Dependency Injection + +- Depend on interfaces or narrow service contracts, not framework globals. +- Pass collaborators through constructors so services are testable without service-locator lookups. + +## Boundaries + +- Isolate ORM models from domain decisions when the model layer is doing more than persistence. +- Wrap third-party SDKs behind small adapters so the rest of the codebase depends on your contract, not theirs. + +## Reference + +See skill: `api-design` for endpoint conventions and response-shape guidance. +See skill: `laravel-patterns` for Laravel-specific architecture guidance. diff --git a/.claude/rules/ecc/php/security.md b/.claude/rules/ecc/php/security.md new file mode 100644 index 0000000..d559b8a --- /dev/null +++ b/.claude/rules/ecc/php/security.md @@ -0,0 +1,37 @@ +--- +paths: + - "**/*.php" + - "**/composer.lock" + - "**/composer.json" +--- +# PHP Security + +> This file extends [common/security.md](../common/security.md) with PHP specific content. + +## Input and Output + +- Validate request input at the framework boundary (`FormRequest`, Symfony Validator, or explicit DTO validation). +- Escape output in templates by default; treat raw HTML rendering as an exception that must be justified. +- Never trust query params, cookies, headers, or uploaded file metadata without validation. + +## Database Safety + +- Use prepared statements (`PDO`, Doctrine, Eloquent query builder) for all dynamic queries. +- Avoid string-building SQL in controllers/views. +- Scope ORM mass-assignment carefully and whitelist writable fields. + +## Secrets and Dependencies + +- Load secrets from environment variables or a secret manager, never from committed config files. +- Run `composer audit` in CI and review new package maintainer trust before adding dependencies. +- Pin major versions deliberately and remove abandoned packages quickly. + +## Auth and Session Safety + +- Use `password_hash()` / `password_verify()` for password storage. +- Regenerate session identifiers after authentication and privilege changes. +- Enforce CSRF protection on state-changing web requests. + +## Reference + +See skill: `laravel-security` for Laravel-specific security guidance. diff --git a/.claude/rules/ecc/php/testing.md b/.claude/rules/ecc/php/testing.md new file mode 100644 index 0000000..b069901 --- /dev/null +++ b/.claude/rules/ecc/php/testing.md @@ -0,0 +1,39 @@ +--- +paths: + - "**/*.php" + - "**/phpunit.xml" + - "**/phpunit.xml.dist" + - "**/composer.json" +--- +# PHP Testing + +> This file extends [common/testing.md](../common/testing.md) with PHP specific content. + +## Framework + +Use **PHPUnit** as the default test framework. If **Pest** is configured in the project, prefer Pest for new tests and avoid mixing frameworks. + +## Coverage + +```bash +vendor/bin/phpunit --coverage-text +# or +vendor/bin/pest --coverage +``` + +Prefer **pcov** or **Xdebug** in CI, and keep coverage thresholds in CI rather than as tribal knowledge. + +## Test Organization + +- Separate fast unit tests from framework/database integration tests. +- Use factory/builders for fixtures instead of large hand-written arrays. +- Keep HTTP/controller tests focused on transport and validation; move business rules into service-level tests. + +## Inertia + +If the project uses Inertia.js, prefer `assertInertia` with `AssertableInertia` to verify component names and props instead of raw JSON assertions. + +## Reference + +See skill: `tdd-workflow` for the repo-wide RED -> GREEN -> REFACTOR loop. +See skill: `laravel-tdd` for Laravel-specific testing patterns (PHPUnit and Pest). diff --git a/.claude/rules/ecc/python/coding-style.md b/.claude/rules/ecc/python/coding-style.md new file mode 100644 index 0000000..3a01ae3 --- /dev/null +++ b/.claude/rules/ecc/python/coding-style.md @@ -0,0 +1,42 @@ +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- +# Python Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Python specific content. + +## Standards + +- Follow **PEP 8** conventions +- Use **type annotations** on all function signatures + +## Immutability + +Prefer immutable data structures: + +```python +from dataclasses import dataclass + +@dataclass(frozen=True) +class User: + name: str + email: str + +from typing import NamedTuple + +class Point(NamedTuple): + x: float + y: float +``` + +## Formatting + +- **black** for code formatting +- **isort** for import sorting +- **ruff** for linting + +## Reference + +See skill: `python-patterns` for comprehensive Python idioms and patterns. diff --git a/.claude/rules/ecc/python/fastapi.md b/.claude/rules/ecc/python/fastapi.md new file mode 100644 index 0000000..6417b3a --- /dev/null +++ b/.claude/rules/ecc/python/fastapi.md @@ -0,0 +1,58 @@ +--- +paths: + - "**/app/**/*.py" + - "**/fastapi/**/*.py" + - "**/*_api.py" +--- +# FastAPI Rules + +Use these rules for FastAPI projects alongside the general Python rules. + +## Structure + +- Put app construction in `create_app()`. +- Keep routers thin; move persistence and business behavior into services or CRUD helpers. +- Keep request schemas, update schemas, and response schemas separate. +- Keep database sessions and auth in dependencies. + +## Async + +- Use `async def` for endpoints that perform I/O. +- Use async database and HTTP clients from async endpoints. +- Do not call `requests`, sync SQLAlchemy sessions, or blocking file/network operations from async routes. + +## Dependency Injection + +```python +@router.get("/users/{user_id}") +async def get_user( + user_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + ... +``` + +Do not create `SessionLocal()` or long-lived clients inside route handlers. + +## Schemas + +- Never include passwords, password hashes, access tokens, refresh tokens, or internal auth state in response models. +- Use `response_model` on endpoints that return application data. +- Use field constraints instead of hand-written validation when Pydantic can express the rule. + +## Security + +- Keep CORS origins environment-specific. +- Do not combine wildcard origins with credentialed CORS. +- Validate JWT expiry, issuer, audience, and algorithm. +- Rate-limit auth and write-heavy endpoints. +- Redact credentials, cookies, authorization headers, and tokens from logs. + +## Testing + +- Override the exact dependency used by `Depends`. +- Clear `app.dependency_overrides` after tests. +- Prefer async test clients for async applications. + +See skill: `fastapi-patterns`. diff --git a/.claude/rules/ecc/python/hooks.md b/.claude/rules/ecc/python/hooks.md new file mode 100644 index 0000000..600c5ea --- /dev/null +++ b/.claude/rules/ecc/python/hooks.md @@ -0,0 +1,19 @@ +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- +# Python Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Python specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **black/ruff**: Auto-format `.py` files after edit +- **mypy/pyright**: Run type checking after editing `.py` files + +## Warnings + +- Warn about `print()` statements in edited files (use `logging` module instead) diff --git a/.claude/rules/ecc/python/patterns.md b/.claude/rules/ecc/python/patterns.md new file mode 100644 index 0000000..5b7f899 --- /dev/null +++ b/.claude/rules/ecc/python/patterns.md @@ -0,0 +1,39 @@ +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- +# Python Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Python specific content. + +## Protocol (Duck Typing) + +```python +from typing import Protocol + +class Repository(Protocol): + def find_by_id(self, id: str) -> dict | None: ... + def save(self, entity: dict) -> dict: ... +``` + +## Dataclasses as DTOs + +```python +from dataclasses import dataclass + +@dataclass +class CreateUserRequest: + name: str + email: str + age: int | None = None +``` + +## Context Managers & Generators + +- Use context managers (`with` statement) for resource management +- Use generators for lazy evaluation and memory-efficient iteration + +## Reference + +See skill: `python-patterns` for comprehensive patterns including decorators, concurrency, and package organization. diff --git a/.claude/rules/ecc/python/security.md b/.claude/rules/ecc/python/security.md new file mode 100644 index 0000000..e795baf --- /dev/null +++ b/.claude/rules/ecc/python/security.md @@ -0,0 +1,30 @@ +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- +# Python Security + +> This file extends [common/security.md](../common/security.md) with Python specific content. + +## Secret Management + +```python +import os +from dotenv import load_dotenv + +load_dotenv() + +api_key = os.environ["OPENAI_API_KEY"] # Raises KeyError if missing +``` + +## Security Scanning + +- Use **bandit** for static security analysis: + ```bash + bandit -r src/ + ``` + +## Reference + +See skill: `django-security` for Django-specific security guidelines (if applicable). diff --git a/.claude/rules/ecc/python/testing.md b/.claude/rules/ecc/python/testing.md new file mode 100644 index 0000000..49e3f08 --- /dev/null +++ b/.claude/rules/ecc/python/testing.md @@ -0,0 +1,38 @@ +--- +paths: + - "**/*.py" + - "**/*.pyi" +--- +# Python Testing + +> This file extends [common/testing.md](../common/testing.md) with Python specific content. + +## Framework + +Use **pytest** as the testing framework. + +## Coverage + +```bash +pytest --cov=src --cov-report=term-missing +``` + +## Test Organization + +Use `pytest.mark` for test categorization: + +```python +import pytest + +@pytest.mark.unit +def test_calculate_total(): + ... + +@pytest.mark.integration +def test_database_connection(): + ... +``` + +## Reference + +See skill: `python-testing` for detailed pytest patterns and fixtures. diff --git a/.claude/rules/ecc/react/coding-style.md b/.claude/rules/ecc/react/coding-style.md new file mode 100644 index 0000000..169f314 --- /dev/null +++ b/.claude/rules/ecc/react/coding-style.md @@ -0,0 +1,109 @@ +--- +paths: + - "**/*.tsx" + - "**/*.jsx" + - "**/components/**/*.ts" + - "**/components/**/*.js" + - "**/hooks/**/*.ts" + - "**/hooks/**/*.js" +--- +# React Coding Style + +> This file extends [typescript/coding-style.md](../typescript/coding-style.md) and [common/coding-style.md](../common/coding-style.md) with React specific content. + +## File Extensions + +- `.tsx` for any file containing JSX, even one-liner snippets +- `.ts` for pure logic, custom hooks without JSX, type definitions, utilities +- `.test.tsx` / `.test.ts` mirroring the source file +- Use `.jsx` only when the project intentionally avoids TypeScript — flag every new untyped React file in review + +## Naming + +- Components: `PascalCase` for both the symbol and the file (`UserCard.tsx`, default export `UserCard`) +- Custom hooks: `useCamelCase` for the symbol, kebab-case for the file when the project convention is kebab-case (`use-debounce.ts` exports `useDebounce`) +- Context: `Context` symbol, `Provider` provider component, `use` consumer hook +- Event handlers: `handleClick`, `handleSubmit` inside the component; the prop that receives it is `onClick`, `onSubmit` +- Boolean props: `isLoading`, `hasError`, `canSubmit` — never `loading` or `error` alone for booleans + +## Component Shape + +```tsx +type Props = { + user: User; + onSelect: (id: string) => void; +}; + +export function UserCard({ user, onSelect }: Props) { + return ( + + ); +} +``` + +- Prefer `type Props = {}` for closed component prop shapes +- Use `interface` only when the prop type is extended via declaration merging or exported as a public API extension point +- Always destructure props in the parameter list — no `props.user` access inside the body +- Type the return implicitly through JSX (`function Foo(): JSX.Element` only when the function returns conditionally and the union confuses inference) + +## JSX + +- Self-close tags with no children: ``, `` +- Use fragments `<>...` over wrapper `
` when no DOM element is needed +- Conditional rendering: `{condition && }` for booleans, ternary for either/or, early return for guard clauses +- Never put logic inline in JSX when it reads as multi-line — extract to a const above the return or a function + +```tsx +// Prefer +const greeting = user.isAdmin ? "Welcome, admin" : `Hello ${user.name}`; +return

{greeting}

; + +// Over +return

{user.isAdmin ? "Welcome, admin" : `Hello ${user.name}`}

; +``` + +## Server / Client Boundary (Next.js App Router, RSC) + +- Default a new file to Server Component — only add `"use client"` when the file uses state, effects, refs, browser APIs, or event handlers +- Place the `"use client"` directive on line 1, before any imports +- Never import a Client Component file from inside a `"use server"` action file +- Never re-export server-only code through a client module — the bundler will silently include it + +## Imports + +- React imports first: `import { useState } from "react"` +- Then third-party libs, then absolute project imports, then relative +- Type-only imports: `import type { ReactNode } from "react"` — never mix runtime and type imports in one statement when ESLint's `consistent-type-imports` is configured + +## Hooks Discipline + +See [hooks.md](./hooks.md) for the full ruleset. Style highlights: + +- Custom hooks must start with `use` — enforced by `eslint-plugin-react-hooks` +- Group all hook calls at the top of the component, before any conditional logic +- Avoid creating ad-hoc hooks for one-line wrappers — inline the call instead + +## State + +- Local first (`useState`), lift only when shared +- Context for cross-cutting state read by many components (theme, auth, i18n) — not for high-frequency updates +- External store (Zustand, Jotai, Redux Toolkit) when state must persist across route changes, sync across tabs, or be debugged via devtools +- Never duplicate state that can be derived — compute during render + +## Class Components + +Forbidden in new code. Convert legacy class components to function components when touching them for non-trivial changes. + +## File Layout per Component + +``` +components/UserCard/ + UserCard.tsx + UserCard.module.css # or styled-components, or Tailwind classes inline + UserCard.test.tsx + index.ts # re-export only +``` + +Inline single-file components are fine for trivial presentational pieces. diff --git a/.claude/rules/ecc/react/hooks.md b/.claude/rules/ecc/react/hooks.md new file mode 100644 index 0000000..a9b9d5a --- /dev/null +++ b/.claude/rules/ecc/react/hooks.md @@ -0,0 +1,187 @@ +--- +paths: + - "**/*.tsx" + - "**/*.jsx" + - "**/hooks/**/*.ts" + - "**/hooks/**/*.js" + - "**/use-*.ts" + - "**/use-*.tsx" +--- +# React Hooks + +> This file covers **React hooks** (`useState`, `useEffect`, `useMemo`, `useCallback`, custom hooks) — NOT the Claude Code `hooks/` runtime system. Naming matches the per-language convention `rules//hooks.md` used across this repo. +> +> Extends [typescript/patterns.md](../typescript/patterns.md) and [common/patterns.md](../common/patterns.md). + +## Rules of Hooks + +Enforce `eslint-plugin-react-hooks` with `react-hooks/rules-of-hooks` set to error. + +1. Hooks only at the top level of a function component or another hook +2. Never in loops, conditionals, nested functions, or after early returns +3. Always called in the same order on every render +4. Only inside React function components or custom hooks (functions starting with `use`) + +```tsx +// WRONG: conditional hook +function Foo({ enabled }: { enabled: boolean }) { + if (enabled) { + const [x, setX] = useState(0); // rule violation + } +} + +// CORRECT: hook unconditional, condition inside +function Foo({ enabled }: { enabled: boolean }) { + const [x, setX] = useState(0); + if (!enabled) return null; + return {x}; +} +``` + +## `useEffect` — When NOT to Use + +`useEffect` is for synchronizing with external systems (subscriptions, browser APIs, third-party libraries). It is **not** the right tool for: + +- Derived state — compute it during render +- Transforming data for rendering — compute it during render +- Resetting state when a prop changes — use a `key` on the parent or derive from props +- Notifying parents of state changes — call the callback in the event handler +- Initializing app-level singletons — call the function module-side or in `main.tsx` + +```tsx +// WRONG: effect for derived state +const [fullName, setFullName] = useState(""); +useEffect(() => { + setFullName(`${first} ${last}`); +}, [first, last]); + +// CORRECT: derive during render +const fullName = `${first} ${last}`; +``` + +## Dependency Arrays + +- Always include every reactive value referenced inside the effect/callback +- Enable `react-hooks/exhaustive-deps` lint rule — never silence it without a comment explaining why +- If the dep array grows unwieldy, the effect is doing too much — split it +- Stable identity for functions passed in deps: wrap in `useCallback` only when the function is itself a dependency of another hook or passed to a memoized child + +## Cleanup + +Every subscription, interval, listener, or in-flight request must clean up. + +```tsx +useEffect(() => { + const controller = new AbortController(); + fetch(url, { signal: controller.signal }).then(handleResponse); + return () => controller.abort(); +}, [url]); +``` + +```tsx +useEffect(() => { + const id = setInterval(tick, 1000); + return () => clearInterval(id); +}, []); +``` + +Missing cleanup = race conditions when deps change, memory leaks on unmount. + +## `useMemo` and `useCallback` — When Worth It + +Default position: **do not memoize**. Add `useMemo` / `useCallback` only when: + +1. The value is passed to a `React.memo`-wrapped child as a prop, and identity matters +2. The value is a dependency of another `useEffect` / `useMemo` / `useCallback` +3. The computation is measurably expensive (profile before assuming) + +Premature memoization adds noise, hides bugs, and can be slower than the recompute it replaces. + +## Custom Hooks + +Extract a custom hook when: + +- The same hook sequence (state + effect + computed) appears in 2+ components +- The logic has a clear, nameable purpose (`useDebounce`, `useOnClickOutside`, `useLocalStorage`) +- You want to test the logic independently of any component + +Do NOT extract when: + +- It would have a single caller — inline it +- The "hook" is just `useState` with a different name — adds indirection, no value + +```tsx +export function useDebounce(value: T, delay: number): T { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + return debounced; +} +``` + +## `useState` Patterns + +- Initial state from prop only at mount: pass a function `useState(() => computeInitial(prop))` when computation is expensive +- Functional updater when the new state depends on the old: `setCount(c => c + 1)` — never `setCount(count + 1)` inside async or batched contexts +- Group related state into one object only when they always change together; otherwise split into multiple `useState` calls +- Use `useReducer` once state transitions are conditional on the previous state or there are 3+ related values + +## `useRef` Patterns + +- DOM refs for imperative APIs (focus, scroll, third-party libs) +- Mutable container that does not trigger re-render (timer ids, previous values, "is mounted" flags) +- Never read or write `ref.current` during render — only inside effects or event handlers +- `useImperativeHandle` only when exposing a child API to a parent ref — last-resort escape hatch + +## `useSyncExternalStore` + +Use this hook to subscribe to any external store (browser API, third-party state lib, custom event emitter). It is the supported way to make external state safe with concurrent rendering. + +```tsx +const isOnline = useSyncExternalStore( + (cb) => { + window.addEventListener("online", cb); + window.addEventListener("offline", cb); + return () => { + window.removeEventListener("online", cb); + window.removeEventListener("offline", cb); + }; + }, + () => navigator.onLine, + () => true, +); +``` + +## React 19 Additions + +- `use()` — unwrap promises and contexts inline; usable conditionally (only hook with that property) +- `useFormStatus()` / `useFormState()` (or `useActionState`) — form submission state without prop drilling +- `useOptimistic()` — optimistic UI updates while a server action is pending +- `useTransition()` — mark non-urgent state updates so urgent ones stay responsive + +When the project targets React 19+, prefer these over hand-rolled equivalents. + +## Stale Closure Trap + +Async handlers and intervals capture the values from the render where they were created. Fix by: + +1. Using the functional updater form of `setState` +2. Putting the changing value in the dep array of `useEffect` and rebuilding the handler +3. Reading from a ref that is kept in sync + +## Lint Configuration + +Required rules: + +```json +{ + "rules": { + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" + } +} +``` + +Treat `exhaustive-deps` warnings as errors in CI for new code. diff --git a/.claude/rules/ecc/react/patterns.md b/.claude/rules/ecc/react/patterns.md new file mode 100644 index 0000000..8a41293 --- /dev/null +++ b/.claude/rules/ecc/react/patterns.md @@ -0,0 +1,194 @@ +--- +paths: + - "**/*.tsx" + - "**/*.jsx" + - "**/components/**/*.ts" + - "**/components/**/*.js" + - "**/app/**/*.tsx" + - "**/pages/**/*.tsx" +--- +# React Patterns + +> This file extends [typescript/patterns.md](../typescript/patterns.md) and [common/patterns.md](../common/patterns.md) with React specific content. For hook-specific rules see [hooks.md](./hooks.md). + +## Container / Presentational Split + +Container components own data fetching, state, and side effects. Presentational components receive props and render — no service calls, no hooks beyond local UI state. + +```tsx +// Container — owns data +export function UserPage({ userId }: { userId: string }) { + const { data: user, isLoading } = useUser(userId); + if (isLoading) return ; + if (!user) return ; + return ; +} + +// Presentational — pure +export function UserCard({ user, onSelect }: { user: User; onSelect: (id: string) => void }) { + return ; +} +``` + +## State Location Decision Tree + +1. Used by one component → `useState` inside it +2. Used by parent + a few children → lift to nearest common ancestor, pass via props +3. Used across distant branches → React Context **for low-frequency reads only** (theme, auth, locale) +4. High-frequency updates shared across the tree → external store (Zustand, Jotai, Redux Toolkit) +5. Server-derived data → server-state library (TanStack Query, SWR, RSC fetch) — not application state + +Context misused for frequently changing values causes every consumer to re-render on every update. + +## Server / Client Component Boundary (RSC, Next.js App Router) + +- Server Components are the default — they run on the server, do not ship to the client, and can `await` directly +- Client Components opt in with `"use client"` at the top of the file +- Data flows down: a Server Component can render a Client Component and pass serializable props +- A Client Component cannot import a Server Component, but it can receive one via `children` or named slots + +```tsx +// Server (default) +export default async function Page() { + const user = await fetchUser(); + return ; +} + +// Client +"use client"; +export function UserClient({ user }: { user: User }) { + const [tab, setTab] = useState("profile"); + return {user.name}; +} +``` + +- Never import `"server-only"` packages (DB clients, secrets) from a Client Component file — wrap them in a Server Component or Server Action +- Mark sensitive modules with `import "server-only"` so the bundler errors if a client file imports them + +## Suspense + Error Boundaries + +Every Suspense boundary needs an Error Boundary above it. The pair handles both states. + +```tsx +}> + }> + + + +``` + +- Place Suspense boundaries close to where data is needed, not at the route root +- Multiple narrower boundaries reveal loaded content progressively +- Error Boundary must be a Class Component (React 19 has no functional equivalent yet) OR use a library wrapper such as `react-error-boundary` + +## Forms + +### Uncontrolled (React 19 + form actions) + +Prefer uncontrolled inputs with form actions when the form has a clear submit step. The browser owns the value; React reads it via `FormData` on submit. + +```tsx +async function action(formData: FormData) { + "use server"; + await saveUser({ name: String(formData.get("name")) }); +} + +export function UserForm() { + return ( +
+ + +
+ ); +} +``` + +### Controlled + +Use controlled inputs when the value drives other UI, requires real-time validation, or formatting. + +```tsx +const [email, setEmail] = useState(""); +return setEmail(e.target.value)} />; +``` + +### Form Libraries + +For complex forms (multi-step, dynamic field arrays, cross-field validation), use a library: + +- React Hook Form — minimal re-renders, uncontrolled-first +- TanStack Form — typed, framework-agnostic +- Final Form — when subscription-based re-renders matter + +## Data Fetching + +| Strategy | When | +|---|---| +| RSC fetch (`await` in Server Component) | Per-request data in Next.js App Router, no client-side cache needed | +| TanStack Query | Client-side cache, mutations, optimistic updates, polling | +| SWR | Lightweight cache + revalidation, simpler than TanStack Query | +| `fetch` in `useEffect` | Avoid — race conditions, no cache, no retry. Only acceptable for one-off fire-and-forget | + +Never fetch in a `useEffect` when a real cache library is available — they handle deduping, cache invalidation, error retry, and Suspense integration. + +## Lists and Keys + +- `key` must be stable across renders — never `index` for any list that can reorder, insert, or delete +- `key` must be unique among siblings, not globally +- A reordered list with index keys causes state in child components to attach to the wrong row + +## Composition over Inheritance + +- Pass `children` for slot-style composition +- Pass render-prop functions for parameterized rendering +- Pass component types for plug-in points: `renderItem={UserRow}` +- Never extend a component class to specialize behavior + +## Compound Components + +For related controls (Tabs, Accordion, Menu), use compound components sharing state via Context: + +```tsx + + + Profile + Settings + + + + +``` + +## Portals + +Use `createPortal` for modals, tooltips, toast containers — anything that must escape the parent's `overflow: hidden` or `z-index` stacking context. Render to a stable DOM node mounted in `index.html`. + +## Refs and Forwarding (React 19+) + +React 19 lets function components accept `ref` as a regular prop — `forwardRef` is no longer required. + +```tsx +export function Input({ ref, ...rest }: { ref?: React.Ref } & InputProps) { + return ; +} +``` + +Older codebases on React 18 still need `forwardRef`. + +## Out of Scope (Pointer Sections) + +### Next.js (App Router) + +- Server Actions, Route Handlers, Middleware, Parallel/Intercepted Routes, streaming Metadata +- Treated as a separate framework concern — when adding deep Next-specific patterns, propose a dedicated `rules/nextjs/` track +- For now follow Next.js official docs for App Router specifics + +### React Native + +- Platform-specific imports (`Platform.OS`, `.ios.tsx` / `.android.tsx`), `StyleSheet`, navigation libraries (React Navigation, Expo Router) +- Treated as a separate track — `rules/react-native/` is not yet present +- React core hooks/patterns from this file still apply + +## Skill Reference + +For React-specific deep dives see `skills/react-patterns/SKILL.md`. For cross-framework frontend concerns see `skills/frontend-patterns/SKILL.md`. For accessibility see `skills/accessibility/SKILL.md`. diff --git a/.claude/rules/ecc/react/security.md b/.claude/rules/ecc/react/security.md new file mode 100644 index 0000000..1e3553e --- /dev/null +++ b/.claude/rules/ecc/react/security.md @@ -0,0 +1,180 @@ +--- +paths: + - "**/*.tsx" + - "**/*.jsx" + - "**/components/**/*.ts" + - "**/app/**/*.ts" + - "**/pages/**/*.ts" +--- +# React Security + +> This file extends [typescript/security.md](../typescript/security.md) and [common/security.md](../common/security.md) with React specific content. + +## XSS via `dangerouslySetInnerHTML` + +CRITICAL. The prop name is deliberately scary — treat every usage as a code review halt. + +```tsx +// CRITICAL: unsanitized user input +
+ +// CORRECT options: +// 1. Render as text +
{userBio}
+ +// 2. Render parsed markdown via a library that sanitizes +{userBio} + +// 3. If raw HTML is required, sanitize first with DOMPurify +import DOMPurify from "isomorphic-dompurify"; +
+``` + +Audit checklist for every `dangerouslySetInnerHTML` call: + +- Is the input always under our control? Document the source. +- If user-derived: is it sanitized at the **same call site**? (Sanitization at the API boundary is acceptable only if every consumer is verified.) +- Is the sanitizer config allowlisting tags, not denylisting? + +## Unsafe URL Schemes + +`javascript:` and `data:` URLs in `href`, `src`, and `xlink:href` execute arbitrary code. + +```tsx +// CRITICAL: javascript: URL injection +Visit // if user.website = "javascript:alert(1)" + +// CORRECT: validate scheme +function safeUrl(url: string): string | undefined { + try { + const parsed = new URL(url); + if (["http:", "https:", "mailto:"].includes(parsed.protocol)) return url; + } catch { + return undefined; + } + return undefined; +} +Visit +``` + +React warns about `javascript:` URLs in `href` in development mode, but does not block them at runtime. `data:` URLs and other schemes also slip through. Always validate. + +## `target="_blank"` Without `rel` + +`` without `rel="noopener noreferrer"` lets the target page access `window.opener` and run navigation hijacks. + +```tsx +// WRONG +External + +// CORRECT +External +``` + +Modern browsers default to `noopener` when `target="_blank"`, but do not rely on browser defaults — be explicit. + +## Server Action Input Validation + +Server Actions (`"use server"`) run with the same trust level as a public API endpoint. Validate every input. + +```tsx +"use server"; +import { z } from "zod"; + +const Input = z.object({ + email: z.string().email(), + age: z.number().int().min(0).max(120), +}); + +export async function updateUser(_state: unknown, formData: FormData) { + const parsed = Input.safeParse({ + email: formData.get("email"), + age: Number(formData.get("age")), + }); + if (!parsed.success) return { error: parsed.error.flatten() }; + // ... +} +``` + +- Authenticate inside the action — do not trust the client-side route gate +- Authorize: confirm the current user has permission for the specific record they are mutating +- Rate limit sensitive actions + +## Secret Exposure via Env Vars + +Prefixed env vars are bundled into the client. Treat them as public. + +| Framework | Public prefix | Private | +|---|---|---| +| Next.js | `NEXT_PUBLIC_*` | All others | +| Vite | `VITE_*` | `.env` server-side only | +| Create React App | `REACT_APP_*`, plus `NODE_ENV` and `PUBLIC_URL` | All others (anything without the `REACT_APP_` prefix is server-side only) | +| Remix | `process.env` access in `loader`/`action` only | Same | + +```ts +// CRITICAL: secret leaked to client bundle +const apiKey = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY; +``` + +Audit on every PR that touches env vars: would this string in the public bundle be a problem? + +## Authentication / Authorization + +- Never store sessions in `localStorage` — accessible to any XSS. Use httpOnly secure cookies. +- Never trust client-set state to gate sensitive UI. Render-gating in JSX prevents display, not access — the API must enforce. +- CSRF: cookie-based auth requires CSRF tokens or `SameSite=Strict`/`Lax` cookies +- Use double-submit cookies or origin verification for form actions when not using framework defaults + +## Content Security Policy (CSP) + +Configure server-side. The minimum acceptable CSP for a React app: + +``` +default-src 'self'; +script-src 'self' 'nonce-{REQUEST_NONCE}'; +style-src 'self' 'unsafe-inline'; +img-src 'self' data: https:; +connect-src 'self' https://api.example.com; +frame-ancestors 'none'; +``` + +- Avoid `unsafe-inline` and `unsafe-eval` in `script-src` +- For SSR with inline scripts (Next.js streaming, hydration data), use per-request nonces — both Next.js and Remix support nonce injection +- `style-src 'unsafe-inline'` is often unavoidable for CSS-in-JS libraries — document the tradeoff + +## Prototype Pollution via Object Spread + +```tsx +// WRONG: untrusted JSON spread directly into state +const update = await req.json(); +setState({ ...state, ...update }); // attacker controls __proto__ + +// CORRECT: parse with a schema, or guard keys +const Allowed = z.object({ name: z.string(), email: z.string().email() }); +const parsed = Allowed.parse(await req.json()); +setState({ ...state, ...parsed }); +``` + +## SSR Template Injection + +When using `renderToString` or `renderToPipeableStream`: + +- All values rendered inside JSX are escaped by React — safe +- Values passed to `dangerouslySetInnerHTML` are NOT escaped — same rules as client +- Manually constructed HTML wrappers around the React output must be escaped or sanitized — never concatenate user input into the surrounding HTML template + +## Third-Party Components + +- Audit `npm audit` before adding any UI library +- Check that the library does not internally use `dangerouslySetInnerHTML` on its input (e.g., rich text editors) +- Pin versions, review changelogs before major upgrades +- Be wary of components that accept HTML strings as props + +## Source Map Exposure in Production + +Production builds should ship without source maps, or with sourcemaps uploaded to an error tracker (Sentry) and stripped from the public bundle. Public source maps leak internal logic and file structure. + +## Agent Support + +- Use `security-reviewer` agent for comprehensive security audits across the codebase +- Use `react-reviewer` agent for React-specific patterns and the above rules in active code review diff --git a/.claude/rules/ecc/react/testing.md b/.claude/rules/ecc/react/testing.md new file mode 100644 index 0000000..fa8da66 --- /dev/null +++ b/.claude/rules/ecc/react/testing.md @@ -0,0 +1,208 @@ +--- +paths: + - "**/*.test.tsx" + - "**/*.test.jsx" + - "**/*.spec.tsx" + - "**/*.spec.jsx" + - "**/__tests__/**/*.ts" + - "**/__tests__/**/*.tsx" +--- +# React Testing + +> This file extends [typescript/testing.md](../typescript/testing.md) and [common/testing.md](../common/testing.md) with React specific content. + +## Library Choice + +- **React Testing Library (RTL)** — the standard for component testing. Tests behavior through the rendered DOM. +- **Vitest** — preferred runner for new Vite-based projects. Faster than Jest, native ESM, same API. +- **Jest** — still the default for Next.js / CRA projects. RTL works identically. +- **Playwright Component Testing** — when component tests need a real browser engine (animation, layout, complex events) +- **Cypress Component Testing** — alternative real-browser component runner + +Pick one component test runner per project — do not mix RTL + Playwright CT in the same repo. + +## Core Principle + +Test what the user sees and does, not implementation details. + +- Query by accessible role first, then label, then text — fall back to `data-testid` only when nothing else fits +- Never assert on internal state, props passed to children, or which hooks were called +- Refactor without breaking tests = the test was testing behavior; that is the goal + +## Query Priority + +RTL exposes queries in three families. Use this priority order top-down: + +1. **Accessible to everyone** + - `getByRole(role, { name })` — primary choice + - `getByLabelText` — for form inputs + - `getByPlaceholderText` — when no label is available (and add a label) + - `getByText` — for non-interactive text + - `getByDisplayValue` — for form fields with a current value + +2. **Semantic queries** + - `getByAltText` — for images + - `getByTitle` — last resort, low accessibility value + +3. **Test IDs** + - `getByTestId("some-id")` — escape hatch only, when none of the above work + +`getBy*` throws when no match. `queryBy*` returns null (use for asserting absence). `findBy*` returns a promise (use for async). + +## User Interaction + +Prefer `userEvent` over `fireEvent`. `userEvent` simulates real browser sequences (focus, keydown, beforeinput, input, keyup) — `fireEvent` dispatches a single synthetic event. + +```tsx +import userEvent from "@testing-library/user-event"; + +test("submits the form", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Email"), "user@example.com"); + await user.click(screen.getByRole("button", { name: /save/i })); + + expect(handleSubmit).toHaveBeenCalledWith({ email: "user@example.com" }); +}); +``` + +- Always `await` `userEvent` calls — they are async +- Call `userEvent.setup()` once at the top of each test, then reuse the returned `user` + +## Async Assertions + +```tsx +// WRONG: synchronous query for async-rendered content +expect(screen.getByText("Loaded")).toBeInTheDocument(); // throws — not in DOM yet + +// CORRECT: findBy* (returns a promise, retries) +expect(await screen.findByText("Loaded")).toBeInTheDocument(); + +// CORRECT: waitFor for non-element assertions +await waitFor(() => expect(saveSpy).toHaveBeenCalled()); +``` + +- `findBy*` for async element appearance +- `waitFor` for async expectations on side effects or other matchers +- Never `setTimeout` + assertion — flaky + +## Network Mocking with MSW + +Use Mock Service Worker for any test that hits a network boundary. MSW runs at the network layer, so the component, hooks, and fetch library all behave as in production. + +```tsx +// test setup +import { setupServer } from "msw/node"; +import { http, HttpResponse } from "msw"; + +const server = setupServer( + http.get("/api/users/:id", ({ params }) => + HttpResponse.json({ id: params.id, name: "Alice" }), + ), +); + +beforeAll(() => server.listen()); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); +``` + +Per-test override: + +```tsx +test("renders error on 500", async () => { + server.use(http.get("/api/users/:id", () => new HttpResponse(null, { status: 500 }))); + render(); + expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument(); +}); +``` + +## Avoid Snapshot Tests for Components + +Snapshots of rendered output are brittle, hard to review, and rubber-stamped by reviewers. Use them only for: + +- Pure data serialization (e.g., a transformer that produces a stable string) +- Catching unintended regressions in non-visual output + +For component visual regression, use Playwright / Cypress / Percy screenshots — actual visual diffs, not DOM diffs. + +## Test Setup Helpers + +Wrap providers once: + +```tsx +function renderWithProviders(ui: React.ReactElement) { + return render( + + + {ui} + + , + ); +} +``` + +Export from `test-utils.tsx` and use everywhere. + +## Custom Hook Testing + +Use `renderHook` from RTL: + +```tsx +import { renderHook, act } from "@testing-library/react"; + +test("useCounter increments", () => { + const { result } = renderHook(() => useCounter()); + act(() => result.current.increment()); + expect(result.current.count).toBe(1); +}); +``` + +- Always wrap state-changing calls in `act` +- Always test through the public hook API, not internal implementation + +## Accessibility Assertions + +```tsx +import { axe } from "vitest-axe"; // or jest-axe + +test("UserCard has no a11y violations", async () => { + const { container } = render(); + expect(await axe(container)).toHaveNoViolations(); +}); +``` + +Run axe assertions in component tests — catches missing labels, ARIA misuse, color contrast (limited). + +## When to Reach for Playwright / Cypress + +Component test with RTL + JSDOM cannot: + +- Test real layout (flexbox, grid, viewport-dependent rendering) +- Test scrolling, drag-and-drop, paste from clipboard +- Test browser-native animation, CSS transitions +- Test cross-frame interactions (iframes, popups) + +For those, use Playwright Component Testing or end-to-end Playwright/Cypress runs. See [e2e-testing skill](../../skills/e2e-testing/SKILL.md). + +## Coverage Targets + +| Layer | Target | +|---|---| +| Pure utility functions | ≥90% | +| Custom hooks | ≥85% | +| Components (presentational) | ≥80% — behavior, not lines | +| Container components | ≥70% — golden paths + error states | +| Pages (E2E covered separately) | Smoke test per route minimum | + +## Anti-Patterns + +- Asserting on `container.querySelector` — bypasses accessibility queries +- Asserting on number of renders — implementation detail +- Mocking React hooks (`jest.mock("react", ...)`) — refactor the component instead +- Mocking child components by default — tests the integration, not the parent in isolation +- Manual `act()` warnings ignored — they indicate real bugs + +## Skill Reference + +See `skills/react-testing/SKILL.md` for end-to-end test examples, MSW patterns, and accessibility test scaffolding. diff --git a/.claude/rules/ecc/ruby/coding-style.md b/.claude/rules/ecc/ruby/coding-style.md new file mode 100644 index 0000000..39506bb --- /dev/null +++ b/.claude/rules/ecc/ruby/coding-style.md @@ -0,0 +1,46 @@ +--- +paths: + - "**/*.rb" + - "**/*.rake" + - "**/Gemfile" + - "**/*.gemspec" + - "**/config.ru" +--- +# Ruby Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Ruby and Rails specific content. + +## Standards + +- Target **Ruby 3.3+** for new Rails work unless the project already pins an older supported runtime. +- Enable **YJIT** in production only after measuring boot time, memory, and request/job throughput. +- Add `# frozen_string_literal: true` to new Ruby files when the project uses that convention. +- Prefer clear Ruby over clever metaprogramming; isolate DSL-heavy code behind narrow, tested boundaries. + +## Formatting And Linting + +- Use the project's checked-in RuboCop config. For Rails 8+ apps, start from `rubocop-rails-omakase` and customize only where the codebase has a real convention. +- Keep formatter/linter commands behind binstubs or scripts so CI and local runs match: + +```bash +bundle exec rubocop +bundle exec rubocop -A +``` + +- Do not silence cops inline unless the exception is narrow, documented, and harder to express cleanly in code. + +## Rails Style + +- Follow Rails naming and directory conventions before adding custom structure. +- Keep controllers transport-focused: authentication, authorization, parameter handling, response shape. +- Put reusable domain behavior in models, concerns, service objects, query objects, or form objects based on actual complexity, not as default ceremony. +- Prefer `bin/rails`, `bin/rake`, and checked-in binstubs over globally installed commands. + +## Error Handling + +- Rescue specific exceptions. Avoid broad `rescue StandardError` blocks unless they re-raise or preserve enough context for operators. +- Use `ActiveSupport::Notifications` or the app's logger for operational events; do not leave `puts`, `pp`, or `debugger` in committed application code. + +## Reference + +See skill: `backend-patterns` for broader service/repository layering guidance. diff --git a/.claude/rules/ecc/ruby/hooks.md b/.claude/rules/ecc/ruby/hooks.md new file mode 100644 index 0000000..1ec61d8 --- /dev/null +++ b/.claude/rules/ecc/ruby/hooks.md @@ -0,0 +1,37 @@ +--- +paths: + - "**/*.rb" + - "**/*.rake" + - "**/Gemfile" + - "**/Gemfile.lock" + - "**/config/routes.rb" +--- +# Ruby Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Ruby and Rails specific content. + +## PostToolUse Hooks + +Configure project-local hooks to prefer binstubs and checked-in tooling: + +- **RuboCop**: run `bundle exec rubocop -A ` or the project's safer formatter command after Ruby edits. +- **Brakeman**: run `bundle exec brakeman --no-progress` after security-sensitive Rails changes. +- **Tests**: run the narrowest matching `bin/rails test ...` or `bundle exec rspec ...` command for touched files. +- **Bundler audit**: run `bundle exec bundle-audit check --update` when `Gemfile` or `Gemfile.lock` changes and the project has bundler-audit installed. + +## Warnings + +- Warn on committed `debugger`, `binding.irb`, `binding.pry`, `puts`, `pp`, or `p` calls in application code. +- Warn when an edit disables CSRF protection, expands mass-assignment, or adds raw SQL without parameterization. +- Warn when a migration changes data destructively without a reversible path or documented rollout plan. + +## CI Gate Suggestions + +```bash +bundle exec rubocop +bundle exec brakeman --no-progress +bin/rails test +bundle exec rspec +``` + +Use only the commands that are present in the project; do not install new hook dependencies without maintainer approval. diff --git a/.claude/rules/ecc/ruby/patterns.md b/.claude/rules/ecc/ruby/patterns.md new file mode 100644 index 0000000..e405384 --- /dev/null +++ b/.claude/rules/ecc/ruby/patterns.md @@ -0,0 +1,44 @@ +--- +paths: + - "**/*.rb" + - "**/*.rake" + - "**/Gemfile" + - "**/app/**/*.erb" + - "**/config/routes.rb" +--- +# Ruby Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Ruby and Rails specific content. + +## Rails Way First + +- Start with plain Rails MVC and Active Record conventions for small and medium features. +- Introduce service objects, query objects, form objects, decorators, or presenters when the model/controller boundary is carrying multiple responsibilities. +- Name extracted objects after the business operation they perform, not after generic layers like `Manager` or `Processor`. + +## Persistence + +- Prefer PostgreSQL for multi-host production Rails apps unless the existing platform has a clear reason for MySQL or SQLite. +- Treat Rails 8 SQLite-backed defaults as viable for single-host or modest deployments, not as an automatic fit for shared multi-service systems. +- Keep raw SQL behind query objects or model scopes and parameterize every dynamic value. + +## Background Jobs And Runtime Services + +- Use **Solid Queue** for greenfield Rails 8 apps with modest throughput and simple deployment needs. +- Use **Sidekiq** when the app needs mature observability, high throughput, existing Redis infrastructure, or Pro/Enterprise features. +- Use **Solid Cache** and **Solid Cable** when their deployment model matches the app; use Redis when shared cross-service behavior, high fanout, or advanced data structures matter. + +## Frontend + +- Prefer **Hotwire** with Turbo, Stimulus, Importmap, and Propshaft for server-rendered Rails apps. +- Use React, Vue, Inertia.js, or a separate SPA when interaction complexity, existing product architecture, or team ownership justifies the extra client surface. +- Keep view components, partials, and presenters focused on rendering decisions; keep persistence and authorization out of templates. + +## Authentication + +- Use the Rails 8 authentication generator for straightforward session auth and password reset needs. +- Use Devise or another established auth system when requirements include OAuth, MFA, confirmable/lockable flows, multi-model auth, or a large existing Devise footprint. + +## Reference + +See skill: `backend-patterns` for service boundaries and adapter patterns. diff --git a/.claude/rules/ecc/ruby/security.md b/.claude/rules/ecc/ruby/security.md new file mode 100644 index 0000000..4821c2c --- /dev/null +++ b/.claude/rules/ecc/ruby/security.md @@ -0,0 +1,51 @@ +--- +paths: + - "**/*.rb" + - "**/*.rake" + - "**/Gemfile" + - "**/Gemfile.lock" + - "**/config/routes.rb" + - "**/config/credentials*.yml.enc" +--- +# Ruby Security + +> This file extends [common/security.md](../common/security.md) with Ruby and Rails specific content. + +## Rails Defaults + +- Keep CSRF protection enabled for state-changing browser requests. +- Use strong parameters or typed boundary objects before mass assignment. +- Store secrets in Rails credentials, environment variables, or a secret manager. Never commit plaintext keys, tokens, private credentials, or copied `.env` values. + +## SQL And Active Record + +- Prefer Active Record query APIs and parameterized SQL. +- Never interpolate request, cookie, header, job, or webhook values into SQL strings. +- Scope model callbacks carefully; security-sensitive side effects should be explicit and covered by tests. + +## Authentication And Sessions + +- Use the Rails 8 authentication generator for simple session auth, or Devise when OAuth, MFA, confirmable, lockable, multi-model auth, or existing Devise conventions are required. +- Rotate sessions after sign-in and privilege changes. +- Protect account recovery flows with expiry, single-use tokens, rate limiting, and audit logging. + +## Dependencies + +- Run dependency checks when the lockfile changes: + +```bash +bundle exec bundle-audit check --update +bundle exec brakeman --no-progress +``` + +- Review new gems for maintainer activity, native extension risk, transitive dependencies, and whether the same behavior can be implemented with Rails core. + +## Web Safety + +- Escape template output by default. Treat `html_safe`, `raw`, and custom sanitizers as security-sensitive code. +- Validate file uploads by content type, extension, size, and storage destination. +- Treat background jobs, webhooks, Action Cable messages, and Turbo Stream inputs as untrusted boundaries. + +## Reference + +See skill: `security-review` for secure-by-default review patterns. diff --git a/.claude/rules/ecc/ruby/testing.md b/.claude/rules/ecc/ruby/testing.md new file mode 100644 index 0000000..e96a1c9 --- /dev/null +++ b/.claude/rules/ecc/ruby/testing.md @@ -0,0 +1,51 @@ +--- +paths: + - "**/*.rb" + - "**/*.rake" + - "**/Gemfile" + - "**/test/**/*.rb" + - "**/spec/**/*.rb" + - "**/config/routes.rb" +--- +# Ruby Testing + +> This file extends [common/testing.md](../common/testing.md) with Ruby and Rails specific content. + +## Framework + +- Use **Minitest** when the Rails app follows the default Rails test stack. +- Use **RSpec** when it is already established in the project or the team has explicit production conventions around it. +- Do not mix Minitest and RSpec inside the same feature area without a migration reason. + +## Test Pyramid + +- Put fast domain behavior in model, service, query, policy, and job tests. +- Use request/controller tests for HTTP contracts, auth behavior, redirects, status codes, and response shapes. +- Use system tests with Capybara for browser-critical flows only; keep them focused and stable. +- Cover background jobs with unit tests for behavior and integration tests for queue/enqueue contracts. + +## Fixtures And Factories + +- Use Rails fixtures when they are the project default and the data graph is small. +- Use `factory_bot` when scenarios need explicit object construction or complex traits. +- Keep test data close to the behavior being asserted; avoid global fixtures that hide setup cost. + +## Commands + +Prefer project-local commands: + +```bash +bin/rails test +bin/rails test test/models/user_test.rb +bundle exec rspec +bundle exec rspec spec/models/user_spec.rb +``` + +## Coverage + +- Use SimpleCov when coverage is enforced; keep thresholds in CI and avoid gaming branch coverage with low-value tests. +- Add regression tests for bug fixes before changing production code. + +## Reference + +See skill: `tdd-workflow` for the repo-wide RED -> GREEN -> REFACTOR loop. diff --git a/.claude/rules/ecc/rust/coding-style.md b/.claude/rules/ecc/rust/coding-style.md new file mode 100644 index 0000000..cda67b5 --- /dev/null +++ b/.claude/rules/ecc/rust/coding-style.md @@ -0,0 +1,151 @@ +--- +paths: + - "**/*.rs" +--- +# Rust Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Rust-specific content. + +## Formatting + +- **rustfmt** for enforcement — always run `cargo fmt` before committing +- **clippy** for lints — `cargo clippy -- -D warnings` (treat warnings as errors) +- 4-space indent (rustfmt default) +- Max line width: 100 characters (rustfmt default) + +## Immutability + +Rust variables are immutable by default — embrace this: + +- Use `let` by default; only use `let mut` when mutation is required +- Prefer returning new values over mutating in place +- Use `Cow<'_, T>` when a function may or may not need to allocate + +```rust +use std::borrow::Cow; + +// GOOD — immutable by default, new value returned +fn normalize(input: &str) -> Cow<'_, str> { + if input.contains(' ') { + Cow::Owned(input.replace(' ', "_")) + } else { + Cow::Borrowed(input) + } +} + +// BAD — unnecessary mutation +fn normalize_bad(input: &mut String) { + *input = input.replace(' ', "_"); +} +``` + +## Naming + +Follow standard Rust conventions: +- `snake_case` for functions, methods, variables, modules, crates +- `PascalCase` (UpperCamelCase) for types, traits, enums, type parameters +- `SCREAMING_SNAKE_CASE` for constants and statics +- Lifetimes: short lowercase (`'a`, `'de`) — descriptive names for complex cases (`'input`) + +## Ownership and Borrowing + +- Borrow (`&T`) by default; take ownership only when you need to store or consume +- Never clone to satisfy the borrow checker without understanding the root cause +- Accept `&str` over `String`, `&[T]` over `Vec` in function parameters +- Use `impl Into` for constructors that need to own a `String` + +```rust +// GOOD — borrows when ownership isn't needed +fn word_count(text: &str) -> usize { + text.split_whitespace().count() +} + +// GOOD — takes ownership in constructor via Into +fn new(name: impl Into) -> Self { + Self { name: name.into() } +} + +// BAD — takes String when &str suffices +fn word_count_bad(text: String) -> usize { + text.split_whitespace().count() +} +``` + +## Error Handling + +- Use `Result` and `?` for propagation — never `unwrap()` in production code +- **Libraries**: define typed errors with `thiserror` +- **Applications**: use `anyhow` for flexible error context +- Add context with `.with_context(|| format!("failed to ..."))?` +- Reserve `unwrap()` / `expect()` for tests and truly unreachable states + +```rust +// GOOD — library error with thiserror +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("failed to read config: {0}")] + Io(#[from] std::io::Error), + #[error("invalid config format: {0}")] + Parse(String), +} + +// GOOD — application error with anyhow +use anyhow::Context; + +fn load_config(path: &str) -> anyhow::Result { + let content = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {path}"))?; + toml::from_str(&content) + .with_context(|| format!("failed to parse {path}")) +} +``` + +## Iterators Over Loops + +Prefer iterator chains for transformations; use loops for complex control flow: + +```rust +// GOOD — declarative and composable +let active_emails: Vec<&str> = users.iter() + .filter(|u| u.is_active) + .map(|u| u.email.as_str()) + .collect(); + +// GOOD — loop for complex logic with early returns +for user in &users { + if let Some(verified) = verify_email(&user.email)? { + send_welcome(&verified)?; + } +} +``` + +## Module Organization + +Organize by domain, not by type: + +```text +src/ +├── main.rs +├── lib.rs +├── auth/ # Domain module +│ ├── mod.rs +│ ├── token.rs +│ └── middleware.rs +├── orders/ # Domain module +│ ├── mod.rs +│ ├── model.rs +│ └── service.rs +└── db/ # Infrastructure + ├── mod.rs + └── pool.rs +``` + +## Visibility + +- Default to private; use `pub(crate)` for internal sharing +- Only mark `pub` what is part of the crate's public API +- Re-export public API from `lib.rs` + +## References + +See skill: `rust-patterns` for comprehensive Rust idioms and patterns. diff --git a/.claude/rules/ecc/rust/hooks.md b/.claude/rules/ecc/rust/hooks.md new file mode 100644 index 0000000..4511f1c --- /dev/null +++ b/.claude/rules/ecc/rust/hooks.md @@ -0,0 +1,16 @@ +--- +paths: + - "**/*.rs" + - "**/Cargo.toml" +--- +# Rust Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Rust-specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **cargo fmt**: Auto-format `.rs` files after edit +- **cargo clippy**: Run lint checks after editing Rust files +- **cargo check**: Verify compilation after changes (faster than `cargo build`) diff --git a/.claude/rules/ecc/rust/patterns.md b/.claude/rules/ecc/rust/patterns.md new file mode 100644 index 0000000..3d807e7 --- /dev/null +++ b/.claude/rules/ecc/rust/patterns.md @@ -0,0 +1,168 @@ +--- +paths: + - "**/*.rs" +--- +# Rust Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Rust-specific content. + +## Repository Pattern with Traits + +Encapsulate data access behind a trait: + +```rust +pub trait OrderRepository: Send + Sync { + fn find_by_id(&self, id: u64) -> Result, StorageError>; + fn find_all(&self) -> Result, StorageError>; + fn save(&self, order: &Order) -> Result; + fn delete(&self, id: u64) -> Result<(), StorageError>; +} +``` + +Concrete implementations handle storage details (Postgres, SQLite, in-memory for tests). + +## Service Layer + +Business logic in service structs; inject dependencies via constructor: + +```rust +pub struct OrderService { + repo: Box, + payment: Box, +} + +impl OrderService { + pub fn new(repo: Box, payment: Box) -> Self { + Self { repo, payment } + } + + pub fn place_order(&self, request: CreateOrderRequest) -> anyhow::Result { + let order = Order::from(request); + self.payment.charge(order.total())?; + let saved = self.repo.save(&order)?; + Ok(OrderSummary::from(saved)) + } +} +``` + +## Newtype Pattern for Type Safety + +Prevent argument mix-ups with distinct wrapper types: + +```rust +struct UserId(u64); +struct OrderId(u64); + +fn get_order(user: UserId, order: OrderId) -> anyhow::Result { + // Can't accidentally swap user and order IDs at call sites + todo!() +} +``` + +## Enum State Machines + +Model states as enums — make illegal states unrepresentable: + +```rust +enum ConnectionState { + Disconnected, + Connecting { attempt: u32 }, + Connected { session_id: String }, + Failed { reason: String, retries: u32 }, +} + +fn handle(state: &ConnectionState) { + match state { + ConnectionState::Disconnected => connect(), + ConnectionState::Connecting { attempt } if *attempt > 3 => abort(), + ConnectionState::Connecting { .. } => wait(), + ConnectionState::Connected { session_id } => use_session(session_id), + ConnectionState::Failed { retries, .. } if *retries < 5 => retry(), + ConnectionState::Failed { reason, .. } => log_failure(reason), + } +} +``` + +Always match exhaustively — no wildcard `_` for business-critical enums. + +## Builder Pattern + +Use for structs with many optional parameters: + +```rust +pub struct ServerConfig { + host: String, + port: u16, + max_connections: usize, +} + +impl ServerConfig { + pub fn builder(host: impl Into, port: u16) -> ServerConfigBuilder { + ServerConfigBuilder { + host: host.into(), + port, + max_connections: 100, + } + } +} + +pub struct ServerConfigBuilder { + host: String, + port: u16, + max_connections: usize, +} + +impl ServerConfigBuilder { + pub fn max_connections(mut self, n: usize) -> Self { + self.max_connections = n; + self + } + + pub fn build(self) -> ServerConfig { + ServerConfig { + host: self.host, + port: self.port, + max_connections: self.max_connections, + } + } +} +``` + +## Sealed Traits for Extensibility Control + +Use a private module to seal a trait, preventing external implementations: + +```rust +mod private { + pub trait Sealed {} +} + +pub trait Format: private::Sealed { + fn encode(&self, data: &[u8]) -> Vec; +} + +pub struct Json; +impl private::Sealed for Json {} +impl Format for Json { + fn encode(&self, data: &[u8]) -> Vec { todo!() } +} +``` + +## API Response Envelope + +Consistent API responses using a generic enum: + +```rust +#[derive(Debug, serde::Serialize)] +#[serde(tag = "status")] +pub enum ApiResponse { + #[serde(rename = "ok")] + Ok { data: T }, + #[serde(rename = "error")] + Error { message: String }, +} +``` + +## References + +See skill: `rust-patterns` for comprehensive patterns including ownership, traits, generics, concurrency, and async. diff --git a/.claude/rules/ecc/rust/security.md b/.claude/rules/ecc/rust/security.md new file mode 100644 index 0000000..83c0e07 --- /dev/null +++ b/.claude/rules/ecc/rust/security.md @@ -0,0 +1,141 @@ +--- +paths: + - "**/*.rs" +--- +# Rust Security + +> This file extends [common/security.md](../common/security.md) with Rust-specific content. + +## Secrets Management + +- Never hardcode API keys, tokens, or credentials in source code +- Use environment variables: `std::env::var("API_KEY")` +- Fail fast if required secrets are missing at startup +- Keep `.env` files in `.gitignore` + +```rust +// BAD +const API_KEY: &str = "sk-abc123..."; + +// GOOD — environment variable with early validation +fn load_api_key() -> anyhow::Result { + std::env::var("PAYMENT_API_KEY") + .context("PAYMENT_API_KEY must be set") +} +``` + +## SQL Injection Prevention + +- Always use parameterized queries — never format user input into SQL strings +- Use query builder or ORM (sqlx, diesel, sea-orm) with bind parameters + +```rust +// BAD — SQL injection via format string +let query = format!("SELECT * FROM users WHERE name = '{name}'"); +sqlx::query(&query).fetch_one(&pool).await?; + +// GOOD — parameterized query with sqlx +// Placeholder syntax varies by backend: Postgres: $1 | MySQL: ? | SQLite: $1 +sqlx::query("SELECT * FROM users WHERE name = $1") + .bind(&name) + .fetch_one(&pool) + .await?; +``` + +## Input Validation + +- Validate all user input at system boundaries before processing +- Use the type system to enforce invariants (newtype pattern) +- Parse, don't validate — convert unstructured data to typed structs at the boundary +- Reject invalid input with clear error messages + +```rust +// Parse, don't validate — invalid states are unrepresentable +pub struct Email(String); + +impl Email { + pub fn parse(input: &str) -> Result { + let trimmed = input.trim(); + let at_pos = trimmed.find('@') + .filter(|&p| p > 0 && p < trimmed.len() - 1) + .ok_or_else(|| ValidationError::InvalidEmail(input.to_string()))?; + let domain = &trimmed[at_pos + 1..]; + if trimmed.len() > 254 || !domain.contains('.') { + return Err(ValidationError::InvalidEmail(input.to_string())); + } + // For production use, prefer a validated email crate (e.g., `email_address`) + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} +``` + +## Unsafe Code + +- Minimize `unsafe` blocks — prefer safe abstractions +- Every `unsafe` block must have a `// SAFETY:` comment explaining the invariant +- Never use `unsafe` to bypass the borrow checker for convenience +- Audit all `unsafe` code during review — it is a red flag without justification +- Prefer `safe` FFI wrappers around C libraries + +```rust +// GOOD — safety comment documents ALL required invariants +let widget: &Widget = { + // SAFETY: `ptr` is non-null, aligned, points to an initialized Widget, + // and no mutable references or mutations exist for its lifetime. + unsafe { &*ptr } +}; + +// BAD — no safety justification +unsafe { &*ptr } +``` + +## Dependency Security + +- Run `cargo audit` to scan for known CVEs in dependencies +- Run `cargo deny check` for license and advisory compliance +- Use `cargo tree` to audit transitive dependencies +- Keep dependencies updated — set up Dependabot or Renovate +- Minimize dependency count — evaluate before adding new crates + +```bash +# Security audit +cargo audit + +# Deny advisories, duplicate versions, and restricted licenses +cargo deny check + +# Inspect dependency tree +cargo tree +cargo tree -d # Show duplicates only +``` + +## Error Messages + +- Never expose internal paths, stack traces, or database errors in API responses +- Log detailed errors server-side; return generic messages to clients +- Use `tracing` or `log` for structured server-side logging + +```rust +// Map errors to appropriate status codes and generic messages +// (Example uses axum; adapt the response type to your framework) +match order_service.find_by_id(id) { + Ok(order) => Ok((StatusCode::OK, Json(order))), + Err(ServiceError::NotFound(_)) => { + tracing::info!(order_id = id, "order not found"); + Err((StatusCode::NOT_FOUND, "Resource not found")) + } + Err(e) => { + tracing::error!(order_id = id, error = %e, "unexpected error"); + Err((StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")) + } +} +``` + +## References + +See skill: `rust-patterns` for unsafe code guidelines and ownership patterns. +See skill: `security-review` for general security checklists. diff --git a/.claude/rules/ecc/rust/testing.md b/.claude/rules/ecc/rust/testing.md new file mode 100644 index 0000000..dae4b67 --- /dev/null +++ b/.claude/rules/ecc/rust/testing.md @@ -0,0 +1,154 @@ +--- +paths: + - "**/*.rs" +--- +# Rust Testing + +> This file extends [common/testing.md](../common/testing.md) with Rust-specific content. + +## Test Framework + +- **`#[test]`** with `#[cfg(test)]` modules for unit tests +- **rstest** for parameterized tests and fixtures +- **proptest** for property-based testing +- **mockall** for trait-based mocking +- **`#[tokio::test]`** for async tests + +## Test Organization + +```text +my_crate/ +├── src/ +│ ├── lib.rs # Unit tests in #[cfg(test)] modules +│ ├── auth/ +│ │ └── mod.rs # #[cfg(test)] mod tests { ... } +│ └── orders/ +│ └── service.rs # #[cfg(test)] mod tests { ... } +├── tests/ # Integration tests (each file = separate binary) +│ ├── api_test.rs +│ ├── db_test.rs +│ └── common/ # Shared test utilities +│ └── mod.rs +└── benches/ # Criterion benchmarks + └── benchmark.rs +``` + +Unit tests go inside `#[cfg(test)]` modules in the same file. Integration tests go in `tests/`. + +## Unit Test Pattern + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_user_with_valid_email() { + let user = User::new("Alice", "alice@example.com").unwrap(); + assert_eq!(user.name, "Alice"); + } + + #[test] + fn rejects_invalid_email() { + let result = User::new("Bob", "not-an-email"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid email")); + } +} +``` + +## Parameterized Tests + +```rust +use rstest::rstest; + +#[rstest] +#[case("hello", 5)] +#[case("", 0)] +#[case("rust", 4)] +fn test_string_length(#[case] input: &str, #[case] expected: usize) { + assert_eq!(input.len(), expected); +} +``` + +## Async Tests + +```rust +#[tokio::test] +async fn fetches_data_successfully() { + let client = TestClient::new().await; + let result = client.get("/data").await; + assert!(result.is_ok()); +} +``` + +## Mocking with mockall + +Define traits in production code; generate mocks in test modules: + +```rust +// Production trait — pub so integration tests can import it +pub trait UserRepository { + fn find_by_id(&self, id: u64) -> Option; +} + +#[cfg(test)] +mod tests { + use super::*; + use mockall::predicate::eq; + + mockall::mock! { + pub Repo {} + impl UserRepository for Repo { + fn find_by_id(&self, id: u64) -> Option; + } + } + + #[test] + fn service_returns_user_when_found() { + let mut mock = MockRepo::new(); + mock.expect_find_by_id() + .with(eq(42)) + .times(1) + .returning(|_| Some(User { id: 42, name: "Alice".into() })); + + let service = UserService::new(Box::new(mock)); + let user = service.get_user(42).unwrap(); + assert_eq!(user.name, "Alice"); + } +} +``` + +## Test Naming + +Use descriptive names that explain the scenario: +- `creates_user_with_valid_email()` +- `rejects_order_when_insufficient_stock()` +- `returns_none_when_not_found()` + +## Coverage + +- Target 80%+ line coverage +- Use **cargo-llvm-cov** for coverage reporting +- Focus on business logic — exclude generated code and FFI bindings + +```bash +cargo llvm-cov # Summary +cargo llvm-cov --html # HTML report +cargo llvm-cov --fail-under-lines 80 # Fail if below threshold +``` + +## Testing Commands + +```bash +cargo test # Run all tests +cargo test -- --nocapture # Show println output +cargo test test_name # Run tests matching pattern +cargo test --lib # Unit tests only +cargo test --test api_test # Specific integration test (tests/api_test.rs) +cargo test --doc # Doc tests only +``` + +## References + +See skill: `rust-testing` for comprehensive testing patterns including property-based testing, fixtures, and benchmarking with Criterion. diff --git a/.claude/rules/ecc/swift/coding-style.md b/.claude/rules/ecc/swift/coding-style.md new file mode 100644 index 0000000..d9fc38d --- /dev/null +++ b/.claude/rules/ecc/swift/coding-style.md @@ -0,0 +1,47 @@ +--- +paths: + - "**/*.swift" + - "**/Package.swift" +--- +# Swift Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Swift specific content. + +## Formatting + +- **SwiftFormat** for auto-formatting, **SwiftLint** for style enforcement +- `swift-format` is bundled with Xcode 16+ as an alternative + +## Immutability + +- Prefer `let` over `var` — define everything as `let` and only change to `var` if the compiler requires it +- Use `struct` with value semantics by default; use `class` only when identity or reference semantics are needed + +## Naming + +Follow [Apple API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/): + +- Clarity at the point of use — omit needless words +- Name methods and properties for their roles, not their types +- Use `static let` for constants over global constants + +## Error Handling + +Use typed throws (Swift 6+) and pattern matching: + +```swift +func load(id: String) throws(LoadError) -> Item { + guard let data = try? read(from: path) else { + throw .fileNotFound(id) + } + return try decode(data) +} +``` + +## Concurrency + +Enable Swift 6 strict concurrency checking. Prefer: + +- `Sendable` value types for data crossing isolation boundaries +- Actors for shared mutable state +- Structured concurrency (`async let`, `TaskGroup`) over unstructured `Task {}` diff --git a/.claude/rules/ecc/swift/hooks.md b/.claude/rules/ecc/swift/hooks.md new file mode 100644 index 0000000..0fbde36 --- /dev/null +++ b/.claude/rules/ecc/swift/hooks.md @@ -0,0 +1,20 @@ +--- +paths: + - "**/*.swift" + - "**/Package.swift" +--- +# Swift Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Swift specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **SwiftFormat**: Auto-format `.swift` files after edit +- **SwiftLint**: Run lint checks after editing `.swift` files +- **swift build**: Type-check modified packages after edit + +## Warning + +Flag `print()` statements — use `os.Logger` or structured logging instead for production code. diff --git a/.claude/rules/ecc/swift/patterns.md b/.claude/rules/ecc/swift/patterns.md new file mode 100644 index 0000000..b03b0ba --- /dev/null +++ b/.claude/rules/ecc/swift/patterns.md @@ -0,0 +1,66 @@ +--- +paths: + - "**/*.swift" + - "**/Package.swift" +--- +# Swift Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Swift specific content. + +## Protocol-Oriented Design + +Define small, focused protocols. Use protocol extensions for shared defaults: + +```swift +protocol Repository: Sendable { + associatedtype Item: Identifiable & Sendable + func find(by id: Item.ID) async throws -> Item? + func save(_ item: Item) async throws +} +``` + +## Value Types + +- Use structs for data transfer objects and models +- Use enums with associated values to model distinct states: + +```swift +enum LoadState: Sendable { + case idle + case loading + case loaded(T) + case failed(Error) +} +``` + +## Actor Pattern + +Use actors for shared mutable state instead of locks or dispatch queues: + +```swift +actor Cache { + private var storage: [Key: Value] = [:] + + func get(_ key: Key) -> Value? { storage[key] } + func set(_ key: Key, value: Value) { storage[key] = value } +} +``` + +## Dependency Injection + +Inject protocols with default parameters — production uses defaults, tests inject mocks: + +```swift +struct UserService { + private let repository: any UserRepository + + init(repository: any UserRepository = DefaultUserRepository()) { + self.repository = repository + } +} +``` + +## References + +See skill: `swift-actor-persistence` for actor-based persistence patterns. +See skill: `swift-protocol-di-testing` for protocol-based DI and testing. diff --git a/.claude/rules/ecc/swift/security.md b/.claude/rules/ecc/swift/security.md new file mode 100644 index 0000000..878503a --- /dev/null +++ b/.claude/rules/ecc/swift/security.md @@ -0,0 +1,33 @@ +--- +paths: + - "**/*.swift" + - "**/Package.swift" +--- +# Swift Security + +> This file extends [common/security.md](../common/security.md) with Swift specific content. + +## Secret Management + +- Use **Keychain Services** for sensitive data (tokens, passwords, keys) — never `UserDefaults` +- Use environment variables or `.xcconfig` files for build-time secrets +- Never hardcode secrets in source — decompilation tools extract them trivially + +```swift +let apiKey = ProcessInfo.processInfo.environment["API_KEY"] +guard let apiKey, !apiKey.isEmpty else { + fatalError("API_KEY not configured") +} +``` + +## Transport Security + +- App Transport Security (ATS) is enforced by default — do not disable it +- Use certificate pinning for critical endpoints +- Validate all server certificates + +## Input Validation + +- Sanitize all user input before display to prevent injection +- Use `URL(string:)` with validation rather than force-unwrapping +- Validate data from external sources (APIs, deep links, pasteboard) before processing diff --git a/.claude/rules/ecc/swift/testing.md b/.claude/rules/ecc/swift/testing.md new file mode 100644 index 0000000..9a1b012 --- /dev/null +++ b/.claude/rules/ecc/swift/testing.md @@ -0,0 +1,45 @@ +--- +paths: + - "**/*.swift" + - "**/Package.swift" +--- +# Swift Testing + +> This file extends [common/testing.md](../common/testing.md) with Swift specific content. + +## Framework + +Use **Swift Testing** (`import Testing`) for new tests. Use `@Test` and `#expect`: + +```swift +@Test("User creation validates email") +func userCreationValidatesEmail() throws { + #expect(throws: ValidationError.invalidEmail) { + try User(email: "not-an-email") + } +} +``` + +## Test Isolation + +Each test gets a fresh instance — set up in `init`, tear down in `deinit`. No shared mutable state between tests. + +## Parameterized Tests + +```swift +@Test("Validates formats", arguments: ["json", "xml", "csv"]) +func validatesFormat(format: String) throws { + let parser = try Parser(format: format) + #expect(parser.isValid) +} +``` + +## Coverage + +```bash +swift test --enable-code-coverage +``` + +## Reference + +See skill: `swift-protocol-di-testing` for protocol-based dependency injection and mock patterns with Swift Testing. diff --git a/.claude/rules/ecc/typescript/coding-style.md b/.claude/rules/ecc/typescript/coding-style.md new file mode 100644 index 0000000..090c0a1 --- /dev/null +++ b/.claude/rules/ecc/typescript/coding-style.md @@ -0,0 +1,199 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" +--- +# TypeScript/JavaScript Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with TypeScript/JavaScript specific content. + +## Types and Interfaces + +Use types to make public APIs, shared models, and component props explicit, readable, and reusable. + +### Public APIs + +- Add parameter and return types to exported functions, shared utilities, and public class methods +- Let TypeScript infer obvious local variable types +- Extract repeated inline object shapes into named types or interfaces + +```typescript +// WRONG: Exported function without explicit types +export function formatUser(user) { + return `${user.firstName} ${user.lastName}` +} + +// CORRECT: Explicit types on public APIs +interface User { + firstName: string + lastName: string +} + +export function formatUser(user: User): string { + return `${user.firstName} ${user.lastName}` +} +``` + +### Interfaces vs. Type Aliases + +- Use `interface` for object shapes that may be extended or implemented +- Use `type` for unions, intersections, tuples, mapped types, and utility types +- Prefer string literal unions over `enum` unless an `enum` is required for interoperability + +```typescript +interface User { + id: string + email: string +} + +type UserRole = 'admin' | 'member' +type UserWithRole = User & { + role: UserRole +} +``` + +### Avoid `any` + +- Avoid `any` in application code +- Use `unknown` for external or untrusted input, then narrow it safely +- Use generics when a value's type depends on the caller + +```typescript +// WRONG: any removes type safety +function getErrorMessage(error: any) { + return error.message +} + +// CORRECT: unknown forces safe narrowing +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return 'Unexpected error' +} +``` + +### React Props + +- Define component props with a named `interface` or `type` +- Type callback props explicitly +- Do not use `React.FC` unless there is a specific reason to do so + +```typescript +interface User { + id: string + email: string +} + +interface UserCardProps { + user: User + onSelect: (id: string) => void +} + +function UserCard({ user, onSelect }: UserCardProps) { + return +} +``` + +### JavaScript Files + +- In `.js` and `.jsx` files, use JSDoc when types improve clarity and a TypeScript migration is not practical +- Keep JSDoc aligned with runtime behavior + +```javascript +/** + * @param {{ firstName: string, lastName: string }} user + * @returns {string} + */ +export function formatUser(user) { + return `${user.firstName} ${user.lastName}` +} +``` + +## Immutability + +Use spread operator for immutable updates: + +```typescript +interface User { + id: string + name: string +} + +// WRONG: Mutation +function updateUser(user: User, name: string): User { + user.name = name // MUTATION! + return user +} + +// CORRECT: Immutability +function updateUser(user: Readonly, name: string): User { + return { + ...user, + name + } +} +``` + +## Error Handling + +Use async/await with try-catch and narrow unknown errors safely: + +```typescript +interface User { + id: string + email: string +} + +declare function riskyOperation(userId: string): Promise + +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + return 'Unexpected error' +} + +const logger = { + error: (message: string, error: unknown) => { + // Replace with your production logger (for example, pino or winston). + } +} + +async function loadUser(userId: string): Promise { + try { + const result = await riskyOperation(userId) + return result + } catch (error: unknown) { + logger.error('Operation failed', error) + throw new Error(getErrorMessage(error)) + } +} +``` + +## Input Validation + +Use Zod for schema-based validation and infer types from the schema: + +```typescript +import { z } from 'zod' + +const userSchema = z.object({ + email: z.string().email(), + age: z.number().int().min(0).max(150) +}) + +type UserInput = z.infer + +const validated: UserInput = userSchema.parse(input) +``` + +## Console.log + +- No `console.log` statements in production code +- Use proper logging libraries instead +- See hooks for automatic detection diff --git a/.claude/rules/ecc/typescript/hooks.md b/.claude/rules/ecc/typescript/hooks.md new file mode 100644 index 0000000..cd4754b --- /dev/null +++ b/.claude/rules/ecc/typescript/hooks.md @@ -0,0 +1,22 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" +--- +# TypeScript/JavaScript Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with TypeScript/JavaScript specific content. + +## PostToolUse Hooks + +Configure in `~/.claude/settings.json`: + +- **Prettier**: Auto-format JS/TS files after edit +- **TypeScript check**: Run `tsc` after editing `.ts`/`.tsx` files +- **console.log warning**: Warn about `console.log` in edited files + +## Stop Hooks + +- **console.log audit**: Check all modified files for `console.log` before session ends diff --git a/.claude/rules/ecc/typescript/patterns.md b/.claude/rules/ecc/typescript/patterns.md new file mode 100644 index 0000000..d50729d --- /dev/null +++ b/.claude/rules/ecc/typescript/patterns.md @@ -0,0 +1,52 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" +--- +# TypeScript/JavaScript Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with TypeScript/JavaScript specific content. + +## API Response Format + +```typescript +interface ApiResponse { + success: boolean + data?: T + error?: string + meta?: { + total: number + page: number + limit: number + } +} +``` + +## Custom Hooks Pattern + +```typescript +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(handler) + }, [value, delay]) + + return debouncedValue +} +``` + +## Repository Pattern + +```typescript +interface Repository { + findAll(filters?: Filters): Promise + findById(id: string): Promise + create(data: CreateDto): Promise + update(id: string, data: UpdateDto): Promise + delete(id: string): Promise +} +``` diff --git a/.claude/rules/ecc/typescript/security.md b/.claude/rules/ecc/typescript/security.md new file mode 100644 index 0000000..e2b01d8 --- /dev/null +++ b/.claude/rules/ecc/typescript/security.md @@ -0,0 +1,28 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" +--- +# TypeScript/JavaScript Security + +> This file extends [common/security.md](../common/security.md) with TypeScript/JavaScript specific content. + +## Secret Management + +```typescript +// NEVER: Hardcoded secrets +const apiKey = "sk-proj-xxxxx" + +// ALWAYS: Environment variables +const apiKey = process.env.API_KEY + +if (!apiKey) { + throw new Error('API_KEY not configured') +} +``` + +## Agent Support + +- Use **security-reviewer** skill for comprehensive security audits diff --git a/.claude/rules/ecc/typescript/testing.md b/.claude/rules/ecc/typescript/testing.md new file mode 100644 index 0000000..6f2f402 --- /dev/null +++ b/.claude/rules/ecc/typescript/testing.md @@ -0,0 +1,18 @@ +--- +paths: + - "**/*.ts" + - "**/*.tsx" + - "**/*.js" + - "**/*.jsx" +--- +# TypeScript/JavaScript Testing + +> This file extends [common/testing.md](../common/testing.md) with TypeScript/JavaScript specific content. + +## E2E Testing + +Use **Playwright** as the E2E testing framework for critical user flows. + +## Agent Support + +- **e2e-runner** - Playwright E2E testing specialist diff --git a/.claude/rules/ecc/vue/coding-style.md b/.claude/rules/ecc/vue/coding-style.md new file mode 100644 index 0000000..fbef678 --- /dev/null +++ b/.claude/rules/ecc/vue/coding-style.md @@ -0,0 +1,54 @@ +--- +paths: + - "**/*.vue" +--- + +# Vue Coding Style + +> This file extends [common/coding-style.md](../common/coding-style.md) with Vue specific content. + +## SFC Structure + +- Always ` +``` + +## Reference + +- ECC skills: `frontend-patterns`, `vite-patterns`. +- Docs: · · diff --git a/.claude/rules/ecc/vue/hooks.md b/.claude/rules/ecc/vue/hooks.md new file mode 100644 index 0000000..16c92ca --- /dev/null +++ b/.claude/rules/ecc/vue/hooks.md @@ -0,0 +1,45 @@ +--- +paths: + - "**/*.vue" + - "**/*.ts" + - "**/*.tsx" +--- + +# Vue Hooks + +> This file extends [common/hooks.md](../common/hooks.md) with Vue specific content. + +## PostToolUse Targets + +Run on `*.vue`, `*.ts`, and `*.tsx` after edits. Scope to changed files where possible. + +## Typecheck + +- Use `vue-tsc --noEmit` for SFC plus TypeScript checking. Plain `tsc` cannot read `.vue` single-file components, so it must not be the typecheck hook for this project. +- Typecheck is project-wide. Debounce or scope it so a save-on-every-keystroke loop does not stall the editor. + +## Lint and Format + +- `eslint --fix` with `eslint-plugin-vue` (flat-config `vue/vue3-recommended`) covers both template and script lint. +- `prettier --write` for formatting. Prefer Prettier-via-ESLint over a separate Prettier pass to avoid double formatting and fight loops. + +## Architecture Boundaries + +- Optional: enforce Feature-Sliced Design slice boundaries with `@feature-sliced/steiger` or `eslint-plugin-boundaries` to block deep cross-slice imports. + +## Sequencing + +```bash +# changed files only +eslint --fix "$FILE" +prettier --write "$FILE" +# project-wide, debounced +vue-tsc --noEmit +``` + +- Run lint and format per-file first, then the project-wide typecheck last so type errors reflect the formatted source. + +## Reference + +- ECC skills: `frontend-patterns`, `vite-patterns`. +- Docs: (vue-tsc) · · diff --git a/.claude/rules/ecc/vue/patterns.md b/.claude/rules/ecc/vue/patterns.md new file mode 100644 index 0000000..e5eb354 --- /dev/null +++ b/.claude/rules/ecc/vue/patterns.md @@ -0,0 +1,56 @@ +--- +paths: + - "**/*.vue" +--- + +# Vue Patterns + +> This file extends [common/patterns.md](../common/patterns.md) with Vue specific content. + +## Composables + +- The composable (`useXxx`) is the reusable-logic unit. In Feature-Sliced Design it lives in the slice `model` segment. +- Accept `MaybeRefOrGetter` inputs and normalize with `toValue`, so callers can pass a ref, a getter, or a raw value. +- Return `toRefs(reactive(...))` so consumers can destructure without losing reactivity. +- A composable that uses lifecycle hooks or `provide` / `inject` must be called inside a component `setup`, not lazily or conditionally. + +## Props, Emits, v-model + +- Type-based `defineProps()` and tuple-form `defineEmits<{ change: [id: number] }>()`. +- `defineModel('name', { default })` for two-way binding. It compiles to a prop plus an `update:*` emit. + +## Provide / Inject + +- Use `provide` / `inject` for tree-scoped data without prop drilling. +- Type-safe collision-free keys: `const key = Symbol() as InjectionKey`. +- The provider owns mutations. Expose a `readonly` ref plus an explicit updater function, never a raw mutable ref. + +## Pinia (FSD model segment) + +- Prefer setup stores: `ref` is state, `computed` is getters, `function` is actions. +- Setup stores do not get `$reset` for free. Define your own. +- Use `storeToRefs` for state and getters. Destructure actions directly off the store. +- Never persist raw auth tokens to `localStorage`. + +## vue-router + +- Lazy-load route components with dynamic `import()`. +- A global `beforeEach` auth gate keyed on `meta.requiresAuth`. Guards return `false` (cancel), a route location (redirect), or `undefined` / `true` (continue). +- Watch `() => route.params.id`, not the whole `route` object. + +## vue-query (server cache) + +- `@tanstack/vue-query` owns server-cache state. Pinia owns client state. +- Put request functions plus `queryOptions` factories in the FSD `api` segment. +- Critical: put the ref or computed ITSELF in the query key, never `.value`. Passing `.value` freezes the key and kills reactive refetch. + +```ts +useQuery({ queryKey: ['auction', id], queryFn: () => fetchAuction(toValue(id)) }) +// after a mutation +queryClient.invalidateQueries({ queryKey: ['auction', id] }) +``` + +## Reference + +- ECC skills: `frontend-patterns`, `vite-patterns`. +- Docs: · · · diff --git a/.claude/rules/ecc/vue/security.md b/.claude/rules/ecc/vue/security.md new file mode 100644 index 0000000..8f21953 --- /dev/null +++ b/.claude/rules/ecc/vue/security.md @@ -0,0 +1,46 @@ +--- +paths: + - "**/*.vue" +--- + +# Vue Security + +> This file extends [common/security.md](../common/security.md) with Vue specific content. + +## What Vue Escapes Automatically + +- Text interpolation `{{ }}` and dynamic attribute bindings (`:title`) are auto-escaped. The vectors below are NOT protected. + +## Rule No.1: Templates from Trusted Sources Only + +- Never use non-trusted content as a component template. No runtime template compilation from user input. +- No user-controlled `:is` that resolves a component from an arbitrary string. + +## v-html and Render Functions + +- `v-html` bypasses escaping and is a direct XSS vector. Avoid it on user content. +- If unavoidable, sanitize with DOMPurify (allowlist config) before binding, or render in a sandboxed iframe. Vue itself recommends sanitizing on the backend before persisting. +- Render-function and scoped-slot output carry the same risk. Passing user HTML through `h()` with `innerHTML` is `v-html` by another name. Sanitize first. + +## URL, Style, and Event Injection + +- `:href` and `:src` are not escaped. `javascript:` URLs execute. Validate the scheme, allow `http` / `https` / `mailto` only. Vue docs reference `@braintree/sanitize-url`, but sanitize on the backend before persisting. +- `:style` with user input is unsafe (CSS exfiltration). Use object syntax with whitelisted properties, never a raw user string. +- Never bind user input to `onclick`, `onfocus`, or any event attribute. + +## Client Bundle Secrets + +- Anything in `import.meta.env.VITE_*` ships to the browser. Keep API keys and tokens server-side. +- Use httpOnly cookies for session tokens. Never bundle credentials into the client. + +```vue + +
+ +
+``` + +## Reference + +- ECC skills: `frontend-patterns`, `vite-patterns`. +- Docs: · · diff --git a/.claude/rules/ecc/vue/testing.md b/.claude/rules/ecc/vue/testing.md new file mode 100644 index 0000000..951712f --- /dev/null +++ b/.claude/rules/ecc/vue/testing.md @@ -0,0 +1,53 @@ +--- +paths: + - "**/*.vue" +--- + +# Vue Testing + +> This file extends [common/testing.md](../common/testing.md) with Vue specific content. + +## Stack + +- Vitest (Vite-native runner) plus `@vue/test-utils`. `create-vue` scaffolds `@vitejs/plugin-vue`. +- DOM environment via `happy-dom` or `jsdom`, set in `vite.config.ts` under `test.environment`. + +## Rendering and Async + +- `mount` for a full render. `shallowMount` to stub all child components. +- `trigger` and `setValue` return promises, `await` them. +- `flushPromises` flushes resolved promise handlers. `nextTick` settles the DOM after a state change. + +## What to Test + +- Test the public interface only: props, emitted events, slots, rendered output. +- Do not assert private state or internal methods, and do not rely solely on snapshots. + +## Composables + +- Composables that use only reactivity APIs unit-test directly: call the function, assert on the returned refs. +- Composables that use lifecycle hooks or `inject` must be tested through a host component. + +## Pinia + +- In components: `createTestingPinia()` from `@pinia/testing`, passed via `global.plugins`. Actions are stubbed by default, set `stubActions: false` to run them. `createSpy: vi.fn` is required under Vitest (no Jest globals). +- In isolation: `beforeEach(() => setActivePinia(createPinia()))` gives a fresh store per test and prevents state leakage. + +## Mount Config + +- `global.plugins`, `global.stubs` (stubs `Transition` / `TransitionGroup` by default), `global.mocks` (e.g. `$router`), `global.provide` (for `inject`, Symbol keys supported). +- `RouterLinkStub` stubs `router-link` without mounting a full router. + +```ts +const wrapper = mount(AuctionCard, { + props: { id: 1 }, + global: { plugins: [createTestingPinia({ createSpy: vi.fn })] }, +}) +await wrapper.find('button').trigger('click') +expect(wrapper.emitted('bid')).toBeTruthy() +``` + +## Reference + +- ECC skills: `frontend-patterns`, `vite-patterns`. +- Docs: · · diff --git a/.claude/rules/ecc/web/coding-style.md b/.claude/rules/ecc/web/coding-style.md new file mode 100644 index 0000000..5707164 --- /dev/null +++ b/.claude/rules/ecc/web/coding-style.md @@ -0,0 +1,96 @@ +> This file extends [common/coding-style.md](../common/coding-style.md) with web-specific frontend content. + +# Web Coding Style + +## File Organization + +Organize by feature or surface area, not by file type: + +```text +src/ +├── components/ +│ ├── hero/ +│ │ ├── Hero.tsx +│ │ ├── HeroVisual.tsx +│ │ └── hero.css +│ ├── scrolly-section/ +│ │ ├── ScrollySection.tsx +│ │ ├── StickyVisual.tsx +│ │ └── scrolly.css +│ └── ui/ +│ ├── Button.tsx +│ ├── SurfaceCard.tsx +│ └── AnimatedText.tsx +├── hooks/ +│ ├── useReducedMotion.ts +│ └── useScrollProgress.ts +├── lib/ +│ ├── animation.ts +│ └── color.ts +└── styles/ + ├── tokens.css + ├── typography.css + └── global.css +``` + +## CSS Custom Properties + +Define design tokens as variables. Do not hardcode palette, typography, or spacing repeatedly: + +```css +:root { + --color-surface: oklch(98% 0 0); + --color-text: oklch(18% 0 0); + --color-accent: oklch(68% 0.21 250); + + --text-base: clamp(1rem, 0.92rem + 0.4vw, 1.125rem); + --text-hero: clamp(3rem, 1rem + 7vw, 8rem); + + --space-section: clamp(4rem, 3rem + 5vw, 10rem); + + --duration-fast: 150ms; + --duration-normal: 300ms; + --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1); +} +``` + +## Animation-Only Properties + +Prefer compositor-friendly motion: +- `transform` +- `opacity` +- `clip-path` +- `filter` (sparingly) + +Avoid animating layout-bound properties: +- `width` +- `height` +- `top` +- `left` +- `margin` +- `padding` +- `border` +- `font-size` + +## Semantic HTML First + +```html +
+ +
+
+
+

...

+
+
+
...
+``` + +Do not reach for generic wrapper `div` stacks when a semantic element exists. + +## Naming + +- Components: PascalCase (`ScrollySection`, `SurfaceCard`) +- Hooks: `use` prefix (`useReducedMotion`) +- CSS classes: kebab-case or utility classes +- Animation timelines: camelCase with intent (`heroRevealTl`) diff --git a/.claude/rules/ecc/web/design-quality.md b/.claude/rules/ecc/web/design-quality.md new file mode 100644 index 0000000..22c63c3 --- /dev/null +++ b/.claude/rules/ecc/web/design-quality.md @@ -0,0 +1,63 @@ +> This file extends [common/patterns.md](../common/patterns.md) with web-specific design-quality guidance. + +# Web Design Quality Standards + +## Anti-Template Policy + +Do not ship generic template-looking UI. Frontend output should look intentional, opinionated, and specific to the product. + +### Banned Patterns + +- Default card grids with uniform spacing and no hierarchy +- Stock hero section with centered headline, gradient blob, and generic CTA +- Unmodified library defaults passed off as finished design +- Flat layouts with no layering, depth, or motion +- Uniform radius, spacing, and shadows across every component +- Safe gray-on-white styling with one decorative accent color +- Dashboard-by-numbers layouts with sidebar + cards + charts and no point of view +- Default font stacks used without a deliberate reason + +### Required Qualities + +Every meaningful frontend surface should demonstrate at least four of these: + +1. Clear hierarchy through scale contrast +2. Intentional rhythm in spacing, not uniform padding everywhere +3. Depth or layering through overlap, shadows, surfaces, or motion +4. Typography with character and a real pairing strategy +5. Color used semantically, not just decoratively +6. Hover, focus, and active states that feel designed +7. Grid-breaking editorial or bento composition where appropriate +8. Texture, grain, or atmosphere when it fits the visual direction +9. Motion that clarifies flow instead of distracting from it +10. Data visualization treated as part of the design system, not an afterthought + +## Before Writing Frontend Code + +1. Pick a specific style direction. Avoid vague defaults like "clean minimal". +2. Define a palette intentionally. +3. Choose typography deliberately. +4. Gather at least a small set of real references. +5. Use ECC design/frontend skills where relevant. + +## Worthwhile Style Directions + +- Editorial / magazine +- Neo-brutalism +- Glassmorphism with real depth +- Dark luxury or light luxury with disciplined contrast +- Bento layouts +- Scrollytelling +- 3D integration +- Swiss / International +- Retro-futurism + +Do not default to dark mode automatically. Choose the visual direction the product actually wants. + +## Component Checklist + +- [ ] Does it avoid looking like a default Tailwind or shadcn template? +- [ ] Does it have intentional hover/focus/active states? +- [ ] Does it use hierarchy rather than uniform emphasis? +- [ ] Would this look believable in a real product screenshot? +- [ ] If it supports both themes, do both light and dark feel intentional? diff --git a/.claude/rules/ecc/web/hooks.md b/.claude/rules/ecc/web/hooks.md new file mode 100644 index 0000000..eaab93a --- /dev/null +++ b/.claude/rules/ecc/web/hooks.md @@ -0,0 +1,129 @@ +> This file extends [common/hooks.md](../common/hooks.md) with web-specific hook recommendations. + +# Web Hooks + +## Recommended PostToolUse Hooks + +Prefer project-local tooling. Do not wire hooks to remote one-off package execution. + +### Format on Save + +Use the project's existing formatter entrypoint after edits: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "pnpm prettier --write \"$FILE_PATH\"", + "description": "Format edited frontend files" + } + ] + } +} +``` + +Equivalent local commands via `yarn prettier` or `npm exec prettier --` are fine when they use repo-owned dependencies. + +### Lint Check + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "pnpm eslint --fix \"$FILE_PATH\"", + "description": "Run ESLint on edited frontend files" + } + ] + } +} +``` + +### Type Check + +Use `--incremental` so re-runs reuse the previous `.tsbuildinfo` (1-3s on unchanged code instead of 30-60s every time). Wrap in `timeout` so a stuck tsc gets reaped by the OS instead of accumulating across edits — this prevents the multi-process buildup that happens when edits fire faster than tsc finishes. + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "timeout 60 pnpm tsc --noEmit --pretty false --incremental --tsBuildInfoFile node_modules/.cache/tsc-hook.tsbuildinfo", + "description": "Type-check after frontend edits (incremental + timeout-capped)" + } + ] + } +} +``` + +**Why both flags matter:** +- Without `--incremental`, every edit re-checks the entire program from scratch. On a real Next.js project this stacks fast: edits at 5-10s intervals + 30-60s tsc runs = N concurrent tsc processes. +- Without `timeout`, a tsc that hangs (transitive dep change, type-checker stuck on a recursive type) never exits and orphans when the parent shell does. +- `--tsBuildInfoFile` is required because `--noEmit` normally suppresses the buildinfo write; specifying the path explicitly keeps incremental working. + +If you're on Windows without GNU coreutils, swap `timeout 60` for a PowerShell wrapper or rely on a Stop/SessionEnd hook to sweep stale tsc processes. + +### CSS Lint + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Write|Edit", + "command": "pnpm stylelint --fix \"$FILE_PATH\"", + "description": "Lint edited stylesheets" + } + ] + } +} +``` + +## PreToolUse Hooks + +### Guard File Size + +Block oversized writes from tool input content, not from a file that may not exist yet: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write", + "command": "node -e \"let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>{const i=JSON.parse(d);const c=i.tool_input?.content||'';const lines=c.split('\\n').length;if(lines>800){console.error('[Hook] BLOCKED: File exceeds 800 lines ('+lines+' lines)');console.error('[Hook] Split into smaller modules');process.exit(2)}console.log(d)})\"", + "description": "Block writes that exceed 800 lines" + } + ] + } +} +``` + +## Stop Hooks + +### Final Build Verification + +```json +{ + "hooks": { + "Stop": [ + { + "command": "pnpm build", + "description": "Verify the production build at session end" + } + ] + } +} +``` + +## Ordering + +Recommended order: +1. format +2. lint +3. type check +4. build verification diff --git a/.claude/rules/ecc/web/patterns.md b/.claude/rules/ecc/web/patterns.md new file mode 100644 index 0000000..ccec368 --- /dev/null +++ b/.claude/rules/ecc/web/patterns.md @@ -0,0 +1,79 @@ +> This file extends [common/patterns.md](../common/patterns.md) with web-specific patterns. + +# Web Patterns + +## Component Composition + +### Compound Components + +Use compound components when related UI shares state and interaction semantics: + +```tsx + + + Overview + Settings + + ... + ... + +``` + +- Parent owns state +- Children consume via context +- Prefer this over prop drilling for complex widgets + +### Render Props / Slots + +- Use render props or slot patterns when behavior is shared but markup must vary +- Keep keyboard handling, ARIA, and focus logic in the headless layer + +### Container / Presentational Split + +- Container components own data loading and side effects +- Presentational components receive props and render UI +- Presentational components should stay pure + +## State Management + +Treat these separately: + +| Concern | Tooling | +|---------|---------| +| Server state | TanStack Query, SWR, tRPC | +| Client state | Zustand, Jotai, signals | +| URL state | search params, route segments | +| Form state | React Hook Form or equivalent | + +- Do not duplicate server state into client stores +- Derive values instead of storing redundant computed state + +## URL As State + +Persist shareable state in the URL: +- filters +- sort order +- pagination +- active tab +- search query + +## Data Fetching + +### Stale-While-Revalidate + +- Return cached data immediately +- Revalidate in the background +- Prefer existing libraries instead of rolling this by hand + +### Optimistic Updates + +- Snapshot current state +- Apply optimistic update +- Roll back on failure +- Emit visible error feedback when rolling back + +### Parallel Loading + +- Fetch independent data in parallel +- Avoid parent-child request waterfalls +- Prefetch likely next routes or states when justified diff --git a/.claude/rules/ecc/web/performance.md b/.claude/rules/ecc/web/performance.md new file mode 100644 index 0000000..b720280 --- /dev/null +++ b/.claude/rules/ecc/web/performance.md @@ -0,0 +1,64 @@ +> This file extends [common/performance.md](../common/performance.md) with web-specific performance content. + +# Web Performance Rules + +## Core Web Vitals Targets + +| Metric | Target | +|--------|--------| +| LCP | < 2.5s | +| INP | < 200ms | +| CLS | < 0.1 | +| FCP | < 1.5s | +| TBT | < 200ms | + +## Bundle Budget + +| Page Type | JS Budget (gzipped) | CSS Budget | +|-----------|---------------------|------------| +| Landing page | < 150kb | < 30kb | +| App page | < 300kb | < 50kb | +| Microsite | < 80kb | < 15kb | + +## Loading Strategy + +1. Inline critical above-the-fold CSS where justified +2. Preload the hero image and primary font only +3. Defer non-critical CSS or JS +4. Dynamically import heavy libraries + +```js +const gsapModule = await import('gsap'); +const { ScrollTrigger } = await import('gsap/ScrollTrigger'); +``` + +## Image Optimization + +- Explicit `width` and `height` +- `loading="eager"` plus `fetchpriority="high"` for hero media only +- `loading="lazy"` for below-the-fold assets +- Prefer AVIF or WebP with fallbacks +- Never ship source images far beyond rendered size + +## Font Loading + +- Max two font families unless there is a clear exception +- `font-display: swap` +- Subset where possible +- Preload only the truly critical weight/style + +## Animation Performance + +- Animate compositor-friendly properties only +- Use `will-change` narrowly and remove it when done +- Prefer CSS for simple transitions +- Use `requestAnimationFrame` or established animation libraries for JS motion +- Avoid scroll handler churn; use IntersectionObserver or well-behaved libraries + +## Performance Checklist + +- [ ] All images have explicit dimensions +- [ ] No accidental render-blocking resources +- [ ] No layout shifts from dynamic content +- [ ] Motion stays on compositor-friendly properties +- [ ] Third-party scripts load async/defer and only when needed diff --git a/.claude/rules/ecc/web/security.md b/.claude/rules/ecc/web/security.md new file mode 100644 index 0000000..b44278c --- /dev/null +++ b/.claude/rules/ecc/web/security.md @@ -0,0 +1,57 @@ +> This file extends [common/security.md](../common/security.md) with web-specific security content. + +# Web Security Rules + +## Content Security Policy + +Always configure a production CSP. + +### Nonce-Based CSP + +Use a per-request nonce for scripts instead of `'unsafe-inline'`. + +```text +Content-Security-Policy: + default-src 'self'; + script-src 'self' 'nonce-{RANDOM}' https://cdn.jsdelivr.net; + style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; + img-src 'self' data: https:; + font-src 'self' https://fonts.gstatic.com; + connect-src 'self' https://*.example.com; + frame-src 'none'; + object-src 'none'; + base-uri 'self'; +``` + +Adjust origins to the project. Do not cargo-cult this block unchanged. + +## XSS Prevention + +- Never inject unsanitized HTML +- Avoid `innerHTML` / `dangerouslySetInnerHTML` unless sanitized first +- Escape dynamic template values +- Sanitize user HTML with a vetted local sanitizer when absolutely necessary + +## Third-Party Scripts + +- Load asynchronously +- Use SRI when serving from a CDN +- Audit quarterly +- Prefer self-hosting for critical dependencies when practical + +## HTTPS and Headers + +```text +Strict-Transport-Security: max-age=31536000; includeSubDomains; preload +X-Content-Type-Options: nosniff +X-Frame-Options: DENY +Referrer-Policy: strict-origin-when-cross-origin +Permissions-Policy: camera=(), microphone=(), geolocation=() +``` + +## Forms + +- CSRF protection on state-changing forms +- Rate limiting on submission endpoints +- Validate client and server side +- Prefer honeypots or light anti-abuse controls over heavy-handed CAPTCHA defaults diff --git a/.claude/rules/ecc/web/testing.md b/.claude/rules/ecc/web/testing.md new file mode 100644 index 0000000..6bf5812 --- /dev/null +++ b/.claude/rules/ecc/web/testing.md @@ -0,0 +1,55 @@ +> This file extends [common/testing.md](../common/testing.md) with web-specific testing content. + +# Web Testing Rules + +## Priority Order + +### 1. Visual Regression + +- Screenshot key breakpoints: 320, 768, 1024, 1440 +- Test hero sections, scrollytelling sections, and meaningful states +- Use Playwright screenshots for visual-heavy work +- If both themes exist, test both + +### 2. Accessibility + +- Run automated accessibility checks +- Test keyboard navigation +- Verify reduced-motion behavior +- Verify color contrast + +### 3. Performance + +- Run Lighthouse or equivalent against meaningful pages +- Keep CWV targets from [performance.md](performance.md) + +### 4. Cross-Browser + +- Minimum: Chrome, Firefox, Safari +- Test scrolling, motion, and fallback behavior + +### 5. Responsive + +- Test 320, 375, 768, 1024, 1440, 1920 +- Verify no overflow +- Verify touch interactions + +## E2E Shape + +```ts +import { test, expect } from '@playwright/test'; + +test('landing hero loads', async ({ page }) => { + await page.goto('/'); + await expect(page.locator('h1')).toBeVisible(); +}); +``` + +- Avoid flaky timeout-based assertions +- Prefer deterministic waits + +## Unit Tests + +- Test utilities, data transforms, and custom hooks +- For highly visual components, visual regression often carries more signal than brittle markup assertions +- Visual regression supplements coverage targets; it does not replace them diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3eb8d05 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "enabledPlugins": { + "github@claude-plugins-official": true, + "typescript-lsp@claude-plugins-official": true, + "ecc@ecc": true + } +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b56bf65..d55414e 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -91,7 +91,13 @@ "Bash(grep -v \"useTranslations\\\\|t\\(\\\\|import\\\\|//\\\\|className\\\\|interface\\\\|type\\\\|const\\\\|export\\\\|function\\\\|return\\\\|<\\\\|}\\\\|{\")", "Bash(grep -v \"//\\\\|import\\\\|className\\\\|href\\\\|aria\\\\|data-\\\\|type=\\\\|value=\\\\|admin\\\\|legal\\\\|impressum\\\\|datenschutz\\\\|nutzung\\\\|health-card\\\\|join\\\\|banned\\\\|auth\\\\|p/\\\\[\")", "Bash(grep -v \"^\\\\s*//\\\\|^import\")", - "Bash(grep -v \"//\\\\|import\\\\|className\\\\|interface\\\\|type\\\\|const\\\\|export\\\\|href\\\\|aria-\\\\|data-\\\\|key=\\\\|useTranslations\\\\|admin\\\\|auth\\\\|legal\\\\|health-card\\\\|p/\\\\[\")" + "Bash(grep -v \"//\\\\|import\\\\|className\\\\|interface\\\\|type\\\\|const\\\\|export\\\\|href\\\\|aria-\\\\|data-\\\\|key=\\\\|useTranslations\\\\|admin\\\\|auth\\\\|legal\\\\|health-card\\\\|p/\\\\[\")", + "Bash(xargs grep -l \"owner\\\\|account\\\\|profile\")", + "Bash(graphify export *)", + "Bash(graphify query *)", + "Skill(artifact-design)", + "Artifact", + "Bash(npx tsx *)" ] } } diff --git a/.gitignore b/.gitignore index e584102..44357ff 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,10 @@ yarn-error.log* # temp scaffold directory (removed after setup) /scaffold-tmp +# unrelated nested repo / generated knowledge-graph cache — not app code +/pentest-ai-agents +/graphify-out + # vercel .vercel diff --git a/CLAUDE.md b/CLAUDE.md index d23d6b4..5dbfff8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,196 +1,104 @@ - +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project -**PawFeed** +**PawFeed** — a social platform where pets are the stars. Dogs, cats, and birds get their own profiles; owners manage them, but the pet is the identity (profile URL, posts, follows — all pet-scoped, not owner-scoped). Photos, short-form video, stories, milestones, DMs, and health tracking, all within a single pet-first social graph. -PawFeed is a social platform where pets are the stars. Dogs, cats, and birds get their own profiles — owners manage them, but the pet is the identity. Think Instagram built from the ground up for animals: pets follow each other, post photos and videos, celebrate milestones, and track their health, all within a community that actually cares about animals. +**Core value:** Pets are first-class social identities, not content on a human's feed. Every feature is built around the pet's life. -**Core Value:** Pets are first-class social identities — not just content on a human's feed — so every feature is built around the pet's life, not the owner's. +**Constraints:** Web-first (mobile-responsive); dogs/cats/birds only in v1; short-form video only (no long-form). -### Constraints +## Commands -- **Platform**: Web-first (mobile-responsive) for v1 — fastest path to launch; React Native is a future milestone -- **Pet types**: Dogs, cats, birds only in v1 -- **Media storage**: Requires cloud storage solution for photos/videos (S3-compatible) -- **Video**: Short-form only — no long-form video to keep storage/bandwidth manageable at launch +```bash +npm run dev # next dev (Turbopack) +npm run build # next build (output: "standalone") +npm run start # next start +npm run lint # eslint (flat config, eslint.config.mjs) - +npx tsc --noEmit # type check (no separate package.json script) - +npx vitest run # run full test suite +npx vitest run src/__tests__/posts.test.ts # run a single test file +npx vitest # watch mode -## Technology Stack +npx prisma generate # regenerate Prisma client after schema.prisma changes +npx prisma migrate dev # create + apply a migration locally +npx prisma db seed # runs prisma/seed.ts via tsx (breed/species taxonomy) +``` -## Recommended Stack - -### Core Framework - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| Next.js | 15.x (stable) | Full-stack React framework | App Router gives co-located server components + API routes in one repo. SSR for SEO on pet/explore pages. Turbopack dev server is now stable. React 19 support. Confirmed stable per official release blog. | -| React | 19.x | UI rendering | Ships with Next.js 15. React Server Components reduce JS bundle for feed pages. Concurrent features matter for infinite scroll. | -| TypeScript | 5.x | Type safety | Next.js 15 ships `next.config.ts` with first-class TS support. Non-negotiable for a team codebase; catches data-shape bugs early (pet profiles, post types). | - -### Styling - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| Tailwind CSS | 4.x | Utility-first styling | Fastest path to a polished, consistent UI. No CSS-in-JS runtime cost. v4 (released early 2025) uses a new CSS-native engine — faster builds, no config file needed. Standard choice for Next.js apps in 2025. | -| shadcn/ui | Latest | Component primitives | Radix UI primitives + Tailwind. Copy-into-repo model means no dependency lock-in. Accessible by default — critical for input forms (onboarding, post upload, DM composer). Not a library to install — a component source strategy. | - -### Backend / API - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| Next.js Route Handlers | 15.x | API layer | For most endpoints (CRUD on pets, posts, follows, reactions) Next.js Route Handlers eliminate a separate API server entirely. Server Actions for form mutations (post creation, profile edits) keep the round-trip minimal. | -| tRPC | 11.x | Type-safe API client-server bridge | Where you need client-side data fetching with full type safety (feed, DM polling, notifications), tRPC with React Query eliminates manual API typing. Pairs well with Next.js App Router via the `@trpc/server/adapters/fetch` adapter. Use for internal browser-to-server calls; use plain Route Handlers for webhooks and external service callbacks. | -| Zod | 3.x | Schema validation | Input validation on all API boundaries. tRPC uses Zod natively. Also use for validating Mux/Cloudinary webhook payloads. | - -### Database - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| PostgreSQL | 16.x | Primary data store | The correct choice for a social graph. Supports relational integrity (pet→owner, post→pet, follow→pet), JSONB for flexible milestone metadata, full-text search for hashtag/breed discovery, and row-level security (RLS) when using Supabase. | -| Supabase | Latest (hosted) | Managed Postgres + realtime + auth option | Supabase provides managed Postgres 15/16, built-in Realtime (websocket pub/sub on DB changes), Storage (S3-compatible for photos), and optional Auth. Dramatically reduces infrastructure work for a v1. The free tier covers early-stage traffic. **Critical:** Supabase Realtime handles feed updates and DM delivery — replaces the need for a separate WebSocket server. | -| Prisma | 5.x | ORM / schema management | Type-safe DB client generated from schema. Migrations are declarative. Integrates with Supabase Postgres directly. Prisma Accelerate (edge-compatible connection pooling) solves the serverless connection limit problem that hits Postgres hard on Vercel. | -| Redis (Upstash) | Serverless | Cache + rate limiting + ephemeral state | Stories TTL management (24h expiry), feed caching for hot users, rate limiting on post upload and DM endpoints. Upstash is serverless Redis — pay-per-request, no always-on instance. Critical: do NOT use Supabase as a cache layer; Redis is the right tool for ephemeral/fast-expiry data. | - -### Media Storage & Delivery - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| Cloudflare R2 | — | Photo storage | S3-compatible object storage with zero egress fees. Photos are the dominant media type by volume. Egress costs on S3 accumulate fast on a social platform; R2 eliminates this. Pair with Cloudflare CDN for global image delivery. | -| Cloudflare Images | — | Image optimization & CDN | Resize, crop, and format-convert (WebP/AVIF) on the fly via URL parameters. Replaces the need for a custom image pipeline. Cost: ~$5/100k images served. Alternative is Next.js Image Optimization pointed at R2, but Cloudflare Images is more capable and decoupled from your Next.js server. | -| Mux | — | Video upload, transcoding, adaptive streaming | Short-form video (pet posts, stories) requires HLS adaptive streaming — raw MP4 upload is not acceptable for mobile networks. Mux handles: upload via signed URLs → auto-transcoding to multiple bitrates → HLS delivery → thumbnail extraction → webhooks on completion. The `mux-player-react` component embeds in 10 lines. Pricing is per-minute stored + per-minute delivered — fair for short-form video. **Do not use Cloudinary for video at scale**: Cloudinary's video pricing is expensive and less optimized for streaming than Mux. | - -### Real-Time Features - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| Supabase Realtime | — | Feed updates, DM delivery, notification toasts | Supabase Realtime uses PostgreSQL logical replication to broadcast DB changes over websockets. Subscribe to `INSERT` on `messages` table → DMs appear instantly. Subscribe to `INSERT` on `posts` from followed pets → live feed updates. No separate Socket.io or Ably subscription needed. Included in Supabase plan. | -| TanStack Query (React Query) | 5.x | Client-side data fetching, caching, optimistic updates | Pairs with tRPC. Handles feed pagination (cursor-based infinite scroll), optimistic like/reaction updates, and background refetch. Do NOT use SWR — React Query v5 has superior cache invalidation and mutation handling for a social app's complexity. | - -### Authentication - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| Clerk | Latest | Authentication provider | Clerk is the standard choice for Next.js 15 App Router auth in 2025. Ships with: pre-built UI components (sign-in, sign-up, user profile), social OAuth (Google, Apple, Facebook — important for pet-owner onboarding friction), session management with edge-compatible JWT verification, webhooks for user lifecycle events. The `@clerk/nextjs` middleware integration is first-class. **Note:** PawFeed has a two-layer identity (owner account + pet profile). Clerk manages the owner account; your DB manages the pet-as-identity layer. Do NOT try to put pets as Clerk users — they are not auth entities, they are app-domain entities. | - -### Infrastructure & Deployment - -| Technology | Version | Purpose | Why | -|------------|---------|---------|-----| -| Vercel | — | Hosting | Next.js is optimized for Vercel — zero-config deployment, edge middleware, ISR, image optimization. For a greenfield social app the operational overhead savings outweigh cost at v1 scale. Preview deployments per PR accelerate iteration. | -| Upstash | — | Serverless Redis | Rate limiting (post uploads, DM sends), story TTL tracking, hot-user feed cache. Serverless pricing eliminates idle cost. | - -## Alternatives Considered - -| Category | Recommended | Alternative | Why Not | -|----------|-------------|-------------|---------| -| Frontend framework | Next.js 15 | Remix v2 | Remix has excellent data loading patterns but smaller ecosystem, less Vercel integration, and fewer community examples for social apps. Next.js App Router now covers Remix's main advantages (nested layouts, data co-location). | -| Frontend framework | Next.js 15 | SvelteKit | Smaller ecosystem, fewer hire-able devs, React component ecosystem (shadcn, Mux player) is unavailable. | -| Database | PostgreSQL (Supabase) | MongoDB | No relational joins — social graph queries become application-level N+1 problems. MongoDB's flexible schema is a liability when you need FK constraints on pet→post→reaction. | -| Database | PostgreSQL (Supabase) | PlanetScale (MySQL) | PlanetScale removed its free tier in 2024; Vitess's lack of FK enforcement is a footgun for a social graph. Supabase offers more value (Realtime, Auth, Storage) in one platform. | -| ORM | Prisma | Drizzle ORM | Drizzle is faster and more lightweight, but Prisma's migration tooling and ecosystem maturity are better for a team starting greenfield. Revisit Drizzle in v2 if Prisma performance becomes an issue. | -| Video | Mux | Cloudinary Video | Cloudinary video pricing is significantly higher per GB and less optimized for HLS adaptive streaming. Mux is purpose-built for streaming video. | -| Video | Mux | AWS MediaConvert + S3 | More control but enormous operational complexity for a v1. Requires Lambda triggers, MediaConvert job polling, CloudFront CDN setup. Mux abstracts all of this. | -| Photo storage | Cloudflare R2 + Images | AWS S3 + CloudFront | Equivalent capability but S3 egress costs accumulate. R2's zero-egress pricing is a direct advantage for a photo-heavy social platform. | -| Photo storage | Cloudflare R2 + Images | Supabase Storage | Supabase Storage lacks image transformation. Use Supabase Storage only for private files needing RLS (health records). | -| Auth | Clerk | NextAuth (Auth.js v5) | More setup, no pre-built UI, requires more custom code for social OAuth flows. Clerk's Next.js 15 App Router integration is tighter and more production-ready. | -| Real-time | Supabase Realtime | Ably / Pusher | Additional paid service when Supabase Realtime (included) covers the use cases. Supabase Realtime's DB-change subscription model is particularly clean for a feed driven by Postgres inserts. | -| Real-time | Supabase Realtime | Socket.io (self-hosted) | Requires a persistent server — incompatible with Vercel serverless. Supabase Realtime is managed and serverless-friendly. | -| Client fetching | TanStack Query v5 | SWR | React Query v5 has richer mutation/optimistic update APIs. For a social app with likes, comments, and reposts all needing optimistic UI, React Query's `useMutation` is more capable than SWR's `useSWRMutation`. | -| Styling | Tailwind CSS v4 | CSS Modules | CSS Modules don't scale as well for a design system approach; Tailwind + shadcn provides a cohesive component language out of the box. | -| Styling | Tailwind CSS v4 | styled-components / Emotion | Runtime CSS-in-JS has a performance penalty in RSC environments and adds complexity. Tailwind generates static CSS — no runtime overhead. | - -## Installation - -# Bootstrap - -# Core dependencies - -# Dev dependencies - -# shadcn/ui (interactive CLI, installs component source into your repo) - -## Key Version Pinning Notes - -| Package | Version to pin | Reason | -|---------|---------------|--------| -| `next` | `^15.0.0` | Stable, confirmed. Avoid `canary`. | -| `react` / `react-dom` | `^19.0.0` | Required for Next.js 15 App Router. | -| `@prisma/client` | `^5.0.0` | v5 has improved query performance and better TS types. | -| `@trpc/server` | `^11.0.0` | v11 is the App Router-native version; v10 had compatibility issues. | -| `@tanstack/react-query` | `^5.0.0` | v5 has breaking API changes from v4; pin major. | -| `tailwindcss` | `^4.0.0` | v4 engine is not backward compatible with v3 config format. | - -## Confidence Assessment - -| Area | Confidence | Notes | -|------|------------|-------| -| Next.js 15 as framework | HIGH | Confirmed via official release blog (fetched). Stable since Oct 2024. | -| React 19 | HIGH | Ships with Next.js 15, confirmed in release blog. | -| TypeScript 5.x | HIGH | Industry standard; no uncertainty. | -| Tailwind CSS v4 | MEDIUM | Released early 2025 per training data; could not verify current minor version with live docs. | -| shadcn/ui | MEDIUM | Well-established pattern as of August 2025 training cutoff. | -| tRPC v11 | MEDIUM | v11 targeting App Router compatibility per training data; verify current release on npmjs.com. | -| Prisma v5 | MEDIUM | Stable per training data; Prisma Accelerate for serverless connection pooling verified as an existing product. | -| Supabase (Postgres + Realtime) | MEDIUM | Core product is well-established; Realtime feature set stable per training data. Cannot confirm latest feature additions. | -| PostgreSQL 16 | HIGH | Mature, stable; Supabase provides it managed. | -| Cloudflare R2 + Images | MEDIUM | R2 launched 2022, zero-egress pricing confirmed stable; Images product behavior from training data. | -| Mux for video | MEDIUM | Mux is the established standard for developer-facing video streaming; `mux-player-react` is the official component. Pricing model from training data — verify before committing. | -| Clerk | MEDIUM | Established Next.js auth provider as of training cutoff; Next.js 15 App Router compatibility from training data. Verify `@clerk/nextjs` supports Next.js 15 before starting. | -| Upstash Redis | MEDIUM | Serverless Redis product is well-established; pricing from training data. | -| Vercel | HIGH | Official Next.js hosting platform, no uncertainty. | - -## Sources - -- Next.js 15 official release blog: https://nextjs.org/blog/next-15 (fetched 2026-06-05) — HIGH confidence -- Remaining technology assessments based on training data (cutoff August 2025) — MEDIUM confidence -- Recommend verifying: Clerk `@clerk/nextjs` Next.js 15 compatibility, tRPC v11 stability, Tailwind v4 migration guide, Mux current pricing - - - - - -## Conventions - -Conventions not yet established. Will populate as patterns emerge during development. - - - +There is no `npm test` script — invoke `vitest` directly via `npx`. ## Architecture -Architecture not yet mapped. Follow existing patterns found in the codebase. - +### Two-layer identity (the load-bearing invariant) - +Clerk manages **Owner** accounts (auth only — `Owner.id` is literally the Clerk `userId`). Pets are app-domain entities, not auth principals. Every social action — posts, follows, reactions, comments, DMs — is scoped by `petId`, never by `ownerId`. The social graph (`Follow`, `Block`, `Reaction`, etc.) has no owner columns at all. When adding a feature, figure out which pet is acting, not which owner is logged in; ownership checks (`pet.ownerId === ctx.userId`) happen in the tRPC procedure, not in the schema. -## Project Skills +The client-side "which pet is currently active" state lives in `src/context/ActivePetContext.tsx` (React Context + `localStorage`, initialized server-side by `ActivePetInitializer` with the owner's first pet). The `localStorage`/SSR mismatch on first paint is expected, not a bug. -No project skills found. Add skills to any of: `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/`, or `.codex/skills/` with a `SKILL.md` index file. - +### tRPC layer - +- `src/trpc/init.ts` — context is `{ userId, prisma }` (userId from Clerk `auth()`); `protectedProcedure` throws `UNAUTHORIZED` if `userId` is null and narrows it to non-null in `ctx` for everything downstream. +- `src/trpc/routers/_app.ts` — aggregates ~20 domain routers (pets, posts, stories, milestones, follows, blocks, reports, feed, reactions, comments, reposts, explore, search, notifications, videos, conversations/messages, health, admin, invites, ads). One router file per domain in `src/trpc/routers/`. +- `src/trpc/server.ts` exposes a server-side caller (`createTRPCCaller()`) used inside Server Components/layouts to prefetch without an HTTP round-trip (see `src/app/(app)/layout.tsx`). `src/trpc/client.tsx` + `query-client.ts` wire up the browser-side TanStack Query client. +- Pet-ownership checks go through the shared `assertPetOwnership(prisma, petId, userId)` helper in `src/lib/assert-pet-ownership.ts` — don't reintroduce a local copy of this check in a new router. -## GSD Workflow Enforcement +### Denormalized engagement counters -Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync. +`Post.reactionCount/commentCount/repostCount` and `Advertisement.adReactionCount/adCommentCount/adRepostCount` are maintained transactionally at every create/delete site (reactions, comments, reposts, ads routers, and `admin.ts` moderation deletes) instead of computed via live `_count`/`count()` on read. Feed/explore/posts/admin reads use these columns directly. If you add a new mutation that creates or deletes a reaction/comment/repost, you must increment/decrement the matching counter in the same transaction — there is no background reconciliation job. -Use these entry points: +### Routing structure -- `/gsd-quick` for small fixes, doc updates, and ad-hoc tasks -- `/gsd-debug` for investigation and bug fixing -- `/gsd-execute-phase` for planned phase work +- `(auth)` route group — sign-up/log-in/password reset, unauthenticated. +- `(app)` route group — the authenticated shell (`layout.tsx` does a belt-and-suspenders `auth()` check, redirects to onboarding if the owner has zero pets, redirects banned owners to `/banned`, and consumes a pending invite cookie). +- `/p/[secret]/...` — the admin/moderation panel. The path segment itself is the secret (`ADMIN_SECRET` env var); wrong or missing secret returns a bare `404`, not `403`, so the panel's existence isn't revealed. +- `/health-card/[token]` — public, tokenized, no auth (shareable vet record). +- Root-level `/impressum`, `/datenschutz`, `/nutzungsbedingungen` — German legal pages, not under `(app)`, not localized (this is a `next-intl` project but these three routes are fixed-language). -Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it. - +### `src/proxy.ts` (not `middleware.ts`) - +Next.js 16 renamed `middleware.ts` to `proxy.ts` — this **is** the middleware; don't go looking for a `middleware.ts` that doesn't exist. It layers three concerns in one Clerk middleware: (1) invite gate — blocks `/sign-up` unless a `pf_invite` cookie is present, unless `INVITE_REQUIRED=false`; (2) admin secret gate — 404s `/p/*` unless the path segment matches `ADMIN_SECRET`; (3) `auth.protect()` for everything else not in the public route matcher. -## Developer Profile +### Media & storage — code has diverged from the original stack plan -> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile. -> This section is managed by `generate-claude-profile` -- do not edit manually. - +The actual, current image/avatar storage path is **Supabase Storage** (`src/lib/supabase-storage.ts`, bucket `pet-avatars`, `createSignedUploadUrl` + `getPublicUrl`), used by `posts`, `stories`, `media`, and `admin` routers. `src/lib/r2.ts` (Cloudflare R2 via presigned S3 PUT) was dead code and has been **deleted** (2026-07-12), along with the `@aws-sdk/*` dependencies — Supabase Storage is the only storage path now. + +All uploads are presigned/direct-to-storage — media bytes never pass through the Next.js server. + +### Video (Mux) + +`src/lib/mux.ts` is a thin singleton client. Video is async: a `VideoPost` row is created in `PROCESSING` state with a `muxUploadId`; the `/api/webhooks/mux` route handler advances it to `READY` (with `muxAssetId`, `muxPlaybackId`, duration, aspect ratio) or `ERROR` as Mux webhooks arrive. Never assume a video is playable synchronously after upload. + +### Redis — self-hosted, not Upstash + +`src/lib/redis.ts` is a plain `ioredis` client (`lazyConnect: true` so module evaluation during build doesn't attempt a TCP connect) pointed at `REDIS_URL`. In production this is the `redis` service in `docker-compose.yml`, not Upstash serverless — despite what older planning docs in `.planning/` say. + +### i18n + +`next-intl`, config at `src/i18n/request.ts`, message catalogs at `messages/en.json` and `messages/de.json`. No locale-prefixed routing — check `request.ts` before assuming a routing scheme when adding a new locale. + +### Admin / moderation panel + +Gated by path secret (see `proxy.ts` above) *and* by role at the tRPC layer: `src/trpc/routers/admin.ts`'s `assertAdmin()` allows either `ADMIN_OWNER_ID` (env-configured super-admin bootstrap) or an `AdminRole` row (`SUPER_ADMIN` / `MODERATOR`). Actions are written to `ModerationLog`. Pages live under `src/app/p/[secret]/{users,moderators,posts,reports,ads,invites}`. + +### Invite system + +`InviteCode` model (word-prefix + 4-char code, e.g. `PAWS-7K3M`, generated in `src/lib/invite-codes.ts`) gates sign-up via a `pf_invite` cookie set by `/join` and consumed by `/api/auth/consume-invite` right after sign-up (the `(app)` layout redirects there if the cookie is still present). `INVITE_REQUIRED=false` turns the whole gate off for post-beta public launch. + +### Testing + +Vitest + `jsdom`. Prisma is fully mocked per-model via `createMockPrisma()` in `src/__tests__/helpers/prisma-mock.ts` — unit tests never touch a real database; when a router gains a new Prisma call, add the corresponding mock method there. Tests are organized one file per domain router in `src/__tests__/` (e.g. `posts.test.ts`, `stories.test.ts`, `follows.test.ts`), not colocated with source. + +### Deployment — self-hosted Docker, not Vercel + +Despite `next.config.ts`'s `output: "standalone"` and the original stack research recommending Vercel, this app is deployed to a self-hosted OpenMediaVault NAS via Docker (`docker/Dockerfile`, `docker/docker-compose.yml`, `docker/start.sh`). **There is no `git pull` on the server** — deploys are manual FTP uploads of changed files followed by `./start.sh`. Don't suggest Vercel-specific features (ISR revalidation webhooks, Vercel KV, edge config) without checking this still holds. + +`src/app/api/cron/trim-feeds/route.ts` trims each pet's Redis feed sorted set to the 90-day retention window and expects a `CRON_SECRET` bearer token. `vercel.json`'s `crons` array that points at it is **inert** on this self-hosted deployment — nothing calls this route yet. It needs an external trigger (e.g. a NAS-side cron job hitting the route with the secret header) before it actually runs. + +### Directories that are not app code + +- `pentest-ai-agents/` — a separately-cloned plugin repo (has its own `.git`) sitting inside this project root. Unrelated to PawFeed; don't treat its `agents/`, `commands/`, `db/` as part of the app. +- `scaffold-tmp/` — leftover `create-next-app` scaffold from initial bootstrap (has its own `package.json`, `node_modules`, generic starter page). Not part of the running app, which lives under `src/`. diff --git a/DEBUG-List.md b/DEBUG-List.md index 4f5f90d..38e0c02 100644 --- a/DEBUG-List.md +++ b/DEBUG-List.md @@ -2,21 +2,21 @@ Debug: ~~\-Die Suchen seite ist noch nicht auf Deutsch übersetzt.~~ ✅ ERLEDIGT -\-Die Erwähnfunktion mittels @ ist impelmentiert, aber es fehlt die Benachrichtung das man Erwähnt wurde ! ~~Zudem ist die Auswahl nach dem tippen @ schlecht designed man kann gerade so den Avatar erkennen aber nicht den namen in der aufploppenden suche, bitte um FIX !~~ ✅ ERLEDIGT (Dropdown-Design) +~~\-Die Erwähnfunktion mittels @ ist impelmentiert, aber es fehlt die Benachrichtung das man Erwähnt wurde ! Zudem ist die Auswahl nach dem tippen @ schlecht designed man kann gerade so den Avatar erkennen aber nicht den namen in der aufploppenden suche, bitte um FIX !~~ ✅ ERLEDIGT (Dropdown-Design + Benachrichtigung + Eingabefeld-Breite) ~~\-Messages ist noch nicht auf Deutsch übersetzt.~~ ✅ ERLEDIGT ~~\-Kommentar Update; wenn man einen Kommentar postet, muss dieser zwingend sofort im Kommentar-Verlauf sichtbar werden (Optische Bestätigung das der Kommentar angekommen ist für den Nutzer), es muss sich als Nutzer Live anfühlen auch wenn der Server noch am arbeiten ist.~~ ✅ ERLEDIGT -\-Benachrichtung wenn man "Paw-Back this post" nutzt für den Original-User des Posts. +~~\-Benachrichtung wenn man "Paw-Back this post" nutzt für den Original-User des Posts.~~ ✅ ERLEDIGT ~~\-Die Mobil Ansicht ist noch nicht ausgereift, das Menü ist teilweise nicht mehr erkennbar im unteren Segemet ( Startseite, Entdecken, +, Benachrichtigungen, Profil).~~ ✅ ERLEDIGT ~~\-Mobilansicht - Add Story Button ist sehr linksbündig, keine Aufteilung vorhanden.~~ ✅ ERLEDIGT -\-Mobilansicht - Kein PawFeed Logo sichtbar, soll nur auf der Startseite erscheinen beim scrollen verschwinden. +~~\-Mobilansicht - Kein PawFeed Logo sichtbar, soll nur auf der Startseite erscheinen beim scrollen verschwinden.~~ ✅ ERLEDIGT -\-Mobilansicht - wenn man bis man hin und her scrollt soll man bei langem hochscrollen einen refresh auslösen. +~~\-Mobilansicht - wenn man bis man hin und her scrollt soll man bei langem hochscrollen einen refresh auslösen.~~ ✅ ERLEDIGT ~~\-Mobilansicht - Impressum/Datenschutz in das Profil rein legen, damit man es erreichen kann.~~ ✅ ERLEDIGT @@ -28,7 +28,7 @@ Debug: ~~\-Kommentarbereich dynamisch: bei wenigen Kommentaren nimmt der Bereich den halben Screen ein, soll sich anpassen und erst ab einer gewissen Höhe scrollbar werden.~~ ✅ ERLEDIGT -\-Mobilansicht - Chat-Eingabe nicht direkt sichtbar: Innerhalb eines Chats muss man scrollen um zur Texteingabe zu gelangen. Die Eingabeleiste soll fixiert am unteren Bildschirmrand bleiben (sticky/fixed), sodass man ohne Scrollen direkt antworten kann. +~~\-Mobilansicht - Chat-Eingabe nicht direkt sichtbar: Innerhalb eines Chats muss man scrollen um zur Texteingabe zu gelangen. Die Eingabeleiste soll fixiert am unteren Bildschirmrand bleiben (sticky/fixed), sodass man ohne Scrollen direkt antworten kann.~~ ✅ ERLEDIGT -\-User Delete Funktion, für den Unwahrscheinlichen fall ein User möchte sein Konto löschen wird ein Workflow benötigt, der nach einer Bestätigung mittels Tiernamen Eingabe (Auswahl eins von möglichen) den Kompletten Account löscht, sowie alle Hochgeladen Bilder/Videos und erzeugten Posts, Rückstandslos. +~~\-User Delete Funktion, für den Unwahrscheinlichen fall ein User möchte sein Konto löschen wird ein Workflow benötigt, der nach einer Bestätigung mittels Tiernamen Eingabe (Auswahl eins von möglichen) den Kompletten Account löscht, sowie alle Hochgeladen Bilder/Videos und erzeugten Posts, Rückstandslos.~~ ✅ ERLEDIGT diff --git a/Supabase-Error.md b/Supabase-Error.md new file mode 100644 index 0000000..af38719 --- /dev/null +++ b/Supabase-Error.md @@ -0,0 +1,42 @@ +| name | title | level | facing | categories | description | detail | remediation | metadata | cache\_key | + +| ------------------------- | ------------------------- | ----- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------- | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Notification\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Notification","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Notification | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.VideoPost\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"VideoPost","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_VideoPost | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Message\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Message","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Message | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.VetVisit\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"VetVisit","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_VetVisit | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Vaccine\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Vaccine","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Vaccine | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.WeightLog\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"WeightLog","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_WeightLog | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Reaction\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Reaction","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Reaction | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Comment\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Comment","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Comment | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Repost\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Repost","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Repost | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.PostHashtag\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"PostHashtag","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_PostHashtag | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Hashtag\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Hashtag","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Hashtag | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Conversation\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Conversation","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Conversation | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.HealthCard\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"HealthCard","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_HealthCard | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.EmergencyVet\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"EmergencyVet","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_EmergencyVet | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.Advertisement\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"Advertisement","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_Advertisement | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.ModerationLog\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"ModerationLog","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_ModerationLog | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.AdminRole\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"AdminRole","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_AdminRole | + +| rls\_disabled\_in\_public | RLS Disabled in Public | ERROR | EXTERNAL | \["SECURITY"] | Detects cases where row level security (RLS) has not been enabled on tables in schemas exposed to PostgREST | Table \\`public.UserBan\\` is public, but RLS has not been enabled. | https://supabase.com/docs/guides/database/database-linter?lint=0013\_rls\_disabled\_in\_public | {"name":"UserBan","type":"table","schema":"public"} | rls\_disabled\_in\_public\_public\_UserBan | + +| sensitive\_columns\_exposed | Sensitive Columns Exposed | ERROR | EXTERNAL | \["SECURITY"] | Detects tables exposed via API that contain columns with potentially sensitive data (PII, credentials, financial info) without RLS protection. | Table `public.HealthCard` is exposed via API without RLS and contains potentially sensitive column(s): token. This may lead to data exposure. | https://supabase.com/docs/guides/database/database-linter?lint=0023\_sensitive\_columns\_exposed | {"name":"HealthCard","type":"table","schema":"public","matched\_patterns":\["token"],"sensitive\_columns":\["token"]} | sensitive\_columns\_exposed\_public\_HealthCard | + diff --git a/messages/de.json b/messages/de.json index b373022..5487028 100644 --- a/messages/de.json +++ b/messages/de.json @@ -139,6 +139,12 @@ "countSingular": "1 Kommentar", "countPlural": "{count} Kommentare" }, + "RelativeTime": { + "justNow": "gerade eben", + "minutes": "{count}m", + "hours": "{count}h", + "days": "{count}d" + }, "Follow": { "follow": "Folgen", "following": "Gefolgt", diff --git a/messages/en.json b/messages/en.json index ac0e51d..9ae7653 100644 --- a/messages/en.json +++ b/messages/en.json @@ -139,6 +139,12 @@ "countSingular": "1 comment", "countPlural": "{count} comments" }, + "RelativeTime": { + "justNow": "now", + "minutes": "{count}m", + "hours": "{count}h", + "days": "{count}d" + }, "Follow": { "follow": "Follow", "following": "Following", diff --git a/mobil.md b/mobil.md new file mode 100644 index 0000000..d3641b3 --- /dev/null +++ b/mobil.md @@ -0,0 +1,75 @@ +# PawFeed Mobile — Nebenplan + +## Ausgangslage + +Die Web-App läuft auf Next.js. Kein Neu-Schreiben nötig — die App wird nur verpackt. + +--- + +## Phase 1 — PWA (kostenlos, sofort) + +Progressive Web App: Nutzer können die Seite zum Home-Screen hinzufügen. + +**Was zu tun ist:** +- `manifest.json` mit App-Icon + Name hinzufügen +- Service Worker für Offline-Fallback (`next-pwa` Plugin) +- HTTPS ist bereits aktiv (Cloudflare) ✓ + +**Kosten:** €0 +**Aufwand:** ~1 Tag +**Ergebnis:** Icon auf dem Home-Screen, Vollbild, kein Browser-Chrome +**Grenze:** Kein App Store Listing; Push Notifications nur Android (iOS erst ab iOS 16.4 im Browser) + +--- + +## Phase 2 — Capacitor (günstiger Store-Eintrag) + +Capacitor verpackt die bestehende Web-App in eine native Shell. Der Browser rendert weiterhin Next.js, die App wirkt aber nativ. + +**Was zu tun ist:** +```bash +npm install @capacitor/core @capacitor/cli @capacitor/ios @capacitor/android +npx cap init PawFeed org.pawfeed.app +npx cap add ios +npx cap add android +``` + +Die App zeigt `https://pawfeed.org` in einem nativen WebView — oder bei Build eine statische Next.js Export-Version. + +**Kosten:** + +| Posten | Kosten | +|---|---| +| Apple Developer Account | 99 €/Jahr (Pflicht für iOS Store) | +| Google Play Console | 25 € einmalig | +| Capacitor | Open Source, kostenlos | + +**Aufwand:** ~2–3 Tage +**Ergebnis:** Echte App im App Store und Play Store, native Push Notifications, Kamera-Zugriff möglich +**Grenze:** Performance ist WebView, kein 100% natives Feel + +--- + +## Phase 3 — React Native / Expo (spätere Milestone) + +Bereits in `CLAUDE.md` als Future Milestone vermerkt. Für Alpha und Beta nicht notwendig. + +**Wann sinnvoll:** +- Nutzerzahl wächst und Performance-Beschwerden kommen +- Features brauchen echten nativen Zugriff (GPS, Kamera-APIs, ARKit) +- Budget für ~4–6 Wochen Rewrite vorhanden + +--- + +## Zeitplan + +| Phase | Wann | Kosten | +|---|---|---| +| PWA | Alpha | €0 | +| Capacitor → iOS + Android | Beta | ~124 € | +| React Native Rewrite | v2 | Projektbudget | + +## Wichtigster Hebel + +Apple Developer Account ist der einzige echte Kostenfaktor. Ohne ihn kein iOS Store — +aber PWA funktioniert bereits auf iPhone ohne Store und ohne Kosten. diff --git a/package.json b/package.json index 2e7ae01..623d07a 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,6 @@ "seed": "npx tsx prisma/seed.ts" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1062.0", - "@aws-sdk/s3-request-presigner": "^3.1062.0", "@base-ui/react": "^1.5.0", "@clerk/nextjs": "^7.4.3", "@hookform/resolvers": "^5.4.0", @@ -28,7 +26,6 @@ "@trpc/server": "^11.17.0", "@trpc/tanstack-react-query": "^11.17.0", "class-variance-authority": "^0.7.1", - "client-only": "^0.0.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", "ioredis": "^5.11.1", @@ -43,7 +40,6 @@ "react-hook-form": "^7.77.0", "react-intersection-observer": "^10.0.3", "server-only": "^0.0.1", - "shadcn": "^4.10.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0", @@ -51,6 +47,7 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", + "shadcn": "^4.10.0", "@types/node": "^20", "@types/pg": "^8.20.0", "@types/react": "^19", diff --git a/prisma/backfill-counters.sql b/prisma/backfill-counters.sql new file mode 100644 index 0000000..536ed6d --- /dev/null +++ b/prisma/backfill-counters.sql @@ -0,0 +1,14 @@ +-- One-off backfill for the new denormalized engagement counters (Cluster A). +-- Run once, immediately after `prisma migrate dev` adds the columns (which +-- default to 0), so existing posts/ads don't briefly show 0 engagement. +-- Safe to re-run — it always recomputes from the source-of-truth tables. + +UPDATE "Post" p +SET "reactionCount" = COALESCE((SELECT COUNT(*) FROM "Reaction" r WHERE r."postId" = p.id), 0), + "commentCount" = COALESCE((SELECT COUNT(*) FROM "Comment" c WHERE c."postId" = p.id), 0), + "repostCount" = COALESCE((SELECT COUNT(*) FROM "Repost" rp WHERE rp."originalPostId" = p.id), 0); + +UPDATE "Advertisement" a +SET "adReactionCount" = COALESCE((SELECT COUNT(*) FROM "AdReaction" ar WHERE ar."adId" = a.id), 0), + "adCommentCount" = COALESCE((SELECT COUNT(*) FROM "AdComment" ac WHERE ac."adId" = a.id), 0), + "adRepostCount" = COALESCE((SELECT COUNT(*) FROM "AdRepost" arp WHERE arp."adId" = a.id), 0); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d742206..b17a58c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,6 +14,8 @@ model Owner { adminRole AdminRole? ban UserBan? issuedInvites InviteCode[] @relation("IssuedBy") + + @@index([createdAt(sort: Desc)]) } model InviteCode { @@ -27,7 +29,6 @@ model InviteCode { createdAt DateTime @default(now()) @@index([issuedById]) - @@index([code]) } model Species { @@ -125,6 +126,9 @@ model Pet { adReposts AdRepost[] @relation("AdReposts") @@index([ownerId]) + @@index([speciesId]) + @@index([breedId]) + @@index([createdAt(sort: Desc)]) } // ─── Phase 6 Enums ─────────────────────────────────────────────────────────── @@ -189,6 +193,14 @@ model Post { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Denormalized engagement counters — kept in sync transactionally at each + // reaction/comment/repost create+delete site (reactions.ts, comments.ts, + // reposts.ts, admin.ts deleteComment). Avoids live _count aggregation on + // feed/explore reads. Backfilled from existing rows in the same migration. + reactionCount Int @default(0) + commentCount Int @default(0) + repostCount Int @default(0) + // Phase 3 back-relations reactions Reaction[] @relation("PostReactions") comments Comment[] @relation("PostComments") @@ -333,8 +345,6 @@ model Hashtag { id String @id @default(cuid()) tag String @unique posts PostHashtag[] - - @@index([tag]) } model PostHashtag { @@ -433,7 +443,7 @@ model Vaccine { notes String? @db.VarChar(200) createdAt DateTime @default(now()) - @@index([petId]) + @@index([petId, dateGiven(sort: Desc)]) } model EmergencyVet { @@ -481,6 +491,8 @@ enum NotificationType { MESSAGE BIRTHDAY ADOPTION_DAY + MENTION + PAW_BACK } // ─── Phase 4 Models ────────────────────────────────────────────────────────── @@ -526,8 +538,6 @@ model UserBan { bannedAt DateTime @default(now()) bannedBy String // ownerId of moderator who issued the ban expiresAt DateTime? // null = permanent ban - - @@index([ownerId]) } model Advertisement { @@ -544,6 +554,11 @@ model Advertisement { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Denormalized engagement counters — see Post model comment for rationale. + adReactionCount Int @default(0) + adCommentCount Int @default(0) + adRepostCount Int @default(0) + reactions AdReaction[] comments AdComment[] reposts AdRepost[] diff --git a/src/__tests__/admin.test.ts b/src/__tests__/admin.test.ts new file mode 100644 index 0000000..f261bb4 --- /dev/null +++ b/src/__tests__/admin.test.ts @@ -0,0 +1,97 @@ +/** + * Admin Router Unit Tests — deleteComment (Cluster A counter fix) + * + * admin.ts had zero test coverage before this change. Scope is limited to + * deleteComment, which previously deleted a Comment without ever decrementing + * the parent Post's commentCount — a real gap fixed alongside the Post + * denormalized-counter rollout (see comments.ts / reactions.ts for the same + * pattern on the user-facing mutation paths). + * + * Tests: + * - deleteComment decrements Post.commentCount and writes a moderation log + * - deleteComment throws FORBIDDEN for a non-admin caller + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { TRPCError } from "@trpc/server"; + +vi.mock("server-only", () => ({})); +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "admin-1" }), +})); +vi.mock("@/lib/supabase-storage", () => ({ + createSignedUploadUrl: vi.fn(), + ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"], + MAX_FILE_SIZE: 10 * 1024 * 1024, +})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +async function createAdminCaller(overrides?: { + userId?: string | null; + prisma?: ReturnType; +}) { + const { adminRouter } = await import("@/trpc/routers/admin"); + const { initTRPC } = await import("@trpc/server"); + + const mockPrisma = overrides?.prisma ?? createMockPrisma(); + const userId = overrides?.userId !== undefined ? overrides.userId : "admin-1"; + // deleteComment uses the interactive-transaction callback form; other sites + // in this codebase use the array form — support both shapes here. + mockPrisma.$transaction.mockImplementation((arg: unknown) => + typeof arg === "function" + ? (arg as (tx: typeof mockPrisma) => unknown)(mockPrisma) + : Promise.all(arg as unknown[]) + ); + + const t = initTRPC + .context<{ userId: string | null; prisma: typeof mockPrisma }>() + .create(); + + const testRouter = t.router({ admin: adminRouter }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any }); + return { caller, mockPrisma }; +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("admin.deleteComment", () => { + it("decrements Post.commentCount and writes a moderation log", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "admin-1"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.comment.delete.mockResolvedValue({ id: "cmt-1", postId: "post-1" }); + mockPrisma.post.update.mockResolvedValue({ id: "post-1", commentCount: 4 }); + mockPrisma.moderationLog.create.mockResolvedValue({ id: "log-1" }); + + const result = await caller.admin.deleteComment({ commentId: "cmt-1", reason: "spam" }); + + expect(mockPrisma.comment.delete).toHaveBeenCalledWith({ where: { id: "cmt-1" } }); + expect(mockPrisma.post.update).toHaveBeenCalledWith({ + where: { id: "post-1" }, + data: { commentCount: { decrement: 1 } }, + }); + expect(mockPrisma.moderationLog.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ action: "DELETE_COMMENT", targetId: "cmt-1" }), + }) + ); + expect(result).toEqual({ success: true }); + }); + + it("throws FORBIDDEN for a non-admin caller", async () => { + vi.stubEnv("ADMIN_OWNER_ID", "someone-else"); + const { caller, mockPrisma } = await createAdminCaller(); + + mockPrisma.adminRole.findUnique.mockResolvedValue(null); + + await expect( + caller.admin.deleteComment({ commentId: "cmt-1" }) + ).rejects.toThrow(TRPCError); + expect(mockPrisma.comment.delete).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/ads.test.ts b/src/__tests__/ads.test.ts new file mode 100644 index 0000000..909ba93 --- /dev/null +++ b/src/__tests__/ads.test.ts @@ -0,0 +1,176 @@ +/** + * Ads Router Unit Tests — Cluster A counter rollout + * + * ads.ts had zero test coverage before this change. It previously ran 3 + * separate count() queries per ad view (getFeedData) and re-counted via + * count() after every single reaction/repost toggle. This now reads/writes + * denormalized Advertisement.adReactionCount/adCommentCount/adRepostCount + * columns instead. + * + * Tests: + * - getFeedData reads counts from the Advertisement row, not count() + * - toggleReaction increments on create, decrements on remove + * - addComment increments adCommentCount + * - toggleRepost increments on create, decrements on remove + */ + +import { describe, it, expect, vi } from "vitest"; + +vi.mock("server-only", () => ({})); +vi.mock("@clerk/nextjs/server", () => ({ + auth: vi.fn().mockResolvedValue({ userId: "owner-1" }), +})); +vi.mock("@/lib/prisma", () => ({ prisma: {} })); + +import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; + +async function createAdsCaller(overrides?: { + userId?: string | null; + prisma?: ReturnType; +}) { + const { adsRouter } = await import("@/trpc/routers/ads"); + const { initTRPC } = await import("@trpc/server"); + + const mockPrisma = overrides?.prisma ?? createMockPrisma(); + const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1"; + mockPrisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops)); + + const t = initTRPC + .context<{ userId: string | null; prisma: typeof mockPrisma }>() + .create(); + + const testRouter = t.router({ ads: adsRouter }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const caller = testRouter.createCaller({ userId, prisma: mockPrisma as any }); + return { caller, mockPrisma }; +} + +describe("ads.getFeedData", () => { + it("reads counts from the Advertisement row instead of count()", async () => { + const { caller, mockPrisma } = await createAdsCaller(); + + mockPrisma.advertisement.findUnique.mockResolvedValue({ + adReactionCount: 12, + adCommentCount: 3, + adRepostCount: 1, + }); + mockPrisma.adReaction.findUnique.mockResolvedValue({ id: "adrxn-1" }); + mockPrisma.adRepost.findUnique.mockResolvedValue(null); + + const result = await caller.ads.getFeedData({ adId: "ad-1", petId: "pet-1" }); + + expect(mockPrisma.advertisement.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "ad-1" } }) + ); + expect(result).toEqual({ + reactionCount: 12, + hasReacted: true, + commentCount: 3, + repostCount: 1, + hasReposted: false, + }); + }); +}); + +describe("ads.toggleReaction", () => { + it("creates a reaction and increments adReactionCount", async () => { + const { caller, mockPrisma } = await createAdsCaller(); + + mockPrisma.adReaction.findUnique.mockResolvedValue(null); + mockPrisma.adReaction.create.mockResolvedValue({ id: "adrxn-1" }); + mockPrisma.advertisement.update.mockResolvedValue({ adReactionCount: 5 }); + + const result = await caller.ads.toggleReaction({ adId: "ad-1", petId: "pet-1" }); + + expect(mockPrisma.adReaction.create).toHaveBeenCalledWith( + expect.objectContaining({ data: { adId: "ad-1", petId: "pet-1" } }) + ); + expect(mockPrisma.advertisement.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "ad-1" }, + data: { adReactionCount: { increment: 1 } }, + }) + ); + expect(result).toEqual({ reacted: true, count: 5 }); + }); + + it("removes an existing reaction and decrements adReactionCount", async () => { + const { caller, mockPrisma } = await createAdsCaller(); + + mockPrisma.adReaction.findUnique.mockResolvedValue({ id: "adrxn-1" }); + mockPrisma.adReaction.delete.mockResolvedValue({ id: "adrxn-1" }); + mockPrisma.advertisement.update.mockResolvedValue({ adReactionCount: 4 }); + + const result = await caller.ads.toggleReaction({ adId: "ad-1", petId: "pet-1" }); + + expect(mockPrisma.adReaction.delete).toHaveBeenCalledWith({ where: { id: "adrxn-1" } }); + expect(mockPrisma.advertisement.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "ad-1" }, + data: { adReactionCount: { decrement: 1 } }, + }) + ); + expect(result).toEqual({ reacted: false, count: 4 }); + }); +}); + +describe("ads.addComment", () => { + it("creates a comment and increments adCommentCount", async () => { + const { caller, mockPrisma } = await createAdsCaller(); + + mockPrisma.adComment.create.mockResolvedValue({ + id: "adcmt-1", + adId: "ad-1", + petId: "pet-1", + body: "Nice!", + pet: { id: "pet-1", name: "Buddy", avatarKey: null }, + }); + mockPrisma.advertisement.update.mockResolvedValue({ adCommentCount: 4 }); + + const result = await caller.ads.addComment({ adId: "ad-1", petId: "pet-1", body: "Nice!" }); + + expect(mockPrisma.advertisement.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "ad-1" }, + data: { adCommentCount: { increment: 1 } }, + }) + ); + expect(result).toMatchObject({ body: "Nice!" }); + }); +}); + +describe("ads.toggleRepost", () => { + it("creates a repost and increments adRepostCount", async () => { + const { caller, mockPrisma } = await createAdsCaller(); + + mockPrisma.adRepost.findUnique.mockResolvedValue(null); + mockPrisma.adRepost.create.mockResolvedValue({ id: "adrep-1" }); + mockPrisma.advertisement.update.mockResolvedValue({ adRepostCount: 2 }); + + const result = await caller.ads.toggleRepost({ adId: "ad-1", petId: "pet-1" }); + + expect(mockPrisma.adRepost.create).toHaveBeenCalledWith( + expect.objectContaining({ data: { adId: "ad-1", petId: "pet-1" } }) + ); + expect(mockPrisma.advertisement.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "ad-1" }, + data: { adRepostCount: { increment: 1 } }, + }) + ); + expect(result).toEqual({ reposted: true, count: 2 }); + }); + + it("removes an existing repost and decrements adRepostCount", async () => { + const { caller, mockPrisma } = await createAdsCaller(); + + mockPrisma.adRepost.findUnique.mockResolvedValue({ id: "adrep-1" }); + mockPrisma.adRepost.delete.mockResolvedValue({ id: "adrep-1" }); + mockPrisma.advertisement.update.mockResolvedValue({ adRepostCount: 1 }); + + const result = await caller.ads.toggleRepost({ adId: "ad-1", petId: "pet-1" }); + + expect(mockPrisma.adRepost.delete).toHaveBeenCalledWith({ where: { id: "adrep-1" } }); + expect(result).toEqual({ reposted: false, count: 1 }); + }); +}); diff --git a/src/__tests__/blocks.test.ts b/src/__tests__/blocks.test.ts index e14ab18..2116f43 100644 --- a/src/__tests__/blocks.test.ts +++ b/src/__tests__/blocks.test.ts @@ -143,11 +143,12 @@ describe("blocks.list", () => { expect(mockPrisma.block.findMany).toHaveBeenCalledWith( expect.objectContaining({ where: { blockerPetId: "pet-1" }, - select: { blockedPetId: true }, + select: { blockedPetId: true, createdAt: true }, }) ); - expect(result).toHaveLength(2); - expect(result[0].blockedPetId).toBe("pet-2"); + expect(result.items).toHaveLength(2); + expect(result.items[0].blockedPetId).toBe("pet-2"); + expect(result.nextCursor).toBeNull(); }); it("SAFE-01 — list ownership assertion: cannot list blocks for a pet you don't own", async () => { diff --git a/src/__tests__/comments.test.ts b/src/__tests__/comments.test.ts index ec59448..9039dcb 100644 --- a/src/__tests__/comments.test.ts +++ b/src/__tests__/comments.test.ts @@ -39,6 +39,9 @@ async function createCommentsCaller(overrides?: { const mockPrisma = overrides?.prisma ?? createMockPrisma(); const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1"; + // $transaction receives an array of already-invoked mock calls (promises or + // plain values) — Promise.all mirrors that shape closely enough for tests. + mockPrisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops)); const t = initTRPC .context<{ userId: string | null; prisma: typeof mockPrisma }>() @@ -67,6 +70,7 @@ describe("comments.create", () => { updatedAt: new Date(), pet: { id: "pet-1", name: "Buddy", avatarKey: null }, }); + mockPrisma.post.update.mockResolvedValue({ petId: "pet-1" }); const result = await caller.comments.create({ postId: "post-1", @@ -75,6 +79,12 @@ describe("comments.create", () => { }); expect(mockPrisma.comment.create).toHaveBeenCalled(); + expect(mockPrisma.post.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "post-1" }, + data: { commentCount: { increment: 1 } }, + }) + ); expect(result).toMatchObject({ body: "Cute pup!", petId: "pet-1" }); }); }); @@ -95,6 +105,7 @@ describe("comments.delete", () => { updatedAt: new Date(), }); mockPrisma.comment.delete.mockResolvedValue({ id: "cmt-1" }); + mockPrisma.post.update.mockResolvedValue({ id: "post-1" }); await expect( caller.comments.delete({ commentId: "cmt-1", petId: "pet-1" }) @@ -103,6 +114,12 @@ describe("comments.delete", () => { expect(mockPrisma.comment.delete).toHaveBeenCalledWith( expect.objectContaining({ where: { id: "cmt-1" } }) ); + expect(mockPrisma.post.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "post-1" }, + data: { commentCount: { decrement: 1 } }, + }) + ); }); it("ENGM-03 — delete other's comment throws FORBIDDEN", async () => { diff --git a/src/__tests__/follows.test.ts b/src/__tests__/follows.test.ts index f697009..3b2b2a6 100644 --- a/src/__tests__/follows.test.ts +++ b/src/__tests__/follows.test.ts @@ -262,8 +262,9 @@ describe("follows.listFollowers", () => { where: { followeePetId: "pet-1" }, }) ); - expect(result).toHaveLength(1); - expect(result[0].follower.name).toBe("Max"); + expect(result.items).toHaveLength(1); + expect(result.items[0].follower.name).toBe("Max"); + expect(result.nextCursor).toBeNull(); }); }); @@ -290,7 +291,8 @@ describe("follows.listFollowing", () => { where: { followerPetId: "pet-1" }, }) ); - expect(result).toHaveLength(1); - expect(result[0].followee.name).toBe("Luna"); + expect(result.items).toHaveLength(1); + expect(result.items[0].followee.name).toBe("Luna"); + expect(result.nextCursor).toBeNull(); }); }); diff --git a/src/__tests__/helpers/prisma-mock.ts b/src/__tests__/helpers/prisma-mock.ts index 1d98652..f069775 100644 --- a/src/__tests__/helpers/prisma-mock.ts +++ b/src/__tests__/helpers/prisma-mock.ts @@ -146,6 +146,47 @@ interface MockPostHashtagClient { findMany: MockFn; } +// ─── Ad-engagement mock interfaces ──────────────────────────────────────────── + +interface MockAdvertisementClient { + findUnique: MockFn; + findMany: MockFn; + update: MockFn; +} + +interface MockAdReactionClient { + findUnique: MockFn; + create: MockFn; + delete: MockFn; +} + +interface MockAdCommentClient { + create: MockFn; + findMany: MockFn; +} + +interface MockAdRepostClient { + findUnique: MockFn; + create: MockFn; + delete: MockFn; +} + +interface MockModerationLogClient { + create: MockFn; + findMany: MockFn; +} + +interface MockAdminRoleClient { + findUnique: MockFn; + findMany: MockFn; + upsert: MockFn; + delete: MockFn; +} + +interface MockNotificationClient { + create: MockFn; +} + interface MockPrismaClient { pet: MockPetClient; owner: MockOwnerClient; @@ -166,6 +207,13 @@ interface MockPrismaClient { repost: MockRepostClient; hashtag: MockHashtagClient; postHashtag: MockPostHashtagClient; + advertisement: MockAdvertisementClient; + adReaction: MockAdReactionClient; + adComment: MockAdCommentClient; + adRepost: MockAdRepostClient; + moderationLog: MockModerationLogClient; + adminRole: MockAdminRoleClient; + notification: MockNotificationClient; $transaction: MockFn; $disconnect: MockFn; } @@ -285,6 +333,41 @@ export function createMockPrisma(): MockPrismaClient { upsert: vi.fn(), findMany: vi.fn(), }, + advertisement: { + findUnique: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + }, + adReaction: { + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }, + adComment: { + create: vi.fn(), + findMany: vi.fn(), + }, + adRepost: { + findUnique: vi.fn(), + create: vi.fn(), + delete: vi.fn(), + }, + moderationLog: { + create: vi.fn(), + findMany: vi.fn(), + }, + adminRole: { + findUnique: vi.fn(), + findMany: vi.fn(), + upsert: vi.fn(), + delete: vi.fn(), + }, + // Fire-and-forget in production code (`.catch(() => {})`) — default to a + // resolved promise so tests that don't care about notifications don't + // need to configure this every time. + notification: { + create: vi.fn().mockResolvedValue({}), + }, $transaction: vi.fn(), $disconnect: vi.fn(), }; diff --git a/src/__tests__/media-upload.test.ts b/src/__tests__/media-upload.test.ts index 62adb95..38a7e1d 100644 --- a/src/__tests__/media-upload.test.ts +++ b/src/__tests__/media-upload.test.ts @@ -13,7 +13,7 @@ * Security (T-4-01, T-4-02, T-4-03, T-4-05, T-4-06): * - Ownership assertions tested (T-4-01) * - ContentType restriction tested (T-4-02) - * - No real R2 calls — getPresignedUploadUrl is mocked (T-4-03) + * - No real Supabase Storage calls — createSignedUploadUrl is mocked (T-4-03) * - key.startsWith('avatars/') validation tested (T-4-05) * - fileSize <= 10MB validation tested (T-4-06) * @@ -28,11 +28,14 @@ import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; // Mock server-only — the import guard throws in non-server environments vi.mock("server-only", () => ({})); -// Mock getPresignedUploadUrl so no real R2 calls are made (T-4-03) -vi.mock("@/lib/r2", () => ({ - getPresignedUploadUrl: vi +// Mock createSignedUploadUrl so no real Supabase Storage calls are made (T-4-03) +vi.mock("@/lib/supabase-storage", () => ({ + createSignedUploadUrl: vi .fn() - .mockResolvedValue("https://fake-r2.example.com/presigned-url"), + .mockResolvedValue({ signedUrl: "https://fake-supabase.example.com/presigned-url", path: "fake-path" }), + getPublicUrl: vi.fn((key: string) => `https://fake-supabase.example.com/public/${key}`), + ALLOWED_CONTENT_TYPES: ["image/jpeg", "image/png", "image/webp"], + MAX_FILE_SIZE: 10 * 1024 * 1024, })); // Mock Clerk server auth @@ -45,7 +48,7 @@ vi.mock("@/lib/prisma", () => ({ prisma: {}, })); -import { getPresignedUploadUrl } from "@/lib/r2"; +import { createSignedUploadUrl } from "@/lib/supabase-storage"; const OWNER_ID = "owner-1"; const ATTACKER_ID = "attacker-999"; @@ -102,16 +105,15 @@ describe("media.getPresignedUrl", () => { fileSize: 1024, }); - // Assert getPresignedUploadUrl was called with correct arguments - expect(getPresignedUploadUrl).toHaveBeenCalledOnce(); - const [calledKey, calledContentType] = ( - getPresignedUploadUrl as ReturnType + // Assert createSignedUploadUrl was called with the correct scoped key + expect(createSignedUploadUrl).toHaveBeenCalledOnce(); + const [calledKey] = ( + createSignedUploadUrl as ReturnType ).mock.calls[0]; expect(calledKey).toMatch(new RegExp(`^avatars/${OWNER_ID}/${PET_ID}/`)); - expect(calledContentType).toBe("image/jpeg"); // Assert returned URL matches mock - expect(result.presignedUrl).toBe("https://fake-r2.example.com/presigned-url"); + expect(result.presignedUrl).toBe("https://fake-supabase.example.com/presigned-url"); // Assert key is scoped under avatars/ expect(result.key).toMatch(/^avatars\//); @@ -134,7 +136,7 @@ describe("media.getPresignedUrl", () => { }); // No presigned URL should be generated for non-owner - expect(getPresignedUploadUrl).not.toHaveBeenCalled(); + expect(createSignedUploadUrl).not.toHaveBeenCalled(); }); it("Test 3 — rejects unsupported contentType image/gif (Zod BAD_REQUEST, T-4-02)", async () => { @@ -149,7 +151,7 @@ describe("media.getPresignedUrl", () => { ).rejects.toThrow(TRPCError); // No DB lookup or presigned URL should be generated for invalid type - expect(getPresignedUploadUrl).not.toHaveBeenCalled(); + expect(createSignedUploadUrl).not.toHaveBeenCalled(); }); it("Test 4 — rejects oversized file (11MB > 10MB limit, T-4-06)", async () => { @@ -163,7 +165,7 @@ describe("media.getPresignedUrl", () => { }) ).rejects.toThrow(TRPCError); - expect(getPresignedUploadUrl).not.toHaveBeenCalled(); + expect(createSignedUploadUrl).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/pet-profile.test.ts b/src/__tests__/pet-profile.test.ts index 6784080..d39f659 100644 --- a/src/__tests__/pet-profile.test.ts +++ b/src/__tests__/pet-profile.test.ts @@ -17,6 +17,8 @@ import { TRPCError } from "@trpc/server"; import { petsRouter } from "@/trpc/routers/pets"; import { createMockPrisma } from "@/__tests__/helpers/prisma-mock"; +vi.mock("server-only", () => ({})); + // Mock Clerk server auth — pets router imports from @clerk/nextjs/server via trpc/init vi.mock("@clerk/nextjs/server", () => ({ auth: vi.fn().mockResolvedValue({ userId: "test-owner-1" }), diff --git a/src/__tests__/reactions.test.ts b/src/__tests__/reactions.test.ts index 5fb64c1..cc7fb50 100644 --- a/src/__tests__/reactions.test.ts +++ b/src/__tests__/reactions.test.ts @@ -40,6 +40,9 @@ async function createReactionsCaller(overrides?: { const mockPrisma = overrides?.prisma ?? createMockPrisma(); const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1"; + // $transaction receives an array of already-invoked mock calls (promises or + // plain values) — Promise.all mirrors that shape closely enough for tests. + mockPrisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops)); const t = initTRPC .context<{ userId: string | null; prisma: typeof mockPrisma }>() @@ -65,12 +68,19 @@ describe("reactions.toggle", () => { petId: "pet-1", createdAt: new Date(), }); + mockPrisma.post.update.mockResolvedValue({ petId: "pet-1" }); const result = await caller.reactions.toggle({ postId: "post-1", petId: "pet-1" }); expect(mockPrisma.reaction.create).toHaveBeenCalledWith( expect.objectContaining({ data: { postId: "post-1", petId: "pet-1" } }) ); + expect(mockPrisma.post.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "post-1" }, + data: { reactionCount: { increment: 1 } }, + }) + ); expect(result).toMatchObject({ reacted: true }); }); @@ -85,10 +95,17 @@ describe("reactions.toggle", () => { createdAt: new Date(), }); mockPrisma.reaction.delete.mockResolvedValue({ id: "rxn-1" }); + mockPrisma.post.update.mockResolvedValue({ id: "post-1" }); const result = await caller.reactions.toggle({ postId: "post-1", petId: "pet-1" }); expect(mockPrisma.reaction.delete).toHaveBeenCalled(); + expect(mockPrisma.post.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "post-1" }, + data: { reactionCount: { decrement: 1 } }, + }) + ); expect(result).toMatchObject({ reacted: false }); }); diff --git a/src/__tests__/reposts.test.ts b/src/__tests__/reposts.test.ts index d12eda1..78bd9eb 100644 --- a/src/__tests__/reposts.test.ts +++ b/src/__tests__/reposts.test.ts @@ -41,6 +41,9 @@ async function createRepostsCaller(overrides?: { const mockPrisma = overrides?.prisma ?? createMockPrisma(); const userId = overrides?.userId !== undefined ? overrides.userId : "owner-1"; + // $transaction receives an array of already-invoked mock calls (promises or + // plain values) — Promise.all mirrors that shape closely enough for tests. + mockPrisma.$transaction.mockImplementation((ops: unknown[]) => Promise.all(ops)); const t = initTRPC .context<{ userId: string | null; prisma: typeof mockPrisma }>() @@ -69,6 +72,8 @@ describe("reposts.create", () => { createdAt, }); + mockPrisma.post.update.mockResolvedValue({ id: "orig-1", repostCount: 1 }); + const result = await caller.reposts.create({ originalPostId: "orig-1", reposterPetId: "pet-1", @@ -79,6 +84,12 @@ describe("reposts.create", () => { data: expect.objectContaining({ type: "REPOST", petId: "pet-1" }), }) ); + expect(mockPrisma.post.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "orig-1" }, + data: { repostCount: { increment: 1 } }, + }) + ); expect(mockFanOutPost).toHaveBeenCalled(); expect(result).toMatchObject({ postId: "repost-1" }); }); @@ -115,3 +126,33 @@ describe("reposts.create", () => { expect(result).toMatchObject({ postId: "existing-repost-1" }); }); }); + +// ─── reposts.delete ────────────────────────────────────────────────────────── + +describe("reposts.delete", () => { + it("ENGM-04 — delete removes the repost Post and decrements the original post's repostCount", async () => { + const { caller, mockPrisma } = await createRepostsCaller(); + + mockPrisma.pet.findUnique.mockResolvedValue({ id: "pet-1", ownerId: "owner-1", name: "Buddy" }); + mockPrisma.repost.findUnique.mockResolvedValue({ + postId: "repost-1", + originalPostId: "orig-1", + reposterPetId: "pet-1", + createdAt: new Date(), + }); + mockPrisma.post.delete.mockResolvedValue({ id: "repost-1" }); + mockPrisma.post.update.mockResolvedValue({ id: "orig-1", repostCount: 0 }); + + await expect( + caller.reposts.delete({ originalPostId: "orig-1", reposterPetId: "pet-1" }) + ).resolves.not.toThrow(); + + expect(mockPrisma.post.delete).toHaveBeenCalledWith({ where: { id: "repost-1" } }); + expect(mockPrisma.post.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: "orig-1" }, + data: { repostCount: { decrement: 1 } }, + }) + ); + }); +}); diff --git a/src/app/(app)/explore/page.tsx b/src/app/(app)/explore/page.tsx index 0872bf3..75300ef 100644 --- a/src/app/(app)/explore/page.tsx +++ b/src/app/(app)/explore/page.tsx @@ -204,7 +204,8 @@ type TrendingPost = { pet: { id: string; name: string; avatarKey: string | null }; images: { storageKey: string; position: number }[]; milestone: { milestoneType: string; notes: string | null } | null; - _count: { reactions: number; comments: number }; + reactionCount: number; + commentCount: number; }; type TrendingHashtag = { @@ -310,7 +311,7 @@ function TrendingContent({
🐾 - {post._count.reactions} + {post.reactionCount}
{isCarousel && ( diff --git a/src/app/(app)/feed/page.tsx b/src/app/(app)/feed/page.tsx index 742592b..56b1551 100644 --- a/src/app/(app)/feed/page.tsx +++ b/src/app/(app)/feed/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { FeedList } from "@/components/feed/FeedList"; import { StoryTray } from "@/components/stories/StoryTray"; +import { FeedHeader } from "@/components/feed/FeedHeader"; +import { FeedPullToRefresh } from "@/components/feed/FeedPullToRefresh"; export const metadata: Metadata = { title: "Home — PawFeed", @@ -8,11 +10,16 @@ export const metadata: Metadata = { export default function FeedPage() { return ( -
- -
- + <> + +
+ + +
+ +
+
-
+ ); } diff --git a/src/app/(app)/invite/page.tsx b/src/app/(app)/invite/page.tsx index 7ee0aa7..580b702 100644 --- a/src/app/(app)/invite/page.tsx +++ b/src/app/(app)/invite/page.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { useState } from "react"; -import { Check, Copy, Loader2, UserCheck, Clock } from "lucide-react"; +import { Check, Copy, Loader2, UserCheck, Clock, Share2 } from "lucide-react"; import { toast } from "sonner"; export default function InvitePage() { @@ -14,15 +14,32 @@ export default function InvitePage() { const available = codes.filter((c) => !c.usedById && !c.revoked); const used = codes.filter((c) => c.usedById || c.revoked); + const canShare = typeof navigator !== "undefined" && typeof navigator.share === "function"; + + function inviteUrl(code: string) { + return `${window.location.origin}/join?code=${code}`; + } + function copyLink(code: string, id: string) { - const url = `${window.location.origin}/join?code=${code}`; - void navigator.clipboard.writeText(url).then(() => { + void navigator.clipboard.writeText(inviteUrl(code)).then(() => { setCopiedId(id); toast.success("Link kopiert!"); setTimeout(() => setCopiedId(null), 2000); }); } + async function shareLink(code: string) { + try { + await navigator.share({ + title: "Du bist zu PawFeed eingeladen!", + text: "Erstelle ein Profil für dein Tier auf PawFeed – der sozialen Plattform für Haustiere 🐾", + url: inviteUrl(code), + }); + } catch { + // User cancelled share sheet — no action needed + } + } + if (isLoading) { return (
@@ -81,17 +98,28 @@ export default function InvitePage() { : ""} ) : ( - )} - {copiedId === code.id ? "Kopiert" : "Link kopieren"} - + +
)}
); diff --git a/src/app/(app)/messages/page.tsx b/src/app/(app)/messages/page.tsx index a9811ac..33e47e8 100644 --- a/src/app/(app)/messages/page.tsx +++ b/src/app/(app)/messages/page.tsx @@ -9,23 +9,12 @@ import { useActivePet } from "@/context/ActivePetContext"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { getAvatarUrl } from "@/lib/avatar-url"; - -type TFunc = (key: string, opts?: Record) => string; - -function formatRelativeTime(date: Date | string, t: TFunc): string { - const diffMs = Date.now() - new Date(date).getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffMins < 1) return t("timeNow"); - if (diffMins < 60) return t("relativeMinutes", { count: diffMins }); - if (diffHours < 24) return t("relativeHours", { count: diffHours }); - if (diffDays < 7) return t("relativeDays", { count: diffDays }); - return new Date(date).toLocaleDateString(); -} +import { formatRelativeCompact } from "@/lib/format-relative-time"; export default function MessagesPage() { const t = useTranslations("Messages"); + const trt = useTranslations("RelativeTime"); + const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt); const trpc = useTRPC(); const router = useRouter(); const { activePetId } = useActivePet(); @@ -87,7 +76,7 @@ export default function MessagesPage() { {conv.otherPet.name}

- {formatRelativeTime(conv.updatedAt, t as TFunc)} + {formatRelativeTime(conv.updatedAt)}
diff --git a/src/app/(app)/notifications/page.tsx b/src/app/(app)/notifications/page.tsx index f9be8f6..94c3d57 100644 --- a/src/app/(app)/notifications/page.tsx +++ b/src/app/(app)/notifications/page.tsx @@ -3,28 +3,17 @@ import { useEffect } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Bell, UserPlus, Heart, MessageCircle, Cake, Home } from "lucide-react"; +import { Bell, UserPlus, Heart, MessageCircle, Cake, Home, AtSign, Repeat2 } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "@/trpc/client"; import { useActivePet } from "@/context/ActivePetContext"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { getAvatarUrl } from "@/lib/avatar-url"; import { getMediaUrl } from "@/lib/media-url"; +import { useTranslations } from "next-intl"; +import { formatRelativeCompact } from "@/lib/format-relative-time"; -function formatRelativeTime(date: Date | string): string { - const diffMs = Date.now() - new Date(date).getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return "just now"; - if (diffMins < 60) return `${diffMins}m`; - if (diffHours < 24) return `${diffHours}h`; - if (diffDays < 7) return `${diffDays}d`; - return new Date(date).toLocaleDateString(); -} - -type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY"; +type NotificationType = "FOLLOW" | "REACTION" | "COMMENT" | "MESSAGE" | "BIRTHDAY" | "ADOPTION_DAY" | "MENTION" | "PAW_BACK"; function notificationIcon(type: NotificationType) { switch (type) { @@ -34,6 +23,8 @@ function notificationIcon(type: NotificationType) { case "MESSAGE": return ; case "BIRTHDAY": return ; case "ADOPTION_DAY": return ; + case "MENTION": return ; + case "PAW_BACK": return ; } } @@ -42,6 +33,8 @@ export default function NotificationsPage() { const queryClient = useQueryClient(); const router = useRouter(); const { activePetId } = useActivePet(); + const trt = useTranslations("RelativeTime"); + const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt); const { data, isLoading } = useQuery({ ...trpc.notifications.list.queryOptions({ petId: activePetId ?? "", limit: 30 }), @@ -73,6 +66,8 @@ export default function NotificationsPage() { return notif.actorPet ? `/pets/${notif.actorPet.id}` : null; case "REACTION": case "COMMENT": + case "MENTION": + case "PAW_BACK": return notif.post?.petId ? `/pets/${notif.post.petId}` : null; case "MESSAGE": return null; @@ -181,6 +176,8 @@ export default function NotificationsPage() { )} {notif.type === "COMMENT" && !notif.comment && "commented on your post"} {notif.type === "MESSAGE" && "sent you a message"} + {notif.type === "MENTION" && "mentioned you in a comment"} + {notif.type === "PAW_BACK" && "shared your post (Paw-Back)"} {notif.type === "BIRTHDAY" && "🎂 Alles Gute zum Geburtstag! Erstelle einen Jubiläums-Post."} {notif.type === "ADOPTION_DAY" && "🏠 Heute ist der Adoptionstag! Feiere den Jahrestag."} diff --git a/src/app/(app)/pets/[petId]/followers/page.tsx b/src/app/(app)/pets/[petId]/followers/page.tsx index c26456b..3cc26dc 100644 --- a/src/app/(app)/pets/[petId]/followers/page.tsx +++ b/src/app/(app)/pets/[petId]/followers/page.tsx @@ -31,7 +31,7 @@ export default async function FollowersPage({ params }: PageProps) { if (!pet) notFound(); - const followers = await trpc.follows.listFollowers({ petId }); + const { items: followers } = await trpc.follows.listFollowers({ petId }); return (
diff --git a/src/app/(app)/pets/[petId]/following/page.tsx b/src/app/(app)/pets/[petId]/following/page.tsx index 78e9fa4..fd22c61 100644 --- a/src/app/(app)/pets/[petId]/following/page.tsx +++ b/src/app/(app)/pets/[petId]/following/page.tsx @@ -31,7 +31,7 @@ export default async function FollowingPage({ params }: PageProps) { if (!pet) notFound(); - const following = await trpc.follows.listFollowing({ petId }); + const { items: following } = await trpc.follows.listFollowing({ petId }); return (
diff --git a/src/app/(app)/pets/[petId]/page.tsx b/src/app/(app)/pets/[petId]/page.tsx index 74d1e48..4610468 100644 --- a/src/app/(app)/pets/[petId]/page.tsx +++ b/src/app/(app)/pets/[petId]/page.tsx @@ -50,7 +50,7 @@ export default async function PetProfilePage({ params }: PageProps) { "owner") : null; - const [followers, following] = await Promise.all([ + const [{ items: followers }, { items: following }] = await Promise.all([ trpc.follows.listFollowers({ petId }), trpc.follows.listFollowing({ petId }), ]); diff --git a/src/app/(app)/pets/page.tsx b/src/app/(app)/pets/page.tsx index 1f8c482..3bcfa35 100644 --- a/src/app/(app)/pets/page.tsx +++ b/src/app/(app)/pets/page.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { createTRPCCaller } from "@/trpc/server"; import { buttonVariants } from "@/components/ui/button"; import { PetGrid } from "./PetGrid"; +import { DeleteAccountDialog } from "@/components/account/DeleteAccountDialog"; /** * My Pets page — /pets @@ -31,6 +32,8 @@ export default async function PetsPage() { Datenschutz AGB + + p.name)} />
); diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts new file mode 100644 index 0000000..5801f0f --- /dev/null +++ b/src/app/api/account/delete/route.ts @@ -0,0 +1,68 @@ +import { auth, clerkClient } from "@clerk/nextjs/server"; +import { prisma } from "@/lib/prisma"; +import { deleteFiles } from "@/lib/supabase-storage"; + +export async function POST(req: Request) { + const { userId } = await auth(); + if (!userId) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = (await req.json()) as { petNameConfirmation?: string }; + const confirmation = body.petNameConfirmation?.trim(); + + if (!confirmation) { + return Response.json({ error: "Pet name required" }, { status: 400 }); + } + + const pets = await prisma.pet.findMany({ + where: { ownerId: userId }, + select: { + id: true, + name: true, + avatarKey: true, + posts: { + where: { type: "PHOTO" }, + select: { images: { select: { storageKey: true } } }, + }, + stories: { select: { storageKey: true } }, + }, + }); + + const validNames = pets.map((p) => p.name.toLowerCase()); + if (!validNames.includes(confirmation.toLowerCase())) { + return Response.json({ error: "Pet name does not match" }, { status: 400 }); + } + + // Collect all Supabase storage keys across avatars, post images, and stories + const storageKeys: string[] = []; + for (const pet of pets) { + if (pet.avatarKey) storageKeys.push(pet.avatarKey); + for (const post of pet.posts) { + for (const image of post.images) { + storageKeys.push(image.storageKey); + } + } + for (const story of pet.stories) { + storageKeys.push(story.storageKey); + } + } + + // Delete storage files — non-blocking on failure (logged inside deleteFiles) + await deleteFiles(storageKeys); + + // Delete all pets — cascades posts, comments, reactions, follows, notifications, etc. + await prisma.pet.deleteMany({ where: { ownerId: userId } }); + + // Delete invite codes this owner issued + await prisma.inviteCode.deleteMany({ where: { issuedById: userId } }); + + // Delete Owner record — cascades AdminRole + UserBan + await prisma.owner.delete({ where: { id: userId } }); + + // Delete Clerk user account — this invalidates the current session + const clerk = await clerkClient(); + await clerk.users.deleteUser(userId); + + return Response.json({ success: true }); +} diff --git a/src/app/api/cron/trim-feeds/route.ts b/src/app/api/cron/trim-feeds/route.ts new file mode 100644 index 0000000..742137d --- /dev/null +++ b/src/app/api/cron/trim-feeds/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { redis } from "@/lib/redis"; +import { trimFeed } from "@/lib/feed-helpers"; + +// Vercel cron sends `Authorization: Bearer `. +// Set CRON_SECRET in your Vercel environment variables. +function authorized(req: Request): boolean { + const secret = process.env.CRON_SECRET; + if (!secret) return false; + return req.headers.get("authorization") === `Bearer ${secret}`; +} + +/** + * Trims every pet's Redis feed sorted set down to the documented 90-day + * retention window (feed-helpers.ts FEED_RETENTION_MS). Without this cron, + * feed:{petId} sorted sets grow unbounded forever. + */ +export async function GET(req: Request) { + if (!authorized(req)) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const pets = await prisma.pet.findMany({ select: { id: true } }); + + for (const pet of pets) { + await trimFeed(redis, pet.id); + } + + return NextResponse.json({ ok: true, petsTrimmed: pets.length }); +} diff --git a/src/app/p/[secret]/invites/page.tsx b/src/app/p/[secret]/invites/page.tsx index 54864b1..3e4f326 100644 --- a/src/app/p/[secret]/invites/page.tsx +++ b/src/app/p/[secret]/invites/page.tsx @@ -38,6 +38,19 @@ export default function AdminInvitesPage() { }) ); + const backfill = useMutation( + trpc.admin.backfillInviteCodes.mutationOptions({ + onSuccess: (result) => { + invalidate(); + toast.success( + result.updated === 0 + ? "Alle User haben bereits Codes." + : `${result.updated} User haben jetzt ihre Codes erhalten.` + ); + }, + }) + ); + function copyLink(code: string, id: string) { const url = `${window.location.origin}/join?code=${code}`; void navigator.clipboard.writeText(url).then(() => { @@ -60,19 +73,35 @@ export default function AdminInvitesPage() { {available.length} verfügbar · {used.length} verwendet · {revoked.length} widerrufen

- +
+ + +
{isLoading ? ( diff --git a/src/app/p/[secret]/posts/page.tsx b/src/app/p/[secret]/posts/page.tsx index b8d4665..58b8dd9 100644 --- a/src/app/p/[secret]/posts/page.tsx +++ b/src/app/p/[secret]/posts/page.tsx @@ -104,10 +104,10 @@ export default function AdminPostsPage() { {post.caption ?? No caption}

- {post.pet.name} · {post._count.reactions} 🐾 · {post._count.comments} 💬 - {post._count.reports > 0 && ( + {post.pet.name} · {post.reactionCount} 🐾 · {post.commentCount} 💬 + {post.reportCount > 0 && ( - ⚑ {post._count.reports} report{post._count.reports !== 1 ? "s" : ""} + ⚑ {post.reportCount} report{post.reportCount !== 1 ? "s" : ""} )}

diff --git a/src/app/p/[secret]/reports/page.tsx b/src/app/p/[secret]/reports/page.tsx index 607a57d..6984c8d 100644 --- a/src/app/p/[secret]/reports/page.tsx +++ b/src/app/p/[secret]/reports/page.tsx @@ -202,11 +202,11 @@ export default function AdminReportsPage() {
- {report.post._count.reactions} + {report.post.reactionCount} - {report.post._count.comments} + {report.post.commentCount}
diff --git a/src/components/account/DeleteAccountDialog.tsx b/src/components/account/DeleteAccountDialog.tsx new file mode 100644 index 0000000..6589d4b --- /dev/null +++ b/src/components/account/DeleteAccountDialog.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useState } from "react"; +import { Trash2, TriangleAlert, Loader2, CheckCircle2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +type Step = "warning" | "confirm" | "loading" | "done"; + +type Props = { + petNames: string[]; +}; + +export function DeleteAccountDialog({ petNames }: Props) { + const [open, setOpen] = useState(false); + const [step, setStep] = useState("warning"); + const [input, setInput] = useState(""); + const [error, setError] = useState(null); + + const isValidName = petNames.some( + (n) => n.toLowerCase() === input.trim().toLowerCase() + ); + + const handleOpen = () => { + setStep("warning"); + setInput(""); + setError(null); + setOpen(true); + }; + + const handleClose = () => { + if (step === "loading") return; + setOpen(false); + }; + + const handleDelete = async () => { + if (!isValidName) return; + setStep("loading"); + setError(null); + + try { + const res = await fetch("/api/account/delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ petNameConfirmation: input.trim() }), + }); + + if (!res.ok) { + const data = (await res.json()) as { error?: string }; + throw new Error(data.error ?? "Unbekannter Fehler"); + } + + setStep("done"); + setTimeout(() => { + window.location.replace("https://pawfeed.org"); + }, 1500); + } catch (err) { + setError(err instanceof Error ? err.message : "Etwas ist schiefgelaufen"); + setStep("confirm"); + } + }; + + return ( + <> + + + + + {step === "warning" && ( + <> + + + + Konto wirklich löschen? + + + +

+ Diese Aktion ist unwiderruflich. Folgendes wird dauerhaft gelöscht: +

+ +
    + {[ + "Alle Tierprofile", + "Alle Posts, Kommentare und Reaktionen", + "Alle Bilder und Videos", + "Alle Chats und Nachrichten", + "Alle Gesundheitsdaten", + "Dein PawFeed-Konto", + ].map((item) => ( +
  • + ✕ + {item} +
  • + ))} +
+ +
+ + +
+ + )} + + {step === "confirm" && ( + <> + + + + Bestätigung erforderlich + + + +

+ Gib den Namen eines deiner Haustiere ein, um die Löschung zu bestätigen: +

+ +
+ {petNames.map((name) => ( + + ))} +
+ + { setInput(e.target.value); setError(null); }} + placeholder="Tiername eingeben…" + autoFocus + /> + + {error && ( +

{error}

+ )} + +
+ + +
+ + )} + + {step === "loading" && ( +
+ +

Konto wird gelöscht…

+
+ )} + + {step === "done" && ( +
+ +

Konto erfolgreich gelöscht.

+

Du wirst weitergeleitet…

+
+ )} +
+
+ + ); +} diff --git a/src/components/feed/AdCard.tsx b/src/components/feed/AdCard.tsx index cdce9ee..4b1b1cc 100644 --- a/src/components/feed/AdCard.tsx +++ b/src/components/feed/AdCard.tsx @@ -4,9 +4,11 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { ExternalLink, PawPrint, MessageCircle, Repeat2, Loader2 } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; +import { useTranslations } from "next-intl"; import { useTRPC } from "@/trpc/client"; import { getMediaUrl } from "@/lib/media-url"; import { getAvatarUrl } from "@/lib/avatar-url"; +import { formatRelativeCompact } from "@/lib/format-relative-time"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Textarea } from "@/components/ui/textarea"; import { Button } from "@/components/ui/button"; @@ -25,18 +27,6 @@ type AdCardAd = { imageKey: string | null; }; -function formatRelative(date: Date | string): string { - const diffMs = Date.now() - new Date(date).getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffMins < 1) return "gerade eben"; - if (diffMins < 60) return `${diffMins}m`; - if (diffHours < 24) return `${diffHours}h`; - if (diffDays < 7) return `${diffDays}d`; - return new Date(date).toLocaleDateString("de-DE"); -} - interface AdCardProps { ad: AdCardAd; activePetId: string; @@ -45,6 +35,8 @@ interface AdCardProps { export function AdCard({ ad, activePetId }: AdCardProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); + const trt = useTranslations("RelativeTime"); + const formatRelative = (date: Date | string) => formatRelativeCompact(date, trt); const imageUrl = getMediaUrl(ad.imageKey); // ── Interaction data ──────────────────────────────────────────────────────── @@ -274,6 +266,8 @@ interface AdCommentSheetProps { function AdCommentSheet({ adId, petId, open, onOpenChange }: AdCommentSheetProps) { const trpc = useTRPC(); const queryClient = useQueryClient(); + const trt = useTranslations("RelativeTime"); + const formatRelative = (date: Date | string) => formatRelativeCompact(date, trt); const [body, setBody] = useState(""); const textareaRef = useRef(null); diff --git a/src/components/feed/FeedHeader.tsx b/src/components/feed/FeedHeader.tsx new file mode 100644 index 0000000..d7e595d --- /dev/null +++ b/src/components/feed/FeedHeader.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { PawPrint } from "lucide-react"; + +export function FeedHeader() { + const [visible, setVisible] = useState(true); + + useEffect(() => { + const mainEl = document.querySelector("main"); + if (!mainEl) return; + + const handleScroll = () => { + setVisible(mainEl.scrollTop < 60); + }; + + mainEl.addEventListener("scroll", handleScroll, { passive: true }); + return () => mainEl.removeEventListener("scroll", handleScroll); + }, []); + + return ( +
+
+
+
+ ); +} diff --git a/src/components/feed/FeedPullToRefresh.tsx b/src/components/feed/FeedPullToRefresh.tsx new file mode 100644 index 0000000..f9a40b2 --- /dev/null +++ b/src/components/feed/FeedPullToRefresh.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { useState, useEffect, useRef, useCallback } from "react"; +import { Loader2 } from "lucide-react"; +import { useQueryClient } from "@tanstack/react-query"; + +const PULL_THRESHOLD = 70; + +export function FeedPullToRefresh({ children }: { children: React.ReactNode }) { + const queryClient = useQueryClient(); + const [pullY, setPullY] = useState(0); + const [refreshing, setRefreshing] = useState(false); + const startYRef = useRef(0); + const isPullingRef = useRef(false); + const pullYRef = useRef(0); + const refreshingRef = useRef(false); + + const triggerRefresh = useCallback(async () => { + refreshingRef.current = true; + setRefreshing(true); + setPullY(0); + pullYRef.current = 0; + try { + await queryClient.invalidateQueries({ queryKey: [["feed", "getFeed"]] }); + } finally { + // Keep spinner visible briefly so fast refreshes still feel intentional + await new Promise((res) => setTimeout(res, 600)); + refreshingRef.current = false; + setRefreshing(false); + } + }, [queryClient]); + + useEffect(() => { + const mainEl = document.querySelector("main"); + if (!mainEl) return; + + const onTouchStart = (e: TouchEvent) => { + if (mainEl.scrollTop === 0 && !refreshingRef.current) { + startYRef.current = e.touches[0]!.clientY; + isPullingRef.current = true; + } + }; + + const onTouchMove = (e: TouchEvent) => { + if (!isPullingRef.current) return; + const deltaY = e.touches[0]!.clientY - startYRef.current; + if (deltaY > 0 && mainEl.scrollTop === 0) { + const dampened = Math.min(deltaY * 0.45, PULL_THRESHOLD + 24); + pullYRef.current = dampened; + setPullY(dampened); + } + }; + + const onTouchEnd = () => { + if (!isPullingRef.current) return; + isPullingRef.current = false; + if (pullYRef.current >= PULL_THRESHOLD) { + void triggerRefresh(); + } else { + pullYRef.current = 0; + setPullY(0); + } + }; + + mainEl.addEventListener("touchstart", onTouchStart, { passive: true }); + mainEl.addEventListener("touchmove", onTouchMove, { passive: true }); + mainEl.addEventListener("touchend", onTouchEnd, { passive: true }); + + return () => { + mainEl.removeEventListener("touchstart", onTouchStart); + mainEl.removeEventListener("touchmove", onTouchMove); + mainEl.removeEventListener("touchend", onTouchEnd); + }; + }, [triggerRefresh]); + + const showIndicator = pullY > 8 || refreshing; + const progress = Math.min(pullY / PULL_THRESHOLD, 1); + + return ( +
+ {showIndicator && ( +
+ +
+ )} + {children} +
+ ); +} diff --git a/src/components/feed/PostCard.tsx b/src/components/feed/PostCard.tsx index af55f49..b6ef2a3 100644 --- a/src/components/feed/PostCard.tsx +++ b/src/components/feed/PostCard.tsx @@ -38,6 +38,7 @@ import Link from "next/link"; import { getAvatarUrl } from "@/lib/avatar-url"; import { getMediaUrl } from "@/lib/media-url"; import { parseCaption } from "@/lib/parse-caption"; +import { formatRelativeVerbose } from "@/lib/format-relative-time"; import { PawRing } from "@/components/stories/PawRing"; import { MilestoneCard } from "@/components/feed/MilestoneCard"; import { VideoCard } from "@/components/video/VideoCard"; @@ -95,7 +96,8 @@ export type PostCardPost = { pet: PostPet; images: PostImage[]; milestone: PostMilestone | null; - _count?: { reactions: number; comments: number } | null; + reactionCount?: number; + commentCount?: number; repost?: { originalPostId: string; reposterPetId: string; @@ -123,19 +125,7 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) { const trpc = useTRPC(); const { activePetId } = useActivePet(); - function formatRelativeTime(date: Date | string): string { - const now = Date.now(); - const diffMs = now - new Date(date).getTime(); - const diffSecs = Math.floor(diffMs / 1000); - const diffMins = Math.floor(diffSecs / 60); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffSecs < 60) return t("justNow"); - if (diffMins < 60) return t("minutesAgo", { count: diffMins }); - if (diffHours < 24) return t("hoursAgo", { count: diffHours }); - if (diffDays < 7) return t("daysAgo", { count: diffDays }); - return new Date(date).toLocaleDateString(); - } + const formatRelativeTime = (date: Date | string) => formatRelativeVerbose(date, t); const { pet, images, createdAt } = post; const [currentCaption, setCurrentCaption] = useState(post.caption); @@ -469,7 +459,7 @@ export function PostCard({ post, onDeleted, variant = "feed" }: PostCardProps) { diff --git a/src/components/feed/PostDetailDialog.tsx b/src/components/feed/PostDetailDialog.tsx index 609d002..75e90ef 100644 --- a/src/components/feed/PostDetailDialog.tsx +++ b/src/components/feed/PostDetailDialog.tsx @@ -39,9 +39,11 @@ import Link from "next/link"; import { getAvatarUrl } from "@/lib/avatar-url"; import { getMediaUrl } from "@/lib/media-url"; import { parseCaption } from "@/lib/parse-caption"; +import { formatRelativeCompact } from "@/lib/format-relative-time"; import { PawButton } from "@/components/feed/PawButton"; import { PawBackButton } from "@/components/feed/PawBackButton"; import { PawRing } from "@/components/stories/PawRing"; +import { VideoCard } from "@/components/video/VideoCard"; import type { PostCardPost } from "@/components/feed/PostCard"; import { useTranslations } from "next-intl"; @@ -55,6 +57,7 @@ interface PostDetailDialogProps { export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailDialogProps) { const t = useTranslations("PostCard"); const tc = useTranslations("Comments"); + const trt = useTranslations("RelativeTime"); const trpc = useTRPC(); const { activePetId } = useActivePet(); const queryClient = useQueryClient(); @@ -163,17 +166,7 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD [handleSubmitComment] ); - function formatRelativeTime(date: Date | string): string { - const diffMs = Date.now() - new Date(date).getTime(); - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - if (diffMins < 1) return tc("justNow"); - if (diffMins < 60) return `${diffMins}m`; - if (diffHours < 24) return `${diffHours}h`; - if (diffDays < 7) return `${diffDays}d`; - return new Date(date).toLocaleDateString(); - } + const formatRelativeTime = (date: Date | string) => formatRelativeCompact(date, trt); if (!post) return null; @@ -242,7 +235,13 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD ); - const imageContent = images.length > 0 && ( + const mediaContent = post.type === "VIDEO" && post.videoPost ? ( + + ) : images.length > 0 && ( isCarousel ? (
@@ -304,7 +303,7 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD {/* Image column — fixed 45dvh strip on mobile, flex-1 full-height on desktop */}
- {imageContent} + {mediaContent}
{/* Right panel: desktop header + scrollable body + sticky input */} @@ -372,7 +371,7 @@ export function PostDetailDialog({ post, open, onClose, onDeleted }: PostDetailD diff --git a/src/components/messages/MessageThread.tsx b/src/components/messages/MessageThread.tsx index 116d577..e9b40c0 100644 --- a/src/components/messages/MessageThread.tsx +++ b/src/components/messages/MessageThread.tsx @@ -110,7 +110,7 @@ export function MessageThread({ conversationId, otherPet }: MessageThreadProps) } return ( -
+
{/* Header */}