@effect-agent/pr-review 0.1.0-beta.28 → 0.1.0-beta.30

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 (52) hide show
  1. package/README.md +11 -204
  2. package/dist/index.d.mts +92 -914
  3. package/dist/index.mjs +176 -71
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +3 -18
  6. package/src/index.ts +1 -25
  7. package/src/review.ts +244 -0
  8. package/dist/action.d.mts +0 -215
  9. package/dist/action.mjs +0 -505
  10. package/dist/action.mjs.map +0 -1
  11. package/dist/cli.d.mts +0 -1
  12. package/dist/cli.mjs +0 -106
  13. package/dist/cli.mjs.map +0 -1
  14. package/dist/fan-out-C3yG1cx3.d.mts +0 -1526
  15. package/dist/github-CCuLgyqb.mjs +0 -3437
  16. package/dist/github-CCuLgyqb.mjs.map +0 -1
  17. package/dist/logging-Q4j0oub-.mjs +0 -75
  18. package/dist/logging-Q4j0oub-.mjs.map +0 -1
  19. package/dist/providers-Br9FRn7j.mjs +0 -1349
  20. package/dist/providers-Br9FRn7j.mjs.map +0 -1
  21. package/dist/testing.d.mts +0 -86
  22. package/dist/testing.mjs +0 -184
  23. package/dist/testing.mjs.map +0 -1
  24. package/src/action.ts +0 -906
  25. package/src/cli.ts +0 -235
  26. package/src/internal/action-entry.ts +0 -45
  27. package/src/internal/adjudication.ts +0 -415
  28. package/src/internal/anchors.ts +0 -20
  29. package/src/internal/coverage.ts +0 -357
  30. package/src/internal/diff.ts +0 -193
  31. package/src/internal/effort.ts +0 -86
  32. package/src/internal/factory.ts +0 -357
  33. package/src/internal/fan-out-scripted.ts +0 -77
  34. package/src/internal/fan-out.ts +0 -1148
  35. package/src/internal/fingerprint.ts +0 -89
  36. package/src/internal/fixtures.ts +0 -148
  37. package/src/internal/github-env.ts +0 -164
  38. package/src/internal/github.ts +0 -1218
  39. package/src/internal/ignore.ts +0 -88
  40. package/src/internal/logging.ts +0 -124
  41. package/src/internal/profiles.ts +0 -91
  42. package/src/internal/progress.ts +0 -433
  43. package/src/internal/providers.ts +0 -133
  44. package/src/internal/render.ts +0 -819
  45. package/src/internal/retirement.ts +0 -337
  46. package/src/internal/review-agent.ts +0 -543
  47. package/src/internal/review-state.ts +0 -782
  48. package/src/internal/review-units.ts +0 -493
  49. package/src/internal/run.ts +0 -611
  50. package/src/internal/scripted.ts +0 -108
  51. package/src/internal/source.ts +0 -110
  52. package/src/testing.ts +0 -8
