@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
- return {
152
- content: [{ type: 'text', text: renderPipelineReport(pipeline, diff) }],
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
- mode: 'pipeline',
155
- lensCount: lensNames.length,
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: working directory changes (unstaged + staged) relative to HEAD.
53
- // `git diff HEAD` fails on a repo with no commits (HEAD is unborn), so
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
- // No HEAD (fresh repo) or an empty HEAD diff → fall back to the working dir.
114
+ let tracked: string;
115
+ let stat: string;
116
+ let label: string;
58
117
  if (headDiff._tag === 'Left' || !headDiff.right.trim()) {
59
- const wdDiff = yield* git(['diff'], cwd);
60
- const wdStat = yield* git(['diff', '--stat'], cwd);
61
- return { diff: wdDiff, stat: wdStat, label: 'working directory changes' };
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 stat = yield* git(['diff', 'HEAD', '--stat'], cwd);
65
- return { diff: headDiff.right, stat, label: 'all uncommitted changes' };
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
- const args = ['diff', '--name-only'];
76
- if (options.staged) args.push('--staged');
77
- else if (options.base) args.push(options.base);
78
- else args.push('HEAD');
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
- const stdout = yield* git(args, cwd);
81
- return stdout
82
- .split('\n')
83
- .map((f) => f.trim())
84
- .filter(Boolean);
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<{ perPass: RawFinding[][]; failedPasses: number }, never, Reviewer> {
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: outcomes.filter((outcome) => outcome.failed).length,
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(basePrompt, config, plan, signal);
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-code-reviewer",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Multi-lens code review extension for pi — configurable review criteria per project",
5
5
  "keywords": [
6
6
  "pi-package"