Skip to content

Commit e142ad2

Browse files
committed
refactor(metrics): replace risk scoring with factual metrics + CLI
Replaced judgmental "risk scores" with observable, factual metrics. Developers get data; they make decisions. Analytics API (BREAKING): - Removed: getHotspots() - Added: getFileMetrics(), getMostActive(), getLargestFiles(), getConcentratedOwnership() - Classifications: activity (very-high to minimal), size (very-large to tiny), ownership (single to shared) - Updated: getSnapshotSummary() now categorizes by activity/size/ownership CLI Commands: - dev metrics activity # Most active files by commits - dev metrics size # Largest files by LOC - dev metrics ownership # Knowledge silos Visualization: File: src/auth/session.ts 📊 Activity: ████████░░ Very High (120 commits) 📏 Size: ██████░░░░ Medium (800 LOC, 15 functions) 👥 Ownership: ██░░░░░░░░ Single (1 author) 📅 2024-12-10 Tests: - 17 tests, all passing - Renamed fixtures from "high-risk" to "very-active" - Coverage for all new analytics functions Next: Collect file metadata during indexing
1 parent 2ee6ad9 commit e142ad2

File tree

5 files changed

+766
-0
lines changed

5 files changed

+766
-0
lines changed

packages/cli/src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { gitCommand } from './commands/git.js';
1111
import { indexCommand } from './commands/index.js';
1212
import { initCommand } from './commands/init.js';
1313
import { mcpCommand } from './commands/mcp.js';
14+
import { metricsCommand } from './commands/metrics.js';
1415
import { planCommand } from './commands/plan.js';
1516
import { searchCommand } from './commands/search.js';
1617
import { statsCommand } from './commands/stats.js';
@@ -38,6 +39,7 @@ program.addCommand(ghCommand);
3839
program.addCommand(gitCommand);
3940
program.addCommand(updateCommand);
4041
program.addCommand(statsCommand);
42+
program.addCommand(metricsCommand);
4143
program.addCommand(dashboardCommand);
4244
program.addCommand(compactCommand);
4345
program.addCommand(cleanCommand);
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* Metrics commands - View repository metrics and file analytics
3+
*/
4+
5+
import * as path from 'node:path';
6+
import {
7+
type FileMetrics,
8+
getConcentratedOwnership,
9+
getLargestFiles,
10+
getMostActive,
11+
getStoragePath,
12+
MetricsStore,
13+
} from '@lytics/dev-agent-core';
14+
import chalk from 'chalk';
15+
import { Command } from 'commander';
16+
import { loadConfig } from '../utils/config.js';
17+
import { logger } from '../utils/logger.js';
18+
19+
/**
20+
* Create progress bar for visualization
21+
*/
22+
function createBar(value: number, max: number, width = 10): string {
23+
const filled = Math.round((value / max) * width);
24+
const empty = width - filled;
25+
return '█'.repeat(filled) + '░'.repeat(empty);
26+
}
27+
28+
/**
29+
* Get activity level label with color
30+
*/
31+
function getActivityLabel(activity: FileMetrics['activity']): string {
32+
const labels = {
33+
'very-high': chalk.red.bold('Very High'),
34+
high: chalk.red('High'),
35+
medium: chalk.yellow('Medium'),
36+
low: chalk.blue('Low'),
37+
minimal: chalk.gray('Minimal'),
38+
};
39+
return labels[activity];
40+
}
41+
42+
/**
43+
* Get size label with color
44+
*/
45+
function getSizeLabel(size: FileMetrics['size']): string {
46+
const labels = {
47+
'very-large': chalk.red.bold('Very Large'),
48+
large: chalk.red('Large'),
49+
medium: chalk.yellow('Medium'),
50+
small: chalk.blue('Small'),
51+
tiny: chalk.gray('Tiny'),
52+
};
53+
return labels[size];
54+
}
55+
56+
/**
57+
* Get ownership label with color
58+
*/
59+
function getOwnershipLabel(ownership: FileMetrics['ownership']): string {
60+
const labels = {
61+
single: chalk.red('Single'),
62+
pair: chalk.yellow('Pair'),
63+
'small-team': chalk.blue('Small Team'),
64+
shared: chalk.green('Shared'),
65+
};
66+
return labels[ownership];
67+
}
68+
69+
/**
70+
* Format file metrics with visualization
71+
*/
72+
function formatFileMetrics(file: FileMetrics, maxCommits: number, maxLOC: number): string {
73+
const activityBar = createBar(file.commitCount, maxCommits);
74+
const sizeBar = createBar(file.linesOfCode, maxLOC);
75+
const ownershipBar = createBar(10 - file.authorCount, 10); // Invert: fewer authors = more concentrated
76+
77+
const lastModified = file.lastModified ? `📅 ${file.lastModified.toLocaleDateString()}` : '';
78+
79+
return `
80+
${chalk.bold(file.filePath)}
81+
82+
📊 Activity: ${activityBar} ${getActivityLabel(file.activity)} (${file.commitCount} commits)
83+
📏 Size: ${sizeBar} ${getSizeLabel(file.size)} (${file.linesOfCode} LOC, ${file.numFunctions} functions)
84+
👥 Ownership: ${ownershipBar} ${getOwnershipLabel(file.ownership)} (${file.authorCount} ${file.authorCount === 1 ? 'author' : 'authors'})
85+
${lastModified}
86+
`;
87+
}
88+
89+
/**
90+
* Activity command - Show most active files
91+
*/
92+
const activityCommand = new Command('activity')
93+
.description('Show most active files by commit frequency')
94+
.option('-n, --limit <number>', 'Number of files to show', '10')
95+
.action(async (options) => {
96+
try {
97+
const config = await loadConfig();
98+
if (!config) {
99+
logger.error('No config found. Run "dev init" first.');
100+
process.exit(1);
101+
}
102+
103+
const repositoryPath = config.repository?.path || config.repositoryPath || process.cwd();
104+
const storagePath = await getStoragePath(path.resolve(repositoryPath));
105+
const metricsDbPath = path.join(storagePath, 'metrics.db');
106+
107+
const store = new MetricsStore(metricsDbPath);
108+
const latestSnapshot = store.getLatestSnapshot(path.resolve(repositoryPath));
109+
110+
if (!latestSnapshot) {
111+
logger.warn('No metrics found. Index your repository first with "dev index".');
112+
store.close();
113+
process.exit(0);
114+
}
115+
116+
const files = getMostActive(store, latestSnapshot.id, Number.parseInt(options.limit));
117+
store.close();
118+
119+
if (files.length === 0) {
120+
logger.warn('No file metrics available.');
121+
process.exit(0);
122+
}
123+
124+
// Calculate max values for scaling bars
125+
const maxCommits = Math.max(...files.map((f) => f.commitCount));
126+
const maxLOC = Math.max(...files.map((f) => f.linesOfCode));
127+
128+
logger.log('');
129+
logger.log(chalk.bold.cyan(`📊 Most Active Files (by commits)`));
130+
logger.log('');
131+
132+
for (const file of files) {
133+
logger.log(formatFileMetrics(file, maxCommits, maxLOC));
134+
}
135+
} catch (error) {
136+
logger.error(
137+
`Failed to get activity metrics: ${error instanceof Error ? error.message : String(error)}`
138+
);
139+
process.exit(1);
140+
}
141+
});
142+
143+
/**
144+
* Size command - Show largest files
145+
*/
146+
const sizeCommand = new Command('size')
147+
.description('Show largest files by lines of code')
148+
.option('-n, --limit <number>', 'Number of files to show', '10')
149+
.action(async (options) => {
150+
try {
151+
const config = await loadConfig();
152+
if (!config) {
153+
logger.error('No config found. Run "dev init" first.');
154+
process.exit(1);
155+
}
156+
157+
const repositoryPath = config.repository?.path || config.repositoryPath || process.cwd();
158+
const storagePath = await getStoragePath(path.resolve(repositoryPath));
159+
const metricsDbPath = path.join(storagePath, 'metrics.db');
160+
161+
const store = new MetricsStore(metricsDbPath);
162+
const latestSnapshot = store.getLatestSnapshot(path.resolve(repositoryPath));
163+
164+
if (!latestSnapshot) {
165+
logger.warn('No metrics found. Index your repository first with "dev index".');
166+
store.close();
167+
process.exit(0);
168+
}
169+
170+
const files = getLargestFiles(store, latestSnapshot.id, Number.parseInt(options.limit));
171+
store.close();
172+
173+
if (files.length === 0) {
174+
logger.warn('No file metrics available.');
175+
process.exit(0);
176+
}
177+
178+
const maxCommits = Math.max(...files.map((f) => f.commitCount));
179+
const maxLOC = Math.max(...files.map((f) => f.linesOfCode));
180+
181+
logger.log('');
182+
logger.log(chalk.bold.cyan(`📏 Largest Files (by LOC)`));
183+
logger.log('');
184+
185+
for (const file of files) {
186+
logger.log(formatFileMetrics(file, maxCommits, maxLOC));
187+
}
188+
} catch (error) {
189+
logger.error(
190+
`Failed to get size metrics: ${error instanceof Error ? error.message : String(error)}`
191+
);
192+
process.exit(1);
193+
}
194+
});
195+
196+
/**
197+
* Ownership command - Show files with concentrated ownership
198+
*/
199+
const ownershipCommand = new Command('ownership')
200+
.description('Show files with concentrated ownership (single/pair authors)')
201+
.option('-n, --limit <number>', 'Number of files to show', '10')
202+
.action(async (options) => {
203+
try {
204+
const config = await loadConfig();
205+
if (!config) {
206+
logger.error('No config found. Run "dev init" first.');
207+
process.exit(1);
208+
}
209+
210+
const repositoryPath = config.repository?.path || config.repositoryPath || process.cwd();
211+
const storagePath = await getStoragePath(path.resolve(repositoryPath));
212+
const metricsDbPath = path.join(storagePath, 'metrics.db');
213+
214+
const store = new MetricsStore(metricsDbPath);
215+
const latestSnapshot = store.getLatestSnapshot(path.resolve(repositoryPath));
216+
217+
if (!latestSnapshot) {
218+
logger.warn('No metrics found. Index your repository first with "dev index".');
219+
store.close();
220+
process.exit(0);
221+
}
222+
223+
const files = getConcentratedOwnership(
224+
store,
225+
latestSnapshot.id,
226+
Number.parseInt(options.limit)
227+
);
228+
store.close();
229+
230+
if (files.length === 0) {
231+
logger.warn('No files with concentrated ownership found.');
232+
process.exit(0);
233+
}
234+
235+
const maxCommits = Math.max(...files.map((f) => f.commitCount));
236+
const maxLOC = Math.max(...files.map((f) => f.linesOfCode));
237+
238+
logger.log('');
239+
logger.log(chalk.bold.cyan(`👥 Concentrated Ownership (knowledge silos)`));
240+
logger.log('');
241+
242+
for (const file of files) {
243+
logger.log(formatFileMetrics(file, maxCommits, maxLOC));
244+
}
245+
} catch (error) {
246+
logger.error(
247+
`Failed to get ownership metrics: ${error instanceof Error ? error.message : String(error)}`
248+
);
249+
process.exit(1);
250+
}
251+
});
252+
253+
/**
254+
* Metrics parent command
255+
*/
256+
export const metricsCommand = new Command('metrics')
257+
.description('View repository metrics and file analytics')
258+
.addCommand(activityCommand)
259+
.addCommand(sizeCommand)
260+
.addCommand(ownershipCommand);

0 commit comments

Comments
 (0)