mvc-kit 2.12.4 → 2.13.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.
Files changed (186) hide show
  1. package/agent-config/bin/postinstall.mjs +4 -3
  2. package/agent-config/bin/setup.mjs +5 -1
  3. package/agent-config/claude-code/agents/mvc-kit-architect.md +11 -8
  4. package/agent-config/claude-code/skills/guide/SKILL.md +20 -7
  5. package/agent-config/claude-code/skills/guide/patterns.md +12 -0
  6. package/agent-config/claude-code/skills/guide/recipes.md +510 -0
  7. package/agent-config/claude-code/skills/guide/testing.md +297 -0
  8. package/agent-config/claude-code/skills/review/SKILL.md +3 -13
  9. package/agent-config/claude-code/skills/review/checklist.md +30 -5
  10. package/agent-config/claude-code/skills/scaffold/SKILL.md +4 -13
  11. package/agent-config/lib/install-claude.mjs +84 -25
  12. package/dist/Channel.cjs +276 -300
  13. package/dist/Channel.cjs.map +1 -1
  14. package/dist/Channel.js +275 -299
  15. package/dist/Channel.js.map +1 -1
  16. package/dist/Collection.cjs +424 -504
  17. package/dist/Collection.cjs.map +1 -1
  18. package/dist/Collection.js +423 -503
  19. package/dist/Collection.js.map +1 -1
  20. package/dist/Controller.cjs +70 -67
  21. package/dist/Controller.cjs.map +1 -1
  22. package/dist/Controller.js +69 -66
  23. package/dist/Controller.js.map +1 -1
  24. package/dist/EventBus.cjs +77 -88
  25. package/dist/EventBus.cjs.map +1 -1
  26. package/dist/EventBus.js +76 -87
  27. package/dist/EventBus.js.map +1 -1
  28. package/dist/Feed.cjs +81 -77
  29. package/dist/Feed.cjs.map +1 -1
  30. package/dist/Feed.js +80 -76
  31. package/dist/Feed.js.map +1 -1
  32. package/dist/Model.cjs +181 -207
  33. package/dist/Model.cjs.map +1 -1
  34. package/dist/Model.js +179 -205
  35. package/dist/Model.js.map +1 -1
  36. package/dist/Pagination.cjs +75 -73
  37. package/dist/Pagination.cjs.map +1 -1
  38. package/dist/Pagination.js +74 -72
  39. package/dist/Pagination.js.map +1 -1
  40. package/dist/Pending.cjs +255 -287
  41. package/dist/Pending.cjs.map +1 -1
  42. package/dist/Pending.js +253 -285
  43. package/dist/Pending.js.map +1 -1
  44. package/dist/PersistentCollection.cjs +242 -285
  45. package/dist/PersistentCollection.cjs.map +1 -1
  46. package/dist/PersistentCollection.js +241 -284
  47. package/dist/PersistentCollection.js.map +1 -1
  48. package/dist/Resource.cjs +166 -174
  49. package/dist/Resource.cjs.map +1 -1
  50. package/dist/Resource.js +164 -172
  51. package/dist/Resource.js.map +1 -1
  52. package/dist/Selection.cjs +84 -94
  53. package/dist/Selection.cjs.map +1 -1
  54. package/dist/Selection.js +83 -93
  55. package/dist/Selection.js.map +1 -1
  56. package/dist/Service.cjs +54 -55
  57. package/dist/Service.cjs.map +1 -1
  58. package/dist/Service.js +53 -54
  59. package/dist/Service.js.map +1 -1
  60. package/dist/Sorting.cjs +102 -101
  61. package/dist/Sorting.cjs.map +1 -1
  62. package/dist/Sorting.js +102 -101
  63. package/dist/Sorting.js.map +1 -1
  64. package/dist/Trackable.cjs +112 -80
  65. package/dist/Trackable.cjs.map +1 -1
  66. package/dist/Trackable.js +111 -79
  67. package/dist/Trackable.js.map +1 -1
  68. package/dist/ViewModel.cjs +528 -576
  69. package/dist/ViewModel.cjs.map +1 -1
  70. package/dist/ViewModel.js +525 -573
  71. package/dist/ViewModel.js.map +1 -1
  72. package/dist/bindPublicMethods.cjs +43 -24
  73. package/dist/bindPublicMethods.cjs.map +1 -1
  74. package/dist/bindPublicMethods.js +43 -24
  75. package/dist/bindPublicMethods.js.map +1 -1
  76. package/dist/errors.cjs +67 -68
  77. package/dist/errors.cjs.map +1 -1
  78. package/dist/errors.js +68 -71
  79. package/dist/errors.js.map +1 -1
  80. package/dist/mvc-kit.cjs +44 -46
  81. package/dist/mvc-kit.js +5 -32
  82. package/dist/produceDraft.cjs +105 -95
  83. package/dist/produceDraft.cjs.map +1 -1
  84. package/dist/produceDraft.js +106 -97
  85. package/dist/produceDraft.js.map +1 -1
  86. package/dist/react/components/CardList.cjs +30 -40
  87. package/dist/react/components/CardList.cjs.map +1 -1
  88. package/dist/react/components/CardList.js +31 -41
  89. package/dist/react/components/CardList.js.map +1 -1
  90. package/dist/react/components/DataTable.cjs +146 -169
  91. package/dist/react/components/DataTable.cjs.map +1 -1
  92. package/dist/react/components/DataTable.js +147 -170
  93. package/dist/react/components/DataTable.js.map +1 -1
  94. package/dist/react/components/InfiniteScroll.cjs +51 -42
  95. package/dist/react/components/InfiniteScroll.cjs.map +1 -1
  96. package/dist/react/components/InfiniteScroll.js +52 -43
  97. package/dist/react/components/InfiniteScroll.js.map +1 -1
  98. package/dist/react/components/types.cjs +10 -6
  99. package/dist/react/components/types.cjs.map +1 -1
  100. package/dist/react/components/types.js +11 -9
  101. package/dist/react/components/types.js.map +1 -1
  102. package/dist/react/guards.cjs +10 -6
  103. package/dist/react/guards.cjs.map +1 -1
  104. package/dist/react/guards.js +11 -9
  105. package/dist/react/guards.js.map +1 -1
  106. package/dist/react/provider.cjs +23 -20
  107. package/dist/react/provider.cjs.map +1 -1
  108. package/dist/react/provider.js +23 -21
  109. package/dist/react/provider.js.map +1 -1
  110. package/dist/react/use-event-bus.cjs +24 -20
  111. package/dist/react/use-event-bus.cjs.map +1 -1
  112. package/dist/react/use-event-bus.js +24 -21
  113. package/dist/react/use-event-bus.js.map +1 -1
  114. package/dist/react/use-instance.cjs +43 -36
  115. package/dist/react/use-instance.cjs.map +1 -1
  116. package/dist/react/use-instance.js +43 -36
  117. package/dist/react/use-instance.js.map +1 -1
  118. package/dist/react/use-local.cjs +48 -64
  119. package/dist/react/use-local.cjs.map +1 -1
  120. package/dist/react/use-local.js +47 -63
  121. package/dist/react/use-local.js.map +1 -1
  122. package/dist/react/use-model.cjs +84 -98
  123. package/dist/react/use-model.cjs.map +1 -1
  124. package/dist/react/use-model.js +84 -100
  125. package/dist/react/use-model.js.map +1 -1
  126. package/dist/react/use-singleton.cjs +19 -23
  127. package/dist/react/use-singleton.cjs.map +1 -1
  128. package/dist/react/use-singleton.js +16 -20
  129. package/dist/react/use-singleton.js.map +1 -1
  130. package/dist/react/use-subscribe-only.cjs +28 -22
  131. package/dist/react/use-subscribe-only.cjs.map +1 -1
  132. package/dist/react/use-subscribe-only.js +28 -22
  133. package/dist/react/use-subscribe-only.js.map +1 -1
  134. package/dist/react/use-teardown.cjs +20 -19
  135. package/dist/react/use-teardown.cjs.map +1 -1
  136. package/dist/react/use-teardown.js +20 -19
  137. package/dist/react/use-teardown.js.map +1 -1
  138. package/dist/react-native/NativeCollection.cjs +98 -78
  139. package/dist/react-native/NativeCollection.cjs.map +1 -1
  140. package/dist/react-native/NativeCollection.js +97 -77
  141. package/dist/react-native/NativeCollection.js.map +1 -1
  142. package/dist/react-native.cjs +2 -4
  143. package/dist/react-native.js +1 -4
  144. package/dist/react.cjs +24 -26
  145. package/dist/react.js +1 -17
  146. package/dist/singleton.cjs +28 -22
  147. package/dist/singleton.cjs.map +1 -1
  148. package/dist/singleton.js +29 -26
  149. package/dist/singleton.js.map +1 -1
  150. package/dist/walkPrototypeChain.cjs +20 -12
  151. package/dist/walkPrototypeChain.cjs.map +1 -1
  152. package/dist/walkPrototypeChain.js +21 -13
  153. package/dist/walkPrototypeChain.js.map +1 -1
  154. package/dist/web/IndexedDBCollection.cjs +53 -36
  155. package/dist/web/IndexedDBCollection.cjs.map +1 -1
  156. package/dist/web/IndexedDBCollection.js +52 -35
  157. package/dist/web/IndexedDBCollection.js.map +1 -1
  158. package/dist/web/WebStorageCollection.cjs +82 -84
  159. package/dist/web/WebStorageCollection.cjs.map +1 -1
  160. package/dist/web/WebStorageCollection.js +81 -83
  161. package/dist/web/WebStorageCollection.js.map +1 -1
  162. package/dist/web/idb.cjs +107 -99
  163. package/dist/web/idb.cjs.map +1 -1
  164. package/dist/web/idb.js +108 -105
  165. package/dist/web/idb.js.map +1 -1
  166. package/dist/web.cjs +4 -6
  167. package/dist/web.js +1 -5
  168. package/dist/wrapAsyncMethods.cjs +141 -168
  169. package/dist/wrapAsyncMethods.cjs.map +1 -1
  170. package/dist/wrapAsyncMethods.js +141 -168
  171. package/dist/wrapAsyncMethods.js.map +1 -1
  172. package/package.json +8 -8
  173. package/src/Pending.test.ts +1 -2
  174. package/src/Sorting.test.ts +1 -1
  175. package/src/produceDraft.test.ts +3 -3
  176. package/src/react/components/CardList.test.tsx +1 -1
  177. package/src/react/components/DataTable.test.tsx +1 -1
  178. package/src/react/components/InfiniteScroll.test.tsx +5 -5
  179. package/dist/mvc-kit.cjs.map +0 -1
  180. package/dist/mvc-kit.js.map +0 -1
  181. package/dist/react-native.cjs.map +0 -1
  182. package/dist/react-native.js.map +0 -1
  183. package/dist/react.cjs.map +0 -1
  184. package/dist/react.js.map +0 -1
  185. package/dist/web.cjs.map +0 -1
  186. package/dist/web.js.map +0 -1
