driftdetect 0.1.6 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/bin/drift.js +8 -1
  2. package/dist/bin/drift.js.map +1 -1
  3. package/dist/commands/dashboard.d.ts +16 -0
  4. package/dist/commands/dashboard.d.ts.map +1 -0
  5. package/dist/commands/dashboard.js +99 -0
  6. package/dist/commands/dashboard.js.map +1 -0
  7. package/dist/commands/export.d.ts +2 -0
  8. package/dist/commands/export.d.ts.map +1 -1
  9. package/dist/commands/export.js +114 -5
  10. package/dist/commands/export.js.map +1 -1
  11. package/dist/commands/files.d.ts +2 -0
  12. package/dist/commands/files.d.ts.map +1 -1
  13. package/dist/commands/files.js +103 -20
  14. package/dist/commands/files.js.map +1 -1
  15. package/dist/commands/index.d.ts +2 -0
  16. package/dist/commands/index.d.ts.map +1 -1
  17. package/dist/commands/index.js +2 -0
  18. package/dist/commands/index.js.map +1 -1
  19. package/dist/commands/scan.d.ts +2 -0
  20. package/dist/commands/scan.d.ts.map +1 -1
  21. package/dist/commands/scan.js +98 -14
  22. package/dist/commands/scan.js.map +1 -1
  23. package/dist/commands/watch.d.ts +13 -0
  24. package/dist/commands/watch.d.ts.map +1 -0
  25. package/dist/commands/watch.js +693 -0
  26. package/dist/commands/watch.js.map +1 -0
  27. package/dist/commands/where.d.ts +2 -0
  28. package/dist/commands/where.d.ts.map +1 -1
  29. package/dist/commands/where.js +79 -5
  30. package/dist/commands/where.js.map +1 -1
  31. package/dist/services/contract-scanner.d.ts +35 -0
  32. package/dist/services/contract-scanner.d.ts.map +1 -0
  33. package/dist/services/contract-scanner.js +183 -0
  34. package/dist/services/contract-scanner.js.map +1 -0
  35. package/dist/services/scanner-service.d.ts.map +1 -1
  36. package/dist/services/scanner-service.js +34 -1
  37. package/dist/services/scanner-service.js.map +1 -1
  38. package/package.json +2 -1
