@effect-agent/pr-review 0.1.0-beta.24 → 0.1.0-beta.26

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.
@@ -1,4 +1,4 @@
1
- import { Effect, Option, Schema } from "effect";
1
+ import { Effect, Schema } from "effect";
2
2
  import {
3
3
  makeUsageBudget,
4
4
  toRunBudgetHook,
@@ -9,12 +9,16 @@ import {
9
9
  } from "effect-agent";
10
10
  import { type Tool } from "effect/unstable/ai";
11
11
 
12
+ import {
13
+ collectReviewAdjudications,
14
+ renderAdjudicationContextLine,
15
+ renderPriorFindingContextLine,
16
+ buildPriorReviewContext,
17
+ } from "./adjudication.ts";
12
18
  import {
13
19
  assessFlatReview,
14
- compatibilityCoverage,
15
20
  fanOutInputCoverage,
16
21
  ReviewAssurance,
17
- ReviewCoverage,
18
22
  ReviewInputCoverage,
19
23
  } from "./coverage.ts";
20
24
  import type { ChangedFile } from "./diff.ts";
@@ -30,17 +34,21 @@ import {
30
34
  ReviewMission,
31
35
  } from "./review-agent.ts";
32
36
  import {
37
+ adjudicationIdentity,
38
+ concernIdentity,
39
+ findingIdentity,
33
40
  fromStoredConcern,
34
41
  fromStoredFinding,
35
42
  MAX_STORED_UNREVIEWED_PASSES,
36
43
  MAX_STORED_UNREVIEWED_PATHS,
37
44
  ReviewExecutionContext,
38
45
  ReviewState,
46
+ StoredAdjudication,
39
47
  StoredUnreviewedPass,
40
48
  toStoredConcern,
41
49
  toStoredFinding,
42
50
  } from "./review-state.ts";
43
- import { rankAndDedupeConcerns, rankAndDedupeFindings } from "./review-units.ts";
51
+ import { rankAndDedupeConcerns, rankAndDedupeFindings, reviewConcernKey } from "./review-units.ts";
44
52
  import { PullRequestSource, type PullRequestMetadata } from "./source.ts";
45
53
 
46
54
  // ---------------------------------------------------------------------------
@@ -91,8 +99,6 @@ export class ReviewRunOutcome extends Schema.Class<ReviewRunOutcome>(
91
99
  activeFindings: Schema.Array(ReviewFinding).check(Schema.isMaxLength(20)),
92
100
  /** All currently unresolved concerns, including concerns carried to final audit. */
93
101
  activeConcerns: Schema.Array(ReviewConcern).check(Schema.isMaxLength(10)),
94
- /** Host-owned structural coverage used by the Actions check conclusion. */
95
- coverage: ReviewCoverage,
96
102
  /** Exact path/evidence assignment, distinct from semantic review work. */
97
103
  inputCoverage: ReviewInputCoverage,
98
104
  /** Settlement of scheduled discovery, specialist, and verification work. */
@@ -110,6 +116,8 @@ export class ReviewRunOutcome extends Schema.Class<ReviewRunOutcome>(
110
116
  reviewMode: Schema.optionalKey(Schema.Literals(["incremental", "full"])),
111
117
  reviewReason: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(1_000))),
112
118
  state: Schema.optionalKey(ReviewState),
119
+ /** Maintainer adjudications standing against this run's identities. */
120
+ adjudications: Schema.optionalKey(Schema.Array(StoredAdjudication).check(Schema.isMaxLength(20))),
113
121
  }) {}
114
122
 