@@ -0,0 +1,297 @@
1
+ # mvc-kit Testing Patterns
2
+
3
+ These patterns are specific to testing mvc-kit classes. Follow them when writing or reviewing tests.
4
+
5
+ ---
6
+
7
+ ## Setup: Singleton Cleanup
8
+
9
+ Every test file that uses singletons needs `teardownAll()` in `beforeEach`. Without this, singleton state leaks between tests.
10
+
11
+ ```typescript
12
+ import { teardownAll } from 'mvc-kit';
13
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
14
+
15
+ beforeEach(() => {
16
+ teardownAll();
17
+ vi.restoreAllMocks();
18
+ });
19
+ ```
20
+
21
+ **Why:** Singletons persist in a module-scoped registry. After a test disposes a singleton ViewModel, the registry still holds the reference. `teardownAll()` disposes all singletons and clears the registry.
22
+
23
+ ---
24
+
25
+ ## Testing ViewModels
26
+
27
+ ### Basic: State and Getters
28
+
29
+ Construct, init, call methods, assert `vm.state` and getter properties.
30
+
31
+ ```typescript
32
+ import { UsersViewModel } from './UsersViewModel';
33
+
34
+ describe('UsersViewModel', () => {
35
+ it('filters by search', async () => {
36
+ const vm = new UsersViewModel({ search: '', roleFilter: 'all', statusFilter: 'all' });
37
+ await vm.init();
38
+
39
+ // Wait for data to load (if onInit fetches)
40
+ await vi.waitFor(() => expect(vm.items.length).toBeGreaterThan(0));
41
+
42
+ vm.setSearch('alice');
43
+ expect(vm.filtered.every(u => u.firstName.toLowerCase().includes('alice'))).toBe(true);
44
+
45
+ vm.dispose();
46
+ });
47
+ });
48
+ ```
49
+
50
+ ### Async Tracking Assertions
51
+
52
+ After calling an async method, assert `vm.async.methodName` during and after the call.
53
+
54
+ ```typescript
55
+ it('tracks loading state', async () => {
56
+ const vm = new UsersViewModel({ search: '', roleFilter: 'all', statusFilter: 'all' });
57
+ await vm.init();
58
+
59
+ // Before call
60
+ expect(vm.async.load.loading).toBe(false);
61
+ expect(vm.async.load.error).toBeNull();
62
+
63
+ // During call
64
+ const promise = vm.load();
65
+ expect(vm.async.load.loading).toBe(true);
66
+
67
+ await promise;
68
+
69
+ // After call
70
+ expect(vm.async.load.loading).toBe(false);
71
+ expect(vm.async.load.error).toBeNull();
72
+
73
+ vm.dispose();
74
+ });
75
+
76
+ it('captures errors in async tracking', async () => {
77
+ vi.spyOn(service, 'getAll').mockRejectedValue(new Error('Network error'));
78
+
79
+ const vm = new MyViewModel({ ... });
80
+ await vm.init();
81
+
82
+ await vm.load().catch(() => {}); // Swallow the re-thrown error
83
+
84
+ expect(vm.async.load.error).toBeInstanceOf(Error);
85
+ expect(vm.async.load.error!.message).toBe('Network error');
86
+
87
+ vm.dispose();
88
+ });
89
+ ```
90
+
91
+ ### Counter-Based Getter Testing (Memoization Verification)
92
+
93
+ Use a subclass override to count getter recomputations. Verifies that memoization works correctly.
94
+
95
+ ```typescript
96
+ class TestableVM extends UsersViewModel {
97
+ filteredCallCount = 0;
98
+
99
+ get filtered(): UserState[] {
100
+ this.filteredCallCount++;
101
+ return super.filtered;
102
+ }
103
+ }
104
+
105
+ it('memoizes filtered getter', async () => {
106
+ const vm = new TestableVM({ search: '', roleFilter: 'all', statusFilter: 'all' });
107
+ await vm.init();
108
+
109
+ // First access — computes
110
+ const result1 = vm.filtered;
111
+ expect(vm.filteredCallCount).toBe(1);
112
+
113
+ // Second access, same state — cache hit (no recompute)
114
+ const result2 = vm.filtered;
115
+ expect(vm.filteredCallCount).toBe(1);
116
+
117
+ // State change — invalidates cache
118
+ vm.setSearch('test');
119
+ const result3 = vm.filtered;
120
+ expect(vm.filteredCallCount).toBe(2);
121
+
122
+ vm.dispose();
123
+ });
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Testing Collections
129
+
130
+ Set up test data with `reset()`, then assert mutations.
131
+
132
+ ```typescript
133
+ import { UsersResource } from './UsersResource';
134
+
135
+ it('resets with data', () => {
136
+ const resource = new UsersResource();
137
+ resource.init();
138
+
139
+ resource.reset([
140
+ { id: '1', firstName: 'Alice', lastName: 'Smith', email: 'alice@test.com', role: 'admin', status: 'active' },
141
+ { id: '2', firstName: 'Bob', lastName: 'Jones', email: 'bob@test.com', role: 'viewer', status: 'inactive' },
142
+ ]);
143
+
144
+ expect(resource.length).toBe(2);
145
+ expect(resource.get('1')?.firstName).toBe('Alice');
146
+
147
+ resource.remove('1');
148
+ expect(resource.length).toBe(1);
149
+
150
+ resource.dispose();
151
+ });
152
+ ```
153
+
154
+ ### Testing ViewModel + Collection Integration
155
+
156
+ Mock the Service, let the ViewModel and Collection interact naturally.
157
+
158
+ ```typescript
159
+ it('loads data through resource into getters', async () => {
160
+ // Mock the service
161
+ vi.spyOn(UserService.prototype, 'getAll').mockResolvedValue([
162
+ { id: '1', firstName: 'Alice', role: 'admin', status: 'active' },
163
+ { id: '2', firstName: 'Bob', role: 'viewer', status: 'inactive' },
164
+ ]);
165
+
166
+ const vm = new UsersViewModel({ search: '', roleFilter: 'all', statusFilter: 'all' });
167
+ await vm.init();
168
+
169
+ // Wait for onInit's load to complete
170
+ await vi.waitFor(() => expect(vm.items.length).toBe(2));
171
+
172
+ // Test getters
173
+ expect(vm.total).toBe(2);
174
+ expect(vm.filteredCount).toBe(2);
175
+
176
+ vm.setRoleFilter('admin');
177
+ expect(vm.filteredCount).toBe(1);
178
+ expect(vm.filtered[0].firstName).toBe('Alice');
179
+
180
+ vm.dispose();
181
+ });
182
+ ```
183
+
184
+ ---
185
+
186
+ ## Testing Services
187
+
188
+ Mock `fetch`, assert correct URL/method/body, verify `HttpError` is thrown on failures.
189
+
190
+ ```typescript
191
+ import { UserService } from './UserService';
192
+
193
+ it('fetches all users', async () => {
194
+ const mockUsers = [{ id: '1', firstName: 'Alice' }];
195
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
196
+ new Response(JSON.stringify(mockUsers), { status: 200 }),
197
+ );
198
+
199
+ const service = new UserService();
200
+ const result = await service.getAll();
201
+
202
+ expect(fetch).toHaveBeenCalledWith('/api/users', expect.objectContaining({ method: 'GET' }));
203
+ expect(result).toEqual(mockUsers);
204
+ });
205
+
206
+ it('throws HttpError on failure', async () => {
207
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(
208
+ new Response('Not Found', { status: 404, statusText: 'Not Found' }),
209
+ );
210
+
211
+ const service = new UserService();
212
+ await expect(service.getById('999')).rejects.toThrow();
213
+ });
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Testing Models
219
+
220
+ Assert validation, dirty state, commit, and rollback.
221
+
222
+ ```typescript
223
+ import { LocationFormModel } from './LocationFormModel';
224
+
225
+ it('validates required fields', () => {
226
+ const model = new LocationFormModel({ name: '', city: '', capacity: 0 });
227
+
228
+ expect(model.valid).toBe(false);
229
+ expect(model.errors.name).toBeDefined();
230
+
231
+ model.setName('HQ');
232
+ model.setCity('Austin');
233
+ model.setCapacity(100);
234
+ expect(model.valid).toBe(true);
235
+ expect(model.errors.name).toBeUndefined();
236
+
237
+ model.dispose();
238
+ });
239
+
240
+ it('tracks dirty state and supports commit/rollback', () => {
241
+ const model = new LocationFormModel({ name: 'HQ', city: 'Austin', capacity: 100 });
242
+
243
+ expect(model.dirty).toBe(false);
244
+
245
+ model.setName('New HQ');
246
+ expect(model.dirty).toBe(true);
247
+
248
+ model.rollback();
249
+ expect(model.state.name).toBe('HQ');
250
+ expect(model.dirty).toBe(false);
251
+
252
+ model.setName('New HQ');
253
+ model.commit();
254
+ expect(model.state.name).toBe('New HQ');
255
+ expect(model.dirty).toBe(false);
256
+
257
+ model.dispose();
258
+ });
259
+ ```
260
+
261
+ ---
262
+
263
+ ## Testing React Components
264
+
265
+ React tests need the jsdom environment directive. Use `renderHook` for hook tests.
266
+
267
+ ```typescript
268
+ // @vitest-environment jsdom
269
+ import { renderHook, act } from '@testing-library/react';
270
+ import { useLocal } from 'mvc-kit/react';
271
+
272
+ it('useLocal creates and disposes ViewModel', () => {
273
+ const { result, unmount } = renderHook(() =>
274
+ useLocal(CounterViewModel, { count: 0 }),
275
+ );
276
+
277
+ const [state, vm] = result.current;
278
+ expect(state.count).toBe(0);
279
+
280
+ act(() => vm.increment());
281
+ expect(result.current[0].count).toBe(1);
282
+
283
+ unmount(); // auto-disposes
284
+ });
285
+ ```
286
+
287
+ ---
288
+
289
+ ## Test Checklist
290
+
291
+ - [ ] `teardownAll()` in `beforeEach` (singleton cleanup)
292
+ - [ ] `vm.dispose()` at end of each test (or rely on `afterEach` cleanup)
293
+ - [ ] Assert `vm.state.x` for raw state, `vm.x` for getters
294
+ - [ ] Assert `vm.async.method.loading` and `vm.async.method.error` for async methods
295
+ - [ ] Mock Services at the `fetch` level or via `vi.spyOn(ServiceClass.prototype, 'method')`
296
+ - [ ] React tests: add `// @vitest-environment jsdom` at top of file
297
+ - [ ] Never test internal fields (`_state`, `_revision`) — test public API only
@@ -1,18 +1,8 @@
1
1
  ---