@@ -0,0 +1,693 @@
1
+ /**
2
+ * Drift Watch Command
3
+ *
4
+ * Real-time file watching with pattern detection and persistence.
5
+ * Monitors file changes, detects patterns, persists to store, and emits events.
6
+ *
7
+ * @requirements Phase 1 - Watch mode should persist patterns to store
8
+ * @requirements Phase 2 - Smart merge strategy for pattern updates
9
+ * @requirements Phase 3 - File-level tracking for incremental updates
10
+ */
11
+ import { Command } from 'commander';
12
+ import * as fs from 'node:fs';
13
+ import * as fsPromises from 'node:fs/promises';
14
+ import * as path from 'node:path';
15
+ import * as crypto from 'node:crypto';
16
+ import chalk from 'chalk';
17
+ import { createAllDetectorsArray } from 'driftdetect-detectors';
18
+ import { PatternStore } from 'driftdetect-core';
19
+ // ============================================================================
20
+ // Constants
21
+ // ============================================================================
22
+ const DRIFT_DIR = '.drift';
23
+ const FILE_MAP_PATH = 'index/file-map.json';
24
+ const LOCK_FILE_PATH = 'index/.lock';
25
+ const LOCK_TIMEOUT_MS = 10000; // 10 seconds max lock hold time
26
+ const LOCK_RETRY_MS = 100; // Retry every 100ms
27
+ const SUPPORTED_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.py', '.css', '.scss', '.json', '.md'];
28
+ const IGNORE_PATTERNS = ['node_modules', '.git', 'dist', 'build', 'coverage', '.turbo', '.drift'];
29
+ // ============================================================================
30
+ // Utility Functions
31
+ // ============================================================================
32
+ function timestamp() {
33
+ return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
34
+ }
35
+ function generateFileHash(content) {
36
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
37
+ }
38
+ function generateStablePatternId(category, subcategory, detectorId, patternId) {
39
+ const key = `${category}:${subcategory}:${detectorId}:${patternId}`;
40
+ return crypto.createHash('sha256').update(key).digest('hex').slice(0, 16);
41
+ }
42
+ function mapToPatternCategory(category) {
43
+ const mapping = {
44
+ 'api': 'api',
45
+ 'auth': 'auth',
46
+ 'security': 'security',
47
+ 'errors': 'errors',
48
+ 'structural': 'structural',
49
+ 'components': 'components',
50
+ 'styling': 'styling',
51
+ 'logging': 'logging',
52
+ 'testing': 'testing',
53
+ 'data-access': 'data-access',
54
+ 'config': 'config',
55
+ 'types': 'types',
56
+ 'performance': 'performance',
57
+ 'accessibility': 'accessibility',
58
+ 'documentation': 'documentation',
59
+ };
60
+ return mapping[category] || 'structural';
61
+ }
62
+ async function fileExists(filePath) {
63
+ try {
64
+ await fsPromises.access(filePath);
65
+ return true;
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ async function ensureDir(dirPath) {
72
+ await fsPromises.mkdir(dirPath, { recursive: true });
73
+ }
74
+ /**
75
+ * Acquire a file lock for exclusive access to .drift directory
76
+ * Uses a simple lock file with PID and timestamp
77
+ */
78
+ async function acquireLock(rootDir, holder) {
79
+ const lockPath = path.join(rootDir, DRIFT_DIR, LOCK_FILE_PATH);
80
+ await ensureDir(path.dirname(lockPath));
81
+ const startTime = Date.now();
82
+ while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
83
+ try {
84
+ // Check if lock exists and is stale
85
+ if (await fileExists(lockPath)) {
86
+ const content = await fsPromises.readFile(lockPath, 'utf-8');
87
+ const lockInfo = JSON.parse(content);
88
+ const lockAge = Date.now() - new Date(lockInfo.timestamp).getTime();
89
+ // If lock is older than timeout, it's stale - remove it
90
+ if (lockAge > LOCK_TIMEOUT_MS) {
91
+ await fsPromises.unlink(lockPath);
92
+ }
93
+ else {
94
+ // Lock is held by another process, wait and retry
95
+ await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_MS));
96
+ continue;
97
+ }
98
+ }
99
+ // Try to create lock file exclusively
100
+ const lockInfo = {
101
+ pid: process.pid,
102
+ timestamp: new Date().toISOString(),
103
+ holder,
104
+ };
105
+ await fsPromises.writeFile(lockPath, JSON.stringify(lockInfo), { flag: 'wx' });
106
+ return true;
107
+ }
108
+ catch (error) {
109
+ const err = error;
110
+ if (err.code === 'EEXIST') {
111
+ // Lock file was created by another process, retry
112
+ await new Promise(resolve => setTimeout(resolve, LOCK_RETRY_MS));
113
+ continue;
114
+ }
115
+ // Other error, fail
116
+ return false;
117
+ }
118
+ }
119
+ // Timeout waiting for lock
120
+ return false;
121
+ }
122
+ /**
123
+ * Release the file lock
124
+ */
125
+ async function releaseLock(rootDir) {
126
+ const lockPath = path.join(rootDir, DRIFT_DIR, LOCK_FILE_PATH);
127
+ try {
128
+ // Only release if we own the lock
129
+ if (await fileExists(lockPath)) {
130
+ const content = await fsPromises.readFile(lockPath, 'utf-8');
131
+ const lockInfo = JSON.parse(content);
132
+ if (lockInfo.pid === process.pid) {
133
+ await fsPromises.unlink(lockPath);
134
+ }
135
+ }
136
+ }
137
+ catch {
138
+ // Ignore errors during release
139
+ }
140
+ }
141
+ /**
142
+ * Execute a function with file lock protection
143
+ */
144
+ async function withLock(rootDir, holder, fn) {
145
+ const acquired = await acquireLock(rootDir, holder);
146
+ if (!acquired) {
147
+ throw new Error('Failed to acquire lock - another process may be writing');
148
+ }
149
+ try {
150
+ return await fn();
151
+ }
152
+ finally {
153
+ await releaseLock(rootDir);
154
+ }
155
+ }
156
+ // ============================================================================
157
+ // File Map Management (Phase 3)
158
+ // ============================================================================
159
+ async function loadFileMap(rootDir) {
160
+ const mapPath = path.join(rootDir, DRIFT_DIR, FILE_MAP_PATH);
161
+ if (await fileExists(mapPath)) {
162
+ try {
163
+ const content = await fsPromises.readFile(mapPath, 'utf-8');
164
+ return JSON.parse(content);
165
+ }
166
+ catch {
167
+ // Corrupted file, start fresh
168
+ }
169
+ }
170
+ return {
171
+ version: '1.0.0',
172
+ files: {},
173
+ lastUpdated: new Date().toISOString(),
174
+ };
175
+ }
176
+ async function saveFileMap(rootDir, fileMap) {
177
+ const mapPath = path.join(rootDir, DRIFT_DIR, FILE_MAP_PATH);
178
+ await ensureDir(path.dirname(mapPath));
179
+ fileMap.lastUpdated = new Date().toISOString();
180
+ // Atomic write: write to temp file, then rename
181
+ const tempPath = `${mapPath}.tmp`;
182
+ await fsPromises.writeFile(tempPath, JSON.stringify(fileMap, null, 2));
183
+ await fsPromises.rename(tempPath, mapPath);
184
+ }
185
+ // ============================================================================
186
+ // Pattern Detection
187
+ // ============================================================================
188
+ async function detectPatternsInFile(filePath, content, detectors, categories, rootDir) {
189
+ const patterns = [];
190
+ const violations = [];
191
+ const ext = path.extname(filePath).toLowerCase();
192
+ const relativePath = path.relative(rootDir, filePath);
193
+ let language = 'typescript';
194
+ if (['.ts', '.tsx'].includes(ext))
195
+ language = 'typescript';
196
+ else if (['.js', '.jsx'].includes(ext))
197
+ language = 'javascript';
198
+ else if (['.py'].includes(ext))
199
+ language = 'python';
200
+ else if (['.css', '.scss'].includes(ext))
201
+ language = 'css';
202
+ else if (['.json'].includes(ext))
203
+ language = 'json';
204
+ else if (['.md'].includes(ext))
205
+ language = 'markdown';
206
+ const isTestFile = /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
207
+ filePath.includes('__tests__') ||
208
+ filePath.includes('/test/') ||
209
+ filePath.includes('/tests/');
210
+ const isTypeDefinition = ext === '.d.ts';
211
+ const projectContext = {
212
+ rootDir,
213
+ files: [relativePath],
214
+ config: {},
215
+ };
216
+ // Aggregate patterns by detector+patternId
217
+ const patternMap = new Map();
218
+ for (const detector of detectors) {
219
+ if (categories && !categories.includes(detector.category)) {
220
+ continue;
221
+ }
222
+ if (!detector.supportsLanguage(language)) {
223
+ continue;
224
+ }
225
+ try {
226
+ const context = {
227
+ file: relativePath,
228
+ content,
229
+ ast: null,
230
+ imports: [],
231
+ exports: [],
232
+ projectContext,
233
+ language,
234
+ extension: ext,
235
+ isTestFile,
236
+ isTypeDefinition,
237
+ };
238
+ const result = await detector.detect(context);
239
+ const info = detector.getInfo();
240
+ // Process matches (patterns)
241
+ if (result.patterns && result.patterns.length > 0) {
242
+ for (const match of result.patterns) {
243
+ const key = `${info.category}:${info.subcategory}:${detector.id}:${match.patternId}`;
244
+ if (!patternMap.has(key)) {
245
+ patternMap.set(key, {
246
+ patternId: match.patternId,
247
+ detectorId: detector.id,
248
+ category: info.category,
249
+ subcategory: info.subcategory,
250
+ name: info.name,
251
+ description: info.description,
252
+ confidence: match.confidence,
253
+ locations: [],
254
+ });
255
+ }
256
+ const pattern = patternMap.get(key);
257
+ const loc = {
258
+ file: relativePath,
259
+ line: match.location?.line ?? 1,
260
+ column: match.location?.column ?? 0,
261
+ };
262
+ if (match.location?.endLine !== undefined) {
263
+ loc.endLine = match.location.endLine;
264
+ }
265
+ if (match.location?.endColumn !== undefined) {
266
+ loc.endColumn = match.location.endColumn;
267
+ }
268
+ pattern.locations.push(loc);
269
+ }
270
+ }
271
+ // Process violations
272
+ if (result.violations && result.violations.length > 0) {
273
+ for (const v of result.violations) {
274
+ violations.push({
275
+ file: relativePath,
276
+ line: v.range?.start?.line ?? 1,
277
+ column: v.range?.start?.character ?? 0,
278
+ endLine: v.range?.end?.line,
279
+ endColumn: v.range?.end?.character,
280
+ message: v.message,
281
+ severity: v.severity,
282
+ patternId: v.patternId,
283
+ detectorId: detector.id,
284
+ });
285
+ }
286
+ }
287
+ }
288
+ catch {
289
+ // Skip detector errors
290
+ }
291
+ }
292
+ patterns.push(...patternMap.values());
293
+ return { patterns, violations };
294
+ }
295
+ // ============================================================================
296
+ // Pattern Store Integration (Phase 1 & 2)
297
+ // ============================================================================
298
+ function mergePatternIntoStore(store, detected, violations, file) {
299
+ const stableId = generateStablePatternId(detected.category, detected.subcategory, detected.detectorId, detected.patternId);
300
+ const now = new Date().toISOString();
301
+ const existingPattern = store.get(stableId);
302
+ // Get violations for this pattern as outliers
303
+ const patternViolations = violations.filter(v => v.detectorId === detected.detectorId && v.patternId === detected.patternId);
304
+ const newOutliers = patternViolations.map(v => ({
305
+ file: v.file,
306
+ line: v.line,
307
+ column: v.column,
308
+ reason: v.message,
309
+ deviationScore: v.severity === 'error' ? 1.0 : v.severity === 'warning' ? 0.7 : 0.4,
310
+ }));
311
+ if (existingPattern) {
312
+ // Phase 2: Smart merge - update existing pattern
313
+ // Remove old locations from this file, add new ones
314
+ const otherFileLocations = existingPattern.locations.filter(loc => loc.file !== file);
315
+ const mergedLocations = [...otherFileLocations, ...detected.locations].slice(0, 100);
316
+ // Same for outliers - filter to only include required fields
317
+ const otherFileOutliers = existingPattern.outliers.filter(o => o.file !== file);
318
+ const mergedOutliers = [
319
+ ...otherFileOutliers,
320
+ ...newOutliers,
321
+ ];
322
+ // Update pattern preserving status
323
+ store.update(stableId, {
324
+ locations: mergedLocations,
325
+ outliers: mergedOutliers,
326
+ metadata: {
327
+ ...existingPattern.metadata,
328
+ lastSeen: now,
329
+ },
330
+ confidence: {
331
+ ...existingPattern.confidence,
332
+ score: Math.max(existingPattern.confidence.score, detected.confidence),
333
+ },
334
+ });
335
+ }
336
+ else {
337
+ // New pattern - add to store
338
+ const confidenceScore = Math.min(0.95, detected.confidence);
339
+ const newPattern = {
340
+ id: stableId,
341
+ category: mapToPatternCategory(detected.category),
342
+ subcategory: detected.subcategory,
343
+ name: detected.name,
344
+ description: detected.description,
345
+ detector: {
346
+ type: 'regex',
347
+ config: {
348
+ detectorId: detected.detectorId,
349
+ patternId: detected.patternId,
350
+ },
351
+ },
352
+ confidence: {
353
+ frequency: Math.min(1, detected.locations.length / 10),
354
+ consistency: 0.9,
355
+ age: 0,
356
+ spread: 1,
357
+ score: confidenceScore,
358
+ level: confidenceScore >= 0.85 ? 'high' : confidenceScore >= 0.65 ? 'medium' : confidenceScore >= 0.45 ? 'low' : 'uncertain',
359
+ },
360
+ locations: detected.locations.slice(0, 100),
361
+ outliers: newOutliers,
362
+ metadata: {
363
+ firstSeen: now,
364
+ lastSeen: now,
365
+ source: 'auto-detected',
366
+ tags: [detected.category, detected.subcategory],
367
+ },
368
+ severity: patternViolations.length > 0
369
+ ? (patternViolations.some(v => v.severity === 'error') ? 'error' : 'warning')
370
+ : 'info',
371
+ autoFixable: false,
372
+ status: 'discovered',
373
+ };
374
+ store.add(newPattern);
375
+ }
376
+ return stableId;
377
+ }
378
+ function removeFileFromStore(store, file) {
379
+ // Remove all locations and outliers for this file from all patterns
380
+ const allPatterns = store.getAll();
381
+ for (const pattern of allPatterns) {
382
+ const hasLocationsInFile = pattern.locations.some(loc => loc.file === file);
383
+ const hasOutliersInFile = pattern.outliers.some(o => o.file === file);
384
+ if (hasLocationsInFile || hasOutliersInFile) {
385
+ const newLocations = pattern.locations.filter(loc => loc.file !== file);
386
+ const newOutliers = pattern.outliers.filter(o => o.file !== file);
387
+ if (newLocations.length === 0 && newOutliers.length === 0) {
388
+ // Pattern has no more locations - delete it
389
+ store.delete(pattern.id);
390
+ }
391
+ else {
392
+ // Update pattern with remaining locations
393
+ store.update(pattern.id, {
394
+ locations: newLocations,
395
+ outliers: newOutliers,
396
+ });
397
+ }
398
+ }
399
+ }
400
+ }
401
+ // ============================================================================
402
+ // Console Output
403
+ // ============================================================================
404
+ function printViolations(filePath, violations, patternsUpdated, verbose) {
405
+ const relativePath = path.relative(process.cwd(), filePath);
406
+ if (violations.length === 0) {
407
+ const patternInfo = patternsUpdated > 0 ? chalk.cyan(` (${patternsUpdated} patterns)`) : '';
408
+ console.log(`${timestamp()} ${chalk.green('✓')} ${relativePath}${patternInfo}`);
409
+ return;
410
+ }
411
+ const errors = violations.filter(v => v.severity === 'error');
412
+ const warnings = violations.filter(v => v.severity === 'warning');
413
+ let summary = '';
414
+ if (errors.length > 0)
415
+ summary += chalk.red(`${errors.length} error${errors.length > 1 ? 's' : ''}`);
416
+ if (warnings.length > 0) {
417
+ if (summary)
418
+ summary += ', ';
419
+ summary += chalk.yellow(`${warnings.length} warning${warnings.length > 1 ? 's' : ''}`);
420
+ }
421
+ const patternInfo = patternsUpdated > 0 ? chalk.cyan(` | ${patternsUpdated} patterns`) : '';
422
+ console.log(`${timestamp()} ${chalk.red('✗')} ${relativePath} - ${summary}${patternInfo}`);
423
+ if (verbose) {
424
+ for (const v of violations) {
425
+ const icon = v.severity === 'error' ? chalk.red('●') : chalk.yellow('●');
426
+ console.log(` ${icon} Line ${v.line}: ${v.message}`);
427
+ }
428
+ }
429
+ }
430
+ function updateContextFile(contextPath, stats) {
431
+ try {
432
+ const content = `# Drift Context (Auto-updated)
433
+
434
+ Last updated: ${new Date().toISOString()}
435
+
436
+ ## Current Stats
437
+ - Patterns tracked: ${stats.patterns}
438
+ - Active violations: ${stats.violations}
439
+
440
+ This file is auto-updated by \`drift watch\`.
441
+ Run \`drift export --format ai-context\` for full pattern details.
442
+
443
+ ## Quick Commands
444
+ - \`drift where <pattern>\` - Find pattern locations
445
+ - \`drift files <path>\` - See patterns in a specific file
446
+ - \`drift status\` - View pattern summary
447
+ - \`drift dashboard\` - Open web UI
448
+ `;
449
+ fs.writeFileSync(contextPath, content);
450
+ }
451
+ catch {
452
+ // Silently fail context updates
453
+ }
454
+ }
455
+ // ============================================================================
456
+ // Watch Command Implementation
457
+ // ============================================================================
458
+ async function watchCommand(options) {
459
+ const rootDir = process.cwd();
460
+ const verbose = options.verbose ?? false;
461
+ const contextPath = options.context;
462
+ const debounceMs = parseInt(options.debounce ?? '300', 10);
463
+ const categories = options.categories?.split(',').map(c => c.trim()) ?? null;
464
+ const persist = options.persist !== false; // Default to true
465
+ console.log(chalk.cyan('\n🔍 Drift Watch Mode\n'));
466
+ console.log(` Watching: ${chalk.white(rootDir)}`);
467
+ if (categories) {
468
+ console.log(` Categories: ${chalk.white(categories.join(', '))}`);
469
+ }
470
+ if (contextPath) {
471
+ console.log(` Context file: ${chalk.white(contextPath)}`);
472
+ }
473
+ console.log(` Debounce: ${chalk.white(`${debounceMs}ms`)}`);
474
+ console.log(` Persistence: ${chalk.white(persist ? 'enabled' : 'disabled')}`);
475
+ console.log(chalk.gray('\n Press Ctrl+C to stop\n'));
476
+ console.log(chalk.gray('─'.repeat(50)));
477
+ // Initialize pattern store
478
+ let store = null;
479
+ let fileMap = null;
480
+ if (persist) {
481
+ try {
482
+ store = new PatternStore({ rootDir });
483
+ await store.initialize();
484
+ fileMap = await loadFileMap(rootDir);
485
+ const stats = store.getStats();
486
+ console.log(`${timestamp()} Loaded ${chalk.cyan(String(stats.totalPatterns))} existing patterns`);
487
+ }
488
+ catch (error) {
489
+ console.log(`${timestamp()} ${chalk.yellow('Warning: Could not initialize store, running without persistence')}`);
490
+ console.log(chalk.gray(` ${error.message}`));
491
+ store = null;
492
+ }
493
+ }
494
+ // Load detectors
495
+ const detectors = createAllDetectorsArray();
496
+ console.log(`${timestamp()} Loaded ${chalk.cyan(String(detectors.length))} detectors`);
497
+ // Track pending scans (for debouncing)
498
+ const pendingScans = new Map();
499
+ // Track save debounce
500
+ let saveTimeout = null;
501
+ const SAVE_DEBOUNCE_MS = 1000;
502
+ function scheduleSave() {
503
+ if (!store || !fileMap)
504
+ return;
505
+ if (saveTimeout) {
506
+ clearTimeout(saveTimeout);
507
+ }
508
+ saveTimeout = setTimeout(async () => {
509
+ try {
510
+ // Use file locking for concurrent write protection (Phase 4)
511
+ await withLock(rootDir, 'drift-watch', async () => {
512
+ await store.saveAll();
513
+ await saveFileMap(rootDir, fileMap);
514
+ });
515
+ if (verbose) {
516
+ console.log(`${timestamp()} ${chalk.gray('Saved patterns to disk')}`);
517
+ }
518
+ }
519
+ catch (error) {
520
+ console.log(`${timestamp()} ${chalk.red('Failed to save:')} ${error.message}`);
521
+ }
522
+ }, SAVE_DEBOUNCE_MS);
523
+ }
524
+ /**
525
+ * Handle file change
526
+ */
527
+ async function handleFileChange(filePath) {
528
+ const relativePath = path.relative(rootDir, filePath);
529
+ // Check if file should be ignored
530
+ for (const pattern of IGNORE_PATTERNS) {
531
+ if (relativePath.includes(pattern)) {
532
+ return;
533
+ }
534
+ }
535
+ // Check extension
536
+ const ext = path.extname(filePath).toLowerCase();
537
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
538
+ return;
539
+ }
540
+ // Debounce
541
+ const existing = pendingScans.get(filePath);
542
+ if (existing) {
543
+ clearTimeout(existing);
544
+ }
545
+ pendingScans.set(filePath, setTimeout(async () => {
546
+ pendingScans.delete(filePath);
547
+ try {
548
+ // Check if file still exists
549
+ if (!fs.existsSync(filePath)) {
550
+ // File was deleted
551
+ if (store && fileMap) {
552
+ removeFileFromStore(store, relativePath);
553
+ delete fileMap.files[relativePath];
554
+ scheduleSave();
555
+ }
556
+ console.log(`${timestamp()} ${chalk.gray('Deleted:')} ${relativePath}`);
557
+ return;
558
+ }
559
+ // Read file content
560
+ const content = fs.readFileSync(filePath, 'utf-8');
561
+ const fileHash = generateFileHash(content);
562
+ // Check if file actually changed (Phase 3)
563
+ if (fileMap && fileMap.files[relativePath]?.hash === fileHash) {
564
+ if (verbose) {
565
+ console.log(`${timestamp()} ${chalk.gray('Unchanged:')} ${relativePath}`);
566
+ }
567
+ return;
568
+ }
569
+ // Detect patterns
570
+ const { patterns, violations } = await detectPatternsInFile(filePath, content, detectors, categories, rootDir);
571
+ // Update store (Phase 1 & 2)
572
+ let patternsUpdated = 0;
573
+ const patternIds = [];
574
+ if (store) {
575
+ // First, remove old data for this file
576
+ removeFileFromStore(store, relativePath);
577
+ // Then add new patterns
578
+ for (const detected of patterns) {
579
+ const patternId = mergePatternIntoStore(store, detected, violations, relativePath);
580
+ patternIds.push(patternId);
581
+ patternsUpdated++;
582
+ }
583
+ // Update file map
584
+ if (fileMap) {
585
+ fileMap.files[relativePath] = {
586
+ lastScanned: new Date().toISOString(),
587
+ hash: fileHash,
588
+ patterns: patternIds,
589
+ };
590
+ }
591
+ scheduleSave();
592
+ }
593
+ // Print results
594
+ printViolations(filePath, violations, patternsUpdated, verbose);
595
+ // Update context file
596
+ if (contextPath && store) {
597
+ const stats = store.getStats();
598
+ updateContextFile(contextPath, {
599
+ patterns: stats.totalPatterns,
600
+ violations: stats.totalOutliers,
601
+ });
602
+ }
603
+ }
604
+ catch (error) {
605
+ console.log(`${timestamp()} ${chalk.red('Error processing')} ${relativePath}: ${error.message}`);
606
+ }
607
+ }, debounceMs));
608
+ }
609
+ /**
610
+ * Handle file deletion
611
+ */
612
+ function handleFileDelete(filePath) {
613
+ const relativePath = path.relative(rootDir, filePath);
614
+ if (store && fileMap) {
615
+ removeFileFromStore(store, relativePath);
616
+ delete fileMap.files[relativePath];
617
+ scheduleSave();
618
+ }
619
+ console.log(`${timestamp()} ${chalk.gray('Removed:')} ${relativePath}`);
620
+ }
621
+ // Watch for file changes
622
+ const watchers = [];
623
+ function watchDirectory(dir) {
624
+ try {
625
+ const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
626
+ if (!filename)
627
+ return;
628
+ const fullPath = path.join(dir, filename);
629
+ if (eventType === 'rename') {
630
+ // Could be create or delete
631
+ if (fs.existsSync(fullPath)) {
632
+ handleFileChange(fullPath);
633
+ }
634
+ else {
635
+ handleFileDelete(fullPath);
636
+ }
637
+ }
638
+ else {
639
+ handleFileChange(fullPath);
640
+ }
641
+ });
642
+ watchers.push(watcher);
643
+ }
644
+ catch (err) {
645
+ console.error(chalk.red(`Failed to watch ${dir}:`), err);
646
+ }
647
+ }
648
+ // Start watching
649
+ watchDirectory(rootDir);
650
+ console.log(`${timestamp()} ${chalk.green('Watching for changes...')}\n`);
651
+ // Handle shutdown
652
+ process.on('SIGINT', async () => {
653
+ console.log(chalk.gray('\n\nStopping watch mode...'));
654
+ // Clear pending operations
655
+ for (const watcher of watchers) {
656
+ watcher.close();
657
+ }
658
+ for (const timeout of pendingScans.values()) {
659
+ clearTimeout(timeout);
660
+ }
661
+ if (saveTimeout) {
662
+ clearTimeout(saveTimeout);
663
+ }
664
+ // Final save
665
+ if (store && fileMap) {
666
+ try {
667
+ await withLock(rootDir, 'drift-watch-exit', async () => {
668
+ await store.saveAll();
669
+ await saveFileMap(rootDir, fileMap);
670
+ });
671
+ console.log(chalk.green('Saved patterns before exit'));
672
+ }
673
+ catch (error) {
674
+ console.log(chalk.red('Failed to save on exit:'), error.message);
675
+ }
676
+ }
677
+ process.exit(0);
678
+ });
679
+ // Keep process alive
680
+ await new Promise(() => { });
681
+ }
682
+ // ============================================================================
683
+ // Command Definition
684
+ // ============================================================================
685
+ export const watchCommandDef = new Command('watch')
686
+ .description('Watch for file changes and detect patterns in real-time')
687
+ .option('--verbose', 'Show detailed output')
688
+ .option('--context <file>', 'Auto-update AI context file on changes')
689
+ .option('-c, --categories <categories>', 'Filter by categories (comma-separated)')
690
+ .option('--debounce <ms>', 'Debounce delay in milliseconds', '300')
691
+ .option('--no-persist', 'Disable pattern persistence (only show violations)')
692
+ .action(watchCommand);
693
+ //# sourceMappingURL=watch.js.map