thuban 0.3.2 → 0.3.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/README.md +137 -102
- package/cli.js +310 -95
- package/package.json +5 -2
- package/packages/scanner/alert-manager.js +398 -398
- package/packages/scanner/code-scanner.js +758 -713
- package/packages/scanner/copy-paste-detector.js +33 -7
- package/packages/scanner/dependency-graph.js +541 -536
- package/packages/scanner/drift-detector.js +423 -423
- package/packages/scanner/file-collector.js +64 -0
- package/packages/scanner/file-watcher.js +323 -323
- package/packages/scanner/ghost-code-detector.js +80 -5
- package/packages/scanner/hallucination-detector.js +189 -19
- package/packages/scanner/health-checker.js +586 -586
- package/packages/scanner/index.js +100 -92
- package/packages/scanner/license-manager.js +15 -2
- package/packages/scanner/master-health-checker.js +513 -513
- package/packages/scanner/monitor-notifier.js +64 -0
- package/packages/scanner/monitor-service.js +103 -0
- package/packages/scanner/monitor-store.js +117 -0
- package/packages/scanner/pre-commit-gate.js +1 -1
- package/packages/scanner/scan-diff.js +50 -0
- package/packages/scanner/scan-runner.js +172 -0
- package/packages/scanner/secret-scanner.js +612 -0
- package/packages/scanner/secret-scanner.test.js +103 -0
- package/packages/scanner/sentinel-core.js +616 -608
- package/packages/scanner/sentinel-knowledge.js +322 -322
- package/packages/scanner/tech-debt-analyzer.js +46 -28
- package/packages/scanner/widget-generator.js +415 -415
|
@@ -1,423 +1,423 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sentinel Drift Detector
|
|
3
|
-
*
|
|
4
|
-
* Detects architectural drift by comparing code against declared contracts.
|
|
5
|
-
* Checks for mismatches between:
|
|
6
|
-
* - File widgets (declared purpose, imports, exports)
|
|
7
|
-
* - Actual code structure
|
|
8
|
-
* - Module relationships
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
const fs = require('fs');
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const EventEmitter = require('events');
|
|
14
|
-
|
|
15
|
-
class DriftDetector extends EventEmitter {
|
|
16
|
-
constructor(config = {}) {
|
|
17
|
-
super();
|
|
18
|
-
|
|
19
|
-
this.config = {
|
|
20
|
-
rootPath: config.rootPath || process.cwd(),
|
|
21
|
-
widgetPattern: config.widgetPattern || /\/\*\*[\s\S]*?@(purpose|module|layer|imports-from|exports-to|depends-on|critical-for)[\s\S]*?\*\//,
|
|
22
|
-
ignorePatterns: config.ignorePatterns || [
|
|
23
|
-
'node_modules/**',
|
|
24
|
-
'.git/**',
|
|
25
|
-
'dist/**',
|
|
26
|
-
'.orion/snapshots/**'
|
|
27
|
-
],
|
|
28
|
-
...config
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
// Cache for file analysis results
|
|
32
|
-
this.analysisCache = new Map();
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* Detect drift across all files
|
|
37
|
-
*/
|
|
38
|
-
async detectAll() {
|
|
39
|
-
const startTime = Date.now();
|
|
40
|
-
|
|
41
|
-
const result = {
|
|
42
|
-
filesAnalyzed: 0,
|
|
43
|
-
filesWithWidgets: 0,
|
|
44
|
-
filesWithoutWidgets: 0,
|
|
45
|
-
issues: [],
|
|
46
|
-
moduleMap: {},
|
|
47
|
-
duration: 0
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
const files = await this._discoverFiles(this.config.rootPath);
|
|
52
|
-
|
|
53
|
-
for (const file of files) {
|
|
54
|
-
const analysis = await this.analyzeFile(file);
|
|
55
|
-
result.filesAnalyzed++;
|
|
56
|
-
|
|
57
|
-
if (analysis.hasWidget) {
|
|
58
|
-
result.filesWithWidgets++;
|
|
59
|
-
} else {
|
|
60
|
-
result.filesWithoutWidgets++;
|
|
61
|
-
// Missing widget is itself drift
|
|
62
|
-
result.issues.push({
|
|
63
|
-
file,
|
|
64
|
-
category: 'drift',
|
|
65
|
-
severity: 'info',
|
|
66
|
-
id: 'DRIFT001',
|
|
67
|
-
name: 'Missing Widget',
|
|
68
|
-
message: 'File has no widget declaration - add @purpose, @module, @layer etc.'
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Check for specific drift issues
|
|
73
|
-
result.issues.push(...analysis.issues);
|
|
74
|
-
|
|
75
|
-
// Build module map
|
|
76
|
-
if (analysis.module) {
|
|
77
|
-
if (!result.moduleMap[analysis.module]) {
|
|
78
|
-
result.moduleMap[analysis.module] = [];
|
|
79
|
-
}
|
|
80
|
-
result.moduleMap[analysis.module].push(file);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Cross-file analysis for circular dependencies and broken references
|
|
85
|
-
result.issues.push(...this._analyzeCrossFile(result.moduleMap));
|
|
86
|
-
|
|
87
|
-
result.duration = Date.now() - startTime;
|
|
88
|
-
|
|
89
|
-
return result;
|
|
90
|
-
} catch (error) {
|
|
91
|
-
console.error('[DRIFT-DETECTOR] Detection failed:', error.message);
|
|
92
|
-
result.error = error.message;
|
|
93
|
-
return result;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* Analyze a single file for drift
|
|
99
|
-
*/
|
|
100
|
-
async analyzeFile(filePath) {
|
|
101
|
-
const result = {
|
|
102
|
-
file: filePath,
|
|
103
|
-
hasWidget: false,
|
|
104
|
-
widget: null,
|
|
105
|
-
actualExports: [],
|
|
106
|
-
actualImports: [],
|
|
107
|
-
module: null,
|
|
108
|
-
issues: []
|
|
109
|
-
};
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
113
|
-
|
|
114
|
-
// Parse widget if present
|
|
115
|
-
result.widget = this._parseWidget(content);
|
|
116
|
-
result.hasWidget = !!result.widget;
|
|
117
|
-
|
|
118
|
-
// Analyze actual code structure
|
|
119
|
-
result.actualExports = this._extractExports(content);
|
|
120
|
-
result.actualImports = this._extractImports(content);
|
|
121
|
-
|
|
122
|
-
// If widget exists, check for drift
|
|
123
|
-
if (result.widget) {
|
|
124
|
-
result.module = result.widget.module;
|
|
125
|
-
result.issues.push(...this._compareWidgetToCode(filePath, result.widget, {
|
|
126
|
-
exports: result.actualExports,
|
|
127
|
-
imports: result.actualImports
|
|
128
|
-
}));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Cache the analysis
|
|
132
|
-
this.analysisCache.set(filePath, result);
|
|
133
|
-
|
|
134
|
-
return result;
|
|
135
|
-
} catch (error) {
|
|
136
|
-
result.issues.push({
|
|
137
|
-
file: filePath,
|
|
138
|
-
category: 'drift',
|
|
139
|
-
severity: 'error',
|
|
140
|
-
id: 'DRIFT000',
|
|
141
|
-
message: `Failed to analyze file: ${error.message}`
|
|
142
|
-
});
|
|
143
|
-
return result;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Check if a specific file has drifted from its widget
|
|
149
|
-
*/
|
|
150
|
-
async checkFileDrift(filePath) {
|
|
151
|
-
const analysis = await this.analyzeFile(filePath);
|
|
152
|
-
return {
|
|
153
|
-
hasDrift: analysis.issues.length > 0,
|
|
154
|
-
issues: analysis.issues,
|
|
155
|
-
widget: analysis.widget
|
|
156
|
-
};
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Generate suggested widget for a file
|
|
161
|
-
*/
|
|
162
|
-
async suggestWidget(filePath) {
|
|
163
|
-
try {
|
|
164
|
-
const content = fs.readFileSync(filePath, 'utf8');
|
|
165
|
-
const exports = this._extractExports(content);
|
|
166
|
-
const imports = this._extractImports(content);
|
|
167
|
-
|
|
168
|
-
// Determine module from path
|
|
169
|
-
const relativePath = path.relative(this.config.rootPath, filePath);
|
|
170
|
-
const pathParts = relativePath.split(path.sep);
|
|
171
|
-
const module = pathParts[0];
|
|
172
|
-
|
|
173
|
-
// Determine layer from path/content
|
|
174
|
-
let layer = 'application';
|
|
175
|
-
if (relativePath.includes('routes') || relativePath.includes('api')) {
|
|
176
|
-
layer = 'presentation';
|
|
177
|
-
} else if (relativePath.includes('core') || relativePath.includes('engine')) {
|
|
178
|
-
layer = 'infrastructure';
|
|
179
|
-
} else if (relativePath.includes('model') || relativePath.includes('domain')) {
|
|
180
|
-
layer = 'domain';
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Generate purpose from exports
|
|
184
|
-
const purpose = exports.length > 0
|
|
185
|
-
? `Provides ${exports.join(', ')}`
|
|
186
|
-
: 'Unknown purpose - needs description';
|
|
187
|
-
|
|
188
|
-
return {
|
|
189
|
-
purpose,
|
|
190
|
-
module,
|
|
191
|
-
layer,
|
|
192
|
-
importsFrom: imports.map(i => `${i.module} - ${i.specifiers.join(', ')}`),
|
|
193
|
-
exportsTo: 'To be determined',
|
|
194
|
-
dataFlow: 'To be documented',
|
|
195
|
-
sideEffects: content.includes('fs.') ? 'File system operations' : 'None documented',
|
|
196
|
-
criticalFor: 'To be documented'
|
|
197
|
-
};
|
|
198
|
-
} catch (error) {
|
|
199
|
-
return { error: error.message };
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Private methods
|
|
204
|
-
|
|
205
|
-
async _discoverFiles(dir, files = []) {
|
|
206
|
-
try {
|
|
207
|
-
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
208
|
-
|
|
209
|
-
for (const entry of entries) {
|
|
210
|
-
const fullPath = path.join(dir, entry.name);
|
|
211
|
-
const relativePath = path.relative(this.config.rootPath, fullPath);
|
|
212
|
-
|
|
213
|
-
if (this._shouldIgnore(relativePath)) {
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
if (entry.isDirectory()) {
|
|
218
|
-
await this._discoverFiles(fullPath, files);
|
|
219
|
-
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
220
|
-
files.push(fullPath);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
} catch (error) {
|
|
224
|
-
// Skip inaccessible directories
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
return files;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
_shouldIgnore(relativePath) {
|
|
231
|
-
const normalized = relativePath.replace(/\\/g, '/');
|
|
232
|
-
|
|
233
|
-
// Always ignore any path containing these directories (anywhere in path)
|
|
234
|
-
const alwaysIgnore = ['node_modules', '.git', 'dist', '.vite'];
|
|
235
|
-
for (const dir of alwaysIgnore) {
|
|
236
|
-
if (normalized.includes('/' + dir + '/') ||
|
|
237
|
-
normalized.startsWith(dir + '/') ||
|
|
238
|
-
normalized === dir ||
|
|
239
|
-
normalized.endsWith('/' + dir)) {
|
|
240
|
-
return true;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
for (const pattern of this.config.ignorePatterns) {
|
|
245
|
-
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
246
|
-
|
|
247
|
-
// Extract base directory from pattern (e.g., 'node_modules' from 'node_modules/**')
|
|
248
|
-
const baseDir = normalizedPattern.split('/')[0].replace(/\*/g, '');
|
|
249
|
-
|
|
250
|
-
// Check if path IS the ignored directory or starts with it
|
|
251
|
-
if (baseDir && (normalized === baseDir || normalized.startsWith(baseDir + '/'))) {
|
|
252
|
-
return true;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// Simple glob matching for more complex patterns
|
|
256
|
-
let regexPattern = normalizedPattern
|
|
257
|
-
.replace(/\./g, '\\.')
|
|
258
|
-
.replace(/\*\*/g, '.*')
|
|
259
|
-
.replace(/\*/g, '[^/]*');
|
|
260
|
-
|
|
261
|
-
if (new RegExp('^' + regexPattern).test(normalized)) {
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
return false;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
_parseWidget(content) {
|
|
270
|
-
// Look for widget comment block at top of file
|
|
271
|
-
const widgetMatch = content.match(/\/\*\*[\s\S]*?\*\//);
|
|
272
|
-
if (!widgetMatch) return null;
|
|
273
|
-
|
|
274
|
-
const widgetBlock = widgetMatch[0];
|
|
275
|
-
const widget = {};
|
|
276
|
-
|
|
277
|
-
// Parse each @ field
|
|
278
|
-
const fields = [
|
|
279
|
-
'purpose', 'module', 'layer', 'imports-from', 'exports-to',
|
|
280
|
-
'data-flow', 'side-effects', 'depends-on-env', 'critical-for',
|
|
281
|
-
'known-issues', 'last-audit', 'last-modified'
|
|
282
|
-
];
|
|
283
|
-
|
|
284
|
-
for (const field of fields) {
|
|
285
|
-
const fieldMatch = widgetBlock.match(new RegExp(`@${field}\\s+(.+?)(?=\\n\\s*\\*\\s*@|\\n\\s*\\*\\/|$)`, 's'));
|
|
286
|
-
if (fieldMatch) {
|
|
287
|
-
const key = field.replace(/-/g, '_');
|
|
288
|
-
widget[key] = fieldMatch[1].trim().replace(/\n\s*\*\s*/g, ' ').trim();
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return Object.keys(widget).length > 0 ? widget : null;
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
_extractExports(content) {
|
|
296
|
-
const exports = [];
|
|
297
|
-
|
|
298
|
-
// module.exports = { ... }
|
|
299
|
-
const objectExportMatch = content.match(/module\.exports\s*=\s*\{([^}]+)\}/);
|
|
300
|
-
if (objectExportMatch) {
|
|
301
|
-
const items = objectExportMatch[1].match(/\w+/g) || [];
|
|
302
|
-
exports.push(...items);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// module.exports = function/class
|
|
306
|
-
const directExportMatch = content.match(/module\.exports\s*=\s*(class\s+)?(\w+)/);
|
|
307
|
-
if (directExportMatch && directExportMatch[2]) {
|
|
308
|
-
exports.push(directExportMatch[2]);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
// exports.name = ...
|
|
312
|
-
const namedExports = content.match(/exports\.(\w+)\s*=/g) || [];
|
|
313
|
-
for (const exp of namedExports) {
|
|
314
|
-
const name = exp.match(/exports\.(\w+)/);
|
|
315
|
-
if (name) exports.push(name[1]);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return [...new Set(exports)];
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
_extractImports(content) {
|
|
322
|
-
const imports = [];
|
|
323
|
-
|
|
324
|
-
// require statements
|
|
325
|
-
const requireMatches = content.matchAll(/(?:const|let|var)\s+(?:\{([^}]+)\}|(\w+))\s*=\s*require\(['"]([^'"]+)['"]\)/g);
|
|
326
|
-
|
|
327
|
-
for (const match of requireMatches) {
|
|
328
|
-
const specifiers = match[1]
|
|
329
|
-
? match[1].split(',').map(s => s.trim().split(':')[0].trim())
|
|
330
|
-
: [match[2]];
|
|
331
|
-
|
|
332
|
-
imports.push({
|
|
333
|
-
module: match[3],
|
|
334
|
-
specifiers: specifiers.filter(Boolean)
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return imports;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
_compareWidgetToCode(filePath, widget, actual) {
|
|
342
|
-
const issues = [];
|
|
343
|
-
|
|
344
|
-
// Check exports drift
|
|
345
|
-
if (widget.exports_to) {
|
|
346
|
-
const declaredExports = widget.exports_to.split(',').map(e => e.trim().split(' ')[0]);
|
|
347
|
-
const missingExports = declaredExports.filter(e =>
|
|
348
|
-
!actual.exports.some(ae => ae.toLowerCase().includes(e.toLowerCase()))
|
|
349
|
-
);
|
|
350
|
-
|
|
351
|
-
if (missingExports.length > 0) {
|
|
352
|
-
issues.push({
|
|
353
|
-
file: filePath,
|
|
354
|
-
category: 'drift',
|
|
355
|
-
severity: 'warning',
|
|
356
|
-
id: 'DRIFT002',
|
|
357
|
-
name: 'Missing Declared Export',
|
|
358
|
-
message: `Widget declares exports not found in code: ${missingExports.join(', ')}`
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Check imports drift
|
|
364
|
-
if (widget.imports_from) {
|
|
365
|
-
const declaredImports = widget.imports_from.split(',').map(i => i.trim().split(' ')[0]);
|
|
366
|
-
const actualModules = actual.imports.map(i => i.module);
|
|
367
|
-
|
|
368
|
-
const missingImports = declaredImports.filter(d =>
|
|
369
|
-
!actualModules.some(am => am.includes(d) || d.includes(am.split('/').pop()))
|
|
370
|
-
);
|
|
371
|
-
|
|
372
|
-
if (missingImports.length > 0) {
|
|
373
|
-
issues.push({
|
|
374
|
-
file: filePath,
|
|
375
|
-
category: 'drift',
|
|
376
|
-
severity: 'warning',
|
|
377
|
-
id: 'DRIFT003',
|
|
378
|
-
name: 'Missing Declared Import',
|
|
379
|
-
message: `Widget declares imports not found in code: ${missingImports.join(', ')}`
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// Check for undeclared side effects
|
|
385
|
-
const hasFsOperations = /fs\.(write|append|unlink|mkdir|rmdir)/i.test(fs.readFileSync(filePath, 'utf8'));
|
|
386
|
-
if (hasFsOperations && (!widget.side_effects || widget.side_effects === 'None')) {
|
|
387
|
-
issues.push({
|
|
388
|
-
file: filePath,
|
|
389
|
-
category: 'drift',
|
|
390
|
-
severity: 'warning',
|
|
391
|
-
id: 'DRIFT004',
|
|
392
|
-
name: 'Undeclared Side Effects',
|
|
393
|
-
message: 'File has file system operations but widget declares no side effects'
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
return issues;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
_analyzeCrossFile(moduleMap) {
|
|
401
|
-
const issues = [];
|
|
402
|
-
|
|
403
|
-
// Check for orphaned modules (single file)
|
|
404
|
-
for (const [module, files] of Object.entries(moduleMap)) {
|
|
405
|
-
if (files.length === 1) {
|
|
406
|
-
issues.push({
|
|
407
|
-
file: files[0],
|
|
408
|
-
category: 'drift',
|
|
409
|
-
severity: 'info',
|
|
410
|
-
id: 'DRIFT005',
|
|
411
|
-
name: 'Orphaned Module',
|
|
412
|
-
message: `Module "${module}" only has one file - verify module organization`
|
|
413
|
-
});
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// Would also check for circular dependencies here if we had full import graph
|
|
418
|
-
|
|
419
|
-
return issues;
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
module.exports = DriftDetector;
|
|
1
|
+
/**
|
|
2
|
+
* Sentinel Drift Detector
|
|
3
|
+
*
|
|
4
|
+
* Detects architectural drift by comparing code against declared contracts.
|
|
5
|
+
* Checks for mismatches between:
|
|
6
|
+
* - File widgets (declared purpose, imports, exports)
|
|
7
|
+
* - Actual code structure
|
|
8
|
+
* - Module relationships
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const EventEmitter = require('events');
|
|
14
|
+
|
|
15
|
+
class DriftDetector extends EventEmitter {
|
|
16
|
+
constructor(config = {}) {
|
|
17
|
+
super();
|
|
18
|
+
|
|
19
|
+
this.config = {
|
|
20
|
+
rootPath: config.rootPath || process.cwd(),
|
|
21
|
+
widgetPattern: config.widgetPattern || /\/\*\*[\s\S]*?@(purpose|module|layer|imports-from|exports-to|depends-on|critical-for)[\s\S]*?\*\//,
|
|
22
|
+
ignorePatterns: config.ignorePatterns || [
|
|
23
|
+
'node_modules/**',
|
|
24
|
+
'.git/**',
|
|
25
|
+
'dist/**',
|
|
26
|
+
'.orion/snapshots/**'
|
|
27
|
+
],
|
|
28
|
+
...config
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Cache for file analysis results
|
|
32
|
+
this.analysisCache = new Map();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Detect drift across all files
|
|
37
|
+
*/
|
|
38
|
+
async detectAll() {
|
|
39
|
+
const startTime = Date.now();
|
|
40
|
+
|
|
41
|
+
const result = {
|
|
42
|
+
filesAnalyzed: 0,
|
|
43
|
+
filesWithWidgets: 0,
|
|
44
|
+
filesWithoutWidgets: 0,
|
|
45
|
+
issues: [],
|
|
46
|
+
moduleMap: {},
|
|
47
|
+
duration: 0
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const files = await this._discoverFiles(this.config.rootPath);
|
|
52
|
+
|
|
53
|
+
for (const file of files) {
|
|
54
|
+
const analysis = await this.analyzeFile(file);
|
|
55
|
+
result.filesAnalyzed++;
|
|
56
|
+
|
|
57
|
+
if (analysis.hasWidget) {
|
|
58
|
+
result.filesWithWidgets++;
|
|
59
|
+
} else {
|
|
60
|
+
result.filesWithoutWidgets++;
|
|
61
|
+
// Missing widget is itself drift
|
|
62
|
+
result.issues.push({
|
|
63
|
+
file,
|
|
64
|
+
category: 'drift',
|
|
65
|
+
severity: 'info',
|
|
66
|
+
id: 'DRIFT001',
|
|
67
|
+
name: 'Missing Widget',
|
|
68
|
+
message: 'File has no widget declaration - add @purpose, @module, @layer etc.'
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check for specific drift issues
|
|
73
|
+
result.issues.push(...analysis.issues);
|
|
74
|
+
|
|
75
|
+
// Build module map
|
|
76
|
+
if (analysis.module) {
|
|
77
|
+
if (!result.moduleMap[analysis.module]) {
|
|
78
|
+
result.moduleMap[analysis.module] = [];
|
|
79
|
+
}
|
|
80
|
+
result.moduleMap[analysis.module].push(file);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Cross-file analysis for circular dependencies and broken references
|
|
85
|
+
result.issues.push(...this._analyzeCrossFile(result.moduleMap));
|
|
86
|
+
|
|
87
|
+
result.duration = Date.now() - startTime;
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
console.error('[DRIFT-DETECTOR] Detection failed:', error.message);
|
|
92
|
+
result.error = error.message;
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Analyze a single file for drift
|
|
99
|
+
*/
|
|
100
|
+
async analyzeFile(filePath) {
|
|
101
|
+
const result = {
|
|
102
|
+
file: filePath,
|
|
103
|
+
hasWidget: false,
|
|
104
|
+
widget: null,
|
|
105
|
+
actualExports: [],
|
|
106
|
+
actualImports: [],
|
|
107
|
+
module: null,
|
|
108
|
+
issues: []
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
113
|
+
|
|
114
|
+
// Parse widget if present
|
|
115
|
+
result.widget = this._parseWidget(content);
|
|
116
|
+
result.hasWidget = !!result.widget;
|
|
117
|
+
|
|
118
|
+
// Analyze actual code structure
|
|
119
|
+
result.actualExports = this._extractExports(content);
|
|
120
|
+
result.actualImports = this._extractImports(content);
|
|
121
|
+
|
|
122
|
+
// If widget exists, check for drift
|
|
123
|
+
if (result.widget) {
|
|
124
|
+
result.module = result.widget.module;
|
|
125
|
+
result.issues.push(...this._compareWidgetToCode(filePath, result.widget, {
|
|
126
|
+
exports: result.actualExports,
|
|
127
|
+
imports: result.actualImports
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Cache the analysis
|
|
132
|
+
this.analysisCache.set(filePath, result);
|
|
133
|
+
|
|
134
|
+
return result;
|
|
135
|
+
} catch (error) {
|
|
136
|
+
result.issues.push({
|
|
137
|
+
file: filePath,
|
|
138
|
+
category: 'drift',
|
|
139
|
+
severity: 'error',
|
|
140
|
+
id: 'DRIFT000',
|
|
141
|
+
message: `Failed to analyze file: ${error.message}`
|
|
142
|
+
});
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a specific file has drifted from its widget
|
|
149
|
+
*/
|
|
150
|
+
async checkFileDrift(filePath) {
|
|
151
|
+
const analysis = await this.analyzeFile(filePath);
|
|
152
|
+
return {
|
|
153
|
+
hasDrift: analysis.issues.length > 0,
|
|
154
|
+
issues: analysis.issues,
|
|
155
|
+
widget: analysis.widget
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Generate suggested widget for a file
|
|
161
|
+
*/
|
|
162
|
+
async suggestWidget(filePath) {
|
|
163
|
+
try {
|
|
164
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
165
|
+
const exports = this._extractExports(content);
|
|
166
|
+
const imports = this._extractImports(content);
|
|
167
|
+
|
|
168
|
+
// Determine module from path
|
|
169
|
+
const relativePath = path.relative(this.config.rootPath, filePath);
|
|
170
|
+
const pathParts = relativePath.split(path.sep);
|
|
171
|
+
const module = pathParts[0];
|
|
172
|
+
|
|
173
|
+
// Determine layer from path/content
|
|
174
|
+
let layer = 'application';
|
|
175
|
+
if (relativePath.includes('routes') || relativePath.includes('api')) {
|
|
176
|
+
layer = 'presentation';
|
|
177
|
+
} else if (relativePath.includes('core') || relativePath.includes('engine')) {
|
|
178
|
+
layer = 'infrastructure';
|
|
179
|
+
} else if (relativePath.includes('model') || relativePath.includes('domain')) {
|
|
180
|
+
layer = 'domain';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Generate purpose from exports
|
|
184
|
+
const purpose = exports.length > 0
|
|
185
|
+
? `Provides ${exports.join(', ')}`
|
|
186
|
+
: 'Unknown purpose - needs description';
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
purpose,
|
|
190
|
+
module,
|
|
191
|
+
layer,
|
|
192
|
+
importsFrom: imports.map(i => `${i.module} - ${i.specifiers.join(', ')}`),
|
|
193
|
+
exportsTo: 'To be determined',
|
|
194
|
+
dataFlow: 'To be documented',
|
|
195
|
+
sideEffects: content.includes('fs.') ? 'File system operations' : 'None documented',
|
|
196
|
+
criticalFor: 'To be documented'
|
|
197
|
+
};
|
|
198
|
+
} catch (error) {
|
|
199
|
+
return { error: error.message };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Private methods
|
|
204
|
+
|
|
205
|
+
async _discoverFiles(dir, files = []) {
|
|
206
|
+
try {
|
|
207
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
208
|
+
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
const fullPath = path.join(dir, entry.name);
|
|
211
|
+
const relativePath = path.relative(this.config.rootPath, fullPath);
|
|
212
|
+
|
|
213
|
+
if (this._shouldIgnore(relativePath)) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (entry.isDirectory()) {
|
|
218
|
+
await this._discoverFiles(fullPath, files);
|
|
219
|
+
} else if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
220
|
+
files.push(fullPath);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
// Skip inaccessible directories
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return files;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_shouldIgnore(relativePath) {
|
|
231
|
+
const normalized = relativePath.replace(/\\/g, '/');
|
|
232
|
+
|
|
233
|
+
// Always ignore any path containing these directories (anywhere in path)
|
|
234
|
+
const alwaysIgnore = ['node_modules', '.git', 'dist', '.vite'];
|
|
235
|
+
for (const dir of alwaysIgnore) {
|
|
236
|
+
if (normalized.includes('/' + dir + '/') ||
|
|
237
|
+
normalized.startsWith(dir + '/') ||
|
|
238
|
+
normalized === dir ||
|
|
239
|
+
normalized.endsWith('/' + dir)) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const pattern of this.config.ignorePatterns) {
|
|
245
|
+
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
246
|
+
|
|
247
|
+
// Extract base directory from pattern (e.g., 'node_modules' from 'node_modules/**')
|
|
248
|
+
const baseDir = normalizedPattern.split('/')[0].replace(/\*/g, '');
|
|
249
|
+
|
|
250
|
+
// Check if path IS the ignored directory or starts with it
|
|
251
|
+
if (baseDir && (normalized === baseDir || normalized.startsWith(baseDir + '/'))) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Simple glob matching for more complex patterns
|
|
256
|
+
let regexPattern = normalizedPattern
|
|
257
|
+
.replace(/\./g, '\\.')
|
|
258
|
+
.replace(/\*\*/g, '.*')
|
|
259
|
+
.replace(/\*/g, '[^/]*');
|
|
260
|
+
|
|
261
|
+
if (new RegExp('^' + regexPattern).test(normalized)) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
_parseWidget(content) {
|
|
270
|
+
// Look for widget comment block at top of file
|
|
271
|
+
const widgetMatch = content.match(/\/\*\*[\s\S]*?\*\//);
|
|
272
|
+
if (!widgetMatch) return null;
|
|
273
|
+
|
|
274
|
+
const widgetBlock = widgetMatch[0];
|
|
275
|
+
const widget = {};
|
|
276
|
+
|
|
277
|
+
// Parse each @ field
|
|
278
|
+
const fields = [
|
|
279
|
+
'purpose', 'module', 'layer', 'imports-from', 'exports-to',
|
|
280
|
+
'data-flow', 'side-effects', 'depends-on-env', 'critical-for',
|
|
281
|
+
'known-issues', 'last-audit', 'last-modified'
|
|
282
|
+
];
|
|
283
|
+
|
|
284
|
+
for (const field of fields) {
|
|
285
|
+
const fieldMatch = widgetBlock.match(new RegExp(`@${field}\\s+(.+?)(?=\\n\\s*\\*\\s*@|\\n\\s*\\*\\/|$)`, 's'));
|
|
286
|
+
if (fieldMatch) {
|
|
287
|
+
const key = field.replace(/-/g, '_');
|
|
288
|
+
widget[key] = fieldMatch[1].trim().replace(/\n\s*\*\s*/g, ' ').trim();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Object.keys(widget).length > 0 ? widget : null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
_extractExports(content) {
|
|
296
|
+
const exports = [];
|
|
297
|
+
|
|
298
|
+
// module.exports = { ... }
|
|
299
|
+
const objectExportMatch = content.match(/module\.exports\s*=\s*\{([^}]+)\}/);
|
|
300
|
+
if (objectExportMatch) {
|
|
301
|
+
const items = objectExportMatch[1].match(/\w+/g) || [];
|
|
302
|
+
exports.push(...items);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// module.exports = function/class
|
|
306
|
+
const directExportMatch = content.match(/module\.exports\s*=\s*(class\s+)?(\w+)/);
|
|
307
|
+
if (directExportMatch && directExportMatch[2]) {
|
|
308
|
+
exports.push(directExportMatch[2]);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// exports.name = ...
|
|
312
|
+
const namedExports = content.match(/exports\.(\w+)\s*=/g) || [];
|
|
313
|
+
for (const exp of namedExports) {
|
|
314
|
+
const name = exp.match(/exports\.(\w+)/);
|
|
315
|
+
if (name) exports.push(name[1]);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return [...new Set(exports)];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
_extractImports(content) {
|
|
322
|
+
const imports = [];
|
|
323
|
+
|
|
324
|
+
// require statements
|
|
325
|
+
const requireMatches = content.matchAll(/(?:const|let|var)\s+(?:\{([^}]+)\}|(\w+))\s*=\s*require\(['"]([^'"]+)['"]\)/g);
|
|
326
|
+
|
|
327
|
+
for (const match of requireMatches) {
|
|
328
|
+
const specifiers = match[1]
|
|
329
|
+
? match[1].split(',').map(s => s.trim().split(':')[0].trim())
|
|
330
|
+
: [match[2]];
|
|
331
|
+
|
|
332
|
+
imports.push({
|
|
333
|
+
module: match[3],
|
|
334
|
+
specifiers: specifiers.filter(Boolean)
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return imports;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
_compareWidgetToCode(filePath, widget, actual) {
|
|
342
|
+
const issues = [];
|
|
343
|
+
|
|
344
|
+
// Check exports drift
|
|
345
|
+
if (widget.exports_to) {
|
|
346
|
+
const declaredExports = widget.exports_to.split(',').map(e => e.trim().split(' ')[0]);
|
|
347
|
+
const missingExports = declaredExports.filter(e =>
|
|
348
|
+
!actual.exports.some(ae => ae.toLowerCase().includes(e.toLowerCase()))
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
if (missingExports.length > 0) {
|
|
352
|
+
issues.push({
|
|
353
|
+
file: filePath,
|
|
354
|
+
category: 'drift',
|
|
355
|
+
severity: 'warning',
|
|
356
|
+
id: 'DRIFT002',
|
|
357
|
+
name: 'Missing Declared Export',
|
|
358
|
+
message: `Widget declares exports not found in code: ${missingExports.join(', ')}`
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Check imports drift
|
|
364
|
+
if (widget.imports_from) {
|
|
365
|
+
const declaredImports = widget.imports_from.split(',').map(i => i.trim().split(' ')[0]);
|
|
366
|
+
const actualModules = actual.imports.map(i => i.module);
|
|
367
|
+
|
|
368
|
+
const missingImports = declaredImports.filter(d =>
|
|
369
|
+
!actualModules.some(am => am.includes(d) || d.includes(am.split('/').pop()))
|
|
370
|
+
);
|
|
371
|
+
|
|
372
|
+
if (missingImports.length > 0) {
|
|
373
|
+
issues.push({
|
|
374
|
+
file: filePath,
|
|
375
|
+
category: 'drift',
|
|
376
|
+
severity: 'warning',
|
|
377
|
+
id: 'DRIFT003',
|
|
378
|
+
name: 'Missing Declared Import',
|
|
379
|
+
message: `Widget declares imports not found in code: ${missingImports.join(', ')}`
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Check for undeclared side effects
|
|
385
|
+
const hasFsOperations = /fs\.(write|append|unlink|mkdir|rmdir)/i.test(fs.readFileSync(filePath, 'utf8'));
|
|
386
|
+
if (hasFsOperations && (!widget.side_effects || widget.side_effects === 'None')) {
|
|
387
|
+
issues.push({
|
|
388
|
+
file: filePath,
|
|
389
|
+
category: 'drift',
|
|
390
|
+
severity: 'warning',
|
|
391
|
+
id: 'DRIFT004',
|
|
392
|
+
name: 'Undeclared Side Effects',
|
|
393
|
+
message: 'File has file system operations but widget declares no side effects'
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return issues;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
_analyzeCrossFile(moduleMap) {
|
|
401
|
+
const issues = [];
|
|
402
|
+
|
|
403
|
+
// Check for orphaned modules (single file)
|
|
404
|
+
for (const [module, files] of Object.entries(moduleMap)) {
|
|
405
|
+
if (files.length === 1) {
|
|
406
|
+
issues.push({
|
|
407
|
+
file: files[0],
|
|
408
|
+
category: 'drift',
|
|
409
|
+
severity: 'info',
|
|
410
|
+
id: 'DRIFT005',
|
|
411
|
+
name: 'Orphaned Module',
|
|
412
|
+
message: `Module "${module}" only has one file - verify module organization`
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Would also check for circular dependencies here if we had full import graph
|
|
418
|
+
|
|
419
|
+
return issues;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
module.exports = DriftDetector;
|