feat(review): add Codex review integration via tmux

Implement automated code review using Codex:

- src/review/types.ts: Core type definitions
  * CodexReview with score, issues, suggestions
  * ReviewRequest, ReviewResult interfaces
  * ReviewScore (0-1 scale for overall/accuracy/style/security)
- src/review/codex-reviewer.ts: Codex execution via tmux
  * runCodexReview() - Execute Codex via MacBook tmux (%2)
  * parseCodexOutput() - Parse Codex JSON/Markdown output
  * evaluateReview() - Check against threshold (default: 0.8)
  * formatReview() - Convert to Markdown/JSON
  * createReviewRequest() - Build review request
  * detectLanguage() - Auto-detect from code patterns
- src/review/codex-reviewer.test.ts: Unit tests (skeleton)
- src/review/index.ts: Module exports

Features:
- tmux send-keys for Codex execution (target: %2 for Kaede)
- Score-based approval (0.8+ = approved)
- Critical issue auto-rejection
- Multi-language detection (TS, Python, Rust)
- Markdown/JSON formatted output

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Shunsuke Hayashi 2026-01-26 07:01:43 +09:00
parent 12d7ff72fa
commit 522e2a1310
4 changed files with 854 additions and 0 deletions

View File

@ -0,0 +1,218 @@
/**
* Codexレビュアーテスト
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
runCodexReview,
parseCodexOutput,
createReviewRequest,
evaluateReview,
formatReview,
detectLanguage,
} from "./codex-reviewer.js";
// tmuxモック
vi.mock("child_process");
describe("codex-reviewer", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("runCodexReview", () => {
it("should execute Codex review via tmux", async () => {
expect(true).toBe(true);
});
it("should handle execution timeout", async () => {
expect(true).toBe(true);
});
it("should return review result with score", async () => {
expect(true).toBe(true);
});
});
describe("parseCodexOutput", () => {
it("should parse Codex output with score", () => {
const output = `## Score
overall: 0.85, accuracy: 0.9, completeness: 0.8
## Issues
[major] src/file.ts:42: Missing error handling
## Suggestions
[high] performance: Use memoization
## Summary
Review completed successfully.`;
const review = parseCodexOutput(output);
expect(review.score.overall).toBe(0.85);
expect(review.issues).toHaveLength(1);
expect(review.suggestions).toHaveLength(1);
expect(review.approved).toBe(true);
});
it("should handle empty output", () => {
const review = parseCodexOutput("");
expect(review).toBeDefined();
});
});
describe("createReviewRequest", () => {
it("should create review request", () => {
const code = "const x = 1;";
const request = createReviewRequest(code);
expect(request.id).toBeDefined();
expect(request.code).toBe(code);
expect(request.language).toBe("typescript");
});
it("should detect language from code", () => {
expect(detectLanguage("def foo():")).toBe("python");
expect(detectLanguage("fn foo()")).toBe("rust");
expect(detectLanguage("const x = 1")).toBe("typescript");
});
});
describe("evaluateReview", () => {
it("should approve review with high score", () => {
const result = {
success: true,
review: {
id: "test",
target: "code",
score: { overall: 0.9, accuracy: 0.9, completeness: 0.9, style: 0.9, security: 0.9 },
issues: [],
suggestions: [],
summary: "Good",
approved: true,
timestamp: Date.now(),
duration: 100,
},
duration: 100,
};
const evalResult = evaluateReview(result, 0.8);
expect(evalResult.approved).toBe(true);
});
it("should reject review with low score", () => {
const result = {
success: true,
review: {
id: "test",
target: "code",
score: { overall: 0.7, accuracy: 0.7, completeness: 0.7, style: 0.7, security: 0.7 },
issues: [],
suggestions: [],
summary: "Needs improvement",
approved: false,
timestamp: Date.now(),
duration: 100,
},
duration: 100,
};
const evalResult = evaluateReview(result, 0.8);
expect(evalResult.approved).toBe(false);
});
it("should reject review with critical issues", () => {
const result = {
success: true,
review: {
id: "test",
target: "code",
score: { overall: 0.9, accuracy: 0.9, completeness: 0.9, style: 0.9, security: 0.9 },
issues: [
{
id: "1",
severity: "critical",
category: "security",
message: "SQL injection vulnerability",
},
],
suggestions: [],
summary: "Critical issue found",
approved: true, // スコアは高いがcritical issueがある
timestamp: Date.now(),
duration: 100,
},
duration: 100,
};
const evalResult = evaluateReview(result, 0.8);
expect(evalResult.approved).toBe(false);
expect(evalResult.reason).toContain("critical");
});
});
describe("formatReview", () => {
it("should format review as markdown", () => {
const review = {
id: "test",
target: "code",
score: { overall: 0.85, accuracy: 0.9, completeness: 0.8, style: 0.9, security: 0.8 },
issues: [
{
id: "1",
severity: "major",
category: "style",
message: "Use const instead of let",
file: "test.ts",
line: 10,
},
],
suggestions: [
{
id: "1",
priority: "medium",
category: "performance",
description: "Add caching",
},
],
summary: "Review completed",
approved: true,
timestamp: Date.now(),
duration: 100,
};
const formatter = formatReview(review);
const markdown = formatter.toMarkdown();
expect(markdown).toContain("## Codex Review Report");
expect(markdown).toContain("**Score**: 0.85");
expect(markdown).toContain("### Issues");
expect(markdown).toContain("### Suggestions");
});
it("should format review as JSON", () => {
const review = {
id: "test",
target: "code",
score: { overall: 0.85, accuracy: 0.9, completeness: 0.8, style: 0.9, security: 0.8 },
issues: [],
suggestions: [],
summary: "Good",
approved: true,
timestamp: Date.now(),
duration: 100,
};
const formatter = formatReview(review);
const json = formatter.toJSON();
expect(() => JSON.parse(json)).not.toThrow();
expect(JSON.parse(json)).toEqual(review);
});
});
});

View File

@ -0,0 +1,468 @@
/**
* Codexレビュアー統合
*
* tmux経由でCodexを実行し
*/
import { exec } from "child_process";
import { promisify } from "util";
import type {
CodexReview,
ReviewRequest,
ReviewResult,
ReviewOptions,
TmuxResult,
CodexExecutionOptions,
ReviewScore,
ReviewIssue,
ReviewSuggestion,
} from "./types.js";
const execAsync = promisify(exec);
/** デフォルトスコア閾値 */
const DEFAULT_THRESHOLD = 0.8;
/** デフォルトタイムアウト (5分) */
const DEFAULT_TIMEOUT = 5 * 60 * 1000;
/** デフォルトtmuxターゲット (MacBook用) */
const DEFAULT_TMUX_TARGET = "%2"; // カエデ (CodeGen) のペイン
/**
* tmuxコマンドを実行
*
* @param command -
* @param target - tmuxターゲット (ID)
* @param options -
* @returns
*/
async function execTmux(
command: string,
target: string = DEFAULT_TMUX_TARGET,
options: CodexExecutionOptions = {},
): Promise<TmuxResult> {
const { timeout = DEFAULT_TIMEOUT, env = {} } = options;
// tmux send-keys でコマンドを送信
const sendCommand = `tmux send-keys -t ${target} "${command}" Enter`;
try {
// タイムアウト付きで実行
const { stdout, stderr } = await execAsync(sendCommand, {
timeout,
env: { ...process.env, ...env },
});
return {
success: true,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: 0,
};
} catch (error: unknown) {
const err = error as { stdout: string; stderr: string; code: number | null };
return {
success: false,
stdout: err.stdout ?? "",
stderr: err.stderr ?? "",
exitCode: err.code ?? -1,
};
}
}
/**
* Codexでコードレビューを実行
*
* @param content -
* @param options -
* @returns
*/
export async function runCodexReview(
content: string,
options: ReviewOptions = {},
): Promise<ReviewResult> {
const startTime = Date.now();
try {
// Codexに送信するコマンド構築
// 既存のスキルやコマンドを使用
const command = buildCodexCommand(content, options);
// tmux経由でCodexを実行
const result = await execTmux(command, options.tmuxTarget, {
timeout: options.timeout ?? DEFAULT_TIMEOUT,
});
const duration = Date.now() - startTime;
if (!result.success) {
return {
success: false,
error: result.stderr || "Codex execution failed",
duration,
};
}
// 結果をパース
const review = parseCodexOutput(result.stdout);
return {
success: true,
review,
duration,
};
} catch (error) {
const duration = Date.now() - startTime;
return {
success: false,
error: error instanceof Error ? error.message : String(error),
duration,
};
}
}
/**
* Codexコマンドを構築
*
* @param content -
* @param options -
* @returns
*/
function buildCodexCommand(content: string, options: ReviewOptions): string {
// コンテンツを一時ファイルに保存または引数として渡す
// 長いコードの場合はファイル経由が安全
const maxLength = 1000;
const useFile = content.length > maxLength;
if (useFile) {
// TODO: 一時ファイルを使用する場合
// 今回は簡易的に引数渡し
return `codex review "${content.replace(/"/g, '\\"')}"`;
}
// オプションを付与
const opts: string[] = [];
if (options.threshold) {
opts.push(`--threshold ${options.threshold}`);
}
if (options.issuesOnly) {
opts.push("--issues-only");
}
if (options.suggestionsOnly) {
opts.push("--suggestions-only");
}
if (options.verbose) {
opts.push("--verbose");
}
const cmd = `codex review ${opts.join(" ")} ${content}`;
return cmd;
}
/**
* Codex出力をパース
*
* @param output - Codex出力
* @returns
*/
export function parseCodexOutput(output: string): CodexReview {
const lines = output.split("\n");
const issues: ReviewIssue[] = [];
const suggestions: ReviewSuggestion[] = [];
let currentSection: "summary" | "issues" | "suggestions" | "score" = "summary";
let summary = "";
const score: ReviewScore = {
overall: 0,
accuracy: 0,
completeness: 0,
style: 0,
security: 0,
};
for (const line of lines) {
const trimmed = line.trim();
// セクション判定
if (trimmed.startsWith("## Summary")) {
currentSection = "summary";
continue;
} else if (trimmed.startsWith("## Issues")) {
currentSection = "issues";
continue;
} else if (trimmed.startsWith("## Suggestions")) {
currentSection = "suggestions";
continue;
} else if (trimmed.startsWith("## Score")) {
currentSection = "score";
continue;
}
// パース処理
if (currentSection === "summary" && trimmed) {
summary += trimmed + "\n";
} else if (currentSection === "issues") {
const issue = parseIssueLine(trimmed);
if (issue) issues.push(issue);
} else if (currentSection === "suggestions") {
const suggestion = parseSuggestionLine(trimmed);
if (suggestion) suggestions.push(suggestion);
} else if (currentSection === "score") {
parseScoreLine(trimmed, score);
}
}
// 承認判定 (閾値チェック)
const approved = score.overall >= DEFAULT_THRESHOLD;
return {
id: `review-${Date.now()}`,
target: "code-snippet",
score,
issues,
suggestions,
summary: summary.trim(),
approved,
timestamp: Date.now(),
duration: 0, // 呼び出し元で設定
};
}
/**
*
*/
function parseIssueLine(line: string): ReviewIssue | null {
// 形式: [SEVERITY] file.ts:123: message
const match = line.match(/^\[(critical|major|minor|nitpick)\]\s+(.+)$/);
if (!match) return null;
const severity = match[1] as "critical" | "major" | "minor" | "nitpick";
const rest = match[2];
// ファイルと行番号を抽出
const fileMatch = rest.match(/^([^:]+):(\d+):\s*(.+)$/);
if (fileMatch) {
return {
id: `issue-${Math.random().toString(36).slice(2, 11)}`,
severity,
category: "general",
message: fileMatch[3],
file: fileMatch[1],
line: parseInt(fileMatch[2], 10),
};
}
return {
id: `issue-${Math.random().toString(36).slice(2, 11)}`,
severity,
category: "general",
message: rest,
};
}
/**
*
*/
function parseSuggestionLine(line: string): ReviewSuggestion | null {
// 形式: [PRIORITY] category: description
const match = line.match(/^\[(low|medium|high)\]\s+(.+):(.+)$/);
if (!match) return null;
const priority = match[1] as "low" | "medium" | "high";
const category = match[2].trim();
const description = match[3].trim();
return {
id: `suggestion-${Math.random().toString(36).slice(2, 11)}`,
priority,
category,
description,
};
}
/**
*
*/
function parseScoreLine(line: string, score: ReviewScore): void {
// 形式: overall: 0.85, accuracy: 0.9, ...
const parts = line.split(",");
for (const part of parts) {
const [key, value] = part.split(":").map((s) => s.trim());
const numValue = parseFloat(value);
if (!isNaN(numValue)) {
switch (key) {
case "overall":
score.overall = numValue;
break;
case "accuracy":
score.accuracy = numValue;
break;
case "completeness":
score.completeness = numValue;
break;
case "style":
score.style = numValue;
break;
case "security":
score.security = numValue;
break;
}
}
}
}
/**
*
*
* @param code -
* @param options -
* @returns
*/
export function createReviewRequest(code: string, options: ReviewOptions = {}): ReviewRequest {
return {
id: `review-req-${Date.now()}`,
code,
language: detectLanguage(code),
options,
};
}
/**
*
*/
function detectLanguage(code: string): string {
// 簡易的な実装
if (code.includes("interface ") || code.includes("type ") || code.includes(": ")) {
return "typescript";
}
if (code.includes("def ") || code.includes("import ")) {
return "python";
}
if (code.includes("fn ") || code.includes("pub ")) {
return "rust";
}
return "javascript";
}
/**
*
*
* @param result -
* @param threshold -
* @returns
*/
export function evaluateReview(
result: ReviewResult,
threshold: number = DEFAULT_THRESHOLD,
): {
approved: boolean;
reason: string;
} {
if (!result.success || !result.review) {
return {
approved: false,
reason: result.error || "Review failed",
};
}
const { review } = result;
if (!review.approved) {
return {
approved: false,
reason: `Score ${review.score.overall} below threshold ${threshold}`,
};
}
// Critical issuesがある場合は拒否
const criticalIssues = review.issues.filter((i) => i.severity === "critical");
if (criticalIssues.length > 0) {
return {
approved: false,
reason: `${criticalIssues.length} critical issue(s) found`,
};
}
return {
approved: true,
reason: "Review passed",
};
}
/**
*
*/
export interface ReviewFormatter {
/** マークダウン形式に変換 */
toMarkdown(): string;
/** JSON形式に変換 */
toJSON(): string;
}
/**
*
*/
export function formatReview(review: CodexReview): ReviewFormatter {
return {
toMarkdown() {
const lines: string[] = [];
lines.push("## Codex Review Report");
lines.push("");
lines.push(`**Score**: ${review.score.overall.toFixed(2)}`);
lines.push(
`Detail: accuracy=${review.score.accuracy.toFixed(2)}, ` +
`completeness=${review.score.completeness.toFixed(2)}, ` +
`style=${review.score.style.toFixed(2)}, ` +
`security=${review.score.security.toFixed(2)}`,
);
lines.push("");
if (review.issues.length > 0) {
lines.push("### Issues");
lines.push("");
for (const issue of review.issues) {
const severityEmoji = {
critical: "🔴",
major: "🟠",
minor: "🟡",
nitpick: "🟢",
};
const location = issue.file ? `${issue.file}:${issue.line}` : "";
lines.push(
`${severityEmoji[issue.severity]} [${issue.category}] ${location}: ${issue.message}`,
);
}
lines.push("");
}
if (review.suggestions.length > 0) {
lines.push("### Suggestions");
lines.push("");
for (const suggestion of review.suggestions) {
const priorityEmoji = {
high: "⬆️",
medium: "➡️",
low: "⬇️",
};
lines.push(
`${priorityEmoji[suggestion.priority]} [${suggestion.category}] ${suggestion.description}`,
);
}
lines.push("");
}
lines.push(`**Approved**: ${review.approved ? "✅" : "❌"}`);
lines.push("");
lines.push(review.summary);
return lines.join("\n");
},
toJSON() {
return JSON.stringify(review, null, 2);
},
};
}

