Skip to content

Commit de20807

Browse files
authored
feat: add pi-code-review automated code review extension (#96)
## Summary - Adds `pi-code-review`, a new Pi extension implementing automated, language-aware code review (closes #80) - **Automatic mode** (zero cost): hooks into `tool_execution_end` to track edited files per turn, injects language-specific review checklist into system prompt at `before_agent_start` so the agent self-reviews - **On-demand `/review`**: detects changed files via git diff, makes a direct Haiku LLM call for structured findings (CRITICAL/HIGH/MEDIUM/INFO) with line references and suggestions, falls back to prompt-based review if no API key - Supports TypeScript, Python, Go, Rust, Java, PHP with per-language review checklists - 8 source modules, 7 test files, 70 tests passing, typecheck/lint/build clean ## Test plan - [ ] `npm run check` passes (tests + lint + typecheck) - [ ] `npm run build -w packages/pi-code-review` compiles successfully - [ ] Install in Pi session via `pi install npm:pi-code-review` - [ ] Edit a file and verify review checklist appears in next turn's system prompt - [ ] Run `/review` and verify structured findings output - [ ] Run `/review --staged` and `/review src/foo.ts` with specific files - [ ] Verify fallback prompt mode works when no Anthropic API key is configured
1 parent 9824ca5 commit de20807

26 files changed

Lines changed: 1607 additions & 3 deletions

.release-please-manifest.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"packages/pi-continuous-learning": "0.13.2",
33
"packages/pi-red-green": "0.2.1",
44
"packages/pi-compass": "0.2.0",
5-
"packages/pi-simplify": "0.2.0"
5+
"packages/pi-simplify": "0.2.0",
6+
"packages/pi-code-review": "0.1.0"
67
}

