@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
@@ -37,18 +37,112 @@ export class StoredReviewFinding extends Schema.Class<StoredReviewFinding>(
37
37
  body: StoredText,
38
38
  }) {}
39
39
 
40
- /** A compact unresolved non-anchored concern carried until a final audit. */
40
+ /** A compact unresolved non-anchored concern with its invalidation paths. */
41
41
  export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
42
42
  "@effect-agent/pr-review/StoredReviewConcern",
43
43
  )({
44
+ /** Absent only on legacy state written before concern path binding. */
45
+ evidencePaths: Schema.optionalKey(
46
+ Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)),
47
+ ),
44
48
  severity: FindingSeverity,
45
49
  title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
46
50
  body: StoredText,
47
51
  }) {}
48
52
 
53
+ /** How a maintainer settled a previously raised finding or concern. */
54
+ export const AdjudicationDisposition = Schema.Literals(["accepted-risk", "refuted", "obsolete"]);
55
+ export type AdjudicationDisposition = typeof AdjudicationDisposition.Type;
56
+
57
+ /** The adjudications bound carried by the ReviewState schema. */
58
+ export const MAX_STORED_ADJUDICATIONS = 20;
59
+
60
+ /**
61
+ * One maintainer adjudication of a finding or concern identity. Anchored
62
+ * findings carry their full location identity; unanchored concerns are
63
+ * identified by title alone, so the location fields stay absent.
64
+ */
65
+ const StoredAdjudicationFields = Schema.Struct({
66
+ path: Schema.optionalKey(ChangedPath),
67
+ startLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
68
+ endLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
69
+ title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
70
+ disposition: AdjudicationDisposition,
71
+ reason: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(300))),
72
+ /** GitHub login of the maintainer whose comment adjudicated the identity. */
73
+ actor: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
74
+ }).check(
75
+ Schema.makeFilter(
76
+ (adjudication) => {
77
+ const locationParts = [
78
+ adjudication.path,
79
+ adjudication.startLine,
80
+ adjudication.endLine,
81
+ ].filter((part) => part !== undefined).length;
82
+ return locationParts === 0 || locationParts === 3
83
+ ? undefined
84
+ : "path, startLine, and endLine must be either all present or all absent";
85
+ },
86
+ { title: "adjudication locations are complete or unanchored" },
87
+ ),
88
+ );
89
+
90
+ export class StoredAdjudication extends Schema.Class<StoredAdjudication>(
91
+ "@effect-agent/pr-review/StoredAdjudication",
92
+ )(StoredAdjudicationFields) {}
93
+
94
+ /**
95
+ * The one finding-identity composition shared by retirement, adjudication,
96
+ * and settlement. A tagged JSON tuple keeps anchored findings in a namespace
97
+ * disjoint from title-only concerns and remains unambiguous even when
98
+ * untrusted path or title text contains delimiter characters.
99
+ */
100
+ export const findingIdentity = (finding: {
101
+ readonly path: string;
102
+ readonly startLine: number;
103
+ readonly endLine: number;
104
+ readonly title: string;
105
+ }): string =>
106
+ JSON.stringify(["finding", finding.path, finding.startLine, finding.endLine, finding.title]);
107
+
108
+ /** The disjoint title-only identity namespace for unanchored concerns. */
109
+ export const concernIdentity = (concern: { readonly title: string }): string =>
110
+ JSON.stringify(["concern", concern.title]);
111
+
112
+ /**
113
+ * An adjudication's identity: the shared finding identity when anchored, the
114
+ * disjoint concern identity when unanchored.
115
+ */
116
+ export const adjudicationIdentity = (adjudication: StoredAdjudication): string =>
117
+ adjudication.path !== undefined &&
118
+ adjudication.startLine !== undefined &&
119
+ adjudication.endLine !== undefined
120
+ ? findingIdentity({
121
+ path: adjudication.path,
122
+ startLine: adjudication.startLine,
123
+ endLine: adjudication.endLine,
124
+ title: adjudication.title,
125
+ })
126
+ : concernIdentity(adjudication);
127
+
49
128
  /** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
50
129
  export const MAX_STORED_UNREVIEWED_PATHS = 100;
51
130
 
131
+ /** Failed-pass records stored beside the leftover paths; one per unit stage. */
132
+ export const MAX_STORED_UNREVIEWED_PASSES = 24;
133
+
134
+ /** Stages a leftover path may need retried without a second general discovery. */
135
+ export const UnreviewedStage = Schema.Literals(["discovery", "specialist", "verification"]);
136
+ export type UnreviewedStage = typeof UnreviewedStage.Type;
137
+
138
+ /** One failed fan-out pass whose paths should be retried, not rediscovered. */
139
+ export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
140
+ "@effect-agent/pr-review/StoredUnreviewedPass",
141
+ )({
142
+ stage: UnreviewedStage,
143
+ paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
144
+ }) {}
145
+
52
146
  /**
53
147
  * Versioned state embedded after EVERY completed run that can be signed. The
54
148
  * head plus full-scope fingerprint forms an incremental baseline; an absent
@@ -56,12 +150,11 @@ export const MAX_STORED_UNREVIEWED_PATHS = 100;
56
150
  * carries retryable review gaps (failed passes) forward so the next
57
151
  * incremental run re-reviews exactly them plus the new delta — the baseline
58
152
  * advances monotonically instead of freezing on one flaky pass and reopening
59
- * the whole post-baseline scope. The `acceptedScopeFingerprint` name is
60
- * retained for wire compatibility. Storing hundreds of path strings
61
- * separately would not fit GitHub's bounded review body in the worst case.
153
+ * the whole post-baseline scope. Storing hundreds of path strings separately
154
+ * would not fit GitHub's bounded review body in the worst case.
62
155
  */
