@mechanai/deepreview 2.2.1 → 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.
- package/.opencode/agents/deepreview-review-formatter.md +32 -0
- package/.opencode/commands/deepreview-loop.md +5 -2
- package/.opencode/commands/deepreview-pr-review.md +4 -1
- package/.opencode/commands/deepreview-spec-loop.md +4 -2
- package/.opencode/commands/deepreview-spec.md +3 -1
- package/.opencode/commands/deepreview.md +6 -3
- package/package.json +1 -1
- package/src/diff-classifier.ts +2 -0
- package/src/post-review.ts +2 -2
- package/src/review-helpers.test.ts +170 -0
- package/src/review-helpers.ts +132 -2
|
@@ -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.
|
|
@@ -18,13 +18,16 @@ Set ITERATION=1
|
|
|
18
18
|
Set PRIOR_CONTEXT="" (empty — built up across iterations; holds both design context and prior findings)
|
|
19
19
|
Set ALL_SESSION_DIRS=[] (list of all session directories used, in order)
|
|
20
20
|
|
|
21
|
+
Determine REPO_ROOT — the main repository root (not a worktree root). Run:
|
|
22
|
+
`REPO_ROOT=$(realpath "$(git rev-parse --git-common-dir)" | sed 's|/\.git$||')`
|
|
23
|
+
|
|
21
24
|
If CONTEXT_FILE exists, read its contents and set PRIOR_CONTEXT to:
|
|
22
25
|
"## Design Decisions (intentional — do not flag)\nThe following are deliberate design choices. Do NOT flag these as issues or suggest alternatives.\n`\n" + contents of CONTEXT_FILE + "\n`\n"
|
|
23
26
|
|
|
24
27
|
STEP 2: RUN INITIAL DEEPREVIEW (full pipeline with cross-validation)
|
|
25
28
|
Run the full deepreview pipeline (Stages 1-5 from the deepreview command):
|
|
26
29
|
|
|
27
|
-
- Determine SESSION_DIR and write input.txt
|
|
30
|
+
- Determine SESSION_DIR=`$REPO_ROOT/.ai/deepreview/loop-iter$ITERATION-$(date +%Y-%m-%d-%H%M%S)` and write input.txt
|
|
28
31
|
- Append SESSION_DIR to ALL_SESSION_DIRS
|
|
29
32
|
- Stage 1: 5 parallel reviewers — prepend PRIOR_CONTEXT (if non-empty) to each reviewer's prompt as "${PRIOR_CONTEXT}You are reviewing ... Read the content at $SESSION_DIR/input.txt. Write your review to $SESSION_DIR/review-{perspective}.md."
|
|
30
33
|
- Stage 2: 5 parallel validators (cross-validation)
|
|
@@ -68,7 +71,7 @@ If ITERATION > 5:
|
|
|
68
71
|
- If user says stop → STOP.
|
|
69
72
|
- If user says continue → reset limit to ITERATION + 5 and proceed.
|
|
70
73
|
|
|
71
|
-
Create new session directory: SESSION_DIR="
|
|
74
|
+
Create new session directory: SESSION_DIR="$REPO_ROOT/.ai/deepreview/loop-iter$ITERATION-$(date +%Y-%m-%d-%H%M%S)"
|
|
72
75
|
Run `mkdir -p $SESSION_DIR`
|
|
73
76
|
Append SESSION_DIR to ALL_SESSION_DIRS
|
|
74
77
|
|
|
@@ -7,7 +7,10 @@ You are an orchestrator for a multi-agent code review pipeline that posts findin
|
|
|
7
7
|
STEP 1: VALIDATE INPUT
|
|
8
8
|
$ARGUMENTS must be a PR number (integer). If it is not a number, tell the user "Usage: /deepreview-pr-review <PR_NUMBER>" and STOP.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Determine REPO_ROOT — the main repository root (not a worktree root). Run:
|
|
11
|
+
`REPO_ROOT=$(realpath "$(git rev-parse --git-common-dir)" | sed 's|/\.git$||')`
|
|
12
|
+
|
|
13
|
+
Set SESSION_DIR="$REPO_ROOT/.ai/deepreview/$ARGUMENTS-review-$(date +%Y-%m-%d-%H%M%S)"
|
|
11
14
|
Create the directory with `mkdir -p $SESSION_DIR`
|
|
12
15
|
Set INPUT_DESCRIPTION="a PR diff (code changes)"
|
|
13
16
|
|
|
@@ -13,12 +13,14 @@ STEP 1: DETERMINE INPUT
|
|
|
13
13
|
- Set ITERATION=1
|
|
14
14
|
- Set PRIOR_CONTEXT="" (empty — built up across iterations; holds both design context and prior findings)
|
|
15
15
|
- Set ALL_SESSION_DIRS=[] (list of all session directories used, in order)
|
|
16
|
+
- Determine REPO_ROOT — the main repository root (not a worktree root). Run:
|
|
17
|
+
`REPO_ROOT=$(realpath "$(git rev-parse --git-common-dir)" | sed 's|/\.git$||')`
|
|
16
18
|
- If CONTEXT_FILE exists, set PRIOR_CONTEXT="## Design Decisions (intentional — do not flag)\nThe following are deliberate design choices. Do NOT flag these as issues or suggest alternatives.\n`\n" + contents of CONTEXT_FILE + "\n`\n\n"
|
|
17
19
|
|
|
18
20
|
STEP 2: RUN INITIAL DEEPREVIEW-SPEC (full pipeline with cross-validation)
|
|
19
21
|
Run the full deepreview-spec pipeline (Stages 1-5 from the deepreview-spec command):
|
|
20
22
|
|
|
21
|
-
- Determine SESSION_DIR="
|
|
23
|
+
- Determine SESSION_DIR="$REPO_ROOT/.ai/deepreview/spec-loop-iter1-$(date +%Y-%m-%d-%H%M%S)" and write input.txt
|
|
22
24
|
- Append SESSION_DIR to ALL_SESSION_DIRS
|
|
23
25
|
- Stage 1: 5 parallel reviewers (completeness, consistency, feasibility, docs, architecture) — prepend PRIOR_CONTEXT (if non-empty) to each reviewer's prompt as "${PRIOR_CONTEXT}You are reviewing ... Read the content at $SESSION_DIR/input.txt. Write your review to $SESSION_DIR/review-{perspective}.md."
|
|
24
26
|
- Stage 2: 5 parallel validators (cross-validation)
|
|
@@ -57,7 +59,7 @@ If ITERATION > 7:
|
|
|
57
59
|
- Show the latest stats.
|
|
58
60
|
- STOP.
|
|
59
61
|
|
|
60
|
-
Create new session directory: SESSION_DIR="
|
|
62
|
+
Create new session directory: SESSION_DIR="$REPO_ROOT/.ai/deepreview/spec-loop-iter$ITERATION-$(date +%Y-%m-%d-%H%M%S)"
|
|
61
63
|
Run `mkdir -p $SESSION_DIR`
|
|
62
64
|
Append SESSION_DIR to ALL_SESSION_DIRS
|
|
63
65
|
|
|
@@ -8,7 +8,9 @@ STEP 1: DETERMINE SESSION DIRECTORY
|
|
|
8
8
|
|
|
9
9
|
- If "$ARGUMENTS" starts with `--context <path>`, extract CONTEXT_FILE=<path> and remove it from $ARGUMENTS before parsing the rest.
|
|
10
10
|
- Validate CONTEXT_FILE: it must be a relative path (no leading `/`), must not contain `..`, must exist on disk, and must be a regular file (not a directory or symlink to outside the project), and must be under 50KB. If validation fails, tell the user the error and STOP.
|
|
11
|
-
-
|
|
11
|
+
- Determine REPO_ROOT — the main repository root (not a worktree root). Run:
|
|
12
|
+
`REPO_ROOT=$(realpath "$(git rev-parse --git-common-dir)" | sed 's|/\.git$||')`
|
|
13
|
+
- Set SESSION_DIR="$REPO_ROOT/.ai/deepreview/spec-$(date +%Y-%m-%d-%H%M%S)"
|
|
12
14
|
- Create the directory with `mkdir -p $SESSION_DIR`
|
|
13
15
|
|
|
14
16
|
STEP 2: PREPARE INPUT
|
|
@@ -14,11 +14,14 @@ Classify "$ARGUMENTS":
|
|
|
14
14
|
- If it is multiple space-separated file paths → MODE=files
|
|
15
15
|
- If it is empty → MODE=branch
|
|
16
16
|
|
|
17
|
+
Determine REPO_ROOT — the main repository root (not a worktree root). Run:
|
|
18
|
+
`REPO_ROOT=$(realpath "$(git rev-parse --git-common-dir)" | sed 's|/\.git$||')`
|
|
19
|
+
|
|
17
20
|
Set SESSION_DIR based on mode:
|
|
18
21
|
|
|
19
|
-
- MODE=pr: SESSION_DIR="
|
|
20
|
-
- MODE=files: SESSION_DIR="
|
|
21
|
-
- MODE=branch: SESSION_DIR="
|
|
22
|
+
- MODE=pr: SESSION_DIR="$REPO_ROOT/.ai/deepreview/$ARGUMENTS-$(date +%Y-%m-%d)"
|
|
23
|
+
- MODE=files: SESSION_DIR="$REPO_ROOT/.ai/deepreview/files-$(date +%Y-%m-%d-%H%M%S)"
|
|
24
|
+
- MODE=branch: SESSION_DIR="$REPO_ROOT/.ai/deepreview/$(git branch --show-current)-$(date +%Y-%m-%d)"
|
|
22
25
|
|
|
23
26
|
Create the directory with `mkdir -p $SESSION_DIR`
|
|
24
27
|
|
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
|
+
}
|