@effect-agent/pr-review 0.1.0-beta.24 → 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.
@@ -1,6 +1,8 @@
1
1
  import { Context, DateTime, Effect, Option, Schema } from "effect";
2
2
 
3
3
  import {
4
+ adjudicationIdentity,
5
+ findingIdentity,
4
6
  ReviewStateAuthenticator,
5
7
  type ReviewState,
6
8
  type StoredReviewFinding,
@@ -92,20 +94,10 @@ export interface ReviewRetirementDecision {
92
94
  readonly priorFindingCount: number;
93
95
  }
94
96
 
95
- const findingIdentity = (finding: {
96
- readonly path: string;
97
- readonly startLine: number;
98
- readonly endLine: number;
99
- readonly title: string;
100
- }): string =>
101
- `${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.title}`;
102
-
103
97
  const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
104
98
  const FINGERPRINT_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:[0-9a-f]{64} -->/g;
105
- // Version-agnostic on purpose: retirement only RECOGNIZES machine markers to
106
- // keep them byte-identical through an edit; it never authenticates them.
107
99
  const STATE_PATTERN =
108
- /<!-- effect-agent-pr-review state-v\d+:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
100
+ /<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
109
101
  const RETIRED_ORIGINAL_PATTERN =
110
102
  /<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
111
103
  const MACHINE_COMMENT_PATTERN = new RegExp(
@@ -114,11 +106,12 @@ const MACHINE_COMMENT_PATTERN = new RegExp(
114
106
  );
115
107
  const VERDICT_CALLOUT_PATTERN =
116
108
  /^(?:> \[!(?:CAUTION|IMPORTANT)\]\n> [^\n]*(?:\n> [^\n]*)*|> (?:ℹ️|✅)[^\n]*)\n*/;
117
- // Accepts both the pre-category first line (`**[⚠️ important] Title**`) and
118
- // the current one carrying an optional category chip (`… · security]`), so
119
- // retirement keeps matching inline comments posted by older package versions.
120
- const INLINE_FINDING_TITLE_PATTERN =
121
- /^\*\*\[(?:🛑 blocking|⚠️ important|💅 nit)(?: · [a-z-]+)?\] ([^\n]+)\*\*$/;
109
+ /**
110
+ * The first line of every inline finding comment this package posts. Shared
111
+ * with adjudication so both parse the identical title shape.
112
+ */
113
+ export const INLINE_FINDING_TITLE_PATTERN =
114
+ /^\*\*\[(?:🛑 blocking|⚠️ important|💅 nit) · [a-z-]+\] ([^\n]+)\*\*$/;
122
115
  const MAX_REVIEW_BODY_CHARS = 60_000;
123
116
 
124
117
  /** The host-authored metadata marker is the authority gate for any edit. */
@@ -191,8 +184,14 @@ export const decideReviewRetirement = (input: {
191
184
  readonly currentReviewUrl: string;
192
185
  }): ReviewRetirementDecision => {
193
186
  const current = new Set(input.currentState.unresolvedFindings.map(findingIdentity));
187
+ // Adjudicated identities are distinct from resolved: a maintainer verdict
188
+ // is not a fix, so retirement neither strikes nor minimizes them.
189
+ const adjudicated = new Set(
190
+ (input.currentState.adjudications ?? []).map((entry) => adjudicationIdentity(entry)),
191
+ );
194
192
  const resolvedFindings = input.priorState.unresolvedFindings.filter(
195
- (finding) => !current.has(findingIdentity(finding)),
193
+ (finding) =>
194
+ !current.has(findingIdentity(finding)) && !adjudicated.has(findingIdentity(finding)),
196
195
  );
197
196
  return {
198
197
  body: renderRetiredBody({ ...input, resolvedFindings }),
@@ -300,6 +300,11 @@ export const ReviewToolkitLayer = ReviewToolkit.toLayer({
300
300
  // Mission input and review output contracts.
301
301
  // ---------------------------------------------------------------------------
302
302
 
303
+ /** Bounded prior-review context lines injected into reviewer instructions. */
304
+ const ReviewContextLines = Schema.Array(Schema.String.check(Schema.isMaxLength(1_200))).check(
305
+ Schema.isMaxLength(20),
306
+ );
307
+
303
308
  export class ReviewMission extends Schema.Class<ReviewMission>(
304
309
  "@effect-agent/pr-review/ReviewMission",
305
310
  )({
@@ -310,6 +315,18 @@ export class ReviewMission extends Schema.Class<ReviewMission>(
310
315
  baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
311
316
  headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
312
317
  changedFileCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
318
+ /**
319
+ * Maintainer-adjudicated identities rendered as bounded context lines; the
320
+ * reviewer must not re-raise them without materially new evidence. Absent
321
+ * from fingerprint missions so an adjudication never invalidates the
322
+ * skip-unchanged authority.
323
+ */
324
+ adjudicatedContext: Schema.optionalKey(ReviewContextLines),
325
+ /**
326
+ * Prior-round findings on re-reviewed scope, rendered as bounded context
327
+ * lines; each must be confirmed, declared fixed, or explicitly withdrawn.
328
+ */
329
+ priorFindingContext: Schema.optionalKey(ReviewContextLines),
313
330
  }) {}
314
331
 
315
332
  export const FindingSeverity = Schema.Literals(["blocking", "important", "nit"]);
@@ -361,12 +378,18 @@ export type ReviewVerdict = typeof ReviewVerdict.Type;
361
378
  * A concern with no diff line to anchor to: a missing deletion or cleanup,
362
379
  * rollout or migration sequencing, a coverage gap the diff implies but does
363
380
  * not add, or a scope question only the author can answer. Rendered as a
364
- * review-body section never as an inline comment, so it needs no anchor and
365
- * is never demoted.
381
+ * review-body section instead of an inline comment. `evidencePaths` binds the
382
+ * concern to changed files so a later incremental review can invalidate and
383
+ * recheck it when any supporting path changes. It remains optional only for
384
+ * decoding review output and continuity state written before path binding was
385
+ * introduced; a pathless concern cannot authorize incremental continuity.
366
386
  */
367
387
  export class ReviewConcern extends Schema.Class<ReviewConcern>(
368
388
  "@effect-agent/pr-review/ReviewConcern",
369
389
  )({
390
+ evidencePaths: Schema.optionalKey(
391
+ Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)),
392
+ ),
370
393
  severity: FindingSeverity,
371
394
  title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
372
395
  body: Schema.NonEmptyString.check(Schema.isMaxLength(2_000)),
@@ -448,6 +471,18 @@ export const makeReviewInstructions =
448
471
  ? `Author description:\n${mission.body}`
449
472
  : "The author provided no description.",
450
473
  ...resolveGuidance(options.guidance, mission),
474
+ ...(mission.adjudicatedContext === undefined || mission.adjudicatedContext.length === 0
475
+ ? []
476
+ : [
477
+ "A maintainer has adjudicated these previously raised review items (disposition, reason). Do not re-raise them unless you have materially new evidence, and if you do, say explicitly what changed since the adjudication:",
478
+ ...mission.adjudicatedContext.map((line) => `- ${line}`),
479
+ ]),
480
+ ...(mission.priorFindingContext === undefined || mission.priorFindingContext.length === 0
481
+ ? []
482
+ : [
483
+ "Your previous review raised these findings on the scope you are re-reviewing. For each, either confirm it still holds, state that it is fixed, or withdraw it; do not demand the opposite of your own prior guidance without explicitly acknowledging the reversal:",
484
+ ...mission.priorFindingContext.map((line) => `- ${line}`),
485
+ ]),
451
486
  "Work in this order:",
452
487
  "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
488
  "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.",
@@ -456,9 +491,9 @@ export const makeReviewInstructions =
456
491
  "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.",
457
492
  "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.",
458
493
  "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.",
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.',
494
+ '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. Every concern must list 1-3 exact changed evidencePaths that support it so later incremental reviews can recheck it when those files change. Report none when none exist, and never split one root concern into differently worded restatements.',
460
495
  `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.`,
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>}.',
496
+ '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: [{"evidencePaths": <array of 1-3 exact changed file paths>, "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>}.',
462
497
  `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.`,
463
498
  '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.',
464
499
  ].join("\n");
@@ -37,15 +37,94 @@ 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
 
@@ -71,12 +150,11 @@ export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
71
150
  * carries retryable review gaps (failed passes) forward so the next
72
151
  * incremental run re-reviews exactly them plus the new delta — the baseline
73
152
  * advances monotonically instead of freezing on one flaky pass and reopening
74
- * the whole post-baseline scope. The `acceptedScopeFingerprint` name is
75
- * retained for wire compatibility. Storing hundreds of path strings
76
- * 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.
77
155
  */
78
156
  export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
79
- version: Schema.Literal(2),
157
+ version: Schema.Literal(1),
80
158
  repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
81
159
  pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
82
160
  baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
@@ -84,19 +162,15 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
84
162
  headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
85
163
  reviewedHeadSha: GitCommitSha,
86
164
  profileFingerprint: Fingerprint,
87
- acceptedScopeFingerprint: Fingerprint,
165
+ settledScopeFingerprint: Fingerprint,
88
166
  reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
89
167
  unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
90
168
  unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
91
169
  /** Retryable review gaps carried into the next incremental run's scope. */
92
170
  unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PATHS)),
93
- /**
94
- * Which failed pass produced those leftovers. Absent on state-v2 markers
95
- * written before this field existed; those leftovers still re-enter scope
96
- * but cannot skip rediscovery. Present (including empty) on new markers.
97
- */
98
- unreviewedPasses: Schema.optionalKey(
99
- Schema.Array(StoredUnreviewedPass).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PASSES)),
171
+ /** Which failed pass produced those leftovers. */
172
+ unreviewedPasses: Schema.Array(StoredUnreviewedPass).check(
173
+ Schema.isMaxLength(MAX_STORED_UNREVIEWED_PASSES),
100
174
  ),
101
175
  /**
102
176
  * True only when the producing run had complete input coverage, no
@@ -105,6 +179,13 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
105
179
  */
106
180
  settled: Schema.Boolean,
107
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
+ ),
108
189
  }) {}
