@dreki-gg/pi-code-reviewer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -0
- package/extensions/code-reviewer/commands/review-init.ts +26 -0
- package/extensions/code-reviewer/commands/review-lenses.ts +38 -0
- package/extensions/code-reviewer/commands/review-tool.ts +135 -0
- package/extensions/code-reviewer/commands/review.ts +92 -0
- package/extensions/code-reviewer/config.ts +36 -0
- package/extensions/code-reviewer/diff.ts +78 -0
- package/extensions/code-reviewer/index.ts +13 -0
- package/extensions/code-reviewer/lenses.ts +117 -0
- package/extensions/code-reviewer/parse-args.ts +25 -0
- package/extensions/code-reviewer/report.ts +109 -0
- package/extensions/code-reviewer/reviewer.ts +130 -0
- package/extensions/code-reviewer/types.ts +37 -0
- package/package.json +62 -0
- package/skills/code-review/SKILL.md +57 -0
- package/skills/code-review/lenses/README.md +8 -0
- package/skills/code-review/lenses/code-quality.md +20 -0
- package/skills/code-review/lenses/maintainability.md +20 -0
- package/skills/code-review/lenses/product-vision.md +19 -0
- package/skills/code-review/lenses/ux-design.md +20 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @dreki-gg/pi-code-reviewer
|
|
2
|
+
|
|
3
|
+
Multi-lens code review extension for [pi](https://github.com/earendil-works/pi). Reviews working directory changes through configurable criteria lenses — each project defines its own review standards and tooling.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@dreki-gg/pi-code-reviewer
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This is a **project-local** extension. Install it per-project so each project can configure its own lenses and tools.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
After installing, scaffold the review configuration:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/review-init
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
This creates:
|
|
22
|
+
- `.code-review.json` — config file (lens directory, default lenses)
|
|
23
|
+
- `.code-review/lenses/` — lens definition files
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
/review # Review all changes with all lenses
|
|
29
|
+
/review --lens code-quality # Single lens
|
|
30
|
+
/review --lens quality,ux # Multiple lenses
|
|
31
|
+
/review --base main # Diff against a branch
|
|
32
|
+
/review --staged # Only staged changes
|
|
33
|
+
/review-lenses # List available lenses
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `code_review` tool is also available for programmatic use by the agent.
|
|
37
|
+
|
|
38
|
+
## Lenses
|
|
39
|
+
|
|
40
|
+
A lens is a markdown file that defines review criteria, project tools to run, and severity rules:
|
|
41
|
+
|
|
42
|
+
```md
|
|
43
|
+
# Code Quality
|
|
44
|
+
|
|
45
|
+
Evaluates changes for correctness and adherence to project standards.
|
|
46
|
+
|
|
47
|
+
## Criteria
|
|
48
|
+
- Does the diff introduce new type errors?
|
|
49
|
+
- Are there new unused exports?
|
|
50
|
+
- Does the change follow naming conventions?
|
|
51
|
+
|
|
52
|
+
## Tools
|
|
53
|
+
- `bun run typecheck`
|
|
54
|
+
- `bun run lint`
|
|
55
|
+
|
|
56
|
+
## Severity
|
|
57
|
+
- blocker: Type errors, unresolved imports
|
|
58
|
+
- warning: New lint violations, unused code
|
|
59
|
+
- note: Style suggestions
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Bundled lenses
|
|
63
|
+
|
|
64
|
+
The package ships with four example lenses:
|
|
65
|
+
|
|
66
|
+
| Lens | Focus |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `code-quality` | Correctness, lint, types, dead code |
|
|
69
|
+
| `maintainability` | Coupling, complexity, readability |
|
|
70
|
+
| `product-vision` | Alignment with product direction |
|
|
71
|
+
| `ux-design` | Accessibility, responsiveness, interaction quality |
|
|
72
|
+
|
|
73
|
+
Run `/review-init` to scaffold these (customized for your project's tools) into `.code-review/lenses/`.
|
|
74
|
+
|
|
75
|
+
## Configuration
|
|
76
|
+
|
|
77
|
+
`.code-review.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"lensDir": ".code-review/lenses",
|
|
82
|
+
"defaultLenses": ["code-quality", "maintainability"]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Field | Default | Description |
|
|
87
|
+
| --- | --- | --- |
|
|
88
|
+
| `lensDir` | `.code-review/lenses` | Directory containing lens files |
|
|
89
|
+
| `defaultLenses` | `[]` (all) | Lenses to run when none specified |
|
|
90
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
import { getConfigPath } from '../config';
|
|
4
|
+
|
|
5
|
+
export function registerReviewInitCommand(pi: ExtensionAPI) {
|
|
6
|
+
pi.registerCommand('review-init', {
|
|
7
|
+
description: 'Scaffold a .code-review/ directory with default lenses for this project',
|
|
8
|
+
handler: async (_args, ctx) => {
|
|
9
|
+
const configPath = getConfigPath(ctx.cwd);
|
|
10
|
+
pi.sendUserMessage(
|
|
11
|
+
[
|
|
12
|
+
`Initialize a code review configuration for this project.`,
|
|
13
|
+
``,
|
|
14
|
+
`1. Read the project's AGENTS.md, package.json, and any CONTEXT.md to understand the stack and conventions.`,
|
|
15
|
+
`2. Create a \`.code-review.json\` config file at the project root.`,
|
|
16
|
+
`3. Create lens files in \`.code-review/lenses/\` — start with: code-quality.md, maintainability.md`,
|
|
17
|
+
`4. Each lens should reference the project's actual tools (from package.json scripts).`,
|
|
18
|
+
`5. Tailor the criteria to the project's stack and conventions.`,
|
|
19
|
+
``,
|
|
20
|
+
`Config path: ${configPath}`,
|
|
21
|
+
].join('\n'),
|
|
22
|
+
{ deliverAs: 'followUp' },
|
|
23
|
+
);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
import { loadConfig, getLensDir } from '../config';
|
|
4
|
+
import { discoverLenses } from '../lenses';
|
|
5
|
+
|
|
6
|
+
export function registerReviewLensesCommand(pi: ExtensionAPI) {
|
|
7
|
+
pi.registerCommand('review-lenses', {
|
|
8
|
+
description: 'List available review lenses for this project',
|
|
9
|
+
handler: async (_args, ctx) => {
|
|
10
|
+
const config = loadConfig(ctx.cwd);
|
|
11
|
+
const lensDir = getLensDir(ctx.cwd, config);
|
|
12
|
+
const available = discoverLenses(lensDir);
|
|
13
|
+
|
|
14
|
+
if (available.size === 0) {
|
|
15
|
+
ctx.ui.notify(
|
|
16
|
+
`No lenses in ${config.lensDir}. Run /review-init to scaffold defaults.`,
|
|
17
|
+
'warning',
|
|
18
|
+
);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const lines = [`Available lenses (${config.lensDir}):`, ''];
|
|
23
|
+
for (const [key, lens] of available) {
|
|
24
|
+
const isDefault = config.defaultLenses.includes(key);
|
|
25
|
+
const marker = isDefault ? ' ★' : '';
|
|
26
|
+
const tools = lens.tools.length > 0 ? ` [${lens.tools.length} tool(s)]` : '';
|
|
27
|
+
lines.push(` ${key}${marker} — ${lens.description}${tools}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (config.defaultLenses.length > 0) {
|
|
31
|
+
lines.push('');
|
|
32
|
+
lines.push(`★ = default lens (runs when no --lens specified)`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
ctx.ui.notify(lines.join('\n'), 'info');
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Type } from 'typebox';
|
|
3
|
+
|
|
4
|
+
import { loadConfig, getLensDir } from '../config';
|
|
5
|
+
import { collectDiff, getChangedFiles } from '../diff';
|
|
6
|
+
import { discoverLenses, getLensContent } from '../lenses';
|
|
7
|
+
import { reviewWithLens } from '../reviewer';
|
|
8
|
+
import { buildReport } from '../report';
|
|
9
|
+
import type { LensResult, ReviewConfig, ReviewReport } from '../types';
|
|
10
|
+
|
|
11
|
+
export function registerReviewTool(pi: ExtensionAPI) {
|
|
12
|
+
pi.registerTool({
|
|
13
|
+
name: 'code_review',
|
|
14
|
+
label: 'Code Review',
|
|
15
|
+
description:
|
|
16
|
+
'Run a multi-lens code review on the current working directory changes. Returns review findings grouped by lens.',
|
|
17
|
+
promptSnippet: 'Run a multi-lens code review against working directory changes',
|
|
18
|
+
promptGuidelines: [
|
|
19
|
+
'Use code_review when the user asks to review their changes, check code quality, or before committing.',
|
|
20
|
+
'code_review reads lens definitions from .code-review/lenses/ in the project root.',
|
|
21
|
+
],
|
|
22
|
+
parameters: Type.Object({
|
|
23
|
+
lenses: Type.Optional(
|
|
24
|
+
Type.Array(Type.String(), {
|
|
25
|
+
description:
|
|
26
|
+
'Specific lenses to apply. If omitted, uses project defaults or all available.',
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
base: Type.Optional(
|
|
30
|
+
Type.String({
|
|
31
|
+
description: 'Git ref to diff against (e.g., "main", "HEAD~3"). Defaults to HEAD.',
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
staged: Type.Optional(
|
|
35
|
+
Type.Boolean({
|
|
36
|
+
description: 'Review only staged changes instead of all working directory changes.',
|
|
37
|
+
}),
|
|
38
|
+
),
|
|
39
|
+
}),
|
|
40
|
+
|
|
41
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
42
|
+
const cwd = ctx.cwd;
|
|
43
|
+
const config = loadConfig(cwd);
|
|
44
|
+
const lensDir = getLensDir(cwd, config);
|
|
45
|
+
const available = discoverLenses(lensDir);
|
|
46
|
+
|
|
47
|
+
if (available.size === 0) {
|
|
48
|
+
return {
|
|
49
|
+
content: [
|
|
50
|
+
{
|
|
51
|
+
type: 'text',
|
|
52
|
+
text: `No lenses found in ${config.lensDir}. Run /review-init to scaffold a default config, or create .code-review/lenses/*.md files.`,
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
details: {},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const lensNames = resolveLensNames(params.lenses, config, available);
|
|
60
|
+
|
|
61
|
+
const diff = await collectDiff(pi, cwd, {
|
|
62
|
+
base: params.base,
|
|
63
|
+
staged: params.staged,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!diff.diff.trim()) {
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: 'text', text: 'No changes to review.' }],
|
|
69
|
+
details: {},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const results: LensResult[] = [];
|
|
74
|
+
for (const name of lensNames) {
|
|
75
|
+
if (signal?.aborted) break;
|
|
76
|
+
|
|
77
|
+
const lens = available.get(name)!;
|
|
78
|
+
const content = getLensContent(lensDir, name) ?? '';
|
|
79
|
+
const result = await reviewWithLens(pi, ctx, cwd, lens, content, diff, signal);
|
|
80
|
+
results.push(result);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const report: ReviewReport = {
|
|
84
|
+
diff: diff.diff,
|
|
85
|
+
diffStat: diff.stat,
|
|
86
|
+
lenses: results,
|
|
87
|
+
generatedAt: new Date().toISOString().slice(0, 10),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const markdown = buildReport(report);
|
|
91
|
+
const toolContext = buildToolContext(results);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
content: [{ type: 'text', text: markdown + toolContext }],
|
|
95
|
+
details: {
|
|
96
|
+
lensCount: lensNames.length,
|
|
97
|
+
availableLenses: [...available.keys()],
|
|
98
|
+
changedFiles: await getChangedFiles(pi, cwd, {
|
|
99
|
+
base: params.base,
|
|
100
|
+
staged: params.staged,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resolveLensNames(
|
|
109
|
+
requested: string[] | undefined,
|
|
110
|
+
config: ReviewConfig,
|
|
111
|
+
available: Map<string, unknown>,
|
|
112
|
+
): string[] {
|
|
113
|
+
if (requested && requested.length > 0) {
|
|
114
|
+
return requested.filter((l) => available.has(l));
|
|
115
|
+
}
|
|
116
|
+
if (config.defaultLenses.length > 0) {
|
|
117
|
+
return config.defaultLenses.filter((l) => available.has(l));
|
|
118
|
+
}
|
|
119
|
+
return [...available.keys()];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildToolContext(results: LensResult[]): string {
|
|
123
|
+
const prompts = results.map((r) => r._prompt).filter(Boolean);
|
|
124
|
+
|
|
125
|
+
if (prompts.length === 0) return '';
|
|
126
|
+
|
|
127
|
+
return [
|
|
128
|
+
'',
|
|
129
|
+
'---',
|
|
130
|
+
'',
|
|
131
|
+
'The tool outputs above provide automated analysis. Now evaluate the diff through each lens criteria:',
|
|
132
|
+
'',
|
|
133
|
+
...prompts,
|
|
134
|
+
].join('\n');
|
|
135
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
import { loadConfig, getLensDir } from '../config';
|
|
4
|
+
import { collectDiff } from '../diff';
|
|
5
|
+
import { discoverLenses, getLensContent } from '../lenses';
|
|
6
|
+
import { reviewWithLens } from '../reviewer';
|
|
7
|
+
import { parseReviewArgs } from '../parse-args';
|
|
8
|
+
|
|
9
|
+
export function registerReviewCommand(pi: ExtensionAPI) {
|
|
10
|
+
pi.registerCommand('review', {
|
|
11
|
+
description:
|
|
12
|
+
'Run a multi-lens code review on working directory changes. Usage: /review [--lens name,...] [--base ref] [--staged]',
|
|
13
|
+
handler: async (args, ctx) => {
|
|
14
|
+
const cwd = ctx.cwd;
|
|
15
|
+
const config = loadConfig(cwd);
|
|
16
|
+
const lensDir = getLensDir(cwd, config);
|
|
17
|
+
const available = discoverLenses(lensDir);
|
|
18
|
+
|
|
19
|
+
if (available.size === 0) {
|
|
20
|
+
ctx.ui.notify(
|
|
21
|
+
`No lenses found in ${config.lensDir}. Run /review-init to scaffold a default config.`,
|
|
22
|
+
'warning',
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parsed = parseReviewArgs(args ?? '');
|
|
28
|
+
const lensNames = resolveLensNames(parsed.lenses, config.defaultLenses, available, (msg) =>
|
|
29
|
+
ctx.ui.notify(msg, 'warning'),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
if (lensNames.length === 0) {
|
|
33
|
+
ctx.ui.notify('No lenses selected', 'warning');
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const diff = await collectDiff(pi, cwd, {
|
|
38
|
+
base: parsed.base,
|
|
39
|
+
staged: parsed.staged,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
if (!diff.diff.trim()) {
|
|
43
|
+
ctx.ui.notify('No changes to review', 'info');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ctx.ui.notify(`Reviewing ${diff.label} through ${lensNames.length} lens(es)...`, 'info');
|
|
48
|
+
|
|
49
|
+
const lensPrompts: string[] = [];
|
|
50
|
+
for (const name of lensNames) {
|
|
51
|
+
const lens = available.get(name)!;
|
|
52
|
+
const content = getLensContent(lensDir, name) ?? '';
|
|
53
|
+
const result = await reviewWithLens(pi, ctx, cwd, lens, content, diff);
|
|
54
|
+
|
|
55
|
+
if (result._prompt) {
|
|
56
|
+
lensPrompts.push(result._prompt);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const combinedPrompt = [
|
|
61
|
+
`Review the following changes through ${lensNames.length} lens(es): ${lensNames.join(', ')}.`,
|
|
62
|
+
'',
|
|
63
|
+
'For each lens, evaluate the diff against its criteria and produce findings.',
|
|
64
|
+
'Output your review as a structured report with sections per lens.',
|
|
65
|
+
'',
|
|
66
|
+
...lensPrompts,
|
|
67
|
+
].join('\n');
|
|
68
|
+
|
|
69
|
+
pi.sendUserMessage(combinedPrompt, { deliverAs: 'followUp' });
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve which lens names to run based on explicit selection, defaults, or all available. */
|
|
75
|
+
function resolveLensNames(
|
|
76
|
+
requested: string[],
|
|
77
|
+
defaults: string[],
|
|
78
|
+
available: Map<string, unknown>,
|
|
79
|
+
warn: (msg: string) => void,
|
|
80
|
+
): string[] {
|
|
81
|
+
if (requested.length > 0) {
|
|
82
|
+
const missing = requested.filter((l) => !available.has(l));
|
|
83
|
+
if (missing.length > 0) warn(`Unknown lenses: ${missing.join(', ')}`);
|
|
84
|
+
return requested.filter((l) => available.has(l));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (defaults.length > 0) {
|
|
88
|
+
return defaults.filter((l) => available.has(l));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return [...available.keys()];
|
|
92
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import type { ReviewConfig } from './types';
|
|
4
|
+
|
|
5
|
+
const CONFIG_FILE = '.code-review.json';
|
|
6
|
+
const DEFAULT_LENS_DIR = '.code-review/lenses';
|
|
7
|
+
|
|
8
|
+
export function loadConfig(cwd: string): ReviewConfig {
|
|
9
|
+
const configPath = resolve(cwd, CONFIG_FILE);
|
|
10
|
+
|
|
11
|
+
if (existsSync(configPath)) {
|
|
12
|
+
try {
|
|
13
|
+
const raw = readFileSync(configPath, 'utf-8');
|
|
14
|
+
const parsed = JSON.parse(raw) as Partial<ReviewConfig>;
|
|
15
|
+
return {
|
|
16
|
+
lensDir: parsed.lensDir ?? DEFAULT_LENS_DIR,
|
|
17
|
+
defaultLenses: parsed.defaultLenses ?? [],
|
|
18
|
+
};
|
|
19
|
+
} catch {
|
|
20
|
+
// Malformed config — fall back to defaults
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
lensDir: DEFAULT_LENS_DIR,
|
|
26
|
+
defaultLenses: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function getLensDir(cwd: string, config: ReviewConfig): string {
|
|
31
|
+
return resolve(cwd, config.lensDir);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getConfigPath(cwd: string): string {
|
|
35
|
+
return resolve(cwd, CONFIG_FILE);
|
|
36
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
export type DiffSource = {
|
|
4
|
+
diff: string;
|
|
5
|
+
stat: string;
|
|
6
|
+
label: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Collect the diff from the working directory or a specific base ref. */
|
|
10
|
+
export async function collectDiff(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
cwd: string,
|
|
13
|
+
options: { base?: string; staged?: boolean },
|
|
14
|
+
): Promise<DiffSource> {
|
|
15
|
+
if (options.staged) {
|
|
16
|
+
const diff = await pi.exec('git', ['diff', '--staged'], { cwd });
|
|
17
|
+
const stat = await pi.exec('git', ['diff', '--staged', '--stat'], { cwd });
|
|
18
|
+
return {
|
|
19
|
+
diff: diff.stdout,
|
|
20
|
+
stat: stat.stdout,
|
|
21
|
+
label: 'staged changes',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (options.base) {
|
|
26
|
+
const diff = await pi.exec('git', ['diff', options.base], { cwd });
|
|
27
|
+
const stat = await pi.exec('git', ['diff', options.base, '--stat'], { cwd });
|
|
28
|
+
return {
|
|
29
|
+
diff: diff.stdout,
|
|
30
|
+
stat: stat.stdout,
|
|
31
|
+
label: `changes since ${options.base}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Default: working directory changes (unstaged + staged)
|
|
36
|
+
const diff = await pi.exec('git', ['diff', 'HEAD'], { cwd });
|
|
37
|
+
const stat = await pi.exec('git', ['diff', 'HEAD', '--stat'], { cwd });
|
|
38
|
+
|
|
39
|
+
// If no HEAD diff, fall back to just working directory
|
|
40
|
+
if (!diff.stdout.trim()) {
|
|
41
|
+
const wdDiff = await pi.exec('git', ['diff'], { cwd });
|
|
42
|
+
const wdStat = await pi.exec('git', ['diff', '--stat'], { cwd });
|
|
43
|
+
return {
|
|
44
|
+
diff: wdDiff.stdout,
|
|
45
|
+
stat: wdStat.stdout,
|
|
46
|
+
label: 'working directory changes',
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
diff: diff.stdout,
|
|
52
|
+
stat: stat.stdout,
|
|
53
|
+
label: 'all uncommitted changes',
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Get a list of changed file paths from the diff. */
|
|
58
|
+
export async function getChangedFiles(
|
|
59
|
+
pi: ExtensionAPI,
|
|
60
|
+
cwd: string,
|
|
61
|
+
options: { base?: string; staged?: boolean },
|
|
62
|
+
): Promise<string[]> {
|
|
63
|
+
const args = ['diff', '--name-only'];
|
|
64
|
+
|
|
65
|
+
if (options.staged) {
|
|
66
|
+
args.push('--staged');
|
|
67
|
+
} else if (options.base) {
|
|
68
|
+
args.push(options.base);
|
|
69
|
+
} else {
|
|
70
|
+
args.push('HEAD');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const result = await pi.exec('git', args, { cwd });
|
|
74
|
+
return result.stdout
|
|
75
|
+
.split('\n')
|
|
76
|
+
.map((f) => f.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
|
|
3
|
+
import { registerReviewCommand } from './commands/review';
|
|
4
|
+
import { registerReviewInitCommand } from './commands/review-init';
|
|
5
|
+
import { registerReviewLensesCommand } from './commands/review-lenses';
|
|
6
|
+
import { registerReviewTool } from './commands/review-tool';
|
|
7
|
+
|
|
8
|
+
export default function codeReviewerExtension(pi: ExtensionAPI) {
|
|
9
|
+
registerReviewCommand(pi);
|
|
10
|
+
registerReviewInitCommand(pi);
|
|
11
|
+
registerReviewLensesCommand(pi);
|
|
12
|
+
registerReviewTool(pi);
|
|
13
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve, basename } from 'node:path';
|
|
3
|
+
import type { LensConfig, LensSeverity } from './types';
|
|
4
|
+
|
|
5
|
+
type SectionKind = 'top' | 'criteria' | 'tools' | 'severity';
|
|
6
|
+
|
|
7
|
+
const SECTION_MAP: Record<string, SectionKind> = {
|
|
8
|
+
'## criteria': 'criteria',
|
|
9
|
+
'## tools': 'tools',
|
|
10
|
+
'## severity': 'severity',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type ParseState = {
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
criteriaLines: string[];
|
|
17
|
+
tools: string[];
|
|
18
|
+
severityRules: Record<LensSeverity, string>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type LineHandler = (trimmed: string, state: ParseState) => void;
|
|
22
|
+
|
|
23
|
+
const SECTION_HANDLERS: Record<SectionKind, LineHandler> = {
|
|
24
|
+
top: (trimmed, state) => {
|
|
25
|
+
if (!state.description) state.description = trimmed;
|
|
26
|
+
},
|
|
27
|
+
criteria: (trimmed, state) => state.criteriaLines.push(trimmed),
|
|
28
|
+
tools: (trimmed, state) => parseTool(trimmed, state.tools),
|
|
29
|
+
severity: (trimmed, state) => parseSeverityRule(trimmed, state.severityRules),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Parse a lens markdown file into a structured LensConfig. */
|
|
33
|
+
function parseLensFile(content: string, filename: string): LensConfig {
|
|
34
|
+
const state: ParseState = {
|
|
35
|
+
name: basename(filename, '.md'),
|
|
36
|
+
description: '',
|
|
37
|
+
criteriaLines: [],
|
|
38
|
+
tools: [],
|
|
39
|
+
severityRules: { blocker: '', warning: '', note: '' },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
let section: SectionKind = 'top';
|
|
43
|
+
|
|
44
|
+
for (const line of content.split('\n')) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
|
|
47
|
+
if (trimmed.startsWith('# ') && !trimmed.startsWith('## ')) {
|
|
48
|
+
state.name = trimmed.slice(2).trim();
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const nextSection = detectSection(trimmed);
|
|
53
|
+
if (nextSection !== undefined) {
|
|
54
|
+
section = nextSection;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (trimmed) SECTION_HANDLERS[section](trimmed, state);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
name: state.name,
|
|
63
|
+
description: state.description,
|
|
64
|
+
criteria: state.criteriaLines.join('\n'),
|
|
65
|
+
tools: state.tools,
|
|
66
|
+
severityRules: state.severityRules,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Detect which section a heading line introduces, or undefined if not a section heading. */
|
|
71
|
+
function detectSection(trimmed: string): SectionKind | undefined {
|
|
72
|
+
if (!trimmed.startsWith('## ')) return undefined;
|
|
73
|
+
return SECTION_MAP[trimmed.toLowerCase()] ?? 'top';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Extract a tool command from a list item line. */
|
|
77
|
+
function parseTool(trimmed: string, tools: string[]): void {
|
|
78
|
+
const backtickMatch = trimmed.match(/^-\s*`(.+)`$/);
|
|
79
|
+
if (backtickMatch) {
|
|
80
|
+
tools.push(backtickMatch[1]);
|
|
81
|
+
} else if (trimmed.startsWith('- ') && trimmed.length > 2) {
|
|
82
|
+
tools.push(trimmed.slice(2));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Extract a severity rule from a list item line. */
|
|
87
|
+
function parseSeverityRule(trimmed: string, rules: Record<LensSeverity, string>): void {
|
|
88
|
+
const match = trimmed.match(/^-\s*(blocker|warning|note):\s*(.+)$/i);
|
|
89
|
+
if (match) {
|
|
90
|
+
rules[match[1].toLowerCase() as LensSeverity] = match[2];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Discover all lens files in a directory. */
|
|
95
|
+
export function discoverLenses(lensDir: string): Map<string, LensConfig> {
|
|
96
|
+
const lenses = new Map<string, LensConfig>();
|
|
97
|
+
|
|
98
|
+
if (!existsSync(lensDir)) return lenses;
|
|
99
|
+
|
|
100
|
+
const files = readdirSync(lensDir).filter((f) => f.endsWith('.md'));
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
const content = readFileSync(resolve(lensDir, file), 'utf-8');
|
|
104
|
+
const config = parseLensFile(content, file);
|
|
105
|
+
const key = basename(file, '.md');
|
|
106
|
+
lenses.set(key, config);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return lenses;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Get raw markdown content for a lens to pass to the reviewer agent. */
|
|
113
|
+
export function getLensContent(lensDir: string, lensName: string): string | null {
|
|
114
|
+
const filePath = resolve(lensDir, `${lensName}.md`);
|
|
115
|
+
if (!existsSync(filePath)) return null;
|
|
116
|
+
return readFileSync(filePath, 'utf-8');
|
|
117
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Parse /review command arguments into structured options. */
|
|
2
|
+
export function parseReviewArgs(args: string): {
|
|
3
|
+
lenses: string[];
|
|
4
|
+
base?: string;
|
|
5
|
+
staged: boolean;
|
|
6
|
+
} {
|
|
7
|
+
const lenses: string[] = [];
|
|
8
|
+
let base: string | undefined;
|
|
9
|
+
let staged = false;
|
|
10
|
+
|
|
11
|
+
const parts = args.split(/\s+/).filter(Boolean);
|
|
12
|
+
|
|
13
|
+
for (let i = 0; i < parts.length; i++) {
|
|
14
|
+
const part = parts[i];
|
|
15
|
+
if (part === '--lens' && i + 1 < parts.length) {
|
|
16
|
+
lenses.push(...parts[++i].split(','));
|
|
17
|
+
} else if (part === '--base' && i + 1 < parts.length) {
|
|
18
|
+
base = parts[++i];
|
|
19
|
+
} else if (part === '--staged') {
|
|
20
|
+
staged = true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { lenses, base, staged };
|
|
25
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { LensFinding, LensResult, LensSeverity, ReviewReport } from './types';
|
|
2
|
+
|
|
3
|
+
/** Build a markdown report from lens results. */
|
|
4
|
+
export function buildReport(report: ReviewReport): string {
|
|
5
|
+
const sections = [
|
|
6
|
+
`# Code Review — ${report.generatedAt}`,
|
|
7
|
+
'',
|
|
8
|
+
buildChangesSection(report.diffStat),
|
|
9
|
+
buildScoreboard(report.lenses),
|
|
10
|
+
...report.lenses.map(buildLensSection),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
return sections.join('\n');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function buildChangesSection(diffStat: string): string {
|
|
17
|
+
return ['## Changes', '', '```', diffStat, '```', ''].join('\n');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function buildScoreboard(lenses: LensResult[]): string {
|
|
21
|
+
const counts = countFindings(lenses);
|
|
22
|
+
return [
|
|
23
|
+
'## Scoreboard',
|
|
24
|
+
'',
|
|
25
|
+
'| Metric | Count |',
|
|
26
|
+
'| --- | --- |',
|
|
27
|
+
`| **Total findings** | **${counts.total}** |`,
|
|
28
|
+
`| 🔴 Blockers | ${counts.blocker} |`,
|
|
29
|
+
`| 🟡 Warnings | ${counts.warning} |`,
|
|
30
|
+
`| 🔵 Notes | ${counts.note} |`,
|
|
31
|
+
`| Lenses applied | ${lenses.length} |`,
|
|
32
|
+
'',
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function countFindings(lenses: LensResult[]): Record<LensSeverity | 'total', number> {
|
|
37
|
+
const counts = { blocker: 0, warning: 0, note: 0, total: 0 };
|
|
38
|
+
for (const lens of lenses) {
|
|
39
|
+
for (const f of lens.findings) {
|
|
40
|
+
counts[f.severity]++;
|
|
41
|
+
counts.total++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return counts;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildLensSection(lens: LensResult): string {
|
|
48
|
+
const lines: string[] = [`## ${lens.lens}`, ''];
|
|
49
|
+
|
|
50
|
+
if (lens.findings.length === 0) {
|
|
51
|
+
lines.push('No findings. ✓', '');
|
|
52
|
+
if (lens.summary) lines.push(lens.summary, '');
|
|
53
|
+
return lines.join('\n');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lines.push(buildFindingsByGroup(lens.findings));
|
|
57
|
+
|
|
58
|
+
if (lens.summary) {
|
|
59
|
+
lines.push(`**Summary:** ${lens.summary}`, '');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (lens.toolOutputs && Object.keys(lens.toolOutputs).length > 0) {
|
|
63
|
+
lines.push(buildToolOutputDetails(lens.toolOutputs));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return lines.join('\n');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const SEVERITY_ICONS: Record<LensSeverity, string> = {
|
|
70
|
+
blocker: '🔴',
|
|
71
|
+
warning: '🟡',
|
|
72
|
+
note: '🔵',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function buildFindingsByGroup(findings: LensFinding[]): string {
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
const severities: LensSeverity[] = ['blocker', 'warning', 'note'];
|
|
78
|
+
|
|
79
|
+
for (const severity of severities) {
|
|
80
|
+
const group = findings.filter((f) => f.severity === severity);
|
|
81
|
+
if (group.length === 0) continue;
|
|
82
|
+
|
|
83
|
+
const label = severity.charAt(0).toUpperCase() + severity.slice(1);
|
|
84
|
+
lines.push(`### ${SEVERITY_ICONS[severity]} ${label}s (${group.length})`, '');
|
|
85
|
+
|
|
86
|
+
for (const f of group) {
|
|
87
|
+
const loc = f.line ? `${f.file}:${f.line}` : f.file;
|
|
88
|
+
lines.push(`- \`${loc}\` — ${f.message}`);
|
|
89
|
+
}
|
|
90
|
+
lines.push('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildToolOutputDetails(toolOutputs: Record<string, string>): string {
|
|
97
|
+
const lines = [
|
|
98
|
+
'<details>',
|
|
99
|
+
`<summary>Tool outputs (${Object.keys(toolOutputs).length})</summary>`,
|
|
100
|
+
'',
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (const [cmd, output] of Object.entries(toolOutputs)) {
|
|
104
|
+
lines.push(`**\`${cmd}\`**`, '```', output.slice(0, 5000), '```');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lines.push('</details>', '');
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { platform } from 'node:os';
|
|
2
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import type { DiffSource } from './diff';
|
|
4
|
+
import type { LensConfig, LensResult } from './types';
|
|
5
|
+
|
|
6
|
+
const isWindows = platform() === 'win32';
|
|
7
|
+
|
|
8
|
+
/** Run project tools specified by a lens and collect their output. */
|
|
9
|
+
async function runLensTools(
|
|
10
|
+
pi: ExtensionAPI,
|
|
11
|
+
cwd: string,
|
|
12
|
+
tools: string[],
|
|
13
|
+
signal?: AbortSignal,
|
|
14
|
+
): Promise<Record<string, string>> {
|
|
15
|
+
const outputs: Record<string, string> = {};
|
|
16
|
+
|
|
17
|
+
for (const tool of tools) {
|
|
18
|
+
if (signal?.aborted) break;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const [shell, shellArgs] = isWindows ? ['cmd', ['/c', tool]] : ['sh', ['-c', tool]];
|
|
22
|
+
const result = await pi.exec(shell, shellArgs, {
|
|
23
|
+
cwd,
|
|
24
|
+
timeout: 60_000,
|
|
25
|
+
signal,
|
|
26
|
+
});
|
|
27
|
+
outputs[tool] = result.stdout || result.stderr || '(no output)';
|
|
28
|
+
} catch {
|
|
29
|
+
outputs[tool] = `(tool failed or timed out: ${tool})`;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return outputs;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Build the review prompt for a single lens. */
|
|
37
|
+
function buildReviewPrompt(
|
|
38
|
+
lens: LensConfig,
|
|
39
|
+
lensContent: string,
|
|
40
|
+
diff: DiffSource,
|
|
41
|
+
toolOutputs: Record<string, string>,
|
|
42
|
+
): string {
|
|
43
|
+
const parts: string[] = [];
|
|
44
|
+
|
|
45
|
+
parts.push(`You are reviewing code changes through the "${lens.name}" lens.`);
|
|
46
|
+
parts.push('');
|
|
47
|
+
parts.push('## Lens Definition');
|
|
48
|
+
parts.push(lensContent);
|
|
49
|
+
parts.push('');
|
|
50
|
+
parts.push(`## Diff (${diff.label})`);
|
|
51
|
+
const maxDiffLen = 50_000;
|
|
52
|
+
const diffTruncated = diff.diff.length > maxDiffLen;
|
|
53
|
+
parts.push('```diff');
|
|
54
|
+
parts.push(diff.diff.slice(0, maxDiffLen));
|
|
55
|
+
parts.push('```');
|
|
56
|
+
if (diffTruncated) {
|
|
57
|
+
parts.push(
|
|
58
|
+
`> ⚠️ Diff truncated (${diff.diff.length} chars → ${maxDiffLen}). Some files may not appear above.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
parts.push('');
|
|
62
|
+
parts.push('## Diff Stats');
|
|
63
|
+
parts.push('```');
|
|
64
|
+
parts.push(diff.stat);
|
|
65
|
+
parts.push('```');
|
|
66
|
+
|
|
67
|
+
if (Object.keys(toolOutputs).length > 0) {
|
|
68
|
+
parts.push('');
|
|
69
|
+
parts.push('## Tool Outputs');
|
|
70
|
+
for (const [cmd, output] of Object.entries(toolOutputs)) {
|
|
71
|
+
parts.push(`### \`${cmd}\``);
|
|
72
|
+
parts.push('```');
|
|
73
|
+
parts.push(output.slice(0, 20_000));
|
|
74
|
+
parts.push('```');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
parts.push('');
|
|
79
|
+
parts.push('## Instructions');
|
|
80
|
+
parts.push('');
|
|
81
|
+
parts.push('Review the diff through this lens. For each finding, output a JSON array:');
|
|
82
|
+
parts.push('');
|
|
83
|
+
parts.push('```json');
|
|
84
|
+
parts.push('[');
|
|
85
|
+
parts.push(
|
|
86
|
+
' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
|
|
87
|
+
);
|
|
88
|
+
parts.push(']');
|
|
89
|
+
parts.push('```');
|
|
90
|
+
parts.push('');
|
|
91
|
+
parts.push('Severity levels:');
|
|
92
|
+
if (lens.severityRules.blocker) parts.push(`- **blocker**: ${lens.severityRules.blocker}`);
|
|
93
|
+
if (lens.severityRules.warning) parts.push(`- **warning**: ${lens.severityRules.warning}`);
|
|
94
|
+
if (lens.severityRules.note) parts.push(`- **note**: ${lens.severityRules.note}`);
|
|
95
|
+
parts.push('');
|
|
96
|
+
parts.push(
|
|
97
|
+
'After the JSON array, write a 2-3 sentence summary of your review through this lens.',
|
|
98
|
+
);
|
|
99
|
+
parts.push('If there are no findings, return an empty array `[]` and note the code looks good.');
|
|
100
|
+
|
|
101
|
+
return parts.join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Execute a review for a single lens using the subagent tool. */
|
|
105
|
+
export async function reviewWithLens(
|
|
106
|
+
pi: ExtensionAPI,
|
|
107
|
+
_ctx: unknown,
|
|
108
|
+
cwd: string,
|
|
109
|
+
lens: LensConfig,
|
|
110
|
+
lensContent: string,
|
|
111
|
+
diff: DiffSource,
|
|
112
|
+
signal?: AbortSignal,
|
|
113
|
+
): Promise<LensResult> {
|
|
114
|
+
// Run lens tools first
|
|
115
|
+
const toolOutputs = await runLensTools(pi, cwd, lens.tools, signal);
|
|
116
|
+
|
|
117
|
+
// Build the prompt
|
|
118
|
+
const prompt = buildReviewPrompt(lens, lensContent, diff, toolOutputs);
|
|
119
|
+
|
|
120
|
+
// Use sendMessage to delegate to the agent for review
|
|
121
|
+
// For now, we return the prompt and let the command handler
|
|
122
|
+
// pass it to a subagent via the subagent tool
|
|
123
|
+
return {
|
|
124
|
+
lens: lens.name,
|
|
125
|
+
findings: [],
|
|
126
|
+
summary: '',
|
|
127
|
+
toolOutputs,
|
|
128
|
+
_prompt: prompt,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type LensSeverity = 'blocker' | 'warning' | 'note';
|
|
2
|
+
|
|
3
|
+
export type LensFinding = {
|
|
4
|
+
file: string;
|
|
5
|
+
line?: number;
|
|
6
|
+
severity: LensSeverity;
|
|
7
|
+
message: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type LensConfig = {
|
|
11
|
+
name: string;
|
|
12
|
+
description: string;
|
|
13
|
+
criteria: string;
|
|
14
|
+
tools: string[];
|
|
15
|
+
severityRules: Record<LensSeverity, string>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type LensResult = {
|
|
19
|
+
lens: string;
|
|
20
|
+
findings: LensFinding[];
|
|
21
|
+
summary: string;
|
|
22
|
+
toolOutputs?: Record<string, string>;
|
|
23
|
+
/** Review prompt built for this lens, used internally to delegate to the agent. */
|
|
24
|
+
_prompt?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ReviewConfig = {
|
|
28
|
+
lensDir: string;
|
|
29
|
+
defaultLenses: string[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type ReviewReport = {
|
|
33
|
+
diff: string;
|
|
34
|
+
diffStat: string;
|
|
35
|
+
lenses: LensResult[];
|
|
36
|
+
generatedAt: string;
|
|
37
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dreki-gg/pi-code-reviewer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Multi-lens code review extension for pi — configurable review criteria per project",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"author": "Juan Albarran <jalbarrandev@gmail.com>",
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"files": [
|
|
11
|
+
"extensions",
|
|
12
|
+
"skills",
|
|
13
|
+
"README.md",
|
|
14
|
+
"package.json"
|
|
15
|
+
],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/dreki-gg/pi-extensions",
|
|
19
|
+
"directory": "packages/code-reviewer"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"lint": "oxlint extensions",
|
|
25
|
+
"format": "oxfmt --write extensions",
|
|
26
|
+
"format:check": "oxfmt --check extensions"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./extensions/code-reviewer"
|
|
31
|
+
],
|
|
32
|
+
"skills": [
|
|
33
|
+
"./skills"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "24",
|
|
38
|
+
"oxfmt": "^0.43.0",
|
|
39
|
+
"oxlint": "^1.58.0",
|
|
40
|
+
"typescript": "^6.0.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-ai": "*",
|
|
44
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
45
|
+
"@earendil-works/pi-tui": "*",
|
|
46
|
+
"typebox": "*"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@earendil-works/pi-ai": {
|
|
50
|
+
"optional": true
|
|
51
|
+
},
|
|
52
|
+
"@earendil-works/pi-coding-agent": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"@earendil-works/pi-tui": {
|
|
56
|
+
"optional": true
|
|
57
|
+
},
|
|
58
|
+
"typebox": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: code-review
|
|
3
|
+
description: Multi-lens code review against working directory changes. Evaluates diffs through configurable criteria like code-quality, maintainability, product-vision, and ux-design. Use when user says "review my changes", "review this code", "check before committing", or "code review".
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Code Review
|
|
7
|
+
|
|
8
|
+
Review working directory changes through configurable lenses — each lens evaluates the diff against specific criteria and project tools.
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
/review # All lenses, working dir diff
|
|
14
|
+
/review --lens code-quality # Single lens
|
|
15
|
+
/review --lens quality,ux # Multiple lenses
|
|
16
|
+
/review --base main # Diff against a branch
|
|
17
|
+
/review --staged # Only staged changes
|
|
18
|
+
/review-lenses # List available lenses
|
|
19
|
+
/review-init # Scaffold lenses for this project
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or use the `code_review` tool directly for programmatic access.
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
## Lens format
|
|
26
|
+
|
|
27
|
+
Each lens is a markdown file in `.code-review/lenses/`:
|
|
28
|
+
|
|
29
|
+
```md
|
|
30
|
+
# Lens Name
|
|
31
|
+
|
|
32
|
+
Description of what this lens evaluates.
|
|
33
|
+
|
|
34
|
+
## Criteria
|
|
35
|
+
- Evaluation point 1
|
|
36
|
+
- Evaluation point 2
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
- `bun run typecheck`
|
|
40
|
+
- `bun run codeql:fallow -- --changed-since HEAD`
|
|
41
|
+
|
|
42
|
+
## Severity
|
|
43
|
+
- blocker: What constitutes a blocking issue
|
|
44
|
+
- warning: What constitutes a warning
|
|
45
|
+
- note: What constitutes a note
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Configuration
|
|
49
|
+
|
|
50
|
+
Optional `.code-review.json` at project root:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"lensDir": ".code-review/lenses",
|
|
55
|
+
"defaultLenses": ["code-quality", "maintainability"]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Example Lenses
|
|
2
|
+
|
|
3
|
+
These are reference lenses that ship with the `@dreki-gg/pi-code-reviewer` package.
|
|
4
|
+
|
|
5
|
+
To use them in your project, run `/review-init` which will scaffold a `.code-review/lenses/` directory
|
|
6
|
+
tailored to your project's tools and conventions.
|
|
7
|
+
|
|
8
|
+
You can also copy these files directly into your project's `.code-review/lenses/` directory and customize them.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Code Quality
|
|
2
|
+
|
|
3
|
+
Evaluates changes for correctness, dead code introduction, and adherence to project lint/type standards.
|
|
4
|
+
|
|
5
|
+
## Criteria
|
|
6
|
+
- Does the diff introduce new lint violations or type errors?
|
|
7
|
+
- Are there new unused exports, imports, or variables?
|
|
8
|
+
- Does the change follow existing naming conventions?
|
|
9
|
+
- Are error cases handled properly?
|
|
10
|
+
- Are there any obvious bugs or logic errors?
|
|
11
|
+
- Does the code avoid known anti-patterns for the project's framework?
|
|
12
|
+
|
|
13
|
+
## Tools
|
|
14
|
+
- `bun run typecheck`
|
|
15
|
+
- `bun run lint`
|
|
16
|
+
|
|
17
|
+
## Severity
|
|
18
|
+
- blocker: Type errors, unresolved imports, obvious bugs, unhandled error paths
|
|
19
|
+
- warning: New lint violations, unused code, inconsistent naming
|
|
20
|
+
- note: Style suggestions, minor improvements
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Maintainability
|
|
2
|
+
|
|
3
|
+
Evaluates whether changes will be easy to understand, modify, and extend in the future.
|
|
4
|
+
|
|
5
|
+
## Criteria
|
|
6
|
+
- Is the code well-organized with clear separation of concerns?
|
|
7
|
+
- Are functions and components reasonably sized (not giant)?
|
|
8
|
+
- Are there clear abstractions or is logic tangled?
|
|
9
|
+
- Does the change increase coupling between modules?
|
|
10
|
+
- Would a new team member understand this code without extensive context?
|
|
11
|
+
- Are there magic numbers, hardcoded values, or unclear abbreviations?
|
|
12
|
+
- Is there adequate documentation for complex logic?
|
|
13
|
+
- Does the change duplicate existing patterns instead of reusing them?
|
|
14
|
+
|
|
15
|
+
## Tools
|
|
16
|
+
|
|
17
|
+
## Severity
|
|
18
|
+
- blocker: Introduces tight coupling across module boundaries, creates circular dependencies
|
|
19
|
+
- warning: Large functions (>100 lines), duplicated logic, unclear naming, missing docs on complex code
|
|
20
|
+
- note: Opportunities to simplify, extract, or clarify
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Product Vision
|
|
2
|
+
|
|
3
|
+
Evaluates whether changes align with the product direction and serve real user needs.
|
|
4
|
+
|
|
5
|
+
## Criteria
|
|
6
|
+
- Does this change serve existing users or is it speculative?
|
|
7
|
+
- Does it introduce UI concepts or flows that don't exist elsewhere in the app?
|
|
8
|
+
- Does it add unnecessary complexity to user-facing functionality?
|
|
9
|
+
- Does it respect the established information architecture?
|
|
10
|
+
- Is the feature discoverable and intuitive?
|
|
11
|
+
- Does it follow the project's domain language (check CONTEXT.md if it exists)?
|
|
12
|
+
- Are there simpler alternatives that would achieve the same user goal?
|
|
13
|
+
|
|
14
|
+
## Tools
|
|
15
|
+
|
|
16
|
+
## Severity
|
|
17
|
+
- blocker: Changes that contradict the product direction or break existing user workflows
|
|
18
|
+
- warning: Speculative features, unnecessary complexity, inconsistent UX patterns
|
|
19
|
+
- note: Opportunities to better align with product vision
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# UX Design
|
|
2
|
+
|
|
3
|
+
Evaluates the user experience quality of UI changes — accessibility, responsiveness, and interaction design.
|
|
4
|
+
|
|
5
|
+
## Criteria
|
|
6
|
+
- Are interactive elements accessible (keyboard navigation, ARIA labels, focus management)?
|
|
7
|
+
- Are loading and error states handled visually?
|
|
8
|
+
- Is the layout responsive or does it break at common viewport sizes?
|
|
9
|
+
- Are animations purposeful and not distracting (100-200ms, no bouncy effects)?
|
|
10
|
+
- Is text readable (minimum 12px body, sufficient contrast)?
|
|
11
|
+
- Are form inputs properly labeled with clear validation feedback?
|
|
12
|
+
- Does the change follow the project's existing component patterns?
|
|
13
|
+
- Are click targets large enough for touch (minimum 44px)?
|
|
14
|
+
|
|
15
|
+
## Tools
|
|
16
|
+
|
|
17
|
+
## Severity
|
|
18
|
+
- blocker: Missing keyboard accessibility on interactive elements, broken layout, no error states on data-fetching UI
|
|
19
|
+
- warning: Missing ARIA labels, tiny text (<12px), missing loading states, non-responsive layout
|
|
20
|
+
- note: Animation polish, spacing consistency, component reuse opportunities
|