diff --git a/README.md b/README.md index 49bbca3..f018a87 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ To check your configured providers: ## Slash Commands -- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base `, `--pr `, `--model `, `--free`, `--wait`, and `--background`. Uses the saved default model when configured and no runtime model flag is supplied. -- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base `, `--pr `, `--model `, `--free`, `--wait`, `--background`, and custom focus text. Uses the saved default model when configured and no runtime model flag is supplied. +- `/opencode:review` -- Normal OpenCode code review (read-only). Supports `--base `, `--pr `, `--path `, `--model `, `--free`, `--wait`, and `--background`. Uses the saved default model when configured and no runtime model flag is supplied. +- `/opencode:adversarial-review` -- Steerable review that challenges implementation and design decisions. Supports `--base `, `--pr `, `--path `, `--model `, `--free`, `--wait`, `--background`, and custom focus text. Uses the saved default model when configured and no runtime model flag is supplied. - `/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. - `/opencode:status` -- Shows running/recent OpenCode jobs for the current repo. - `/opencode:result` -- Shows final output for a finished job, including OpenCode session ID for resuming. diff --git a/plugins/opencode/commands/adversarial-review.md b/plugins/opencode/commands/adversarial-review.md index 7842756..6a692ad 100644 --- a/plugins/opencode/commands/adversarial-review.md +++ b/plugins/opencode/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run a steerable adversarial OpenCode review that challenges implementation and design decisions -argument-hint: '[--wait|--background] [--base ] [--model | --free] [--pr ] [focus area or custom review instructions]' +argument-hint: '[--wait|--background] [--base ] [--model | --free] [--pr ] [--path ] [--path ] [focus area or custom review instructions]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion --- @@ -40,6 +40,7 @@ Argument handling: - `--model ` 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. - `--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. - `--pr ` 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. +- `--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). PR reference extraction (REQUIRED — read this carefully): - 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. diff --git a/plugins/opencode/commands/review.md b/plugins/opencode/commands/review.md index 1054d6d..bbc54f9 100644 --- a/plugins/opencode/commands/review.md +++ b/plugins/opencode/commands/review.md @@ -1,6 +1,6 @@ --- description: Run an OpenCode code review against local git state or a GitHub PR -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model | --free] [--pr ]' +argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model | --free] [--pr ] [--path ] [--path ]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion --- @@ -42,6 +42,7 @@ Argument handling: - `--model ` 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. - `--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. - `--pr ` 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. +- `--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). - **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`. Foreground flow: diff --git a/plugins/opencode/scripts/lib/args.mjs b/plugins/opencode/scripts/lib/args.mjs index 7e5413b..d0a6062 100644 --- a/plugins/opencode/scripts/lib/args.mjs +++ b/plugins/opencode/scripts/lib/args.mjs @@ -3,11 +3,12 @@ /** * Parse CLI arguments into options and positional args. * @param {string[]} argv - * @param {{ valueOptions?: string[], booleanOptions?: string[] }} schema + * @param {{ valueOptions?: string[], booleanOptions?: string[], multiValueOptions?: string[] }} schema * @returns {{ options: Record, positional: string[] }} */ export function parseArgs(argv, schema = {}) { const valueSet = new Set(schema.valueOptions ?? []); + const multiValueSet = new Set(schema.multiValueOptions ?? []); const boolSet = new Set(schema.booleanOptions ?? []); const options = {}; const positional = []; @@ -20,7 +21,18 @@ export function parseArgs(argv, schema = {}) { } const key = arg.slice(2); if (valueSet.has(key)) { - options[key] = argv[++i] ?? ""; + const value = argv[++i] ?? ""; + if (multiValueSet.has(key)) { + if (options[key] === undefined) { + options[key] = [value]; + } else if (Array.isArray(options[key])) { + options[key].push(value); + } else { + options[key] = [options[key], value]; + } + } else { + options[key] = value; + } } else if (boolSet.has(key) || !valueSet.has(key)) { options[key] = true; } diff --git a/plugins/opencode/scripts/lib/fs.mjs b/plugins/opencode/scripts/lib/fs.mjs index a4591df..b956e13 100644 --- a/plugins/opencode/scripts/lib/fs.mjs +++ b/plugins/opencode/scripts/lib/fs.mjs @@ -2,6 +2,185 @@ import fs from "node:fs"; import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const DEFAULT_MAX_BYTES = 256 * 1024; +const DEFAULT_MAX_FILES = 50; + +function toGitPath(filePath) { + return filePath.split(path.sep).join("/"); +} + +function isInsidePath(parent, candidate) { + const relative = path.relative(parent, candidate); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function isGitignored(filePath, cwd) { + try { + const result = fs.statSync(filePath); + if (!result.isFile()) return false; + + const relativePath = path.relative(cwd, filePath); + if (!isInsidePath(cwd, filePath)) return false; + + const checked = spawnSync("git", ["check-ignore", "-q", "--", toGitPath(relativePath)], { + cwd, + stdio: "ignore", + }); + return checked.status === 0; + } catch { + return false; + } +} + +function isBinaryFile(filePath) { + let fd = null; + try { + const buffer = Buffer.alloc(8192); + fd = fs.openSync(filePath, "r"); + const bytesRead = fs.readSync(fd, buffer, 0, 8192, 0); + for (let i = 0; i < bytesRead; i++) { + if (buffer[i] === 0) return true; + } + return false; + } catch { + return false; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // best-effort + } + } + } +} + +/** + * Collect file contents for a set of paths within cwd. + * Respects per-file and total size caps, skips binary files and broken symlinks. + * + * @param {string} cwd - Working directory + * @param {string[]} targetPaths - Relative paths to include + * @param {{ maxBytes?: number, maxFiles?: number }} opts + * @returns {Promise<{ content: string, files: string[], totalBytes: number, overflowed: boolean, overflowedBytes: boolean, overflowedFiles: boolean }>} + */ +export async function collectFolderContext(cwd, targetPaths, opts = {}) { + const maxBytes = Number.isFinite(opts.maxBytes) ? opts.maxBytes : DEFAULT_MAX_BYTES; + const maxFiles = Number.isFinite(opts.maxFiles) ? opts.maxFiles : DEFAULT_MAX_FILES; + const root = path.resolve(cwd); + let realRoot; + try { + realRoot = fs.realpathSync(root); + } catch { + realRoot = root; + } + + const result = { + content: "", + files: [], + totalBytes: 0, + overflowed: false, + overflowedBytes: false, + overflowedFiles: false, + }; + + const visited = new Set(); + const pending = []; + + for (const targetPath of targetPaths) { + const resolvedPath = path.resolve(cwd, targetPath); + if (!isInsidePath(root, resolvedPath)) continue; + pending.push(resolvedPath); + } + + while (pending.length > 0) { + if (result.files.length >= maxFiles) { + result.overflowed = true; + result.overflowedFiles = true; + break; + } + + const resolvedPath = pending.shift(); + + try { + const stat = fs.lstatSync(resolvedPath); + let realPath = resolvedPath; + + if (stat.isSymbolicLink()) { + try { + realPath = fs.realpathSync(resolvedPath); + } catch { + continue; + } + } else { + realPath = fs.realpathSync(resolvedPath); + } + + if (!isInsidePath(realRoot, realPath)) continue; + if (visited.has(realPath)) continue; + visited.add(realPath); + if (path.basename(realPath) === ".git") continue; + + const realStat = fs.statSync(realPath); + if (realStat.isDirectory()) { + const entries = fs.readdirSync(realPath, { withFileTypes: true }) + .sort((a, b) => a.name.localeCompare(b.name)); + for (let i = entries.length - 1; i >= 0; i -= 1) { + pending.unshift(path.join(realPath, entries[i].name)); + } + } else if (realStat.isFile()) { + if (isBinaryFile(realPath)) continue; + + const relativePath = path.relative(root, realPath); + if (!isInsidePath(root, realPath)) continue; + if (isGitignored(realPath, root)) continue; + + const content = fs.readFileSync(realPath, "utf8"); + const fileBytes = Buffer.byteLength(content, "utf8"); + + if (result.totalBytes + fileBytes > maxBytes) { + result.overflowed = true; + result.overflowedBytes = true; + const remaining = maxBytes - result.totalBytes; + if (remaining > 0) { + const truncated = truncateUtf8(content, remaining); + result.content += `// File: ${toGitPath(relativePath)} (truncated)\n${truncated}\n\n`; + result.totalBytes += Buffer.byteLength(truncated, "utf8"); + result.files.push(toGitPath(relativePath)); + } + break; + } + + result.content += `// File: ${toGitPath(relativePath)}\n${content}\n\n`; + result.totalBytes += fileBytes; + result.files.push(toGitPath(relativePath)); + + if (result.files.length >= maxFiles) { + if (pending.length > 0) { + result.overflowed = true; + result.overflowedFiles = true; + } + break; + } + } + } catch (err) { + if (err?.code !== "ENOENT") { + // Skip files that don't exist + } + } + } + + return result; +} + +function truncateUtf8(text, maxBytes) { + if (!text) return text; + const buf = Buffer.from(text, "utf8"); + if (buf.length <= maxBytes) return text; + return buf.subarray(0, maxBytes).toString("utf8").replace(/\uFFFD$/, ""); +} /** * Ensure a directory exists (recursive mkdir). diff --git a/plugins/opencode/scripts/lib/prompts.mjs b/plugins/opencode/scripts/lib/prompts.mjs index 5573490..7a2e0af 100644 --- a/plugins/opencode/scripts/lib/prompts.mjs +++ b/plugins/opencode/scripts/lib/prompts.mjs @@ -15,6 +15,7 @@ import { getPrInfo, getPrDiff, } from "./git.mjs"; +import { collectFolderContext } from "./fs.mjs"; // Inline-diff thresholds. When a review exceeds either, we keep the prompt // bounded by including a diff excerpt instead of the full diff. The review @@ -44,6 +45,7 @@ function truncateUtf8(text, maxBytes) { * @param {number} [opts.pr] - GitHub PR number to review (uses `gh pr diff`) * @param {boolean} [opts.adversarial] - use adversarial review prompt * @param {string} [opts.focus] - user-supplied focus text + * @param {string[]} [opts.paths] - specific paths to review instead of git diff * @param {string} pluginRoot - CLAUDE_PLUGIN_ROOT for reading prompt templates * @returns {Promise} */ @@ -61,6 +63,53 @@ export async function buildReviewPrompt(cwd, opts, pluginRoot) { let prInfo = null; let diffStat = ""; let overByteLimit = false; + let folderContext = null; + + // Step 1: When --path is specified, collect path context instead of git diff. + // Paths take precedence over PR mode so a command that includes both remains + // local and does not require gh/auth. + if (opts.paths && opts.paths.length > 0) { + folderContext = await collectFolderContext(cwd, opts.paths, { + maxBytes, + maxFiles, + }); + changedFiles = folderContext.files; + overByteLimit = folderContext.overflowedBytes; + const diffBytes = folderContext.totalBytes; + const diffIsComplete = !folderContext.overflowed; + const collectionGuidance = buildCollectionGuidance(diffIsComplete); + + const targetLabel = `Review of ${opts.paths.join(", ")}`; + + const reviewContext = buildFolderContext(folderContext, { + diffIsComplete, + originalDiffBytes: diffBytes, + maxInlineDiffBytes: maxBytes, + maxInlineDiffFiles: maxFiles, + overFileLimit: folderContext.overflowedFiles, + overByteLimit: folderContext.overflowedBytes, + }); + + let systemPrompt; + if (opts.adversarial) { + const templatePath = path.join(pluginRoot, "prompts", "adversarial-review.md"); + systemPrompt = fs.readFileSync(templatePath, "utf8") + .replace("{{TARGET_LABEL}}", targetLabel) + .replace("{{USER_FOCUS}}", opts.focus || "General review") + .replace("{{REVIEW_COLLECTION_GUIDANCE}}", collectionGuidance) + .replace("{{REVIEW_INPUT}}", reviewContext); + } else { + systemPrompt = buildStandardReviewPrompt(folderContext.content, status, changedFiles, { + ...opts, + targetLabel, + prInfo, + reviewContext, + collectionGuidance, + }); + } + + return systemPrompt; + } // Step 1: cheap metadata. The status / changed-file list / shortstat // reads do not materialize the full diff and are safe on any size. @@ -227,6 +276,47 @@ function buildReviewContext(diff, status, changedFiles, prInfo, opts = {}) { return sections.join("\n\n"); } +/** + * Build the repository context block for folder/path-based review prompts. + * Uses section instead of when context is collected from paths. + */ +function buildFolderContext(folderContext, opts = {}) { + const sections = []; + + if (folderContext.files.length > 0) { + sections.push(`\n${folderContext.files.join("\n")}\n`); + } + + if (opts.overFileLimit || opts.overByteLimit) { + const reasons = []; + if (opts.overFileLimit) { + const max = opts.maxInlineDiffFiles; + reasons.push(max ? `file count limit ${max} reached` : "file count limit reached"); + } + if (opts.overByteLimit) { + reasons.push(`content size ${opts.originalDiffBytes} bytes`); + } + const budget = opts.overByteLimit && opts.maxInlineDiffBytes + ? `; excerpt budget ${opts.maxInlineDiffBytes} bytes` + : ""; + const note = opts.diffIsComplete === false + ? "File content is bounded" + : "Review spans multiple files, but all content is included"; + sections.push( + `\n` + + `${note} (${reasons.join(", ")}${budget}). ` + + `Findings must be supported by the file evidence below.\n` + + `` + ); + } + + if (folderContext.content) { + sections.push(`\n${folderContext.content}\n`); + } + + return sections.join("\n\n"); +} + /** * Build a task prompt from user input. * @param {string} taskText diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index e97d7c7..7814363 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -300,7 +300,8 @@ async function resolveRequestedModel(options) { async function handleReview(argv) { const { options } = parseArgs(argv, { - valueOptions: ["base", "scope", "model", "pr"], + valueOptions: ["base", "scope", "model", "pr", "path"], + multiValueOptions: ["path"], booleanOptions: ["wait", "background", "free"], }); @@ -310,6 +311,9 @@ async function handleReview(argv) { process.exit(1); } + const paths = normalizePathOption(options.path); + const effectivePrNumber = paths?.length ? null : prNumber; + const workspace = await resolveWorkspace(); const state = loadState(workspace); const defaults = normalizeDefaults(state.config?.defaults); @@ -326,7 +330,8 @@ async function handleReview(argv) { const job = createJobRecord(workspace, "review", { base: options.base, model: requestedModel?.raw ?? modelOptions.model, - pr: prNumber, + pr: effectivePrNumber, + paths, }); try { @@ -340,7 +345,8 @@ async function handleReview(argv) { const prompt = await buildReviewPrompt(workspace, { base: options.base, - pr: prNumber, + pr: effectivePrNumber, + paths, adversarial: false, }, PLUGIN_ROOT); @@ -348,7 +354,7 @@ async function handleReview(argv) { const modelLabel = requestedModel?.raw ?? null; report("reviewing", "Running review..."); - log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}${modelLabel ? `, model: ${modelLabel}${options.free ? " (--free picked)" : ""}` : ""}${prNumber ? `, pr: #${prNumber}` : ""}`); + log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}${modelLabel ? `, model: ${modelLabel}${options.free ? " (--free picked)" : ""}` : ""}${effectivePrNumber ? `, pr: #${effectivePrNumber}` : ""}${paths?.length ? `, paths: ${paths.join(", ")}` : ""}`); const response = await client.sendPrompt(session.id, prompt, { agent: reviewAgent.agent, @@ -381,7 +387,8 @@ async function handleReview(argv) { async function handleAdversarialReview(argv) { const { options, positional } = parseArgs(argv, { - valueOptions: ["base", "scope", "model", "pr"], + valueOptions: ["base", "scope", "model", "pr", "path"], + multiValueOptions: ["path"], booleanOptions: ["wait", "background", "free"], }); @@ -418,11 +425,15 @@ async function handleAdversarialReview(argv) { } } + const paths = normalizePathOption(options.path); + const effectivePrNumber = paths?.length ? null : prNumber; + const job = createJobRecord(workspace, "adversarial-review", { base: options.base, focus, model: requestedModel?.raw ?? modelOptions.model, - pr: prNumber, + pr: effectivePrNumber, + paths, }); try { @@ -436,7 +447,8 @@ async function handleAdversarialReview(argv) { const prompt = await buildReviewPrompt(workspace, { base: options.base, - pr: prNumber, + pr: effectivePrNumber, + paths, adversarial: true, focus, }, PLUGIN_ROOT); @@ -445,7 +457,7 @@ async function handleAdversarialReview(argv) { const modelLabel = requestedModel?.raw ?? null; report("reviewing", "Running adversarial review..."); - log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}, focus: ${focus || "(none)"}${modelLabel ? `, model: ${modelLabel}${options.free ? " (--free picked)" : ""}` : ""}${prNumber ? `, pr: #${prNumber}` : ""}`); + log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}, focus: ${focus || "(none)"}${modelLabel ? `, model: ${modelLabel}${options.free ? " (--free picked)" : ""}` : ""}${effectivePrNumber ? `, pr: #${effectivePrNumber}` : ""}${paths?.length ? `, paths: ${paths.join(", ")}` : ""}`); const response = await client.sendPrompt(session.id, prompt, { agent: reviewAgent.agent, @@ -1148,6 +1160,23 @@ async function handleLastReview(argv) { // Helpers // ------------------------------------------------------------------ +/** + * Normalize path option to always return an array. + * Handles both `--path src --path lib` (array) and `--path src,lib` (comma-separated string). + * @param {string|string[]|undefined} pathOption + * @returns {string[]|null} + */ +function normalizePathOption(pathOption) { + if (!pathOption) return null; + if (Array.isArray(pathOption)) { + return pathOption.flatMap((p) => p.split(",")).map((p) => p.trim()).filter(Boolean); + } + if (typeof pathOption === "string") { + return pathOption.split(",").map((p) => p.trim()).filter(Boolean); + } + return null; +} + /** * Extract text from an OpenCode API response. * @param {any} response diff --git a/tests/args.test.mjs b/tests/args.test.mjs index 6a78af4..252dc75 100644 --- a/tests/args.test.mjs +++ b/tests/args.test.mjs @@ -35,6 +35,15 @@ describe("parseArgs", () => { assert.equal(options.write, true); assert.deepEqual(positional, ["fix", "the", "bug"]); }); + + it("accumulates repeated multi-value options", () => { + const { options } = parseArgs( + ["--path", "src", "--path", "lib", "--model", "anthropic/claude"], + { valueOptions: ["path", "model"], multiValueOptions: ["path"] } + ); + assert.deepEqual(options.path, ["src", "lib"]); + assert.equal(options.model, "anthropic/claude"); + }); }); describe("extractTaskText", () => { diff --git a/tests/path-review.test.mjs b/tests/path-review.test.mjs new file mode 100644 index 0000000..fb7a3fb --- /dev/null +++ b/tests/path-review.test.mjs @@ -0,0 +1,159 @@ +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { createTmpDir, cleanupTmpDir } from "./helpers.mjs"; +import { runCommand } from "../plugins/opencode/scripts/lib/process.mjs"; +import { collectFolderContext } from "../plugins/opencode/scripts/lib/fs.mjs"; +import { buildReviewPrompt } from "../plugins/opencode/scripts/lib/prompts.mjs"; + +const PLUGIN_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "plugins", + "opencode" +); + +let tmpDir; + +beforeEach(() => { + tmpDir = createTmpDir("path-review-"); +}); + +afterEach(() => { + cleanupTmpDir(tmpDir); +}); + +describe("collectFolderContext", () => { + it("recursively collects directory targets", async () => { + fs.mkdirSync(path.join(tmpDir, "src", "nested"), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, "src", "index.js"), "export const root = true;\n"); + fs.writeFileSync(path.join(tmpDir, "src", "nested", "child.js"), "export const child = true;\n"); + + const context = await collectFolderContext(tmpDir, ["src"]); + + assert.deepEqual(context.files, ["src/index.js", "src/nested/child.js"]); + assert.match(context.content, /root = true/); + assert.match(context.content, /child = true/); + assert.equal(context.overflowed, false); + }); + + it("rejects sibling paths that share the workspace prefix", async () => { + const repo = path.join(tmpDir, "repo"); + const sibling = path.join(tmpDir, "repo-evil"); + fs.mkdirSync(repo); + fs.mkdirSync(sibling); + fs.writeFileSync(path.join(sibling, "secret.txt"), "do not read me\n"); + + const context = await collectFolderContext(repo, ["../repo-evil/secret.txt"]); + + assert.deepEqual(context.files, []); + assert.doesNotMatch(context.content, /do not read me/); + }); + + it("does not follow symlinks outside the workspace", async () => { + const repo = path.join(tmpDir, "repo"); + fs.mkdirSync(repo); + const outside = path.join(tmpDir, "outside.txt"); + fs.writeFileSync(outside, "outside secret\n"); + fs.symlinkSync(outside, path.join(repo, "inside-link.txt")); + + const context = await collectFolderContext(repo, ["inside-link.txt"]); + + assert.deepEqual(context.files, []); + assert.doesNotMatch(context.content, /outside secret/); + }); + + it("respects gitignore rules for nested files", async () => { + await runCommand("git", ["init", "-q"], { cwd: tmpDir }); + fs.writeFileSync(path.join(tmpDir, ".gitignore"), "ignored/\n"); + fs.mkdirSync(path.join(tmpDir, "ignored")); + fs.writeFileSync(path.join(tmpDir, "ignored", "secret.js"), "export const secret = true;\n"); + fs.writeFileSync(path.join(tmpDir, "visible.js"), "export const visible = true;\n"); + + const context = await collectFolderContext(tmpDir, ["ignored", "visible.js"]); + + assert.deepEqual(context.files, ["visible.js"]); + assert.doesNotMatch(context.content, /secret = true/); + assert.match(context.content, /visible = true/); + }); + + it("skips binary files", async () => { + fs.writeFileSync(path.join(tmpDir, "image.bin"), Buffer.from([0x01, 0x00, 0x02])); + fs.writeFileSync(path.join(tmpDir, "text.txt"), "plain text\n"); + + const context = await collectFolderContext(tmpDir, ["."]); + + assert.deepEqual(context.files, ["text.txt"]); + assert.doesNotMatch(context.content, /image\.bin/); + assert.match(context.content, /plain text/); + }); + + it("reports file-count overflow when maxFiles is reached", async () => { + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "a.js"), "a\n"); + fs.writeFileSync(path.join(tmpDir, "src", "b.js"), "b\n"); + + const context = await collectFolderContext(tmpDir, ["src"], { maxFiles: 1 }); + + assert.deepEqual(context.files, ["src/a.js"]); + assert.equal(context.overflowed, true); + assert.equal(context.overflowedFiles, true); + assert.equal(context.overflowedBytes, false); + }); + + it("closes binary reads and reports byte overflow for truncated content", async () => { + fs.writeFileSync(path.join(tmpDir, "large.txt"), "x".repeat(100)); + + const context = await collectFolderContext(tmpDir, ["large.txt"], { maxBytes: 10 }); + + assert.deepEqual(context.files, ["large.txt"]); + assert.equal(context.overflowed, true); + assert.equal(context.overflowedBytes, true); + assert.match(context.content, /truncated/); + }); +}); + +describe("path review prompts", () => { + it("uses path mode even when pr is also provided", async () => { + fs.writeFileSync(path.join(tmpDir, "target.js"), "export const target = true;\n"); + + const prompt = await buildReviewPrompt( + tmpDir, + { + pr: 12345, + paths: ["target.js"], + adversarial: true, + focus: "path mode", + }, + PLUGIN_ROOT + ); + + assert.match(prompt, /Review of target\.js/); + assert.match(prompt, //); + assert.match(prompt, /target = true/); + assert.doesNotMatch(prompt, //); + }); + + it("emits an incomplete-evidence note when path collection is capped", async () => { + fs.mkdirSync(path.join(tmpDir, "src")); + fs.writeFileSync(path.join(tmpDir, "src", "a.js"), "a\n"); + fs.writeFileSync(path.join(tmpDir, "src", "b.js"), "b\n"); + + const prompt = await buildReviewPrompt( + tmpDir, + { + paths: ["src"], + adversarial: true, + focus: "file cap", + maxInlineDiffFiles: 1, + }, + PLUGIN_ROOT + ); + + assert.match(prompt, //); + assert.match(prompt, /File content is bounded/); + assert.match(prompt, /file count limit 1 reached/); + }); +});