thuban 0.3.1 → 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.
@@ -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;