AGENTS.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ packages/
2020
pi-simplify/ # Pi extension: code simplification (/simplify command)
2121
src/ # TypeScript source + tests (*.test.ts alongside source)
2222
CHANGELOG.md # Release history (managed by release-please)
23+
pi-code-review/ # Pi extension: automated language-aware code review
24+
src/ # TypeScript source + tests (*.test.ts alongside source)
25+
CHANGELOG.md # Release history (managed by release-please)
2326
```
2427

2528
## Commands (run from repo root)

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A monorepo of [Pi](https://github.com/nicholasgasior/pi-coding-agent) extensions
1313
| [pi-red-green](packages/pi-red-green) | TDD enforcement for agent sessions: RED-GREEN-REFACTOR state machine with phase-specific prompt injection and test run detection | [![npm](https://img.shields.io/npm/v/pi-red-green)](https://www.npmjs.com/package/pi-red-green) |
1414
| [pi-compass](packages/pi-compass) | Codebase navigation: generates structured codemaps and interactive code tours for faster agent onboarding | [![npm](https://img.shields.io/npm/v/pi-compass)](https://www.npmjs.com/package/pi-compass) |
1515
| [pi-simplify](packages/pi-simplify) | Code simplification: reviews recently changed files for clarity, consistency, and maintainability | [![npm](https://img.shields.io/npm/v/pi-simplify)](https://www.npmjs.com/package/pi-simplify) |
16+
| [pi-code-review](packages/pi-code-review) | Automated code review: language-aware review after edits with structured findings | [![npm](https://img.shields.io/npm/v/pi-code-review)](https://www.npmjs.com/package/pi-code-review) |
1617

1718
## Development
1819

package-lock.json

Lines changed: 25 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/pi-code-review/README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# pi-code-review
2+
3+
A [Pi](https://github.com/nicholasgasior/pi-coding-agent) extension that provides automated, language-aware code review after the agent writes or modifies files.
4+
5+
## Installation
6+
7+
```bash
8+
pi install npm:pi-code-review
9+
```
10+
11+
## Features
12+
13+
### Automatic review (zero cost)
14+
15+
After each turn where the agent edits files, a language-aware review checklist is injected into the system prompt. The agent self-reviews before proceeding, catching type safety issues, error handling gaps, security concerns, and naming problems.
16+
17+
Supports: TypeScript, Python, Go, Rust, Java, PHP.
18+
19+
### On-demand review (`/review`)
20+
21+
Run a thorough code review with structured findings:
22+
23+
```
24+
/review # review all uncommitted changes
25+
/review --staged # only staged changes
26+
/review --ref=main # diff against main
27+
/review src/foo.ts # specific files
28+
```
29+
30+
When an Anthropic API key is available, `/review` uses a direct Haiku call for structured output with severity-leveled findings (CRITICAL / HIGH / MEDIUM / INFO). Without an API key, it falls back to a prompt-based review via the session agent.
31+
32+
## How it works
33+
34+
1. **Edit tracking**: hooks into `tool_execution_end` to collect files modified by Write/Edit tools during each turn
35+
2. **Turn batching**: at `turn_end`, snapshots the accumulated edits (no per-edit overhead)
36+
3. **Prompt injection**: at `before_agent_start`, injects a brief language-specific review checklist into the system prompt
37+
4. **On-demand**: `/review` reads file contents, calls Haiku for structured analysis, and formats findings with severity, line numbers, and suggestions
38+
39+
## License
40+
41+
MIT
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
{
2+
"name": "pi-code-review",
3+
"version": "0.1.0",
4+
"description": "A Pi extension that provides automated, language-aware code review after the agent writes or modifies files.",
5+
"type": "module",
6+
"license": "MIT",
7+
"author": "Matt Devy",
8+
"repository": {
9+
"type": "git",
10+
"url": "https://github.com/MattDevy/pi-extensions.git",
11+
"directory": "packages/pi-code-review"
12+
},
13+
"homepage": "https://github.com/MattDevy/pi-extensions/tree/main/packages/pi-code-review#readme",
14+
"bugs": {
15+
"url": "https://github.com/MattDevy/pi-extensions/issues"
16+
},
17+
"keywords": [
18+
"pi-package",
19+
"pi-extension",
20+
"pi-coding-agent",
21+
"code-review",
22+
"ai",
23+
"llm",
24+
"ai-agent",
25+
"coding-assistant",
26+
"developer-tools"
27+
],
28+
"engines": {
29+
"node": ">=18"
30+
},
31+
"files": [
32+
"dist",
33+
"src",
34+
"!src/**/*.test.ts",
35+
"README.md",
36+
"LICENSE"
37+
],
38+
"main": "./dist/index.js",
39+
"types": "./dist/index.d.ts",
40+
"pi": {
41+
"extensions": [
42+
"dist/index.js"
43+
]
44+
},
45+
"scripts": {
46+
"clean": "rm -rf dist tsconfig.build.tsbuildinfo",
47+
"build": "npm run clean && tsc -p tsconfig.build.json",
48+
"typecheck": "tsc --noEmit",
49+
"test": "vitest run",
50+
"lint": "eslint src/",
51+
"check": "vitest run && eslint src/ && tsc --noEmit",
52+
"prepublishOnly": "npm run build && npm run check",
53+
"prepack": "test -d dist || { echo 'Error: dist/ missing. Run npm run build first.' && exit 1; }"
54+
},
55+
"peerDependencies": {
56+
"@mariozechner/pi-coding-agent": "^0.62.0",
57+
"@mariozechner/pi-ai": "^0.62.0",
58+
"@mariozechner/pi-tui": "^0.62.0",
59+
"@sinclair/typebox": "^0.34.0"
60+
},
61+
"devDependencies": {
62+
"@types/node": "^24.0.0",
63+
"@typescript-eslint/eslint-plugin": "^8.0.0",
64+
"@typescript-eslint/parser": "^8.0.0",
65+
"eslint": "^9.0.0",
66+
"typescript": "^5.7.0",
67+
"vitest": "^3.0.0"
68+
}
69+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { describe, it, expect } from "vitest";
2+
import { createEditTracker } from "./edit-tracker.js";
3+
4+
describe("createEditTracker", () => {
5+
it("tracks Write tool edits", () => {
6+
const tracker = createEditTracker();
7+
8+
tracker.trackEdit("Write", { file_path: "src/foo.ts" });
9+
10+
tracker.onTurnEnd(0);
11+
const edits = tracker.getLastTurnEdits();
12+
expect(edits).toEqual({
13+
files: [{ path: "src/foo.ts", language: "typescript" }],
14+
turnIndex: 0,
15+
});
16+
});
17+
18+
it("tracks Edit tool edits", () => {
19+
const tracker = createEditTracker();
20+
21+
tracker.trackEdit("Edit", { file_path: "src/bar.py" });
22+
23+
tracker.onTurnEnd(1);
24+
const edits = tracker.getLastTurnEdits();
25+
expect(edits).toEqual({
26+
files: [{ path: "src/bar.py", language: "python" }],
27+
turnIndex: 1,
28+
});
29+
});
30+
31+
it("extracts path from result.path field", () => {
32+
const tracker = createEditTracker();
33+
34+
tracker.trackEdit("Write", { path: "main.go" });
35+
36+
tracker.onTurnEnd(0);
37+
expect(tracker.getLastTurnEdits()?.files).toEqual([
38+
{ path: "main.go", language: "go" },
39+
]);
40+
});
41+
42+
it("extracts path from string result via regex", () => {
43+
const tracker = createEditTracker();
44+
45+
tracker.trackEdit("Edit", "File: src/lib.rs updated successfully");
46+
47+
tracker.onTurnEnd(0);
48+
expect(tracker.getLastTurnEdits()?.files).toEqual([
49+
{ path: "src/lib.rs", language: "rust" },
50+
]);
51+
});
52+
53+
it("deduplicates files edited multiple times in a turn", () => {
54+
const tracker = createEditTracker();
55+
56+
tracker.trackEdit("Edit", { file_path: "src/foo.ts" });
57+
tracker.trackEdit("Edit", { file_path: "src/foo.ts" });
58+
tracker.trackEdit("Write", { file_path: "src/foo.ts" });
59+
60+
tracker.onTurnEnd(0);
61+
expect(tracker.getLastTurnEdits()?.files).toHaveLength(1);
62+
});
63+
64+
it("tracks multiple different files", () => {
65+
const tracker = createEditTracker();
66+
67+
tracker.trackEdit("Write", { file_path: "src/a.ts" });
68+
tracker.trackEdit("Edit", { file_path: "src/b.py" });
69+
tracker.trackEdit("Write", { file_path: "src/c.go" });
70+
71+
tracker.onTurnEnd(0);
72+
expect(tracker.getLastTurnEdits()?.files).toHaveLength(3);
73+
});
74+
75+
it("ignores non-Write/Edit tools", () => {
76+
const tracker = createEditTracker();
77+
78+
tracker.trackEdit("Bash", { stdout: "ok" });
79+
tracker.trackEdit("Read", { file_path: "src/foo.ts" });
80+
81+
tracker.onTurnEnd(0);
82+
expect(tracker.getLastTurnEdits()).toBeNull();
83+
});
84+
85+
it("ignores non-code files", () => {
86+
const tracker = createEditTracker();
87+
88+
tracker.trackEdit("Write", { file_path: "package.json" });
89+
tracker.trackEdit("Edit", { file_path: "README.md" });
90+
91+
tracker.onTurnEnd(0);
92+
expect(tracker.getLastTurnEdits()).toBeNull();
93+
});
94+
95+
it("clears accumulator after onTurnEnd", () => {
96+
const tracker = createEditTracker();
97+
98+
tracker.trackEdit("Write", { file_path: "src/foo.ts" });
99+
tracker.onTurnEnd(0);
100+
101+
tracker.trackEdit("Write", { file_path: "src/bar.ts" });
102+
tracker.onTurnEnd(1);
103+
104+
const edits = tracker.getLastTurnEdits();
105+
expect(edits?.files).toEqual([{ path: "src/bar.ts", language: "typescript" }]);
106+
expect(edits?.turnIndex).toBe(1);
107+
});
108+
109+
it("returns null when no edits in last turn", () => {
110+
const tracker = createEditTracker();
111+
112+
tracker.onTurnEnd(0);
113+
114+
expect(tracker.getLastTurnEdits()).toBeNull();
115+
});
116+
117+
it("clearLastTurnEdits removes snapshot", () => {
118+
const tracker = createEditTracker();
119+
120+
tracker.trackEdit("Write", { file_path: "src/foo.ts" });
121+
tracker.onTurnEnd(0);
122+
tracker.clearLastTurnEdits();
123+
124+
expect(tracker.getLastTurnEdits()).toBeNull();
125+
});
126+
127+
it("handles null/undefined result gracefully", () => {
128+
const tracker = createEditTracker();
129+
130+
tracker.trackEdit("Write", null);
131+
tracker.trackEdit("Edit", undefined);
132+
133+
tracker.onTurnEnd(0);
134+
expect(tracker.getLastTurnEdits()).toBeNull();
135+
});
136+
});
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { detectLanguage } from "./language-detector.js";
2+
import type { EditedFile, TurnEdits } from "./types.js";
3+
4+
const FILE_PATH_RE = /(?:File|file|path)[:\s]+(\S+)/;
5+
6+
type TrackedTool = "Write" | "Edit";
7+
const TRACKED_TOOLS = new Set<TrackedTool>(["Write", "Edit"]);
8+
9+
function extractFilePath(result: unknown): string | null {
10+
if (!result) return null;
11+
12+
if (typeof result === "object") {
13+
const obj = result as Record<string, unknown>;
14+
if (typeof obj["file_path"] === "string") return obj["file_path"];
15+
if (typeof obj["path"] === "string") return obj["path"];
16+
}
17+
18+
if (typeof result === "string") {
19+
const match = result.match(FILE_PATH_RE);
20+
return match?.[1] ?? null;
21+
}
22+
23+
return null;
24+
}
25+
26+
export interface EditTracker {
27+
trackEdit(toolName: string, result: unknown): void;
28+
onTurnEnd(turnIndex: number): void;
29+
getLastTurnEdits(): TurnEdits | null;
30+
clearLastTurnEdits(): void;
31+
}
32+
33+
export function createEditTracker(): EditTracker {
34+
const current = new Map<string, EditedFile>();
35+
let lastTurn: TurnEdits | null = null;
36+
37+
return {
38+
trackEdit(toolName: string, result: unknown): void {
39+
if (!TRACKED_TOOLS.has(toolName as TrackedTool)) return;
40+
41+
const path = extractFilePath(result);
42+
if (!path) return;
43+
44+
const language = detectLanguage(path);
45+
if (!language) return;
46+
if (current.has(path)) return;
47+
48+
current.set(path, { path, language });
49+
},
50+
51+
onTurnEnd(turnIndex: number): void {
52+
if (current.size === 0) {
53+
lastTurn = null;
54+
} else {
55+
lastTurn = { files: [...current.values()], turnIndex };
56+
}
57+
current.clear();
58+
},
59+
60+
getLastTurnEdits(): TurnEdits | null {
61+
return lastTurn;
62+
},
63+
64+
clearLastTurnEdits(): void {
65+
lastTurn = null;
66+
},
67+
};
68+
}

0 commit comments

Comments
 (0)