Skip to content

Commit d2c393e

Browse files
committed
feat: Decouple validators from file system with dependency injection
ARCH-001: File System Service Abstraction Implemented a testable file system abstraction layer using dependency injection, enabling validators to be tested without real file I/O operations. Features: - FileSystemService interface for file operations (exists, readFile) - NodeFileSystemService for production use with real file system - MockFileSystemService for in-memory testing without disk I/O - Global service instance with setter for test injection - Path normalization for cross-platform compatibility - Comprehensive error handling Implementation Details: - services/file-system.service.ts: Core interfaces and implementations - validators/triggering-validator.ts: Injected FileSystemService via constructor - validators/integration-validator.ts: Injected FileSystemService via constructor - tests/file-system.service.test.ts: 31 comprehensive tests covering all scenarios Benefits: - Faster tests (no real file I/O in unit tests) - More reliable tests (no file system flakiness) - Better testability (easy to mock file system state) - Cleaner architecture (explicit dependencies) - Cross-platform compatibility (path normalization) Architecture Pattern: - Dependency Injection via constructor parameters - Default to global singleton for convenience - Interface-based design for flexibility - Mock implementation for testing Tests: 355/355 passing (100%) Build: ✅ Passing (0 TypeScript errors)
1 parent b13f111 commit d2c393e

4 files changed

Lines changed: 564 additions & 6 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* File System Service Abstraction
3+
*
4+
* Provides a testable abstraction over file system operations used by validators.
5+
* Enables dependency injection and mocking for unit tests without touching the real file system.
6+
*
7+
* @example
8+
* ```typescript
9+
* // Production usage
10+
* const fsService = new NodeFileSystemService();
11+
* const validator = new TriggeringValidator(fsService);
12+
*
13+
* // Test usage
14+
* const mockFs = new MockFileSystemService();
15+
* mockFs.setFile('/test/file.json', '{"test": true}');
16+
* const validator = new TriggeringValidator(mockFs);
17+
* ```
18+
*/
19+
20+
/**
21+
* File system operations interface.
22+
*
23+
* All validators should depend on this interface rather than directly
24+
* importing from 'fs' module.
25+
*/
26+
export interface FileSystemService {
27+
/**
28+
* Check if a file or directory exists at the given path.
29+
*
30+
* @param path - Absolute or relative file path
31+
* @returns True if file/directory exists, false otherwise
32+
*
33+
* @example
34+
* ```typescript
35+
* if (fs.exists('/path/to/file.json')) {
36+
* const content = fs.readFile('/path/to/file.json');
37+
* }
38+
* ```
39+
*/
40+
exists(path: string): boolean;
41+
42+
/**
43+
* Read file contents as UTF-8 string.
44+
*
45+
* @param path - File path to read
46+
* @returns File contents as string
47+
* @throws If file doesn't exist or cannot be read
48+
*
49+
* @example
50+
* ```typescript
51+
* try {
52+
* const content = fs.readFile('/path/to/config.json');
53+
* const config = JSON.parse(content);
54+
* } catch (error) {
55+
* console.error('Failed to read file:', error);
56+
* }
57+
* ```
58+
*/
59+
readFile(path: string): string;
60+
}
61+
62+
/**
63+
* Real file system implementation using Node.js 'fs' module.
64+
*
65+
* Use this in production code.
66+
*/
67+
export class NodeFileSystemService implements FileSystemService {
68+
exists(path: string): boolean {
69+
try {
70+
const { existsSync } = require('fs');
71+
return existsSync(path);
72+
} catch {
73+
return false;
74+
}
75+
}
76+
77+
readFile(path: string): string {
78+
const { readFileSync } = require('fs');
79+
return readFileSync(path, 'utf-8');
80+
}
81+
}
82+
83+
/**
84+
* Mock file system implementation for testing.
85+
*
86+
* Simulates file system operations in-memory without touching the real disk.
87+
* Use this in unit tests to avoid file I/O and enable fast, deterministic tests.
88+
*
89+
* @example
90+
* ```typescript
91+
* const mockFs = new MockFileSystemService();
92+
* mockFs.setFile('/test/data.json', '{"key": "value"}');
93+
*
94+
* expect(mockFs.exists('/test/data.json')).toBe(true);
95+
* expect(mockFs.readFile('/test/data.json')).toBe('{"key": "value"}');
96+
*
97+
* mockFs.deleteFile('/test/data.json');
98+
* expect(mockFs.exists('/test/data.json')).toBe(false);
99+
* ```
100+
*/
101+
export class MockFileSystemService implements FileSystemService {
102+
private readonly files = new Map<string, string>();
103+
104+
exists(path: string): boolean {
105+
return this.files.has(this.normalizePath(path));
106+
}
107+
108+
readFile(path: string): string {
109+
const normalizedPath = this.normalizePath(path);
110+
const content = this.files.get(normalizedPath);
111+
112+
if (content === undefined) {
113+
throw new Error(`ENOENT: no such file or directory, open '${path}'`);
114+
}
115+
116+
return content;
117+
}
118+
119+
/**
120+
* Set file content in the mock file system.
121+
* Creates the file if it doesn't exist, overwrites if it does.
122+
*
123+
* @param path - File path
124+
* @param content - File content as string
125+
*
126+
* @example
127+
* ```typescript
128+
* mockFs.setFile('/test/config.json', '{"debug": true}');
129+
* ```
130+
*/
131+
setFile(path: string, content: string): void {
132+
this.files.set(this.normalizePath(path), content);
133+
}
134+
135+
/**
136+
* Delete a file from the mock file system.
137+
*
138+
* @param path - File path to delete
139+
* @returns True if file was deleted, false if it didn't exist
140+
*
141+
* @example
142+
* ```typescript
143+
* const deleted = mockFs.deleteFile('/test/old-file.json');
144+
* ```
145+
*/
146+
deleteFile(path: string): boolean {
147+
return this.files.delete(this.normalizePath(path));
148+
}
149+
150+
/**
151+
* Clear all files from the mock file system.
152+
* Useful for resetting state between tests.
153+
*
154+
* @example
155+
* ```typescript
156+
* beforeEach(() => {
157+
* mockFs.clear();
158+
* });
159+
* ```
160+
*/
161+
clear(): void {
162+
this.files.clear();
163+
}
164+
165+
/**
166+
* Get all file paths in the mock file system.
167+
* Useful for debugging tests.
168+
*
169+
* @returns Array of file paths
170+
*
171+
* @example
172+
* ```typescript
173+
* const files = mockFs.listFiles();
174+
* console.log('Mock FS contains:', files);
175+
* ```
176+
*/
177+
listFiles(): string[] {
178+
return Array.from(this.files.keys());
179+
}
180+
181+
/**
182+
* Normalize path for consistent storage (lowercase, forward slashes).
183+
*/
184+
private normalizePath(path: string): string {
185+
return path.replace(/\\/g, '/').toLowerCase();
186+
}
187+
}
188+
189+
/**
190+
* Global file system service instance.
191+
*
192+
* By default, uses the real Node.js file system.
193+
* Can be overridden for testing or custom implementations.
194+
*/
195+
export let globalFileSystemService: FileSystemService = new NodeFileSystemService();
196+
197+
/**
198+
* Set the global file system service.
199+
* Use this to inject a mock implementation for testing.
200+
*
201+
* @param service - File system service implementation
202+
*
203+
* @example
204+
* ```typescript
205+
* // In test setup
206+
* const mockFs = new MockFileSystemService();
207+
* setGlobalFileSystemService(mockFs);
208+
*
209+
* // Run tests...
210+
*
211+
* // In test teardown
212+
* setGlobalFileSystemService(new NodeFileSystemService());
213+
* ```
214+
*/
215+
export function setGlobalFileSystemService(service: FileSystemService): void {
216+
globalFileSystemService = service;
217+
}

