@effect-agent/pr-review 0.1.0-beta.24 → 0.1.0-beta.26
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.
- package/README.md +83 -202
- package/dist/action.d.mts +25 -16
- package/dist/action.mjs +51 -39
- package/dist/action.mjs.map +1 -1
- package/dist/cli.mjs +3 -3
- package/dist/cli.mjs.map +1 -1
- package/dist/{fan-out-Bi1v0VaU.d.mts → fan-out-CMEsbFLk.d.mts} +432 -206
- package/dist/{github-C6jrBLA2.mjs → github-NjgxGqwM.mjs} +2141 -1658
- package/dist/github-NjgxGqwM.mjs.map +1 -0
- package/dist/index.d.mts +20 -12
- package/dist/index.mjs +3 -3
- package/dist/{providers-2Jao2ZAX.mjs → providers-CODZQCmL.mjs} +192 -79
- package/dist/providers-CODZQCmL.mjs.map +1 -0
- package/dist/testing.d.mts +1 -1
- package/dist/testing.mjs +1 -1
- package/package.json +2 -2
- package/src/action.ts +123 -78
- package/src/cli.ts +6 -1
- package/src/index.ts +1 -0
- package/src/internal/adjudication.ts +415 -0
- package/src/internal/coverage.ts +41 -60
- package/src/internal/fan-out.ts +56 -4
- package/src/internal/github-env.ts +9 -0
- package/src/internal/github.ts +207 -0
- package/src/internal/progress.ts +1 -1
- package/src/internal/render.ts +186 -42
- package/src/internal/retirement.ts +16 -17
- package/src/internal/review-agent.ts +39 -4
- package/src/internal/review-state.ts +161 -42
- package/src/internal/review-units.ts +10 -9
- package/src/internal/run.ts +178 -64
- package/dist/github-C6jrBLA2.mjs.map +0 -1
- package/dist/providers-2Jao2ZAX.mjs.map +0 -1
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { Context, DateTime, Effect, Layer, Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
import { INLINE_FINDING_TITLE_PATTERN } from "./retirement.ts";
|
|
4
|
+
import {
|
|
5
|
+
adjudicationIdentity,
|
|
6
|
+
MAX_STORED_ADJUDICATIONS,
|
|
7
|
+
StoredAdjudication,
|
|
8
|
+
type AdjudicationDisposition,
|
|
9
|
+
type StoredReviewFinding,
|
|
10
|
+
} from "./review-state.ts";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Maintainer adjudication. GitHub reads stay behind ReviewAdjudicationHost;
|
|
14
|
+
// this module owns only the deterministic verb grammar, fail-closed
|
|
15
|
+
// authorization, later-wins resolution, and prompt-context rendering. Only an
|
|
16
|
+
// explicit, authorized `/adjudicate` verb adjudicates — free-text rebuttals
|
|
17
|
+
// are deliberately never parsed, because only an explicit verb is auditable
|
|
18
|
+
// and fail-closed (model output and third-party comments are untrusted
|
|
19
|
+
// input, AGENTS.md rule 11).
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
const PositiveLine = Schema.Int.check(Schema.isGreaterThan(0));
|
|
23
|
+
|
|
24
|
+
/** Maximum authorized command candidates retained for one inline thread. */
|
|
25
|
+
export const MAX_THREAD_ADJUDICATION_COMMANDS = 100;
|
|
26
|
+
|
|
27
|
+
/** One reply or top-level comment observed through the adjudication host. */
|
|
28
|
+
export class AdjudicationComment extends Schema.Class<AdjudicationComment>(
|
|
29
|
+
"@effect-agent/pr-review/AdjudicationComment",
|
|
30
|
+
)({
|
|
31
|
+
body: Schema.String.check(Schema.isMaxLength(65_536)),
|
|
32
|
+
/** GitHub's author_association for the comment author, verbatim. */
|
|
33
|
+
authorAssociation: Schema.String.check(Schema.isMaxLength(40)),
|
|
34
|
+
authorLogin: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
|
|
35
|
+
/** Creation time; a comment without one loses every later-wins tie. */
|
|
36
|
+
createdAt: Schema.NullOr(Schema.DateTimeUtc),
|
|
37
|
+
/** Stable zero-based order in the source listing, before thread grouping. */
|
|
38
|
+
sourceOrder: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
39
|
+
}) {}
|
|
40
|
+
|
|
41
|
+
/** One of the action's own inline finding threads, replies in creation order. */
|
|
42
|
+
export class AdjudicableThread extends Schema.Class<AdjudicableThread>(
|
|
43
|
+
"@effect-agent/pr-review/AdjudicableThread",
|
|
44
|
+
)({
|
|
45
|
+
path: Schema.NonEmptyString.check(Schema.isMaxLength(500)),
|
|
46
|
+
startLine: Schema.NullOr(PositiveLine),
|
|
47
|
+
endLine: Schema.NullOr(PositiveLine),
|
|
48
|
+
/** The root comment's body; its first line carries the finding title. */
|
|
49
|
+
rootBody: Schema.String.check(Schema.isMaxLength(65_536)),
|
|
50
|
+
replies: Schema.Array(AdjudicationComment).check(
|
|
51
|
+
Schema.isMaxLength(MAX_THREAD_ADJUDICATION_COMMANDS),
|
|
52
|
+
),
|
|
53
|
+
}) {}
|
|
54
|
+
|
|
55
|
+
/** A GitHub adjudication read failed. */
|
|
56
|
+
export class ReviewAdjudicationFailure extends Schema.TaggedError<ReviewAdjudicationFailure>()(
|
|
57
|
+
"ReviewAdjudicationFailure",
|
|
58
|
+
{
|
|
59
|
+
operation: Schema.String,
|
|
60
|
+
reason: Schema.String,
|
|
61
|
+
},
|
|
62
|
+
) {
|
|
63
|
+
override get message() {
|
|
64
|
+
return `Review adjudication operation '${this.operation}' failed: ${this.reason}`;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Host-side GitHub reads used by adjudication. Domain code never reaches into
|
|
70
|
+
* REST directly, and deterministic tests substitute this port. Both listings
|
|
71
|
+
* return comments in creation order.
|
|
72
|
+
*/
|
|
73
|
+
export class ReviewAdjudicationHost extends Context.Service<
|
|
74
|
+
ReviewAdjudicationHost,
|
|
75
|
+
{
|
|
76
|
+
/** This action's own inline finding threads with their replies. */
|
|
77
|
+
readonly listFindingThreads: Effect.Effect<
|
|
78
|
+
ReadonlyArray<AdjudicableThread>,
|
|
79
|
+
ReviewAdjudicationFailure
|
|
80
|
+
>;
|
|
81
|
+
/** Top-level pull-request conversation comments. */
|
|
82
|
+
readonly listIssueComments: Effect.Effect<
|
|
83
|
+
ReadonlyArray<AdjudicationComment>,
|
|
84
|
+
ReviewAdjudicationFailure
|
|
85
|
+
>;
|
|
86
|
+
}
|
|
87
|
+
>()("@effect-agent/pr-review/ReviewAdjudicationHost") {}
|
|
88
|
+
|
|
89
|
+
/** Explicit program-edge adapter for runs that intentionally perform no host reads. */
|
|
90
|
+
export const noReviewAdjudicationHost = ReviewAdjudicationHost.of({
|
|
91
|
+
listFindingThreads: Effect.succeed([]),
|
|
92
|
+
listIssueComments: Effect.succeed([]),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Layer form of {@link noReviewAdjudicationHost}. */
|
|
96
|
+
export const noReviewAdjudicationHostLayer =
|
|
97
|
+
Layer.succeed(ReviewAdjudicationHost)(noReviewAdjudicationHost);
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Verb grammar. A body whose first line starts with `/adjudicate` is a
|
|
101
|
+
// command; a command that fails the grammar is malformed and ignored rather
|
|
102
|
+
// than guessed at. Fail-closed authorization: only OWNER, MEMBER, and
|
|
103
|
+
// COLLABORATOR authors may adjudicate.
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/** author_associations allowed to adjudicate; everything else is ignored. */
|
|
107
|
+
export const AUTHORIZED_ADJUDICATION_ASSOCIATIONS: ReadonlySet<string> = new Set([
|
|
108
|
+
"OWNER",
|
|
109
|
+
"MEMBER",
|
|
110
|
+
"COLLABORATOR",
|
|
111
|
+
]);
|
|
112
|
+
|
|
113
|
+
const AdjudicationDispositionSchema = Schema.Literals(["accepted-risk", "refuted", "obsolete"]);
|
|
114
|
+
|
|
115
|
+
const THREAD_COMMAND_PATTERN = /^\/adjudicate[ \t]+([a-z-]+)[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
|
|
116
|
+
const ISSUE_COMMAND_PATTERN =
|
|
117
|
+
/^\/adjudicate[ \t]+([a-z-]+)[ \t]+"([^"\n]+)"[ \t]*(?::[ \t]*(.*\S))?[ \t]*$/;
|
|
118
|
+
|
|
119
|
+
export interface ParsedAdjudicationCommand {
|
|
120
|
+
readonly disposition: AdjudicationDisposition;
|
|
121
|
+
/** Present only for the issue-comment grammar's quoted target title. */
|
|
122
|
+
readonly title?: string | undefined;
|
|
123
|
+
readonly reason?: string | undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const firstLine = (body: string): string => (body.split("\n", 1)[0] ?? "").trim();
|
|
127
|
+
|
|
128
|
+
const boundedReason = (raw: string | undefined): string | undefined => {
|
|
129
|
+
if (raw === undefined) return undefined;
|
|
130
|
+
const trimmed = raw.trim().slice(0, 300);
|
|
131
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Parse one inline-thread reply: `/adjudicate <disposition>(: <reason>)?`.
|
|
136
|
+
* The thread itself names the target identity. Returns undefined for a
|
|
137
|
+
* non-command body and "malformed" for a command that fails the grammar.
|
|
138
|
+
*/
|
|
139
|
+
export const parseThreadAdjudication = (
|
|
140
|
+
body: string,
|
|
141
|
+
): ParsedAdjudicationCommand | "malformed" | undefined => {
|
|
142
|
+
const line = firstLine(body);
|
|
143
|
+
if (!line.startsWith("/adjudicate")) return undefined;
|
|
144
|
+
const match = THREAD_COMMAND_PATTERN.exec(line);
|
|
145
|
+
const disposition = match?.[1];
|
|
146
|
+
if (disposition === undefined || !Schema.is(AdjudicationDispositionSchema)(disposition)) {
|
|
147
|
+
return "malformed";
|
|
148
|
+
}
|
|
149
|
+
return { disposition, reason: boundedReason(match?.[2]) };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Parse one top-level PR comment:
|
|
154
|
+
* `/adjudicate <disposition> "<exact title>"(: <reason>)?`. The quoted title
|
|
155
|
+
* is required because the conversation names no finding thread; it targets
|
|
156
|
+
* the title-alone identity of an unanchored concern.
|
|
157
|
+
*/
|
|
158
|
+
export const parseIssueAdjudication = (
|
|
159
|
+
body: string,
|
|
160
|
+
): ParsedAdjudicationCommand | "malformed" | undefined => {
|
|
161
|
+
const line = firstLine(body);
|
|
162
|
+
if (!line.startsWith("/adjudicate")) return undefined;
|
|
163
|
+
const match = ISSUE_COMMAND_PATTERN.exec(line);
|
|
164
|
+
const disposition = match?.[1];
|
|
165
|
+
const title = match?.[2];
|
|
166
|
+
if (
|
|
167
|
+
disposition === undefined ||
|
|
168
|
+
!Schema.is(AdjudicationDispositionSchema)(disposition) ||
|
|
169
|
+
title === undefined ||
|
|
170
|
+
title.length > 120
|
|
171
|
+
) {
|
|
172
|
+
return "malformed";
|
|
173
|
+
}
|
|
174
|
+
return { disposition, title, reason: boundedReason(match?.[3]) };
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** The finding identity an inline thread names, or undefined when unparsable. */
|
|
178
|
+
export const threadFindingTarget = (
|
|
179
|
+
thread: AdjudicableThread,
|
|
180
|
+
):
|
|
181
|
+
| {
|
|
182
|
+
readonly path: string;
|
|
183
|
+
readonly startLine: number;
|
|
184
|
+
readonly endLine: number;
|
|
185
|
+
readonly title: string;
|
|
186
|
+
}
|
|
187
|
+
| undefined => {
|
|
188
|
+
if (thread.startLine === null || thread.endLine === null) return undefined;
|
|
189
|
+
const title = INLINE_FINDING_TITLE_PATTERN.exec(firstLine(thread.rootBody))?.[1];
|
|
190
|
+
if (title === undefined || title.length > 120) return undefined;
|
|
191
|
+
return {
|
|
192
|
+
path: thread.path,
|
|
193
|
+
startLine: thread.startLine,
|
|
194
|
+
endLine: thread.endLine,
|
|
195
|
+
title,
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Deterministic derivation: authorization, later-wins, bounded storage.
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
interface AdjudicationCandidate {
|
|
204
|
+
readonly adjudication: StoredAdjudication;
|
|
205
|
+
readonly epochMillis: number;
|
|
206
|
+
readonly sourceOrder: number;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface DerivedAdjudications {
|
|
210
|
+
readonly adjudications: ReadonlyArray<StoredAdjudication>;
|
|
211
|
+
/** Commands ignored fail-closed: unauthorized authors and malformed bodies. */
|
|
212
|
+
readonly ignored: ReadonlyArray<string>;
|
|
213
|
+
/** Later-wins winners dropped oldest-first at the storage bound. */
|
|
214
|
+
readonly droppedOldest: number;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Derive the standing adjudications from the host's listings. Every command
|
|
219
|
+
* is screened fail-closed (authorization, grammar, a parsable target); later
|
|
220
|
+
* adjudications of the same identity win by comment creation order; the
|
|
221
|
+
* result is capped at the ReviewState bound dropping the oldest winners.
|
|
222
|
+
*/
|
|
223
|
+
export const deriveAdjudications = (input: {
|
|
224
|
+
readonly threads: ReadonlyArray<AdjudicableThread>;
|
|
225
|
+
readonly issueComments: ReadonlyArray<AdjudicationComment>;
|
|
226
|
+
}): DerivedAdjudications => {
|
|
227
|
+
const candidates: Array<AdjudicationCandidate> = [];
|
|
228
|
+
const ignored: Array<string> = [];
|
|
229
|
+
const admit = (
|
|
230
|
+
comment: AdjudicationComment,
|
|
231
|
+
command: ParsedAdjudicationCommand,
|
|
232
|
+
target: {
|
|
233
|
+
readonly path?: string | undefined;
|
|
234
|
+
readonly startLine?: number | undefined;
|
|
235
|
+
readonly endLine?: number | undefined;
|
|
236
|
+
readonly title: string;
|
|
237
|
+
},
|
|
238
|
+
): void => {
|
|
239
|
+
candidates.push({
|
|
240
|
+
adjudication: StoredAdjudication.make({
|
|
241
|
+
...(target.path === undefined ? {} : { path: target.path }),
|
|
242
|
+
...(target.startLine === undefined ? {} : { startLine: target.startLine }),
|
|
243
|
+
...(target.endLine === undefined ? {} : { endLine: target.endLine }),
|
|
244
|
+
title: target.title,
|
|
245
|
+
disposition: command.disposition,
|
|
246
|
+
...(command.reason === undefined ? {} : { reason: command.reason }),
|
|
247
|
+
actor: comment.authorLogin,
|
|
248
|
+
}),
|
|
249
|
+
epochMillis: comment.createdAt === null ? -1 : DateTime.toEpochMillis(comment.createdAt),
|
|
250
|
+
sourceOrder: comment.sourceOrder,
|
|
251
|
+
});
|
|
252
|
+
};
|
|
253
|
+
const authorized = (comment: AdjudicationComment, surface: string): boolean => {
|
|
254
|
+
if (AUTHORIZED_ADJUDICATION_ASSOCIATIONS.has(comment.authorAssociation)) return true;
|
|
255
|
+
ignored.push(
|
|
256
|
+
`${surface}: unauthorized /adjudicate from @${comment.authorLogin} (${comment.authorAssociation})`,
|
|
257
|
+
);
|
|
258
|
+
return false;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
for (const thread of input.threads) {
|
|
262
|
+
const target = threadFindingTarget(thread);
|
|
263
|
+
for (const reply of thread.replies) {
|
|
264
|
+
const command = parseThreadAdjudication(reply.body);
|
|
265
|
+
if (command === undefined) continue;
|
|
266
|
+
const surface = `inline thread ${thread.path}`;
|
|
267
|
+
if (command === "malformed") {
|
|
268
|
+
ignored.push(`${surface}: malformed /adjudicate command from @${reply.authorLogin}`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!authorized(reply, surface)) continue;
|
|
272
|
+
if (target === undefined) {
|
|
273
|
+
ignored.push(`${surface}: thread root names no parsable finding title`);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
admit(reply, command, target);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
for (const comment of input.issueComments) {
|
|
280
|
+
const command = parseIssueAdjudication(comment.body);
|
|
281
|
+
if (command === undefined) continue;
|
|
282
|
+
const surface = "pull-request conversation";
|
|
283
|
+
if (command === "malformed") {
|
|
284
|
+
ignored.push(`${surface}: malformed /adjudicate command from @${comment.authorLogin}`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!authorized(comment, surface)) continue;
|
|
288
|
+
if (command.title === undefined) {
|
|
289
|
+
ignored.push(`${surface}: /adjudicate without a quoted target title`);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
admit(comment, command, { title: command.title });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const byIdentity = new Map<string, AdjudicationCandidate>();
|
|
296
|
+
const ordered = [...candidates].sort(
|
|
297
|
+
(left, right) => left.epochMillis - right.epochMillis || left.sourceOrder - right.sourceOrder,
|
|
298
|
+
);
|
|
299
|
+
for (const candidate of ordered) {
|
|
300
|
+
const identity = adjudicationIdentity(candidate.adjudication);
|
|
301
|
+
// Delete-then-set so a later adjudication also refreshes its recency for
|
|
302
|
+
// the oldest-first drop below.
|
|
303
|
+
byIdentity.delete(identity);
|
|
304
|
+
byIdentity.set(identity, candidate);
|
|
305
|
+
}
|
|
306
|
+
const winners = [...byIdentity.values()];
|
|
307
|
+
const droppedOldest = Math.max(0, winners.length - MAX_STORED_ADJUDICATIONS);
|
|
308
|
+
return {
|
|
309
|
+
adjudications: winners.slice(droppedOldest).map((candidate) => candidate.adjudication),
|
|
310
|
+
ignored,
|
|
311
|
+
droppedOldest,
|
|
312
|
+
};
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
/** Later-wins merge of stored prior adjudications with freshly derived ones. */
|
|
316
|
+
export const mergeAdjudications = (
|
|
317
|
+
prior: ReadonlyArray<StoredAdjudication>,
|
|
318
|
+
fresh: ReadonlyArray<StoredAdjudication>,
|
|
319
|
+
): ReadonlyArray<StoredAdjudication> => {
|
|
320
|
+
const byIdentity = new Map<string, StoredAdjudication>();
|
|
321
|
+
for (const adjudication of [...prior, ...fresh]) {
|
|
322
|
+
const identity = adjudicationIdentity(adjudication);
|
|
323
|
+
byIdentity.delete(identity);
|
|
324
|
+
byIdentity.set(identity, adjudication);
|
|
325
|
+
}
|
|
326
|
+
const merged = [...byIdentity.values()];
|
|
327
|
+
return merged.slice(Math.max(0, merged.length - MAX_STORED_ADJUDICATIONS));
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Collect the standing maintainer adjudications: freshly derived through the
|
|
332
|
+
* host, merged later-wins over the prior state's stored set. The host is a
|
|
333
|
+
* visible Effect requirement; program edges that intentionally perform no
|
|
334
|
+
* reads provide {@link noReviewAdjudicationHost}. Fail-open — any listing
|
|
335
|
+
* fault keeps the complete prior set and never fails the review, because NOT
|
|
336
|
+
* suppressing a finding is the conservative direction.
|
|
337
|
+
*/
|
|
338
|
+
export const collectReviewAdjudications = Effect.fn("collectReviewAdjudications")(function* (
|
|
339
|
+
prior: ReadonlyArray<StoredAdjudication>,
|
|
340
|
+
) {
|
|
341
|
+
const host = yield* ReviewAdjudicationHost;
|
|
342
|
+
const listings = yield* Effect.all({
|
|
343
|
+
threads: host.listFindingThreads,
|
|
344
|
+
issueComments: host.listIssueComments,
|
|
345
|
+
}).pipe(
|
|
346
|
+
Effect.catch((error) =>
|
|
347
|
+
Effect.logWarning(
|
|
348
|
+
`Could not collect adjudications from '${error.operation}': ${error.reason}; retaining stored adjudications unchanged.`,
|
|
349
|
+
).pipe(Effect.as(undefined)),
|
|
350
|
+
),
|
|
351
|
+
);
|
|
352
|
+
if (listings === undefined) return prior;
|
|
353
|
+
const derived = deriveAdjudications({
|
|
354
|
+
threads: listings.threads,
|
|
355
|
+
issueComments: listings.issueComments,
|
|
356
|
+
});
|
|
357
|
+
for (const note of derived.ignored) {
|
|
358
|
+
yield* Effect.logDebug(`Ignored adjudication command — ${note}`);
|
|
359
|
+
}
|
|
360
|
+
if (derived.droppedOldest > 0) {
|
|
361
|
+
yield* Effect.logWarning(
|
|
362
|
+
`Dropped ${derived.droppedOldest} oldest adjudication(s) over the ${MAX_STORED_ADJUDICATIONS}-entry bound.`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return mergeAdjudications(prior, derived.adjudications);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Prompt-context rendering: deterministic bounded lines the reviewer sees.
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
const lineRange = (startLine: number, endLine: number): string =>
|
|
373
|
+
`${startLine}${endLine === startLine ? "" : `-${endLine}`}`;
|
|
374
|
+
|
|
375
|
+
/** One adjudication as a bounded reviewer-prompt context line. */
|
|
376
|
+
export const renderAdjudicationContextLine = (adjudication: StoredAdjudication): string => {
|
|
377
|
+
const location =
|
|
378
|
+
adjudication.path !== undefined &&
|
|
379
|
+
adjudication.startLine !== undefined &&
|
|
380
|
+
adjudication.endLine !== undefined
|
|
381
|
+
? `${adjudication.path}:${lineRange(adjudication.startLine, adjudication.endLine)}`
|
|
382
|
+
: "(unanchored)";
|
|
383
|
+
const reason = adjudication.reason === undefined ? "" : `: ${adjudication.reason}`;
|
|
384
|
+
return `${location} "${adjudication.title}" — ${adjudication.disposition} by @${adjudication.actor}${reason}`;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/** One prior-round finding as a bounded reviewer-prompt context line. */
|
|
388
|
+
export const renderPriorFindingContextLine = (finding: StoredReviewFinding): string =>
|
|
389
|
+
`${finding.path}:${lineRange(finding.startLine, finding.endLine)} [${finding.severity}] "${finding.title}" — ${finding.body.slice(0, 400)}`;
|
|
390
|
+
|
|
391
|
+
/** Prior-review context threaded into fan-out discovery briefs, per path. */
|
|
392
|
+
export interface PriorReviewContext {
|
|
393
|
+
/** Adjudicated identities; path-free entries apply to every unit. */
|
|
394
|
+
readonly adjudicated: ReadonlyArray<{
|
|
395
|
+
readonly path: string | undefined;
|
|
396
|
+
readonly line: string;
|
|
397
|
+
}>;
|
|
398
|
+
/** Prior-round findings whose paths are being re-reviewed. */
|
|
399
|
+
readonly priorFindings: ReadonlyArray<{ readonly path: string; readonly line: string }>;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Build the fan-out prior-review context from the resolved continuity data. */
|
|
403
|
+
export const buildPriorReviewContext = (
|
|
404
|
+
adjudications: ReadonlyArray<StoredAdjudication>,
|
|
405
|
+
priorFindingsOnScope: ReadonlyArray<StoredReviewFinding>,
|
|
406
|
+
): PriorReviewContext => ({
|
|
407
|
+
adjudicated: adjudications.map((adjudication) => ({
|
|
408
|
+
path: adjudication.path,
|
|
409
|
+
line: renderAdjudicationContextLine(adjudication),
|
|
410
|
+
})),
|
|
411
|
+
priorFindings: priorFindingsOnScope.map((finding) => ({
|
|
412
|
+
path: finding.path,
|
|
413
|
+
line: renderPriorFindingContextLine(finding),
|
|
414
|
+
})),
|
|
415
|
+
});
|
package/src/internal/coverage.ts
CHANGED
|
@@ -19,37 +19,6 @@ import type { ReviewUnitPlan } from "./review-units.ts";
|
|
|
19
19
|
// results; only the flat reviewer is assessed from its Run event trace here.
|
|
20
20
|
// ---------------------------------------------------------------------------
|
|
21
21
|
|
|
22
|
-
export class FailedReviewUnit extends Schema.Class<FailedReviewUnit>(
|
|
23
|
-
"@effect-agent/pr-review/FailedReviewUnit",
|
|
24
|
-
)({
|
|
25
|
-
unitId: Schema.NonEmptyString.check(Schema.isMaxLength(32)),
|
|
26
|
-
errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
|
|
27
|
-
}) {}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Compatibility diagnostic retained for callers that consumed the original
|
|
31
|
-
* `coverage` field. New UI and state decisions use ReviewInputCoverage and
|
|
32
|
-
* ReviewAssurance directly.
|
|
33
|
-
*/
|
|
34
|
-
export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
|
|
35
|
-
"@effect-agent/pr-review/ReviewCoverage",
|
|
36
|
-
)({
|
|
37
|
-
status: Schema.Literals(["complete", "incomplete"]),
|
|
38
|
-
requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
|
|
39
|
-
Schema.isMaxLength(300),
|
|
40
|
-
),
|
|
41
|
-
reviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
|
|
42
|
-
Schema.isMaxLength(300),
|
|
43
|
-
),
|
|
44
|
-
unreviewedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
|
|
45
|
-
Schema.isMaxLength(300),
|
|
46
|
-
),
|
|
47
|
-
failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
|
|
48
|
-
reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
|
|
49
|
-
Schema.isMaxLength(32),
|
|
50
|
-
),
|
|
51
|
-
}) {}
|
|
52
|
-
|
|
53
22
|
export class ReviewInputCoverage extends Schema.Class<ReviewInputCoverage>(
|
|
54
23
|
"@effect-agent/pr-review/ReviewInputCoverage",
|
|
55
24
|
)({
|
|
@@ -159,6 +128,47 @@ export const boundedListReason = (label: string, values: Iterable<string>): stri
|
|
|
159
128
|
return rendered;
|
|
160
129
|
};
|
|
161
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
|
+
|
|
162
172
|
const anchorSurfaceAdjusted = (
|
|
163
173
|
inputCoverage: ReviewInputCoverage,
|
|
164
174
|
anchorFiles: ReadonlyArray<ChangedFile>,
|
|
@@ -345,32 +355,3 @@ export const fanOutInputCoverage = (input: {
|
|
|
345
355
|
input.totalAnchorFiles,
|
|
346
356
|
);
|
|
347
357
|
};
|
|
348
|
-
|
|
349
|
-
/** Compatibility aggregate over the two precise claims. */
|
|
350
|
-
export const compatibilityCoverage = (
|
|
351
|
-
inputCoverage: ReviewInputCoverage,
|
|
352
|
-
assurance: ReviewAssurance,
|
|
353
|
-
): ReviewCoverage => {
|
|
354
|
-
const assuranceIncomplete = assurance.status === "incomplete";
|
|
355
|
-
const failedUnits = new Map<string, FailedReviewUnit>();
|
|
356
|
-
for (const pass of assurance.failedPasses) {
|
|
357
|
-
const unitId = pass.workId.slice(0, "unit-000".length);
|
|
358
|
-
if (!failedUnits.has(unitId)) {
|
|
359
|
-
failedUnits.set(
|
|
360
|
-
unitId,
|
|
361
|
-
FailedReviewUnit.make({ unitId, errorTag: `${pass.stage}:${pass.errorTag}` }),
|
|
362
|
-
);
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
return ReviewCoverage.make({
|
|
366
|
-
status: inputCoverage.status === "complete" && !assuranceIncomplete ? "complete" : "incomplete",
|
|
367
|
-
requiredPaths: inputCoverage.requiredPaths,
|
|
368
|
-
reviewedPaths: inputCoverage.assignedPaths,
|
|
369
|
-
unreviewedPaths: sortedUnique([
|
|
370
|
-
...inputCoverage.partialPaths,
|
|
371
|
-
...inputCoverage.unassignedPaths,
|
|
372
|
-
]),
|
|
373
|
-
failedUnits: [...failedUnits.values()].slice(0, 8),
|
|
374
|
-
reasons: [...inputCoverage.reasons, ...(assuranceIncomplete ? assurance.reasons : [])],
|
|
375
|
-
});
|
|
376
|
-
};
|
package/src/internal/fan-out.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "effect-agent";
|
|
12
12
|
import { Toolkit } from "effect/unstable/ai";
|
|
13
13
|
|
|
14
|
+
import type { PriorReviewContext } from "./adjudication.ts";
|
|
14
15
|
import { anchorViolation } from "./anchors.ts";
|
|
15
16
|
import { boundedListReason, FailedReviewPass, ReviewAssurance } from "./coverage.ts";
|
|
16
17
|
import { ChangedFileStatus, ChangedPath, type ChangedFile } from "./diff.ts";
|
|
@@ -174,8 +175,8 @@ export const confirmedFindingForPublication = (
|
|
|
174
175
|
/**
|
|
175
176
|
* Concern candidates need explicit paths internally to bind the claim to
|
|
176
177
|
* scheduled evidence. The verifier receives the complete bounded unit so it
|
|
177
|
-
* can use neighboring evidence to falsify the claim. The
|
|
178
|
-
*
|
|
178
|
+
* can use neighboring evidence to falsify the claim. The host copies these
|
|
179
|
+
* validated paths onto a confirmed public concern for incremental continuity.
|
|
179
180
|
*/
|
|
180
181
|
export class DiscoveredConcern extends Schema.Class<DiscoveredConcern>(
|
|
181
182
|
"@effect-agent/pr-review/DiscoveredConcern",
|
|
@@ -190,6 +191,11 @@ const UnitPaths = Schema.Array(ChangedPath)
|
|
|
190
191
|
.check(Schema.isMinLength(1))
|
|
191
192
|
.check(Schema.isMaxLength(MAX_UNIT_FILES));
|
|
192
193
|
|
|
194
|
+
/** Bounded prior-review context lines injected into discovery instructions. */
|
|
195
|
+
const UnitContextLines = Schema.Array(Schema.String.check(Schema.isMaxLength(1_200))).check(
|
|
196
|
+
Schema.isMaxLength(20),
|
|
197
|
+
);
|
|
198
|
+
|
|
193
199
|
const RiskCategories = Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6));
|
|
194
200
|
const Candidates = Schema.Array(ReviewCandidate).check(Schema.isMaxLength(MAX_UNIT_CANDIDATES));
|
|
195
201
|
const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
|
|
@@ -225,6 +231,10 @@ export class FileReviewBrief extends Schema.Class<FileReviewBrief>(
|
|
|
225
231
|
evidence: Schema.Array(FileReviewEvidence)
|
|
226
232
|
.check(Schema.isMinLength(1))
|
|
227
233
|
.check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS)),
|
|
234
|
+
/** Maintainer-adjudicated identities on this unit; do not re-raise. */
|
|
235
|
+
adjudicatedContext: Schema.optionalKey(UnitContextLines),
|
|
236
|
+
/** Prior-round findings on this unit's re-reviewed paths. */
|
|
237
|
+
priorFindingContext: Schema.optionalKey(UnitContextLines),
|
|
228
238
|
}) {}
|
|
229
239
|
|
|
230
240
|
/** Child output; phase-inapplicable collections must be empty. */
|
|
@@ -297,11 +307,25 @@ export const makeFileReviewerInstructions =
|
|
|
297
307
|
? `This is a fresh specialist discovery pass. Concentrate on these host-classified risks without relying on another pass: ${brief.riskCategories.join(", ")}.`
|
|
298
308
|
: "This is a fresh specialist discovery pass. The host found no keyword-classified category, so independently scrutinize authentication/authorization, security boundaries, durability, concurrency, credentials, and external side effects rather than treating classification silence as low risk."
|
|
299
309
|
: "This is the general discovery pass. Review broadly for correctness, security, concurrency, resource, API, and error-handling defects.";
|
|
310
|
+
const adjudicated = brief.adjudicatedContext ?? [];
|
|
311
|
+
const priorFindings = brief.priorFindingContext ?? [];
|
|
300
312
|
return [
|
|
301
313
|
...common,
|
|
302
314
|
focus,
|
|
315
|
+
...(adjudicated.length === 0
|
|
316
|
+
? []
|
|
317
|
+
: [
|
|
318
|
+
"A maintainer has adjudicated these previously raised items on this unit (disposition, reason). Do not re-raise them unless you have materially new evidence, and if you do, say explicitly what changed since the adjudication:",
|
|
319
|
+
...adjudicated.map((line) => `- ${line}`),
|
|
320
|
+
]),
|
|
321
|
+
...(priorFindings.length === 0
|
|
322
|
+
? []
|
|
323
|
+
: [
|
|
324
|
+
"A previous review round raised these findings on this unit's paths. For each, either confirm it still holds, state that it is fixed, or withdraw it; do not demand the opposite of that prior guidance without explicitly acknowledging the reversal:",
|
|
325
|
+
...priorFindings.map((line) => `- ${line}`),
|
|
326
|
+
]),
|
|
303
327
|
"The discovery evidence array contains every complete shard in the unit. Review every entry and every shard of a multi-shard path. A later independent verifier, not you, decides which candidates publish.",
|
|
304
|
-
"
|
|
328
|
+
"Every non-anchored concern must list 1-3 exact evidencePaths to bind the claim to scheduled evidence. Report one root concern once; never split it into differently worded restatements.",
|
|
305
329
|
`Return ONLY JSON with phase "discovery", the exact workId/unitId, up to ${MAX_CHILD_FINDINGS} findings, up to ${MAX_CHILD_CONCERNS} concerns shaped as {concern, evidencePaths}, one factual file summary per path (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars), and an empty assessments array. Empty candidate arrays are valid; do not invent defects.`,
|
|
306
330
|
'Each finding is {"path": <a unit file path>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL problem-kind label>, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement source code for exactly lines startLine..endLine, ready to commit>}.',
|
|
307
331
|
'Include "suggestion" only when you are confident the replacement compiles and preserves intent; its text must contain the full replacement source for every line in the range and nothing else — never prose describing the change, which belongs in "body".',
|
|
@@ -388,6 +412,12 @@ export interface FanOutPipelineInput {
|
|
|
388
412
|
readonly stages: ReadonlyArray<FailedReviewPass["stage"]>;
|
|
389
413
|
}
|
|
390
414
|
| undefined;
|
|
415
|
+
/**
|
|
416
|
+
* Adjudicated identities and prior-round findings injected as discovery
|
|
417
|
+
* context on the units whose paths they touch. Context only — they never
|
|
418
|
+
* enter candidates or publication.
|
|
419
|
+
*/
|
|
420
|
+
readonly priorContext?: PriorReviewContext | undefined;
|
|
391
421
|
}
|
|
392
422
|
|
|
393
423
|
const candidateOrdinal = (index: number): string => String(index + 1).padStart(3, "0");
|
|
@@ -625,6 +655,19 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
|
|
|
625
655
|
let turns = 0;
|
|
626
656
|
let completedGeneralPasses = 0;
|
|
627
657
|
let completedSpecialistPasses = 0;
|
|
658
|
+
// Discovery-only context: adjudicated identities (path-free entries apply
|
|
659
|
+
// to every unit) and prior-round findings on this unit's paths. The
|
|
660
|
+
// verifier stays unbiased — it judges only the candidate claims and the
|
|
661
|
+
// bounded evidence.
|
|
662
|
+
const unitPaths = new Set(unit.paths);
|
|
663
|
+
const adjudicatedContext = (input.priorContext?.adjudicated ?? [])
|
|
664
|
+
.filter((entry) => entry.path === undefined || unitPaths.has(entry.path))
|
|
665
|
+
.map((entry) => entry.line)
|
|
666
|
+
.slice(0, 20);
|
|
667
|
+
const priorFindingContext = (input.priorContext?.priorFindings ?? [])
|
|
668
|
+
.filter((entry) => unitPaths.has(entry.path))
|
|
669
|
+
.map((entry) => entry.line)
|
|
670
|
+
.slice(0, 20);
|
|
628
671
|
for (const pass of passes) {
|
|
629
672
|
const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
|
|
630
673
|
const brief = FileReviewBrief.make({
|
|
@@ -637,6 +680,8 @@ const reviewUnit = <Provider, ModelProvides, ModelRequires>(
|
|
|
637
680
|
riskCategories: pass.riskCategories,
|
|
638
681
|
candidates: [],
|
|
639
682
|
evidence,
|
|
683
|
+
...(adjudicatedContext.length === 0 ? {} : { adjudicatedContext }),
|
|
684
|
+
...(priorFindingContext.length === 0 ? {} : { priorFindingContext }),
|
|
640
685
|
});
|
|
641
686
|
const outcome = yield* runReviewPass(binding, brief, input.budget);
|
|
642
687
|
if (outcome._tag === "failed") {
|
|
@@ -962,7 +1007,14 @@ export const runFanOutReview = <Provider, ModelProvides, ModelRequires>(
|
|
|
962
1007
|
);
|
|
963
1008
|
const concerns = rankAndDedupeConcerns(
|
|
964
1009
|
confirmed.flatMap(({ candidate }) =>
|
|
965
|
-
candidate._tag === "ConcernCandidate"
|
|
1010
|
+
candidate._tag === "ConcernCandidate"
|
|
1011
|
+
? [
|
|
1012
|
+
ReviewConcern.make({
|
|
1013
|
+
...candidate.concern,
|
|
1014
|
+
evidencePaths: [...new Set(candidate.evidencePaths)].sort(),
|
|
1015
|
+
}),
|
|
1016
|
+
]
|
|
1017
|
+
: [],
|
|
966
1018
|
),
|
|
967
1019
|
);
|
|
968
1020
|
const walkthrough = outcomes.flatMap((outcome) => outcome.walkthrough);
|