pi-pr-review 1.15.2 → 1.15.3
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/CHANGELOG.md +7 -0
- package/extensions/pr-review-subagent.ts +23 -20
- package/lib/pr-review-artifacts.ts +315 -17
- package/lib/pr-review-publish.ts +36 -5
- package/package.json +1 -1
- package/prompts/pr-review.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.15.3](https://github.com/10ego/pi-pr-review/compare/v1.15.2...v1.15.3) (2026-08-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **review:** harden the nonempty refusal check and document the contract ([#92](https://github.com/10ego/pi-pr-review/issues/92)) ([e8339a0](https://github.com/10ego/pi-pr-review/commit/e8339a029b1d12722f941339d1e8164e6d1ef09d))
|
|
9
|
+
|
|
3
10
|
## [1.15.2](https://github.com/10ego/pi-pr-review/compare/v1.15.1...v1.15.2) (2026-08-24)
|
|
4
11
|
|
|
5
12
|
|
|
@@ -429,6 +429,8 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
|
429
429
|
return { command: "pi", args };
|
|
430
430
|
}
|
|
431
431
|
|
|
432
|
+
const NONEMPTY_CONTRACT_DESCRIPTION = "After a successful terminal stop, nonempty output is exact Markdown with no wrapper: the first nonblank line must be exactly `Review status: COMPLETE` with no leading or trailing whitespace. If you could not inspect required evidence for any reason, return the top-level exact line `Review status: INCOMPLETE` followed by a concise reason; INCOMPLETE and every other status are partial. COMPLETE must be followed in exact order by meaningful one-line `Overview: ...`, `Strengths: ...`, and `Risk areas: ...` sections, then either the exact standalone `NO FINDINGS.` line or one or more complete candidate blocks. Nonblank contract lines have no trailing spaces or tabs. Framing may use plain labels, bold labels, or an ATX label followed immediately by exactly one top-level value line; do not use list-indented or quoted contract lines. Candidate fields are in exact order and each appears once: title prefixed [P0], [P1], [P2], [P3], or [nit] (the tag must match severity); severity P0/P1/P2/P3/nit; meaningful why; location as repo-relative/path:positive-line or repo-relative/path:positive-start-positive-end (ordered), or repo-wide; side RIGHT/LEFT; in_diff yes/no; pr_related yes/no; confidence 0.0-1.0. Candidate labels may be plain/bold or use exactly one top-level `- ` list marker; no other indentation or list marker is accepted. A why value may continue only on lines beginning with exactly two spaces plus a non-list character until the next reserved field. Scalar and continuation values must not contain status/field/severity/sentinel productions, code fences, HTML/container wrappers, blockquotes, lists, or code indentation. Do not emit code fences, blockquotes, JSON, HTML/container wrappers, duplicate/out-of-order fields, arbitrary prose, or placeholder values (none, n/a, unavailable, unknown, skipped, error, review complete). Status/refusal/access/tool/model/error reasons belong in an INCOMPLETE status; inability discussed inside a valid candidate why is allowed. COMPLETE is the primary completion attestation; only obvious exact failure placeholders are rejected as defense-in-depth."
|
|
433
|
+
|
|
432
434
|
const TIER_GUIDANCE: Record<Tier, string> = {
|
|
433
435
|
light:
|
|
434
436
|
"You are a fast overview reviewer. Produce a concise overview of what the change does and how, list genuine strengths, and note high-level risk areas worth closer specialist review. Do not deep-dive into defects.",
|
|
@@ -467,26 +469,27 @@ function buildSubagentSystemPrompt(tier: Tier, majorOnly = false, minorHygiene =
|
|
|
467
469
|
lines.push(
|
|
468
470
|
"Start from the supplied complete diff. Use repository tools only to substantiate a concrete candidate caused by that diff; never browse for unrelated issues or run broad repository audits/tests.",
|
|
469
471
|
"Before your first tool call, identify the evidence needed for all current candidates. Issue independent reads/searches/checks together when the interface supports concurrent calls, grouped to avoid rereading the same file. Use at most one follow-up tool turn, only for a dependency revealed by the first evidence wave. This schedules validation efficiently but never permits skipping evidence needed to substantiate a finding.",
|
|
470
|
-
"Return your findings as a concise Markdown list. For each finding include, on its own lines:",
|
|
471
|
-
majorOnly
|
|
472
|
-
? "- title: an imperative summary prefixed with [P0], [P1], or [P2]"
|
|
473
|
-
: "- title: an imperative summary prefixed with a severity tag [P0]|[P1]|[P2]|[P3]|[nit]",
|
|
474
|
-
majorOnly
|
|
475
|
-
? "- severity: one of P0, P1, P2 (P0/P1 are blocking)"
|
|
476
|
-
: "- severity: one of P0, P1, P2, P3, nit (P0/P1 are blocking)",
|
|
477
|
-
"- why: the impact and the exact input/environment needed for it to bite",
|
|
478
|
-
"- location: <repo-relative file path>:<start-end lines exactly as they appear in the diff> (or 'repo-wide')",
|
|
479
|
-
"- side: RIGHT for added/context lines, LEFT for removed lines",
|
|
480
|
-
"- in_diff: yes if those lines are inside the PR diff (so an inline comment can be posted), otherwise no",
|
|
481
|
-
"- pr_related: yes only if this PR introduces or provably affects the issue (drop pre-existing/unrelated issues)",
|
|
482
|
-
"- confidence: a float 0.0-1.0",
|
|
483
|
-
// A nonempty completion contract lets the pass carry framing prose
|
|
484
|
-
// (overview, strengths, risk) around its findings; the exact-match
|
|
485
|
-
// sentinel would forbid that and strand the lane as partial.
|
|
486
472
|
expectedOutput === "nonempty"
|
|
487
|
-
?
|
|
488
|
-
|
|
489
|
-
|
|
473
|
+
? NONEMPTY_CONTRACT_DESCRIPTION
|
|
474
|
+
: "Return your findings as a concise Markdown list. For each finding include, on its own lines:",
|
|
475
|
+
expectedOutput === "nonempty"
|
|
476
|
+
? "For nonempty output, begin with the exact first nonblank top-level line `Review status: COMPLETE` with no trailing whitespace; use the exact top-level `Review status: INCOMPLETE` plus a concise reason whenever required evidence cannot be inspected. COMPLETE then requires Overview, Strengths, and Risk areas in order, followed immediately by NO FINDINGS. or complete candidate blocks; do not add other prose."
|
|
477
|
+
: majorOnly
|
|
478
|
+
? "- title: an imperative summary prefixed with [P0], [P1], or [P2]"
|
|
479
|
+
: "- title: an imperative summary prefixed with a severity tag [P0]|[P1]|[P2]|[P3]|[nit]",
|
|
480
|
+
...(expectedOutput === "nonempty" ? [] : [
|
|
481
|
+
majorOnly
|
|
482
|
+
? "- severity: one of P0, P1, P2 (P0/P1 are blocking)"
|
|
483
|
+
: "- severity: one of P0, P1, P2, P3, nit (P0/P1 are blocking)",
|
|
484
|
+
"- why: the impact and the exact input/environment needed for it to bite",
|
|
485
|
+
"- location: <repo-relative file path>:<start-end lines exactly as they appear in the diff> (or 'repo-wide')",
|
|
486
|
+
"- side: RIGHT for added/context lines, LEFT for removed lines",
|
|
487
|
+
"- in_diff: yes if those lines are inside the PR diff (so an inline comment can be posted), otherwise no",
|
|
488
|
+
"- pr_related: yes only if this PR introduces or provably affects the issue (drop pre-existing/unrelated issues)",
|
|
489
|
+
"- confidence: a float 0.0-1.0",
|
|
490
|
+
]),
|
|
491
|
+
expectedOutput === "nonempty"
|
|
492
|
+
? "After the exact COMPLETE status and three framing sections, the clean sentinel is one top-level standalone uppercase line `NO FINDINGS.`; never use `No findings.` or append a sentence. Candidate fields are top-level or use exactly one `- ` marker, with no arbitrary indentation; candidate `why` may use exactly-two-space non-list continuation lines, but never absorb a reserved field, status, sentinel, or wrapper."
|
|
490
493
|
: majorOnly
|
|
491
494
|
? "If there are no substantiated P0-P2 findings, reply exactly with: NO FINDINGS."
|
|
492
495
|
: "If there are genuinely no findings at any severity, reply exactly with: NO FINDINGS.",
|
|
@@ -1424,7 +1427,7 @@ const ReviewSubagentsParams = Type.Object({
|
|
|
1424
1427
|
Type.Object({
|
|
1425
1428
|
expected_output: Type.Optional(
|
|
1426
1429
|
StringEnum(["review_lane", "nonempty"] as const, {
|
|
1427
|
-
description:
|
|
1430
|
+
description: `Completion contract for this pass. review_lane (default): candidate-evidence fields or exactly NO FINDINGS. nonempty: ${NONEMPTY_CONTRACT_DESCRIPTION}`,
|
|
1428
1431
|
}),
|
|
1429
1432
|
),
|
|
1430
1433
|
id: Type.Optional(
|
|
@@ -114,34 +114,327 @@ function hasMeaningfulLightSection(text: string, field: string, fields: readonly
|
|
|
114
114
|
.some((line) => line.replace(/^[ \\t]*(?:[-*>][ \\t]*)+/, "").trim().length > 0);
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* The `nonempty` lane is a small Markdown grammar, not a prose detector. Its
|
|
119
|
+
* first nonblank line is the exact `Review status: COMPLETE` attestation;
|
|
120
|
+
* `INCOMPLETE` and every other status are partial. The remaining productions
|
|
121
|
+
* are documented in the public tool schema and both reviewer prompts: framing
|
|
122
|
+
* labels may be plain (`Overview:`), bold (`**Overview:**`), or ATX headings
|
|
123
|
+
* followed by one top-level value line; candidate fields may be plain/bold or
|
|
124
|
+
* use the one exact top-level `- ` list marker. Blockquotes, code fences, JSON,
|
|
125
|
+
* and other wrappers are not part of the contract. Nonblank contract lines do
|
|
126
|
+
* not carry trailing horizontal whitespace, and a `why` continuation is exactly
|
|
127
|
+
* two spaces plus a non-list line.
|
|
128
|
+
*/
|
|
129
|
+
const CANDIDATE_FIELDS = ["title", "severity", "why", "location", "side", "in_diff", "pr_related", "confidence"] as const;
|
|
130
|
+
const CANDIDATE_SEVERITIES = new Set(["P0", "P1", "P2", "P3", "nit"]);
|
|
131
|
+
const FRAMING_LABELS = ["Overview", "Strengths", "Risk areas"] as const;
|
|
132
|
+
const PLACEHOLDER_ONLY = /^(?:none|n\/?a|na|unavailable|unknown|skipped|error|review complete|no findings|nothing to review)(?:\s+(?:identified|found|available|present))?[.!]?$/i;
|
|
133
|
+
const NO_FINDINGS_SENTINEL = "NO FINDINGS.";
|
|
134
|
+
const CODE_FENCE = /^ {0,3}(?:`{3,}|~{3,})/m;
|
|
135
|
+
const COMMONMARK_HTML_BLOCK_TAGS = [
|
|
136
|
+
"address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center", "col",
|
|
137
|
+
"colgroup", "dd", "details", "dialog", "dir", "div", "dl", "dt", "fieldset", "figcaption", "figure",
|
|
138
|
+
"footer", "form", "frame", "frameset", "h1", "h2", "h3", "h4", "h5", "h6", "head", "header", "hr",
|
|
139
|
+
"html", "iframe", "legend", "li", "link", "main", "menu", "menuitem", "nav", "noframes", "ol",
|
|
140
|
+
"optgroup", "option", "p", "param", "search", "section", "summary", "table", "tbody", "td", "tfoot",
|
|
141
|
+
"th", "thead", "title", "tr", "track", "ul",
|
|
142
|
+
].join("|");
|
|
143
|
+
const COMMONMARK_BLANK_TERMINATED_HTML = new RegExp(
|
|
144
|
+
`^ {0,3}</?(?:${COMMONMARK_HTML_BLOCK_TAGS})(?:[ \\t]+|/?>|$)`,
|
|
145
|
+
"i",
|
|
146
|
+
);
|
|
147
|
+
// CommonMark type 7 accepts a complete open or closing tag for any tag name,
|
|
148
|
+
// including custom elements. Unlike types 1–6, it cannot interrupt a paragraph.
|
|
149
|
+
const COMMONMARK_COMPLETE_HTML_TAG = /^ {0,3}(?:<[A-Za-z][A-Za-z0-9-]*(?:[ \t]+[A-Za-z_:][A-Za-z0-9_.:-]*(?:[ \t]*=[ \t]*(?:[^ "'=<>`\u0000-\u0020]+|'[^']*'|"[^"]*"))?)*[ \t]*\/?>|<\/[A-Za-z][A-Za-z0-9-]*[ \t]*>)[ \t]*$/;
|
|
150
|
+
// These are CommonMark HTML block openers. The opener itself is sufficient:
|
|
151
|
+
// an assistant must not be able to hide later contract lines behind an
|
|
152
|
+
// unterminated comment, declaration, raw block tag, or other container.
|
|
153
|
+
function htmlBlockOpener(line: string): boolean {
|
|
154
|
+
return /^ {0,3}<(?:script|pre|style|textarea)(?:[ \t]+|>|$)/i.test(line) ||
|
|
155
|
+
/^ {0,3}<!--/.test(line) ||
|
|
156
|
+
/^ {0,3}<\?/.test(line) ||
|
|
157
|
+
/^ {0,3}<![A-Z]/i.test(line) ||
|
|
158
|
+
/^ {0,3}<!\[CDATA\[/i.test(line) ||
|
|
159
|
+
COMMONMARK_BLANK_TERMINATED_HTML.test(line) ||
|
|
160
|
+
COMMONMARK_COMPLETE_HTML_TAG.test(line);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function hasHtmlContainer(text: string): boolean {
|
|
164
|
+
return text.split("\n").some((line) => htmlBlockOpener(line));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const CONTAINER_PREFIX = /^(?:[-*+>]|#{1,6})[ \t]+/;
|
|
168
|
+
const RESERVED_LABEL_PRODUCTION = /(?:^|[ \t])(?:\*\*|__)?(?:Overview|Strengths|Risk areas|title|severity|why|location|side|in_diff|pr_related|confidence)(?:\*\*|__)?[ \t]*:/i;
|
|
169
|
+
const SEVERITY_TAG_PRODUCTION = /\[P[0-3]\]/gi;
|
|
170
|
+
|
|
171
|
+
interface MarkdownLabel {
|
|
172
|
+
readonly field: string;
|
|
173
|
+
readonly value: string;
|
|
174
|
+
readonly kind: "inline" | "heading";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface CandidateBlock {
|
|
178
|
+
readonly start: number;
|
|
179
|
+
readonly end: number;
|
|
180
|
+
readonly fields: ReadonlyMap<string, string>;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function normalizeReviewText(text: string): string {
|
|
184
|
+
// Only CRLF is transport normalization. In particular, do not trim or
|
|
185
|
+
// rewrite line-leading whitespace before applying the grammar.
|
|
186
|
+
return text.replace(/\r\n/g, "\n");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function hasTrailingHorizontalWhitespace(text: string): boolean {
|
|
190
|
+
return text.split("\n").some((line) => /[ \t]+$/.test(line));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function escapePattern(value: string): string {
|
|
194
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Parse the exact framing forms; headings are accepted only for framing. */
|
|
198
|
+
function framingLabel(line: string): MarkdownLabel | undefined {
|
|
199
|
+
if (/^[ \t]/.test(line)) return undefined;
|
|
200
|
+
let match = /^\*\*(Overview|Strengths|Risk areas):\*\*[ \t]*(.*)$/.exec(line);
|
|
201
|
+
if (match) return { field: match[1]!, value: match[2] ?? "", kind: "inline" };
|
|
202
|
+
match = /^__(Overview|Strengths|Risk areas):__[ \t]*(.*)$/.exec(line);
|
|
203
|
+
if (match) return { field: match[1]!, value: match[2] ?? "", kind: "inline" };
|
|
204
|
+
match = /^(Overview|Strengths|Risk areas):[ \t]*(.*)$/.exec(line);
|
|
205
|
+
if (match) return { field: match[1]!, value: match[2] ?? "", kind: "inline" };
|
|
206
|
+
match = /^#{1,6}[ \t]+(?:(?:\*\*)(Overview|Strengths|Risk areas):(?:\*\*)|(?:__)(Overview|Strengths|Risk areas):(?:__)|(Overview|Strengths|Risk areas):?)[ \t]*$/.exec(line);
|
|
207
|
+
if (match) return { field: (match[1] ?? match[2] ?? match[3])!, value: "", kind: "heading" };
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Candidate fields are top-level one-line values with optional bold syntax or one exact `- ` marker. */
|
|
212
|
+
function candidateLabel(line: string): MarkdownLabel | undefined {
|
|
213
|
+
const names = CANDIDATE_FIELDS.map(escapePattern).join("|");
|
|
214
|
+
const match = new RegExp(`^(?:- )?(?:(?:\\*\\*)(${names}):(?:\\*\\*)|(?:__)(${names}):(?:__)|(${names}):)[ \t]*(.*)$`).exec(line);
|
|
215
|
+
if (!match) return undefined;
|
|
216
|
+
return { field: (match[1] ?? match[2] ?? match[3])!, value: match[4] ?? "", kind: "inline" };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function canonicalField(field: string): string {
|
|
220
|
+
return field.toLowerCase();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function meaningfulValue(value: string): boolean {
|
|
224
|
+
const normalized = value.trim();
|
|
225
|
+
const words = normalized.match(/[\p{L}\p{N}]+/gu) ?? [];
|
|
226
|
+
return words.length >= 2 && !PLACEHOLDER_ONLY.test(normalized) &&
|
|
227
|
+
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(normalized);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function confidenceValue(value: string): boolean {
|
|
231
|
+
const normalized = value.trim();
|
|
232
|
+
if (!/^(?:0(?:\.\d+)?|1(?:\.0+)?)$/.test(normalized)) return false;
|
|
233
|
+
const number = Number(normalized);
|
|
234
|
+
return Number.isFinite(number) && number >= 0 && number <= 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Validate the prompt's repo-relative/path:positive-line-range location syntax. */
|
|
238
|
+
function safeLocation(value: string): boolean {
|
|
239
|
+
const normalized = value.trim();
|
|
240
|
+
if (normalized === "repo-wide") return true;
|
|
241
|
+
const match = /^(.+):(\d+)(?:-(\d+))?$/.exec(normalized);
|
|
242
|
+
if (!match) return false;
|
|
243
|
+
const locationPath = match[1]!;
|
|
244
|
+
const start = Number(match[2]);
|
|
245
|
+
const end = match[3] === undefined ? start : Number(match[3]);
|
|
246
|
+
if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(end) || end < start) return false;
|
|
247
|
+
if (!locationPath || locationPath.startsWith("/") || locationPath.startsWith("~") || /^[A-Za-z]:/.test(locationPath)) return false;
|
|
248
|
+
if (/[\\\u0000-\u001f\u007f]/.test(locationPath)) return false;
|
|
249
|
+
const segments = locationPath.split("/");
|
|
250
|
+
return segments.length > 0 && segments.every((segment) => segment.length > 0 && segment === segment.trim() && segment !== "." && segment !== ".." && !segment.includes(":"));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function hasReservedStatusProduction(line: string): boolean {
|
|
254
|
+
return /^(?:[-*+>][ \t]+)?Review status\s*:/i.test(line.trim());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Values are prose, but they may not contain a line-shaped contract
|
|
259
|
+
* production. This is deliberately limited to the documented grammar (and
|
|
260
|
+
* containers), rather than an English keyword denylist. Every line is
|
|
261
|
+
* checked independently so a multiline `why` cannot hide a fence or an
|
|
262
|
+
* unterminated HTML block opener in a continuation.
|
|
263
|
+
*/
|
|
264
|
+
function hasReservedContractProduction(value: string): boolean {
|
|
265
|
+
if (!value) return false;
|
|
266
|
+
if (value.includes(NO_FINDINGS_SENTINEL) || /Review status\s*:/i.test(value) || RESERVED_LABEL_PRODUCTION.test(value)) return true;
|
|
267
|
+
if (hasHtmlContainer(value)) return true;
|
|
268
|
+
return value.split("\n").some((line) => {
|
|
269
|
+
const structural = line.trim();
|
|
270
|
+
return CODE_FENCE.test(line) || htmlBlockOpener(line) ||
|
|
271
|
+
structural === NO_FINDINGS_SENTINEL || hasReservedStatusProduction(structural) ||
|
|
272
|
+
!!framingLabel(structural) || !!candidateLabel(structural) || CONTAINER_PREFIX.test(structural);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function hasReservedCandidateProduction(field: string, value: string): boolean {
|
|
277
|
+
if (hasReservedContractProduction(value)) return true;
|
|
278
|
+
let allowedLeadingTitleTag = field === "title";
|
|
279
|
+
for (const [lineIndex, line] of value.split("\n").entries()) {
|
|
280
|
+
for (const match of line.matchAll(SEVERITY_TAG_PRODUCTION)) {
|
|
281
|
+
if (allowedLeadingTitleTag && lineIndex === 0 && match.index === 0) {
|
|
282
|
+
allowedLeadingTitleTag = false;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function validCandidateFields(fields: ReadonlyMap<string, string>): boolean {
|
|
292
|
+
if (CANDIDATE_FIELDS.some((field) => !fields.has(field))) return false;
|
|
293
|
+
if ([...fields.entries()].some(([field, value]) => hasReservedCandidateProduction(field, value))) return false;
|
|
294
|
+
const title = fields.get("title")!.trim();
|
|
295
|
+
const titleMatch = /^\[(P0|P1|P2|P3|nit)\][ \t]+(.+)$/.exec(title);
|
|
296
|
+
const severity = fields.get("severity")!.trim();
|
|
297
|
+
const why = fields.get("why")!;
|
|
298
|
+
const location = fields.get("location")!;
|
|
299
|
+
const side = fields.get("side")!.trim();
|
|
300
|
+
const inDiff = fields.get("in_diff")!.trim();
|
|
301
|
+
const prRelated = fields.get("pr_related")!.trim();
|
|
302
|
+
return !!titleMatch && meaningfulValue(titleMatch[2]!) &&
|
|
303
|
+
CANDIDATE_SEVERITIES.has(severity) && titleMatch[1] === severity &&
|
|
304
|
+
meaningfulValue(why) && safeLocation(location) &&
|
|
305
|
+
/^(?:RIGHT|LEFT)$/.test(side) &&
|
|
306
|
+
/^(?:yes|no)$/.test(inDiff) &&
|
|
307
|
+
/^(?:yes|no)$/.test(prRelated) &&
|
|
308
|
+
confidenceValue(fields.get("confidence")!);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function framingValueHasReservedProduction(value: string): boolean {
|
|
312
|
+
return hasReservedContractProduction(value);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function isReservedContractLine(line: string): boolean {
|
|
316
|
+
return hasReservedContractProduction(line);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Only obvious, deterministic placeholders are defense-in-depth; COMPLETE is the primary contract. */
|
|
320
|
+
function topLevelFailure(text: string): boolean {
|
|
321
|
+
return text.split("\n").some((line) => /^(?:internal server error|fatal error|access denied|review (?:failed|unavailable|skipped)|no (?:diff|patch|source context|review context)(?: was)? (?:provided|available|accessible))[.!]?$/i.test(line.trim()));
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Parse and validate the integrated deep-lane grammar statefully. COMPLETE is
|
|
326
|
+
* an explicit reviewer attestation: every other status, a missing/later/
|
|
327
|
+
* wrapped/duplicate status, or a malformed production is partial. Framing has
|
|
328
|
+
* exactly one nonempty value line per field. Candidate `why` may have
|
|
329
|
+
* indented continuation lines; reserved productions cannot be continuations.
|
|
330
|
+
*/
|
|
331
|
+
function parseIntegratedCompletion(text: string): boolean {
|
|
332
|
+
if (hasTrailingHorizontalWhitespace(text) || CODE_FENCE.test(text) || hasHtmlContainer(text)) return false;
|
|
333
|
+
const lines = text.split("\n");
|
|
334
|
+
let cursor = 0;
|
|
335
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
336
|
+
if (cursor >= lines.length || lines[cursor] !== "Review status: COMPLETE") return false;
|
|
337
|
+
const statusIndex = cursor;
|
|
338
|
+
if (lines.some((line, index) => index !== statusIndex && hasReservedStatusProduction(line))) return false;
|
|
339
|
+
cursor++;
|
|
340
|
+
const framingValues: string[] = [];
|
|
341
|
+
for (const expected of FRAMING_LABELS) {
|
|
342
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
343
|
+
const label = framingLabel(lines[cursor] ?? "");
|
|
344
|
+
if (!label || canonicalField(label.field) !== expected.toLowerCase()) return false;
|
|
345
|
+
cursor++;
|
|
346
|
+
let value = label.value;
|
|
347
|
+
if (label.kind === "heading") {
|
|
348
|
+
const valueLine = lines[cursor] ?? "";
|
|
349
|
+
if (!valueLine || !valueLine.trim() || /^[ \t]/.test(valueLine) || CONTAINER_PREFIX.test(valueLine) || isReservedContractLine(valueLine)) return false;
|
|
350
|
+
value = valueLine;
|
|
351
|
+
cursor++;
|
|
352
|
+
}
|
|
353
|
+
if (!meaningfulValue(value) || framingValueHasReservedProduction(value)) return false;
|
|
354
|
+
framingValues.push(value);
|
|
355
|
+
}
|
|
356
|
+
if (topLevelFailure(framingValues.join("\n"))) return false;
|
|
357
|
+
|
|
358
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
359
|
+
if (cursor < lines.length && lines[cursor] === NO_FINDINGS_SENTINEL) {
|
|
360
|
+
cursor++;
|
|
361
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
362
|
+
return cursor === lines.length;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const candidates: CandidateBlock[] = [];
|
|
366
|
+
while (cursor < lines.length) {
|
|
367
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
368
|
+
if (cursor >= lines.length) break;
|
|
369
|
+
const start = cursor;
|
|
370
|
+
const first = candidateLabel(lines[cursor]!);
|
|
371
|
+
if (!first || canonicalField(first.field) !== "title") return false;
|
|
372
|
+
const fields = new Map<string, string>();
|
|
373
|
+
for (const expected of CANDIDATE_FIELDS) {
|
|
374
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
375
|
+
const field = candidateLabel(lines[cursor] ?? "");
|
|
376
|
+
if (!field || canonicalField(field.field) !== expected || fields.has(expected)) return false;
|
|
377
|
+
if (hasReservedCandidateProduction(expected, field.value)) return false;
|
|
378
|
+
fields.set(expected, field.value);
|
|
379
|
+
cursor++;
|
|
380
|
+
if (expected !== "why") continue;
|
|
381
|
+
const whyLines = [field.value];
|
|
382
|
+
while (cursor < lines.length) {
|
|
383
|
+
const continuation = lines[cursor]!;
|
|
384
|
+
if (!continuation.trim()) break;
|
|
385
|
+
if (isReservedContractLine(continuation)) break;
|
|
386
|
+
if (!/^ {2}\S/.test(continuation) || /^ {2}(?:[-*+>]|#{1,6})[ \t]+/.test(continuation)) return false;
|
|
387
|
+
if (hasReservedCandidateProduction("why", continuation.slice(2))) return false;
|
|
388
|
+
whyLines.push(continuation.slice(2));
|
|
389
|
+
cursor++;
|
|
390
|
+
}
|
|
391
|
+
fields.set("why", whyLines.join("\n"));
|
|
392
|
+
}
|
|
393
|
+
if (!validCandidateFields(fields)) return false;
|
|
394
|
+
candidates.push({ start, end: cursor, fields });
|
|
395
|
+
}
|
|
396
|
+
return candidates.length > 0;
|
|
397
|
+
}
|
|
398
|
+
|
|
117
399
|
function hasMeaningfulField(text: string, field: string): boolean {
|
|
118
|
-
const match =
|
|
400
|
+
const match = fieldPattern(field).exec(text);
|
|
119
401
|
return !!match?.[1]?.trim();
|
|
120
402
|
}
|
|
121
403
|
|
|
404
|
+
function fieldPattern(field: string): RegExp {
|
|
405
|
+
return new RegExp(`^[ \\t]*(?:[-*+][ \\t]*)?(?:#{1,6}[ \\t]*)?(?:\\*\\*|__)?${escapePattern(field)}(?:\\*\\*|__)?[ \\t]*:[ \\t]*(.+?)[ \\t]*\\r?$`, "im");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function hasMeaningfulLightSection(text: string, field: string, fields: readonly string[]): boolean {
|
|
409
|
+
const escapedFields = fields.map((candidate) => escapePattern(candidate)).join("|");
|
|
410
|
+
const heading = new RegExp(`^[ \\t]*(?:#{1,6}[ \\t]*)?${escapePattern(field)}[ \\t]*:?[ \\t]*(.*)\\r?$`, "im").exec(text);
|
|
411
|
+
if (!heading || heading.index === undefined) return false;
|
|
412
|
+
const inlineValue = heading[1] ?? "";
|
|
413
|
+
const bodyStart = heading.index + heading[0].length;
|
|
414
|
+
const nextHeading = new RegExp(`^[ \\t]*(?:#{1,6}[ \\t]*)?(?:${escapedFields})[ \\t]*:?[ \\t]*`, "im").exec(text.slice(bodyStart));
|
|
415
|
+
const body = nextHeading ? text.slice(bodyStart, bodyStart + nextHeading.index) : text.slice(bodyStart);
|
|
416
|
+
return `${inlineValue}\n${body}`
|
|
417
|
+
.split("\n")
|
|
418
|
+
.some((line) => line.replace(/^[ \\t]*(?:[-*>][ \\t]*)+/, "").trim().length > 0);
|
|
419
|
+
}
|
|
420
|
+
|
|
122
421
|
function expectedLaneSections(input: ReviewLaneCompletionInput): boolean {
|
|
123
|
-
const
|
|
124
|
-
if (!
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// substantive statement (overview/no-findings text), not a bare refusal.
|
|
128
|
-
if (input.expectedOutput === "nonempty") {
|
|
129
|
-
if (["title", "severity", "why", "overview"].some((field) => hasMeaningfulField(text, field))) return true;
|
|
130
|
-
return text.length >= 16 && !/^(?:i could not|unable to|cannot|i cannot|error|failed)[^.]*\.*$/i.test(text);
|
|
131
|
-
}
|
|
422
|
+
const normalized = normalizeReviewText(input.rawText);
|
|
423
|
+
if (!normalized.trim()) return false;
|
|
424
|
+
if (input.expectedOutput === "nonempty") return parseIntegratedCompletion(normalized);
|
|
425
|
+
const text = normalized.trim();
|
|
132
426
|
if (input.tier === "light") {
|
|
133
427
|
const fields = input.minorHygiene ? ["overview", "strengths", "minor candidates"] : ["overview", "strengths"];
|
|
134
428
|
return fields.every((field) => hasMeaningfulLightSection(text, field, fields));
|
|
135
429
|
}
|
|
136
|
-
if (text ===
|
|
137
|
-
return
|
|
138
|
-
.every((field) => hasMeaningfulField(text, field));
|
|
430
|
+
if (text === NO_FINDINGS_SENTINEL) return true;
|
|
431
|
+
return CANDIDATE_FIELDS.every((field) => hasMeaningfulField(text, field));
|
|
139
432
|
}
|
|
140
433
|
|
|
141
434
|
/** Process exit is necessary but insufficient: only a terminal stop with valid lane output is complete. */
|
|
142
435
|
export function classifyReviewLane(input: ReviewLaneCompletionInput): ReviewLaneLifecycle {
|
|
143
|
-
const reason = input.stopReason
|
|
144
|
-
const error = input.errorMessage
|
|
436
|
+
const reason = typeof input.stopReason === "string" ? input.stopReason.toLowerCase() : undefined;
|
|
437
|
+
const error = typeof input.errorMessage === "string" ? input.errorMessage.toLowerCase() : undefined;
|
|
145
438
|
if (reason?.includes("timeout") || error?.includes("timed out") || error?.includes("timeout")) return "timed_out";
|
|
146
439
|
const hasText = input.rawText.length > 0;
|
|
147
440
|
if (input.exitCode === 0 && reason === "stop" && expectedLaneSections(input)) return "complete";
|
|
@@ -162,11 +455,16 @@ export class ReviewLaneArtifactRegistry {
|
|
|
162
455
|
expect(generation: number, lanes: readonly ExpectedReviewLane[]): boolean {
|
|
163
456
|
if (
|
|
164
457
|
this.generation !== generation || lanes.length === 0 ||
|
|
165
|
-
lanes.some((lane) => !lane.key || !new Set(["light", "medium", "heavy"]).has(lane.tier)
|
|
458
|
+
lanes.some((lane) => !lane.key || !new Set(["light", "medium", "heavy"]).has(lane.tier) ||
|
|
459
|
+
(lane.expectedOutput !== undefined && !new Set(["review_lane", "nonempty"]).has(lane.expectedOutput)))
|
|
166
460
|
) return false;
|
|
167
461
|
for (const lane of lanes) {
|
|
168
462
|
const existing = this.expectedLanes.get(lane.key);
|
|
169
|
-
if (existing && (
|
|
463
|
+
if (existing && (
|
|
464
|
+
existing.tier !== lane.tier ||
|
|
465
|
+
existing.minorHygiene !== lane.minorHygiene ||
|
|
466
|
+
(existing.expectedOutput ?? "review_lane") !== (lane.expectedOutput ?? "review_lane")
|
|
467
|
+
)) return false;
|
|
170
468
|
this.expectedLanes.set(lane.key, Object.freeze({ ...lane }));
|
|
171
469
|
}
|
|
172
470
|
return true;
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -664,14 +664,44 @@ function parsePersistedInvocation(value: unknown): ReviewInvocation | undefined
|
|
|
664
664
|
function parsePersistedLaneArtifacts(value: unknown): readonly ReviewLaneArtifact[] | undefined {
|
|
665
665
|
if (!Array.isArray(value)) return undefined;
|
|
666
666
|
const lifecycles = new Set(["complete", "partial", "timed_out", "failed"]);
|
|
667
|
+
const tiers = new Set(["light", "medium", "heavy"]);
|
|
668
|
+
const deadlineKinds = new Set(["total", "synthesis"]);
|
|
669
|
+
const deadlineSources = new Set(["default", "user", "project"]);
|
|
670
|
+
const optional = (object: Record<string, unknown>, key: string, predicate: (value: unknown) => boolean): boolean =>
|
|
671
|
+
object[key] === undefined || predicate(object[key]);
|
|
672
|
+
const isFiniteNumber = (item: unknown): boolean => typeof item === "number" && Number.isFinite(item);
|
|
673
|
+
const isString = (item: unknown): boolean => typeof item === "string";
|
|
674
|
+
const validAttempt = (attempt: unknown): attempt is Record<string, unknown> => {
|
|
675
|
+
if (!isObject(attempt)) return false;
|
|
676
|
+
return Number.isInteger(attempt.ordinal) &&
|
|
677
|
+
(attempt.kind === undefined || new Set(["primary", "fallback", "nearest", "default"]).has(String(attempt.kind))) &&
|
|
678
|
+
typeof attempt.rawText === "string" && Number.isInteger(attempt.exitCode) && lifecycles.has(String(attempt.lifecycle)) &&
|
|
679
|
+
(typeof attempt.retryable === "boolean" || attempt.retryable === undefined) &&
|
|
680
|
+
optional(attempt, "requestedModel", isString) && optional(attempt, "observedModel", isString) &&
|
|
681
|
+
optional(attempt, "usedTier", (item) => tiers.has(String(item))) &&
|
|
682
|
+
optional(attempt, "processSignal", isString) && optional(attempt, "stopReason", isString) &&
|
|
683
|
+
optional(attempt, "errorMessage", isString) && optional(attempt, "deadlineExpired", (item) => deadlineKinds.has(String(item))) &&
|
|
684
|
+
optional(attempt, "elapsedMs", isFiniteNumber) && optional(attempt, "firstEventMs", isFiniteNumber) &&
|
|
685
|
+
optional(attempt, "firstAssistantMs", isFiniteNumber) && optional(attempt, "toolElapsedMs", isFiniteNumber) &&
|
|
686
|
+
optional(attempt, "toolCallCount", (item) => Number.isInteger(item)) && optional(attempt, "timedOut", (item) => typeof item === "boolean") &&
|
|
687
|
+
optional(attempt, "terminationGraceMs", isFiniteNumber) && optional(attempt, "forcedTermination", (item) => typeof item === "boolean") &&
|
|
688
|
+
optional(attempt, "deadlineMs", isFiniteNumber) && optional(attempt, "configuredDeadlineMs", isFiniteNumber);
|
|
689
|
+
};
|
|
667
690
|
const lanes = value.filter((lane): lane is Record<string, unknown> => isObject(lane));
|
|
668
691
|
if (lanes.length !== value.length || lanes.some((lane) =>
|
|
669
692
|
!Number.isInteger(lane.generation) || typeof lane.key !== "string" || typeof lane.passId !== "string" ||
|
|
670
|
-
!
|
|
693
|
+
!tiers.has(String(lane.tier)) ||
|
|
671
694
|
(lane.minorHygiene !== undefined && typeof lane.minorHygiene !== "boolean") || typeof lane.rawText !== "string" ||
|
|
672
|
-
!Number.isInteger(lane.exitCode) || !lifecycles.has(String(lane.lifecycle)) ||
|
|
673
|
-
lane.attempts.some((attempt) => !
|
|
674
|
-
|
|
695
|
+
!Number.isInteger(lane.exitCode) || !lifecycles.has(String(lane.lifecycle)) ||
|
|
696
|
+
!Array.isArray(lane.attempts) || lane.attempts.some((attempt) => !validAttempt(attempt)) ||
|
|
697
|
+
!optional(lane, "requestedModel", isString) || !optional(lane, "observedModel", isString) ||
|
|
698
|
+
!optional(lane, "processSignal", isString) || !optional(lane, "stopReason", isString) ||
|
|
699
|
+
!optional(lane, "errorMessage", isString) || !optional(lane, "deadlineExpired", (item) => deadlineKinds.has(String(item))) ||
|
|
700
|
+
!optional(lane, "firstEventMs", isFiniteNumber) || !optional(lane, "firstAssistantMs", isFiniteNumber) ||
|
|
701
|
+
!optional(lane, "startOffsetMs", isFiniteNumber) || !optional(lane, "endOffsetMs", isFiniteNumber) ||
|
|
702
|
+
!optional(lane, "fallbackBudgetRejected", (item) => typeof item === "boolean") ||
|
|
703
|
+
!optional(lane, "deadlineSource", (item) => deadlineSources.has(String(item))) ||
|
|
704
|
+
!optional(lane, "batchDeadlineMs", isFiniteNumber) || !optional(lane, "totalDeadlineMs", isFiniteNumber)
|
|
675
705
|
)) return undefined;
|
|
676
706
|
return lanes as unknown as readonly ReviewLaneArtifact[];
|
|
677
707
|
}
|
|
@@ -800,7 +830,8 @@ export class CompletedReviewCache {
|
|
|
800
830
|
const expectedLaneDescriptors = Array.isArray(value.expectedLaneDescriptors) &&
|
|
801
831
|
value.expectedLaneDescriptors.length <= 200 && value.expectedLaneDescriptors.every((lane) =>
|
|
802
832
|
isObject(lane) && typeof lane.key === "string" && !!lane.key &&
|
|
803
|
-
new Set(["light", "medium", "heavy"]).has(String(lane.tier)) && typeof lane.minorHygiene === "boolean"
|
|
833
|
+
new Set(["light", "medium", "heavy"]).has(String(lane.tier)) && typeof lane.minorHygiene === "boolean" &&
|
|
834
|
+
(lane.expectedOutput === undefined || lane.expectedOutput === "review_lane" || lane.expectedOutput === "nonempty")) &&
|
|
804
835
|
new Set(value.expectedLaneDescriptors.map((lane) => (lane as Record<string, unknown>).key)).size ===
|
|
805
836
|
value.expectedLaneDescriptors.length
|
|
806
837
|
? value.expectedLaneDescriptors as unknown as ExpectedReviewLane[]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.15.
|
|
3
|
+
"version": "1.15.3",
|
|
4
4
|
"description": "Parallel AI code review for GitHub pull requests in the Pi coding agent, with model-agnostic tiered subagents, structured findings, optional verification, and host-gated COMMENT or qualified APPROVE publishing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/prompts/pr-review.md
CHANGED
|
@@ -41,7 +41,7 @@ If the `review_subagents` batch tool is available, prefer it over multiple singl
|
|
|
41
41
|
|
|
42
42
|
By default, and when `--balanced` is present, call `review_subagents` with `major_only: true`, `minor_hygiene: true`, and exactly these five passes: `overview`, `correctness`, `correctness-contracts`, `security-performance`, and `performance-resources`. Do **not** dispatch `conventions-maintainability`. For an ordinary diff use `max_parallel: 5`; for 200,000–399,999 byte multi-file diffs use `shard_count: 2` and `max_parallel: 10`; for diffs at least 400,000 bytes with at least three changed files use `shard_count: 3` and `max_parallel: 15`. The four heavy passes remain P0–P2-only. The overview remains context-only and may return at most three direct-diff P3/nit candidates; it must not use tools or conduct a repository audit.
|
|
43
43
|
|
|
44
|
-
When `--deep` is present, call `review_subagents` with exactly one pass: id `deep-review`, `tier: heavy`, `tool_policy: configured`, `major_only: false`, `minor_hygiene: false`, `expected_output: "nonempty"`, and `max_parallel: 1`. The objective must be the integrated whole-PR review: read the complete diff and the PR's stated intent, explore the surrounding repository with configured tools (callers, tests, sibling implementations) as needed to confirm or refute each candidate, and return every substantiated finding from nit through P0 with severity proportional to real impact
|
|
44
|
+
When `--deep` is present, call `review_subagents` with exactly one pass: id `deep-review`, `tier: heavy`, `tool_policy: configured`, `major_only: false`, `minor_hygiene: false`, `expected_output: "nonempty"`, and `max_parallel: 1`. The objective must be the integrated whole-PR review: read the complete diff and the PR's stated intent, explore the surrounding repository with configured tools (callers, tests, sibling implementations) as needed to confirm or refute each candidate, and return every substantiated finding from nit through P0 with severity proportional to real impact in the reviewer's standard candidate-evidence field format. The exact Markdown completion contract is fail-closed and explicit: the first nonblank line MUST be the top-level byte-exact `Review status: COMPLETE` with no leading or trailing whitespace when all required evidence was inspected; if required evidence could not be inspected for any reason, return the top-level byte-exact `Review status: INCOMPLETE` followed by a concise reason, and every non-COMPLETE/missing/duplicate/later/wrapped status is partial. Nonblank contract lines must not have trailing spaces or tabs. After COMPLETE, require exactly this order: one meaningful nonempty-line `Overview: ...`, `Strengths: ...`, and `Risk areas: ...` section (plain labels, bold labels, or an ATX label followed immediately by one top-level value line are accepted; no list-indented, quoted, fenced, HTML, or other wrapped framing), then either one top-level standalone uppercase `NO FINDINGS.` line or one or more complete candidate blocks. Candidate fields are exactly once and in order: `title` prefixed `[P0]`, `[P1]`, `[P2]`, `[P3]`, or `[nit]` with a tag matching `severity`; `severity` from `P0|P1|P2|P3|nit`; meaningful `why` (which may continue only on lines beginning with exactly two spaces plus a non-list character until the next reserved field); `location` as a safe repository-relative path plus a positive line or ordered positive line range, or `repo-wide`; `side` as `RIGHT|LEFT`; `in_diff` and `pr_related` as `yes|no`; and `confidence` as a decimal from `0.0` through `1.0`. Candidate labels may be plain/bold or use exactly one top-level `- ` list marker; arbitrary indentation and other list markers are invalid. Scalar and continuation values must not contain embedded status/field/severity/sentinel productions, code fences, HTML/container wrappers, blockquotes, lists, or code indentation. Do not emit arbitrary prose, code fences, blockquotes, JSON, HTML/container wrappers, duplicate/out-of-order/reserved productions, or placeholder values (`none`, `n/a`, `unavailable`, `unknown`, `skipped`, `error`, `review complete`); status/refusal/access/tool/model/error reasons belong in INCOMPLETE, while inability discussed inside a valid candidate why is allowed. COMPLETE is the primary contract; only obvious exact failure placeholders are deterministic defense-in-depth rejection. This pass replaces the Step 3 overview and every Step 5 lens: there is no separate overview lane and no sharding, whatever the diff size. You still own the terminal Markdown synthesis, verification handling, and final validation exactly as in any other mode.
|
|
45
45
|
|
|
46
46
|
When `--major-only` is present, use the same five passes and parallelism with `major_only: true` and `minor_hygiene: false`. This removes P3/nit exploration without removing any major correctness/security/resource lens.
|
|
47
47
|
|