plugins/ui5/skill-lint/src/validators/integration-validator.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
* This executes ACTUAL Claude prompts — it is slow and uses real API calls.
55
*/
66

7-
import { existsSync, readFileSync } from 'fs';
87
import { join } from 'path';
98
import { BaseValidator } from './base-validator.js';
109
import { getAdapter } from '../adapters/adapter-registry.js';
1110
import { Logger } from '../utils/logger.js';
1211
import { TEST_THRESHOLDS } from '../utils/constants.js';
12+
import { globalFileSystemService, type FileSystemService } from '../services/file-system.service.js';
1313
import type {
1414
ValidationResult,
1515
Violation,
@@ -33,8 +33,14 @@ export class IntegrationValidator extends BaseValidator {
3333
readonly name = 'integration';
3434
readonly description = 'Runs real prompts through Claude Code CLI and checks skill detection';
3535

36+
private readonly fs: FileSystemService;
3637
private skillConfig: SkillTestConfiguration | null = null;
3738

39+
constructor(fs: FileSystemService = globalFileSystemService) {
40+
super();
41+
this.fs = fs;
42+
}
43+
3844
async validate(skill: Skill, config: LintConfig): Promise<ValidationResult> {
3945
const start = Date.now();
4046
const violations: Violation[] = [];
@@ -156,9 +162,9 @@ export class IntegrationValidator extends BaseValidator {
156162
].filter(Boolean) as string[];
157163

158164
for (const p of paths) {
159-
if (existsSync(p)) {
165+
if (this.fs.exists(p)) {
160166
try {
161-
const raw = readFileSync(p, 'utf-8');
167+
const raw = this.fs.readFile(p);
162168
const data = JSON.parse(raw);
163169

164170
// Check if data has skill configuration

plugins/ui5/skill-lint/src/validators/triggering-validator.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77
* This is only a keyword coverage proxy useful during development.
88
*/
99

10-
import { existsSync, readFileSync } from 'fs';
1110
import { join } from 'path';
1211
import { BaseValidator } from './base-validator.js';
12+
import { globalFileSystemService, type FileSystemService } from '../services/file-system.service.js';
1313
import type {
1414
ValidationResult,
1515
Violation,
@@ -25,10 +25,16 @@ export class TriggeringValidator extends BaseValidator {
2525
readonly name = 'triggering';
2626
readonly description = 'Simulates keyword-based triggering accuracy (NOT real Claude behavior)';
2727

28+
private readonly fs: FileSystemService;
2829
private skillConfig: SkillTestConfiguration | null = null;
2930
private triggerKeywordsLower: Set<string> = new Set();
3031
private antiKeywordsLower: Set<string> = new Set();
3132

33+
constructor(fs: FileSystemService = globalFileSystemService) {
34+
super();
35+
this.fs = fs;
36+
}
37+
3238
async validate(skill: Skill, config: LintConfig): Promise<ValidationResult> {
3339
const start = Date.now();
3440
const violations: Violation[] = [];
@@ -137,9 +143,9 @@ export class TriggeringValidator extends BaseValidator {
137143
].filter(Boolean) as string[];
138144

139145
for (const p of paths) {
140-
if (existsSync(p)) {
146+
if (this.fs.exists(p)) {
141147
try {
142-
const data: TriggerTestCaseFile = JSON.parse(readFileSync(p, 'utf-8'));
148+
const data: TriggerTestCaseFile = JSON.parse(this.fs.readFile(p));
143149
if (data.skill) {
144150
this.skillConfig = data.skill;
145151
this.initializeKeywordCaches();

0 commit comments

Comments
 (0)