driftdetect 0.1.7 → 0.2.1

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 +4 -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 +1 -0
  16. package/dist/commands/index.d.ts.map +1 -1
  17. package/dist/commands/index.js +1 -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 +6 -5
  24. package/dist/commands/watch.d.ts.map +1 -1
  25. package/dist/commands/watch.js +555 -126
  26. package/dist/commands/watch.js.map +1 -1
  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 +3 -2
@@ -1,111 +1,411 @@
1
1
  /**
2
2
  * Drift Watch Command
3
3
  *
4
- * Real-time file watching with pattern detection.
5
- * Monitors file changes and reports violations as they happen.
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
6
10
  */
7
11
  import { Command } from 'commander';
8
12
  import * as fs from 'node:fs';
13
+ import * as fsPromises from 'node:fs/promises';
9
14
  import * as path from 'node:path';
15
+ import * as crypto from 'node:crypto';
10
16
  import chalk from 'chalk';
11
17
  import { createAllDetectorsArray } from 'driftdetect-detectors';
12
- /**
13
- * Format timestamp for output
14
- */
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
+ // ============================================================================
15
32
  function timestamp() {
16
33
  return chalk.gray(`[${new Date().toLocaleTimeString()}]`);
17
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
+ }
18
74
  /**
19
- * Scan a single file for violations
75
+ * Acquire a file lock for exclusive access to .drift directory
76
+ * Uses a simple lock file with PID and timestamp
20
77
  */
21
- async function scanFile(filePath, detectors, categories) {
22
- const violations = [];
23
- try {
24
- const content = fs.readFileSync(filePath, 'utf-8');
25
- const ext = path.extname(filePath).toLowerCase();
26
- const cwd = process.cwd();
27
- const relativePath = path.relative(cwd, filePath);
28
- let language = 'typescript';
29
- if (['.ts', '.tsx'].includes(ext))
30
- language = 'typescript';
31
- else if (['.js', '.jsx'].includes(ext))
32
- language = 'javascript';
33
- else if (['.py'].includes(ext))
34
- language = 'python';
35
- else if (['.css', '.scss'].includes(ext))
36
- language = 'css';
37
- else if (['.json'].includes(ext))
38
- language = 'json';
39
- else if (['.md'].includes(ext))
40
- language = 'markdown';
41
- // Check if test file
42
- const isTestFile = /\.(test|spec)\.[jt]sx?$/.test(filePath) ||
43
- filePath.includes('__tests__') ||
44
- filePath.includes('/test/') ||
45
- filePath.includes('/tests/');
46
- // Check if type definition
47
- const isTypeDefinition = ext === '.d.ts';
48
- // Create minimal project context
49
- const projectContext = {
50
- rootDir: cwd,
51
- files: [relativePath],
52
- config: {},
53
- };
54
- // Run detectors
55
- for (const detector of detectors) {
56
- // Filter by category if specified
57
- if (categories && !categories.includes(detector.category)) {
58
- continue;
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
+ }
59
98
  }
60
- // Check if detector supports this language
61
- if (!detector.supportsLanguage(language)) {
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));
62
113
  continue;
63
114
  }
64
- try {
65
- // Create full detection context
66
- const context = {
67
- file: relativePath,
68
- content,
69
- ast: null,
70
- imports: [],
71
- exports: [],
72
- projectContext,
73
- language,
74
- extension: ext,
75
- isTestFile,
76
- isTypeDefinition,
77
- };
78
- const result = await detector.detect(context);
79
- // Check for violations
80
- if (result.violations && result.violations.length > 0) {
81
- for (const v of result.violations) {
82
- violations.push({
83
- file: filePath,
84
- line: v.range?.start?.line ?? 1,
85
- message: v.message,
86
- severity: v.severity,
87
- patternName: detector.name,
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: [],
88
254
  });
89
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);
90
269
  }
91
270
  }
92
- catch {
93
- // Skip detector errors silently
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
+ }
94
286
  }
95
287
  }
288
+ catch {
289
+ // Skip detector errors
290
+ }
96
291
  }
97
- catch {
98
- // File read error - skip
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);
99
375
  }
100
- return violations;
376
+ return stableId;
101
377
  }
