@dreki-gg/pi-code-reviewer 0.2.0 → 0.4.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 +12 -1
- package/extensions/code-reviewer/commands/review-init.ts +9 -3
- package/extensions/code-reviewer/commands/review-lenses.ts +2 -2
- package/extensions/code-reviewer/commands/review-tool.ts +63 -26
- package/extensions/code-reviewer/commands/review.ts +17 -11
- package/extensions/code-reviewer/config.ts +45 -12
- package/extensions/code-reviewer/diff.ts +94 -62
- package/extensions/code-reviewer/effects/exec.ts +47 -0
- package/extensions/code-reviewer/effects/filesystem.ts +38 -0
- package/extensions/code-reviewer/effects/runtime.ts +21 -0
- package/extensions/code-reviewer/errors.ts +57 -0
- package/extensions/code-reviewer/lenses.ts +46 -19
- package/extensions/code-reviewer/reviewer.ts +88 -76
- package/extensions/code-reviewer/types.ts +12 -10
- package/package.json +9 -4
- package/extensions/code-reviewer/report.ts +0 -109
package/README.md
CHANGED
|
@@ -59,6 +59,13 @@ Evaluates changes for correctness and adherence to project standards.
|
|
|
59
59
|
- note: Style suggestions
|
|
60
60
|
```
|
|
61
61
|
|
|
62
|
+
> **Tools must be fast and exit on their own** (typecheck, lint, unit tests).
|
|
63
|
+
> Do **not** list dev servers, watch mode, e2e suites, or full production
|
|
64
|
+
> builds — they bind ports / run for minutes and belong in CI. Tools are
|
|
65
|
+
> **deduped across lenses and run concurrently**, so a command shared by
|
|
66
|
+
> several lenses runs once, and a slow/hanging command stalls the whole review
|
|
67
|
+
> (bounded by `toolTimeoutMs`).
|
|
68
|
+
|
|
62
69
|
### Bundled lenses
|
|
63
70
|
|
|
64
71
|
The package ships with four example lenses:
|
|
@@ -79,7 +86,9 @@ Run `/review-init` to scaffold these (customized for your project's tools) into
|
|
|
79
86
|
```json
|
|
80
87
|
{
|
|
81
88
|
"lensDir": ".code-review/lenses",
|
|
82
|
-
"defaultLenses": ["code-quality", "maintainability"]
|
|
89
|
+
"defaultLenses": ["code-quality", "maintainability"],
|
|
90
|
+
"toolTimeoutMs": 60000,
|
|
91
|
+
"toolConcurrency": 4
|
|
83
92
|
}
|
|
84
93
|
```
|
|
85
94
|
|
|
@@ -87,4 +96,6 @@ Run `/review-init` to scaffold these (customized for your project's tools) into
|
|
|
87
96
|
| --- | --- | --- |
|
|
88
97
|
| `lensDir` | `.code-review/lenses` | Directory containing lens files |
|
|
89
98
|
| `defaultLenses` | `[]` (all) | Lenses to run when none specified |
|
|
99
|
+
| `toolTimeoutMs` | `60000` | Per-tool wall-clock timeout (ms); an exceeding tool is killed and reported as timed-out |
|
|
100
|
+
| `toolConcurrency` | `4` | Max distinct tools run in parallel (tools are deduped across lenses first) |
|
|
90
101
|
|
|
@@ -12,10 +12,16 @@ export function registerReviewInitCommand(pi: ExtensionAPI) {
|
|
|
12
12
|
`Initialize a code review configuration for this project.`,
|
|
13
13
|
``,
|
|
14
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
|
|
15
|
+
`2. Create a \`.code-review.json\` config file at the project root. Supported keys:`,
|
|
16
|
+
` - \`lensDir\` (default \`.code-review/lenses\`), \`defaultLenses\` (lenses run when none are specified),`,
|
|
17
|
+
` - \`toolTimeoutMs\` (per-tool timeout, default 60000), \`toolConcurrency\` (parallel tools, default 4).`,
|
|
16
18
|
`3. Create lens files in \`.code-review/lenses/\` — start with: code-quality.md, maintainability.md`,
|
|
17
|
-
`4. Each lens
|
|
18
|
-
`
|
|
19
|
+
`4. Each lens's \`## Tools\` must list ONLY fast, non-side-effecting commands that EXIT on their own`,
|
|
20
|
+
` (e.g. typecheck, lint, unit tests). Do NOT list dev servers, watch mode, e2e suites, or full`,
|
|
21
|
+
` production builds — they bind ports / run for minutes and belong in CI. Tools are deduped across`,
|
|
22
|
+
` lenses and run concurrently, so a slow or hanging command stalls the whole review.`,
|
|
23
|
+
`5. Tailor the criteria to the project's stack and conventions; prefer concrete, pattern-matched checks`,
|
|
24
|
+
` (name the project's real failure modes + the diff "smells" to look for) over generic virtues.`,
|
|
19
25
|
``,
|
|
20
26
|
`Config path: ${configPath}`,
|
|
21
27
|
].join('\n'),
|
|
@@ -7,9 +7,9 @@ export function registerReviewLensesCommand(pi: ExtensionAPI) {
|
|
|
7
7
|
pi.registerCommand('review-lenses', {
|
|
8
8
|
description: 'List available review lenses for this project',
|
|
9
9
|
handler: async (_args, ctx) => {
|
|
10
|
-
const config = loadConfig(ctx.cwd);
|
|
10
|
+
const config = await loadConfig(ctx.cwd);
|
|
11
11
|
const lensDir = getLensDir(ctx.cwd, config);
|
|
12
|
-
const available = discoverLenses(lensDir);
|
|
12
|
+
const available = await discoverLenses(lensDir);
|
|
13
13
|
|
|
14
14
|
if (available.size === 0) {
|
|
15
15
|
ctx.ui.notify(
|
|
@@ -4,9 +4,9 @@ import { Type } from 'typebox';
|
|
|
4
4
|
import { loadConfig, getLensDir } from '../config';
|
|
5
5
|
import { collectDiff, getChangedFiles } from '../diff';
|
|
6
6
|
import { discoverLenses, getLensContent } from '../lenses';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import type { LensResult, ReviewConfig
|
|
7
|
+
import { buildDiffSection, buildLensResult, pickLensToolOutputs, runTools } from '../reviewer';
|
|
8
|
+
import type { DiffSource } from '../diff';
|
|
9
|
+
import type { LensResult, ReviewConfig } from '../types';
|
|
10
10
|
|
|
11
11
|
export function registerReviewTool(pi: ExtensionAPI) {
|
|
12
12
|
pi.registerTool({
|
|
@@ -40,9 +40,9 @@ export function registerReviewTool(pi: ExtensionAPI) {
|
|
|
40
40
|
|
|
41
41
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
42
42
|
const cwd = ctx.cwd;
|
|
43
|
-
const config = loadConfig(cwd);
|
|
43
|
+
const config = await loadConfig(cwd);
|
|
44
44
|
const lensDir = getLensDir(cwd, config);
|
|
45
|
-
const available = discoverLenses(lensDir);
|
|
45
|
+
const available = await discoverLenses(lensDir);
|
|
46
46
|
|
|
47
47
|
if (available.size === 0) {
|
|
48
48
|
return {
|
|
@@ -72,6 +72,23 @@ export function registerReviewTool(pi: ExtensionAPI) {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
const selected = lensNames.map((name) => available.get(name)!);
|
|
76
|
+
|
|
77
|
+
// Run the DISTINCT tool set once (deduped across lenses), concurrently —
|
|
78
|
+
// not once per lens. A command shared by several lenses executes a single
|
|
79
|
+
// time and its output is shared.
|
|
80
|
+
const allTools = [...new Set(selected.flatMap((lens) => lens.tools))];
|
|
81
|
+
if (allTools.length > 0) {
|
|
82
|
+
ctx.ui.setStatus('code-review', `🔍 Running ${allTools.length} tool(s)...`);
|
|
83
|
+
}
|
|
84
|
+
const toolOutputs = await runTools(
|
|
85
|
+
pi,
|
|
86
|
+
cwd,
|
|
87
|
+
allTools,
|
|
88
|
+
{ timeoutMs: config.toolTimeoutMs, concurrency: config.toolConcurrency },
|
|
89
|
+
signal,
|
|
90
|
+
);
|
|
91
|
+
|
|
75
92
|
const results: LensResult[] = [];
|
|
76
93
|
for (let i = 0; i < lensNames.length; i++) {
|
|
77
94
|
if (signal?.aborted) break;
|
|
@@ -84,26 +101,21 @@ export function registerReviewTool(pi: ExtensionAPI) {
|
|
|
84
101
|
details: { currentLens: name, lensIndex: i + 1, totalLenses: lensNames.length },
|
|
85
102
|
});
|
|
86
103
|
|
|
87
|
-
const lens =
|
|
88
|
-
const content = getLensContent(lensDir, name) ?? '';
|
|
89
|
-
|
|
90
|
-
results.push(result);
|
|
104
|
+
const lens = selected[i];
|
|
105
|
+
const content = (await getLensContent(lensDir, name)) ?? '';
|
|
106
|
+
results.push(buildLensResult(lens, content, pickLensToolOutputs(lens, toolOutputs)));
|
|
91
107
|
}
|
|
92
108
|
|
|
93
109
|
ctx.ui.setStatus('code-review', undefined);
|
|
94
110
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
const markdown = buildReport(report);
|
|
103
|
-
const toolContext = buildToolContext(results);
|
|
111
|
+
// The tool returns a pre-review skeleton + the review task. Findings are
|
|
112
|
+
// produced by the agent in its follow-up message (per the instructions
|
|
113
|
+
// below), NOT parsed back here — so we deliberately do not render a
|
|
114
|
+
// findings scoreboard that would always read "0".
|
|
115
|
+
const text = buildToolContext(results, diff);
|
|
104
116
|
|
|
105
117
|
return {
|
|
106
|
-
content: [{ type: 'text', text
|
|
118
|
+
content: [{ type: 'text', text }],
|
|
107
119
|
details: {
|
|
108
120
|
lensCount: lensNames.length,
|
|
109
121
|
availableLenses: [...available.keys()],
|
|
@@ -131,17 +143,42 @@ function resolveLensNames(
|
|
|
131
143
|
return [...available.keys()];
|
|
132
144
|
}
|
|
133
145
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
146
|
+
/**
|
|
147
|
+
* Build the agent-facing review instructions appended to the report. The diff
|
|
148
|
+
* is embedded ONCE (not per lens) followed by each lens's section — large
|
|
149
|
+
* diffs would otherwise be repeated for every lens, bloating the tool output.
|
|
150
|
+
*/
|
|
151
|
+
function buildToolContext(results: LensResult[], diff: DiffSource): string {
|
|
152
|
+
const sections = results.map((r) => r._lensSection).filter(Boolean) as string[];
|
|
153
|
+
if (sections.length === 0) return '';
|
|
138
154
|
|
|
139
155
|
return [
|
|
156
|
+
`# Code Review — ${new Date().toISOString().slice(0, 10)}`,
|
|
157
|
+
'',
|
|
158
|
+
'## Changes',
|
|
159
|
+
'```',
|
|
160
|
+
diff.stat.trim() || '(no diffstat)',
|
|
161
|
+
'```',
|
|
162
|
+
'',
|
|
163
|
+
'Evaluate the diff through each lens below; the tool outputs are automated analysis.',
|
|
164
|
+
'',
|
|
165
|
+
buildDiffSection(diff),
|
|
166
|
+
'',
|
|
167
|
+
'## Lenses',
|
|
168
|
+
'',
|
|
169
|
+
...sections,
|
|
170
|
+
'',
|
|
171
|
+
'## Instructions',
|
|
140
172
|
'',
|
|
141
|
-
'
|
|
173
|
+
'For each lens above, review the diff against its criteria and output a JSON array of findings:',
|
|
142
174
|
'',
|
|
143
|
-
'
|
|
175
|
+
'```json',
|
|
176
|
+
'[',
|
|
177
|
+
' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
|
|
178
|
+
']',
|
|
179
|
+
'```',
|
|
144
180
|
'',
|
|
145
|
-
|
|
181
|
+
'After each lens JSON array, write a 2-3 sentence summary.',
|
|
182
|
+
'If a lens has no findings, return an empty array `[]` and note the code looks good.',
|
|
146
183
|
].join('\n');
|
|
147
184
|
}
|
|
@@ -3,7 +3,7 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
3
3
|
import { loadConfig, getLensDir } from '../config';
|
|
4
4
|
import { collectDiff } from '../diff';
|
|
5
5
|
import { discoverLenses, getLensContent } from '../lenses';
|
|
6
|
-
import {
|
|
6
|
+
import { buildDiffSection, buildLensResult, pickLensToolOutputs, runTools } from '../reviewer';
|
|
7
7
|
import { parseReviewArgs } from '../parse-args';
|
|
8
8
|
|
|
9
9
|
export function registerReviewCommand(pi: ExtensionAPI) {
|
|
@@ -12,9 +12,9 @@ export function registerReviewCommand(pi: ExtensionAPI) {
|
|
|
12
12
|
'Run a multi-lens code review on working directory changes. Usage: /review [--lens name,...] [--base ref] [--staged]',
|
|
13
13
|
handler: async (args, ctx) => {
|
|
14
14
|
const cwd = ctx.cwd;
|
|
15
|
-
const config = loadConfig(cwd);
|
|
15
|
+
const config = await loadConfig(cwd);
|
|
16
16
|
const lensDir = getLensDir(cwd, config);
|
|
17
|
-
const available = discoverLenses(lensDir);
|
|
17
|
+
const available = await discoverLenses(lensDir);
|
|
18
18
|
|
|
19
19
|
if (available.size === 0) {
|
|
20
20
|
ctx.ui.notify(
|
|
@@ -47,20 +47,26 @@ export function registerReviewCommand(pi: ExtensionAPI) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
ctx.ui.notify(`Reviewing ${diff.label} through ${lensNames.length} lens(es)...`, 'info');
|
|
50
|
-
|
|
50
|
+
|
|
51
|
+
const selected = lensNames.map((name) => available.get(name)!);
|
|
52
|
+
|
|
53
|
+
// Run the DISTINCT tool set once (deduped across lenses), concurrently.
|
|
54
|
+
const allTools = [...new Set(selected.flatMap((lens) => lens.tools))];
|
|
55
|
+
ctx.ui.setStatus('code-review', `🔍 Running ${allTools.length} tool(s)...`);
|
|
56
|
+
const toolOutputs = await runTools(pi, cwd, allTools, {
|
|
57
|
+
timeoutMs: config.toolTimeoutMs,
|
|
58
|
+
concurrency: config.toolConcurrency,
|
|
59
|
+
});
|
|
51
60
|
|
|
52
61
|
const lensSections: string[] = [];
|
|
53
62
|
for (let i = 0; i < lensNames.length; i++) {
|
|
54
63
|
const name = lensNames[i];
|
|
55
64
|
ctx.ui.setStatus('code-review', `🔍 Lens ${i + 1}/${lensNames.length}: ${name}`);
|
|
56
65
|
|
|
57
|
-
const lens =
|
|
58
|
-
const content = getLensContent(lensDir, name) ?? '';
|
|
59
|
-
const result =
|
|
60
|
-
|
|
61
|
-
if (result._lensSection) {
|
|
62
|
-
lensSections.push(result._lensSection);
|
|
63
|
-
}
|
|
66
|
+
const lens = selected[i];
|
|
67
|
+
const content = (await getLensContent(lensDir, name)) ?? '';
|
|
68
|
+
const result = buildLensResult(lens, content, pickLensToolOutputs(lens, toolOutputs));
|
|
69
|
+
if (result._lensSection) lensSections.push(result._lensSection);
|
|
64
70
|
}
|
|
65
71
|
|
|
66
72
|
ctx.ui.setStatus('code-review', undefined);
|
|
@@ -1,30 +1,63 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Review configuration loader.
|
|
3
|
+
*
|
|
4
|
+
* Reading `.code-review.json` is an Effect program against the FileSystem
|
|
5
|
+
* service; a missing or malformed file falls back to defaults (never fails).
|
|
6
|
+
* The Promise wrapper provides the live service for imperative call sites.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Effect } from 'effect';
|
|
2
10
|
import { resolve } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { FileSystem, nodeFileSystemService } from './effects/filesystem';
|
|
3
13
|
import type { ReviewConfig } from './types';
|
|
4
14
|
|
|
5
15
|
const CONFIG_FILE = '.code-review.json';
|
|
6
16
|
const DEFAULT_LENS_DIR = '.code-review/lenses';
|
|
17
|
+
const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
|
|
18
|
+
const DEFAULT_TOOL_CONCURRENCY = 4;
|
|
7
19
|
|
|
8
|
-
|
|
9
|
-
|
|
20
|
+
function defaultConfig(): ReviewConfig {
|
|
21
|
+
return {
|
|
22
|
+
lensDir: DEFAULT_LENS_DIR,
|
|
23
|
+
defaultLenses: [],
|
|
24
|
+
toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
|
|
25
|
+
toolConcurrency: DEFAULT_TOOL_CONCURRENCY,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Coerce a config value to a positive integer, falling back when absent/invalid. */
|
|
30
|
+
function positiveIntOr(value: unknown, fallback: number): number {
|
|
31
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
|
32
|
+
? Math.floor(value)
|
|
33
|
+
: fallback;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function loadConfigEffect(cwd: string): Effect.Effect<ReviewConfig, never, FileSystem> {
|
|
37
|
+
return Effect.gen(function* () {
|
|
38
|
+
const fs = yield* FileSystem;
|
|
39
|
+
const raw = yield* fs.readTextFile(getConfigPath(cwd)).pipe(Effect.either);
|
|
40
|
+
if (raw._tag === 'Left') return defaultConfig();
|
|
10
41
|
|
|
11
|
-
if (existsSync(configPath)) {
|
|
12
42
|
try {
|
|
13
|
-
const
|
|
14
|
-
const parsed = JSON.parse(raw) as Partial<ReviewConfig>;
|
|
43
|
+
const parsed = JSON.parse(raw.right) as Partial<ReviewConfig>;
|
|
15
44
|
return {
|
|
16
45
|
lensDir: parsed.lensDir ?? DEFAULT_LENS_DIR,
|
|
17
46
|
defaultLenses: parsed.defaultLenses ?? [],
|
|
47
|
+
toolTimeoutMs: positiveIntOr(parsed.toolTimeoutMs, DEFAULT_TOOL_TIMEOUT_MS),
|
|
48
|
+
toolConcurrency: positiveIntOr(parsed.toolConcurrency, DEFAULT_TOOL_CONCURRENCY),
|
|
18
49
|
};
|
|
19
50
|
} catch {
|
|
20
|
-
// Malformed config — fall back to defaults
|
|
51
|
+
// Malformed config — fall back to defaults.
|
|
52
|
+
return defaultConfig();
|
|
21
53
|
}
|
|
22
|
-
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
23
56
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
57
|
+
export function loadConfig(cwd: string): Promise<ReviewConfig> {
|
|
58
|
+
return Effect.runPromise(
|
|
59
|
+
loadConfigEffect(cwd).pipe(Effect.provideService(FileSystem, nodeFileSystemService)),
|
|
60
|
+
);
|
|
28
61
|
}
|
|
29
62
|
|
|
30
63
|
export function getLensDir(cwd: string, config: ReviewConfig): string {
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diff collection.
|
|
3
|
+
*
|
|
4
|
+
* Git invocations run through the Executor service as typed Effects. The
|
|
5
|
+
* Promise wrappers build a live Executor from `pi` for imperative call sites.
|
|
6
|
+
*/
|
|
7
|
+
|
|
1
8
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import { Effect } from 'effect';
|
|
10
|
+
|
|
11
|
+
import { Executor, makeExecutorService } from './effects/exec';
|
|
12
|
+
import type { ExecError } from './errors';
|
|
2
13
|
|
|
3
14
|
export type DiffSource = {
|
|
4
15
|
diff: string;
|
|
@@ -6,73 +17,94 @@ export type DiffSource = {
|
|
|
6
17
|
label: string;
|
|
7
18
|
};
|
|
8
19
|
|
|
20
|
+
export type DiffOptions = { base?: string; staged?: boolean };
|
|
21
|
+
|
|
22
|
+
/** git diffs are normally instant; cap them so a pathological repo can't hang
|
|
23
|
+
* the whole review. */
|
|
24
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
function git(args: string[], cwd: string): Effect.Effect<string, ExecError, Executor> {
|
|
27
|
+
return Effect.gen(function* () {
|
|
28
|
+
const executor = yield* Executor;
|
|
29
|
+
const result = yield* executor.exec('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
|
|
30
|
+
return result.stdout;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
9
34
|
/** Collect the diff from the working directory or a specific base ref. */
|
|
10
|
-
export
|
|
11
|
-
pi: ExtensionAPI,
|
|
35
|
+
export function collectDiffEffect(
|
|
12
36
|
cwd: string,
|
|
13
|
-
options:
|
|
14
|
-
):
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
diff:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
};
|
|
37
|
+
options: DiffOptions,
|
|
38
|
+
): Effect.Effect<DiffSource, ExecError, Executor> {
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
if (options.staged) {
|
|
41
|
+
const diff = yield* git(['diff', '--staged'], cwd);
|
|
42
|
+
const stat = yield* git(['diff', '--staged', '--stat'], cwd);
|
|
43
|
+
return { diff, stat, label: 'staged changes' };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (options.base) {
|
|
47
|
+
const diff = yield* git(['diff', options.base], cwd);
|
|
48
|
+
const stat = yield* git(['diff', options.base, '--stat'], cwd);
|
|
49
|
+
return { diff, stat, label: `changes since ${options.base}` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Default: working directory changes (unstaged + staged) relative to HEAD.
|
|
53
|
+
// `git diff HEAD` fails on a repo with no commits (HEAD is unborn), so
|
|
54
|
+
// tolerate that and fall back to the bare working-directory diff.
|
|
55
|
+
const headDiff = yield* git(['diff', 'HEAD'], cwd).pipe(Effect.either);
|
|
56
|
+
|
|
57
|
+
// No HEAD (fresh repo) or an empty HEAD diff → fall back to the working dir.
|
|
58
|
+
if (headDiff._tag === 'Left' || !headDiff.right.trim()) {
|
|
59
|
+
const wdDiff = yield* git(['diff'], cwd);
|
|
60
|
+
const wdStat = yield* git(['diff', '--stat'], cwd);
|
|
61
|
+
return { diff: wdDiff, stat: wdStat, label: 'working directory changes' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const stat = yield* git(['diff', 'HEAD', '--stat'], cwd);
|
|
65
|
+
return { diff: headDiff.right, stat, label: 'all uncommitted changes' };
|
|
66
|
+
});
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
/** Get a list of changed file paths from the diff. */
|
|
58
|
-
export
|
|
59
|
-
|
|
70
|
+
export function getChangedFilesEffect(
|
|
71
|
+
cwd: string,
|
|
72
|
+
options: DiffOptions,
|
|
73
|
+
): Effect.Effect<string[], ExecError, Executor> {
|
|
74
|
+
return Effect.gen(function* () {
|
|
75
|
+
const args = ['diff', '--name-only'];
|
|
76
|
+
if (options.staged) args.push('--staged');
|
|
77
|
+
else if (options.base) args.push(options.base);
|
|
78
|
+
else args.push('HEAD');
|
|
79
|
+
|
|
80
|
+
const stdout = yield* git(args, cwd);
|
|
81
|
+
return stdout
|
|
82
|
+
.split('\n')
|
|
83
|
+
.map((f) => f.trim())
|
|
84
|
+
.filter(Boolean);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Promise wrappers (live Executor from pi) ──────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export function collectDiff(
|
|
91
|
+
pi: Pick<ExtensionAPI, 'exec'>,
|
|
92
|
+
cwd: string,
|
|
93
|
+
options: DiffOptions,
|
|
94
|
+
): Promise<DiffSource> {
|
|
95
|
+
return Effect.runPromise(
|
|
96
|
+
collectDiffEffect(cwd, options).pipe(Effect.provideService(Executor, makeExecutorService(pi))),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getChangedFiles(
|
|
101
|
+
pi: Pick<ExtensionAPI, 'exec'>,
|
|
60
102
|
cwd: string,
|
|
61
|
-
options:
|
|
103
|
+
options: DiffOptions,
|
|
62
104
|
): Promise<string[]> {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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);
|
|
105
|
+
return Effect.runPromise(
|
|
106
|
+
getChangedFilesEffect(cwd, options).pipe(
|
|
107
|
+
Effect.provideService(Executor, makeExecutorService(pi)),
|
|
108
|
+
),
|
|
109
|
+
);
|
|
78
110
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor service — wraps `pi.exec` so shelling out (git, lens tools) becomes
|
|
3
|
+
* an injectable, typed Effect. Tests provide a fake executor instead of running
|
|
4
|
+
* real subprocesses.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
8
|
+
import { Context, Effect } from 'effect';
|
|
9
|
+
|
|
10
|
+
import { ExecError } from '../errors';
|
|
11
|
+
|
|
12
|
+
export interface ExecOptions {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
timeout?: number;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ExecResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ExecutorService {
|
|
24
|
+
readonly exec: (
|
|
25
|
+
command: string,
|
|
26
|
+
args: string[],
|
|
27
|
+
options?: ExecOptions,
|
|
28
|
+
) => Effect.Effect<ExecResult, ExecError>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class Executor extends Context.Tag('CodeReviewer/Executor')<Executor, ExecutorService>() {}
|
|
32
|
+
|
|
33
|
+
type ExecCapableApi = Pick<ExtensionAPI, 'exec'>;
|
|
34
|
+
|
|
35
|
+
/** Build a live Executor backed by `pi.exec`. */
|
|
36
|
+
export function makeExecutorService(pi: ExecCapableApi): ExecutorService {
|
|
37
|
+
return {
|
|
38
|
+
exec: (command, args, options) =>
|
|
39
|
+
Effect.tryPromise({
|
|
40
|
+
try: async () => {
|
|
41
|
+
const result = await pi.exec(command, args, options);
|
|
42
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
43
|
+
},
|
|
44
|
+
catch: (cause) => new ExecError({ command, args, cause }),
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FileSystem service — the only place the code-reviewer reads disk.
|
|
3
|
+
*
|
|
4
|
+
* Wrapping Node's `fs/promises` behind an Effect service keeps config and lens
|
|
5
|
+
* loading pure and injectable: tests swap in an in-memory implementation, and
|
|
6
|
+
* read failures surface as typed `FileReadError` values.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Context, Effect } from 'effect';
|
|
10
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
11
|
+
|
|
12
|
+
import { FileReadError } from '../errors';
|
|
13
|
+
|
|
14
|
+
export interface FileSystemService {
|
|
15
|
+
/** Read a UTF-8 file, failing with FileReadError when unreadable/missing. */
|
|
16
|
+
readonly readTextFile: (path: string) => Effect.Effect<string, FileReadError>;
|
|
17
|
+
/** List directory entries, failing with FileReadError when the dir is missing. */
|
|
18
|
+
readonly readDirectory: (path: string) => Effect.Effect<string[], FileReadError>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class FileSystem extends Context.Tag('CodeReviewer/FileSystem')<
|
|
22
|
+
FileSystem,
|
|
23
|
+
FileSystemService
|
|
24
|
+
>() {}
|
|
25
|
+
|
|
26
|
+
export const nodeFileSystemService: FileSystemService = {
|
|
27
|
+
readTextFile: (path) =>
|
|
28
|
+
Effect.tryPromise({
|
|
29
|
+
try: () => readFile(path, 'utf-8'),
|
|
30
|
+
catch: (cause) => new FileReadError({ path, cause }),
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
readDirectory: (path) =>
|
|
34
|
+
Effect.tryPromise({
|
|
35
|
+
try: () => readdir(path),
|
|
36
|
+
catch: (cause) => new FileReadError({ path, cause }),
|
|
37
|
+
}),
|
|
38
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Effect layers for the code-reviewer extension.
|
|
3
|
+
*
|
|
4
|
+
* `fileSystemLayer` covers disk-only programs (config + lens loading).
|
|
5
|
+
* `makeRuntimeLayer(pi)` adds the `pi.exec`-backed Executor for git/diff and
|
|
6
|
+
* lens-tool programs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
10
|
+
import { Layer } from 'effect';
|
|
11
|
+
|
|
12
|
+
import { Executor, makeExecutorService } from './exec';
|
|
13
|
+
import { FileSystem, nodeFileSystemService } from './filesystem';
|
|
14
|
+
|
|
15
|
+
export const fileSystemLayer = Layer.succeed(FileSystem, nodeFileSystemService);
|
|
16
|
+
|
|
17
|
+
export function makeRuntimeLayer(pi: Pick<ExtensionAPI, 'exec'>) {
|
|
18
|
+
return Layer.mergeAll(fileSystemLayer, Layer.succeed(Executor, makeExecutorService(pi)));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CodeReviewerServices = FileSystem | Executor;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged error types for the code-reviewer extension.
|
|
3
|
+
*
|
|
4
|
+
* Modeled with Effect's `Data.TaggedError` so failures are typed and carry
|
|
5
|
+
* structured context. Helpers convert them into human-readable messages and
|
|
6
|
+
* native `Error`s when an Effect crosses back into Promise-land.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Data } from 'effect';
|
|
10
|
+
|
|
11
|
+
export class FileReadError extends Data.TaggedError('FileReadError')<{
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly cause: unknown;
|
|
14
|
+
}> {
|
|
15
|
+
get message(): string {
|
|
16
|
+
return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class ExecError extends Data.TaggedError('ExecError')<{
|
|
21
|
+
readonly command: string;
|
|
22
|
+
readonly args: readonly string[];
|
|
23
|
+
readonly cause: unknown;
|
|
24
|
+
}> {
|
|
25
|
+
get message(): string {
|
|
26
|
+
const cmd = [this.command, ...this.args].join(' ');
|
|
27
|
+
return `Command failed: ${cmd}: ${causeMessage(this.cause)}`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type CodeReviewerError = FileReadError | ExecError;
|
|
32
|
+
|
|
33
|
+
// ── Helpers ───────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
export function causeMessage(cause: unknown): string {
|
|
36
|
+
if (cause instanceof Error) return cause.message;
|
|
37
|
+
return String(cause);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function errorMessage(error: unknown): string {
|
|
41
|
+
if (error instanceof Error) return error.message;
|
|
42
|
+
if (typeof error === 'object' && error !== null && 'message' in error) {
|
|
43
|
+
const message = (error as { message?: unknown }).message;
|
|
44
|
+
if (typeof message === 'string') return message;
|
|
45
|
+
}
|
|
46
|
+
return String(error);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Convert a tagged/unknown error into a native Error for Promise rejection. */
|
|
50
|
+
export function toNativeError(error: unknown): Error {
|
|
51
|
+
if (error instanceof Error) return error;
|
|
52
|
+
const native = new Error(errorMessage(error));
|
|
53
|
+
if (typeof error === 'object' && error !== null && '_tag' in error) {
|
|
54
|
+
native.name = String((error as { _tag: unknown })._tag);
|
|
55
|
+
}
|
|
56
|
+
return native;
|
|
57
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { Effect } from 'effect';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { FileSystem, nodeFileSystemService } from './effects/filesystem';
|
|
3
5
|
import type { LensConfig, LensSeverity } from './types';
|
|
4
6
|
|
|
5
7
|
type SectionKind = 'top' | 'criteria' | 'tools' | 'severity';
|
|
@@ -91,27 +93,52 @@ function parseSeverityRule(trimmed: string, rules: Record<LensSeverity, string>)
|
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
/** Discover all lens files in a directory. */
|
|
95
|
-
export function
|
|
96
|
-
|
|
96
|
+
/** Discover all lens files in a directory (missing dir → empty map). */
|
|
97
|
+
export function discoverLensesEffect(
|
|
98
|
+
lensDir: string,
|
|
99
|
+
): Effect.Effect<Map<string, LensConfig>, never, FileSystem> {
|
|
100
|
+
return Effect.gen(function* () {
|
|
101
|
+
const fs = yield* FileSystem;
|
|
102
|
+
const lenses = new Map<string, LensConfig>();
|
|
103
|
+
|
|
104
|
+
const entries = yield* fs.readDirectory(lensDir).pipe(Effect.either);
|
|
105
|
+
if (entries._tag === 'Left') return lenses;
|
|
106
|
+
|
|
107
|
+
for (const file of entries.right.filter((f) => f.endsWith('.md'))) {
|
|
108
|
+
const content = yield* fs.readTextFile(resolve(lensDir, file)).pipe(Effect.either);
|
|
109
|
+
if (content._tag === 'Left') continue;
|
|
110
|
+
const key = basename(file, '.md');
|
|
111
|
+
lenses.set(key, parseLensFile(content.right, file));
|
|
112
|
+
}
|
|
97
113
|
|
|
98
|
-
|
|
114
|
+
return lenses;
|
|
115
|
+
});
|
|
116
|
+
}
|
|
99
117
|
|
|
100
|
-
|
|
118
|
+
/** Get raw markdown content for a lens to pass to the reviewer agent. */
|
|
119
|
+
export function getLensContentEffect(
|
|
120
|
+
lensDir: string,
|
|
121
|
+
lensName: string,
|
|
122
|
+
): Effect.Effect<string | null, never, FileSystem> {
|
|
123
|
+
return Effect.gen(function* () {
|
|
124
|
+
const fs = yield* FileSystem;
|
|
125
|
+
const content = yield* fs.readTextFile(resolve(lensDir, `${lensName}.md`)).pipe(Effect.either);
|
|
126
|
+
return content._tag === 'Right' ? content.right : null;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
101
129
|
|
|
102
|
-
|
|
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
|
-
}
|
|
130
|
+
// ── Promise wrappers (live FileSystem provided) ──────────────────────────────
|
|
108
131
|
|
|
109
|
-
|
|
132
|
+
export function discoverLenses(lensDir: string): Promise<Map<string, LensConfig>> {
|
|
133
|
+
return Effect.runPromise(
|
|
134
|
+
discoverLensesEffect(lensDir).pipe(Effect.provideService(FileSystem, nodeFileSystemService)),
|
|
135
|
+
);
|
|
110
136
|
}
|
|
111
137
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
138
|
+
export function getLensContent(lensDir: string, lensName: string): Promise<string | null> {
|
|
139
|
+
return Effect.runPromise(
|
|
140
|
+
getLensContentEffect(lensDir, lensName).pipe(
|
|
141
|
+
Effect.provideService(FileSystem, nodeFileSystemService),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
117
144
|
}
|
|
@@ -1,36 +1,70 @@
|
|
|
1
1
|
import { platform } from 'node:os';
|
|
2
2
|
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
3
|
+
import { Effect } from 'effect';
|
|
4
|
+
|
|
3
5
|
import type { DiffSource } from './diff';
|
|
6
|
+
import { Executor, makeExecutorService } from './effects/exec';
|
|
4
7
|
import type { LensConfig, LensResult } from './types';
|
|
5
8
|
|
|
6
9
|
const isWindows = platform() === 'win32';
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
+
export type ToolRunOptions = { timeoutMs: number; concurrency: number };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run a set of project tool commands ONCE, deduped and concurrently, and
|
|
15
|
+
* collect their output keyed by the original command string.
|
|
16
|
+
*
|
|
17
|
+
* Tools are deduped across lenses by the caller (and again here defensively),
|
|
18
|
+
* so a command shared by several lenses runs a single time — not once per
|
|
19
|
+
* lens. Each command is shelled out with a bounded timeout; a failure or
|
|
20
|
+
* timeout degrades to a sentinel string instead of failing the whole review.
|
|
21
|
+
*/
|
|
22
|
+
export function runToolsEffect(
|
|
11
23
|
cwd: string,
|
|
12
24
|
tools: string[],
|
|
25
|
+
options: ToolRunOptions,
|
|
13
26
|
signal?: AbortSignal,
|
|
14
|
-
):
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
): Effect.Effect<Record<string, string>, never, Executor> {
|
|
28
|
+
return Effect.gen(function* () {
|
|
29
|
+
const unique = [...new Set(tools)];
|
|
30
|
+
if (unique.length === 0 || signal?.aborted) return {};
|
|
31
|
+
|
|
32
|
+
const executor = yield* Executor;
|
|
33
|
+
|
|
34
|
+
const entries = yield* Effect.forEach(
|
|
35
|
+
unique,
|
|
36
|
+
(tool) =>
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
if (signal?.aborted) return [tool, '(skipped: review aborted)'] as const;
|
|
39
|
+
|
|
40
|
+
const [shell, shellArgs] = isWindows ? ['cmd', ['/c', tool]] : ['sh', ['-c', tool]];
|
|
41
|
+
const result = yield* executor
|
|
42
|
+
.exec(shell, shellArgs as string[], { cwd, timeout: options.timeoutMs, signal })
|
|
43
|
+
.pipe(Effect.either);
|
|
44
|
+
|
|
45
|
+
const output =
|
|
46
|
+
result._tag === 'Right'
|
|
47
|
+
? result.right.stdout || result.right.stderr || '(no output)'
|
|
48
|
+
: `(tool failed or timed out: ${tool})`;
|
|
49
|
+
return [tool, output] as const;
|
|
50
|
+
}),
|
|
51
|
+
{ concurrency: Math.max(1, options.concurrency) },
|
|
52
|
+
);
|
|
32
53
|
|
|
33
|
-
|
|
54
|
+
return Object.fromEntries(entries);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Pick the subset of already-run tool outputs that a given lens declares. */
|
|
59
|
+
export function pickLensToolOutputs(
|
|
60
|
+
lens: LensConfig,
|
|
61
|
+
allOutputs: Record<string, string>,
|
|
62
|
+
): Record<string, string> {
|
|
63
|
+
const picked: Record<string, string> = {};
|
|
64
|
+
for (const tool of lens.tools) {
|
|
65
|
+
if (tool in allOutputs) picked[tool] = allOutputs[tool];
|
|
66
|
+
}
|
|
67
|
+
return picked;
|
|
34
68
|
}
|
|
35
69
|
|
|
36
70
|
/** Build the shared diff section of the review prompt (included once). */
|
|
@@ -38,14 +72,20 @@ export function buildDiffSection(diff: DiffSource): string {
|
|
|
38
72
|
const parts: string[] = [];
|
|
39
73
|
const maxDiffLen = 50_000;
|
|
40
74
|
const diffTruncated = diff.diff.length > maxDiffLen;
|
|
75
|
+
// Cut at the last newline within budget so we never emit a half-line of
|
|
76
|
+
// diff (which reads as a corrupt hunk); fall back to a hard slice if a
|
|
77
|
+
// single line already exceeds the budget.
|
|
78
|
+
const body = diffTruncated
|
|
79
|
+
? diff.diff.slice(0, Math.max(diff.diff.lastIndexOf('\n', maxDiffLen), 0) || maxDiffLen)
|
|
80
|
+
: diff.diff;
|
|
41
81
|
|
|
42
82
|
parts.push(`## Diff (${diff.label})`);
|
|
43
83
|
parts.push('```diff');
|
|
44
|
-
parts.push(
|
|
84
|
+
parts.push(body);
|
|
45
85
|
parts.push('```');
|
|
46
86
|
if (diffTruncated) {
|
|
47
87
|
parts.push(
|
|
48
|
-
`> ⚠️ Diff truncated (${diff.diff.length} chars →
|
|
88
|
+
`> ⚠️ Diff truncated (${diff.diff.length} chars → ~${maxDiffLen}). Some files may not appear above; re-run scoped with \`--base\` or per-area if needed.`,
|
|
49
89
|
);
|
|
50
90
|
}
|
|
51
91
|
parts.push('');
|
|
@@ -58,7 +98,7 @@ export function buildDiffSection(diff: DiffSource): string {
|
|
|
58
98
|
}
|
|
59
99
|
|
|
60
100
|
/** Build the lens-specific section of the review prompt (no diff duplication). */
|
|
61
|
-
function buildLensSection(
|
|
101
|
+
export function buildLensSection(
|
|
62
102
|
lens: LensConfig,
|
|
63
103
|
lensContent: string,
|
|
64
104
|
toolOutputs: Record<string, string>,
|
|
@@ -90,64 +130,36 @@ function buildLensSection(
|
|
|
90
130
|
return parts.join('\n');
|
|
91
131
|
}
|
|
92
132
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Build the lens result from PRE-COMPUTED tool outputs. Pure — no IO — so tool
|
|
135
|
+
* execution happens once up front (see {@link runToolsEffect}) and is shared
|
|
136
|
+
* across every lens that declares the same command.
|
|
137
|
+
*/
|
|
138
|
+
export function buildLensResult(
|
|
95
139
|
lens: LensConfig,
|
|
96
140
|
lensContent: string,
|
|
97
|
-
diff: DiffSource,
|
|
98
141
|
toolOutputs: Record<string, string>,
|
|
99
|
-
):
|
|
100
|
-
const parts: string[] = [];
|
|
101
|
-
|
|
102
|
-
parts.push(`You are reviewing code changes through the "${lens.name}" lens.`);
|
|
103
|
-
parts.push('');
|
|
104
|
-
parts.push(buildDiffSection(diff));
|
|
105
|
-
parts.push('');
|
|
106
|
-
parts.push(buildLensSection(lens, lensContent, toolOutputs));
|
|
107
|
-
parts.push('');
|
|
108
|
-
parts.push('## Instructions');
|
|
109
|
-
parts.push('');
|
|
110
|
-
parts.push('Review the diff above through this lens. For each finding, output a JSON array:');
|
|
111
|
-
parts.push('');
|
|
112
|
-
parts.push('```json');
|
|
113
|
-
parts.push('[');
|
|
114
|
-
parts.push(
|
|
115
|
-
' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
|
|
116
|
-
);
|
|
117
|
-
parts.push(']');
|
|
118
|
-
parts.push('```');
|
|
119
|
-
parts.push('');
|
|
120
|
-
parts.push(
|
|
121
|
-
'After the JSON array, write a 2-3 sentence summary of your review through this lens.',
|
|
122
|
-
);
|
|
123
|
-
parts.push('If there are no findings, return an empty array `[]` and note the code looks good.');
|
|
124
|
-
|
|
125
|
-
return parts.join('\n');
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Execute a review for a single lens using the subagent tool. */
|
|
129
|
-
export async function reviewWithLens(
|
|
130
|
-
pi: ExtensionAPI,
|
|
131
|
-
_ctx: unknown,
|
|
132
|
-
cwd: string,
|
|
133
|
-
lens: LensConfig,
|
|
134
|
-
lensContent: string,
|
|
135
|
-
diff: DiffSource,
|
|
136
|
-
signal?: AbortSignal,
|
|
137
|
-
): Promise<LensResult> {
|
|
138
|
-
// Run lens tools first
|
|
139
|
-
const toolOutputs = await runLensTools(pi, cwd, lens.tools, signal);
|
|
140
|
-
|
|
141
|
-
// Build the prompt
|
|
142
|
-
const prompt = buildReviewPrompt(lens, lensContent, diff, toolOutputs);
|
|
143
|
-
const lensSection = buildLensSection(lens, lensContent, toolOutputs);
|
|
144
|
-
|
|
142
|
+
): LensResult {
|
|
145
143
|
return {
|
|
146
144
|
lens: lens.name,
|
|
147
145
|
findings: [],
|
|
148
146
|
summary: '',
|
|
149
147
|
toolOutputs,
|
|
150
|
-
|
|
151
|
-
_lensSection: lensSection,
|
|
148
|
+
_lensSection: buildLensSection(lens, lensContent, toolOutputs),
|
|
152
149
|
};
|
|
153
150
|
}
|
|
151
|
+
|
|
152
|
+
/** Promise wrapper: run a deduped tool set once, building a live Executor from `pi`. */
|
|
153
|
+
export function runTools(
|
|
154
|
+
pi: Pick<ExtensionAPI, 'exec'>,
|
|
155
|
+
cwd: string,
|
|
156
|
+
tools: string[],
|
|
157
|
+
options: ToolRunOptions,
|
|
158
|
+
signal?: AbortSignal,
|
|
159
|
+
): Promise<Record<string, string>> {
|
|
160
|
+
return Effect.runPromise(
|
|
161
|
+
runToolsEffect(cwd, tools, options, signal).pipe(
|
|
162
|
+
Effect.provideService(Executor, makeExecutorService(pi)),
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -20,20 +20,22 @@ export type LensResult = {
|
|
|
20
20
|
findings: LensFinding[];
|
|
21
21
|
summary: string;
|
|
22
22
|
toolOutputs?: Record<string, string>;
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
/** Lens-specific section (without diff), used by /review command to avoid diff duplication. */
|
|
23
|
+
/** Lens-specific prompt section (without the diff), assembled by the command
|
|
24
|
+
* layer with a single shared diff to avoid per-lens duplication. */
|
|
26
25
|
_lensSection?: string;
|
|
27
26
|
};
|
|
28
27
|
|
|
28
|
+
// NOTE: findings + summary on LensResult describe what the agent produces in
|
|
29
|
+
// its follow-up message; the tool/command layer emits a review *task*, it does
|
|
30
|
+
// not parse findings back into a rendered report.
|
|
31
|
+
|
|
29
32
|
export type ReviewConfig = {
|
|
30
33
|
lensDir: string;
|
|
31
34
|
defaultLenses: string[];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
generatedAt: string;
|
|
35
|
+
/** Per-tool wall-clock timeout in ms. A lens tool that exceeds it is killed
|
|
36
|
+
* and reported as timed-out (it must never hang the review). */
|
|
37
|
+
toolTimeoutMs: number;
|
|
38
|
+
/** Max lens tools run in parallel. Tools are deduped across lenses first,
|
|
39
|
+
* so this bounds the distinct command set, not lens count. */
|
|
40
|
+
toolConcurrency: number;
|
|
39
41
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-code-reviewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Multi-lens code review extension for pi — configurable review criteria per project",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -21,9 +21,10 @@
|
|
|
21
21
|
"type": "module",
|
|
22
22
|
"scripts": {
|
|
23
23
|
"typecheck": "tsc --noEmit",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"format
|
|
24
|
+
"test": "bun test",
|
|
25
|
+
"lint": "oxlint extensions test",
|
|
26
|
+
"format": "oxfmt --write extensions test",
|
|
27
|
+
"format:check": "oxfmt --check extensions test"
|
|
27
28
|
},
|
|
28
29
|
"pi": {
|
|
29
30
|
"extensions": [
|
|
@@ -33,8 +34,12 @@
|
|
|
33
34
|
"./skills"
|
|
34
35
|
]
|
|
35
36
|
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"effect": "^3.21.2"
|
|
39
|
+
},
|
|
36
40
|
"devDependencies": {
|
|
37
41
|
"@types/node": "24",
|
|
42
|
+
"bun-types": "latest",
|
|
38
43
|
"oxfmt": "^0.43.0",
|
|
39
44
|
"oxlint": "^1.58.0",
|
|
40
45
|
"typescript": "^6.0.0"
|
|
@@ -1,109 +0,0 @@
|
|
|
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
|
-
}
|