@effect-agent/pr-review 0.1.0-beta.11 → 0.1.0-beta.13

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.
@@ -28,6 +28,7 @@ import {
28
28
  /** One fixture file: its changeset entry plus optional head content. */
29
29
  export class FixtureFile extends Schema.Class<FixtureFile>("@effect-agent/pr-review/FixtureFile")({
30
30
  file: ChangedFile,
31
+ baseContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
31
32
  headContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(MAX_FILE_CHARS))),
32
33
  }) {}
33
34
 
@@ -57,12 +58,21 @@ const requireChanged = (
57
58
  /** Deterministic `PullRequestSource` over one fixture pull request. */
58
59
  export const fixturePullRequestSourceLayer = (
59
60
  fixture: FixturePullRequest,
60
- ): Layer.Layer<PullRequestSource> =>
61
- Layer.succeed(PullRequestSource)(
61
+ ): Layer.Layer<PullRequestSource> => {
62
+ const files = fixture.files.map((entry) =>
63
+ entry.file.patch !== undefined
64
+ ? entry.file
65
+ : ChangedFile.make({
66
+ ...entry.file,
67
+ ...(entry.baseContent === undefined ? {} : { reviewBaseContent: entry.baseContent }),
68
+ ...(entry.headContent === undefined ? {} : { reviewHeadContent: entry.headContent }),
69
+ }),
70
+ );
71
+ return Layer.succeed(PullRequestSource)(
62
72
  PullRequestSource.of({
63
73
  metadata: Effect.succeed(fixture.metadata),
64
- changedFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),
65
- anchorFiles: Effect.succeed(fixture.files.map((entry) => entry.file)),
74
+ changedFiles: Effect.succeed(files),
75
+ anchorFiles: Effect.succeed(files),
66
76
  readFile: (path) =>
67
77
  Effect.gen(function* () {
68
78
  const relative = yield* normalizeRepoRelativePath(path);
@@ -77,6 +87,7 @@ export const fixturePullRequestSourceLayer = (
77
87
  }),
78
88
  }),
79
89
  );
90
+ };
80
91
 
81
92
  /** In-memory publisher: records every plan and mints a deterministic receipt. */
82
93
  export const collectingReviewPublisherLayer = (
@@ -313,21 +313,13 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
313
313
  const metadata = yield* Effect.cached(
314
314
  fetchMetadata.pipe(Effect.provideService(HttpClient.HttpClient, client)),
315
315
  );
316
- const changedFiles = yield* Effect.cached(
316
+ const rawFiles = yield* Effect.cached(
317
317
  fetchFiles.pipe(Effect.provideService(HttpClient.HttpClient, client)),
318
318
  );
319
319
 
320
- const readFile = (path: string) =>
320
+ const readRepositoryFile = (path: string, ref: string) =>
321
321
  Effect.gen(function* () {
322
322
  const relative = yield* normalizeRepoRelativePath(path);
323
- const files = yield* changedFiles;
324
- if (!files.some((file) => file.path === relative)) {
325
- return yield* ReviewInputViolation.make({
326
- input: relative,
327
- reason: "Path is not part of this pull request's changeset.",
328
- });
329
- }
330
- const head = yield* metadata;
331
323
  const encodedPath = relative.split("/").map(encodeURIComponent).join("/");
332
324
  const response = yield* executeOk(
333
325
  "readFile",
@@ -336,12 +328,32 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
336
328
  `${target.apiUrl}/repos/${target.repository}/contents/${encodedPath}`,
337
329
  ).pipe(
338
330
  HttpClientRequest.accept("application/vnd.github.raw+json"),
339
- HttpClientRequest.setUrlParams({ ref: head.headSha }),
331
+ HttpClientRequest.setUrlParams({ ref }),
340
332
  ),
341
333
  target.token,
342
334
  ),
343
335
  ).pipe(Effect.provideService(HttpClient.HttpClient, client));
344
- const text = yield* response.text.pipe(Effect.mapError(failWith("readFile")));
336
+ const buffer = yield* response.arrayBuffer.pipe(Effect.mapError(failWith("readFile")));
337
+ if (buffer.byteLength > MAX_FILE_CHARS) {
338
+ return yield* ReviewInputViolation.make({
339
+ input: relative,
340
+ reason: `File is larger than the ${MAX_FILE_CHARS}-byte read bound.`,
341
+ });
342
+ }
343
+ const text = yield* Effect.try({
344
+ try: () => new TextDecoder("utf-8", { fatal: true }).decode(buffer),
345
+ catch: () =>
346
+ ReviewInputViolation.make({
347
+ input: relative,
348
+ reason: "File is not valid UTF-8 text.",
349
+ }),
350
+ });
351
+ if (text.includes("\u0000")) {
352
+ return yield* ReviewInputViolation.make({
353
+ input: relative,
354
+ reason: "File contains binary NUL bytes.",
355
+ });
356
+ }
345
357
  if (text.length > MAX_FILE_CHARS) {
346
358
  return yield* ReviewInputViolation.make({
347
359
  input: relative,
@@ -351,6 +363,55 @@ export const gitHubPullRequestSourceLayer: Layer.Layer<
351
363
  return text;
352
364
  });
353
365
 
366
+ const changedFiles = yield* Effect.cached(
367
+ Effect.gen(function* () {
368
+ const [files, pullRequest] = yield* Effect.all([rawFiles, metadata]);
369
+ return yield* Effect.forEach(
370
+ files,
371
+ (file) => {
372
+ if (file.patch !== undefined) return Effect.succeed(file);
373
+ const basePath = file.previousPath ?? file.path;
374
+ const base =
375
+ file.status === "added"
376
+ ? Effect.succeed(Option.none<string>())
377
+ : readRepositoryFile(basePath, pullRequest.baseSha ?? pullRequest.baseRef).pipe(
378
+ Effect.option,
379
+ );
380
+ const head =
381
+ file.status === "removed"
382
+ ? Effect.succeed(Option.none<string>())
383
+ : readRepositoryFile(file.path, pullRequest.headSha).pipe(Effect.option);
384
+ return Effect.all({ base, head }).pipe(
385
+ Effect.map(({ base, head }) =>
386
+ ChangedFile.make({
387
+ ...file,
388
+ ...(Option.isSome(base) ? { reviewBaseContent: base.value } : {}),
389
+ ...(Option.isSome(head) ? { reviewHeadContent: head.value } : {}),
390
+ }),
391
+ ),
392
+ );
393
+ },
394
+ { concurrency: 4 },
395
+ );
396
+ }),
397
+ );
398
+
399
+ const readFile = (path: string) =>
400
+ Effect.gen(function* () {
401
+ const relative = yield* normalizeRepoRelativePath(path);
402
+ const files = yield* changedFiles;
403
+ const file = files.find((candidate) => candidate.path === relative);
404
+ if (file === undefined) {
405
+ return yield* ReviewInputViolation.make({
406
+ input: relative,
407
+ reason: "Path is not part of this pull request's changeset.",
408
+ });
409
+ }
410
+ if (file.reviewHeadContent !== undefined) return file.reviewHeadContent;
411
+ const head = yield* metadata;
412
+ return yield* readRepositoryFile(relative, head.headSha);
413
+ });
414
+
354
415
  return PullRequestSource.of({ metadata, changedFiles, anchorFiles: changedFiles, readFile });
355
416
  }),
356
417
  );
@@ -192,7 +192,7 @@ export const anchorViolation = (
192
192
  ): string | undefined => {
193
193
  const file = files.find((candidate) => candidate.path === finding.path);
194
194
  if (file === undefined) return "path is not part of the changeset";
195
- if (file.patch === undefined) return "file has no textual diff";
195
+ if (file.patch === undefined) return "file has no anchorable textual diff";
196
196
  if (finding.endLine < finding.startLine) return "endLine precedes startLine";
197
197
  if (finding.endLine - finding.startLine + 1 > 100) return "range is implausibly large";
198
198
  const anchors = commentableLines(file.patch);
@@ -1,8 +1,14 @@
1
1
  import { Effect, Schema } from "effect";
2
- import { Agent, AgentPolicy, ToolExecutionClass } from "effect-agent";
2
+ import { Agent, AgentPolicy, ToolExecutionClass, ToolResultBounds } from "effect-agent";
3
3
  import { Tool, Toolkit } from "effect/unstable/ai";
4
4
 
5
- import { annotatePatch, ChangedFileStatus, ChangedPath } from "./diff.ts";
5
+ import {
6
+ annotatePatch,
7
+ ChangedFileStatus,
8
+ ChangedPath,
9
+ hasReviewableContent,
10
+ renderReviewContent,
11
+ } from "./diff.ts";
6
12
  import {
7
13
  normalizeRepoRelativePath,
8
14
  PullRequestSource,
@@ -27,6 +33,9 @@ export const MAX_CONCERNS = 10;
27
33
  /** Annotated patches larger than this are truncated with an explicit marker. */
28
34
  const MAX_PATCH_CHARS = 60_000;
29
35
 
36
+ /** The encoded Tool result must retain one complete bounded content fallback. */
37
+ export const REVIEW_TOOL_RESULT_MAX_BYTES = 2 * 1024 * 1024;
38
+
30
39
  /** One `read_file` slice never exceeds this many lines. */
31
40
  const MAX_SLICE_LINES = 1_000;
32
41
  const DEFAULT_SLICE_LINES = 400;
@@ -43,6 +52,8 @@ export class ChangedFileSummary extends Schema.Class<ChangedFileSummary>(
43
52
  additions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
44
53
  deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
45
54
  hasTextualDiff: Schema.Boolean,
55
+ /** True when a missing patch was recovered as bounded UTF-8 base/head content. */
56
+ hasReviewableContent: Schema.Boolean,
46
57
  }) {}
47
58
 
48
59
  export class ChangedFilesView extends Schema.Class<ChangedFilesView>(
@@ -82,10 +93,13 @@ export class FileDiffView extends Schema.Class<FileDiffView>(
82
93
  )({
83
94
  path: ChangedPath,
84
95
  status: ChangedFileStatus,
96
+ reviewMode: Schema.Literals(["diff", "content", "unavailable"]),
85
97
  /**
86
98
  * The unified diff with explicit RIGHT-side line numbers: `R<n>` marks a
87
99
  * line present in the new file version (only those may anchor findings);
88
- * `-` marks removed lines. Empty when no textual diff exists.
100
+ * `-` marks removed lines. For content fallback, `B<n>` and `H<n>`
101
+ * identify base/head lines for reading only; they are never valid anchors.
102
+ * Empty only when neither a patch nor bounded textual content exists.
89
103
  */
90
104
  annotatedPatch: Schema.String,
91
105
  truncated: Schema.Boolean,
@@ -98,7 +112,7 @@ export class FileDiffView extends Schema.Class<FileDiffView>(
98
112
  // security (the run stays bounded by AgentPolicy regardless).
99
113
  export const ReadFileDiff = Tool.make("read_file_diff", {
100
114
  description:
101
- "Read the annotated unified diff of one changed file. Lines marked R<number> exist in the new version and are the only valid finding anchors.",
115
+ "Read one changed file's review evidence. A normal unified diff marks valid anchors as R<number>. When GitHub omitted the diff, bounded base/head content is returned with B/H line labels for review but no valid inline anchors.",
102
116
  parameters: FileDiffQuery,
103
117
  success: FileDiffView,
104
118
  failure: Schema.Union([PullRequestSourceFailure, ReviewInputViolation]),
@@ -158,6 +172,7 @@ export const listChangedFilesHandler = (_query: ListChangedFilesQuery) =>
158
172
  additions: file.additions,
159
173
  deletions: file.deletions,
160
174
  hasTextualDiff: file.patch !== undefined,
175
+ hasReviewableContent: hasReviewableContent(file),
161
176
  }),
162
177
  ),
163
178
  });
@@ -179,11 +194,20 @@ export const readFileDiffHandler = (query: FileDiffQuery) =>
179
194
  reason: "Path is not part of this pull request's changeset.",
180
195
  });
181
196
  }
