@effect-agent/pr-review 0.1.0-beta.20 → 0.1.0-beta.22

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.
@@ -50,6 +50,14 @@ export class FanOutReviewerProfile extends Schema.Class<FanOutReviewerProfile>(
50
50
  attachedEphemeralDelegation: Schema.Literal(true),
51
51
  /** A failed unit surfaces to the coordinator as a typed failed result, never retried. */
52
52
  failedUnitsReportedNotRetried: Schema.Literal(true),
53
+ /** Risk categories and required specialist passes are pure host policy. */
54
+ hostOwnedRiskClassification: Schema.Literal(true),
55
+ /** Every host-classified high-risk unit receives a fresh specialist pass. */
56
+ redundantHighRiskDiscovery: Schema.Literal(true),
57
+ /** Only exact candidates confirmed by a fresh verifier child may publish. */
58
+ independentCandidateVerification: Schema.Literal(true),
59
+ /** No bounded model pipeline proves that a pull request is defect-free. */
60
+ defectAbsenceProven: Schema.Literal(false),
53
61
  /** The live profile is env-gated out of every ordinary test gate. */
54
62
  liveProfileOptIn: Schema.Literal(true),
55
63
  /** Never claimed at any phase (DUR-003). */
@@ -63,6 +71,10 @@ export const fanOutReviewerProfile = FanOutReviewerProfile.make({
63
71
  anchorsValidatedBeforePublication: true,
64
72
  attachedEphemeralDelegation: true,
65
73
  failedUnitsReportedNotRetried: true,
74
+ hostOwnedRiskClassification: true,
75
+ redundantHighRiskDiscovery: true,
76
+ independentCandidateVerification: true,
77
+ defectAbsenceProven: false,
66
78
  liveProfileOptIn: true,
67
79
  exactlyOnceExternalEffects: false,
68
80
  });
@@ -135,7 +135,7 @@ const settleCallout = (info: ReviewProgressSettle): string => {
135
135
  case "blocking":
136
136
  return `> 🛑 **Code review posted** — blocking findings; the check fails until they are addressed.`;
137
137
  case "incomplete":
138
- return `> ⚠️ **Code review posted** — required coverage is incomplete, so the check fails.`;
138
+ return `> ⚠️ **Code review posted** — input coverage or configured review assurance is incomplete, so the check fails.`;
139
139
  }
140
140
  };
141
141
 
@@ -1,7 +1,9 @@
1
1
  import { Schema } from "effect";
2
2
 
3
- import type { ReviewCoverage } from "./coverage.ts";
4
- import { commentableLines, type ChangedFile } from "./diff.ts";
3
+ import { anchorViolation } from "./anchors.ts";
4
+ export { anchorViolation } from "./anchors.ts";
5
+ import type { ReviewAssurance, ReviewCoverage, ReviewInputCoverage } from "./coverage.ts";
6
+ import type { ChangedFile } from "./diff.ts";
5
7
  import { renderFingerprintMarker } from "./fingerprint.ts";
