@mechanai/deepreview 2.1.5 → 2.2.0

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.
@@ -21,7 +21,7 @@ For each fix in the plan, in the order specified by the "Order of Operations" se
21
21
 
22
22
  1. Read the current file at the referenced location
23
23
  2. Apply the code change exactly as specified in the plan
24
- 3. **Globalize check:** After applying, check whether other files _listed in input.txt or the plan_ have the same pattern. If so, apply the equivalent fix there too. Do NOT search the broader codebase. Common cases:
24
+ 3. **Globalize check:** After applying, check whether other files _listed in input.txt or the plan_ have the same pattern. If so, apply the equivalent fix there too. Do NOT search the broader codebase. To identify "listed files": for diff inputs, use files from `diff --git a/... b/...` headers; for concatenated file inputs, use files from `=== filename ===` headers. Common cases:
25
25
  - A loop command fix that applies to the other loop command (code-loop ↔ spec-loop)
26
26
  - A prompt/contract change affecting multiple agent files
27
27
  - A variable rename or policy change referenced in multiple files
@@ -22,18 +22,34 @@ Read both files.
22
22
  ## Process
23
23
 
24
24
  1. Read the synthesis and identify every individual finding (each bullet or paragraph that describes a distinct issue)
25
- 2. For each finding, determine:
25
+ 2. If the synthesis contains an "Overall Assessment" section, emit it as the **first document** with frontmatter `summary: true` (no `path` or `line`). The body should be the assessment text, lightly edited for brevity.
26
+ 3. For each finding, determine:
26
27
  - `path`: the file path (relative to repo root) the finding refers to
27
28
  - `line`: the specific line number (new-side of diff). If the synthesis gives a range, use the end line.
28
29
  - `startLine`: if the finding spans multiple lines, use the start of the range. Omit if single-line.
29
- 3. Read `input.txt` (the diff) to:
30
+ 4. Read `input.txt` (the diff) to:
30
31
  - Verify line references are correct
31
32
  - Generate ` ```suggestion ` blocks where a concrete fix is obvious and fits within the diff
32
- 4. Write each finding as a document in the output file
33
+ 5. Write each finding as a document in the output file
33
34
 
34
35
  ## Output format
35
36
 
36
- Write to the output path provided. Use this exact format — one document per finding, separated by `---`:
37
+ Write to the output path provided. The file has two parts:
38
+
39
+ ### 1. Summary document (first, when synthesis has an Overall Assessment)
40
+
41
+ If the synthesis contains an "Overall Assessment" section, the first document must have `summary: true` in its frontmatter. Its body is a 2-3 sentence overall assessment. This appears as the review body on GitHub. Omit this document only if the synthesis has no assessment section.
42
+
43
+ ```
44
+ ---
45
+ summary: true
46
+ ---
47
+ <2-3 sentence overall assessment from the synthesis>
48
+ ```
49
+
50
+ ### 2. Finding documents (one per finding)
51
+
52
+ Each finding follows the summary, separated by `---`:
37
53
 
38
54
  ```
39
55
  ---
@@ -49,7 +65,7 @@ line: <line number>
49
65
  - One finding per document. Never bundle multiple issues.
50
66
  - No stats, severity counts, or framing ("3 critical issues found")
51
67
  - No references to local file paths, session directories, AI tooling, or the deepreview pipeline
52
- - Use permalinks for code references: `https://github.com/OWNER/REPO/blob/$PR_HEAD_SHA/<path>#L<line>`
68
+ - Use permalinks for code references: `https://github.com/OWNER/REPO/blob/<PR_HEAD_SHA>/<path>#L<line>`
53
69
  - Get OWNER/REPO from the diff header or from `input.txt` context
