@northbridge-security/secureai 0.1.13
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/.claude/README.md +122 -0
- package/.claude/commands/architect/clean.md +978 -0
- package/.claude/commands/architect/kiss.md +762 -0
- package/.claude/commands/architect/review.md +704 -0
- package/.claude/commands/catchup.md +90 -0
- package/.claude/commands/code.md +115 -0
- package/.claude/commands/commit.md +1218 -0
- package/.claude/commands/cover.md +1298 -0
- package/.claude/commands/fmea.md +275 -0
- package/.claude/commands/kaizen.md +312 -0
- package/.claude/commands/pr.md +503 -0
- package/.claude/commands/todo.md +99 -0
- package/.claude/commands/worktree.md +738 -0
- package/.claude/commands/wrapup.md +103 -0
- package/LICENSE +183 -0
- package/README.md +108 -0
- package/dist/cli.js +75634 -0
- package/docs/agents/devops-reviewer.md +889 -0
- package/docs/agents/kiss-simplifier.md +1088 -0
- package/docs/agents/typescript.md +8 -0
- package/docs/guides/README.md +109 -0
- package/docs/guides/agents.clean.arch.md +244 -0
- package/docs/guides/agents.clean.arch.ts.md +1314 -0
- package/docs/guides/agents.gotask.md +1037 -0
- package/docs/guides/agents.markdown.md +1209 -0
- package/docs/guides/agents.onepassword.md +285 -0
- package/docs/guides/agents.sonar.md +857 -0
- package/docs/guides/agents.tdd.md +838 -0
- package/docs/guides/agents.tdd.ts.md +1062 -0
- package/docs/guides/agents.typesript.md +1389 -0
- package/docs/guides/github-mcp.md +1075 -0
- package/package.json +130 -0
- package/packages/secureai-cli/src/cli.ts +21 -0
- package/tasks/README.md +880 -0
- package/tasks/aws.yml +64 -0
- package/tasks/bash.yml +118 -0
- package/tasks/bun.yml +738 -0
- package/tasks/claude.yml +183 -0
- package/tasks/docker.yml +420 -0
- package/tasks/docs.yml +127 -0
- package/tasks/git.yml +1336 -0
- package/tasks/gotask.yml +132 -0
- package/tasks/json.yml +77 -0
- package/tasks/markdown.yml +95 -0
- package/tasks/onepassword.yml +350 -0
- package/tasks/security.yml +102 -0
- package/tasks/sonar.yml +437 -0
- package/tasks/template.yml +74 -0
- package/tasks/vscode.yml +103 -0
- package/tasks/yaml.yml +121 -0
|
@@ -0,0 +1,1314 @@
|
|
|
1
|
+
# Clean Architecture in TypeScript
|
|
2
|
+
|
|
3
|
+
This guide shows how to implement clean architecture principles in TypeScript, focusing on interface-based dependency injection, the `*.system.ts` naming convention, and constructor-based DI patterns.
|
|
4
|
+
|
|
5
|
+
**See also**: [Clean Architecture for AI Agents](./agents.clean.arch.md) - Language-agnostic principles
|
|
6
|
+
|
|
7
|
+
## Target Audience
|
|
8
|
+
|
|
9
|
+
AI agents refactoring TypeScript/Node.js/Bun projects to improve testability, test coverage, and separation of concerns using clean architecture patterns.
|
|
10
|
+
|
|
11
|
+
## Problem Statement
|
|
12
|
+
|
|
13
|
+
TypeScript codebases often mix business logic with system interactions (file I/O, shell execution, network calls, database queries). This creates several issues:
|
|
14
|
+
|
|
15
|
+
**Testing challenges:**
|
|
16
|
+
|
|
17
|
+
- Cannot unit test without triggering real system operations
|
|
18
|
+
- Tests become slow, flaky, and environment-dependent
|
|
19
|
+
- Complex mocking required for every test
|
|
20
|
+
|
|
21
|
+
**Coverage challenges:**
|
|
22
|
+
|
|
23
|
+
- Business logic coverage diluted by untestable system calls
|
|
24
|
+
- Coverage metrics don't reflect actual code complexity
|
|
25
|
+
- Must exclude entire files from coverage, losing visibility into business logic
|
|
26
|
+
|
|
27
|
+
**Maintenance challenges:**
|
|
28
|
+
|
|
29
|
+
- System interaction code scattered across many files
|
|
30
|
+
- Hard to change system interaction strategy (e.g., switch from child_process to worker_threads)
|
|
31
|
+
- Difficult to mock system behavior for testing edge cases
|
|
32
|
+
|
|
33
|
+
## Solution Pattern
|
|
34
|
+
|
|
35
|
+
Use **interface-based dependency injection** with a **naming convention** to separate concerns:
|
|
36
|
+
|
|
37
|
+
1. **Interface** - Define contracts for system operations
|
|
38
|
+
2. **System implementation** - Thin wrapper around actual system calls (excluded from coverage)
|
|
39
|
+
3. **Mock implementation** - In-memory test doubles
|
|
40
|
+
4. **Business logic** - Uses interface, fully testable
|
|
41
|
+
|
|
42
|
+
### File Naming Convention
|
|
43
|
+
|
|
44
|
+
**Standard naming pattern:**
|
|
45
|
+
|
|
46
|
+
- **Interface**: `{domain}-interface.ts` (pure TypeScript types, no .system suffix)
|
|
47
|
+
- **System wrapper**: `{domain}.system.ts` (actual system calls, excluded from coverage)
|
|
48
|
+
- **Mock**: `tests/mocks/{domain}-mock.ts` (test doubles)
|
|
49
|
+
- **Business logic**: Original filename or separate module
|
|
50
|
+
|
|
51
|
+
**Benefits of `*.system.ts` convention:**
|
|
52
|
+
|
|
53
|
+
- **Self-documenting** - File name clearly indicates system interaction
|
|
54
|
+
- **Simple exclusion** - Single glob pattern (`**/*.system.ts`) in coverage config
|
|
55
|
+
- **Scalable** - Works for all future refactorings without config changes
|
|
56
|
+
- **Discoverable** - Easy to find all system boundary files
|
|
57
|
+
|
|
58
|
+
### Folder Organization
|
|
59
|
+
|
|
60
|
+
**Module-based structure (Recommended):**
|
|
61
|
+
|
|
62
|
+
Organize related files into feature/module folders:
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
// ✅ GOOD - Module folder with clear separation
|
|
66
|
+
src/utils/onepassword/
|
|
67
|
+
├── index.ts // Public API: re-export public functions
|
|
68
|
+
├── cli-interface.ts // IOnePasswordCLI interface definition
|
|
69
|
+
├── cli.system.ts // System implementation (excluded from coverage)
|
|
70
|
+
├── session.ts // Business logic: session management
|
|
71
|
+
├── resolver.ts // Business logic: secret resolution
|
|
72
|
+
└── secrets.ts // Business logic: secret parsing
|
|
73
|
+
|
|
74
|
+
tests/mocks/onepassword/
|
|
75
|
+
└── cli-mock.ts // Mock implementation for testing
|
|
76
|
+
|
|
77
|
+
// Import from module
|
|
78
|
+
import { authenticate, readSecret } from '@/utils/onepassword';
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Flat structure (Current, but less scalable):**
|
|
82
|
+
|
|
83
|
+
```typescript
|
|
84
|
+
// ⚠️ ACCEPTABLE - Flat structure with prefixes
|
|
85
|
+
src/utils/
|
|
86
|
+
├── onepassword-cli-interface.ts
|
|
87
|
+
├── onepassword-cli.system.ts
|
|
88
|
+
├── onepassword-session.ts
|
|
89
|
+
├── onepassword-resolver.ts
|
|
90
|
+
├── onepassword-secrets.ts
|
|
91
|
+
└── onepassword.ts // Re-exports
|
|
92
|
+
|
|
93
|
+
tests/mocks/
|
|
94
|
+
└── onepassword-cli-mock.ts
|
|
95
|
+
|
|
96
|
+
// Import with full names
|
|
97
|
+
import { authenticate } from '@/utils/onepassword-session';
|
|
98
|
+
import { readSecret } from '@/utils/onepassword-resolver';
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Benefits of module folders:**
|
|
102
|
+
|
|
103
|
+
1. **Co-location** - All related code in one directory
|
|
104
|
+
2. **Clear boundaries** - Module folder defines public API via `index.ts`
|
|
105
|
+
3. **Easier to find** - All onepassword code in `onepassword/` folder
|
|
106
|
+
4. **Scales better** - Add new modules without cluttering parent directory
|
|
107
|
+
5. **Testability** - Module-level mocks in `tests/mocks/{module}/`
|
|
108
|
+
6. **Refactoring** - Move entire module without updating many imports
|
|
109
|
+
|
|
110
|
+
**When to use each:**
|
|
111
|
+
|
|
112
|
+
- **Module folders**: 5+ related files, clear domain boundary
|
|
113
|
+
- **Flat structure**: 2-3 related files, simple utility functions
|
|
114
|
+
|
|
115
|
+
**Real-world example transformation:**
|
|
116
|
+
|
|
117
|
+
```diff
|
|
118
|
+
# Before (flat structure)
|
|
119
|
+
src/utils/
|
|
120
|
+
+ onepassword-cli-interface.ts (42 lines)
|
|
121
|
+
+ onepassword-cli.system.ts (179 lines)
|
|
122
|
+
+ onepassword-session.ts (254 lines)
|
|
123
|
+
+ onepassword-resolver.ts (331 lines)
|
|
124
|
+
+ onepassword-secrets.ts (187 lines)
|
|
125
|
+
+ onepassword.ts (15 lines)
|
|
126
|
+
= 1,008 lines across 6 files with "onepassword-" prefix
|
|
127
|
+
|
|
128
|
+
# After (module structure)
|
|
129
|
+
src/utils/op/
|
|
130
|
+
+ index.ts (275 lines - namespaced public API)
|
|
131
|
+
+ cli-interface.ts (42 lines)
|
|
132
|
+
+ cli.system.ts (179 lines)
|
|
133
|
+
+ session.ts (254 lines)
|
|
134
|
+
+ resolver.ts (331 lines)
|
|
135
|
+
+ secrets.ts (187 lines)
|
|
136
|
+
= Same 1,008 lines, better organized
|
|
137
|
+
|
|
138
|
+
# Import comparison
|
|
139
|
+
- import { parseSecretReference, buildSecretReference } from '@/utils/onepassword-secrets';
|
|
140
|
+
+ import { Secrets } from '@/utils/op';
|
|
141
|
+
+ Secrets.parse('op://...');
|
|
142
|
+
+ Secrets.build({ vault: 'Private', item: 'API', field: 'token' });
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Clean Namespace API Pattern
|
|
146
|
+
|
|
147
|
+
**Problem**: Exporting many individual functions creates messy imports and unclear API boundaries.
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
// ❌ BAD - Function-based exports are verbose and unclear
|
|
151
|
+
import {
|
|
152
|
+
parseSecretReference,
|
|
153
|
+
buildSecretReference,
|
|
154
|
+
validateSecretReference,
|
|
155
|
+
findSecretReferences,
|
|
156
|
+
extractSecretReferences,
|
|
157
|
+
hasSecretReferences,
|
|
158
|
+
redactSecretReferences,
|
|
159
|
+
isSecretReference,
|
|
160
|
+
getOnePasswordStatus,
|
|
161
|
+
getOnePasswordCLIPath,
|
|
162
|
+
getOnePasswordCLIVersion,
|
|
163
|
+
isOnePasswordCLIAvailable,
|
|
164
|
+
authenticateWithSession,
|
|
165
|
+
readSecretWithSession,
|
|
166
|
+
signOut,
|
|
167
|
+
} from "@/utils/op";
|
|
168
|
+
|
|
169
|
+
// Use functions
|
|
170
|
+
const result = parseSecretReference("op://Private/API/token");
|
|
171
|
+
const status = await getOnePasswordStatus();
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
**Solution**: Use namespaced classes with static methods for cleaner API.
|
|
175
|
+
|
|
176
|
+
```typescript
|
|
177
|
+
// ✅ GOOD - Namespaced classes provide clear boundaries
|
|
178
|
+
import { Secrets, Status, Session } from "@/utils/op";
|
|
179
|
+
|
|
180
|
+
// Use namespaced methods
|
|
181
|
+
const result = Secrets.parse("op://Private/API/token");
|
|
182
|
+
const status = await Status.get();
|
|
183
|
+
await Session.authenticate();
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
**Implementation pattern:**
|
|
187
|
+
|
|
188
|
+
<details>
|
|
189
|
+
<summary><strong>Step 1: Module files export functions AND classes</strong></summary>
|
|
190
|
+
|
|
191
|
+
Each module file exports both functions and a namespaced class:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
// src/utils/op/secrets.ts
|
|
195
|
+
|
|
196
|
+
// Individual functions (implementation)
|
|
197
|
+
export function parseSecretReference(uri: string): ParseResult {
|
|
198
|
+
// Implementation
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function buildSecretReference(ref: SecretReference): string {
|
|
202
|
+
// Implementation
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function validateSecretReference(ref: SecretReference): ParseResult {
|
|
206
|
+
// Implementation
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Namespaced class (public API)
|
|
210
|
+
export class Secrets {
|
|
211
|
+
static build = buildSecretReference;
|
|
212
|
+
static extract = extractSecretReferences;
|
|
213
|
+
static find = findSecretReferences;
|
|
214
|
+
static has = hasSecretReferences;
|
|
215
|
+
static is = isSecretReference;
|
|
216
|
+
static parse = parseSecretReference;
|
|
217
|
+
static redact = redactSecretReferences;
|
|
218
|
+
static validate = validateSecretReference;
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
**Why both exports?**
|
|
223
|
+
|
|
224
|
+
- **Functions**: Backward compatibility, tree-shaking optimization
|
|
225
|
+
- **Class**: Clean namespace API, better discoverability
|
|
226
|
+
|
|
227
|
+
</details>
|
|
228
|
+
|
|
229
|
+
<details>
|
|
230
|
+
<summary><strong>Step 2: Index.ts re-exports classes</strong></summary>
|
|
231
|
+
|
|
232
|
+
The `index.ts` file simply re-exports classes from modules:
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
// src/utils/op/index.ts
|
|
236
|
+
|
|
237
|
+
// ============================================================================
|
|
238
|
+
// Namespaced Class Exports
|
|
239
|
+
// ============================================================================
|
|
240
|
+
|
|
241
|
+
export { Errors } from "./errors.js";
|
|
242
|
+
export { Resolver } from "./resolver.js";
|
|
243
|
+
export { Secrets } from "./secrets.js";
|
|
244
|
+
export { Session } from "./session.js";
|
|
245
|
+
export { Setup } from "./setup.js";
|
|
246
|
+
export { Status } from "./status.js";
|
|
247
|
+
|
|
248
|
+
// ============================================================================
|
|
249
|
+
// Individual Function Exports (for backward compatibility)
|
|
250
|
+
// NOTE: Only applicable if the library is used externally in production, or we are not
|
|
251
|
+
// moving to a new major version.
|
|
252
|
+
// Always ASK THE USER if they need this unless this is only used inside the
|
|
253
|
+
// project itself.
|
|
254
|
+
// ============================================================================
|
|
255
|
+
|
|
256
|
+
export {
|
|
257
|
+
parseSecretReference,
|
|
258
|
+
buildSecretReference,
|
|
259
|
+
validateSecretReference,
|
|
260
|
+
// ... other functions
|
|
261
|
+
} from "./secrets.js";
|
|
262
|
+
|
|
263
|
+
// Export types directly
|
|
264
|
+
export type { ParseResult, SecretReference } from "./secrets.js";
|
|
265
|
+
export type { OnePasswordStatus } from "./status.js";
|
|
266
|
+
export type { AuthResult } from "./session.js";
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Benefits of this approach:**
|
|
270
|
+
|
|
271
|
+
- Classes are defined where implementation lives
|
|
272
|
+
- `index.ts` is a simple re-export gateway
|
|
273
|
+
- Maintainers see class and functions together in same file
|
|
274
|
+
- No mental mapping between file structure and API surface
|
|
275
|
+
|
|
276
|
+
</details>
|
|
277
|
+
|
|
278
|
+
<details>
|
|
279
|
+
<summary><strong>Step 3: Consumers use namespaced API</strong></summary>
|
|
280
|
+
|
|
281
|
+
Application code imports and uses namespaced classes:
|
|
282
|
+
|
|
283
|
+
```typescript
|
|
284
|
+
// src/services/config-loader.ts
|
|
285
|
+
import { Secrets, Session } from "@/utils/op";
|
|
286
|
+
|
|
287
|
+
export async function loadSecretConfig(configText: string): Promise<Config> {
|
|
288
|
+
// Check if config contains secrets
|
|
289
|
+
if (!Secrets.has(configText)) {
|
|
290
|
+
return parseConfig(configText);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Authenticate if needed
|
|
294
|
+
if (!Session.isValid()) {
|
|
295
|
+
await Session.authenticate();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Resolve all secrets in config
|
|
299
|
+
const resolved = await Resolver.resolveInText(configText);
|
|
300
|
+
|
|
301
|
+
return parseConfig(resolved.text);
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
</details>
|
|
306
|
+
|
|
307
|
+
**Benefits of namespaced classes:**
|
|
308
|
+
|
|
309
|
+
1. **Clear organization** - Related functions grouped under logical namespaces
|
|
310
|
+
2. **Discoverable API** - IDE autocomplete shows `Secrets.` and lists all secret operations
|
|
311
|
+
3. **Self-documenting** - Class name indicates domain (`Secrets`, `Session`, `Status`)
|
|
312
|
+
4. **Easier imports** - Import 3 classes instead of 20 functions
|
|
313
|
+
5. **Better tree-shaking** - Unused namespaces can be eliminated by bundlers
|
|
314
|
+
6. **Consistent naming** - `Secrets.parse()` instead of `parseSecretReference()`
|
|
315
|
+
7. **Type safety** - Same TypeScript checking as functions
|
|
316
|
+
8. **No runtime overhead** - Static methods compile to function calls
|
|
317
|
+
|
|
318
|
+
**API comparison:**
|
|
319
|
+
|
|
320
|
+
| Pattern | Import Statement | Usage | Clarity |
|
|
321
|
+
| -------------------- | ------------------------------------ | ---------------------------------- | ---------- |
|
|
322
|
+
| Individual functions | `import { parseSecret... } from '.'` | `parseSecretReference('op://...')` | ❌ Verbose |
|
|
323
|
+
| Namespaced classes | `import { Secrets } from '.'` | `Secrets.parse('op://...')` | ✅ Clear |
|
|
324
|
+
| Default export | `import secrets from '.'` | `secrets.parse('op://...')` | ⚠️ Opaque |
|
|
325
|
+
| Instance methods | `const s = new Secrets(); s.parse()` | Creates unnecessary instances | ❌ Wrong |
|
|
326
|
+
|
|
327
|
+
**When to use this pattern:**
|
|
328
|
+
|
|
329
|
+
- **5+ related functions** - Namespace reduces import clutter
|
|
330
|
+
- **Clear domain boundaries** - Functions naturally group by purpose
|
|
331
|
+
- **Stable API** - Public API unlikely to change frequently
|
|
332
|
+
- **Module folders** - Works best with organized folder structure
|
|
333
|
+
|
|
334
|
+
**When NOT to use:**
|
|
335
|
+
|
|
336
|
+
- **1-3 utility functions** - Direct exports are simpler
|
|
337
|
+
- **Mixed concerns** - Functions don't group naturally
|
|
338
|
+
- **Tree-shakeable critical** - Function-level exports optimize better in some bundlers
|
|
339
|
+
|
|
340
|
+
**Coverage note**: `index.ts` files with only re-exports should be excluded from coverage:
|
|
341
|
+
|
|
342
|
+
```toml
|
|
343
|
+
# bunfig.toml
|
|
344
|
+
[test]
|
|
345
|
+
coveragePathIgnorePatterns = [
|
|
346
|
+
"**/*.system.ts",
|
|
347
|
+
"**/index.ts", # Re-export files have no business logic
|
|
348
|
+
]
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
## Step-by-Step Refactoring Process
|
|
352
|
+
|
|
353
|
+
### Step 1: Identify System Boundaries
|
|
354
|
+
|
|
355
|
+
Find files that mix business logic with system interactions:
|
|
356
|
+
|
|
357
|
+
```typescript
|
|
358
|
+
// ❌ Mixed concerns - hard to test
|
|
359
|
+
export async function processUserData(userId: string): Promise<User> {
|
|
360
|
+
// Business logic
|
|
361
|
+
if (!userId || userId.length < 5) {
|
|
362
|
+
throw new Error("Invalid user ID");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// System interaction (blocks testing)
|
|
366
|
+
const { stdout } = await execAsync(`curl https://api.example.com/users/${userId}`);
|
|
367
|
+
const rawData = JSON.parse(stdout);
|
|
368
|
+
|
|
369
|
+
// Business logic
|
|
370
|
+
return {
|
|
371
|
+
id: rawData.id,
|
|
372
|
+
name: rawData.full_name.toUpperCase(),
|
|
373
|
+
email: rawData.email_address,
|
|
374
|
+
verified: rawData.status === "active",
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
**Identify the boundary:**
|
|
380
|
+
|
|
381
|
+
- **Business logic**: Validation, transformation, business rules
|
|
382
|
+
- **System interaction**: HTTP calls, file I/O, process execution, database queries
|
|
383
|
+
|
|
384
|
+
### Step 2: Define Interface
|
|
385
|
+
|
|
386
|
+
Create an interface that describes all system operations:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
// src/services/user-api-interface.ts
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* User API system operations interface
|
|
393
|
+
* Abstracts HTTP calls for testability
|
|
394
|
+
*/
|
|
395
|
+
export interface IUserAPI {
|
|
396
|
+
/**
|
|
397
|
+
* Fetch user data from remote API
|
|
398
|
+
*
|
|
399
|
+
* @param userId - User identifier
|
|
400
|
+
* @returns Raw user data from API
|
|
401
|
+
* @throws {Error} If API call fails or user not found
|
|
402
|
+
*/
|
|
403
|
+
fetchUser(userId: string): Promise<RawUserData>;
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Update user data via API
|
|
407
|
+
*
|
|
408
|
+
* @param userId - User identifier
|
|
409
|
+
* @param updates - Fields to update
|
|
410
|
+
* @returns Updated user data
|
|
411
|
+
*/
|
|
412
|
+
updateUser(userId: string, updates: Partial<RawUserData>): Promise<RawUserData>;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Raw API response shape
|
|
417
|
+
*/
|
|
418
|
+
export interface RawUserData {
|
|
419
|
+
id: string;
|
|
420
|
+
full_name: string;
|
|
421
|
+
email_address: string;
|
|
422
|
+
status: "active" | "inactive" | "suspended";
|
|
423
|
+
created_at: string;
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
**Interface design guidelines:**
|
|
428
|
+
|
|
429
|
+
- **One interface per system boundary** (HTTP client, database, file system, etc.)
|
|
430
|
+
- **Synchronous when possible** - Use sync methods for file system if business logic needs sync behavior
|
|
431
|
+
- **Throw errors** - Don't return error codes; use exceptions for system failures
|
|
432
|
+
- **Platform-agnostic** - Abstract away OS-specific details
|
|
433
|
+
- **Minimal surface area** - Only expose operations actually needed by business logic
|
|
434
|
+
|
|
435
|
+
### Step 3: Create System Implementation
|
|
436
|
+
|
|
437
|
+
Implement the interface with actual system calls in a `*.system.ts` file:
|
|
438
|
+
|
|
439
|
+
```typescript
|
|
440
|
+
// src/services/user-api.system.ts
|
|
441
|
+
|
|
442
|
+
import { exec } from "node:child_process";
|
|
443
|
+
import { promisify } from "node:util";
|
|
444
|
+
import type { IUserAPI, RawUserData } from "./user-api-interface.js";
|
|
445
|
+
|
|
446
|
+
const execAsync = promisify(exec);
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Real User API implementation using curl
|
|
450
|
+
*
|
|
451
|
+
* **Coverage note**: This file uses the .system.ts suffix and is automatically
|
|
452
|
+
* excluded from coverage via glob pattern: **\/*.system.ts
|
|
453
|
+
*
|
|
454
|
+
* This is a thin wrapper with no business logic - all system interaction.
|
|
455
|
+
*
|
|
456
|
+
* @file user-api.system.ts
|
|
457
|
+
*/
|
|
458
|
+
export class UserAPISystem implements IUserAPI {
|
|
459
|
+
constructor(private baseURL: string = "https://api.example.com") {}
|
|
460
|
+
|
|
461
|
+
async fetchUser(userId: string): Promise<RawUserData> {
|
|
462
|
+
try {
|
|
463
|
+
const { stdout } = await execAsync(`curl -s ${this.baseURL}/users/${userId}`);
|
|
464
|
+
return JSON.parse(stdout);
|
|
465
|
+
} catch (error) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`Failed to fetch user ${userId}: ${error instanceof Error ? error.message : String(error)}`
|
|
468
|
+
);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async updateUser(userId: string, updates: Partial<RawUserData>): Promise<RawUserData> {
|
|
473
|
+
try {
|
|
474
|
+
const payload = JSON.stringify(updates);
|
|
475
|
+
const { stdout } = await execAsync(
|
|
476
|
+
`curl -s -X PATCH -H "Content-Type: application/json" -d '${payload}' ${this.baseURL}/users/${userId}`
|
|
477
|
+
);
|
|
478
|
+
return JSON.parse(stdout);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
throw new Error(
|
|
481
|
+
`Failed to update user ${userId}: ${error instanceof Error ? error.message : String(error)}`
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Singleton instance for convenience
|
|
489
|
+
* Use this in production code
|
|
490
|
+
*/
|
|
491
|
+
export const defaultUserAPI = new UserAPISystem();
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
**System implementation guidelines:**
|
|
495
|
+
|
|
496
|
+
- **Keep it thin** - No business logic, only system call wrappers
|
|
497
|
+
- **Document exclusion** - Add comment about `*.system.ts` coverage exclusion
|
|
498
|
+
- **Provide default instance** - Export singleton for convenience
|
|
499
|
+
- **Error translation** - Convert system errors to domain exceptions
|
|
500
|
+
- **Configuration via constructor** - Allow dependency injection of config (URLs, paths, etc.)
|
|
501
|
+
|
|
502
|
+
### Step 4: Create Mock Implementation
|
|
503
|
+
|
|
504
|
+
Create a test double that implements the interface with in-memory operations:
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
// tests/mocks/user-api-mock.ts
|
|
508
|
+
|
|
509
|
+
import type { IUserAPI, RawUserData } from "../../src/services/user-api-interface.js";
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Mock User API for testing
|
|
513
|
+
* No network calls, all operations are in-memory
|
|
514
|
+
*/
|
|
515
|
+
export class UserAPIMock implements IUserAPI {
|
|
516
|
+
private users = new Map<string, RawUserData>();
|
|
517
|
+
private fetchShouldFail = false;
|
|
518
|
+
private updateShouldFail = false;
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Pre-populate mock with user data
|
|
522
|
+
*/
|
|
523
|
+
setUser(userId: string, data: RawUserData): void {
|
|
524
|
+
this.users.set(userId, data);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* Simulate API failures for error testing
|
|
529
|
+
*/
|
|
530
|
+
setFetchShouldFail(fail: boolean): void {
|
|
531
|
+
this.fetchShouldFail = fail;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
setUpdateShouldFail(fail: boolean): void {
|
|
535
|
+
this.updateShouldFail = fail;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Clear all mock data
|
|
540
|
+
*/
|
|
541
|
+
clear(): void {
|
|
542
|
+
this.users.clear();
|
|
543
|
+
this.fetchShouldFail = false;
|
|
544
|
+
this.updateShouldFail = false;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// IUserAPI implementation
|
|
548
|
+
|
|
549
|
+
async fetchUser(userId: string): Promise<RawUserData> {
|
|
550
|
+
if (this.fetchShouldFail) {
|
|
551
|
+
throw new Error("Network error: Connection timeout");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const user = this.users.get(userId);
|
|
555
|
+
if (!user) {
|
|
556
|
+
throw new Error(`User not found: ${userId}`);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return user;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async updateUser(userId: string, updates: Partial<RawUserData>): Promise<RawUserData> {
|
|
563
|
+
if (this.updateShouldFail) {
|
|
564
|
+
throw new Error("Network error: Server returned 500");
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const existing = this.users.get(userId);
|
|
568
|
+
if (!existing) {
|
|
569
|
+
throw new Error(`User not found: ${userId}`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const updated = { ...existing, ...updates };
|
|
573
|
+
this.users.set(userId, updated);
|
|
574
|
+
return updated;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
**Mock implementation guidelines:**
|
|
580
|
+
|
|
581
|
+
- **Implement full interface** - All methods from system interface
|
|
582
|
+
- **In-memory state** - No actual system calls
|
|
583
|
+
- **Configuration methods** - Allow tests to control behavior (simulate failures, set data)
|
|
584
|
+
- **Realistic behavior** - Throw same errors as real implementation
|
|
585
|
+
- **Stateful** - Maintain state across calls within a test
|
|
586
|
+
|
|
587
|
+
### Step 5: Refactor Business Logic
|
|
588
|
+
|
|
589
|
+
Update business logic to accept interface parameter with default value:
|
|
590
|
+
|
|
591
|
+
```typescript
|
|
592
|
+
// src/services/user-service.ts
|
|
593
|
+
|
|
594
|
+
import type { IUserAPI, RawUserData } from "./user-api-interface.js";
|
|
595
|
+
import { defaultUserAPI } from "./user-api.system.js";
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Normalized user model for application use
|
|
599
|
+
*/
|
|
600
|
+
export interface User {
|
|
601
|
+
id: string;
|
|
602
|
+
name: string;
|
|
603
|
+
email: string;
|
|
604
|
+
verified: boolean;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Process and normalize user data
|
|
609
|
+
*
|
|
610
|
+
* @param userId - User identifier
|
|
611
|
+
* @param api - User API implementation (defaults to real API)
|
|
612
|
+
* @returns Normalized user object
|
|
613
|
+
* @throws {Error} If user ID invalid or API call fails
|
|
614
|
+
*/
|
|
615
|
+
export async function processUserData(
|
|
616
|
+
userId: string,
|
|
617
|
+
api: IUserAPI = defaultUserAPI
|
|
618
|
+
): Promise<User> {
|
|
619
|
+
// Business logic: Validation
|
|
620
|
+
if (!userId || userId.length < 5) {
|
|
621
|
+
throw new Error("Invalid user ID: must be at least 5 characters");
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// System interaction (delegated to interface)
|
|
625
|
+
const rawData = await api.fetchUser(userId);
|
|
626
|
+
|
|
627
|
+
// Business logic: Transformation
|
|
628
|
+
return {
|
|
629
|
+
id: rawData.id,
|
|
630
|
+
name: rawData.full_name.toUpperCase(),
|
|
631
|
+
email: rawData.email_address,
|
|
632
|
+
verified: rawData.status === "active",
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Update user status
|
|
638
|
+
*
|
|
639
|
+
* @param userId - User identifier
|
|
640
|
+
* @param active - Whether user should be active
|
|
641
|
+
* @param api - User API implementation (defaults to real API)
|
|
642
|
+
*/
|
|
643
|
+
export async function updateUserStatus(
|
|
644
|
+
userId: string,
|
|
645
|
+
active: boolean,
|
|
646
|
+
api: IUserAPI = defaultUserAPI
|
|
647
|
+
): Promise<void> {
|
|
648
|
+
// Business logic: Determine status value
|
|
649
|
+
const status: RawUserData["status"] = active ? "active" : "inactive";
|
|
650
|
+
|
|
651
|
+
// System interaction (delegated to interface)
|
|
652
|
+
await api.updateUser(userId, { status });
|
|
653
|
+
}
|
|
654
|
+
```
|
|
655
|
+
|
|
656
|
+
**Refactoring guidelines:**
|
|
657
|
+
|
|
658
|
+
- **Add interface parameter last** - With default value for backward compatibility
|
|
659
|
+
- **Use interface type** - Not concrete implementation type
|
|
660
|
+
- **Keep business logic pure** - All system interaction through interface
|
|
661
|
+
- **Maintain public API** - Existing callers work without changes
|
|
662
|
+
- **Document parameter** - Explain why interface is injectable
|
|
663
|
+
|
|
664
|
+
### Step 6: Write Tests
|
|
665
|
+
|
|
666
|
+
Use mock implementation for fast, deterministic tests:
|
|
667
|
+
|
|
668
|
+
```typescript
|
|
669
|
+
// tests/unit/services/user-service.test.ts
|
|
670
|
+
|
|
671
|
+
import { describe, expect, it, beforeEach } from "bun:test";
|
|
672
|
+
import { UserAPIMock } from "../../mocks/user-api-mock.js";
|
|
673
|
+
import { processUserData, updateUserStatus } from "../../../src/services/user-service.js";
|
|
674
|
+
|
|
675
|
+
describe("User Service", () => {
|
|
676
|
+
let mockAPI: UserAPIMock;
|
|
677
|
+
|
|
678
|
+
beforeEach(() => {
|
|
679
|
+
mockAPI = new UserAPIMock();
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
describe("processUserData", () => {
|
|
683
|
+
it("should normalize user data from API", async () => {
|
|
684
|
+
// Arrange
|
|
685
|
+
mockAPI.setUser("user-12345", {
|
|
686
|
+
id: "user-12345",
|
|
687
|
+
full_name: "john doe",
|
|
688
|
+
email_address: "john@example.com",
|
|
689
|
+
status: "active",
|
|
690
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
// Act
|
|
694
|
+
const user = await processUserData("user-12345", mockAPI);
|
|
695
|
+
|
|
696
|
+
// Assert
|
|
697
|
+
expect(user).toEqual({
|
|
698
|
+
id: "user-12345",
|
|
699
|
+
name: "JOHN DOE", // Uppercased
|
|
700
|
+
email: "john@example.com",
|
|
701
|
+
verified: true, // active -> verified
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("should reject invalid user IDs", async () => {
|
|
706
|
+
// Act & Assert
|
|
707
|
+
await expect(processUserData("123", mockAPI)).rejects.toThrow(
|
|
708
|
+
"Invalid user ID: must be at least 5 characters"
|
|
709
|
+
);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("should handle API failures", async () => {
|
|
713
|
+
// Arrange
|
|
714
|
+
mockAPI.setFetchShouldFail(true);
|
|
715
|
+
|
|
716
|
+
// Act & Assert
|
|
717
|
+
await expect(processUserData("user-12345", mockAPI)).rejects.toThrow("Network error");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("should mark inactive users as unverified", async () => {
|
|
721
|
+
// Arrange
|
|
722
|
+
mockAPI.setUser("user-12345", {
|
|
723
|
+
id: "user-12345",
|
|
724
|
+
full_name: "jane doe",
|
|
725
|
+
email_address: "jane@example.com",
|
|
726
|
+
status: "inactive",
|
|
727
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
// Act
|
|
731
|
+
const user = await processUserData("user-12345", mockAPI);
|
|
732
|
+
|
|
733
|
+
// Assert
|
|
734
|
+
expect(user.verified).toBe(false);
|
|
735
|
+
});
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
describe("updateUserStatus", () => {
|
|
739
|
+
beforeEach(() => {
|
|
740
|
+
mockAPI.setUser("user-12345", {
|
|
741
|
+
id: "user-12345",
|
|
742
|
+
full_name: "john doe",
|
|
743
|
+
email_address: "john@example.com",
|
|
744
|
+
status: "active",
|
|
745
|
+
created_at: "2024-01-01T00:00:00Z",
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
it("should activate user", async () => {
|
|
750
|
+
// Act
|
|
751
|
+
await updateUserStatus("user-12345", true, mockAPI);
|
|
752
|
+
|
|
753
|
+
// Assert
|
|
754
|
+
const updated = await mockAPI.fetchUser("user-12345");
|
|
755
|
+
expect(updated.status).toBe("active");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("should deactivate user", async () => {
|
|
759
|
+
// Act
|
|
760
|
+
await updateUserStatus("user-12345", false, mockAPI);
|
|
761
|
+
|
|
762
|
+
// Assert
|
|
763
|
+
const updated = await mockAPI.fetchUser("user-12345");
|
|
764
|
+
expect(updated.status).toBe("inactive");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("should handle update failures", async () => {
|
|
768
|
+
// Arrange
|
|
769
|
+
mockAPI.setUpdateShouldFail(true);
|
|
770
|
+
|
|
771
|
+
// Act & Assert
|
|
772
|
+
await expect(updateUserStatus("user-12345", true, mockAPI)).rejects.toThrow("Network error");
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
**Test guidelines:**
|
|
779
|
+
|
|
780
|
+
- **No system calls** - Tests run in-memory only
|
|
781
|
+
- **Fast execution** - Entire suite should run in seconds
|
|
782
|
+
- **Deterministic** - Same inputs always produce same outputs
|
|
783
|
+
- **Test business logic** - Focus on validation, transformation, error handling
|
|
784
|
+
- **Test error paths** - Use mock configuration to simulate failures
|
|
785
|
+
|
|
786
|
+
### Step 7: Configure Coverage Exclusion
|
|
787
|
+
|
|
788
|
+
Add `*.system.ts` pattern to coverage configuration:
|
|
789
|
+
|
|
790
|
+
<details>
|
|
791
|
+
<summary><strong>Bun (bunfig.toml)</strong></summary>
|
|
792
|
+
|
|
793
|
+
```toml
|
|
794
|
+
[test]
|
|
795
|
+
coveragePathIgnorePatterns = [
|
|
796
|
+
"**/*.system.ts", # All system interaction files
|
|
797
|
+
"tests/**", # Test files
|
|
798
|
+
"**/*.test.ts", # More test files
|
|
799
|
+
"**/types/**", # Type definition files
|
|
800
|
+
]
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
</details>
|
|
804
|
+
|
|
805
|
+
<details>
|
|
806
|
+
<summary><strong>Jest (jest.config.js)</strong></summary>
|
|
807
|
+
|
|
808
|
+
```javascript
|
|
809
|
+
export default {
|
|
810
|
+
coveragePathIgnorePatterns: [
|
|
811
|
+
".*\\.system\\.ts$", // All system interaction files
|
|
812
|
+
"/node_modules/",
|
|
813
|
+
"/tests/",
|
|
814
|
+
"\\.test\\.ts$",
|
|
815
|
+
],
|
|
816
|
+
};
|
|
817
|
+
```
|
|
818
|
+
|
|
819
|
+
</details>
|
|
820
|
+
|
|
821
|
+
<details>
|
|
822
|
+
<summary><strong>Vitest (vitest.config.ts)</strong></summary>
|
|
823
|
+
|
|
824
|
+
```typescript
|
|
825
|
+
export default defineConfig({
|
|
826
|
+
test: {
|
|
827
|
+
coverage: {
|
|
828
|
+
exclude: [
|
|
829
|
+
"**/*.system.ts", // All system interaction files
|
|
830
|
+
"tests/**",
|
|
831
|
+
"**/*.test.ts",
|
|
832
|
+
"**/types/**",
|
|
833
|
+
],
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
```
|
|
838
|
+
|
|
839
|
+
</details>
|
|
840
|
+
|
|
841
|
+
<details>
|
|
842
|
+
<summary><strong>SonarQube (sonar-project.properties)</strong></summary>
|
|
843
|
+
|
|
844
|
+
```properties
|
|
845
|
+
# Coverage exclusions
|
|
846
|
+
sonar.coverage.exclusions=\
|
|
847
|
+
**/*.system.ts,\
|
|
848
|
+
tests/**,\
|
|
849
|
+
**/*.test.ts,\
|
|
850
|
+
**/types/**
|
|
851
|
+
```
|
|
852
|
+
|
|
853
|
+
</details>
|
|
854
|
+
|
|
855
|
+
## Common Patterns
|
|
856
|
+
|
|
857
|
+
### Pattern 1: File System Operations
|
|
858
|
+
|
|
859
|
+
<details>
|
|
860
|
+
<summary><strong>File system abstraction example</strong></summary>
|
|
861
|
+
|
|
862
|
+
**Interface:**
|
|
863
|
+
|
|
864
|
+
```typescript
|
|
865
|
+
// src/utils/filesystem-interface.ts
|
|
866
|
+
|
|
867
|
+
export interface IFileSystem {
|
|
868
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
869
|
+
writeFile(path: string, content: string, encoding: BufferEncoding): Promise<void>;
|
|
870
|
+
exists(path: string): Promise<boolean>;
|
|
871
|
+
mkdir(path: string, options?: { recursive?: boolean }): Promise<void>;
|
|
872
|
+
readdir(path: string): Promise<string[]>;
|
|
873
|
+
}
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
**System implementation:**
|
|
877
|
+
|
|
878
|
+
```typescript
|
|
879
|
+
// src/utils/filesystem.system.ts
|
|
880
|
+
|
|
881
|
+
import { readFile, writeFile, access, mkdir, readdir } from "node:fs/promises";
|
|
882
|
+
import type { IFileSystem } from "./filesystem-interface.js";
|
|
883
|
+
|
|
884
|
+
export class FileSystemReal implements IFileSystem {
|
|
885
|
+
async readFile(path: string, encoding: BufferEncoding): Promise<string> {
|
|
886
|
+
return readFile(path, encoding);
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async writeFile(path: string, content: string, encoding: BufferEncoding): Promise<void> {
|
|
890
|
+
await writeFile(path, content, encoding);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
async exists(path: string): Promise<boolean> {
|
|
894
|
+
try {
|
|
895
|
+
await access(path);
|
|
896
|
+
return true;
|
|
897
|
+
} catch {
|
|
898
|
+
return false;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
903
|
+
await mkdir(path, options);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
async readdir(path: string): Promise<string[]> {
|
|
907
|
+
return readdir(path);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
export const defaultFS = new FileSystemReal();
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
**Mock:**
|
|
915
|
+
|
|
916
|
+
```typescript
|
|
917
|
+
// tests/mocks/filesystem-mock.ts
|
|
918
|
+
|
|
919
|
+
export class FileSystemMock implements IFileSystem {
|
|
920
|
+
private files = new Map<string, string>();
|
|
921
|
+
private dirs = new Set<string>();
|
|
922
|
+
|
|
923
|
+
setFile(path: string, content: string): void {
|
|
924
|
+
this.files.set(path, content);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
setDir(path: string): void {
|
|
928
|
+
this.dirs.add(path);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
clear(): void {
|
|
932
|
+
this.files.clear();
|
|
933
|
+
this.dirs.clear();
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async readFile(path: string, encoding: BufferEncoding): Promise<string> {
|
|
937
|
+
const content = this.files.get(path);
|
|
938
|
+
if (!content) throw new Error(`ENOENT: no such file or directory, open '${path}'`);
|
|
939
|
+
return content;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
async writeFile(path: string, content: string, encoding: BufferEncoding): Promise<void> {
|
|
943
|
+
this.files.set(path, content);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
async exists(path: string): Promise<boolean> {
|
|
947
|
+
return this.files.has(path) || this.dirs.has(path);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async mkdir(path: string, options?: { recursive?: boolean }): Promise<void> {
|
|
951
|
+
this.dirs.add(path);
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
async readdir(path: string): Promise<string[]> {
|
|
955
|
+
if (!this.dirs.has(path))
|
|
956
|
+
throw new Error(`ENOENT: no such file or directory, scandir '${path}'`);
|
|
957
|
+
return Array.from(this.files.keys()).filter((f) => f.startsWith(path + "/"));
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
```
|
|
961
|
+
|
|
962
|
+
</details>
|
|
963
|
+
|
|
964
|
+
### Pattern 2: Shell Command Execution
|
|
965
|
+
|
|
966
|
+
<details>
|
|
967
|
+
<summary><strong>Shell execution abstraction example</strong></summary>
|
|
968
|
+
|
|
969
|
+
**Interface:**
|
|
970
|
+
|
|
971
|
+
```typescript
|
|
972
|
+
// src/utils/shell-interface.ts
|
|
973
|
+
|
|
974
|
+
export interface IShellExecutor {
|
|
975
|
+
exec(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
976
|
+
execSync(command: string): { stdout: string; stderr: string; exitCode: number };
|
|
977
|
+
commandExists(command: string): Promise<boolean>;
|
|
978
|
+
}
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
**System implementation:**
|
|
982
|
+
|
|
983
|
+
```typescript
|
|
984
|
+
// src/utils/shell.system.ts
|
|
985
|
+
|
|
986
|
+
import { exec, execSync as nodeExecSync } from "node:child_process";
|
|
987
|
+
import { promisify } from "node:util";
|
|
988
|
+
import type { IShellExecutor } from "./shell-interface.js";
|
|
989
|
+
|
|
990
|
+
const execAsync = promisify(exec);
|
|
991
|
+
|
|
992
|
+
export class ShellExecutorReal implements IShellExecutor {
|
|
993
|
+
async exec(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
994
|
+
try {
|
|
995
|
+
const { stdout, stderr } = await execAsync(command);
|
|
996
|
+
return { stdout, stderr, exitCode: 0 };
|
|
997
|
+
} catch (error: any) {
|
|
998
|
+
return {
|
|
999
|
+
stdout: error.stdout || "",
|
|
1000
|
+
stderr: error.stderr || "",
|
|
1001
|
+
exitCode: error.code || 1,
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
execSync(command: string): { stdout: string; stderr: string; exitCode: number } {
|
|
1007
|
+
try {
|
|
1008
|
+
const stdout = nodeExecSync(command, { encoding: "utf-8" });
|
|
1009
|
+
return { stdout, stderr: "", exitCode: 0 };
|
|
1010
|
+
} catch (error: any) {
|
|
1011
|
+
return {
|
|
1012
|
+
stdout: error.stdout || "",
|
|
1013
|
+
stderr: error.stderr || "",
|
|
1014
|
+
exitCode: error.status || 1,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
async commandExists(command: string): Promise<boolean> {
|
|
1020
|
+
const result = await this.exec(`command -v ${command}`);
|
|
1021
|
+
return result.exitCode === 0;
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export const defaultShell = new ShellExecutorReal();
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
**Mock:**
|
|
1029
|
+
|
|
1030
|
+
```typescript
|
|
1031
|
+
// tests/mocks/shell-mock.ts
|
|
1032
|
+
|
|
1033
|
+
export class ShellExecutorMock implements IShellExecutor {
|
|
1034
|
+
private commands = new Map<string, { stdout: string; stderr: string; exitCode: number }>();
|
|
1035
|
+
private availableCommands = new Set<string>(["ls", "cat", "grep"]);
|
|
1036
|
+
|
|
1037
|
+
setCommandOutput(command: string, stdout: string, stderr = "", exitCode = 0): void {
|
|
1038
|
+
this.commands.set(command, { stdout, stderr, exitCode });
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
setCommandExists(command: string, exists: boolean): void {
|
|
1042
|
+
if (exists) {
|
|
1043
|
+
this.availableCommands.add(command);
|
|
1044
|
+
} else {
|
|
1045
|
+
this.availableCommands.delete(command);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
clear(): void {
|
|
1050
|
+
this.commands.clear();
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async exec(command: string): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
1054
|
+
const result = this.commands.get(command);
|
|
1055
|
+
if (result) return result;
|
|
1056
|
+
|
|
1057
|
+
// Default behavior for unknown commands
|
|
1058
|
+
return { stdout: "", stderr: `sh: command not found: ${command}`, exitCode: 127 };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
execSync(command: string): { stdout: string; stderr: string; exitCode: number } {
|
|
1062
|
+
return this.exec(command) as any; // Sync version returns same data
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async commandExists(command: string): Promise<boolean> {
|
|
1066
|
+
return this.availableCommands.has(command);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
</details>
|
|
1072
|
+
|
|
1073
|
+
### Pattern 3: HTTP Client
|
|
1074
|
+
|
|
1075
|
+
<details>
|
|
1076
|
+
<summary><strong>HTTP client abstraction example</strong></summary>
|
|
1077
|
+
|
|
1078
|
+
**Interface:**
|
|
1079
|
+
|
|
1080
|
+
```typescript
|
|
1081
|
+
// src/utils/http-interface.ts
|
|
1082
|
+
|
|
1083
|
+
export interface IHTTPClient {
|
|
1084
|
+
get<T>(url: string, headers?: Record<string, string>): Promise<T>;
|
|
1085
|
+
post<T>(url: string, body: any, headers?: Record<string, string>): Promise<T>;
|
|
1086
|
+
put<T>(url: string, body: any, headers?: Record<string, string>): Promise<T>;
|
|
1087
|
+
delete<T>(url: string, headers?: Record<string, string>): Promise<T>;
|
|
1088
|
+
}
|
|
1089
|
+
```
|
|
1090
|
+
|
|
1091
|
+
**System implementation:**
|
|
1092
|
+
|
|
1093
|
+
```typescript
|
|
1094
|
+
// src/utils/http.system.ts
|
|
1095
|
+
|
|
1096
|
+
export class HTTPClientReal implements IHTTPClient {
|
|
1097
|
+
async get<T>(url: string, headers?: Record<string, string>): Promise<T> {
|
|
1098
|
+
const response = await fetch(url, { method: "GET", headers });
|
|
1099
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1100
|
+
return response.json();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
async post<T>(url: string, body: any, headers?: Record<string, string>): Promise<T> {
|
|
1104
|
+
const response = await fetch(url, {
|
|
1105
|
+
method: "POST",
|
|
1106
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
1107
|
+
body: JSON.stringify(body),
|
|
1108
|
+
});
|
|
1109
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1110
|
+
return response.json();
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
async put<T>(url: string, body: any, headers?: Record<string, string>): Promise<T> {
|
|
1114
|
+
const response = await fetch(url, {
|
|
1115
|
+
method: "PUT",
|
|
1116
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
1117
|
+
body: JSON.stringify(body),
|
|
1118
|
+
});
|
|
1119
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1120
|
+
return response.json();
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
async delete<T>(url: string, headers?: Record<string, string>): Promise<T> {
|
|
1124
|
+
const response = await fetch(url, { method: "DELETE", headers });
|
|
1125
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
1126
|
+
return response.json();
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
export const defaultHTTP = new HTTPClientReal();
|
|
1131
|
+
```
|
|
1132
|
+
|
|
1133
|
+
**Mock:**
|
|
1134
|
+
|
|
1135
|
+
```typescript
|
|
1136
|
+
// tests/mocks/http-mock.ts
|
|
1137
|
+
|
|
1138
|
+
export class HTTPClientMock implements IHTTPClient {
|
|
1139
|
+
private routes = new Map<string, any>();
|
|
1140
|
+
private shouldFail = false;
|
|
1141
|
+
|
|
1142
|
+
setRoute(method: string, url: string, response: any): void {
|
|
1143
|
+
this.routes.set(`${method}:${url}`, response);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
setShouldFail(fail: boolean): void {
|
|
1147
|
+
this.shouldFail = fail;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
clear(): void {
|
|
1151
|
+
this.routes.clear();
|
|
1152
|
+
this.shouldFail = false;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
async get<T>(url: string, headers?: Record<string, string>): Promise<T> {
|
|
1156
|
+
if (this.shouldFail) throw new Error("HTTP 500: Internal Server Error");
|
|
1157
|
+
const response = this.routes.get(`GET:${url}`);
|
|
1158
|
+
if (!response) throw new Error(`HTTP 404: Not Found`);
|
|
1159
|
+
return response;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
async post<T>(url: string, body: any, headers?: Record<string, string>): Promise<T> {
|
|
1163
|
+
if (this.shouldFail) throw new Error("HTTP 500: Internal Server Error");
|
|
1164
|
+
const response = this.routes.get(`POST:${url}`);
|
|
1165
|
+
if (!response) throw new Error(`HTTP 404: Not Found`);
|
|
1166
|
+
return response;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async put<T>(url: string, body: any, headers?: Record<string, string>): Promise<T> {
|
|
1170
|
+
if (this.shouldFail) throw new Error("HTTP 500: Internal Server Error");
|
|
1171
|
+
const response = this.routes.get(`PUT:${url}`);
|
|
1172
|
+
if (!response) throw new Error(`HTTP 404: Not Found`);
|
|
1173
|
+
return response;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async delete<T>(url: string, headers?: Record<string, string>): Promise<T> {
|
|
1177
|
+
if (this.shouldFail) throw new Error("HTTP 500: Internal Server Error");
|
|
1178
|
+
const response = this.routes.get(`DELETE:${url}`);
|
|
1179
|
+
if (!response) throw new Error(`HTTP 404: Not Found`);
|
|
1180
|
+
return response;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
```
|
|
1184
|
+
|
|
1185
|
+
</details>
|
|
1186
|
+
|
|
1187
|
+
## Benefits Summary
|
|
1188
|
+
|
|
1189
|
+
### Testing Benefits
|
|
1190
|
+
|
|
1191
|
+
- **Fast tests** - No system calls, all in-memory
|
|
1192
|
+
- **Deterministic** - Same inputs always produce same outputs
|
|
1193
|
+
- **Isolated** - No external dependencies
|
|
1194
|
+
- **Comprehensive** - Easy to test edge cases and error paths
|
|
1195
|
+
- **No mocking complexity** - Simple mock classes instead of complex spy setups
|
|
1196
|
+
|
|
1197
|
+
### Coverage Benefits
|
|
1198
|
+
|
|
1199
|
+
- **Accurate metrics** - Coverage reflects business logic complexity
|
|
1200
|
+
- **Focused exclusions** - Only thin system wrappers excluded
|
|
1201
|
+
- **Better visibility** - Business logic coverage clearly visible
|
|
1202
|
+
- **Scalable pattern** - One glob pattern covers all system files
|
|
1203
|
+
|
|
1204
|
+
### Maintainability Benefits
|
|
1205
|
+
|
|
1206
|
+
- **Clear boundaries** - Business logic vs. system interaction
|
|
1207
|
+
- **Single responsibility** - System files only do system calls
|
|
1208
|
+
- **Easy to change** - Swap system implementation without touching business logic
|
|
1209
|
+
- **Type safety** - Interface contract enforced at compile time
|
|
1210
|
+
- **Self-documenting** - File naming convention makes architecture clear
|
|
1211
|
+
|
|
1212
|
+
## Migration Checklist
|
|
1213
|
+
|
|
1214
|
+
When refactoring existing code:
|
|
1215
|
+
|
|
1216
|
+
- [ ] Identify file with mixed business logic and system calls
|
|
1217
|
+
- [ ] Define interface for system operations (`{domain}-interface.ts`)
|
|
1218
|
+
- [ ] Create system implementation (`{domain}.system.ts`)
|
|
1219
|
+
- [ ] Create mock implementation (`tests/mocks/{domain}-mock.ts`)
|
|
1220
|
+
- [ ] Refactor business logic to accept interface parameter
|
|
1221
|
+
- [ ] Add default parameter for backward compatibility
|
|
1222
|
+
- [ ] Update tests to use mock implementation
|
|
1223
|
+
- [ ] Add `**/*.system.ts` to coverage exclusion config
|
|
1224
|
+
- [ ] Verify all tests pass with mocks
|
|
1225
|
+
- [ ] Verify production code works with real implementation
|
|
1226
|
+
- [ ] Remove old file from coverage exclusion list (if it was excluded)
|
|
1227
|
+
- [ ] Update documentation to explain new architecture
|
|
1228
|
+
|
|
1229
|
+
## Anti-Patterns to Avoid
|
|
1230
|
+
|
|
1231
|
+
**Don't put business logic in `*.system.ts` files:**
|
|
1232
|
+
|
|
1233
|
+
```typescript
|
|
1234
|
+
// ❌ BAD - Business logic in system file
|
|
1235
|
+
export class UserAPISystem implements IUserAPI {
|
|
1236
|
+
async fetchUser(userId: string): Promise<RawUserData> {
|
|
1237
|
+
// Business logic belongs in service layer, not here
|
|
1238
|
+
if (!userId || userId.length < 5) {
|
|
1239
|
+
throw new Error("Invalid user ID");
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const { stdout } = await execAsync(`curl ${this.baseURL}/users/${userId}`);
|
|
1243
|
+
return JSON.parse(stdout);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
```
|
|
1247
|
+
|
|
1248
|
+
```typescript
|
|
1249
|
+
// ✅ GOOD - Pure system interaction
|
|
1250
|
+
export class UserAPISystem implements IUserAPI {
|
|
1251
|
+
async fetchUser(userId: string): Promise<RawUserData> {
|
|
1252
|
+
const { stdout } = await execAsync(`curl ${this.baseURL}/users/${userId}`);
|
|
1253
|
+
return JSON.parse(stdout);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
```
|
|
1257
|
+
|
|
1258
|
+
**Don't create overly broad interfaces:**
|
|
1259
|
+
|
|
1260
|
+
```typescript
|
|
1261
|
+
// ❌ BAD - Too many responsibilities
|
|
1262
|
+
export interface ISystem {
|
|
1263
|
+
execCommand(cmd: string): Promise<string>;
|
|
1264
|
+
readFile(path: string): Promise<string>;
|
|
1265
|
+
httpGet(url: string): Promise<any>;
|
|
1266
|
+
queryDatabase(sql: string): Promise<any[]>;
|
|
1267
|
+
}
|
|
1268
|
+
```
|
|
1269
|
+
|
|
1270
|
+
```typescript
|
|
1271
|
+
// ✅ GOOD - Focused interfaces
|
|
1272
|
+
export interface IShellExecutor {
|
|
1273
|
+
exec(command: string): Promise<{ stdout: string; stderr: string }>;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
export interface IFileSystem {
|
|
1277
|
+
readFile(path: string, encoding: BufferEncoding): Promise<string>;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
export interface IHTTPClient {
|
|
1281
|
+
get<T>(url: string): Promise<T>;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
export interface IDatabase {
|
|
1285
|
+
query<T>(sql: string, params?: any[]): Promise<T[]>;
|
|
1286
|
+
}
|
|
1287
|
+
```
|
|
1288
|
+
|
|
1289
|
+
**Don't skip the interface:**
|
|
1290
|
+
|
|
1291
|
+
```typescript
|
|
1292
|
+
// ❌ BAD - Direct dependency on concrete class
|
|
1293
|
+
export async function processUser(userId: string, api: UserAPISystem): Promise<User> {
|
|
1294
|
+
// Now can't substitute mock
|
|
1295
|
+
}
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
```typescript
|
|
1299
|
+
// ✅ GOOD - Depend on interface
|
|
1300
|
+
export async function processUser(userId: string, api: IUserAPI): Promise<User> {
|
|
1301
|
+
// Can use real or mock implementation
|
|
1302
|
+
}
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
## Related Documentation
|
|
1306
|
+
|
|
1307
|
+
- [Clean Architecture for AI Agents](./agents.clean.arch.md) - Language-agnostic clean architecture principles
|
|
1308
|
+
- [Task Master AI Integration](./agents.task-master.md) - Project task management
|
|
1309
|
+
- [GoTask Usage Guide](./agents.gotask.md) - Build automation patterns
|
|
1310
|
+
- [Markdown Standards](./agents.markdown.md) - Documentation formatting
|
|
1311
|
+
|
|
1312
|
+
---
|
|
1313
|
+
|
|
1314
|
+
**For AI Agents**: This guide implements clean architecture principles for TypeScript. Use `*.system.ts` naming convention for system interaction files, define interfaces for all external dependencies, and use constructor-based dependency injection for testable code.
|