102
- /**
103
- * Print violations to console
104
- */
105
- function printViolations(filePath, violations, verbose) {
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) {
106
405
  const relativePath = path.relative(process.cwd(), filePath);
107
406
  if (violations.length === 0) {
108
- console.log(`${timestamp()} ${chalk.green('✓')} ${relativePath}`);
407
+ const patternInfo = patternsUpdated > 0 ? chalk.cyan(` (${patternsUpdated} patterns)`) : '';
408
+ console.log(`${timestamp()} ${chalk.green('✓')} ${relativePath}${patternInfo}`);
109
409
  return;
110
410
  }
111
411
  const errors = violations.filter(v => v.severity === 'error');
@@ -118,24 +418,25 @@ function printViolations(filePath, violations, verbose) {
118
418
  summary += ', ';
119
419
  summary += chalk.yellow(`${warnings.length} warning${warnings.length > 1 ? 's' : ''}`);
120
420
  }
121
- console.log(`${timestamp()} ${chalk.red('✗')} ${relativePath} - ${summary}`);
421
+ const patternInfo = patternsUpdated > 0 ? chalk.cyan(` | ${patternsUpdated} patterns`) : '';
422
+ console.log(`${timestamp()} ${chalk.red('✗')} ${relativePath} - ${summary}${patternInfo}`);
122
423
  if (verbose) {
123
424
  for (const v of violations) {
124
425
  const icon = v.severity === 'error' ? chalk.red('●') : chalk.yellow('●');
125
426
  console.log(` ${icon} Line ${v.line}: ${v.message}`);
126
- console.log(` ${chalk.gray(`[${v.patternName}]`)}`);
127
427
  }
128
428
  }
129
429
  }
130
- /**
131
- * Update AI context file
132
- */
133
- function updateContextFile(contextPath) {
430
+ function updateContextFile(contextPath, stats) {
134
431
  try {
135
432
  const content = `# Drift Context (Auto-updated)
136
433
 
137
434
  Last updated: ${new Date().toISOString()}
138
435
 
436
+ ## Current Stats
437
+ - Patterns tracked: ${stats.patterns}
438
+ - Active violations: ${stats.violations}
439
+
139
440
  This file is auto-updated by \`drift watch\`.
140
441
  Run \`drift export --format ai-context\` for full pattern details.
141
442
 
@@ -143,6 +444,7 @@ Run \`drift export --format ai-context\` for full pattern details.
143
444
  - \`drift where <pattern>\` - Find pattern locations
144
445
  - \`drift files <path>\` - See patterns in a specific file
145
446
  - \`drift status\` - View pattern summary
447
+ - \`drift dashboard\` - Open web UI
146
448
  `;
147
449
  fs.writeFileSync(contextPath, content);
148
450
  }
@@ -150,17 +452,18 @@ Run \`drift export --format ai-context\` for full pattern details.
150
452
  // Silently fail context updates
151
453
  }
152
454
  }
153
- /**
154
- * Watch command implementation
155
- */
455
+ // ============================================================================
456
+ // Watch Command Implementation
457
+ // ============================================================================
156
458
  async function watchCommand(options) {
157
- const cwd = process.cwd();
459
+ const rootDir = process.cwd();
158
460
  const verbose = options.verbose ?? false;
159
461
  const contextPath = options.context;
160
462
  const debounceMs = parseInt(options.debounce ?? '300', 10);
161
463
  const categories = options.categories?.split(',').map(c => c.trim()) ?? null;
464
+ const persist = options.persist !== false; // Default to true
162
465
  console.log(chalk.cyan('\n🔍 Drift Watch Mode\n'));
163
- console.log(` Watching: ${chalk.white(cwd)}`);
466
+ console.log(` Watching: ${chalk.white(rootDir)}`);
164
467
  if (categories) {
165
468
  console.log(` Categories: ${chalk.white(categories.join(', '))}`);
166
469
  }
@@ -168,33 +471,72 @@ async function watchCommand(options) {
168
471
  console.log(` Context file: ${chalk.white(contextPath)}`);
169
472
  }
170
473
  console.log(` Debounce: ${chalk.white(`${debounceMs}ms`)}`);
474
+ console.log(` Persistence: ${chalk.white(persist ? 'enabled' : 'disabled')}`);
171
475
  console.log(chalk.gray('\n Press Ctrl+C to stop\n'));
172
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
+ }
173
494
  // Load detectors
174
495
  const detectors = createAllDetectorsArray();
175
496
  console.log(`${timestamp()} Loaded ${chalk.cyan(String(detectors.length))} detectors`);
176
497
  // Track pending scans (for debouncing)
177
498
  const pendingScans = new Map();
178
- // Ignore patterns
179
- const ignorePatterns = [
180
- 'node_modules',
181
- '.git',
182
- 'dist',
183
- 'build',
184
- 'coverage',
185
- '.turbo',
186
- ];
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
+ }
187
524
  /**
188
525
  * Handle file change
189
526
  */
190
- function handleFileChange(filePath) {
527
+ async function handleFileChange(filePath) {
528
+ const relativePath = path.relative(rootDir, filePath);
191
529
  // Check if file should be ignored
192
- const relativePath = path.relative(cwd, filePath);
193
- for (const pattern of ignorePatterns) {
530
+ for (const pattern of IGNORE_PATTERNS) {
194
531
  if (relativePath.includes(pattern)) {
195
532
  return;
196
533
  }
197
534
  }
535
+ // Check extension
536
+ const ext = path.extname(filePath).toLowerCase();
537
+ if (!SUPPORTED_EXTENSIONS.includes(ext)) {
538
+ return;
539
+ }
198
540
  // Debounce
199
541
  const existing = pendingScans.get(filePath);
200
542
  if (existing) {
@@ -202,31 +544,100 @@ async function watchCommand(options) {
202
544
  }
203
545
  pendingScans.set(filePath, setTimeout(async () => {
204
546
  pendingScans.delete(filePath);
205
- const violations = await scanFile(filePath, detectors, categories);
206
- printViolations(filePath, violations, verbose);
207
- // Update context file if specified
208
- if (contextPath) {
209
- updateContextFile(contextPath);
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}`);
210
606
  }
