@effect-agent/pr-review 0.1.0-beta.24 → 0.1.0-beta.25
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
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Context, DateTime, Effect, Option, Schema } from "effect";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
+
adjudicationIdentity,
|
|
5
|
+
findingIdentity,
|
|
4
6
|
ReviewStateAuthenticator,
|
|
5
7
|
type ReviewState,
|
|
6
8
|
type StoredReviewFinding,
|
|
@@ -92,20 +94,10 @@ export interface ReviewRetirementDecision {
|
|
|
92
94
|
readonly priorFindingCount: number;
|
|
93
95
|
}
|
|
94
96
|
|
|
95
|
-
const findingIdentity = (finding: {
|
|
96
|
-
readonly path: string;
|
|
97
|
-
readonly startLine: number;
|
|
98
|
-
readonly endLine: number;
|
|
99
|
-
readonly title: string;
|
|
100
|
-
}): string =>
|
|
101
|
-
`${finding.path}\u0000${finding.startLine}\u0000${finding.endLine}\u0000${finding.title}`;
|
|
102
|
-
|
|
103
97
|
const REVIEW_METADATA_PATTERN = /<!-- effect-agent-pr-review metadata\n[\s\S]*?\n-->/g;
|
|
104
98
|
const FINGERPRINT_PATTERN = /<!-- effect-agent-pr-review fingerprint=sha256:[0-9a-f]{64} -->/g;
|
|
105
|
-
// Version-agnostic on purpose: retirement only RECOGNIZES machine markers to
|
|
106
|
-
// keep them byte-identical through an edit; it never authenticates them.
|
|
107
99
|
const STATE_PATTERN =
|
|
108
|
-
/<!-- effect-agent-pr-review state-
|
|
100
|
+
/<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->/g;
|
|
109
101
|
const RETIRED_ORIGINAL_PATTERN =
|
|
110
102
|
/<!-- effect-agent-pr-review retired-original:start -->\n([\s\S]*?)\n<!-- effect-agent-pr-review retired-original:end -->/;
|
|
111
103
|
const MACHINE_COMMENT_PATTERN = new RegExp(
|
|
@@ -114,11 +106,12 @@ const MACHINE_COMMENT_PATTERN = new RegExp(
|
|
|
114
106
|
);
|
|
115
107
|
const VERDICT_CALLOUT_PATTERN =
|
|
116
108
|
/^(?:> \[!(?:CAUTION|IMPORTANT)\]\n> [^\n]*(?:\n> [^\n]*)*|> (?:ℹ️|✅)[^\n]*)\n*/;
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
109
|
+
/**
|
|
110
|
+
* The first line of every inline finding comment this package posts. Shared
|
|
111
|
+
* with adjudication so both parse the identical title shape.
|
|
112
|
+
*/
|
|
113
|
+
export const INLINE_FINDING_TITLE_PATTERN =
|
|
114
|
+
/^\*\*\[(?:🛑 blocking|⚠️ important|💅 nit) · [a-z-]+\] ([^\n]+)\*\*$/;
|
|
122
115
|
const MAX_REVIEW_BODY_CHARS = 60_000;
|
|
123
116
|
|
|
124
117
|
/** The host-authored metadata marker is the authority gate for any edit. */
|
|
@@ -191,8 +184,14 @@ export const decideReviewRetirement = (input: {
|
|
|
191
184
|
readonly currentReviewUrl: string;
|
|
192
185
|
}): ReviewRetirementDecision => {
|
|
193
186
|
const current = new Set(input.currentState.unresolvedFindings.map(findingIdentity));
|
|
187
|
+
// Adjudicated identities are distinct from resolved: a maintainer verdict
|
|
188
|
+
// is not a fix, so retirement neither strikes nor minimizes them.
|
|
189
|
+
const adjudicated = new Set(
|
|
190
|
+
(input.currentState.adjudications ?? []).map((entry) => adjudicationIdentity(entry)),
|
|
191
|
+
);
|
|
194
192
|
const resolvedFindings = input.priorState.unresolvedFindings.filter(
|
|
195
|
-
(finding) =>
|
|
193
|
+
(finding) =>
|
|
194
|
+
!current.has(findingIdentity(finding)) && !adjudicated.has(findingIdentity(finding)),
|
|
196
195
|
);
|
|
197
196
|
return {
|
|
198
197
|
body: renderRetiredBody({ ...input, resolvedFindings }),
|
|
@@ -300,6 +300,11 @@ export const ReviewToolkitLayer = ReviewToolkit.toLayer({
|
|
|
300
300
|
// Mission input and review output contracts.
|
|
301
301
|
// ---------------------------------------------------------------------------
|
|
302
302
|
|
|
303
|
+
/** Bounded prior-review context lines injected into reviewer instructions. */
|
|
304
|
+
const ReviewContextLines = Schema.Array(Schema.String.check(Schema.isMaxLength(1_200))).check(
|
|
305
|
+
Schema.isMaxLength(20),
|
|
306
|
+
);
|
|
307
|
+
|
|
303
308
|
export class ReviewMission extends Schema.Class<ReviewMission>(
|
|
304
309
|
"@effect-agent/pr-review/ReviewMission",
|
|
305
310
|
)({
|
|
@@ -310,6 +315,18 @@ export class ReviewMission extends Schema.Class<ReviewMission>(
|
|
|
310
315
|
baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
311
316
|
headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
312
317
|
changedFileCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
|
|
318
|
+
/**
|
|
319
|
+
* Maintainer-adjudicated identities rendered as bounded context lines; the
|
|
320
|
+
* reviewer must not re-raise them without materially new evidence. Absent
|
|
321
|
+
* from fingerprint missions so an adjudication never invalidates the
|
|
322
|
+
* skip-unchanged authority.
|
|
323
|
+
*/
|
|
324
|
+
adjudicatedContext: Schema.optionalKey(ReviewContextLines),
|
|
325
|
+
/**
|
|
326
|
+
* Prior-round findings on re-reviewed scope, rendered as bounded context
|
|
327
|
+
* lines; each must be confirmed, declared fixed, or explicitly withdrawn.
|
|
328
|
+
*/
|
|
329
|
+
priorFindingContext: Schema.optionalKey(ReviewContextLines),
|
|
313
330
|
}) {}
|
|
314
331
|
|
|
315
332
|
export const FindingSeverity = Schema.Literals(["blocking", "important", "nit"]);
|
|
@@ -361,12 +378,18 @@ export type ReviewVerdict = typeof ReviewVerdict.Type;
|
|
|
361
378
|
* A concern with no diff line to anchor to: a missing deletion or cleanup,
|
|
362
379
|
* rollout or migration sequencing, a coverage gap the diff implies but does
|
|
363
380
|
* not add, or a scope question only the author can answer. Rendered as a
|
|
364
|
-
* review-body section
|
|
365
|
-
*
|
|
381
|
+
* review-body section instead of an inline comment. `evidencePaths` binds the
|
|
382
|
+
* concern to changed files so a later incremental review can invalidate and
|
|
383
|
+
* recheck it when any supporting path changes. It remains optional only for
|
|
384
|
+
* decoding review output and continuity state written before path binding was
|
|
385
|
+
* introduced; a pathless concern cannot authorize incremental continuity.
|
|
366
386
|
*/
|
|
367
387
|
export class ReviewConcern extends Schema.Class<ReviewConcern>(
|
|
368
388
|
"@effect-agent/pr-review/ReviewConcern",
|
|
369
389
|
)({
|
|
390
|
+
evidencePaths: Schema.optionalKey(
|
|
391
|
+
Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)),
|
|
392
|
+
),
|
|
370
393
|
severity: FindingSeverity,
|
|
371
394
|
title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
|
|
372
395
|
body: Schema.NonEmptyString.check(Schema.isMaxLength(2_000)),
|
|
@@ -448,6 +471,18 @@ export const makeReviewInstructions =
|
|
|
448
471
|
? `Author description:\n${mission.body}`
|
|
449
472
|
: "The author provided no description.",
|
|
450
473
|
...resolveGuidance(options.guidance, mission),
|
|
474
|
+
...(mission.adjudicatedContext === undefined || mission.adjudicatedContext.length === 0
|
|
475
|
+
? []
|
|
476
|
+
: [
|
|
477
|
+
"A maintainer has adjudicated these previously raised review items (disposition, reason). Do not re-raise them unless you have materially new evidence, and if you do, say explicitly what changed since the adjudication:",
|
|
478
|
+
...mission.adjudicatedContext.map((line) => `- ${line}`),
|
|
479
|
+
]),
|
|
480
|
+
...(mission.priorFindingContext === undefined || mission.priorFindingContext.length === 0
|
|
481
|
+
? []
|
|
482
|
+
: [
|
|
483
|
+
"Your previous review raised these findings on the scope you are re-reviewing. For each, either confirm it still holds, state that it is fixed, or withdraw it; do not demand the opposite of your own prior guidance without explicitly acknowledging the reversal:",
|
|
484
|
+
...mission.priorFindingContext.map((line) => `- ${line}`),
|
|
485
|
+
]),
|
|
451
486
|
"Work in this order:",
|
|
452
487
|
"1. Call list_changed_files once to see the selected input scope. In incremental reviews it is deliberately a subset of the pull request's full diff (totalFiles counts the whole pull request); omitted paths belong to settled prior scope or explicit host exclusions, not to this run.",
|
|
453
488
|
"2. Call read_file_diff for every listed file. A normal diff marks new-version anchors as R<number>; only those numbers are valid startLine/endLine values. When GitHub omitted a diff, the tool may return bounded base/head content marked B/H instead. Review that content, but report its defects as non-anchored concerns because B/H lines cannot anchor GitHub comments. Never anchor a finding to a removed (-), B, or H line.",
|
|
@@ -456,9 +491,9 @@ export const makeReviewInstructions =
|
|
|
456
491
|
"When the diff adds or changes a test, check that it can actually fail: a test that would still pass with the bug present is theatre, not coverage. The usual tell is a loose assertion standing where an exact one belongs — >= or a truthiness check over an expected value, or a snapshot that absorbs whatever it is handed.",
|
|
457
492
|
"Go shallow only when the diff has no behavioral surface at all: doc typos, formatting, lockfile or generated-code regeneration, a mechanical rename. Line count is not the signal — a one-line change to auth, money, SQL, a comparison operator, or a config default is not trivial.",
|
|
458
493
|
"Drop bloat-shaped findings before reporting: defensive checks for cases that cannot happen, abstractions used once, comments restating obvious code, tests asserting tautologies, just-in-case guards. A finding must be sound, correct, and worth acting on; prefer an explicit keep over an invented finding.",
|
|
459
|
-
'5. After collecting anchored findings, deliberately scan for concerns with NO line to point at: deletion or cleanup plans for code the diff replaces, rollout or migration sequencing, coverage gaps the diff implies but does not add, scope questions only the author can answer. Report each as a "concern", never as a finding with an invented anchor
|
|
494
|
+
'5. After collecting anchored findings, deliberately scan for concerns with NO line to point at: deletion or cleanup plans for code the diff replaces, rollout or migration sequencing, coverage gaps the diff implies but does not add, scope questions only the author can answer. Report each as a "concern", never as a finding with an invented anchor. Every concern must list 1-3 exact changed evidencePaths that support it so later incremental reviews can recheck it when those files change. Report none when none exist, and never split one root concern into differently worded restatements.',
|
|
460
495
|
`6. Write a walkthrough: for every file whose evidence you examined, one factual sentence (<= ${MAX_WALKTHROUGH_SUMMARY_CHARS} chars) describing what changed in that file — written for a reader scanning the pull request, never restating the diff line by line. Use only paths from list_changed_files; invented paths are dropped.`,
|
|
461
|
-
'7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string, a changed file path>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for step-5 concerns with no valid anchor — never duplicate a finding here>, "walkthrough": <array, OPTIONAL: [{"path": <string, a changed file path>, "summary": <string, the step-6 sentence>}], one entry per reviewed file>}.',
|
|
496
|
+
'7. Then return ONLY a JSON object — no Markdown fences, no prose before or after — exactly this shape: {"summary": <string, 1-3 paragraphs of overall assessment>, "verdict": <"approve" | "comment" | "request-changes">, "findings": [{"path": <string, a changed file path>, "startLine": <integer, an R-marked new-file line>, "endLine": <integer, >= startLine, same file, R-marked>, "severity": <"blocking" | "important" | "nit">, "category": <OPTIONAL: "correctness" | "security" | "concurrency" | "performance" | "resources" | "error-handling" | "testing" | "maintainability" | "style" | "docs">, "title": <string, <= 120 chars>, "body": <string, why it matters and what to do>, "suggestion": <string, OPTIONAL: replacement text for exactly lines startLine..endLine, ready to commit>}], "concerns": <array, OPTIONAL: [{"evidencePaths": <array of 1-3 exact changed file paths>, "severity": <"blocking" | "important" | "nit">, "title": <string, <= 120 chars>, "body": <string>}], only for step-5 concerns with no valid anchor — never duplicate a finding here>, "walkthrough": <array, OPTIONAL: [{"path": <string, a changed file path>, "summary": <string, the step-6 sentence>}], one entry per reviewed file>}.',
|
|
462
497
|
`Report at most ${maxFindings} findings and at most ${MAX_CONCERNS} concerns; prefer the most important ones. An empty findings array with verdict "approve" is a valid review. Include "suggestion" only when you are confident the replacement compiles and preserves intent; its text must contain the full replacement for every line in the range and nothing else.`,
|
|
463
498
|
'Use verdict "request-changes" only when at least one finding or concern is "blocking". Line anchors you invent will be discarded, so copy R-numbers from read_file_diff output.',
|
|
464
499
|
].join("\n");
|
|
@@ -37,15 +37,94 @@ export class StoredReviewFinding extends Schema.Class<StoredReviewFinding>(
|
|
|
37
37
|
body: StoredText,
|
|
38
38
|
}) {}
|
|
39
39
|
|
|
40
|
-
/** A compact unresolved non-anchored concern
|
|
40
|
+
/** A compact unresolved non-anchored concern with its invalidation paths. */
|
|
41
41
|
export class StoredReviewConcern extends Schema.Class<StoredReviewConcern>(
|
|
42
42
|
"@effect-agent/pr-review/StoredReviewConcern",
|
|
43
43
|
)({
|
|
44
|
+
/** Absent only on legacy state written before concern path binding. */
|
|
45
|
+
evidencePaths: Schema.optionalKey(
|
|
46
|
+
Schema.Array(ChangedPath).check(Schema.isMinLength(1)).check(Schema.isMaxLength(3)),
|
|
47
|
+
),
|
|
44
48
|
severity: FindingSeverity,
|
|
45
49
|
title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
|
|
46
50
|
body: StoredText,
|
|
47
51
|
}) {}
|
|
48
52
|
|
|
53
|
+
/** How a maintainer settled a previously raised finding or concern. */
|
|
54
|
+
export const AdjudicationDisposition = Schema.Literals(["accepted-risk", "refuted", "obsolete"]);
|
|
55
|
+
export type AdjudicationDisposition = typeof AdjudicationDisposition.Type;
|
|
56
|
+
|
|
57
|
+
/** The adjudications bound carried by the ReviewState schema. */
|
|
58
|
+
export const MAX_STORED_ADJUDICATIONS = 20;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One maintainer adjudication of a finding or concern identity. Anchored
|
|
62
|
+
* findings carry their full location identity; unanchored concerns are
|
|
63
|
+
* identified by title alone, so the location fields stay absent.
|
|
64
|
+
*/
|
|
65
|
+
const StoredAdjudicationFields = Schema.Struct({
|
|
66
|
+
path: Schema.optionalKey(ChangedPath),
|
|
67
|
+
startLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
|
|
68
|
+
endLine: Schema.optionalKey(Schema.Int.check(Schema.isGreaterThan(0))),
|
|
69
|
+
title: Schema.NonEmptyString.check(Schema.isMaxLength(120)),
|
|
70
|
+
disposition: AdjudicationDisposition,
|
|
71
|
+
reason: Schema.optionalKey(Schema.NonEmptyString.check(Schema.isMaxLength(300))),
|
|
72
|
+
/** GitHub login of the maintainer whose comment adjudicated the identity. */
|
|
73
|
+
actor: Schema.NonEmptyString.check(Schema.isMaxLength(100)),
|
|
74
|
+
}).check(
|
|
75
|
+
Schema.makeFilter(
|
|
76
|
+
(adjudication) => {
|
|
77
|
+
const locationParts = [
|
|
78
|
+
adjudication.path,
|
|
79
|
+
adjudication.startLine,
|
|
80
|
+
adjudication.endLine,
|
|
81
|
+
].filter((part) => part !== undefined).length;
|
|
82
|
+
return locationParts === 0 || locationParts === 3
|
|
83
|
+
? undefined
|
|
84
|
+
: "path, startLine, and endLine must be either all present or all absent";
|
|
85
|
+
},
|
|
86
|
+
{ title: "adjudication locations are complete or unanchored" },
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
export class StoredAdjudication extends Schema.Class<StoredAdjudication>(
|
|
91
|
+
"@effect-agent/pr-review/StoredAdjudication",
|
|
92
|
+
)(StoredAdjudicationFields) {}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The one finding-identity composition shared by retirement, adjudication,
|
|
96
|
+
* and settlement. A tagged JSON tuple keeps anchored findings in a namespace
|
|
97
|
+
* disjoint from title-only concerns and remains unambiguous even when
|
|
98
|
+
* untrusted path or title text contains delimiter characters.
|
|
99
|
+
*/
|
|
100
|
+
export const findingIdentity = (finding: {
|
|
101
|
+
readonly path: string;
|
|
102
|
+
readonly startLine: number;
|
|
103
|
+
readonly endLine: number;
|
|
104
|
+
readonly title: string;
|
|
105
|
+
}): string =>
|
|
106
|
+
JSON.stringify(["finding", finding.path, finding.startLine, finding.endLine, finding.title]);
|
|
107
|
+
|
|
108
|
+
/** The disjoint title-only identity namespace for unanchored concerns. */
|
|
109
|
+
export const concernIdentity = (concern: { readonly title: string }): string =>
|
|
110
|
+
JSON.stringify(["concern", concern.title]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* An adjudication's identity: the shared finding identity when anchored, the
|
|
114
|
+
* disjoint concern identity when unanchored.
|
|
115
|
+
*/
|
|
116
|
+
export const adjudicationIdentity = (adjudication: StoredAdjudication): string =>
|
|
117
|
+
adjudication.path !== undefined &&
|
|
118
|
+
adjudication.startLine !== undefined &&
|
|
119
|
+
adjudication.endLine !== undefined
|
|
120
|
+
? findingIdentity({
|
|
121
|
+
path: adjudication.path,
|
|
122
|
+
startLine: adjudication.startLine,
|
|
123
|
+
endLine: adjudication.endLine,
|
|
124
|
+
title: adjudication.title,
|
|
125
|
+
})
|
|
126
|
+
: concernIdentity(adjudication);
|
|
127
|
+
|
|
49
128
|
/** The carried-scope bound; a run that cannot fit its leftovers publishes no state. */
|
|
50
129
|
export const MAX_STORED_UNREVIEWED_PATHS = 100;
|
|
51
130
|
|
|
@@ -71,12 +150,11 @@ export class StoredUnreviewedPass extends Schema.Class<StoredUnreviewedPass>(
|
|
|
71
150
|
* carries retryable review gaps (failed passes) forward so the next
|
|
72
151
|
* incremental run re-reviews exactly them plus the new delta — the baseline
|
|
73
152
|
* advances monotonically instead of freezing on one flaky pass and reopening
|
|
74
|
-
* the whole post-baseline scope.
|
|
75
|
-
*
|
|
76
|
-
* separately would not fit GitHub's bounded review body in the worst case.
|
|
153
|
+
* the whole post-baseline scope. Storing hundreds of path strings separately
|
|
154
|
+
* would not fit GitHub's bounded review body in the worst case.
|
|
77
155
|
*/
|
|
78
156
|
export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-review/ReviewState")({
|
|
79
|
-
version: Schema.Literal(
|
|
157
|
+
version: Schema.Literal(1),
|
|
80
158
|
repository: Schema.NonEmptyString.check(Schema.isMaxLength(200)),
|
|
81
159
|
pullRequestNumber: Schema.Int.check(Schema.isGreaterThan(0)),
|
|
82
160
|
baseRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
@@ -84,19 +162,15 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
|
|
|
84
162
|
headRef: Schema.NonEmptyString.check(Schema.isMaxLength(300)),
|
|
85
163
|
reviewedHeadSha: GitCommitSha,
|
|
86
164
|
profileFingerprint: Fingerprint,
|
|
87
|
-
|
|
165
|
+
settledScopeFingerprint: Fingerprint,
|
|
88
166
|
reviewedPathCount: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 300 })),
|
|
89
167
|
unresolvedFindings: Schema.Array(StoredReviewFinding).check(Schema.isMaxLength(20)),
|
|
90
168
|
unresolvedConcerns: Schema.Array(StoredReviewConcern).check(Schema.isMaxLength(10)),
|
|
91
169
|
/** Retryable review gaps carried into the next incremental run's scope. */
|
|
92
170
|
unreviewedPaths: Schema.Array(ChangedPath).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PATHS)),
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
* but cannot skip rediscovery. Present (including empty) on new markers.
|
|
97
|
-
*/
|
|
98
|
-
unreviewedPasses: Schema.optionalKey(
|
|
99
|
-
Schema.Array(StoredUnreviewedPass).check(Schema.isMaxLength(MAX_STORED_UNREVIEWED_PASSES)),
|
|
171
|
+
/** Which failed pass produced those leftovers. */
|
|
172
|
+
unreviewedPasses: Schema.Array(StoredUnreviewedPass).check(
|
|
173
|
+
Schema.isMaxLength(MAX_STORED_UNREVIEWED_PASSES),
|
|
100
174
|
),
|
|
101
175
|
/**
|
|
102
176
|
* True only when the producing run had complete input coverage, no
|
|
@@ -105,6 +179,13 @@ export class ReviewState extends Schema.Class<ReviewState>("@effect-agent/pr-rev
|
|
|
105
179
|
*/
|
|
106
180
|
settled: Schema.Boolean,
|
|
107
181
|
lastReviewMode: ReviewScopeMode,
|
|
182
|
+
/**
|
|
183
|
+
* Maintainer adjudications standing against this pull request. optionalKey
|
|
184
|
+
* so state markers signed before the field existed still decode.
|
|
185
|
+
*/
|
|
186
|
+
adjudications: Schema.optionalKey(
|
|
187
|
+
Schema.Array(StoredAdjudication).check(Schema.isMaxLength(MAX_STORED_ADJUDICATIONS)),
|
|
188
|
+
),
|
|
108
189
|
}) {}
|
|
109
190
|
|
|
110
191
|
export const toStoredFinding = (finding: ReviewFinding): StoredReviewFinding =>
|
|
@@ -129,23 +210,29 @@ export const fromStoredFinding = (finding: StoredReviewFinding): ReviewFinding =
|
|
|
129
210
|
|
|
130
211
|
export const toStoredConcern = (concern: ReviewConcern): StoredReviewConcern =>
|
|
131
212
|
StoredReviewConcern.make({
|
|
213
|
+
...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
|
|
132
214
|
severity: concern.severity,
|
|
133
215
|
title: concern.title,
|
|
134
216
|
body: concern.body.slice(0, 800),
|
|
135
217
|
});
|
|
136
218
|
|
|
137
219
|
export const fromStoredConcern = (concern: StoredReviewConcern): ReviewConcern =>
|
|
138
|
-
ReviewConcern.make({
|
|
220
|
+
ReviewConcern.make({
|
|
221
|
+
...(concern.evidencePaths === undefined ? {} : { evidencePaths: concern.evidencePaths }),
|
|
222
|
+
severity: concern.severity,
|
|
223
|
+
title: concern.title,
|
|
224
|
+
body: concern.body,
|
|
225
|
+
});
|
|
139
226
|
|
|
140
|
-
const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-
|
|
227
|
+
const STATE_MARKER_PREFIX = "<!-- effect-agent-pr-review state-v1:";
|
|
141
228
|
const STATE_MARKER_SUFFIX = " -->";
|
|
142
229
|
const STATE_MARKER_PATTERN =
|
|
143
|
-
/(?:^|\n)<!-- effect-agent-pr-review state-
|
|
144
|
-
const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-
|
|
230
|
+
/(?:^|\n)<!-- effect-agent-pr-review state-v1:([A-Za-z0-9+/]+={0,2})\.([0-9a-f]{64}) -->$/;
|
|
231
|
+
const STATE_SIGNATURE_DOMAIN = "effect-agent-pr-review/state-v1\u0000";
|
|
145
232
|
export const MAX_REVIEW_STATE_MARKER_CHARS = 24_000;
|
|
146
233
|
export const ReviewStateMarker = Schema.NonEmptyString.check(
|
|
147
234
|
Schema.isMaxLength(MAX_REVIEW_STATE_MARKER_CHARS),
|
|
148
|
-
Schema.isPattern(/^<!-- effect-agent-pr-review state-
|
|
235
|
+
Schema.isPattern(/^<!-- effect-agent-pr-review state-v1:[A-Za-z0-9+/]+={0,2}\.[0-9a-f]{64} -->$/),
|
|
149
236
|
).pipe(Schema.brand("@effect-agent/pr-review/ReviewStateMarker"));
|
|
150
237
|
export type ReviewStateMarker = typeof ReviewStateMarker.Type;
|
|
151
238
|
|
|
@@ -324,16 +411,17 @@ export interface ReviewSelection {
|
|
|
324
411
|
readonly totalFiles: number;
|
|
325
412
|
readonly baselineSha: string | undefined;
|
|
326
413
|
readonly priorState: ReviewState | undefined;
|
|
327
|
-
|
|
414
|
+
/** Absent only for an explicit full review with no continuity profile. */
|
|
415
|
+
readonly profileFingerprint: string | undefined;
|
|
328
416
|
/** Action-owned authentication capability, constructed at the composition root. */
|
|
329
417
|
readonly stateAuthenticator?: ReviewStateAuthenticator["Service"] | undefined;
|
|
330
418
|
}
|
|
331
419
|
|
|
332
|
-
const
|
|
420
|
+
export const fullReviewSelection = (input: {
|
|
333
421
|
readonly reason: string;
|
|
334
422
|
readonly files: ReadonlyArray<ChangedFile>;
|
|
335
423
|
readonly totalFiles: number;
|
|
336
|
-
readonly profileFingerprint
|
|
424
|
+
readonly profileFingerprint?: string | undefined;
|
|
337
425
|
}): ReviewSelection => ({
|
|
338
426
|
mode: "full",
|
|
339
427
|
reason: input.reason,
|
|
@@ -380,6 +468,9 @@ export const validateReviewState = (
|
|
|
380
468
|
if (state.profileFingerprint !== profileFingerprint) {
|
|
381
469
|
return "the reviewer profile or model configuration changed";
|
|
382
470
|
}
|
|
471
|
+
if (state.unresolvedConcerns.some((concern) => concern.evidencePaths === undefined)) {
|
|
472
|
+
return "stored concerns predate affected-path tracking";
|
|
473
|
+
}
|
|
383
474
|
return undefined;
|
|
384
475
|
};
|
|
385
476
|
|
|
@@ -400,6 +491,23 @@ const incrementalFromDelta = (input: {
|
|
|
400
491
|
...input.deltaFiles.flatMap(filePaths),
|
|
401
492
|
...(input.extraAffectedPaths ?? []),
|
|
402
493
|
]);
|
|
494
|
+
const initialAffectedCount = affectedPaths.size;
|
|
495
|
+
// Reopen every current path needed to reassess a concern touched by this
|
|
496
|
+
// delta. Repeat to a fixed point because two concerns may overlap on a path.
|
|
497
|
+
let expanded = true;
|
|
498
|
+
while (expanded) {
|
|
499
|
+
expanded = false;
|
|
500
|
+
for (const concern of input.priorState.unresolvedConcerns) {
|
|
501
|
+
const paths = concern.evidencePaths ?? [];
|
|
502
|
+
if (!paths.some((path) => affectedPaths.has(path))) continue;
|
|
503
|
+
for (const path of paths) {
|
|
504
|
+
if (!affectedPaths.has(path)) {
|
|
505
|
+
affectedPaths.add(path);
|
|
506
|
+
expanded = true;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
403
511
|
const selectedByPath = new Map<string, ChangedFile>();
|
|
404
512
|
for (const file of input.deltaFiles) {
|
|
405
513
|
if (
|
|
@@ -410,22 +518,16 @@ const incrementalFromDelta = (input: {
|
|
|
410
518
|
}
|
|
411
519
|
}
|
|
412
520
|
const carriedPaths = input.priorState.unreviewedPaths.filter((path) => currentPaths.has(path));
|
|
413
|
-
const surgical = input.priorState.unreviewedPasses !== undefined;
|
|
414
521
|
const retryOnly = new Set<string>();
|
|
415
522
|
const retryStages = new Set<UnreviewedStage>();
|
|
416
523
|
for (const path of carriedPaths) {
|
|
417
524
|
if (affectedPaths.has(path)) continue;
|
|
418
525
|
retryOnly.add(path);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (pass.paths.includes(path)) retryStages.add(pass.stage);
|
|
422
|
-
}
|
|
423
|
-
} else {
|
|
424
|
-
affectedPaths.add(path);
|
|
526
|
+
for (const pass of input.priorState.unreviewedPasses) {
|
|
527
|
+
if (pass.paths.includes(path)) retryStages.add(pass.stage);
|
|
425
528
|
}
|
|
426
529
|
}
|
|
427
530
|
if (
|
|
428
|
-
surgical &&
|
|
429
531
|
retryStages.has("verification") &&
|
|
430
532
|
!retryStages.has("discovery") &&
|
|
431
533
|
!retryStages.has("specialist")
|
|
@@ -433,35 +535,38 @@ const incrementalFromDelta = (input: {
|
|
|
433
535
|
retryStages.add("discovery");
|
|
434
536
|
retryStages.add("specialist");
|
|
435
537
|
}
|
|
436
|
-
if (
|
|
538
|
+
if (retryOnly.size > 0 && retryStages.size === 0) {
|
|
437
539
|
for (const path of retryOnly) affectedPaths.add(path);
|
|
438
540
|
retryStages.add("discovery");
|
|
439
541
|
retryStages.add("specialist");
|
|
440
542
|
retryStages.add("verification");
|
|
441
543
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (needed) selectedByPath.set(file.path, file);
|
|
450
|
-
}
|
|
544
|
+
for (const file of input.fullFiles) {
|
|
545
|
+
const needed =
|
|
546
|
+
affectedPaths.has(file.path) ||
|
|
547
|
+
(file.previousPath !== undefined && affectedPaths.has(file.previousPath)) ||
|
|
548
|
+
retryOnly.has(file.path) ||
|
|
549
|
+
(file.previousPath !== undefined && retryOnly.has(file.previousPath));
|
|
550
|
+
if (needed) selectedByPath.set(file.path, file);
|
|
451
551
|
}
|
|
452
552
|
const selectedFiles = [...selectedByPath.values()].sort((left, right) =>
|
|
453
553
|
left.path < right.path ? -1 : left.path > right.path ? 1 : 0,
|
|
454
554
|
);
|
|
455
555
|
const leftoverCount = [...retryOnly].filter((path) => !affectedPaths.has(path)).length;
|
|
456
556
|
const carriedReason =
|
|
457
|
-
leftoverCount > 0
|
|
557
|
+
leftoverCount > 0
|
|
458
558
|
? `; retrying ${leftoverCount} unchanged leftover path(s) without rediscovery`
|
|
459
559
|
: carriedPaths.length > 0
|
|
460
560
|
? `; retrying ${carriedPaths.length} carried unreviewed path(s)`
|
|
461
561
|
: "";
|
|
562
|
+
const concernPathCount = affectedPaths.size - initialAffectedCount;
|
|
563
|
+
const concernReason =
|
|
564
|
+
concernPathCount === 0
|
|
565
|
+
? ""
|
|
566
|
+
: `; reopening ${concernPathCount} related concern path(s) for context`;
|
|
462
567
|
return {
|
|
463
568
|
mode: "incremental",
|
|
464
|
-
reason: `${input.reason}${carriedReason}`,
|
|
569
|
+
reason: `${input.reason}${carriedReason}${concernReason}`,
|
|
465
570
|
files: selectedFiles,
|
|
466
571
|
affectedPaths: [...affectedPaths].sort(),
|
|
467
572
|
retryPaths: [...retryOnly].filter((path) => !affectedPaths.has(path)).sort(),
|
|
@@ -491,7 +596,7 @@ export const selectReviewRange = (input: {
|
|
|
491
596
|
readonly lookupFailure?: string | undefined;
|
|
492
597
|
}): ReviewSelection => {
|
|
493
598
|
const full = (reason: string) =>
|
|
494
|
-
|
|
599
|
+
fullReviewSelection({
|
|
495
600
|
reason,
|
|
496
601
|
files: input.fullFiles,
|
|
497
602
|
totalFiles: input.current.totalChangedFiles,
|
|
@@ -560,6 +665,20 @@ export class ReviewExecutionContext extends Context.Service<
|
|
|
560
665
|
ReviewSelection
|
|
561
666
|
>()("@effect-agent/pr-review/ReviewExecutionContext") {}
|
|
562
667
|
|
|
668
|
+
/**
|
|
669
|
+
* Explicit direct-run adapter for callers that intentionally review the full
|
|
670
|
+
* source without authenticated incremental continuity.
|
|
671
|
+
*/
|
|
672
|
+
export const fullReviewExecutionContextLayer = (reason: string) =>
|
|
673
|
+
Layer.effect(
|
|
674
|
+
ReviewExecutionContext,
|
|
675
|
+
Effect.gen(function* () {
|
|
676
|
+
const source = yield* PullRequestSource;
|
|
677
|
+
const [metadata, files] = yield* Effect.all([source.metadata, source.changedFiles]);
|
|
678
|
+
return fullReviewSelection({ reason, files, totalFiles: metadata.totalChangedFiles });
|
|
679
|
+
}),
|
|
680
|
+
);
|
|
681
|
+
|
|
563
682
|
/**
|
|
564
683
|
* Decorate the full source with the selected review range. Full anchor files
|
|
565
684
|
* remain available to host-side publication validation; model tools see only
|
|
@@ -24,9 +24,6 @@ export const MAX_REVIEW_UNITS = 8;
|
|
|
24
24
|
/** A unit never carries more files than this, regardless of their size. */
|
|
25
25
|
export const MAX_UNIT_FILES = 12;
|
|
26
26
|
|
|
27
|
-
/** Compatibility export; complete evidence chars now own unit packing. */
|
|
28
|
-
export const UNIT_CHANGED_LINE_BUDGET = 800;
|
|
29
|
-
|
|
30
27
|
/**
|
|
31
28
|
* Bound the complete model-visible evidence assigned to one child. This is a
|
|
32
29
|
* character bound rather than a token estimate because it is deterministic,
|
|
@@ -45,9 +42,6 @@ export const MAX_UNIT_EVIDENCE_SHARDS = 12;
|
|
|
45
42
|
*/
|
|
46
43
|
export const MAX_REPORTED_UNASSIGNED_EVIDENCE_SHARDS = MAX_REVIEW_UNITS * MAX_UNIT_EVIDENCE_SHARDS;
|
|
47
44
|
|
|
48
|
-
/** @deprecated Use `MAX_PATCH_CHARS`; this is now the per-shard bound. */
|
|
49
|
-
export const MAX_FILE_EVIDENCE_CHARS = MAX_PATCH_CHARS;
|
|
50
|
-
|
|
51
45
|
/** The merged review never exceeds the `CodeReview` findings bound. */
|
|
52
46
|
export const MAX_MERGED_FINDINGS = 20;
|
|
53
47
|
|
|
@@ -468,8 +462,15 @@ export const rankAndDedupeFindings = (
|
|
|
468
462
|
};
|
|
469
463
|
|
|
470
464
|
/**
|
|
471
|
-
*
|
|
472
|
-
*
|
|
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
|
|
473
474
|
* `CodeReview` concerns bound.
|
|
474
475
|
*/
|
|
475
476
|
export const rankAndDedupeConcerns = (
|
|
@@ -477,7 +478,7 @@ export const rankAndDedupeConcerns = (
|
|
|
477
478
|
): ReadonlyArray<ReviewConcern> => {
|
|
478
479
|
const byContent = new Map<string, ReviewConcern>();
|
|
479
480
|
for (const concern of concerns) {
|
|
480
|
-
const key =
|
|
481
|
+
const key = reviewConcernKey(concern);
|
|
481
482
|
const previous = byContent.get(key);
|
|
482
483
|
if (
|
|
483
484
|
previous === undefined ||
|