@effect-agent/pr-review 0.1.0-beta.23 → 0.1.0-beta.25

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.
Files changed (37) hide show
  1. package/README.md +83 -195
  2. package/dist/action.d.mts +27 -18
  3. package/dist/action.mjs +60 -39
  4. package/dist/action.mjs.map +1 -1
  5. package/dist/cli.mjs +3 -3
  6. package/dist/cli.mjs.map +1 -1
  7. package/dist/{fan-out-BJBTAYuh.d.mts → fan-out-CMEsbFLk.d.mts} +455 -177
  8. package/dist/{github-BbwYzNrC.mjs → github-NjgxGqwM.mjs} +2163 -1518
  9. package/dist/github-NjgxGqwM.mjs.map +1 -0
  10. package/dist/index.d.mts +30 -20
  11. package/dist/index.mjs +3 -3
  12. package/dist/{providers-NyP-4rS6.mjs → providers-CODZQCmL.mjs} +202 -80
  13. package/dist/providers-CODZQCmL.mjs.map +1 -0
  14. package/dist/testing.d.mts +3 -1
  15. package/dist/testing.mjs +3 -2
  16. package/dist/testing.mjs.map +1 -1
  17. package/package.json +2 -2
  18. package/src/action.ts +141 -78
  19. package/src/cli.ts +6 -1
  20. package/src/index.ts +1 -0
  21. package/src/internal/adjudication.ts +415 -0
  22. package/src/internal/coverage.ts +41 -60
  23. package/src/internal/factory.ts +4 -4
  24. package/src/internal/fan-out.ts +208 -14
  25. package/src/internal/fingerprint.ts +16 -10
  26. package/src/internal/fixtures.ts +6 -0
  27. package/src/internal/github-env.ts +9 -0
  28. package/src/internal/github.ts +243 -7
  29. package/src/internal/progress.ts +1 -1
  30. package/src/internal/render.ts +186 -42
  31. package/src/internal/retirement.ts +16 -17
  32. package/src/internal/review-agent.ts +39 -4
  33. package/src/internal/review-state.ts +315 -105
  34. package/src/internal/review-units.ts +10 -9
  35. package/src/internal/run.ts +197 -63
  36. package/dist/github-BbwYzNrC.mjs.map +0 -1
  37. package/dist/providers-NyP-4rS6.mjs.map +0 -1