2
- name: review
2
+ name: mvc-kit-review
3
3
  description: "Review code for mvc-kit pattern adherence. Usage: /mvc-kit:review <path>"
4
- invocable_by:
5
- - user
6
- - model
7
- user_instructions: |
8
- Usage: /mvc-kit:review <path>
9
-
10
- Reviews files at the given path for mvc-kit pattern adherence.
11
- Accepts a file path or directory.
12
-
13
- Examples:
14
- /mvc-kit:review src/viewmodels/
15
- /mvc-kit:review src/viewmodels/OrderViewModel.ts
4
+ argument-hint: "<path>"
5
+ allowed-tools: Read Grep Glob
16
6
  ---
17
7
 
18
8
  # Review mvc-kit Code
@@ -129,7 +129,7 @@
129
129
 
130
130
  ---
131
131
 
132
- ## Composable Helper Checks (5)
132
+ ## Composable Helper Checks (9)
133
133
 
134
134
  ### Critical
135
135
  1. **Correct ownership** — Sorting, Pagination, Selection belong on ViewModel. Pending belongs on singleton Resource (survives unmount).
@@ -139,18 +139,43 @@
139
139
  3. **`apply()` in getters** — Helpers should be composed via `apply()` in getter pipelines: `pagination.apply(sorting.apply(filtered))`.
