|
| 1 | +/** |
| 2 | + * Map Command |
| 3 | + * Show codebase structure with component counts and change frequency |
| 4 | + */ |
| 5 | + |
| 6 | +import * as path from 'node:path'; |
| 7 | +import { |
| 8 | + ensureStorageDirectory, |
| 9 | + formatCodebaseMap, |
| 10 | + generateCodebaseMap, |
| 11 | + getStorageFilePaths, |
| 12 | + getStoragePath, |
| 13 | + LocalGitExtractor, |
| 14 | + type MapOptions, |
| 15 | + RepositoryIndexer, |
| 16 | +} from '@lytics/dev-agent-core'; |
| 17 | +import { createLogger } from '@lytics/kero'; |
| 18 | +import { Command } from 'commander'; |
| 19 | +import ora from 'ora'; |
| 20 | +import { loadConfig } from '../utils/config.js'; |
| 21 | +import { logger } from '../utils/logger.js'; |
| 22 | +import { output } from '../utils/output.js'; |
| 23 | + |
| 24 | +export const mapCommand = new Command('map') |
| 25 | + .description('Show codebase structure with component counts') |
| 26 | + .option('-d, --depth <number>', 'Directory depth to show (1-5)', '2') |
| 27 | + .option('-f, --focus <path>', 'Focus on a specific directory path') |
| 28 | + .option('--no-exports', 'Hide exported symbols') |
| 29 | + .option('--change-frequency', 'Include git change frequency (hotspots)', false) |
| 30 | + .option('--token-budget <number>', 'Maximum tokens for output', '2000') |
| 31 | + .option('--verbose', 'Enable debug logging', false) |
| 32 | + .addHelpText( |
| 33 | + 'after', |
| 34 | + ` |
| 35 | +Examples: |
| 36 | + $ dev map Show structure at depth 2 |
| 37 | + $ dev map --depth 3 Show deeper nesting |
| 38 | + $ dev map --focus packages/core Focus on specific directory |
| 39 | + $ dev map --change-frequency Show git activity hotspots |
| 40 | +
|
| 41 | +What You'll See: |
| 42 | + 📊 Directory structure with component counts |
| 43 | + 📦 Classes, functions, interfaces per directory |
| 44 | + 🔥 Hot files (with --change-frequency) |
| 45 | + 📤 Key exports per directory |
| 46 | +
|
| 47 | +Use Case: |
| 48 | + - Understanding codebase organization |
| 49 | + - Finding where code lives |
| 50 | + - Identifying hotspots and frequently changed areas |
| 51 | + - Better than 'ls' or 'tree' for code exploration |
| 52 | +` |
| 53 | + ) |
| 54 | + .action(async (options) => { |
| 55 | + const startTime = Date.now(); |
| 56 | + |
| 57 | + // Create logger with debug enabled if --verbose |
| 58 | + const mapLogger = createLogger({ |
| 59 | + level: options.verbose ? 'debug' : 'info', |
| 60 | + format: 'pretty', |
| 61 | + }); |
| 62 | + |
| 63 | + const spinner = ora('Loading configuration...').start(); |
| 64 | + |
| 65 | + try { |
| 66 | + const config = await loadConfig(); |
| 67 | + if (!config) { |
| 68 | + spinner.fail('No config found'); |
| 69 | + logger.error('Run "dev init" first to initialize dev-agent'); |
| 70 | + process.exit(1); |
| 71 | + } |
| 72 | + |
| 73 | + const repositoryPath = config.repository?.path || config.repositoryPath || process.cwd(); |
| 74 | + const resolvedRepoPath = path.resolve(repositoryPath); |
| 75 | + |
| 76 | + spinner.text = 'Initializing indexer...'; |
| 77 | + const t1 = Date.now(); |
| 78 | + mapLogger.info({ repositoryPath: resolvedRepoPath }, 'Loading repository configuration'); |
| 79 | + |
| 80 | + const storagePath = await getStoragePath(resolvedRepoPath); |
| 81 | + await ensureStorageDirectory(storagePath); |
| 82 | + const filePaths = getStorageFilePaths(storagePath); |
| 83 | + mapLogger.debug({ storagePath, filePaths }, 'Storage paths resolved'); |
| 84 | + |
| 85 | + const indexer = new RepositoryIndexer({ |
| 86 | + repositoryPath: resolvedRepoPath, |
| 87 | + vectorStorePath: filePaths.vectors, |
| 88 | + statePath: filePaths.indexerState, |
| 89 | + }); |
| 90 | + |
| 91 | + // Skip embedder initialization for read-only map generation (10-20x faster) |
| 92 | + mapLogger.info('Initializing indexer (skipping embedder for fast read-only access)'); |
| 93 | + await indexer.initialize({ skipEmbedder: true }); |
| 94 | + const t2 = Date.now(); |
| 95 | + mapLogger.info({ duration_ms: t2 - t1 }, 'Indexer initialized'); |
| 96 | + spinner.text = `Indexer initialized (${t2 - t1}ms). Generating map...`; |
| 97 | + |
| 98 | + // Check if repository is indexed (use fast basic stats - skips git enrichment) |
| 99 | + mapLogger.debug('Checking if repository is indexed'); |
| 100 | + const stats = await indexer.getBasicStats(); |
| 101 | + if (!stats || stats.filesScanned === 0) { |
| 102 | + spinner.fail('Repository not indexed'); |
| 103 | + logger.error('Run "dev index" first to index your repository'); |
| 104 | + await indexer.close(); |
| 105 | + process.exit(1); |
| 106 | + } |
| 107 | + |
| 108 | + mapLogger.info( |
| 109 | + { |
| 110 | + filesScanned: stats.filesScanned, |
| 111 | + documentsIndexed: stats.documentsIndexed, |
| 112 | + }, |
| 113 | + 'Repository index loaded' |
| 114 | + ); |
| 115 | + |
| 116 | + spinner.text = 'Generating codebase map...'; |
| 117 | + |
| 118 | + // Parse options |
| 119 | + mapLogger.debug( |
| 120 | + { rawDepth: options.depth, rawTokenBudget: options.tokenBudget }, |
| 121 | + 'Parsing options' |
| 122 | + ); |
| 123 | + const depth = Number.parseInt(options.depth, 10); |
| 124 | + if (Number.isNaN(depth) || depth < 1 || depth > 5) { |
| 125 | + spinner.fail('Invalid depth'); |
| 126 | + logger.error('Depth must be between 1 and 5'); |
| 127 | + await indexer.close(); |
| 128 | + process.exit(1); |
| 129 | + } |
| 130 | + |
| 131 | + const tokenBudget = Number.parseInt(options.tokenBudget, 10); |
| 132 | + if (Number.isNaN(tokenBudget) || tokenBudget < 500) { |
| 133 | + spinner.fail('Invalid token budget'); |
| 134 | + logger.error('Token budget must be at least 500'); |
| 135 | + await indexer.close(); |
| 136 | + process.exit(1); |
| 137 | + } |
| 138 | + |
| 139 | + // Create git extractor for change frequency if requested |
| 140 | + const gitExtractor = options.changeFrequency |
| 141 | + ? new LocalGitExtractor(resolvedRepoPath) |
| 142 | + : undefined; |
| 143 | + |
| 144 | + if (options.changeFrequency) { |
| 145 | + mapLogger.info('Git change frequency analysis enabled'); |
| 146 | + } |
| 147 | + |
| 148 | + // Generate map |
| 149 | + const mapOptions: MapOptions = { |
| 150 | + depth, |
| 151 | + focus: options.focus, |
| 152 | + includeExports: options.exports, |
| 153 | + tokenBudget, |
| 154 | + includeChangeFrequency: options.changeFrequency, |
| 155 | + }; |
| 156 | + |
| 157 | + mapLogger.info( |
| 158 | + { |
| 159 | + depth, |
| 160 | + focus: options.focus || '(all)', |
| 161 | + includeExports: options.exports, |
| 162 | + tokenBudget, |
| 163 | + includeChangeFrequency: options.changeFrequency, |
| 164 | + }, |
| 165 | + 'Starting map generation' |
| 166 | + ); |
| 167 | + |
| 168 | + const t3 = Date.now(); |
| 169 | + const map = await generateCodebaseMap( |
| 170 | + { |
| 171 | + indexer, |
| 172 | + gitExtractor, |
| 173 | + logger: mapLogger, |
| 174 | + }, |
| 175 | + mapOptions |
| 176 | + ); |
| 177 | + const t4 = Date.now(); |
| 178 | + |
| 179 | + mapLogger.success( |
| 180 | + { |
| 181 | + totalDuration_ms: t4 - startTime, |
| 182 | + initDuration_ms: t2 - t1, |
| 183 | + mapDuration_ms: t4 - t3, |
| 184 | + totalComponents: map.totalComponents, |
| 185 | + totalDirectories: map.totalDirectories, |
| 186 | + }, |
| 187 | + 'Map generation complete' |
| 188 | + ); |
| 189 | + |
| 190 | + spinner.succeed( |
| 191 | + `Map generated in ${t4 - startTime}ms (init: ${t2 - t1}ms, map: ${t4 - t3}ms)` |
| 192 | + ); |
| 193 | + |
| 194 | + // Format and display |
| 195 | + mapLogger.debug('Formatting map output'); |
| 196 | + const t5 = Date.now(); |
| 197 | + const formatted = formatCodebaseMap(map, { |
| 198 | + includeExports: options.exports, |
| 199 | + includeChangeFrequency: options.changeFrequency, |
| 200 | + }); |
| 201 | + const t6 = Date.now(); |
| 202 | + mapLogger.debug({ duration_ms: t6 - t5, outputLength: formatted.length }, 'Map formatted'); |
| 203 | + |
| 204 | + output.log(''); |
| 205 | + output.log(formatted); |
| 206 | + output.log(''); |
| 207 | + |
| 208 | + // Show summary |
| 209 | + output.log( |
| 210 | + `📊 Total: ${map.totalComponents.toLocaleString()} components across ${map.totalDirectories.toLocaleString()} directories` |
| 211 | + ); |
| 212 | + if (map.hotPaths.length > 0) { |
| 213 | + output.log(`🔥 ${map.hotPaths.length} hot paths identified`); |
| 214 | + } |
| 215 | + output.log(''); |
| 216 | + |
| 217 | + mapLogger.info('Closing indexer'); |
| 218 | + await indexer.close(); |
| 219 | + mapLogger.debug('Indexer closed'); |
| 220 | + } catch (error) { |
| 221 | + spinner.fail('Failed to generate map'); |
| 222 | + logger.error(`Error: ${error instanceof Error ? error.message : String(error)}`); |
| 223 | + process.exit(1); |
| 224 | + } |
| 225 | + }); |
0 commit comments