@effect-agent/pr-review 0.1.0-beta.20 → 0.1.0-beta.22

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,16 +1,42 @@
1
1
  import { Option, Schema } from "effect";
2
2
  import type { RunEvent } from "effect-agent";
3
3
 
4
+ import { anchorViolation } from "./anchors.ts";
4
5
  import type { ChangedFile } from "./diff.ts";
5
6
  import { isReviewableFile } from "./diff.ts";
6
- import { FileReviewDelegationFailure, FileReviewRequest, FileReviewUnitResult } from "./fan-out.ts";
7
- import { FileDiffQuery, type WalkthroughEntry } from "./review-agent.ts";
8
- import { planReviewUnits } from "./review-units.ts";
7
+ import {
8
+ assessmentSettlesSuggestionExactly,
9
+ type CandidateAssessment,
10
+ confirmedFindingForPublication,
11
+ FileReviewDelegationFailure,
12
+ FileReviewRequest,
13
+ FileReviewUnitResult,
14
+ ReviewCandidate,
15
+ reviewCandidateSubjectKey,
16
+ } from "./fan-out.ts";
17
+ import {
18
+ FileDiffView,
19
+ FileDiffQuery,
20
+ type ReviewConcern,
21
+ type ReviewFinding,
22
+ type WalkthroughEntry,
23
+ } from "./review-agent.ts";
24
+ import {
25
+ findingAnchorInUnitEvidence,
26
+ planReviewUnits,
27
+ type ReviewDiscoveryPass,
28
+ type ReviewUnit,
29
+ } from "./review-units.ts";
9
30
 
10
31
  // ---------------------------------------------------------------------------
11
- // Host-owned coverage. Model summaries are untrusted prose; the check result
12
- // is based on deterministic unit planning plus the semantic Tool events that
13
- // prove which required review operations actually settled successfully.
32
+ // Two different claims are deliberately modeled:
33
+ //
34
+ // - input coverage: every required path was assigned bounded evidence or was
35
+ // explicitly reported outside the pipeline's capacity;
36
+ // - review assurance: every configured discovery/specialist pass and every
37
+ // candidate-verification batch settled exactly.
38
+ //
39
+ // Neither claims that the model found every defect.
14
40
  // ---------------------------------------------------------------------------
15
41
 
16
42
  export const ReviewShape = Schema.Literals(["flat", "fan-out"]);
@@ -23,6 +49,11 @@ export class FailedReviewUnit extends Schema.Class<FailedReviewUnit>(
23
49
  errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
24
50
  }) {}
25
51
 
52
+ /**
53
+ * Compatibility diagnostic retained for callers that consumed the original
54
+ * `coverage` field. New UI and state decisions use ReviewInputCoverage and
55
+ * ReviewAssurance directly.
56
+ */
26
57
  export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
27
58
  "@effect-agent/pr-review/ReviewCoverage",
28
59
  )({
@@ -37,11 +68,62 @@ export class ReviewCoverage extends Schema.Class<ReviewCoverage>(
37
68
  Schema.isMaxLength(300),
38
69
  ),
39
70
  failedUnits: Schema.Array(FailedReviewUnit).check(Schema.isMaxLength(8)),
71
+ reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
72
+ Schema.isMaxLength(32),
73
+ ),
74
+ }) {}
75
+
76
+ export class ReviewInputCoverage extends Schema.Class<ReviewInputCoverage>(
77
+ "@effect-agent/pr-review/ReviewInputCoverage",
78
+ )({
79
+ status: Schema.Literals(["complete", "incomplete"]),
80
+ requiredPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
81
+ Schema.isMaxLength(300),
82
+ ),
83
+ assignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
84
+ Schema.isMaxLength(300),
85
+ ),
86
+ /** Assigned paths whose model-visible diff was truncated by the evidence bound. */
87
+ partialPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
88
+ Schema.isMaxLength(300),
89
+ ),
90
+ unassignedPaths: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(512))).check(
91
+ Schema.isMaxLength(300),
92
+ ),
40
93
  reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
41
94
  Schema.isMaxLength(20),
42
95
  ),
43
96
  }) {}
44
97
 
