@dreki-gg/pi-code-reviewer 0.3.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 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 should reference the project's actual tools (from package.json scripts).`,
18
- `5. Tailor the criteria to the project's stack and conventions.`,
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'),
@@ -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 { reviewWithLens } from '../reviewer';
8
- import { buildReport } from '../report';
9
- import type { LensResult, ReviewConfig, ReviewReport } from '../types';
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({
@@ -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 = available.get(name)!;
104
+ const lens = selected[i];
88
105
  const content = (await getLensContent(lensDir, name)) ?? '';
89
- const result = await reviewWithLens(pi, ctx, cwd, lens, content, diff, signal);
90
- results.push(result);
106
+ results.push(buildLensResult(lens, content, pickLensToolOutputs(lens, toolOutputs)));
91
107
  }
92
108
 
93
109
  ctx.ui.setStatus('code-review', undefined);
94
110
 
95
- const report: ReviewReport = {
96
- diff: diff.diff,
97
- diffStat: diff.stat,
98
- lenses: results,
99
- generatedAt: new Date().toISOString().slice(0, 10),
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: markdown + toolContext }],
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
- function buildToolContext(results: LensResult[]): string {
135
- const prompts = results.map((r) => r._prompt).filter(Boolean);
136
-
137
- if (prompts.length === 0) return '';
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
- 'The tool outputs above provide automated analysis. Now evaluate the diff through each lens criteria:',
175
+ '```json',
176
+ '[',
177
+ ' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
178
+ ']',
179
+ '```',
144
180
  '',
145
- ...prompts,
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 { reviewWithLens, buildDiffSection } from '../reviewer';
6
+ import { buildDiffSection, buildLensResult, pickLensToolOutputs, runTools } from '../reviewer';
7
7
  import { parseReviewArgs } from '../parse-args';
8
8
 
9
9
  export function registerReviewCommand(pi: ExtensionAPI) {
@@ -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
- ctx.ui.setStatus('code-review', `🔍 Reviewing (0/${lensNames.length})...`);
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 = available.get(name)!;
66
+ const lens = selected[i];
58
67
  const content = (await getLensContent(lensDir, name)) ?? '';
59
- const result = await reviewWithLens(pi, ctx, cwd, lens, content, diff);
60
-
61
- if (result._lensSection) {
62
- lensSections.push(result._lensSection);
63
- }
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);
@@ -14,9 +14,23 @@ import type { ReviewConfig } from './types';
14
14
 
15
15
  const CONFIG_FILE = '.code-review.json';
16
16
  const DEFAULT_LENS_DIR = '.code-review/lenses';
17
+ const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
18
+ const DEFAULT_TOOL_CONCURRENCY = 4;
17
19
 
18
20
  function defaultConfig(): ReviewConfig {
19
- return { lensDir: DEFAULT_LENS_DIR, defaultLenses: [] };
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;
20
34
  }
21
35
 
22
36
  export function loadConfigEffect(cwd: string): Effect.Effect<ReviewConfig, never, FileSystem> {
@@ -30,6 +44,8 @@ export function loadConfigEffect(cwd: string): Effect.Effect<ReviewConfig, never
30
44
  return {
31
45
  lensDir: parsed.lensDir ?? DEFAULT_LENS_DIR,
32
46
  defaultLenses: parsed.defaultLenses ?? [],
47
+ toolTimeoutMs: positiveIntOr(parsed.toolTimeoutMs, DEFAULT_TOOL_TIMEOUT_MS),
48
+ toolConcurrency: positiveIntOr(parsed.toolConcurrency, DEFAULT_TOOL_CONCURRENCY),
33
49
  };
34
50
  } catch {
35
51
  // Malformed config — fall back to defaults.
@@ -19,10 +19,14 @@ export type DiffSource = {
19
19
 
20
20
  export type DiffOptions = { base?: string; staged?: boolean };
21
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
+
22
26
  function git(args: string[], cwd: string): Effect.Effect<string, ExecError, Executor> {
23
27
  return Effect.gen(function* () {
24
28
  const executor = yield* Executor;
25
- const result = yield* executor.exec('git', args, { cwd });
29
+ const result = yield* executor.exec('git', args, { cwd, timeout: GIT_TIMEOUT_MS });
26
30
  return result.stdout;
27
31
  });
28
32
  }
@@ -46,17 +50,19 @@ export function collectDiffEffect(
46
50
  }
47
51
 
48
52
  // Default: working directory changes (unstaged + staged) relative to HEAD.
49
- const diff = yield* git(['diff', 'HEAD'], cwd);
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);
50
56
 
51
- // If no HEAD diff, fall back to just the working directory.
52
- if (!diff.trim()) {
57
+ // No HEAD (fresh repo) or an empty HEAD diff fall back to the working dir.
58
+ if (headDiff._tag === 'Left' || !headDiff.right.trim()) {
53
59
  const wdDiff = yield* git(['diff'], cwd);
54
60
  const wdStat = yield* git(['diff', '--stat'], cwd);
55
61
  return { diff: wdDiff, stat: wdStat, label: 'working directory changes' };
56
62
  }
57
63
 
58
64
  const stat = yield* git(['diff', 'HEAD', '--stat'], cwd);
59
- return { diff, stat, label: 'all uncommitted changes' };
65
+ return { diff: headDiff.right, stat, label: 'all uncommitted changes' };
60
66
  });
61
67
  }
62
68
 
@@ -8,47 +8,84 @@ import type { LensConfig, LensResult } from './types';
8
8
 