@@ -1,782 +0,0 @@
1
- import { Context, Effect, Encoding, Layer, Option, Redacted, Result, Schema } from "effect";
2
-
3
- import { ChangedFile, ChangedPath } from "./diff.ts";
4
- import { FindingSeverity, ReviewConcern, ReviewFinding, ReviewMission } from "./review-agent.ts";
5
- import { PullRequestSource, ReviewInputViolation, type PullRequestMetadata } from "./source.ts";
6
-
7
- // ---------------------------------------------------------------------------
8
- // Bounded review continuity. The Action is still deployment class E, but every
9
- // completed review can publish authenticated state inside its GitHub review body.
10
- // A later Action run validates the state against the live PR/base lineage and
11
- // uses a head-to-head comparison to select only newly affected scope.
12
- // ---------------------------------------------------------------------------
13
-
14
- export const ReviewMode = Schema.Literals(["incremental", "final"]);
15
- export type ReviewMode = typeof ReviewMode.Type;
16
-
17
- export const ReviewScopeMode = Schema.Literals(["incremental", "full"]);
18
- export type ReviewScopeMode = typeof ReviewScopeMode.Type;
19
-
20
- export const GitCommitSha = Schema.NonEmptyString.check(
21
- Schema.isMaxLength(64),
22
- Schema.isPattern(/^[0-9a-f]{40,64}$/),
23
- );
24
-
25
- const Fingerprint = Schema.String.check(Schema.isPattern(/^[0-9a-f]{64}$/));
26
- const StoredText = Schema.NonEmptyString.check(Schema.isMaxLength(800));
27
-
28
- /** A compact unresolved finding suitable for the bounded review-body marker. */
29
- export class StoredReviewFinding extends Schema.Class<StoredReviewFinding>(
30
- "@effect-agent/pr-review/StoredReviewFinding",
31
- )({
32
- path: ChangedPath,
33
- startLine: Schema.Int.check(Schema.isGreaterThan(0)),
34
- endLine: Schema.Int.check(Schema.isGreaterThan(0)),
35
- severity: FindingSeverity,
36
- title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
37
- body: StoredText,
38
- }) {}
39
-
40
- /** A compact unresolved non-anchored concern with its invalidation paths. */
41
- export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
42
- "@effect-agent/pr-review/StoredReviewConcern",
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
- ),
48
- severity: FindingSeverity,
49
- title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
50
- body: StoredText,
51
- }) {}
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
-
128
- /** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
129
- export const MAX_STORED_UNREVIEWED_PATHS = 100;
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 on the next incremental run. */
135
- export const UnreviewedStage = Schema.Literals(["discovery", "specialist", "verification"]);
136
- export type UnreviewedStage = typeof UnreviewedStage.Type;
137
-
138
- /** One failed fan-out pass whose stage remains attached to its exact paths. */
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
-
146
- /**
147
- * Versioned state embedded after EVERY completed run that can be signed. The
148
- * head plus full-scope fingerprint forms an incremental baseline; an absent
149
- * unresolved item never means the path is defect-free. `unreviewedPaths`
150
- * carries retryable review gaps (failed passes) forward so the next
151
- * incremental run re-reviews exactly them plus the new delta — the baseline
152
- * advances monotonically instead of freezing on one flaky pass and reopening
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.
155
- */
156
- export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
157
- version: Schema.Literal(1),
158
- repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
159
- pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
160
- baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
161
- baseSha: GitCommitSha,
162
- headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
163
- reviewedHeadSha: GitCommitSha,
164
- profileFingerprint: Fingerprint,
165
- settledScopeFingerprint: Fingerprint,
166
- reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
167
- unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
168
- unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
169
- /** Retryable review gaps carried into the next incremental run's scope. */
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
- ),
175
- /**
176
- * True only when the producing run had complete input coverage, no
177
- * unsettled pass, and nothing carried. Skip-unchanged authority: an
178
- * unchanged patch may skip re-review only over a settled state.
179
- */
180
- settled: Schema.Boolean,
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
- ),
189
- }) {}
190
-
191
- export const toStoredFinding = (finding: ReviewFinding): StoredReviewFinding =>
192
- StoredReviewFinding.make({
193
- path: finding.path,
194
- startLine: finding.startLine,
195
- endLine: finding.endLine,
196
- severity: finding.severity,
197
- title: finding.title,
198
- body: finding.body.slice(0, 800),
199
- });
200
-
201
- export const fromStoredFinding = (finding: StoredReviewFinding): ReviewFinding =>
202
- ReviewFinding.make({
203
- path: finding.path,
204
- startLine: finding.startLine,
205
- endLine: finding.endLine,
206
- severity: finding.severity,
207
- title: finding.title,
208
- body: finding.body,
209
- });
210
-
211
- export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
212
- StoredReviewConcern.make({
213
- ...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
214
- severity: concern.severity,
215
- title: concern.title,
216
- body: concern.body.slice(0, 800),
217
- });
218
-
219
- export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
220
- ReviewConcern.make({
221
- ...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
222
- severity: concern.severity,
223
- title: concern.title,
224
- body: concern.body,
225
- });
226
-
227
- const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
228
- const STATE_MARKER_SUFFIX = " -->";
229
- const STATE_MARKER_PATTERN =
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";
232
- export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
233
- export const ReviewStateMarker = Schema.NonEmptyString.check(
234
- Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
235
- Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
236
- ).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
237
- export type ReviewStateMarker = typeof ReviewStateMarker.Type;
238
-
239
- export class ReviewStateAuthenticationFailure extends Schema.TaggedError<ReviewStateAuthenticationFailure>()(
240
- "ReviewStateAuthenticationFailure",
241
- {
242
- operation: Schema.Literals(["sign", "verify"]),
243
- reason: Schema.NonEmptyString.check(Schema.isMaxLength(2_048)),
244
- },
245
- ) {}
246
-
247
- export class ReviewStateMarkerTooLarge extends Schema.TaggedError<ReviewStateMarkerTooLarge>()(
248
- "ReviewStateMarkerTooLarge",
249
- {
250
- observedChars: Schema.Int.check(Schema.isGreaterThan(0)),
251
- maximumChars: Schema.Int.check(Schema.isGreaterThan(0)),
252
- },
253
- ) {}
254
-
255
- export class ReviewStateAuthenticator extends Context.Service<
256
- ReviewStateAuthenticator,
257
- {
258
- readonly status: "available" | "unavailable";
259
- readonly unavailableReason: string | undefined;
260
- readonly render: (
261
- state: ReviewState,
262
- ) => Effect.Effect<
263
- ReviewStateMarker,
264
- ReviewStateAuthenticationFailure | ReviewStateMarkerTooLarge
265
- >;
266
- readonly extract: (
267
- body: string,
268
- ) => Effect.Effect<Option.Option<ReviewState>, ReviewStateAuthenticationFailure>;
269
- }
270
- >()("@effect-agent/pr-review/ReviewStateAuthenticator") {}
271
-
272
- const authenticationFailure = (
273
- operation: "sign" | "verify",
274
- cause: unknown,
275
- ): ReviewStateAuthenticationFailure =>
276
- ReviewStateAuthenticationFailure.make({
277
- operation,
278
- reason: String(cause).slice(0, 2_048),
279
- });
280
-
281
- const hmacKey = (secret: Redacted.Redacted<string>, operation: "sign" | "verify") =>
282
- Effect.tryPromise({
283
- try: () =>
284
- globalThis.crypto.subtle.importKey(
285
- "raw",
286
- new TextEncoder().encode(Redacted.value(secret)),
287
- { name: "HMAC", hash: "SHA-256" },
288
- false,
289
- ["sign", "verify"],
290
- ),
291
- catch: (cause) => authenticationFailure(operation, cause),
292
- });
293
-
294
- const signatureBytes = (signature: string): ArrayBuffer => {
295
- const pairs = signature.match(/../g) ?? [];
296
- const buffer = new ArrayBuffer(pairs.length);
297
- const bytes = new Uint8Array(buffer);
298
- for (let index = 0; index < pairs.length; index += 1) {
299
- bytes[index] = Number.parseInt(pairs[index] ?? "", 16);
300
- }
301
- return buffer;
302
- };
303
-
304
- /** Validated WebCrypto adapter selected at the Action composition root. */
305
- export const webCryptoReviewStateAuthenticatorLayer = (
306
- secret: Redacted.Redacted<string>,
307
- ): Layer.Layer<ReviewStateAuthenticator> =>
308
- Layer.succeed(ReviewStateAuthenticator)(
309
- ReviewStateAuthenticator.of({
310
- status: "available",
311
- unavailableReason: undefined,
312
- render: (state) =>
313
- Effect.gen(function* () {
314
- const json = yield* Schema.encodeUnknownEffect(Schema.fromJsonString(ReviewState))(
315
- state,
316
- ).pipe(Effect.mapError((cause) => authenticationFailure("sign", cause)));
317
- const payload = Encoding.encodeBase64(json);
318
- const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
319
- const key = yield* hmacKey(secret, "sign");
320
- const signature = yield* Effect.tryPromise({
321
- try: () => globalThis.crypto.subtle.sign("HMAC", key, message),
322
- catch: (cause) => authenticationFailure("sign", cause),
323
- });
324
- const hex = Array.from(new Uint8Array(signature))
325
- .map((byte) => byte.toString(16).padStart(2, "0"))
326
- .join("");
327
- const marker = `${STATE_MARKER_PREFIX}${payload}.${hex}${STATE_MARKER_SUFFIX}`;
328
- if (marker.length > MAX_REVIEW_STATE_MARKER_CHARS) {
329
- return yield* ReviewStateMarkerTooLarge.make({
330
- observedChars: marker.length,
331
- maximumChars: MAX_REVIEW_STATE_MARKER_CHARS,
332
- });
333
- }
334
- return yield* Schema.decodeUnknownEffect(ReviewStateMarker)(marker).pipe(
335
- Effect.mapError((cause) => authenticationFailure("sign", cause)),
336
- );
337
- }),
338
- extract: (body) => {
339
- if (body.length > 60_000) return Effect.succeed(Option.none());
340
- const match = STATE_MARKER_PATTERN.exec(body);
341
- const payload = match?.[1];
342
- const signature = match?.[2];
343
- if (payload === undefined || signature === undefined) return Effect.succeed(Option.none());
344
- const marker = `${STATE_MARKER_PREFIX}${payload}.${signature}${STATE_MARKER_SUFFIX}`;
345
- if (!Schema.is(ReviewStateMarker)(marker)) return Effect.succeed(Option.none());
346
- const json = Result.getOrUndefined(Encoding.decodeBase64String(payload));
347
- if (json === undefined) return Effect.succeed(Option.none());
348
- const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(ReviewState))(json);
349
- if (Option.isNone(decoded)) return Effect.succeed(Option.none());
350
- const message = new TextEncoder().encode(`${STATE_SIGNATURE_DOMAIN}${payload}`);
351
- return Effect.gen(function* () {
352
- const key = yield* hmacKey(secret, "verify");
353
- const valid = yield* Effect.tryPromise({
354
- try: () =>
355
- globalThis.crypto.subtle.verify("HMAC", key, signatureBytes(signature), message),
356
- catch: (cause) => authenticationFailure("verify", cause),
357
- });
358
- return valid ? Option.some(decoded.value) : Option.none();
359
- });
360
- },
361
- }),
362
- );
363
-
364
- /** Explicit no-state implementation for hosts without a stable authentication secret. */
365
- export const unavailableReviewStateAuthenticatorLayer = (
366
- reason: string,
367
- ): Layer.Layer<ReviewStateAuthenticator> => {
368
- const safeReason = reason === "" ? "review-state authentication is unavailable" : reason;
369
- return Layer.succeed(ReviewStateAuthenticator)(
370
- ReviewStateAuthenticator.of({
371
- status: "unavailable",
372
- unavailableReason: safeReason.slice(0, 1_000),
373
- render: () =>
374
- Effect.fail(
375
- ReviewStateAuthenticationFailure.make({
376
- operation: "sign",
377
- reason: safeReason.slice(0, 2_048),
378
- }),
379
- ),
380
- extract: () => Effect.succeed(Option.none()),
381
- }),
382
- );
383
- };
384
-
385
- /** The bounded result of GitHub's previous-head...current-head comparison. */
386
- export class ReviewHeadComparison extends Schema.Class<ReviewHeadComparison>(
387
- "@effect-agent/pr-review/ReviewHeadComparison",
388
- )({
389
- status: Schema.Literals(["ahead", "behind", "diverged", "identical"]),
390
- baseSha: GitCommitSha,
391
- headSha: GitCommitSha,
392
- mergeBaseSha: GitCommitSha,
393
- files: Schema.Array(ChangedFile).check(Schema.isMaxLength(300)),
394
- /** GitHub caps compare-file payloads at 300; equality is conservatively truncated. */
395
- truncated: Schema.Boolean,
396
- }) {}
397
-
398
- /**
399
- * Current and previous paths for 300 PR files plus bounded stored continuity
400
- * paths. The live adapter refuses a larger snapshot-comparison request.
401
- */
402
- export const MAX_TREE_COMPARISON_PATHS = 750;
403
-
404
- /** A direct comparison of two complete commit tree snapshots. */
405
- export class ReviewTreeComparison extends Schema.Class<ReviewTreeComparison>(
406
- "@effect-agent/pr-review/ReviewTreeComparison",
407
- )({
408
- baseSha: GitCommitSha,
409
- headSha: GitCommitSha,
410
- changedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_TREE_COMPARISON_PATHS)),
411
- /** True when GitHub returned either recursive tree incompletely. */
412
- truncated: Schema.Boolean,
413
- }) {}
414
-
415
- /** Internal review selection applied as a decorator over the full PR source. */
416
- export interface ReviewSelection {
417
- readonly mode: ReviewScopeMode;
418
- readonly reason: string;
419
- readonly files: ReadonlyArray<ChangedFile>;
420
- /** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
421
- readonly affectedPaths: ReadonlyArray<string>;
422
- /**
423
- * Failed stages attached to the unchanged paths that own them. Verification
424
- * retries reopen discovery for only their paths because candidates are not
425
- * persisted in review state.
426
- */
427
- readonly retryPasses?: ReadonlyArray<StoredUnreviewedPass>;
428
- /** Flattened summaries retained for diagnostics and compatibility. */
429
- readonly retryPaths: ReadonlyArray<string>;
430
- readonly retryStages: ReadonlyArray<UnreviewedStage>;
431
- readonly totalFiles: number;
432
- readonly baselineSha: string | undefined;
433
- readonly priorState: ReviewState | undefined;
434
- /** Absent only for an explicit full review with no continuity profile. */
435
- readonly profileFingerprint: string | undefined;
436
- /** Action-owned authentication capability, constructed at the composition root. */
437
- readonly stateAuthenticator?: ReviewStateAuthenticator["Service"] | undefined;
438
- }
439
-
440
- export const fullReviewSelection = (input: {
441
- readonly reason: string;
442
- readonly files: ReadonlyArray<ChangedFile>;
443
- readonly totalFiles: number;
444
- readonly profileFingerprint?: string | undefined;
445
- }): ReviewSelection => ({
446
- mode: "full",
447
- reason: input.reason,
448
- files: input.files,
449
- affectedPaths: input.files.flatMap((file) =>
450
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
451
- ),
452
- retryPasses: [],
453
- retryPaths: [],
454
- retryStages: [],
455
- totalFiles: input.totalFiles,
456
- baselineSha: undefined,
457
- priorState: undefined,
458
- profileFingerprint: input.profileFingerprint,
459
- });
460
-
461
- /** Three-dot lineage from the reviewed head to the current head is usable. */
462
- export const isLineageAncestor = (
463
- comparison: ReviewHeadComparison,
464
- priorState: ReviewState,
465
- currentHeadSha: string,
466
- ): boolean =>
467
- comparison.baseSha === priorState.reviewedHeadSha &&
468
- comparison.headSha === currentHeadSha &&
469
- comparison.mergeBaseSha === priorState.reviewedHeadSha &&
470
- !comparison.truncated &&
471
- (comparison.status === "ahead" || comparison.status === "identical");
472
-
473
- /**
474
- * Validate that persisted state belongs to this exact PR/base lineage and the
475
- * same review profile. A mismatch is a full-review reason, never an error that
476
- * silently suppresses review work.
477
- */
478
- export const validateReviewState = (
479
- state: ReviewState,
480
- current: PullRequestMetadata,
481
- profileFingerprint: string,
482
- ): string | undefined => {
483
- if (state.repository !== current.repository || state.pullRequestNumber !== current.number) {
484
- return "stored state belongs to a different pull request";
485
- }
486
- if (current.baseSha === undefined) return "the current base commit is unavailable";
487
- if (state.baseRef !== current.baseRef) return "the pull request base ref changed";
488
- if (state.headRef !== current.headRef) return "the pull request head ref changed";
489
- if (state.profileFingerprint !== profileFingerprint) {
490
- return "the reviewer profile or model configuration changed";
491
- }
492
- if (state.unresolvedConcerns.some((concern) => concern.evidencePaths === undefined)) {
493
- return "stored concerns predate affected-path tracking";
494
- }
495
- return undefined;
496
- };
497
-
498
- const filePaths = (file: ChangedFile): ReadonlyArray<string> =>
499
- file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
500
-
501
- const incrementalFromDelta = (input: {
502
- readonly fullFiles: ReadonlyArray<ChangedFile>;
503
- readonly profileFingerprint: string;
504
- readonly priorState: ReviewState;
505
- readonly deltaPaths: ReadonlyArray<string>;
506
- readonly extraAffectedPaths?: ReadonlyArray<string> | undefined;
507
- readonly reason: string;
508
- }): ReviewSelection => {
509
- const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
510
- const affectedPaths = new Set([...input.deltaPaths, ...(input.extraAffectedPaths ?? [])]);
511
- const initialAffectedCount = affectedPaths.size;
512
- // Reopen every current path needed to reassess a concern touched by this
513
- // delta. Repeat to a fixed point because two concerns may overlap on a path.
514
- let expanded = true;
515
- while (expanded) {
516
- expanded = false;
517
- for (const concern of input.priorState.unresolvedConcerns) {
518
- const paths = concern.evidencePaths ?? [];
519
- if (!paths.some((path) => affectedPaths.has(path))) continue;
520
- for (const path of paths) {
521
- if (!affectedPaths.has(path)) {
522
- affectedPaths.add(path);
523
- expanded = true;
524
- }
525
- }
526
- }
527
- }
528
- const selectedByPath = new Map<string, ChangedFile>();
529
- const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
530
- const retryOnly = new Set<string>();
531
- for (const path of carriedPaths) {
532
- if (affectedPaths.has(path)) continue;
533
- retryOnly.add(path);
534
- }
535
-
536
- const retryPathsByStage = new Map<UnreviewedStage, Set<string>>();
537
- const representedRetryPaths = new Set<string>();
538
- for (const pass of input.priorState.unreviewedPasses) {
539
- for (const path of pass.paths) {
540
- if (!retryOnly.has(path)) continue;
541
- const paths = retryPathsByStage.get(pass.stage) ?? new Set<string>();
542
- paths.add(path);
543
- retryPathsByStage.set(pass.stage, paths);
544
- representedRetryPaths.add(path);
545
- }
546
- }
547
- // Some continuity gaps (capacity overflow, partial evidence, legacy state)
548
- // have no failed-pass record. They conservatively re-enter fresh discovery
549
- // instead of inheriting another path's unrelated failed stage.
550
- for (const path of retryOnly) {
551
- if (representedRetryPaths.has(path)) continue;
552
- retryOnly.delete(path);
553
- affectedPaths.add(path);
554
- }
555
- const retryPasses = (["discovery", "specialist", "verification"] as const).flatMap((stage) => {
556
- const paths = [...(retryPathsByStage.get(stage) ?? [])]
557
- .filter((path) => retryOnly.has(path))
558
- .sort();
559
- return Array.from({ length: Math.ceil(paths.length / 12) }, (_, index) =>
560
- StoredUnreviewedPass.make({
561
- stage,
562
- paths: paths.slice(index * 12, (index + 1) * 12),
563
- }),
564
- );
565
- });
566
- const retryPaths = [...retryOnly].sort();
567
- const retryStages = [...new Set(retryPasses.map((pass) => pass.stage))];
568
- for (const file of input.fullFiles) {
569
- const needed =
570
- affectedPaths.has(file.path) ||
571
- (file.previousPath !== undefined && affectedPaths.has(file.previousPath)) ||
572
- retryOnly.has(file.path) ||
573
- (file.previousPath !== undefined && retryOnly.has(file.previousPath));
574
- if (needed) selectedByPath.set(file.path, file);
575
- }
576
- const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
577
- left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
578
- );
579
- const leftoverCount = retryPaths.length;
580
- const carriedReason =
581
- leftoverCount > 0
582
- ? `; retrying ${leftoverCount} unchanged leftover path(s) by recorded failed stage`
583
- : carriedPaths.length > 0
584
- ? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
585
- : "";
586
- const concernPathCount = affectedPaths.size - initialAffectedCount;
587
- const concernReason =
588
- concernPathCount === 0
589
- ? ""
590
- : `; reopening ${concernPathCount} related concern path(s) for context`;
591
- return {
592
- mode: "incremental",
593
- reason: `${input.reason}${carriedReason}${concernReason}`,
594
- files: selectedFiles,
595
- affectedPaths: [...affectedPaths].sort(),
596
- retryPasses,
597
- retryPaths,
598
- retryStages,
599
- totalFiles: selectedFiles.length,
600
- baselineSha: input.priorState.reviewedHeadSha,
601
- priorState: input.priorState,
602
- profileFingerprint: input.profileFingerprint,
603
- };
604
- };
605
-
606
- /** Pure, deterministic range selection with conservative full-review fallbacks. */
607
- export const selectReviewRange = (input: {
608
- readonly requestedMode: ReviewMode;
609
- readonly current: PullRequestMetadata;
610
- readonly fullFiles: ReadonlyArray<ChangedFile>;
611
- readonly profileFingerprint: string;
612
- readonly priorState: ReviewState | undefined;
613
- readonly comparison: ReviewHeadComparison | undefined;
614
- readonly baseComparison?: ReviewHeadComparison | undefined;
615
- /**
616
- * Direct commit-tree snapshot comparison used when the reviewed head is not
617
- * a git ancestor. Selection hydrates these paths from the current PR files.
618
- */
619
- readonly contentComparison?: ReviewTreeComparison | undefined;
620
- /** Why the direct snapshot comparison could not produce complete evidence. */
621
- readonly contentComparisonFailure?: string | undefined;
622
- readonly lookupFailure?: string | undefined;
623
- }): ReviewSelection => {
624
- const full = (reason: string) =>
625
- fullReviewSelection({
626
- reason,
627
- files: input.fullFiles,
628
- totalFiles: input.current.totalChangedFiles,
629
- profileFingerprint: input.profileFingerprint,
630
- });
631
- if (input.requestedMode === "final") return full("explicit final full-diff audit requested");
632
- if (input.lookupFailure !== undefined) {
633
- return full(`stored review state could not be recovered: ${input.lookupFailure}`);
634
- }
635
- if (input.priorState === undefined) return full("no compatible stored review state was found");
636
- const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
637
- if (invalid !== undefined) return full(invalid);
638
- const comparison = input.comparison;
639
- if (
640
- comparison !== undefined &&
641
- isLineageAncestor(comparison, input.priorState, input.current.headSha)
642
- ) {
643
- const extraAffected: Array<string> = [];
644
- let baseReason = "";
645
- if (input.priorState.baseSha !== input.current.baseSha) {
646
- const baseComparison = input.baseComparison;
647
- if (baseComparison === undefined) {
648
- return full("the pull request base changed and its lineage comparison was unavailable");
649
- }
650
- if (
651
- baseComparison.baseSha !== input.priorState.baseSha ||
652
- baseComparison.headSha !== input.current.baseSha ||
653
- baseComparison.mergeBaseSha !== input.priorState.baseSha ||
654
- (baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
655
- baseComparison.truncated
656
- ) {
657
- return full("the pull request base changed materially or exceeded the comparison bound");
658
- }
659
- for (const file of baseComparison.files) extraAffected.push(...filePaths(file));
660
- baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
661
- }
662
- return incrementalFromDelta({
663
- fullFiles: input.fullFiles,
664
- profileFingerprint: input.profileFingerprint,
665
- priorState: input.priorState,
666
- deltaPaths: comparison.files.flatMap(filePaths),
667
- extraAffectedPaths: extraAffected,
668
- reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
669
- });
670
- }
671
- const contentComparison = input.contentComparison;
672
- if (contentComparison !== undefined) {
673
- if (
674
- contentComparison.baseSha !== input.priorState.reviewedHeadSha ||
675
- contentComparison.headSha !== input.current.headSha
676
- ) {
677
- return full("the rewritten-head tree snapshot comparison did not match the requested heads");
678
- }
679
- if (contentComparison.truncated) {
680
- return full("the rewritten-head tree snapshot comparison was truncated");
681
- }
682
- return incrementalFromDelta({
683
- fullFiles: input.fullFiles,
684
- profileFingerprint: input.profileFingerprint,
685
- priorState: input.priorState,
686
- deltaPaths: contentComparison.changedPaths,
687
- reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`,
688
- });
689
- }
690
- if (input.contentComparisonFailure !== undefined) {
691
- return full(
692
- `the rewritten-head tree snapshot comparison failed: ${input.contentComparisonFailure.slice(0, 2_048)}`,
693
- );
694
- }
695
- if (comparison === undefined) return full("the incremental head comparison was unavailable");
696
- if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
697
- return full("the prior reviewed head is not an ancestor of the current head");
698
- };
699
-
700
- /** Per-run context consumed by orchestration and publication, not by the model. */
701
- export class ReviewExecutionContext extends Context.Service<
702
- ReviewExecutionContext,
703
- ReviewSelection
704
- >()("@effect-agent/pr-review/ReviewExecutionContext") {}
705
-
706
- /**
707
- * Explicit direct-run adapter for callers that intentionally review the full
708
- * source without authenticated incremental continuity.
709
- */
710
- export const fullReviewExecutionContextLayer = (reason: string) =>
711
- Layer.effect(
712
- ReviewExecutionContext,
713
- Effect.gen(function* () {
714
- const source = yield* PullRequestSource;
715
- const [metadata, files] = yield* Effect.all([source.metadata, source.changedFiles]);
716
- return fullReviewSelection({ reason, files, totalFiles: metadata.totalChangedFiles });
717
- }),
718
- );
719
-
720
- /**
721
- * Decorate the full source with the selected review range. Full anchor files
722
- * remain available to host-side publication validation; model tools see only
723
- * the selected delta and may read head context only for that delta's paths.
724
- */
725
- export const selectedPullRequestSourceLayer = (
726
- selection: ReviewSelection,
727
- ): Layer.Layer<PullRequestSource, never, PullRequestSource> =>
728
- Layer.effect(PullRequestSource)(
729
- Effect.gen(function* () {
730
- const source = yield* PullRequestSource;
731
- const selectedPaths = new Set(selection.files.map((file) => file.path));
732
- const selectedFiles = source.changedFiles.pipe(
733
- Effect.map((fullFiles) => {
734
- const fullByPath = new Map(fullFiles.map((file) => [file.path, file] as const));
735
- return selection.files.map((file) => {
736
- if (file.patch !== undefined) return file;
737
- const full = fullByPath.get(file.path);
738
- return full === undefined
739
- ? file
740
- : ChangedFile.make({
741
- ...file,
742
- ...(full.reviewBaseContent === undefined
743
- ? {}
744
- : { reviewBaseContent: full.reviewBaseContent }),
745
- ...(full.reviewHeadContent === undefined
746
- ? {}
747
- : { reviewHeadContent: full.reviewHeadContent }),
748
- });
749
- });
750
- }),
751
- );
752
- return PullRequestSource.of({
753
- metadata: source.metadata,
754
- changedFiles: selectedFiles,
755
- anchorFiles: source.anchorFiles,
756
- readFile: (path) =>
757
- selectedPaths.has(path)
758
- ? source.readFile(path)
759
- : Effect.fail(
760
- ReviewInputViolation.make({
761
- input: path,
762
- reason: "Path is outside this incremental review range.",
763
- }),
764
- ),
765
- });
766
- }),
767
- );
768
-
769
- /** Build the full-surface mission used only to resolve profile guidance. */
770
- export const buildProfileMission = (
771
- metadata: PullRequestMetadata,
772
- files: ReadonlyArray<ChangedFile>,
773
- ): ReviewMission =>
774
- ReviewMission.make({
775
- repository: metadata.repository,
776
- number: metadata.number,
777
- title: metadata.title,
778
- body: metadata.body,
779
- baseRef: metadata.baseRef,
780
- headRef: metadata.headRef,
781
- changedFileCount: files.length,
782
- });