211
607
  }, debounceMs));
212
608
  }
213
- // Watch for file changes using fs.watch recursively
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
214
622
  const watchers = [];
215
623
  function watchDirectory(dir) {
216
624
  try {
217
- const watcher = fs.watch(dir, { recursive: true }, (_eventType, filename) => {
625
+ const watcher = fs.watch(dir, { recursive: true }, (eventType, filename) => {
218
626
  if (!filename)
219
627
  return;
220
628
  const fullPath = path.join(dir, filename);
221
- // Only watch supported file types
222
- const ext = path.extname(filename).toLowerCase();
223
- const supportedExts = ['.ts', '.tsx', '.js', '.jsx', '.py', '.css', '.scss', '.json', '.md'];
224
- if (!supportedExts.includes(ext))
225
- return;
226
- // Check if file exists (might be deleted)
227
- if (!fs.existsSync(fullPath))
228
- return;
229
- handleFileChange(fullPath);
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
+ }
230
641
  });
231
642
  watchers.push(watcher);
232
643
  }
@@ -235,30 +646,48 @@ async function watchCommand(options) {
235
646
  }
236
647
  }
237
648
  // Start watching
238
- watchDirectory(cwd);
649
+ watchDirectory(rootDir);
239
650
  console.log(`${timestamp()} ${chalk.green('Watching for changes...')}\n`);
240
651
  // Handle shutdown
241
- process.on('SIGINT', () => {
652
+ process.on('SIGINT', async () => {
242
653
  console.log(chalk.gray('\n\nStopping watch mode...'));
654
+ // Clear pending operations
243
655
  for (const watcher of watchers) {
244
656
  watcher.close();
245
657
  }
246
658
  for (const timeout of pendingScans.values()) {
247
659
  clearTimeout(timeout);
248
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
+ }
249
677
  process.exit(0);
250
678
  });
251
679
  // Keep process alive
252
680
  await new Promise(() => { });
253
681
  }
254
- /**
255
- * Create the watch command
256
- */
682
+ // ============================================================================
683
+ // Command Definition
684
+ // ============================================================================
257
685
  export const watchCommandDef = new Command('watch')
258
- .description('Watch for file changes and report violations in real-time')
259
- .option('--verbose', 'Show detailed violation information')
686
+ .description('Watch for file changes and detect patterns in real-time')
687
+ .option('--verbose', 'Show detailed output')
260
688
  .option('--context <file>', 'Auto-update AI context file on changes')
261
689
  .option('-c, --categories <categories>', 'Filter by categories (comma-separated)')
262
690
  .option('--debounce <ms>', 'Debounce delay in milliseconds', '300')
691
+ .option('--no-persist', 'Disable pattern persistence (only show violations)')
263
692
  .action(watchCommand);
264
693
  //# sourceMappingURL=watch.js.map