@mechanai/deepreview 2.2.2 → 2.2.3
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.
|
@@ -77,6 +77,38 @@ line: <line number>
|
|
|
77
77
|
- If the synthesis says "line 42" but looking at the diff that corresponds to a different new-side line, use the correct new-side line
|
|
78
78
|
- If you cannot determine an exact line, use the first line of the relevant function/block
|
|
79
79
|
|
|
80
|
+
## Suggestion block rules
|
|
81
|
+
|
|
82
|
+
When a finding contains a ` ```suggestion ` block, the `startLine..line` anchor MUST cover every line that the suggestion replaces — not just the line referenced in the synthesis.
|
|
83
|
+
|
|
84
|
+
Before writing a suggestion:
|
|
85
|
+
|
|
86
|
+
1. Identify the exact lines in the diff that will be replaced by the suggestion content
|
|
87
|
+
2. Count those lines — this is your replacement scope
|
|
88
|
+
3. Set `startLine` to the first line being replaced and `line` to the last
|
|
89
|
+
4. The anchor range (startLine..line) MUST cover all original lines being removed or modified. The suggestion may be shorter than the anchor range (deletions are fine), but should not be longer — if your replacement has more lines than the anchor covers, widen the anchor to include all affected lines.
|
|
90
|
+
|
|
91
|
+
Example — replacing a 5-line callout block (lines 246-250). Note: use exactly three backticks for suggestion blocks in output — the four-backtick fence below is only for illustration:
|
|
92
|
+
|
|
93
|
+
````
|
|
94
|
+
---
|
|
95
|
+
path: docs/migration.md
|
|
96
|
+
startLine: 246
|
|
97
|
+
line: 250
|
|
98
|
+
---
|
|
99
|
+
```suggestion
|
|
100
|
+
> [!WARNING]
|
|
101
|
+
> The actual default is 1 MB, not 10 MB.
|
|
102
|
+
```
|
|
103
|
+
````
|
|
104
|
+
|
|
105
|
+
Common mistakes to avoid:
|
|
106
|
+
|
|
107
|
+
- Anchoring to a single line inside a multi-line block (e.g., `line: 247` when replacing lines 246-250) — this duplicates surrounding lines when applied
|
|
108
|
+
- Including lines in the suggestion body that already exist outside the anchor range — this creates duplicates
|
|
109
|
+
- If you cannot determine the exact replacement scope from the diff, use prose instead of a suggestion block
|
|
110
|
+
- As a safety net, the posting pipeline strips suggestion blocks that exceed the anchor range — if your suggestion disappears, widen the anchor
|
|
111
|
+
|
|
80
112
|
## Response contract
|
|
81
113
|
|
|
82
114
|
After writing the threads file, your ONLY response must be the absolute path to your output file and a count line (e.g., "12 threads written"). Do not summarize findings.
|
package/package.json
CHANGED
package/src/diff-classifier.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface Finding {
|
|
|
9
9
|
|
|
10
10
|
export interface ClassifiedFinding extends Finding {
|
|
11
11
|
tier: 1 | 2 | 3;
|
|
12
|
+
/** Body with suggestion blocks stripped for rendering. Set for tier-1 findings with oversized suggestions and all tier-2 findings containing suggestions. Original `body` is used for ID computation. Set in review-helpers.ts, not in classifyFindings. */
|
|
13
|
+
renderedBody?: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
interface FileHunks {
|
package/src/post-review.ts
CHANGED
|
@@ -65,7 +65,7 @@ async function updateExistingThreads(
|
|
|
65
65
|
const batch = toUpdate.slice(i, i + CONCURRENCY);
|
|
66
66
|
await Promise.all(
|
|
67
67
|
batch.map(async ({ finding, commentId, id }) => {
|
|
68
|
-
const body = embedFindingId(finding.body, id);
|
|
68
|
+
const body = embedFindingId(finding.renderedBody ?? finding.body, id);
|
|
69
69
|
await updateReviewComment(commentId, body);
|
|
70
70
|
}),
|
|
71
71
|
);
|
|
@@ -147,7 +147,7 @@ async function postInlineThreads(
|
|
|
147
147
|
batch.map(async (f) => {
|
|
148
148
|
const id = findingId(f.path, f.startLine, f.line, f.body);
|
|
149
149
|
// Body rendered by GitHub's Markdown sanitizer; no escaping needed here
|
|
150
|
-
const body = embedFindingId(f.body, id);
|
|
150
|
+
const body = embedFindingId(f.renderedBody ?? f.body, id);
|
|
151
151
|
const ok = await postWithRetry(reviewId, f, body);
|
|
152
152
|
return ok ? null : id;
|
|
153
153
|
}),
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it } from "bun:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { validateSuggestionAnchors, classifyAndLog } from "./review-helpers.ts";
|
|
4
|
+
|
|
5
|
+
describe("validateSuggestionAnchors — no warnings", () => {
|
|
6
|
+
it("returns no warnings for findings without suggestion blocks", () => {
|
|
7
|
+
const findings = [
|
|
8
|
+
{ path: "foo.go", line: 10, body: "This is just prose feedback.", tier: 1 as const },
|
|
9
|
+
];
|
|
10
|
+
const warnings = validateSuggestionAnchors(findings);
|
|
11
|
+
assert.equal(warnings.length, 0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns no warnings when suggestion lines fit within anchor range", () => {
|
|
15
|
+
const body = [
|
|
16
|
+
"Replace this function:",
|
|
17
|
+
"```suggestion",
|
|
18
|
+
"func foo() {",
|
|
19
|
+
" return 42",
|
|
20
|
+
"}",
|
|
21
|
+
"```",
|
|
22
|
+
].join("\n");
|
|
23
|
+
// Anchor covers 3 lines (8..10), suggestion has 3 lines — matches
|
|
24
|
+
const findings = [{ path: "foo.go", startLine: 8, line: 10, body, tier: 1 as const }];
|
|
25
|
+
const warnings = validateSuggestionAnchors(findings);
|
|
26
|
+
assert.equal(warnings.length, 0);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("ignores non-suggestion fenced code blocks", () => {
|
|
30
|
+
const body = [
|
|
31
|
+
"Example:",
|
|
32
|
+
"```go",
|
|
33
|
+
"func foo() {}",
|
|
34
|
+
"func bar() {}",
|
|
35
|
+
"func baz() {}",
|
|
36
|
+
"```",
|
|
37
|
+
].join("\n");
|
|
38
|
+
const findings = [{ path: "foo.go", line: 5, body, tier: 1 as const }];
|
|
39
|
+
const warnings = validateSuggestionAnchors(findings);
|
|
40
|
+
assert.equal(warnings.length, 0);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("skips tier 2 and tier 3 findings", () => {
|
|
44
|
+
const body = ["```suggestion", "a", "b", "c", "```"].join("\n");
|
|
45
|
+
const findings = [
|
|
46
|
+
{ path: "foo.go", line: 5, body, tier: 2 as const },
|
|
47
|
+
{ path: "bar.go", line: 10, body, tier: 3 as const },
|
|
48
|
+
];
|
|
49
|
+
const warnings = validateSuggestionAnchors(findings);
|
|
50
|
+
assert.equal(warnings.length, 0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("validateSuggestionAnchors — warnings", () => {
|
|
55
|
+
it("warns when suggestion has more lines than anchor range", () => {
|
|
56
|
+
const body = [
|
|
57
|
+
"Fix this:",
|
|
58
|
+
"```suggestion",
|
|
59
|
+
"line1",
|
|
60
|
+
"line2",
|
|
61
|
+
"line3",
|
|
62
|
+
"line4",
|
|
63
|
+
"line5",
|
|
64
|
+
"```",
|
|
65
|
+
].join("\n");
|
|
66
|
+
// Anchor is single-line (31), suggestion has 5 lines
|
|
67
|
+
const findings = [{ path: "mise.toml", line: 31, body, tier: 1 as const }];
|
|
68
|
+
const warnings = validateSuggestionAnchors(findings);
|
|
69
|
+
assert.equal(warnings.length, 1);
|
|
70
|
+
assert.ok(warnings[0].includes("mise.toml:31"));
|
|
71
|
+
assert.ok(warnings[0].includes("1-line anchor"));
|
|
72
|
+
assert.ok(warnings[0].includes("5-line suggestion"));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("warns for multi-line anchor that is still too narrow", () => {
|
|
76
|
+
const body = ["```suggestion", "line1", "line2", "line3", "line4", "line5", "```"].join("\n");
|
|
77
|
+
// Anchor covers 2 lines (893..894), suggestion has 5 lines
|
|
78
|
+
const findings = [{ path: "migration.md", startLine: 893, line: 894, body, tier: 1 as const }];
|
|
79
|
+
const warnings = validateSuggestionAnchors(findings);
|
|
80
|
+
assert.equal(warnings.length, 1);
|
|
81
|
+
assert.ok(warnings[0].includes("migration.md:893-894"));
|
|
82
|
+
assert.ok(warnings[0].includes("2-line anchor"));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("handles multiple suggestion blocks — warns based on largest", () => {
|
|
86
|
+
const body = [
|
|
87
|
+
"First fix:",
|
|
88
|
+
"```suggestion",
|
|
89
|
+
"a",
|
|
90
|
+
"b",
|
|
91
|
+
"c",
|
|
92
|
+
"```",
|
|
93
|
+
"Second fix:",
|
|
94
|
+
"```suggestion",
|
|
95
|
+
"x",
|
|
96
|
+
"y",
|
|
97
|
+
"```",
|
|
98
|
+
].join("\n");
|
|
99
|
+
// Single-line anchor, two suggestion blocks (3 lines and 2 lines)
|
|
100
|
+
const findings = [{ path: "foo.go", line: 5, body, tier: 1 as const }];
|
|
101
|
+
const warnings = validateSuggestionAnchors(findings);
|
|
102
|
+
// Should warn — the largest suggestion (3 lines) exceeds the 1-line anchor
|
|
103
|
+
assert.equal(warnings.length, 1);
|
|
104
|
+
assert.ok(warnings[0].includes("3-line suggestion"));
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Minimal diff that puts foo.go line 10 in a hunk (tier 1)
|
|
109
|
+
const DIFF_FOO = `diff --git a/foo.go b/foo.go
|
|
110
|
+
--- a/foo.go
|
|
111
|
+
+++ b/foo.go
|
|
112
|
+
@@ -1,15 +1,15 @@
|
|
113
|
+
line1
|
|
114
|
+
line2
|
|
115
|
+
line3
|
|
116
|
+
line4
|
|
117
|
+
line5
|
|
118
|
+
line6
|
|
119
|
+
line7
|
|
120
|
+
line8
|
|
121
|
+
line9
|
|
122
|
+
-old
|
|
123
|
+
+new
|
|
124
|
+
line11
|
|
125
|
+
line12
|
|
126
|
+
line13
|
|
127
|
+
line14
|
|
128
|
+
line15
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
describe("classifyAndLog — suggestion stripping", () => {
|
|
132
|
+
it("sets renderedBody when suggestion exceeds anchor", () => {
|
|
133
|
+
const body = ["Replace this:", "```suggestion", "line A", "line B", "line C", "```"].join("\n");
|
|
134
|
+
// Single-line anchor (no startLine), 3-line suggestion — exceeds
|
|
135
|
+
const findings = [{ path: "foo.go", line: 10, body }];
|
|
136
|
+
const { tier1 } = classifyAndLog(findings, DIFF_FOO);
|
|
137
|
+
assert.equal(tier1.length, 1);
|
|
138
|
+
assert.notEqual(tier1[0].renderedBody, undefined);
|
|
139
|
+
assert.ok(!tier1[0].renderedBody!.includes("```suggestion"));
|
|
140
|
+
// Original body is untouched
|
|
141
|
+
assert.ok(tier1[0].body.includes("```suggestion"));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("preserves content from unclosed suggestion blocks", () => {
|
|
145
|
+
const body = [
|
|
146
|
+
"Some prose",
|
|
147
|
+
"```suggestion",
|
|
148
|
+
"line A",
|
|
149
|
+
"line B",
|
|
150
|
+
// No closing fence
|
|
151
|
+
].join("\n");
|
|
152
|
+
const findings = [{ path: "foo.go", line: 10, body }];
|
|
153
|
+
const { tier1 } = classifyAndLog(findings, DIFF_FOO);
|
|
154
|
+
assert.equal(tier1.length, 1);
|
|
155
|
+
// renderedBody should contain the unclosed block content
|
|
156
|
+
const rendered = tier1[0].renderedBody ?? tier1[0].body;
|
|
157
|
+
assert.ok(rendered.includes("line A"));
|
|
158
|
+
assert.ok(rendered.includes("line B"));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("handles inverted startLine/line without negative anchorLines", () => {
|
|
162
|
+
const body = ["Fix:", "```suggestion", "x", "```"].join("\n");
|
|
163
|
+
// startLine > line (inverted) — anchorLines should clamp to 1
|
|
164
|
+
const findings = [{ path: "foo.go", startLine: 12, line: 10, body }];
|
|
165
|
+
const { tier1 } = classifyAndLog(findings, DIFF_FOO);
|
|
166
|
+
assert.equal(tier1.length, 1);
|
|
167
|
+
// 1-line suggestion vs clamped 1-line anchor — fits, no stripping
|
|
168
|
+
assert.equal(tier1[0].renderedBody, undefined);
|
|
169
|
+
});
|
|
170
|
+
});
|
package/src/review-helpers.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { classifyFindings, type Finding, type ClassifiedFinding } from "./diff-c
|
|
|
4
4
|
const FINDING_ID_RE = /<!-- finding:([a-f0-9]+) -->/u;
|
|
5
5
|
const AI_TRAILER = "\n\n---\n🤖 Review generated by AI";
|
|
6
6
|
const GITHUB_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u;
|
|
7
|
+
const SUGGESTION_OPEN_RE = /^```suggestion\b/u;
|
|
7
8
|
|
|
8
9
|
export function escapeHtml(s: string): string {
|
|
9
10
|
return s
|
|
@@ -96,9 +97,9 @@ export function buildReviewBody(
|
|
|
96
97
|
}
|
|
97
98
|
if (validOid && validRepo) {
|
|
98
99
|
const permalink = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/blob/${headOid}/${f.path.split("/").map(encodeURIComponent).join("/")}#L${f.line}`;
|
|
99
|
-
body += `<details>\n<summary><a href="${permalink}"><code>${escapeHtml(f.path)}:${f.line}</code></a></summary>\n\n${f.body}\n</details>\n\n`;
|
|
100
|
+
body += `<details>\n<summary><a href="${permalink}"><code>${escapeHtml(f.path)}:${f.line}</code></a></summary>\n\n${f.renderedBody ?? f.body}\n</details>\n\n`;
|
|
100
101
|
} else {
|
|
101
|
-
body += `<details>\n<summary><code>${escapeHtml(f.path)}:${f.line}</code></summary>\n\n${f.body}\n</details>\n\n`;
|
|
102
|
+
body += `<details>\n<summary><code>${escapeHtml(f.path)}:${f.line}</code></summary>\n\n${f.renderedBody ?? f.body}\n</details>\n\n`;
|
|
102
103
|
}
|
|
103
104
|
}
|
|
104
105
|
body += AI_TRAILER;
|
|
@@ -127,5 +128,134 @@ export function classifyAndLog(
|
|
|
127
128
|
for (const f of tier3) {
|
|
128
129
|
console.warn(`WARN: ${f.path}:${f.line} demoted to review body`);
|
|
129
130
|
}
|
|
131
|
+
// Identify findings whose suggestion blocks exceed the anchor range
|
|
132
|
+
const oversized = findingsExceedingAnchor(classified);
|
|
133
|
+
const suggestionWarnings = formatSuggestionWarnings(oversized);
|
|
134
|
+
for (const w of suggestionWarnings) {
|
|
135
|
+
console.warn(w);
|
|
136
|
+
}
|
|
137
|
+
// Strip oversized suggestion blocks from tier-1 findings
|
|
138
|
+
for (const f of tier1) {
|
|
139
|
+
if (oversized.has(f)) {
|
|
140
|
+
f.renderedBody = stripSuggestionBlocks(f.body);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// Tier-2 findings are posted as file-level comments where suggestion blocks
|
|
144
|
+
// render as inert code — strip them unconditionally.
|
|
145
|
+
for (const f of tier2) {
|
|
146
|
+
const stripped = stripSuggestionBlocks(f.body);
|
|
147
|
+
if (stripped !== f.body) {
|
|
148
|
+
f.renderedBody = stripped;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Tier-3 findings are posted inside the review body where suggestion blocks
|
|
152
|
+
// render as inert code — strip them unconditionally.
|
|
153
|
+
for (const f of tier3) {
|
|
154
|
+
const stripped = stripSuggestionBlocks(f.body);
|
|
155
|
+
if (stripped !== f.body) {
|
|
156
|
+
f.renderedBody = stripped;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
130
159
|
return { tier1, tier2, tier3 };
|
|
131
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Find the largest ```suggestion block in a finding body by line count.
|
|
164
|
+
* Returns the maximum line count across all suggestion blocks, or 0 if none.
|
|
165
|
+
*/
|
|
166
|
+
function maxSuggestionLines(body: string): number {
|
|
167
|
+
let max = 0;
|
|
168
|
+
let inSuggestion = false;
|
|
169
|
+
let count = 0;
|
|
170
|
+
for (const line of body.split("\n")) {
|
|
171
|
+
if (!inSuggestion && SUGGESTION_OPEN_RE.test(line.trim())) {
|
|
172
|
+
inSuggestion = true;
|
|
173
|
+
count = 0;
|
|
174
|
+
} else if (inSuggestion && line.trimEnd() === "```") {
|
|
175
|
+
inSuggestion = false;
|
|
176
|
+
max = Math.max(max, count);
|
|
177
|
+
} else if (inSuggestion) {
|
|
178
|
+
count++;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (inSuggestion) {
|
|
182
|
+
max = Math.max(max, count);
|
|
183
|
+
}
|
|
184
|
+
return max;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Remove all ```suggestion blocks from a finding body, preserving other content.
|
|
189
|
+
*/
|
|
190
|
+
function stripSuggestionBlocks(body: string): string {
|
|
191
|
+
const lines = body.split("\n");
|
|
192
|
+
const result: string[] = [];
|
|
193
|
+
let inSuggestion = false;
|
|
194
|
+
let pendingBlock: string[] = [];
|
|
195
|
+
for (const line of lines) {
|
|
196
|
+
if (!inSuggestion && SUGGESTION_OPEN_RE.test(line.trim())) {
|
|
197
|
+
inSuggestion = true;
|
|
198
|
+
pendingBlock = [line];
|
|
199
|
+
} else if (inSuggestion && line.trimEnd() === "```") {
|
|
200
|
+
inSuggestion = false;
|
|
201
|
+
pendingBlock = [];
|
|
202
|
+
} else if (inSuggestion) {
|
|
203
|
+
pendingBlock.push(line);
|
|
204
|
+
} else {
|
|
205
|
+
result.push(line);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (inSuggestion) {
|
|
209
|
+
// Unclosed block — preserve all lines as-is
|
|
210
|
+
result.push(...pendingBlock);
|
|
211
|
+
}
|
|
212
|
+
return result.join("\n");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function anchorLineCount(f: Finding): number {
|
|
216
|
+
return f.startLine === undefined ? 1 : Math.max(1, f.line - f.startLine + 1);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Identify tier-1 findings whose largest suggestion block exceeds the anchor range.
|
|
221
|
+
* Returns a Set of findings that need their suggestion blocks stripped.
|
|
222
|
+
*/
|
|
223
|
+
function findingsExceedingAnchor(findings: ClassifiedFinding[]): Set<ClassifiedFinding> {
|
|
224
|
+
const exceeded = new Set<ClassifiedFinding>();
|
|
225
|
+
for (const f of findings) {
|
|
226
|
+
if (f.tier !== 1) continue;
|
|
227
|
+
const suggLines = maxSuggestionLines(f.body);
|
|
228
|
+
if (suggLines === 0) continue;
|
|
229
|
+
const anchorLines = anchorLineCount(f);
|
|
230
|
+
if (suggLines > anchorLines) {
|
|
231
|
+
exceeded.add(f);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return exceeded;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Format warning messages for findings whose suggestion blocks exceed their anchor range.
|
|
239
|
+
* @internal
|
|
240
|
+
*/
|
|
241
|
+
export function formatSuggestionWarnings(oversized: Set<ClassifiedFinding>): string[] {
|
|
242
|
+
const warnings: string[] = [];
|
|
243
|
+
for (const f of oversized) {
|
|
244
|
+
const suggLines = maxSuggestionLines(f.body);
|
|
245
|
+
const anchorLines = anchorLineCount(f);
|
|
246
|
+
const anchor =
|
|
247
|
+
f.startLine === undefined ? `${f.path}:${f.line}` : `${f.path}:${f.startLine}-${f.line}`;
|
|
248
|
+
warnings.push(
|
|
249
|
+
`WARN: ${anchor} has ${anchorLines}-line anchor but ${suggLines}-line suggestion — anchor should be widened to cover all lines being replaced`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return warnings;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Validate that suggestion blocks fit within their anchor ranges.
|
|
257
|
+
* @internal
|
|
258
|
+
*/
|
|
259
|
+
export function validateSuggestionAnchors(findings: ClassifiedFinding[]): string[] {
|
|
260
|
+
return formatSuggestionWarnings(findingsExceedingAnchor(findings));
|
|
261
|
+
}
|