@effect-agent/pr-review 0.1.0-beta.22 → 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.
@@ -1,4 +1,4 @@
1
- import { Effect } from "effect";
1
+ import { Crypto, Effect, Encoding } from "effect";
2
2
 
3
3
  import type { ChangedFile } from "./diff.ts";
4
4
 
@@ -36,14 +36,14 @@ export const extractFingerprint = (body: string): string | undefined => {
36
36
  return last;
37
37
  };
38
38
 
39
- /** WebCrypto SHA-256; unavailable crypto is a defect, not an expected failure. */
40
- const sha256Hex = (text: string): Effect.Effect<string> =>
41
- Effect.promise(async () => {
42
- const digest = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
43
- return Array.from(new Uint8Array(digest))
44
- .map((byte) => byte.toString(16).padStart(2, "0"))
45
- .join("");
46
- });
39
+ /** SHA-256 via `Crypto.Crypto`; unavailable crypto is a defect, not an expected failure. */
40
+ const sha256Hex = Effect.fn("sha256Hex")(function* (
41
+ text: string,
42
+ ): Effect.fn.Return<string, never, Crypto.Crypto> {
43
+ const crypto = yield* Crypto.Crypto;
44
+ const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(text)).pipe(Effect.orDie);
45
+ return Encoding.encodeHex(digest);
46
+ });
47
47
 
48
48
  const FIELD = "\u0000";
49
49
  const RECORD = "\u0001";
@@ -80,4 +80,10 @@ const canonicalChangeset = (files: ReadonlyArray<ChangedFile>): string =>
80
80
  export const computeChangesetFingerprint = (
81
81
  files: ReadonlyArray<ChangedFile>,
82
82
  signature: string,
83
- ): Effect.Effect<string> => sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
83
+ ): Effect.Effect<string, never, Crypto.Crypto> =>
84
+ sha256Hex(`${canonicalChangeset(files)}${SECTION}${signature}`);
85
+
86
+ /** Profile fingerprints are SHA-256 over configuration-only signatures. */
87
+ export const computeProfileFingerprint = (
88
+ signature: string,
89
+ ): Effect.Effect<string, never, Crypto.Crypto> => sha256Hex(signature);
@@ -120,6 +120,7 @@ export const staticPriorReviews = (
120
120
  options: {
121
121
  readonly state?: Option.Option<ReviewState> | undefined;
122
122
  readonly comparison?: ReviewHeadComparison | undefined;
123
+ readonly treeComparison?: ReviewHeadComparison | undefined;
123
124
  } = {},
124
125
  ): PriorReviews["Service"] =>