8
src/review/index.ts Normal file
View File

@ -0,0 +1,8 @@
/**
* Codexレビュー統合モジュール
*
* @module review
*/
export * from "./types.js";
export * from "./codex-reviewer.js";

160
src/review/types.ts Normal file
View File

@ -0,0 +1,160 @@
/**
* Codexレビュー統合の型定義
*
* tmux経由でCodexを実行し
*/
/**
* (0-1)
*/
export interface ReviewScore {
/** 総合スコア */
overall: number;
/** 正確性 */
accuracy: number;
/** 完全性 */
completeness: number;
/** スタイル */
style: number;
/** セキュリティ */
security: number;
}
/**
*
*/
export interface ReviewIssue {
/** 問題ID */
id: string;
/** 重大度 */
severity: "critical" | "major" | "minor" | "nitpick";
/** カテゴリ */
category: string;
/** メッセージ */
message: string;
/** ファイルパス */
file?: string;
/** 行番号 */
line?: number;
/** コードスニペット */
code?: string;
/** 修正提案 */
suggestion?: string;
}
/**
*
*/
export interface ReviewSuggestion {
/** 提案ID */
id: string;
/** 優先度 */
priority: "low" | "medium" | "high";
/** カテゴリ */
category: string;
/** 説明 */
description: string;
/** 例 */
example?: {
before: string;
after: string;
};
}
/**
* Codexレビュー結果
*/
export interface CodexReview {
/** レビューID */
id: string;
/** 対象コード/ファイル */
target: string;
/** スコア */
score: ReviewScore;
/** 問題一覧 */
issues: ReviewIssue[];
/** 提案一覧 */
suggestions: ReviewSuggestion[];
/** 要約 */
summary: string;
/** 承認判定 */
approved: boolean;
/** 実行時刻 */
timestamp: number;
/** 実行時間(ms) */
duration: number;
}
/**
*
*/
export interface ReviewRequest {
/** リクエストID */
id: string;
/** 対象コード */
code: string;
/** ファイルパス */
filePath?: string;
/** 言語 */
language?: string;
/** オプション */
options?: ReviewOptions;
}
/**
*
*/
export interface ReviewOptions {
/** スコア閾値 (デフォルト: 0.8) */
threshold?: number;
/** 問題のみを抽出 */
issuesOnly?: boolean;
/** 提案のみを抽出 */
suggestionsOnly?: boolean;
/** 最大実行時間(ms) */
timeout?: number;
/** 詳細モード */
verbose?: boolean;
}
/**
*
*/
export interface ReviewResult {
/** 成功判定 */
success: boolean;
/** レビュー結果 */
review?: CodexReview;
/** エラー */
error?: string;
/** 実行時間(ms) */
duration: number;
}
/**
* Codex実行オプション
*/
export interface CodexExecutionOptions {
/** tmuxターゲット (ペインID) */
tmuxTarget?: string;
/** コマンド */
command?: string;
/** タイムアウト(ms) */
timeout?: number;
/** 環境変数 */
env?: Record<string, string>;
}
/**
* tmuxコマンド実行結果
*/
export interface TmuxResult {
/** 成功判定 */
success: boolean;
/** 標準出力 */
stdout: string;
/** 標準エラー */
stderr: string;
/** 終了コード */
exitCode: number;
}