6
8
  import {
7
9
  ReviewFinding,
@@ -214,13 +216,23 @@ const renderVerdictCallout = (
214
216
  readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
215
217
  readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
216
218
  readonly coverage?: ReviewCoverage | undefined;
219
+ readonly inputCoverage?: ReviewInputCoverage | undefined;
220
+ readonly assurance?: ReviewAssurance | undefined;
217
221
  },
218
222
  ): string => {
219
223
  const counts = severityCounts(review, options.carriedFindings, options.carriedConcerns);
220
- if (options.coverage?.status === "incomplete") {
224
+ if (
225
+ options.inputCoverage?.status === "incomplete" ||
226
+ (options.inputCoverage === undefined && options.coverage?.status === "incomplete")
227
+ ) {
228
+ const suffix =
229
+ counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
230
+ return `> [!CAUTION]\n> Input coverage is incomplete — the check must not pass.${suffix}`;
231
+ }
232
+ if (options.assurance !== undefined && options.assurance.status !== "settled") {
221
233
  const suffix =
222
234
  counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
223
- return `> [!CAUTION]\n> Review coverage is incomplete — the check must not pass.${suffix}`;
235
+ return `> [!CAUTION]\n> Configured review assurance did not settle — the check must not pass.${suffix}`;
224
236
  }
225
237
  if (counts.blocking > 0) {
226
238
  return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} — do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
@@ -360,22 +372,6 @@ const renderReviewMetadata = (options: {
360
372
  * Why one finding cannot become an inline comment, or undefined when it can.
361
373
  * Exported so tests can pin each rule individually.
362
374
  */
363
- export const anchorViolation = (
364
- finding: ReviewFinding,
365
- files: ReadonlyArray<ChangedFile>,
366
- ): string | undefined => {
367
- const file = files.find((candidate) => candidate.path === finding.path);
368
- if (file === undefined) return "path is not part of the changeset";
369
- if (file.patch === undefined) return "file has no anchorable textual diff";
370
- if (finding.endLine < finding.startLine) return "endLine precedes startLine";
371
- if (finding.endLine - finding.startLine + 1 > 100) return "range is implausibly large";
372
- const anchors = commentableLines(file.patch);
373
- for (let line = finding.startLine; line <= finding.endLine; line += 1) {
374
- if (!anchors.has(line)) return `line ${line} is not part of the diff`;
375
- }
376
- return undefined;
377
- };
378
-
379
375
  /**
380
376
  * Turn one validated review into the exact GitHub publication payload.
381
377
  * `applyVerdict: false` (the safe default) always posts a COMMENT review;
@@ -408,7 +404,11 @@ export const planPublication = (
408
404
  readonly fingerprint?: string | undefined;
409
405
  /** Host-owned coverage; incomplete coverage is rendered and fails the check. */
410
406
  readonly coverage?: ReviewCoverage | undefined;
411
- /** Unchanged unresolved items carried from the prior successfully reviewed head. */
407
+ /** Host-owned path/evidence assignment, separate from review assurance. */
408
+ readonly inputCoverage?: ReviewInputCoverage | undefined;
409
+ /** Host-owned discovery/specialist/verification settlement. */
410
+ readonly assurance?: ReviewAssurance | undefined;
411
+ /** Unchanged unresolved items carried from the prior settled assurance baseline. */
412
412
  readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
413
413
  readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
414
414
  /** Selected review scope, made visible whenever orchestration chose it. */
@@ -491,13 +491,15 @@ export const planPublication = (
491
491
  carriedFindings,
492
492
  carriedConcerns,
493
493
  coverage: options.coverage,
494
+ inputCoverage: options.inputCoverage,
495
+ assurance: options.assurance,
494
496
  }),
495
497
  ];
496
498
  if (options.reviewMode !== undefined && options.reviewReason !== undefined) {
497
499
  parts.push(
498
500
  "",
499
501
  options.reviewMode === "incremental"
500
- ? `**Incremental scope:** reviewed ${options.reviewFilesVisible ?? files.length} file(s) ${options.reviewReason}. Unchanged accepted scope was preserved and not reopened.`
502
+ ? `**Incremental scope:** reopened ${options.reviewFilesVisible ?? files.length} affected file(s) ${options.reviewReason}. Unchanged settled scope was preserved and not reopened.`
501
503
  : `**Full-diff scope:** ${options.reviewReason}.`,
502
504
  );
503
505
  }
@@ -508,13 +510,26 @@ export const planPublication = (
508
510
  );
509
511
  }
510
512
  parts.push("", renderReviewStats(files, options.totalChangedFiles, counts));
513
+ if (options.inputCoverage !== undefined && options.assurance !== undefined) {
514
+ parts.push(
515
+ "",
516
+ `**Input coverage:** ${options.inputCoverage.status} (${options.inputCoverage.assignedPaths.length}/${options.inputCoverage.requiredPaths.length} paths assigned, ${options.inputCoverage.partialPaths.length} partial) · **Review assurance:** ${options.assurance.status} (${options.assurance.completedGeneralDiscoveryPasses}/${options.assurance.requiredGeneralDiscoveryPasses} general discovery, ${options.assurance.completedSpecialistPasses}/${options.assurance.requiredSpecialistPasses} specialist, ${options.assurance.completedVerificationPasses}/${options.assurance.requiredVerificationPasses} verification; ${options.assurance.confirmedCandidates} confirmed / ${options.assurance.rejectedCandidates} rejected / ${options.assurance.unsettledCandidates} unsettled candidates)`,
517
+ );
518
+ }
511
519
  parts.push("", review.summary);
512
520
  if (walkthroughKept && walkthrough.length > 0) {
513
521
  parts.push("", renderWalkthrough(walkthrough));
514
522
  } else if (walkthrough.length > 0) {
515
523
  parts.push("", "⚠️ Walkthrough omitted — the body exceeded GitHub's review size cap.");
516
524
  }
517
- if (options.coverage?.status === "incomplete") {
525
+ if (options.inputCoverage?.status === "incomplete") {
526
+ parts.push(
527
+ "",
528
+ "### 🛑 Incomplete input coverage",
529
+ "",
530
+ ...options.inputCoverage.reasons.map((reason) => `- ${reason}`),
531
+ );
532
+ } else if (options.inputCoverage === undefined && options.coverage?.status === "incomplete") {
518
533
  parts.push(
519
534
  "",
520
535
  "### 🛑 Incomplete coverage",
@@ -522,6 +537,14 @@ export const planPublication = (
522
537
  ...options.coverage.reasons.map((reason) => `- ${reason}`),
523
538
  );
524
539
  }
540
+ if (options.assurance !== undefined && options.assurance.status !== "settled") {
541
+ parts.push(
542
+ "",
543
+ "### 🛑 Incomplete review assurance",
544
+ "",
545
+ ...options.assurance.reasons.map((reason) => `- ${reason}`),
546
+ );
547
+ }
525
548
  if (carriedFindings.length > 0) {
526
549
  parts.push(
527
550
  "",
@@ -543,7 +566,7 @@ export const planPublication = (
543
566
  if (files.length < options.totalChangedFiles) {
544
567
  parts.push(
545
568
  "",
546
- `⚠️ Reviewed ${files.length} of ${options.totalChangedFiles} changed files — the changeset exceeded the reviewer's file bound.`,
569
+ `⚠️ Input exposed ${files.length} of ${options.totalChangedFiles} changed files — the changeset exceeded the reviewer's file bound.`,
547
570
  );
548
571
  }
549
572
  if (demotedKept > 0) {
@@ -592,7 +615,10 @@ export const planPublication = (
592
615
  );
593
616
  const event: ReviewEvent = !options.applyVerdict
594
617
  ? "COMMENT"
595
- : options.coverage?.status === "incomplete" || counts.blocking > 0
618
+ : options.inputCoverage?.status === "incomplete" ||
619
+ (options.inputCoverage === undefined && options.coverage?.status === "incomplete") ||
620
+ (options.assurance !== undefined && options.assurance.status !== "settled") ||
621
+ counts.blocking > 0
596
622
  ? "REQUEST_CHANGES"
597
623
  : review.verdict === "approve" && counts.important === 0
598
624
  ? "APPROVE"
@@ -9,6 +9,7 @@ import {
9
9
  hasReviewableContent,
10
10
  renderReviewContent,
11
11
  } from "./diff.ts";
12
+ import type { ChangedFile } from "./diff.ts";
12
13
  import {
13
14
  normalizeRepoRelativePath,
14
15
  PullRequestSource,
@@ -30,8 +31,8 @@ export const MAX_FINDINGS = 20;
30
31
  /** The hard non-anchored-concerns bound carried by the CodeReview schema. */
31
32
  export const MAX_CONCERNS = 10;
32
33
 
33
- /** Annotated patches larger than this are truncated with an explicit marker. */
34
- const MAX_PATCH_CHARS = 60_000;
34
+ /** Maximum characters in one deterministic model-visible evidence chunk. */
35
+ export const MAX_PATCH_CHARS = 60_000;
35
36
 
36
37
  /** The encoded Tool result must retain one complete bounded content fallback. */
37
38
  export const REVIEW_TOOL_RESULT_MAX_BYTES = 2 * 1024 * 1024;
@@ -105,6 +106,67 @@ export class FileDiffView extends Schema.Class<FileDiffView>(
105
106
  truncated: Schema.Boolean,
106
107
  }) {}
107
108
 
109
+ export interface FileReviewEvidenceChunk {
110
+ readonly reviewMode: "diff" | "content" | "unavailable";
111
+ readonly annotatedPatch: string;
112
+ }
113
+
114
+ /**
115
+ * Split complete model-visible evidence at deterministic line boundaries.
116
+ * A pathological single line is hard-sliced so every character is still
117
+ * assigned and every chunk remains within the provider-independent bound.
118
+ */
119
+ const boundedEvidenceChunks = (evidence: string): ReadonlyArray<string> => {
120
+ if (evidence.length <= MAX_PATCH_CHARS) return [evidence];
121
+ const chunks: Array<string> = [];
122
+ let offset = 0;
123
+ while (offset < evidence.length) {
124
+ let end = Math.min(offset + MAX_PATCH_CHARS, evidence.length);
125
+ if (end < evidence.length) {
126
+ const boundary = evidence.lastIndexOf("\n", end - 1);
127
+ if (boundary >= offset) end = boundary + 1;
128
+ }
129
+ // No newline exists inside the bound: preserve complete input with a
130
+ // deterministic hard slice instead of silently truncating the line.
131
+ if (end === offset) end = Math.min(offset + MAX_PATCH_CHARS, evidence.length);
132
+ chunks.push(evidence.slice(offset, end));
133
+ offset = end;
134
+ }
135
+ return chunks;
136
+ };
137
+
138
+ /** Complete bounded evidence chunks used by deterministic fan-out planning. */
139
+ export const fileReviewEvidenceChunks = (
140
+ file: ChangedFile,
141
+ ): ReadonlyArray<FileReviewEvidenceChunk> => {
142
+ const contentEvidence = renderReviewContent(file);
143
+ const reviewMode =
144
+ file.patch !== undefined
145
+ ? ("diff" as const)
146
+ : contentEvidence !== undefined
147
+ ? ("content" as const)
148
+ : ("unavailable" as const);
149
+ const annotated = file.patch === undefined ? (contentEvidence ?? "") : annotatePatch(file.patch);
150
+ return boundedEvidenceChunks(annotated).map((annotatedPatch) => ({
151
+ reviewMode,
152
+ annotatedPatch,
153
+ }));
154
+ };
155
+
156
+ /** Host-owned rendering of one changed file's bounded review evidence. */
157
+ export const fileDiffView = (file: ChangedFile): FileDiffView => {
158
+ const chunks = fileReviewEvidenceChunks(file);
159
+ const first = chunks[0] ?? { reviewMode: "unavailable" as const, annotatedPatch: "" };
160
+ const truncated = first.reviewMode === "diff" && chunks.length > 1;
161
+ return FileDiffView.make({
162
+ path: file.path,
163
+ status: file.status,
164
+ reviewMode: first.reviewMode,
165
+ annotatedPatch: truncated ? `${first.annotatedPatch}\n[diff truncated]` : first.annotatedPatch,
166
+ truncated,
167
+ });
168
+ };
169
+
108
170
  // Read failures stay model-visible results ("return"), never run-killers:
109
171
  // a model asking for an out-of-changeset path is expected untrusted-input
110
172
  // behavior, and the fail-closed answer is a typed refusal it can correct —
@@ -194,25 +256,7 @@ export const readFileDiffHandler = (query: FileDiffQuery) =>
194
256
  reason: "Path is not part of this pull request's changeset.",
195
257
  });
196
258
  }
197
- const contentEvidence = renderReviewContent(file);
198
- const reviewMode =
199
- file.patch !== undefined
200
- ? ("diff" as const)
201
- : contentEvidence !== undefined
202
- ? ("content" as const)
203
- : ("unavailable" as const);
204
- const annotated =
205
- file.patch === undefined ? (contentEvidence ?? "") : annotatePatch(file.patch);
206
- const truncated = reviewMode === "diff" && annotated.length > MAX_PATCH_CHARS;
207
- return FileDiffView.make({
208
- path: file.path,
209
- status: file.status,
210
- reviewMode,
211
- annotatedPatch: truncated
212
- ? `${annotated.slice(0, MAX_PATCH_CHARS)}\n[diff truncated]`
213
- : annotated,
214
- truncated,
215
- });
259
+ return fileDiffView(file);
216
260
  });
217
261
 
218
262
  /**
@@ -302,7 +346,12 @@ export class ReviewFinding extends Schema.Class<ReviewFinding>(
302
346
  title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
303
347
  body: Schema.NonEmptyString.check(Schema.isMaxLength(2_000)),
304
348
  /** Replacement for exactly lines startLine..endLine; omit when unsure. */
305
- suggestion: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(2_000))),
349
+ suggestion: Schema.optionalKey(
350
+ Schema.String.annotate({
351
+ description:
352
+ "Committable replacement source code for exactly lines startLine..endLine: the full replacement for every line in the range and nothing else — never prose describing the change, which belongs in body.",
353
+ }).check(Schema.isMaxLength(2_000)),
354
+ ),
306
355
  }) {}
