@dreki-gg/pi-code-reviewer 0.5.0 → 0.6.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.
|
@@ -148,17 +148,32 @@ export function registerReviewTool(pi: ExtensionAPI) {
|
|
|
148
148
|
signal,
|
|
149
149
|
);
|
|
150
150
|
ctx.ui.setStatus('code-review', undefined);
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
// Every pass failed (e.g. the review model/pi-ai was unavailable for
|
|
152
|
+
// each call). The swallowed failures would render as a misleading
|
|
153
|
+
// "0 findings" report — instead, degrade to the single-pass prompt so
|
|
154
|
+
// the reviewing agent still produces a real review.
|
|
155
|
+
const allPassesFailed =
|
|
156
|
+
config.review.passes > 0 && pipeline.telemetry.failedPasses >= config.review.passes;
|
|
157
|
+
if (!allPassesFailed) {
|
|
158
|
+
return {
|
|
159
|
+
content: [{ type: 'text', text: renderPipelineReport(pipeline, diff) }],
|
|
160
|
+
details: {
|
|
161
|
+
mode: 'pipeline',
|
|
162
|
+
lensCount: lensNames.length,
|
|
163
|
+
availableLenses: [...available.keys()],
|
|
164
|
+
changedFiles,
|
|
165
|
+
findings: pipeline.findings,
|
|
166
|
+
telemetry: pipeline.telemetry,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
onUpdate?.({
|
|
171
|
+
content: [{ type: 'text', text: 'all review passes failed — single-pass fallback' }],
|
|
153
172
|
details: {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
availableLenses: [...available.keys()],
|
|
157
|
-
changedFiles,
|
|
158
|
-
findings: pipeline.findings,
|
|
159
|
-
telemetry: pipeline.telemetry,
|
|
173
|
+
failedPasses: pipeline.telemetry.failedPasses,
|
|
174
|
+
passError: pipeline.telemetry.passErrorSample,
|
|
160
175
|
},
|
|
161
|
-
};
|
|
176
|
+
});
|
|
162
177
|
} catch (cause) {
|
|
163
178
|
// Pipeline failed hard (e.g. model/pi-ai unavailable at runtime) —
|
|
164
179
|
// degrade to the single-pass prompt instead of failing the review.
|
|
@@ -23,6 +23,14 @@ export type DiffOptions = { base?: string; staged?: boolean };
|
|
|
23
23
|
* the whole review. */
|
|
24
24
|
const GIT_TIMEOUT_MS = 30_000;
|
|
25
25
|
|
|
26
|
+
/** Cap on untracked files diffed against /dev/null so a repo full of generated
|
|
27
|
+
* junk can't blow up the prompt. The whole diff is truncated downstream too. */
|
|
28
|
+
const MAX_UNTRACKED_FILES = 200;
|
|
29
|
+
|
|
30
|
+
/** The empty tree object — diffing a path against it yields a full new-file
|
|
31
|
+
* diff portably (no reliance on /dev/null path handling across platforms). */
|
|
32
|
+
const NULL_DEVICE = '/dev/null';
|
|
33
|
+
|
|
26
34
|
function git(args: string[], cwd: string): Effect.Effect<string, ExecError, Executor> {
|
|
27
35
|
return Effect.gen(function* () {
|
|
28
36
|
const executor = yield* Executor;
|
|
@@ -31,6 +39,51 @@ function git(args: string[], cwd: string): Effect.Effect<string, ExecError, Exec
|
|
|
31
39
|
});
|
|
32
40
|
}
|
|
33
41
|
|
|
42
|
+
/**
|
|
43
|
+
* Diff every untracked (new, not-yet-`git add`ed) file against /dev/null so
|
|
44
|
+
* brand-new files show up in a working-directory review — `git diff HEAD`
|
|
45
|
+
* omits them entirely, which is exactly the class of change agents introduce.
|
|
46
|
+
*
|
|
47
|
+
* Read-only: it NEVER touches the index (no `git add -N`). `git diff --no-index`
|
|
48
|
+
* exits non-zero when files differ, but pi.exec resolves with the diff on stdout
|
|
49
|
+
* regardless; any per-file failure degrades to an empty string rather than
|
|
50
|
+
* sinking the whole review.
|
|
51
|
+
*/
|
|
52
|
+
function collectUntrackedEffect(
|
|
53
|
+
cwd: string,
|
|
54
|
+
): Effect.Effect<{ diff: string; files: string[] }, never, Executor> {
|
|
55
|
+
return Effect.gen(function* () {
|
|
56
|
+
const listed = yield* git(['ls-files', '--others', '--exclude-standard'], cwd).pipe(
|
|
57
|
+
Effect.orElseSucceed(() => ''),
|
|
58
|
+
);
|
|
59
|
+
const files = listed
|
|
60
|
+
.split('\n')
|
|
61
|
+
.map((f) => f.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
if (files.length === 0) return { diff: '', files: [] };
|
|
64
|
+
|
|
65
|
+
const parts = yield* Effect.forEach(
|
|
66
|
+
files.slice(0, MAX_UNTRACKED_FILES),
|
|
67
|
+
(file) =>
|
|
68
|
+
git(['diff', '--no-index', '--', NULL_DEVICE, file], cwd).pipe(
|
|
69
|
+
Effect.orElseSucceed(() => ''),
|
|
70
|
+
),
|
|
71
|
+
{ concurrency: 4 },
|
|
72
|
+
);
|
|
73
|
+
return { diff: parts.filter((part) => part.trim()).join('\n'), files };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Append a one-line-per-file summary of untracked files to a `--stat` block so
|
|
78
|
+
* the change overview reflects new files that git's own stat never lists. */
|
|
79
|
+
function appendUntrackedStat(stat: string, files: string[]): string {
|
|
80
|
+
if (files.length === 0) return stat;
|
|
81
|
+
const shown = files.slice(0, MAX_UNTRACKED_FILES);
|
|
82
|
+
const lines = shown.map((file) => ` ${file} | (new, untracked)`);
|
|
83
|
+
const note = `${files.length} untracked file(s) included`;
|
|
84
|
+
return [stat.trimEnd(), ...lines, note].filter(Boolean).join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
34
87
|
/** Collect the diff from the working directory or a specific base ref. */
|
|
35
88
|
export function collectDiffEffect(
|
|
36
89
|
cwd: string,
|
|
@@ -49,20 +102,31 @@ export function collectDiffEffect(
|
|
|
49
102
|
return { diff, stat, label: `changes since ${options.base}` };
|
|
50
103
|
}
|
|
51
104
|
|
|
52
|
-
// Default:
|
|
53
|
-
//
|
|
105
|
+
// Default: EVERYTHING the agent is working on but hasn't committed —
|
|
106
|
+
// tracked changes (unstaged + staged) relative to HEAD, PLUS untracked
|
|
107
|
+
// (brand-new) files. `git diff HEAD` covers only the former; untracked
|
|
108
|
+
// files are collected separately and merged so new files are reviewed too.
|
|
109
|
+
// `git diff HEAD` also fails on a repo with no commits (HEAD is unborn), so
|
|
54
110
|
// tolerate that and fall back to the bare working-directory diff.
|
|
55
111
|
const headDiff = yield* git(['diff', 'HEAD'], cwd).pipe(Effect.either);
|
|
112
|
+
const untracked = yield* collectUntrackedEffect(cwd);
|
|
56
113
|
|
|
57
|
-
|
|
114
|
+
let tracked: string;
|
|
115
|
+
let stat: string;
|
|
116
|
+
let label: string;
|
|
58
117
|
if (headDiff._tag === 'Left' || !headDiff.right.trim()) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
118
|
+
// No HEAD (fresh repo) or no tracked changes → use the bare working dir.
|
|
119
|
+
tracked = yield* git(['diff'], cwd);
|
|
120
|
+
stat = yield* git(['diff', '--stat'], cwd);
|
|
121
|
+
label = 'working directory changes';
|
|
122
|
+
} else {
|
|
123
|
+
tracked = headDiff.right;
|
|
124
|
+
stat = yield* git(['diff', 'HEAD', '--stat'], cwd);
|
|
125
|
+
label = 'all uncommitted changes';
|
|
62
126
|
}
|
|
63
127
|
|
|
64
|
-
const
|
|
65
|
-
return { diff:
|
|
128
|
+
const diff = [tracked, untracked.diff].filter((part) => part.trim()).join('\n');
|
|
129
|
+
return { diff, stat: appendUntrackedStat(stat, untracked.files), label };
|
|
66
130
|
});
|
|
67
131
|
}
|
|
68
132
|
|
|
@@ -72,19 +136,31 @@ export function getChangedFilesEffect(
|
|
|
72
136
|
options: DiffOptions,
|
|
73
137
|
): Effect.Effect<string[], ExecError, Executor> {
|
|
74
138
|
return Effect.gen(function* () {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
139
|
+
if (options.staged || options.base) {
|
|
140
|
+
const args = ['diff', '--name-only', options.staged ? '--staged' : options.base!];
|
|
141
|
+
const stdout = yield* git(args, cwd);
|
|
142
|
+
return splitPaths(stdout);
|
|
143
|
+
}
|
|
79
144
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
145
|
+
// Default: tracked changes vs HEAD (tolerate an unborn HEAD) plus untracked
|
|
146
|
+
// files, deduped, so the changed-file list mirrors the merged default diff.
|
|
147
|
+
const tracked = yield* git(['diff', '--name-only', 'HEAD'], cwd).pipe(
|
|
148
|
+
Effect.orElseSucceed(() => ''),
|
|
149
|
+
);
|
|
150
|
+
const untracked = yield* git(['ls-files', '--others', '--exclude-standard'], cwd).pipe(
|
|
151
|
+
Effect.orElseSucceed(() => ''),
|
|
152
|
+
);
|
|
153
|
+
return [...new Set([...splitPaths(tracked), ...splitPaths(untracked)])];
|
|
85
154
|
});
|
|
86
155
|
}
|
|
87
156
|
|
|
157
|
+
function splitPaths(stdout: string): string[] {
|
|
158
|
+
return stdout
|
|
159
|
+
.split('\n')
|
|
160
|
+
.map((f) => f.trim())
|
|
161
|
+
.filter(Boolean);
|
|
162
|
+
}
|
|
163
|
+
|
|
88
164
|
// ── Promise wrappers (live Executor from pi) ──────────────────────────────────
|
|
89
165
|
|
|
90
166
|
export function collectDiff(
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import { Effect } from 'effect';
|
|
18
18
|
|
|
19
|
+
import { causeMessage } from './errors';
|
|
19
20
|
import { type ModelResolution, Reviewer, makeReviewerService } from './effects/model';
|
|
20
21
|
import type {
|
|
21
22
|
CandidateFinding,
|
|
@@ -355,7 +356,11 @@ export function runPassesEffect(
|
|
|
355
356
|
config: ReviewPipelineConfig,
|
|
356
357
|
plan: ModelPlan,
|
|
357
358
|
signal?: AbortSignal,
|
|
358
|
-
): Effect.Effect<
|
|
359
|
+
): Effect.Effect<
|
|
360
|
+
{ perPass: RawFinding[][]; failedPasses: number; passErrorSample?: string },
|
|
361
|
+
never,
|
|
362
|
+
Reviewer
|
|
363
|
+
> {
|
|
359
364
|
return Effect.gen(function* () {
|
|
360
365
|
const reviewer = yield* Reviewer;
|
|
361
366
|
const indices = Array.from({ length: config.passes }, (_unused, index) => index);
|
|
@@ -380,19 +385,29 @@ export function runPassesEffect(
|
|
|
380
385
|
})
|
|
381
386
|
.pipe(Effect.either);
|
|
382
387
|
return result._tag === 'Right'
|
|
383
|
-
? { findings: parseFindings(result.right), failed: false }
|
|
384
|
-
: { findings: [] as RawFinding[], failed: true };
|
|
388
|
+
? { findings: parseFindings(result.right), failed: false, error: undefined }
|
|
389
|
+
: { findings: [] as RawFinding[], failed: true, error: describePassError(result.left) };
|
|
385
390
|
}),
|
|
386
391
|
{ concurrency: Math.max(1, config.concurrency) },
|
|
387
392
|
);
|
|
388
393
|
|
|
394
|
+
const failures = outcomes.filter((outcome) => outcome.failed);
|
|
389
395
|
return {
|
|
390
396
|
perPass: outcomes.map((outcome) => outcome.findings),
|
|
391
|
-
failedPasses:
|
|
397
|
+
failedPasses: failures.length,
|
|
398
|
+
passErrorSample: failures[0]?.error,
|
|
392
399
|
};
|
|
393
400
|
});
|
|
394
401
|
}
|
|
395
402
|
|
|
403
|
+
/** Best-effort human message for a failed pass: the ModelError's own message
|
|
404
|
+
* when present, else its underlying cause. */
|
|
405
|
+
function describePassError(error: unknown): string {
|
|
406
|
+
const message = (error as { message?: unknown }).message;
|
|
407
|
+
if (typeof message === 'string' && message.trim()) return message;
|
|
408
|
+
return causeMessage((error as { cause?: unknown }).cause);
|
|
409
|
+
}
|
|
410
|
+
|
|
396
411
|
function buildValidatorUser(basePrompt: string, candidates: CandidateFinding[]): string {
|
|
397
412
|
const list = candidates
|
|
398
413
|
.map((candidate, index) => {
|
|
@@ -514,7 +529,12 @@ export function runPipelineEffect(
|
|
|
514
529
|
): Effect.Effect<PipelineResult, never, Reviewer> {
|
|
515
530
|
return Effect.gen(function* () {
|
|
516
531
|
hooks.onStage?.(`running ${config.passes} passes`);
|
|
517
|
-
const { perPass, failedPasses } = yield* runPassesEffect(
|
|
532
|
+
const { perPass, failedPasses, passErrorSample } = yield* runPassesEffect(
|
|
533
|
+
basePrompt,
|
|
534
|
+
config,
|
|
535
|
+
plan,
|
|
536
|
+
signal,
|
|
537
|
+
);
|
|
518
538
|
|
|
519
539
|
const buckets = bucketFindings(perPass);
|
|
520
540
|
const { kept, droppedLowSignal } = selectCandidates(buckets, config);
|
|
@@ -547,6 +567,7 @@ export function runPipelineEffect(
|
|
|
547
567
|
droppedFalsePositives,
|
|
548
568
|
droppedLowSignal,
|
|
549
569
|
failedPasses,
|
|
570
|
+
passErrorSample,
|
|
550
571
|
passModels: plan.passes.map((assignment) => assignment.label),
|
|
551
572
|
validatorModel: plan.validator.label,
|
|
552
573
|
};
|
|
@@ -159,10 +159,38 @@ export function renderPipelineReport(result: PipelineResult, diff: DiffSource):
|
|
|
159
159
|
'',
|
|
160
160
|
];
|
|
161
161
|
|
|
162
|
+
// A pass fails when its model call errors; failures are swallowed into 0
|
|
163
|
+
// findings, so an all-failed run must NOT masquerade as a clean review.
|
|
164
|
+
const someFailed = telemetry.failedPasses > 0;
|
|
165
|
+
const allFailed = telemetry.passes > 0 && telemetry.failedPasses >= telemetry.passes;
|
|
166
|
+
const errSuffix = telemetry.passErrorSample ? ` — e.g. ${telemetry.passErrorSample}` : '';
|
|
167
|
+
|
|
162
168
|
if (findings.length === 0) {
|
|
169
|
+
if (allFailed) {
|
|
170
|
+
return [
|
|
171
|
+
...header,
|
|
172
|
+
`> ⚠️ **Inconclusive — all ${telemetry.passes} review pass(es) failed${errSuffix}.**`,
|
|
173
|
+
'> No analysis actually ran; this is NOT a clean result. Re-run the review',
|
|
174
|
+
'> (check that the review model / pi-ai is available) before trusting it.',
|
|
175
|
+
].join('\n');
|
|
176
|
+
}
|
|
177
|
+
if (someFailed) {
|
|
178
|
+
return [
|
|
179
|
+
...header,
|
|
180
|
+
`> ⚠️ **Partial review — ${telemetry.failedPasses}/${telemetry.passes} pass(es) failed${errSuffix}.**`,
|
|
181
|
+
`> The ${telemetry.passes - telemetry.failedPasses} surviving pass(es) found nothing, but coverage was reduced.`,
|
|
182
|
+
].join('\n');
|
|
183
|
+
}
|
|
163
184
|
return [...header, 'No bugs found that survived validation. ✅'].join('\n');
|
|
164
185
|
}
|
|
165
186
|
|
|
187
|
+
const partialWarning = someFailed
|
|
188
|
+
? [
|
|
189
|
+
`> ⚠️ **Partial review — ${telemetry.failedPasses}/${telemetry.passes} pass(es) failed${errSuffix}; findings below may be incomplete.**`,
|
|
190
|
+
'',
|
|
191
|
+
]
|
|
192
|
+
: [];
|
|
193
|
+
|
|
166
194
|
// Only attribute models per finding when more than one distinct model ran
|
|
167
195
|
// (a bake-off); with a single model it's noise.
|
|
168
196
|
const multiModel = new Set(telemetry.passModels).size > 1;
|
|
@@ -180,7 +208,7 @@ export function renderPipelineReport(result: PipelineResult, diff: DiffSource):
|
|
|
180
208
|
return `- ${SEVERITY_EMOJI[finding.severity]} **${finding.severity}** ${where} — ${finding.message} _(${meta})_${justification}`;
|
|
181
209
|
});
|
|
182
210
|
|
|
183
|
-
return [...header, '## Findings', '', ...lines].join('\n');
|
|
211
|
+
return [...header, ...partialWarning, '## Findings', '', ...lines].join('\n');
|
|
184
212
|
}
|
|
185
213
|
|
|
186
214
|
/** Build the lens-specific section of the review prompt (no diff duplication). */
|
|
@@ -95,6 +95,9 @@ export type PipelineTelemetry = {
|
|
|
95
95
|
droppedFalsePositives: number;
|
|
96
96
|
droppedLowSignal: number;
|
|
97
97
|
failedPasses: number;
|
|
98
|
+
/** A representative error message from the first failed pass, surfaced so a
|
|
99
|
+
* fully-failed run reports WHY instead of a misleading "0 findings". */
|
|
100
|
+
passErrorSample?: string;
|
|
98
101
|
/** Model key used for each pass (parallel to pass index). */
|
|
99
102
|
passModels: string[];
|
|
100
103
|
/** Model key used for the validator stage. */
|