125
126
  PriorReviews.of({
@@ -129,6 +130,10 @@ export const staticPriorReviews = (
129
130
  options.comparison === undefined
130
131
  ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture comparison" }))
131
132
  : Effect.succeed(options.comparison),
133
+ compareTrees: () =>
134
+ options.treeComparison === undefined
135
+ ? Effect.fail(PriorReviewLookupFailure.make({ reason: "no fixture tree comparison" }))
136
+ : Effect.succeed(options.treeComparison),
132
137
  });
133
138
 
134
139
  /** Layer form for consumers whose Effect explicitly requires `PriorReviews`. */
@@ -137,6 +142,7 @@ export const staticPriorReviewsLayer = (
137
142
  options: {
138
143
  readonly state?: Option.Option<ReviewState> | undefined;
139
144
  readonly comparison?: ReviewHeadComparison | undefined;
145
+ readonly treeComparison?: ReviewHeadComparison | undefined;
140
146
  } = {},
141
147
  ): Layer.Layer<PriorReviews> =>
142
148
  Layer.succeed(PriorReviews)(staticPriorReviews(fingerprint, options));
@@ -130,8 +130,9 @@ const GitHubReviewCommentWire = Schema.Struct({
130
130
  node_id: Schema.String,
131
131
  path: Schema.String,
132
132
  body: Schema.String,
133
- line: Schema.NullOr(Schema.Int),
134
- original_line: Schema.NullOr(Schema.Int),
133
+ // Outdated comments omit `line` entirely instead of sending null.
134
+ line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
135
+ original_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
135
136
  start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
136
137
  original_start_line: Schema.optionalKey(Schema.NullOr(Schema.Int)),
137
138
  });
@@ -578,8 +579,11 @@ export const gitHubReviewRetirementHostLayer: Layer.Layer<
578
579
  }).pipe(
579
580
  Effect.map((comments) =>
580
581
  comments.map((comment) => {
581
- const endLine = comment.line ?? comment.original_line;
582
- const startLine = comment.start_line ?? comment.original_start_line ?? endLine;
582
+ const positiveLine = (value: number | null | undefined): number | null =>
583
+ value !== undefined && value !== null && value > 0 ? value : null;
584
+ const endLine = positiveLine(comment.line ?? comment.original_line);
585
+ const startLine =
586
+ positiveLine(comment.start_line ?? comment.original_start_line) ?? endLine;
583
587
  return RetirableReviewComment.make({
584
588
  nodeId: comment.node_id,
585
589
  path: comment.path,
@@ -672,6 +676,15 @@ export class PriorReviews extends Context.Service<
672
676
  baseSha: string,
673
677
  headSha: string,
674
678
  ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
679
+ /**
680
+ * Two-dot tree comparison (`base..head`). Used when the reviewed head is
681
+ * not a git ancestor so a rebase or amend can still name the paths whose
682
+ * blob contents actually changed.
683
+ */
684
+ readonly compareTrees: (
685
+ baseSha: string,
686
+ headSha: string,
687
+ ) => Effect.Effect<ReviewHeadComparison, PriorReviewLookupFailure>;
675
688
  }
676
689
  >()("@effect-agent/pr-review/PriorReviews") {}
677
690
 
@@ -775,12 +788,12 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
775
788
  }
776
789
  return { latestFingerprint: latest, latestState };
777
790
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
778
- const compareHeads = (baseSha: string, headSha: string) =>
791
+ const compareCommits = (baseSha: string, headSha: string, separator: "..." | "..") =>
779
792
  Effect.gen(function* () {
780
793
  const response = yield* HttpClient.execute(
781
794
  withCommonHeaders(
782
795
  HttpClientRequest.get(
783
- `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}...${encodeURIComponent(headSha)}`,
796
+ `${target.apiUrl}/repos/${target.repository}/compare/${encodeURIComponent(baseSha)}${separator}${encodeURIComponent(headSha)}`,
784
797
  ).pipe(HttpClientRequest.acceptJson),
785
798
  target.token,
786
799
  ),
@@ -803,6 +816,21 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
803
816
  truncated: files.length >= MAX_CHANGED_FILES,
804
817
  });
805
818
  }).pipe(Effect.provideService(HttpClient.HttpClient, client));
819
+ const compareTrees = (baseSha: string, headSha: string) =>
820
+ compareCommits(baseSha, headSha, "..").pipe(
821
+ Effect.map((comparison) =>
822
+ ReviewHeadComparison.make({
823
+ // This is a content snapshot, not a lineage claim. Selection
824
+ // intersects these files with the current PR path set.
825
+ status: comparison.status === "identical" ? "identical" : "ahead",
826
+ baseSha,
827
+ headSha,
828
+ mergeBaseSha: baseSha,
829
+ files: comparison.files,
830
+ truncated: comparison.truncated,
831
+ }),
832
+ ),
833
+ );
806
834
  return PriorReviews.of({
807
835
  latestFingerprint: readMarkers(Option.none()).pipe(
808
836
  Effect.map((markers) => markers.latestFingerprint),
@@ -813,7 +841,8 @@ export const gitHubPriorReviewsLayer: Layer.Layer<
813
841
  Effect.map((markers) => markers.latestState),
814
842
  );
815
843
  }),
816
- compareHeads,
844
+ compareHeads: (baseSha, headSha) => compareCommits(baseSha, headSha, "..."),
845
+ compareTrees,
817
846
  });
818
847
  }),
819
848
  );