109
190
 
110
191
  export const toStoredFinding = (finding: ReviewFinding): StoredReviewFinding =>
@@ -129,23 +210,29 @@ export const fromStoredFinding = (finding: StoredReviewFinding): ReviewFinding =
129
210
 
130
211
  export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
131
212
  StoredReviewConcern.make({
213
+ ...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
132
214
  severity: concern.severity,
133
215
  title: concern.title,
134
216
  body: concern.body.slice(0, 800),
135
217
  });
136
218
 
137
219
  export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
138
- 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
+ });
139
226
 
140
- const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v2:";
227
+ const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
141
228
  const STATE_MARKER_SUFFIX = " -->";
142
229
  const STATE_MARKER_PATTERN =
143
- /(?:^|\n)<!-- effect-agent-pr-review state-v2:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
144
- 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";
145
232
  export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
146
233
  export const ReviewStateMarker = Schema.NonEmptyString.check(
147
234
  Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
148
- 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} -->$/),
149
236
  ).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
150
237
  export type ReviewStateMarker = typeof ReviewStateMarker.Type;
151
238
 
@@ -324,16 +411,17 @@ export interface ReviewSelection {
324
411
  readonly totalFiles: number;
325
412
  readonly baselineSha: string | undefined;
326
413
  readonly priorState: ReviewState | undefined;
327
- readonly profileFingerprint: string;
414
+ /** Absent only for an explicit full review with no continuity profile. */
415
+ readonly profileFingerprint: string | undefined;
328
416
  /** Action-owned authentication capability, constructed at the composition root. */
329
417
  readonly stateAuthenticator?: ReviewStateAuthenticator["Service"] | undefined;
330
418
  }
