@malamute/ai-rules 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +174 -0
- package/bin/cli.js +5 -0
- package/configs/_shared/.claude/commands/fix-issue.md +38 -0
- package/configs/_shared/.claude/commands/generate-tests.md +49 -0
- package/configs/_shared/.claude/commands/review-pr.md +77 -0
- package/configs/_shared/.claude/rules/accessibility.md +270 -0
- package/configs/_shared/.claude/rules/performance.md +226 -0
- package/configs/_shared/.claude/rules/security.md +188 -0
- package/configs/_shared/.claude/skills/debug/SKILL.md +118 -0
- package/configs/_shared/.claude/skills/learning/SKILL.md +224 -0
- package/configs/_shared/.claude/skills/review/SKILL.md +86 -0
- package/configs/_shared/.claude/skills/spec/SKILL.md +112 -0
- package/configs/_shared/CLAUDE.md +174 -0
- package/configs/angular/.claude/rules/components.md +257 -0
- package/configs/angular/.claude/rules/state.md +250 -0
- package/configs/angular/.claude/rules/testing.md +422 -0
- package/configs/angular/.claude/settings.json +31 -0
- package/configs/angular/CLAUDE.md +251 -0
- package/configs/dotnet/.claude/rules/api.md +370 -0
- package/configs/dotnet/.claude/rules/architecture.md +199 -0
- package/configs/dotnet/.claude/rules/database/efcore.md +408 -0
- package/configs/dotnet/.claude/rules/testing.md +389 -0
- package/configs/dotnet/.claude/settings.json +9 -0
- package/configs/dotnet/CLAUDE.md +319 -0
- package/configs/nestjs/.claude/rules/auth.md +321 -0
- package/configs/nestjs/.claude/rules/database/prisma.md +305 -0
- package/configs/nestjs/.claude/rules/database/typeorm.md +379 -0
- package/configs/nestjs/.claude/rules/modules.md +215 -0
- package/configs/nestjs/.claude/rules/testing.md +315 -0
- package/configs/nestjs/.claude/rules/validation.md +279 -0
- package/configs/nestjs/.claude/settings.json +15 -0
- package/configs/nestjs/CLAUDE.md +263 -0
- package/configs/nextjs/.claude/rules/components.md +211 -0
- package/configs/nextjs/.claude/rules/state/redux-toolkit.md +429 -0
- package/configs/nextjs/.claude/rules/state/zustand.md +299 -0
- package/configs/nextjs/.claude/rules/testing.md +315 -0
- package/configs/nextjs/.claude/settings.json +29 -0
- package/configs/nextjs/CLAUDE.md +376 -0
- package/configs/python/.claude/rules/database/sqlalchemy.md +355 -0
- package/configs/python/.claude/rules/fastapi.md +272 -0
- package/configs/python/.claude/rules/flask.md +332 -0
- package/configs/python/.claude/rules/testing.md +374 -0
- package/configs/python/.claude/settings.json +18 -0
- package/configs/python/CLAUDE.md +273 -0
- package/package.json +41 -0
- package/src/install.js +315 -0
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- "**/*.spec.ts"
|
|
4
|
+
- "**/*.test.ts"
|
|
5
|
+
- "**/*.e2e.ts"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Testing Guidelines
|
|
9
|
+
|
|
10
|
+
## Framework
|
|
11
|
+
|
|
12
|
+
- **Vitest** (preferred for Angular 21+)
|
|
13
|
+
- **Playwright** for E2E testing
|
|
14
|
+
- Jest as fallback when required
|
|
15
|
+
|
|
16
|
+
## Critical Rules
|
|
17
|
+
|
|
18
|
+
### No `.subscribe()` for RxJS Testing
|
|
19
|
+
|
|
20
|
+
Never use `.subscribe()` for testing RxJS streams (services, effects, observables). Always use `TestScheduler` with marble testing.
|
|
21
|
+
|
|
22
|
+
```typescript
|
|
23
|
+
// FORBIDDEN - async callback hell
|
|
24
|
+
it('should load users', (done) => {
|
|
25
|
+
service.getUsers().subscribe(users => {
|
|
26
|
+
expect(users.length).toBe(2);
|
|
27
|
+
done();
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// FORBIDDEN - fakeAsync with subscribe
|
|
32
|
+
it('should load users', fakeAsync(() => {
|
|
33
|
+
let result: User[];
|
|
34
|
+
service.getUsers().subscribe(users => result = users);
|
|
35
|
+
tick();
|
|
36
|
+
expect(result.length).toBe(2);
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
// CORRECT - marble testing with TestScheduler
|
|
40
|
+
it('should load users', () => {
|
|
41
|
+
testScheduler.run(({ cold, expectObservable }) => {
|
|
42
|
+
const users = [{ id: '1' }, { id: '2' }];
|
|
43
|
+
jest.spyOn(service, 'getUsers').mockReturnValue(cold('--a|', { a: users }));
|
|
44
|
+
|
|
45
|
+
expectObservable(service.getUsers()).toBe('--a|', { a: users });
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Exception**: Component `output()` testing uses `.subscribe()` because OutputEmitterRef is event-based, not RxJS stream-based.
|
|
51
|
+
|
|
52
|
+
## Test File Structure
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
component.ts
|
|
56
|
+
component.spec.ts # Unit tests
|
|
57
|
+
|
|
58
|
+
# or in separate folder
|
|
59
|
+
__tests__/
|
|
60
|
+
component.spec.ts
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Component Testing (Zoneless)
|
|
64
|
+
|
|
65
|
+
Angular 21 is zoneless by default - tests must not rely on zone.js:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
69
|
+
|
|
70
|
+
describe('UserListComponent', () => {
|
|
71
|
+
let component: UserListComponent;
|
|
72
|
+
let fixture: ComponentFixture<UserListComponent>;
|
|
73
|
+
|
|
74
|
+
beforeEach(async () => {
|
|
75
|
+
await TestBed.configureTestingModule({
|
|
76
|
+
imports: [UserListComponent],
|
|
77
|
+
// No need for provideZonelessChangeDetection() - it's the default in Angular 21
|
|
78
|
+
}).compileComponents();
|
|
79
|
+
|
|
80
|
+
fixture = TestBed.createComponent(UserListComponent);
|
|
81
|
+
component = fixture.componentInstance;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should display users', async () => {
|
|
85
|
+
// Set input signal
|
|
86
|
+
fixture.componentRef.setInput('users', [
|
|
87
|
+
{ id: '1', name: 'John' },
|
|
88
|
+
{ id: '2', name: 'Jane' },
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
// Use whenStable() instead of detectChanges() for zoneless
|
|
92
|
+
await fixture.whenStable();
|
|
93
|
+
|
|
94
|
+
const items = fixture.nativeElement.querySelectorAll('.user-item');
|
|
95
|
+
expect(items.length).toBe(2);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should emit on user click', async () => {
|
|
99
|
+
const spy = vi.fn();
|
|
100
|
+
|
|
101
|
+
// Subscribe to output signal
|
|
102
|
+
const subscription = component.userSelected.subscribe(spy);
|
|
103
|
+
|
|
104
|
+
fixture.componentRef.setInput('users', [{ id: '1', name: 'John' }]);
|
|
105
|
+
await fixture.whenStable();
|
|
106
|
+
|
|
107
|
+
fixture.nativeElement.querySelector('.user-item').click();
|
|
108
|
+
await fixture.whenStable();
|
|
109
|
+
|
|
110
|
+
expect(spy).toHaveBeenCalledWith({ id: '1', name: 'John' });
|
|
111
|
+
subscription.unsubscribe();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Key Testing Patterns for Zoneless
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
// Use whenStable() instead of detectChanges()
|
|
120
|
+
await fixture.whenStable();
|
|
121
|
+
|
|
122
|
+
// For signal inputs
|
|
123
|
+
fixture.componentRef.setInput('inputName', value);
|
|
124
|
+
|
|
125
|
+
// For checking signal values directly
|
|
126
|
+
expect(component.mySignal()).toBe(expectedValue);
|
|
127
|
+
|
|
128
|
+
// For outputs (OutputEmitterRef)
|
|
129
|
+
const spy = vi.fn();
|
|
130
|
+
component.myOutput.subscribe(spy);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Service Mocking with createSpyFromClass
|
|
134
|
+
|
|
135
|
+
Use `jasmine-auto-spies` or equivalent:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { createSpyFromClass, Spy } from 'jasmine-auto-spies';
|
|
139
|
+
|
|
140
|
+
describe('UserEffects', () => {
|
|
141
|
+
let userService: Spy<UserService>;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
userService = createSpyFromClass(UserService);
|
|
145
|
+
|
|
146
|
+
TestBed.configureTestingModule({
|
|
147
|
+
providers: [
|
|
148
|
+
{ provide: UserService, useValue: userService },
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('should load users', () => {
|
|
154
|
+
userService.getAll.and.returnValue(of([{ id: '1', name: 'John' }]));
|
|
155
|
+
// ... test
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should handle error', () => {
|
|
159
|
+
userService.getAll.and.returnValue(throwError(() => new Error('API Error')));
|
|
160
|
+
// ... test
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## NgRx Effects Testing with Marble
|
|
166
|
+
|
|
167
|
+
Use `TestScheduler` for RxJS marble testing:
|
|
168
|
+
|
|
169
|
+
```typescript
|
|
170
|
+
import { TestScheduler } from 'rxjs/testing';
|
|
171
|
+
|
|
172
|
+
describe('User Effects', () => {
|
|
173
|
+
let testScheduler: TestScheduler;
|
|
174
|
+
let actions$: Observable<Action>;
|
|
175
|
+
let userService: Spy<UserService>;
|
|
176
|
+
|
|
177
|
+
beforeEach(() => {
|
|
178
|
+
testScheduler = new TestScheduler((actual, expected) => {
|
|
179
|
+
expect(actual).toEqual(expected);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
userService = createSpyFromClass(UserService);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should load users successfully', () => {
|
|
186
|
+
testScheduler.run(({ hot, cold, expectObservable }) => {
|
|
187
|
+
// Setup
|
|
188
|
+
const users = [{ id: '1', name: 'John' }];
|
|
189
|
+
actions$ = hot('-a', { a: UserActions.loadUsers() });
|
|
190
|
+
userService.getAll.and.returnValue(cold('--b|', { b: users }));
|
|
191
|
+
|
|
192
|
+
// Execute
|
|
193
|
+
const effect = loadUsers$(actions$, userService);
|
|
194
|
+
|
|
195
|
+
// Assert
|
|
196
|
+
expectObservable(effect).toBe('---c', {
|
|
197
|
+
c: UserActions.loadUsersSuccess({ users }),
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('should handle load error', () => {
|
|
203
|
+
testScheduler.run(({ hot, cold, expectObservable }) => {
|
|
204
|
+
const error = new Error('API Error');
|
|
205
|
+
actions$ = hot('-a', { a: UserActions.loadUsers() });
|
|
206
|
+
userService.getAll.and.returnValue(cold('--#', {}, error));
|
|
207
|
+
|
|
208
|
+
const effect = loadUsers$(actions$, userService);
|
|
209
|
+
|
|
210
|
+
expectObservable(effect).toBe('---c', {
|
|
211
|
+
c: UserActions.loadUsersFailure({ error: 'API Error' }),
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Marble Syntax Reference
|
|
219
|
+
|
|
220
|
+
| Symbol | Meaning |
|
|
221
|
+
|--------|---------|
|
|
222
|
+
| `-` | 10ms of time passing |
|
|
223
|
+
| `a-z` | Emission with value from values object |
|
|
224
|
+
| `\|` | Complete |
|
|
225
|
+
| `#` | Error |
|
|
226
|
+
| `^` | Subscription point |
|
|
227
|
+
| `!` | Unsubscription point |
|
|
228
|
+
| `()` | Sync grouping |
|
|
229
|
+
|
|
230
|
+
## Selector Testing
|
|
231
|
+
|
|
232
|
+
Test selectors in isolation:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
describe('User Selectors', () => {
|
|
236
|
+
const initialState: UserState = {
|
|
237
|
+
ids: ['1', '2'],
|
|
238
|
+
entities: {
|
|
239
|
+
'1': { id: '1', name: 'John', active: true },
|
|
240
|
+
'2': { id: '2', name: 'Jane', active: false },
|
|
241
|
+
},
|
|
242
|
+
selectedId: '1',
|
|
243
|
+
loading: false,
|
|
244
|
+
error: null,
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
it('should select all users', () => {
|
|
248
|
+
const result = selectAllUsers.projector(initialState);
|
|
249
|
+
expect(result.length).toBe(2);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should select active users only', () => {
|
|
253
|
+
const allUsers = selectAllUsers.projector(initialState);
|
|
254
|
+
const result = selectActiveUsers.projector(allUsers);
|
|
255
|
+
expect(result.length).toBe(1);
|
|
256
|
+
expect(result[0].name).toBe('John');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should select current user', () => {
|
|
260
|
+
const entities = selectUserEntities.projector(initialState);
|
|
261
|
+
const result = selectSelectedUser.projector(entities, '1');
|
|
262
|
+
expect(result?.name).toBe('John');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Reducer Testing
|
|
268
|
+
|
|
269
|
+
```typescript
|
|
270
|
+
describe('User Reducer', () => {
|
|
271
|
+
it('should set loading on loadUsers', () => {
|
|
272
|
+
const state = userReducer(initialUserState, UserActions.loadUsers());
|
|
273
|
+
expect(state.loading).toBe(true);
|
|
274
|
+
expect(state.error).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should add users on loadUsersSuccess', () => {
|
|
278
|
+
const users = [{ id: '1', name: 'John' }];
|
|
279
|
+
const state = userReducer(
|
|
280
|
+
{ ...initialUserState, loading: true },
|
|
281
|
+
UserActions.loadUsersSuccess({ users })
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
expect(state.loading).toBe(false);
|
|
285
|
+
expect(state.ids).toEqual(['1']);
|
|
286
|
+
expect(state.entities['1'].name).toBe('John');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## Test Organization
|
|
292
|
+
|
|
293
|
+
```typescript
|
|
294
|
+
describe('FeatureName', () => {
|
|
295
|
+
// Setup
|
|
296
|
+
beforeEach(() => { /* ... */ });
|
|
297
|
+
afterEach(() => { /* ... */ });
|
|
298
|
+
|
|
299
|
+
// Group by behavior
|
|
300
|
+
describe('when initialized', () => {
|
|
301
|
+
it('should have default state', () => {});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('when loading data', () => {
|
|
305
|
+
it('should show loading indicator', () => {});
|
|
306
|
+
it('should handle success', () => {});
|
|
307
|
+
it('should handle error', () => {});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('when user interacts', () => {
|
|
311
|
+
it('should emit selection event', () => {});
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
## E2E Testing with Playwright
|
|
317
|
+
|
|
318
|
+
Prefer Playwright for E2E tests when possible.
|
|
319
|
+
|
|
320
|
+
### Setup
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
// playwright.config.ts
|
|
324
|
+
import { defineConfig } from '@playwright/test';
|
|
325
|
+
|
|
326
|
+
export default defineConfig({
|
|
327
|
+
testDir: './e2e',
|
|
328
|
+
use: {
|
|
329
|
+
baseURL: 'http://localhost:4200',
|
|
330
|
+
trace: 'on-first-retry',
|
|
331
|
+
},
|
|
332
|
+
webServer: {
|
|
333
|
+
command: 'nx serve app-name',
|
|
334
|
+
url: 'http://localhost:4200',
|
|
335
|
+
reuseExistingServer: !process.env.CI,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
### E2E Test Example
|
|
341
|
+
|
|
342
|
+
```typescript
|
|
343
|
+
// e2e/user-flow.e2e.ts
|
|
344
|
+
import { test, expect } from '@playwright/test';
|
|
345
|
+
|
|
346
|
+
test.describe('User Management', () => {
|
|
347
|
+
test.beforeEach(async ({ page }) => {
|
|
348
|
+
await page.goto('/users');
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('should display user list', async ({ page }) => {
|
|
352
|
+
await expect(page.getByTestId('user-list')).toBeVisible();
|
|
353
|
+
await expect(page.getByTestId('user-item')).toHaveCount(3);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('should filter users by name', async ({ page }) => {
|
|
357
|
+
await page.getByPlaceholder('Search users').fill('John');
|
|
358
|
+
await expect(page.getByTestId('user-item')).toHaveCount(1);
|
|
359
|
+
await expect(page.getByText('John Doe')).toBeVisible();
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test('should navigate to user details', async ({ page }) => {
|
|
363
|
+
await page.getByTestId('user-item').first().click();
|
|
364
|
+
await expect(page).toHaveURL(/\/users\/\d+/);
|
|
365
|
+
await expect(page.getByTestId('user-details')).toBeVisible();
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
### Page Object Pattern
|
|
371
|
+
|
|
372
|
+
```typescript
|
|
373
|
+
// e2e/pages/user-list.page.ts
|
|
374
|
+
import { Page, Locator } from '@playwright/test';
|
|
375
|
+
|
|
376
|
+
export class UserListPage {
|
|
377
|
+
readonly page: Page;
|
|
378
|
+
readonly searchInput: Locator;
|
|
379
|
+
readonly userItems: Locator;
|
|
380
|
+
readonly addUserButton: Locator;
|
|
381
|
+
|
|
382
|
+
constructor(page: Page) {
|
|
383
|
+
this.page = page;
|
|
384
|
+
this.searchInput = page.getByPlaceholder('Search users');
|
|
385
|
+
this.userItems = page.getByTestId('user-item');
|
|
386
|
+
this.addUserButton = page.getByRole('button', { name: 'Add User' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async goto(): Promise<void> {
|
|
390
|
+
await this.page.goto('/users');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async searchUser(name: string): Promise<void> {
|
|
394
|
+
await this.searchInput.fill(name);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async selectUser(index: number): Promise<void> {
|
|
398
|
+
await this.userItems.nth(index).click();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Commands
|
|
404
|
+
|
|
405
|
+
```bash
|
|
406
|
+
# Run E2E tests
|
|
407
|
+
nx e2e app-name-e2e
|
|
408
|
+
|
|
409
|
+
# Run with UI mode
|
|
410
|
+
nx e2e app-name-e2e --ui
|
|
411
|
+
|
|
412
|
+
# Run specific test file
|
|
413
|
+
nx e2e app-name-e2e --grep "User Management"
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
## Coverage Expectations
|
|
417
|
+
|
|
418
|
+
- Aim for >80% coverage on business logic
|
|
419
|
+
- 100% coverage on reducers and selectors
|
|
420
|
+
- Effects: test success and error paths (with marble)
|
|
421
|
+
- UI components: test inputs, outputs, rendering
|
|
422
|
+
- E2E: critical user flows
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"permissions": {
|
|
3
|
+
"allow": [
|
|
4
|
+
"Bash(nx serve *)",
|
|
5
|
+
"Bash(nx build *)",
|
|
6
|
+
"Bash(nx test *)",
|
|
7
|
+
"Bash(nx lint *)",
|
|
8
|
+
"Bash(nx run-many *)",
|
|
9
|
+
"Bash(nx affected *)",
|
|
10
|
+
"Bash(nx g *)",
|
|
11
|
+
"Bash(nx generate *)",
|
|
12
|
+
"Bash(npm run *)",
|
|
13
|
+
"Bash(npm install *)",
|
|
14
|
+
"Bash(npm ci)",
|
|
15
|
+
"Bash(npx nx *)",
|
|
16
|
+
"Read",
|
|
17
|
+
"Edit",
|
|
18
|
+
"Write"
|
|
19
|
+
],
|
|
20
|
+
"deny": [
|
|
21
|
+
"Bash(rm -rf *)",
|
|
22
|
+
"Bash(nx reset)",
|
|
23
|
+
"Read(.env)",
|
|
24
|
+
"Read(.env.*)",
|
|
25
|
+
"Read(**/secrets/**)"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"env": {
|
|
29
|
+
"NX_DAEMON": "true"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Angular Project Guidelines
|
|
2
|
+
|
|
3
|
+
@../_shared/CLAUDE.md
|
|
4
|
+
|
|
5
|
+
## Stack
|
|
6
|
+
|
|
7
|
+
- Angular 21+ (latest)
|
|
8
|
+
- Nx monorepo
|
|
9
|
+
- NgRx (store, effects, entity)
|
|
10
|
+
- Vitest
|
|
11
|
+
- TypeScript strict mode
|
|
12
|
+
|
|
13
|
+
## Architecture - Nx Structure
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
apps/
|
|
17
|
+
[app-name]/
|
|
18
|
+
|
|
19
|
+
libs/
|
|
20
|
+
[domain]/ # Ex: users, products, checkout
|
|
21
|
+
feature/ # Smart components, pages, routing (lazy-loaded)
|
|
22
|
+
data-access/ # Services API + NgRx state
|
|
23
|
+
src/lib/+state/ # Actions, reducers, effects, selectors
|
|
24
|
+
ui/ # Dumb/presentational components
|
|
25
|
+
util/ # Domain-specific helpers
|
|
26
|
+
|
|
27
|
+
shared/
|
|
28
|
+
ui/ # Reusable UI components
|
|
29
|
+
data-access/ # Shared services (auth, http interceptors)
|
|
30
|
+
util/ # Pure functions, helpers
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Dependency Rules (enforce via Nx tags)
|
|
34
|
+
|
|
35
|
+
| Type | Can import |
|
|
36
|
+
|------|------------|
|
|
37
|
+
| `feature` | Everything |
|
|
38
|
+
| `ui` | `ui`, `util` only |
|
|
39
|
+
| `data-access` | `data-access`, `util` only |
|
|
40
|
+
| `util` | `util` only |
|
|
41
|
+
|
|
42
|
+
## Angular 21 - Core Principles
|
|
43
|
+
|
|
44
|
+
### Zoneless by Default
|
|
45
|
+
|
|
46
|
+
- No zone.js - use signals for reactivity
|
|
47
|
+
- Use `ChangeDetectionStrategy.OnPush` on all components
|
|
48
|
+
- Never rely on zone.js for change detection
|
|
49
|
+
|
|
50
|
+
### Signals Everywhere
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// State
|
|
54
|
+
count = signal(0);
|
|
55
|
+
items = signal<Item[]>([]);
|
|
56
|
+
|
|
57
|
+
// Derived state
|
|
58
|
+
doubleCount = computed(() => this.count() * 2);
|
|
59
|
+
isEmpty = computed(() => this.items().length === 0);
|
|
60
|
+
|
|
61
|
+
// Effects for side effects
|
|
62
|
+
effect(() => {
|
|
63
|
+
console.log('Count changed:', this.count());
|
|
64
|
+
});
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Signal Forms (experimental but preferred)
|
|
68
|
+
|
|
69
|
+
```typescript
|
|
70
|
+
// Use signal forms, NOT reactive forms
|
|
71
|
+
import { SignalForm } from '@angular/forms';
|
|
72
|
+
|
|
73
|
+
form = signalForm({
|
|
74
|
+
name: '',
|
|
75
|
+
email: '',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Access values
|
|
79
|
+
form.value(); // { name: '', email: '' }
|
|
80
|
+
form.controls.name(); // ''
|
|
81
|
+
form.valid(); // boolean signal
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Standalone Components (Default)
|
|
85
|
+
|
|
86
|
+
- No NgModules for components
|
|
87
|
+
- `standalone: true` is the default - don't add it
|
|
88
|
+
- Import dependencies directly in component
|
|
89
|
+
- Always use separate template files (`.html`)
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
@Component({
|
|
93
|
+
selector: 'app-example',
|
|
94
|
+
imports: [RouterModule],
|
|
95
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
96
|
+
templateUrl: './example.component.html',
|
|
97
|
+
styleUrl: './example.component.scss',
|
|
98
|
+
})
|
|
99
|
+
export class ExampleComponent {}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Inject Function
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// Preferred
|
|
106
|
+
export class MyComponent {
|
|
107
|
+
private readonly store = inject(Store);
|
|
108
|
+
private readonly http = inject(HttpClient);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Avoid constructor injection
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### Signal Inputs/Outputs (not decorators)
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// Inputs - use input() function, NOT @Input() decorator
|
|
118
|
+
name = input<string>(); // Optional
|
|
119
|
+
name = input('default'); // With default
|
|
120
|
+
name = input.required<string>(); // Required
|
|
121
|
+
|
|
122
|
+
// Outputs - use output() function, NOT @Output() decorator
|
|
123
|
+
clicked = output<void>();
|
|
124
|
+
selected = output<Item>();
|
|
125
|
+
|
|
126
|
+
// Two-way binding - use model() function
|
|
127
|
+
value = model<string>(''); // Creates input + output pair
|
|
128
|
+
value = model.required<string>(); // Required two-way binding
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Component Architecture
|
|
132
|
+
|
|
133
|
+
### Smart Components (feature/)
|
|
134
|
+
|
|
135
|
+
- Located in `feature/` libs
|
|
136
|
+
- Inject store, dispatch actions
|
|
137
|
+
- Handle routing logic
|
|
138
|
+
- Pass data to UI components via inputs
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// user-list-page.component.ts
|
|
142
|
+
@Component({
|
|
143
|
+
selector: 'app-user-list-page',
|
|
144
|
+
imports: [UserListComponent],
|
|
145
|
+
templateUrl: './user-list-page.component.html',
|
|
146
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
147
|
+
})
|
|
148
|
+
export class UserListPageComponent {
|
|
149
|
+
private readonly store = inject(Store);
|
|
150
|
+
|
|
151
|
+
users = this.store.selectSignal(selectAllUsers);
|
|
152
|
+
loading = this.store.selectSignal(selectUsersLoading);
|
|
153
|
+
|
|
154
|
+
onUserSelect(user: User): void {
|
|
155
|
+
this.store.dispatch(UserActions.selectUser({ user }));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```html
|
|
161
|
+
<!-- user-list-page.component.html -->
|
|
162
|
+
<app-user-list
|
|
163
|
+
[users]="users()"
|
|
164
|
+
[loading]="loading()"
|
|
165
|
+
(userSelected)="onUserSelect($event)"
|
|
166
|
+
/>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### UI Components (ui/)
|
|
170
|
+
|
|
171
|
+
- Located in `ui/` libs
|
|
172
|
+
- NO store injection - never!
|
|
173
|
+
- Pure inputs/outputs only
|
|
174
|
+
- Fully presentational
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// user-list.component.ts
|
|
178
|
+
@Component({
|
|
179
|
+
selector: 'app-user-list',
|
|
180
|
+
templateUrl: './user-list.component.html',
|
|
181
|
+
styleUrl: './user-list.component.scss',
|
|
182
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
183
|
+
})
|
|
184
|
+
export class UserListComponent {
|
|
185
|
+
users = input.required<User[]>();
|
|
186
|
+
loading = input(false);
|
|
187
|
+
userSelected = output<User>();
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```html
|
|
192
|
+
<!-- user-list.component.html -->
|
|
193
|
+
@for (user of users(); track user.id) {
|
|
194
|
+
<div class="user-item" (click)="userSelected.emit(user)">
|
|
195
|
+
{{ user.name }}
|
|
196
|
+
</div>
|
|
197
|
+
} @empty {
|
|
198
|
+
<p>No users found</p>
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Build & Commands
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
# Development
|
|
206
|
+
nx serve [app-name]
|
|
207
|
+
|
|
208
|
+
# Build
|
|
209
|
+
nx build [app-name]
|
|
210
|
+
nx build [app-name] --configuration=production
|
|
211
|
+
|
|
212
|
+
# Test
|
|
213
|
+
nx test [lib-name] # Single lib
|
|
214
|
+
nx run-many -t test # All tests
|
|
215
|
+
nx affected -t test # Only affected
|
|
216
|
+
|
|
217
|
+
# Lint
|
|
218
|
+
nx lint [project-name]
|
|
219
|
+
nx run-many -t lint
|
|
220
|
+
|
|
221
|
+
# Generate
|
|
222
|
+
nx g @nx/angular:component [name] --project=[lib]
|
|
223
|
+
nx g @nx/angular:library [name] --directory=libs/[domain]
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## Code Style
|
|
227
|
+
|
|
228
|
+
- Prefix: configurable per project (default: `app`)
|
|
229
|
+
- File structure: folder-based (`user-list/user-list.component.ts`)
|
|
230
|
+
- Always explicit return types on public methods
|
|
231
|
+
- Use `readonly` for injected services
|
|
232
|
+
- Use `track` in `@for` loops
|
|
233
|
+
|
|
234
|
+
## RxJS Guidelines
|
|
235
|
+
|
|
236
|
+
- Prefer signals over observables when possible
|
|
237
|
+
- Use `toSignal()` to convert observables
|
|
238
|
+
- Clean subscriptions with `takeUntilDestroyed()`
|
|
239
|
+
- Avoid nested subscribes - use operators
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
// Convert observable to signal
|
|
243
|
+
data = toSignal(this.http.get<Data[]>('/api/data'), { initialValue: [] });
|
|
244
|
+
|
|
245
|
+
// If you must use observables
|
|
246
|
+
private readonly destroyRef = inject(DestroyRef);
|
|
247
|
+
|
|
248
|
+
this.source$.pipe(
|
|
249
|
+
takeUntilDestroyed(this.destroyRef)
|
|
250
|
+
).subscribe();
|
|
251
|
+
```
|