63
156
  export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
64
- version: Schema.Literal(2),
157
+ version: Schema.Literal(1),
65
158
  repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
66
159
  pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
67
160
  baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
@@ -69,12 +162,16 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
69
162
  headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
70
163
  reviewedHeadSha: GitCommitSha,
71
164
  profileFingerprint: Fingerprint,
72
- acceptedScopeFingerprint: Fingerprint,
165
+ settledScopeFingerprint: Fingerprint,
73
166
  reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
74
167
  unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
75
168
  unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
76
169
  /** Retryable review gaps carried into the next incremental run's scope. */
77
170
  unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PATHS)),
171
+ /** Which failed pass produced those leftovers. */
172
+ unreviewedPasses: Schema.Array(StoredUnreviewedPass).check(
173
+ Schema.isMaxLength(MAX_STORED_UNREVIEWED_PASSES),
174
+ ),
78
175
  /**
79
176
  * True only when the producing run had complete input coverage, no
80
177
  * unsettled pass, and nothing carried. Skip-unchanged authority: an
@@ -82,6 +179,13 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
82
179
  */
83
180
  settled: Schema.Boolean,
84
181
  lastReviewMode: ReviewScopeMode,
182
+ /**
183
+ * Maintainer adjudications standing against this pull request. optionalKey
184
+ * so state markers signed before the field existed still decode.
185
+ */
186
+ adjudications: Schema.optionalKey(
187
+ Schema.Array(StoredAdjudication).check(Schema.isMaxLength(MAX_STORED_ADJUDICATIONS)),
188
+ ),
85
189
  }) {}
86
190
 
87
191
  export const toStoredFinding = (finding: ReviewFinding): StoredReviewFinding =>
@@ -106,23 +210,29 @@ export const fromStoredFinding = (finding: StoredReviewFinding): ReviewFinding =
106
210
 
107
211
  export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
108
212
  StoredReviewConcern.make({
213
+ ...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
109
214
  severity: concern.severity,
110
215
  title: concern.title,
111
216
  body: concern.body.slice(0, 800),
112
217
  });
113
218
 
114
219
  export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
