@effect-agent/pr-review 0.1.0-beta.28 → 0.1.0-beta.30
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 +11 -204
- package/dist/index.d.mts +92 -914
- package/dist/index.mjs +176 -71
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -18
- package/src/index.ts +1 -25
- package/src/review.ts +244 -0
- package/dist/action.d.mts +0 -215
- package/dist/action.mjs +0 -505
- package/dist/action.mjs.map +0 -1
- package/dist/cli.d.mts +0 -1
- package/dist/cli.mjs +0 -106
- package/dist/cli.mjs.map +0 -1
- package/dist/fan-out-C3yG1cx3.d.mts +0 -1526
- package/dist/github-CCuLgyqb.mjs +0 -3437
- package/dist/github-CCuLgyqb.mjs.map +0 -1
- package/dist/logging-Q4j0oub-.mjs +0 -75
- package/dist/logging-Q4j0oub-.mjs.map +0 -1
- package/dist/providers-Br9FRn7j.mjs +0 -1349
- package/dist/providers-Br9FRn7j.mjs.map +0 -1
- package/dist/testing.d.mts +0 -86
- package/dist/testing.mjs +0 -184
- package/dist/testing.mjs.map +0 -1
- package/src/action.ts +0 -906
- package/src/cli.ts +0 -235
- package/src/internal/action-entry.ts +0 -45
- package/src/internal/adjudication.ts +0 -415
- package/src/internal/anchors.ts +0 -20
- package/src/internal/coverage.ts +0 -357
- package/src/internal/diff.ts +0 -193
- package/src/internal/effort.ts +0 -86
- package/src/internal/factory.ts +0 -357
- package/src/internal/fan-out-scripted.ts +0 -77
- package/src/internal/fan-out.ts +0 -1148
- package/src/internal/fingerprint.ts +0 -89
- package/src/internal/fixtures.ts +0 -148
- package/src/internal/github-env.ts +0 -164
- package/src/internal/github.ts +0 -1218
- package/src/internal/ignore.ts +0 -88
- package/src/internal/logging.ts +0 -124
- package/src/internal/profiles.ts +0 -91
- package/src/internal/progress.ts +0 -433
- package/src/internal/providers.ts +0 -133
- package/src/internal/render.ts +0 -819
- package/src/internal/retirement.ts +0 -337
- package/src/internal/review-agent.ts +0 -543
- package/src/internal/review-state.ts +0 -782
- package/src/internal/review-units.ts +0 -493
- package/src/internal/run.ts +0 -611
- package/src/internal/scripted.ts +0 -108
- package/src/internal/source.ts +0 -110
- package/src/testing.ts +0 -8
|
@@ -1,493 +0,0 @@
|
|
|
1
|
-
import { Schema } from "effect";
|
|
2
|
-
|
|
3
|
-
import type { ChangedFile } from "./diff.ts";
|
|
4
|
-
import { ChangedPath, isReviewableFile } from "./diff.ts";
|
|
5
|
-
import {
|
|
6
|
-
fileReviewEvidenceChunks,
|
|
7
|
-
type FindingSeverity,
|
|
8
|
-
MAX_PATCH_CHARS,
|
|
9
|
-
type ReviewConcern,
|
|
10
|
-
type ReviewFinding,
|
|
11
|
-
} from "./review-agent.ts";
|
|
12
|
-
|
|
13
|
-
// ---------------------------------------------------------------------------
|
|
14
|
-
// Pure, deterministic planning for the fan-out reviewer: group the changeset
|
|
15
|
-
// into bounded review units (the work one delegated child reviews) and merge
|
|
16
|
-
// the children's findings back into one bounded review. Both operations are
|
|
17
|
-
// plain functions so tests pin them directly and the coordinator's tool
|
|
18
|
-
// surface stays deterministic — grouping is an algorithm, not model prose.
|
|
19
|
-
// ---------------------------------------------------------------------------
|
|
20
|
-
|
|
21
|
-
/** The delegation fan-out bound: one parent Run spawns at most this many children. */
|
|
22
|
-
export const MAX_REVIEW_UNITS = 8;
|
|
23
|
-
|
|
24
|
-
/** A unit never carries more files than this, regardless of their size. */
|
|
25
|
-
export const MAX_UNIT_FILES = 12;
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Bound the complete model-visible evidence assigned to one child. This is a
|
|
29
|
-
* character bound rather than a token estimate because it is deterministic,
|
|
30
|
-
* provider-independent, and enforced before any model call.
|
|
31
|
-
*/
|
|
32
|
-
export const UNIT_EVIDENCE_CHAR_BUDGET = 240_000;
|
|
33
|
-
|
|
34
|
-
/** Maximum complete evidence shards placed in one child brief. */
|
|
35
|
-
export const MAX_UNIT_EVIDENCE_SHARDS = 12;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Keep overflow diagnostics bounded to one plan's total assignment capacity.
|
|
39
|
-
* The plan separately records the exact overflow count and every affected
|
|
40
|
-
* path, so identifiers are a deterministic diagnostic sample rather than the
|
|
41
|
-
* authority for whether input coverage is complete.
|
|
42
|
-
*/
|
|
43
|
-
export const MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS = MAX_REVIEW_UNITS * MAX_UNIT_EVIDENCE_SHARDS;
|
|
44
|
-
|
|
45
|
-
/** The merged review never exceeds the `CodeReview` findings bound. */
|
|
46
|
-
export const MAX_MERGED_FINDINGS = 20;
|
|
47
|
-
|
|
48
|
-
export const ReviewUnitId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
|
|
49
|
-
|
|
50
|
-
/** High-risk surfaces that receive an explicit specialist focus label. */
|
|
51
|
-
export const ReviewRiskCategory = Schema.Literals([
|
|
52
|
-
"authentication-authorization",
|
|
53
|
-
"security-boundary",
|
|
54
|
-
"persistence-durability",
|
|
55
|
-
"concurrency",
|
|
56
|
-
"credential-handling",
|
|
57
|
-
"external-side-effects",
|
|
58
|
-
]);
|
|
59
|
-
export type ReviewRiskCategory = typeof ReviewRiskCategory.Type;
|
|
60
|
-
|
|
61
|
-
export const ReviewDiscoveryPerspective = Schema.Literals(["general", "risk-specialist"]);
|
|
62
|
-
export type ReviewDiscoveryPerspective = typeof ReviewDiscoveryPerspective.Type;
|
|
63
|
-
|
|
64
|
-
export const ReviewPassId = Schema.NonEmptyString.check(Schema.isMaxLength(64));
|
|
65
|
-
export const ReviewEvidenceShardId = Schema.NonEmptyString.check(Schema.isMaxLength(32));
|
|
66
|
-
|
|
67
|
-
/** One complete bounded slice of a changed path's model-visible evidence. */
|
|
68
|
-
export class ReviewEvidenceShard extends Schema.Class<ReviewEvidenceShard>(
|
|
69
|
-
"@effect-agent/pr-review/ReviewEvidenceShard",
|
|
70
|
-
)({
|
|
71
|
-
shardId: ReviewEvidenceShardId,
|
|
72
|
-
path: ChangedPath,
|
|
73
|
-
ordinal: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
|
74
|
-
total: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
|
|
75
|
-
evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(
|
|
76
|
-
Schema.isLessThanOrEqualTo(MAX_PATCH_CHARS),
|
|
77
|
-
),
|
|
78
|
-
}) {}
|
|
79
|
-
|
|
80
|
-
const EvidenceShardIds = Schema.Array(ReviewEvidenceShardId)
|
|
81
|
-
.check(Schema.isMinLength(1))
|
|
82
|
-
.check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS));
|
|
83
|
-
|
|
84
|
-
/** One required, independently scoped discovery attempt. */
|
|
85
|
-
export class ReviewDiscoveryPass extends Schema.Class<ReviewDiscoveryPass>(
|
|
86
|
-
"@effect-agent/pr-review/ReviewDiscoveryPass",
|
|
87
|
-
)({
|
|
88
|
-
passId: ReviewPassId,
|
|
89
|
-
unitId: ReviewUnitId,
|
|
90
|
-
paths: Schema.Array(ChangedPath)
|
|
91
|
-
.check(Schema.isMinLength(1))
|
|
92
|
-
.check(Schema.isMaxLength(MAX_UNIT_FILES)),
|
|
93
|
-
evidenceShardIds: EvidenceShardIds,
|
|
94
|
-
perspective: ReviewDiscoveryPerspective,
|
|
95
|
-
/** Empty for the general pass; explicit deterministic focus for specialists. */
|
|
96
|
-
riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6)),
|
|
97
|
-
}) {}
|
|
98
|
-
|
|
99
|
-
/** One bounded slice of the changeset delegated to one child reviewer. */
|
|
100
|
-
export class ReviewUnit extends Schema.Class<ReviewUnit>("@effect-agent/pr-review/ReviewUnit")({
|
|
101
|
-
unitId: ReviewUnitId,
|
|
102
|
-
paths: Schema.Array(ChangedPath)
|
|
103
|
-
.check(Schema.isMinLength(1))
|
|
104
|
-
.check(Schema.isMaxLength(MAX_UNIT_FILES)),
|
|
105
|
-
evidenceShards: Schema.Array(ReviewEvidenceShard)
|
|
106
|
-
.check(Schema.isMinLength(1))
|
|
107
|
-
.check(Schema.isMaxLength(MAX_UNIT_EVIDENCE_SHARDS)),
|
|
108
|
-
/** additions + deletions across the unit's files, for honest sizing. */
|
|
109
|
-
changedLines: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
110
|
-
/** Complete model-visible diff/content evidence assigned to each child. */
|
|
111
|
-
evidenceChars: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)).check(
|
|
112
|
-
Schema.isLessThanOrEqualTo(UNIT_EVIDENCE_CHAR_BUDGET),
|
|
113
|
-
),
|
|
114
|
-
/** Host-classified focus labels for the unit's redundant specialist pass. */
|
|
115
|
-
riskCategories: Schema.Array(ReviewRiskCategory).check(Schema.isMaxLength(6)),
|
|
116
|
-
}) {}
|
|
117
|
-
|
|
118
|
-
/** The complete deterministic fan-out plan over one changeset. */
|
|
119
|
-
export class ReviewUnitPlan extends Schema.Class<ReviewUnitPlan>(
|
|
120
|
-
"@effect-agent/pr-review/ReviewUnitPlan",
|
|
121
|
-
)({
|
|
122
|
-
totalFiles: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
123
|
-
/** True when the source returned fewer files than the pull request has. */
|
|
124
|
-
truncated: Schema.Boolean,
|
|
125
|
-
units: Schema.Array(ReviewUnit).check(Schema.isMaxLength(MAX_REVIEW_UNITS)),
|
|
126
|
-
/** Exact discovery calls the coordinator must make. */
|
|
127
|
-
discoveryPasses: Schema.Array(ReviewDiscoveryPass).check(
|
|
128
|
-
Schema.isMaxLength(MAX_REVIEW_UNITS * 2),
|
|
129
|
-
),
|
|
130
|
-
/** Changed files with neither a textual diff nor bounded base/head text. */
|
|
131
|
-
undiffablePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
|
|
132
|
-
/** Assigned paths with one or more evidence shards beyond plan capacity. */
|
|
133
|
-
partialEvidencePaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
|
|
134
|
-
/** Exact number of shards beyond the bounded unit capacity. */
|
|
135
|
-
unassignedEvidenceShardCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
136
|
-
/** Bounded deterministic prefix of the unassigned shard identifiers. */
|
|
137
|
-
unassignedEvidenceShardIds: Schema.Array(ReviewEvidenceShardId).check(
|
|
138
|
-
Schema.isMaxLength(MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS),
|
|
139
|
-
),
|
|
140
|
-
/**
|
|
141
|
-
* Diffable files beyond the fan-out capacity (MAX_REVIEW_UNITS units of
|
|
142
|
-
* MAX_UNIT_FILES files). Never silently dropped: the coordinator must name
|
|
143
|
-
* them as unreviewed in its summary.
|
|
144
|
-
*/
|
|
145
|
-
unassignedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(300)),
|
|
146
|
-
}) {}
|
|
147
|
-
|
|
148
|
-
const riskRules: ReadonlyArray<{
|
|
149
|
-
readonly category: ReviewRiskCategory;
|
|
150
|
-
readonly patterns: ReadonlyArray<RegExp>;
|
|
151
|
-
}> = [
|
|
152
|
-
{
|
|
153
|
-
category: "authentication-authorization",
|
|
154
|
-
patterns: [/auth/, /authoriz/, /permission/, /principal/, /access[-_ ]?control/, /role\b/],
|
|
155
|
-
},
|
|
156
|
-
{
|
|
157
|
-
category: "security-boundary",
|
|
158
|
-
patterns: [
|
|
159
|
-
/security/,
|
|
160
|
-
/sandbox/,
|
|
161
|
-
/untrusted/,
|
|
162
|
-
/schema\.decode/,
|
|
163
|
-
/validation/,
|
|
164
|
-
/injection/,
|
|
165
|
-
/csrf/,
|
|
166
|
-
/xss/,
|
|
167
|
-
/path traversal/,
|
|
168
|
-
],
|
|
169
|
-
},
|
|
170
|
-
{
|
|
171
|
-
category: "persistence-durability",
|
|
172
|
-
patterns: [
|
|
173
|
-
/durab/,
|
|
174
|
-
/persist/,
|
|
175
|
-
/storage/,
|
|
176
|
-
/database/,
|
|
177
|
-
/\bsql\b/,
|
|
178
|
-
/journal/,
|
|
179
|
-
/ledger/,
|
|
180
|
-
/checkpoint/,
|
|
181
|
-
/migration/,
|
|
182
|
-
/transaction/,
|
|
183
|
-
],
|
|
184
|
-
},
|
|
185
|
-
{
|
|
186
|
-
category: "concurrency",
|
|
187
|
-
patterns: [
|
|
188
|
-
/concurr/,
|
|
189
|
-
/semaphore/,
|
|
190
|
-
/\bfiber/,
|
|
191
|
-
/race/,
|
|
192
|
-
/mutex/,
|
|
193
|
-
/\block\b/,
|
|
194
|
-
/queue/,
|
|
195
|
-
/parallel/,
|
|
196
|
-
/interrupt/,
|
|
197
|
-
],
|
|
198
|
-
},
|
|
199
|
-
{
|
|
200
|
-
category: "credential-handling",
|
|
201
|
-
patterns: [/credential/, /secret/, /password/, /api[-_ ]?key/, /bearer/, /hmac/, /signature/],
|
|
202
|
-
},
|
|
203
|
-
{
|
|
204
|
-
category: "external-side-effects",
|
|
205
|
-
patterns: [
|
|
206
|
-
/publish/,
|
|
207
|
-
/webhook/,
|
|
208
|
-
/github/,
|
|
209
|
-
/fetch\(/,
|
|
210
|
-
/http/,
|
|
211
|
-
/send[-_ ]?(email|message)/,
|
|
212
|
-
/write[-_ ]?(file|record)/,
|
|
213
|
-
/delete/,
|
|
214
|
-
/mutation/,
|
|
215
|
-
/side[-_ ]?effect/,
|
|
216
|
-
/spawn/,
|
|
217
|
-
/exec/,
|
|
218
|
-
],
|
|
219
|
-
},
|
|
220
|
-
];
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Deterministic host policy for specialist assignment. It intentionally
|
|
224
|
-
* favors false positives: an extra bounded pass costs work, while a missed
|
|
225
|
-
* high-risk classification removes redundancy. This is not a claim that the
|
|
226
|
-
* keyword policy recognizes every semantically risky change.
|
|
227
|
-
*/
|
|
228
|
-
export const classifyReviewRisks = (file: ChangedFile): ReadonlyArray<ReviewRiskCategory> => {
|
|
229
|
-
const text = [
|
|
230
|
-
file.path,
|
|
231
|
-
file.previousPath ?? "",
|
|
232
|
-
file.patch ?? "",
|
|
233
|
-
file.reviewBaseContent ?? "",
|
|
234
|
-
file.reviewHeadContent ?? "",
|
|
235
|
-
]
|
|
236
|
-
.join("\n")
|
|
237
|
-
.toLowerCase();
|
|
238
|
-
return riskRules
|
|
239
|
-
.filter((rule) => rule.patterns.some((pattern) => pattern.test(text)))
|
|
240
|
-
.map((rule) => rule.category);
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* Whether every claimed finding anchor was present in the exact bounded
|
|
245
|
-
* evidence shards assigned to one unit. This is stricter than checking the
|
|
246
|
-
* full pull-request diff when an oversized path spans multiple units.
|
|
247
|
-
*/
|
|
248
|
-
export const findingAnchorInUnitEvidence = (
|
|
249
|
-
finding: ReviewFinding,
|
|
250
|
-
unit: ReviewUnit,
|
|
251
|
-
files: ReadonlyArray<ChangedFile>,
|
|
252
|
-
): boolean => {
|
|
253
|
-
const file = files.find((candidate) => candidate.path === finding.path);
|
|
254
|
-
if (file?.patch === undefined || finding.endLine < finding.startLine) return false;
|
|
255
|
-
const assignedOrdinals = new Set(
|
|
256
|
-
unit.evidenceShards
|
|
257
|
-
.filter((shard) => shard.path === finding.path)
|
|
258
|
-
.map((shard) => shard.ordinal),
|
|
259
|
-
);
|
|
260
|
-
const visibleLines = new Set<number>();
|
|
261
|
-
const chunks = fileReviewEvidenceChunks(file);
|
|
262
|
-
for (let index = 0; index < chunks.length; index += 1) {
|
|
263
|
-
if (!assignedOrdinals.has(index + 1)) continue;
|
|
264
|
-
for (const line of chunks[index]?.annotatedPatch.split("\n") ?? []) {
|
|
265
|
-
const match = /^R(\d+) /.exec(line);
|
|
266
|
-
if (match?.[1] !== undefined) visibleLines.add(Number(match[1]));
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
for (let line = finding.startLine; line <= finding.endLine; line += 1) {
|
|
270
|
-
if (!visibleLines.has(line)) return false;
|
|
271
|
-
}
|
|
272
|
-
return true;
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
interface PlannedEvidenceShard {
|
|
276
|
-
readonly shard: ReviewEvidenceShard;
|
|
277
|
-
readonly file: ChangedFile;
|
|
278
|
-
readonly changedLines: number;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const uniquePaths = (shards: ReadonlyArray<PlannedEvidenceShard>): ReadonlyArray<string> => [
|
|
282
|
-
...new Set(shards.map(({ shard }) => shard.path)),
|
|
283
|
-
];
|
|
284
|
-
|
|
285
|
-
const plannedEvidenceShards = (
|
|
286
|
-
files: ReadonlyArray<ChangedFile>,
|
|
287
|
-
): ReadonlyArray<PlannedEvidenceShard> => {
|
|
288
|
-
const planned: Array<PlannedEvidenceShard> = [];
|
|
289
|
-
let shardIndex = 0;
|
|
290
|
-
for (const file of files) {
|
|
291
|
-
const chunks = fileReviewEvidenceChunks(file);
|
|
292
|
-
for (let index = 0; index < chunks.length; index += 1) {
|
|
293
|
-
const chunk = chunks[index];
|
|
294
|
-
if (chunk === undefined) continue;
|
|
295
|
-
shardIndex += 1;
|
|
296
|
-
planned.push({
|
|
297
|
-
shard: ReviewEvidenceShard.make({
|
|
298
|
-
shardId: `shard-${String(shardIndex).padStart(4, "0")}`,
|
|
299
|
-
path: file.path,
|
|
300
|
-
ordinal: index + 1,
|
|
301
|
-
total: chunks.length,
|
|
302
|
-
evidenceChars: chunk.annotatedPatch.length,
|
|
303
|
-
}),
|
|
304
|
-
file,
|
|
305
|
-
changedLines: index === 0 ? file.additions + file.deletions : 0,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
return planned;
|
|
310
|
-
};
|
|
311
|
-
|
|
312
|
-
const unitOf = (index: number, shards: ReadonlyArray<PlannedEvidenceShard>): ReviewUnit =>
|
|
313
|
-
ReviewUnit.make({
|
|
314
|
-
unitId: `unit-${String(index + 1).padStart(3, "0")}`,
|
|
315
|
-
paths: uniquePaths(shards),
|
|
316
|
-
evidenceShards: shards.map(({ shard }) => shard),
|
|
317
|
-
changedLines: shards.reduce((total, shard) => total + shard.changedLines, 0),
|
|
318
|
-
evidenceChars: shards.reduce((total, { shard }) => total + shard.evidenceChars, 0),
|
|
319
|
-
riskCategories: [...new Set(shards.flatMap(({ file }) => classifyReviewRisks(file)))],
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
const discoveryPassesFor = (units: ReadonlyArray<ReviewUnit>): ReadonlyArray<ReviewDiscoveryPass> =>
|
|
323
|
-
units.flatMap((unit) => [
|
|
324
|
-
ReviewDiscoveryPass.make({
|
|
325
|
-
passId: `${unit.unitId}-general`,
|
|
326
|
-
unitId: unit.unitId,
|
|
327
|
-
paths: unit.paths,
|
|
328
|
-
evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
|
|
329
|
-
perspective: "general",
|
|
330
|
-
riskCategories: [],
|
|
331
|
-
}),
|
|
332
|
-
ReviewDiscoveryPass.make({
|
|
333
|
-
passId: `${unit.unitId}-specialist`,
|
|
334
|
-
unitId: unit.unitId,
|
|
335
|
-
paths: unit.paths,
|
|
336
|
-
evidenceShardIds: unit.evidenceShards.map((shard) => shard.shardId),
|
|
337
|
-
perspective: "risk-specialist",
|
|
338
|
-
riskCategories: unit.riskCategories,
|
|
339
|
-
}),
|
|
340
|
-
]);
|
|
341
|
-
|
|
342
|
-
/**
|
|
343
|
-
* Group the changeset into at most `MAX_REVIEW_UNITS` review units.
|
|
344
|
-
*
|
|
345
|
-
* Deterministic by construction: files are ordered by path (so files sharing
|
|
346
|
-
* a directory become neighbors — directory affinity without a heuristic),
|
|
347
|
-
* then split into complete line-bounded evidence shards and packed greedily
|
|
348
|
-
* under the hard evidence and per-unit shard bounds. Capacity is finite and
|
|
349
|
-
* explicit:
|
|
350
|
-
*
|
|
351
|
-
* - files without a textual diff are still delegated when the source
|
|
352
|
-
* recovered complete bounded UTF-8 base/head content. Findings from that
|
|
353
|
-
* evidence cannot anchor inline and are reported as concerns;
|
|
354
|
-
* - files with neither form of textual evidence surface in
|
|
355
|
-
* `undiffablePaths` instead of laundering missing coverage;
|
|
356
|
-
* - an oversized path spans as many deterministic shards and units as needed;
|
|
357
|
-
* - shards beyond `MAX_REVIEW_UNITS` full units surface explicitly, so a path
|
|
358
|
-
* is partial only when finite plan capacity is genuinely exhausted.
|
|
359
|
-
*/
|
|
360
|
-
export const planReviewUnits = (
|
|
361
|
-
files: ReadonlyArray<ChangedFile>,
|
|
362
|
-
options: { readonly totalChangedFiles: number },
|
|
363
|
-
): ReviewUnitPlan => {
|
|
364
|
-
const ordered = [...files].sort((left, right) => (left.path < right.path ? -1 : 1));
|
|
365
|
-
const reviewable = ordered.filter(isReviewableFile);
|
|
366
|
-
const undiffable = ordered.filter((file) => !isReviewableFile(file));
|
|
367
|
-
|
|
368
|
-
const shards = plannedEvidenceShards(reviewable);
|
|
369
|
-
const groups: Array<Array<PlannedEvidenceShard>> = [];
|
|
370
|
-
const unassigned: Array<PlannedEvidenceShard> = [];
|
|
371
|
-
let current: Array<PlannedEvidenceShard> = [];
|
|
372
|
-
let currentEvidenceChars = 0;
|
|
373
|
-
for (const shard of shards) {
|
|
374
|
-
const nextPaths = new Set([...uniquePaths(current), shard.shard.path]);
|
|
375
|
-
const wouldOverflow =
|
|
376
|
-
current.length >= MAX_UNIT_EVIDENCE_SHARDS ||
|
|
377
|
-
nextPaths.size > MAX_UNIT_FILES ||
|
|
378
|
-
(current.length > 0 &&
|
|
379
|
-
currentEvidenceChars + shard.shard.evidenceChars > UNIT_EVIDENCE_CHAR_BUDGET);
|
|
380
|
-
if (wouldOverflow) {
|
|
381
|
-
groups.push(current);
|
|
382
|
-
current = [];
|
|
383
|
-
currentEvidenceChars = 0;
|
|
384
|
-
}
|
|
385
|
-
if (groups.length >= MAX_REVIEW_UNITS) {
|
|
386
|
-
unassigned.push(shard);
|
|
387
|
-
continue;
|
|
388
|
-
}
|
|
389
|
-
current.push(shard);
|
|
390
|
-
currentEvidenceChars += shard.shard.evidenceChars;
|
|
391
|
-
}
|
|
392
|
-
if (current.length > 0 && groups.length < MAX_REVIEW_UNITS) {
|
|
393
|
-
groups.push(current);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
const units = groups.map((group, index) => unitOf(index, group));
|
|
397
|
-
const assignedShardIds = new Set(
|
|
398
|
-
units.flatMap((unit) => unit.evidenceShards.map((shard) => shard.shardId)),
|
|
399
|
-
);
|
|
400
|
-
const assignedPaths = new Set(
|
|
401
|
-
shards
|
|
402
|
-
.filter(({ shard }) => assignedShardIds.has(shard.shardId))
|
|
403
|
-
.map(({ shard }) => shard.path),
|
|
404
|
-
);
|
|
405
|
-
const unassignedPathsWithEvidence = new Set(unassigned.map(({ shard }) => shard.path));
|
|
406
|
-
return ReviewUnitPlan.make({
|
|
407
|
-
totalFiles: files.length,
|
|
408
|
-
truncated: files.length < options.totalChangedFiles,
|
|
409
|
-
units,
|
|
410
|
-
discoveryPasses: discoveryPassesFor(units),
|
|
411
|
-
undiffablePaths: undiffable.map((file) => file.path),
|
|
412
|
-
partialEvidencePaths: [...unassignedPathsWithEvidence].filter((path) =>
|
|
413
|
-
assignedPaths.has(path),
|
|
414
|
-
),
|
|
415
|
-
unassignedEvidenceShardCount: unassigned.length,
|
|
416
|
-
unassignedEvidenceShardIds: unassigned
|
|
417
|
-
.slice(0, MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS)
|
|
418
|
-
.map(({ shard }) => shard.shardId),
|
|
419
|
-
unassignedPaths: [...unassignedPathsWithEvidence].filter((path) => !assignedPaths.has(path)),
|
|
420
|
-
});
|
|
421
|
-
};
|
|
422
|
-
|
|
423
|
-
const severityRank: Record<FindingSeverity, number> = {
|
|
424
|
-
blocking: 0,
|
|
425
|
-
important: 1,
|
|
426
|
-
nit: 2,
|
|
427
|
-
};
|
|
428
|
-
|
|
429
|
-
const anchorKey = (finding: ReviewFinding): string =>
|
|
430
|
-
`${finding.path} ${finding.startLine} ${finding.endLine}`;
|
|
431
|
-
|
|
432
|
-
/**
|
|
433
|
-
* Merge the children's findings into one bounded, deterministic list: dedupe
|
|
434
|
-
* findings sharing an anchor (path + line range) keeping the most severe —
|
|
435
|
-
* and, at equal severity, the first in declaration order — then rank by
|
|
436
|
-
* severity, path, and line, and cap at the `CodeReview` findings bound.
|
|
437
|
-
* This is the merge policy the coordinator's instructions state in prose;
|
|
438
|
-
* pinning it here keeps the policy itself deterministic and testable.
|
|
439
|
-
*/
|
|
440
|
-
export const rankAndDedupeFindings = (
|
|
441
|
-
findings: ReadonlyArray<ReviewFinding>,
|
|
442
|
-
): ReadonlyArray<ReviewFinding> => {
|
|
443
|
-
const byAnchor = new Map<string, ReviewFinding>();
|
|
444
|
-
for (const finding of findings) {
|
|
445
|
-
const key = anchorKey(finding);
|
|
446
|
-
const existing = byAnchor.get(key);
|
|
447
|
-
if (
|
|
448
|
-
existing === undefined ||
|
|
449
|
-
severityRank[finding.severity] < severityRank[existing.severity]
|
|
450
|
-
) {
|
|
451
|
-
byAnchor.set(key, finding);
|
|
452
|
-
}
|
|
453
|
-
}
|
|
454
|
-
return [...byAnchor.values()]
|
|
455
|
-
.sort((left, right) => {
|
|
456
|
-
const bySeverity = severityRank[left.severity] - severityRank[right.severity];
|
|
457
|
-
if (bySeverity !== 0) return bySeverity;
|
|
458
|
-
if (left.path !== right.path) return left.path < right.path ? -1 : 1;
|
|
459
|
-
return left.startLine - right.startLine;
|
|
460
|
-
})
|
|
461
|
-
.slice(0, MAX_MERGED_FINDINGS);
|
|
462
|
-
};
|
|
463
|
-
|
|
464
|
-
/**
|
|
465
|
-
* Stable identity for one concern. The paths are part of the claim: identical
|
|
466
|
-
* prose about two independent files must not collapse into one item.
|
|
467
|
-
*/
|
|
468
|
-
export const reviewConcernKey = (concern: ReviewConcern): string =>
|
|
469
|
-
`${(concern.evidencePaths ?? []).join("\u0000")}\u0001${concern.title}\u0000${concern.body}`;
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* The concern analogue of `rankAndDedupeFindings`: dedupe by exact scoped
|
|
473
|
-
* content keeping the most severe duplicate, rank by severity, and cap at the
|
|
474
|
-
* `CodeReview` concerns bound.
|
|
475
|
-
*/
|
|
476
|
-
export const rankAndDedupeConcerns = (
|
|
477
|
-
concerns: ReadonlyArray<ReviewConcern>,
|
|
478
|
-
): ReadonlyArray<ReviewConcern> => {
|
|
479
|
-
const byContent = new Map<string, ReviewConcern>();
|
|
480
|
-
for (const concern of concerns) {
|
|
481
|
-
const key = reviewConcernKey(concern);
|
|
482
|
-
const previous = byContent.get(key);
|
|
483
|
-
if (
|
|
484
|
-
previous === undefined ||
|
|
485
|
-
severityRank[concern.severity] < severityRank[previous.severity]
|
|
486
|
-
) {
|
|
487
|
-
byContent.set(key, concern);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
return [...byContent.values()]
|
|
491
|
-
.sort((left, right) => severityRank[left.severity] - severityRank[right.severity])
|
|
492
|
-
.slice(0, 10);
|
|
493
|
-
};
|