182
- const annotated = file.patch === undefined ? "" : annotatePatch(file.patch);
183
- const truncated = annotated.length > MAX_PATCH_CHARS;
197
+ const contentEvidence = renderReviewContent(file);
198
+ const reviewMode =
199
+ file.patch !== undefined
200
+ ? ("diff" as const)
201
+ : contentEvidence !== undefined
202
+ ? ("content" as const)
203
+ : ("unavailable" as const);
204
+ const annotated =
205
+ file.patch === undefined ? (contentEvidence ?? "") : annotatePatch(file.patch);
206
+ const truncated = reviewMode === "diff" && annotated.length > MAX_PATCH_CHARS;
184
207
  return FileDiffView.make({
185
208
  path: file.path,
186
209
  status: file.status,
210
+ reviewMode,
187
211
  annotatedPatch: truncated
188
212
  ? `${annotated.slice(0, MAX_PATCH_CHARS)}\n[diff truncated]`
189
213
  : annotated,
@@ -337,7 +361,7 @@ export const makeReviewInstructions =
337
361
  ...resolveGuidance(options.guidance, mission),
338
362
  "Work in this order:",
339
363
  "1. Call list_changed_files once to see the changeset.",
340
- "2. Call read_file_diff for every file you review. In its output, only lines marked R<number> exist in the new version; those numbers are the only valid values for startLine and endLine. Never anchor a finding to a removed (-) line.",
364
+ "2. Call read_file_diff for every file you review. 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.",
341
365
  "3. Call read_file when you need surrounding context the diff does not show. ONLY files in the changeset are readable: a request for any other path (an import, a neighbor, a config) returns a failed result — do not retry it; reason from the diff instead and note the gap honestly in your summary when it matters.",
342
366
  "4. Review for real defects first: correctness, security, concurrency, resource leaks, error handling, API misuse. Style nits are least important. Do not praise; do not restate the diff.",
343
367
  "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.",
@@ -363,6 +387,7 @@ export const defaultReviewPolicy = AgentPolicy.make({
363
387
  // Keep enough output/summary headroom for the 200k-class provider window;
364
388
  // tool-heavy histories prune before the engine spends a summarization call.
365
389
  contextTokenLimit: 150_000,
390
+ toolResultBounds: ToolResultBounds.make({ maxBytes: REVIEW_TOOL_RESULT_MAX_BYTES }),
366
391
  // Budget soft landing (RUN-018): an exhausted reviewer returns its partial
367
392
  // review on one final tool-free turn instead of failing the whole run.
368
393
  onExhaustion: "final-answer",
@@ -443,9 +443,29 @@ export const selectedPullRequestSourceLayer = (
443
443
  Effect.gen(function* () {
444
444
  const source = yield* PullRequestSource;
445
445
  const selectedPaths = new Set(selection.files.map((file) => file.path));
446
+ const selectedFiles = source.changedFiles.pipe(
447
+ Effect.map((fullFiles) => {
448
+ const fullByPath = new Map(fullFiles.map((file) => [file.path, file] as const));
449
+ return selection.files.map((file) => {
450
+ if (file.patch !== undefined) return file;
451
+ const full = fullByPath.get(file.path);
452
+ return full === undefined
453
+ ? file
454
+ : ChangedFile.make({
455
+ ...file,
456
+ ...(full.reviewBaseContent === undefined
457
+ ? {}
458
+ : { reviewBaseContent: full.reviewBaseContent }),
459
+ ...(full.reviewHeadContent === undefined
460
+ ? {}
461
+ : { reviewHeadContent: full.reviewHeadContent }),
462
+ });
463
+ });
464
+ }),
465
+ );
446
466
  return PullRequestSource.of({
447
467
  metadata: source.metadata,
448
- changedFiles: Effect.succeed(selection.files),
468
+ changedFiles: selectedFiles,
449
469
  anchorFiles: source.anchorFiles,
450
470
  readFile: (path) =>
451
471
  selectedPaths.has(path)
@@ -1,7 +1,7 @@
1
1
  import { Schema } from "effect";
2
2
 
3
3
  import type { ChangedFile } from "./diff.ts";
4
- import { ChangedPath } from "./diff.ts";
4
+ import { ChangedPath, isReviewableFile } from "./diff.ts";
5
5
  import type { FindingSeverity } from "./review-agent.ts";
6
6
  import { ReviewFinding } from "./review-agent.ts";
7
7
 
@@ -48,7 +48,7 @@ export class ReviewUnitPlan extends Schema.Class<ReviewUnitPlan>(
48
48
  /** True when the source returned fewer files than the pull request has. */
49
49
  truncated: Schema.Boolean,
50
50
  units: Schema.Array(ReviewUnit).check(Schema.isMaxLength(MAX_REVIEW_UNITS)),
51
- /** Changed files without a textual diff; no finding can anchor to them. */
51
+ /** Changed files with neither a textual diff nor bounded base/head text. */
52
52
  undiffablePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
53
53
  /**
54
54
  * Diffable files beyond the fan-out capacity (MAX_REVIEW_UNITS units of
@@ -58,8 +58,12 @@ export class ReviewUnitPlan extends Schema.Class<ReviewUnitPlan>(
58
58
  unassignedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
59
59
  }) {}
60
60
 
61
- const fileCost = (file: ChangedFile): number =>
62
- file.additions + file.deletions + FILE_OVERHEAD_LINES;
61
+ const fileCost = (file: ChangedFile): number => {
62
+ const contentChars =
63
+ (file.reviewBaseContent?.length ?? 0) + (file.reviewHeadContent?.length ?? 0);
64
+ const contentWeight = Math.ceil(contentChars / 200);
65
+ return file.additions + file.deletions + contentWeight + FILE_OVERHEAD_LINES;
66
+ };
63
67
 
64
68
  const unitOf = (index: number, files: ReadonlyArray<ChangedFile>): ReviewUnit =>
65
69
  ReviewUnit.make({
@@ -76,10 +80,12 @@ const unitOf = (index: number, files: ReadonlyArray<ChangedFile>): ReviewUnit =>
76
80
  * then packed greedily in that order under the soft changed-line budget and
77
81
  * the hard per-unit file bound. Capacity is finite and explicit:
78
82
  *
79
- * - files without a textual diff are not delegated no finding can anchor
80
- * to them (anchor validation demands a parsed patch), so they surface in
81
- * `undiffablePaths` instead of consuming a child's budget;
82
- * - diffable files beyond `MAX_REVIEW_UNITS` full units surface in
83
+ * - files without a textual diff are still delegated when the source
84
+ * recovered complete bounded UTF-8 base/head content. Findings from that
85
+ * evidence cannot anchor inline and are reported as concerns;
86
+ * - files with neither form of textual evidence surface in
87
+ * `undiffablePaths` instead of laundering missing coverage;
88
+ * - reviewable files beyond `MAX_REVIEW_UNITS` full units surface in
83
89
  * `unassignedPaths` so the review can report them as unreviewed, never
84
90
  * silently truncated.
85
91
  */
@@ -88,14 +94,14 @@ export const planReviewUnits = (
88
94
  options: { readonly totalChangedFiles: number },
89
95
  ): ReviewUnitPlan => {
90
96
  const ordered = [...files].sort((left, right) => (left.path < right.path ? -1 : 1));
91
- const diffable = ordered.filter((file) => file.patch !== undefined);
92
- const undiffable = ordered.filter((file) => file.patch === undefined);
97
+ const reviewable = ordered.filter(isReviewableFile);
98
+ const undiffable = ordered.filter((file) => !isReviewableFile(file));
93
99
 
94
100
  const groups: Array<Array<ChangedFile>> = [];
95
101
  const unassigned: Array<ChangedFile> = [];
96
102
  let current: Array<ChangedFile> = [];
97
103
  let currentCost = 0;
98
- for (const file of diffable) {
104
+ for (const file of reviewable) {
99
105
  const cost = fileCost(file);
100
106
  const wouldOverflow =
101
107
  current.length >= MAX_UNIT_FILES ||