@erichll/pi-auto-review 0.16.0 → 0.17.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.
@@ -134,7 +134,7 @@ function statementOperandPathToken(
134
134
  function executableIndex(tokens: readonly string[]): number {
135
135
  let index = 0;
136
136
  if (tokens[index] === "env") index++;
137
- while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index])) index++;
137
+ while (index < tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=.*/.test(tokens[index] ?? "")) index++;
138
138
  return index;
139
139
  }
140
140
 
@@ -43,9 +43,10 @@ export function parsePolicyAuditArguments(raw: string, retentionDays: number): P
43
43
  const tokens = raw.trim() ? raw.trim().split(/\s+/u) : [];
44
44
  for (let index = 0; index < tokens.length; index++) {
45
45
  const token = tokens[index];
46
+ if (token === undefined) continue;
46
47
  const [flag, inline] = token.split("=", 2);
47
48
  const value = inline ?? tokens[++index];
48
- if (!value) throw new Error(`missing value for ${flag}`);
49
+ if (!flag || !value) throw new Error(`missing value for ${flag}`);
49
50
  if (flag === "--scope") {
50
51
  if (value !== "current" && value !== "all") throw new Error("--scope must be current or all");
51
52
  result.scope = value;
package/src/policy.ts CHANGED
@@ -209,7 +209,7 @@ function exactKeys(
209
209
  function stripReviewerFence(text: string): string {
210
210
  const t = text.trim();
211
211
  const fenced = t.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/i);
212
- return (fenced ? fenced[1] : t).trim();
212
+ return (fenced?.[1] ?? t).trim();
213
213
  }
214
214
 
215
215
  export function parseDecision(text: string): ModelDecision {
@@ -504,6 +504,7 @@ export function deterministicHardDeny(
504
504
  let stagedCredentialVariableUpload = false;
505
505
  for (const match of command.matchAll(stagedCredentialVariableRead)) {
506
506
  const variable = match[1];
507
+ if (!variable) continue;
507
508
  const suffix = command.slice((match.index ?? 0) + match[0].length);
508
509
  const escapedVariable = variable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
509
510
  const variableUpload = new RegExp(
@@ -533,6 +534,7 @@ export function deterministicHardDeny(
533
534
  let stagedCredentialUpload = false;
534
535
  for (const match of command.matchAll(stagedCredentialRead)) {
535
536
  const target = match[1];
537
+ if (!target) continue;
536
538
  const suffix = command.slice((match.index ?? 0) + match[0].length);
537
539
  const escapedTarget = target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
538
540
  const targetUpload = new RegExp(
@@ -798,6 +800,7 @@ function explicitPushBranch(command: string): string | undefined {
798
800
  // push. Multi-ref pushes deliberately receive only the generic Git context.
799
801
  if (positional.length !== 2) return;
800
802
  const refspec = positional[positional.length - 1];
803
+ if (!refspec) return;
801
804
  const destination = refspec.includes(":")
802
805
  ? refspec.slice(refspec.lastIndexOf(":") + 1)
803
806
  : refspec;
@@ -812,7 +815,7 @@ function providerBranchQuery(command: string): ProviderBranchQuery | undefined {
812
815
  /^gh\s+api\s+["']?\/?repos\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/branches\/([^\/\s"']+)(?:\/protection)?["']?$/,
813
816
  );
814
817
  if (github) {
815
- const branch = normalizedBranch(github[1]);
818
+ const branch = normalizedBranch(github[1] ?? "");
816
819
  return branch ? { provider: "github", branch } : undefined;
817
820
  }
818
821
  const gitlab = command
@@ -821,7 +824,7 @@ function providerBranchQuery(command: string): ProviderBranchQuery | undefined {
821
824
  /^glab\s+api\s+["']?\/?projects\/[A-Za-z0-9_.%/-]+\/protected_branches\/([^\/\s"']+)["']?$/,
822
825
  );
823
826
  if (gitlab) {
824
- const branch = normalizedBranch(gitlab[1]);
827
+ const branch = normalizedBranch(gitlab[1] ?? "");
825
828
  return branch ? { provider: "gitlab", branch } : undefined;
826
829
  }
827
830
  return undefined;
@@ -1248,17 +1251,21 @@ function selectRequestAwareTools(
1248
1251
  (left, right) => priority.indexOf(left) - priority.indexOf(right),
1249
1252
  );
1250
1253
  const call = calls.find((candidate) => candidate.candidateId === item.id)!;
1254
+ // Invariant: reasons entries are only created via addReason, which
1255
+ // always pushes at least one reason, and this item passed the
1256
+ // reasons.has(item.id) filter above — so ordered[0] is defined.
1257
+ const primaryReason = ordered[0]!;
1251
1258
  const linkage = exactToolCallReviewerRepresentation(
1252
1259
  call,
1253
1260
  request,
1254
- ordered[0],
1261
+ primaryReason,
1255
1262
  );
1256
1263
  return {
1257
1264
  ...item,
1258
1265
  ...(linkage
1259
1266
  ? { text: linkage, representationCharacters: linkage.length }
1260
1267
  : {}),
1261
- reason: ordered[0],
1268
+ reason: primaryReason,
1262
1269
  secondaryReasons: ordered.slice(1),
1263
1270
  sensitivity:
1264
1271
  ordered.includes("security-combination")
@@ -1384,6 +1391,7 @@ function utf8Suffix(value: string, maxBytes: number): string {
1384
1391
  let result = "";
1385
1392
  for (let index = characters.length - 1; index >= 0; index--) {
1386
1393
  const character = characters[index];
1394
+ if (!character) continue;
1387
1395
  const size = Buffer.byteLength(character, "utf8");
1388
1396
  if (bytes + size > maxBytes) break;
1389
1397
  result = character + result;
@@ -1558,11 +1566,14 @@ function selectEvidence(
1558
1566
  candidates.length > 1
1559
1567
  ? Math.max(1, Math.floor(budgetTokens / 2))
1560
1568
  : budgetTokens;
1561
- add(candidates[0], firstBudget);
1562
- if (candidates.length > 1) add(candidates[candidates.length - 1]);
1569
+ const first = candidates[0];
1570
+ if (first) add(first, firstBudget);
1571
+ const last = candidates[candidates.length - 1];
1572
+ if (candidates.length > 1 && last) add(last);
1563
1573
  }
1564
1574
  for (let index = candidates.length - 1; index >= 0; index--) {
1565
- add(candidates[index]);
1575
+ const candidate = candidates[index];
1576
+ if (candidate) add(candidate);
1566
1577
  }
1567
1578
  return {
1568
1579
  selected: [...selected.values()],
@@ -0,0 +1,14 @@
1
+ import { appendFileSync } from "node:fs";
2
+
3
+ export function writeOptionalAuditFile(event: unknown): void {
4
+ const auditFile = process.env.PI_AUTO_REVIEW_AUDIT_FILE;
5
+ if (!auditFile) return;
6
+ try {
7
+ appendFileSync(auditFile, `${JSON.stringify(event)}\n`, {
8
+ encoding: "utf8",
9
+ mode: 0o600,
10
+ });
11
+ } catch {
12
+ // The optional test/release audit sink is observational only.
13
+ }
14
+ }
@@ -0,0 +1,347 @@
1
+ import {
2
+ ReviewExecutionError,
3
+ } from "./types.ts";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ buildClassifierTranscript,
7
+ parseDecision,
8
+ type ModelDecision,
9
+ } from "../policy.ts";
10
+ import type {
11
+ BoundaryRequest,
12
+ BoundaryReview,
13
+ BoundaryReviewerContext,
14
+ } from "../broker/index.ts";
15
+ import type {
16
+ CompletionMessage,
17
+ Config,
18
+ ReviewAttemptObservation,
19
+ ReviewAttemptStatus,
20
+ ReviewErrorClass,
21
+ ReviewExecutionSummary,
22
+ ReviewResult,
23
+ ReviewerMeta,
24
+ ReviewerRuntime,
25
+ ReviewerTelemetryEvent,
26
+ } from "./types.ts";
27
+ import { FORMAT_RETRY_INSTRUCTION } from "./consts.ts";
28
+ import {
29
+ applyReviewerInputBudget,
30
+ preflightPart,
31
+ reviewPreflight,
32
+ sharedReviewContext,
33
+ textFromAssistant,
34
+ } from "./input.ts";
35
+ import {
36
+ abortableDelay,
37
+ abortableOperation,
38
+ classifyProviderFailure,
39
+ incrementError,
40
+ isFormatError,
41
+ isRetryableError,
42
+ modelCall,
43
+ normalizedStopReason,
44
+ observedUsage,
45
+ parseErrorClass,
46
+ resolveApiKeyAndHeaders,
47
+ resolveReviewerMeta,
48
+ retryDelayMs,
49
+ reviewerSessionId,
50
+ type ProviderAttemptMetadata,
51
+ type ReviewerResolver,
52
+ } from "./provider.ts";
53
+
54
+ export async function complete(
55
+ ctx: ExtensionContext,
56
+ config: Config,
57
+ request: BoundaryRequest,
58
+ reviewerContext?: BoundaryReviewerContext,
59
+ resolve: ReviewerResolver = resolveReviewerMeta,
60
+ observe?: (event: ReviewerTelemetryEvent) => void,
61
+ ): Promise<ReviewResult> {
62
+ const started = Date.now();
63
+ const selectedTranscript = buildClassifierTranscript(
64
+ ctx.sessionManager.buildContextEntries(),
65
+ config,
66
+ {
67
+ ...request,
68
+ trustedRetryOriginalRequestId:
69
+ reviewerContext?.userOverride?.originalRequestId,
70
+ },
71
+ );
72
+ const transcript = applyReviewerInputBudget(
73
+ request,
74
+ selectedTranscript,
75
+ reviewerContext,
76
+ config.maxReviewerInputTokens,
77
+ );
78
+ const sharedContext = sharedReviewContext(
79
+ request,
80
+ transcript,
81
+ reviewerContext,
82
+ );
83
+ const preflight = reviewPreflight(
84
+ request,
85
+ transcript,
86
+ reviewerContext,
87
+ sharedContext,
88
+ config.maxReviewerInputTokens,
89
+ );
90
+ const deadlineAt = started + config.timeoutMs;
91
+ const controller = new AbortController();
92
+ const onSessionAbort = () => controller.abort();
93
+ if (ctx.signal?.aborted) controller.abort();
94
+ else ctx.signal?.addEventListener("abort", onSessionAbort, { once: true });
95
+ let timeoutFired = false;
96
+ const timeout = setTimeout(() => {
97
+ timeoutFired = true;
98
+ controller.abort();
99
+ }, Math.max(0, deadlineAt - Date.now()));
100
+ const attempts: ReviewAttemptObservation[] = [];
101
+ const errorCounts: ReviewExecutionSummary["errorCounts"] = {};
102
+
103
+ const summary = (): ReviewExecutionSummary => ({
104
+ attempts,
105
+ errorCounts,
106
+ durationMs: Date.now() - started,
107
+ transcript,
108
+ preflight,
109
+ });
110
+
111
+ try {
112
+ // A budget preflight failure is a sizing estimate, not a safety verdict.
113
+ // When a human explicitly authorized this exact retry, their decision
114
+ // must not be vetoed by an estimator: proceed with the truncated
115
+ // evidence and let the reviewer see the override. The failureCode stays
116
+ // on the transcript for observability.
117
+ if (transcript.failureCode && !reviewerContext?.userOverride) {
118
+ incrementError(errorCounts, transcript.failureCode);
119
+ throw new ReviewExecutionError(
120
+ transcript.failureCode,
121
+ summary(),
122
+ );
123
+ }
124
+ if (controller.signal.aborted) {
125
+ incrementError(errorCounts, "abort");
126
+ throw new ReviewExecutionError("abort", summary());
127
+ }
128
+ let meta: ReviewerMeta;
129
+ try {
130
+ meta = await abortableOperation(resolve(ctx, config), controller.signal);
131
+ } catch {
132
+ const errorClass = controller.signal.aborted
133
+ ? timeoutFired ? "timeout" : "abort"
134
+ : "model_resolution";
135
+ incrementError(errorCounts, errorClass);
136
+ throw new ReviewExecutionError(errorClass, summary());
137
+ }
138
+ let lastError: unknown;
139
+ let lastErrorClass: ReviewErrorClass = "unknown";
140
+ const retryErrors: ReviewErrorClass[] = [];
141
+ const maxAttempts = Math.min(config.retries + 1, 2);
142
+ const formatRetryFitsBudget =
143
+ preflight.total.estimatedTokens +
144
+ preflightPart(`\n\n${FORMAT_RETRY_INSTRUCTION}`).estimatedTokens <=
145
+ config.maxReviewerInputTokens;
146
+ let formatRetry = false;
147
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
148
+ let runtime: ReviewerRuntime;
149
+ try {
150
+ const auth = await abortableOperation(
151
+ resolveApiKeyAndHeaders(ctx, meta.model),
152
+ controller.signal,
153
+ );
154
+ runtime = {
155
+ ...meta,
156
+ auth,
157
+ sessionId: reviewerSessionId(ctx, config, meta.model, auth),
158
+ };
159
+ } catch (error) {
160
+ lastError = error;
161
+ lastErrorClass = controller.signal.aborted
162
+ ? timeoutFired ? "timeout" : "abort"
163
+ : "authentication";
164
+ retryErrors.push(lastErrorClass);
165
+ incrementError(errorCounts, lastErrorClass);
166
+ break;
167
+ }
168
+ if (controller.signal.aborted) {
169
+ lastErrorClass = timeoutFired ? "timeout" : "abort";
170
+ retryErrors.push(lastErrorClass);
171
+ incrementError(errorCounts, lastErrorClass);
172
+ break;
173
+ }
174
+
175
+ const attemptStarted = Date.now();
176
+ let message: CompletionMessage | undefined;
177
+ let status: ReviewAttemptStatus = "transport_failure";
178
+ let errorClass: ReviewErrorClass = "unknown";
179
+ let decision: ModelDecision | undefined;
180
+ const providerMetadata: ProviderAttemptMetadata = {};
181
+ try {
182
+ const remainingMs = deadlineAt - Date.now();
183
+ if (remainingMs <= 0) {
184
+ timeoutFired = true;
185
+ controller.abort();
186
+ throw new Error("review deadline exhausted");
187
+ }
188
+ message = await modelCall(
189
+ runtime,
190
+ config,
191
+ controller,
192
+ sharedContext,
193
+ config.maxTokens,
194
+ remainingMs,
195
+ formatRetry,
196
+ providerMetadata,
197
+ );
198
+ if (message.stopReason !== "stop") {
199
+ if (message.stopReason === "aborted") {
200
+ status = timeoutFired ? "timeout" : "abort";
201
+ errorClass = timeoutFired ? "timeout" : "abort";
202
+ } else {
203
+ status = message.stopReason === "error"
204
+ ? "transport_failure"
205
+ : "non_stop";
206
+ errorClass = message.stopReason === "length"
207
+ ? "output_limit"
208
+ : message.stopReason === "error"
209
+ ? classifyProviderFailure(message, undefined, providerMetadata)
210
+ : "provider_stop";
211
+ }
212
+ throw new Error("reviewer returned a non-stop response");
213
+ }
214
+ const text = textFromAssistant(message);
215
+ if (!text) {
216
+ status = "format_error";
217
+ errorClass = "empty_output";
218
+ throw new Error("reviewer returned empty output");
219
+ }
220
+ try {
221
+ decision = parseDecision(text);
222
+ } catch (error) {
223
+ status = "format_error";
224
+ errorClass = parseErrorClass(error);
225
+ throw error;
226
+ }
227
+ status = "success";
228
+ errorClass = "none";
229
+ } catch (error) {
230
+ lastError = error;
231
+ if (controller.signal.aborted) {
232
+ status = timeoutFired ? "timeout" : "abort";
233
+ errorClass = timeoutFired ? "timeout" : "abort";
234
+ } else if (errorClass === "unknown") {
235
+ errorClass = classifyProviderFailure(
236
+ message,
237
+ error,
238
+ providerMetadata,
239
+ );
240
+ }
241
+ }
242
+ const usage = observedUsage(message);
243
+ const delayMs = retryDelayMs(errorClass, providerMetadata);
244
+ const willRetry =
245
+ !decision &&
246
+ isRetryableError(errorClass) &&
247
+ (!isFormatError(errorClass) || formatRetryFitsBudget) &&
248
+ attempt < maxAttempts &&
249
+ !controller.signal.aborted &&
250
+ deadlineAt - Date.now() > delayMs;
251
+ const observation: ReviewAttemptObservation = {
252
+ attempt: attempts.length + 1,
253
+ model: message?.responseModel || `${meta.model.provider}/${meta.model.id}`,
254
+ status,
255
+ errorClass,
256
+ stopReason: normalizedStopReason(message?.stopReason),
257
+ durationMs: Date.now() - attemptStarted,
258
+ willRetry,
259
+ usageAvailability: usage.availability,
260
+ usage: usage.usage,
261
+ };
262
+ attempts.push(observation);
263
+ observe?.({
264
+ type: "review_attempt",
265
+ requestId: request.id,
266
+ surface: request.surface,
267
+ ...observation,
268
+ });
269
+ if (decision) {
270
+ return {
271
+ decision,
272
+ attempts: attempts.length,
273
+ retryErrors,
274
+ durationMs: Date.now() - started,
275
+ transcript,
276
+ summary: summary(),
277
+ };
278
+ }
279
+ lastErrorClass = errorClass;
280
+ retryErrors.push(errorClass);
281
+ incrementError(errorCounts, errorClass);
282
+ if (controller.signal.aborted) break;
283
+ if (!willRetry) break;
284
+ formatRetry = isFormatError(errorClass);
285
+ try {
286
+ await abortableDelay(delayMs, controller.signal);
287
+ } catch {
288
+ lastErrorClass = timeoutFired ? "timeout" : "abort";
289
+ incrementError(errorCounts, lastErrorClass);
290
+ break;
291
+ }
292
+ }
293
+ void lastError;
294
+ throw new ReviewExecutionError(lastErrorClass, summary());
295
+ } finally {
296
+ clearTimeout(timeout);
297
+ ctx.signal?.removeEventListener("abort", onSessionAbort);
298
+ }
299
+ }
300
+
301
+ export function modelDecisionToBoundaryReview(
302
+ decision: ModelDecision,
303
+ ): BoundaryReview {
304
+ return {
305
+ outcome: decision.outcome,
306
+ riskLevel: decision.risk_level,
307
+ userAuthorization: decision.user_authorization,
308
+ rationale: decision.rationale,
309
+ };
310
+ }
311
+
312
+ export function currentTurnScope(ctx: ExtensionContext): string {
313
+ const userMessages = ctx.sessionManager
314
+ .buildContextEntries()
315
+ .filter((entry) => {
316
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
317
+ return false;
318
+ }
319
+ const message = (entry as unknown as Record<string, unknown>).message;
320
+ return (
321
+ Boolean(message) &&
322
+ typeof message === "object" &&
323
+ !Array.isArray(message) &&
324
+ (message as Record<string, unknown>).role === "user"
325
+ );
326
+ }).length;
327
+ return `${ctx.sessionManager.getSessionId()}:${userMessages}`;
328
+ }
329
+
330
+ export function denialLabel(
331
+ denial: {
332
+ request: BoundaryRequest;
333
+ review: BoundaryReview;
334
+ },
335
+ index: number,
336
+ ): string {
337
+ const target =
338
+ denial.request.resolvedPath ??
339
+ denial.request.path ??
340
+ denial.request.destination ??
341
+ denial.request.command ??
342
+ denial.request.toolName ??
343
+ denial.request.operation;
344
+ const compact = String(target).replace(/\s+/g, " ").slice(0, 90);
345
+ return `${index + 1}. ${denial.request.surface}: ${compact} — ${denial.review.rationale.slice(0, 70)}`;
346
+ }
347
+