Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { gitCommand } from './commands/git.js';
import { indexCommand } from './commands/index.js';
import { initCommand } from './commands/init.js';
import { mcpCommand } from './commands/mcp.js';
import { metricsCommand } from './commands/metrics.js';
import { planCommand } from './commands/plan.js';
import { searchCommand } from './commands/search.js';
import { statsCommand } from './commands/stats.js';
Expand Down Expand Up @@ -38,6 +39,7 @@ program.addCommand(ghCommand);
program.addCommand(gitCommand);
program.addCommand(updateCommand);
program.addCommand(statsCommand);
program.addCommand(metricsCommand);
program.addCommand(dashboardCommand);
program.addCommand(compactCommand);
program.addCommand(cleanCommand);
Expand Down
51 changes: 43 additions & 8 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { execSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import {
AsyncEventBus,
ensureStorageDirectory,
GitIndexer,
getStorageFilePaths,
getStoragePath,
type IndexUpdatedEvent,
LocalGitExtractor,
MetricsStore,
RepositoryIndexer,
updateIndexedStats,
VectorStorage,
Expand Down Expand Up @@ -116,16 +119,47 @@ export const indexCommand = new Command('index')
const filePaths = getStorageFilePaths(storagePath);

spinner.text = 'Initializing indexer...';
const indexer = new RepositoryIndexer({
repositoryPath: resolvedRepoPath,
vectorStorePath: filePaths.vectors,
statePath: filePaths.indexerState,
excludePatterns: config.repository?.excludePatterns || config.excludePatterns,
languages: config.repository?.languages || config.languages,
embeddingModel: config.embeddingModel,
embeddingDimension: config.dimension,

// Create event bus for metrics (no logger in CLI to keep it simple)
const eventBus = new AsyncEventBus();

// Initialize metrics store (no logger in CLI to avoid noise)
const metricsDbPath = join(storagePath, 'metrics.db');
const metricsStore = new MetricsStore(metricsDbPath);

// Subscribe to index.updated events for automatic metrics persistence
eventBus.on<IndexUpdatedEvent>('index.updated', async (event) => {
try {
const snapshotId = metricsStore.recordSnapshot(
event.stats,
event.isIncremental ? 'update' : 'index'
);

// Store code metadata if available
if (event.codeMetadata && event.codeMetadata.length > 0) {
metricsStore.appendCodeMetadata(snapshotId, event.codeMetadata);
}
} catch (error) {
// Log error but don't fail indexing - metrics are non-critical
logger.error(
`Failed to record metrics: ${error instanceof Error ? error.message : String(error)}`
);
}
});

const indexer = new RepositoryIndexer(
{
repositoryPath: resolvedRepoPath,
vectorStorePath: filePaths.vectors,
statePath: filePaths.indexerState,
excludePatterns: config.repository?.excludePatterns || config.excludePatterns,
languages: config.repository?.languages || config.languages,
embeddingModel: config.embeddingModel,
embeddingDimension: config.dimension,
},
eventBus
);

await indexer.initialize();

spinner.text = 'Scanning repository...';
Expand Down Expand Up @@ -165,6 +199,7 @@ export const indexCommand = new Command('index')
});

await indexer.close();
metricsStore.close();

const codeDuration = (Date.now() - startTime) / 1000;

Expand Down
260 changes: 260 additions & 0 deletions packages/cli/src/commands/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/**
* Metrics commands - View repository metrics and file analytics
*/

import * as path from 'node:path';
import {
type FileMetrics,
getConcentratedOwnership,
getLargestFiles,
getMostActive,
getStoragePath,
MetricsStore,
} from '@lytics/dev-agent-core';
import chalk from 'chalk';
import { Command } from 'commander';
import { loadConfig } from '../utils/config.js';
import { logger } from '../utils/logger.js';

/**
* Create progress bar for visualization
*/
function createBar(value: number, max: number, width = 10): string {
const filled = Math.round((value / max) * width);
const empty = width - filled;
return 'β–ˆ'.repeat(filled) + 'β–‘'.repeat(empty);
}

/**
* Get activity level label with color
*/
function getActivityLabel(activity: FileMetrics['activity']): string {
const labels = {
'very-high': chalk.red.bold('Very High'),
high: chalk.red('High'),
medium: chalk.yellow('Medium'),
low: chalk.blue('Low'),
minimal: chalk.gray('Minimal'),
};
return labels[activity];
}

/**
* Get size label with color
*/
function getSizeLabel(size: FileMetrics['size']): string {
const labels = {
'very-large': chalk.red.bold('Very Large'),
large: chalk.red('Large'),
medium: chalk.yellow('Medium'),
small: chalk.blue('Small'),
tiny: chalk.gray('Tiny'),
};
return labels[size];
}

/**
* Get ownership label with color
*/
function getOwnershipLabel(ownership: FileMetrics['ownership']): string {
const labels = {
single: chalk.red('Single'),
pair: chalk.yellow('Pair'),
'small-team': chalk.blue('Small Team'),
shared: chalk.green('Shared'),
};
return labels[ownership];
}

/**
* Format file metrics with visualization
*/
function formatFileMetrics(file: FileMetrics, maxCommits: number, maxLOC: number): string {
const activityBar = createBar(file.commitCount, maxCommits);
const sizeBar = createBar(file.linesOfCode, maxLOC);
const ownershipBar = createBar(10 - file.authorCount, 10); // Invert: fewer authors = more concentrated

const lastModified = file.lastModified ? `πŸ“… ${file.lastModified.toLocaleDateString()}` : '';

return `
${chalk.bold(file.filePath)}

πŸ“Š Activity: ${activityBar} ${getActivityLabel(file.activity)} (${file.commitCount} commits)
πŸ“ Size: ${sizeBar} ${getSizeLabel(file.size)} (${file.linesOfCode} LOC, ${file.numFunctions} functions)
πŸ‘₯ Ownership: ${ownershipBar} ${getOwnershipLabel(file.ownership)} (${file.authorCount} ${file.authorCount === 1 ? 'author' : 'authors'})
${lastModified}
`;
}

/**
* Activity command - Show most active files
*/
const activityCommand = new Command('activity')
.description('Show most active files by commit frequency')
.option('-n, --limit <number>', 'Number of files to show', '10')
.action(async (options) => {
try {
const config = await loadConfig();
if (!config) {
logger.error('No config found. Run "dev init" first.');
process.exit(1);
}

const repositoryPath = config.repository?.path || config.repositoryPath || process.cwd();
const storagePath = await getStoragePath(path.resolve(repositoryPath));
const metricsDbPath = path.join(storagePath, 'metrics.db');

const store = new MetricsStore(metricsDbPath);
const latestSnapshot = store.getLatestSnapshot(path.resolve(repositoryPath));

if (!latestSnapshot) {
logger.warn('No metrics found. Index your repository first with "dev index".');
store.close();
process.exit(0);
}

const files = getMostActive(store, latestSnapshot.id, Number.parseInt(options.limit, 10));
store.close();

if (files.length === 0) {
logger.warn('No file metrics available.');
process.exit(0);
}

// Calculate max values for scaling bars
const maxCommits = Math.max(...files.map((f) => f.commitCount));
const maxLOC = Math.max(...files.map((f) => f.linesOfCode));

logger.log('');
logger.log(chalk.bold.cyan(`πŸ“Š Most Active Files (by commits)`));
logger.log('');

for (const file of files) {
logger.log(formatFileMetrics(file, maxCommits, maxLOC));
}
} catch (error) {
logger.error(
`Failed to get activity metrics: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});

/**
* Size command - Show largest files
*/
const sizeCommand = new Command('size')
.description('Show largest files by lines of code')
.option('-n, --limit <number>', 'Number of files to show', '10')
.action(async (options) => {
try {
const config = await loadConfig();
if (!config) {
logger.error('No config found. Run "dev init" first.');
process.exit(1);
}

const repositoryPath = config.repository?.path || config.repositoryPath || process.cwd();
const storagePath = await getStoragePath(path.resolve(repositoryPath));
const metricsDbPath = path.join(storagePath, 'metrics.db');

const store = new MetricsStore(metricsDbPath);
const latestSnapshot = store.getLatestSnapshot(path.resolve(repositoryPath));

if (!latestSnapshot) {
logger.warn('No metrics found. Index your repository first with "dev index".');
store.close();
process.exit(0);
}

const files = getLargestFiles(store, latestSnapshot.id, Number.parseInt(options.limit, 10));
store.close();

if (files.length === 0) {
logger.warn('No file metrics available.');
process.exit(0);
}

const maxCommits = Math.max(...files.map((f) => f.commitCount));
const maxLOC = Math.max(...files.map((f) => f.linesOfCode));

logger.log('');
logger.log(chalk.bold.cyan(`πŸ“ Largest Files (by LOC)`));
logger.log('');

for (const file of files) {
logger.log(formatFileMetrics(file, maxCommits, maxLOC));
}
} catch (error) {
logger.error(
`Failed to get size metrics: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});

/**
* Ownership command - Show files with concentrated ownership
*/
const ownershipCommand = new Command('ownership')
.description('Show files with concentrated ownership (single/pair authors)')
.option('-n, --limit <number>', 'Number of files to show', '10')
.action(async (options) => {
try {
const config = await loadConfig();
if (!config) {
logger.error('No config found. Run "dev init" first.');
process.exit(1);
}

const repositoryPath = config.repository?.path || config.repositoryPath || process.cwd();
const storagePath = await getStoragePath(path.resolve(repositoryPath));
const metricsDbPath = path.join(storagePath, 'metrics.db');

const store = new MetricsStore(metricsDbPath);
const latestSnapshot = store.getLatestSnapshot(path.resolve(repositoryPath));

if (!latestSnapshot) {
logger.warn('No metrics found. Index your repository first with "dev index".');
store.close();
process.exit(0);
}

const files = getConcentratedOwnership(
store,
latestSnapshot.id,
Number.parseInt(options.limit, 10)
);
store.close();

if (files.length === 0) {
logger.warn('No files with concentrated ownership found.');
process.exit(0);
}

const maxCommits = Math.max(...files.map((f) => f.commitCount));
const maxLOC = Math.max(...files.map((f) => f.linesOfCode));

logger.log('');
logger.log(chalk.bold.cyan(`πŸ‘₯ Concentrated Ownership (knowledge silos)`));
logger.log('');

for (const file of files) {
logger.log(formatFileMetrics(file, maxCommits, maxLOC));
}
} catch (error) {
logger.error(
`Failed to get ownership metrics: ${error instanceof Error ? error.message : String(error)}`
);
process.exit(1);
}
});

/**
* Metrics parent command
*/
export const metricsCommand = new Command('metrics')
.description('View repository metrics and file analytics')
.addCommand(activityCommand)
.addCommand(sizeCommand)
.addCommand(ownershipCommand);
Loading