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

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.
@@ -49,6 +49,21 @@ export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
49
49
  /** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
50
50
  export const MAX_STORED_UNREVIEWED_PATHS = 100;
51
51
 
52
+ /** Failed-pass records stored beside the leftover paths; one per unit stage. */
53
+ export const MAX_STORED_UNREVIEWED_PASSES = 24;
54
+
55
+ /** Stages a leftover path may need retried without a second general discovery. */
56
+ export const UnreviewedStage = Schema.Literals(["discovery", "specialist", "verification"]);
57
+ export type UnreviewedStage = typeof UnreviewedStage.Type;
58
+
59
+ /** One failed fan-out pass whose paths should be retried, not rediscovered. */
60
+ export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
61
+ "@effect-agent/pr-review/StoredUnreviewedPass",
62
+ )({
63
+ stage: UnreviewedStage,
64
+ paths: Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(12)),
65
+ }) {}
66
+
52
67
  /**
53
68
  * Versioned state embedded after EVERY completed run that can be signed. The
54
69
  * head plus full-scope fingerprint forms an incremental baseline; an absent
@@ -75,6 +90,14 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
75
90
  unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
76
91
  /** Retryable review gaps carried into the next incremental run's scope. */
77
92
  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)),
100
+ ),
78
101
  /**
79
102
  * True only when the producing run had complete input coverage, no
80
103
  * unsettled pass, and nothing carried. Skip-unchanged authority: an
@@ -292,6 +315,12 @@ export interface ReviewSelection {
292
315
  readonly files: ReadonlyArray<ChangedFile>;
293
316
  /** Paths whose changes invalidate prior findings, including paths reverted out of the PR. */
294
317
  readonly affectedPaths: ReadonlyArray<string>;
318
+ /**
319
+ * Leftover paths whose contents did not change. Fan-out retries only the
320
+ * recorded failed stages on these paths and keeps their stored findings.
321
+ */
322
+ readonly retryPaths: ReadonlyArray<string>;
323
+ readonly retryStages: ReadonlyArray<UnreviewedStage>;
295
324
  readonly totalFiles: number;
296
325
  readonly baselineSha: string | undefined;
297
326
  readonly priorState: ReviewState | undefined;
@@ -312,12 +341,26 @@ const fullSelection = (input: {
312
341
  affectedPaths: input.files.flatMap((file) =>
313
342
  file.previousPath === undefined ? [file.path] : [file.path, file.previousPath],
314
343
  ),
344
+ retryPaths: [],
345
+ retryStages: [],
315
346
  totalFiles: input.totalFiles,
316
347
  baselineSha: undefined,
317
348
  priorState: undefined,
318
349
  profileFingerprint: input.profileFingerprint,
319
350
  });
320
351
 