140
140
  4. **Feed uses `setResult()` with Resource** — When items live in a Resource, Feed tracks only cursor/hasMore via `setResult()`. Use `appendPage()` only for component-scoped item accumulation.
141
141
  5. **No `disposeSignal` in Pending execute** — Pending's `enqueue` callback receives its own signal. Do not pass `vm.disposeSignal` (would abort on component unmount).
142
+ 6. **Pagination reset on filter changes** — Every setter that changes filter/sort criteria must call `pagination.reset()`. Without this, users can land on invalid pages after filtering.
143
+ 7. **Helpers are `readonly` public** — Sorting, Pagination, Selection should be `readonly` public properties. DataTable and headless components need direct access.
144
+ 8. **Getter pipeline order** — Pipeline should be `items → filtered → sorted → paged`. Selection operates on `filtered` (not `paged`) for cross-page selection.
145
+
146
+ ### Suggestion
147
+ 9. **Pass `filteredCount` as pagination total** — DataTable/pagination components need the filtered count, not the total item count.
142
148
 
143
149
  ---
144
150
 
145
- ## Test Checks (5)
151
+ ## Test Checks (8)
146
152
 
147
153
  ### Critical
148
154
  1. **`teardownAll()` in beforeEach** — Singleton registry must be reset between tests.
