driftdetect-dashboard 0.1.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 (33) hide show
  1. package/dist/client/assets/main-DQAs4VF9.js +59 -0
  2. package/dist/client/assets/main-DQAs4VF9.js.map +1 -0
  3. package/dist/client/assets/main-Du5_09U3.css +2 -0
  4. package/dist/client/index.html +19 -0
  5. package/dist/server/api-routes.d.ts +50 -0
  6. package/dist/server/api-routes.d.ts.map +1 -0
  7. package/dist/server/api-routes.js +478 -0
  8. package/dist/server/api-routes.js.map +1 -0
  9. package/dist/server/dashboard-server.d.ts +64 -0
  10. package/dist/server/dashboard-server.d.ts.map +1 -0
  11. package/dist/server/dashboard-server.js +154 -0
  12. package/dist/server/dashboard-server.js.map +1 -0
  13. package/dist/server/drift-data-reader.d.ts +411 -0
  14. package/dist/server/drift-data-reader.d.ts.map +1 -0
  15. package/dist/server/drift-data-reader.js +1151 -0
  16. package/dist/server/drift-data-reader.js.map +1 -0
  17. package/dist/server/express-app.d.ts +24 -0
  18. package/dist/server/express-app.d.ts.map +1 -0
  19. package/dist/server/express-app.js +74 -0
  20. package/dist/server/express-app.js.map +1 -0
  21. package/dist/server/index.d.ts +20 -0
  22. package/dist/server/index.d.ts.map +1 -0
  23. package/dist/server/index.js +14 -0
  24. package/dist/server/index.js.map +1 -0
  25. package/dist/server/pattern-watcher.d.ts +55 -0
  26. package/dist/server/pattern-watcher.d.ts.map +1 -0
  27. package/dist/server/pattern-watcher.js +157 -0
  28. package/dist/server/pattern-watcher.js.map +1 -0
  29. package/dist/server/websocket-server.d.ts +83 -0
  30. package/dist/server/websocket-server.d.ts.map +1 -0
  31. package/dist/server/websocket-server.js +189 -0
  32. package/dist/server/websocket-server.js.map +1 -0
  33. package/package.json +86 -0