115
123
  export interface ExecuteReviewOptions {
@@ -137,10 +145,19 @@ export interface ExecuteReviewOptions {
137
145
  readonly runUrl?: string | undefined;
138
146
  }
139
147
 
140
- /** Build the mission one review run frames from the source's snapshot. */
148
+ /**
149
+ * Build the mission one review run frames from the source's snapshot. The
150
+ * optional continuity context (adjudicated identities, prior-round findings
151
+ * on re-reviewed scope) reaches only RUN missions — fingerprint missions stay
152
+ * plain so an adjudication never invalidates skip-unchanged authority.
153
+ */
141
154
  export const buildReviewMission = (
142
155
  metadata: PullRequestMetadata,
143
156
  files: ReadonlyArray<ChangedFile>,
157
+ context?: {
158
+ readonly adjudicated?: ReadonlyArray<string> | undefined;
159
+ readonly priorFindings?: ReadonlyArray<string> | undefined;
160
+ },
144
161
  ): ReviewMission =>
145
162
  ReviewMission.make({
146
163
  repository: metadata.repository,
@@ -150,6 +167,12 @@ export const buildReviewMission = (
150
167
  baseRef: metadata.baseRef,
151
168
  headRef: metadata.headRef,
152
169
  changedFileCount: files.length,
170
+ ...(context?.adjudicated !== undefined && context.adjudicated.length > 0
171
+ ? { adjudicatedContext: context.adjudicated.slice(0, 20) }
172
+ : {}),
173
+ ...(context?.priorFindings !== undefined && context.priorFindings.length > 0
174
+ ? { priorFindingContext: context.priorFindings.slice(0, 20) }
175
+ : {}),
153
176
  });
154
177
 
155
178
  /** Enforce the configured findings bound on an already-validated review. */
@@ -167,6 +190,25 @@ export const enforceFindingsBound = (review: CodeReview, maxFindings: number): C
167
190
  const findingKey = (finding: ReviewFinding): string =>
168
191
  `${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.severity}\u0000${finding.title}`;
169
192
 
193
+ /**
194
+ * Continuity inputs resolved BEFORE any model work: the standing maintainer
195
+ * adjudications (fresh host listing merged later-wins over the prior state's
196
+ * stored set) and the prior-round findings whose paths this run re-reviews.
197
+ * The latter are dropped from the carry (the new round re-decides them) but
198
+ * injected as prompt context so successive rounds do not silently contradict
199
+ * each other — context ONLY, never auto-carried into active findings.
200
+ */
201
+ const resolveReviewContinuityContext = Effect.fn("resolveReviewContinuityContext")(function* () {
202
+ const executionContext = yield* ReviewExecutionContext;
203
+ const priorState =
204
+ executionContext.mode === "incremental" ? executionContext.priorState : undefined;
205
+ const adjudications = yield* collectReviewAdjudications(priorState?.adjudications ?? []);
206
+ const affectedPaths = new Set(executionContext.affectedPaths);
207
+ const priorFindingsOnScope =
208
+ priorState?.unresolvedFindings.filter((finding) => affectedPaths.has(finding.path)) ?? [];
209
+ return { adjudications, priorFindingsOnScope };
210
+ });
211
+
170
212
  /** One shape-specific review result, before the shared settlement tail. */
171
213
  interface ReviewCore {
172
214
  readonly review: CodeReview;
@@ -192,76 +234,137 @@ const settleReviewRun = (
192
234
  readonly anchorFiles: ReadonlyArray<ChangedFile>;
193
235
  readonly fingerprint: string | undefined;
194
236
  readonly usage: UsageTotals | undefined;
237
+ readonly adjudications?: ReadonlyArray<StoredAdjudication> | undefined;
195
238
  },
196
239
  options: ExecuteReviewOptions,
197
240
  ) =>
198
241
  Effect.gen(function* () {
199
242
  const { metadata, files, anchorFiles, fingerprint, usage } = context;
200
- const executionContext = Option.getOrUndefined(
201
- yield* Effect.serviceOption(ReviewExecutionContext),
202
- );
203
- const review = enforceFindingsBound(core.review, clampMaxFindings(options.maxFindings));
243
+ const executionContext = yield* ReviewExecutionContext;
244
+ const adjudications = context.adjudications ?? [];
245
+ const adjudicatedIdentities = new Set(adjudications.map(adjudicationIdentity));
246
+ const isAdjudicatedFinding = (finding: ReviewFinding): boolean =>
247
+ adjudicatedIdentities.has(findingIdentity(finding));
248
+ const isAdjudicatedConcern = (concern: ReviewConcern): boolean =>
249
+ adjudicatedIdentities.has(concernIdentity(concern));
250
+ // Suppress adjudicated model output before any ranking or bounding. A
251
+ // suppressed blocker must never consume the slot of an active finding.
252
+ const filteredReview =
253
+ adjudicatedIdentities.size === 0
254
+ ? core.review
255
+ : CodeReview.make({
256
+ summary: core.review.summary,
257
+ verdict: core.review.verdict,
258
+ findings: core.review.findings.filter((finding) => !isAdjudicatedFinding(finding)),
259
+ ...(core.review.concerns === undefined
260
+ ? {}
261
+ : {
262
+ concerns: core.review.concerns.filter(
263
+ (concern) => !isAdjudicatedConcern(concern),
264
+ ),
265
+ }),
266
+ ...(core.review.walkthrough === undefined
267
+ ? {}
268
+ : { walkthrough: core.review.walkthrough }),
269
+ });
270
+ const reviewPaths = new Set(files.map((file) => file.path));
271
+ const normalizedReview = CodeReview.make({
272
+ ...filteredReview,
273
+ ...(filteredReview.concerns === undefined
274
+ ? {}
275
+ : {
276
+ concerns: filteredReview.concerns.map((concern) => {
277
+ const evidencePaths = concern.evidencePaths;
278
+ if (
279
+ evidencePaths === undefined ||
280
+ evidencePaths.some((path) => !reviewPaths.has(path))
281
+ ) {
282
+ const { evidencePaths: _invalid, ...pathless } = concern;
283
+ return ReviewConcern.make(pathless);
284
+ }
285
+ return ReviewConcern.make({
286
+ ...concern,
287
+ evidencePaths: [...new Set(evidencePaths)].sort(),
288
+ });
289
+ }),
290
+ }),
291
+ });
292
+ // Adjudicated identities leave the published review entirely: no inline
293
+ // comment, no severity count, no verdict influence — they render only in
294
+ // the plan's collapsed adjudicated section. Only identity-equal items are
295
+ // suppressed; a materially different finding at the same location (a
296
+ // different title) is untouched.
297
+ const review = enforceFindingsBound(normalizedReview, clampMaxFindings(options.maxFindings));
204
298
  const { inputCoverage, assurance } = core;
205
299
  const unreviewedPaths = [...new Set(core.unreviewedPaths)].sort();
206
- const reviewTotalFiles = executionContext?.totalFiles ?? metadata.totalChangedFiles;
207
- const affectedPaths = new Set(
208
- executionContext?.affectedPaths ??
209
- files.flatMap((file) =>
210
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
211
- ),
212
- );
300
+ const reviewTotalFiles = executionContext.totalFiles;
301
+ const affectedPaths = new Set(executionContext.affectedPaths);
213
302
  const priorState =
214
- executionContext?.mode === "incremental" ? executionContext.priorState : undefined;
303
+ executionContext.mode === "incremental" ? executionContext.priorState : undefined;
215
304
  const carriedCandidates =
216
305
  priorState?.unresolvedFindings
217
306
  .filter((finding) => !affectedPaths.has(finding.path))
218
307
  .map(fromStoredFinding) ?? [];
219
- const activeFindings = rankAndDedupeFindings([...carriedCandidates, ...review.findings]).slice(
220
- 0,
221
- clampMaxFindings(options.maxFindings),
308
+ const eligibleCarriedCandidates = carriedCandidates.filter(
309
+ (finding) => !isAdjudicatedFinding(finding),
222
310
  );
311
+ const activeFindings = rankAndDedupeFindings([
312
+ ...eligibleCarriedCandidates,
313
+ ...review.findings.filter((finding) => !isAdjudicatedFinding(finding)),
314
+ ]).slice(0, clampMaxFindings(options.maxFindings));
223
315
  const activeFindingKeys = new Set(activeFindings.map(findingKey));
224
316
  const currentFindingKeys = new Set(review.findings.map(findingKey));
225
- const carriedFindings = carriedCandidates.filter(
317
+ const carriedFindings = eligibleCarriedCandidates.filter(
226
318
  (finding) =>
227
319
  activeFindingKeys.has(findingKey(finding)) && !currentFindingKeys.has(findingKey(finding)),
228
320
  );
229
- // Non-anchored concerns cannot be mapped safely to one affected path, so
230
- // incremental runs carry them conservatively until the explicit final audit.
231
- const carriedConcernCandidates = priorState?.unresolvedConcerns.map(fromStoredConcern) ?? [];
321
+ // A concern remains active only while every host-validated evidence path
322
+ // is unchanged. Touching or removing any one invalidates the old claim;
323
+ // range selection reopens its remaining current paths for fresh context.
324
+ const carriedConcernCandidates =
325
+ priorState?.unresolvedConcerns
326
+ .filter(
327
+ (concern) =>
328
+ concern.evidencePaths !== undefined &&
329
+ concern.evidencePaths.every((path) => !affectedPaths.has(path)),
330
+ )
331
+ .map(fromStoredConcern) ?? [];
332
+ const eligibleCarriedConcernCandidates = carriedConcernCandidates.filter(
333
+ (concern) => !isAdjudicatedConcern(concern),
334
+ );
232
335
  const activeConcerns = rankAndDedupeConcerns([
233
- ...carriedConcernCandidates,
234
- ...(review.concerns ?? []),
336
+ ...eligibleCarriedConcernCandidates,
337
+ ...(review.concerns ?? []).filter((concern) => !isAdjudicatedConcern(concern)),
235
338
  ]);
236
- const currentConcernKeys = new Set(
237
- (review.concerns ?? []).map((concern) => `${concern.title}\u0000${concern.body}`),
238
- );
239
- const activeConcernKeys = new Set(
240
- activeConcerns.map((concern) => `${concern.title}\u0000${concern.body}`),
241
- );
242
- const carriedConcerns = carriedConcernCandidates.filter((concern) => {
243
- const key = `${concern.title}\u0000${concern.body}`;
339
+ const currentConcernKeys = new Set((review.concerns ?? []).map(reviewConcernKey));
340
+ const activeConcernKeys = new Set(activeConcerns.map(reviewConcernKey));
341
+ const carriedConcerns = eligibleCarriedConcernCandidates.filter((concern) => {
342
+ const key = reviewConcernKey(concern);
244
343
  return activeConcernKeys.has(key) && !currentConcernKeys.has(key);
245
344
  });
246
345
  const settled =
247
346
  inputCoverage.status === "complete" &&
248
347
  assurance.status !== "incomplete" &&
249
348
  unreviewedPaths.length === 0;
349
+ const concernsHaveEvidencePaths = activeConcerns.every(
350
+ (concern) => concern.evidencePaths !== undefined,
351
+ );
250
352
  // The fingerprint marker is standalone skip authority for fingerprint-only
251
353
  // harnesses, so it is embedded only for a fully settled run.
252
- const skipFingerprint = settled ? fingerprint : undefined;
354
+ const skipFingerprint = settled && concernsHaveEvidencePaths ? fingerprint : undefined;
253
355
  const carriedScopeFits = unreviewedPaths.length <= MAX_STORED_UNREVIEWED_PATHS;
254
356
  const stateCandidate =
255
- executionContext !== undefined &&
256
357
  fingerprint !== undefined &&
358
+ executionContext.profileFingerprint !== undefined &&
257
359
  metadata.baseSha !== undefined &&
258
360
  // The fingerprint and stored baseline describe the FULL pull-request
259
361
  // surface; a truncated anchor surface cannot make either claim.
260
362
  anchorFiles.length >= metadata.totalChangedFiles &&
261
363
  carriedScopeFits &&
364
+ concernsHaveEvidencePaths &&
262
365
  executionContext.stateAuthenticator?.status === "available"
263
366
  ? ReviewState.make({
264
- version: 2,
367
+ version: 1,
265
368
  repository: metadata.repository,
266
369
  pullRequestNumber: metadata.number,
267
370
  baseRef: metadata.baseRef,
@@ -269,7 +372,7 @@ const settleReviewRun = (
269
372
  headRef: metadata.headRef,
270
373
  reviewedHeadSha: metadata.headSha,
271
374
  profileFingerprint: executionContext.profileFingerprint,
272
- acceptedScopeFingerprint: fingerprint,
375
+ settledScopeFingerprint: fingerprint,
273
376
  reviewedPathCount: anchorFiles.length,
274
377
  unresolvedFindings: activeFindings.map(toStoredFinding),
275
378
  unresolvedConcerns: activeConcerns.map(toStoredConcern),
@@ -277,17 +380,19 @@ const settleReviewRun = (
277
380
  unreviewedPasses: (core.unreviewedPasses ?? []).slice(0, MAX_STORED_UNREVIEWED_PASSES),
278
381
  settled,
279
382
  lastReviewMode: executionContext.mode,
383
+ ...(adjudications.length === 0 ? {} : { adjudications }),
280
384
  })
281
385
  : undefined;
282
386
  const continuity =
283
- stateCandidate === undefined || executionContext?.stateAuthenticator === undefined
387
+ stateCandidate === undefined || executionContext.stateAuthenticator === undefined
284
388
  ? {
285
389
  state: undefined,
286
390
  marker: undefined,
287
- notice:
288
- executionContext !== undefined && !carriedScopeFits
289
- ? `carried unreviewed scope (${unreviewedPaths.length} paths) exceeded the ${MAX_STORED_UNREVIEWED_PATHS}-path continuity bound`
290
- : executionContext?.stateAuthenticator?.status === "unavailable"
391
+ notice: !carriedScopeFits
392
+ ? `carried unreviewed scope (${unreviewedPaths.length} paths) exceeded the ${MAX_STORED_UNREVIEWED_PATHS}-path continuity bound`
393
+ : !concernsHaveEvidencePaths
394
+ ? "one or more review concerns lacked host-validated affected paths"
395
+ : executionContext.stateAuthenticator?.status === "unavailable"
291
396
  ? (executionContext.stateAuthenticator.unavailableReason ??
292
397
  "authenticated continuity state is unavailable")
293
398
  : undefined,
@@ -320,29 +425,29 @@ const settleReviewRun = (
320
425
  unreviewedPaths,
321
426
  carriedFindings,
322
427
  carriedConcerns,
323
- reviewMode: executionContext?.mode,
324
- reviewReason: executionContext?.reason,
325
- baselineSha: executionContext?.baselineSha,
428
+ reviewMode: executionContext.mode,
429
+ reviewReason: executionContext.reason,
430
+ baselineSha: executionContext.baselineSha,
326
431
  reviewFilesVisible: files.length,
327
432
  reviewTotalFiles,
328
433
  stateMarker: continuity.marker,
329
434
  stateNotice: continuity.notice,
435
+ ...(adjudications.length === 0 ? {} : { adjudications }),
330
436
  });
331
437
  const shared = {
332
438
  review,
333
439
  activeFindings,
334
440
  activeConcerns,
335
- coverage: compatibilityCoverage(inputCoverage, assurance),
336
441
  inputCoverage,
337
442
  assurance,
338
443
  unreviewedPaths,
339
444
  plan,
340
445
  turns: core.turns,
341
446
  ...(usage === undefined ? {} : { usage }),
342
- ...(executionContext === undefined
343
- ? {}
344
- : { reviewMode: executionContext.mode, reviewReason: executionContext.reason }),
447
+ reviewMode: executionContext.mode,
448
+ reviewReason: executionContext.reason,
345
449
  ...(continuity.state === undefined ? {} : { state: continuity.state }),
450
+ ...(adjudications.length === 0 ? {} : { adjudications }),
346
451
  };
347
452
  if (!options.post) return ReviewRunOutcome.make(shared);
348
453
  const publisher = yield* ReviewPublisher;
@@ -379,10 +484,12 @@ export const executeReview = <
379
484
  const metadata = yield* source.metadata;
380
485
  const files = yield* source.changedFiles;
381
486
  const anchorFiles = yield* source.anchorFiles;
382
- const executionContext = Option.getOrUndefined(
383
- yield* Effect.serviceOption(ReviewExecutionContext),
384
- );
385
- const mission = buildReviewMission(metadata, files);
487
+ const executionContext = yield* ReviewExecutionContext;
488
+ const continuity = yield* resolveReviewContinuityContext();
489
+ const mission = buildReviewMission(metadata, files, {
490
+ adjudicated: continuity.adjudications.map(renderAdjudicationContextLine),
491
+ priorFindings: continuity.priorFindingsOnScope.map(renderPriorFindingContextLine),
492
+ });
386
493
  const fullMission = buildReviewMission(metadata, anchorFiles);
387
494
  const fingerprint =
388
495
  options.signature === undefined
@@ -402,7 +509,7 @@ export const executeReview = <
402
509
  const review = yield* Schema.decodeUnknownEffect(CodeReview)(result.output);
403
510
  const assessment = assessFlatReview({
404
511
  files,
405
- totalFiles: executionContext?.totalFiles ?? metadata.totalChangedFiles,
512
+ totalFiles: executionContext.totalFiles,
406
513
  anchorFiles,
407
514
  totalAnchorFiles: metadata.totalChangedFiles,
408
515
  events,
@@ -416,7 +523,7 @@ export const executeReview = <
416
523
  unreviewedPaths: assessment.unreviewedPaths,
417
524
  turns: result.turns,
418
525
  },
419
- { metadata, files, anchorFiles, fingerprint, usage },
526
+ { metadata, files, anchorFiles, fingerprint, usage, adjudications: continuity.adjudications },
420
527
  options,
421
528
  );
422
529
  });
@@ -436,9 +543,7 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
436
543
  const metadata = yield* source.metadata;
437
544
  const files = yield* source.changedFiles;
438
545
  const anchorFiles = yield* source.anchorFiles;
439
- const executionContext = Option.getOrUndefined(
440
- yield* Effect.serviceOption(ReviewExecutionContext),
441
- );
546
+ const executionContext = yield* ReviewExecutionContext;
442
547
  const fullMission = buildReviewMission(metadata, anchorFiles);
443
548
  const fingerprint =
444
549
  options.signature === undefined
@@ -446,14 +551,15 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
446
551
  : yield* computeChangesetFingerprint(anchorFiles, options.signature(fullMission));
447
552
 
448
553
  const budget = yield* makeUsageBudget(options.limits ?? fanOutReviewBudgetLimits);
449
- const totalFiles = executionContext?.totalFiles ?? metadata.totalChangedFiles;
554
+ const totalFiles = executionContext.totalFiles;
555
+ const continuity = yield* resolveReviewContinuityContext();
450
556
  const pipeline = yield* runFanOutReview(binding, {
451
557
  files,
452
558
  anchorFiles,
453
559
  totalChangedFiles: totalFiles,
454
560
  maxFindings: options.maxFindings,
455
561
  budget: toRunBudgetHook(budget),
456
- ...(executionContext !== undefined && executionContext.retryPaths.length > 0
562
+ ...(executionContext.retryPaths.length > 0
457
563
  ? {
458
564
  retry: {
459
565
  paths: executionContext.retryPaths,
@@ -461,6 +567,14 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
461
567
  },
462
568
  }
463
569
  : {}),
570
+ ...(continuity.adjudications.length > 0 || continuity.priorFindingsOnScope.length > 0
571
+ ? {
572
+ priorContext: buildPriorReviewContext(
573
+ continuity.adjudications,
574
+ continuity.priorFindingsOnScope,
575
+ ),
576
+ }
577
+ : {}),
464
578
  });
465
579
  const inputCoverage = fanOutInputCoverage({
466
580
  plan: pipeline.plan,
@@ -486,7 +600,7 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
486
600
  ),
487
601
  turns: pipeline.turns,
488
602
  },
489
- { metadata, files, anchorFiles, fingerprint, usage },
603
+ { metadata, files, anchorFiles, fingerprint, usage, adjudications: continuity.adjudications },
490
604
  options,
491
605
  );
492
606
  });