@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.
@@ -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
+ }