pi-pr-review 1.17.7 → 1.17.9
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 +14 -0
- package/README.md +2 -2
- package/extensions/pr-review-subagent.ts +2 -2
- package/extensions/review-table.ts +9 -4
- package/lib/pr-review-artifacts.ts +70 -8
- package/lib/pr-review-markdown.ts +133 -54
- package/lib/pr-review-publish.ts +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.17.9](https://github.com/10ego/pi-pr-review/compare/v1.17.8...v1.17.9) (2026-09-02)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* **review:** publish omitted lane candidates ([#140](https://github.com/10ego/pi-pr-review/issues/140)) ([846c85a](https://github.com/10ego/pi-pr-review/commit/846c85a38a0b2f54becef74c5e35b11487701963))
|
|
9
|
+
|
|
10
|
+
## [1.17.8](https://github.com/10ego/pi-pr-review/compare/v1.17.7...v1.17.8) (2026-09-01)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **review:** publish retained incomplete findings ([#138](https://github.com/10ego/pi-pr-review/issues/138)) ([586205b](https://github.com/10ego/pi-pr-review/commit/586205bbe46b50e04f00d53d43b3c353c3bea95e))
|
|
16
|
+
|
|
3
17
|
## [1.17.7](https://github.com/10ego/pi-pr-review/compare/v1.17.6...v1.17.7) (2026-09-01)
|
|
4
18
|
|
|
5
19
|
|
package/README.md
CHANGED
|
@@ -195,7 +195,7 @@ Every invocation has a host-owned monotonic 15-minute hard cap, including the tw
|
|
|
195
195
|
|
|
196
196
|
A timed-out or retryable quota/rate-limit/capacity lane may start at most one configured fallback attempt. It starts only when at least `minimumFallbackMs` plus cleanup reserve remains; the host never changes the configured model, thinking level, or tool policy to save time. If a tier is unset, its existing nearest-configured-tier/Pi-default behavior is unchanged.
|
|
197
197
|
|
|
198
|
-
On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for concise `COMMENT` publication. Complete contract-valid candidate blocks retained before a timeout are deterministically recovered as canonical findings, including
|
|
198
|
+
On an attempt deadline the host records timeout separately from user cancellation, sends TERM to the original child, waits only `terminationGraceMs`, then sends KILL if no exit was observed and stops draining after the cleanup reserve. Partial assistant text and telemetry survive this lifecycle. The synthesis cap arms only once review work goes quiet: any turn that starts or any review tool that runs again while the cap is armed defers it, and it re-arms from the next turn end, so early review-tool turns (for example verification discovery) cannot starve later heavy lanes. Batch/total expiry stops queued work and waiting lanes; completed and partial artifacts proceed to Markdown synthesis or deterministic lane assembly, identify every incomplete reviewer, and remain eligible for concise `COMMENT` publication. Complete contract-valid candidate blocks retained before a timeout are deterministically recovered as canonical findings from the terminal lane output and retained primary/fallback attempt history, including conventional YAML-list field indentation and cases where a later attempt or terminal synthesis is empty or malformed. A recovered lane candidate omitted by terminal synthesis keeps its priority and inline eligibility and carries an explicit recommendation that readers validate it independently. Host-recorded attempt ordinals—not array position—define chronology; duplicate ordinals disable history recovery, a later exact clean contract supersedes older provisional findings, and truncated blocks or arbitrary lane prose remain private. A timed-out lane is never reported as `NO FINDINGS` or full coverage, and a lane ended by the host total or synthesis deadline is disclosed with that kind (`deadline_expired`) instead of being mistaken for its own attempt deadline expiring. Host lane artifacts are authoritative for completeness in both directions: a false assistant completion claim cannot upgrade incomplete lanes, and a paraphrased or omitted `Lane completeness` line cannot downgrade a host-complete batch away from the concise renderer.
|
|
199
199
|
|
|
200
200
|
Initial operating targets are ordinary-review p50 ≤ 6 minutes and p95 ≤ 12 minutes, and large-review p50 ≤ 10 minutes and p95 ≤ 14 minutes, with the 15-minute hard cap authoritative. Invocation telemetry starts before GitHub preflight and records configured deadline source/caps, termination grace, cleanup reserve, and active wall time; batch details record invocation time consumed before each attempt, batch/total time remaining at dispatch, first event/output timing, lifecycle counts, configured and effective batch-truncated attempt deadlines, fallback starts/budget rejections, external total/synthesis deadline expiries per lane, and termination grace/escalation data. These are initial production targets, not a promise that every provider completes before its host deadline.
|
|
201
201
|
|
|
@@ -231,7 +231,7 @@ Review semantics are Markdown-first. A deterministic tolerant parser normalizes
|
|
|
231
231
|
|
|
232
232
|
You can publish the cache later with `/pr-review-publish 123`, or directly ask the agent to “post the inline review,” “manually post the review comments,” “post it as an inline review,” or “publish the review for PR #123.” The extension handles that request directly before an agent turn. `/pr-review-publish` and a matching direct request publish only the cache; they never start or rerun review agents. Unnumbered direct requests select the latest cached review for the current repository. Only fresh interactive/RPC input can use the direct path.
|
|
233
233
|
|
|
234
|
-
Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Host lane evidence is authoritative for completeness: a paraphrased or omitted `All requested lanes completed.` disclosure cannot downgrade a host-complete batch, and a false disclosure cannot upgrade incomplete or missing lane coverage; the assistant's canonical line is consulted only when no batch evidence exists. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, incomplete or missing host lane coverage, malformed or unsafe output, and lane-fallback artifacts remain `COMMENT` reviews; their safely parsed findings keep inline placement, while unparsable output publishes body-only. Cache restore reclassifies persisted lane output and requires the exact host-recorded artifact key set, with every complete artifact matching the frozen invocation generation, tier, and minor-hygiene contract, and re-synthesizes the retained raw text under that frozen binding before retaining approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The concise top-level body starts with the verdict, points readers to inline findings, and places nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow under `Other Notes`; overview, verification, strengths, completion warnings, and transport diagnostics stay out of the public summary. The complete original Markdown and retained lane evidence remain internal and are never substituted for a concise GitHub review, including degraded, incomplete, cached, direct, and stale `COMMENT` publication. If the concise body plus its canonical marker exceeds GitHub's limit, publication fails closed rather than dumping the full report. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
|
|
234
|
+
Every authorized publish path builds one GitHub review payload and sends at most one review `POST`; it never submits `REQUEST_CHANGES` or retries a rejected write with a fallback POST. Fully parsed Markdown with one complete retained artifact for every host-registered dispatch may emit a gated `APPROVE` through the same host binding, priority, lifecycle, self-author, and stale checks as strict JSON. Host lane evidence is authoritative for completeness: a paraphrased or omitted `All requested lanes completed.` disclosure cannot downgrade a host-complete batch, and a false disclosure cannot upgrade incomplete or missing lane coverage; the assistant's canonical line is consulted only when no batch evidence exists. Verdict fields outside the document preamble, hidden in CommonMark code/HTML blocks, or inside lazy container continuations, severity-tagged headings outside `Findings`, incomplete or missing host lane coverage, malformed or unsafe output, and lane-fallback artifacts remain `COMMENT` reviews; their safely parsed findings keep inline placement, while unparsable output publishes body-only. Cache restore reclassifies persisted lane output and requires the exact host-recorded artifact key set, with every complete artifact matching the frozen invocation generation, tier, and minor-hygiene contract, and re-synthesizes the retained raw text under that frozen binding before retaining approval eligibility. For a current, open PR, the first 50 eligible P0–P3 findings with valid, unique diff anchors are inline. The concise top-level body starts with the verdict, points readers to inline findings, and places nits, off-diff findings, unavailable diff metadata, duplicate anchors, and overflow under `Other Notes`; overview, verification, strengths, completion warnings, and transport diagnostics stay out of the public summary. Incomplete/degraded reviews publish whenever synthesis, retained attempts, or validated extraction produced at least one canonical finding. Complete host-validated lane candidates omitted by synthesis retain normal priority markers and inline placement with an explicit independent-validation advisory rather than disappearing. If none did, the completed record remains available privately and GitHub publication is skipped rather than creating a verdict-only review. The complete original Markdown and retained lane evidence remain internal and are never substituted for a concise GitHub review, including degraded, incomplete, cached, direct, and stale `COMMENT` publication. If the concise body plus its canonical marker exceeds GitHub's limit, publication fails closed rather than dumping the full report. Stale or authorized non-open reviews are body-only. A stale approval additionally requires `allowStaleApprovals: true`.
|
|
235
235
|
|
|
236
236
|
All publication paths apply host-enforced safety gates: captured posting authority, repository and requested-PR binding, reviewed/current-head and stale policy, bounded bodies and payloads, draft and lifecycle checks, non-open authorization, authenticated-identity same-head duplicate detection, and a final head check. Fully or partially parsed review paths additionally enforce safe inline locations, including degraded incomplete-lane reviews whose findings still parse. Raw and lane-assembled fallbacks without parsed findings are body-only; every degraded path is `COMMENT`-only, and assistant text cannot select event, commit, repository, hostname, API path, or inline anchors. Unknown or invalid host states fail closed before a write.
|
|
237
237
|
|
|
@@ -490,7 +490,7 @@ function getPiInvocation(args: string[]): { command: string; args: string[] } {
|
|
|
490
490
|
return { command: "pi", args };
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
-
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
|
|
493
|
+
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, repeat one top-level `- ` marker per field, or use conventional YAML-list form with `- title:` followed by fields indented exactly two spaces; no other indentation or list marker is accepted. A why value may continue only on lines beginning with exactly two spaces in top-level form or exactly four spaces in YAML-list form 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."
|
|
494
494
|
|
|
495
495
|
type InternalExpectedOutput = "review_lane" | "nonempty" | "json_object";
|
|
496
496
|
|
|
@@ -554,7 +554,7 @@ function buildSubagentSystemPrompt(tier: Tier, majorOnly = false, minorHygiene =
|
|
|
554
554
|
"- confidence: a float 0.0-1.0",
|
|
555
555
|
]),
|
|
556
556
|
expectedOutput === "nonempty"
|
|
557
|
-
? "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
|
|
557
|
+
? "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 may be top-level, repeat one `- ` marker per field, or use conventional YAML-list form with `- title:` followed by exactly-two-space-indented fields; candidate `why` continuations use two spaces in top-level form or four in YAML-list form and may never absorb a reserved field, status, sentinel, or wrapper."
|
|
558
558
|
: majorOnly
|
|
559
559
|
? "If there are no substantiated P0-P2 findings, reply exactly with: NO FINDINGS."
|
|
560
560
|
: "If there are genuinely no findings at any severity, reply exactly with: NO FINDINGS.",
|
|
@@ -461,6 +461,14 @@ async function publishCompletedReview(
|
|
|
461
461
|
}
|
|
462
462
|
const degradedPublication = record.completeness === "incomplete" ||
|
|
463
463
|
(record.synthesisQuality !== undefined && record.synthesisQuality !== "fully_parsed");
|
|
464
|
+
const findings = Array.isArray(record.review.findings) ? record.review.findings : [];
|
|
465
|
+
if (degradedPublication && findings.length === 0) {
|
|
466
|
+
// A result-less degraded record remains cached for private diagnostics and a
|
|
467
|
+
// later rerun, but must not create a verdict-only GitHub review. Incomplete
|
|
468
|
+
// reviews with any validated synthesis/lane/extraction finding still publish.
|
|
469
|
+
ctx.ui.notify("PR review was not posted: the incomplete review retained no validated findings", "warning");
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
464
472
|
const result = await publishPullReview({
|
|
465
473
|
cwd: ctx.cwd,
|
|
466
474
|
prNumber: record.invocation.prNumber,
|
|
@@ -475,9 +483,6 @@ async function publishCompletedReview(
|
|
|
475
483
|
// inline findings. Retained raw synthesis and lane evidence remain private
|
|
476
484
|
// completion diagnostics, including for degraded/incomplete COMMENT runs.
|
|
477
485
|
// If the concise payload cannot fit, publication fails closed.
|
|
478
|
-
forceBodyOnly: record.synthesisQuality !== undefined &&
|
|
479
|
-
record.synthesisQuality !== "fully_parsed" &&
|
|
480
|
-
!(Array.isArray(record.review.findings) && record.review.findings.length > 0),
|
|
481
486
|
// A publication body identifies Markdown-derived or degraded synthesis.
|
|
482
487
|
// Enforce COMMENT again at the final publication boundary so restored
|
|
483
488
|
// canonical artifacts created by an older parser cannot inherit APPROVE.
|
|
@@ -1076,7 +1081,7 @@ export default function registerReviewTable(
|
|
|
1076
1081
|
}
|
|
1077
1082
|
}
|
|
1078
1083
|
const publishResult = await publishCompletedReview(pending.record, { kind: "frozen-invocation" }, ctx);
|
|
1079
|
-
if (pending.extraction) {
|
|
1084
|
+
if (pending.extraction && publishResult) {
|
|
1080
1085
|
try {
|
|
1081
1086
|
// `published` decorates exactly the attempt whose merged event it
|
|
1082
1087
|
// follows: the spread carries that attempt's identity, so tooling can
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
1
2
|
import type { ReviewDeadlineKind } from "./pr-review-deadlines.ts";
|
|
2
3
|
|
|
3
4
|
export type ReviewLaneLifecycle = "complete" | "partial" | "timed_out" | "failed";
|
|
@@ -137,6 +138,9 @@ const CANDIDATE_SEVERITIES = new Set(["P0", "P1", "P2", "P3", "nit"]);
|
|
|
137
138
|
const FRAMING_LABELS = ["Overview", "Strengths", "Risk areas"] as const;
|
|
138
139
|
const PLACEHOLDER_ONLY = /^(?:none|n\/?a|na|unavailable|unknown|skipped|error|review complete|no findings|nothing to review)(?:\s+(?:identified|found|available|present))?[.!]?$/i;
|
|
139
140
|
const NO_FINDINGS_SENTINEL = "NO FINDINGS.";
|
|
141
|
+
const MAX_CANDIDATE_TITLE_BYTES = 1_024;
|
|
142
|
+
const MAX_CANDIDATE_WHY_BYTES = 16 * 1_024;
|
|
143
|
+
const MAX_CANDIDATE_PATH_BYTES = 4_096;
|
|
140
144
|
const CODE_FENCE = /^ {0,3}(?:`{3,}|~{3,})/m;
|
|
141
145
|
const COMMONMARK_HTML_BLOCK_TAGS = [
|
|
142
146
|
"address", "article", "aside", "base", "basefont", "blockquote", "body", "caption", "center", "col",
|
|
@@ -236,6 +240,21 @@ function candidateLabel(line: string): MarkdownLabel | undefined {
|
|
|
236
240
|
return { field: (match[1] ?? match[2] ?? match[3])!, value: match[4] ?? "", kind: "inline" };
|
|
237
241
|
}
|
|
238
242
|
|
|
243
|
+
type CandidateBlockStyle = "top-level" | "list-undecided" | "repeated-list" | "yaml-list";
|
|
244
|
+
|
|
245
|
+
/** Parse top-level, repeated-list-marker, or conventional YAML-list fields. */
|
|
246
|
+
function candidateBlockLabel(line: string, style: CandidateBlockStyle): MarkdownLabel | undefined {
|
|
247
|
+
if (style === "top-level") return line.startsWith("- ") ? undefined : candidateLabel(line);
|
|
248
|
+
if (style === "repeated-list") return line.startsWith("- ") ? candidateLabel(line) : undefined;
|
|
249
|
+
if (style === "list-undecided") return undefined;
|
|
250
|
+
// In conventional YAML form the title owns the list marker and subsequent
|
|
251
|
+
// fields use exactly two spaces with no nested marker. Broader indentation
|
|
252
|
+
// remains unavailable to arbitrary Markdown containers.
|
|
253
|
+
if (!/^ {2}\S/.test(line) || /^ {3}/.test(line)) return undefined;
|
|
254
|
+
const unindented = line.slice(2);
|
|
255
|
+
return unindented.startsWith("- ") ? undefined : candidateLabel(unindented);
|
|
256
|
+
}
|
|
257
|
+
|
|
239
258
|
function canonicalField(field: string): string {
|
|
240
259
|
return field.toLowerCase();
|
|
241
260
|
}
|
|
@@ -285,7 +304,8 @@ function safeLocation(value: string): boolean {
|
|
|
285
304
|
const start = Number(match[2]);
|
|
286
305
|
const end = match[3] === undefined ? start : Number(match[3]);
|
|
287
306
|
if (!Number.isSafeInteger(start) || start < 1 || !Number.isSafeInteger(end) || end < start) return false;
|
|
288
|
-
if (!locationPath ||
|
|
307
|
+
if (!locationPath || Buffer.byteLength(locationPath, "utf8") > MAX_CANDIDATE_PATH_BYTES ||
|
|
308
|
+
locationPath.startsWith("/") || locationPath.startsWith("~") || /^[A-Za-z]:/.test(locationPath)) return false;
|
|
289
309
|
if (/[\\\u0000-\u001f\u007f]/.test(locationPath)) return false;
|
|
290
310
|
const segments = locationPath.split("/");
|
|
291
311
|
return segments.length > 0 && segments.every((segment) => segment.length > 0 && segment === segment.trim() && segment !== "." && segment !== ".." && !segment.includes(":"));
|
|
@@ -341,8 +361,9 @@ function validCandidateFields(fields: ReadonlyMap<string, string>): boolean {
|
|
|
341
361
|
const inDiff = fields.get("in_diff")!.trim();
|
|
342
362
|
const prRelated = fields.get("pr_related")!.trim();
|
|
343
363
|
return !!titleMatch && meaningfulValue(titleMatch[2]!) &&
|
|
364
|
+
Buffer.byteLength(title, "utf8") <= MAX_CANDIDATE_TITLE_BYTES &&
|
|
344
365
|
CANDIDATE_SEVERITIES.has(severity) && titleMatch[1] === severity &&
|
|
345
|
-
meaningfulValue(why) && safeLocation(location) &&
|
|
366
|
+
meaningfulValue(why) && Buffer.byteLength(why, "utf8") <= MAX_CANDIDATE_WHY_BYTES && safeLocation(location) &&
|
|
346
367
|
/^(?:RIGHT|LEFT)$/.test(side) &&
|
|
347
368
|
/^(?:yes|no)$/.test(inDiff) &&
|
|
348
369
|
/^(?:yes|no)$/.test(prRelated) &&
|
|
@@ -402,11 +423,18 @@ function parseCandidatePrefix(
|
|
|
402
423
|
if (cursor >= lines.length) return { candidates, consumedAll: true };
|
|
403
424
|
const start = cursor;
|
|
404
425
|
const fields = new Map<string, string>();
|
|
426
|
+
let style: CandidateBlockStyle = "top-level";
|
|
405
427
|
for (const expected of CANDIDATE_FIELDS) {
|
|
406
428
|
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
407
429
|
const fieldLine = lines[cursor] ?? "";
|
|
408
430
|
if (fieldLine.trim() && /[ \t]+$/.test(fieldLine)) return { candidates, consumedAll: false };
|
|
409
|
-
|
|
431
|
+
if (expected === "title" && fieldLine.startsWith("- ")) style = "list-undecided";
|
|
432
|
+
if (expected === "severity" && style === "list-undecided") {
|
|
433
|
+
style = fieldLine.startsWith("- ") ? "repeated-list" : "yaml-list";
|
|
434
|
+
}
|
|
435
|
+
const field = expected === "title"
|
|
436
|
+
? candidateLabel(fieldLine)
|
|
437
|
+
: candidateBlockLabel(fieldLine, style);
|
|
410
438
|
if (!field || canonicalField(field.field) !== expected || fields.has(expected)) {
|
|
411
439
|
return { candidates, consumedAll: false };
|
|
412
440
|
}
|
|
@@ -419,12 +447,20 @@ function parseCandidatePrefix(
|
|
|
419
447
|
const continuation = lines[cursor]!;
|
|
420
448
|
if (!continuation.trim()) break;
|
|
421
449
|
if (/[ \t]+$/.test(continuation)) return { candidates, consumedAll: false };
|
|
450
|
+
const nextField = candidateBlockLabel(continuation, style);
|
|
451
|
+
if (nextField && canonicalField(nextField.field) === "location") break;
|
|
422
452
|
if (isReservedContractLine(continuation)) break;
|
|
423
|
-
|
|
453
|
+
const continuationIndent = style === "yaml-list" ? 4 : 2;
|
|
454
|
+
const prefix = " ".repeat(continuationIndent);
|
|
455
|
+
if (!continuation.startsWith(prefix) || continuation.startsWith(`${prefix} `) ||
|
|
456
|
+
new RegExp(`^ {${continuationIndent}}(?:[-*+>]|#{1,6})[ \\t]+`).test(continuation)) {
|
|
457
|
+
return { candidates, consumedAll: false };
|
|
458
|
+
}
|
|
459
|
+
const continuationValue = continuation.slice(continuationIndent);
|
|
460
|
+
if (continuationValue.startsWith("\t") || hasReservedCandidateProduction("why", continuationValue)) {
|
|
424
461
|
return { candidates, consumedAll: false };
|
|
425
462
|
}
|
|
426
|
-
|
|
427
|
-
whyLines.push(continuation.slice(2));
|
|
463
|
+
whyLines.push(continuationValue);
|
|
428
464
|
cursor++;
|
|
429
465
|
}
|
|
430
466
|
fields.set("why", whyLines.join("\n"));
|
|
@@ -480,6 +516,15 @@ function parseIntegratedCompletion(text: string): boolean {
|
|
|
480
516
|
* output. A timed-out final block may be truncated; earlier complete blocks
|
|
481
517
|
* remain usable, while arbitrary prose and unsafe containers recover nothing.
|
|
482
518
|
*/
|
|
519
|
+
export function isValidatedReviewLaneNoFindings(
|
|
520
|
+
rawText: string,
|
|
521
|
+
expectedOutput: "review_lane" | "nonempty" = "review_lane",
|
|
522
|
+
): boolean {
|
|
523
|
+
const text = normalizeReviewText(rawText);
|
|
524
|
+
if (expectedOutput === "review_lane") return /^NO FINDINGS\.?$/.test(text);
|
|
525
|
+
return parseIntegratedCompletion(text) && text.split("\n").some((line) => line === NO_FINDINGS_SENTINEL);
|
|
526
|
+
}
|
|
527
|
+
|
|
483
528
|
export function extractValidatedReviewLaneCandidates(
|
|
484
529
|
rawText: string,
|
|
485
530
|
expectedOutput: "review_lane" | "nonempty" = "review_lane",
|
|
@@ -559,8 +604,25 @@ function expectedLaneSections(input: ReviewLaneCompletionInput): boolean {
|
|
|
559
604
|
// missing final period carries no semantic uncertainty and must not amplify
|
|
560
605
|
// into expensive replacement passes. Integrated/deep `nonempty` output keeps
|
|
561
606
|
// its exact byte contract in parseIntegratedCompletion().
|
|
562
|
-
if (/^NO FINDINGS\.?$/.test(
|
|
563
|
-
return
|
|
607
|
+
if (/^NO FINDINGS\.?$/.test(normalized)) return true;
|
|
608
|
+
return parseOrdinaryCandidateCompletion(normalized);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function parseOrdinaryCandidateCompletion(rawText: string): boolean {
|
|
612
|
+
const text = normalizeReviewText(rawText);
|
|
613
|
+
if (
|
|
614
|
+
!text.trim() || hasTrailingHorizontalWhitespace(text) || CODE_FENCE.test(text) ||
|
|
615
|
+
hasHtmlContainer(text) || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(text) ||
|
|
616
|
+
/<!--\s*pi-pr-review:/i.test(text)
|
|
617
|
+
) return false;
|
|
618
|
+
const lines = text.split("\n");
|
|
619
|
+
if (lines.some((line) => hasReservedStatusProduction(line) || line.trim() === NO_FINDINGS_SENTINEL || line.trim() === "NO FINDINGS")) {
|
|
620
|
+
return false;
|
|
621
|
+
}
|
|
622
|
+
let cursor = 0;
|
|
623
|
+
while (cursor < lines.length && !lines[cursor]!.trim()) cursor++;
|
|
624
|
+
const parsed = parseCandidatePrefix(lines, cursor);
|
|
625
|
+
return parsed.consumedAll && parsed.candidates.length > 0;
|
|
564
626
|
}
|
|
565
627
|
|
|
566
628
|
/** Process exit is necessary but insufficient: only a terminal stop with valid lane output is complete. */
|
|
@@ -2,6 +2,7 @@ import { Buffer } from "node:buffer";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
3
|
import {
|
|
4
4
|
extractValidatedReviewLaneCandidates,
|
|
5
|
+
isValidatedReviewLaneNoFindings,
|
|
5
6
|
type ExpectedReviewLane,
|
|
6
7
|
type ReviewLaneArtifact,
|
|
7
8
|
} from "./pr-review-artifacts.ts";
|
|
@@ -492,51 +493,98 @@ function retainedLaneText(lane: ReviewLaneArtifact): string {
|
|
|
492
493
|
return "";
|
|
493
494
|
}
|
|
494
495
|
|
|
496
|
+
function retainedLaneCandidateTexts(
|
|
497
|
+
lane: ReviewLaneArtifact,
|
|
498
|
+
contract: "review_lane" | "nonempty",
|
|
499
|
+
): string[] {
|
|
500
|
+
const texts: string[] = [];
|
|
501
|
+
const seen = new Set<string>();
|
|
502
|
+
const retain = (value: string): "continue" | "stop" => {
|
|
503
|
+
if (!value.trim() || seen.has(value)) return "continue";
|
|
504
|
+
seen.add(value);
|
|
505
|
+
const text = value;
|
|
506
|
+
// A later exact clean contract supersedes provisional findings from older
|
|
507
|
+
// attempts. Malformed or empty later output does not.
|
|
508
|
+
if (isValidatedReviewLaneNoFindings(text, contract)) return "stop";
|
|
509
|
+
texts.push(text);
|
|
510
|
+
return "continue";
|
|
511
|
+
};
|
|
512
|
+
// The lane-level text is the latest authoritative output. Earlier attempts
|
|
513
|
+
// remain eligible only for independently contract-valid findings; malformed
|
|
514
|
+
// prose can neither become a finding nor poison a valid earlier attempt.
|
|
515
|
+
if (retain(lane.rawText) === "stop") return texts;
|
|
516
|
+
const ordinals = new Set<number>();
|
|
517
|
+
for (const attempt of lane.attempts) {
|
|
518
|
+
if (ordinals.has(attempt.ordinal)) return texts;
|
|
519
|
+
ordinals.add(attempt.ordinal);
|
|
520
|
+
}
|
|
521
|
+
const newestFirst = [...lane.attempts].sort((left, right) =>
|
|
522
|
+
left.ordinal === right.ordinal ? 0 : left.ordinal > right.ordinal ? -1 : 1);
|
|
523
|
+
for (const attempt of newestFirst) {
|
|
524
|
+
if (retain(attempt.rawText) === "stop") break;
|
|
525
|
+
}
|
|
526
|
+
return texts;
|
|
527
|
+
}
|
|
528
|
+
|
|
495
529
|
function retainedLaneFindings(
|
|
496
530
|
lanes: readonly ReviewLaneArtifact[],
|
|
497
531
|
expected: readonly ExpectedReviewLane[],
|
|
498
532
|
): ReviewFindingLike[] {
|
|
499
533
|
const findings: ReviewFindingLike[] = [];
|
|
500
|
-
const seen = new
|
|
534
|
+
const seen = new Map<string, number>();
|
|
501
535
|
for (const lane of lanes) {
|
|
502
536
|
const contract = expected.find((descriptor) => descriptor.key === lane.key)?.expectedOutput ?? "review_lane";
|
|
503
|
-
for (const
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
537
|
+
for (const text of retainedLaneCandidateTexts(lane, contract)) {
|
|
538
|
+
for (const candidate of extractValidatedReviewLaneCandidates(text, contract)) {
|
|
539
|
+
if (!candidate.prRelated) continue;
|
|
540
|
+
const parsedLocation = candidate.location === "repo-wide"
|
|
541
|
+
? { status: "absent" as const, location: null }
|
|
542
|
+
: parseLocation(`${candidate.location} ${candidate.side}`);
|
|
543
|
+
if (parsedLocation.status === "unsafe") continue;
|
|
544
|
+
const codeLocation = parsedLocation.location
|
|
545
|
+
? { ...parsedLocation.location, commentable: candidate.inDiff }
|
|
546
|
+
: null;
|
|
547
|
+
const key = JSON.stringify([
|
|
548
|
+
candidate.severity,
|
|
549
|
+
candidate.title,
|
|
550
|
+
candidate.why,
|
|
551
|
+
candidate.location,
|
|
552
|
+
candidate.side,
|
|
553
|
+
]);
|
|
554
|
+
const duplicateIndex = seen.get(key);
|
|
555
|
+
if (duplicateIndex !== undefined) {
|
|
556
|
+
const existing = findings[duplicateIndex];
|
|
557
|
+
if (candidate.inDiff && existing?.code_location && !existing.code_location.commentable) {
|
|
558
|
+
findings[duplicateIndex] = {
|
|
559
|
+
...existing,
|
|
560
|
+
code_location: { ...existing.code_location, commentable: true },
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
seen.set(key, findings.length);
|
|
566
|
+
findings.push({
|
|
567
|
+
title: candidate.title,
|
|
568
|
+
severity: candidate.severity,
|
|
569
|
+
blocking: candidate.severity === "P0" || candidate.severity === "P1",
|
|
570
|
+
body: candidate.why,
|
|
571
|
+
confidence_score: candidate.confidence,
|
|
572
|
+
code_location: codeLocation,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
529
575
|
}
|
|
530
576
|
}
|
|
531
577
|
return findings;
|
|
532
578
|
}
|
|
533
579
|
|
|
580
|
+
const INDEPENDENT_VALIDATION_ADVISORY = "_Recommend validating this comment independently._";
|
|
581
|
+
|
|
534
582
|
function mergeUniqueFindings(
|
|
535
583
|
primary: readonly ReviewFindingLike[],
|
|
536
584
|
additional: readonly ReviewFindingLike[],
|
|
537
585
|
): ReviewFindingLike[] {
|
|
538
586
|
const merged = [...primary];
|
|
539
|
-
const
|
|
587
|
+
const findingKey = (finding: ReviewFindingLike) => JSON.stringify([
|
|
540
588
|
finding.severity,
|
|
541
589
|
finding.title,
|
|
542
590
|
finding.body,
|
|
@@ -544,20 +592,30 @@ function mergeUniqueFindings(
|
|
|
544
592
|
finding.code_location?.line_range?.start,
|
|
545
593
|
finding.code_location?.line_range?.end,
|
|
546
594
|
finding.code_location?.side,
|
|
547
|
-
])
|
|
595
|
+
]);
|
|
596
|
+
const keys = new Map(merged.map((finding, index) => [findingKey(finding), index]));
|
|
548
597
|
for (const finding of additional) {
|
|
549
|
-
const key =
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
finding.code_location?.
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
598
|
+
const key = findingKey(finding);
|
|
599
|
+
const duplicateIndex = keys.get(key);
|
|
600
|
+
if (duplicateIndex !== undefined) {
|
|
601
|
+
const existing = merged[duplicateIndex];
|
|
602
|
+
if (finding.code_location?.commentable && existing?.code_location && !existing.code_location.commentable) {
|
|
603
|
+
merged[duplicateIndex] = {
|
|
604
|
+
...existing,
|
|
605
|
+
code_location: { ...existing.code_location, commentable: true },
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
keys.set(key, merged.length);
|
|
611
|
+
// These are complete, host-validated lane candidate blocks, but the
|
|
612
|
+
// terminal synthesizer did not independently confirm them. Keep their
|
|
613
|
+
// normal severity and inline eligibility while making that provenance
|
|
614
|
+
// explicit to the reader.
|
|
615
|
+
merged.push({
|
|
616
|
+
...finding,
|
|
617
|
+
body: `${finding.body?.trim() ?? ""}\n\n${INDEPENDENT_VALIDATION_ADVISORY}`.trim(),
|
|
618
|
+
});
|
|
561
619
|
}
|
|
562
620
|
return merged;
|
|
563
621
|
}
|
|
@@ -789,10 +847,12 @@ export function synthesizeReviewArtifact(input: {
|
|
|
789
847
|
batchEvidence ? lanes.every((lane) => lane.lifecycle === "complete") && (expectedLaneCount === 0 || exactLaneCoverage) : true,
|
|
790
848
|
);
|
|
791
849
|
const safe = publicationSafeStrictReview(input.strictJsonReview);
|
|
792
|
-
const
|
|
850
|
+
const recoveredOverridesSkip = input.strictJsonReview.disposition === "skipped" &&
|
|
851
|
+
validatedLaneFindings.length > 0;
|
|
852
|
+
const bodyFallback = !safe || completeness === "incomplete" || recoveredOverridesSkip;
|
|
793
853
|
const strictFindings = mergeUniqueFindings(
|
|
794
|
-
safe ? (input.strictJsonReview.findings ?? []) : [],
|
|
795
|
-
|
|
854
|
+
safe && !recoveredOverridesSkip ? (input.strictJsonReview.findings ?? []) : [],
|
|
855
|
+
validatedLaneFindings,
|
|
796
856
|
);
|
|
797
857
|
const body = bodyFallback
|
|
798
858
|
? buildDegradedReviewBody({
|
|
@@ -801,9 +861,11 @@ export function synthesizeReviewArtifact(input: {
|
|
|
801
861
|
findings: strictFindings,
|
|
802
862
|
expectedLaneCount,
|
|
803
863
|
exactCoverage: exactLaneCoverage || expectedLaneCount === 0,
|
|
804
|
-
reason:
|
|
805
|
-
? "
|
|
806
|
-
:
|
|
864
|
+
reason: recoveredOverridesSkip
|
|
865
|
+
? "retained lane findings overrode a skipped model synthesis"
|
|
866
|
+
: safe
|
|
867
|
+
? "incomplete lane evidence degraded this synthesis"
|
|
868
|
+
: "publication-invalid extracted content degraded this synthesis",
|
|
807
869
|
})
|
|
808
870
|
: "";
|
|
809
871
|
return Object.freeze({
|
|
@@ -814,10 +876,21 @@ export function synthesizeReviewArtifact(input: {
|
|
|
814
876
|
// Rebind every target field to the frozen host snapshot so an assistant
|
|
815
877
|
// cannot substitute the final head and bypass stale-head handling.
|
|
816
878
|
review: bodyFallback
|
|
817
|
-
?
|
|
879
|
+
? input.strictJsonReview.disposition === "skipped" && !recoveredOverridesSkip
|
|
880
|
+
? {
|
|
881
|
+
...input.strictJsonReview,
|
|
882
|
+
pr: { number: input.prNumber, title: input.prTitle, head_sha: input.headSha },
|
|
883
|
+
}
|
|
884
|
+
: syntheticReview(input.prNumber, input.prTitle, input.headSha, body, strictFindings)
|
|
818
885
|
: {
|
|
819
886
|
...input.strictJsonReview,
|
|
820
887
|
pr: { number: input.prNumber, title: input.prTitle, head_sha: input.headSha },
|
|
888
|
+
// A model-level skip cannot suppress complete host-retained findings.
|
|
889
|
+
disposition: validatedLaneFindings.length > 0 ? "reviewed" : input.strictJsonReview.disposition,
|
|
890
|
+
findings: strictFindings,
|
|
891
|
+
...(strictFindings.some((finding) => finding.severity === "P0" || finding.severity === "P1")
|
|
892
|
+
? { verdict: "request_changes", overall_correctness: "patch is incorrect" }
|
|
893
|
+
: {}),
|
|
821
894
|
},
|
|
822
895
|
laneArtifacts: lanes,
|
|
823
896
|
expectedLaneDescriptors,
|
|
@@ -825,21 +898,24 @@ export function synthesizeReviewArtifact(input: {
|
|
|
825
898
|
completeness,
|
|
826
899
|
mergeApprovalEligible: !bodyFallback,
|
|
827
900
|
diagnostics: Object.freeze(bodyFallback
|
|
828
|
-
? [
|
|
829
|
-
? "
|
|
830
|
-
:
|
|
901
|
+
? [recoveredOverridesSkip
|
|
902
|
+
? "retained lane findings overrode a skipped model synthesis"
|
|
903
|
+
: safe
|
|
904
|
+
? "incomplete lane evidence degraded this synthesis"
|
|
905
|
+
: "publication-invalid extracted content degraded this synthesis"]
|
|
831
906
|
: []),
|
|
832
907
|
});
|
|
833
908
|
}
|
|
834
909
|
const raw = input.rawText.trim().replace(/\r\n?/g, "\n");
|
|
835
910
|
if (!raw) {
|
|
836
911
|
const completeness = synthesisCompleteness(input.rawText, lanes);
|
|
912
|
+
const recoveredLaneFindings = mergeUniqueFindings([], validatedLaneFindings);
|
|
837
913
|
// Retained lane output is bounded by the host-owned body cap so an early
|
|
838
914
|
// large lane cannot truncate away the coverage disclosure above it.
|
|
839
915
|
const body = buildDegradedReviewBody({
|
|
840
916
|
rawText: "",
|
|
841
917
|
lanes,
|
|
842
|
-
findings:
|
|
918
|
+
findings: recoveredLaneFindings,
|
|
843
919
|
expectedLaneCount,
|
|
844
920
|
exactCoverage: exactLaneCoverage,
|
|
845
921
|
reason: "terminal synthesis was absent",
|
|
@@ -848,7 +924,7 @@ export function synthesizeReviewArtifact(input: {
|
|
|
848
924
|
quality: "lane_fallback" as const,
|
|
849
925
|
rawText: input.rawText,
|
|
850
926
|
body,
|
|
851
|
-
review: syntheticReview(input.prNumber, input.prTitle, input.headSha, body,
|
|
927
|
+
review: syntheticReview(input.prNumber, input.prTitle, input.headSha, body, recoveredLaneFindings),
|
|
852
928
|
laneArtifacts: lanes,
|
|
853
929
|
expectedLaneDescriptors,
|
|
854
930
|
expectedLaneCount,
|
|
@@ -898,7 +974,10 @@ export function synthesizeReviewArtifact(input: {
|
|
|
898
974
|
? "fully_parsed"
|
|
899
975
|
: canonicalParsed.findings.length > 0 ? "partially_parsed" : "raw";
|
|
900
976
|
const parsedSynthesisFindings = canonicalParsed.unsafe ? [] : canonicalParsed.findings;
|
|
901
|
-
|
|
977
|
+
// Terminal semantic validation may confirm a candidate, but it may not erase
|
|
978
|
+
// a complete host-validated lane block. Omitted candidates retain ordinary
|
|
979
|
+
// finding behavior with an explicit independent-validation advisory.
|
|
980
|
+
const recoveredLaneFindings = validatedLaneFindings;
|
|
902
981
|
const safeFindings = mergeUniqueFindings(parsedSynthesisFindings, recoveredLaneFindings);
|
|
903
982
|
const degradationReasons = (() => {
|
|
904
983
|
if (canonicalParsed.unsafe) {
|
package/lib/pr-review-publish.ts
CHANGED
|
@@ -596,7 +596,7 @@ export interface CompletedReviewSessionIdentity {
|
|
|
596
596
|
}
|
|
597
597
|
|
|
598
598
|
export interface PersistedCompletedReview {
|
|
599
|
-
schemaVersion:
|
|
599
|
+
schemaVersion: 4;
|
|
600
600
|
session: CompletedReviewSessionIdentity;
|
|
601
601
|
invocation: ReviewInvocation;
|
|
602
602
|
repository: RepositoryBinding;
|
|
@@ -819,7 +819,7 @@ export class CompletedReviewCache {
|
|
|
819
819
|
const digest = reviewHash(record.review);
|
|
820
820
|
const useReference = !!reviewEntryId && !!referencedReview && reviewHash(referencedReview) === digest;
|
|
821
821
|
return {
|
|
822
|
-
schemaVersion:
|
|
822
|
+
schemaVersion: 4,
|
|
823
823
|
session: { ...session },
|
|
824
824
|
invocation: { ...record.invocation, autoPost: { ...record.invocation.autoPost } },
|
|
825
825
|
repository: { ...record.repository },
|
|
@@ -849,7 +849,7 @@ export class CompletedReviewCache {
|
|
|
849
849
|
): boolean {
|
|
850
850
|
if (
|
|
851
851
|
!isObject(value) ||
|
|
852
|
-
value.schemaVersion !==
|
|
852
|
+
value.schemaVersion !== 4 ||
|
|
853
853
|
!validSessionIdentity(value.session) ||
|
|
854
854
|
!sameSessionIdentity(value.session, session) ||
|
|
855
855
|
!validRepositoryBinding(value.repository)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pr-review",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.9",
|
|
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",
|