115
- ReviewConcern.make({ severity: concern.severity, title: concern.title, body: concern.body });
220
+ ReviewConcern.make({
221
+ ...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
222
+ severity: concern.severity,
223
+ title: concern.title,
224
+ body: concern.body,
225
+ });
116
226
 
117
- const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v2:";
227
+ const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
118
228
  const STATE_MARKER_SUFFIX = " -->";
119
229
  const STATE_MARKER_PATTERN =
120
- /(?:^|\n)<!-- effect-agent-pr-review state-v2:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
121
- const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v2\u0000";
230
+ /(?:^|\n)<!-- effect-agent-pr-review state-v1:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
231
+ const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v1\u0000";
122
232
  export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
123
233
  export const ReviewStateMarker = Schema.NonEmptyString.check(
124
234
  Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
125
- Schema.isPattern(/^<!-- effect-agent-pr-review state-v2:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
235
+ Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
126
236
  ).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
127
237
  export type ReviewStateMarker = typeof ReviewStateMarker.Type;
128
238
 
@@ -292,19 +402,26 @@ export interface ReviewSelection {
292
402
  readonly files: ReadonlyArray<ChangedFile>;
293
403
  /** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
294
404
  readonly affectedPaths: ReadonlyArray<string>;
405
+ /**
406
+ * Leftover paths whose contents did not change. Fan-out retries only the
407
+ * recorded failed stages on these paths and keeps their stored findings.
408
+ */
409
+ readonly retryPaths: ReadonlyArray<string>;
410
+ readonly retryStages: ReadonlyArray<UnreviewedStage>;
295
411
  readonly totalFiles: number;
296
412
  readonly baselineSha: string | undefined;
297
413
  readonly priorState: ReviewState | undefined;
298
- readonly profileFingerprint: string;
414
+ /** Absent only for an explicit full review with no continuity profile. */
415
+ readonly profileFingerprint: string | undefined;
299
416
  /** Action-owned authentication capability, constructed at the composition root. */
300
417
  readonly stateAuthenticator?: ReviewStateAuthenticator["Service"] | undefined;
301
418
  }
302
419
 
303
- const fullSelection = (input: {
420
+ export const fullReviewSelection = (input: {
304
421
  readonly reason: string;
305
422
  readonly files: ReadonlyArray<ChangedFile>;
306
423
  readonly totalFiles: number;
307
- readonly profileFingerprint: string;
424
+ readonly profileFingerprint?: string | undefined;
308
425
  }): ReviewSelection => ({
309
426
  mode: "full",
310
427
  reason: input.reason,
@@ -312,12 +429,26 @@ const fullSelection = (input: {
312
429
  affectedPaths: input.files.flatMap((file) =>
313
430
  file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
314
431
  ),
432
+ retryPaths: [],
433
+ retryStages: [],
315
434
  totalFiles: input.totalFiles,
316
435
  baselineSha: undefined,
317
436
  priorState: undefined,
318
437
  profileFingerprint: input.profileFingerprint,
319
438
  });
320
439
 
440
+ /** Three-dot lineage from the reviewed head to the current head is usable. */
441
+ export const isLineageAncestor = (
442
+ comparison: ReviewHeadComparison,
443
+ priorState: ReviewState,
444
+ currentHeadSha: string,
445
+ ): boolean =>
446
+ comparison.baseSha === priorState.reviewedHeadSha &&
447
+ comparison.headSha === currentHeadSha &&
448
+ comparison.mergeBaseSha === priorState.reviewedHeadSha &&
449
+ !comparison.truncated &&
450
+ (comparison.status === "ahead" || comparison.status === "identical");
451
+
321
452
  /**
322
453
  * Validate that persisted state belongs to this exact PR/base lineage and the
323
454
  * same review profile. A mismatch is a full-review reason, never an error that
@@ -337,9 +468,116 @@ export const validateReviewState = (
337
468
  if (state.profileFingerprint !== profileFingerprint) {
338
469
  return "the reviewer profile or model configuration changed";
339
470
  }
471
+ if (state.unresolvedConcerns.some((concern) => concern.evidencePaths === undefined)) {
472
+ return "stored concerns predate affected-path tracking";
473
+ }
340
474
  return undefined;
341
475
  };
342
476
 
477
+ const filePaths = (file: ChangedFile): ReadonlyArray<string> =>
478
+ file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
479
+
480
+ const incrementalFromDelta = (input: {
481
+ readonly current: PullRequestMetadata;
482
+ readonly fullFiles: ReadonlyArray<ChangedFile>;
483
+ readonly profileFingerprint: string;
484
+ readonly priorState: ReviewState;
485
+ readonly deltaFiles: ReadonlyArray<ChangedFile>;
486
+ readonly extraAffectedPaths?: ReadonlyArray<string> | undefined;
487
+ readonly reason: string;
488
+ }): ReviewSelection => {
489
+ const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
490
+ const affectedPaths = new Set([
491
+ ...input.deltaFiles.flatMap(filePaths),
492
+ ...(input.extraAffectedPaths ?? []),
493
+ ]);
494
+ const initialAffectedCount = affectedPaths.size;
495
+ // Reopen every current path needed to reassess a concern touched by this
496
+ // delta. Repeat to a fixed point because two concerns may overlap on a path.
497
+ let expanded = true;
498
+ while (expanded) {
499
+ expanded = false;
500
+ for (const concern of input.priorState.unresolvedConcerns) {
501
+ const paths = concern.evidencePaths ?? [];
502
+ if (!paths.some((path) => affectedPaths.has(path))) continue;
503
+ for (const path of paths) {
504
+ if (!affectedPaths.has(path)) {
505
+ affectedPaths.add(path);
506
+ expanded = true;
507
+ }
508
+ }
509
+ }
510
+ }
511
+ const selectedByPath = new Map<string, ChangedFile>();
512
+ for (const file of input.deltaFiles) {
513
+ if (
514
+ currentPaths.has(file.path) ||
515
+ (file.previousPath !== undefined && currentPaths.has(file.previousPath))
516
+ ) {
517
+ selectedByPath.set(file.path, file);
518
+ }
519
+ }
520
+ const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
521
+ const retryOnly = new Set<string>();
522
+ const retryStages = new Set<UnreviewedStage>();
523
+ for (const path of carriedPaths) {
524
+ if (affectedPaths.has(path)) continue;
525
+ retryOnly.add(path);
526
+ for (const pass of input.priorState.unreviewedPasses) {
527
+ if (pass.paths.includes(path)) retryStages.add(pass.stage);
528
+ }
529
+ }
530
+ if (
531
+ retryStages.has("verification") &&
532
+ !retryStages.has("discovery") &&
533
+ !retryStages.has("specialist")
534
+ ) {
535
+ retryStages.add("discovery");
536
+ retryStages.add("specialist");
537
+ }
538
+ if (retryOnly.size > 0 && retryStages.size === 0) {
539
+ for (const path of retryOnly) affectedPaths.add(path);
540
+ retryStages.add("discovery");
541
+ retryStages.add("specialist");
542
+ retryStages.add("verification");
543
+ }
544
+ for (const file of input.fullFiles) {
545
+ const needed =
546
+ affectedPaths.has(file.path) ||
547
+ (file.previousPath !== undefined && affectedPaths.has(file.previousPath)) ||
548
+ retryOnly.has(file.path) ||
549
+ (file.previousPath !== undefined && retryOnly.has(file.previousPath));
550
+ if (needed) selectedByPath.set(file.path, file);
551
+ }
552
+ const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
553
+ left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
554
+ );
555
+ const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
556
+ const carriedReason =
557
+ leftoverCount > 0
558
+ ? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery`
559
+ : carriedPaths.length > 0
560
+ ? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
561
+ : "";
562
+ const concernPathCount = affectedPaths.size - initialAffectedCount;
563
+ const concernReason =
564
+ concernPathCount === 0
565
+ ? ""
566
+ : `; reopening ${concernPathCount} related concern path(s) for context`;
567
+ return {
568
+ mode: "incremental",
569
+ reason: `${input.reason}${carriedReason}${concernReason}`,
570
+ files: selectedFiles,
571
+ affectedPaths: [...affectedPaths].sort(),
572
+ retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
573
+ retryStages: [...retryStages].sort(),
574
+ totalFiles: selectedFiles.length,
575
+ baselineSha: input.priorState.reviewedHeadSha,
576
+ priorState: input.priorState,
577
+ profileFingerprint: input.profileFingerprint,
578
+ };
579
+ };
580
+
343
581
  /** Pure, deterministic range selection with conservative full-review fallbacks. */
344
582
  export const selectReviewRange = (input: {
345
583
  readonly requestedMode: ReviewMode;
@@ -349,10 +587,16 @@ export const selectReviewRange = (input: {
349
587
  readonly priorState: ReviewState | undefined;
350
588
  readonly comparison: ReviewHeadComparison | undefined;
351
589
  readonly baseComparison?: ReviewHeadComparison | undefined;
590
+ /**
591
+ * Two-dot tree comparison used when the reviewed head is not a git ancestor
592
+ * (rebase, amend, force-push). Intersected with the current PR path set so
593
+ * main-drift outside the pull request never re-enters scope.
594
+ */
595
+ readonly contentComparison?: ReviewHeadComparison | undefined;
352
596
  readonly lookupFailure?: string | undefined;
353
597
  }): ReviewSelection => {
354
598
  const full = (reason: string) =>
355
- fullSelection({
599
+ fullReviewSelection({
356
600
  reason,
357
601
  files: input.fullFiles,
358
602
  totalFiles: input.current.totalChangedFiles,
@@ -366,89 +610,53 @@ export const selectReviewRange = (input: {
366
610
  const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
367
611
  if (invalid !== undefined) return full(invalid);
368
612
  const comparison = input.comparison;
369
- if (comparison === undefined) return full("the incremental head comparison was unavailable");
370
613
  if (
371
- comparison.baseSha !== input.priorState.reviewedHeadSha ||
372
- comparison.headSha !== input.current.headSha ||
373
- comparison.mergeBaseSha !== input.priorState.reviewedHeadSha ||
374
- (comparison.status !== "ahead" && comparison.status !== "identical")
614
+ comparison !== undefined &&
615
+ isLineageAncestor(comparison, input.priorState, input.current.headSha)
375
616
  ) {
376
- return full("the prior reviewed head is not an ancestor of the current head");
377
- }
378
- if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
379
- const affectedPaths = new Set(
380
- comparison.files.flatMap((file) =>
381
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
382
- ),
383
- );
384
- let baseReason = "";
385
- if (input.priorState.baseSha !== input.current.baseSha) {
386
- const baseComparison = input.baseComparison;
387
- if (baseComparison === undefined) {
388
- return full("the pull request base changed and its lineage comparison was unavailable");
389
- }
390
- if (
391
- baseComparison.baseSha !== input.priorState.baseSha ||
392
- baseComparison.headSha !== input.current.baseSha ||
393
- baseComparison.mergeBaseSha !== input.priorState.baseSha ||
394
- (baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
395
- baseComparison.truncated
396
- ) {
397
- return full("the pull request base changed materially or exceeded the comparison bound");
398
- }
399
- for (const file of baseComparison.files) {
400
- affectedPaths.add(file.path);
401
- if (file.previousPath !== undefined) affectedPaths.add(file.previousPath);
402
- }
403
- baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
404
- }
405
- const currentPaths = new Set(
406
- input.fullFiles.flatMap((file) =>
407
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
408
- ),
409
- );
410
- const selectedByPath = new Map<string, ChangedFile>();
411
- for (const file of comparison.files) {
412
- if (
413
- currentPaths.has(file.path) ||
414
- (file.previousPath !== undefined && currentPaths.has(file.previousPath))
415
- ) {
416
- selectedByPath.set(file.path, file);
617
+ const extraAffected: Array<string> = [];
618
+ let baseReason = "";
619
+ if (input.priorState.baseSha !== input.current.baseSha) {
620
+ const baseComparison = input.baseComparison;
621
+ if (baseComparison === undefined) {
622
+ return full("the pull request base changed and its lineage comparison was unavailable");
623
+ }
624
+ if (
625
+ baseComparison.baseSha !== input.priorState.baseSha ||
626
+ baseComparison.headSha !== input.current.baseSha ||
627
+ baseComparison.mergeBaseSha !== input.priorState.baseSha ||
628
+ (baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
629
+ baseComparison.truncated
630
+ ) {
631
+ return full("the pull request base changed materially or exceeded the comparison bound");
632
+ }
633
+ for (const file of baseComparison.files) extraAffected.push(...filePaths(file));
634
+ baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
417
635
  }
636
+ return incrementalFromDelta({
637
+ current: input.current,
638
+ fullFiles: input.fullFiles,
639
+ profileFingerprint: input.profileFingerprint,
640
+ priorState: input.priorState,
641
+ deltaFiles: comparison.files,
642
+ extraAffectedPaths: extraAffected,
643
+ reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
644
+ });
418
645
  }
419
- // Retryable gaps carried by the prior state re-enter this run's scope; a
420
- // carried path reverted out of the pull request has nothing left to review.
421
- const carriedPaths = new Set(
422
- input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path)),
423
- );
424
- for (const path of carriedPaths) affectedPaths.add(path);
425
- const rescuePaths = input.priorState.baseSha !== input.current.baseSha;
426
- if (rescuePaths || carriedPaths.size > 0) {
427
- for (const file of input.fullFiles) {
428
- const affected =
429
- (rescuePaths &&
430
- (affectedPaths.has(file.path) ||
431
- (file.previousPath !== undefined && affectedPaths.has(file.previousPath)))) ||
432
- carriedPaths.has(file.path) ||
433
- (file.previousPath !== undefined && carriedPaths.has(file.previousPath));
434
- if (affected) selectedByPath.set(file.path, file);
435
- }
646
+ const contentComparison = input.contentComparison;
647
+ if (contentComparison !== undefined && !contentComparison.truncated) {
648
+ return incrementalFromDelta({
649
+ current: input.current,
650
+ fullFiles: input.fullFiles,
651
+ profileFingerprint: input.profileFingerprint,
652
+ priorState: input.priorState,
653
+ deltaFiles: contentComparison.files,
654
+ reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`,
655
+ });
436
656
  }
437
- const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
438
- left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
439
- );
440
- const carriedReason =
441
- carriedPaths.size === 0 ? "" : `; retrying ${carriedPaths.size} carried unreviewed path(s)`;
442
- return {
443
- mode: "incremental",
444
- reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}${carriedReason}`,
445
- files: selectedFiles,
446
- affectedPaths: [...affectedPaths].sort(),
447
- totalFiles: selectedFiles.length,
448
- baselineSha: input.priorState.reviewedHeadSha,
449
- priorState: input.priorState,
450
- profileFingerprint: input.profileFingerprint,
451
- };
657
+ if (comparison === undefined) return full("the incremental head comparison was unavailable");
658
+ if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
659
+ return full("the prior reviewed head is not an ancestor of the current head");
452
660
  };
453
661
 
454
662
  /** Per-run context consumed by orchestration and publication, not by the model. */
@@ -457,6 +665,20 @@ export class ReviewExecutionContext extends Context.Service<
457
665
  ReviewSelection
458
666
  >()("@effect-agent/pr-review/ReviewExecutionContext") {}
459
667
 
668
+ /**
669
+ * Explicit direct-run adapter for callers that intentionally review the full
670
+ * source without authenticated incremental continuity.
671
+ */
672
+ export const fullReviewExecutionContextLayer = (reason: string) =>
673
+ Layer.effect(
674
+ ReviewExecutionContext,
675
+ Effect.gen(function* () {
676
+ const source = yield* PullRequestSource;
677
+ const [metadata, files] = yield* Effect.all([source.metadata, source.changedFiles]);
678
+ return fullReviewSelection({ reason, files, totalFiles: metadata.totalChangedFiles });
679
+ }),
680
+ );
681
+
460
682
  /**
461
683
  * Decorate the full source with the selected review range. Full anchor files
462
684
  * remain available to host-side publication validation; model tools see only
@@ -506,18 +728,6 @@ export const selectedPullRequestSourceLayer = (
506
728
  }),
507
729
  );
508
730
 
509
- /** Profile fingerprints are SHA-256 over configuration-only signatures. */
510
- export const computeProfileFingerprint = (signature: string): Effect.Effect<string> =>
511
- Effect.promise(async () => {
512
- const digest = await globalThis.crypto.subtle.digest(
513
- "SHA-256",
514
- new TextEncoder().encode(signature),
515
- );
516
- return Array.from(new Uint8Array(digest))
517
- .map((byte) => byte.toString(16).padStart(2, "0"))
518
- .join("");
519
- });
520
-
521
731
  /** Build the full-surface mission used only to resolve profile guidance. */
522
732
  export const buildProfileMission = (
523
733
  metadata: PullRequestMetadata,
@@ -24,9 +24,6 @@ export const MAX_REVIEW_UNITS = 8;
24
24
  /** A unit never carries more files than this, regardless of their size. */
25
25
  export const MAX_UNIT_FILES = 12;
26
26
 
27
- /** Compatibility export; complete evidence chars now own unit packing. */
28
- export const UNIT_CHANGED_LINE_BUDGET = 800;
29
-
30
27
  /**
31
28
  * Bound the complete model-visible evidence assigned to one child. This is a
32
29
  * character bound rather than a token estimate because it is deterministic,
@@ -45,9 +42,6 @@ export const MAX_UNIT_EVIDENCE_SHARDS = 12;
45
42
  */
46
43
  export const MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS = MAX_REVIEW_UNITS * MAX_UNIT_EVIDENCE_SHARDS;
47
44
 
48
- /** @deprecated Use `MAX_PATCH_CHARS`; this is now the per-shard bound. */
49
- export const MAX_FILE_EVIDENCE_CHARS = MAX_PATCH_CHARS;
50
-
51
45
  /** The merged review never exceeds the `CodeReview` findings bound. */
52
46
  export const MAX_MERGED_FINDINGS = 20;
53
47
 
@@ -468,8 +462,15 @@ export const rankAndDedupeFindings = (
468
462
  };
469
463
 
470
464
  /**
471
- * The concern analogue of `rankAndDedupeFindings`: dedupe by exact content
472
- * keeping the most severe duplicate, rank by severity, and cap at the
465
+ * Stable identity for one concern. The paths are part of the claim: identical
466
+ * prose about two independent files must not collapse into one item.
467
+ */
468
+ export const reviewConcernKey = (concern: ReviewConcern): string =>
469
+ `${(concern.evidencePaths ?? []).join("\u0000")}\u0001${concern.title}\u0000${concern.body}`;
470
+
471
+ /**
472
+ * The concern analogue of `rankAndDedupeFindings`: dedupe by exact scoped
473
+ * content keeping the most severe duplicate, rank by severity, and cap at the
473
474
  * `CodeReview` concerns bound.
474
475
  */
475
476
  export const rankAndDedupeConcerns = (
@@ -477,7 +478,7 @@ export const rankAndDedupeConcerns = (
477
478
  ): ReadonlyArray<ReviewConcern> => {
478
479
  const byContent = new Map<string, ReviewConcern>();
479
480
  for (const concern of concerns) {
480
- const key = `${concern.title}\u0000${concern.body}`;
481
+ const key = reviewConcernKey(concern);
481
482
  const previous = byContent.get(key);
482
483
  if (
483
484
  previous === undefined ||