307
356
 
308
357
  export const ReviewVerdict = Schema.Literals(["approve", "comment", "request-changes"]);
@@ -400,15 +449,15 @@ export const makeReviewInstructions =
400
449
  : "The author provided no description.",
401
450
  ...resolveGuidance(options.guidance, mission),
402
451
  "Work in this order:",
403
- "1. Call list_changed_files once to see the changeset. That list is your COMPLETE review scope: in incremental reviews it is deliberately a subset of the pull request's full diff (totalFiles counts the whole pull request), and everything it omits was already reviewed or excluded.",
404
- "2. Call read_file_diff for every file you review. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
452
+ "1. Call list_changed_files once to see the selected input scope. In incremental reviews it is deliberately a subset of the pull request's full diff (totalFiles counts the whole pull request); omitted paths belong to settled prior scope or explicit host exclusions, not to this run.",
453
+ "2. Call read_file_diff for every listed file. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
405
454
  "3. Call read_file when you need surrounding context the diff does not show. ONLY listed files are readable — read_file_diff and read_file both return a failed result for any other path (an import, a neighbor, a file named in the description). Do not request or retry unlisted paths; reason from the visible diffs instead and note the gap honestly in your summary when it matters.",
406
455
  "4. Review for real defects first: correctness, security, concurrency, resource leaks, error handling, API misuse. Style nits are least important. Do not praise; do not restate the diff.",