352
+ /** Three-dot lineage from the reviewed head to the current head is usable. */
353
+ export const isLineageAncestor = (
354
+ comparison: ReviewHeadComparison,
355
+ priorState: ReviewState,
356
+ currentHeadSha: string,
357
+ ): boolean =>
358
+ comparison.baseSha === priorState.reviewedHeadSha &&
359
+ comparison.headSha === currentHeadSha &&
360
+ comparison.mergeBaseSha === priorState.reviewedHeadSha &&
361
+ !comparison.truncated &&
362
+ (comparison.status === "ahead" || comparison.status === "identical");
363
+
321
364
  /**
322
365
  * Validate that persisted state belongs to this exact PR/base lineage and the
323
366
  * same review profile. A mismatch is a full-review reason, never an error that
@@ -340,6 +383,96 @@ export const validateReviewState = (
340
383
  return undefined;
341
384
  };
342
385
 
386
+ const filePaths = (file: ChangedFile): ReadonlyArray<string> =>
387
+ file.previousPath === undefined ? [file.path] : [file.path, file.previousPath];
388
+
389
+ const incrementalFromDelta = (input: {
390
+ readonly current: PullRequestMetadata;
391
+ readonly fullFiles: ReadonlyArray<ChangedFile>;
392
+ readonly profileFingerprint: string;
393
+ readonly priorState: ReviewState;
394
+ readonly deltaFiles: ReadonlyArray<ChangedFile>;
395
+ readonly extraAffectedPaths?: ReadonlyArray<string> | undefined;
396
+ readonly reason: string;
397
+ }): ReviewSelection => {
398
+ const currentPaths = new Set(input.fullFiles.flatMap(filePaths));
399
+ const affectedPaths = new Set([
400
+ ...input.deltaFiles.flatMap(filePaths),
401
+ ...(input.extraAffectedPaths ?? []),
402
+ ]);
403
+ const selectedByPath = new Map<string, ChangedFile>();
404
+ for (const file of input.deltaFiles) {
405
+ if (
406
+ currentPaths.has(file.path) ||
407
+ (file.previousPath !== undefined && currentPaths.has(file.previousPath))
408
+ ) {
409
+ selectedByPath.set(file.path, file);
410
+ }
411
+ }
412
+ const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
413
+ const surgical = input.priorState.unreviewedPasses !== undefined;
414
+ const retryOnly = new Set<string>();
415
+ const retryStages = new Set<UnreviewedStage>();
416
+ for (const path of carriedPaths) {
417
+ if (affectedPaths.has(path)) continue;
418
+ 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);
425
+ }
426
+ }
427
+ if (
428
+ surgical &&
429
+ retryStages.has("verification") &&
430
+ !retryStages.has("discovery") &&
431
+ !retryStages.has("specialist")
432
+ ) {
433
+ retryStages.add("discovery");
434
+ retryStages.add("specialist");
435
+ }
436
+ if (surgical && retryOnly.size > 0 && retryStages.size === 0) {
437
+ for (const path of retryOnly) affectedPaths.add(path);
438
+ retryStages.add("discovery");
439
+ retryStages.add("specialist");
440
+ retryStages.add("verification");
441
+ }
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
+ }
451
+ }
452
+ const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
453
+ left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
454
+ );
455
+ const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
456
+ const carriedReason =
457
+ leftoverCount > 0 && surgical
458
+ ? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery`
459
+ : carriedPaths.length > 0
460
+ ? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
461
+ : "";
462
+ return {
463
+ mode: "incremental",
464
+ reason: `${input.reason}${carriedReason}`,
465
+ files: selectedFiles,
466
+ affectedPaths: [...affectedPaths].sort(),
467
+ retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
468
+ retryStages: [...retryStages].sort(),
469
+ totalFiles: selectedFiles.length,
470
+ baselineSha: input.priorState.reviewedHeadSha,
471
+ priorState: input.priorState,
472
+ profileFingerprint: input.profileFingerprint,
473
+ };
474
+ };
475
+
343
476
  /** Pure, deterministic range selection with conservative full-review fallbacks. */
344
477
  export const selectReviewRange = (input: {
345
478
  readonly requestedMode: ReviewMode;
@@ -349,6 +482,12 @@ export const selectReviewRange = (input: {
349
482
  readonly priorState: ReviewState | undefined;
350
483
  readonly comparison: ReviewHeadComparison | undefined;
351
484
  readonly baseComparison?: ReviewHeadComparison | undefined;
485
+ /**
486
+ * Two-dot tree comparison used when the reviewed head is not a git ancestor
487
+ * (rebase, amend, force-push). Intersected with the current PR path set so
488
+ * main-drift outside the pull request never re-enters scope.
489
+ */
490
+ readonly contentComparison?: ReviewHeadComparison | undefined;
352
491
  readonly lookupFailure?: string | undefined;
353
492
  }): ReviewSelection => {
354
493
  const full = (reason: string) =>
@@ -366,89 +505,53 @@ export const selectReviewRange = (input: {
366
505
  const invalid = validateReviewState(input.priorState, input.current, input.profileFingerprint);
367
506
  if (invalid !== undefined) return full(invalid);
368
507
  const comparison = input.comparison;
369
- if (comparison === undefined) return full("the incremental head comparison was unavailable");
370
508
  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")
509
+ comparison !== undefined &&
510
+ isLineageAncestor(comparison, input.priorState, input.current.headSha)
375
511
  ) {
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);
512
+ const extraAffected: Array<string> = [];
513
+ let baseReason = "";
514
+ if (input.priorState.baseSha !== input.current.baseSha) {
515
+ const baseComparison = input.baseComparison;
516
+ if (baseComparison === undefined) {
517
+ return full("the pull request base changed and its lineage comparison was unavailable");
518
+ }
519
+ if (
520
+ baseComparison.baseSha !== input.priorState.baseSha ||
521
+ baseComparison.headSha !== input.current.baseSha ||
522
+ baseComparison.mergeBaseSha !== input.priorState.baseSha ||
523
+ (baseComparison.status !== "ahead" && baseComparison.status !== "identical") ||
524
+ baseComparison.truncated
525
+ ) {
526
+ return full("the pull request base changed materially or exceeded the comparison bound");
527
+ }
528
+ for (const file of baseComparison.files) extraAffected.push(...filePaths(file));
529
+ baseReason = `; base advanced from ${input.priorState.baseSha.slice(0, 7)} and overlapping PR paths were included`;
417
530
  }
531
+ return incrementalFromDelta({
532
+ current: input.current,
533
+ fullFiles: input.fullFiles,
534
+ profileFingerprint: input.profileFingerprint,
535
+ priorState: input.priorState,
536
+ deltaFiles: comparison.files,
537
+ extraAffectedPaths: extraAffected,
538
+ reason: `changes since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}${baseReason}`,
539
+ });
418
540
  }
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
- }
541
+ const contentComparison = input.contentComparison;
542
+ if (contentComparison !== undefined && !contentComparison.truncated) {
543
+ return incrementalFromDelta({
544
+ current: input.current,
545
+ fullFiles: input.fullFiles,
546
+ profileFingerprint: input.profileFingerprint,
547
+ priorState: input.priorState,
548
+ deltaFiles: contentComparison.files,
549
+ reason: `rewritten history; contents changed since reviewed head ${input.priorState.reviewedHeadSha.slice(0, 7)}`,
550
+ });
436
551
  }
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
- };
552
+ if (comparison === undefined) return full("the incremental head comparison was unavailable");
553
+ if (comparison.truncated) return full("the incremental comparison exceeded GitHub's file bound");
554
+ return full("the prior reviewed head is not an ancestor of the current head");
452
555
  };
