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: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@mesadev/saguaro",
"version": "0.4.18",
"version": "0.4.19",
"description": "AI code review that enforces your team's rules during development",
"license": "Apache-2.0",
"type": "module",
Expand Down
30 changes: 30 additions & 0 deletions src/adapter/daemon-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// src/adapter/daemon-stats.ts

import type { DaemonFinding, DaemonFindingsFilter, DaemonStatsAggregation, TimeWindow } from '../daemon/stats-types.js';
import { DaemonStore } from '../daemon/store.js';

export type { DaemonFinding, DaemonFindingsFilter, TimeWindow };

export interface DaemonStatsResult {
stats: DaemonStatsAggregation;
empty: boolean;
}

export function getDaemonStats(window: TimeWindow = '7d'): DaemonStatsResult {
const store = new DaemonStore();
try {
const stats = store.getStats(window);
return { stats, empty: stats.overview.totalReviews === 0 };
} finally {
store.close();
}
}

export function getDaemonFindings(window: TimeWindow = '7d', filters?: DaemonFindingsFilter): DaemonFinding[] {
const store = new DaemonStore();
try {
return store.getRecentFindings(window, filters);
} finally {
store.close();
}
}
3 changes: 2 additions & 1 deletion src/ai/agent-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,14 @@ export function buildClaudeArgs(options: {
allowedTools?: string[];
maxTurns?: number;
effort?: 'low' | 'medium' | 'high';
outputFormat?: 'text' | 'json';
}): string[] {
const args: string[] = [
'-p',
'-',
'--dangerously-skip-permissions',
'--output-format',
'text',
options.outputFormat ?? 'text',
'--verbose',
'--no-session-persistence',
'--disable-slash-commands',
Expand Down
8 changes: 8 additions & 0 deletions src/cli/commands/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { getCliForProvider, loadValidatedConfig, resolveModelForReview } from '../../config/model-config.js';
import { SaguaroDaemon } from '../../daemon/server.js';
import type { TimeWindow } from '../../daemon/stats-types.js';
import { daemonStatsCommand } from '../lib/daemon-stats.js';

export async function daemonStart(): Promise<number> {
const existing = SaguaroDaemon.readPidFile();
Expand Down Expand Up @@ -62,3 +64,9 @@ export function daemonStatus(): number {
console.log(`[sag] Started at: ${pid.startedAt}`);
return 0;
}

export function daemonStats(options: { window?: string }): number {
const validWindows = ['1h', '1d', '7d', '30d', 'all'];
const window: TimeWindow = validWindows.includes(options.window ?? '') ? (options.window as TimeWindow) : '7d';
return daemonStatsCommand({ window });
}
17 changes: 15 additions & 2 deletions src/cli/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import modelHandler from '../lib/model.js';
import { createRule, deleteRule, explainRule, listRules, locateRulesDirectory, validateRules } from '../lib/rules.js';
import serveHandler from '../lib/serve.js';
import { statsCommand } from '../lib/stats.js';
import { daemonStart, daemonStatus, daemonStop } from './daemon.js';
import { daemonStart, daemonStats, daemonStatus, daemonStop } from './daemon.js';
import { reviewCommand } from './review.js';

const secondary = chalk.hex('#be3c00');
Expand Down Expand Up @@ -460,7 +460,20 @@ export async function cli(argv: string[]): Promise<boolean> {
y.demandCommand(1, 'Please specify a daemon command.')
.command('start', 'Start the daemon', {}, wrapHandler(daemonStart))
.command('stop', 'Stop the daemon', {}, wrapHandler(daemonStop))
.command('status', 'Check daemon status', {}, wrapHandler(daemonStatus));
.command('status', 'Check daemon status', {}, wrapHandler(daemonStatus))
.command(
'stats',
'Show daemon review analytics',
(yy: Argv) => {
yy.option('window', {
alias: 'w',
describe: 'Time window (1h, 1d, 7d, 30d, all)',
type: 'string',
default: '7d',
});
},
wrapHandler(daemonStats as (argv: unknown) => number)
);
})

.command(
Expand Down
115 changes: 115 additions & 0 deletions src/cli/lib/daemon-stats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import path from 'node:path';
import chalk from 'chalk';
import { getDaemonStats } from '../../adapter/daemon-stats.js';
import type { DaemonStatsAggregation, TimeWindow } from '../../daemon/stats-types.js';

const CLI_ACCENT = chalk.hex('#be3c00');

const WINDOW_LABELS: Record<TimeWindow, string> = {
'1h': 'last 1h',
'1d': 'last 24h',
'7d': 'last 7d',
'30d': 'last 30d',
all: 'all time',
};

export function formatTokens(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1)}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(0)}K`;
}
return String(n);
}

function printOverview(stats: DaemonStatsAggregation, label: string): void {
const { totalReviews, findings, hitRate, errors, warnings, failedJobs, avgDurationSecs } = stats.overview;

console.log(CLI_ACCENT(`\n DAEMON REVIEW STATS (${label})`));
console.log(chalk.gray(' ─────────────────────────────'));
console.log(` Reviews: ${totalReviews} | Findings: ${findings} | Hit rate: ${Math.round(hitRate)}%`);
console.log(` Errors: ${errors} | Warnings: ${warnings} | Failed: ${failedJobs}`);
console.log(` Avg review: ${Math.round(avgDurationSecs)}s`);
}

function printCost(stats: DaemonStatsAggregation): void {
if (stats.cost === null) return;

const { totalCostUsd, avgCostPerReview, totalInputTokens, totalOutputTokens } = stats.cost;

console.log(CLI_ACCENT('\n COST & TOKENS'));
console.log(chalk.gray(' ─────────────────────────────'));
console.log(` Total cost: $${totalCostUsd.toFixed(2)}`);
console.log(` Avg cost/review: $${avgCostPerReview.toFixed(3)}`);
console.log(` Input tokens: ${formatTokens(totalInputTokens)}`);
console.log(` Output tokens: ${formatTokens(totalOutputTokens)}`);
}

function printModels(stats: DaemonStatsAggregation): void {
if (stats.byModel.length === 0) return;

const maxCount = stats.byModel[0].count;

console.log(CLI_ACCENT('\n MODELS'));
console.log(chalk.gray(' ─────────────────────────────'));

for (const { model, count, costUsd } of stats.byModel) {
const barLen = Math.max(1, Math.round((count / maxCount) * 20));
const bar = '\u2588'.repeat(barLen);
console.log(` ${model.padEnd(25)} ${bar} ${String(count).padStart(3)} $${costUsd.toFixed(2)}`);
}
}

function printRepos(stats: DaemonStatsAggregation): void {
if (stats.byRepo.length === 0) return;

const maxReviews = stats.byRepo[0].reviews;

console.log(CLI_ACCENT('\n REPOS'));
console.log(chalk.gray(' ─────────────────────────────'));

for (const { repo, reviews, findings } of stats.byRepo) {
const basename = path.basename(repo);
const barLen = Math.max(1, Math.round((reviews / maxReviews) * 20));
const bar = '\u2588'.repeat(barLen);
console.log(
` ${basename.padEnd(25)} ${bar} ${String(reviews).padStart(3)} reviews ${String(findings).padStart(2)} findings`
);
}
}

function printCategories(stats: DaemonStatsAggregation): void {
if (stats.byCategory.length === 0) return;

const maxCount = stats.byCategory[0].count;

console.log(CLI_ACCENT('\n CATEGORIES'));
console.log(chalk.gray(' ─────────────────────────────'));

for (const { category, count } of stats.byCategory) {
const barLen = Math.max(1, Math.round((count / maxCount) * 20));
const bar = '\u2588'.repeat(barLen);
console.log(` ${category.padEnd(25)} ${bar} ${String(count).padStart(3)}`);
}
}

export function daemonStatsCommand(options: { window: TimeWindow }): number {
const { stats, empty } = getDaemonStats(options.window);

if (empty) {
console.log(chalk.gray('No daemon reviews found. Start the daemon with "sag daemon start".'));
return 0;
}

const label = WINDOW_LABELS[options.window];

printOverview(stats, label);
printCost(stats);
printModels(stats);
printRepos(stats);
printCategories(stats);

console.log();
return 0;
}
43 changes: 43 additions & 0 deletions src/daemon/__tests__/agent-cli-json.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/// <reference types="bun-types" />

import { describe, expect, test } from 'bun:test';
import { parseAgentJsonOutput } from '../agent-cli.js';

describe('parseAgentJsonOutput', () => {
test('parses valid JSON envelope', () => {
const json = JSON.stringify({
type: 'result',
subtype: 'success',
result: '[error] src/app.ts:10 - SQL injection vulnerability',
total_cost_usd: 0.05,
usage: { input_tokens: 1000, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 },
num_turns: 3,
});
const output = parseAgentJsonOutput(json);
expect(output.text).toBe('[error] src/app.ts:10 - SQL injection vulnerability');
expect(output.usage?.costUsd).toBe(0.05);
expect(output.usage?.inputTokens).toBe(1000);
expect(output.usage?.outputTokens).toBe(200);
expect(output.usage?.numTurns).toBe(3);
});

test('handles missing usage fields gracefully', () => {
const json = JSON.stringify({ type: 'result', result: 'No issues found' });
const output = parseAgentJsonOutput(json);
expect(output.text).toBe('No issues found');
expect(output.usage).toBeUndefined();
});

test('returns raw text when JSON parsing fails', () => {
const output = parseAgentJsonOutput('This is not JSON at all');
expect(output.text).toBe('This is not JSON at all');
expect(output.usage).toBeUndefined();
});

test('returns empty text when result field is missing', () => {
const json = JSON.stringify({ type: 'result', subtype: 'error' });
const output = parseAgentJsonOutput(json);
expect(output.text).toBe('');
expect(output.usage).toBeUndefined();
});
});
79 changes: 79 additions & 0 deletions src/daemon/__tests__/categorize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/// <reference types="bun-types" />

import { describe, expect, test } from 'bun:test';
import { categorizeFinding } from '../categorize.js';

describe('categorizeFinding', () => {
test('returns uncategorized for empty message', () => {
expect(categorizeFinding('')).toEqual(['uncategorized']);
});

test('detects security findings', () => {
const cats = categorizeFinding('IDOR / missing org scope on update');
expect(cats[0]).toBe('security');
});

test('detects bug findings', () => {
const cats = categorizeFinding('will throw TypeError: undefined is not iterable');
expect(cats[0]).toBe('bug');
});

test('detects regression findings', () => {
const cats = categorizeFinding('Throwing on missing repo is a regression. The old code silently dropped it.');
expect(cats[0]).toBe('regression');
});

test('detects performance findings', () => {
const cats = categorizeFinding('Sequential awaits instead of Promise.all, serializing N parallel calls');
expect(cats[0]).toBe('performance');
});

test('detects dead-code findings', () => {
const cats = categorizeFinding('Dead prop: repo is declared but never used');
expect(cats[0]).toBe('dead-code');
});

test('detects error-handling findings', () => {
const cats = categorizeFinding('no error handling around the database write operation');
expect(cats[0]).toBe('error-handling');
});

test('detects race-condition findings', () => {
const cats = categorizeFinding('race condition: no concurrency guard');
expect(cats[0]).toBe('race-condition');
});

test('detects merge-conflict findings', () => {
const cats = categorizeFinding('Unresolved merge conflict markers in the file');
expect(cats[0]).toBe('merge-conflict');
});

test('detects needless-complexity findings', () => {
const cats = categorizeFinding('Needlessly complex: reimplements Math.min with a hand-rolled loop');
expect(cats[0]).toBe('needless-complexity');
});

test('detects spec-issue findings', () => {
const cats = categorizeFinding('Task 2 Step 1 instructs adding a field that already exists');
expect(cats[0]).toBe('spec-issue');
});

test('returns multiple categories when message matches several', () => {
const cats = categorizeFinding(
'Hardcoded 50ms sleep adds latency — a regression from the previous parallel approach'
);
expect(cats.length).toBeGreaterThan(1);
expect(cats).toContain('performance');
expect(cats).toContain('regression');
});

test('priority order: security before bug', () => {
const cats = categorizeFinding('SQL injection: attacker can break out and crash the process');
expect(cats[0]).toBe('security');
});

test('falls back to uncategorized for unrecognizable messages', () => {
const cats = categorizeFinding('The denominator is all expectations, not just the negative class');
expect(cats).toContain('uncategorized');
});
});
Loading
Loading