407
456
  "When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
408
457
  "Go shallow only when the diff has no behavioral surface at all: doc typos, formatting, lockfile or generated-code regeneration, a mechanical rename. Line count is not the signal — a one-line change to auth, money, SQL, a comparison operator, or a config default is not trivial.",
409
458
  "Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
410
459
  '5. After collecting anchored findings, deliberately scan for concerns with NO line to point at: deletion or cleanup plans for code the diff replaces, rollout or migration sequencing, coverage gaps the diff implies but does not add, scope questions only the author can answer. Report each as a "concern", never as a finding with an invented anchor; report none when none exist.',
411
- `6. Write a walkthrough: for every file you reviewed, one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file — written for a reader scanning the pull request, never restating the diff line by line. Use only paths from list_changed_files; invented paths are dropped.`,
460
+ `6. Write a walkthrough: for every file whose evidence you examined, one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file — written for a reader scanning the pull request, never restating the diff line by line. Use only paths from list_changed_files; invented paths are dropped.`,
412
461
  '7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string, a changed file path>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for step-5 concerns with no valid anchor — never duplicate a finding here>, "walkthrough": <array, OPTIONAL: [{"path": <string, a changed file path>, "summary": <string, the step-6 sentence>}], one entry per reviewed file>}.',
413
462
  `Report at most ${maxFindings} findings and at most ${MAX_CONCERNS} concerns; prefer the most important ones. An empty findings array with verdict "approve" is a valid review. Include "suggestion" only when you are confident the replacement compiles and preserves intent; its text must contain the full replacement for every line in the range and nothing else.`,