@@ -0,0 +1,1151 @@
1
+ /**
2
+ * DriftDataReader
3
+ *
4
+ * Reads and parses data from the .drift/ folder structure.
5
+ * Provides methods for accessing patterns, violations, files, and configuration.
6
+ *
7
+ * @requirements 1.6 - THE Dashboard_Server SHALL read pattern and violation data from the existing `.drift/` folder structure
8
+ * @requirements 8.1 - THE Dashboard_Server SHALL expose GET `/api/patterns` to list all patterns
9
+ * @requirements 8.2 - THE Dashboard_Server SHALL expose GET `/api/patterns/:id` to get pattern details with locations
10
+ * @requirements 8.6 - THE Dashboard_Server SHALL expose GET `/api/violations` to list all violations
11
+ * @requirements 8.7 - THE Dashboard_Server SHALL expose GET `/api/files` to get the file tree
12
+ * @requirements 8.8 - THE Dashboard_Server SHALL expose GET `/api/files/:path` to get patterns and violations for a specific file
13
+ * @requirements 8.9 - THE Dashboard_Server SHALL expose GET `/api/stats` to get overview statistics
14
+ */
15
+ import * as fs from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ // ============================================================================
18
+ // Constants
19
+ // ============================================================================
20
+ const PATTERNS_DIR = 'patterns';
21
+ const STATUS_DIRS = ['discovered', 'approved', 'ignored'];
22
+ const PATTERN_CATEGORIES = [
23
+ 'structural',
24
+ 'components',
25
+ 'styling',
26
+ 'api',
27
+ 'auth',
28
+ 'errors',
29
+ 'data-access',
30
+ 'testing',
31
+ 'logging',
32
+ 'security',
33
+ 'config',
34
+ 'types',
35
+ 'performance',
36
+ 'accessibility',
37
+ 'documentation',
38
+ ];
39
+ // ============================================================================
40
+ // Helper Functions
41
+ // ============================================================================
42
+ /**
43
+ * Check if a file exists
44
+ */
45
+ async function fileExists(filePath) {
46
+ try {
47
+ await fs.access(filePath);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /**
55
+ * Convert a PatternLocation to SemanticLocation
56
+ */
57
+ function toSemanticLocation(loc) {
58
+ return {
59
+ file: loc.file,
60
+ range: {
61
+ start: { line: loc.line, character: loc.column },
62
+ end: { line: loc.endLine ?? loc.line, character: loc.endColumn ?? loc.column },
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * Convert an OutlierLocation to OutlierWithDetails
68
+ */
69
+ function toOutlierWithDetails(outlier) {
70
+ return {
71
+ file: outlier.file,
72
+ range: {
73
+ start: { line: outlier.line, character: outlier.column },
74
+ end: { line: outlier.endLine ?? outlier.line, character: outlier.endColumn ?? outlier.column },
75
+ },
76
+ reason: outlier.reason,
77
+ deviationScore: outlier.deviationScore,
78
+ };
79
+ }
80
+ /**
81
+ * Generate a unique violation ID from pattern and outlier
82
+ */
83
+ function generateViolationId(patternId, outlier) {
84
+ return `${patternId}-${outlier.file}-${outlier.line}-${outlier.column}`;
85
+ }
86
+ // ============================================================================
87
+ // DriftDataReader Class
88
+ // ============================================================================
89
+ export class DriftDataReader {
90
+ driftDir;
91
+ patternsDir;
92
+ constructor(driftDir) {
93
+ this.driftDir = driftDir;
94
+ this.patternsDir = path.join(driftDir, PATTERNS_DIR);
95
+ }
96
+ /**
97
+ * Get the drift directory path
98
+ */
99
+ get directory() {
100
+ return this.driftDir;
101
+ }
102
+ /**
103
+ * Get all patterns, optionally filtered
104
+ *
105
+ * @requirements 8.1 - List all patterns
106
+ */
107
+ async getPatterns(query) {
108
+ const patterns = [];
109
+ // Read patterns from all status directories
110
+ for (const status of STATUS_DIRS) {
111
+ const statusDir = path.join(this.patternsDir, status);
112
+ if (!(await fileExists(statusDir))) {
113
+ continue;
114
+ }
115
+ // Dynamically read all JSON files in this status directory
116
+ try {
117
+ const files = await fs.readdir(statusDir);
118
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
119
+ for (const jsonFile of jsonFiles) {
120
+ const filePath = path.join(statusDir, jsonFile);
121
+ const category = jsonFile.replace('.json', '');
122
+ try {
123
+ const content = await fs.readFile(filePath, 'utf-8');
124
+ const patternFile = JSON.parse(content);
125
+ for (const stored of patternFile.patterns) {
126
+ const dashboardPattern = this.storedToDashboardPattern(stored, category, status);
127
+ patterns.push(dashboardPattern);
128
+ }
129
+ }
130
+ catch (error) {
131
+ // Skip files that can't be parsed
132
+ console.error(`Error reading pattern file ${filePath}:`, error);
133
+ }
134
+ }
135
+ }
136
+ catch (error) {
137
+ console.error(`Error reading status directory ${statusDir}:`, error);
138
+ }
139
+ }
140
+ // Apply filters if provided
141
+ return this.filterPatterns(patterns, query);
142
+ }
143
+ /**
144
+ * Get a single pattern by ID with all locations
145
+ *
146
+ * @requirements 8.2 - Get pattern details with locations
147
+ */
148
+ async getPattern(id) {
149
+ // Search through all status directories dynamically
150
+ for (const status of STATUS_DIRS) {
151
+ const statusDir = path.join(this.patternsDir, status);
152
+ if (!(await fileExists(statusDir))) {
153
+ continue;
154
+ }
155
+ try {
156
+ const files = await fs.readdir(statusDir);
157
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
158
+ for (const jsonFile of jsonFiles) {
159
+ const filePath = path.join(statusDir, jsonFile);
160
+ const category = jsonFile.replace('.json', '');
161
+ try {
162
+ const content = await fs.readFile(filePath, 'utf-8');
163
+ const patternFile = JSON.parse(content);
164
+ const stored = patternFile.patterns.find((p) => p.id === id);
165
+ if (stored) {
166
+ return this.storedToDashboardPatternWithLocations(stored, category, status);
167
+ }
168
+ }
169
+ catch (error) {
170
+ // Skip files that can't be parsed
171
+ console.error(`Error reading pattern file ${filePath}:`, error);
172
+ }
173
+ }
174
+ }
175
+ catch (error) {
176
+ console.error(`Error reading status directory ${statusDir}:`, error);
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+ /**
182
+ * Get all violations, optionally filtered
183
+ *
184
+ * Violations are derived from pattern outliers.
185
+ *
186
+ * @requirements 8.6 - List all violations
187
+ */
188
+ async getViolations(query) {
189
+ const violations = [];
190
+ // Read patterns from all status directories dynamically
191
+ for (const status of STATUS_DIRS) {
192
+ const statusDir = path.join(this.patternsDir, status);
193
+ if (!(await fileExists(statusDir))) {
194
+ continue;
195
+ }
196
+ try {
197
+ const files = await fs.readdir(statusDir);
198
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
199
+ for (const jsonFile of jsonFiles) {
200
+ const filePath = path.join(statusDir, jsonFile);
201
+ try {
202
+ const content = await fs.readFile(filePath, 'utf-8');
203
+ const patternFile = JSON.parse(content);
204
+ for (const stored of patternFile.patterns) {
205
+ // Convert outliers to violations
206
+ for (const outlier of stored.outliers) {
207
+ const violation = this.outlierToViolation(stored, outlier);
208
+ violations.push(violation);
209
+ }
210
+ }
211
+ }
212
+ catch (error) {
213
+ // Skip files that can't be parsed
214
+ console.error(`Error reading pattern file ${filePath}:`, error);
215
+ }
216
+ }
217
+ }
218
+ catch (error) {
219
+ console.error(`Error reading status directory ${statusDir}:`, error);
220
+ }
221
+ }
222
+ // Apply filters if provided
223
+ return this.filterViolations(violations, query);
224
+ }
225
+ /**
226
+ * Get dashboard statistics
227
+ * @requirements 8.9 - GET `/api/stats` to get overview statistics
228
+ */
229
+ async getStats() {
230
+ const patterns = await this.getPatterns();
231
+ const violations = await this.getViolations();
232
+ // Count patterns by status
233
+ const byStatus = {
234
+ discovered: 0,
235
+ approved: 0,
236
+ ignored: 0,
237
+ };
238
+ for (const pattern of patterns) {
239
+ byStatus[pattern.status]++;
240
+ }
241
+ // Count patterns by category - dynamically from actual patterns
242
+ const byCategory = {};
243
+ for (const pattern of patterns) {
244
+ const category = pattern.category;
245
+ byCategory[category] = (byCategory[category] || 0) + 1;
246
+ }
247
+ // Count violations by severity
248
+ const bySeverity = {
249
+ error: 0,
250
+ warning: 0,
251
+ info: 0,
252
+ hint: 0,
253
+ };
254
+ for (const violation of violations) {
255
+ const severity = violation.severity;
256
+ if (severity in bySeverity) {
257
+ bySeverity[severity]++;
258
+ }
259
+ }
260
+ // Collect unique files from patterns and violations
261
+ const filesSet = new Set();
262
+ for (const pattern of patterns) {
263
+ // We need to get the full pattern to access locations
264
+ const fullPattern = await this.getPattern(pattern.id);
265
+ if (fullPattern) {
266
+ for (const loc of fullPattern.locations) {
267
+ filesSet.add(loc.file);
268
+ }
269
+ for (const outlier of fullPattern.outliers) {
270
+ filesSet.add(outlier.file);
271
+ }
272
+ }
273
+ }
274
+ // Calculate health score
275
+ const healthScore = this.calculateHealthScore(violations, patterns);
276
+ // Get last scan time from pattern metadata
277
+ let lastScan = null;
278
+ for (const pattern of patterns) {
279
+ if (pattern.metadata.lastSeen) {
280
+ if (!lastScan || pattern.metadata.lastSeen > lastScan) {
281
+ lastScan = pattern.metadata.lastSeen;
282
+ }
283
+ }
284
+ }
285
+ return {
286
+ healthScore,
287
+ patterns: {
288
+ total: patterns.length,
289
+ byStatus,
290
+ byCategory: byCategory,
291
+ },
292
+ violations: {
293
+ total: violations.length,
294
+ bySeverity,
295
+ },
296
+ files: {
297
+ total: filesSet.size,
298
+ scanned: filesSet.size,
299
+ },
300
+ detectors: {
301
+ active: Object.keys(byCategory).length, // Count unique categories found
302
+ total: Object.keys(byCategory).length,
303
+ },
304
+ lastScan,
305
+ };
306
+ }
307
+ /**
308
+ * Get the file tree structure
309
+ * @requirements 8.7 - GET `/api/files` to get the file tree
310
+ */
311
+ async getFileTree() {
312
+ const patterns = await this.getPatterns();
313
+ const violations = await this.getViolations();
314
+ // Collect file information
315
+ const fileInfo = new Map();
316
+ // Count patterns per file
317
+ for (const pattern of patterns) {
318
+ const fullPattern = await this.getPattern(pattern.id);
319
+ if (fullPattern) {
320
+ for (const loc of fullPattern.locations) {
321
+ const info = fileInfo.get(loc.file) || { patternCount: 0, violationCount: 0 };
322
+ info.patternCount++;
323
+ fileInfo.set(loc.file, info);
324
+ }
325
+ }
326
+ }
327
+ // Count violations per file and track highest severity
328
+ for (const violation of violations) {
329
+ const info = fileInfo.get(violation.file) || { patternCount: 0, violationCount: 0 };
330
+ info.violationCount++;
331
+ // Track highest severity
332
+ const violationSeverity = violation.severity;
333
+ if (!info.severity || this.compareSeverity(violationSeverity, info.severity) > 0) {
334
+ info.severity = violationSeverity;
335
+ }
336
+ fileInfo.set(violation.file, info);
337
+ }
338
+ // Build tree structure
339
+ return this.buildFileTree(fileInfo);
340
+ }
341
+ /**
342
+ * Get details for a specific file
343
+ * @requirements 8.8 - GET `/api/files/:path` to get patterns and violations for a specific file
344
+ */
345
+ async getFileDetails(filePath) {
346
+ const patterns = await this.getPatterns();
347
+ const violations = await this.getViolations();
348
+ // Find patterns that have locations in this file
349
+ const filePatterns = [];
350
+ for (const pattern of patterns) {
351
+ const fullPattern = await this.getPattern(pattern.id);
352
+ if (fullPattern) {
353
+ const locationsInFile = fullPattern.locations.filter((loc) => loc.file === filePath);
354
+ if (locationsInFile.length > 0) {
355
+ filePatterns.push({
356
+ id: pattern.id,
357
+ name: pattern.name,
358
+ category: pattern.category,
359
+ locations: locationsInFile,
360
+ });
361
+ }
362
+ }
363
+ }
364
+ // Find violations in this file
365
+ const fileViolations = violations.filter((v) => v.file === filePath);
366
+ // If no patterns or violations found, return null
367
+ if (filePatterns.length === 0 && fileViolations.length === 0) {
368
+ return null;
369
+ }
370
+ // Determine language from file extension
371
+ const language = this.getLanguageFromPath(filePath);
372
+ return {
373
+ path: filePath,
374
+ language,
375
+ lineCount: 0, // We don't have access to actual file content
376
+ patterns: filePatterns,
377
+ violations: fileViolations,
378
+ };
379
+ }
380
+ /**
381
+ * Get configuration
382
+ * @requirements 8.10 - GET `/api/config` to get configuration
383
+ */
384
+ async getConfig() {
385
+ const configPath = path.join(this.driftDir, 'config.json');
386
+ if (!(await fileExists(configPath))) {
387
+ // Return default config if none exists
388
+ return this.getDefaultConfig();
389
+ }
390
+ try {
391
+ const content = await fs.readFile(configPath, 'utf-8');
392
+ return JSON.parse(content);
393
+ }
394
+ catch (error) {
395
+ console.error('Error reading config:', error);
396
+ return this.getDefaultConfig();
397
+ }
398
+ }
399
+ /**
400
+ * Update configuration
401
+ * @requirements 8.11 - PUT `/api/config` to update configuration
402
+ */
403
+ async updateConfig(partial) {
404
+ const configPath = path.join(this.driftDir, 'config.json');
405
+ const currentConfig = await this.getConfig();
406
+ // Merge the partial config with current config
407
+ const newConfig = {
408
+ ...currentConfig,
409
+ ...partial,
410
+ detectors: partial.detectors ?? currentConfig.detectors,
411
+ severityOverrides: {
412
+ ...currentConfig.severityOverrides,
413
+ ...partial.severityOverrides,
414
+ },
415
+ ignorePatterns: partial.ignorePatterns ?? currentConfig.ignorePatterns,
416
+ };
417
+ await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
418
+ }
419
+ /**
420
+ * Approve a pattern - changes status to 'approved'
421
+ * @requirements 4.4 - Approve pattern
422
+ * @requirements 8.3 - POST `/api/patterns/:id/approve` to approve a pattern
423
+ */
424
+ async approvePattern(id) {
425
+ await this.changePatternStatus(id, 'approved');
426
+ }
427
+ /**
428
+ * Ignore a pattern - changes status to 'ignored'
429
+ * @requirements 4.5 - Ignore pattern
430
+ * @requirements 8.4 - POST `/api/patterns/:id/ignore` to ignore a pattern
431
+ */
432
+ async ignorePattern(id) {
433
+ await this.changePatternStatus(id, 'ignored');
434
+ }
435
+ /**
436
+ * Delete a pattern - removes from storage
437
+ * @requirements 4.6 - Delete pattern
438
+ * @requirements 8.5 - DELETE `/api/patterns/:id` to delete a pattern
439
+ */
440
+ async deletePattern(id) {
441
+ // Find the pattern and its location
442
+ const location = await this.findPatternLocation(id);
443
+ if (!location) {
444
+ throw new Error(`Pattern not found: ${id}`);
445
+ }
446
+ const { filePath } = location;
447
+ // Read the pattern file
448
+ const content = await fs.readFile(filePath, 'utf-8');
449
+ const patternFile = JSON.parse(content);
450
+ // Remove the pattern
451
+ patternFile.patterns = patternFile.patterns.filter((p) => p.id !== id);
452
+ patternFile.lastUpdated = new Date().toISOString();
453
+ // Write back or delete file if empty
454
+ if (patternFile.patterns.length === 0) {
455
+ await fs.unlink(filePath);
456
+ }
457
+ else {
458
+ await fs.writeFile(filePath, JSON.stringify(patternFile, null, 2));
459
+ }
460
+ }
461
+ // ==========================================================================
462
+ // Private Helper Methods
463
+ // ==========================================================================
464
+ /**
465
+ * Convert a StoredPattern to DashboardPattern
466
+ */
467
+ storedToDashboardPattern(stored, category, status) {
468
+ return {
469
+ id: stored.id,
470
+ name: stored.name,
471
+ category,
472
+ subcategory: stored.subcategory,
473
+ status,
474
+ description: stored.description,
475
+ confidence: {
476
+ score: stored.confidence.score,
477
+ level: stored.confidence.level,
478
+ },
479
+ locationCount: stored.locations.length,
480
+ outlierCount: stored.outliers.length,
481
+ severity: stored.severity,
482
+ metadata: {
483
+ firstSeen: stored.metadata.firstSeen,
484
+ lastSeen: stored.metadata.lastSeen,
485
+ tags: stored.metadata.tags,
486
+ },
487
+ };
488
+ }
489
+ /**
490
+ * Convert a StoredPattern to DashboardPatternWithLocations
491
+ */
492
+ storedToDashboardPatternWithLocations(stored, category, status) {
493
+ const base = this.storedToDashboardPattern(stored, category, status);
494
+ return {
495
+ ...base,
496
+ locations: stored.locations.map(toSemanticLocation),
497
+ outliers: stored.outliers.map(toOutlierWithDetails),
498
+ };
499
+ }
500
+ /**
501
+ * Convert an outlier to a violation
502
+ */
503
+ outlierToViolation(pattern, outlier) {
504
+ return {
505
+ id: generateViolationId(pattern.id, outlier),
506
+ patternId: pattern.id,
507
+ patternName: pattern.name,
508
+ severity: pattern.severity,
509
+ file: outlier.file,
510
+ range: {
511
+ start: { line: outlier.line, character: outlier.column },
512
+ end: { line: outlier.endLine ?? outlier.line, character: outlier.endColumn ?? outlier.column },
513
+ },
514
+ message: outlier.reason,
515
+ expected: pattern.description,
516
+ actual: outlier.reason,
517
+ };
518
+ }
519
+ /**
520
+ * Filter patterns based on query
521
+ */
522
+ filterPatterns(patterns, query) {
523
+ if (!query) {
524
+ return patterns;
525
+ }
526
+ return patterns.filter((pattern) => {
527
+ // Filter by category
528
+ if (query.category && pattern.category !== query.category) {
529
+ return false;
530
+ }
531
+ // Filter by status
532
+ if (query.status && pattern.status !== query.status) {
533
+ return false;
534
+ }
535
+ // Filter by minimum confidence
536
+ if (query.minConfidence !== undefined && pattern.confidence.score < query.minConfidence) {
537
+ return false;
538
+ }
539
+ // Filter by search term (name or description)
540
+ if (query.search) {
541
+ const searchLower = query.search.toLowerCase();
542
+ const nameMatch = pattern.name.toLowerCase().includes(searchLower);
543
+ const descMatch = pattern.description.toLowerCase().includes(searchLower);
544
+ if (!nameMatch && !descMatch) {
545
+ return false;
546
+ }
547
+ }
548
+ return true;
549
+ });
550
+ }
551
+ /**
552
+ * Filter violations based on query
553
+ */
554
+ filterViolations(violations, query) {
555
+ if (!query) {
556
+ return violations;
557
+ }
558
+ return violations.filter((violation) => {
559
+ // Filter by severity
560
+ if (query.severity && violation.severity !== query.severity) {
561
+ return false;
562
+ }
563
+ // Filter by file
564
+ if (query.file && violation.file !== query.file) {
565
+ return false;
566
+ }
567
+ // Filter by pattern ID
568
+ if (query.patternId && violation.patternId !== query.patternId) {
569
+ return false;
570
+ }
571
+ // Filter by search term (message or pattern name)
572
+ if (query.search) {
573
+ const searchLower = query.search.toLowerCase();
574
+ const messageMatch = violation.message.toLowerCase().includes(searchLower);
575
+ const nameMatch = violation.patternName.toLowerCase().includes(searchLower);
576
+ if (!messageMatch && !nameMatch) {
577
+ return false;
578
+ }
579
+ }
580
+ return true;
581
+ });
582
+ }
583
+ /**
584
+ * Calculate health score based on violations and patterns
585
+ *
586
+ * Health score formula:
587
+ * - Base score starts at 100
588
+ * - Deduct for violations by severity (error: -10, warning: -3, info: -1, hint: 0)
589
+ * - Bonus for approved patterns (shows intentional architecture)
590
+ * - Clamp to 0-100
591
+ */
592
+ calculateHealthScore(violations, patterns) {
593
+ let score = 100;
594
+ // Deduct for violations by severity
595
+ for (const violation of violations) {
596
+ switch (violation.severity) {
597
+ case 'error':
598
+ score -= 10;
599
+ break;
600
+ case 'warning':
601
+ score -= 3;
602
+ break;
603
+ case 'info':
604
+ score -= 1;
605
+ break;
606
+ // hint doesn't deduct
607
+ }
608
+ }
609
+ // Bonus for approved patterns (shows intentional architecture)
610
+ if (patterns.length > 0) {
611
+ const approvedCount = patterns.filter((p) => p.status === 'approved').length;
612
+ const approvalRate = approvedCount / patterns.length;
613
+ score += approvalRate * 10;
614
+ }
615
+ // Clamp to 0-100
616
+ return Math.max(0, Math.min(100, Math.round(score)));
617
+ }
618
+ /**
619
+ * Build a hierarchical file tree from file information
620
+ */
621
+ buildFileTree(fileInfo) {
622
+ // First pass: collect all unique directory paths and files
623
+ const nodeMap = new Map();
624
+ for (const [filePath, info] of fileInfo) {
625
+ const parts = filePath.split('/').filter(Boolean);
626
+ if (parts.length === 0)
627
+ continue;
628
+ let currentPath = '';
629
+ // Create directory nodes
630
+ for (let i = 0; i < parts.length - 1; i++) {
631
+ const part = parts[i];
632
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
633
+ if (!nodeMap.has(currentPath)) {
634
+ nodeMap.set(currentPath, {
635
+ name: part,
636
+ path: currentPath,
637
+ type: 'directory',
638
+ children: [],
639
+ patternCount: 0,
640
+ violationCount: 0,
641
+ });
642
+ }
643
+ }
644
+ // Create file node
645
+ const fileName = parts[parts.length - 1];
646
+ const fullPath = parts.join('/');
647
+ const fileNode = {
648
+ name: fileName,
649
+ path: fullPath,
650
+ type: 'file',
651
+ patternCount: info.patternCount,
652
+ violationCount: info.violationCount,
653
+ };
654
+ if (info.severity) {
655
+ fileNode.severity = info.severity;
656
+ }
657
+ nodeMap.set(fullPath, fileNode);
658
+ }
659
+ // Second pass: build parent-child relationships and aggregate counts
660
+ for (const [nodePath, node] of nodeMap) {
661
+ if (node.type === 'file') {
662
+ // Find parent directory
663
+ const parts = nodePath.split('/');
664
+ if (parts.length > 1) {
665
+ const parentPath = parts.slice(0, -1).join('/');
666
+ const parent = nodeMap.get(parentPath);
667
+ if (parent && parent.children) {
668
+ parent.children.push(node);
669
+ // Aggregate counts to parent
670
+ if (parent.patternCount !== undefined && node.patternCount !== undefined) {
671
+ parent.patternCount += node.patternCount;
672
+ }
673
+ if (parent.violationCount !== undefined && node.violationCount !== undefined) {
674
+ parent.violationCount += node.violationCount;
675
+ }
676
+ // Track highest severity
677
+ if (node.severity) {
678
+ if (!parent.severity || this.compareSeverity(node.severity, parent.severity) > 0) {
679
+ parent.severity = node.severity;
680
+ }
681
+ }
682
+ }
683
+ }
684
+ }
685
+ }
686
+ // Third pass: link directories to their parents
687
+ for (const [nodePath, node] of nodeMap) {
688
+ if (node.type === 'directory') {
689
+ const parts = nodePath.split('/');
690
+ if (parts.length > 1) {
691
+ const parentPath = parts.slice(0, -1).join('/');
692
+ const parent = nodeMap.get(parentPath);
693
+ if (parent && parent.children) {
694
+ // Check if not already added
695
+ if (!parent.children.some(c => c.path === node.path)) {
696
+ parent.children.push(node);
697
+ }
698
+ // Aggregate counts to parent
699
+ if (parent.patternCount !== undefined && node.patternCount !== undefined) {
700
+ parent.patternCount += node.patternCount;
701
+ }
702
+ if (parent.violationCount !== undefined && node.violationCount !== undefined) {
703
+ parent.violationCount += node.violationCount;
704
+ }
705
+ // Track highest severity
706
+ if (node.severity) {
707
+ if (!parent.severity || this.compareSeverity(node.severity, parent.severity) > 0) {
708
+ parent.severity = node.severity;
709
+ }
710
+ }
711
+ }
712
+ }
713
+ }
714
+ }
715
+ // Get root nodes (nodes without parents)
716
+ const rootNodes = [];
717
+ for (const [nodePath, node] of nodeMap) {
718
+ const parts = nodePath.split('/');
719
+ if (parts.length === 1) {
720
+ rootNodes.push(node);
721
+ }
722
+ }
723
+ // Sort and return
724
+ return this.sortFileTree(rootNodes);
725
+ }
726
+ /**
727
+ * Sort file tree: directories first, then alphabetically
728
+ */
729
+ sortFileTree(nodes) {
730
+ return nodes
731
+ .map((node) => {
732
+ if (node.children && node.children.length > 0) {
733
+ return {
734
+ ...node,
735
+ children: this.sortFileTree(node.children),
736
+ };
737
+ }
738
+ return node;
739
+ })
740
+ .sort((a, b) => {
741
+ // Directories first
742
+ if (a.type !== b.type) {
743
+ return a.type === 'directory' ? -1 : 1;
744
+ }
745
+ // Then alphabetically
746
+ return a.name.localeCompare(b.name);
747
+ });
748
+ }
749
+ /**
750
+ * Compare severity levels
751
+ * Returns positive if a > b, negative if a < b, 0 if equal
752
+ */
753
+ compareSeverity(a, b) {
754
+ const order = {
755
+ error: 4,
756
+ warning: 3,
757
+ info: 2,
758
+ hint: 1,
759
+ };
760
+ return order[a] - order[b];
761
+ }
762
+ /**
763
+ * Get programming language from file path
764
+ */
765
+ getLanguageFromPath(filePath) {
766
+ const ext = path.extname(filePath).toLowerCase();
767
+ const languageMap = {
768
+ '.ts': 'typescript',
769
+ '.tsx': 'typescript',
770
+ '.js': 'javascript',
771
+ '.jsx': 'javascript',
772
+ '.py': 'python',
773
+ '.rb': 'ruby',
774
+ '.java': 'java',
775
+ '.go': 'go',
776
+ '.rs': 'rust',
777
+ '.c': 'c',
778
+ '.cpp': 'cpp',
779
+ '.h': 'c',
780
+ '.hpp': 'cpp',
781
+ '.cs': 'csharp',
782
+ '.php': 'php',
783
+ '.swift': 'swift',
784
+ '.kt': 'kotlin',
785
+ '.scala': 'scala',
786
+ '.vue': 'vue',
787
+ '.svelte': 'svelte',
788
+ '.html': 'html',
789
+ '.css': 'css',
790
+ '.scss': 'scss',
791
+ '.less': 'less',
792
+ '.json': 'json',
793
+ '.yaml': 'yaml',
794
+ '.yml': 'yaml',
795
+ '.xml': 'xml',
796
+ '.md': 'markdown',
797
+ '.sql': 'sql',
798
+ '.sh': 'bash',
799
+ '.bash': 'bash',
800
+ '.zsh': 'zsh',
801
+ };
802
+ return languageMap[ext] || 'plaintext';
803
+ }
804
+ /**
805
+ * Change pattern status (move between status directories)
806
+ */
807
+ async changePatternStatus(id, newStatus) {
808
+ // Find the pattern and its current location
809
+ const location = await this.findPatternLocation(id);
810
+ if (!location) {
811
+ throw new Error(`Pattern not found: ${id}`);
812
+ }
813
+ const { status: currentStatus, category, filePath, pattern } = location;
814
+ // If already in the target status, nothing to do
815
+ if (currentStatus === newStatus) {
816
+ return;
817
+ }
818
+ // Read the source pattern file
819
+ const sourceContent = await fs.readFile(filePath, 'utf-8');
820
+ const sourceFile = JSON.parse(sourceContent);
821
+ // Remove pattern from source file
822
+ sourceFile.patterns = sourceFile.patterns.filter((p) => p.id !== id);
823
+ sourceFile.lastUpdated = new Date().toISOString();
824
+ // Write back source file or delete if empty
825
+ if (sourceFile.patterns.length === 0) {
826
+ await fs.unlink(filePath);
827
+ }
828
+ else {
829
+ await fs.writeFile(filePath, JSON.stringify(sourceFile, null, 2));
830
+ }
831
+ // Add pattern to target status directory
832
+ const targetDir = path.join(this.patternsDir, newStatus);
833
+ const targetPath = path.join(targetDir, `${category}.json`);
834
+ // Ensure target directory exists
835
+ await fs.mkdir(targetDir, { recursive: true });
836
+ // Read or create target file
837
+ let targetFile;
838
+ if (await fileExists(targetPath)) {
839
+ const targetContent = await fs.readFile(targetPath, 'utf-8');
840
+ targetFile = JSON.parse(targetContent);
841
+ }
842
+ else {
843
+ targetFile = {
844
+ version: '1.0.0',
845
+ category,
846
+ patterns: [],
847
+ lastUpdated: new Date().toISOString(),
848
+ };
849
+ }
850
+ // Update pattern metadata if approving
851
+ const updatedPattern = { ...pattern };
852
+ if (newStatus === 'approved') {
853
+ updatedPattern.metadata = {
854
+ ...updatedPattern.metadata,
855
+ approvedAt: new Date().toISOString(),
856
+ };
857
+ }
858
+ // Add pattern to target file
859
+ targetFile.patterns.push(updatedPattern);
860
+ targetFile.lastUpdated = new Date().toISOString();
861
+ // Write target file
862
+ await fs.writeFile(targetPath, JSON.stringify(targetFile, null, 2));
863
+ }
864
+ /**
865
+ * Find a pattern's location in the file system
866
+ */
867
+ async findPatternLocation(id) {
868
+ for (const status of STATUS_DIRS) {
869
+ const statusDir = path.join(this.patternsDir, status);
870
+ if (!(await fileExists(statusDir))) {
871
+ continue;
872
+ }
873
+ try {
874
+ const files = await fs.readdir(statusDir);
875
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
876
+ for (const jsonFile of jsonFiles) {
877
+ const filePath = path.join(statusDir, jsonFile);
878
+ const category = jsonFile.replace('.json', '');
879
+ try {
880
+ const content = await fs.readFile(filePath, 'utf-8');
881
+ const patternFile = JSON.parse(content);
882
+ const pattern = patternFile.patterns.find((p) => p.id === id);
883
+ if (pattern) {
884
+ return { status, category, filePath, pattern };
885
+ }
886
+ }
887
+ catch (error) {
888
+ // Skip files that can't be parsed
889
+ console.error(`Error reading pattern file ${filePath}:`, error);
890
+ }
891
+ }
892
+ }
893
+ catch (error) {
894
+ console.error(`Error reading status directory ${statusDir}:`, error);
895
+ }
896
+ }
897
+ return null;
898
+ }
899
+ /**
900
+ * Get default configuration
901
+ */
902
+ getDefaultConfig() {
903
+ return {
904
+ version: '1.0.0',
905
+ detectors: PATTERN_CATEGORIES.map((category) => ({
906
+ id: category,
907
+ name: category.charAt(0).toUpperCase() + category.slice(1).replace(/-/g, ' '),
908
+ enabled: true,
909
+ category,
910
+ })),
911
+ severityOverrides: {},
912
+ ignorePatterns: ['node_modules/**', 'dist/**', '.git/**'],
913
+ };
914
+ }
915
+ /**
916
+ * Get code snippet from a file at a specific line with context
917
+ */
918
+ async getCodeSnippet(filePath, line, contextLines = 3) {
919
+ // driftDir is .drift/, so workspace root is the parent
920
+ const workspaceRoot = path.dirname(this.driftDir);
921
+ const fullPath = path.join(workspaceRoot, filePath);
922
+ try {
923
+ const content = await fs.readFile(fullPath, 'utf-8');
924
+ const lines = content.split('\n');
925
+ const startLine = Math.max(1, line - contextLines);
926
+ const endLine = Math.min(lines.length, line + contextLines);
927
+ const snippetLines = lines.slice(startLine - 1, endLine);
928
+ const code = snippetLines.join('\n');
929
+ return {
930
+ code,
931
+ startLine,
932
+ endLine,
933
+ language: this.getLanguageFromPath(filePath),
934
+ };
935
+ }
936
+ catch (error) {
937
+ console.error(`Error reading file ${fullPath}:`, error);
938
+ return null;
939
+ }
940
+ }
941
+ // ==========================================================================
942
+ // Contract Methods (BE↔FE mismatch detection)
943
+ // ==========================================================================
944
+ /**
945
+ * Get all contracts, optionally filtered
946
+ */
947
+ async getContracts(query) {
948
+ const contracts = [];
949
+ const contractsDir = path.join(this.driftDir, 'contracts');
950
+ const statusDirs = ['discovered', 'verified', 'mismatch', 'ignored'];
951
+ for (const status of statusDirs) {
952
+ const statusDir = path.join(contractsDir, status);
953
+ if (!(await fileExists(statusDir))) {
954
+ continue;
955
+ }
956
+ const filePath = path.join(statusDir, 'contracts.json');
957
+ if (!(await fileExists(filePath))) {
958
+ continue;
959
+ }
960
+ try {
961
+ const content = await fs.readFile(filePath, 'utf-8');
962
+ const contractFile = JSON.parse(content);
963
+ for (const stored of contractFile.contracts) {
964
+ contracts.push({
965
+ ...stored,
966
+ status,
967
+ mismatchCount: stored.mismatches?.length || 0,
968
+ });
969
+ }
970
+ }
971
+ catch (error) {
972
+ console.error(`Error reading contract file ${filePath}:`, error);
973
+ }
974
+ }
975
+ // Apply filters
976
+ return this.filterContracts(contracts, query);
977
+ }
978
+ /**
979
+ * Get a single contract by ID
980
+ */
981
+ async getContract(id) {
982
+ const contractsDir = path.join(this.driftDir, 'contracts');
983
+ const statusDirs = ['discovered', 'verified', 'mismatch', 'ignored'];
984
+ for (const status of statusDirs) {
985
+ const filePath = path.join(contractsDir, status, 'contracts.json');
986
+ if (!(await fileExists(filePath))) {
987
+ continue;
988
+ }
989
+ try {
990
+ const content = await fs.readFile(filePath, 'utf-8');
991
+ const contractFile = JSON.parse(content);
992
+ const contract = contractFile.contracts.find((c) => c.id === id);
993
+ if (contract) {
994
+ return {
995
+ ...contract,
996
+ status,
997
+ mismatchCount: contract.mismatches?.length || 0,
998
+ };
999
+ }
1000
+ }
1001
+ catch (error) {
1002
+ console.error(`Error reading contract file ${filePath}:`, error);
1003
+ }
1004
+ }
1005
+ return null;
1006
+ }
1007
+ /**
1008
+ * Get contract statistics
1009
+ */
1010
+ async getContractStats() {
1011
+ const contracts = await this.getContracts();
1012
+ const byStatus = {
1013
+ discovered: 0,
1014
+ verified: 0,
1015
+ mismatch: 0,
1016
+ ignored: 0,
1017
+ };
1018
+ const byMethod = {
1019
+ GET: 0,
1020
+ POST: 0,
1021
+ PUT: 0,
1022
+ PATCH: 0,
1023
+ DELETE: 0,
1024
+ };
1025
+ let totalMismatches = 0;
1026
+ const mismatchesByType = {};
1027
+ for (const contract of contracts) {
1028
+ const statusKey = contract.status;
1029
+ const methodKey = contract.method;
1030
+ if (statusKey in byStatus) {
1031
+ byStatus[statusKey] = (byStatus[statusKey] || 0) + 1;
1032
+ }
1033
+ if (methodKey in byMethod) {
1034
+ byMethod[methodKey] = (byMethod[methodKey] || 0) + 1;
1035
+ }
1036
+ totalMismatches += contract.mismatchCount;
1037
+ for (const mismatch of contract.mismatches || []) {
1038
+ mismatchesByType[mismatch.mismatchType] = (mismatchesByType[mismatch.mismatchType] || 0) + 1;
1039
+ }
1040
+ }
1041
+ return {
1042
+ totalContracts: contracts.length,
1043
+ byStatus,
1044
+ byMethod,
1045
+ totalMismatches,
1046
+ mismatchesByType,
1047
+ };
1048
+ }
1049
+ /**
1050
+ * Verify a contract
1051
+ */
1052
+ async verifyContract(id) {
1053
+ await this.changeContractStatus(id, 'verified');
1054
+ }
1055
+ /**
1056
+ * Ignore a contract
1057
+ */
1058
+ async ignoreContract(id) {
1059
+ await this.changeContractStatus(id, 'ignored');
1060
+ }
1061
+ /**
1062
+ * Change contract status
1063
+ */
1064
+ async changeContractStatus(id, newStatus) {
1065
+ const contractsDir = path.join(this.driftDir, 'contracts');
1066
+ const statusDirs = ['discovered', 'verified', 'mismatch', 'ignored'];
1067
+ let foundContract = null;
1068
+ // Find the contract
1069
+ for (const status of statusDirs) {
1070
+ const filePath = path.join(contractsDir, status, 'contracts.json');
1071
+ if (!(await fileExists(filePath))) {
1072
+ continue;
1073
+ }
1074
+ try {
1075
+ const content = await fs.readFile(filePath, 'utf-8');
1076
+ const contractFile = JSON.parse(content);
1077
+ const contractIndex = contractFile.contracts.findIndex((c) => c.id === id);
1078
+ if (contractIndex !== -1) {
1079
+ foundContract = contractFile.contracts[contractIndex];
1080
+ // Remove from current file
1081
+ contractFile.contracts.splice(contractIndex, 1);
1082
+ contractFile.lastUpdated = new Date().toISOString();
1083
+ if (contractFile.contracts.length === 0) {
1084
+ await fs.unlink(filePath);
1085
+ }
1086
+ else {
1087
+ await fs.writeFile(filePath, JSON.stringify(contractFile, null, 2));
1088
+ }
1089
+ break;
1090
+ }
1091
+ }
1092
+ catch (error) {
1093
+ console.error(`Error reading contract file ${filePath}:`, error);
1094
+ }
1095
+ }
1096
+ if (!foundContract) {
1097
+ throw new Error(`Contract not found: ${id}`);
1098
+ }
1099
+ // Add to new status directory
1100
+ const targetDir = path.join(contractsDir, newStatus);
1101
+ const targetPath = path.join(targetDir, 'contracts.json');
1102
+ await fs.mkdir(targetDir, { recursive: true });
1103
+ let targetFile;
1104
+ if (await fileExists(targetPath)) {
1105
+ const content = await fs.readFile(targetPath, 'utf-8');
1106
+ targetFile = JSON.parse(content);
1107
+ }
1108
+ else {
1109
+ targetFile = {
1110
+ version: '1.0.0',
1111
+ status: newStatus,
1112
+ contracts: [],
1113
+ lastUpdated: new Date().toISOString(),
1114
+ };
1115
+ }
1116
+ // Update metadata
1117
+ foundContract.metadata = {
1118
+ ...foundContract.metadata,
1119
+ lastSeen: new Date().toISOString(),
1120
+ };
1121
+ if (newStatus === 'verified') {
1122
+ foundContract.metadata.verifiedAt = new Date().toISOString();
1123
+ }
1124
+ targetFile.contracts.push(foundContract);
1125
+ targetFile.lastUpdated = new Date().toISOString();
1126
+ await fs.writeFile(targetPath, JSON.stringify(targetFile, null, 2));
1127
+ }
1128
+ /**
1129
+ * Filter contracts based on query
1130
+ */
1131
+ filterContracts(contracts, query) {
1132
+ if (!query)
1133
+ return contracts;
1134
+ return contracts.filter((contract) => {
1135
+ if (query.status && contract.status !== query.status)
1136
+ return false;
1137
+ if (query.method && contract.method !== query.method)
1138
+ return false;
1139
+ if (query.hasMismatches !== undefined) {
1140
+ const hasMismatches = contract.mismatchCount > 0;
1141
+ if (query.hasMismatches !== hasMismatches)
1142
+ return false;
1143
+ }
1144
+ if (query.search && !contract.endpoint.toLowerCase().includes(query.search.toLowerCase())) {
1145
+ return false;
1146
+ }
1147
+ return true;
1148
+ });
1149
+ }
1150
+ }
1151
+ //# sourceMappingURL=drift-data-reader.js.map