98
+ export class FailedReviewPass extends Schema.Class<FailedReviewPass>(
99
+ "@effect-agent/pr-review/FailedReviewPass",
100
+ )({
101
+ workId: Schema.NonEmptyString.check(Schema.isMaxLength(96)),
102
+ stage: Schema.Literals(["discovery", "specialist", "verification"]),
103
+ errorTag: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
104
+ }) {}
105
+
106
+ export class ReviewAssurance extends Schema.Class<ReviewAssurance>(
107
+ "@effect-agent/pr-review/ReviewAssurance",
108
+ )({
109
+ status: Schema.Literals(["settled", "incomplete", "unverified"]),
110
+ requiredGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
111
+ completedGeneralDiscoveryPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
112
+ requiredSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
113
+ completedSpecialistPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
114
+ requiredVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
115
+ completedVerificationPasses: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
116
+ discoveredCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
117
+ confirmedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
118
+ rejectedCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
119
+ unsettledCandidates: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
120
+ /** Every failure remains visible within the coordinator's 32-call hard bound. */
121
+ failedPasses: Schema.Array(FailedReviewPass).check(Schema.isMaxLength(64)),
122
+ reasons: Schema.Array(Schema.NonEmptyString.check(Schema.isMaxLength(1_000))).check(
123
+ Schema.isMaxLength(32),
124
+ ),
125
+ }) {}
126
+
45
127
  interface ToolTrace {
46
128
  readonly declared: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallDeclared" }>>;
47
129
  readonly succeeded: Map<string, Extract<RunEvent, { readonly _tag: "ToolCallSucceeded" }>>;
@@ -81,24 +163,30 @@ const boundedListReason = (label: string, values: Iterable<string>): string => {
81
163
  return rendered;
82
164
  };
83
165
 
84
- const flatCoverage = (
166
+ const flatInputCoverage = (
85
167
  files: ReadonlyArray<ChangedFile>,
86
168
  totalFiles: number,
87
169
  trace: ToolTrace,
88
- ): ReviewCoverage => {
170
+ ): ReviewInputCoverage => {
89
171
  const requiredPaths = sortedUnique(files.map((file) => file.path));
90
- const reviewed = new Set<string>();
172
+ const assigned = new Set<string>();
173
+ const partial = new Set<string>();
91
174
  const failedPaths = new Set<string>();
92
175
  for (const [toolCallId, declaration] of trace.declared) {
93
176
  if (declaration.toolName !== "read_file_diff") continue;
94
177
  const query = Schema.decodeUnknownOption(FileDiffQuery)(declaration.parameters);
95
178
  if (Option.isNone(query)) continue;
96
- if (trace.succeeded.has(toolCallId)) reviewed.add(query.value.path);
179
+ const success = trace.succeeded.get(toolCallId);
180
+ if (success !== undefined) {
181
+ assigned.add(query.value.path);
182
+ const view = Schema.decodeUnknownOption(FileDiffView)(success.result);
183
+ if (Option.isSome(view) && view.value.truncated) partial.add(query.value.path);
184
+ }
97
185
  if (trace.failed.has(toolCallId)) failedPaths.add(query.value.path);
98
186
  }
99
187
  const undiffable = files.filter((file) => !isReviewableFile(file)).map((file) => file.path);
100
- const unreviewed = requiredPaths.filter(
101
- (path) => !reviewed.has(path) || undiffable.includes(path) || failedPaths.has(path),
188
+ const unassigned = requiredPaths.filter(
189
+ (path) => !assigned.has(path) || undiffable.includes(path) || failedPaths.has(path),
102
190
  );
103
191
  const reasons: Array<string> = [];
104
192
  if (files.length < totalFiles) {
@@ -109,92 +197,31 @@ const flatCoverage = (
109
197
  boundedListReason("required paths have no reviewable diff or bounded text", undiffable),
110
198
  );
111
199
  }
112
- if (failedPaths.size > 0) {
113
- reasons.push(boundedListReason("diff reads failed", failedPaths));
200
+ if (failedPaths.size > 0) reasons.push(boundedListReason("diff reads failed", failedPaths));
201
+ if (partial.size > 0) {
202
+ reasons.push(boundedListReason("model-visible diff evidence was truncated", partial));
114
203
  }
115
- if (unreviewed.length > 0) {
116
- reasons.push(boundedListReason("required paths were not successfully reviewed", unreviewed));
204
+ if (unassigned.length > 0) {
205
+ reasons.push(boundedListReason("required paths received no successful diff input", unassigned));
117
206
  }
118
- return ReviewCoverage.make({
207
+ return ReviewInputCoverage.make({
119
208
  status: reasons.length === 0 ? "complete" : "incomplete",
120
209
  requiredPaths,
121
- reviewedPaths: sortedUnique(reviewed),
122
- unreviewedPaths: sortedUnique(unreviewed),
123
- failedUnits: [],
210
+ assignedPaths: sortedUnique(assigned),
211
+ partialPaths: sortedUnique(partial),
212
+ unassignedPaths: sortedUnique(unassigned),
124
213
  reasons,
125
214
  });
126
215
  };
127
216
 
128
- const fanOutCoverage = (
217
+ const fanOutInputCoverage = (
129
218
  files: ReadonlyArray<ChangedFile>,
130
219
  totalFiles: number,
131
- trace: ToolTrace,
132
- ): ReviewCoverage => {
220
+ ): ReviewInputCoverage => {
133
221
  const plan = planReviewUnits(files, { totalChangedFiles: totalFiles });
134
- const declarationsByUnit = new Map<
135
- string,
136
- Array<{ readonly id: string; readonly paths: ReadonlyArray<string> }>
137
- >();
138
- for (const [toolCallId, declaration] of trace.declared) {
139
- if (declaration.toolName !== "delegate_file_review") continue;
140
- const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
141
- if (Option.isNone(request)) continue;
142
- const declarations = declarationsByUnit.get(request.value.unitId) ?? [];
143
- declarations.push({ id: toolCallId, paths: request.value.paths });
144
- declarationsByUnit.set(request.value.unitId, declarations);
145
- }
146
-
147
- const reviewed = new Set<string>();
148
- const unreviewed = new Set<string>([...plan.undiffablePaths, ...plan.unassignedPaths]);
149
- const failedUnits: Array<FailedReviewUnit> = [];
222
+ const assignedPaths = sortedUnique(plan.units.flatMap((unit) => unit.paths));
223
+ const unassignedPaths = sortedUnique([...plan.undiffablePaths, ...plan.unassignedPaths]);
150
224
  const reasons: Array<string> = [];
151
- for (const unit of plan.units) {
152
- const declarations = declarationsByUnit.get(unit.unitId) ?? [];
153
- const expectedPaths = [...unit.paths];
154
- const exact = declarations.filter(
155
- (declaration) =>
156
- declaration.paths.length === expectedPaths.length &&
157
- declaration.paths.every((path, index) => path === expectedPaths[index]),
158
- );
159
- const successful = exact.filter((declaration) => {
160
- const event = trace.succeeded.get(declaration.id);
161
- if (event === undefined || trace.failed.has(declaration.id)) return false;
162
- const result = Schema.decodeUnknownOption(FileReviewUnitResult)(event.result);
163
- return Option.isSome(result) && result.value.unitId === unit.unitId;
164
- });
165
- if (declarations.length === 1 && exact.length === 1 && successful.length === 1) {
166
- for (const path of unit.paths) reviewed.add(path);
167
- continue;
168
- }
169
- for (const path of unit.paths) unreviewed.add(path);
170
- const failure = declarations
171
- .map((declaration) => trace.failed.get(declaration.id))
172
- .find((event) => event !== undefined);
173
- const returnedFailure = declarations
174
- .map((declaration) => trace.succeeded.get(declaration.id))
175
- .filter((event) => event !== undefined)
176
- .map((event) => Schema.decodeUnknownOption(FileReviewDelegationFailure)(event.result))
177
- .find(Option.isSome);
178
- failedUnits.push(
179
- FailedReviewUnit.make({
180
- unitId: unit.unitId,
181
- errorTag:
182
- failure?.errorTag ??
183
- (returnedFailure !== undefined
184
- ? returnedFailure.value._tag === "FileReviewUnitFailed"
185
- ? `${returnedFailure.value._tag}:${returnedFailure.value.childErrorTag}`
186
- : returnedFailure.value._tag
187
- : undefined) ??
188
- (declarations.length === 0
189
- ? "UnitNotAssigned"
190
- : declarations.length > 1
191
- ? "UnitAssignedMultipleTimes"
192
- : exact.length === 0
193
- ? "UnitAssignmentMismatch"
194
- : "UnitDidNotSettleSuccessfully"),
195
- }),
196
- );
197
- }
198
225
  if (plan.truncated) {
199
226
  reasons.push(`review range exposed ${files.length} of ${totalFiles} required files`);
200
227
  }
@@ -206,78 +233,545 @@ const fanOutCoverage = (
206
233
  ),
207
234
  );
208
235
  }
209
- if (plan.unassignedPaths.length > 0) {
210
- reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
236
+ if (plan.partialEvidencePaths.length > 0) {
237
+ reasons.push(
238
+ boundedListReason(
239
+ "fan-out capacity left some deterministic evidence shards unassigned",
240
+ plan.partialEvidencePaths,
241
+ ),
242
+ );
211
243
  }
212
- if (failedUnits.length > 0) {
244
+ if (plan.unassignedEvidenceShardCount > 0) {
245
+ reasons.push(
246
+ `${plan.unassignedEvidenceShardCount} deterministic evidence shard(s) exceeded fan-out capacity`,
247
+ );
213
248
  reasons.push(
214
249
  boundedListReason(
215
- "review units did not complete",
216
- failedUnits.map((unit) => `${unit.unitId} (${unit.errorTag})`),
250
+ `unassigned evidence shard identifier sample (${plan.unassignedEvidenceShardIds.length} of ${plan.unassignedEvidenceShardCount})`,
251
+ plan.unassignedEvidenceShardIds,
217
252
  ),
218
253
  );
219
254
  }
220
- return ReviewCoverage.make({
255
+ if (plan.unassignedPaths.length > 0) {
256
+ reasons.push(boundedListReason("fan-out capacity left paths unassigned", plan.unassignedPaths));
257
+ }
258
+ return ReviewInputCoverage.make({
221
259
  status: reasons.length === 0 ? "complete" : "incomplete",
222
260
  requiredPaths: sortedUnique(files.map((file) => file.path)),
223
- reviewedPaths: sortedUnique(reviewed),
224
- unreviewedPaths: sortedUnique(unreviewed),
225
- failedUnits,
261
+ assignedPaths,
262
+ partialPaths: plan.partialEvidencePaths,
263
+ unassignedPaths,
226
264
  reasons,
227
265
  });
228
266
  };
229
267
 
230
- /**
231
- * Host-verified per-file summaries from the fan-out run's Tool events: for
232
- * every successfully settled delegation, the child-reported `fileSummaries`
233
- * whose paths belong to that invocation's requested unit. This is the
234
- * declassification check `projectResult` cannot perform itself (it never sees
235
- * the request): a child assigned file A cannot smuggle a summary for changed
236
- * file B into the merged walkthrough, and a coordinator cannot invent or edit
237
- * entries — only exact child-reported, in-unit summaries survive.
238
- */
239
- export const collectUnitFileSummaries = (
240
- events: ReadonlyArray<RunEvent>,
241
- ): ReadonlyArray<WalkthroughEntry> => {
242
- const trace = toolTrace(events);
243
- const entries: Array<WalkthroughEntry> = [];
244
- for (const [toolCallId, declaration] of trace.declared) {
245
- if (declaration.toolName !== "delegate_file_review") continue;
268
+ const sameStrings = (left: ReadonlyArray<string>, right: ReadonlyArray<string>): boolean =>
269
+ left.length === right.length && left.every((value, index) => value === right[index]);
270
+
271
+ const candidateKey = (candidate: ReviewCandidate): string =>
272
+ JSON.stringify(Schema.encodeSync(ReviewCandidate)(candidate));
273
+
274
+ const sameCandidates = (
275
+ left: ReadonlyArray<ReviewCandidate>,
276
+ right: ReadonlyArray<ReviewCandidate>,
277
+ ): boolean =>
278
+ left.length === right.length &&
279
+ left.every((candidate, index) => {
280
+ const corresponding = right[index];
281
+ return corresponding !== undefined && candidateKey(candidate) === candidateKey(corresponding);
282
+ });
283
+
284
+ const delegationDeclarations = (trace: ToolTrace) =>
285
+ [...trace.declared].flatMap(([id, declaration]) => {
286
+ if (declaration.toolName !== "delegate_file_review") return [];
246
287
  const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
247
- if (Option.isNone(request)) continue;
248
- const success = trace.succeeded.get(toolCallId);
249
- if (success === undefined || trace.failed.has(toolCallId)) continue;
250
- const result = Schema.decodeUnknownOption(FileReviewUnitResult)(success.result);
251
- if (Option.isNone(result) || result.value.unitId !== request.value.unitId) continue;
252
- const assigned = new Set(request.value.paths);
253
- for (const entry of result.value.fileSummaries ?? []) {
254
- if (assigned.has(entry.path)) entries.push(entry);
288
+ return Option.isNone(request) ? [] : [{ id, request: request.value }];
289
+ });
290
+
291
+ const failureTag = (trace: ToolTrace, id: string): string | undefined => {
292
+ const failed = trace.failed.get(id);
293
+ if (failed !== undefined) return failed.errorTag;
294
+ const succeeded = trace.succeeded.get(id);
295
+ if (succeeded === undefined) return undefined;
296
+ const returned = Schema.decodeUnknownOption(FileReviewDelegationFailure)(succeeded.result);
297
+ if (Option.isNone(returned)) return undefined;
298
+ return returned.value._tag === "FileReviewUnitFailed"
299
+ ? `${returned.value._tag}:${returned.value.childErrorTag}`
300
+ : returned.value._tag;
301
+ };
302
+
303
+ const exactDiscoveryRequest = (request: FileReviewRequest, pass: ReviewDiscoveryPass): boolean =>
304
+ request.phase === "discovery" &&
305
+ request.workId === pass.passId &&
306
+ request.unitId === pass.unitId &&
307
+ request.perspective === pass.perspective &&
308
+ sameStrings(request.paths, pass.paths) &&
309
+ sameStrings(request.evidenceShardIds, pass.evidenceShardIds) &&
310
+ sameStrings(request.riskCategories, pass.riskCategories) &&
311
+ request.candidates.length === 0;
312
+
313
+ const validCandidate = (
314
+ candidate: ReviewCandidate,
315
+ pass: ReviewDiscoveryPass,
316
+ unit: ReviewUnit,
317
+ files: ReadonlyArray<ChangedFile>,
318
+ anchorFiles: ReadonlyArray<ChangedFile>,
319
+ ): boolean => {
320
+ const allowed = new Set(pass.paths);
321
+ const kind = candidate._tag === "FindingCandidate" ? "finding" : "concern";
322
+ const idPrefix = `${pass.passId}:${kind}:`;
323
+ return (
324
+ candidate.candidateId.startsWith(idPrefix) &&
325
+ /^\d{3}$/.test(candidate.candidateId.slice(idPrefix.length)) &&
326
+ candidate.workId === pass.passId &&
327
+ candidate.unitId === pass.unitId &&
328
+ candidate.evidencePaths.length > 0 &&
329
+ candidate.evidencePaths.every((path) => allowed.has(path)) &&
330
+ (candidate._tag !== "FindingCandidate" ||
331
+ (allowed.has(candidate.finding.path) &&
332
+ anchorViolation(candidate.finding, anchorFiles) === undefined &&
333
+ findingAnchorInUnitEvidence(candidate.finding, unit, files)))
334
+ );
335
+ };
336
+
337
+ interface AssuranceAssessment {
338
+ readonly assurance: ReviewAssurance;
339
+ readonly confirmedFindings: ReadonlyArray<ReviewFinding>;
340
+ readonly confirmedConcerns: ReadonlyArray<ReviewConcern>;
341
+ readonly walkthrough: ReadonlyArray<WalkthroughEntry>;
342
+ }
343
+
344
+ const flatAssurance = (): AssuranceAssessment => ({
345
+ assurance: ReviewAssurance.make({
346
+ status: "unverified",
347
+ requiredGeneralDiscoveryPasses: 1,
348
+ completedGeneralDiscoveryPasses: 1,
349
+ requiredSpecialistPasses: 0,
350
+ completedSpecialistPasses: 0,
351
+ requiredVerificationPasses: 1,
352
+ completedVerificationPasses: 0,
353
+ discoveredCandidates: 0,
354
+ confirmedCandidates: 0,
355
+ rejectedCandidates: 0,
356
+ unsettledCandidates: 0,
357
+ failedPasses: [],
358
+ reasons: [
359
+ "flat review has no independent candidate-verification pass; use the fan-out pipeline for a settled assurance result",
360
+ ],
361
+ }),
362
+ confirmedFindings: [],
363
+ confirmedConcerns: [],
364
+ walkthrough: [],
365
+ });
366
+
367
+ const fanOutAssurance = (
368
+ files: ReadonlyArray<ChangedFile>,
369
+ totalFiles: number,
370
+ anchorFiles: ReadonlyArray<ChangedFile>,
371
+ trace: ToolTrace,
372
+ ): AssuranceAssessment => {
373
+ const plan = planReviewUnits(files, { totalChangedFiles: totalFiles });
374
+ const declarations = delegationDeclarations(trace);
375
+ const consumedDeclarationIds = new Set<string>();
376
+ const failedPasses: Array<FailedReviewPass> = [];
377
+ const reasons: Array<string> = [];
378
+ const candidatesByUnit = new Map<string, Array<ReviewCandidate>>();
379
+ const walkthrough: Array<WalkthroughEntry> = [];
380
+ let completedGeneralDiscoveryPasses = 0;
381
+ let completedSpecialistPasses = 0;
382
+
383
+ for (const pass of plan.discoveryPasses) {
384
+ const unit = plan.units.find((candidate) => candidate.unitId === pass.unitId);
385
+ const matching = declarations.filter(({ request }) => exactDiscoveryRequest(request, pass));
386
+ const stage = pass.perspective === "risk-specialist" ? "specialist" : "discovery";
387
+ if (matching.length !== 1) {
388
+ failedPasses.push(
389
+ FailedReviewPass.make({
390
+ workId: pass.passId,
391
+ stage,
392
+ errorTag: matching.length === 0 ? "PassNotAssigned" : "PassAssignedMultipleTimes",
393
+ }),
394
+ );
395
+ continue;
396
+ }
397
+ const call = matching[0];
398
+ if (call === undefined) {
399
+ failedPasses.push(
400
+ FailedReviewPass.make({
401
+ workId: pass.passId,
402
+ stage,
403
+ errorTag: "PassLookupInvariantFailed",
404
+ }),
405
+ );
406
+ continue;
407
+ }
408
+ consumedDeclarationIds.add(call.id);
409
+ const failure = failureTag(trace, call.id);
410
+ const succeeded = trace.succeeded.get(call.id);
411
+ const result =
412
+ succeeded === undefined
413
+ ? Option.none()
414
+ : Schema.decodeUnknownOption(FileReviewUnitResult)(succeeded.result);
415
+ if (
416
+ failure !== undefined ||
417
+ Option.isNone(result) ||
418
+ result.value.phase !== "discovery" ||
419
+ result.value.workId !== pass.passId ||
420
+ result.value.unitId !== pass.unitId ||
421
+ result.value.assessments.length !== 0
422
+ ) {
423
+ failedPasses.push(
424
+ FailedReviewPass.make({
425
+ workId: pass.passId,
426
+ stage,
427
+ errorTag: failure ?? "DiscoveryDidNotSettleExactly",
428
+ }),
429
+ );
430
+ continue;
431
+ }
432
+ const ids = new Set<string>();
433
+ let candidatesValid = true;
434
+ for (const candidate of result.value.candidates) {
435
+ if (
436
+ unit === undefined ||
437
+ ids.has(candidate.candidateId) ||
438
+ !validCandidate(candidate, pass, unit, files, anchorFiles)
439
+ ) {
440
+ candidatesValid = false;
441
+ break;
442
+ }
443
+ ids.add(candidate.candidateId);
444
+ }
445
+ const unitCandidates = candidatesByUnit.get(pass.unitId) ?? [];
446
+ const unitIds = new Set(unitCandidates.map((candidate) => candidate.candidateId));
447
+ if (result.value.candidates.some((candidate) => unitIds.has(candidate.candidateId))) {
448
+ candidatesValid = false;
449
+ }
450
+ if (!candidatesValid) {
451
+ failedPasses.push(
452
+ FailedReviewPass.make({
453
+ workId: pass.passId,
454
+ stage,
455
+ errorTag: "DiscoveryCandidateMismatch",
456
+ }),
457
+ );
458
+ continue;
459
+ }
460
+ if (stage === "specialist") {
461
+ completedSpecialistPasses += 1;
462
+ } else {
463
+ completedGeneralDiscoveryPasses += 1;
464
+ }
465
+ const subjectKeys = new Set(unitCandidates.map(reviewCandidateSubjectKey));
466
+ for (const candidate of result.value.candidates) {
467
+ const subjectKey = reviewCandidateSubjectKey(candidate);
468
+ if (subjectKeys.has(subjectKey)) continue;
469
+ subjectKeys.add(subjectKey);
470
+ unitCandidates.push(candidate);
471
+ }
472
+ candidatesByUnit.set(pass.unitId, unitCandidates);
473
+ if (pass.perspective === "general") {
474
+ const allowed = new Set(pass.paths);
475
+ walkthrough.push(...result.value.fileSummaries.filter((entry) => allowed.has(entry.path)));
255
476
  }
256
477
  }
257
- return entries;
478
+
479
+ const confirmedCandidates: Array<{
480
+ readonly assessment: CandidateAssessment;
481
+ readonly candidate: ReviewCandidate;
482
+ }> = [];
483
+ let rejectedCandidates = 0;
484
+ let unsettledCandidates = 0;
485
+ let requiredVerificationPasses = 0;
486
+ let completedVerificationPasses = 0;
487
+ for (const unit of plan.units) {
488
+ const candidates = candidatesByUnit.get(unit.unitId) ?? [];
489
+ if (candidates.length === 0) continue;
490
+ requiredVerificationPasses += 1;
491
+ const workId = `${unit.unitId}-verification`;
492
+ const matching = declarations.filter(
493
+ ({ request }) =>
494
+ request.phase === "verification" &&
495
+ request.workId === workId &&
496
+ request.unitId === unit.unitId &&
497
+ request.perspective === "candidate-verification" &&
498
+ sameStrings(request.paths, unit.paths) &&
499
+ sameStrings(
500
+ request.evidenceShardIds,
501
+ unit.evidenceShards.map((shard) => shard.shardId),
502
+ ) &&
503
+ sameStrings(request.riskCategories, unit.riskCategories) &&
504
+ sameCandidates(request.candidates, candidates),
505
+ );
506
+ if (matching.length !== 1) {
507
+ unsettledCandidates += candidates.length;
508
+ failedPasses.push(
509
+ FailedReviewPass.make({
510
+ workId,
511
+ stage: "verification",
512
+ errorTag:
513
+ matching.length === 0
514
+ ? "VerificationNotAssignedOrCandidateMismatch"
515
+ : "VerificationAssignedMultipleTimes",
516
+ }),
517
+ );
518
+ continue;
519
+ }
520
+ const call = matching[0];
521
+ if (call === undefined) {
522
+ unsettledCandidates += candidates.length;
523
+ failedPasses.push(
524
+ FailedReviewPass.make({
525
+ workId,
526
+ stage: "verification",
527
+ errorTag: "PassLookupInvariantFailed",
528
+ }),
529
+ );
530
+ continue;
531
+ }
532
+ consumedDeclarationIds.add(call.id);
533
+ const failure = failureTag(trace, call.id);
534
+ const succeeded = trace.succeeded.get(call.id);
535
+ const result =
536
+ succeeded === undefined
537
+ ? Option.none()
538
+ : Schema.decodeUnknownOption(FileReviewUnitResult)(succeeded.result);
539
+ if (
540
+ failure !== undefined ||
541
+ Option.isNone(result) ||
542
+ result.value.phase !== "verification" ||
543
+ result.value.workId !== workId ||
544
+ result.value.unitId !== unit.unitId ||
545
+ result.value.candidates.length !== 0 ||
546
+ result.value.fileSummaries.length !== 0
547
+ ) {
548
+ unsettledCandidates += candidates.length;
549
+ failedPasses.push(
550
+ FailedReviewPass.make({
551
+ workId,
552
+ stage: "verification",
553
+ errorTag: failure ?? "VerificationDidNotSettleExactly",
554
+ }),
555
+ );
556
+ continue;
557
+ }
558
+ const byId = new Map(
559
+ candidates.map((candidate) => [candidate.candidateId, candidate] as const),
560
+ );
561
+ const assessedIds = new Set<string>();
562
+ let suggestionSettlementExact = true;
563
+ const exactAssessments = result.value.assessments.every((assessment) => {
564
+ const candidate = byId.get(assessment.candidateId);
565
+ if (candidate === undefined || assessedIds.has(assessment.candidateId)) {
566
+ return false;
567
+ }
568
+ assessedIds.add(assessment.candidateId);
569
+ if (!assessmentSettlesSuggestionExactly(assessment, candidate)) {
570
+ suggestionSettlementExact = false;
571
+ }
572
+ return true;
573
+ });
574
+ if (
575
+ byId.size !== candidates.length ||
576
+ !exactAssessments ||
577
+ assessedIds.size !== byId.size ||
578
+ !suggestionSettlementExact
579
+ ) {
580
+ unsettledCandidates += candidates.length;
581
+ failedPasses.push(
582
+ FailedReviewPass.make({
583
+ workId,
584
+ stage: "verification",
585
+ errorTag:
586
+ exactAssessments && !suggestionSettlementExact
587
+ ? "SuggestionSettlementMismatch"
588
+ : "VerificationAssessmentMismatch",
589
+ }),
590
+ );
591
+ continue;
592
+ }
593
+ completedVerificationPasses += 1;
594
+ for (const assessment of result.value.assessments) {
595
+ if (assessment.disposition === "confirmed") {
596
+ const candidate = byId.get(assessment.candidateId);
597
+ if (candidate !== undefined) confirmedCandidates.push({ assessment, candidate });
598
+ } else {
599
+ rejectedCandidates += 1;
600
+ }
601
+ }
602
+ }
603
+
604
+ const unexpected = [...trace.declared].filter(
605
+ ([id, declaration]) =>
606
+ declaration.toolName === "delegate_file_review" && !consumedDeclarationIds.has(id),
607
+ );
608
+ for (const [, declaration] of unexpected) {
609
+ const request = Schema.decodeUnknownOption(FileReviewRequest)(declaration.parameters);
610
+ failedPasses.push(
611
+ FailedReviewPass.make({
612
+ workId: Option.isSome(request) ? request.value.workId : "invalid-delegation-request",
613
+ stage:
614
+ Option.isSome(request) && request.value.phase === "verification"
615
+ ? "verification"
616
+ : "discovery",
617
+ errorTag: "UnexpectedPass",
618
+ }),
619
+ );
620
+ }
621
+ if (failedPasses.length > 0) {
622
+ reasons.push(
623
+ boundedListReason(
624
+ "configured review passes did not settle",
625
+ failedPasses.map((pass) => `${pass.workId} (${pass.errorTag})`),
626
+ ),
627
+ );
628
+ }
629
+ if (unsettledCandidates > 0) {
630
+ reasons.push(
631
+ `${unsettledCandidates} discovered candidate(s) did not receive exact verification`,
632
+ );
633
+ }
634
+ const requiredSpecialistPasses = plan.discoveryPasses.filter(
635
+ (pass) => pass.perspective === "risk-specialist",
636
+ ).length;
637
+ const requiredGeneralDiscoveryPasses = plan.discoveryPasses.length - requiredSpecialistPasses;
638
+ const discoveredCandidates = [...candidatesByUnit.values()].reduce(
639
+ (total, candidates) => total + candidates.length,
640
+ 0,
641
+ );
642
+ return {
643
+ assurance: ReviewAssurance.make({
644
+ status: reasons.length === 0 ? "settled" : "incomplete",
645
+ requiredGeneralDiscoveryPasses,
646
+ completedGeneralDiscoveryPasses,
647
+ requiredSpecialistPasses,
648
+ completedSpecialistPasses,
649
+ requiredVerificationPasses,
650
+ completedVerificationPasses,
651
+ discoveredCandidates,
652
+ confirmedCandidates: confirmedCandidates.length,
653
+ rejectedCandidates,
654
+ unsettledCandidates,
655
+ failedPasses,
656
+ reasons,
657
+ }),
658
+ confirmedFindings: confirmedCandidates.flatMap(({ assessment, candidate }) =>
659
+ candidate._tag === "FindingCandidate"
660
+ ? [confirmedFindingForPublication(assessment, candidate)]
661
+ : [],
662
+ ),
663
+ confirmedConcerns: confirmedCandidates.flatMap(({ candidate }) =>
664
+ candidate._tag === "ConcernCandidate" ? [candidate.concern] : [],
665
+ ),
666
+ walkthrough,
667
+ };
258
668
  };
259
669
 
260
- /** Assess one settled run without trusting its prose summary or verdict. */
261
- export const assessReviewCoverage = (input: {
670
+ const compatibilityCoverage = (
671
+ inputCoverage: ReviewInputCoverage,
672
+ assurance: ReviewAssurance,
673
+ ): ReviewCoverage => {
674
+ const assuranceIncomplete = assurance.status === "incomplete";
675
+ const failedUnits = new Map<string, FailedReviewUnit>();
676
+ for (const pass of assurance.failedPasses) {
677
+ const unitId = pass.workId.slice(0, "unit-000".length);
678
+ if (!failedUnits.has(unitId)) {
679
+ failedUnits.set(
680
+ unitId,
681
+ FailedReviewUnit.make({ unitId, errorTag: `${pass.stage}:${pass.errorTag}` }),
682
+ );
683
+ }
684
+ }
685
+ return ReviewCoverage.make({
686
+ status: inputCoverage.status === "complete" && !assuranceIncomplete ? "complete" : "incomplete",
687
+ requiredPaths: inputCoverage.requiredPaths,
688
+ reviewedPaths: inputCoverage.assignedPaths,
689
+ unreviewedPaths: sortedUnique([
690
+ ...inputCoverage.partialPaths,
691
+ ...inputCoverage.unassignedPaths,
692
+ ]),
693
+ failedUnits: [...failedUnits.values()].slice(0, 8),
694
+ reasons: [...inputCoverage.reasons, ...(assuranceIncomplete ? assurance.reasons : [])],
695
+ });
696
+ };
697
+
698
+ export interface ReviewPipelineAssessment {
699
+ readonly inputCoverage: ReviewInputCoverage;
700
+ readonly assurance: ReviewAssurance;
701
+ /** Deprecated compatibility aggregate. */
702
+ readonly coverage: ReviewCoverage;
703
+ readonly confirmedFindings: ReadonlyArray<ReviewFinding>;
704
+ readonly confirmedConcerns: ReadonlyArray<ReviewConcern>;
705
+ readonly walkthrough: ReadonlyArray<WalkthroughEntry>;
706
+ }
707
+
708
+ /** Assess one settled run without trusting coordinator prose or findings. */
709
+ export const assessReviewPipeline = (input: {
262
710
  readonly shape: ReviewShape;
263
711
  readonly files: ReadonlyArray<ChangedFile>;
264
712
  readonly totalFiles: number;
265
713
  readonly anchorFiles: ReadonlyArray<ChangedFile>;
266
714
  readonly totalAnchorFiles: number;
267
715
  readonly events: ReadonlyArray<RunEvent>;
268
- }): ReviewCoverage => {
716
+ }): ReviewPipelineAssessment => {
269
717
  const trace = toolTrace(input.events);
270
- const coverage =
718
+ let inputCoverage =
271
719
  input.shape === "fan-out"
272
- ? fanOutCoverage(input.files, input.totalFiles, trace)
273
- : flatCoverage(input.files, input.totalFiles, trace);
274
- if (input.anchorFiles.length >= input.totalAnchorFiles) return coverage;
275
- return ReviewCoverage.make({
276
- ...coverage,
277
- status: "incomplete",
278
- reasons: [
279
- ...coverage.reasons,
280
- `full pull-request anchor surface exposed ${input.anchorFiles.length} of ${input.totalAnchorFiles} required files`,
281
- ],
720
+ ? fanOutInputCoverage(input.files, input.totalFiles)
721
+ : flatInputCoverage(input.files, input.totalFiles, trace);
722
+ if (input.anchorFiles.length < input.totalAnchorFiles) {
723
+ inputCoverage = ReviewInputCoverage.make({
724
+ ...inputCoverage,
725
+ status: "incomplete",
726
+ reasons: [
727
+ ...inputCoverage.reasons,
728
+ `full pull-request anchor surface exposed ${input.anchorFiles.length} of ${input.totalAnchorFiles} required files`,
729
+ ],
730
+ });
731
+ }
732
+ const assessed =
733
+ input.shape === "fan-out"
734
+ ? fanOutAssurance(input.files, input.totalFiles, input.anchorFiles, trace)
735
+ : flatAssurance();
736
+ return {
737
+ inputCoverage,
738
+ assurance: assessed.assurance,
739
+ coverage: compatibilityCoverage(inputCoverage, assessed.assurance),
740
+ confirmedFindings: assessed.confirmedFindings,
741
+ confirmedConcerns: assessed.confirmedConcerns,
742
+ walkthrough: assessed.walkthrough,
743
+ };
744
+ };
745
+
746
+ /** Compatibility helper; prefer assessReviewPipeline for precise claims. */
747
+ export const assessReviewCoverage = (input: {
748
+ readonly shape: ReviewShape;
749
+ readonly files: ReadonlyArray<ChangedFile>;
750
+ readonly totalFiles: number;
751
+ readonly anchorFiles: ReadonlyArray<ChangedFile>;
752
+ readonly totalAnchorFiles: number;
753
+ readonly events: ReadonlyArray<RunEvent>;
754
+ }): ReviewCoverage => assessReviewPipeline(input).coverage;
755
+
756
+ /** Host-verified summaries from successful general discovery passes only. */
757
+ export const collectUnitFileSummaries = (
758
+ events: ReadonlyArray<RunEvent>,
759
+ ): ReadonlyArray<WalkthroughEntry> => {
760
+ const trace = toolTrace(events);
761
+ return delegationDeclarations(trace).flatMap(({ id, request }) => {
762
+ if (request.phase !== "discovery" || request.perspective !== "general") return [];
763
+ const success = trace.succeeded.get(id);
764
+ if (success === undefined || trace.failed.has(id)) return [];
765
+ const result = Schema.decodeUnknownOption(FileReviewUnitResult)(success.result);
766
+ if (
767
+ Option.isNone(result) ||
768
+ result.value.phase !== "discovery" ||
769
+ result.value.workId !== request.workId ||
770
+ result.value.unitId !== request.unitId
771
+ ) {
772
+ return [];
773
+ }
774
+ const assigned = new Set(request.paths);
775
+ return result.value.fileSummaries.filter((entry) => assigned.has(entry.path));
282
776
  });
283
777
  };