453
556
 
454
557
  /** Per-run context consumed by orchestration and publication, not by the model. */
@@ -506,18 +609,6 @@ export const selectedPullRequestSourceLayer = (
506
609
  }),
507
610
  );
508
611
 
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
612
  /** Build the full-surface mission used only to resolve profile guidance. */
522
613
  export const buildProfileMission = (
523
614
  metadata: PullRequestMetadata,
@@ -32,9 +32,11 @@ import {
32
32
  import {
33
33
  fromStoredConcern,
34
34
  fromStoredFinding,
35
+ MAX_STORED_UNREVIEWED_PASSES,
35
36
  MAX_STORED_UNREVIEWED_PATHS,
36
37
  ReviewExecutionContext,
37
38
  ReviewState,
39
+ StoredUnreviewedPass,
38
40
  toStoredConcern,
39
41
  toStoredFinding,
40
42
  } from "./review-state.ts";
@@ -171,6 +173,7 @@ interface ReviewCore {
171
173
  readonly inputCoverage: ReviewInputCoverage;
172
174
  readonly assurance: ReviewAssurance;
173
175
  readonly unreviewedPaths: ReadonlyArray<string>;
176
+ readonly unreviewedPasses?: ReadonlyArray<StoredUnreviewedPass> | undefined;
174
177
  readonly turns: number;
175
178
  }
176
179
 
@@ -271,6 +274,7 @@ const settleReviewRun = (
271
274
  unresolvedFindings: activeFindings.map(toStoredFinding),
272
275
  unresolvedConcerns: activeConcerns.map(toStoredConcern),
273
276
  unreviewedPaths,
277
+ unreviewedPasses: (core.unreviewedPasses ?? []).slice(0, MAX_STORED_UNREVIEWED_PASSES),
274
278
  settled,
275
279
  lastReviewMode: executionContext.mode,
276
280
  })
@@ -449,6 +453,14 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
449
453
  totalChangedFiles: totalFiles,
450
454
  maxFindings: options.maxFindings,
451
455
  budget: toRunBudgetHook(budget),
456
+ ...(executionContext !== undefined && executionContext.retryPaths.length > 0
457
+ ? {
458
+ retry: {
459
+ paths: executionContext.retryPaths,
460
+ stages: executionContext.retryStages,
461
+ },
462
+ }
463
+ : {}),
452
464
  });
453
465
  const inputCoverage = fanOutInputCoverage({
454
466
  plan: pipeline.plan,
@@ -464,6 +476,14 @@ export const executeFanOutReview = <Provider, ModelProvides, ModelRequires>(
464
476
  inputCoverage,
465
477
  assurance: pipeline.assurance,
466
478
  unreviewedPaths: pipeline.unreviewedPaths,
479
+ unreviewedPasses: pipeline.unreviewedPasses
480
+ .slice(0, MAX_STORED_UNREVIEWED_PASSES)
481
+ .map((pass) =>
482
+ StoredUnreviewedPass.make({
483
+ stage: pass.stage,
484
+ paths: pass.paths.slice(0, 12),
485
+ }),
486
+ ),
467
487
  turns: pipeline.turns,
468
488
  },
469
489
  { metadata, files, anchorFiles, fingerprint, usage },