149
155
  2. **Dispose after test** — ViewModel/Model instances created in tests must be disposed.
156
+ 3. **Test file exists** — Every ViewModel, Resource, and Service should have a colocated `.test.ts` file.
157
+
158
+ ### Warning
159
+ 4. **Test state and getters** — Tests should assert `vm.state.x` and `vm.x` (getters), not internal fields.
160
+ 5. **Test async tracking** — For async methods, assert `vm.async.method.loading` and `vm.async.method.error`.
161
+ 6. **Mock at Service boundary** — Mock `fetch` or `vi.spyOn(ServiceClass.prototype, 'method')`. Don't mock Collections or Resources — let them interact naturally.
162
+ 7. **React tests use jsdom** — React test files need `// @vitest-environment jsdom` directive at top of file.
163
+
164
+ ### Suggestion
165
+ 8. **Counter-based getter testing** — Use subclass override pattern for verifying getter memoization.
166
+
167
+ ---
168
+
169
+ ## Composition Checks (6)
170
+
171
+ ### Critical
172
+ 1. **Model lifecycle pairing** — If a ViewModel creates a Model in `onInit()` or an action, it must dispose it in `onDispose()`.
150
173
 
151
174
  ### Warning
152
- 3. **Test state and getters** — Tests should assert `vm.state.x` and `vm.x` (getters), not internal fields.
153
- 4. **Test async tracking** — For async methods, assert `vm.async.method.loading` and `vm.async.method.error`.
175
+ 2. **Per-call cancellation for rapid interactions** — When users can switch contexts rapidly (conversations, search-as-you-type), use `AbortSignal.any([this.disposeSignal, controller.signal])` instead of `disposeSignal` alone.
176
+ 3. **Two-tier events** — ViewModel `.emit()` for component-scoped reactions (navigate, show error). EventBus `.emit()` for app-wide side effects (toasts, analytics). Don't use EventBus for single-component concerns.
177
+ 4. **Singleton DEFAULT_STATE** — Singleton ViewModels used with `useSingleton()` should have `static DEFAULT_STATE` to avoid repeating initial state at every call site.
178
+ 5. **Smart init** — `onInit()` should check `if (this.collection.length === 0)` before fetching to prevent re-fetching when component re-mounts and data is already loaded.
154
179
 
