@dreki-gg/pi-code-reviewer 0.1.1 → 0.3.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/extensions/code-reviewer/commands/review-lenses.ts +2 -2
- package/extensions/code-reviewer/commands/review-tool.ts +17 -5
- package/extensions/code-reviewer/commands/review.ts +34 -9
- package/extensions/code-reviewer/config.ts +29 -12
- package/extensions/code-reviewer/diff.ts +83 -57
- 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 +96 -58
- package/extensions/code-reviewer/types.ts +2 -0
- package/package.json +9 -4
|
@@ -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(
|
|
@@ -38,11 +38,11 @@ export function registerReviewTool(pi: ExtensionAPI) {
|
|
|
38
38
|
),
|
|
39
39
|
}),
|
|
40
40
|
|
|
41
|
-
async execute(_toolCallId, params, signal,
|
|
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 {
|
|
@@ -58,12 +58,14 @@ export function registerReviewTool(pi: ExtensionAPI) {
|
|
|
58
58
|
|
|
59
59
|
const lensNames = resolveLensNames(params.lenses, config, available);
|
|
60
60
|
|
|
61
|
+
ctx.ui.setStatus('code-review', '🔍 Collecting diff...');
|
|
61
62
|
const diff = await collectDiff(pi, cwd, {
|
|
62
63
|
base: params.base,
|
|
63
64
|
staged: params.staged,
|
|
64
65
|
});
|
|
65
66
|
|
|
66
67
|
if (!diff.diff.trim()) {
|
|
68
|
+
ctx.ui.setStatus('code-review', undefined);
|
|
67
69
|
return {
|
|
68
70
|
content: [{ type: 'text', text: 'No changes to review.' }],
|
|
69
71
|
details: {},
|
|
@@ -71,15 +73,25 @@ export function registerReviewTool(pi: ExtensionAPI) {
|
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
const results: LensResult[] = [];
|
|
74
|
-
for (
|
|
76
|
+
for (let i = 0; i < lensNames.length; i++) {
|
|
75
77
|
if (signal?.aborted) break;
|
|
76
78
|
|
|
79
|
+
const name = lensNames[i];
|
|
80
|
+
const progressMsg = `Lens ${i + 1}/${lensNames.length}: ${name}`;
|
|
81
|
+
ctx.ui.setStatus('code-review', `🔍 ${progressMsg}`);
|
|
82
|
+
onUpdate?.({
|
|
83
|
+
content: [{ type: 'text', text: progressMsg }],
|
|
84
|
+
details: { currentLens: name, lensIndex: i + 1, totalLenses: lensNames.length },
|
|
85
|
+
});
|
|
86
|
+
|
|
77
87
|
const lens = available.get(name)!;
|
|
78
|
-
const content = getLensContent(lensDir, name) ?? '';
|
|
88
|
+
const content = (await getLensContent(lensDir, name)) ?? '';
|
|
79
89
|
const result = await reviewWithLens(pi, ctx, cwd, lens, content, diff, signal);
|
|
80
90
|
results.push(result);
|
|
81
91
|
}
|
|
82
92
|
|
|
93
|
+
ctx.ui.setStatus('code-review', undefined);
|
|
94
|
+
|
|
83
95
|
const report: ReviewReport = {
|
|
84
96
|
diff: diff.diff,
|
|
85
97
|
diffStat: diff.stat,
|
|
@@ -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 { reviewWithLens } from '../reviewer';
|
|
6
|
+
import { reviewWithLens, buildDiffSection } 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(
|
|
@@ -34,36 +34,61 @@ export function registerReviewCommand(pi: ExtensionAPI) {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
ctx.ui.setStatus('code-review', '🔍 Collecting diff...');
|
|
37
38
|
const diff = await collectDiff(pi, cwd, {
|
|
38
39
|
base: parsed.base,
|
|
39
40
|
staged: parsed.staged,
|
|
40
41
|
});
|
|
41
42
|
|
|
42
43
|
if (!diff.diff.trim()) {
|
|
44
|
+
ctx.ui.setStatus('code-review', undefined);
|
|
43
45
|
ctx.ui.notify('No changes to review', 'info');
|
|
44
46
|
return;
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
ctx.ui.notify(`Reviewing ${diff.label} through ${lensNames.length} lens(es)...`, 'info');
|
|
50
|
+
ctx.ui.setStatus('code-review', `🔍 Reviewing (0/${lensNames.length})...`);
|
|
51
|
+
|
|
52
|
+
const lensSections: string[] = [];
|
|
53
|
+
for (let i = 0; i < lensNames.length; i++) {
|
|
54
|
+
const name = lensNames[i];
|
|
55
|
+
ctx.ui.setStatus('code-review', `🔍 Lens ${i + 1}/${lensNames.length}: ${name}`);
|
|
48
56
|
|
|
49
|
-
const lensPrompts: string[] = [];
|
|
50
|
-
for (const name of lensNames) {
|
|
51
57
|
const lens = available.get(name)!;
|
|
52
|
-
const content = getLensContent(lensDir, name) ?? '';
|
|
58
|
+
const content = (await getLensContent(lensDir, name)) ?? '';
|
|
53
59
|
const result = await reviewWithLens(pi, ctx, cwd, lens, content, diff);
|
|
54
60
|
|
|
55
|
-
if (result.
|
|
56
|
-
|
|
61
|
+
if (result._lensSection) {
|
|
62
|
+
lensSections.push(result._lensSection);
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
ctx.ui.setStatus('code-review', undefined);
|
|
67
|
+
|
|
60
68
|
const combinedPrompt = [
|
|
61
69
|
`Review the following changes through ${lensNames.length} lens(es): ${lensNames.join(', ')}.`,
|
|
62
70
|
'',
|
|
63
71
|
'For each lens, evaluate the diff against its criteria and produce findings.',
|
|
64
72
|
'Output your review as a structured report with sections per lens.',
|
|
65
73
|
'',
|
|
66
|
-
|
|
74
|
+
buildDiffSection(diff),
|
|
75
|
+
'',
|
|
76
|
+
'## Lenses',
|
|
77
|
+
'',
|
|
78
|
+
...lensSections,
|
|
79
|
+
'',
|
|
80
|
+
'## Instructions',
|
|
81
|
+
'',
|
|
82
|
+
'For each lens above, review the diff and output a JSON array of findings:',
|
|
83
|
+
'',
|
|
84
|
+
'```json',
|
|
85
|
+
'[',
|
|
86
|
+
' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
|
|
87
|
+
']',
|
|
88
|
+
'```',
|
|
89
|
+
'',
|
|
90
|
+
'After each lens JSON array, write a 2-3 sentence summary.',
|
|
91
|
+
'If there are no findings for a lens, return an empty array `[]` and note the code looks good.',
|
|
67
92
|
].join('\n');
|
|
68
93
|
|
|
69
94
|
pi.sendUserMessage(combinedPrompt, { deliverAs: 'followUp' });
|
|
@@ -1,30 +1,47 @@
|
|
|
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';
|
|
7
17
|
|
|
8
|
-
|
|
9
|
-
|
|
18
|
+
function defaultConfig(): ReviewConfig {
|
|
19
|
+
return { lensDir: DEFAULT_LENS_DIR, defaultLenses: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadConfigEffect(cwd: string): Effect.Effect<ReviewConfig, never, FileSystem> {
|
|
23
|
+
return Effect.gen(function* () {
|
|
24
|
+
const fs = yield* FileSystem;
|
|
25
|
+
const raw = yield* fs.readTextFile(getConfigPath(cwd)).pipe(Effect.either);
|
|
26
|
+
if (raw._tag === 'Left') return defaultConfig();
|
|
10
27
|
|
|
11
|
-
if (existsSync(configPath)) {
|
|
12
28
|
try {
|
|
13
|
-
const
|
|
14
|
-
const parsed = JSON.parse(raw) as Partial<ReviewConfig>;
|
|
29
|
+
const parsed = JSON.parse(raw.right) as Partial<ReviewConfig>;
|
|
15
30
|
return {
|
|
16
31
|
lensDir: parsed.lensDir ?? DEFAULT_LENS_DIR,
|
|
17
32
|
defaultLenses: parsed.defaultLenses ?? [],
|
|
18
33
|
};
|
|
19
34
|
} catch {
|
|
20
|
-
// Malformed config — fall back to defaults
|
|
35
|
+
// Malformed config — fall back to defaults.
|
|
36
|
+
return defaultConfig();
|
|
21
37
|
}
|
|
22
|
-
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
23
40
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
41
|
+
export function loadConfig(cwd: string): Promise<ReviewConfig> {
|
|
42
|
+
return Effect.runPromise(
|
|
43
|
+
loadConfigEffect(cwd).pipe(Effect.provideService(FileSystem, nodeFileSystemService)),
|
|
44
|
+
);
|
|
28
45
|
}
|
|
29
46
|
|
|
30
47
|
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,88 @@ export type DiffSource = {
|
|
|
6
17
|
label: string;
|
|
7
18
|
};
|
|
8
19
|
|
|
20
|
+
export type DiffOptions = { base?: string; staged?: boolean };
|
|
21
|
+
|
|
22
|
+
function git(args: string[], cwd: string): Effect.Effect<string, ExecError, Executor> {
|
|
23
|
+
return Effect.gen(function* () {
|
|
24
|
+
const executor = yield* Executor;
|
|
25
|
+
const result = yield* executor.exec('git', args, { cwd });
|
|
26
|
+
return result.stdout;
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
/** Collect the diff from the working directory or a specific base ref. */
|
|
10
|
-
export
|
|
11
|
-
pi: ExtensionAPI,
|
|
31
|
+
export function collectDiffEffect(
|
|
12
32
|
cwd: string,
|
|
13
|
-
options:
|
|
14
|
-
):
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
diff:
|
|
20
|
-
|
|
21
|
-
label: 'staged changes',
|
|
22
|
-
};
|
|
23
|
-
}
|
|
33
|
+
options: DiffOptions,
|
|
34
|
+
): Effect.Effect<DiffSource, ExecError, Executor> {
|
|
35
|
+
return Effect.gen(function* () {
|
|
36
|
+
if (options.staged) {
|
|
37
|
+
const diff = yield* git(['diff', '--staged'], cwd);
|
|
38
|
+
const stat = yield* git(['diff', '--staged', '--stat'], cwd);
|
|
39
|
+
return { diff, stat, label: 'staged changes' };
|
|
40
|
+
}
|
|
24
41
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
stat: stat.stdout,
|
|
31
|
-
label: `changes since ${options.base}`,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
42
|
+
if (options.base) {
|
|
43
|
+
const diff = yield* git(['diff', options.base], cwd);
|
|
44
|
+
const stat = yield* git(['diff', options.base, '--stat'], cwd);
|
|
45
|
+
return { diff, stat, label: `changes since ${options.base}` };
|
|
46
|
+
}
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const stat = await pi.exec('git', ['diff', 'HEAD', '--stat'], { cwd });
|
|
48
|
+
// Default: working directory changes (unstaged + staged) relative to HEAD.
|
|
49
|
+
const diff = yield* git(['diff', 'HEAD'], cwd);
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
stat: wdStat.stdout,
|
|
46
|
-
label: 'working directory changes',
|
|
47
|
-
};
|
|
48
|
-
}
|
|
51
|
+
// If no HEAD diff, fall back to just the working directory.
|
|
52
|
+
if (!diff.trim()) {
|
|
53
|
+
const wdDiff = yield* git(['diff'], cwd);
|
|
54
|
+
const wdStat = yield* git(['diff', '--stat'], cwd);
|
|
55
|
+
return { diff: wdDiff, stat: wdStat, label: 'working directory changes' };
|
|
56
|
+
}
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
diff:
|
|
52
|
-
|
|
53
|
-
label: 'all uncommitted changes',
|
|
54
|
-
};
|
|
58
|
+
const stat = yield* git(['diff', 'HEAD', '--stat'], cwd);
|
|
59
|
+
return { diff, stat, label: 'all uncommitted changes' };
|
|
60
|
+
});
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/** Get a list of changed file paths from the diff. */
|
|
58
|
-
export
|
|
59
|
-
pi: ExtensionAPI,
|
|
64
|
+
export function getChangedFilesEffect(
|
|
60
65
|
cwd: string,
|
|
61
|
-
options:
|
|
62
|
-
):
|
|
63
|
-
|
|
66
|
+
options: DiffOptions,
|
|
67
|
+
): Effect.Effect<string[], ExecError, Executor> {
|
|
68
|
+
return Effect.gen(function* () {
|
|
69
|
+
const args = ['diff', '--name-only'];
|
|
70
|
+
if (options.staged) args.push('--staged');
|
|
71
|
+
else if (options.base) args.push(options.base);
|
|
72
|
+
else args.push('HEAD');
|
|
64
73
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
const stdout = yield* git(args, cwd);
|
|
75
|
+
return stdout
|
|
76
|
+
.split('\n')
|
|
77
|
+
.map((f) => f.trim())
|
|
78
|
+
.filter(Boolean);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
72
81
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// ── Promise wrappers (live Executor from pi) ──────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export function collectDiff(
|
|
85
|
+
pi: Pick<ExtensionAPI, 'exec'>,
|
|
86
|
+
cwd: string,
|
|
87
|
+
options: DiffOptions,
|
|
88
|
+
): Promise<DiffSource> {
|
|
89
|
+
return Effect.runPromise(
|
|
90
|
+
collectDiffEffect(cwd, options).pipe(Effect.provideService(Executor, makeExecutorService(pi))),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getChangedFiles(
|
|
95
|
+
pi: Pick<ExtensionAPI, 'exec'>,
|
|
96
|
+
cwd: string,
|
|
97
|
+
options: DiffOptions,
|
|
98
|
+
): Promise<string[]> {
|
|
99
|
+
return Effect.runPromise(
|
|
100
|
+
getChangedFilesEffect(cwd, options).pipe(
|
|
101
|
+
Effect.provideService(Executor, makeExecutorService(pi)),
|
|
102
|
+
),
|
|
103
|
+
);
|
|
78
104
|
}
|
|
@@ -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,55 +1,48 @@
|
|
|
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
11
|
/** Run project tools specified by a lens and collect their output. */
|
|
9
|
-
|
|
10
|
-
pi: ExtensionAPI,
|
|
12
|
+
function runLensToolsEffect(
|
|
11
13
|
cwd: string,
|
|
12
14
|
tools: string[],
|
|
13
15
|
signal?: AbortSignal,
|
|
14
|
-
):
|
|
15
|
-
|
|
16
|
+
): Effect.Effect<Record<string, string>, never, Executor> {
|
|
17
|
+
return Effect.gen(function* () {
|
|
18
|
+
const executor = yield* Executor;
|
|
19
|
+
const outputs: Record<string, string> = {};
|
|
16
20
|
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
for (const tool of tools) {
|
|
22
|
+
if (signal?.aborted) break;
|
|
19
23
|
|
|
20
|
-
try {
|
|
21
24
|
const [shell, shellArgs] = isWindows ? ['cmd', ['/c', tool]] : ['sh', ['-c', tool]];
|
|
22
|
-
const result =
|
|
23
|
-
cwd,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
const result = yield* executor
|
|
26
|
+
.exec(shell, shellArgs as string[], { cwd, timeout: 60_000, signal })
|
|
27
|
+
.pipe(Effect.either);
|
|
28
|
+
|
|
29
|
+
outputs[tool] =
|
|
30
|
+
result._tag === 'Right'
|
|
31
|
+
? result.right.stdout || result.right.stderr || '(no output)'
|
|
32
|
+
: `(tool failed or timed out: ${tool})`;
|
|
30
33
|
}
|
|
31
|
-
}
|
|
32
34
|
|
|
33
|
-
|
|
35
|
+
return outputs;
|
|
36
|
+
});
|
|
34
37
|
}
|
|
35
38
|
|
|
36
|
-
/** Build the review prompt
|
|
37
|
-
function
|
|
38
|
-
lens: LensConfig,
|
|
39
|
-
lensContent: string,
|
|
40
|
-
diff: DiffSource,
|
|
41
|
-
toolOutputs: Record<string, string>,
|
|
42
|
-
): string {
|
|
39
|
+
/** Build the shared diff section of the review prompt (included once). */
|
|
40
|
+
export function buildDiffSection(diff: DiffSource): string {
|
|
43
41
|
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
42
|
const maxDiffLen = 50_000;
|
|
52
43
|
const diffTruncated = diff.diff.length > maxDiffLen;
|
|
44
|
+
|
|
45
|
+
parts.push(`## Diff (${diff.label})`);
|
|
53
46
|
parts.push('```diff');
|
|
54
47
|
parts.push(diff.diff.slice(0, maxDiffLen));
|
|
55
48
|
parts.push('```');
|
|
@@ -64,21 +57,60 @@ function buildReviewPrompt(
|
|
|
64
57
|
parts.push(diff.stat);
|
|
65
58
|
parts.push('```');
|
|
66
59
|
|
|
60
|
+
return parts.join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Build the lens-specific section of the review prompt (no diff duplication). */
|
|
64
|
+
function buildLensSection(
|
|
65
|
+
lens: LensConfig,
|
|
66
|
+
lensContent: string,
|
|
67
|
+
toolOutputs: Record<string, string>,
|
|
68
|
+
): string {
|
|
69
|
+
const parts: string[] = [];
|
|
70
|
+
|
|
71
|
+
parts.push(`### Lens: ${lens.name}`);
|
|
72
|
+
parts.push('');
|
|
73
|
+
parts.push('#### Lens Definition');
|
|
74
|
+
parts.push(lensContent);
|
|
75
|
+
|
|
67
76
|
if (Object.keys(toolOutputs).length > 0) {
|
|
68
77
|
parts.push('');
|
|
69
|
-
parts.push('
|
|
78
|
+
parts.push('#### Tool Outputs');
|
|
70
79
|
for (const [cmd, output] of Object.entries(toolOutputs)) {
|
|
71
|
-
parts.push(
|
|
80
|
+
parts.push(`##### \`${cmd}\``);
|
|
72
81
|
parts.push('```');
|
|
73
82
|
parts.push(output.slice(0, 20_000));
|
|
74
83
|
parts.push('```');
|
|
75
84
|
}
|
|
76
85
|
}
|
|
77
86
|
|
|
87
|
+
parts.push('');
|
|
88
|
+
parts.push('#### Severity levels');
|
|
89
|
+
if (lens.severityRules.blocker) parts.push(`- **blocker**: ${lens.severityRules.blocker}`);
|
|
90
|
+
if (lens.severityRules.warning) parts.push(`- **warning**: ${lens.severityRules.warning}`);
|
|
91
|
+
if (lens.severityRules.note) parts.push(`- **note**: ${lens.severityRules.note}`);
|
|
92
|
+
|
|
93
|
+
return parts.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Build the full review prompt for a single lens (includes diff — used by the tool path). */
|
|
97
|
+
function buildReviewPrompt(
|
|
98
|
+
lens: LensConfig,
|
|
99
|
+
lensContent: string,
|
|
100
|
+
diff: DiffSource,
|
|
101
|
+
toolOutputs: Record<string, string>,
|
|
102
|
+
): string {
|
|
103
|
+
const parts: string[] = [];
|
|
104
|
+
|
|
105
|
+
parts.push(`You are reviewing code changes through the "${lens.name}" lens.`);
|
|
106
|
+
parts.push('');
|
|
107
|
+
parts.push(buildDiffSection(diff));
|
|
108
|
+
parts.push('');
|
|
109
|
+
parts.push(buildLensSection(lens, lensContent, toolOutputs));
|
|
78
110
|
parts.push('');
|
|
79
111
|
parts.push('## Instructions');
|
|
80
112
|
parts.push('');
|
|
81
|
-
parts.push('Review the diff through this lens. For each finding, output a JSON array:');
|
|
113
|
+
parts.push('Review the diff above through this lens. For each finding, output a JSON array:');
|
|
82
114
|
parts.push('');
|
|
83
115
|
parts.push('```json');
|
|
84
116
|
parts.push('[');
|
|
@@ -88,11 +120,6 @@ function buildReviewPrompt(
|
|
|
88
120
|
parts.push(']');
|
|
89
121
|
parts.push('```');
|
|
90
122
|
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
123
|
parts.push(
|
|
97
124
|
'After the JSON array, write a 2-3 sentence summary of your review through this lens.',
|
|
98
125
|
);
|
|
@@ -101,9 +128,31 @@ function buildReviewPrompt(
|
|
|
101
128
|
return parts.join('\n');
|
|
102
129
|
}
|
|
103
130
|
|
|
104
|
-
/** Execute a review for a single lens
|
|
105
|
-
export
|
|
106
|
-
|
|
131
|
+
/** Execute a review for a single lens: run its tools, then build the prompt. */
|
|
132
|
+
export function reviewWithLensEffect(
|
|
133
|
+
cwd: string,
|
|
134
|
+
lens: LensConfig,
|
|
135
|
+
lensContent: string,
|
|
136
|
+
diff: DiffSource,
|
|
137
|
+
signal?: AbortSignal,
|
|
138
|
+
): Effect.Effect<LensResult, never, Executor> {
|
|
139
|
+
return Effect.gen(function* () {
|
|
140
|
+
const toolOutputs = yield* runLensToolsEffect(cwd, lens.tools, signal);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
lens: lens.name,
|
|
144
|
+
findings: [],
|
|
145
|
+
summary: '',
|
|
146
|
+
toolOutputs,
|
|
147
|
+
_prompt: buildReviewPrompt(lens, lensContent, diff, toolOutputs),
|
|
148
|
+
_lensSection: buildLensSection(lens, lensContent, toolOutputs),
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Promise wrapper building a live Executor from `pi`. */
|
|
154
|
+
export function reviewWithLens(
|
|
155
|
+
pi: Pick<ExtensionAPI, 'exec'>,
|
|
107
156
|
_ctx: unknown,
|
|
108
157
|
cwd: string,
|
|
109
158
|
lens: LensConfig,
|
|
@@ -111,20 +160,9 @@ export async function reviewWithLens(
|
|
|
111
160
|
diff: DiffSource,
|
|
112
161
|
signal?: AbortSignal,
|
|
113
162
|
): Promise<LensResult> {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
};
|
|
163
|
+
return Effect.runPromise(
|
|
164
|
+
reviewWithLensEffect(cwd, lens, lensContent, diff, signal).pipe(
|
|
165
|
+
Effect.provideService(Executor, makeExecutorService(pi)),
|
|
166
|
+
),
|
|
167
|
+
);
|
|
130
168
|
}
|
|
@@ -22,6 +22,8 @@ export type LensResult = {
|
|
|
22
22
|
toolOutputs?: Record<string, string>;
|
|
23
23
|
/** Review prompt built for this lens, used internally to delegate to the agent. */
|
|
24
24
|
_prompt?: string;
|
|
25
|
+
/** Lens-specific section (without diff), used by /review command to avoid diff duplication. */
|
|
26
|
+
_lensSection?: string;
|
|
25
27
|
};
|
|
26
28
|
|
|
27
29
|
export type ReviewConfig = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dreki-gg/pi-code-reviewer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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"
|