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.
- package/dist/bin/drift.js +8 -1
- package/dist/bin/drift.js.map +1 -1
- package/dist/commands/dashboard.d.ts +16 -0
- package/dist/commands/dashboard.d.ts.map +1 -0
- package/dist/commands/dashboard.js +99 -0
- package/dist/commands/dashboard.js.map +1 -0
- package/dist/commands/export.d.ts +2 -0
- package/dist/commands/export.d.ts.map +1 -1
- package/dist/commands/export.js +114 -5
- package/dist/commands/export.js.map +1 -1
- package/dist/commands/files.d.ts +2 -0
- package/dist/commands/files.d.ts.map +1 -1
- package/dist/commands/files.js +103 -20
- package/dist/commands/files.js.map +1 -1
- package/dist/commands/index.d.ts +2 -0
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/index.js +2 -0
- package/dist/commands/index.js.map +1 -1
- package/dist/commands/scan.d.ts +2 -0
- package/dist/commands/scan.d.ts.map +1 -1
- package/dist/commands/scan.js +98 -14
- package/dist/commands/scan.js.map +1 -1
- package/dist/commands/watch.d.ts +13 -0
- package/dist/commands/watch.d.ts.map +1 -0
- package/dist/commands/watch.js +693 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/commands/where.d.ts +2 -0
- package/dist/commands/where.d.ts.map +1 -1
- package/dist/commands/where.js +79 -5
- package/dist/commands/where.js.map +1 -1
- package/dist/services/contract-scanner.d.ts +35 -0
- package/dist/services/contract-scanner.d.ts.map +1 -0
- package/dist/services/contract-scanner.js +183 -0
- package/dist/services/contract-scanner.js.map +1 -0
- package/dist/services/scanner-service.d.ts.map +1 -1
- package/dist/services/scanner-service.js +34 -1
- package/dist/services/scanner-service.js.map +1 -1
- 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
|