331
419
 
332
- const fullSelection = (input: {
420
+ export const fullReviewSelection = (input: {
333
421
  readonly reason: string;
334
422
  readonly files: ReadonlyArray<ChangedFile>;
335
423
  readonly totalFiles: number;
336
- readonly profileFingerprint: string;
424
+ readonly profileFingerprint?: string | undefined;
337
425
  }): ReviewSelection => ({
338
426
  mode: "full",
339
427
  reason: input.reason,
@@ -380,6 +468,9 @@ export const validateReviewState = (
380
468
  if (state.profileFingerprint !== profileFingerprint) {
381
469
  return "the reviewer profile or model configuration changed";
382
470
  }
471
+ if (state.unresolvedConcerns.some((concern) => concern.evidencePaths === undefined)) {
472
+ return "stored concerns predate affected-path tracking";
473
+ }
383
474
  return undefined;
384
475
  };
385
476
 
@@ -400,6 +491,23 @@ const incrementalFromDelta = (input: {
400
491
  ...input.deltaFiles.flatMap(filePaths),
401
492
  ...(input.extraAffectedPaths ?? []),
402
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
+ }
403
511
  const selectedByPath = new Map<string, ChangedFile>();
404
512
  for (const file of input.deltaFiles) {
405
513
  if (
@@ -410,22 +518,16 @@ const incrementalFromDelta = (input: {
410
518
  }
411
519
  }
412
520
  const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
413
- const surgical = input.priorState.unreviewedPasses !== undefined;
414
521
  const retryOnly = new Set<string>();
415
522
  const retryStages = new Set<UnreviewedStage>();
416
523
  for (const path of carriedPaths) {
417
524
  if (affectedPaths.has(path)) continue;
418
525
  retryOnly.add(path);
419
- if (surgical) {
420
- for (const pass of input.priorState.unreviewedPasses ?? []) {
421
- if (pass.paths.includes(path)) retryStages.add(pass.stage);
422
- }
423
- } else {
424
- affectedPaths.add(path);
526
+ for (const pass of input.priorState.unreviewedPasses) {
527
+ if (pass.paths.includes(path)) retryStages.add(pass.stage);
425
528
  }
426
529
  }
427
530
  if (
428
- surgical &&
429
531
  retryStages.has("verification") &&
430
532
  !retryStages.has("discovery") &&
431
533
  !retryStages.has("specialist")
@@ -433,35 +535,38 @@ const incrementalFromDelta = (input: {
433
535
  retryStages.add("discovery");
434
536
  retryStages.add("specialist");
435
537
  }
436
- if (surgical && retryOnly.size > 0 && retryStages.size === 0) {
538
+ if (retryOnly.size > 0 && retryStages.size === 0) {
437
539
  for (const path of retryOnly) affectedPaths.add(path);
438
540
  retryStages.add("discovery");
439
541
  retryStages.add("specialist");
440
542
  retryStages.add("verification");
441
543
  }
442
- if (carriedPaths.length > 0 || (input.extraAffectedPaths?.length ?? 0) > 0) {
443
- for (const file of input.fullFiles) {
444
- const needed =
445
- affectedPaths.has(file.path) ||
446
- (file.previousPath !== undefined && affectedPaths.has(file.previousPath)) ||
447
- retryOnly.has(file.path) ||
448
- (file.previousPath !== undefined && retryOnly.has(file.previousPath));
449
- if (needed) selectedByPath.set(file.path, file);
450
- }
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);
451
551
  }
452
552
  const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
453
553
  left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
454
554
  );
455
555
  const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
456
556
  const carriedReason =
457
- leftoverCount > 0 && surgical
557
+ leftoverCount > 0
458
558
  ? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery`
459
559
  : carriedPaths.length > 0
460
560
  ? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
461
561
  : "";
562
+ const concernPathCount = affectedPaths.size - initialAffectedCount;
563
+ const concernReason =
564
+ concernPathCount === 0
565
+ ? ""
566
+ : `; reopening ${concernPathCount} related concern path(s) for context`;
462
567
  return {
463
568
  mode: "incremental",
464
- reason: `${input.reason}${carriedReason}`,
569
+ reason: `${input.reason}${carriedReason}${concernReason}`,
465
570
  files: selectedFiles,
466
571
  affectedPaths: [...affectedPaths].sort(),
467
572
  retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
@@ -491,7 +596,7 @@ export const selectReviewRange = (input: {
491
596
  readonly lookupFailure?: string | undefined;
492
597
  }): ReviewSelection => {
493
598
  const full = (reason: string) =>
494
- fullSelection({
599
+ fullReviewSelection({
495
600
  reason,
496
601
  files: input.fullFiles,
497
602
  totalFiles: input.current.totalChangedFiles,
@@ -560,6 +665,20 @@ export class ReviewExecutionContext extends Context.Service<
560
665
  ReviewSelection
561
666
  >()("@effect-agent/pr-review/ReviewExecutionContext") {}
562
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
+
563
682
  /**
564
683
  * Decorate the full source with the selected review range. Full anchor files
565
684
  * remain available to host-side publication validation; model tools see only
@@ -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 ||