9
9
  const isWindows = platform() === 'win32';
10
10
 
11
- /** Run project tools specified by a lens and collect their output. */
12
- function runLensToolsEffect(
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(
13
23
  cwd: string,
14
24
  tools: string[],
25
+ options: ToolRunOptions,
15
26
  signal?: AbortSignal,
16
27
  ): Effect.Effect<Record<string, string>, never, Executor> {
17
28
  return Effect.gen(function* () {
18
- const executor = yield* Executor;
19
- const outputs: Record<string, string> = {};
20
-
21
- for (const tool of tools) {
22
- if (signal?.aborted) break;
29
+ const unique = [...new Set(tools)];
30
+ if (unique.length === 0 || signal?.aborted) return {};
23
31
 
24
- const [shell, shellArgs] = isWindows ? ['cmd', ['/c', tool]] : ['sh', ['-c', tool]];
25
- const result = yield* executor
26
- .exec(shell, shellArgs as string[], { cwd, timeout: 60_000, signal })
27
- .pipe(Effect.either);
32
+ const executor = yield* Executor;
28
33
 
29
- outputs[tool] =
30
- result._tag === 'Right'
31
- ? result.right.stdout || result.right.stderr || '(no output)'
32
- : `(tool failed or timed out: ${tool})`;
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
+ );
34
53
 
35
- return outputs;
54
+ return Object.fromEntries(entries);
36
55
  });
37
56
  }
38
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;
68
+ }
69
+
39
70
  /** Build the shared diff section of the review prompt (included once). */
40
71
  export function buildDiffSection(diff: DiffSource): string {
41
72
  const parts: string[] = [];
42
73
  const maxDiffLen = 50_000;
43
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;
44
81
 
45
82
  parts.push(`## Diff (${diff.label})`);
46
83
  parts.push('```diff');
47
- parts.push(diff.diff.slice(0, maxDiffLen));
84
+ parts.push(body);
48
85
  parts.push('```');
49
86
  if (diffTruncated) {
50
87
  parts.push(
51
- `> ⚠️ Diff truncated (${diff.diff.length} chars → ${maxDiffLen}). Some files may not appear above.`,
88
+ `> ⚠️ Diff truncated (${diff.diff.length} chars → ~${maxDiffLen}). Some files may not appear above; re-run scoped with \`--base\` or per-area if needed.`,
52
89
  );
53
90
  }
54
91
  parts.push('');
@@ -61,7 +98,7 @@ export function buildDiffSection(diff: DiffSource): string {
61
98
  }
62
99
 
63
100
  /** Build the lens-specific section of the review prompt (no diff duplication). */
64
- function buildLensSection(
101
+ export function buildLensSection(
65
102
  lens: LensConfig,
66
103
  lensContent: string,
67
104
  toolOutputs: Record<string, string>,
@@ -93,75 +130,35 @@ function buildLensSection(
93
130
  return parts.join('\n');
94
131
  }
95
132
 
96
- /** Build the full review prompt for a single lens (includes diff — used by the tool path). */
97
- function buildReviewPrompt(
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(
98
139
  lens: LensConfig,
99
140
  lensContent: string,
100
- diff: DiffSource,
101
141
  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));
110
- parts.push('');
111
- parts.push('## Instructions');
112
- parts.push('');
113
- parts.push('Review the diff above through this lens. For each finding, output a JSON array:');
114
- parts.push('');
115
- parts.push('```json');
116
- parts.push('[');
117
- parts.push(
118
- ' { "file": "path/to/file.ts", "line": 42, "severity": "warning", "message": "Description" }',
119
- );
120
- parts.push(']');
121
- parts.push('```');
122
- parts.push('');
123
- parts.push(
124
- 'After the JSON array, write a 2-3 sentence summary of your review through this lens.',
125
- );
126
- parts.push('If there are no findings, return an empty array `[]` and note the code looks good.');
127
-
128
- return parts.join('\n');
142
+ ): LensResult {
143
+ return {
144
+ lens: lens.name,
145
+ findings: [],
146
+ summary: '',
147
+ toolOutputs,
148
+ _lensSection: buildLensSection(lens, lensContent, toolOutputs),
149
+ };
129
150
  }
130
151
 
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(
152
+ /** Promise wrapper: run a deduped tool set once, building a live Executor from `pi`. */
153
+ export function runTools(
155
154
  pi: Pick<ExtensionAPI, 'exec'>,
156
- _ctx: unknown,
157
155
  cwd: string,
158
- lens: LensConfig,
159
- lensContent: string,
160
- diff: DiffSource,
156
+ tools: string[],
157
+ options: ToolRunOptions,
161
158
  signal?: AbortSignal,
162
- ): Promise<LensResult> {
159
+ ): Promise<Record<string, string>> {
163
160
  return Effect.runPromise(
164
- reviewWithLensEffect(cwd, lens, lensContent, diff, signal).pipe(
161
+ runToolsEffect(cwd, tools, options, signal).pipe(
165
162
  Effect.provideService(Executor, makeExecutorService(pi)),
166
163
  ),
167
164
  );
@@ -20,20 +20,22 @@ export type LensResult = {
20
20
  findings: LensFinding[];
21
21
  summary: string;
22
22
  toolOutputs?: Record<string, string>;
23
- /** Review prompt built for this lens, used internally to delegate to the agent. */
24
- _prompt?: string;
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
- export type ReviewReport = {
35
- diff: string;
36
- diffStat: string;
37
- lenses: LensResult[];
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.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"
@@ -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
- }