Skip to content

Commit a22632d

Browse files
committed
feat(cli): add dev map command with 224x performance optimization
Add new 'dev map' command to visualize codebase structure with component counts, exports, and hot paths. Includes major performance optimizations for read-only operations. Features: - Directory structure with component counts at configurable depth - Hot paths showing most referenced files - Exported symbols per directory - Optional git change frequency analysis - Focus on specific directories - Verbose logging with --verbose flag Performance Optimizations: - Add getAll() method to skip semantic search for structural queries - Add skipEmbedder option to initialize() for read-only operations - Add getBasicStats() to avoid expensive git enrichment - Result: 224x speedup (103s → 0.46s) Bug Fixes: - Fix component count propagation causing exponential overflow - Add getAll() to test mocks Examples: $ dev map # Show full structure $ dev map --depth 3 # Deeper nesting $ dev map --focus packages/core # Focus on directory $ dev map --change-frequency # Show git hotspots $ dev map --verbose # Debug logging
1 parent 980d17d commit a22632d

File tree

8 files changed

+378
-12
lines changed

8 files changed

+378
-12
lines changed

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { gitCommand } from './commands/git.js';
1010
import { githubCommand } from './commands/github.js';
1111
import { indexCommand } from './commands/index.js';
1212
import { initCommand } from './commands/init.js';
13+
import { mapCommand } from './commands/map.js';
1314
import { mcpCommand } from './commands/mcp.js';
1415
import { metricsCommand } from './commands/metrics.js';
1516
import { planCommand } from './commands/plan.js';
@@ -37,6 +38,7 @@ program.addCommand(exploreCommand);
3738
program.addCommand(planCommand);
3839
program.addCommand(githubCommand);
3940
program.addCommand(gitCommand);
41+
program.addCommand(mapCommand);
4042
program.addCommand(updateCommand);
4143
program.addCommand(statsCommand);
4244
program.addCommand(metricsCommand);

packages/cli/src/commands/map.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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+
});

packages/core/src/indexer/index.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,10 +70,12 @@ export class RepositoryIndexer {
7070

7171
/**
7272
* Initialize the indexer (load state and initialize vector storage)
73+
* @param options Optional initialization options
74+
* @param options.skipEmbedder Skip embedder initialization (useful for read-only operations like map/stats)
7375
*/
74-
async initialize(): Promise<void> {
75-
// Initialize vector storage
76-
await this.vectorStorage.initialize();
76+
async initialize(options?: { skipEmbedder?: boolean }): Promise<void> {
77+
// Initialize vector storage (optionally skip embedder for read-only operations)
78+
await this.vectorStorage.initialize(options);
7779

7880
// Load existing state if available
7981
await this.loadState();
@@ -509,9 +511,32 @@ export class RepositoryIndexer {
509511
return this.vectorStorage.search(query, options);
510512
}
511513

514+
/**
515+
* Get all indexed documents without semantic search (fast scan)
516+
* Use this when you need all documents and don't need relevance ranking
517+
* This is 10-20x faster than search() as it skips embedding generation
518+
*/
519+
async getAll(options?: { limit?: number }): Promise<SearchResult[]> {
520+
return this.vectorStorage.getAll(options);
521+
}
522+
512523
/**
513524
* Get indexing statistics
514525
*/
526+
/**
527+
* Get basic stats without expensive git enrichment (fast)
528+
*/
529+
async getBasicStats(): Promise<{ filesScanned: number; documentsIndexed: number } | null> {
530+
if (!this.state) {
531+
return null;
532+
}
533+
534+
return {
535+
filesScanned: this.state.stats.totalFiles,
536+
documentsIndexed: this.state.stats.totalDocuments,
537+
};
538+
}
539+
515540
async getStats(): Promise<DetailedIndexStats | null> {
516541
if (!this.state) {
517542
return null;

packages/core/src/map/__tests__/map.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe('Codebase Map', () => {
9595
function createMockIndexer(results: SearchResult[] = mockSearchResults): RepositoryIndexer {
9696
return {
9797
search: vi.fn().mockResolvedValue(results),
98+
getAll: vi.fn().mockResolvedValue(results),
9899
} as unknown as RepositoryIndexer;
99100
}
100101

0 commit comments

Comments
 (0)