54
70
  - Use ` ```suggestion ` blocks where a concrete fix is obvious
55
71
  - American English. Succinct. No filler.
@@ -8,13 +8,14 @@ STEP 1: DETERMINE INPUT MODE
8
8
  Parse "$ARGUMENTS" the same way as /deepreview:
9
9
 
10
10
  - If it starts with `--context <path>`, extract CONTEXT_FILE=<path> and remove it from $ARGUMENTS before parsing the rest.
11
- - 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
+ - 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.
12
+ _(Canonical source for `CONTEXT_FILE` validation rules. Keep `deepreview.md`, `deepreview-spec.md`, and `deepreview-spec-loop.md` in sync.)_
12
13
  - If it is a number → MODE=pr, TARGET="$ARGUMENTS"
13
14
  - If it is a file path or multiple file paths → MODE=files, TARGET="$ARGUMENTS"
14
15
  - If it is empty → MODE=branch, TARGET=""
15
16
 
16
17
  Set ITERATION=1
17
- Set PRIOR_CONTEXT="" (empty — built up across iterations)
18
+ Set PRIOR_CONTEXT="" (empty — built up across iterations; holds both design context and prior findings)
18
19
  Set ALL_SESSION_DIRS=[] (list of all session directories used, in order)
19
20
 
20
21
  If CONTEXT_FILE exists, read its contents and set PRIOR_CONTEXT to:
@@ -11,7 +11,7 @@ STEP 1: DETERMINE INPUT
11
11
  - If remaining "$ARGUMENTS" is empty, tell the user "Usage: /deepreview-spec-loop [--context <file>] <file1> [file2 ...]" and STOP.
12
12
  - Set FILES="$ARGUMENTS"
13
13
  - Set ITERATION=1
14
- - Set PRIOR_CONTEXT="" (empty — built up across iterations)
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
16
  - 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
17
 
@@ -81,7 +81,7 @@ Task — Use the Task tool with subagent_type="general":
81
81
 
82
82
  Deduplicate findings that appear in multiple syntheses. Return ONLY these two sections, nothing else."
83
83
 
84
- Set PRIOR_CONTEXT to the returned text. If CONTEXT_FILE exists, prepend:
84
+ Set PRIOR_CONTEXT to the returned text. Validate that it contains "## Prior Findings" — if not, warn the user ("Helper returned malformed prior context — proceeding without deduplication") and set PRIOR_CONTEXT="". If CONTEXT_FILE exists, prepend:
85
85
  "## 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"
86
86
 
87
87
  The REVIEWER_PREAMBLE for all iter2+ reviewers is:
package/README.md CHANGED
@@ -83,6 +83,23 @@ its own context, keeping token usage minimal.
83
83
  - `git`
84
84
  - `gh` CLI (only for PR commands)
85
85
 
86
+ ## Upgrade
87
+
88
+ OpenCode caches plugins on first install and does not automatically check for newer versions.
89
+ To upgrade:
90
+
91
+ ```bash
92
+ rm -rf ~/.cache/opencode/packages/*deepreview*/
93
+ ```
94
+
95
+ Then restart OpenCode. It will re-fetch the latest version.
96
+
97
+ If you installed with `--local`, also re-run the setup script to update symlinks:
98
+
99
+ ```bash
100
+ bunx @mechanai/deepreview@latest/setup --local
101
+ ```
102
+
86
103
  > [!NOTE]
87
104
  > If upgrading from the old `npx @anthropic/deepreview install` workflow, remove
88
105
  > the old copied files first (`rm ~/.config/opencode/agents/deepreview*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mechanai/deepreview",