@@ -0,0 +1,415 @@
1
+ import { Context, DateTime, Effect, Layer, Schema } from "effect";
2
+
3
+ import { INLINE_FINDING_TITLE_PATTERN } from "./retirement.ts";
4
+ import {
5
+ adjudicationIdentity,
6
+ MAX_STORED_ADJUDICATIONS,
7
+ StoredAdjudication,
8
+ type AdjudicationDisposition,
9
+ type StoredReviewFinding,
10
+ } from "./review-state.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Maintainer adjudication. GitHub reads stay behind ReviewAdjudicationHost;
14
+ // this module owns only the deterministic verb grammar, fail-closed
15
+ // authorization, later-wins resolution, and prompt-context rendering. Only an
16
+ // explicit, authorized `/adjudicate` verb adjudicates — free-text rebuttals
17
+ // are deliberately never parsed, because only an explicit verb is auditable
18
+ // and fail-closed (model output and third-party comments are untrusted
19
+ // input, AGENTS.md rule 11).
20
+ // ---------------------------------------------------------------------------
21
+
22
+ const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
23
+
24
+ /** Maximum authorized command candidates retained for one inline thread. */
25
+ export const MAX_THREAD_ADJUDICATION_COMMANDS = 100;
26
+
27
+ /** One reply or top-level comment observed through the adjudication host. */
28
+ export class AdjudicationComment extends Schema.Class<AdjudicationComment>(
29
+ "@effect-agent/pr-review/AdjudicationComment",
30
+ )({
31
+ body: Schema.String.check(Schema.isMaxLength(65_536)),
32
+ /** GitHub's author_association for the comment author, verbatim. */
33
+ authorAssociation: Schema.String.check(Schema.isMaxLength(40)),
34
+ authorLogin: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
35
+ /** Creation time; a comment without one loses every later-wins tie. */
36
+ createdAt: Schema.NullOr(Schema.DateTimeUtc),
37
+ /** Stable zero-based order in the source listing, before thread grouping. */
38
+ sourceOrder: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
39
+ }) {}
40
+
41
+ /** One of the action's own inline finding threads, replies in creation order. */
42
+ export class AdjudicableThread extends Schema.Class<AdjudicableThread>(
43
+ "@effect-agent/pr-review/AdjudicableThread",
44
+ )({
45
+ path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
46
+ startLine: Schema.NullOr(PositiveLine),
47
+ endLine: Schema.NullOr(PositiveLine),
48
+ /** The root comment's body; its first line carries the finding title. */
49
+ rootBody: Schema.String.check(Schema.isMaxLength(65_536)),
50
+ replies: Schema.Array(AdjudicationComment).check(
51
+ Schema.isMaxLength(MAX_THREAD_ADJUDICATION_COMMANDS),
52
+ ),
53
+ }) {}
54
+
55
+ /** A GitHub adjudication read failed. */
56
+ export class ReviewAdjudicationFailure extends Schema.TaggedError<ReviewAdjudicationFailure>()(
57
+ "ReviewAdjudicationFailure",
58
+ {
59
+ operation: Schema.String,
60
+ reason: Schema.String,
61
+ },
62
+ ) {
63
+ override get message() {
64
+ return `Review adjudication operation '${this.operation}' failed: ${this.reason}`;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Host-side GitHub reads used by adjudication. Domain code never reaches into
70
+ * REST directly, and deterministic tests substitute this port. Both listings
71
+ * return comments in creation order.
72
+ */
73
+ export class ReviewAdjudicationHost extends Context.Service<
74
+ ReviewAdjudicationHost,
75
+ {
76
+ /** This action's own inline finding threads with their replies. */
77
+ readonly listFindingThreads: Effect.Effect<
78
+ ReadonlyArray<AdjudicableThread>,
79
+ ReviewAdjudicationFailure
80
+ >;
81
+ /** Top-level pull-request conversation comments. */
82
+ readonly listIssueComments: Effect.Effect<
83
+ ReadonlyArray<AdjudicationComment>,
84
+ ReviewAdjudicationFailure
85
+ >;
86
+ }
87
+ >()("@effect-agent/pr-review/ReviewAdjudicationHost") {}
88
+
89
+ /** Explicit program-edge adapter for runs that intentionally perform no host reads. */
90
+ export const noReviewAdjudicationHost = ReviewAdjudicationHost.of({
91
+ listFindingThreads: Effect.succeed([]),
92
+ listIssueComments: Effect.succeed([]),
93
+ });
94
+
95
+ /** Layer form of {@link noReviewAdjudicationHost}. */
96
+ export const noReviewAdjudicationHostLayer =
97
+ Layer.succeed(ReviewAdjudicationHost)(noReviewAdjudicationHost);
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Verb grammar. A body whose first line starts with `/adjudicate` is a
101
+ // command; a command that fails the grammar is malformed and ignored rather
102
+ // than guessed at. Fail-closed authorization: only OWNER, MEMBER, and
103
+ // COLLABORATOR authors may adjudicate.
104
+ // ---------------------------------------------------------------------------
105
+
106
+ /** author_associations allowed to adjudicate; everything else is ignored. */
107
+ export const AUTHORIZED_ADJUDICATION_ASSOCIATIONS: ReadonlySet<string> = new Set([
108
+ "OWNER",
109
+ "MEMBER",
110
+ "COLLABORATOR",
111
+ ]);
112
+
113
+ const AdjudicationDispositionSchema = Schema.Literals(["accepted-risk", "refuted", "obsolete"]);
114
+
115
+ const THREAD_COMMAND_PATTERN = /^\/adjudicate[ \t]+([a-z-]+)[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
116
+ const ISSUE_COMMAND_PATTERN =
117
+ /^\/adjudicate[ \t]+([a-z-]+)[ \t]+"([^"\n]+)"[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
118
+
119
+ export interface ParsedAdjudicationCommand {
120
+ readonly disposition: AdjudicationDisposition;
121
+ /** Present only for the issue-comment grammar's quoted target title. */
122
+ readonly title?: string | undefined;
123
+ readonly reason?: string | undefined;
124
+ }
125
+
126
+ const firstLine = (body: string): string => (body.split("\n", 1)[0] ?? "").trim();
127
+
128
+ const boundedReason = (raw: string | undefined): string | undefined => {
129
+ if (raw === undefined) return undefined;
130
+ const trimmed = raw.trim().slice(0, 300);
131
+ return trimmed.length === 0 ? undefined : trimmed;
132
+ };
133
+
134
+ /**
135
+ * Parse one inline-thread reply: `/adjudicate <disposition>(: <reason>)?`.
136
+ * The thread itself names the target identity. Returns undefined for a
137
+ * non-command body and "malformed" for a command that fails the grammar.
138
+ */
139
+ export const parseThreadAdjudication = (
140
+ body: string,
141
+ ): ParsedAdjudicationCommand | "malformed" | undefined => {
142
+ const line = firstLine(body);
143
+ if (!line.startsWith("/adjudicate")) return undefined;
144
+ const match = THREAD_COMMAND_PATTERN.exec(line);
145
+ const disposition = match?.[1];
146
+ if (disposition === undefined || !Schema.is(AdjudicationDispositionSchema)(disposition)) {
147
+ return "malformed";
148
+ }
149
+ return { disposition, reason: boundedReason(match?.[2]) };
150
+ };
151
+
152
+ /**
153
+ * Parse one top-level PR comment:
154
+ * `/adjudicate <disposition> "<exact title>"(: <reason>)?`. The quoted title
155
+ * is required because the conversation names no finding thread; it targets
156
+ * the title-alone identity of an unanchored concern.
157
+ */
158
+ export const parseIssueAdjudication = (
159
+ body: string,
160
+ ): ParsedAdjudicationCommand | "malformed" | undefined => {
161
+ const line = firstLine(body);
162
+ if (!line.startsWith("/adjudicate")) return undefined;
163
+ const match = ISSUE_COMMAND_PATTERN.exec(line);
164
+ const disposition = match?.[1];
165
+ const title = match?.[2];
166
+ if (
167
+ disposition === undefined ||
168
+ !Schema.is(AdjudicationDispositionSchema)(disposition) ||
169
+ title === undefined ||
170
+ title.length > 120
171
+ ) {
172
+ return "malformed";
173
+ }
174
+ return { disposition, title, reason: boundedReason(match?.[3]) };
175
+ };
176
+
177
+ /** The finding identity an inline thread names, or undefined when unparsable. */
178
+ export const threadFindingTarget = (
179
+ thread: AdjudicableThread,
180
+ ):
181
+ | {
182
+ readonly path: string;
183
+ readonly startLine: number;
184
+ readonly endLine: number;
185
+ readonly title: string;
186
+ }
187
+ | undefined => {
188
+ if (thread.startLine === null || thread.endLine === null) return undefined;
189
+ const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine(thread.rootBody))?.[1];
190
+ if (title === undefined || title.length > 120) return undefined;
191
+ return {
192
+ path: thread.path,
193
+ startLine: thread.startLine,
194
+ endLine: thread.endLine,
195
+ title,
196
+ };
197
+ };
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // Deterministic derivation: authorization, later-wins, bounded storage.
201
+ // ---------------------------------------------------------------------------
202
+
203
+ interface AdjudicationCandidate {
204
+ readonly adjudication: StoredAdjudication;
205
+ readonly epochMillis: number;
206
+ readonly sourceOrder: number;
207
+ }
208
+
209
+ export interface DerivedAdjudications {
210
+ readonly adjudications: ReadonlyArray<StoredAdjudication>;
211
+ /** Commands ignored fail-closed: unauthorized authors and malformed bodies. */
212
+ readonly ignored: ReadonlyArray<string>;
213
+ /** Later-wins winners dropped oldest-first at the storage bound. */
214
+ readonly droppedOldest: number;
215
+ }
216
+
217
+ /**
218
+ * Derive the standing adjudications from the host's listings. Every command
219
+ * is screened fail-closed (authorization, grammar, a parsable target); later
220
+ * adjudications of the same identity win by comment creation order; the
221
+ * result is capped at the ReviewState bound dropping the oldest winners.
222
+ */
223
+ export const deriveAdjudications = (input: {
224
+ readonly threads: ReadonlyArray<AdjudicableThread>;
225
+ readonly issueComments: ReadonlyArray<AdjudicationComment>;
226
+ }): DerivedAdjudications => {
227
+ const candidates: Array<AdjudicationCandidate> = [];
228
+ const ignored: Array<string> = [];
229
+ const admit = (
230
+ comment: AdjudicationComment,
231
+ command: ParsedAdjudicationCommand,
232
+ target: {
233
+ readonly path?: string | undefined;
234
+ readonly startLine?: number | undefined;
235
+ readonly endLine?: number | undefined;
236
+ readonly title: string;
237
+ },
238
+ ): void => {
239
+ candidates.push({
240
+ adjudication: StoredAdjudication.make({
241
+ ...(target.path === undefined ? {} : { path: target.path }),
242
+ ...(target.startLine === undefined ? {} : { startLine: target.startLine }),
243
+ ...(target.endLine === undefined ? {} : { endLine: target.endLine }),
244
+ title: target.title,
245
+ disposition: command.disposition,
246
+ ...(command.reason === undefined ? {} : { reason: command.reason }),
247
+ actor: comment.authorLogin,
248
+ }),
249
+ epochMillis: comment.createdAt === null ? -1 : DateTime.toEpochMillis(comment.createdAt),
250
+ sourceOrder: comment.sourceOrder,
251
+ });
252
+ };
253
+ const authorized = (comment: AdjudicationComment, surface: string): boolean => {
254
+ if (AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(comment.authorAssociation)) return true;
255
+ ignored.push(
256
+ `${surface}: unauthorized /adjudicate from @${comment.authorLogin} (${comment.authorAssociation})`,
257
+ );
258
+ return false;
259
+ };
260
+
261
+ for (const thread of input.threads) {
262
+ const target = threadFindingTarget(thread);
263
+ for (const reply of thread.replies) {
264
+ const command = parseThreadAdjudication(reply.body);
265
+ if (command === undefined) continue;
266
+ const surface = `inline thread ${thread.path}`;
267
+ if (command === "malformed") {
268
+ ignored.push(`${surface}: malformed /adjudicate command from @${reply.authorLogin}`);
269
+ continue;
270
+ }
271
+ if (!authorized(reply, surface)) continue;
272
+ if (target === undefined) {
273
+ ignored.push(`${surface}: thread root names no parsable finding title`);
274
+ continue;
275
+ }
276
+ admit(reply, command, target);
277
+ }
278
+ }
279
+ for (const comment of input.issueComments) {
280
+ const command = parseIssueAdjudication(comment.body);
281
+ if (command === undefined) continue;
282
+ const surface = "pull-request conversation";
283
+ if (command === "malformed") {
284
+ ignored.push(`${surface}: malformed /adjudicate command from @${comment.authorLogin}`);
285
+ continue;
286
+ }
287
+ if (!authorized(comment, surface)) continue;
288
+ if (command.title === undefined) {
289
+ ignored.push(`${surface}: /adjudicate without a quoted target title`);
290
+ continue;
291
+ }
292
+ admit(comment, command, { title: command.title });
293
+ }
294
+
295
+ const byIdentity = new Map<string, AdjudicationCandidate>();
296
+ const ordered = [...candidates].sort(
297
+ (left, right) => left.epochMillis - right.epochMillis || left.sourceOrder - right.sourceOrder,
298
+ );
299
+ for (const candidate of ordered) {
300
+ const identity = adjudicationIdentity(candidate.adjudication);
301
+ // Delete-then-set so a later adjudication also refreshes its recency for
302
+ // the oldest-first drop below.
303
+ byIdentity.delete(identity);
304
+ byIdentity.set(identity, candidate);
305
+ }
306
+ const winners = [...byIdentity.values()];
307
+ const droppedOldest = Math.max(0, winners.length - MAX_STORED_ADJUDICATIONS);
308
+ return {
309
+ adjudications: winners.slice(droppedOldest).map((candidate) => candidate.adjudication),
310
+ ignored,
311
+ droppedOldest,
312
+ };
313
+ };
314
+
315
+ /** Later-wins merge of stored prior adjudications with freshly derived ones. */
316
+ export const mergeAdjudications = (
317
+ prior: ReadonlyArray<StoredAdjudication>,
318
+ fresh: ReadonlyArray<StoredAdjudication>,
319
+ ): ReadonlyArray<StoredAdjudication> => {
320
+ const byIdentity = new Map<string, StoredAdjudication>();
321
+ for (const adjudication of [...prior, ...fresh]) {
322
+ const identity = adjudicationIdentity(adjudication);
323
+ byIdentity.delete(identity);
324
+ byIdentity.set(identity, adjudication);
325
+ }
326
+ const merged = [...byIdentity.values()];
327
+ return merged.slice(Math.max(0, merged.length - MAX_STORED_ADJUDICATIONS));
328
+ };
329
+
330
+ /**
331
+ * Collect the standing maintainer adjudications: freshly derived through the
332
+ * host, merged later-wins over the prior state's stored set. The host is a
333
+ * visible Effect requirement; program edges that intentionally perform no
334
+ * reads provide {@link noReviewAdjudicationHost}. Fail-open — any listing
335
+ * fault keeps the complete prior set and never fails the review, because NOT
336
+ * suppressing a finding is the conservative direction.
337
+ */
338
+ export const collectReviewAdjudications = Effect.fn("collectReviewAdjudications")(function* (
339
+ prior: ReadonlyArray<StoredAdjudication>,
340
+ ) {
341
+ const host = yield* ReviewAdjudicationHost;
342
+ const listings = yield* Effect.all({
343
+ threads: host.listFindingThreads,
344
+ issueComments: host.listIssueComments,
345
+ }).pipe(
346
+ Effect.catch((error) =>
347
+ Effect.logWarning(
348
+ `Could not collect adjudications from '${error.operation}': ${error.reason}; retaining stored adjudications unchanged.`,
349
+ ).pipe(Effect.as(undefined)),
350
+ ),
351
+ );
352
+ if (listings === undefined) return prior;
353
+ const derived = deriveAdjudications({
354
+ threads: listings.threads,
355
+ issueComments: listings.issueComments,
356
+ });
357
+ for (const note of derived.ignored) {
358
+ yield* Effect.logDebug(`Ignored adjudication command — ${note}`);
359
+ }
360
+ if (derived.droppedOldest > 0) {
361
+ yield* Effect.logWarning(
362
+ `Dropped ${derived.droppedOldest} oldest adjudication(s) over the ${MAX_STORED_ADJUDICATIONS}-entry bound.`,
363
+ );
364
+ }
365
+ return mergeAdjudications(prior, derived.adjudications);
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Prompt-context rendering: deterministic bounded lines the reviewer sees.
370
+ // ---------------------------------------------------------------------------
371
+
372
+ const lineRange = (startLine: number, endLine: number): string =>
373
+ `${startLine}${endLine === startLine ? "" : `-${endLine}`}`;
374
+
375
+ /** One adjudication as a bounded reviewer-prompt context line. */
376
+ export const renderAdjudicationContextLine = (adjudication: StoredAdjudication): string => {
377
+ const location =
378
+ adjudication.path !== undefined &&
379
+ adjudication.startLine !== undefined &&
380
+ adjudication.endLine !== undefined
381
+ ? `${adjudication.path}:${lineRange(adjudication.startLine, adjudication.endLine)}`
382
+ : "(unanchored)";
383
+ const reason = adjudication.reason === undefined ? "" : `: ${adjudication.reason}`;
384
+ return `${location} "${adjudication.title}" — ${adjudication.disposition} by @${adjudication.actor}${reason}`;
385
+ };
386
+
387
+ /** One prior-round finding as a bounded reviewer-prompt context line. */
388
+ export const renderPriorFindingContextLine = (finding: StoredReviewFinding): string =>
389
+ `${finding.path}:${lineRange(finding.startLine, finding.endLine)} [${finding.severity}] "${finding.title}" — ${finding.body.slice(0, 400)}`;
390
+
391
+ /** Prior-review context threaded into fan-out discovery briefs, per path. */
392
+ export interface PriorReviewContext {
393
+ /** Adjudicated identities; path-free entries apply to every unit. */
394
+ readonly adjudicated: ReadonlyArray<{
395
+ readonly path: string | undefined;
396
+ readonly line: string;
397
+ }>;
398
+ /** Prior-round findings whose paths are being re-reviewed. */
399
+ readonly priorFindings: ReadonlyArray<{ readonly path: string; readonly line: string }>;
400
+ }
401
+
402
+ /** Build the fan-out prior-review context from the resolved continuity data. */
403
+ export const buildPriorReviewContext = (
404
+ adjudications: ReadonlyArray<StoredAdjudication>,
405
+ priorFindingsOnScope: ReadonlyArray<StoredReviewFinding>,
406
+ ): PriorReviewContext => ({
407
+ adjudicated: adjudications.map((adjudication) => ({
408
+ path: adjudication.path,
409
+ line: renderAdjudicationContextLine(adjudication),
410
+ })),
411
+ priorFindings: priorFindingsOnScope.map((finding) => ({
412
+ path: finding.path,
413
+ line: renderPriorFindingContextLine(finding),
414
+ })),
415
+ });
@@ -19,37 +19,6 @@ import type { ReviewUnitPlan } from "./review-units.ts";
19
19
  // results; only the flat reviewer is assessed from its Run event trace here.
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- export class FailedReviewUnit extends Schema.Class<FailedReviewUnit>(
23
- "@effect-agent/pr-review/FailedReviewUnit",
24
- )({
25
- unitId: Schema.NonEmptyString.check(Schema.isMaxLength(32)),
26
- errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
27
- }) {}
28
-
29
- /**
30
- * Compatibility diagnostic retained for callers that consumed the original
31
- * `coverage` field. New UI and state decisions use ReviewInputCoverage and
32
- * ReviewAssurance directly.
33
- */
34
- export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
35
- "@effect-agent/pr-review/ReviewCoverage",
36
- )({
37
- status: Schema.Literals(["complete", "incomplete"]),
38
- requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
39
- Schema.isMaxLength(300),
40
- ),
41
- reviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
42
- Schema.isMaxLength(300),
43
- ),
44
- unreviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
45
- Schema.isMaxLength(300),
46
- ),
47
- failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
48
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
49
- Schema.isMaxLength(32),
50
- ),
51
- }) {}
52
-
53
22
  export class ReviewInputCoverage extends Schema.Class<ReviewInputCoverage>(
54
23
  "@effect-agent/pr-review/ReviewInputCoverage",
55
24
  )({
@@ -159,6 +128,47 @@ export const boundedListReason = (label: string, values: Iterable<string>): stri
159
128
  return rendered;
160
129
  };
161
130
 
131
+ export interface CarriedScope {
132
+ /** Carried paths a retry can actually settle (failed passes, overflow). */
133
+ readonly retryablePaths: ReadonlyArray<string>;
134
+ /** Carried paths no retry can settle (binaries, oversized files). */
135
+ readonly undiffablePaths: ReadonlyArray<string>;
136
+ /** Whether any incompleteness beyond the undiffable files exists. */
137
+ readonly retryableGap: boolean;
138
+ }
139
+
140
+ /**
141
+ * Split carried scope into paths a retry can settle and paths it never can.
142
+ * Undiffable files are a property of the pull request, not a transient
143
+ * reviewer-side failure: gate reasons and rendered callouts must never promise
144
+ * they are "retried automatically" — the honest instruction is to remove them
145
+ * from the pull request or exclude them with ignore globs.
146
+ */
147
+ export const splitCarriedScope = (input: {
148
+ readonly inputCoverage?: ReviewInputCoverage | undefined;
149
+ readonly assurance?: ReviewAssurance | undefined;
150
+ readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
151
+ }): CarriedScope => {
152
+ const undiffable = new Set(input.inputCoverage?.undiffablePaths ?? []);
153
+ const retryablePaths = (input.unreviewedPaths ?? []).filter((path) => !undiffable.has(path));
154
+ const undiffablePaths = sortedUnique(undiffable);
155
+ // Every non-undiffable coverage gap (range truncation, capacity overflow,
156
+ // truncated or missing evidence, anchor surface) contributes its own reason
157
+ // line, so a lone reason alongside undiffable paths means the undiffable
158
+ // files are the entire gap.
159
+ const coverageGapBeyondUndiffable =
160
+ input.inputCoverage?.status === "incomplete" &&
161
+ input.inputCoverage.reasons.length > (undiffablePaths.length > 0 ? 1 : 0);
162
+ return {
163
+ retryablePaths,
164
+ undiffablePaths,
165
+ retryableGap:
166
+ input.assurance?.status === "incomplete" ||
167
+ retryablePaths.length > 0 ||
168
+ coverageGapBeyondUndiffable,
169
+ };
170
+ };
171
+
162
172
  const anchorSurfaceAdjusted = (
163
173
  inputCoverage: ReviewInputCoverage,
164
174
  anchorFiles: ReadonlyArray<ChangedFile>,
@@ -345,32 +355,3 @@ export const fanOutInputCoverage = (input: {
345
355
  input.totalAnchorFiles,
346
356
  );
347
357
  };
348
-
349
- /** Compatibility aggregate over the two precise claims. */
350
- export const compatibilityCoverage = (
351
- inputCoverage: ReviewInputCoverage,
352
- assurance: ReviewAssurance,
353
- ): ReviewCoverage => {
354
- const assuranceIncomplete = assurance.status === "incomplete";
355
- const failedUnits = new Map<string, FailedReviewUnit>();
356
- for (const pass of assurance.failedPasses) {
357
- const unitId = pass.workId.slice(0, "unit-000".length);
358
- if (!failedUnits.has(unitId)) {
359
- failedUnits.set(
360
- unitId,
361
- FailedReviewUnit.make({ unitId, errorTag: `${pass.stage}:${pass.errorTag}` }),
362
- );
363
- }
364
- }
365
- return ReviewCoverage.make({
366
- status: inputCoverage.status === "complete" && !assuranceIncomplete ? "complete" : "incomplete",
367
- requiredPaths: inputCoverage.requiredPaths,
368
- reviewedPaths: inputCoverage.assignedPaths,
369
- unreviewedPaths: sortedUnique([
370
- ...inputCoverage.partialPaths,
371
- ...inputCoverage.unassignedPaths,
372
- ]),
373
- failedUnits: [...failedUnits.values()].slice(0, 8),
374
- reasons: [...inputCoverage.reasons, ...(assuranceIncomplete ? assurance.reasons : [])],
375
- });
376
- };
@@ -11,7 +11,7 @@ import { Toolkit, type LanguageModel, type Model, type Tool } from "effect/unsta
11
11
 
12
12
  import type { ChangedFile } from "./diff.ts";
13
13
  import { makeFileReviewerDefinition } from "./fan-out.ts";
14
- import { computeChangesetFingerprint } from "./fingerprint.ts";
14
+ import { computeChangesetFingerprint, computeProfileFingerprint } from "./fingerprint.ts";
15
15
  import { compileIgnoreGlobs, ignoringPullRequestSourceLayer } from "./ignore.ts";
16
16
  import {
17
17
  clampMaxFindings,
@@ -26,7 +26,7 @@ import {
26
26
  resolveGuidance as resolveReviewGuidance,
27
27
  type ReviewGuidance,
28
28
  } from "./review-agent.ts";
29
- import { buildProfileMission, computeProfileFingerprint } from "./review-state.ts";
29
+ import { buildProfileMission } from "./review-state.ts";
30
30
  import {
31
31
  buildReviewMission,
32
32
  executeFanOutReview,
@@ -175,8 +175,8 @@ const makeReviewSnapshot = (ignore: ReadonlyArray<string> | undefined) =>
175
175
  * Build the flat reviewer: one bounded read-only agent over the whole
176
176
  * changeset. Returns the model-agnostic definition, the explicit binding, and
177
177
  * a `run` whose error and requirement channels stay fully inferred — the
178
- * pull-request source, the publisher, extra tool handlers, and the Model
179
- * Layer's requirements all remain visible to the caller.
178
+ * pull-request source, the publisher, `Crypto.Crypto`, extra tool handlers,
179
+ * and the Model Layer's requirements all remain visible to the caller.
180
180
  */
181
181
  const make = <
182
182
  Provider,