155
180
  ### Suggestion
156
- 5. **Counter-based getter testing** — Use subclass override pattern for verifying getter memoization.
181
+ 6. **File naming convention** — Files should follow `{Name}{Role}.ts` pattern: `UsersViewModel.ts`, `UserService.ts`, `UsersCollection.ts`, `UsersResource.ts`, `LocationFormModel.ts`, `AppEventBus.ts`.
@@ -1,18 +1,9 @@
1
1
  ---
2
- name: scaffold
2
+ name: mvc-kit-scaffold
3
3
  description: "Scaffold mvc-kit classes with correct patterns. Usage: /mvc-kit:scaffold <type> <Name>"
4
- invocable_by:
5
- - user
6
- - model
7
- user_instructions: |
8
- Usage: /mvc-kit:scaffold <type> <Name>
9
-
10
- Types: viewmodel, model, collection, persistent-collection, resource, service, eventbus, channel, controller, page-component
11
-
12
- Examples:
13
- /mvc-kit:scaffold viewmodel OrderList
14
- /mvc-kit:scaffold service Payment
15
- /mvc-kit:scaffold page-component Users
4
+ argument-hint: "<type> <Name>"
5
+ disable-model-invocation: true
6
+ allowed-tools: Read Write Edit
16
7
  ---