3
- "version": "2.1.5",
3
+ "version": "2.2.0",
4
4
  "description": "Multi-agent parallel code/spec review for OpenCode",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,8 +22,18 @@ interface FileHunks {
22
22
  * Tier 2: file-level (file in diff but line not in any hunk)
23
23
  * Tier 3: review body (file not in diff)
24
24
  */
25
+ // 5MB — diffs larger than this are truncated before parsing
26
+ const MAX_DIFF_SIZE = 5 * 1024 * 1024;
27
+
25
28
  export function classifyFindings(findings: Finding[], diffText: string): ClassifiedFinding[] {
26
- const parsed = parseDiff(diffText);
29
+ let effectiveDiff = diffText;
30
+ if (diffText.length > MAX_DIFF_SIZE) {
31
+ console.warn(
32
+ `WARN: Diff size (${(diffText.length / 1024 / 1024).toFixed(1)}MB) exceeds ${MAX_DIFF_SIZE / 1024 / 1024}MB limit. Truncating — some findings may be demoted to tier 3.`,
33
+ );
34
+ effectiveDiff = diffText.slice(0, MAX_DIFF_SIZE);
35
+ }
36
+ const parsed = parseDiff(effectiveDiff);
27
37
  const fileMap = buildFileMap(parsed);
28
38
 
29
39
  return findings.map((finding) => {
@@ -2,7 +2,76 @@ import { describe, it } from "bun:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { parseThreads } from "./parse-threads.ts";
4
4
 
5
- describe("parseThreads", () => {
5
+ describe("parseThreads summary extraction", () => {
6
+ it("extracts a summary document marked with summary: true", () => {
7
+ const input = [
8
+ "---",
9
+ "summary: true",
10
+ "---",
11
+ "Overall this PR looks good with minor issues.",
12
+ "---",
13
+ "path: src/main.go",
14
+ "line: 10",
15
+ "---",
16
+ "Error not propagated.",
17
+ ].join("\n");
18
+
19
+ const result = parseThreads(input);
20
+ assert.equal(result.summary, "Overall this PR looks good with minor issues.");
21
+ assert.equal(result.findings.length, 1);
22
+ assert.equal(result.findings[0].path, "src/main.go");
23
+ });
24
+
25
+ it("returns undefined summary when no summary document exists", () => {
26
+ const input = ["---", "path: src/main.go", "line: 10", "---", "Error not propagated."].join(
27
+ "\n",
28
+ );
29
+
30
+ const result = parseThreads(input);
31
+ assert.equal(result.summary, undefined);
32
+ assert.equal(result.findings.length, 1);
33
+ });
34
+
35
+ it("handles summary at end of file", () => {
36
+ const input = [
37
+ "---",
38
+ "path: src/main.go",
39
+ "line: 10",
40
+ "---",
41
+ "Finding body.",
42
+ "---",
43
+ "summary: true",
44
+ "---",
45
+ "Summary at the end.",
46
+ ].join("\n");
47
+
48
+ const result = parseThreads(input);
49
+ assert.equal(result.summary, "Summary at the end.");
50
+ assert.equal(result.findings.length, 1);
51
+ });
52
+ });
53
+
54
+ describe("parseThreads summary edge cases", () => {
55
+ it("treats empty summary body as empty string", () => {
56
+ const input = [
57
+ "---",
58
+ "summary: true",
59
+ "---",
60
+ "",
61
+ "---",
62
+ "path: src/main.go",
63
+ "line: 10",
64
+ "---",
65
+ "Finding.",
66
+ ].join("\n");
67
+
68
+ const result = parseThreads(input);
69
+ assert.equal(result.summary, "");
70
+ assert.equal(result.findings.length, 1);
71
+ });
72
+ });
73
+
74
+ describe("parseThreads findings", () => {
6
75
  it("parses a single finding", () => {
7
76
  const input = [
8
77
  "---",
@@ -13,11 +82,11 @@ describe("parseThreads", () => {
13
82
  ].join("\n");
14
83
 
15
84
  const result = parseThreads(input);
16
- assert.equal(result.length, 1);
17
- assert.equal(result[0].path, "pkg/server/handler.go");
18
- assert.equal(result[0].line, 48);
19
- assert.equal(result[0].startLine, undefined);
20
- assert.equal(result[0].body.trim(), "The error is silently discarded.");
85
+ assert.equal(result.findings.length, 1);
86
+ assert.equal(result.findings[0].path, "pkg/server/handler.go");
87
+ assert.equal(result.findings[0].line, 48);
88
+ assert.equal(result.findings[0].startLine, undefined);
89
+ assert.equal(result.findings[0].body.trim(), "The error is silently discarded.");
21
90
  });
22
91
 
23
92
  it("parses multiple findings separated by ---", () => {
@@ -36,23 +105,23 @@ describe("parseThreads", () => {
36
105
  ].join("\n");
37
106
 
38
107
  const result = parseThreads(input);
39
- assert.equal(result.length, 2);
40
- assert.equal(result[0].path, "a.go");
41
- assert.equal(result[0].startLine, 10);
42
- assert.equal(result[0].line, 15);
43
- assert.equal(result[1].path, "b.go");
44
- assert.equal(result[1].line, 3);
108
+ assert.equal(result.findings.length, 2);
109
+ assert.equal(result.findings[0].path, "a.go");
110
+ assert.equal(result.findings[0].startLine, 10);
111
+ assert.equal(result.findings[0].line, 15);
112
+ assert.equal(result.findings[1].path, "b.go");
113
+ assert.equal(result.findings[1].line, 3);
45
114
  });
46
115
 
47
116
  it("ignores startLine: 0 (treats as single-line)", () => {
48
117
  const input = ["---", "path: x.go", "startLine: 0", "line: 5", "---", "Body."].join("\n");
49
118
 
50
119
  const result = parseThreads(input);
51
- assert.equal(result[0].startLine, undefined);
120
+ assert.equal(result.findings[0].startLine, undefined);
52
121
  });
53
122
 
54
- it("returns empty array for empty input", () => {
123
+ it("returns empty findings for empty input", () => {
55
124
  const result = parseThreads("");
56
- assert.equal(result.length, 0);
125
+ assert.equal(result.findings.length, 0);
57
126
  });
58
127
  });
@@ -2,6 +2,12 @@ import matter from "gray-matter";
2
2
  import yaml from "js-yaml";
3
3
  import type { Finding } from "./diff-classifier.ts";
4
4
 
5
+ /** @internal Not part of the public API — subject to change without notice. */
6
+ export interface ParsedThreads {
7
+ findings: Finding[];
8
+ summary?: string;
9
+ }
10
+
5
11
  /**
6
12
  * gray-matter engine restricted to safe YAML parsing (no !!js/function etc.).
7
13
  * FAILSAFE_SCHEMA returns all scalars as strings — numeric fields (line, startLine)
@@ -16,11 +22,13 @@ const safeYamlEngine = (s: string): Record<string, unknown> => {
16
22
  const matterOptions = { engines: { yaml: safeYamlEngine } };
17
23
 
18
24
  /**
19
- * Parse a threads.md file into an array of finding objects.
20
- * The file uses --- separators between findings, each with YAML frontmatter.
25
+ * Parse a threads.md file into findings and an optional summary.
26
+ * The file uses --- separators between documents, each with YAML frontmatter.
27
+ * A document with `summary: true` in its frontmatter is extracted as the review summary.
21
28
  */
22
- function parseThreads(content: string): Finding[] {
29
+ function parseThreads(content: string): ParsedThreads {
23
30
  const findings: Finding[] = [];
31
+ let summary: string | undefined;
24
32
  const documents = splitDocuments(content);
25
33
 
26
34
  for (const doc of documents) {
@@ -33,6 +41,16 @@ function parseThreads(content: string): Finding[] {
33
41
  continue;
34
42
  }
35
43
  const data = parsed.data as Record<string, unknown>;
44
+
45
+ // Handle summary documents
46
+ if (String(data.summary).toLowerCase() === "true") {
47
+ if (summary !== undefined) {
48
+ console.warn("WARN: Multiple summary documents found — using the last one.");
49
+ }
50
+ summary = parsed.content.trim();
51
+ continue;
52
+ }
53
+
36
54
  const path = typeof data.path === "string" ? data.path : undefined;
37
55
  const lineNum = Number(data.line);
38
56
  if (path === undefined || path === "" || !Number.isFinite(lineNum) || lineNum < 1) {
@@ -56,7 +74,7 @@ function parseThreads(content: string): Finding[] {
56
74
  });
57
75
  }
58
76
 
59
- return findings;
77
+ return { findings, summary };
60
78
  }
61
79
 
62
80
  const YAML_KEY_RE = /^\w[\w\s]*:/u;
@@ -2,6 +2,36 @@ import { describe, it } from "bun:test";
2
2
  import assert from "node:assert/strict";
3
3
  import { parseThreads } from "./parse-threads.ts";
4
4
  import { classifyFindings } from "./diff-classifier.ts";
5
+ import { buildReviewBody } from "./review-helpers.ts";
6
+
7
+ describe("buildReviewBody", () => {
8
+ it("includes summary when provided and no tier3 findings", () => {
9
+ const body = buildReviewBody([], "owner", "repo", "a".repeat(40), "Overall looks good.");
10
+ assert.ok(body.includes("Overall looks good."));
11
+ assert.ok(body.includes("🤖 Review generated by AI"));
12
+ });
13
+
14
+ it("includes both summary and tier3 findings", () => {
15
+ const tier3 = [{ path: "pkg/other.go", line: 5, body: "Unused import.", tier: 3 as const }];
16
+ const body = buildReviewBody(tier3, "owner", "repo", "a".repeat(40), "Has issues.");
17
+ assert.ok(body.includes("Has issues."));
18
+ assert.ok(body.includes("pkg/other.go"));
19
+ assert.ok(body.includes("Unused import."));
20
+ });
21
+
22
+ it("works without summary (backward compatible)", () => {
23
+ const tier3 = [{ path: "pkg/other.go", line: 5, body: "Unused import.", tier: 3 as const }];
24
+ const body = buildReviewBody(tier3, "owner", "repo", "a".repeat(40));
25
+ assert.ok(!body.includes("undefined"));
26
+ assert.ok(body.includes("pkg/other.go"));
27
+ assert.ok(body.includes("🤖 Review generated by AI"));
28
+ });
29
+
30
+ it("only shows trailer when no summary and no tier3 findings", () => {
31
+ const body = buildReviewBody([], "owner", "repo", "a".repeat(40));
32
+ assert.equal(body, "\n\n---\n🤖 Review generated by AI");
33
+ });
34
+ });
5
35
 
6
36
  describe("integration: parse → classify", () => {
7
37
  const threadsContent = [
@@ -38,7 +68,7 @@ describe("integration: parse → classify", () => {
38
68
  ].join("\n");
39
69
 
40
70
  it("classifies findings correctly across all 3 tiers", () => {
41
- const findings = parseThreads(threadsContent);
71
+ const { findings } = parseThreads(threadsContent);
42
72
  assert.equal(findings.length, 3);
43
73
 
44
74
  const classified = classifyFindings(findings, diff);
@@ -1,8 +1,7 @@
1
1
  import { readFile, realpath } from "node:fs/promises";
2
2
  import { resolve } from "node:path";
3
3
  import { parseThreads } from "./parse-threads.ts";
4
- import { classifyFindings } from "./diff-classifier.ts";
5
- import type { Finding, ClassifiedFinding } from "./diff-classifier.ts";
4
+ import { type Finding, type ClassifiedFinding } from "./diff-classifier.ts";
6
5
  import { type PrInfo, getPrInfo, execFileAsync } from "./graphql.ts";
7
6
  import {
8
7
  type PendingReview,
@@ -14,12 +13,13 @@ import {
14
13
  updateReviewComment,
15
14
  } from "./review-api.ts";
16
15
  import {
17
- escapeHtml,
18
16
  isValidPath,
19
17
  isRateLimitError,
20
18
  findingId,
21
19
  embedFindingId,
22
20
  extractFindingId,
21
+ buildReviewBody,
22
+ classifyAndLog,
23
23
  } from "./review-helpers.ts";
24
24
 
25
25
  export interface PostReviewOptions {
@@ -37,27 +37,7 @@ export interface PostReviewResult {
37
37
  failed: string[];
38
38
  }
39
39
 
40
- const AI_TRAILER = "\n\n---\n🤖 Review generated by AI";
41
-
42
- function buildReviewBody(
43
- tier3Findings: ClassifiedFinding[],
44
- owner: string,
45
- name: string,
46
- headOid: string,
47
- ): string {
48
- let body = "";
49
- for (const f of tier3Findings) {
50
- if (!isValidPath(f.path)) {
51
- console.warn(`WARN: Skipping finding with suspicious path: ${f.path}`);
52
- continue;
53
- }
54
- const permalink = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/blob/${headOid}/${f.path.split("/").map(encodeURIComponent).join("/")}#L${f.line}`;
55
- // Neutralize all HTML tags — GitHub re-renders markdown inside <details>.
56
- body += `<details>\n<summary><a href="${permalink}"><code>${escapeHtml(f.path)}:${f.line}</code></a></summary>\n\n${f.body}\n</details>\n\n`;
57
- }
58
- body += AI_TRAILER;
59
- return body;
60
- }
40
+ const MAX_INLINE_FINDINGS = 200;
61
41
 
62
42
  async function updateExistingThreads(
63
43
  existingReview: PendingReview,
@@ -79,9 +59,16 @@ async function updateExistingThreads(
79
59
  }
80
60
  }
81
61
 
82
- for (const { finding, commentId, id } of toUpdate) {
83
- const body = embedFindingId(finding.body, id);
84
- await updateReviewComment(commentId, body);
62
+ const CONCURRENCY = 2;
63
+ for (let i = 0; i < toUpdate.length; i += CONCURRENCY) {
64
+ if (i > 0) await Bun.sleep(1000);
65
+ const batch = toUpdate.slice(i, i + CONCURRENCY);
66
+ await Promise.all(
67
+ batch.map(async ({ finding, commentId, id }) => {
68
+ const body = embedFindingId(finding.body, id);
69
+ await updateReviewComment(commentId, body);
70
+ }),
71
+ );
85
72
  }
86
73
 
87
74
  console.log(`Updated ${toUpdate.length} existing threads.`);
@@ -146,13 +133,20 @@ async function postInlineThreads(
146
133
  (f) => !updatedIds.has(findingId(f.path, f.startLine, f.line, f.body)),
147
134
  );
148
135
 
149
- for (let i = 0; i < pending.length; i += CONCURRENCY) {
136
+ if (pending.length > MAX_INLINE_FINDINGS) {
137
+ console.warn(
138
+ `WARN: ${pending.length} inline findings exceed limit of ${MAX_INLINE_FINDINGS}. Truncating.`,
139
+ );
140
+ }
141
+ const capped = pending.slice(0, MAX_INLINE_FINDINGS);
142
+
143
+ for (let i = 0; i < capped.length; i += CONCURRENCY) {
150
144
  if (i > 0) await Bun.sleep(1000);
151
- const batch = pending.slice(i, i + CONCURRENCY);
145
+ const batch = capped.slice(i, i + CONCURRENCY);
152
146
  const results = await Promise.all(
153
147
  batch.map(async (f) => {
154
148
  const id = findingId(f.path, f.startLine, f.line, f.body);
155
- // Trust boundary: body content is rendered by GitHub's Markdown sanitizer
149
+ // Body rendered by GitHub's Markdown sanitizer; no escaping needed here
156
150
  const body = embedFindingId(f.body, id);
157
151
  const ok = await postWithRetry(reviewId, f, body);
158
152
  return ok ? null : id;
@@ -165,31 +159,6 @@ async function postInlineThreads(
165
159
  return failedFindings;
166
160
  }
167
161
 
168
- function classifyAndLog(
169
- findings: Finding[],
170
- diff: string,
171
- ): { tier1: ClassifiedFinding[]; tier2: ClassifiedFinding[]; tier3: ClassifiedFinding[] } {
172
- const validated = findings.filter((f) => {
173
- if (!isValidPath(f.path)) {
174
- console.warn(`WARN: Skipping finding with suspicious path: ${f.path}`);
175
- return false;
176
- }
177
- return true;
178
- });
179
- const classified = classifyFindings(validated, diff);
180
- const tier1 = classified.filter((f) => f.tier === 1);
181
- const tier2 = classified.filter((f) => f.tier === 2);
182
- const tier3 = classified.filter((f) => f.tier === 3);
183
-
184
- for (const f of tier2) {
185
- console.warn(`WARN: ${f.path}:${f.line} demoted to file-level`);
186
- }
187
- for (const f of tier3) {
188
- console.warn(`WARN: ${f.path}:${f.line} demoted to review body`);
189
- }
190
- return { tier1, tier2, tier3 };
191
- }
192
-
193
162
  async function ensureReview(
194
163
  prNodeId: string,
195
164
  headOid: string,
@@ -198,18 +167,22 @@ async function ensureReview(
198
167
  tier3: ClassifiedFinding[],
199
168
  owner: string,
200
169
  name: string,
170
+ summary?: string,
201
171
  ): Promise<{ reviewId: string; updatedFindings: Set<string> }> {
202
172
  const existingReview = await findPendingReview(prNodeId);
203
173
  if (existingReview) {
204
174
  console.log(`Found existing pending review: ${existingReview.id}`);
205
- await updateReviewBody(existingReview.id, buildReviewBody(tier3, owner, name, headOid));
175
+ await updateReviewBody(
176
+ existingReview.id,
177
+ buildReviewBody(tier3, owner, name, headOid, summary),
178
+ );
206
179
  const updated = await updateExistingThreads(existingReview, [...tier1, ...tier2]);
207
180
  return { reviewId: existingReview.id, updatedFindings: updated };
208
181
  }
209
182
  const reviewId = await createPendingReview(
210
183
  prNodeId,
211
184
  headOid,
212
- buildReviewBody(tier3, owner, name, headOid),
185
+ buildReviewBody(tier3, owner, name, headOid, summary),
213
186
  );
214
187
  console.log(`Created pending review: ${reviewId}`);
215
188
  return { reviewId, updatedFindings: new Set() };
@@ -220,10 +193,11 @@ async function loadAndValidatePr(
220
193
  prNumber: number,
221
194
  expectedSha: string | undefined,
222
195
  cwd: string | undefined,
223
- ): Promise<{ findings: Finding[]; prInfo: PrInfo } | null> {
196
+ ): Promise<{ findings: Finding[]; prInfo: PrInfo; summary?: string } | null> {
224
197
  if (!isValidPath(threadsPath)) {
225
198
  throw new Error(`Invalid threadsPath: ${threadsPath}`);
226
199
  }
200
+ // cwd is a trusted parameter set by the plugin framework (not user-supplied)
227
201
  const base = await realpath(resolve(cwd ?? process.cwd()));
228
202
  const candidatePath = resolve(base, threadsPath);
229
203
  let resolved: string;
@@ -236,8 +210,8 @@ async function loadAndValidatePr(
236
210
  throw new Error(`threadsPath escapes working directory: ${threadsPath}`);
237
211
  }
238
212
  const content = await readFile(resolved, "utf8");
239
- const findings = parseThreads(content);
240
- if (findings.length === 0) return null;
213
+ const { findings, summary } = parseThreads(content);
214
+ if (findings.length === 0 && (summary === undefined || summary === "")) return null;
241
215
 
242
216
  const prInfo = await getPrInfo(prNumber, { cwd });
243
217
  if (prInfo.state !== "OPEN") {
@@ -249,7 +223,7 @@ async function loadAndValidatePr(
249
223
  throw new Error(`ABORT: PR head moved (expected ${resolvedSha}, got ${prInfo.headOid}).`);
250
224
  }
251
225
 
252
- return { findings, prInfo };
226
+ return { findings, prInfo, summary };
253
227
  }
254
228
 
255
229
  async function postFindings(
@@ -258,6 +232,7 @@ async function postFindings(
258
232
  tier3: ClassifiedFinding[],
259
233
  prInfo: PrInfo,
260
234
  skipIds: string[],
235
+ summary?: string,
261
236
  ): Promise<PostReviewResult> {
262
237
  const { owner, name, prNodeId, headOid } = prInfo;
263
238
  const { reviewId, updatedFindings } = await ensureReview(
@@ -268,6 +243,7 @@ async function postFindings(
268
243
  tier3,
269
244
  owner,
270
245
  name,
246
+ summary,
271
247
  );
272
248
 
273
249
  const skipSet = new Set(skipIds);
@@ -280,28 +256,28 @@ async function postFindings(
280
256
  const skipped = inlineFindings.filter((f) =>
281
257
  updatedFindings.has(findingId(f.path, f.startLine, f.line, f.body)),
282
258
  ).length;
283
- const posted = inlineFindings.length - skipped - failedIds.length;
259
+ const pending = inlineFindings.length - skipped;
260
+ const truncatedCount = Math.max(0, pending - MAX_INLINE_FINDINGS);
261
+ const posted = Math.min(pending, MAX_INLINE_FINDINGS) - failedIds.length;
284
262
  const totalFindings = [...tier1, ...tier2].length;
285
263
  const skippedByUser = totalFindings - inlineFindings.length;
286
- const summary =
287
- `Posted ${posted}/${totalFindings} inline threads (${skipped} up-to-date, ${skippedByUser} skipped, ${failedIds.length} failed). ` +
264
+ const statusSummary =
265
+ `Posted ${posted}/${totalFindings} inline threads (${skipped} up-to-date, ${skippedByUser} skipped, ${failedIds.length} failed${truncatedCount > 0 ? `, ${truncatedCount} truncated` : ""}). ` +
288
266
  `${tier3.length} findings in review body.`;
289
267
 
290
- console.log(summary);
268
+ console.log(statusSummary);
291
269
 
292
- return { summary, failed: failedIds };
270
+ return { summary: statusSummary, failed: failedIds };
293
271
  }
294
272
 
295
- /**
296
- * Post a review to a GitHub PR.
297
- */
298
273
  export async function postReview(opts: PostReviewOptions): Promise<PostReviewResult> {
299
274
  const { threadsPath, prNumber, dryRun = false, skipIds = [], expectedSha } = opts;
300
275
 
301
276
  const result = await loadAndValidatePr(threadsPath, prNumber, expectedSha, opts.cwd);
302
277
  if (!result) return { summary: "No findings to post.", failed: [] };
303
278
 
304
- const { findings, prInfo } = result;
279
+ const { findings, prInfo, summary: reviewSummary } = result;
280
+
305
281
  const diff =
306
282
  opts.diffText ??
307
283
  (
@@ -321,5 +297,5 @@ export async function postReview(opts: PostReviewOptions): Promise<PostReviewRes
321
297
  };
322
298
  }
323
299
 
324
- return postFindings(tier1, tier2, tier3, prInfo, skipIds);
300
+ return postFindings(tier1, tier2, tier3, prInfo, skipIds, reviewSummary);
325
301
  }
package/src/review-api.ts CHANGED
@@ -64,7 +64,15 @@ async function fetchRemainingComments(
64
64
  const comments: ReviewComment[] = [];
65
65
  let pageInfo = initialPageInfo;
66
66
  let pages = 0;
67
+ // 5 minute aggregate timeout for pagination
68
+ const deadline = Date.now() + 5 * 60 * 1000;
67
69
  while (pageInfo.hasNextPage && pages++ < MAX_PAGES) {
70
+ if (Date.now() > deadline) {
71
+ console.warn(
72
+ `WARN: Aggregate timeout reached after ${pages} pages. Returning partial results.`,
73
+ );
74
+ break;
75
+ }
68
76
  const page = await graphql<CommentsPage>(
69
77
  `
70
78
  query ($reviewId: ID!, $after: String!) {
@@ -1,6 +1,9 @@
1
1
  import crypto from "node:crypto";
2
+ import { classifyFindings, type Finding, type ClassifiedFinding } from "./diff-classifier.ts";
2
3
 
3
4
  const FINDING_ID_RE = /<!-- finding:([a-f0-9]+) -->/u;
5
+ const AI_TRAILER = "\n\n---\n🤖 Review generated by AI";
6
+ const GITHUB_NAME_RE = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u;
4
7
 
5
8
  export function escapeHtml(s: string): string {
6
9
  return s
@@ -63,3 +66,66 @@ export function extractFindingId(body: string): string | null {
63
66
  const match = FINDING_ID_RE.exec(body);
64
67
  return match ? match[1] : null;
65
68
  }
69
+
70
+ /** @internal Not part of the public API — subject to change without notice. */
71
+ export function buildReviewBody(
72
+ tier3Findings: ClassifiedFinding[],
73
+ owner: string,
74
+ name: string,
75
+ headOid: string,
76
+ summary?: string,
77
+ ): string {
78
+ const validOid = /^[0-9a-f]{40,64}$/u.test(headOid);
79
+ if (!validOid) {
80
+ console.warn(`WARN: Invalid headOid format (${headOid}). Skipping permalinks.`);
81
+ }
82
+ const validRepo = GITHUB_NAME_RE.test(owner) && GITHUB_NAME_RE.test(name);
83
+ if (!validRepo) {
84
+ console.warn(`WARN: Invalid owner/name format (${owner}/${name}). Skipping permalinks.`);
85
+ }
86
+ let body = "";
87
+ if (summary !== undefined && summary !== "") {
88
+ // Trust boundary: summary is AI-generated Markdown from the formatter agent,
89
+ // rendered by GitHub's Markdown sanitizer (same trust level as finding bodies).
90
+ body += summary + "\n\n";
91
+ }
92
+ for (const f of tier3Findings) {
93
+ if (!isValidPath(f.path)) {
94
+ console.warn(`WARN: Skipping finding with suspicious path: ${f.path}`);
95
+ continue;
96
+ }
97
+ if (validOid && validRepo) {
98
+ 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
+ } else {
101
+ body += `<details>\n<summary><code>${escapeHtml(f.path)}:${f.line}</code></summary>\n\n${f.body}\n</details>\n\n`;
102
+ }
103
+ }
104
+ body += AI_TRAILER;
105
+ return body;
106
+ }
107
+
108
+ export function classifyAndLog(
109
+ findings: Finding[],
110
+ diff: string,
111
+ ): { tier1: ClassifiedFinding[]; tier2: ClassifiedFinding[]; tier3: ClassifiedFinding[] } {
112
+ const validated = findings.filter((f) => {
113
+ if (!isValidPath(f.path)) {
114
+ console.warn(`WARN: Skipping finding with suspicious path: ${f.path}`);
115
+ return false;
116
+ }
117
+ return true;
118
+ });
119
+ const classified = classifyFindings(validated, diff);
120
+ const tier1 = classified.filter((f) => f.tier === 1);
121
+ const tier2 = classified.filter((f) => f.tier === 2);
122
+ const tier3 = classified.filter((f) => f.tier === 3);
123
+
124
+ for (const f of tier2) {
125
+ console.warn(`WARN: ${f.path}:${f.line} demoted to file-level`);
126
+ }
127
+ for (const f of tier3) {
128
+ console.warn(`WARN: ${f.path}:${f.line} demoted to review body`);
129
+ }
130
+ return { tier1, tier2, tier3 };
131
+ }