thuban 0.3.0

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