@mandujs/core 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +9 -1
- package/src/brain/adapters/base.ts +120 -0
- package/src/brain/adapters/index.ts +8 -0
- package/src/brain/adapters/ollama.ts +249 -0
- package/src/brain/brain.ts +324 -0
- package/src/brain/doctor/analyzer.ts +366 -0
- package/src/brain/doctor/index.ts +40 -0
- package/src/brain/doctor/patcher.ts +349 -0
- package/src/brain/doctor/reporter.ts +336 -0
- package/src/brain/index.ts +45 -0
- package/src/brain/memory.ts +154 -0
- package/src/brain/permissions.ts +270 -0
- package/src/brain/types.ts +268 -0
- package/src/contract/contract.test.ts +381 -0
- package/src/contract/integration.test.ts +394 -0
- package/src/contract/validator.ts +113 -8
- package/src/generator/contract-glue.test.ts +211 -0
- package/src/guard/check.ts +51 -1
- package/src/guard/contract-guard.test.ts +303 -0
- package/src/guard/rules.ts +37 -0
- package/src/index.ts +2 -0
- package/src/openapi/openapi.test.ts +277 -0
- package/src/slot/validator.test.ts +203 -0
- package/src/slot/validator.ts +236 -17
- package/src/watcher/index.ts +44 -0
- package/src/watcher/reporter.ts +232 -0
- package/src/watcher/rules.ts +248 -0
- package/src/watcher/watcher.ts +330 -0
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain v0.1 - Watch Architecture Rules
|
|
3
|
+
*
|
|
4
|
+
* 5 MVP rules that catch the most common mistakes.
|
|
5
|
+
* Rules only warn - they never block operations.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ArchRule, WatchWarning } from "../brain/types";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import fs from "fs/promises";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The 5 MVP Architecture Rules
|
|
14
|
+
*
|
|
15
|
+
* These rules cover the most frequent mistakes in Mandu projects:
|
|
16
|
+
* 1. Direct modification of generated files
|
|
17
|
+
* 2. Slot files in wrong locations
|
|
18
|
+
* 3. Slot file naming convention
|
|
19
|
+
* 4. Contract file naming convention
|
|
20
|
+
* 5. Forbidden imports in generated files
|
|
21
|
+
*/
|
|
22
|
+
export const MVP_RULES: ArchRule[] = [
|
|
23
|
+
{
|
|
24
|
+
id: "GENERATED_DIRECT_EDIT",
|
|
25
|
+
name: "Generated Direct Edit",
|
|
26
|
+
description: "Generated 파일은 직접 수정하면 안 됩니다",
|
|
27
|
+
pattern: "generated/**",
|
|
28
|
+
action: "warn",
|
|
29
|
+
message: "Generated 파일이 직접 수정되었습니다. 이 파일은 `mandu generate`로 재생성됩니다.",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "WRONG_SLOT_LOCATION",
|
|
33
|
+
name: "Wrong Slot Location",
|
|
34
|
+
description: "Slot 파일은 spec/slots/ 디렉토리에 있어야 합니다",
|
|
35
|
+
pattern: "src/**/*.slot.ts",
|
|
36
|
+
action: "warn",
|
|
37
|
+
message: "Slot 파일이 잘못된 위치에 있습니다. spec/slots/ 디렉토리로 이동하세요.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: "SLOT_NAMING",
|
|
41
|
+
name: "Slot Naming Convention",
|
|
42
|
+
description: "Slot 파일은 .slot.ts로 끝나야 합니다",
|
|
43
|
+
pattern: "spec/slots/*.ts",
|
|
44
|
+
action: "warn",
|
|
45
|
+
message: "Slot 파일명이 .slot.ts로 끝나야 합니다.",
|
|
46
|
+
mustEndWith: ".slot.ts",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: "CONTRACT_NAMING",
|
|
50
|
+
name: "Contract Naming Convention",
|
|
51
|
+
description: "Contract 파일은 .contract.ts로 끝나야 합니다",
|
|
52
|
+
pattern: "spec/contracts/*.ts",
|
|
53
|
+
action: "warn",
|
|
54
|
+
message: "Contract 파일명이 .contract.ts로 끝나야 합니다.",
|
|
55
|
+
mustEndWith: ".contract.ts",
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "FORBIDDEN_IMPORT",
|
|
59
|
+
name: "Forbidden Import in Generated",
|
|
60
|
+
description: "Generated 파일에서 금지된 모듈 import",
|
|
61
|
+
pattern: "generated/**",
|
|
62
|
+
action: "warn",
|
|
63
|
+
message: "Generated 파일에서 금지된 모듈이 import되었습니다.",
|
|
64
|
+
forbiddenImports: ["fs", "child_process", "cluster", "worker_threads"],
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get all rules as a map by ID
|
|
70
|
+
*/
|
|
71
|
+
export function getRulesMap(): Map<string, ArchRule> {
|
|
72
|
+
const map = new Map<string, ArchRule>();
|
|
73
|
+
for (const rule of MVP_RULES) {
|
|
74
|
+
map.set(rule.id, rule);
|
|
75
|
+
}
|
|
76
|
+
return map;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Simple glob pattern matching
|
|
81
|
+
*
|
|
82
|
+
* Supports:
|
|
83
|
+
* - ** for any path segment(s)
|
|
84
|
+
* - * for any characters in a single segment
|
|
85
|
+
*/
|
|
86
|
+
export function matchGlob(pattern: string, filePath: string): boolean {
|
|
87
|
+
// Normalize path separators
|
|
88
|
+
const normalizedPath = filePath.replace(/\\/g, "/");
|
|
89
|
+
const normalizedPattern = pattern.replace(/\\/g, "/");
|
|
90
|
+
|
|
91
|
+
// Convert glob to regex
|
|
92
|
+
const regexPattern = normalizedPattern
|
|
93
|
+
.replace(/\*\*/g, "<<DOUBLESTAR>>")
|
|
94
|
+
.replace(/\*/g, "[^/]*")
|
|
95
|
+
.replace(/<<DOUBLESTAR>>/g, ".*")
|
|
96
|
+
.replace(/\//g, "\\/");
|
|
97
|
+
|
|
98
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
99
|
+
return regex.test(normalizedPath);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if a file path matches any rule
|
|
104
|
+
*/
|
|
105
|
+
export function matchRules(filePath: string): ArchRule[] {
|
|
106
|
+
const matched: ArchRule[] = [];
|
|
107
|
+
|
|
108
|
+
for (const rule of MVP_RULES) {
|
|
109
|
+
if (matchGlob(rule.pattern, filePath)) {
|
|
110
|
+
matched.push(rule);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return matched;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check file naming convention
|
|
119
|
+
*/
|
|
120
|
+
export function checkNamingConvention(
|
|
121
|
+
filePath: string,
|
|
122
|
+
rule: ArchRule
|
|
123
|
+
): boolean {
|
|
124
|
+
if (!rule.mustEndWith) return true;
|
|
125
|
+
|
|
126
|
+
const fileName = path.basename(filePath);
|
|
127
|
+
return fileName.endsWith(rule.mustEndWith);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Check for forbidden imports in file content
|
|
132
|
+
*/
|
|
133
|
+
export async function checkForbiddenImports(
|
|
134
|
+
filePath: string,
|
|
135
|
+
content: string,
|
|
136
|
+
rule: ArchRule
|
|
137
|
+
): Promise<string[]> {
|
|
138
|
+
if (!rule.forbiddenImports || rule.forbiddenImports.length === 0) {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const found: string[] = [];
|
|
143
|
+
|
|
144
|
+
for (const forbidden of rule.forbiddenImports) {
|
|
145
|
+
const importRegex = new RegExp(
|
|
146
|
+
`import\\s+.*from\\s+['"]${forbidden}['"]|require\\s*\\(\\s*['"]${forbidden}['"]\\s*\\)`,
|
|
147
|
+
"g"
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
if (importRegex.test(content)) {
|
|
151
|
+
found.push(forbidden);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return found;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Validate a file against all applicable rules
|
|
160
|
+
*/
|
|
161
|
+
export async function validateFile(
|
|
162
|
+
filePath: string,
|
|
163
|
+
event: "create" | "modify" | "delete",
|
|
164
|
+
rootDir: string
|
|
165
|
+
): Promise<WatchWarning[]> {
|
|
166
|
+
const warnings: WatchWarning[] = [];
|
|
167
|
+
|
|
168
|
+
// Get relative path from root
|
|
169
|
+
const relativePath = path.relative(rootDir, filePath).replace(/\\/g, "/");
|
|
170
|
+
|
|
171
|
+
// Find matching rules
|
|
172
|
+
const matchedRules = matchRules(relativePath);
|
|
173
|
+
|
|
174
|
+
for (const rule of matchedRules) {
|
|
175
|
+
// Skip delete events for most rules
|
|
176
|
+
if (event === "delete" && rule.id !== "GENERATED_DIRECT_EDIT") {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check naming convention
|
|
181
|
+
if (rule.mustEndWith && !checkNamingConvention(relativePath, rule)) {
|
|
182
|
+
warnings.push({
|
|
183
|
+
ruleId: rule.id,
|
|
184
|
+
file: relativePath,
|
|
185
|
+
message: rule.message,
|
|
186
|
+
timestamp: new Date(),
|
|
187
|
+
event,
|
|
188
|
+
});
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Check forbidden imports (only for modify/create)
|
|
193
|
+
if (
|
|
194
|
+
rule.forbiddenImports &&
|
|
195
|
+
rule.forbiddenImports.length > 0 &&
|
|
196
|
+
event !== "delete"
|
|
197
|
+
) {
|
|
198
|
+
try {
|
|
199
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
200
|
+
const forbidden = await checkForbiddenImports(
|
|
201
|
+
relativePath,
|
|
202
|
+
content,
|
|
203
|
+
rule
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
if (forbidden.length > 0) {
|
|
207
|
+
warnings.push({
|
|
208
|
+
ruleId: rule.id,
|
|
209
|
+
file: relativePath,
|
|
210
|
+
message: `${rule.message} (${forbidden.join(", ")})`,
|
|
211
|
+
timestamp: new Date(),
|
|
212
|
+
event,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
} catch {
|
|
216
|
+
// File might not exist or be readable
|
|
217
|
+
}
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Default: generate warning for pattern match
|
|
222
|
+
if (rule.id === "GENERATED_DIRECT_EDIT" || rule.id === "WRONG_SLOT_LOCATION") {
|
|
223
|
+
warnings.push({
|
|
224
|
+
ruleId: rule.id,
|
|
225
|
+
file: relativePath,
|
|
226
|
+
message: rule.message,
|
|
227
|
+
timestamp: new Date(),
|
|
228
|
+
event,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return warnings;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Get rule by ID
|
|
238
|
+
*/
|
|
239
|
+
export function getRule(ruleId: string): ArchRule | undefined {
|
|
240
|
+
return MVP_RULES.find((r) => r.id === ruleId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Get all rules
|
|
245
|
+
*/
|
|
246
|
+
export function getAllRules(): readonly ArchRule[] {
|
|
247
|
+
return MVP_RULES;
|
|
248
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brain v0.1 - File Watcher
|
|
3
|
+
*
|
|
4
|
+
* Watches for file changes and triggers warnings (no blocking).
|
|
5
|
+
* Uses native file system watching for efficiency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
WatchStatus,
|
|
10
|
+
WatchWarning,
|
|
11
|
+
WatchEventHandler,
|
|
12
|
+
} from "../brain/types";
|
|
13
|
+
import { validateFile, MVP_RULES } from "./rules";
|
|
14
|
+
import path from "path";
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Watcher configuration
|
|
19
|
+
*/
|
|
20
|
+
export interface WatcherConfig {
|
|
21
|
+
/** Root directory to watch */
|
|
22
|
+
rootDir: string;
|
|
23
|
+
/** Debounce delay in ms (default: 300) */
|
|
24
|
+
debounceMs?: number;
|
|
25
|
+
/** Extra commands to run on violations */
|
|
26
|
+
extraCommands?: string[];
|
|
27
|
+
/** Directories to ignore */
|
|
28
|
+
ignoreDirs?: string[];
|
|
29
|
+
/** File extensions to watch */
|
|
30
|
+
watchExtensions?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Default watcher configuration
|
|
35
|
+
*/
|
|
36
|
+
const DEFAULT_CONFIG: Partial<WatcherConfig> = {
|
|
37
|
+
debounceMs: 300,
|
|
38
|
+
ignoreDirs: ["node_modules", ".git", "dist", ".next", ".turbo"],
|
|
39
|
+
watchExtensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* File Watcher class
|
|
44
|
+
*
|
|
45
|
+
* Monitors file changes and emits warnings based on architecture rules.
|
|
46
|
+
* Never blocks operations - only warns.
|
|
47
|
+
*/
|
|
48
|
+
export class FileWatcher {
|
|
49
|
+
private config: WatcherConfig;
|
|
50
|
+
private watchers: Map<string, fs.FSWatcher> = new Map();
|
|
51
|
+
private handlers: Set<WatchEventHandler> = new Set();
|
|
52
|
+
private recentWarnings: WatchWarning[] = [];
|
|
53
|
+
private debounceTimers: Map<string, NodeJS.Timeout> = new Map();
|
|
54
|
+
private _active: boolean = false;
|
|
55
|
+
private _startedAt: Date | null = null;
|
|
56
|
+
private _fileCount: number = 0;
|
|
57
|
+
|
|
58
|
+
constructor(config: WatcherConfig) {
|
|
59
|
+
this.config = {
|
|
60
|
+
...DEFAULT_CONFIG,
|
|
61
|
+
...config,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Start watching
|
|
67
|
+
*/
|
|
68
|
+
async start(): Promise<void> {
|
|
69
|
+
if (this._active) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { rootDir } = this.config;
|
|
74
|
+
|
|
75
|
+
// Verify root directory exists
|
|
76
|
+
if (!fs.existsSync(rootDir)) {
|
|
77
|
+
throw new Error(`Root directory does not exist: ${rootDir}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Watch the root directory and subdirectories
|
|
81
|
+
await this.watchDirectory(rootDir);
|
|
82
|
+
|
|
83
|
+
this._active = true;
|
|
84
|
+
this._startedAt = new Date();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stop watching
|
|
89
|
+
*/
|
|
90
|
+
stop(): void {
|
|
91
|
+
if (!this._active) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Close all watchers
|
|
96
|
+
for (const [, watcher] of this.watchers) {
|
|
97
|
+
watcher.close();
|
|
98
|
+
}
|
|
99
|
+
this.watchers.clear();
|
|
100
|
+
|
|
101
|
+
// Clear debounce timers
|
|
102
|
+
for (const [, timer] of this.debounceTimers) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
}
|
|
105
|
+
this.debounceTimers.clear();
|
|
106
|
+
|
|
107
|
+
this._active = false;
|
|
108
|
+
this._fileCount = 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Add an event handler
|
|
113
|
+
*/
|
|
114
|
+
onWarning(handler: WatchEventHandler): () => void {
|
|
115
|
+
this.handlers.add(handler);
|
|
116
|
+
return () => this.handlers.delete(handler);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Get current status
|
|
121
|
+
*/
|
|
122
|
+
getStatus(): WatchStatus {
|
|
123
|
+
return {
|
|
124
|
+
active: this._active,
|
|
125
|
+
rootDir: this._active ? this.config.rootDir : null,
|
|
126
|
+
fileCount: this._fileCount,
|
|
127
|
+
recentWarnings: this.recentWarnings.slice(-20), // Last 20 warnings
|
|
128
|
+
startedAt: this._startedAt,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get recent warnings
|
|
134
|
+
*/
|
|
135
|
+
getRecentWarnings(limit: number = 20): WatchWarning[] {
|
|
136
|
+
return this.recentWarnings.slice(-limit);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Clear recent warnings
|
|
141
|
+
*/
|
|
142
|
+
clearWarnings(): void {
|
|
143
|
+
this.recentWarnings = [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Watch a directory recursively
|
|
148
|
+
*/
|
|
149
|
+
private async watchDirectory(dir: string): Promise<void> {
|
|
150
|
+
const { ignoreDirs } = this.config;
|
|
151
|
+
|
|
152
|
+
// Skip ignored directories
|
|
153
|
+
const dirName = path.basename(dir);
|
|
154
|
+
if (ignoreDirs?.includes(dirName)) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
// Watch this directory
|
|
160
|
+
const watcher = fs.watch(dir, (eventType, filename) => {
|
|
161
|
+
if (filename) {
|
|
162
|
+
this.handleFileEvent(eventType, path.join(dir, filename));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
watcher.on("error", (error) => {
|
|
167
|
+
console.error(`[Watch] Error watching ${dir}:`, error.message);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
this.watchers.set(dir, watcher);
|
|
171
|
+
this._fileCount++;
|
|
172
|
+
|
|
173
|
+
// Recursively watch subdirectories
|
|
174
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
175
|
+
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (entry.isDirectory() && !ignoreDirs?.includes(entry.name)) {
|
|
178
|
+
await this.watchDirectory(path.join(dir, entry.name));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
// Directory might not exist or be accessible
|
|
183
|
+
console.error(
|
|
184
|
+
`[Watch] Failed to watch ${dir}:`,
|
|
185
|
+
error instanceof Error ? error.message : error
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Handle a file system event
|
|
192
|
+
*/
|
|
193
|
+
private handleFileEvent(eventType: string, filePath: string): void {
|
|
194
|
+
const { debounceMs, watchExtensions } = this.config;
|
|
195
|
+
|
|
196
|
+
// Check file extension
|
|
197
|
+
const ext = path.extname(filePath);
|
|
198
|
+
if (watchExtensions && !watchExtensions.includes(ext)) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Debounce events for the same file
|
|
203
|
+
const existingTimer = this.debounceTimers.get(filePath);
|
|
204
|
+
if (existingTimer) {
|
|
205
|
+
clearTimeout(existingTimer);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const timer = setTimeout(() => {
|
|
209
|
+
this.debounceTimers.delete(filePath);
|
|
210
|
+
this.processFileEvent(eventType, filePath);
|
|
211
|
+
}, debounceMs);
|
|
212
|
+
|
|
213
|
+
this.debounceTimers.set(filePath, timer);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Process a debounced file event
|
|
218
|
+
*/
|
|
219
|
+
private async processFileEvent(
|
|
220
|
+
eventType: string,
|
|
221
|
+
filePath: string
|
|
222
|
+
): Promise<void> {
|
|
223
|
+
const { rootDir } = this.config;
|
|
224
|
+
|
|
225
|
+
// Determine event type
|
|
226
|
+
let event: "create" | "modify" | "delete";
|
|
227
|
+
|
|
228
|
+
if (eventType === "rename") {
|
|
229
|
+
// Check if file exists to determine create vs delete
|
|
230
|
+
event = fs.existsSync(filePath) ? "create" : "delete";
|
|
231
|
+
} else {
|
|
232
|
+
event = "modify";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Validate file against rules
|
|
236
|
+
try {
|
|
237
|
+
const warnings = await validateFile(filePath, event, rootDir);
|
|
238
|
+
|
|
239
|
+
for (const warning of warnings) {
|
|
240
|
+
this.emitWarning(warning);
|
|
241
|
+
}
|
|
242
|
+
} catch (error) {
|
|
243
|
+
console.error(
|
|
244
|
+
`[Watch] Error validating ${filePath}:`,
|
|
245
|
+
error instanceof Error ? error.message : error
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Emit a warning to all handlers
|
|
252
|
+
*/
|
|
253
|
+
private emitWarning(warning: WatchWarning): void {
|
|
254
|
+
// Add to recent warnings
|
|
255
|
+
this.recentWarnings.push(warning);
|
|
256
|
+
|
|
257
|
+
// Keep only last 100 warnings
|
|
258
|
+
if (this.recentWarnings.length > 100) {
|
|
259
|
+
this.recentWarnings = this.recentWarnings.slice(-100);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Notify all handlers
|
|
263
|
+
for (const handler of this.handlers) {
|
|
264
|
+
try {
|
|
265
|
+
handler(warning);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
console.error(
|
|
268
|
+
"[Watch] Handler error:",
|
|
269
|
+
error instanceof Error ? error.message : error
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Create a file watcher with default console output
|
|
278
|
+
*/
|
|
279
|
+
export function createWatcher(config: WatcherConfig): FileWatcher {
|
|
280
|
+
const watcher = new FileWatcher(config);
|
|
281
|
+
|
|
282
|
+
// Add default console handler
|
|
283
|
+
watcher.onWarning((warning) => {
|
|
284
|
+
const icon = warning.event === "delete" ? "🗑️" : "⚠️";
|
|
285
|
+
console.log(
|
|
286
|
+
`${icon} [${warning.ruleId}] ${warning.file}\n ${warning.message}`
|
|
287
|
+
);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return watcher;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Global watcher instance
|
|
295
|
+
*/
|
|
296
|
+
let globalWatcher: FileWatcher | null = null;
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Get or create the global watcher
|
|
300
|
+
*/
|
|
301
|
+
export function getWatcher(config?: WatcherConfig): FileWatcher | null {
|
|
302
|
+
if (!globalWatcher && config) {
|
|
303
|
+
globalWatcher = new FileWatcher(config);
|
|
304
|
+
}
|
|
305
|
+
return globalWatcher;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Start the global watcher
|
|
310
|
+
*/
|
|
311
|
+
export async function startWatcher(config: WatcherConfig): Promise<FileWatcher> {
|
|
312
|
+
if (globalWatcher) {
|
|
313
|
+
globalWatcher.stop();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
globalWatcher = createWatcher(config);
|
|
317
|
+
await globalWatcher.start();
|
|
318
|
+
|
|
319
|
+
return globalWatcher;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Stop the global watcher
|
|
324
|
+
*/
|
|
325
|
+
export function stopWatcher(): void {
|
|
326
|
+
if (globalWatcher) {
|
|
327
|
+
globalWatcher.stop();
|
|
328
|
+
globalWatcher = null;
|
|
329
|
+
}
|
|
330
|
+
}
|