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,539 @@
1
+ /**
2
+ * Sentinel Dependency Graph
3
+ *
4
+ * Maps all file dependencies in the Orion codebase.
5
+ * Answers: "If I change X, what breaks?"
6
+ *
7
+ * @purpose Track and analyze file dependencies across codebase
8
+ * @module sentinel
9
+ * @layer infrastructure
10
+ * @exports-to sentinel-core, widget-generator
11
+ * @side-effects File system reads
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+ const EventEmitter = require('events');
17
+
18
+ class DependencyGraph extends EventEmitter {
19
+ constructor(config = {}) {
20
+ super();
21
+
22
+ this.config = {
23
+ rootPath: config.rootPath || process.cwd(),
24
+ ignorePatterns: config.ignorePatterns || [
25
+ 'node_modules', '.git', 'dist', '.vite', '.orion/snapshots'
26
+ ],
27
+ extensions: config.extensions || ['.js', '.ts', '.jsx', '.tsx'],
28
+ ...config
29
+ };
30
+
31
+ // The graph: file -> { imports: [], importedBy: [] }
32
+ this.graph = new Map();
33
+
34
+ // Module resolution cache
35
+ this.resolveCache = new Map();
36
+
37
+ // Statistics
38
+ this.stats = {
39
+ filesAnalyzed: 0,
40
+ totalImports: 0,
41
+ unresolvedImports: 0,
42
+ circularDependencies: []
43
+ };
44
+ }
45
+
46
+ /**
47
+ * Build the full dependency graph
48
+ */
49
+ async build() {
50
+ console.log('[DEPENDENCY-GRAPH] Building dependency graph...');
51
+ const startTime = Date.now();
52
+
53
+ // Clear existing graph
54
+ this.graph.clear();
55
+ this.resolveCache.clear();
56
+ this.stats = {
57
+ filesAnalyzed: 0,
58
+ totalImports: 0,
59
+ unresolvedImports: 0,
60
+ circularDependencies: []
61
+ };
62
+
63
+ // Discover all files
64
+ const files = await this._discoverFiles(this.config.rootPath);
65
+ console.log(`[DEPENDENCY-GRAPH] Found ${files.length} files to analyze`);
66
+
67
+ // First pass: extract all imports
68
+ for (const file of files) {
69
+ await this._analyzeFile(file);
70
+ }
71
+
72
+ // Second pass: build reverse dependencies (importedBy)
73
+ this._buildReverseDependencies();
74
+
75
+ // Third pass: detect circular dependencies
76
+ this._detectCircularDependencies();
77
+
78
+ const duration = Date.now() - startTime;
79
+ console.log(`[DEPENDENCY-GRAPH] Graph built in ${duration}ms`);
80
+ console.log(`[DEPENDENCY-GRAPH] ${this.stats.filesAnalyzed} files, ${this.stats.totalImports} imports, ${this.stats.unresolvedImports} unresolved`);
81
+
82
+ return this.getStats();
83
+ }
84
+
85
+ /**
86
+ * Get what a file depends on (imports)
87
+ */
88
+ getDependencies(filePath) {
89
+ const normalized = this._normalizePath(filePath);
90
+ const node = this.graph.get(normalized);
91
+ return node ? node.imports : [];
92
+ }
93
+
94
+ /**
95
+ * Get what depends on a file (dependents)
96
+ */
97
+ getDependents(filePath) {
98
+ const normalized = this._normalizePath(filePath);
99
+ const node = this.graph.get(normalized);
100
+ return node ? node.importedBy : [];
101
+ }
102
+
103
+ /**
104
+ * Get full dependency chain (recursive)
105
+ */
106
+ getFullDependencyChain(filePath, visited = new Set()) {
107
+ const normalized = this._normalizePath(filePath);
108
+ if (visited.has(normalized)) return []; // Prevent infinite loop
109
+
110
+ visited.add(normalized);
111
+ const deps = this.getDependencies(normalized);
112
+ const chain = [...deps];
113
+
114
+ for (const dep of deps) {
115
+ if (dep.resolved) {
116
+ chain.push(...this.getFullDependencyChain(dep.resolved, visited));
117
+ }
118
+ }
119
+
120
+ return chain;
121
+ }
122
+
123
+ /**
124
+ * IMPACT ANALYSIS: What could break if I change this file?
125
+ */
126
+ getImpactAnalysis(filePath) {
127
+ const normalized = this._normalizePath(filePath);
128
+ const result = {
129
+ file: normalized,
130
+ relativePath: path.relative(this.config.rootPath, normalized),
131
+ directDependents: [],
132
+ indirectDependents: [],
133
+ totalImpact: 0,
134
+ riskLevel: 'low',
135
+ affectedModules: new Set()
136
+ };
137
+
138
+ // Get direct dependents
139
+ result.directDependents = this.getDependents(normalized).map(d => ({
140
+ file: d,
141
+ relativePath: path.relative(this.config.rootPath, d)
142
+ }));
143
+
144
+ // Get indirect dependents (recursive)
145
+ const visited = new Set([normalized]);
146
+ const queue = [...result.directDependents.map(d => d.file)];
147
+
148
+ while (queue.length > 0) {
149
+ const current = queue.shift();
150
+ if (visited.has(current)) continue;
151
+ visited.add(current);
152
+
153
+ const dependents = this.getDependents(current);
154
+ for (const dep of dependents) {
155
+ if (!visited.has(dep)) {
156
+ result.indirectDependents.push({
157
+ file: dep,
158
+ relativePath: path.relative(this.config.rootPath, dep),
159
+ via: path.relative(this.config.rootPath, current)
160
+ });
161
+ queue.push(dep);
162
+ }
163
+ }
164
+ }
165
+
166
+ // Calculate total impact
167
+ result.totalImpact = result.directDependents.length + result.indirectDependents.length;
168
+
169
+ // Determine risk level
170
+ if (result.totalImpact === 0) {
171
+ result.riskLevel = 'none';
172
+ } else if (result.totalImpact <= 3) {
173
+ result.riskLevel = 'low';
174
+ } else if (result.totalImpact <= 10) {
175
+ result.riskLevel = 'medium';
176
+ } else if (result.totalImpact <= 25) {
177
+ result.riskLevel = 'high';
178
+ } else {
179
+ result.riskLevel = 'critical';
180
+ }
181
+
182
+ // Extract affected modules
183
+ for (const dep of [...result.directDependents, ...result.indirectDependents]) {
184
+ const parts = dep.relativePath.split(path.sep);
185
+ if (parts.length > 0) {
186
+ result.affectedModules.add(parts[0]);
187
+ }
188
+ }
189
+ result.affectedModules = Array.from(result.affectedModules);
190
+
191
+ return result;
192
+ }
193
+
194
+ /**
195
+ * Find most critical files (most dependents)
196
+ */
197
+ getMostCriticalFiles(limit = 20) {
198
+ const files = [];
199
+
200
+ for (const [filePath, node] of this.graph) {
201
+ files.push({
202
+ file: filePath,
203
+ relativePath: path.relative(this.config.rootPath, filePath),
204
+ dependentCount: node.importedBy.length,
205
+ dependencyCount: node.imports.length
206
+ });
207
+ }
208
+
209
+ return files
210
+ .sort((a, b) => b.dependentCount - a.dependentCount)
211
+ .slice(0, limit);
212
+ }
213
+
214
+ /**
215
+ * Find orphan files (no imports, no dependents)
216
+ */
217
+ getOrphanFiles() {
218
+ const orphans = [];
219
+
220
+ for (const [filePath, node] of this.graph) {
221
+ if (node.imports.length === 0 && node.importedBy.length === 0) {
222
+ orphans.push({
223
+ file: filePath,
224
+ relativePath: path.relative(this.config.rootPath, filePath)
225
+ });
226
+ }
227
+ }
228
+
229
+ return orphans;
230
+ }
231
+
232
+ /**
233
+ * Get module dependency summary
234
+ */
235
+ getModuleSummary() {
236
+ const modules = new Map();
237
+
238
+ for (const [filePath, node] of this.graph) {
239
+ const relativePath = path.relative(this.config.rootPath, filePath);
240
+ const parts = relativePath.split(path.sep);
241
+ const moduleName = parts[0];
242
+
243
+ if (!modules.has(moduleName)) {
244
+ modules.set(moduleName, {
245
+ name: moduleName,
246
+ files: [],
247
+ internalDeps: 0,
248
+ externalDeps: 0,
249
+ dependedOnBy: new Set()
250
+ });
251
+ }
252
+
253
+ const mod = modules.get(moduleName);
254
+ mod.files.push(relativePath);
255
+
256
+ // Count internal vs external dependencies
257
+ for (const imp of node.imports) {
258
+ if (imp.resolved) {
259
+ const impRelative = path.relative(this.config.rootPath, imp.resolved);
260
+ const impModule = impRelative.split(path.sep)[0];
261
+ if (impModule === moduleName) {
262
+ mod.internalDeps++;
263
+ } else {
264
+ mod.externalDeps++;
265
+ }
266
+ }
267
+ }
268
+
269
+ // Track which modules depend on this one
270
+ for (const depPath of node.importedBy) {
271
+ const depRelative = path.relative(this.config.rootPath, depPath);
272
+ const depModule = depRelative.split(path.sep)[0];
273
+ if (depModule !== moduleName) {
274
+ mod.dependedOnBy.add(depModule);
275
+ }
276
+ }
277
+ }
278
+
279
+ // Convert to array and calculate metrics
280
+ const result = [];
281
+ for (const [name, mod] of modules) {
282
+ result.push({
283
+ name,
284
+ fileCount: mod.files.length,
285
+ internalDeps: mod.internalDeps,
286
+ externalDeps: mod.externalDeps,
287
+ dependedOnByCount: mod.dependedOnBy.size,
288
+ dependedOnBy: Array.from(mod.dependedOnBy)
289
+ });
290
+ }
291
+
292
+ return result.sort((a, b) => b.fileCount - a.fileCount);
293
+ }
294
+
295
+ /**
296
+ * Get statistics
297
+ */
298
+ getStats() {
299
+ return {
300
+ ...this.stats,
301
+ totalFiles: this.graph.size,
302
+ circularCount: this.stats.circularDependencies.length
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Export graph as JSON
308
+ */
309
+ toJSON() {
310
+ const data = {
311
+ generated: new Date().toISOString(),
312
+ rootPath: this.config.rootPath,
313
+ stats: this.getStats(),
314
+ files: {}
315
+ };
316
+
317
+ for (const [filePath, node] of this.graph) {
318
+ const relativePath = path.relative(this.config.rootPath, filePath);
319
+ data.files[relativePath] = {
320
+ imports: node.imports.map(i => ({
321
+ module: i.module,
322
+ resolved: i.resolved ? path.relative(this.config.rootPath, i.resolved) : null,
323
+ specifiers: i.specifiers
324
+ })),
325
+ importedBy: node.importedBy.map(p => path.relative(this.config.rootPath, p))
326
+ };
327
+ }
328
+
329
+ return data;
330
+ }
331
+
332
+ // Private methods
333
+
334
+ async _discoverFiles(dir, files = []) {
335
+ try {
336
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
337
+
338
+ for (const entry of entries) {
339
+ const fullPath = path.join(dir, entry.name);
340
+
341
+ // Check ignore patterns
342
+ if (this._shouldIgnore(entry.name)) {
343
+ continue;
344
+ }
345
+
346
+ if (entry.isDirectory()) {
347
+ await this._discoverFiles(fullPath, files);
348
+ } else if (entry.isFile()) {
349
+ const ext = path.extname(entry.name).toLowerCase();
350
+ if (this.config.extensions.includes(ext)) {
351
+ files.push(fullPath);
352
+ }
353
+ }
354
+ }
355
+ } catch (error) {
356
+ // Skip inaccessible directories
357
+ }
358
+
359
+ return files;
360
+ }
361
+
362
+ _shouldIgnore(name) {
363
+ return this.config.ignorePatterns.some(pattern =>
364
+ name === pattern || name.startsWith(pattern)
365
+ );
366
+ }
367
+
368
+ async _analyzeFile(filePath) {
369
+ try {
370
+ const content = fs.readFileSync(filePath, 'utf8');
371
+ const imports = this._extractImports(content, filePath);
372
+
373
+ this.graph.set(filePath, {
374
+ imports,
375
+ importedBy: [] // Will be filled in second pass
376
+ });
377
+
378
+ this.stats.filesAnalyzed++;
379
+ this.stats.totalImports += imports.length;
380
+ this.stats.unresolvedImports += imports.filter(i => !i.resolved).length;
381
+
382
+ } catch (error) {
383
+ // Skip files that can't be read
384
+ }
385
+ }
386
+
387
+ _extractImports(content, filePath) {
388
+ const imports = [];
389
+ const fileDir = path.dirname(filePath);
390
+
391
+ // CommonJS: require('module') or require("module")
392
+ const requireRegex = /(?:const|let|var)\s+(?:\{([^}]+)\}|(\w+))\s*=\s*require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
393
+ let match;
394
+
395
+ while ((match = requireRegex.exec(content)) !== null) {
396
+ const specifiers = match[1]
397
+ ? match[1].split(',').map(s => s.trim().split(':')[0].trim()).filter(Boolean)
398
+ : [match[2]];
399
+ const modulePath = match[3];
400
+
401
+ imports.push({
402
+ module: modulePath,
403
+ specifiers,
404
+ resolved: this._resolveModule(modulePath, fileDir),
405
+ type: 'require'
406
+ });
407
+ }
408
+
409
+ // Also catch: require('module') without assignment
410
+ const bareRequireRegex = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g;
411
+ while ((match = bareRequireRegex.exec(content)) !== null) {
412
+ const modulePath = match[1];
413
+ // Skip if already captured
414
+ if (!imports.some(i => i.module === modulePath)) {
415
+ imports.push({
416
+ module: modulePath,
417
+ specifiers: [],
418
+ resolved: this._resolveModule(modulePath, fileDir),
419
+ type: 'require'
420
+ });
421
+ }
422
+ }
423
+
424
+ // ES6: import ... from 'module'
425
+ const importRegex = /import\s+(?:(?:\{([^}]+)\}|(\w+)|\*\s+as\s+(\w+))(?:\s*,\s*)?)+\s+from\s+['"]([^'"]+)['"]/g;
426
+ while ((match = importRegex.exec(content)) !== null) {
427
+ const specifiers = [];
428
+ if (match[1]) specifiers.push(...match[1].split(',').map(s => s.trim().split(' as ')[0].trim()).filter(Boolean));
429
+ if (match[2]) specifiers.push(match[2]);
430
+ if (match[3]) specifiers.push(match[3]);
431
+ const modulePath = match[4];
432
+
433
+ imports.push({
434
+ module: modulePath,
435
+ specifiers,
436
+ resolved: this._resolveModule(modulePath, fileDir),
437
+ type: 'import'
438
+ });
439
+ }
440
+
441
+ return imports;
442
+ }
443
+
444
+ _resolveModule(modulePath, fromDir) {
445
+ // Check cache
446
+ const cacheKey = `${fromDir}:${modulePath}`;
447
+ if (this.resolveCache.has(cacheKey)) {
448
+ return this.resolveCache.get(cacheKey);
449
+ }
450
+
451
+ let resolved = null;
452
+
453
+ // Skip external modules (node_modules)
454
+ if (!modulePath.startsWith('.') && !modulePath.startsWith('/')) {
455
+ this.resolveCache.set(cacheKey, null);
456
+ return null;
457
+ }
458
+
459
+ // Try to resolve relative path
460
+ const basePath = path.resolve(fromDir, modulePath);
461
+ const extensions = ['', '.js', '.ts', '.jsx', '.tsx', '/index.js', '/index.ts'];
462
+
463
+ for (const ext of extensions) {
464
+ const tryPath = basePath + ext;
465
+ if (fs.existsSync(tryPath) && fs.statSync(tryPath).isFile()) {
466
+ resolved = tryPath;
467
+ break;
468
+ }
469
+ }
470
+
471
+ this.resolveCache.set(cacheKey, resolved);
472
+ return resolved;
473
+ }
474
+
475
+ _buildReverseDependencies() {
476
+ for (const [filePath, node] of this.graph) {
477
+ for (const imp of node.imports) {
478
+ if (imp.resolved && this.graph.has(imp.resolved)) {
479
+ const targetNode = this.graph.get(imp.resolved);
480
+ if (!targetNode.importedBy.includes(filePath)) {
481
+ targetNode.importedBy.push(filePath);
482
+ }
483
+ }
484
+ }
485
+ }
486
+ }
487
+
488
+ _detectCircularDependencies() {
489
+ const visited = new Set();
490
+ const recursionStack = new Set();
491
+
492
+ const dfs = (filePath, chain = []) => {
493
+ if (recursionStack.has(filePath)) {
494
+ // Found circular dependency
495
+ const cycleStart = chain.indexOf(filePath);
496
+ const cycle = chain.slice(cycleStart).map(p =>
497
+ path.relative(this.config.rootPath, p)
498
+ );
499
+ cycle.push(path.relative(this.config.rootPath, filePath));
500
+
501
+ // Only add if not already recorded
502
+ const cycleKey = cycle.sort().join(' -> ');
503
+ if (!this.stats.circularDependencies.some(c => c.sort().join(' -> ') === cycleKey)) {
504
+ this.stats.circularDependencies.push(cycle);
505
+ }
506
+ return;
507
+ }
508
+
509
+ if (visited.has(filePath)) return;
510
+
511
+ visited.add(filePath);
512
+ recursionStack.add(filePath);
513
+
514
+ const node = this.graph.get(filePath);
515
+ if (node) {
516
+ for (const imp of node.imports) {
517
+ if (imp.resolved) {
518
+ dfs(imp.resolved, [...chain, filePath]);
519
+ }
520
+ }
521
+ }
522
+
523
+ recursionStack.delete(filePath);
524
+ };
525
+
526
+ for (const filePath of this.graph.keys()) {
527
+ dfs(filePath);
528
+ }
529
+ }
530
+
531
+ _normalizePath(filePath) {
532
+ if (path.isAbsolute(filePath)) {
533
+ return filePath;
534
+ }
535
+ return path.resolve(this.config.rootPath, filePath);
536
+ }
537
+ }
538
+
539
+ module.exports = DependencyGraph;