rlm-analyzer 1.5.1 → 1.7.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,721 @@
1
+ /**
2
+ * Structural Index Module
3
+ * Caches file metadata, builds dependency graphs, and clusters related files
4
+ * for more efficient and accurate code analysis.
5
+ *
6
+ * Key features:
7
+ * - File hashing for change detection
8
+ * - Import/export extraction for dependency tracking
9
+ * - Dependency graph construction
10
+ * - File clustering for grouped analysis
11
+ * - Persistent caching to ~/.rlm-analyzer/cache/
12
+ */
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import * as crypto from 'crypto';
16
+ import * as os from 'os';
17
+ import { CODE_EXTENSIONS, INCLUDE_FILENAMES, IGNORE_DIRS } from './types.js';
18
+ /** Current index format version */
19
+ const INDEX_VERSION = '1.0.0';
20
+ /** Default max file size before chunking (100KB) */
21
+ const DEFAULT_MAX_FILE_SIZE = 100_000;
22
+ /** Default chunk size (50KB) */
23
+ const DEFAULT_CHUNK_SIZE = 50_000;
24
+ /** Cache directory */
25
+ const CACHE_DIR = path.join(os.homedir(), '.rlm-analyzer', 'cache');
26
+ // ============================================================================
27
+ // File Hashing
28
+ // ============================================================================
29
+ /**
30
+ * Calculate SHA-256 hash of file content
31
+ */
32
+ export function hashContent(content) {
33
+ return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
34
+ }
35
+ /**
36
+ * Calculate hash for a project directory (based on file paths and mtimes)
37
+ */
38
+ export function hashProject(directory) {
39
+ const absPath = path.resolve(directory);
40
+ return crypto.createHash('sha256').update(absPath).digest('hex').slice(0, 12);
41
+ }
42
+ // ============================================================================
43
+ // Import/Export Extraction
44
+ // ============================================================================
45
+ /** Import patterns by language */
46
+ const IMPORT_PATTERNS = {
47
+ typescript: [
48
+ /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g,
49
+ /import\s+['"]([^'"]+)['"]/g,
50
+ /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
51
+ /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
52
+ ],
53
+ javascript: [
54
+ /import\s+.*?\s+from\s+['"]([^'"]+)['"]/g,
55
+ /import\s+['"]([^'"]+)['"]/g,
56
+ /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g,
57
+ ],
58
+ python: [
59
+ /^import\s+(\S+)/gm,
60
+ /^from\s+(\S+)\s+import/gm,
61
+ ],
62
+ go: [
63
+ /import\s+["']([^"']+)["']/g,
64
+ /import\s+\(\s*\n([^)]+)\)/gs,
65
+ ],
66
+ rust: [
67
+ /use\s+([a-zA-Z_][a-zA-Z0-9_:]*)/g,
68
+ /mod\s+([a-zA-Z_][a-zA-Z0-9_]*)/g,
69
+ ],
70
+ java: [
71
+ /import\s+([a-zA-Z_][a-zA-Z0-9_.]*)/g,
72
+ ],
73
+ csharp: [
74
+ /using\s+([a-zA-Z_][a-zA-Z0-9_.]*)/g,
75
+ ],
76
+ ruby: [
77
+ /require\s+['"]([^'"]+)['"]/g,
78
+ /require_relative\s+['"]([^'"]+)['"]/g,
79
+ ],
80
+ php: [
81
+ /use\s+([a-zA-Z_\\][a-zA-Z0-9_\\]*)/g,
82
+ /require(?:_once)?\s+['"]([^'"]+)['"]/g,
83
+ /include(?:_once)?\s+['"]([^'"]+)['"]/g,
84
+ ],
85
+ };
86
+ /** Export patterns by language */
87
+ const EXPORT_PATTERNS = {
88
+ typescript: [
89
+ /export\s+(?:default\s+)?(?:class|function|const|let|var|interface|type|enum)\s+([a-zA-Z_][a-zA-Z0-9_]*)/g,
90
+ /export\s+\{\s*([^}]+)\s*\}/g,
91
+ ],
92
+ javascript: [
93
+ /export\s+(?:default\s+)?(?:class|function|const|let|var)\s+([a-zA-Z_][a-zA-Z0-9_]*)/g,
94
+ /module\.exports\s*=\s*\{([^}]+)\}/g,
95
+ /exports\.([a-zA-Z_][a-zA-Z0-9_]*)/g,
96
+ ],
97
+ python: [
98
+ /^(?:class|def)\s+([a-zA-Z_][a-zA-Z0-9_]*)/gm,
99
+ /__all__\s*=\s*\[([^\]]+)\]/g,
100
+ ],
101
+ go: [
102
+ /^func\s+([A-Z][a-zA-Z0-9_]*)/gm,
103
+ /^type\s+([A-Z][a-zA-Z0-9_]*)/gm,
104
+ ],
105
+ rust: [
106
+ /pub\s+(?:fn|struct|enum|trait|type|const|static)\s+([a-zA-Z_][a-zA-Z0-9_]*)/g,
107
+ ],
108
+ java: [
109
+ /public\s+(?:class|interface|enum)\s+([a-zA-Z_][a-zA-Z0-9_]*)/g,
110
+ ],
111
+ };
112
+ /**
113
+ * Get language from file extension
114
+ */
115
+ function getLanguage(ext) {
116
+ const langMap = {
117
+ '.ts': 'typescript', '.tsx': 'typescript', '.mts': 'typescript',
118
+ '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript', '.cjs': 'javascript',
119
+ '.py': 'python', '.pyw': 'python',
120
+ '.go': 'go',
121
+ '.rs': 'rust',
122
+ '.java': 'java', '.kt': 'java', '.scala': 'java',
123
+ '.cs': 'csharp', '.fs': 'csharp',
124
+ '.rb': 'ruby',
125
+ '.php': 'php',
126
+ };
127
+ return langMap[ext] || 'unknown';
128
+ }
129
+ /**
130
+ * Extract imports from file content
131
+ */
132
+ export function extractImports(content, ext) {
133
+ const lang = getLanguage(ext);
134
+ const patterns = IMPORT_PATTERNS[lang] || IMPORT_PATTERNS.typescript;
135
+ const imports = new Set();
136
+ for (const pattern of patterns) {
137
+ // Reset regex state
138
+ pattern.lastIndex = 0;
139
+ let match;
140
+ while ((match = pattern.exec(content)) !== null) {
141
+ const imported = match[1]?.trim();
142
+ if (imported) {
143
+ // Handle multiple imports in one statement
144
+ if (imported.includes(',')) {
145
+ imported.split(',').forEach(i => imports.add(i.trim()));
146
+ }
147
+ else {
148
+ imports.add(imported);
149
+ }
150
+ }
151
+ }
152
+ }
153
+ return Array.from(imports);
154
+ }
155
+ /**
156
+ * Extract exports from file content
157
+ */
158
+ export function extractExports(content, ext) {
159
+ const lang = getLanguage(ext);
160
+ const patterns = EXPORT_PATTERNS[lang] || EXPORT_PATTERNS.typescript;
161
+ const exports = new Set();
162
+ for (const pattern of patterns) {
163
+ pattern.lastIndex = 0;
164
+ let match;
165
+ while ((match = pattern.exec(content)) !== null) {
166
+ const exported = match[1]?.trim();
167
+ if (exported) {
168
+ if (exported.includes(',')) {
169
+ exported.split(',').forEach(e => {
170
+ const name = e.trim().split(/\s+as\s+/)[0].trim();
171
+ if (name)
172
+ exports.add(name);
173
+ });
174
+ }
175
+ else {
176
+ exports.add(exported);
177
+ }
178
+ }
179
+ }
180
+ }
181
+ return Array.from(exports);
182
+ }
183
+ // ============================================================================
184
+ // Large File Chunking
185
+ // ============================================================================
186
+ /**
187
+ * Check if a file needs chunking
188
+ */
189
+ export function needsChunking(size, maxSize = DEFAULT_MAX_FILE_SIZE) {
190
+ return size > maxSize;
191
+ }
192
+ /**
193
+ * Chunk a large file by lines
194
+ */
195
+ export function chunkFile(content, chunkSize = DEFAULT_CHUNK_SIZE) {
196
+ const lines = content.split('\n');
197
+ const chunks = [];
198
+ let currentChunk = [];
199
+ let currentSize = 0;
200
+ let startLine = 0;
201
+ for (let i = 0; i < lines.length; i++) {
202
+ const line = lines[i];
203
+ const lineSize = line.length + 1; // +1 for newline
204
+ if (currentSize + lineSize > chunkSize && currentChunk.length > 0) {
205
+ // Save current chunk
206
+ chunks.push({
207
+ index: chunks.length,
208
+ total: 0, // Will be updated
209
+ startLine,
210
+ endLine: i - 1,
211
+ content: currentChunk.join('\n'),
212
+ });
213
+ currentChunk = [];
214
+ currentSize = 0;
215
+ startLine = i;
216
+ }
217
+ currentChunk.push(line);
218
+ currentSize += lineSize;
219
+ }
220
+ // Don't forget the last chunk
221
+ if (currentChunk.length > 0) {
222
+ chunks.push({
223
+ index: chunks.length,
224
+ total: 0,
225
+ startLine,
226
+ endLine: lines.length - 1,
227
+ content: currentChunk.join('\n'),
228
+ });
229
+ }
230
+ // Update total count
231
+ const total = chunks.length;
232
+ chunks.forEach(chunk => { chunk.total = total; });
233
+ return chunks;
234
+ }
235
+ // ============================================================================
236
+ // Dependency Graph
237
+ // ============================================================================
238
+ /**
239
+ * Resolve an import path to a file path
240
+ */
241
+ function resolveImportPath(importPath, sourceFile, fileIndex) {
242
+ // Skip external packages
243
+ if (!importPath.startsWith('.') && !importPath.startsWith('/')) {
244
+ return null;
245
+ }
246
+ const sourceDir = path.dirname(sourceFile);
247
+ let resolved = path.join(sourceDir, importPath);
248
+ // Try common extensions
249
+ const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '', '/index.ts', '/index.js'];
250
+ for (const ext of extensions) {
251
+ const candidate = resolved + ext;
252
+ if (fileIndex[candidate]) {
253
+ return candidate;
254
+ }
255
+ }
256
+ return null;
257
+ }
258
+ /**
259
+ * Build dependency graph from file index
260
+ */
261
+ export function buildDependencyGraph(fileIndex) {
262
+ const edges = [];
263
+ for (const [filePath, entry] of Object.entries(fileIndex)) {
264
+ for (const imp of entry.imports) {
265
+ const resolved = resolveImportPath(imp, filePath, fileIndex);
266
+ if (resolved) {
267
+ edges.push({
268
+ from: filePath,
269
+ to: resolved,
270
+ type: 'import',
271
+ });
272
+ }
273
+ }
274
+ }
275
+ return edges;
276
+ }
277
+ // ============================================================================
278
+ // File Clustering
279
+ // ============================================================================
280
+ /**
281
+ * Find connected components in the dependency graph
282
+ */
283
+ function findConnectedComponents(files, edges) {
284
+ const adjacency = new Map();
285
+ // Build undirected adjacency list
286
+ for (const file of files) {
287
+ adjacency.set(file, new Set());
288
+ }
289
+ for (const edge of edges) {
290
+ adjacency.get(edge.from)?.add(edge.to);
291
+ adjacency.get(edge.to)?.add(edge.from);
292
+ }
293
+ const visited = new Set();
294
+ const components = [];
295
+ function dfs(node, component) {
296
+ if (visited.has(node))
297
+ return;
298
+ visited.add(node);
299
+ component.push(node);
300
+ for (const neighbor of adjacency.get(node) || []) {
301
+ dfs(neighbor, component);
302
+ }
303
+ }
304
+ for (const file of files) {
305
+ if (!visited.has(file)) {
306
+ const component = [];
307
+ dfs(file, component);
308
+ if (component.length > 0) {
309
+ components.push(component);
310
+ }
311
+ }
312
+ }
313
+ return components;
314
+ }
315
+ /**
316
+ * Detect cluster type based on file paths and content
317
+ */
318
+ function detectClusterType(files) {
319
+ const patterns = {
320
+ test: [/test/, /spec/, /__tests__/],
321
+ config: [/config/, /\.config\./, /settings/],
322
+ utility: [/util/, /helper/, /lib/, /common/],
323
+ };
324
+ for (const file of files) {
325
+ const lower = file.toLowerCase();
326
+ if (patterns.test.some(p => p.test(lower)))
327
+ return 'test';
328
+ if (patterns.config.some(p => p.test(lower)))
329
+ return 'config';
330
+ if (patterns.utility.some(p => p.test(lower)))
331
+ return 'utility';
332
+ }
333
+ return files.length > 5 ? 'feature' : 'module';
334
+ }
335
+ /**
336
+ * Find entry points in a cluster (files that are imported but don't import much)
337
+ */
338
+ function findEntryPoints(clusterFiles, edges) {
339
+ const clusterSet = new Set(clusterFiles);
340
+ const importedBy = new Map();
341
+ const imports = new Map();
342
+ for (const file of clusterFiles) {
343
+ importedBy.set(file, 0);
344
+ imports.set(file, 0);
345
+ }
346
+ for (const edge of edges) {
347
+ if (clusterSet.has(edge.from) && clusterSet.has(edge.to)) {
348
+ importedBy.set(edge.to, (importedBy.get(edge.to) || 0) + 1);
349
+ imports.set(edge.from, (imports.get(edge.from) || 0) + 1);
350
+ }
351
+ }
352
+ // Entry points: high imports count, low imported-by count
353
+ return clusterFiles
354
+ .filter(f => (importedBy.get(f) || 0) > 0 || clusterFiles.length === 1)
355
+ .sort((a, b) => {
356
+ const scoreA = (importedBy.get(a) || 0) - (imports.get(a) || 0);
357
+ const scoreB = (importedBy.get(b) || 0) - (imports.get(b) || 0);
358
+ return scoreB - scoreA;
359
+ })
360
+ .slice(0, 3);
361
+ }
362
+ /**
363
+ * Build file clusters from dependency graph
364
+ */
365
+ export function buildClusters(fileIndex, edges) {
366
+ const files = Object.keys(fileIndex);
367
+ const components = findConnectedComponents(files, edges);
368
+ return components.map((component, i) => {
369
+ const totalSize = component.reduce((sum, f) => sum + (fileIndex[f]?.size || 0), 0);
370
+ return {
371
+ id: `cluster-${i}`,
372
+ files: component,
373
+ entryPoints: findEntryPoints(component, edges),
374
+ totalSize,
375
+ type: detectClusterType(component),
376
+ };
377
+ });
378
+ }
379
+ // ============================================================================
380
+ // Cache Management
381
+ // ============================================================================
382
+ /**
383
+ * Get cache path for a project
384
+ */
385
+ export function getCachePath(directory) {
386
+ const projectHash = hashProject(directory);
387
+ return path.join(CACHE_DIR, projectHash, 'index.json');
388
+ }
389
+ /**
390
+ * Load cached index if valid
391
+ */
392
+ export function loadCachedIndex(directory) {
393
+ const cachePath = getCachePath(directory);
394
+ if (!fs.existsSync(cachePath)) {
395
+ return null;
396
+ }
397
+ try {
398
+ const data = fs.readFileSync(cachePath, 'utf-8');
399
+ const index = JSON.parse(data);
400
+ // Validate version
401
+ if (index.version !== INDEX_VERSION) {
402
+ return null;
403
+ }
404
+ // Check if project root matches
405
+ if (path.resolve(index.projectRoot) !== path.resolve(directory)) {
406
+ return null;
407
+ }
408
+ return index;
409
+ }
410
+ catch {
411
+ return null;
412
+ }
413
+ }
414
+ /**
415
+ * Save index to cache
416
+ */
417
+ export function saveIndexToCache(index) {
418
+ const cachePath = getCachePath(index.projectRoot);
419
+ const cacheDir = path.dirname(cachePath);
420
+ // Ensure cache directory exists
421
+ fs.mkdirSync(cacheDir, { recursive: true });
422
+ fs.writeFileSync(cachePath, JSON.stringify(index, null, 2));
423
+ }
424
+ /**
425
+ * Clear cache for a project
426
+ */
427
+ export function clearCache(directory) {
428
+ const cachePath = getCachePath(directory);
429
+ const cacheDir = path.dirname(cachePath);
430
+ if (fs.existsSync(cacheDir)) {
431
+ fs.rmSync(cacheDir, { recursive: true });
432
+ }
433
+ }
434
+ // ============================================================================
435
+ // Main Indexing Function
436
+ // ============================================================================
437
+ /**
438
+ * Check if a file should be included
439
+ */
440
+ function shouldIncludeFile(filePath, fileName) {
441
+ // Check by filename first
442
+ if (INCLUDE_FILENAMES.includes(fileName)) {
443
+ return true;
444
+ }
445
+ // Check by extension
446
+ const ext = path.extname(filePath);
447
+ return CODE_EXTENSIONS.some(e => ext === e || filePath.endsWith(e));
448
+ }
449
+ /**
450
+ * Build or update structural index for a directory
451
+ */
452
+ export async function buildStructuralIndex(directory, options = {}) {
453
+ const { force = false, maxFileSize = DEFAULT_MAX_FILE_SIZE, chunkSize = DEFAULT_CHUNK_SIZE, buildDependencyGraph: buildDeps = true, enableClustering = true, verbose = false, } = options;
454
+ const absDir = path.resolve(directory);
455
+ // Try to load cached index
456
+ if (!force) {
457
+ const cached = loadCachedIndex(absDir);
458
+ if (cached) {
459
+ // Validate cache by checking a sample of files
460
+ const sampleFiles = Object.keys(cached.files).slice(0, 10);
461
+ let cacheValid = true;
462
+ for (const file of sampleFiles) {
463
+ const fullPath = path.join(absDir, file);
464
+ if (!fs.existsSync(fullPath)) {
465
+ cacheValid = false;
466
+ break;
467
+ }
468
+ const stats = fs.statSync(fullPath);
469
+ if (stats.mtimeMs > cached.files[file].mtime) {
470
+ cacheValid = false;
471
+ break;
472
+ }
473
+ }
474
+ if (cacheValid) {
475
+ if (verbose) {
476
+ console.log('[Index] Using cached structural index');
477
+ }
478
+ return cached;
479
+ }
480
+ }
481
+ }
482
+ if (verbose) {
483
+ console.log('[Index] Building structural index...');
484
+ }
485
+ const fileIndex = {};
486
+ const languages = new Set();
487
+ let totalSize = 0;
488
+ // Walk directory
489
+ function walk(dir) {
490
+ try {
491
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
492
+ for (const entry of entries) {
493
+ const fullPath = path.join(dir, entry.name);
494
+ const relativePath = path.relative(absDir, fullPath);
495
+ if (entry.isDirectory()) {
496
+ if (!IGNORE_DIRS.includes(entry.name)) {
497
+ walk(fullPath);
498
+ }
499
+ }
500
+ else if (entry.isFile() && shouldIncludeFile(relativePath, entry.name)) {
501
+ try {
502
+ const stats = fs.statSync(fullPath);
503
+ const content = fs.readFileSync(fullPath, 'utf-8');
504
+ const ext = path.extname(entry.name);
505
+ const lang = getLanguage(ext);
506
+ if (lang !== 'unknown') {
507
+ languages.add(lang);
508
+ }
509
+ const isLarge = needsChunking(stats.size, maxFileSize);
510
+ fileIndex[relativePath] = {
511
+ path: relativePath,
512
+ hash: hashContent(content),
513
+ size: stats.size,
514
+ mtime: stats.mtimeMs,
515
+ imports: extractImports(content, ext),
516
+ exports: extractExports(content, ext),
517
+ extension: ext,
518
+ chunked: isLarge,
519
+ chunkCount: isLarge ? chunkFile(content, chunkSize).length : undefined,
520
+ };
521
+ totalSize += stats.size;
522
+ }
523
+ catch {
524
+ // Skip unreadable files
525
+ }
526
+ }
527
+ }
528
+ }
529
+ catch {
530
+ // Skip unreadable directories
531
+ }
532
+ }
533
+ walk(absDir);
534
+ // Build dependency graph
535
+ const dependencies = buildDeps ? buildDependencyGraph(fileIndex) : [];
536
+ // Build clusters
537
+ const clusters = enableClustering ? buildClusters(fileIndex, dependencies) : [];
538
+ // Detect frameworks and package manager
539
+ const metadata = {
540
+ fileCount: Object.keys(fileIndex).length,
541
+ totalSize,
542
+ languages: Array.from(languages),
543
+ frameworks: detectFrameworks(fileIndex),
544
+ packageManager: detectPackageManager(absDir),
545
+ };
546
+ const index = {
547
+ version: INDEX_VERSION,
548
+ projectRoot: absDir,
549
+ createdAt: Date.now(),
550
+ updatedAt: Date.now(),
551
+ files: fileIndex,
552
+ dependencies,
553
+ clusters,
554
+ metadata,
555
+ };
556
+ // Save to cache
557
+ saveIndexToCache(index);
558
+ if (verbose) {
559
+ console.log(`[Index] Indexed ${metadata.fileCount} files, ${clusters.length} clusters`);
560
+ }
561
+ return index;
562
+ }
563
+ /**
564
+ * Detect frameworks from file index
565
+ */
566
+ function detectFrameworks(fileIndex) {
567
+ const frameworks = new Set();
568
+ const files = Object.keys(fileIndex);
569
+ const patterns = [
570
+ [/next\.config/, 'Next.js'],
571
+ [/nuxt\.config/, 'Nuxt'],
572
+ [/angular\.json/, 'Angular'],
573
+ [/vue\.config|\.vue$/, 'Vue'],
574
+ [/svelte\.config|\.svelte$/, 'Svelte'],
575
+ [/astro\.config|\.astro$/, 'Astro'],
576
+ [/remix\.config/, 'Remix'],
577
+ [/express/, 'Express'],
578
+ [/fastify/, 'Fastify'],
579
+ [/nest-cli\.json/, 'NestJS'],
580
+ [/django|wsgi\.py/, 'Django'],
581
+ [/flask/, 'Flask'],
582
+ [/fastapi/, 'FastAPI'],
583
+ [/rails/, 'Rails'],
584
+ [/spring/, 'Spring'],
585
+ [/\.prisma$/, 'Prisma'],
586
+ ];
587
+ for (const file of files) {
588
+ for (const [pattern, name] of patterns) {
589
+ if (pattern.test(file)) {
590
+ frameworks.add(name);
591
+ }
592
+ }
593
+ }
594
+ return Array.from(frameworks);
595
+ }
596
+ /**
597
+ * Detect package manager
598
+ */
599
+ function detectPackageManager(dir) {
600
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')))
601
+ return 'pnpm';
602
+ if (fs.existsSync(path.join(dir, 'yarn.lock')))
603
+ return 'yarn';
604
+ if (fs.existsSync(path.join(dir, 'package-lock.json')))
605
+ return 'npm';
606
+ if (fs.existsSync(path.join(dir, 'Cargo.toml')))
607
+ return 'cargo';
608
+ if (fs.existsSync(path.join(dir, 'go.mod')))
609
+ return 'go';
610
+ if (fs.existsSync(path.join(dir, 'requirements.txt')) || fs.existsSync(path.join(dir, 'pyproject.toml')))
611
+ return 'pip';
612
+ if (fs.existsSync(path.join(dir, 'pom.xml')))
613
+ return 'maven';
614
+ if (fs.existsSync(path.join(dir, 'build.gradle')) || fs.existsSync(path.join(dir, 'build.gradle.kts')))
615
+ return 'gradle';
616
+ return undefined;
617
+ }
618
+ // ============================================================================
619
+ // Incremental Update
620
+ // ============================================================================
621
+ /**
622
+ * Update index with changed files only
623
+ */
624
+ export async function updateStructuralIndex(directory, options = {}) {
625
+ const absDir = path.resolve(directory);
626
+ const cached = loadCachedIndex(absDir);
627
+ if (!cached) {
628
+ const index = await buildStructuralIndex(directory, options);
629
+ return { index, changedFiles: Object.keys(index.files) };
630
+ }
631
+ const changedFiles = [];
632
+ const { maxFileSize = DEFAULT_MAX_FILE_SIZE, chunkSize = DEFAULT_CHUNK_SIZE } = options;
633
+ // Check each cached file for changes
634
+ for (const [relativePath, entry] of Object.entries(cached.files)) {
635
+ const fullPath = path.join(absDir, relativePath);
636
+ if (!fs.existsSync(fullPath)) {
637
+ // File deleted
638
+ delete cached.files[relativePath];
639
+ changedFiles.push(relativePath);
640
+ }
641
+ else {
642
+ const stats = fs.statSync(fullPath);
643
+ if (stats.mtimeMs > entry.mtime) {
644
+ // File modified
645
+ const content = fs.readFileSync(fullPath, 'utf-8');
646
+ const ext = path.extname(relativePath);
647
+ const isLarge = needsChunking(stats.size, maxFileSize);
648
+ cached.files[relativePath] = {
649
+ ...entry,
650
+ hash: hashContent(content),
651
+ size: stats.size,
652
+ mtime: stats.mtimeMs,
653
+ imports: extractImports(content, ext),
654
+ exports: extractExports(content, ext),
655
+ chunked: isLarge,
656
+ chunkCount: isLarge ? chunkFile(content, chunkSize).length : undefined,
657
+ };
658
+ changedFiles.push(relativePath);
659
+ }
660
+ }
661
+ }
662
+ // Rebuild dependency graph if files changed
663
+ if (changedFiles.length > 0) {
664
+ cached.dependencies = buildDependencyGraph(cached.files);
665
+ cached.clusters = buildClusters(cached.files, cached.dependencies);
666
+ cached.updatedAt = Date.now();
667
+ cached.metadata.fileCount = Object.keys(cached.files).length;
668
+ saveIndexToCache(cached);
669
+ }
670
+ return { index: cached, changedFiles };
671
+ }
672
+ // ============================================================================
673
+ // Query Helpers
674
+ // ============================================================================
675
+ /**
676
+ * Get files that depend on a given file
677
+ */
678
+ export function getDependents(index, filePath) {
679
+ return index.dependencies
680
+ .filter(e => e.to === filePath)
681
+ .map(e => e.from);
682
+ }
683
+ /**
684
+ * Get files that a given file depends on
685
+ */
686
+ export function getDependencies(index, filePath) {
687
+ return index.dependencies
688
+ .filter(e => e.from === filePath)
689
+ .map(e => e.to);
690
+ }
691
+ /**
692
+ * Get the cluster containing a file
693
+ */
694
+ export function getFileCluster(index, filePath) {
695
+ return index.clusters.find(c => c.files.includes(filePath));
696
+ }
697
+ /**
698
+ * Get files ordered by analysis priority (entry points first)
699
+ */
700
+ export function getAnalysisPriority(index) {
701
+ const priority = [];
702
+ const added = new Set();
703
+ // Add cluster entry points first
704
+ for (const cluster of index.clusters) {
705
+ for (const entry of cluster.entryPoints) {
706
+ if (!added.has(entry)) {
707
+ priority.push(entry);
708
+ added.add(entry);
709
+ }
710
+ }
711
+ }
712
+ // Add remaining files
713
+ for (const file of Object.keys(index.files)) {
714
+ if (!added.has(file)) {
715
+ priority.push(file);
716
+ added.add(file);
717
+ }
718
+ }
719
+ return priority;
720
+ }
721
+ //# sourceMappingURL=structural-index.js.map