@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mechanai/deepreview",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "Multi-agent parallel code/spec review for OpenCode",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 {
@@ -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
+ });
@@ -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
+ }