driftdetect-dashboard 0.8.1 → 0.8.3
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/LICENSE +121 -0
- package/dist/server/api-routes.d.ts +50 -0
- package/dist/server/api-routes.js +652 -0
- package/dist/server/api-routes.js.map +1 -0
- package/dist/server/dashboard-server.d.ts +64 -0
- package/dist/server/dashboard-server.js +154 -0
- package/dist/server/dashboard-server.js.map +1 -0
- package/dist/server/drift-data-reader.d.ts +522 -0
- package/dist/server/drift-data-reader.js +1550 -0
- package/dist/server/drift-data-reader.js.map +1 -0
- package/dist/server/express-app.d.ts +24 -0
- package/dist/server/express-app.js +74 -0
- package/dist/server/express-app.js.map +1 -0
- package/dist/server/galaxy-data-transformer.d.ts +178 -0
- package/dist/server/galaxy-data-transformer.d.ts.map +1 -1
- package/dist/server/galaxy-data-transformer.js +588 -0
- package/dist/server/galaxy-data-transformer.js.map +1 -0
- package/dist/server/index.d.ts +20 -0
- package/dist/server/index.js +14 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/pattern-watcher.d.ts +55 -0
- package/dist/server/pattern-watcher.d.ts.map +1 -0
- package/dist/server/pattern-watcher.js +157 -0
- package/dist/server/pattern-watcher.js.map +1 -0
- package/dist/server/quality-gates-api.d.ts +12 -0
- package/dist/server/quality-gates-api.js +226 -0
- package/dist/server/quality-gates-api.js.map +1 -0
- package/dist/server/websocket-server.d.ts +83 -0
- package/dist/server/websocket-server.js +189 -0
- package/dist/server/websocket-server.js.map +1 -0
- package/package.json +20 -20
|
@@ -0,0 +1,1550 @@
|
|
|
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
|
+
* OPTIMIZED: Uses DataLake for fast reads with pre-computed views.
|
|
8
|
+
* Falls back to direct file reading when lake data is unavailable.
|
|
9
|
+
*
|
|
10
|
+
* @requirements 1.6 - THE Dashboard_Server SHALL read pattern and violation data from the existing `.drift/` folder structure
|
|
11
|
+
* @requirements 8.1 - THE Dashboard_Server SHALL expose GET `/api/patterns` to list all patterns
|
|
12
|
+
* @requirements 8.2 - THE Dashboard_Server SHALL expose GET `/api/patterns/:id` to get pattern details with locations
|
|
13
|
+
* @requirements 8.6 - THE Dashboard_Server SHALL expose GET `/api/violations` to list all violations
|
|
14
|
+
* @requirements 8.7 - THE Dashboard_Server SHALL expose GET `/api/files` to get the file tree
|
|
15
|
+
* @requirements 8.8 - THE Dashboard_Server SHALL expose GET `/api/files/:path` to get patterns and violations for a specific file
|
|
16
|
+
* @requirements 8.9 - THE Dashboard_Server SHALL expose GET `/api/stats` to get overview statistics
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'node:fs/promises';
|
|
19
|
+
import * as path from 'node:path';
|
|
20
|
+
import { createDataLake } from 'driftdetect-core';
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Constants
|
|
23
|
+
// ============================================================================
|
|
24
|
+
const PATTERNS_DIR = 'patterns';
|
|
25
|
+
const STATUS_DIRS = ['discovered', 'approved', 'ignored'];
|
|
26
|
+
const PATTERN_CATEGORIES = [
|
|
27
|
+
'structural',
|
|
28
|
+
'components',
|
|
29
|
+
'styling',
|
|
30
|
+
'api',
|
|
31
|
+
'auth',
|
|
32
|
+
'errors',
|
|
33
|
+
'data-access',
|
|
34
|
+
'testing',
|
|
35
|
+
'logging',
|
|
36
|
+
'security',
|
|
37
|
+
'config',
|
|
38
|
+
'types',
|
|
39
|
+
'performance',
|
|
40
|
+
'accessibility',
|
|
41
|
+
'documentation',
|
|
42
|
+
];
|
|
43
|
+
// ============================================================================
|
|
44
|
+
// Helper Functions
|
|
45
|
+
// ============================================================================
|
|
46
|
+
/**
|
|
47
|
+
* Check if a file exists
|
|
48
|
+
*/
|
|
49
|
+
async function fileExists(filePath) {
|
|
50
|
+
try {
|
|
51
|
+
await fs.access(filePath);
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Convert a PatternLocation to SemanticLocation
|
|
60
|
+
*/
|
|
61
|
+
function toSemanticLocation(loc) {
|
|
62
|
+
return {
|
|
63
|
+
file: loc.file,
|
|
64
|
+
range: {
|
|
65
|
+
start: { line: loc.line, character: loc.column },
|
|
66
|
+
end: { line: loc.endLine ?? loc.line, character: loc.endColumn ?? loc.column },
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Convert an OutlierLocation to OutlierWithDetails
|
|
72
|
+
*/
|
|
73
|
+
function toOutlierWithDetails(outlier) {
|
|
74
|
+
return {
|
|
75
|
+
file: outlier.file,
|
|
76
|
+
range: {
|
|
77
|
+
start: { line: outlier.line, character: outlier.column },
|
|
78
|
+
end: { line: outlier.endLine ?? outlier.line, character: outlier.endColumn ?? outlier.column },
|
|
79
|
+
},
|
|
80
|
+
reason: outlier.reason,
|
|
81
|
+
deviationScore: outlier.deviationScore,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Generate a unique violation ID from pattern and outlier
|
|
86
|
+
*/
|
|
87
|
+
function generateViolationId(patternId, outlier) {
|
|
88
|
+
return `${patternId}-${outlier.file}-${outlier.line}-${outlier.column}`;
|
|
89
|
+
}
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// DriftDataReader Class
|
|
92
|
+
// ============================================================================
|
|
93
|
+
export class DriftDataReader {
|
|
94
|
+
driftDir;
|
|
95
|
+
patternsDir;
|
|
96
|
+
dataLake;
|
|
97
|
+
lakeInitialized = false;
|
|
98
|
+
constructor(driftDir) {
|
|
99
|
+
this.driftDir = driftDir;
|
|
100
|
+
this.patternsDir = path.join(driftDir, PATTERNS_DIR);
|
|
101
|
+
// Initialize DataLake for optimized reads
|
|
102
|
+
// rootDir is the parent of .drift/
|
|
103
|
+
const rootDir = path.dirname(driftDir);
|
|
104
|
+
this.dataLake = createDataLake({ rootDir });
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get the drift directory path
|
|
108
|
+
*/
|
|
109
|
+
get directory() {
|
|
110
|
+
return this.driftDir;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Initialize the data lake (lazy initialization)
|
|
114
|
+
*/
|
|
115
|
+
async initializeLake() {
|
|
116
|
+
if (this.lakeInitialized)
|
|
117
|
+
return true;
|
|
118
|
+
try {
|
|
119
|
+
await this.dataLake.initialize();
|
|
120
|
+
this.lakeInitialized = true;
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Get all patterns, optionally filtered
|
|
129
|
+
* OPTIMIZED: Uses DataLake pattern shards for fast category-based queries
|
|
130
|
+
*
|
|
131
|
+
* @requirements 8.1 - List all patterns
|
|
132
|
+
*/
|
|
133
|
+
async getPatterns(query) {
|
|
134
|
+
// OPTIMIZATION: Try DataLake first for fast reads
|
|
135
|
+
if (await this.initializeLake()) {
|
|
136
|
+
try {
|
|
137
|
+
const lakePatterns = await this.getPatternsFromLake(query);
|
|
138
|
+
if (lakePatterns && lakePatterns.length > 0) {
|
|
139
|
+
return lakePatterns;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Fall through to direct file reading
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Fallback: Read patterns from all status directories
|
|
147
|
+
const patterns = [];
|
|
148
|
+
for (const status of STATUS_DIRS) {
|
|
149
|
+
const statusDir = path.join(this.patternsDir, status);
|
|
150
|
+
if (!(await fileExists(statusDir))) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// Dynamically read all JSON files in this status directory
|
|
154
|
+
try {
|
|
155
|
+
const files = await fs.readdir(statusDir);
|
|
156
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
157
|
+
for (const jsonFile of jsonFiles) {
|
|
158
|
+
const filePath = path.join(statusDir, jsonFile);
|
|
159
|
+
const category = jsonFile.replace('.json', '');
|
|
160
|
+
try {
|
|
161
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
162
|
+
const patternFile = JSON.parse(content);
|
|
163
|
+
for (const stored of patternFile.patterns) {
|
|
164
|
+
const dashboardPattern = this.storedToDashboardPattern(stored, category, status);
|
|
165
|
+
patterns.push(dashboardPattern);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
// Skip files that can't be parsed
|
|
170
|
+
console.error(`Error reading pattern file ${filePath}:`, error);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
console.error(`Error reading status directory ${statusDir}:`, error);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Apply filters if provided
|
|
179
|
+
return this.filterPatterns(patterns, query);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Get patterns from DataLake (optimized path)
|
|
183
|
+
*/
|
|
184
|
+
async getPatternsFromLake(query) {
|
|
185
|
+
try {
|
|
186
|
+
// Build query options for DataLake
|
|
187
|
+
const queryOptions = {
|
|
188
|
+
limit: 1000, // Get all patterns
|
|
189
|
+
};
|
|
190
|
+
if (query?.category) {
|
|
191
|
+
queryOptions.categories = [query.category];
|
|
192
|
+
}
|
|
193
|
+
if (query?.status && query.status !== 'all') {
|
|
194
|
+
queryOptions.status = query.status;
|
|
195
|
+
}
|
|
196
|
+
if (query?.minConfidence !== undefined) {
|
|
197
|
+
queryOptions.minConfidence = query.minConfidence;
|
|
198
|
+
}
|
|
199
|
+
if (query?.search) {
|
|
200
|
+
queryOptions.search = query.search;
|
|
201
|
+
}
|
|
202
|
+
const result = await this.dataLake.query.getPatterns(queryOptions);
|
|
203
|
+
if (result.items.length === 0 && result.total === 0) {
|
|
204
|
+
return null; // No data in lake, fall back to files
|
|
205
|
+
}
|
|
206
|
+
// Convert lake PatternSummary to DashboardPattern
|
|
207
|
+
return result.items.map(p => ({
|
|
208
|
+
id: p.id,
|
|
209
|
+
name: p.name,
|
|
210
|
+
category: p.category,
|
|
211
|
+
subcategory: p.subcategory,
|
|
212
|
+
status: p.status,
|
|
213
|
+
description: '', // PatternSummary doesn't include description
|
|
214
|
+
confidence: {
|
|
215
|
+
score: p.confidence,
|
|
216
|
+
level: p.confidenceLevel,
|
|
217
|
+
},
|
|
218
|
+
locationCount: p.locationCount,
|
|
219
|
+
outlierCount: p.outlierCount,
|
|
220
|
+
severity: p.severity || (p.outlierCount > 0 ? 'warning' : 'info'),
|
|
221
|
+
metadata: {
|
|
222
|
+
firstSeen: new Date().toISOString(),
|
|
223
|
+
lastSeen: new Date().toISOString(),
|
|
224
|
+
},
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Get a single pattern by ID with all locations
|
|
233
|
+
*
|
|
234
|
+
* @requirements 8.2 - Get pattern details with locations
|
|
235
|
+
*/
|
|
236
|
+
async getPattern(id) {
|
|
237
|
+
// Search through all status directories dynamically
|
|
238
|
+
for (const status of STATUS_DIRS) {
|
|
239
|
+
const statusDir = path.join(this.patternsDir, status);
|
|
240
|
+
if (!(await fileExists(statusDir))) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
try {
|
|
244
|
+
const files = await fs.readdir(statusDir);
|
|
245
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
246
|
+
for (const jsonFile of jsonFiles) {
|
|
247
|
+
const filePath = path.join(statusDir, jsonFile);
|
|
248
|
+
const category = jsonFile.replace('.json', '');
|
|
249
|
+
try {
|
|
250
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
251
|
+
const patternFile = JSON.parse(content);
|
|
252
|
+
const stored = patternFile.patterns.find((p) => p.id === id);
|
|
253
|
+
if (stored) {
|
|
254
|
+
return this.storedToDashboardPatternWithLocations(stored, category, status);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
// Skip files that can't be parsed
|
|
259
|
+
console.error(`Error reading pattern file ${filePath}:`, error);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
console.error(`Error reading status directory ${statusDir}:`, error);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Get all violations, optionally filtered
|
|
271
|
+
*
|
|
272
|
+
* Violations are derived from pattern outliers.
|
|
273
|
+
*
|
|
274
|
+
* @requirements 8.6 - List all violations
|
|
275
|
+
*/
|
|
276
|
+
async getViolations(query) {
|
|
277
|
+
const violations = [];
|
|
278
|
+
// Read patterns from all status directories dynamically
|
|
279
|
+
for (const status of STATUS_DIRS) {
|
|
280
|
+
const statusDir = path.join(this.patternsDir, status);
|
|
281
|
+
if (!(await fileExists(statusDir))) {
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
try {
|
|
285
|
+
const files = await fs.readdir(statusDir);
|
|
286
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
287
|
+
for (const jsonFile of jsonFiles) {
|
|
288
|
+
const filePath = path.join(statusDir, jsonFile);
|
|
289
|
+
try {
|
|
290
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
291
|
+
const patternFile = JSON.parse(content);
|
|
292
|
+
for (const stored of patternFile.patterns) {
|
|
293
|
+
// Convert outliers to violations
|
|
294
|
+
for (const outlier of stored.outliers) {
|
|
295
|
+
const violation = this.outlierToViolation(stored, outlier);
|
|
296
|
+
violations.push(violation);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
// Skip files that can't be parsed
|
|
302
|
+
console.error(`Error reading pattern file ${filePath}:`, error);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
console.error(`Error reading status directory ${statusDir}:`, error);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Apply filters if provided
|
|
311
|
+
return this.filterViolations(violations, query);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Get dashboard statistics
|
|
315
|
+
* OPTIMIZED: Uses DataLake status view for instant response
|
|
316
|
+
* @requirements 8.9 - GET `/api/stats` to get overview statistics
|
|
317
|
+
*/
|
|
318
|
+
async getStats() {
|
|
319
|
+
// OPTIMIZATION: Try DataLake status view first (instant)
|
|
320
|
+
if (await this.initializeLake()) {
|
|
321
|
+
try {
|
|
322
|
+
const statusView = await this.dataLake.query.getStatus();
|
|
323
|
+
if (statusView) {
|
|
324
|
+
return this.statusViewToStats(statusView);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// Fall through to direct file reading
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
// Fallback: Compute from raw pattern files
|
|
332
|
+
const patterns = await this.getPatterns();
|
|
333
|
+
const violations = await this.getViolations();
|
|
334
|
+
// Count patterns by status
|
|
335
|
+
const byStatus = {
|
|
336
|
+
discovered: 0,
|
|
337
|
+
approved: 0,
|
|
338
|
+
ignored: 0,
|
|
339
|
+
};
|
|
340
|
+
for (const pattern of patterns) {
|
|
341
|
+
byStatus[pattern.status]++;
|
|
342
|
+
}
|
|
343
|
+
// Count patterns by category - dynamically from actual patterns
|
|
344
|
+
const byCategory = {};
|
|
345
|
+
for (const pattern of patterns) {
|
|
346
|
+
const category = pattern.category;
|
|
347
|
+
byCategory[category] = (byCategory[category] || 0) + 1;
|
|
348
|
+
}
|
|
349
|
+
// Count violations by severity
|
|
350
|
+
const bySeverity = {
|
|
351
|
+
error: 0,
|
|
352
|
+
warning: 0,
|
|
353
|
+
info: 0,
|
|
354
|
+
hint: 0,
|
|
355
|
+
};
|
|
356
|
+
for (const violation of violations) {
|
|
357
|
+
const severity = violation.severity;
|
|
358
|
+
if (severity in bySeverity) {
|
|
359
|
+
bySeverity[severity]++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// Collect unique files from patterns and violations
|
|
363
|
+
const filesSet = new Set();
|
|
364
|
+
for (const pattern of patterns) {
|
|
365
|
+
// We need to get the full pattern to access locations
|
|
366
|
+
const fullPattern = await this.getPattern(pattern.id);
|
|
367
|
+
if (fullPattern) {
|
|
368
|
+
for (const loc of fullPattern.locations) {
|
|
369
|
+
filesSet.add(loc.file);
|
|
370
|
+
}
|
|
371
|
+
for (const outlier of fullPattern.outliers) {
|
|
372
|
+
filesSet.add(outlier.file);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Calculate health score
|
|
377
|
+
const healthScore = this.calculateHealthScore(violations, patterns);
|
|
378
|
+
// Get last scan time from pattern metadata
|
|
379
|
+
let lastScan = null;
|
|
380
|
+
for (const pattern of patterns) {
|
|
381
|
+
if (pattern.metadata.lastSeen) {
|
|
382
|
+
if (!lastScan || pattern.metadata.lastSeen > lastScan) {
|
|
383
|
+
lastScan = pattern.metadata.lastSeen;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
healthScore,
|
|
389
|
+
patterns: {
|
|
390
|
+
total: patterns.length,
|
|
391
|
+
byStatus,
|
|
392
|
+
byCategory: byCategory,
|
|
393
|
+
},
|
|
394
|
+
violations: {
|
|
395
|
+
total: violations.length,
|
|
396
|
+
bySeverity,
|
|
397
|
+
},
|
|
398
|
+
files: {
|
|
399
|
+
total: filesSet.size,
|
|
400
|
+
scanned: filesSet.size,
|
|
401
|
+
},
|
|
402
|
+
detectors: {
|
|
403
|
+
active: Object.keys(byCategory).length, // Count unique categories found
|
|
404
|
+
total: Object.keys(byCategory).length,
|
|
405
|
+
},
|
|
406
|
+
lastScan,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Convert StatusView from DataLake to DashboardStats
|
|
411
|
+
*/
|
|
412
|
+
statusViewToStats(view) {
|
|
413
|
+
return {
|
|
414
|
+
healthScore: view.health.score,
|
|
415
|
+
patterns: {
|
|
416
|
+
total: view.patterns.total,
|
|
417
|
+
byStatus: {
|
|
418
|
+
discovered: view.patterns.discovered,
|
|
419
|
+
approved: view.patterns.approved,
|
|
420
|
+
ignored: view.patterns.ignored,
|
|
421
|
+
},
|
|
422
|
+
byCategory: view.patterns.byCategory,
|
|
423
|
+
},
|
|
424
|
+
violations: {
|
|
425
|
+
total: view.issues.critical + view.issues.warnings,
|
|
426
|
+
bySeverity: {
|
|
427
|
+
error: view.issues.critical,
|
|
428
|
+
warning: view.issues.warnings,
|
|
429
|
+
info: 0,
|
|
430
|
+
hint: 0,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
files: {
|
|
434
|
+
// StatusView doesn't track files, estimate from patterns
|
|
435
|
+
total: 0,
|
|
436
|
+
scanned: view.lastScan.filesScanned,
|
|
437
|
+
},
|
|
438
|
+
detectors: {
|
|
439
|
+
active: Object.keys(view.patterns.byCategory).length,
|
|
440
|
+
total: Object.keys(view.patterns.byCategory).length,
|
|
441
|
+
},
|
|
442
|
+
lastScan: view.lastScan.timestamp || null,
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Get the file tree structure
|
|
447
|
+
* @requirements 8.7 - GET `/api/files` to get the file tree
|
|
448
|
+
*/
|
|
449
|
+
async getFileTree() {
|
|
450
|
+
const patterns = await this.getPatterns();
|
|
451
|
+
const violations = await this.getViolations();
|
|
452
|
+
// Collect file information
|
|
453
|
+
const fileInfo = new Map();
|
|
454
|
+
// Count patterns per file
|
|
455
|
+
for (const pattern of patterns) {
|
|
456
|
+
const fullPattern = await this.getPattern(pattern.id);
|
|
457
|
+
if (fullPattern) {
|
|
458
|
+
for (const loc of fullPattern.locations) {
|
|
459
|
+
const info = fileInfo.get(loc.file) || { patternCount: 0, violationCount: 0 };
|
|
460
|
+
info.patternCount++;
|
|
461
|
+
fileInfo.set(loc.file, info);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Count violations per file and track highest severity
|
|
466
|
+
for (const violation of violations) {
|
|
467
|
+
const info = fileInfo.get(violation.file) || { patternCount: 0, violationCount: 0 };
|
|
468
|
+
info.violationCount++;
|
|
469
|
+
// Track highest severity
|
|
470
|
+
const violationSeverity = violation.severity;
|
|
471
|
+
if (!info.severity || this.compareSeverity(violationSeverity, info.severity) > 0) {
|
|
472
|
+
info.severity = violationSeverity;
|
|
473
|
+
}
|
|
474
|
+
fileInfo.set(violation.file, info);
|
|
475
|
+
}
|
|
476
|
+
// Build tree structure
|
|
477
|
+
return this.buildFileTree(fileInfo);
|
|
478
|
+
}
|
|
479
|
+
/**
|
|
480
|
+
* Get details for a specific file
|
|
481
|
+
* @requirements 8.8 - GET `/api/files/:path` to get patterns and violations for a specific file
|
|
482
|
+
*/
|
|
483
|
+
async getFileDetails(filePath) {
|
|
484
|
+
const patterns = await this.getPatterns();
|
|
485
|
+
const violations = await this.getViolations();
|
|
486
|
+
// Find patterns that have locations in this file
|
|
487
|
+
const filePatterns = [];
|
|
488
|
+
for (const pattern of patterns) {
|
|
489
|
+
const fullPattern = await this.getPattern(pattern.id);
|
|
490
|
+
if (fullPattern) {
|
|
491
|
+
const locationsInFile = fullPattern.locations.filter((loc) => loc.file === filePath);
|
|
492
|
+
if (locationsInFile.length > 0) {
|
|
493
|
+
filePatterns.push({
|
|
494
|
+
id: pattern.id,
|
|
495
|
+
name: pattern.name,
|
|
496
|
+
category: pattern.category,
|
|
497
|
+
locations: locationsInFile,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
// Find violations in this file
|
|
503
|
+
const fileViolations = violations.filter((v) => v.file === filePath);
|
|
504
|
+
// If no patterns or violations found, return null
|
|
505
|
+
if (filePatterns.length === 0 && fileViolations.length === 0) {
|
|
506
|
+
return null;
|
|
507
|
+
}
|
|
508
|
+
// Determine language from file extension
|
|
509
|
+
const language = this.getLanguageFromPath(filePath);
|
|
510
|
+
return {
|
|
511
|
+
path: filePath,
|
|
512
|
+
language,
|
|
513
|
+
lineCount: 0, // We don't have access to actual file content
|
|
514
|
+
patterns: filePatterns,
|
|
515
|
+
violations: fileViolations,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Get configuration
|
|
520
|
+
* @requirements 8.10 - GET `/api/config` to get configuration
|
|
521
|
+
*/
|
|
522
|
+
async getConfig() {
|
|
523
|
+
const configPath = path.join(this.driftDir, 'config.json');
|
|
524
|
+
if (!(await fileExists(configPath))) {
|
|
525
|
+
// Return default config if none exists
|
|
526
|
+
return this.getDefaultConfig();
|
|
527
|
+
}
|
|
528
|
+
try {
|
|
529
|
+
const content = await fs.readFile(configPath, 'utf-8');
|
|
530
|
+
return JSON.parse(content);
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
console.error('Error reading config:', error);
|
|
534
|
+
return this.getDefaultConfig();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Update configuration
|
|
539
|
+
* @requirements 8.11 - PUT `/api/config` to update configuration
|
|
540
|
+
*/
|
|
541
|
+
async updateConfig(partial) {
|
|
542
|
+
const configPath = path.join(this.driftDir, 'config.json');
|
|
543
|
+
const currentConfig = await this.getConfig();
|
|
544
|
+
// Merge the partial config with current config
|
|
545
|
+
const newConfig = {
|
|
546
|
+
...currentConfig,
|
|
547
|
+
...partial,
|
|
548
|
+
detectors: partial.detectors ?? currentConfig.detectors,
|
|
549
|
+
severityOverrides: {
|
|
550
|
+
...currentConfig.severityOverrides,
|
|
551
|
+
...partial.severityOverrides,
|
|
552
|
+
},
|
|
553
|
+
ignorePatterns: partial.ignorePatterns ?? currentConfig.ignorePatterns,
|
|
554
|
+
};
|
|
555
|
+
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2));
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Approve a pattern - changes status to 'approved'
|
|
559
|
+
* @requirements 4.4 - Approve pattern
|
|
560
|
+
* @requirements 8.3 - POST `/api/patterns/:id/approve` to approve a pattern
|
|
561
|
+
*/
|
|
562
|
+
async approvePattern(id) {
|
|
563
|
+
await this.changePatternStatus(id, 'approved');
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Ignore a pattern - changes status to 'ignored'
|
|
567
|
+
* @requirements 4.5 - Ignore pattern
|
|
568
|
+
* @requirements 8.4 - POST `/api/patterns/:id/ignore` to ignore a pattern
|
|
569
|
+
*/
|
|
570
|
+
async ignorePattern(id) {
|
|
571
|
+
await this.changePatternStatus(id, 'ignored');
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Delete a pattern - removes from storage
|
|
575
|
+
* @requirements 4.6 - Delete pattern
|
|
576
|
+
* @requirements 8.5 - DELETE `/api/patterns/:id` to delete a pattern
|
|
577
|
+
*/
|
|
578
|
+
async deletePattern(id) {
|
|
579
|
+
// Find the pattern and its location
|
|
580
|
+
const location = await this.findPatternLocation(id);
|
|
581
|
+
if (!location) {
|
|
582
|
+
throw new Error(`Pattern not found: ${id}`);
|
|
583
|
+
}
|
|
584
|
+
const { filePath } = location;
|
|
585
|
+
// Read the pattern file
|
|
586
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
587
|
+
const patternFile = JSON.parse(content);
|
|
588
|
+
// Remove the pattern
|
|
589
|
+
patternFile.patterns = patternFile.patterns.filter((p) => p.id !== id);
|
|
590
|
+
patternFile.lastUpdated = new Date().toISOString();
|
|
591
|
+
// Write back or delete file if empty
|
|
592
|
+
if (patternFile.patterns.length === 0) {
|
|
593
|
+
await fs.unlink(filePath);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
await fs.writeFile(filePath, JSON.stringify(patternFile, null, 2));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// ==========================================================================
|
|
600
|
+
// Private Helper Methods
|
|
601
|
+
// ==========================================================================
|
|
602
|
+
/**
|
|
603
|
+
* Convert a StoredPattern to DashboardPattern
|
|
604
|
+
*/
|
|
605
|
+
storedToDashboardPattern(stored, category, status) {
|
|
606
|
+
return {
|
|
607
|
+
id: stored.id,
|
|
608
|
+
name: stored.name,
|
|
609
|
+
category,
|
|
610
|
+
subcategory: stored.subcategory,
|
|
611
|
+
status,
|
|
612
|
+
description: stored.description,
|
|
613
|
+
confidence: {
|
|
614
|
+
score: stored.confidence.score,
|
|
615
|
+
level: stored.confidence.level,
|
|
616
|
+
},
|
|
617
|
+
locationCount: stored.locations.length,
|
|
618
|
+
outlierCount: stored.outliers.length,
|
|
619
|
+
severity: stored.severity,
|
|
620
|
+
metadata: {
|
|
621
|
+
firstSeen: stored.metadata.firstSeen,
|
|
622
|
+
lastSeen: stored.metadata.lastSeen,
|
|
623
|
+
tags: stored.metadata.tags,
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Convert a StoredPattern to DashboardPatternWithLocations
|
|
629
|
+
*/
|
|
630
|
+
storedToDashboardPatternWithLocations(stored, category, status) {
|
|
631
|
+
const base = this.storedToDashboardPattern(stored, category, status);
|
|
632
|
+
return {
|
|
633
|
+
...base,
|
|
634
|
+
locations: stored.locations.map(toSemanticLocation),
|
|
635
|
+
outliers: stored.outliers.map(toOutlierWithDetails),
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Convert an outlier to a violation
|
|
640
|
+
*/
|
|
641
|
+
outlierToViolation(pattern, outlier) {
|
|
642
|
+
return {
|
|
643
|
+
id: generateViolationId(pattern.id, outlier),
|
|
644
|
+
patternId: pattern.id,
|
|
645
|
+
patternName: pattern.name,
|
|
646
|
+
severity: pattern.severity,
|
|
647
|
+
file: outlier.file,
|
|
648
|
+
range: {
|
|
649
|
+
start: { line: outlier.line, character: outlier.column },
|
|
650
|
+
end: { line: outlier.endLine ?? outlier.line, character: outlier.endColumn ?? outlier.column },
|
|
651
|
+
},
|
|
652
|
+
message: outlier.reason,
|
|
653
|
+
expected: pattern.description,
|
|
654
|
+
actual: outlier.reason,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Filter patterns based on query
|
|
659
|
+
*/
|
|
660
|
+
filterPatterns(patterns, query) {
|
|
661
|
+
if (!query) {
|
|
662
|
+
return patterns;
|
|
663
|
+
}
|
|
664
|
+
return patterns.filter((pattern) => {
|
|
665
|
+
// Filter by category
|
|
666
|
+
if (query.category && pattern.category !== query.category) {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
// Filter by status
|
|
670
|
+
if (query.status && pattern.status !== query.status) {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
// Filter by minimum confidence
|
|
674
|
+
if (query.minConfidence !== undefined && pattern.confidence.score < query.minConfidence) {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
// Filter by search term (name or description)
|
|
678
|
+
if (query.search) {
|
|
679
|
+
const searchLower = query.search.toLowerCase();
|
|
680
|
+
const nameMatch = pattern.name.toLowerCase().includes(searchLower);
|
|
681
|
+
const descMatch = pattern.description.toLowerCase().includes(searchLower);
|
|
682
|
+
if (!nameMatch && !descMatch) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return true;
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Filter violations based on query
|
|
691
|
+
*/
|
|
692
|
+
filterViolations(violations, query) {
|
|
693
|
+
if (!query) {
|
|
694
|
+
return violations;
|
|
695
|
+
}
|
|
696
|
+
return violations.filter((violation) => {
|
|
697
|
+
// Filter by severity
|
|
698
|
+
if (query.severity && violation.severity !== query.severity) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
// Filter by file
|
|
702
|
+
if (query.file && violation.file !== query.file) {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
// Filter by pattern ID
|
|
706
|
+
if (query.patternId && violation.patternId !== query.patternId) {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
// Filter by search term (message or pattern name)
|
|
710
|
+
if (query.search) {
|
|
711
|
+
const searchLower = query.search.toLowerCase();
|
|
712
|
+
const messageMatch = violation.message.toLowerCase().includes(searchLower);
|
|
713
|
+
const nameMatch = violation.patternName.toLowerCase().includes(searchLower);
|
|
714
|
+
if (!messageMatch && !nameMatch) {
|
|
715
|
+
return false;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return true;
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Calculate health score based on violations and patterns
|
|
723
|
+
*
|
|
724
|
+
* Health score formula:
|
|
725
|
+
* - Base score starts at 100
|
|
726
|
+
* - Deduct for violations by severity (error: -10, warning: -3, info: -1, hint: 0)
|
|
727
|
+
* - Bonus for approved patterns (shows intentional architecture)
|
|
728
|
+
* - Clamp to 0-100
|
|
729
|
+
*/
|
|
730
|
+
calculateHealthScore(violations, patterns) {
|
|
731
|
+
let score = 100;
|
|
732
|
+
// Deduct for violations by severity
|
|
733
|
+
for (const violation of violations) {
|
|
734
|
+
switch (violation.severity) {
|
|
735
|
+
case 'error':
|
|
736
|
+
score -= 10;
|
|
737
|
+
break;
|
|
738
|
+
case 'warning':
|
|
739
|
+
score -= 3;
|
|
740
|
+
break;
|
|
741
|
+
case 'info':
|
|
742
|
+
score -= 1;
|
|
743
|
+
break;
|
|
744
|
+
// hint doesn't deduct
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// Bonus for approved patterns (shows intentional architecture)
|
|
748
|
+
if (patterns.length > 0) {
|
|
749
|
+
const approvedCount = patterns.filter((p) => p.status === 'approved').length;
|
|
750
|
+
const approvalRate = approvedCount / patterns.length;
|
|
751
|
+
score += approvalRate * 10;
|
|
752
|
+
}
|
|
753
|
+
// Clamp to 0-100
|
|
754
|
+
return Math.max(0, Math.min(100, Math.round(score)));
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Build a hierarchical file tree from file information
|
|
758
|
+
*/
|
|
759
|
+
buildFileTree(fileInfo) {
|
|
760
|
+
// First pass: collect all unique directory paths and files
|
|
761
|
+
const nodeMap = new Map();
|
|
762
|
+
for (const [filePath, info] of fileInfo) {
|
|
763
|
+
const parts = filePath.split('/').filter(Boolean);
|
|
764
|
+
if (parts.length === 0)
|
|
765
|
+
continue;
|
|
766
|
+
let currentPath = '';
|
|
767
|
+
// Create directory nodes
|
|
768
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
769
|
+
const part = parts[i];
|
|
770
|
+
currentPath = currentPath ? `${currentPath}/${part}` : part;
|
|
771
|
+
if (!nodeMap.has(currentPath)) {
|
|
772
|
+
nodeMap.set(currentPath, {
|
|
773
|
+
name: part,
|
|
774
|
+
path: currentPath,
|
|
775
|
+
type: 'directory',
|
|
776
|
+
children: [],
|
|
777
|
+
patternCount: 0,
|
|
778
|
+
violationCount: 0,
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
// Create file node
|
|
783
|
+
const fileName = parts[parts.length - 1];
|
|
784
|
+
const fullPath = parts.join('/');
|
|
785
|
+
const fileNode = {
|
|
786
|
+
name: fileName,
|
|
787
|
+
path: fullPath,
|
|
788
|
+
type: 'file',
|
|
789
|
+
patternCount: info.patternCount,
|
|
790
|
+
violationCount: info.violationCount,
|
|
791
|
+
};
|
|
792
|
+
if (info.severity) {
|
|
793
|
+
fileNode.severity = info.severity;
|
|
794
|
+
}
|
|
795
|
+
nodeMap.set(fullPath, fileNode);
|
|
796
|
+
}
|
|
797
|
+
// Second pass: build parent-child relationships and aggregate counts
|
|
798
|
+
for (const [nodePath, node] of nodeMap) {
|
|
799
|
+
if (node.type === 'file') {
|
|
800
|
+
// Find parent directory
|
|
801
|
+
const parts = nodePath.split('/');
|
|
802
|
+
if (parts.length > 1) {
|
|
803
|
+
const parentPath = parts.slice(0, -1).join('/');
|
|
804
|
+
const parent = nodeMap.get(parentPath);
|
|
805
|
+
if (parent && parent.children) {
|
|
806
|
+
parent.children.push(node);
|
|
807
|
+
// Aggregate counts to parent
|
|
808
|
+
if (parent.patternCount !== undefined && node.patternCount !== undefined) {
|
|
809
|
+
parent.patternCount += node.patternCount;
|
|
810
|
+
}
|
|
811
|
+
if (parent.violationCount !== undefined && node.violationCount !== undefined) {
|
|
812
|
+
parent.violationCount += node.violationCount;
|
|
813
|
+
}
|
|
814
|
+
// Track highest severity
|
|
815
|
+
if (node.severity) {
|
|
816
|
+
if (!parent.severity || this.compareSeverity(node.severity, parent.severity) > 0) {
|
|
817
|
+
parent.severity = node.severity;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// Third pass: link directories to their parents
|
|
825
|
+
for (const [nodePath, node] of nodeMap) {
|
|
826
|
+
if (node.type === 'directory') {
|
|
827
|
+
const parts = nodePath.split('/');
|
|
828
|
+
if (parts.length > 1) {
|
|
829
|
+
const parentPath = parts.slice(0, -1).join('/');
|
|
830
|
+
const parent = nodeMap.get(parentPath);
|
|
831
|
+
if (parent && parent.children) {
|
|
832
|
+
// Check if not already added
|
|
833
|
+
if (!parent.children.some(c => c.path === node.path)) {
|
|
834
|
+
parent.children.push(node);
|
|
835
|
+
}
|
|
836
|
+
// Aggregate counts to parent
|
|
837
|
+
if (parent.patternCount !== undefined && node.patternCount !== undefined) {
|
|
838
|
+
parent.patternCount += node.patternCount;
|
|
839
|
+
}
|
|
840
|
+
if (parent.violationCount !== undefined && node.violationCount !== undefined) {
|
|
841
|
+
parent.violationCount += node.violationCount;
|
|
842
|
+
}
|
|
843
|
+
// Track highest severity
|
|
844
|
+
if (node.severity) {
|
|
845
|
+
if (!parent.severity || this.compareSeverity(node.severity, parent.severity) > 0) {
|
|
846
|
+
parent.severity = node.severity;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
// Get root nodes (nodes without parents)
|
|
854
|
+
const rootNodes = [];
|
|
855
|
+
for (const [nodePath, node] of nodeMap) {
|
|
856
|
+
const parts = nodePath.split('/');
|
|
857
|
+
if (parts.length === 1) {
|
|
858
|
+
rootNodes.push(node);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
// Sort and return
|
|
862
|
+
return this.sortFileTree(rootNodes);
|
|
863
|
+
}
|
|
864
|
+
/**
|
|
865
|
+
* Sort file tree: directories first, then alphabetically
|
|
866
|
+
*/
|
|
867
|
+
sortFileTree(nodes) {
|
|
868
|
+
return nodes
|
|
869
|
+
.map((node) => {
|
|
870
|
+
if (node.children && node.children.length > 0) {
|
|
871
|
+
return {
|
|
872
|
+
...node,
|
|
873
|
+
children: this.sortFileTree(node.children),
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
return node;
|
|
877
|
+
})
|
|
878
|
+
.sort((a, b) => {
|
|
879
|
+
// Directories first
|
|
880
|
+
if (a.type !== b.type) {
|
|
881
|
+
return a.type === 'directory' ? -1 : 1;
|
|
882
|
+
}
|
|
883
|
+
// Then alphabetically
|
|
884
|
+
return a.name.localeCompare(b.name);
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Compare severity levels
|
|
889
|
+
* Returns positive if a > b, negative if a < b, 0 if equal
|
|
890
|
+
*/
|
|
891
|
+
compareSeverity(a, b) {
|
|
892
|
+
const order = {
|
|
893
|
+
error: 4,
|
|
894
|
+
warning: 3,
|
|
895
|
+
info: 2,
|
|
896
|
+
hint: 1,
|
|
897
|
+
};
|
|
898
|
+
return order[a] - order[b];
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* Get programming language from file path
|
|
902
|
+
*/
|
|
903
|
+
getLanguageFromPath(filePath) {
|
|
904
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
905
|
+
const languageMap = {
|
|
906
|
+
'.ts': 'typescript',
|
|
907
|
+
'.tsx': 'typescript',
|
|
908
|
+
'.js': 'javascript',
|
|
909
|
+
'.jsx': 'javascript',
|
|
910
|
+
'.py': 'python',
|
|
911
|
+
'.rb': 'ruby',
|
|
912
|
+
'.java': 'java',
|
|
913
|
+
'.go': 'go',
|
|
914
|
+
'.rs': 'rust',
|
|
915
|
+
'.c': 'c',
|
|
916
|
+
'.cpp': 'cpp',
|
|
917
|
+
'.h': 'c',
|
|
918
|
+
'.hpp': 'cpp',
|
|
919
|
+
'.cs': 'csharp',
|
|
920
|
+
'.php': 'php',
|
|
921
|
+
'.swift': 'swift',
|
|
922
|
+
'.kt': 'kotlin',
|
|
923
|
+
'.scala': 'scala',
|
|
924
|
+
'.vue': 'vue',
|
|
925
|
+
'.svelte': 'svelte',
|
|
926
|
+
'.html': 'html',
|
|
927
|
+
'.css': 'css',
|
|
928
|
+
'.scss': 'scss',
|
|
929
|
+
'.less': 'less',
|
|
930
|
+
'.json': 'json',
|
|
931
|
+
'.yaml': 'yaml',
|
|
932
|
+
'.yml': 'yaml',
|
|
933
|
+
'.xml': 'xml',
|
|
934
|
+
'.md': 'markdown',
|
|
935
|
+
'.sql': 'sql',
|
|
936
|
+
'.sh': 'bash',
|
|
937
|
+
'.bash': 'bash',
|
|
938
|
+
'.zsh': 'zsh',
|
|
939
|
+
};
|
|
940
|
+
return languageMap[ext] || 'plaintext';
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Change pattern status (move between status directories)
|
|
944
|
+
*/
|
|
945
|
+
async changePatternStatus(id, newStatus) {
|
|
946
|
+
// Find the pattern and its current location
|
|
947
|
+
const location = await this.findPatternLocation(id);
|
|
948
|
+
if (!location) {
|
|
949
|
+
throw new Error(`Pattern not found: ${id}`);
|
|
950
|
+
}
|
|
951
|
+
const { status: currentStatus, category, filePath, pattern } = location;
|
|
952
|
+
// If already in the target status, nothing to do
|
|
953
|
+
if (currentStatus === newStatus) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
// Read the source pattern file
|
|
957
|
+
const sourceContent = await fs.readFile(filePath, 'utf-8');
|
|
958
|
+
const sourceFile = JSON.parse(sourceContent);
|
|
959
|
+
// Remove pattern from source file
|
|
960
|
+
sourceFile.patterns = sourceFile.patterns.filter((p) => p.id !== id);
|
|
961
|
+
sourceFile.lastUpdated = new Date().toISOString();
|
|
962
|
+
// Write back source file or delete if empty
|
|
963
|
+
if (sourceFile.patterns.length === 0) {
|
|
964
|
+
await fs.unlink(filePath);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
await fs.writeFile(filePath, JSON.stringify(sourceFile, null, 2));
|
|
968
|
+
}
|
|
969
|
+
// Add pattern to target status directory
|
|
970
|
+
const targetDir = path.join(this.patternsDir, newStatus);
|
|
971
|
+
const targetPath = path.join(targetDir, `${category}.json`);
|
|
972
|
+
// Ensure target directory exists
|
|
973
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
974
|
+
// Read or create target file
|
|
975
|
+
let targetFile;
|
|
976
|
+
if (await fileExists(targetPath)) {
|
|
977
|
+
const targetContent = await fs.readFile(targetPath, 'utf-8');
|
|
978
|
+
targetFile = JSON.parse(targetContent);
|
|
979
|
+
}
|
|
980
|
+
else {
|
|
981
|
+
targetFile = {
|
|
982
|
+
version: '1.0.0',
|
|
983
|
+
category,
|
|
984
|
+
patterns: [],
|
|
985
|
+
lastUpdated: new Date().toISOString(),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
// Update pattern metadata if approving
|
|
989
|
+
const updatedPattern = { ...pattern };
|
|
990
|
+
if (newStatus === 'approved') {
|
|
991
|
+
updatedPattern.metadata = {
|
|
992
|
+
...updatedPattern.metadata,
|
|
993
|
+
approvedAt: new Date().toISOString(),
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
// Add pattern to target file
|
|
997
|
+
targetFile.patterns.push(updatedPattern);
|
|
998
|
+
targetFile.lastUpdated = new Date().toISOString();
|
|
999
|
+
// Write target file
|
|
1000
|
+
await fs.writeFile(targetPath, JSON.stringify(targetFile, null, 2));
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Find a pattern's location in the file system
|
|
1004
|
+
*/
|
|
1005
|
+
async findPatternLocation(id) {
|
|
1006
|
+
for (const status of STATUS_DIRS) {
|
|
1007
|
+
const statusDir = path.join(this.patternsDir, status);
|
|
1008
|
+
if (!(await fileExists(statusDir))) {
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
const files = await fs.readdir(statusDir);
|
|
1013
|
+
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
|
1014
|
+
for (const jsonFile of jsonFiles) {
|
|
1015
|
+
const filePath = path.join(statusDir, jsonFile);
|
|
1016
|
+
const category = jsonFile.replace('.json', '');
|
|
1017
|
+
try {
|
|
1018
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
1019
|
+
const patternFile = JSON.parse(content);
|
|
1020
|
+
const pattern = patternFile.patterns.find((p) => p.id === id);
|
|
1021
|
+
if (pattern) {
|
|
1022
|
+
return { status, category, filePath, pattern };
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
catch (error) {
|
|
1026
|
+
// Skip files that can't be parsed
|
|
1027
|
+
console.error(`Error reading pattern file ${filePath}:`, error);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
console.error(`Error reading status directory ${statusDir}:`, error);
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Get default configuration
|
|
1039
|
+
*/
|
|
1040
|
+
getDefaultConfig() {
|
|
1041
|
+
return {
|
|
1042
|
+
version: '1.0.0',
|
|
1043
|
+
detectors: PATTERN_CATEGORIES.map((category) => ({
|
|
1044
|
+
id: category,
|
|
1045
|
+
name: category.charAt(0).toUpperCase() + category.slice(1).replace(/-/g, ' '),
|
|
1046
|
+
enabled: true,
|
|
1047
|
+
category,
|
|
1048
|
+
})),
|
|
1049
|
+
severityOverrides: {},
|
|
1050
|
+
ignorePatterns: ['node_modules/**', 'dist/**', '.git/**'],
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Get code snippet from a file at a specific line with context
|
|
1055
|
+
*/
|
|
1056
|
+
async getCodeSnippet(filePath, line, contextLines = 3) {
|
|
1057
|
+
// driftDir is .drift/, so workspace root is the parent
|
|
1058
|
+
const workspaceRoot = path.dirname(this.driftDir);
|
|
1059
|
+
const fullPath = path.join(workspaceRoot, filePath);
|
|
1060
|
+
try {
|
|
1061
|
+
const content = await fs.readFile(fullPath, 'utf-8');
|
|
1062
|
+
const lines = content.split('\n');
|
|
1063
|
+
const startLine = Math.max(1, line - contextLines);
|
|
1064
|
+
const endLine = Math.min(lines.length, line + contextLines);
|
|
1065
|
+
const snippetLines = lines.slice(startLine - 1, endLine);
|
|
1066
|
+
const code = snippetLines.join('\n');
|
|
1067
|
+
return {
|
|
1068
|
+
code,
|
|
1069
|
+
startLine,
|
|
1070
|
+
endLine,
|
|
1071
|
+
language: this.getLanguageFromPath(filePath),
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
catch (error) {
|
|
1075
|
+
console.error(`Error reading file ${fullPath}:`, error);
|
|
1076
|
+
return null;
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
// ==========================================================================
|
|
1080
|
+
// Contract Methods (BE↔FE mismatch detection)
|
|
1081
|
+
// ==========================================================================
|
|
1082
|
+
/**
|
|
1083
|
+
* Get all contracts, optionally filtered
|
|
1084
|
+
*/
|
|
1085
|
+
async getContracts(query) {
|
|
1086
|
+
const contracts = [];
|
|
1087
|
+
const contractsDir = path.join(this.driftDir, 'contracts');
|
|
1088
|
+
const statusDirs = ['discovered', 'verified', 'mismatch', 'ignored'];
|
|
1089
|
+
for (const status of statusDirs) {
|
|
1090
|
+
const statusDir = path.join(contractsDir, status);
|
|
1091
|
+
if (!(await fileExists(statusDir))) {
|
|
1092
|
+
continue;
|
|
1093
|
+
}
|
|
1094
|
+
const filePath = path.join(statusDir, 'contracts.json');
|
|
1095
|
+
if (!(await fileExists(filePath))) {
|
|
1096
|
+
continue;
|
|
1097
|
+
}
|
|
1098
|
+
try {
|
|
1099
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
1100
|
+
const contractFile = JSON.parse(content);
|
|
1101
|
+
for (const stored of contractFile.contracts) {
|
|
1102
|
+
contracts.push({
|
|
1103
|
+
...stored,
|
|
1104
|
+
status,
|
|
1105
|
+
mismatchCount: stored.mismatches?.length || 0,
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
catch (error) {
|
|
1110
|
+
console.error(`Error reading contract file ${filePath}:`, error);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// Apply filters
|
|
1114
|
+
return this.filterContracts(contracts, query);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Get a single contract by ID
|
|
1118
|
+
*/
|
|
1119
|
+
async getContract(id) {
|
|
1120
|
+
const contractsDir = path.join(this.driftDir, 'contracts');
|
|
1121
|
+
const statusDirs = ['discovered', 'verified', 'mismatch', 'ignored'];
|
|
1122
|
+
for (const status of statusDirs) {
|
|
1123
|
+
const filePath = path.join(contractsDir, status, 'contracts.json');
|
|
1124
|
+
if (!(await fileExists(filePath))) {
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
try {
|
|
1128
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
1129
|
+
const contractFile = JSON.parse(content);
|
|
1130
|
+
const contract = contractFile.contracts.find((c) => c.id === id);
|
|
1131
|
+
if (contract) {
|
|
1132
|
+
return {
|
|
1133
|
+
...contract,
|
|
1134
|
+
status,
|
|
1135
|
+
mismatchCount: contract.mismatches?.length || 0,
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
catch (error) {
|
|
1140
|
+
console.error(`Error reading contract file ${filePath}:`, error);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Get contract statistics
|
|
1147
|
+
*/
|
|
1148
|
+
async getContractStats() {
|
|
1149
|
+
const contracts = await this.getContracts();
|
|
1150
|
+
const byStatus = {
|
|
1151
|
+
discovered: 0,
|
|
1152
|
+
verified: 0,
|
|
1153
|
+
mismatch: 0,
|
|
1154
|
+
ignored: 0,
|
|
1155
|
+
};
|
|
1156
|
+
const byMethod = {
|
|
1157
|
+
GET: 0,
|
|
1158
|
+
POST: 0,
|
|
1159
|
+
PUT: 0,
|
|
1160
|
+
PATCH: 0,
|
|
1161
|
+
DELETE: 0,
|
|
1162
|
+
};
|
|
1163
|
+
let totalMismatches = 0;
|
|
1164
|
+
const mismatchesByType = {};
|
|
1165
|
+
for (const contract of contracts) {
|
|
1166
|
+
const statusKey = contract.status;
|
|
1167
|
+
const methodKey = contract.method;
|
|
1168
|
+
if (statusKey in byStatus) {
|
|
1169
|
+
byStatus[statusKey] = (byStatus[statusKey] || 0) + 1;
|
|
1170
|
+
}
|
|
1171
|
+
if (methodKey in byMethod) {
|
|
1172
|
+
byMethod[methodKey] = (byMethod[methodKey] || 0) + 1;
|
|
1173
|
+
}
|
|
1174
|
+
totalMismatches += contract.mismatchCount;
|
|
1175
|
+
for (const mismatch of contract.mismatches || []) {
|
|
1176
|
+
mismatchesByType[mismatch.mismatchType] = (mismatchesByType[mismatch.mismatchType] || 0) + 1;
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
return {
|
|
1180
|
+
totalContracts: contracts.length,
|
|
1181
|
+
byStatus,
|
|
1182
|
+
byMethod,
|
|
1183
|
+
totalMismatches,
|
|
1184
|
+
mismatchesByType,
|
|
1185
|
+
};
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* Verify a contract
|
|
1189
|
+
*/
|
|
1190
|
+
async verifyContract(id) {
|
|
1191
|
+
await this.changeContractStatus(id, 'verified');
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Ignore a contract
|
|
1195
|
+
*/
|
|
1196
|
+
async ignoreContract(id) {
|
|
1197
|
+
await this.changeContractStatus(id, 'ignored');
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Change contract status
|
|
1201
|
+
*/
|
|
1202
|
+
async changeContractStatus(id, newStatus) {
|
|
1203
|
+
const contractsDir = path.join(this.driftDir, 'contracts');
|
|
1204
|
+
const statusDirs = ['discovered', 'verified', 'mismatch', 'ignored'];
|
|
1205
|
+
let foundContract = null;
|
|
1206
|
+
// Find the contract
|
|
1207
|
+
for (const status of statusDirs) {
|
|
1208
|
+
const filePath = path.join(contractsDir, status, 'contracts.json');
|
|
1209
|
+
if (!(await fileExists(filePath))) {
|
|
1210
|
+
continue;
|
|
1211
|
+
}
|
|
1212
|
+
try {
|
|
1213
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
1214
|
+
const contractFile = JSON.parse(content);
|
|
1215
|
+
const contractIndex = contractFile.contracts.findIndex((c) => c.id === id);
|
|
1216
|
+
if (contractIndex !== -1) {
|
|
1217
|
+
foundContract = contractFile.contracts[contractIndex];
|
|
1218
|
+
// Remove from current file
|
|
1219
|
+
contractFile.contracts.splice(contractIndex, 1);
|
|
1220
|
+
contractFile.lastUpdated = new Date().toISOString();
|
|
1221
|
+
if (contractFile.contracts.length === 0) {
|
|
1222
|
+
await fs.unlink(filePath);
|
|
1223
|
+
}
|
|
1224
|
+
else {
|
|
1225
|
+
await fs.writeFile(filePath, JSON.stringify(contractFile, null, 2));
|
|
1226
|
+
}
|
|
1227
|
+
break;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
catch (error) {
|
|
1231
|
+
console.error(`Error reading contract file ${filePath}:`, error);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if (!foundContract) {
|
|
1235
|
+
throw new Error(`Contract not found: ${id}`);
|
|
1236
|
+
}
|
|
1237
|
+
// Add to new status directory
|
|
1238
|
+
const targetDir = path.join(contractsDir, newStatus);
|
|
1239
|
+
const targetPath = path.join(targetDir, 'contracts.json');
|
|
1240
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
1241
|
+
let targetFile;
|
|
1242
|
+
if (await fileExists(targetPath)) {
|
|
1243
|
+
const content = await fs.readFile(targetPath, 'utf-8');
|
|
1244
|
+
targetFile = JSON.parse(content);
|
|
1245
|
+
}
|
|
1246
|
+
else {
|
|
1247
|
+
targetFile = {
|
|
1248
|
+
version: '1.0.0',
|
|
1249
|
+
status: newStatus,
|
|
1250
|
+
contracts: [],
|
|
1251
|
+
lastUpdated: new Date().toISOString(),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
// Update metadata
|
|
1255
|
+
foundContract.metadata = {
|
|
1256
|
+
...foundContract.metadata,
|
|
1257
|
+
lastSeen: new Date().toISOString(),
|
|
1258
|
+
};
|
|
1259
|
+
if (newStatus === 'verified') {
|
|
1260
|
+
foundContract.metadata.verifiedAt = new Date().toISOString();
|
|
1261
|
+
}
|
|
1262
|
+
targetFile.contracts.push(foundContract);
|
|
1263
|
+
targetFile.lastUpdated = new Date().toISOString();
|
|
1264
|
+
await fs.writeFile(targetPath, JSON.stringify(targetFile, null, 2));
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Filter contracts based on query
|
|
1268
|
+
*/
|
|
1269
|
+
filterContracts(contracts, query) {
|
|
1270
|
+
if (!query)
|
|
1271
|
+
return contracts;
|
|
1272
|
+
return contracts.filter((contract) => {
|
|
1273
|
+
if (query.status && contract.status !== query.status)
|
|
1274
|
+
return false;
|
|
1275
|
+
if (query.method && contract.method !== query.method)
|
|
1276
|
+
return false;
|
|
1277
|
+
if (query.hasMismatches !== undefined) {
|
|
1278
|
+
const hasMismatches = contract.mismatchCount > 0;
|
|
1279
|
+
if (query.hasMismatches !== hasMismatches)
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
if (query.search && !contract.endpoint.toLowerCase().includes(query.search.toLowerCase())) {
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
return true;
|
|
1286
|
+
});
|
|
1287
|
+
}
|
|
1288
|
+
// ==========================================================================
|
|
1289
|
+
// Trend / History Methods
|
|
1290
|
+
// ==========================================================================
|
|
1291
|
+
/**
|
|
1292
|
+
* Get trend summary for pattern regressions and improvements
|
|
1293
|
+
* OPTIMIZED: Uses DataLake trends view for instant response
|
|
1294
|
+
*/
|
|
1295
|
+
async getTrends(period = '7d') {
|
|
1296
|
+
// OPTIMIZATION: Try DataLake trends view first
|
|
1297
|
+
if (await this.initializeLake()) {
|
|
1298
|
+
try {
|
|
1299
|
+
const trendsView = await this.dataLake.views.getTrendsView();
|
|
1300
|
+
if (trendsView) {
|
|
1301
|
+
return this.trendsViewToSummary(trendsView, period);
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
catch {
|
|
1305
|
+
// Fall through to direct file reading
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
// Fallback: Read from history snapshots directly
|
|
1309
|
+
const historyDir = path.join(this.driftDir, 'history', 'snapshots');
|
|
1310
|
+
if (!(await fileExists(historyDir))) {
|
|
1311
|
+
return null;
|
|
1312
|
+
}
|
|
1313
|
+
const days = period === '7d' ? 7 : period === '30d' ? 30 : 90;
|
|
1314
|
+
const now = new Date();
|
|
1315
|
+
const startDate = new Date(now);
|
|
1316
|
+
startDate.setDate(startDate.getDate() - days);
|
|
1317
|
+
// Get snapshots
|
|
1318
|
+
const files = await fs.readdir(historyDir);
|
|
1319
|
+
const jsonFiles = files.filter(f => f.endsWith('.json')).sort();
|
|
1320
|
+
if (jsonFiles.length < 2) {
|
|
1321
|
+
return null; // Need at least 2 snapshots to calculate trends
|
|
1322
|
+
}
|
|
1323
|
+
// Get latest and comparison snapshot
|
|
1324
|
+
const latestFile = jsonFiles[jsonFiles.length - 1];
|
|
1325
|
+
const latestContent = await fs.readFile(path.join(historyDir, latestFile), 'utf-8');
|
|
1326
|
+
const latestSnapshot = JSON.parse(latestContent);
|
|
1327
|
+
// Find snapshot closest to start date
|
|
1328
|
+
const startDateStr = startDate.toISOString().split('T')[0];
|
|
1329
|
+
let comparisonFile = jsonFiles[0];
|
|
1330
|
+
for (const file of jsonFiles) {
|
|
1331
|
+
const fileDate = file.replace('.json', '');
|
|
1332
|
+
if (fileDate <= startDateStr) {
|
|
1333
|
+
comparisonFile = file;
|
|
1334
|
+
}
|
|
1335
|
+
else {
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
const comparisonContent = await fs.readFile(path.join(historyDir, comparisonFile), 'utf-8');
|
|
1340
|
+
const comparisonSnapshot = JSON.parse(comparisonContent);
|
|
1341
|
+
// Calculate trends
|
|
1342
|
+
return this.calculateTrendSummary(latestSnapshot, comparisonSnapshot, period);
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Convert TrendsView from DataLake to TrendSummary
|
|
1346
|
+
* TrendsView has: generatedAt, period, overallTrend, healthDelta, regressions, improvements, stableCount, categoryTrends
|
|
1347
|
+
*/
|
|
1348
|
+
trendsViewToSummary(view, period) {
|
|
1349
|
+
// Calculate date range from period
|
|
1350
|
+
const now = new Date();
|
|
1351
|
+
const days = period === '7d' ? 7 : period === '30d' ? 30 : 90;
|
|
1352
|
+
const startDate = new Date(now);
|
|
1353
|
+
startDate.setDate(startDate.getDate() - days);
|
|
1354
|
+
// Convert TrendItem[] to PatternTrend[]
|
|
1355
|
+
const regressions = (view.regressions || []).map(r => ({
|
|
1356
|
+
patternId: r.patternId,
|
|
1357
|
+
patternName: r.patternName,
|
|
1358
|
+
category: r.category,
|
|
1359
|
+
type: 'regression',
|
|
1360
|
+
metric: r.metric,
|
|
1361
|
+
previousValue: r.previousValue,
|
|
1362
|
+
currentValue: r.currentValue,
|
|
1363
|
+
change: r.change,
|
|
1364
|
+
changePercent: r.previousValue > 0 ? (r.change / r.previousValue) * 100 : 0,
|
|
1365
|
+
severity: r.severity,
|
|
1366
|
+
firstSeen: view.generatedAt,
|
|
1367
|
+
details: `${r.metric} changed from ${r.previousValue} to ${r.currentValue}`,
|
|
1368
|
+
}));
|
|
1369
|
+
const improvements = (view.improvements || []).map(i => ({
|
|
1370
|
+
patternId: i.patternId,
|
|
1371
|
+
patternName: i.patternName,
|
|
1372
|
+
category: i.category,
|
|
1373
|
+
type: 'improvement',
|
|
1374
|
+
metric: i.metric,
|
|
1375
|
+
previousValue: i.previousValue,
|
|
1376
|
+
currentValue: i.currentValue,
|
|
1377
|
+
change: i.change,
|
|
1378
|
+
changePercent: i.previousValue > 0 ? (i.change / i.previousValue) * 100 : 0,
|
|
1379
|
+
severity: i.severity,
|
|
1380
|
+
firstSeen: view.generatedAt,
|
|
1381
|
+
details: `${i.metric} improved from ${i.previousValue} to ${i.currentValue}`,
|
|
1382
|
+
}));
|
|
1383
|
+
// Convert CategoryTrend to TrendSummary format
|
|
1384
|
+
const categoryTrends = {};
|
|
1385
|
+
for (const [category, trend] of Object.entries(view.categoryTrends || {})) {
|
|
1386
|
+
categoryTrends[category] = {
|
|
1387
|
+
trend: trend.trend,
|
|
1388
|
+
avgConfidenceChange: trend.avgConfidenceChange,
|
|
1389
|
+
complianceChange: trend.complianceChange,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
return {
|
|
1393
|
+
period,
|
|
1394
|
+
startDate: startDate.toISOString().split('T')[0],
|
|
1395
|
+
endDate: now.toISOString().split('T')[0],
|
|
1396
|
+
regressions,
|
|
1397
|
+
improvements,
|
|
1398
|
+
stable: view.stableCount || 0,
|
|
1399
|
+
overallTrend: view.overallTrend,
|
|
1400
|
+
healthDelta: view.healthDelta || 0,
|
|
1401
|
+
categoryTrends,
|
|
1402
|
+
};
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Get historical snapshots for charting
|
|
1406
|
+
*/
|
|
1407
|
+
async getSnapshots(limit = 30) {
|
|
1408
|
+
const historyDir = path.join(this.driftDir, 'history', 'snapshots');
|
|
1409
|
+
if (!(await fileExists(historyDir))) {
|
|
1410
|
+
return [];
|
|
1411
|
+
}
|
|
1412
|
+
const files = await fs.readdir(historyDir);
|
|
1413
|
+
const jsonFiles = files.filter(f => f.endsWith('.json')).sort().slice(-limit);
|
|
1414
|
+
const snapshots = [];
|
|
1415
|
+
for (const file of jsonFiles) {
|
|
1416
|
+
try {
|
|
1417
|
+
const content = await fs.readFile(path.join(historyDir, file), 'utf-8');
|
|
1418
|
+
snapshots.push(JSON.parse(content));
|
|
1419
|
+
}
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
console.error(`Error reading snapshot ${file}:`, error);
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
return snapshots;
|
|
1425
|
+
}
|
|
1426
|
+
/**
|
|
1427
|
+
* Calculate trend summary between two snapshots
|
|
1428
|
+
*/
|
|
1429
|
+
calculateTrendSummary(current, previous, period) {
|
|
1430
|
+
const regressions = [];
|
|
1431
|
+
const improvements = [];
|
|
1432
|
+
const previousMap = new Map(previous.patterns.map(p => [p.patternId, p]));
|
|
1433
|
+
// Thresholds
|
|
1434
|
+
const CONFIDENCE_THRESHOLD = 0.05;
|
|
1435
|
+
const COMPLIANCE_THRESHOLD = 0.10;
|
|
1436
|
+
const OUTLIER_THRESHOLD = 3;
|
|
1437
|
+
for (const currentPattern of current.patterns) {
|
|
1438
|
+
const prevPattern = previousMap.get(currentPattern.patternId);
|
|
1439
|
+
if (!prevPattern)
|
|
1440
|
+
continue;
|
|
1441
|
+
// Check confidence change
|
|
1442
|
+
const confidenceChange = currentPattern.confidence - prevPattern.confidence;
|
|
1443
|
+
if (Math.abs(confidenceChange) >= CONFIDENCE_THRESHOLD) {
|
|
1444
|
+
const trend = {
|
|
1445
|
+
patternId: currentPattern.patternId,
|
|
1446
|
+
patternName: currentPattern.patternName,
|
|
1447
|
+
category: currentPattern.category,
|
|
1448
|
+
type: confidenceChange < 0 ? 'regression' : 'improvement',
|
|
1449
|
+
metric: 'confidence',
|
|
1450
|
+
previousValue: prevPattern.confidence,
|
|
1451
|
+
currentValue: currentPattern.confidence,
|
|
1452
|
+
change: confidenceChange,
|
|
1453
|
+
changePercent: (confidenceChange / prevPattern.confidence) * 100,
|
|
1454
|
+
severity: confidenceChange <= -0.15 ? 'critical' : confidenceChange < 0 ? 'warning' : 'info',
|
|
1455
|
+
firstSeen: previous.timestamp,
|
|
1456
|
+
details: `Confidence ${confidenceChange < 0 ? 'dropped' : 'improved'} from ${(prevPattern.confidence * 100).toFixed(0)}% to ${(currentPattern.confidence * 100).toFixed(0)}%`,
|
|
1457
|
+
};
|
|
1458
|
+
if (trend.type === 'regression') {
|
|
1459
|
+
regressions.push(trend);
|
|
1460
|
+
}
|
|
1461
|
+
else {
|
|
1462
|
+
improvements.push(trend);
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
// Check compliance change
|
|
1466
|
+
const complianceChange = currentPattern.complianceRate - prevPattern.complianceRate;
|
|
1467
|
+
if (Math.abs(complianceChange) >= COMPLIANCE_THRESHOLD) {
|
|
1468
|
+
const trend = {
|
|
1469
|
+
patternId: currentPattern.patternId,
|
|
1470
|
+
patternName: currentPattern.patternName,
|
|
1471
|
+
category: currentPattern.category,
|
|
1472
|
+
type: complianceChange < 0 ? 'regression' : 'improvement',
|
|
1473
|
+
metric: 'compliance',
|
|
1474
|
+
previousValue: prevPattern.complianceRate,
|
|
1475
|
+
currentValue: currentPattern.complianceRate,
|
|
1476
|
+
change: complianceChange,
|
|
1477
|
+
changePercent: prevPattern.complianceRate > 0 ? (complianceChange / prevPattern.complianceRate) * 100 : 0,
|
|
1478
|
+
severity: complianceChange <= -0.20 ? 'critical' : complianceChange < 0 ? 'warning' : 'info',
|
|
1479
|
+
firstSeen: previous.timestamp,
|
|
1480
|
+
details: `Compliance ${complianceChange < 0 ? 'dropped' : 'improved'} from ${(prevPattern.complianceRate * 100).toFixed(0)}% to ${(currentPattern.complianceRate * 100).toFixed(0)}%`,
|
|
1481
|
+
};
|
|
1482
|
+
if (trend.type === 'regression') {
|
|
1483
|
+
regressions.push(trend);
|
|
1484
|
+
}
|
|
1485
|
+
else {
|
|
1486
|
+
improvements.push(trend);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// Check outlier increase
|
|
1490
|
+
const outlierChange = currentPattern.outlierCount - prevPattern.outlierCount;
|
|
1491
|
+
if (outlierChange >= OUTLIER_THRESHOLD) {
|
|
1492
|
+
regressions.push({
|
|
1493
|
+
patternId: currentPattern.patternId,
|
|
1494
|
+
patternName: currentPattern.patternName,
|
|
1495
|
+
category: currentPattern.category,
|
|
1496
|
+
type: 'regression',
|
|
1497
|
+
metric: 'outliers',
|
|
1498
|
+
previousValue: prevPattern.outlierCount,
|
|
1499
|
+
currentValue: currentPattern.outlierCount,
|
|
1500
|
+
change: outlierChange,
|
|
1501
|
+
changePercent: prevPattern.outlierCount > 0 ? (outlierChange / prevPattern.outlierCount) * 100 : 100,
|
|
1502
|
+
severity: outlierChange >= 10 ? 'critical' : 'warning',
|
|
1503
|
+
firstSeen: previous.timestamp,
|
|
1504
|
+
details: `${outlierChange} new outliers (${prevPattern.outlierCount} → ${currentPattern.outlierCount})`,
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
// Calculate category trends
|
|
1509
|
+
const categoryTrends = {};
|
|
1510
|
+
const categories = new Set([
|
|
1511
|
+
...Object.keys(current.summary.byCategory),
|
|
1512
|
+
...Object.keys(previous.summary.byCategory),
|
|
1513
|
+
]);
|
|
1514
|
+
for (const category of categories) {
|
|
1515
|
+
const currentCat = current.summary.byCategory[category];
|
|
1516
|
+
const prevCat = previous.summary.byCategory[category];
|
|
1517
|
+
if (currentCat && prevCat) {
|
|
1518
|
+
const avgConfidenceChange = currentCat.avgConfidence - prevCat.avgConfidence;
|
|
1519
|
+
const complianceChange = currentCat.complianceRate - prevCat.complianceRate;
|
|
1520
|
+
categoryTrends[category] = {
|
|
1521
|
+
trend: avgConfidenceChange > 0.02 ? 'improving'
|
|
1522
|
+
: avgConfidenceChange < -0.02 ? 'declining'
|
|
1523
|
+
: 'stable',
|
|
1524
|
+
avgConfidenceChange,
|
|
1525
|
+
complianceChange,
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
// Calculate overall trend
|
|
1530
|
+
const healthDelta = current.summary.overallComplianceRate - previous.summary.overallComplianceRate;
|
|
1531
|
+
const overallTrend = healthDelta > 0.02 ? 'improving'
|
|
1532
|
+
: healthDelta < -0.02 ? 'declining'
|
|
1533
|
+
: 'stable';
|
|
1534
|
+
// Count stable patterns
|
|
1535
|
+
const changedPatternIds = new Set([...regressions, ...improvements].map(t => t.patternId));
|
|
1536
|
+
const stableCount = current.patterns.filter(p => !changedPatternIds.has(p.patternId)).length;
|
|
1537
|
+
return {
|
|
1538
|
+
period,
|
|
1539
|
+
startDate: previous.date,
|
|
1540
|
+
endDate: current.date,
|
|
1541
|
+
regressions,
|
|
1542
|
+
improvements,
|
|
1543
|
+
stable: stableCount,
|
|
1544
|
+
overallTrend,
|
|
1545
|
+
healthDelta,
|
|
1546
|
+
categoryTrends,
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
//# sourceMappingURL=drift-data-reader.js.map
|