414
463
  'Use verdict "request-changes" only when at least one finding or concern is "blocking". Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output.',
@@ -47,10 +47,12 @@ export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
47
47
  }) {}
48
48
 
49
49
  /**
50
- * Versioned state embedded in one successfully covered review. The reviewed
51
- * head plus the full-scope fingerprint means every path not represented by an
52
- * unresolved item is accepted at that head; storing hundreds of path strings
53
- * separately would not fit GitHub's bounded review body in the worst case.
50
+ * Versioned state embedded only after complete input assignment and settled
51
+ * configured review assurance. The head plus full-scope fingerprint forms an
52
+ * incremental baseline; an absent unresolved item never means the path is
53
+ * defect-free. The `acceptedScopeFingerprint` name is retained for wire
54
+ * compatibility. Storing hundreds of path strings separately would not fit
55
+ * GitHub's bounded review body in the worst case.
54
56
  */
55
57
  export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
56
58
  version: Schema.Literal(1),
@@ -415,7 +417,7 @@ export const selectReviewRange = (input: {
415
417
  );
416
418
  return {
417
419
  mode: "incremental",
418
- reason: `changes since successfully reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
420
+ reason: `changes since settled review head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
419
421
  files: selectedFiles,
420
422
  affectedPaths: [...affectedPaths].sort(),
421
423
  totalFiles: selectedFiles.length,