@dreki-gg/pi-code-reviewer 0.3.0 → 0.5.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 +74 -1
- package/extensions/code-reviewer/commands/review-init.ts +13 -3
- package/extensions/code-reviewer/commands/review-tool.ts +125 -27
- package/extensions/code-reviewer/commands/review.ts +49 -8
- package/extensions/code-reviewer/config.ts +90 -2
- package/extensions/code-reviewer/diff.ts +11 -5
- package/extensions/code-reviewer/effects/model.ts +112 -0
- package/extensions/code-reviewer/errors.ts +10 -1
- package/extensions/code-reviewer/model-plan.ts +84 -0
- package/extensions/code-reviewer/passes.ts +571 -0
- package/extensions/code-reviewer/reviewer.ts +164 -81
- package/extensions/code-reviewer/types.ts +124 -10
- package/package.json +1 -1
- package/skills/code-review/lenses/code-quality.md +16 -2
- package/extensions/code-reviewer/report.ts +0 -109
|
@@ -4,51 +4,88 @@ import { Effect } from 'effect';
|
|
|
4
4
|
|
|
5
5
|
import type { DiffSource } from './diff';
|
|
6
6
|
import { Executor, makeExecutorService } from './effects/exec';
|
|
7
|
-
import type { LensConfig, LensResult } from './types';
|
|
7
|
+
import type { LensConfig, LensResult, PipelineResult, ValidatedFinding } from './types';
|
|
8
8
|
|
|
9
9
|
const isWindows = platform() === 'win32';
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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(
|
|
84
|
+
parts.push(body);
|
|
48
85
|
parts.push('```');
|
|
49
86
|
if (diffTruncated) {
|
|
50
87
|
parts.push(
|
|
51
|
-
`> ⚠️ 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.`,
|
|
52
89
|
);
|
|
53
90
|
}
|
|
54
91
|
parts.push('');
|
|
@@ -60,8 +97,94 @@ export function buildDiffSection(diff: DiffSource): string {
|
|
|
60
97
|
return parts.join('\n');
|
|
61
98
|
}
|
|
62
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Build the shared review body fed to every pipeline pass: the diff (once) plus
|
|
102
|
+
* each lens definition + its tool outputs, WITHOUT the legacy per-lens output
|
|
103
|
+
* instructions (the pipeline supplies its own adversarial instructions). The
|
|
104
|
+
* legacy single-pass fallback appends its instructions separately.
|
|
105
|
+
*/
|
|
106
|
+
export function buildReviewBasePrompt(lensSections: string[], diff: DiffSource): string {
|
|
107
|
+
return [
|
|
108
|
+
'## Changes',
|
|
109
|
+
'```',
|
|
110
|
+
diff.stat.trim() || '(no diffstat)',
|
|
111
|
+
'```',
|
|
112
|
+
'',
|
|
113
|
+
buildDiffSection(diff),
|
|
114
|
+
'',
|
|
115
|
+
'## Review lenses (project invariants to check)',
|
|
116
|
+
'',
|
|
117
|
+
...lensSections,
|
|
118
|
+
].join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SEVERITY_EMOJI: Record<ValidatedFinding['severity'], string> = {
|
|
122
|
+
blocker: '🔴',
|
|
123
|
+
warning: '🟡',
|
|
124
|
+
note: '🔵',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** A one-line model summary, shown only when a non-default model is in play. */
|
|
128
|
+
function renderModelLine(telemetry: PipelineResult['telemetry']): string[] {
|
|
129
|
+
const passKeys = new Set(telemetry.passModels);
|
|
130
|
+
const allDefault =
|
|
131
|
+
passKeys.size === 1 && passKeys.has('default') && telemetry.validatorModel === 'default';
|
|
132
|
+
if (allDefault) return [];
|
|
133
|
+
|
|
134
|
+
const passCounts = new Map<string, number>();
|
|
135
|
+
for (const key of telemetry.passModels) passCounts.set(key, (passCounts.get(key) ?? 0) + 1);
|
|
136
|
+
const passSummary = [...passCounts.entries()].map(([key, count]) => `${key}×${count}`).join(', ');
|
|
137
|
+
return [`Models — passes: ${passSummary}; validator: ${telemetry.validatorModel}.`];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Render the validated pipeline findings into a Markdown review report. */
|
|
141
|
+
export function renderPipelineReport(result: PipelineResult, diff: DiffSource): string {
|
|
142
|
+
const { findings, telemetry } = result;
|
|
143
|
+
const counts = {
|
|
144
|
+
blocker: findings.filter((finding) => finding.severity === 'blocker').length,
|
|
145
|
+
warning: findings.filter((finding) => finding.severity === 'warning').length,
|
|
146
|
+
note: findings.filter((finding) => finding.severity === 'note').length,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const header = [
|
|
150
|
+
`# Code Review — ${new Date().toISOString().slice(0, 10)}`,
|
|
151
|
+
'',
|
|
152
|
+
`Reviewed ${diff.label} across ${telemetry.passes} adversarial pass(es)` +
|
|
153
|
+
`${telemetry.failedPasses ? ` (${telemetry.failedPasses} failed)` : ''}.`,
|
|
154
|
+
'',
|
|
155
|
+
`**${findings.length} finding(s)** — ${counts.blocker} blocker, ${counts.warning} warning, ${counts.note} note.`,
|
|
156
|
+
`Pipeline: ${telemetry.buckets} buckets → ${telemetry.candidates} candidates → ${telemetry.validated} validated` +
|
|
157
|
+
` (dropped ${telemetry.droppedFalsePositives} false-positive, ${telemetry.droppedLowSignal} low-signal).`,
|
|
158
|
+
...renderModelLine(telemetry),
|
|
159
|
+
'',
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
if (findings.length === 0) {
|
|
163
|
+
return [...header, 'No bugs found that survived validation. ✅'].join('\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Only attribute models per finding when more than one distinct model ran
|
|
167
|
+
// (a bake-off); with a single model it's noise.
|
|
168
|
+
const multiModel = new Set(telemetry.passModels).size > 1;
|
|
169
|
+
const lines = findings.map((finding) => {
|
|
170
|
+
const where = finding.line ? `\`${finding.file}:${finding.line}\`` : `\`${finding.file}\``;
|
|
171
|
+
const meta = [
|
|
172
|
+
`${finding.votes}/${telemetry.passes} votes`,
|
|
173
|
+
`${Math.round(finding.confidence * 100)}% conf`,
|
|
174
|
+
finding.category,
|
|
175
|
+
multiModel && finding.models.length > 0 ? `models: ${finding.models.join(', ')}` : undefined,
|
|
176
|
+
]
|
|
177
|
+
.filter(Boolean)
|
|
178
|
+
.join(', ');
|
|
179
|
+
const justification = finding.justification ? `\n ↳ ${finding.justification}` : '';
|
|
180
|
+
return `- ${SEVERITY_EMOJI[finding.severity]} **${finding.severity}** ${where} — ${finding.message} _(${meta})_${justification}`;
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
return [...header, '## Findings', '', ...lines].join('\n');
|
|
184
|
+
}
|
|
185
|
+
|
|
63
186
|
/** Build the lens-specific section of the review prompt (no diff duplication). */
|
|
64
|
-
function buildLensSection(
|
|
187
|
+
export function buildLensSection(
|
|
65
188
|
lens: LensConfig,
|
|
66
189
|
lensContent: string,
|
|
67
190
|
toolOutputs: Record<string, string>,
|
|
@@ -93,75 +216,35 @@ function buildLensSection(
|
|
|
93
216
|
return parts.join('\n');
|
|
94
217
|
}
|
|
95
218
|
|
|
96
|
-
/**
|
|
97
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Build the lens result from PRE-COMPUTED tool outputs. Pure — no IO — so tool
|
|
221
|
+
* execution happens once up front (see {@link runToolsEffect}) and is shared
|
|
222
|
+
* across every lens that declares the same command.
|
|
223
|
+
*/
|
|
224
|
+
export function buildLensResult(
|
|
98
225
|
lens: LensConfig,
|
|
99
226
|
lensContent: string,
|
|
100
|
-
diff: DiffSource,
|
|
101
227
|
toolOutputs: Record<string, string>,
|
|
102
|
-
):
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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');
|
|
228
|
+
): LensResult {
|
|
229
|
+
return {
|
|
230
|
+
lens: lens.name,
|
|
231
|
+
findings: [],
|
|
232
|
+
summary: '',
|
|
233
|
+
toolOutputs,
|
|
234
|
+
_lensSection: buildLensSection(lens, lensContent, toolOutputs),
|
|
235
|
+
};
|
|
129
236
|
}
|
|
130
237
|
|
|
131
|
-
/**
|
|
132
|
-
export function
|
|
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(
|
|
238
|
+
/** Promise wrapper: run a deduped tool set once, building a live Executor from `pi`. */
|
|
239
|
+
export function runTools(
|
|
155
240
|
pi: Pick<ExtensionAPI, 'exec'>,
|
|
156
|
-
_ctx: unknown,
|
|
157
241
|
cwd: string,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
diff: DiffSource,
|
|
242
|
+
tools: string[],
|
|
243
|
+
options: ToolRunOptions,
|
|
161
244
|
signal?: AbortSignal,
|
|
162
|
-
): Promise<
|
|
245
|
+
): Promise<Record<string, string>> {
|
|
163
246
|
return Effect.runPromise(
|
|
164
|
-
|
|
247
|
+
runToolsEffect(cwd, tools, options, signal).pipe(
|
|
165
248
|
Effect.provideService(Executor, makeExecutorService(pi)),
|
|
166
249
|
),
|
|
167
250
|
);
|
|
@@ -20,20 +20,134 @@ 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
|
+
// ── Self-driving review pipeline (Bugbot-style) ──────────────────────────────
|
|
29
|
+
//
|
|
30
|
+
// The tool can run the review itself by driving the session's model through
|
|
31
|
+
// several parallel adversarial passes, bucketing + majority-voting the
|
|
32
|
+
// findings, then validating each survivor — instead of returning a prompt for
|
|
33
|
+
// a single downstream pass. The types below describe that pipeline's data.
|
|
34
|
+
|
|
35
|
+
/** A finding as emitted by one bug-finding pass (before bucketing). */
|
|
36
|
+
export type RawFinding = {
|
|
37
|
+
file: string;
|
|
38
|
+
line?: number;
|
|
39
|
+
severity: LensSeverity;
|
|
40
|
+
message: string;
|
|
41
|
+
/** Optional bug taxonomy tag the pass assigned (e.g. "boundary-input"). */
|
|
42
|
+
category?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A merged bucket of near-duplicate raw findings across passes. */
|
|
46
|
+
export type CandidateFinding = RawFinding & {
|
|
47
|
+
/** Number of DISTINCT passes that independently surfaced this bucket. */
|
|
48
|
+
votes: number;
|
|
49
|
+
/** Indices of the passes that contributed (0-based). */
|
|
50
|
+
passIndices: number[];
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** A candidate after the validator stage has confirmed or refuted it. */
|
|
54
|
+
export type ValidatedFinding = CandidateFinding & {
|
|
55
|
+
verdict: 'real' | 'false-positive';
|
|
56
|
+
/** Validator confidence in `verdict`, 0..1. */
|
|
57
|
+
confidence: number;
|
|
58
|
+
justification?: string;
|
|
59
|
+
/** Distinct model keys whose passes contributed to this finding (for the
|
|
60
|
+
* model bake-off: "which model caught this"). */
|
|
61
|
+
models: string[];
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Reasoning/thinking effort for a step (mirrors pi-ai's `ThinkingLevel`). */
|
|
65
|
+
export type ReasoningLevel = 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
66
|
+
|
|
67
|
+
/** A per-step model choice in config: either a bare spec string
|
|
68
|
+
* ("provider/id", id, or name) or that spec plus a reasoning level. */
|
|
69
|
+
export type ModelSpec = { model: string; reasoning?: ReasoningLevel };
|
|
70
|
+
export type ModelStepConfig = string | ModelSpec;
|
|
71
|
+
|
|
72
|
+
/** A resolved per-step assignment the pipeline runs against. `key` is either
|
|
73
|
+
* {@link DEFAULT_MODEL_KEY} (the session model) or a spec that resolved to a
|
|
74
|
+
* real model; `label` is the human display (key + reasoning). */
|
|
75
|
+
export type ModelAssignment = {
|
|
76
|
+
key: string;
|
|
77
|
+
label: string;
|
|
78
|
+
reasoning?: ReasoningLevel;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type ModelPlan = {
|
|
82
|
+
/** Assignment for each pass, length === `passes` (round-robin from config). */
|
|
83
|
+
passes: ModelAssignment[];
|
|
84
|
+
/** Assignment for the validator stage. */
|
|
85
|
+
validator: ModelAssignment;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Counts describing what the pipeline did, for transparency in the report. */
|
|
89
|
+
export type PipelineTelemetry = {
|
|
90
|
+
passes: number;
|
|
91
|
+
passFindingCounts: number[];
|
|
92
|
+
buckets: number;
|
|
93
|
+
candidates: number;
|
|
94
|
+
validated: number;
|
|
95
|
+
droppedFalsePositives: number;
|
|
96
|
+
droppedLowSignal: number;
|
|
97
|
+
failedPasses: number;
|
|
98
|
+
/** Model key used for each pass (parallel to pass index). */
|
|
99
|
+
passModels: string[];
|
|
100
|
+
/** Model key used for the validator stage. */
|
|
101
|
+
validatorModel: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export type PipelineResult = {
|
|
105
|
+
findings: ValidatedFinding[];
|
|
106
|
+
telemetry: PipelineTelemetry;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Tunables for the self-driving pipeline (all overridable in config). */
|
|
110
|
+
export type ReviewPipelineConfig = {
|
|
111
|
+
/** Parallel adversarial bug-finding passes. 0 disables the pipeline
|
|
112
|
+
* (falls back to returning a single-pass review prompt). */
|
|
113
|
+
passes: number;
|
|
114
|
+
/** Run the validator stage that falsifies each surviving candidate. */
|
|
115
|
+
validate: boolean;
|
|
116
|
+
/** Min distinct passes a NOTE-severity bucket needs to survive pre-validation
|
|
117
|
+
* (blockers/warnings are never dropped for low votes). */
|
|
118
|
+
minVotes: number;
|
|
119
|
+
/** Max passes run concurrently. */
|
|
120
|
+
concurrency: number;
|
|
121
|
+
/** Base sampling temperature; each pass adds a small deterministic jitter so
|
|
122
|
+
* passes diverge instead of collapsing onto identical reasoning. */
|
|
123
|
+
temperature: number;
|
|
124
|
+
/** Hard cap on findings returned (safety valve against runaway output). */
|
|
125
|
+
maxFindings: number;
|
|
126
|
+
/** Model for ALL passes — a spec string or `{ model, reasoning }`. Omitted →
|
|
127
|
+
* session model. Overridden per-pass by {@link passModels}. */
|
|
128
|
+
passModel?: ModelStepConfig;
|
|
129
|
+
/** Models rotated round-robin across passes — run the same diff through
|
|
130
|
+
* several models/reasoning levels in one review (a bake-off). Overrides
|
|
131
|
+
* `passModel`. */
|
|
132
|
+
passModels?: ModelStepConfig[];
|
|
133
|
+
/** Model for the validator stage — a spec string or `{ model, reasoning }`.
|
|
134
|
+
* Omitted → session model. */
|
|
135
|
+
validateModel?: ModelStepConfig;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// NOTE: findings + summary on LensResult describe what the agent produces in
|
|
139
|
+
// its follow-up message; the tool/command layer emits a review *task*, it does
|
|
140
|
+
// not parse findings back into a rendered report.
|
|
141
|
+
|
|
29
142
|
export type ReviewConfig = {
|
|
30
143
|
lensDir: string;
|
|
31
144
|
defaultLenses: string[];
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
145
|
+
/** Per-tool wall-clock timeout in ms. A lens tool that exceeds it is killed
|
|
146
|
+
* and reported as timed-out (it must never hang the review). */
|
|
147
|
+
toolTimeoutMs: number;
|
|
148
|
+
/** Max lens tools run in parallel. Tools are deduped across lenses first,
|
|
149
|
+
* so this bounds the distinct command set, not lens count. */
|
|
150
|
+
toolConcurrency: number;
|
|
151
|
+
/** Self-driving pipeline tunables (see {@link ReviewPipelineConfig}). */
|
|
152
|
+
review: ReviewPipelineConfig;
|
|
39
153
|
};
|
package/package.json
CHANGED
|
@@ -10,11 +10,25 @@ Evaluates changes for correctness, dead code introduction, and adherence to proj
|
|
|
10
10
|
- Are there any obvious bugs or logic errors?
|
|
11
11
|
- Does the code avoid known anti-patterns for the project's framework?
|
|
12
12
|
|
|
13
|
+
### Adversarial inputs (enumerate, don't assume)
|
|
14
|
+
For each changed function, construct the edge inputs that break it rather than
|
|
15
|
+
trusting the happy path or the surrounding comment:
|
|
16
|
+
- `null` / `undefined` / `NaN` / `Infinity` / `-0` / `""` / `[]` / `{}` / huge /
|
|
17
|
+
negative / duplicate / out-of-order / unicode.
|
|
18
|
+
- Numeric-type guards that the wrong value defeats: `typeof NaN === "number"`,
|
|
19
|
+
`typeof null === "object"`, `0`/`""`/`NaN` as falsy, `JSON.parse` of
|
|
20
|
+
attacker input. Prefer `Number.isFinite` / explicit checks.
|
|
21
|
+
- **Claim-vs-code audit:** every comment or test that asserts an invariant
|
|
22
|
+
("non-numeric falls through", "never empty") — find the input that violates it
|
|
23
|
+
and confirm the code actually enforces the claim.
|
|
24
|
+
- Off-by-one, boundary indices, wrong id/key space, missing `await`, swallowed
|
|
25
|
+
errors, unhandled rejection, cancellation/abort paths.
|
|
26
|
+
|
|
13
27
|
## Tools
|
|
14
28
|
- `bun run typecheck`
|
|
15
29
|
- `bun run lint`
|
|
16
30
|
|
|
17
31
|
## Severity
|
|
18
|
-
- blocker: Type errors, unresolved imports, obvious bugs, unhandled error paths
|
|
19
|
-
- warning: New lint violations, unused code, inconsistent naming
|
|
32
|
+
- blocker: Type errors, unresolved imports, obvious bugs, unhandled error paths, an edge input (NaN/empty/boundary) that crashes or corrupts on a path users hit
|
|
33
|
+
- warning: New lint violations, unused code, inconsistent naming, an unguarded edge input on a lower-risk path, a comment/test claim the code does not actually honor
|
|
20
34
|
- note: Style suggestions, minor improvements
|
|
@@ -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
|
-
}
|