Skip to content

Commit 6ea6d14

Browse files
feat(review): add --path support for targeted folder/file reviews (#60)
* feat(review): add --path support for targeted folder/file reviews Extends /opencode:review and /opencode:adversarial-review to accept --path <path> flags for reviewing specific directories or files instead of only git diff. Useful for reviewing specific subdirectories, fixed sets of files, or large untracked/imported code drops. Changes: - Add collectFolderContext() to fs.mjs with per-file and total size caps - Extend buildReviewPrompt() in prompts.mjs to accept paths option - Add normalizePathOption() helper and --path to valueOptions in companion - Update review.md and adversarial-review.md command documentation - Update README.md to document the new --path option Implements: #21 * fix(review): harden path review collection
1 parent 80d4b03 commit 6ea6d14

9 files changed

Lines changed: 494 additions & 14 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ To check your configured providers:
9797

9898
## Slash Commands
9999

100-
- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base <ref>`, `--pr <number>`, `--model <provider/model>`, `--free`, `--wait`, and `--background`. Uses the saved default model when configured and no runtime model flag is supplied.
101-
- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base <ref>`, `--pr <number>`, `--model <provider/model>`, `--free`, `--wait`, `--background`, and custom focus text. Uses the saved default model when configured and no runtime model flag is supplied.
100+
- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base <ref>`, `--pr <number>`, `--path <path>`, `--model <provider/model>`, `--free`, `--wait`, and `--background`. Uses the saved default model when configured and no runtime model flag is supplied.
101+
- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base <ref>`, `--pr <number>`, `--path <path>`, `--model <provider/model>`, `--free`, `--wait`, `--background`, and custom focus text. Uses the saved default model when configured and no runtime model flag is supplied.
102102
- `/opencode:rescue` -- Delegates a task to OpenCode via the `safe-command.mjs` bridge, which validates flags and feeds the task text through a shell-insulated heredoc. Supports `--model`, `--free`, `--agent`, `--resume`, `--fresh`, `--worktree`, `--wait`, and `--background`. Foreground is the default; `--wait` is an explicit no-op alias for foreground; `--background` detaches a worker and returns a job id you can poll with `/opencode:status`. Uses saved default model/agent values when configured and no runtime flag is supplied.
103103
- `/opencode:status` -- Shows running/recent OpenCode jobs for the current repo.
104104
- `/opencode:result` -- Shows final output for a finished job, including OpenCode session ID for resuming.

plugins/opencode/commands/adversarial-review.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
description: Run a steerable adversarial OpenCode review that challenges implementation and design decisions
3-
argument-hint: '[--wait|--background] [--base <ref>] [--model <id> | --free] [--pr <number>] [focus area or custom review instructions]'
3+
argument-hint: '[--wait|--background] [--base <ref>] [--model <id> | --free] [--pr <number>] [--path <path>] [--path <path2>] [focus area or custom review instructions]'
44
disable-model-invocation: true
55
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion
66
---
@@ -40,6 +40,7 @@ Argument handling:
4040
- `--model <id>` overrides the saved setup default model and OpenCode's own default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it.
4141
- `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given.
4242
- `--pr <number>` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated.
43+
- `--path <path>` reviews a specific file or directory instead of git diff. Can be specified multiple times (`--path src --path lib`). When `--path` is set, the review is assembled from the actual file contents at those paths rather than from `git diff`. This is useful for reviewing specific directories, fixed sets of files, or large untracked/imported code drops. Mutually exclusive with `--pr` (paths take precedence over PR mode).
4344

4445
PR reference extraction (REQUIRED — read this carefully):
4546
- If the user's input contains a PR reference like `PR #390`, `pr #390`, `PR 390`, or `pr 390`, you MUST extract the number yourself and pass it as `--pr 390`. Then strip the matched PR phrase from whatever you put in the focus text.

plugins/opencode/commands/review.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
description: Run an OpenCode code review against local git state or a GitHub PR
3-
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [--model <id> | --free] [--pr <number>]'
3+
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [--model <id> | --free] [--pr <number>] [--path <path>] [--path <path2>]'
44
disable-model-invocation: true
55
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion
66
---
@@ -42,6 +42,7 @@ Argument handling:
4242
- `--model <id>` overrides the saved setup default model and OpenCode's own default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it.
4343
- `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given.
4444
- `--pr <number>` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated. Pass it through verbatim if the user supplied it.
45+
- `--path <path>` reviews a specific file or directory instead of git diff. Can be specified multiple times (`--path src --path lib`). When `--path` is set, the review is assembled from the actual file contents at those paths rather than from `git diff`. This is useful for reviewing specific directories, fixed sets of files, or large untracked/imported code drops. Mutually exclusive with `--pr` (paths take precedence over PR mode).
4546
- **PR reference extraction (REQUIRED)**: if the user's input contains a PR reference like `PR #390`, `pr #390`, `PR 390`, or `pr 390` (e.g. `/opencode:review on PR #390`), you MUST extract the number yourself and pass it as `--pr 390`. Do not pass `PR #390` literally to bash — bash strips unquoted `#NNN` tokens as comments before they reach the companion script. Example: `node ... review --pr 390`, NOT `node ... review on PR #390`.
4647

4748
Foreground flow:

plugins/opencode/scripts/lib/args.mjs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@
33
/**
44
* Parse CLI arguments into options and positional args.
55
* @param {string[]} argv
6-
* @param {{ valueOptions?: string[], booleanOptions?: string[] }} schema
6+
* @param {{ valueOptions?: string[], booleanOptions?: string[], multiValueOptions?: string[] }} schema
77
* @returns {{ options: Record<string, string|boolean>, positional: string[] }}
88
*/
99
export function parseArgs(argv, schema = {}) {
1010
const valueSet = new Set(schema.valueOptions ?? []);
11+
const multiValueSet = new Set(schema.multiValueOptions ?? []);
1112
const boolSet = new Set(schema.booleanOptions ?? []);
1213
const options = {};
1314
const positional = [];
@@ -20,7 +21,18 @@ export function parseArgs(argv, schema = {}) {
2021
}
2122
const key = arg.slice(2);
2223
if (valueSet.has(key)) {
23-
options[key] = argv[++i] ?? "";
24+
const value = argv[++i] ?? "";
25+
if (multiValueSet.has(key)) {
26+
if (options[key] === undefined) {
27+
options[key] = [value];
28+
} else if (Array.isArray(options[key])) {
29+
options[key].push(value);
30+
} else {
31+
options[key] = [options[key], value];
32+
}
33+
} else {
34+
options[key] = value;
35+
}
2436
} else if (boolSet.has(key) || !valueSet.has(key)) {
2537
options[key] = true;
2638
}

plugins/opencode/scripts/lib/fs.mjs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,185 @@
22

33
import fs from "node:fs";
44
import path from "node:path";
5+
import { spawnSync } from "node:child_process";
6+
7+
const DEFAULT_MAX_BYTES = 256 * 1024;
8+
const DEFAULT_MAX_FILES = 50;
9+
10+
function toGitPath(filePath) {
11+
return filePath.split(path.sep).join("/");
12+
}
13+
14+
function isInsidePath(parent, candidate) {
15+
const relative = path.relative(parent, candidate);
16+
return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
17+
}
18+
19+
function isGitignored(filePath, cwd) {
20+
try {
21+
const result = fs.statSync(filePath);
22+
if (!result.isFile()) return false;
23+
24+
const relativePath = path.relative(cwd, filePath);
25+
if (!isInsidePath(cwd, filePath)) return false;
26+
27+
const checked = spawnSync("git", ["check-ignore", "-q", "--", toGitPath(relativePath)], {
28+
cwd,
29+
stdio: "ignore",
30+
});
31+
return checked.status === 0;
32+
} catch {
33+
return false;
34+
}
35+
}
36+
37+
function isBinaryFile(filePath) {
38+
let fd = null;
39+
try {
40+
const buffer = Buffer.alloc(8192);
41+
fd = fs.openSync(filePath, "r");
42+
const bytesRead = fs.readSync(fd, buffer, 0, 8192, 0);
43+
for (let i = 0; i < bytesRead; i++) {
44+
if (buffer[i] === 0) return true;
45+
}
46+
return false;
47+
} catch {
48+
return false;
49+
} finally {
50+
if (fd !== null) {
51+
try {
52+
fs.closeSync(fd);
53+
} catch {
54+
// best-effort
55+
}
56+
}
57+
}
58+
}
59+
60+
/**
61+
* Collect file contents for a set of paths within cwd.
62+
* Respects per-file and total size caps, skips binary files and broken symlinks.
63+
*
64+
* @param {string} cwd - Working directory
65+
* @param {string[]} targetPaths - Relative paths to include
66+
* @param {{ maxBytes?: number, maxFiles?: number }} opts
67+
* @returns {Promise<{ content: string, files: string[], totalBytes: number, overflowed: boolean, overflowedBytes: boolean, overflowedFiles: boolean }>}
68+
*/
69+
export async function collectFolderContext(cwd, targetPaths, opts = {}) {
70+
const maxBytes = Number.isFinite(opts.maxBytes) ? opts.maxBytes : DEFAULT_MAX_BYTES;
71+
const maxFiles = Number.isFinite(opts.maxFiles) ? opts.maxFiles : DEFAULT_MAX_FILES;
72+
const root = path.resolve(cwd);
73+
let realRoot;
74+
try {
75+
realRoot = fs.realpathSync(root);
76+
} catch {
77+
realRoot = root;
78+
}
79+
80+
const result = {
81+
content: "",
82+
files: [],
83+
totalBytes: 0,
84+
overflowed: false,
85+
overflowedBytes: false,
86+
overflowedFiles: false,
87+
};
88+
89+
const visited = new Set();
90+
const pending = [];
91+
92+
for (const targetPath of targetPaths) {
93+
const resolvedPath = path.resolve(cwd, targetPath);
94+
if (!isInsidePath(root, resolvedPath)) continue;
95+
pending.push(resolvedPath);
96+
}
97+
98+
while (pending.length > 0) {
99+
if (result.files.length >= maxFiles) {
100+
result.overflowed = true;
101+
result.overflowedFiles = true;
102+
break;
103+
}
104+
105+
const resolvedPath = pending.shift();
106+
107+
try {
108+
const stat = fs.lstatSync(resolvedPath);
109+
let realPath = resolvedPath;
110+
111+
if (stat.isSymbolicLink()) {
112+
try {
113+
realPath = fs.realpathSync(resolvedPath);
114+
} catch {
115+
continue;
116+
}
117+
} else {
118+
realPath = fs.realpathSync(resolvedPath);
119+
}
120+
121+
if (!isInsidePath(realRoot, realPath)) continue;
122+
if (visited.has(realPath)) continue;
123+
visited.add(realPath);
124+
if (path.basename(realPath) === ".git") continue;
125+
126+
const realStat = fs.statSync(realPath);
127+
if (realStat.isDirectory()) {
128+
const entries = fs.readdirSync(realPath, { withFileTypes: true })
129+
.sort((a, b) => a.name.localeCompare(b.name));
130+
for (let i = entries.length - 1; i >= 0; i -= 1) {
131+
pending.unshift(path.join(realPath, entries[i].name));
132+
}
133+
} else if (realStat.isFile()) {
134+
if (isBinaryFile(realPath)) continue;
135+
136+
const relativePath = path.relative(root, realPath);
137+
if (!isInsidePath(root, realPath)) continue;
138+
if (isGitignored(realPath, root)) continue;
139+
140+
const content = fs.readFileSync(realPath, "utf8");
141+
const fileBytes = Buffer.byteLength(content, "utf8");
142+
143+
if (result.totalBytes + fileBytes > maxBytes) {
144+
result.overflowed = true;
145+
result.overflowedBytes = true;
146+
const remaining = maxBytes - result.totalBytes;
147+
if (remaining > 0) {
148+
const truncated = truncateUtf8(content, remaining);
149+
result.content += `// File: ${toGitPath(relativePath)} (truncated)\n${truncated}\n\n`;
150+
result.totalBytes += Buffer.byteLength(truncated, "utf8");
151+
result.files.push(toGitPath(relativePath));
152+
}
153+
break;
154+
}
155+
156+
result.content += `// File: ${toGitPath(relativePath)}\n${content}\n\n`;
157+
result.totalBytes += fileBytes;
158+
result.files.push(toGitPath(relativePath));
159+
160+
if (result.files.length >= maxFiles) {
161+
if (pending.length > 0) {
162+
result.overflowed = true;
163+
result.overflowedFiles = true;
164+
}
165+
break;
166+
}
167+
}
168+
} catch (err) {
169+
if (err?.code !== "ENOENT") {
170+
// Skip files that don't exist
171+
}
172+
}
173+
}
174+
175+
return result;
176+
}
177+
178+
function truncateUtf8(text, maxBytes) {
179+
if (!text) return text;
180+
const buf = Buffer.from(text, "utf8");
181+
if (buf.length <= maxBytes) return text;
182+
return buf.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/, "");
183+
}
5184

6185
/**
7186
* Ensure a directory exists (recursive mkdir).

plugins/opencode/scripts/lib/prompts.mjs

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
getPrInfo,
1616
getPrDiff,
1717
} from "./git.mjs";
18+
import { collectFolderContext } from "./fs.mjs";
1819

1920
// Inline-diff thresholds. When a review exceeds either, we keep the prompt
2021
// bounded by including a diff excerpt instead of the full diff. The review
@@ -44,6 +45,7 @@ function truncateUtf8(text, maxBytes) {
4445
* @param {number} [opts.pr] - GitHub PR number to review (uses `gh pr diff`)
4546
* @param {boolean} [opts.adversarial] - use adversarial review prompt
4647
* @param {string} [opts.focus] - user-supplied focus text
48+
* @param {string[]} [opts.paths] - specific paths to review instead of git diff
4749
* @param {string} pluginRoot - CLAUDE_PLUGIN_ROOT for reading prompt templates
4850
* @returns {Promise<string>}
4951
*/
@@ -61,6 +63,53 @@ export async function buildReviewPrompt(cwd, opts, pluginRoot) {
6163
let prInfo = null;
6264
let diffStat = "";
6365
let overByteLimit = false;
66+
let folderContext = null;
67+
68+
// Step 1: When --path is specified, collect path context instead of git diff.
69+
// Paths take precedence over PR mode so a command that includes both remains
70+
// local and does not require gh/auth.
71+
if (opts.paths && opts.paths.length > 0) {
72+
folderContext = await collectFolderContext(cwd, opts.paths, {
73+
maxBytes,
74+
maxFiles,
75+
});
76+
changedFiles = folderContext.files;
77+
overByteLimit = folderContext.overflowedBytes;
78+
const diffBytes = folderContext.totalBytes;
79+
const diffIsComplete = !folderContext.overflowed;
80+
const collectionGuidance = buildCollectionGuidance(diffIsComplete);
81+
82+
const targetLabel = `Review of ${opts.paths.join(", ")}`;
83+
84+
const reviewContext = buildFolderContext(folderContext, {
85+
diffIsComplete,
86+
originalDiffBytes: diffBytes,
87+
maxInlineDiffBytes: maxBytes,
88+
maxInlineDiffFiles: maxFiles,
89+
overFileLimit: folderContext.overflowedFiles,
90+
overByteLimit: folderContext.overflowedBytes,
91+
});
92+
93+
let systemPrompt;
94+
if (opts.adversarial) {
95+
const templatePath = path.join(pluginRoot, "prompts", "adversarial-review.md");
96+
systemPrompt = fs.readFileSync(templatePath, "utf8")
97+
.replace("{{TARGET_LABEL}}", targetLabel)
98+
.replace("{{USER_FOCUS}}", opts.focus || "General review")
99+
.replace("{{REVIEW_COLLECTION_GUIDANCE}}", collectionGuidance)
100+
.replace("{{REVIEW_INPUT}}", reviewContext);
101+
} else {
102+
systemPrompt = buildStandardReviewPrompt(folderContext.content, status, changedFiles, {
103+
...opts,
104+
targetLabel,
105+
prInfo,
106+
reviewContext,
107+
collectionGuidance,
108+
});
109+
}
110+
111+
return systemPrompt;
112+
}
64113

65114
// Step 1: cheap metadata. The status / changed-file list / shortstat
66115
// reads do not materialize the full diff and are safe on any size.
@@ -227,6 +276,47 @@ function buildReviewContext(diff, status, changedFiles, prInfo, opts = {}) {
227276
return sections.join("\n\n");
228277
}
229278

279+
/**
280+
* Build the repository context block for folder/path-based review prompts.
281+
* Uses <files> section instead of <diff> when context is collected from paths.
282+
*/
283+
function buildFolderContext(folderContext, opts = {}) {
284+
const sections = [];
285+
286+
if (folderContext.files.length > 0) {
287+
sections.push(`<files_reviewed>\n${folderContext.files.join("\n")}\n</files_reviewed>`);
288+
}
289+
290+
if (opts.overFileLimit || opts.overByteLimit) {
291+
const reasons = [];
292+
if (opts.overFileLimit) {
293+
const max = opts.maxInlineDiffFiles;
294+
reasons.push(max ? `file count limit ${max} reached` : "file count limit reached");
295+
}
296+
if (opts.overByteLimit) {
297+
reasons.push(`content size ${opts.originalDiffBytes} bytes`);
298+
}
299+
const budget = opts.overByteLimit && opts.maxInlineDiffBytes
300+
? `; excerpt budget ${opts.maxInlineDiffBytes} bytes`
301+
: "";
302+
const note = opts.diffIsComplete === false
303+
? "File content is bounded"
304+
: "Review spans multiple files, but all content is included";
305+
sections.push(
306+
`<content_note>\n` +
307+
`${note} (${reasons.join(", ")}${budget}). ` +
308+
`Findings must be supported by the file evidence below.\n` +
309+
`</content_note>`
310+
);
311+
}
312+
313+
if (folderContext.content) {
314+
sections.push(`<files>\n${folderContext.content}\n</files>`);
315+
}
316+
317+
return sections.join("\n\n");
318+
}
319+
230320
/**
231321
* Build a task prompt from user input.
232322
* @param {string} taskText

0 commit comments

Comments
 (0)