@effect-agent/pr-review 0.1.0-beta.27 → 0.1.0-beta.29

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 +9 -204
  2. package/dist/index.d.mts +87 -914
  3. package/dist/index.mjs +163 -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 +212 -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,20 +0,0 @@
1
- import { commentableLines } from "./diff.ts";
2
- import type { ChangedFile } from "./diff.ts";
3
- import type { ReviewFinding } from "./review-agent.ts";
4
-
5
- /** Why a finding cannot anchor to the current new-version diff, if any. */
6
- export const anchorViolation = (
7
- finding: ReviewFinding,
8
- files: ReadonlyArray<ChangedFile>,
9
- ): string | undefined => {
10
- const file = files.find((candidate) => candidate.path === finding.path);
11
- if (file === undefined) return "path is not part of the changeset";
12
- if (file.patch === undefined) return "file has no anchorable textual diff";
13
- if (finding.endLine < finding.startLine) return "endLine precedes startLine";
14
- if (finding.endLine - finding.startLine + 1 > 100) return "range is implausibly large";
15
- const anchors = commentableLines(file.patch);
16
- for (let line = finding.startLine; line <= finding.endLine; line += 1) {
17
- if (!anchors.has(line)) return `line ${line} is not part of the diff`;
18
- }
19
- return undefined;
20
- };
@@ -1,357 +0,0 @@
1
- import { Option, Schema } from "effect";
2
- import type { RunEvent } from "effect-agent";
3
-
4
- import type { ChangedFile } from "./diff.ts";
5
- import { isReviewableFile } from "./diff.ts";
6
- import { FileDiffView, FileDiffQuery } from "./review-agent.ts";
7
- import type { ReviewUnitPlan } from "./review-units.ts";
8
-
9
- // ---------------------------------------------------------------------------
10
- // Two different claims are deliberately modeled:
11
- //
12
- // - input coverage: every required path was assigned bounded evidence or was
13
- // explicitly reported outside the pipeline's capacity;
14
- // - review assurance: every scheduled discovery/specialist pass and every
15
- // candidate-verification pass settled.
16
- //
17
- // Neither claims that the model found every defect. The fan-out pipeline is
18
- // host-scheduled (fan-out.ts), so its assurance is computed from direct pass
19
- // results; only the flat reviewer is assessed from its Run event trace here.
20
- // ---------------------------------------------------------------------------
21
-
22
- export class ReviewInputCoverage extends Schema.Class<ReviewInputCoverage>(
23
- "@effect-agent/pr-review/ReviewInputCoverage",
24
- )({
25
- status: Schema.Literals(["complete", "incomplete"]),
26
- requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
27
- Schema.isMaxLength(300),
28
- ),
29
- assignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
30
- Schema.isMaxLength(300),
31
- ),
32
- /** Assigned paths whose model-visible diff was truncated by the evidence bound. */
33
- partialPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
34
- Schema.isMaxLength(300),
35
- ),
36
- unassignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
37
- Schema.isMaxLength(300),
38
- ),
39
- /**
40
- * Paths with neither a textual diff nor bounded base/head text (binaries,
41
- * oversized files). Fail-closed: they keep the status incomplete for as
42
- * long as they are part of the pull request — an unreviewable change must
43
- * never authorize a green check. Exclude them deliberately with ignore
44
- * globs when that is intended.
45
- */
46
- undiffablePaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
47
- Schema.isMaxLength(300),
48
- ),
49
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
50
- Schema.isMaxLength(20),
51
- ),
52
- }) {}
53
-
54
- export class FailedReviewPass extends Schema.Class<FailedReviewPass>(
55
- "@effect-agent/pr-review/FailedReviewPass",
56
- )({
57
- workId: Schema.NonEmptyString.check(Schema.isMaxLength(96)),
58
- stage: Schema.Literals(["discovery", "specialist", "verification"]),
59
- errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
60
- }) {}
61
-
62
- /**
63
- * Settlement of scheduled review work. `incomplete` means reviewer-side work
64
- * failed after its bounded retry — a machinery gap that is carried forward and
65
- * retried on the next run, never a statement about the code under review.
66
- * `unverified` is the flat reviewer's honest constant: one pass with no
67
- * independent verifier is neither settled assurance nor a failure.
68
- */
69
- export class ReviewAssurance extends Schema.Class<ReviewAssurance>(
70
- "@effect-agent/pr-review/ReviewAssurance",
71
- )({
72
- status: Schema.Literals(["settled", "incomplete", "unverified"]),
73
- requiredGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
74
- completedGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
75
- requiredSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
76
- completedSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
77
- requiredVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
78
- completedVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
79
- discoveredCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
80
- confirmedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
81
- rejectedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
82
- unsettledCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
83
- /** Discovery claims discarded for anchors/paths outside their assigned evidence. */
84
- discardedInvalidFindings: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
85
- failedPasses: Schema.Array(FailedReviewPass).check(Schema.isMaxLength(64)),
86
- reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
87
- Schema.isMaxLength(32),
88
- ),
89
- }) {}
90
-
91
- interface ToolTrace {
92
- readonly declared: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallDeclared" }>>;
93
- readonly succeeded: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallSucceeded" }>>;
94
- readonly failed: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallFailed" }>>;
95
- }
96
-
97
- const toolTrace = (events: ReadonlyArray<RunEvent>): ToolTrace => {
98
- const declared = new Map<string, Extract<RunEvent, { readonly _tag: "ToolCallDeclared" }>>();
99
- const succeeded = new Map<string, Extract<RunEvent, { readonly _tag: "ToolCallSucceeded" }>>();
100
- const failed = new Map<string, Extract<RunEvent, { readonly _tag: "ToolCallFailed" }>>();
101
- for (const event of events) {
102
- if (event._tag === "ToolCallDeclared") declared.set(event.toolCallId, event);
103
- if (event._tag === "ToolCallSucceeded") succeeded.set(event.toolCallId, event);
104
- if (event._tag === "ToolCallFailed") failed.set(event.toolCallId, event);
105
- }
106
- return { declared, succeeded, failed };
107
- };
108
-
109
- const sortedUnique = (values: Iterable<string>): ReadonlyArray<string> =>
110
- [...new Set(values)].sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
111
-
112
- /** Render a bounded, deterministic "label (n): a, b, … (+k more)" reason line. */
113
- export const boundedListReason = (label: string, values: Iterable<string>): string => {
114
- const items = sortedUnique(values);
115
- const prefix = `${label} (${items.length}): `;
116
- let rendered = prefix;
117
- for (let index = 0; index < items.length; index += 1) {
118
- const item = items[index] ?? "";
119
- const separator = index === 0 ? "" : ", ";
120
- const omitted = items.length - index - 1;
121
- const suffix = omitted === 0 ? "" : ` … (+${omitted} more)`;
122
- if (`${rendered}${separator}${item}${suffix}`.length > 1_000) {
123
- const omission = `… (+${items.length - index} more)`;
124
- return `${rendered.slice(0, 1_000 - omission.length)}${omission}`;
125
- }
126
- rendered = `${rendered}${separator}${item}`;
127
- }
128
- return rendered;
129
- };
130
-
131
- export interface CarriedScope {
132
- /** Carried paths a retry can actually settle (failed passes, overflow). */
133
- readonly retryablePaths: ReadonlyArray<string>;
134
- /** Carried paths no retry can settle (binaries, oversized files). */
135
- readonly undiffablePaths: ReadonlyArray<string>;
136
- /** Whether any incompleteness beyond the undiffable files exists. */
137
- readonly retryableGap: boolean;
138
- }
139
-
140
- /**
141
- * Split carried scope into paths a retry can settle and paths it never can.
142
- * Undiffable files are a property of the pull request, not a transient
143
- * reviewer-side failure: gate reasons and rendered callouts must never promise
144
- * they are "retried automatically" — the honest instruction is to remove them
145
- * from the pull request or exclude them with ignore globs.
146
- */
147
- export const splitCarriedScope = (input: {
148
- readonly inputCoverage?: ReviewInputCoverage | undefined;
149
- readonly assurance?: ReviewAssurance | undefined;
150
- readonly unreviewedPaths?: ReadonlyArray<string> | undefined;
151
- }): CarriedScope => {
152
- const undiffable = new Set(input.inputCoverage?.undiffablePaths ?? []);
153
- const retryablePaths = (input.unreviewedPaths ?? []).filter((path) => !undiffable.has(path));
154
- const undiffablePaths = sortedUnique(undiffable);
155
- // Every non-undiffable coverage gap (range truncation, capacity overflow,
156
- // truncated or missing evidence, anchor surface) contributes its own reason
157
- // line, so a lone reason alongside undiffable paths means the undiffable
158
- // files are the entire gap.
159
- const coverageGapBeyondUndiffable =
160
- input.inputCoverage?.status === "incomplete" &&
161
- input.inputCoverage.reasons.length > (undiffablePaths.length > 0 ? 1 : 0);
162
- return {
163
- retryablePaths,
164
- undiffablePaths,
165
- retryableGap:
166
- input.assurance?.status === "incomplete" ||
167
- retryablePaths.length > 0 ||
168
- coverageGapBeyondUndiffable,
169
- };
170
- };
171
-
172
- const anchorSurfaceAdjusted = (
173
- inputCoverage: ReviewInputCoverage,
174
- anchorFiles: ReadonlyArray<ChangedFile>,
175
- totalAnchorFiles: number,
176
- ): ReviewInputCoverage =>
177
- anchorFiles.length >= totalAnchorFiles
178
- ? inputCoverage
179
- : ReviewInputCoverage.make({
180
- ...inputCoverage,
181
- status: "incomplete",
182
- reasons: [
183
- ...inputCoverage.reasons,
184
- `full pull-request anchor surface exposed ${anchorFiles.length} of ${totalAnchorFiles} required files`,
185
- ],
186
- });
187
-
188
- /** The flat reviewer's honest constant assurance: one pass, no verifier. */
189
- export const flatAssurance = (): ReviewAssurance =>
190
- ReviewAssurance.make({
191
- status: "unverified",
192
- requiredGeneralDiscoveryPasses: 1,
193
- completedGeneralDiscoveryPasses: 1,
194
- requiredSpecialistPasses: 0,
195
- completedSpecialistPasses: 0,
196
- requiredVerificationPasses: 0,
197
- completedVerificationPasses: 0,
198
- discoveredCandidates: 0,
199
- confirmedCandidates: 0,
200
- rejectedCandidates: 0,
201
- unsettledCandidates: 0,
202
- discardedInvalidFindings: 0,
203
- failedPasses: [],
204
- reasons: [
205
- "flat review has no independent candidate-verification pass; use the fan-out pipeline for a settled assurance result",
206
- ],
207
- });
208
-
209
- export interface FlatReviewAssessment {
210
- readonly inputCoverage: ReviewInputCoverage;
211
- readonly assurance: ReviewAssurance;
212
- /** Retryable evidence gaps (failed or missing diff reads), never undiffable paths. */
213
- readonly unreviewedPaths: ReadonlyArray<string>;
214
- }
215
-
216
- /**
217
- * Assess one settled flat run from its Run event trace: which required paths
218
- * received successful bounded diff evidence. This observes tool INPUT
219
- * assignment only — the host cannot know which evidence the model weighed.
220
- */
221
- export const assessFlatReview = (input: {
222
- readonly files: ReadonlyArray<ChangedFile>;
223
- readonly totalFiles: number;
224
- readonly anchorFiles: ReadonlyArray<ChangedFile>;
225
- readonly totalAnchorFiles: number;
226
- readonly events: ReadonlyArray<RunEvent>;
227
- }): FlatReviewAssessment => {
228
- const trace = toolTrace(input.events);
229
- const requiredPaths = sortedUnique(input.files.map((file) => file.path));
230
- const assigned = new Set<string>();
231
- const partial = new Set<string>();
232
- const failedPaths = new Set<string>();
233
- for (const [toolCallId, declaration] of trace.declared) {
234
- if (declaration.toolName !== "read_file_diff") continue;
235
- const query = Schema.decodeUnknownOption(FileDiffQuery)(declaration.parameters);
236
- if (Option.isNone(query)) continue;
237
- const success = trace.succeeded.get(toolCallId);
238
- if (success !== undefined) {
239
- assigned.add(query.value.path);
240
- const view = Schema.decodeUnknownOption(FileDiffView)(success.result);
241
- if (Option.isSome(view) && view.value.truncated) partial.add(query.value.path);
242
- }
243
- if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
244
- }
245
- const undiffable = new Set(
246
- input.files.filter((file) => !isReviewableFile(file)).map((file) => file.path),
247
- );
248
- const unassigned = requiredPaths.filter(
249
- (path) => !undiffable.has(path) && (!assigned.has(path) || failedPaths.has(path)),
250
- );
251
- const reasons: Array<string> = [];
252
- if (input.files.length < input.totalFiles) {
253
- reasons.push(
254
- `review range exposed ${input.files.length} of ${input.totalFiles} required files`,
255
- );
256
- }
257
- if (undiffable.size > 0) {
258
- reasons.push(
259
- boundedListReason("required paths have no reviewable diff or bounded text", undiffable),
260
- );
261
- }
262
- if (failedPaths.size > 0) reasons.push(boundedListReason("diff reads failed", failedPaths));
263
- if (partial.size > 0) {
264
- reasons.push(boundedListReason("model-visible diff evidence was truncated", partial));
265
- }
266
- if (unassigned.length > 0) {
267
- reasons.push(boundedListReason("required paths received no successful diff input", unassigned));
268
- }
269
- const inputCoverage = anchorSurfaceAdjusted(
270
- ReviewInputCoverage.make({
271
- status: reasons.length === 0 ? "complete" : "incomplete",
272
- requiredPaths,
273
- assignedPaths: sortedUnique(assigned),
274
- partialPaths: sortedUnique(partial),
275
- unassignedPaths: sortedUnique(unassigned),
276
- undiffablePaths: sortedUnique(undiffable),
277
- reasons,
278
- }),
279
- input.anchorFiles,
280
- input.totalAnchorFiles,
281
- );
282
- return {
283
- inputCoverage,
284
- assurance: flatAssurance(),
285
- // Everything still unreviewed and still part of the pull request carries
286
- // forward — undiffable paths included, so the check stays fail-closed
287
- // even after they leave the incremental delta.
288
- unreviewedPaths: sortedUnique([...unassigned, ...undiffable]),
289
- };
290
- };
291
-
292
- /**
293
- * Input coverage of one host-scheduled fan-out plan: which required paths the
294
- * bounded plan actually assigned complete evidence for. Capacity overflow and
295
- * undiffable paths are both real gaps; the pipeline carries them so the check
296
- * stays fail-closed until they are reviewed, removed, or explicitly ignored.
297
- */
298
- export const fanOutInputCoverage = (input: {
299
- readonly plan: ReviewUnitPlan;
300
- readonly files: ReadonlyArray<ChangedFile>;
301
- readonly totalFiles: number;
302
- readonly anchorFiles: ReadonlyArray<ChangedFile>;
303
- readonly totalAnchorFiles: number;
304
- }): ReviewInputCoverage => {
305
- const plan = input.plan;
306
- const assignedPaths = sortedUnique(plan.units.flatMap((unit) => unit.paths));
307
- const unassignedPaths = sortedUnique(plan.unassignedPaths);
308
- const reasons: Array<string> = [];
309
- if (plan.truncated) {
310
- reasons.push(
311
- `review range exposed ${input.files.length} of ${input.totalFiles} required files`,
312
- );
313
- }
314
- if (plan.undiffablePaths.length > 0) {
315
- reasons.push(
316
- boundedListReason(
317
- "required paths have no reviewable diff or bounded text",
318
- plan.undiffablePaths,
319
- ),
320
- );
321
- }
322
- if (plan.partialEvidencePaths.length > 0) {
323
- reasons.push(
324
- boundedListReason(
325
- "fan-out capacity left some deterministic evidence shards unassigned",
326
- plan.partialEvidencePaths,
327
- ),
328
- );
329
- }
330
- if (plan.unassignedEvidenceShardCount > 0) {
331
- reasons.push(
332
- `${plan.unassignedEvidenceShardCount} deterministic evidence shard(s) exceeded fan-out capacity`,
333
- );
334
- reasons.push(
335
- boundedListReason(
336
- `unassigned evidence shard identifier sample (${plan.unassignedEvidenceShardIds.length} of ${plan.unassignedEvidenceShardCount})`,
337
- plan.unassignedEvidenceShardIds,
338
- ),
339
- );
340
- }
341
- if (plan.unassignedPaths.length > 0) {
342
- reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
343
- }
344
- return anchorSurfaceAdjusted(
345
- ReviewInputCoverage.make({
346
- status: reasons.length === 0 ? "complete" : "incomplete",
347
- requiredPaths: sortedUnique(input.files.map((file) => file.path)),
348
- assignedPaths,
349
- partialPaths: plan.partialEvidencePaths,
350
- unassignedPaths,
351
- undiffablePaths: sortedUnique(plan.undiffablePaths),
352
- reasons,
353
- }),
354
- input.anchorFiles,
355
- input.totalAnchorFiles,
356
- );
357
- };
@@ -1,193 +0,0 @@
1
- import { Schema } from "effect";
2
-
3
- // ---------------------------------------------------------------------------
4
- // Changed-file and unified-diff primitives shared by the tool surface, the
5
- // publication planner, and the GitHub adapter. The parser is deterministic
6
- // and bounded; it never throws on malformed hunks — unparseable patch text
7
- // simply yields no commentable lines, which fails findings closed.
8
- // ---------------------------------------------------------------------------
9
-
10
- /** A repository-relative file path as transported values carry it. */
11
- export const ChangedPath = Schema.NonEmptyString.check(Schema.isMaxLength(512));
12
-
13
- /** GitHub's changed-file status vocabulary, kept verbatim. */
14
- export const ChangedFileStatus = Schema.Literals([
15
- "added",
16
- "removed",
17
- "modified",
18
- "renamed",
19
- "copied",
20
- "changed",
21
- "unchanged",
22
- ]);
23
-
24
- /** One file changed by the pull request, with its optional textual patch. */
25
- export class ChangedFile extends Schema.Class<ChangedFile>("@effect-agent/pr-review/ChangedFile")({
26
- path: ChangedPath,
27
- status: ChangedFileStatus,
28
- additions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
29
- deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
30
- /** Present for renames/copies: the path the file previously had. */
31
- previousPath: Schema.optionalKey(ChangedPath),
32
- /** Unified-diff hunks; absent for binary or oversized files. */
33
- patch: Schema.optionalKey(Schema.String),
34
- /**
35
- * Bounded UTF-8 content used only when the provider omitted `patch`.
36
- * Modified files require both sides; additions require head content and
37
- * deletions require base content. These values are review evidence, never
38
- * GitHub inline-comment anchors.
39
- */
40
- reviewBaseContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200_000))),
41
- reviewHeadContent: Schema.optionalKey(Schema.String.check(Schema.isMaxLength(200_000))),
42
- }) {}
43
-
44
- /** Complete rendered fallback evidence must fit one ordinary model context. */
45
- export const MAX_REVIEW_CONTENT_CHARS = 220_000;
46
-
47
- /**
48
- * Render complete patchless evidence, or refuse it when a required side is
49
- * absent or B/H annotation would exceed the model-facing bound. Callers use
50
- * this same value for planning and tool output so truncated fallback evidence
51
- * can never count as complete coverage.
52
- */
53
- export const renderReviewContent = (file: ChangedFile): string | undefined => {
54
- if (file.patch !== undefined) return undefined;
55
- const includeBase = file.status !== "added";
56
- const includeHead = file.status !== "removed";
57
- const sections: Array<string> = [
58
- "[GitHub omitted the unified diff. B/H lines below are bounded full-file review content, not valid inline-comment anchors. Report defects from this evidence as non-anchored concerns.]",
59
- ];
60
- let renderedLength = sections[0]?.length ?? 0;
61
- const append = (part: string): boolean => {
62
- const nextLength = renderedLength + 1 + part.length;
63
- if (nextLength > MAX_REVIEW_CONTENT_CHARS) return false;
64
- sections.push(part);
65
- renderedLength = nextLength;
66
- return true;
67
- };
68
- const appendSide = (side: "B" | "H", header: string, content: string): boolean => {
69
- if (!append(header)) return false;
70
- const lines = content.split("\n");
71
- for (let index = 0; index < lines.length; index += 1) {
72
- if (!append(`${side}${index + 1} ${lines[index] ?? ""}`)) return false;
73
- }
74
- return true;
75
- };
76
- if (includeBase) {
77
- if (file.reviewBaseContent === undefined) return undefined;
78
- if (!appendSide("B", "[BASE VERSION]", file.reviewBaseContent)) return undefined;
79
- }
80
- if (includeHead) {
81
- if (file.reviewHeadContent === undefined) return undefined;
82
- if (!appendSide("H", "[HEAD VERSION]", file.reviewHeadContent)) return undefined;
83
- }
84
- return sections.join("\n");
85
- };
86
-
87
- /** Whether complete patchless evidence fits the model-facing review bound. */
88
- export const hasReviewableContent = (file: ChangedFile): boolean =>
89
- renderReviewContent(file) !== undefined;
90
-
91
- /** Whether the reviewer has either a real patch or bounded textual fallback evidence. */
92
- export const isReviewableFile = (file: ChangedFile): boolean =>
93
- file.patch !== undefined || hasReviewableContent(file);
94
-
95
- /** One parsed line of a unified diff, with both coordinate systems. */
96
- export interface PatchLine {
97
- readonly kind: "context" | "add" | "del";
98
- readonly oldLine: number | undefined;
99
- readonly newLine: number | undefined;
100
- readonly text: string;
101
- }
102
-
103
- const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
104
-
105
- /**
106
- * Parse unified-diff hunk text into coordinate-tagged lines. Lines outside a
107
- * recognized hunk header are ignored rather than guessed at.
108
- */
109
- export const parsePatch = (patch: string): ReadonlyArray<PatchLine> => {
110
- const lines: Array<PatchLine> = [];
111
- let oldLine = 0;
112
- let newLine = 0;
113
- let inHunk = false;
114
- for (const raw of patch.split("\n")) {
115
- const header = HUNK_HEADER.exec(raw);
116
- if (header !== null) {
117
- oldLine = Number(header[1]);
118
- newLine = Number(header[2]);
119
- inHunk = true;
120
- continue;
121
- }
122
- if (!inHunk) continue;
123
- if (raw.startsWith("+")) {
124
- lines.push({ kind: "add", oldLine: undefined, newLine, text: raw.slice(1) });
125
- newLine += 1;
126
- } else if (raw.startsWith("-")) {
127
- lines.push({ kind: "del", oldLine, newLine: undefined, text: raw.slice(1) });
128
- oldLine += 1;
129
- } else if (raw.startsWith(" ") || raw === "") {
130
- lines.push({ kind: "context", oldLine, newLine, text: raw.slice(1) });
131
- oldLine += 1;
132
- newLine += 1;
133
- } else if (raw.startsWith("\\")) {
134
- // "" — metadata, not a diff line.
135
- } else {
136
- // Unrecognized content ends the current hunk conservatively.
137
- inHunk = false;
138
- }
139
- }
140
- return lines;
141
- };
142
-
143
- /**
144
- * The new-file line numbers a GitHub review comment may anchor to on the
145
- * RIGHT side: every added or context line that appears in the diff.
146
- */
147
- export const commentableLines = (patch: string): ReadonlySet<number> => {
148
- const lines = new Set<number>();
149
- for (const line of parsePatch(patch)) {
150
- if (line.newLine !== undefined) lines.add(line.newLine);
151
- }
152
- return lines;
153
- };
154
-
155
- /**
156
- * Render a patch with explicit RIGHT-side line numbers so the model can
157
- * anchor findings without arithmetic. `R<n>` marks a line that exists in the
158
- * new version of the file (`+` added, blank context); deleted lines keep a
159
- * bare `-` marker and no number.
160
- */
161
- export const annotatePatch = (patch: string): string => {
162
- const output: Array<string> = [];
163
- let oldLine = 0;
164
- let newLine = 0;
165
- let inHunk = false;
166
- for (const raw of patch.split("\n")) {
167
- const header = HUNK_HEADER.exec(raw);
168
- if (header !== null) {
169
- oldLine = Number(header[1]);
170
- newLine = Number(header[2]);
171
- inHunk = true;
172
- output.push(raw);
173
- continue;
174
- }
175
- if (!inHunk) continue;
176
- if (raw.startsWith("+")) {
177
- output.push(`R${newLine} + ${raw.slice(1)}`);
178
- newLine += 1;
179
- } else if (raw.startsWith("-")) {
180
- output.push(` - ${raw.slice(1)}`);
181
- oldLine += 1;
182
- } else if (raw.startsWith(" ") || raw === "") {
183
- output.push(`R${newLine} ${raw.slice(1)}`);
184
- oldLine += 1;
185
- newLine += 1;
186
- } else if (raw.startsWith("\\")) {
187
- output.push(` ${raw}`);
188
- } else {
189
- inHunk = false;
190
- }
191
- }
192
- return output.join("\n");
193
- };
@@ -1,86 +0,0 @@
1
- import { Schema } from "effect";
2
-
3
- // ---------------------------------------------------------------------------
4
- // Reasoning effort, stored as a POSITION on [0, 1] rather than a rung name.
5
- // A rung name is only meaningful inside the provider that published it: the
6
- // same word can be one provider's floor and another's midpoint, and a stored
7
- // name silently changes meaning when the model under the setting changes. A
8
- // position has no such problem: 0 is whatever the provider calls its cheapest
9
- // offered rung and 1 its most expensive, and resolution is a lookup into that
10
- // provider's own ladder — the result is always a rung the provider offers.
11
- // ---------------------------------------------------------------------------
12
-
13
- /** A point on the effort axis: 0 = cheapest offered rung, 1 = most expensive. */
14
- export type EffortPosition = number;
15
-
16
- /**
17
- * Names accepted on user-facing surfaces (the action input, the CLI flag),
18
- * mapped to fixed points on the axis. These same names anchor every offered
19
- * rung during resolution, so a named input always lands on its same-named
20
- * rung when the provider offers it — `high` never resolves to `medium` just
21
- * because a ladder is short.
22
- */
23
- export const EFFORT_ALIASES = {
24
- low: 0,
25
- medium: 0.25,
26
- high: 0.5,
27
- xhigh: 0.75,
28
- max: 1,
29
- } as const satisfies Readonly<Record<string, EffortPosition>>;
30
-
31
- /** A rung name every provider ladder must draw from. */
32
- export type EffortAliasName = keyof typeof EFFORT_ALIASES;
33
-
34
- const aliasPosition: Readonly<Record<string, EffortPosition | undefined>> = EFFORT_ALIASES;
35
-
36
- /** An effort input that is neither a known name nor a number on [0, 1]. */
37
- export class InvalidEffortInput extends Schema.TaggedError<InvalidEffortInput>()(
38
- "InvalidEffortInput",
39
- {
40
- input: Schema.String,
41
- },
42
- ) {
43
- override get message() {
44
- return (
45
- `Invalid effort '${this.input}': expected one of ` +
46
- `${Object.keys(EFFORT_ALIASES).join(", ")} or a number between 0 and 1.`
47
- );
48
- }
49
- }
50
-
51
- export const isEffortPosition = (value: number): boolean =>
52
- Number.isFinite(value) && value >= 0 && value <= 1;
53
-
54
- /**
55
- * Parse a user-supplied effort into a position: a name (`high`) or a bare
56
- * number (`0.75`). Returns undefined for anything else so the caller can fail
57
- * typed — a typo must stay visible, never silently become a level.
58
- */
59
- export const parseEffortPosition = (raw: string): EffortPosition | undefined => {
60
- const normalized = raw.trim().toLowerCase();
61
- const named = aliasPosition[normalized];
62
- if (named !== undefined) return named;
63
- if (normalized === "") return undefined;
64
- const numeric = Number(normalized);
65
- return isEffortPosition(numeric) ? numeric : undefined;
66
- };
67
-
68
- /**
69
- * Land a position on one provider's offered ladder: the highest offered rung
70
- * whose canonical alias position is at or below the requested position.
71
- * Anchoring on the alias positions (instead of scaling by ladder index) keeps
72
- * two properties at once: a named input lands on its same-named rung whenever
73
- * the provider offers it, and anything between rungs rounds DOWN so
74
- * resolution never costs more than was asked for.
75
- */
76
- export const resolveEffortRung = <const Rung extends EffortAliasName>(
77
- position: EffortPosition,
78
- rungs: readonly [Rung, ...ReadonlyArray<Rung>],
79
- ): Rung => {
80
- const clamped = Math.min(1, Math.max(0, position));
81
- let selected = rungs[0];
82
- for (const rung of rungs) {
83
- if (EFFORT_ALIASES[rung] <= clamped) selected = rung;
84
- }
85
- return selected;
86
- };