@@ -38,18 +38,18 @@ export const pullRequestReviewerProfile = PullRequestReviewerProfile.make({
38
38
  export class FanOutReviewerProfile extends Schema.Class<FanOutReviewerProfile>(
39
39
  "@effect-agent/pr-review/FanOutReviewerProfile",
40
40
  )({
41
- /** Ephemeral runtime: one bounded AgentRuntime.run per invocation. */
41
+ /** Ephemeral runtime: bounded host-scheduled child runs per invocation. */
42
42
  deploymentClass: Schema.Literal("E"),
43
- /** Every model-callable tool parent and child is a read; none mutate. */
43
+ /** Every model-callable surface is evidence-only; children expose no tools. */
44
44
  readOnlyToolSurface: Schema.Literal(true),
45
45
  /** The review is posted by the host AFTER the run settles, never by a tool. */
46
46
  publicationOutsideAgentLoop: Schema.Literal(true),
47
47
  /** Child findings are untrusted; anchors are validated against the parsed diff. */
48
48
  anchorsValidatedBeforePublication: Schema.Literal(true),
49
- /** S1 attached ephemeral delegation at depth 1; nested delegation is rejected. */
50
- attachedEphemeralDelegation: Schema.Literal(true),
51
- /** A failed unit surfaces to the coordinator as a typed failed result, never retried. */
52
- failedUnitsReportedNotRetried: Schema.Literal(true),
49
+ /** Host code schedules every pass from the deterministic plan; no coordinator model. */
50
+ hostScheduledPasses: Schema.Literal(true),
51
+ /** A failed pass is retried once, then reported and carried as unreviewed scope. */
52
+ failedPassesRetriedOnceThenCarried: Schema.Literal(true),
53
53
  /** Risk categories and required specialist passes are pure host policy. */
54
54
  hostOwnedRiskClassification: Schema.Literal(true),
55
55
  /** Every host-classified high-risk unit receives a fresh specialist pass. */
@@ -69,8 +69,8 @@ export const fanOutReviewerProfile = FanOutReviewerProfile.make({
69
69
  readOnlyToolSurface: true,
70
70
  publicationOutsideAgentLoop: true,
71
71
  anchorsValidatedBeforePublication: true,
72
- attachedEphemeralDelegation: true,
73
- failedUnitsReportedNotRetried: true,
72
+ hostScheduledPasses: true,
73
+ failedPassesRetriedOnceThenCarried: true,
74
74
  hostOwnedRiskClassification: true,
75
75
  redundantHighRiskDiscovery: true,
76
76
  independentCandidateVerification: true,
@@ -2,7 +2,7 @@ import { Schema } from "effect";
2
2
 
3
3
  import { anchorViolation } from "./anchors.ts";
4
4
  export { anchorViolation } from "./anchors.ts";
5
- import type { ReviewAssurance, ReviewCoverage, ReviewInputCoverage } from "./coverage.ts";
5
+ import type { ReviewAssurance, ReviewInputCoverage } from "./coverage.ts";
6
6
  import type { ChangedFile } from "./diff.ts";
7
7
  import { renderFingerprintMarker } from "./fingerprint.ts";
8
8
  import {
@@ -215,27 +215,27 @@ const renderVerdictCallout = (
215
215
  options: {
216
216
  readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
217
217
  readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
218
- readonly coverage?: ReviewCoverage | undefined;
219
218
  readonly inputCoverage?: ReviewInputCoverage | undefined;
220
219
  readonly assurance?: ReviewAssurance | undefined;
220
+ readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
221
221
  },
222
222
  ): string => {
223
223
  const counts = severityCounts(review, options.carriedFindings, options.carriedConcerns);
224
+ // Code findings outrank machinery gaps: a blocking finding is the
225
+ // actionable signal, and unsettled reviewer-side work is carried forward.
226
+ if (counts.blocking > 0) {
227
+ return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} — do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
228
+ }
229
+ const carried = options.unreviewedPaths?.length ?? 0;
224
230
  if (
225
231
  options.inputCoverage?.status === "incomplete" ||
226
- (options.inputCoverage === undefined && options.coverage?.status === "incomplete")
232
+ options.assurance?.status === "incomplete"
227
233
  ) {
228
- const suffix =
229
- counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
230
- return `> [!CAUTION]\n> Input coverage is incomplete the check must not pass.${suffix}`;
231
- }
232
- if (options.assurance !== undefined && options.assurance.status !== "settled") {
233
- const suffix =
234
- counts.blocking > 0 ? ` It also has ${countNoun(counts.blocking, "blocking finding")}.` : "";
235
- return `> [!CAUTION]\n> Configured review assurance did not settle — the check must not pass.${suffix}`;
236
- }
237
- if (counts.blocking > 0) {
238
- return `> [!CAUTION]\n> ${countNoun(counts.blocking, "blocking finding")} — do not merge before addressing ${counts.blocking === 1 ? "it" : "them"}.`;
234
+ const carriedNote =
235
+ carried > 0
236
+ ? ` ${countNoun(carried, "affected path")} ${carried === 1 ? "is" : "are"} carried forward and retried automatically on the next run.`
237
+ : "";
238
+ return `> [!WARNING]\n> Review infrastructure did not settle — a reviewer-side gap, NOT a request to change code.${carriedNote} The check reports "incomplete" until a run settles.`;
239
239
  }
240
240
  if (counts.important > 0) {
241
241
  return `> [!IMPORTANT]\n> ${countNoun(counts.important, "important finding")} to address before merging.`;
@@ -393,22 +393,20 @@ export const planPublication = (
393
393
  readonly modelLabel?: string | undefined;
394
394
  /** Workflow-run URL rendered into the footer. */
395
395
  readonly runUrl?: string | undefined;
396
- /** Observed run usage rendered into the footer. */
396
+ /** Observed whole-run usage rendered into the footer. */
397
397
  readonly usage?: { readonly inputTokens: number; readonly outputTokens: number } | undefined;
398
- /** What the usage observed: the whole run, or the coordinator only. */
399
- readonly usageScope?: "run" | "coordinator" | undefined;
400
398
  /**
401
399
  * Changeset fingerprint embedded invisibly in the review body so a later
402
400
  * run can skip re-reviewing an unchanged changeset.
403
401
  */
404
402
  readonly fingerprint?: string | undefined;
405
- /** Host-owned coverage; incomplete coverage is rendered and fails the check. */
406
- readonly coverage?: ReviewCoverage | undefined;
407
403
  /** Host-owned path/evidence assignment, separate from review assurance. */
408
404
  readonly inputCoverage?: ReviewInputCoverage | undefined;
409
405
  /** Host-owned discovery/specialist/verification settlement. */
410
406
  readonly assurance?: ReviewAssurance | undefined;
411
- /** Unchanged unresolved items carried from the prior settled assurance baseline. */
407
+ /** Retryable scope this run could not settle; carried to the next run. */
408
+ readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
409
+ /** Unchanged unresolved items carried from the prior reviewed baseline. */
412
410
  readonly carriedFindings?: ReadonlyArray<ReviewFinding> | undefined;
413
411
  readonly carriedConcerns?: ReadonlyArray<ReviewConcern> | undefined;
414
412
  /** Selected review scope, made visible whenever orchestration chose it. */
@@ -452,14 +450,8 @@ export const planPublication = (
452
450
 
453
451
  const footerParts = ["Automated review by @effect-agent/pr-review"];
454
452
  if (options.modelLabel !== undefined) footerParts.push(options.modelLabel);
455
- // Usage renders only under an EXPLICIT scope: this planner cannot know
456
- // whether a budget snapshot observed the whole run or only a fan-out
457
- // coordinator, and omitting the number is honest where mislabeling is not.
458
- if (options.usage !== undefined && options.usageScope !== undefined) {
459
- const scope = options.usageScope === "coordinator" ? " (coordinator)" : "";
460
- footerParts.push(
461
- `${options.usage.inputTokens} in / ${options.usage.outputTokens} out tokens${scope}`,
462
- );
453
+ if (options.usage !== undefined) {
454
+ footerParts.push(`${options.usage.inputTokens} in / ${options.usage.outputTokens} out tokens`);
463
455
  }
464
456
  if (options.runUrl !== undefined) footerParts.push(`[run](${options.runUrl})`);
465
457
  footerParts.push(`reviewed at ${options.headSha.slice(0, 7)}`);
@@ -490,9 +482,9 @@ export const planPublication = (
490
482
  renderVerdictCallout(review, {
491
483
  carriedFindings,
492
484
  carriedConcerns,
493
- coverage: options.coverage,
494
485
  inputCoverage: options.inputCoverage,
495
486
  assurance: options.assurance,
487
+ unreviewedPaths: options.unreviewedPaths,
496
488
  }),
497
489
  ];
498
490
  if (options.reviewMode !== undefined && options.reviewReason !== undefined) {
@@ -513,7 +505,7 @@ export const planPublication = (
513
505
  if (options.inputCoverage !== undefined && options.assurance !== undefined) {
514
506
  parts.push(
515
507
  "",
516
- `**Input coverage:** ${options.inputCoverage.status} (${options.inputCoverage.assignedPaths.length}/${options.inputCoverage.requiredPaths.length} paths assigned, ${options.inputCoverage.partialPaths.length} partial) · **Review assurance:** ${options.assurance.status} (${options.assurance.completedGeneralDiscoveryPasses}/${options.assurance.requiredGeneralDiscoveryPasses} general discovery, ${options.assurance.completedSpecialistPasses}/${options.assurance.requiredSpecialistPasses} specialist, ${options.assurance.completedVerificationPasses}/${options.assurance.requiredVerificationPasses} verification; ${options.assurance.confirmedCandidates} confirmed / ${options.assurance.rejectedCandidates} rejected / ${options.assurance.unsettledCandidates} unsettled candidates)`,
508
+ `**Input coverage:** ${options.inputCoverage.status} (${options.inputCoverage.assignedPaths.length}/${options.inputCoverage.requiredPaths.length} paths assigned, ${options.inputCoverage.partialPaths.length} partial) · **Review assurance:** ${options.assurance.status} (${options.assurance.completedGeneralDiscoveryPasses}/${options.assurance.requiredGeneralDiscoveryPasses} general discovery, ${options.assurance.completedSpecialistPasses}/${options.assurance.requiredSpecialistPasses} specialist, ${options.assurance.completedVerificationPasses}/${options.assurance.requiredVerificationPasses} verification; ${options.assurance.confirmedCandidates} confirmed / ${options.assurance.rejectedCandidates} rejected / ${options.assurance.unsettledCandidates} unsettled${options.assurance.discardedInvalidFindings > 0 ? ` / ${options.assurance.discardedInvalidFindings} discarded` : ""} candidates)`,
517
509
  );
518
510
  }
519
511
  parts.push("", review.summary);
@@ -525,22 +517,17 @@ export const planPublication = (
525
517
  if (options.inputCoverage?.status === "incomplete") {
526
518
  parts.push(
527
519
  "",
528
- "### 🛑 Incomplete input coverage",
520
+ "### ⚠️ Incomplete input coverage",
529
521
  "",
530
522
  ...options.inputCoverage.reasons.map((reason) => `- ${reason}`),
531
523
  );
532
- } else if (options.inputCoverage === undefined && options.coverage?.status === "incomplete") {
533
- parts.push(
534
- "",
535
- "### 🛑 Incomplete coverage",
536
- "",
537
- ...options.coverage.reasons.map((reason) => `- ${reason}`),
538
- );
539
524
  }
540
- if (options.assurance !== undefined && options.assurance.status !== "settled") {
525
+ if (options.assurance?.status === "incomplete") {
541
526
  parts.push(
542
527
  "",
543
- "### 🛑 Incomplete review assurance",
528
+ "### ⚠️ Unsettled review passes",
529
+ "",
530
+ "The passes below failed on the reviewer's side after a bounded retry. Their paths are carried forward and re-reviewed automatically on the next run — do not change code to satisfy this section.",
544
531
  "",
545
532
  ...options.assurance.reasons.map((reason) => `- ${reason}`),
546
533
  );
@@ -613,14 +600,16 @@ export const planPublication = (
613
600
  options.carriedFindings ?? [],
614
601
  options.carriedConcerns ?? [],
615
602
  );
603
+ // Machinery gaps (incomplete input or unsettled passes) block APPROVE but
604
+ // never REQUEST_CHANGES: requesting changes for a reviewer-side fault would
605
+ // tell the author to edit code nobody reviewed.
606
+ const unclean =
607
+ options.inputCoverage?.status === "incomplete" || options.assurance?.status === "incomplete";
616
608
  const event: ReviewEvent = !options.applyVerdict
617
609
  ? "COMMENT"
618
- : options.inputCoverage?.status === "incomplete" ||
619
- (options.inputCoverage === undefined && options.coverage?.status === "incomplete") ||
620
- (options.assurance !== undefined && options.assurance.status !== "settled") ||
621
- counts.blocking > 0
610
+ : counts.blocking > 0
622
611
  ? "REQUEST_CHANGES"
623
- : review.verdict === "approve" && counts.important === 0
612
+ : review.verdict === "approve" && counts.important === 0 && !unclean
624
613
  ? "APPROVE"
625
614
  : "COMMENT";
626
615
 
@@ -102,8 +102,10 @@ const findingIdentity = (finding: {
102
102
 
103
103
  const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
104
104
  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.
105
107
  const STATE_PATTERN =
106
- /<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
108
+ /<!-- effect-agent-pr-review state-v\d+:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
107
109
  const RETIRED_ORIGINAL_PATTERN =
108
110
  /<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
109
111
  const MACHINE_COMMENT_PATTERN = new RegExp(