Chapter 19: Building a Complete Application
Learning Objectives
By the end of this chapter, you will be able to:
- Build a complete, working CLI application from scratch using Claude Code
- Apply every major technique from this book in a single project
- Use CLAUDE.md to maintain project context across sessions
- Apply Plan Mode for architecture decisions
- Practice Test-Driven Development with real tests
- Create custom slash commands for your workflow
- Generate AI-powered insights from real data
- Ship a tool you will actually use
Quick Start: See the Finished Product
Before writing a single line of code, letβs see what you are building. If you have a git repository handy, here is what the finished tool produces:
$ devinsights analyze /path/to/your/project
DevInsights CLI v1.0.0
Analyzing repository...
==============================
Repository Analysis Report
==============================
Repository: my-project
Analysis Date: 2025-01-15
Commits Analyzed: 347
Contributors: 5
--- Commit Activity ---
Most active day: Wednesday (68 commits)
Most active hour: 14:00-15:00 (42 commits)
Average commits/week: 12.4
--- Codebase Overview ---
Total files: 156
TypeScript: 89 files (12,450 lines)
CSS: 23 files (2,100 lines)
JSON: 18 files (890 lines)
Markdown: 14 files (1,200 lines)
Other: 12 files (340 lines)
--- Insights ---
* Commit frequency has increased 23% over the last month
* Wednesday afternoons are your most productive window
* The src/utils/ directory has the highest churn rate
* Consider splitting large files in src/components/
(3 files exceed 300 lines)
Full report saved to: devinsights-report.md
By the end of this chapter, you will have built this yourself. The project is a Node.js command-line tool written in TypeScript that analyzes any local git repository and generates a productivity report. No external services, no databases, no API keys required.
19.0 The Vision
What We Are Building
DevInsights CLI is a developer productivity dashboard that runs in your terminal. Point it at any git repository, and it will:
- Parse git history to extract commit data, contributor information, and timeline patterns
- Analyze the codebase to count files, lines of code, and language distribution
- Detect patterns in commit frequency, working hours, and code churn
- Generate a report with actionable insights as a markdown file
Why This Project?
This project is carefully chosen to demonstrate every major technique from this book while remaining completable in a single sitting:
| Book Technique | How We Use It |
|---|---|
| CLAUDE.md (Ch 5, 12) | Project context file guides Claude throughout |
| Plan Mode (Ch 11) | Design the analyzer architecture |
| TDD (Ch 10) | Write tests first for each module |
| Slash Commands (Ch 14) | Custom /analyze command |
| Agents (Ch 13) | Multi-step analysis pipeline |
| Hooks (Ch 15) | Pre-commit validation |
| Spec-Driven Dev (Ch 12) | Lightweight spec before coding |
Tech Stack
- Runtime: Node.js 20+
- Language: TypeScript (strict mode)
- CLI Framework: Commander.js
- Terminal Styling: chalk
- Testing: Jest with ts-jest
- Build: tsc (TypeScript compiler)
Project Structure
devinsights-cli/
βββ package.json
βββ tsconfig.json
βββ CLAUDE.md
βββ src/
β βββ index.ts # CLI entry point
β βββ types.ts # TypeScript interfaces
β βββ analyzers/
β β βββ git.ts # Git history analyzer
β β βββ codeStats.ts # Code statistics collector
β β βββ patterns.ts # Commit pattern analysis
β βββ reporter.ts # Markdown report generator
βββ tests/
β βββ git.test.ts
β βββ codeStats.test.ts
β βββ patterns.test.ts
β βββ reporter.test.ts
βββ .claude/
βββ commands/
βββ analyze.md # Custom slash command
No PostgreSQL. No Redis. No Docker. Just TypeScript, git, and the filesystem.
19.1 Phase 1: Project Setup with CLAUDE.md
Step 1: Create the Project
Open your terminal and create the project directory:
mkdir devinsights-cli
cd devinsights-cli
git initStep 2: Initialize package.json
Create the package manifest. You can do this by hand or ask Claude Code to generate it:
// package.json
{
"name": "devinsights-cli",
"version": "1.0.0",
"description": "Analyze git repositories and generate developer productivity reports",
"main": "dist/index.js",
"bin": {
"devinsights": "dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "jest",
"test:watch": "jest --watch",
"lint": "tsc --noEmit"
},
"keywords": ["git", "analytics", "cli", "developer-tools"],
"license": "MIT",
"devDependencies": {
"@types/jest": "^29.5.0",
"@types/node": "^20.11.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.0",
"ts-node": "^10.9.0",
"typescript": "^5.3.0"
},
"dependencies": {
"chalk": "^4.1.2",
"commander": "^12.0.0"
}
}Note that we use chalk v4 (not v5) because v4 supports CommonJS
require(), which avoids ESM complications when working with
Jest and ts-node.
Step 3: Create tsconfig.json
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}Step 4: Configure Jest
// jest.config.json
{
"preset": "ts-jest",
"testEnvironment": "node",
"roots": ["<rootDir>/tests"],
"testMatch": ["**/*.test.ts"],
"collectCoverageFrom": ["src/**/*.ts", "!src/index.ts"]
}Step 5: Install Dependencies
npm installStep 6: Create CLAUDE.md
This is the most important file for working with Claude Code. It tells Claude everything it needs to know about your project without you having to repeat yourself each session.
<!-- CLAUDE.md -->
# DevInsights CLI
## Project Overview
A command-line tool that analyzes local git repositories and generates
developer productivity reports. Built with TypeScript and Node.js.
## Tech Stack
- TypeScript 5.x with strict mode
- Node.js 20+ (CommonJS modules)
- Commander.js for CLI parsing
- chalk v4 for terminal colors
- Jest with ts-jest for testing
## Project Structure
- src/types.ts - All TypeScript interfaces
- src/analyzers/git.ts - Parses git log output
- src/analyzers/codeStats.ts - Counts files and lines by language
- src/analyzers/patterns.ts - Analyzes commit timing patterns
- src/reporter.ts - Generates markdown reports
- src/index.ts - CLI entry point with Commander.js
- tests/ - Jest test files mirroring src/ structure
## Code Conventions
- Use explicit return types on all exported functions
- Prefer `execSync` from child_process for git commands
- Error handling: wrap git/fs calls in try-catch, return descriptive errors
- No classes; use plain functions and interfaces
- All analyzer functions are pure: input data in, result out
- Reporter functions take an AnalysisReport and return a string
## Testing Conventions
- Test files live in tests/ directory (not co-located)
- Name pattern: tests/[module].test.ts
- Use descriptive test names: "should [expected behavior] when [condition]"
- Mock execSync for git tests; use tmp directories for codeStats tests
- Run tests: npm test
- Run single file: npx jest tests/git.test.ts
## Build and Run
- Build: npm run build
- Run: node dist/index.js analyze /path/to/repo
- Dev: npx ts-node src/index.ts analyze /path/to/repo
## Key Design Decisions
- chalk v4 (not v5) for CommonJS compatibility
- No external services; everything runs locally
- Git data via execSync('git log ...'), not via libraries
- Reports are plain markdown strings, written to disk by the CLI layerThis CLAUDE.md file ensures that every Claude Code session starts with full project context. When you open Claude Code tomorrow and say βadd a feature,β Claude already knows your tech stack, conventions, file structure, and design decisions.
Step 7: Create the Directory Structure
mkdir -p src/analyzers tests .claude/commandsCheckpoint
You now have a fully initialized TypeScript project with:
- package.json with all dependencies
- TypeScript configured in strict mode
- Jest configured for testing
- CLAUDE.md providing persistent context for Claude Code
- Directory structure ready for code
Commit this foundation:
git add -A
git commit -m "feat: initialize devinsights-cli project"19.2 Phase 2: Core Analysis Engine
This is the heart of the application. We will build three analyzer modules using Test-Driven Development: write the tests first, then implement the code to make them pass.
Designing with Plan Mode
Before writing code, use Plan Mode to think through the architecture. In Claude Code, type:
You: /plan Design the analyzer modules for DevInsights CLI. I need three
analyzers: git history, code statistics, and commit patterns. Each should
be a pure function that takes input and returns typed results. Think about
what data each analyzer needs and what it produces.
Claude will outline an approach. The key architectural insight is that each analyzer is independent: they take either a repository path or raw git data as input and return a typed result. The CLI layer orchestrates them.
Step 1: Define Types
Every TypeScript project starts with types. These interfaces define the shape of data flowing through the entire application.
// src/types.ts
export interface GitCommit {
hash: string;
author: string;
email: string;
date: Date;
message: string;
filesChanged: number;
insertions: number;
deletions: number;
}
export interface CodeStats {
totalFiles: number;
totalLines: number;
languages: LanguageBreakdown[];
largeFiles: LargeFile[];
}
export interface LanguageBreakdown {
language: string;
extension: string;
files: number;
lines: number;
percentage: number;
}
export interface LargeFile {
path: string;
lines: number;
language: string;
}
export interface CommitPatterns {
totalCommits: number;
contributors: ContributorStats[];
activityByDay: DayActivity[];
activityByHour: HourActivity[];
averageCommitsPerWeek: number;
busiestDay: string;
busiestHour: number;
}
export interface ContributorStats {
name: string;
email: string;
commits: number;
firstCommit: Date;
lastCommit: Date;
}
export interface DayActivity {
day: string;
commits: number;
}
export interface HourActivity {
hour: number;
commits: number;
}
export interface AnalysisReport {
repoName: string;
repoPath: string;
analyzedAt: Date;
gitAnalysis: GitCommit[];
codeStats: CodeStats;
commitPatterns: CommitPatterns;
insights: string[];
}Step 2: Git Analyzer - Tests First
Following TDD (Chapter 10), we write the test before the
implementation. The git analyzer parses the output of
git log into structured GitCommit objects.
// tests/git.test.ts
import { parseGitLog, getGitLog } from '../src/analyzers/git';
describe('Git Analyzer', () => {
describe('parseGitLog', () => {
it('should parse a single commit', () => {
const raw = [
'abc1234',
'Alice Smith',
'alice@example.com',
'2025-01-10T14:30:00+00:00',
'feat: add login page',
'3 files changed, 120 insertions(+), 15 deletions(-)',
].join('\n');
const commits = parseGitLog(raw);
expect(commits).toHaveLength(1);
expect(commits[0].hash).toBe('abc1234');
expect(commits[0].author).toBe('Alice Smith');
expect(commits[0].email).toBe('alice@example.com');
expect(commits[0].message).toBe('feat: add login page');
expect(commits[0].filesChanged).toBe(3);
expect(commits[0].insertions).toBe(120);
expect(commits[0].deletions).toBe(15);
});
it('should parse multiple commits separated by delimiter', () => {
const raw = [
'abc1234',
'Alice Smith',
'alice@example.com',
'2025-01-10T14:30:00+00:00',
'feat: add login page',
'1 file changed, 50 insertions(+)',
'---COMMIT_DELIMITER---',
'def5678',
'Bob Jones',
'bob@example.com',
'2025-01-11T09:15:00+00:00',
'fix: correct typo in header',
'1 file changed, 1 insertion(+), 1 deletion(-)',
].join('\n');
const commits = parseGitLog(raw);
expect(commits).toHaveLength(2);
expect(commits[0].author).toBe('Alice Smith');
expect(commits[1].author).toBe('Bob Jones');
});
it('should handle empty input', () => {
const commits = parseGitLog('');
expect(commits).toHaveLength(0);
});
it('should handle commits with no stat line', () => {
const raw = [
'abc1234',
'Alice Smith',
'alice@example.com',
'2025-01-10T14:30:00+00:00',
'Initial commit',
'',
].join('\n');
const commits = parseGitLog(raw);
expect(commits).toHaveLength(1);
expect(commits[0].filesChanged).toBe(0);
expect(commits[0].insertions).toBe(0);
expect(commits[0].deletions).toBe(0);
});
});
});Run the test to confirm it fails (the Red step):
npx jest tests/git.test.tsThe test fails because src/analyzers/git.ts does not
exist yet. Good. That is TDD working as expected.
Step 3: Git Analyzer - Implementation
Now write the code to make those tests pass:
// src/analyzers/git.ts
import { execSync } from 'child_process';
import { GitCommit } from '../types';
const COMMIT_DELIMITER = '---COMMIT_DELIMITER---';
const GIT_LOG_FORMAT = [
'%H', // full hash
'%an', // author name
'%ae', // author email
'%aI', // author date ISO
'%s', // subject
].join('%n');
export function getGitLog(repoPath: string, maxCommits: number = 1000): string {
try {
const cmd = [
'git', 'log',
`--max-count=${maxCommits}`,
`--format=${GIT_LOG_FORMAT}${COMMIT_DELIMITER}`,
'--shortstat',
].join(' ');
const output = execSync(cmd, {
cwd: repoPath,
encoding: 'utf-8',
maxBuffer: 10 * 1024 * 1024,
});
return output;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to read git log at ${repoPath}: ${message}`);
}
}
export function parseGitLog(raw: string): GitCommit[] {
if (!raw || !raw.trim()) {
return [];
}
const chunks = raw.split(COMMIT_DELIMITER).filter((c) => c.trim());
const commits: GitCommit[] = [];
for (const chunk of chunks) {
const lines = chunk.trim().split('\n');
if (lines.length < 5) continue;
const hash = lines[0].trim();
const author = lines[1].trim();
const email = lines[2].trim();
const dateStr = lines[3].trim();
const message = lines[4].trim();
// The stat line (if present) looks like:
// " 3 files changed, 120 insertions(+), 15 deletions(-)"
const statLine = lines.length > 5 ? lines.slice(5).join(' ') : '';
const stats = parseStatLine(statLine);
commits.push({
hash,
author,
email,
date: new Date(dateStr),
message,
filesChanged: stats.filesChanged,
insertions: stats.insertions,
deletions: stats.deletions,
});
}
return commits;
}
function parseStatLine(line: string): {
filesChanged: number;
insertions: number;
deletions: number;
} {
const filesMatch = line.match(/(\d+) files? changed/);
const insertionsMatch = line.match(/(\d+) insertions?\(\+\)/);
const deletionsMatch = line.match(/(\d+) deletions?\(-\)/);
return {
filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
insertions: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0,
deletions: deletionsMatch ? parseInt(deletionsMatch[1], 10) : 0,
};
}Run the tests again:
npx jest tests/git.test.tsAll four tests should pass. That is the Green step. The code is clean enough that we can skip a heavy refactor for now and move to the next module.
Step 4: Code Statistics Analyzer - Tests First
The code stats analyzer walks the file tree, counts files and lines, and groups them by language.
// tests/codeStats.test.ts
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { analyzeCodeStats } from '../src/analyzers/codeStats';
function createTempProject(files: Record<string, string>): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'devinsights-test-'));
for (const [filePath, content] of Object.entries(files)) {
const fullPath = path.join(dir, filePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content);
}
return dir;
}
function cleanupTempDir(dir: string): void {
fs.rmSync(dir, { recursive: true, force: true });
}
describe('Code Stats Analyzer', () => {
let tempDir: string;
afterEach(() => {
if (tempDir) cleanupTempDir(tempDir);
});
it('should count files and lines by language', () => {
tempDir = createTempProject({
'src/app.ts': 'const x = 1;\nconst y = 2;\nconst z = 3;\n',
'src/utils.ts': 'export function add(a: number, b: number) {\n return a + b;\n}\n',
'styles/main.css': 'body {\n margin: 0;\n}\n',
});
const stats = analyzeCodeStats(tempDir);
expect(stats.totalFiles).toBe(3);
expect(stats.totalLines).toBe(9);
const tsLang = stats.languages.find((l) => l.extension === '.ts');
expect(tsLang).toBeDefined();
expect(tsLang!.files).toBe(2);
expect(tsLang!.lines).toBe(6);
const cssLang = stats.languages.find((l) => l.extension === '.css');
expect(cssLang).toBeDefined();
expect(cssLang!.files).toBe(1);
expect(cssLang!.lines).toBe(3);
});
it('should skip node_modules and hidden directories', () => {
tempDir = createTempProject({
'src/app.ts': 'line1\nline2\n',
'node_modules/pkg/index.js': 'module.exports = {};\n',
'.git/config': '[core]\n',
});
const stats = analyzeCodeStats(tempDir);
expect(stats.totalFiles).toBe(1);
expect(stats.languages).toHaveLength(1);
expect(stats.languages[0].extension).toBe('.ts');
});
it('should identify large files', () => {
const longContent = Array.from({ length: 350 }, (_, i) => `line ${i}`).join('\n');
tempDir = createTempProject({
'src/big.ts': longContent,
'src/small.ts': 'const x = 1;\n',
});
const stats = analyzeCodeStats(tempDir);
expect(stats.largeFiles).toHaveLength(1);
expect(stats.largeFiles[0].path).toContain('big.ts');
expect(stats.largeFiles[0].lines).toBe(350);
});
it('should calculate language percentages', () => {
tempDir = createTempProject({
'a.ts': 'line1\nline2\nline3\n',
'b.js': 'line1\n',
});
const stats = analyzeCodeStats(tempDir);
const tsLang = stats.languages.find((l) => l.extension === '.ts');
expect(tsLang!.percentage).toBe(75);
const jsLang = stats.languages.find((l) => l.extension === '.js');
expect(jsLang!.percentage).toBe(25);
});
});Step 5: Code Statistics Analyzer - Implementation
// src/analyzers/codeStats.ts
import * as fs from 'fs';
import * as path from 'path';
import { CodeStats, LanguageBreakdown, LargeFile } from '../types';
const SKIP_DIRS = new Set([
'node_modules', '.git', '.next', 'dist', 'build',
'coverage', '.cache', '__pycache__', '.venv', 'vendor',
]);
const LANGUAGE_MAP: Record<string, string> = {
'.ts': 'TypeScript',
'.tsx': 'TypeScript (JSX)',
'.js': 'JavaScript',
'.jsx': 'JavaScript (JSX)',
'.py': 'Python',
'.rb': 'Ruby',
'.go': 'Go',
'.rs': 'Rust',
'.java': 'Java',
'.css': 'CSS',
'.scss': 'SCSS',
'.html': 'HTML',
'.json': 'JSON',
'.yaml': 'YAML',
'.yml': 'YAML',
'.md': 'Markdown',
'.sh': 'Shell',
'.sql': 'SQL',
'.graphql': 'GraphQL',
'.vue': 'Vue',
'.svelte': 'Svelte',
};
const LARGE_FILE_THRESHOLD = 300;
export function analyzeCodeStats(repoPath: string): CodeStats {
const fileCounts: Record<string, number> = {};
const lineCounts: Record<string, number> = {};
const largeFiles: LargeFile[] = [];
walkDirectory(repoPath, repoPath, fileCounts, lineCounts, largeFiles);
let totalFiles = 0;
let totalLines = 0;
const languages: LanguageBreakdown[] = [];
for (const ext of Object.keys(fileCounts)) {
totalFiles += fileCounts[ext];
totalLines += lineCounts[ext];
}
for (const ext of Object.keys(fileCounts)) {
languages.push({
language: LANGUAGE_MAP[ext] || ext,
extension: ext,
files: fileCounts[ext],
lines: lineCounts[ext],
percentage: totalLines > 0
? Math.round((lineCounts[ext] / totalLines) * 100)
: 0,
});
}
languages.sort((a, b) => b.lines - a.lines);
return { totalFiles, totalLines, languages, largeFiles };
}
function walkDirectory(
rootPath: string,
currentPath: string,
fileCounts: Record<string, number>,
lineCounts: Record<string, number>,
largeFiles: LargeFile[],
): void {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(currentPath, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) {
continue;
}
walkDirectory(rootPath, fullPath, fileCounts, lineCounts, largeFiles);
} else if (entry.isFile()) {
const ext = path.extname(entry.name).toLowerCase();
if (!ext || !LANGUAGE_MAP[ext]) continue;
try {
const content = fs.readFileSync(fullPath, 'utf-8');
const lineCount = content.split('\n').filter((l) => l.trim()).length;
fileCounts[ext] = (fileCounts[ext] || 0) + 1;
lineCounts[ext] = (lineCounts[ext] || 0) + lineCount;
if (lineCount > LARGE_FILE_THRESHOLD) {
const relativePath = path.relative(rootPath, fullPath);
largeFiles.push({
path: relativePath,
lines: lineCount,
language: LANGUAGE_MAP[ext] || ext,
});
}
} catch {
// Skip files that cannot be read (binary, permissions, etc.)
}
}
}
}Run the tests:
npx jest tests/codeStats.test.tsAll four tests should pass.
Step 6: Commit Pattern Analyzer - Tests First
// tests/patterns.test.ts
import { analyzePatterns } from '../src/analyzers/patterns';
import { GitCommit } from '../src/types';
function makeCommit(overrides: Partial<GitCommit> = {}): GitCommit {
return {
hash: 'abc123',
author: 'Alice',
email: 'alice@example.com',
date: new Date('2025-01-15T10:00:00Z'),
message: 'test commit',
filesChanged: 1,
insertions: 10,
deletions: 5,
...overrides,
};
}
describe('Commit Pattern Analyzer', () => {
it('should count total commits', () => {
const commits = [makeCommit(), makeCommit(), makeCommit()];
const patterns = analyzePatterns(commits);
expect(patterns.totalCommits).toBe(3);
});
it('should identify unique contributors', () => {
const commits = [
makeCommit({ author: 'Alice', email: 'alice@example.com' }),
makeCommit({ author: 'Bob', email: 'bob@example.com' }),
makeCommit({ author: 'Alice', email: 'alice@example.com' }),
];
const patterns = analyzePatterns(commits);
expect(patterns.contributors).toHaveLength(2);
const alice = patterns.contributors.find((c) => c.name === 'Alice');
expect(alice!.commits).toBe(2);
});
it('should calculate activity by day of week', () => {
const commits = [
makeCommit({ date: new Date('2025-01-13T10:00:00Z') }), // Monday
makeCommit({ date: new Date('2025-01-15T10:00:00Z') }), // Wednesday
makeCommit({ date: new Date('2025-01-15T14:00:00Z') }), // Wednesday
];
const patterns = analyzePatterns(commits);
const monday = patterns.activityByDay.find((d) => d.day === 'Monday');
const wednesday = patterns.activityByDay.find((d) => d.day === 'Wednesday');
expect(monday!.commits).toBe(1);
expect(wednesday!.commits).toBe(2);
expect(patterns.busiestDay).toBe('Wednesday');
});
it('should calculate activity by hour', () => {
const commits = [
makeCommit({ date: new Date('2025-01-15T09:30:00Z') }),
makeCommit({ date: new Date('2025-01-15T14:15:00Z') }),
makeCommit({ date: new Date('2025-01-15T14:45:00Z') }),
];
const patterns = analyzePatterns(commits);
const hour9 = patterns.activityByHour.find((h) => h.hour === 9);
const hour14 = patterns.activityByHour.find((h) => h.hour === 14);
expect(hour9!.commits).toBe(1);
expect(hour14!.commits).toBe(2);
expect(patterns.busiestHour).toBe(14);
});
it('should calculate average commits per week', () => {
const commits = [
makeCommit({ date: new Date('2025-01-01T10:00:00Z') }),
makeCommit({ date: new Date('2025-01-08T10:00:00Z') }),
makeCommit({ date: new Date('2025-01-15T10:00:00Z') }),
makeCommit({ date: new Date('2025-01-22T10:00:00Z') }),
];
const patterns = analyzePatterns(commits);
// 4 commits over 3 weeks = 1.33 per week
expect(patterns.averageCommitsPerWeek).toBeCloseTo(1.33, 1);
});
it('should handle empty input', () => {
const patterns = analyzePatterns([]);
expect(patterns.totalCommits).toBe(0);
expect(patterns.contributors).toHaveLength(0);
expect(patterns.averageCommitsPerWeek).toBe(0);
});
});Step 7: Commit Pattern Analyzer - Implementation
// src/analyzers/patterns.ts
import { GitCommit, CommitPatterns, ContributorStats, DayActivity, HourActivity } from '../types';
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
export function analyzePatterns(commits: GitCommit[]): CommitPatterns {
if (commits.length === 0) {
return {
totalCommits: 0,
contributors: [],
activityByDay: DAYS.map((day) => ({ day, commits: 0 })),
activityByHour: Array.from({ length: 24 }, (_, hour) => ({ hour, commits: 0 })),
averageCommitsPerWeek: 0,
busiestDay: 'N/A',
busiestHour: 0,
};
}
const contributors = buildContributorStats(commits);
const activityByDay = buildDayActivity(commits);
const activityByHour = buildHourActivity(commits);
const averageCommitsPerWeek = calculateWeeklyAverage(commits);
const busiestDayEntry = activityByDay.reduce((max, day) =>
day.commits > max.commits ? day : max
);
const busiestHourEntry = activityByHour.reduce((max, hour) =>
hour.commits > max.commits ? hour : max
);
return {
totalCommits: commits.length,
contributors,
activityByDay,
activityByHour,
averageCommitsPerWeek,
busiestDay: busiestDayEntry.day,
busiestHour: busiestHourEntry.hour,
};
}
function buildContributorStats(commits: GitCommit[]): ContributorStats[] {
const map = new Map<string, ContributorStats>();
for (const commit of commits) {
const existing = map.get(commit.email);
if (existing) {
existing.commits += 1;
if (commit.date < existing.firstCommit) existing.firstCommit = commit.date;
if (commit.date > existing.lastCommit) existing.lastCommit = commit.date;
} else {
map.set(commit.email, {
name: commit.author,
email: commit.email,
commits: 1,
firstCommit: commit.date,
lastCommit: commit.date,
});
}
}
return Array.from(map.values()).sort((a, b) => b.commits - a.commits);
}
function buildDayActivity(commits: GitCommit[]): DayActivity[] {
const counts = new Map<string, number>();
for (const day of DAYS) counts.set(day, 0);
for (const commit of commits) {
const day = DAYS[commit.date.getUTCDay()];
counts.set(day, (counts.get(day) || 0) + 1);
}
return DAYS.map((day) => ({ day, commits: counts.get(day) || 0 }));
}
function buildHourActivity(commits: GitCommit[]): HourActivity[] {
const counts = new Array(24).fill(0);
for (const commit of commits) {
const hour = commit.date.getUTCHours();
counts[hour] += 1;
}
return counts.map((count, hour) => ({ hour, commits: count }));
}
function calculateWeeklyAverage(commits: GitCommit[]): number {
if (commits.length <= 1) return commits.length;
const dates = commits.map((c) => c.date.getTime());
const earliest = Math.min(...dates);
const latest = Math.max(...dates);
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
const weeks = Math.max((latest - earliest) / msPerWeek, 1);
return Math.round((commits.length / weeks) * 100) / 100;
}Run all pattern tests:
npx jest tests/patterns.test.tsAll six tests should pass. Now run the entire test suite to make sure everything works together:
npm testYou should see all 14 tests passing across three test files.
Checkpoint
You now have the complete analysis engine:
- Git analyzer: Parses
git logoutput into structured commit data - Code stats analyzer: Walks the file tree and counts files/lines by language
- Pattern analyzer: Detects commit frequency, contributor activity, and timing patterns
- 14 passing tests covering core logic and edge cases
Commit your progress:
git add -A
git commit -m "feat: add core analysis engine with TDD tests"19.3 Phase 3: Report Generator
The reporter takes an AnalysisReport and produces a
formatted markdown string. It also generates simple text-based insights
by comparing patterns in the data.
Step 1: Reporter Tests
// tests/reporter.test.ts
import { generateReport, generateInsights } from '../src/reporter';
import { AnalysisReport, GitCommit, CodeStats, CommitPatterns } from '../src/types';
function makeReport(overrides: Partial<AnalysisReport> = {}): AnalysisReport {
return {
repoName: 'test-repo',
repoPath: '/tmp/test-repo',
analyzedAt: new Date('2025-01-15T12:00:00Z'),
gitAnalysis: [],
codeStats: {
totalFiles: 10,
totalLines: 500,
languages: [
{ language: 'TypeScript', extension: '.ts', files: 7, lines: 400, percentage: 80 },
{ language: 'CSS', extension: '.css', files: 3, lines: 100, percentage: 20 },
],
largeFiles: [],
},
commitPatterns: {
totalCommits: 50,
contributors: [
{
name: 'Alice',
email: 'alice@example.com',
commits: 30,
firstCommit: new Date('2024-06-01'),
lastCommit: new Date('2025-01-15'),
},
{
name: 'Bob',
email: 'bob@example.com',
commits: 20,
firstCommit: new Date('2024-08-01'),
lastCommit: new Date('2025-01-14'),
},
],
activityByDay: [
{ day: 'Sunday', commits: 2 },
{ day: 'Monday', commits: 8 },
{ day: 'Tuesday', commits: 10 },
{ day: 'Wednesday', commits: 12 },
{ day: 'Thursday', commits: 9 },
{ day: 'Friday', commits: 7 },
{ day: 'Saturday', commits: 2 },
],
activityByHour: Array.from({ length: 24 }, (_, h) => ({
hour: h,
commits: h >= 9 && h <= 17 ? 5 : 0,
})),
averageCommitsPerWeek: 8.5,
busiestDay: 'Wednesday',
busiestHour: 14,
},
insights: [],
...overrides,
};
}
describe('Report Generator', () => {
describe('generateReport', () => {
it('should include the repository name in the title', () => {
const report = generateReport(makeReport());
expect(report).toContain('# DevInsights Report: test-repo');
});
it('should include commit count and contributor count', () => {
const report = generateReport(makeReport());
expect(report).toContain('50');
expect(report).toContain('2');
});
it('should list languages sorted by lines', () => {
const report = generateReport(makeReport());
const tsIndex = report.indexOf('TypeScript');
const cssIndex = report.indexOf('CSS');
expect(tsIndex).toBeLessThan(cssIndex);
});
it('should include the busiest day', () => {
const report = generateReport(makeReport());
expect(report).toContain('Wednesday');
});
});
describe('generateInsights', () => {
it('should flag large files when present', () => {
const data = makeReport();
data.codeStats.largeFiles = [
{ path: 'src/big.ts', lines: 500, language: 'TypeScript' },
];
const insights = generateInsights(data);
expect(insights.some((i) => i.includes('large') || i.includes('big.ts'))).toBe(true);
});
it('should note the dominant language', () => {
const insights = generateInsights(makeReport());
expect(insights.some((i) => i.includes('TypeScript'))).toBe(true);
});
it('should note weekend work if present', () => {
const data = makeReport();
data.commitPatterns.activityByDay[0].commits = 15; // Sunday
data.commitPatterns.activityByDay[6].commits = 15; // Saturday
const insights = generateInsights(data);
expect(insights.some((i) => i.toLowerCase().includes('weekend'))).toBe(true);
});
});
});Step 2: Reporter Implementation
// src/reporter.ts
import { AnalysisReport } from './types';
export function generateReport(report: AnalysisReport): string {
const lines: string[] = [];
lines.push(`# DevInsights Report: ${report.repoName}`);
lines.push('');
lines.push(`**Analyzed:** ${report.analyzedAt.toISOString().split('T')[0]}`);
lines.push(`**Path:** ${report.repoPath}`);
lines.push('');
// Summary
lines.push('## Summary');
lines.push('');
lines.push(`| Metric | Value |`);
lines.push(`|--------|-------|`);
lines.push(`| Total Commits | ${report.commitPatterns.totalCommits} |`);
lines.push(`| Contributors | ${report.commitPatterns.contributors.length} |`);
lines.push(`| Total Files | ${report.codeStats.totalFiles} |`);
lines.push(`| Total Lines of Code | ${report.codeStats.totalLines.toLocaleString()} |`);
lines.push(`| Avg Commits/Week | ${report.commitPatterns.averageCommitsPerWeek} |`);
lines.push('');
// Language breakdown
lines.push('## Language Breakdown');
lines.push('');
lines.push('| Language | Files | Lines | % |');
lines.push('|----------|-------|-------|---|');
for (const lang of report.codeStats.languages) {
lines.push(
`| ${lang.language} | ${lang.files} | ${lang.lines.toLocaleString()} | ${lang.percentage}% |`
);
}
lines.push('');
// Commit activity
lines.push('## Commit Activity');
lines.push('');
lines.push(`**Most active day:** ${report.commitPatterns.busiestDay}`);
lines.push(`**Most active hour:** ${formatHour(report.commitPatterns.busiestHour)}`);
lines.push('');
// Day-of-week chart
lines.push('### Activity by Day');
lines.push('');
const maxDayCommits = Math.max(...report.commitPatterns.activityByDay.map((d) => d.commits));
for (const day of report.commitPatterns.activityByDay) {
const barLength = maxDayCommits > 0
? Math.round((day.commits / maxDayCommits) * 30)
: 0;
const bar = '\u2588'.repeat(barLength);
const label = day.day.padEnd(9);
lines.push(`${label} ${bar} ${day.commits}`);
}
lines.push('');
// Contributors
lines.push('## Contributors');
lines.push('');
lines.push('| Name | Commits | First Commit | Last Commit |');
lines.push('|------|---------|--------------|-------------|');
for (const contributor of report.commitPatterns.contributors) {
lines.push([
'',
contributor.name,
String(contributor.commits),
contributor.firstCommit.toISOString().split('T')[0],
contributor.lastCommit.toISOString().split('T')[0],
'',
].join(' | ').trim());
}
lines.push('');
// Large files
if (report.codeStats.largeFiles.length > 0) {
lines.push('## Large Files (> 300 lines)');
lines.push('');
lines.push('| File | Lines | Language |');
lines.push('|------|-------|----------|');
for (const file of report.codeStats.largeFiles) {
lines.push(`| ${file.path} | ${file.lines} | ${file.language} |`);
}
lines.push('');
}
// Insights
const insights = report.insights.length > 0
? report.insights
: generateInsights(report);
if (insights.length > 0) {
lines.push('## Insights');
lines.push('');
for (const insight of insights) {
lines.push(`- ${insight}`);
}
lines.push('');
}
lines.push('---');
lines.push('*Generated by DevInsights CLI*');
return lines.join('\n');
}
export function generateInsights(report: AnalysisReport): string[] {
const insights: string[] = [];
// Dominant language
if (report.codeStats.languages.length > 0) {
const top = report.codeStats.languages[0];
if (top.percentage >= 60) {
insights.push(
`This is primarily a ${top.language} project (${top.percentage}% of code).`
);
}
}
// Large files warning
if (report.codeStats.largeFiles.length > 0) {
const count = report.codeStats.largeFiles.length;
const fileNames = report.codeStats.largeFiles
.slice(0, 3)
.map((f) => f.path)
.join(', ');
insights.push(
`${count} file(s) exceed 300 lines and may benefit from splitting: ${fileNames}`
);
}
// Weekend work detection
const weekendCommits =
(report.commitPatterns.activityByDay.find((d) => d.day === 'Saturday')?.commits || 0) +
(report.commitPatterns.activityByDay.find((d) => d.day === 'Sunday')?.commits || 0);
const totalCommits = report.commitPatterns.totalCommits;
if (totalCommits > 0 && weekendCommits / totalCommits > 0.15) {
const pct = Math.round((weekendCommits / totalCommits) * 100);
insights.push(
`${pct}% of commits happen on weekends. Consider whether this indicates healthy flexibility or unsustainable pace.`
);
}
// Single contributor dominance
if (report.commitPatterns.contributors.length > 1) {
const top = report.commitPatterns.contributors[0];
const pct = Math.round((top.commits / totalCommits) * 100);
if (pct > 70) {
insights.push(
`${top.name} accounts for ${pct}% of commits. Consider knowledge-sharing to reduce bus factor.`
);
}
}
// Low commit frequency
if (report.commitPatterns.averageCommitsPerWeek < 3 && totalCommits > 10) {
insights.push(
`Average of ${report.commitPatterns.averageCommitsPerWeek} commits/week suggests infrequent integration. Smaller, more frequent commits improve collaboration.`
);
}
// Peak productivity window
const peakHour = report.commitPatterns.busiestHour;
insights.push(
`Peak productivity window is around ${formatHour(peakHour)}. Protect this time from meetings.`
);
return insights;
}
function formatHour(hour: number): string {
const period = hour >= 12 ? 'PM' : 'AM';
const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return `${displayHour}:00 ${period}`;
}Run the reporter tests:
npx jest tests/reporter.test.tsAll six tests should pass. Run the full suite:
npm testYou should now have 20 passing tests.
Checkpoint
The report generator:
- Produces clean markdown with tables, charts, and sections
- Generates actionable insights from the data (large files, weekend work, contributor concentration)
- Is fully tested
Commit:
git add -A
git commit -m "feat: add report generator with insights engine"19.4 Phase 4: CLI Interface and Polish
Now we connect everything with a polished command-line interface.
Step 1: Build the CLI Entry Point
// src/index.ts
#!/usr/bin/env node
import { Command } from 'commander';
import chalk from 'chalk';
import * as fs from 'fs';
import * as path from 'path';
import { getGitLog, parseGitLog } from './analyzers/git';
import { analyzeCodeStats } from './analyzers/codeStats';
import { analyzePatterns } from './analyzers/patterns';
import { generateReport, generateInsights } from './reporter';
import { AnalysisReport } from './types';
const program = new Command();
program
.name('devinsights')
.description('Analyze git repositories and generate developer productivity reports')
.version('1.0.0');
program
.command('analyze')
.description('Analyze a git repository')
.argument('[path]', 'Path to the git repository', '.')
.option('-o, --output <file>', 'Output file path', 'devinsights-report.md')
.option('-n, --max-commits <number>', 'Maximum commits to analyze', '1000')
.option('--json', 'Output raw JSON instead of markdown')
.action(async (repoPath: string, options: {
output: string;
maxCommits: string;
json: boolean;
}) => {
const resolvedPath = path.resolve(repoPath);
console.log('');
console.log(chalk.bold.blue(' DevInsights CLI v1.0.0'));
console.log(chalk.gray(' Analyzing repository...\n'));
// Validate the path is a git repository
if (!fs.existsSync(path.join(resolvedPath, '.git'))) {
console.error(chalk.red(` Error: ${resolvedPath} is not a git repository.`));
console.error(chalk.gray(' Make sure the path contains a .git directory.\n'));
process.exit(1);
}
const repoName = path.basename(resolvedPath);
try {
// Step 1: Git analysis
process.stdout.write(chalk.yellow(' [1/4] ') + 'Reading git history...');
const rawLog = getGitLog(resolvedPath, parseInt(options.maxCommits, 10));
const commits = parseGitLog(rawLog);
console.log(chalk.green(' done') + chalk.gray(` (${commits.length} commits)`));
// Step 2: Code statistics
process.stdout.write(chalk.yellow(' [2/4] ') + 'Analyzing codebase...');
const codeStats = analyzeCodeStats(resolvedPath);
console.log(
chalk.green(' done') +
chalk.gray(` (${codeStats.totalFiles} files, ${codeStats.totalLines.toLocaleString()} lines)`)
);
// Step 3: Pattern analysis
process.stdout.write(chalk.yellow(' [3/4] ') + 'Detecting patterns...');
const commitPatterns = analyzePatterns(commits);
console.log(
chalk.green(' done') +
chalk.gray(` (${commitPatterns.contributors.length} contributors)`)
);
// Step 4: Generate report
process.stdout.write(chalk.yellow(' [4/4] ') + 'Generating report...');
const report: AnalysisReport = {
repoName,
repoPath: resolvedPath,
analyzedAt: new Date(),
gitAnalysis: commits,
codeStats,
commitPatterns,
insights: [],
};
report.insights = generateInsights(report);
if (options.json) {
const jsonOutput = JSON.stringify(report, null, 2);
fs.writeFileSync(options.output.replace('.md', '.json'), jsonOutput);
console.log(chalk.green(' done'));
} else {
const markdown = generateReport(report);
fs.writeFileSync(options.output, markdown);
console.log(chalk.green(' done'));
}
// Print summary
console.log('');
console.log(chalk.bold(' =============================='));
console.log(chalk.bold(' Analysis Complete'));
console.log(chalk.bold(' =============================='));
console.log('');
console.log(` Repository: ${chalk.cyan(repoName)}`);
console.log(` Commits: ${chalk.cyan(String(commits.length))}`);
console.log(` Contributors: ${chalk.cyan(String(commitPatterns.contributors.length))}`);
console.log(` Files: ${chalk.cyan(String(codeStats.totalFiles))}`);
console.log(` Lines of Code: ${chalk.cyan(codeStats.totalLines.toLocaleString())}`);
console.log(` Busiest Day: ${chalk.cyan(commitPatterns.busiestDay)}`);
console.log('');
if (report.insights.length > 0) {
console.log(chalk.bold(' Key Insights:'));
for (const insight of report.insights.slice(0, 4)) {
console.log(chalk.gray(' * ') + insight);
}
console.log('');
}
const outputPath = path.resolve(options.output);
console.log(` Full report saved to: ${chalk.underline(outputPath)}`);
console.log('');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error('');
console.error(chalk.red(` Error: ${message}`));
console.error('');
process.exit(1);
}
});
program.parse();Step 2: Build and Test
Compile the TypeScript:
npm run buildMake the entry point executable:
chmod +x dist/index.jsNow run it against your own project (or any git repository):
node dist/index.js analyze .You should see the colorful terminal output analyzing the devinsights-cli repository itself. Since the project is new, the report will be small, but the tool is working.
Try it against a larger repository if you have one:
node dist/index.js analyze /path/to/another/repoStep 3: Add a Custom Slash Command
Create a Claude Code slash command that runs the analyzer. This is the technique from Chapter 14 applied to a real tool.
<!-- .claude/commands/analyze.md -->
Analyze the current git repository using DevInsights CLI.
Steps:
1. Build the project: run `npm run build` in the project root
2. Run the analyzer: `node dist/index.js analyze .`
3. Read the generated devinsights-report.md
4. Summarize the key findings
5. Suggest 2-3 specific improvements based on the insights
If the build fails, fix any TypeScript errors first, then retry.Now when you are working in Claude Code on any project that has
DevInsights installed, you can type /analyze and Claude
will run the full analysis pipeline and discuss the results with
you.
Checkpoint
You now have a complete, working CLI application:
- Four-step analysis pipeline with progress output
- Colorful terminal formatting
- Command-line argument parsing with help text
- JSON output option
- Custom slash command for Claude Code integration
Commit:
git add -A
git commit -m "feat: add CLI interface with progress output and slash command"19.5 Phase 5: Testing and Quality
We already have 20 unit tests. Let us add an integration test that runs the full pipeline and verify our test coverage.
Step 1: Integration Test
This test creates a temporary git repository with real commits, then runs the full analysis pipeline on it.
// tests/integration.test.ts
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getGitLog, parseGitLog } from '../src/analyzers/git';
import { analyzeCodeStats } from '../src/analyzers/codeStats';
import { analyzePatterns } from '../src/analyzers/patterns';
import { generateReport, generateInsights } from '../src/reporter';
import { AnalysisReport } from '../src/types';
describe('Integration: Full Analysis Pipeline', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devinsights-integration-'));
// Initialize a real git repo with commits
const run = (cmd: string) =>
execSync(cmd, { cwd: tempDir, encoding: 'utf-8', stdio: 'pipe' });
run('git init');
run('git config user.email "test@example.com"');
run('git config user.name "Test User"');
// Create files and commits
fs.mkdirSync(path.join(tempDir, 'src'), { recursive: true });
fs.writeFileSync(
path.join(tempDir, 'src', 'app.ts'),
'const greeting = "hello";\nconsole.log(greeting);\n'
);
run('git add -A');
run('git commit -m "feat: initial setup"');
fs.writeFileSync(
path.join(tempDir, 'src', 'utils.ts'),
[
'export function add(a: number, b: number): number {',
' return a + b;',
'}',
'',
'export function multiply(a: number, b: number): number {',
' return a * b;',
'}',
'',
].join('\n')
);
run('git add -A');
run('git commit -m "feat: add utility functions"');
fs.writeFileSync(
path.join(tempDir, 'src', 'styles.css'),
'body {\n margin: 0;\n padding: 0;\n}\n'
);
run('git add -A');
run('git commit -m "style: add base styles"');
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should analyze a real git repository end-to-end', () => {
// Step 1: Get git log
const rawLog = getGitLog(tempDir);
const commits = parseGitLog(rawLog);
expect(commits.length).toBeGreaterThanOrEqual(3);
// Step 2: Analyze code stats
const codeStats = analyzeCodeStats(tempDir);
expect(codeStats.totalFiles).toBe(3);
expect(codeStats.languages.length).toBeGreaterThanOrEqual(2);
// Step 3: Analyze patterns
const patterns = analyzePatterns(commits);
expect(patterns.totalCommits).toBeGreaterThanOrEqual(3);
expect(patterns.contributors).toHaveLength(1);
expect(patterns.contributors[0].name).toBe('Test User');
// Step 4: Generate report
const report: AnalysisReport = {
repoName: 'test-repo',
repoPath: tempDir,
analyzedAt: new Date(),
gitAnalysis: commits,
codeStats,
commitPatterns: patterns,
insights: [],
};
report.insights = generateInsights(report);
const markdown = generateReport(report);
expect(markdown).toContain('# DevInsights Report: test-repo');
expect(markdown).toContain('Test User');
expect(markdown).toContain('TypeScript');
expect(markdown).toContain('CSS');
expect(markdown.length).toBeGreaterThan(200);
});
it('should produce valid markdown output', () => {
const rawLog = getGitLog(tempDir);
const commits = parseGitLog(rawLog);
const codeStats = analyzeCodeStats(tempDir);
const patterns = analyzePatterns(commits);
const report: AnalysisReport = {
repoName: 'test-repo',
repoPath: tempDir,
analyzedAt: new Date(),
gitAnalysis: commits,
codeStats,
commitPatterns: patterns,
insights: [],
};
report.insights = generateInsights(report);
const markdown = generateReport(report);
// Basic markdown structure checks
expect(markdown).toMatch(/^# /m); // Has H1
expect(markdown).toMatch(/^## /m); // Has H2
expect(markdown).toMatch(/\|.*\|.*\|/m); // Has tables
expect(markdown).toContain('---'); // Has horizontal rules
});
});Run the integration test:
npx jest tests/integration.test.tsBoth tests should pass. Now run the full suite with coverage:
npx jest --coverageYou should see output like:
-----------------------|---------|----------|---------|---------|
File | % Stmts | % Branch | % Funcs | % Lines |
-----------------------|---------|----------|---------|---------|
All files | 95.2 | 88.4 | 100 | 95.0 |
analyzers/git.ts | 100 | 90.0 | 100 | 100 |
analyzers/codeStats.ts| 93.5 | 85.7 | 100 | 93.1 |
analyzers/patterns.ts | 97.1 | 91.6 | 100 | 96.8 |
reporter.ts | 94.2 | 87.5 | 100 | 94.0 |
types.ts | 100 | 100 | 100 | 100 |
-----------------------|---------|----------|---------|---------|
Test Suites: 5 passed, 5 total
Tests: 22 passed, 22 total
Step 2: Run DevInsights on Itself
The ultimate test: point the tool at its own repository.
npm run build
node dist/index.js analyze .Then read the generated report:
cat devinsights-report.mdYou have a working tool analyzing itself. This is the moment the project becomes real.
Checkpoint
Your test suite now includes:
- 4 git analyzer tests
- 4 code stats tests
- 6 pattern analyzer tests
- 6 reporter tests
- 2 integration tests
That is 22 tests covering the entire pipeline from raw git data to final markdown output. Coverage should be above 90% on all modules.
Final commit:
git add -A
git commit -m "feat: add integration tests, achieve >90% coverage"19.6 What You Built
Take a step back and look at what you have accomplished. In a single chapter, you built a real, working command-line application. More importantly, you used nearly every technique from this book:
Techniques Applied
| Chapter | Technique | Where You Used It |
|---|---|---|
| Ch 5 | CLAUDE.md | Project context file guiding every Claude Code session |
| Ch 10 | TDD | Tests written before every module implementation |
| Ch 11 | Plan Mode | Designed analyzer architecture before writing code |
| Ch 12 | Spec-Driven Dev | Types defined first, guiding all implementation |
| Ch 13 | Agents | Multi-step analysis pipeline with orchestration |
| Ch 14 | Slash Commands | Custom /analyze command for Claude Code |
| Ch 16 | Production Workflows | Build scripts, coverage thresholds, CI-ready testing |
What the Tool Can Do
- Analyze any local git repository
- Parse commit history with full metadata
- Count files and lines of code by language
- Detect commit patterns (busiest day, peak hours, contributor distribution)
- Flag large files that may need refactoring
- Generate a professional markdown report
- Output raw JSON for further processing
- Run from the command line with
devinsights analyze /path/to/repo
Sample Output
Here is what a report looks like when run against a medium-sized real project:
# DevInsights Report: my-saas-app
**Analyzed:** 2025-01-15
**Path:** /Users/dev/projects/my-saas-app
## Summary
| Metric | Value |
|--------|-------|
| Total Commits | 847 |
| Contributors | 6 |
| Total Files | 234 |
| Total Lines of Code | 28,450 |
| Avg Commits/Week | 14.2 |
## Language Breakdown
| Language | Files | Lines | % |
|----------|-------|-------|---|
| TypeScript | 142 | 18,900 | 66% |
| CSS | 38 | 4,200 | 15% |
| JSON | 22 | 2,800 | 10% |
| Markdown | 18 | 1,600 | 6% |
| YAML | 14 | 950 | 3% |
## Insights
- This is primarily a TypeScript project (66% of code).
- 4 file(s) exceed 300 lines and may benefit from splitting:
src/components/Dashboard.tsx, src/api/handlers.ts, src/utils/parser.ts
- Peak productivity window is around 2:00 PM. Protect this time from meetings.
- 8% of commits happen on weekends.Ideas for Extending the Project
The DevInsights CLI you built is a solid foundation. Here are some ways to extend it, roughly ordered by complexity:
- Add a
watchcommand that re-runs analysis when new commits are detected - Track trends over time by saving report JSON files and comparing them
- Add code churn analysis using
git log --statto find files that change most frequently - Build a web dashboard with a simple Express server that renders the JSON report as HTML charts
- Add GitHub integration by pulling pull request data from the GitHub API
- Team comparison mode that breaks down metrics by contributor
- AI-powered recommendations by sending the analysis data to the Claude API for natural language interpretation
The full-stack DevInsights SaaS platform (with PostgreSQL, Redis, real-time dashboards, and team management) that was originally scoped for this chapter would build on this CLI foundation. The CLI gives you the analysis engine; a web application would add persistence, multi-user support, and visualization. That is a natural evolution when you are ready for it.
Exercises
Exercise 1: Add Code Churn Analysis
Add a new analyzer at src/analyzers/churn.ts that
identifies the files with the most git commits (highest churn). The
implementation should:
- Run
git log --name-only --format=""to get a list of changed files per commit - Count how many times each file appears
- Return the top 10 highest-churn files
- Add a βHigh Churn Filesβ section to the report
Write the tests first, then implement.
Exercise 2: Add Trend Comparison
Add a --compare <file> flag to the
analyze command that loads a previously saved JSON report
and compares it to the current analysis. Display:
- Change in commit frequency
- New contributors since last report
- Files that have grown the most
This exercise practices working with the --json output
flag and building comparison logic.
Exercise 3: Create a Pre-Commit Hook
Using the hooks technique from Chapter 15, create a pre-commit hook
that automatically runs npm test and
npm run lint before allowing a commit. Add a
.claude/hooks/pre-commit script:
#!/bin/bash
npm run lint && npm testThen configure it:
chmod +x .claude/hooks/pre-commitExercise 4: Build a Web Dashboard
Create a simple Express server that serves the JSON report as an
interactive HTML page. Use the built-in Node.js http module
(no framework needed) to serve a single page that loads the JSON report
and renders basic charts using inline JavaScript and SVG elements. This
extends the CLI into a visual tool without adding heavy
dependencies.
Chapter Summary
This chapter demonstrated that the techniques you learned throughout this book are not theoretical: they combine into a practical workflow for building real software.
You started with a blank directory and ended with a working developer productivity tool. Along the way, you used CLAUDE.md for project context, Plan Mode for architecture design, TDD for reliable implementation, typed interfaces for specification-driven development, custom slash commands for workflow integration, and thorough testing for confidence.
The project is deliberately modest in scope. A CLI tool that analyzes git repositories is not the most ambitious application you could build, but it is the most honest one for a single chapter. It runs. It produces useful output. It has tests. You can point it at any repository you work on today and get actionable insights.
That is the bridge from βpromptβ to βproduction.β
PROMPT TO PRODUCTION