17
8
 
18
9
  # Scaffold mvc-kit Class
@@ -1,4 +1,4 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, copyFileSync } from 'node:fs';
2
2
  import { join, dirname } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
 
@@ -7,13 +7,22 @@ const __dirname = dirname(__filename);
7
7
  const SKILLS_DIR = join(__dirname, '..', 'claude-code', 'skills');
8
8
  const AGENTS_DIR = join(__dirname, '..', 'claude-code', 'agents');
9
9
 
10
- const AUTO_HEADER = '<!-- Auto-generated by mvc-kit. Updated on npm install/update. Do not edit. -->\n\n';
10
+ const AUTO_HEADER = '<!-- Auto-generated by mvc-kit. Updated on npm install/update. Do not edit. -->';
11
11
 
12
12
  function stripFrontmatter(content) {
13
13
  const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/);
14
14
  return match ? match[1].trim() : content.trim();
15
15
  }
16
16
 
17
+ /** Insert auto-generated header after YAML frontmatter (preserves parsing). */
18
+ function addHeader(content) {
19
+ const match = content.match(/^(---\n[\s\S]*?\n---\n)([\s\S]*)$/);
20
+ if (match) {
21
+ return match[1] + '\n' + AUTO_HEADER + '\n\n' + match[2].trim() + '\n';
22
+ }
23
+ return AUTO_HEADER + '\n\n' + content;
24
+ }
25
+
17
26
  function ensureDir(dir) {
18
27
  if (!existsSync(dir)) {
19
28
  mkdirSync(dir, { recursive: true });
@@ -24,7 +33,9 @@ function ensureDir(dir) {
24
33
  * Install Claude Code integration files into a project's .claude/ directory.
25
34
  *
26
35
  * Creates:
27
- * .claude/skills/mvc-kit.md — Framework reference skill (on-demand, model-invocable)
36
+ * .claude/skills/mvc-kit/ — Framework reference skill + supporting docs
37
+ * .claude/skills/mvc-kit-review/ — Code review skill + checklist
38
+ * .claude/skills/mvc-kit-scaffold/ — Scaffolding skill + templates
28
39
  * .claude/agents/mvc-kit-architect.md — Architecture planning agent
29
40
  *
30
41
  * @param {string} projectRoot — Absolute path to the consuming project's root
@@ -32,50 +43,98 @@ function ensureDir(dir) {
32
43
  */
33
44
  export function installClaude(projectRoot) {
34
45
  const claudeDir = join(projectRoot, '.claude');
35
- const skillsDir = join(claudeDir, 'skills');
46
+ const guideDir = join(claudeDir, 'skills', 'mvc-kit');
47
+ const reviewDir = join(claudeDir, 'skills', 'mvc-kit-review');
48
+ const scaffoldDir = join(claudeDir, 'skills', 'mvc-kit-scaffold');
49
+ const templatesDir = join(scaffoldDir, 'templates');
36
50
  const agentsDir = join(claudeDir, 'agents');
37
51
 
38
- ensureDir(skillsDir);
52
+ ensureDir(guideDir);
53
+ ensureDir(reviewDir);
54
+ ensureDir(scaffoldDir);
55
+ ensureDir(templatesDir);
39
56
  ensureDir(agentsDir);
40
57
 
41
58
  const files = [];
42
59
 
43
- // 1. Guide skill — framework reference (on-demand, model-invocable)
60
+ // ─── 1. Guide skill — framework reference (model-invocable, auto-loaded) ───
61
+
44
62
  const guideSkill = readFileSync(join(SKILLS_DIR, 'guide', 'SKILL.md'), 'utf-8');
45
63
  const guideBody = stripFrontmatter(guideSkill)
46
- // Remove the "Supporting Files" section — replaced by "Detailed Reference" below
64
+ // Remove the "Supporting Files" section — replaced with colocated file references below
47
65
  .replace(/\n## Supporting Files[\s\S]*$/, '');
48
66
 
49
- const SKILL_FRONTMATTER = `---
67
+ const GUIDE_FRONTMATTER = `---
50
68
  name: mvc-kit
51
69
  description: "mvc-kit framework reference — class roles, architecture rules, React hooks, and decision framework. Loaded on-demand when working with mvc-kit code."
52
- invocable_by:
53
- - model
54
- ---
70
+ user-invocable: false
71
+ ---`;
55
72
 
56
- `;
73
+ const skillContent = GUIDE_FRONTMATTER + '\n\n' + AUTO_HEADER + '\n\n' + guideBody + `
57
74
 
58
- const skillContent = SKILL_FRONTMATTER + AUTO_HEADER + guideBody + `
75
+ ## Supporting Files
59
76
 
60
- ## Detailed Reference
61
-
62
- For complete API details, patterns, and anti-patterns, read the files in:
63
- \`node_modules/mvc-kit/agent-config/claude-code/skills/guide/\`
64
-
65
- - \`api-reference.md\` — Full API reference for all classes and hooks
66
- - \`patterns.md\` — Prescribed patterns with code examples
67
- - \`anti-patterns.md\` — Anti-patterns to reject with fixes
77
+ - [api-reference.md](api-reference.md) — Full API reference for all classes and hooks
78
+ - [patterns.md](patterns.md) — Prescribed patterns with code examples
79
+ - [anti-patterns.md](anti-patterns.md) Anti-patterns to reject with fixes
80
+ - [recipes.md](recipes.md) — Composition recipes for real-world features
81
+ - [testing.md](testing.md) — Testing patterns (teardownAll, async assertions, memoization verification)
68
82
 
69
83
  For detailed per-class documentation, read the \`.md\` files colocated with source in:
70
84
  \`node_modules/mvc-kit/src/\`
71
85
  `;
72
86
 
73
- writeFileSync(join(skillsDir, 'mvc-kit', 'SKILL.md'), skillContent, 'utf-8');
87
+ writeFileSync(join(guideDir, 'SKILL.md'), skillContent, 'utf-8');
74
88
  files.push('.claude/skills/mvc-kit/SKILL.md');
75
89
 
76
- // 2. Architect agent
77
- const architectAgent = readFileSync(join(AGENTS_DIR, 'mvc-kit-architect.md'), 'utf-8');
78
- writeFileSync(join(agentsDir, 'mvc-kit-architect.md'), AUTO_HEADER + architectAgent, 'utf-8');
90
+ // Copy supporting files alongside SKILL.md
91
+ const guideSupportFiles = ['api-reference.md', 'patterns.md', 'anti-patterns.md', 'recipes.md', 'testing.md'];
92
+ for (const file of guideSupportFiles) {
93
+ const src = join(SKILLS_DIR, 'guide', file);
94
+ if (existsSync(src)) {
95
+ copyFileSync(src, join(guideDir, file));
96
+ files.push(`.claude/skills/mvc-kit/${file}`);
97
+ }
98
+ }
99
+
100
+ // ─── 2. Review skill — code review with checklist ──────────────────────────
101
+
102
+ const reviewSkill = readFileSync(join(SKILLS_DIR, 'review', 'SKILL.md'), 'utf-8');
103
+ writeFileSync(join(reviewDir, 'SKILL.md'), addHeader(reviewSkill), 'utf-8');
104
+ files.push('.claude/skills/mvc-kit-review/SKILL.md');
105
+
106
+ copyFileSync(
107
+ join(SKILLS_DIR, 'review', 'checklist.md'),
108
+ join(reviewDir, 'checklist.md'),
109
+ );
110
+ files.push('.claude/skills/mvc-kit-review/checklist.md');
111
+
112
+ // ─── 3. Scaffold skill — code generation with templates ────────────────────
113
+
114
+ const scaffoldSkill = readFileSync(join(SKILLS_DIR, 'scaffold', 'SKILL.md'), 'utf-8');
115
+ writeFileSync(join(scaffoldDir, 'SKILL.md'), addHeader(scaffoldSkill), 'utf-8');
116
+ files.push('.claude/skills/mvc-kit-scaffold/SKILL.md');
117
+
118
+ const templatesSrc = join(SKILLS_DIR, 'scaffold', 'templates');
119
+ if (existsSync(templatesSrc)) {
120
+ for (const file of readdirSync(templatesSrc)) {
121
+ if (file.endsWith('.md')) {
122
+ copyFileSync(join(templatesSrc, file), join(templatesDir, file));
123
+ files.push(`.claude/skills/mvc-kit-scaffold/templates/${file}`);
124
+ }
125
+ }
126
+ }
127
+
128
+ // ─── 4. Architect agent ────────────────────────────────────────────────────
129
+
130
+ let architectAgent = readFileSync(join(AGENTS_DIR, 'mvc-kit-architect.md'), 'utf-8');
131
+ // The source agent preloads "mvc-kit-guide" (the source skill name).
132
+ // In the installed context the guide skill is named "mvc-kit", so fix the reference.
133
+ architectAgent = architectAgent.replace(
134
+ /^(\s*- )mvc-kit-guide$/m,
135
+ '$1mvc-kit',
136
+ );
137
+ writeFileSync(join(agentsDir, 'mvc-kit-architect.md'), addHeader(architectAgent), 'utf-8');
79
138
  files.push('.claude/agents/mvc-kit-architect.md');
80
139
 
81
140
  return { files };