Skip to content

feat: Add project statistics command (--stats) to analyze codebase metrics #1288

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion codex-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"@types/diff": "^7.0.2",
"@types/js-yaml": "^4.0.9",
"@types/marked-terminal": "^6.1.1",
"@types/node": "^20.14.11",
"@types/react": "^18.0.32",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
Expand Down Expand Up @@ -84,4 +85,4 @@
"type": "git",
"url": "https://github.com/openai/codex"
}
}
}
74 changes: 47 additions & 27 deletions codex-cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { createInputItem } from "./utils/input-utils";
import { initLogger } from "./utils/logger/log";
import { isModelSupportedForResponses } from "./utils/model-utils.js";
import { parseToolCall } from "./utils/parsers";
import { showProjectStats } from "./utils/project-status";
import { onExit, setInkRenderer } from "./utils/terminal";
import chalk from "chalk";
import { spawnSync } from "child_process";
Expand Down Expand Up @@ -60,6 +61,7 @@ const cli = meow(
-i, --image <path> Path(s) to image files to include as input
-v, --view <rollout> Inspect a previously saved rollout instead of starting a session
-q, --quiet Non-interactive mode that only prints the assistant's final output
-s, --stats Show project code statistics and exit
-c, --config Open the instructions file in your editor
-w, --writable-root <path> Writable folder for sandbox in full-auto mode (can be specified multiple times)
-a, --approval-mode <mode> Override the approval policy: 'suggest', 'auto-edit', or 'full-auto'
Expand All @@ -71,6 +73,7 @@ const cli = meow(
--project-doc <file> Include an additional markdown file at <file> as context
--full-stdout Do not truncate stdout/stderr from command outputs
--notify Enable desktop notifications for responses
--json Output in JSON format (use with --stats or --quiet)

--disable-response-storage Disable server‑side response storage (sends the
full conversation context with every request)
Expand All @@ -91,6 +94,8 @@ const cli = meow(
Examples
$ codex "Write and run a python program that prints ASCII art"
$ codex -q "fix build issues"
$ codex --stats
$ codex --stats --json
$ codex completion bash
`,
{
Expand All @@ -109,11 +114,20 @@ const cli = meow(
aliases: ["q"],
description: "Non-interactive quiet mode",
},
stats: {
type: "boolean",
aliases: ["s"],
description: "Show project code statistics and exit",
},
config: {
type: "boolean",
aliases: ["c"],
description: "Open the instructions file in your editor",
},
json: {
type: "boolean",
description: "Output in JSON format (use with --stats or --quiet)",
},
dangerouslyAutoApproveEverything: {
type: "boolean",
description:
Expand Down Expand Up @@ -221,6 +235,12 @@ if (cli.flags.help) {
cli.showHelp();
}

// For --stats, show project statistics and exit.
if (cli.flags.stats) {
await showProjectStats(Boolean(cli.flags.json));
process.exit(0);
}

// For --config, open custom instructions file in editor and exit.
if (cli.flags.config) {
try {
Expand Down Expand Up @@ -262,19 +282,19 @@ if (!apiKey && !NO_API_KEY_REQUIRED.has(provider.toLowerCase())) {
// eslint-disable-next-line no-console
console.error(
`\n${chalk.red(`Missing ${provider} API key.`)}\n\n` +
`Set the environment variable ${chalk.bold(
`Set the environment variable ${chalk.bold(
`${provider.toUpperCase()}_API_KEY`,
)} ` +
`and re-run this command.\n` +
`${
provider.toLowerCase() === "openai"
? `You can create a key here: ${chalk.bold(
chalk.underline("https://platform.openai.com/account/api-keys"),
)}\n`
: `You can create a ${chalk.bold(
`${provider.toUpperCase()}_API_KEY`,
)} ` +
`and re-run this command.\n` +
`${
provider.toLowerCase() === "openai"
? `You can create a key here: ${chalk.bold(
chalk.underline("https://platform.openai.com/account/api-keys"),
)}\n`
: `You can create a ${chalk.bold(
`${provider.toUpperCase()}_API_KEY`,
)} ` + `in the ${chalk.bold(`${provider}`)} dashboard.\n`
}`,
)} ` + `in the ${chalk.bold(`${provider}`)} dashboard.\n`
}`,
);
process.exit(1);
}
Expand Down Expand Up @@ -307,7 +327,7 @@ if (cli.flags.flexMode) {
// eslint-disable-next-line no-console
console.error(
`The --flex-mode option is only supported when using the 'o3' or 'o4-mini' models. ` +
`Current model: '${config.model}'.`,
`Current model: '${config.model}'.`,
);
process.exit(1);
}
Expand All @@ -320,9 +340,9 @@ if (
// eslint-disable-next-line no-console
console.error(
`The model "${config.model}" does not appear in the list of models ` +
`available to your account. Double-check the spelling (use\n` +
` openai models list\n` +
`to see the full list) or choose another model with the --model flag.`,
`available to your account. Double-check the spelling (use\n` +
` openai models list\n` +
`to see the full list) or choose another model with the --model flag.`,
);
process.exit(1);
}
Expand Down Expand Up @@ -377,8 +397,8 @@ if (cli.flags.quiet) {
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;

await runQuietMode({
prompt,
Expand Down Expand Up @@ -408,8 +428,8 @@ const approvalPolicy: ApprovalPolicy =
cli.flags.fullAuto || cli.flags.approvalMode === "full-auto"
? AutoApprovalMode.FULL_AUTO
: cli.flags.autoEdit || cli.flags.approvalMode === "auto-edit"
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;

const instance = render(
<App
Expand Down Expand Up @@ -477,12 +497,12 @@ function formatResponseItemForQuietMode(item: ResponseItem): string {
}

async function runQuietMode({
prompt,
imagePaths,
approvalPolicy,
additionalWritableRoots,
config,
}: {
prompt,
imagePaths,
approvalPolicy,
additionalWritableRoots,
config,
}: {
prompt: string;
imagePaths: Array<string>;
approvalPolicy: ApprovalPolicy;
Expand Down Expand Up @@ -552,4 +572,4 @@ if (process.stdin.isTTY) {

// Ensure terminal clean-up always runs, even when other code calls
// `process.exit()` directly.
process.once("exit", onExit);
process.once("exit", onExit);
204 changes: 204 additions & 0 deletions codex-cli/src/utils/__tests__/project-stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import {
analyzeProject,
formatFileSize,
formatProjectStats,
} from "../project-status";
import { writeFile, mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it, expect, beforeEach, afterEach } from "vitest";

describe("project-stats", () => {
let tempDir: string;

beforeEach(async () => {
// 创建临时测试目录
tempDir = await mkdtemp(join(tmpdir(), "codex-test-"));
});

afterEach(async () => {
// 清理临时目录
await rm(tempDir, { recursive: true, force: true });
});

it("should analyze a simple project structure", async () => {
// 创建测试文件
await writeFile(
join(tempDir, "index.js"),
'console.log("hello");\n// Comment\n',
);
await writeFile(
join(tempDir, "style.css"),
"body { margin: 0; }\n",
);
await writeFile(
join(tempDir, "README.md"),
"# Test Project\n\nDescription here.\n",
);

// 创建子目录
const srcDir = join(tempDir, "src");
await mkdir(srcDir);
await writeFile(
join(srcDir, "app.ts"),
"const x: number = 42;\nexport default x;\n",
);

const stats = await analyzeProject(tempDir);

expect(stats.totalFiles).toBe(4);
expect(stats.totalLines).toBe(6); // 只计算代码文件的行数

// 使用可选链和非空断言来处理可能为 undefined 的情况
const jsExt = stats.filesByExtension[".js"];
expect(jsExt).toBeDefined();
expect(jsExt!.extension).toBe(".js");
expect(jsExt!.count).toBe(1);
expect(jsExt!.totalLines).toBe(2);

const tsExt = stats.filesByExtension[".ts"];
expect(tsExt).toBeDefined();
expect(tsExt!.extension).toBe(".ts");
expect(tsExt!.count).toBe(1);
expect(tsExt!.totalLines).toBe(2);

const cssExt = stats.filesByExtension[".css"];
expect(cssExt).toBeDefined();
expect(cssExt!.extension).toBe(".css");
expect(cssExt!.count).toBe(1);
expect(cssExt!.totalLines).toBe(1);
});

it("should ignore node_modules and .git directories", async () => {
// 创建应该被忽略的目录和文件
await mkdir(join(tempDir, "node_modules"));
await writeFile(
join(tempDir, "node_modules", "package.js"),
"module.exports = {};",
);

await mkdir(join(tempDir, ".git"));
await writeFile(join(tempDir, ".git", "config"), "[core]");

// 创建应该被包含的文件
await writeFile(join(tempDir, "index.js"), 'console.log("test");');

const stats = await analyzeProject(tempDir);

expect(stats.totalFiles).toBe(1);

const jsExt = stats.filesByExtension[".js"];
expect(jsExt).toBeDefined();
expect(jsExt!.count).toBe(1);
});

it("should format file sizes correctly", () => {
expect(formatFileSize(512)).toBe("512.0 B");
expect(formatFileSize(1024)).toBe("1.0 KB");
expect(formatFileSize(1536)).toBe("1.5 KB");
expect(formatFileSize(1048576)).toBe("1.0 MB");
expect(formatFileSize(1073741824)).toBe("1.0 GB");
});

it("should track recently modified files", async () => {
await writeFile(join(tempDir, "old.js"), "old file");

// 等待一毫秒确保时间戳不同
await new Promise((resolve) => setTimeout(resolve, 10));

await writeFile(join(tempDir, "new.js"), "new file");

const stats = await analyzeProject(tempDir);

expect(stats.recentFiles).toHaveLength(2);
expect(stats.recentFiles.length).toBeGreaterThan(0);

// 安全地访问数组元素
const firstFile = stats.recentFiles[0];
const secondFile = stats.recentFiles[1];

expect(firstFile).toBeDefined();
expect(secondFile).toBeDefined();
expect(firstFile!.path).toBe("new.js"); // 最新的文件在前
expect(secondFile!.path).toBe("old.js");
});

it("should generate formatted output", async () => {
await writeFile(join(tempDir, "test.js"), 'console.log("test");\n');

const stats = await analyzeProject(tempDir);
const output = formatProjectStats(stats);

expect(output).toContain("📊 Project Statistics");
expect(output).toContain("📁 Total Files: 1");
expect(output).toContain("📝 Total Lines of Code: 1");
expect(output).toContain(".js");
expect(output).toContain("🕒 Recently Modified Files:");
});

it("should handle empty directories", async () => {
const stats = await analyzeProject(tempDir);

expect(stats.totalFiles).toBe(0);
expect(stats.totalLines).toBe(0);
expect(Object.keys(stats.filesByExtension)).toHaveLength(0);
expect(stats.recentFiles).toHaveLength(0);
});

it("should handle file extensions properly", async () => {
// 创建没有扩展名的文件
await writeFile(join(tempDir, "Dockerfile"), "FROM node:18");

// 创建有扩展名的文件
await writeFile(join(tempDir, "test.json"), '{"test": true}');

const stats = await analyzeProject(tempDir);

expect(stats.totalFiles).toBe(2);

// 检查无扩展名文件
const noExt = stats.filesByExtension["no-extension"];
expect(noExt).toBeDefined();
expect(noExt!.count).toBe(1);

// 检查 JSON 文件
const jsonExt = stats.filesByExtension[".json"];
expect(jsonExt).toBeDefined();
expect(jsonExt!.count).toBe(1);
});

it("should calculate project size correctly", async () => {
const content = "test content";
await writeFile(join(tempDir, "test.txt"), content);

const stats = await analyzeProject(tempDir);

expect(stats.projectSize).toBeGreaterThan(0);
expect(stats.projectSize).toBe(content.length);
});

it("should sort recent files by modification time", async () => {
// 创建多个文件,确保时间戳不同
await writeFile(join(tempDir, "file1.txt"), "content1");
await new Promise((resolve) => setTimeout(resolve, 10));

await writeFile(join(tempDir, "file2.txt"), "content2");
await new Promise((resolve) => setTimeout(resolve, 10));

await writeFile(join(tempDir, "file3.txt"), "content3");

const stats = await analyzeProject(tempDir);

expect(stats.recentFiles).toHaveLength(3);

// 验证排序(最新的在前)
const files = stats.recentFiles;
expect(files[0]!.path).toBe("file3.txt");
expect(files[1]!.path).toBe("file2.txt");
expect(files[2]!.path).toBe("file1.txt");

// 验证时间戳递减
expect(files[0]!.lastModified.getTime()).toBeGreaterThan(files[1]!.lastModified.getTime());
expect(files[1]!.lastModified.getTime()).toBeGreaterThan(files[2]!.lastModified.getTime());
});
});
Loading
Loading