From ba1bdd814de09a1fc33a356ce5e6cfed224e1478 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Tue, 25 Aug 2026 22:55:11 -0300 Subject: [PATCH 1/2] feat: enforce review preflight coverage and budgets --- CHANGELOG.md | 1 + README.md | 9 +- agents/code-review/agent.ts | 167 +++++++++++++++++++++++++++++------- docs/OPERATIONS.md | 5 +- llms-full.txt | 15 +++- readme-standard-v1.json | 2 +- src/cli.ts | 45 ++++++++-- src/review-config.ts | 15 +++- test/cli-smoke.test.mjs | 56 +++++++++++- test/fixtures/bin/codex | 8 +- test/review-config.test.mjs | 4 + 11 files changed, 274 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b09c7bd..e52740d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes will be documented here. This project follows Semantic Versi - Added strict versioned `.agentskit-review.json` policy with lens coverage, budgets, thresholds, context, and safe CI precedence; incomplete profiles require explicit local opt-in. - Hardened the shared local CLI worker with cancellation, process-tree cleanup, isolated temporary environments, bounded output, and redacted diagnostics. - Added bounded source snapshots with infrastructure/configuration file support, denylisted sensitive paths, symlink checks, input limits, and data-boundary-aware secret redaction. +- Added provider-free `--plan`/`--dry-run` preflight with explicit file/byte/call budgets, bounded retries, CLI concurrency defaults, and fail-closed required-lens coverage. - Made reviews fail closed when any reviewable file has no successful primary lens or cannot be ingested; advisory mode now suppresses finding-based failures only, never source/provider/execution failures. - Added primary-lens execution coverage to review summaries so partial provider degradation is visible. - Repositioned the CLI and GitHub Action as provider-neutral. diff --git a/README.md b/README.md index 2ad27bf..dc1bac1 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ AgentsKit Code Review is built around a different contract: - **Low noise by design.** Findings are challenged by independent verification votes before they survive. - **Local first, CI ready.** Review a diff before pushing, inspect complete paths, read stdin, or comment directly on a GitHub PR. - **Control cost and policy.** Set file budgets, concurrency, thresholds, project conventions, and blocking severity. +- **See the cost before execution.** Use `--plan --json` to inspect files, lenses, retries, concurrency, and estimated provider calls without a model request. ## Run your first review @@ -62,6 +63,8 @@ The CLI reviews the current repository's diff against `origin/main` and prints t Local `codex-cli` and `claude-cli` subprocesses have a 120-second deadline per model call. Set `AGENTSKIT_REVIEW_SUBPROCESS_TIMEOUT_MS` to a positive integer when a provider needs a different limit; timed-out lenses fail explicitly and cannot turn an unreviewed file into an approval. +Preflight refuses an over-budget run before the first provider call. `--dry-run` and `--plan` print the refusal and concrete reductions; `--json` makes the plan machine-readable. CLI providers default to concurrency `1`, while API providers retain concurrency `4`. Required-lens or source coverage failures always exit `2`, even with `--no-fail`. + ![AgentsKit Code Review showing an APPROVE result after seven review lenses complete](docs/assets/code-review-terminal.png) The current command runs directly from GitHub. After the first npm release, the shorter form will be: @@ -252,8 +255,10 @@ In shortened examples, replace `...` with `npx --yes github:AgentsKit-io/code-re | `--votes ` | Adversarial verification votes; default `3` | | `--min-severity ` | Minimum reported severity | | `--min-confidence ` | Minimum reported confidence | -| `--max-files ` | Positive file budget | -| `--concurrency ` | Parallel model calls; default `4` | +| `--max-files ` | Positive file budget; over-budget runs are refused before the provider | +| `--max-calls ` | Provider-call budget; absolute ceiling `1000` | +| `--concurrency ` | Parallel model calls; default `1` for CLI providers, `4` for API providers | +| `--plan`, `--dry-run` | Print provider-free preflight; add `--json` for machine output | | `--validate-patch` | Run `git apply --check` on suggested patches | | `--block ` | CI gate floor; default `blocker` | | `--no-fail` | Keep findings advisory | diff --git a/agents/code-review/agent.ts b/agents/code-review/agent.ts index 119487a..d413531 100644 --- a/agents/code-review/agent.ts +++ b/agents/code-review/agent.ts @@ -83,6 +83,40 @@ export interface LensExecutionStats { failed: number } +export interface ReviewPlan { + files: number + bytes: number + enabledLenses: Category[] + requiredLenses: Category[] + votes: number + retries: number + concurrency: number + estimatedProviderCalls: number + maxCalls: number + unreviewedFiles: number + overBudget: string[] + suggestions: string[] +} + +export class ReviewPreflightError extends Error { + readonly plan: ReviewPlan + + constructor(plan: ReviewPlan) { + super(`review preflight refused: ${plan.overBudget.join('; ')}`) + this.name = 'ReviewPreflightError' + this.plan = plan + } +} + +class ReviewCallBudgetError extends Error { + constructor(maxCalls: number) { + super(`review provider-call budget exceeded (${maxCalls})`) + this.name = 'ReviewCallBudgetError' + } +} + +class InvalidStructuredOutputError extends Error {} + /** A review had targets, but no lens produced a usable response. */ export class ReviewExecutionError extends Error { readonly execution: LensExecutionStats @@ -104,6 +138,7 @@ export interface ReviewResult { verdict: Verdict /** True when a finding at/above `blockingSeverity` survived — wire to your CI exit code. */ blocking: boolean + incomplete: boolean findings: Finding[] dropped: Finding[] droppedNote?: string @@ -126,12 +161,14 @@ export interface Lens { } export interface CodeReviewConfig { - adapter: AdapterFactory + adapter?: AdapterFactory source: SourceConfig /** Defaults to the 7 built-in lenses. Pass a subset to disable, or add custom lenses. */ lenses?: Lens[] /** A declared incomplete profile is reported as COMMENT, never APPROVE. */ incompleteProfile?: boolean + requiredLenses?: readonly Category[] + retries?: number /** Project conventions injected into every lens — a string, or a file to read. */ conventions?: string | { path: string } thresholds?: { minSeverity?: Severity; minConfidence?: number; maxPerFile?: number; suppressNits?: boolean } @@ -141,7 +178,7 @@ export interface CodeReviewConfig { consolidate?: boolean /** Validate suggested patches by `git apply --check` (git-diff/paths sources) before reporting. */ validatePatch?: boolean - budget?: { maxFiles?: number; concurrency?: number } + budget?: { maxFiles?: number; maxBytes?: number; maxCalls?: number; concurrency?: number } /** Default = [markdownReporter()]. */ reporters?: Reporter[] /** CI gate floor: a surviving finding at/above this severity sets `blocking`. Default 'blocker'. */ @@ -219,7 +256,12 @@ function createLimiter(max: number): Limiter { export function createCodeReviewAgent(config: CodeReviewConfig) { const lenses = config.lenses ?? DEFAULT_LENSES const auditVotes = Math.max(1, config.auditVotes ?? 3) + const retries = Math.min(1, Math.max(0, config.retries ?? 1)) const concurrency = Math.max(1, config.budget?.concurrency ?? 4) + const maxCalls = Math.min(1000, Math.max(1, config.budget?.maxCalls ?? 1000)) + const requiredLenses = new Set(config.requiredLenses ?? ['correctness', 'security', 'tests']) + let adapter = config.adapter + let providerCalls = 0 const maxSteps = config.maxSteps ?? 3 const minSeverity = config.thresholds?.minSeverity ?? 'nit' const minConfidence = config.thresholds?.minConfidence ?? 0.5 @@ -246,11 +288,22 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { }) as ToolDefinition async function runStructured(skill: SkillDefinition, task: string, tool: ToolDefinition, schema: T): Promise> { - const runtime = createRuntime({ adapter: config.adapter, tools: [tool], memory: config.memory, onConfirm: config.onConfirm, maxSteps }) - const result = await limit(() => runtime.run(task, { skill })) - const call = result.toolCalls.find((c) => c.name === tool.name) - if (!call) throw new Error(`${skill.name} did not submit a result`) - return schema.parse(call.args) + if (!adapter) throw new Error('provider adapter is not configured') + const activeAdapter = adapter + const invoke = async (): Promise> => { + if (++providerCalls > maxCalls) throw new ReviewCallBudgetError(maxCalls) + const runtime = createRuntime({ adapter: activeAdapter, tools: [tool], memory: config.memory, onConfirm: config.onConfirm, maxSteps }) + const result = await limit(() => runtime.run(task, { skill })) + const call = result.toolCalls.find((c) => c.name === tool.name) + if (!call) throw new InvalidStructuredOutputError(`${skill.name} did not submit a result`) + try { return schema.parse(call.args) } catch { throw new InvalidStructuredOutputError(`${skill.name} returned invalid structured output`) } + } + for (let attempt = 0; ; attempt++) { + try { return await invoke() } + catch (error) { + if (!(error instanceof InvalidStructuredOutputError) || attempt >= retries) throw error + } + } } async function resolveConventions(): Promise { @@ -282,7 +335,7 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { async function reviewTarget( target: ReviewTarget, conventions: string, - ): Promise<{ findings: Finding[]; execution: LensExecutionStats }> { + ): Promise<{ findings: Finding[]; execution: LensExecutionStats; succeededLenses: Category[] }> { const ranges = target.changedRanges?.length ? `CHANGED LINES (review focus, marked ▸): ${target.changedRanges.map((r) => `${r.start}-${r.end}`).join(', ')}` : 'WHOLE-FILE REVIEW (no diff).' @@ -295,18 +348,21 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { lens.severityCeiling && SEV_RANK[f.severity] < SEV_RANK[lens.severityCeiling] ? lens.severityCeiling : f.severity return { ...f, file: target.file, category: lens.key, severity, inDiff: inDiff(target, f.line) } }) - return { findings, succeeded: true } + return { findings, succeeded: true, lens: lens.key } } catch (e) { + if (e instanceof ReviewCallBudgetError) throw e // One bad model response (malformed JSON, missing tool call) must not sink // the whole review — drop this lens for this file and carry on. emit(`lens:${lens.key}`, 'error', `${target.file}: ${e instanceof Error ? e.message.split('\n')[0] : 'failed'}`) - return { findings: [] as Finding[], succeeded: false } + return { findings: [] as Finding[], succeeded: false, lens: lens.key } } })) - const succeeded = results.filter((result) => result.succeeded).length + const succeededResults = results.filter((result) => result.succeeded) + const succeeded = succeededResults.length return { findings: results.flatMap((result) => result.findings), execution: { attempted: results.length, succeeded, failed: results.length - succeeded }, + succeededLenses: succeededResults.map((result) => result.lens), } } @@ -335,7 +391,8 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { try { const out = await runStructured(consolidator, fenced(list), submit('submit_duplicate_groups', Consolidation), Consolidation) groups = out.duplicateGroups - } catch { + } catch (error) { + if (error instanceof ReviewCallBudgetError) throw error return findings // consolidation is best-effort, never fatal } const merged = new Set() @@ -365,7 +422,8 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { Array.from({ length: auditVotes }, async () => { try { return await runStructured(skeptic, task, submit('submit_verdict', SkepticVerdict), SkepticVerdict) - } catch { + } catch (error) { + if (error instanceof ReviewCallBudgetError) throw error return null // a malformed vote is ignored, not fatal } }), @@ -425,10 +483,12 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { droppedFiles: number, execution: LensExecutionStats, unreviewedCount: number, + incomplete: boolean, + missingRequired: Category[], ): ReviewResult { const counts = (['blocker', 'high', 'med', 'nit'] as Severity[]).map((s) => ({ s, n: kept.filter((f) => f.severity === s).length })) const worst = kept.length ? Math.min(...kept.map((f) => SEV_RANK[f.severity])) : 3 - const verdict: Verdict = config.incompleteProfile ? 'COMMENT' : !kept.length ? 'APPROVE' : worst <= SEV_RANK.high ? 'REQUEST CHANGES' : 'COMMENT' + const verdict: Verdict = incomplete ? 'COMMENT' : !kept.length ? 'APPROVE' : worst <= SEV_RANK.high ? 'REQUEST CHANGES' : 'COMMENT' const blocking = kept.some((f) => SEV_RANK[f.severity] <= SEV_RANK[blockingSeverity]) const breakdown = counts.filter((c) => c.n).map((c) => `${c.n} ${c.s}`).join(', ') || 'no findings' const executionSummary = @@ -436,11 +496,60 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { (execution.failed ? `; ${execution.failed} failed` : '') const summary = `${kept.length} finding(s) (${breakdown}) across ${reviewed} file(s)` + - (config.incompleteProfile ? ' Incomplete profile; this review is not an approval.' : '') + + (incomplete ? ` INCOMPLETE; this review is not an approval${missingRequired.length ? ` (missing required lenses: ${missingRequired.join(', ')})` : ''}.` : '') + (unreviewedCount ? ` ${unreviewedCount} file(s) UNREVIEWED.` : '') + (droppedFiles ? `, ${droppedFiles} file(s) skipped for budget` : '') + `. ${executionSummary}.` - return { verdict, blocking, findings: kept, dropped, execution, summary } + return { verdict, blocking, incomplete, findings: kept, dropped, execution, summary } + } + + function rankTargets(all: ReviewTarget[]): ReviewTarget[] { + return all.filter((target) => target.reviewStatus !== 'UNREVIEWED').sort( + (a, b) => + Number(b.isChanged) - Number(a.isChanged) || + (b.changedRanges?.length ?? 0) - (a.changedRanges?.length ?? 0) || + b.fullContent.length - a.fullContent.length, + ) + } + + function makePlan(all: ReviewTarget[]): ReviewPlan { + const ranked = rankTargets(all) + const budgetSkipped = all.filter((target) => target.reviewStatus === 'UNREVIEWED' && target.unreviewedReason?.startsWith('snapshot exceeds')) + const files = ranked.length + const bytes = ranked.reduce((total, target) => total + Buffer.byteLength(target.fullContent, 'utf8'), 0) + const enabledLenses = lenses.map((lens) => lens.key) + const required = [...requiredLenses] + const estimatedProviderCalls = files * enabledLenses.length * (1 + retries + auditVotes) + (files && enabledLenses.length ? 1 : 0) + const plan: ReviewPlan = { + files, bytes, enabledLenses, requiredLenses: required, votes: auditVotes, retries, concurrency, + estimatedProviderCalls, maxCalls, unreviewedFiles: all.length - files, overBudget: [], suggestions: [], + } + const maxFiles = config.budget?.maxFiles + const maxBytes = config.budget?.maxBytes + if (maxFiles !== undefined && files > maxFiles) { + plan.overBudget.push(`${files} files exceed maxFiles ${maxFiles}`) + plan.suggestions.push(`reduce scope with --max-files ${maxFiles} or --paths`) + } + if (budgetSkipped.length) { + plan.overBudget.push(`${budgetSkipped.length} snapshot file(s) were excluded by a source budget`) + plan.suggestions.push('raise the snapshot budget or narrow the context patterns') + } + if (maxBytes !== undefined && bytes > maxBytes) { + plan.overBudget.push(`${bytes} bytes exceed maxBytes ${maxBytes}`) + plan.suggestions.push('reduce scope with --paths or an isolated context pattern') + } + if (estimatedProviderCalls > maxCalls) { + const perFile = Math.max(1, enabledLenses.length * (1 + retries + auditVotes)) + plan.overBudget.push(`${estimatedProviderCalls} estimated provider calls exceed maxCalls ${maxCalls}`) + plan.suggestions.push(`reduce scope to at most ${Math.max(1, Math.floor((maxCalls - 1) / perFile))} files or lower --votes`) + } + return plan + } + + let cachedTargets: ReviewTarget[] | undefined + async function plan(): Promise { + cachedTargets ??= await loadTargets(config.source) + return makePlan(cachedTargets) } async function review(): Promise { @@ -449,20 +558,15 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { } emit('ingest', 'start') const t0 = Date.now() - const all = await loadTargets(config.source) + const all = cachedTargets ??= await loadTargets(config.source) + const plan = makePlan(all) + if (plan.overBudget.length) throw new ReviewPreflightError(plan) const unreviewed = all.filter((target) => target.reviewStatus === 'UNREVIEWED') for (const target of unreviewed) emit('ingest', 'skip', `${target.file}: ${target.unreviewedReason ?? 'unreviewed'}`) - // Prioritise: changed first, then by amount of change, then size. - const ranked = all.filter((target) => target.reviewStatus !== 'UNREVIEWED').sort( - (a, b) => - Number(b.isChanged) - Number(a.isChanged) || - (b.changedRanges?.length ?? 0) - (a.changedRanges?.length ?? 0) || - b.fullContent.length - a.fullContent.length, - ) - const maxFiles = config.budget?.maxFiles ?? ranked.length - const targets = ranked.slice(0, maxFiles) - const droppedFiles = ranked.length - targets.length - emit('ingest', 'ok', `${targets.length} file(s)${droppedFiles ? ` (+${droppedFiles} over budget)` : ''}`, Date.now() - t0) + const ranked = rankTargets(all) + const targets = ranked + const droppedFiles = 0 + emit('ingest', 'ok', `${targets.length} file(s)`, Date.now() - t0) if (!targets.length) { return { verdict: 'APPROVE', @@ -470,6 +574,7 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { findings: [], dropped: [], execution: { attempted: 0, succeeded: 0, failed: 0 }, + incomplete: Boolean(unreviewed.length > 0 || config.incompleteProfile), unreviewed: unreviewed.map((target) => ({ file: target.file, reason: target.unreviewedReason ?? 'unreviewed' })), summary: unreviewed.length ? `${unreviewed.length} file(s) UNREVIEWED; nothing else to review.` : 'Nothing to review.', } @@ -489,6 +594,7 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { }), { attempted: 0, succeeded: 0, failed: 0 }, ) + const missingRequired = [...requiredLenses].filter((key) => targetResults.some((result) => !result.succeededLenses.includes(key))) const unreviewedFiles = targetResults.flatMap((result, index) => result.execution.succeeded === 0 ? [targets[index]!.file] : [], ) @@ -527,7 +633,8 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { emit('validate-patch', 'ok', undefined, Date.now() - t3) } - const result = synthesize(kept, dropped, targets.length, droppedFiles, execution, unreviewed.length) + const incomplete = Boolean(config.incompleteProfile || unreviewed.length || droppedFiles || missingRequired.length) + const result = synthesize(kept, dropped, targets.length, droppedFiles, execution, unreviewed.length, incomplete, missingRequired) result.unreviewed = unreviewed.map((target) => ({ file: target.file, reason: target.unreviewedReason ?? 'unreviewed' })) result.droppedNote = `${refuted.length} refuted by skeptics; ${belowThreshold.length} below threshold` + @@ -543,6 +650,8 @@ export function createCodeReviewAgent(config: CodeReviewConfig) { return { name: 'code-review', run: review, + plan, + setAdapter(value: AdapterFactory) { adapter = value }, /** AgentHandle: treats the task string as a snippet to review, returns the summary. */ asHandle() { return { diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index e79c2cd..0828557 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -144,13 +144,16 @@ Then require the workflow check in branch protection. CLI exit codes are: A model response that is malformed may drop one lens while other lenses continue; progress output and the final summary report successful and failed primary-lens counts. If any reviewable file cannot be ingested or has zero successful primary lenses, the pipeline stops before reporters run and exits `2`, including in advisory mode. Treat missing output or exit `2` as unavailable review, not approval. +Use `--plan --json` (or `--dry-run`) to run the source and budget preflight without a model request. The plan reports files, bytes, enabled and required lenses, votes, retries, concurrency, estimated provider calls, and concrete reductions when a limit would be exceeded. The preflight refuses before the provider starts; `maxCalls` is capped at 1000 and unlimited mode is not supported. A required-lens failure is `INCOMPLETE` and exits `2`, including with `--no-fail`. + ## Cost and latency controls Seven lenses fan out over selected files; candidate findings then receive adversarial votes. The primary controls are: - `--max-files`: positive hard file budget; +- `--max-calls`: bounded provider-call budget (absolute ceiling 1000); - `--votes`: verification depth and cost; -- `--concurrency`: simultaneous model/subprocess calls; +- `--concurrency`: simultaneous model/subprocess calls (default 1 for CLI providers, 4 for API providers); - `--paths` or workflow path filters: narrow scope; - `--min-severity` and `--min-confidence`: output noise, not input-token cost. diff --git a/llms-full.txt b/llms-full.txt index 52f0a67..7ba0f40 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -58,6 +58,7 @@ AgentsKit Code Review is built around a different contract: - **Low noise by design.** Findings are challenged by independent verification votes before they survive. - **Local first, CI ready.** Review a diff before pushing, inspect complete paths, read stdin, or comment directly on a GitHub PR. - **Control cost and policy.** Set file budgets, concurrency, thresholds, project conventions, and blocking severity. +- **See the cost before execution.** Use `--plan --json` to inspect files, lenses, retries, concurrency, and estimated provider calls without a model request. ## Run your first review @@ -80,6 +81,8 @@ The CLI reviews the current repository's diff against `origin/main` and prints t Local `codex-cli` and `claude-cli` subprocesses have a 120-second deadline per model call. Set `AGENTSKIT_REVIEW_SUBPROCESS_TIMEOUT_MS` to a positive integer when a provider needs a different limit; timed-out lenses fail explicitly and cannot turn an unreviewed file into an approval. +Preflight refuses an over-budget run before the first provider call. `--dry-run` and `--plan` print the refusal and concrete reductions; `--json` makes the plan machine-readable. CLI providers default to concurrency `1`, while API providers retain concurrency `4`. Required-lens or source coverage failures always exit `2`, even with `--no-fail`. + ![AgentsKit Code Review showing an APPROVE result after seven review lenses complete](https://raw.githubusercontent.com/AgentsKit-io/code-review-cli/main/docs/assets/code-review-terminal.png) The current command runs directly from GitHub. After the first npm release, the shorter form will be: @@ -270,8 +273,10 @@ In shortened examples, replace `...` with `npx --yes github:AgentsKit-io/code-re | `--votes ` | Adversarial verification votes; default `3` | | `--min-severity ` | Minimum reported severity | | `--min-confidence ` | Minimum reported confidence | -| `--max-files ` | Positive file budget | -| `--concurrency ` | Parallel model calls; default `4` | +| `--max-files ` | Positive file budget; over-budget runs are refused before the provider | +| `--max-calls ` | Provider-call budget; absolute ceiling `1000` | +| `--concurrency ` | Parallel model calls; default `1` for CLI providers, `4` for API providers | +| `--plan`, `--dry-run` | Print provider-free preflight; add `--json` for machine output | | `--validate-patch` | Run `git apply --check` on suggested patches | | `--block ` | CI gate floor; default `blocker` | | `--no-fail` | Keep findings advisory | @@ -538,13 +543,16 @@ Then require the workflow check in branch protection. CLI exit codes are: A model response that is malformed may drop one lens while other lenses continue; progress output and the final summary report successful and failed primary-lens counts. If any reviewable file cannot be ingested or has zero successful primary lenses, the pipeline stops before reporters run and exits `2`, including in advisory mode. Treat missing output or exit `2` as unavailable review, not approval. +Use `--plan --json` (or `--dry-run`) to run the source and budget preflight without a model request. The plan reports files, bytes, enabled and required lenses, votes, retries, concurrency, estimated provider calls, and concrete reductions when a limit would be exceeded. The preflight refuses before the provider starts; `maxCalls` is capped at 1000 and unlimited mode is not supported. A required-lens failure is `INCOMPLETE` and exits `2`, including with `--no-fail`. + ## Cost and latency controls Seven lenses fan out over selected files; candidate findings then receive adversarial votes. The primary controls are: - `--max-files`: positive hard file budget; +- `--max-calls`: bounded provider-call budget (absolute ceiling 1000); - `--votes`: verification depth and cost; -- `--concurrency`: simultaneous model/subprocess calls; +- `--concurrency`: simultaneous model/subprocess calls (default 1 for CLI providers, 4 for API providers); - `--paths` or workflow path filters: narrow scope; - `--min-severity` and `--min-confidence`: output noise, not input-token cost. @@ -816,6 +824,7 @@ All notable changes will be documented here. This project follows Semantic Versi - Added strict versioned `.agentskit-review.json` policy with lens coverage, budgets, thresholds, context, and safe CI precedence; incomplete profiles require explicit local opt-in. - Hardened the shared local CLI worker with cancellation, process-tree cleanup, isolated temporary environments, bounded output, and redacted diagnostics. - Added bounded source snapshots with infrastructure/configuration file support, denylisted sensitive paths, symlink checks, input limits, and data-boundary-aware secret redaction. +- Added provider-free `--plan`/`--dry-run` preflight with explicit file/byte/call budgets, bounded retries, CLI concurrency defaults, and fail-closed required-lens coverage. - Made reviews fail closed when any reviewable file has no successful primary lens or cannot be ingested; advisory mode now suppresses finding-based failures only, never source/provider/execution failures. - Added primary-lens execution coverage to review summaries so partial provider degradation is visible. - Repositioned the CLI and GitHub Action as provider-neutral. diff --git a/readme-standard-v1.json b/readme-standard-v1.json index 81a5243..43607f3 100644 --- a/readme-standard-v1.json +++ b/readme-standard-v1.json @@ -221,7 +221,7 @@ "docs/OPERATIONS.md", "test/cli-smoke.test.mjs" ], - "sourceHash": "sha256:608f0984d346b0defcbfb81efd7b962fdba0bdb45a4be7c52ed95de3bea7fc0b" + "sourceHash": "sha256:aae7093f7641e5be3cec91d5a24bee31311bfe474395895e95923f91d6b33b5a" }, "exceptions": [] } diff --git a/src/cli.ts b/src/cli.ts index fd5eb9f..bf5c945 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,7 @@ import type { AdapterFactory } from '@agentskit/core' import { createProgressObserver } from '@agentskit/ink' import { readFileSync } from 'node:fs' -import { builtInLenses, createCodeReviewAgent, type Category, type CodeReviewConfig, type Reporter, type Severity } from '../agents/code-review/agent.js' +import { builtInLenses, createCodeReviewAgent, type Category, type CodeReviewConfig, type Reporter, type ReviewPlan, type Severity } from '../agents/code-review/agent.js' import { githubInlineReporter, githubSummaryReporter, markdownReporter, sarifReporter } from '../agents/code-review/reporters.js' import { claudeCode } from './claude-code-adapter.js' import { codexCli } from './codex-adapter.js' @@ -52,7 +52,8 @@ Review options: --min-confidence Minimum finding confidence --block CI gate floor (default: blocker) --max-files Positive file budget - --concurrency Parallel model calls (default: 4) + --max-calls Provider-call budget (absolute ceiling: 1000) + --concurrency Parallel model calls (default: 1 for CLI, 4 for API) --conventions Project conventions file --allow-incomplete Local-only exception for an explicitly incomplete profile --allow-unredacted Local-only exception for secret redaction @@ -60,6 +61,8 @@ Review options: --sarif Also write a SARIF report --post Post a PR review (with --pr) --no-fail Report findings without failing the process + --dry-run, --plan Print the provider-free preflight plan without model calls + --json Emit machine-readable plan output with --plan/--dry-run Provider options: --model Model id (required for API/local-server providers) @@ -148,14 +151,12 @@ async function main() { minSeverity: flag('min-severity') as Severity | undefined, minConfidence: flag('min-confidence') === undefined ? undefined : Number(flag('min-confidence')), maxFiles: flag('max-files') === undefined ? undefined : Number(flag('max-files')), + maxCalls: flag('max-calls') === undefined ? undefined : Number(flag('max-calls')), concurrency: flag('concurrency') === undefined ? undefined : Number(flag('concurrency')), conventions: flag('conventions'), }, }) const source = await resolveSource(reviewConfig) - await preflightProvider(reviewConfig) - const adapter = buildAdapter(reviewConfig) - const reporters: Reporter[] = [markdownReporter()] const sarif = flag('sarif') if (sarif) reporters.push(sarifReporter({ file: sarif })) @@ -165,7 +166,6 @@ async function main() { } const config: CodeReviewConfig = { - adapter, source, reporters, observers: [createProgressObserver()], @@ -174,15 +174,27 @@ async function main() { auditVotes: reviewConfig.votes, validatePatch: has('validate-patch'), blockingSeverity: (flag('block') as Severity) ?? 'blocker', - budget: { maxFiles: reviewConfig.budget.maxFiles, concurrency: reviewConfig.budget.concurrency }, + requiredLenses: Object.entries(reviewConfig.lenses).filter(([, policy]) => policy.required).map(([key]) => key as Category), + retries: reviewConfig.retries, + budget: { maxFiles: reviewConfig.budget.maxFiles, maxBytes: reviewConfig.budget.maxBytes, maxCalls: reviewConfig.budget.maxCalls, concurrency: reviewConfig.budget.concurrency }, conventions: reviewConfig.conventions ? { path: reviewConfig.conventions } : autoConventions(), thresholds: reviewConfig.thresholds, } - const review = await createCodeReviewAgent(config).run() + const agent = createCodeReviewAgent(config) + const plan = await agent.plan() + if (has('dry-run') || has('plan')) { + if (has('json')) console.log(JSON.stringify(plan)) + else console.log(formatPlan(plan)) + if (plan.overBudget.length) process.exitCode = 2 + return + } + await preflightProvider(reviewConfig) + agent.setAdapter(buildAdapter(reviewConfig)) + const review = await agent.run() // --no-fail = advisory: post the review but never fail the job (exit 0). Real errors // still surface via the catch below (exit 2). - process.exit(reviewConfig.incompleteProfile ? 2 : review.blocking && !has('no-fail') ? 1 : 0) + process.exit(review.incomplete ? 2 : review.blocking && !has('no-fail') ? 1 : 0) } /** @@ -259,6 +271,21 @@ function formatDoctor(report: DoctorReport): string { return lines.join('\n') } +function formatPlan(plan: ReviewPlan): string { + const status = plan.overBudget.length ? 'REFUSED' : 'READY' + const lines = [ + `Review preflight — ${status}`, + `Files: ${plan.files} · Bytes: ${plan.bytes} · Unreviewed: ${plan.unreviewedFiles}`, + `Lenses: ${plan.enabledLenses.join(', ') || 'none'}`, + `Required: ${plan.requiredLenses.join(', ') || 'none'}`, + `Votes: ${plan.votes} · Retries: ${plan.retries} · Concurrency: ${plan.concurrency}`, + `Estimated provider calls: ${plan.estimatedProviderCalls}/${plan.maxCalls}`, + ] + for (const reason of plan.overBudget) lines.push(`Refusal: ${reason}`) + for (const suggestion of plan.suggestions) lines.push(`Suggestion: ${suggestion}`) + return lines.join('\n') +} + /** Best-effort: feed a conventions doc to every lens if one exists. */ function autoConventions(): string | undefined { for (const f of ['CONVENTIONS.md', 'CONTRIBUTING.md', '.cursorrules', 'AGENTS.md']) { diff --git a/src/review-config.ts b/src/review-config.ts index 666d0b8..b059945 100644 --- a/src/review-config.ts +++ b/src/review-config.ts @@ -58,7 +58,7 @@ type FileConfig = z.infer export interface ReviewConfigOverrides { provider?: string; model?: string; transport?: string; votes?: number; retries?: number - minSeverity?: Severity; minConfidence?: number; maxFiles?: number; concurrency?: number; conventions?: string + minSeverity?: Severity; minConfidence?: number; maxFiles?: number; maxCalls?: number; concurrency?: number; conventions?: string } export interface ResolvedReviewConfig { @@ -126,11 +126,18 @@ export function resolveReviewConfig( if (incompleteProfile && !options.allowIncomplete) throw new ReviewConfigError('incomplete profile requires explicit --allow-incomplete for a local run') const thresholds = { ...file?.thresholds, ...(overrides.minSeverity === undefined ? {} : { minSeverity: overrides.minSeverity }), ...(overrides.minConfidence === undefined ? {} : { minConfidence: overrides.minConfidence }) } - const budget = { ...file?.budget, ...(overrides.maxFiles === undefined ? {} : { maxFiles: overrides.maxFiles }), ...(overrides.concurrency === undefined ? {} : { concurrency: overrides.concurrency }) } + const budget = { + ...file?.budget, + ...(overrides.maxFiles === undefined ? {} : { maxFiles: overrides.maxFiles }), + ...(overrides.maxCalls === undefined ? {} : { maxCalls: overrides.maxCalls }), + ...(overrides.concurrency === undefined ? {} : { concurrency: overrides.concurrency }), + } + const provider = overrides.provider ?? file?.provider + const defaultConcurrency = provider?.endsWith('-cli') ? 1 : 4 const effective = { configVersion: 1 as const, lenses, incompleteProfile, votes: overrides.votes ?? file?.votes ?? 3, retries: overrides.retries ?? file?.retries ?? 1, - thresholds, budget: { ...budget, concurrency: budget.concurrency ?? 4 }, + thresholds, budget: { ...budget, concurrency: budget.concurrency ?? defaultConcurrency, maxCalls: budget.maxCalls ?? 1000 }, worker: { timeoutMs: file?.worker?.timeoutMs ?? localCliTimeoutMs(), maxOutputBytes: file?.worker?.maxOutputBytes ?? DEFAULT_LOCAL_CLI_OUTPUT_BYTES }, conventions: overrides.conventions ?? file?.conventions, context: { mode: file?.context?.mode ?? 'prompt', patterns: file?.context?.patterns ?? [] }, @@ -142,7 +149,7 @@ export function resolveReviewConfig( const validation = z.object({ votes: positiveInt.max(25), retries: nonNegativeInt.max(1), thresholds: z.object({ minSeverity: z.enum(['blocker', 'high', 'med', 'nit']).optional(), minConfidence: z.number().min(0).max(1).optional() }), - budget: z.object({ maxFiles: positiveInt.max(500).optional(), concurrency: positiveInt.max(32) }), + budget: z.object({ maxFiles: positiveInt.max(500).optional(), maxBytes: positiveInt.max(25 * 1024 * 1024).optional(), maxCalls: positiveInt.max(1000), concurrency: positiveInt.max(32) }), }).safeParse(effective) if (!validation.success) throw new ReviewConfigError(`invalid effective review config: ${diagnostic(validation.error)}`) return effective diff --git a/test/cli-smoke.test.mjs b/test/cli-smoke.test.mjs index 828f012..4588e07 100644 --- a/test/cli-smoke.test.mjs +++ b/test/cli-smoke.test.mjs @@ -1,7 +1,9 @@ import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join, resolve } from 'node:path' +import { tmpdir } from 'node:os' import test from 'node:test' const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -74,7 +76,7 @@ test('a local Codex subprocess timeout fails fast instead of hanging the review' assert.match(run.stderr, /review execution failed: 0 of 7 lens executions succeeded/i) }) -test('a partially degraded review stays advisory and reports execution coverage', () => { +test('a required-lens failure is incomplete even in advisory mode', () => { const fixtureBin = join(root, 'test/fixtures/bin') const run = spawnSync(process.execPath, [ 'dist/src/cli.js', @@ -89,9 +91,57 @@ test('a partially degraded review stays advisory and reports execution coverage' env: { ...process.env, CODEX_FIXTURE_FAIL_CATEGORY: 'security', PATH: `${fixtureBin}:${process.env.PATH ?? ''}` }, }) - assert.equal(run.status, 0, run.stderr) + assert.equal(run.status, 2, run.stderr) assert.match(run.stdout, /6\/7 lens executions succeeded; 1 failed/) - assert.match(run.stdout, /Code review — APPROVE/) + assert.match(run.stdout, /Code review — COMMENT/) + assert.match(run.stdout, /INCOMPLETE/) +}) + +test('plan is provider-free and machine-readable', () => { + const run = spawnSync(process.execPath, [ + 'dist/src/cli.js', '--provider', 'codex-cli', '--stdin', '--dry-run', '--json', + ], { + cwd: root, input: 'export const answer = 42\n', encoding: 'utf8', + env: { ...process.env, PATH: '/usr/bin:/bin' }, + }) + + assert.equal(run.status, 0, run.stderr) + const plan = JSON.parse(run.stdout) + assert.equal(plan.files, 1) + assert.deepEqual(plan.requiredLenses, ['correctness', 'security', 'tests']) + assert.equal(plan.concurrency, 1) + assert.ok(plan.estimatedProviderCalls > 0) + assert.equal(plan.overBudget.length, 0) +}) + +test('preflight refuses an over-call-budget run before the provider starts', () => { + const fixtureBin = join(root, 'test/fixtures/bin') + const run = spawnSync(process.execPath, [ + 'dist/src/cli.js', '--provider', 'codex-cli', '--stdin', '--max-calls', '1', '--no-fail', + ], { + cwd: root, input: 'export const answer = 42\n', encoding: 'utf8', + env: { ...process.env, PATH: `${fixtureBin}:${process.env.PATH ?? ''}` }, + }) + + assert.equal(run.status, 2, run.stderr) + assert.match(run.stderr, /review preflight refused/i) + assert.doesNotMatch(run.stdout, /Code review —/) +}) + +test('retries one invalid structured response but not provider failures', () => { + const fixtureBin = join(root, 'test/fixtures/bin') + const cwd = mkdtempSync(join(tmpdir(), 'agentskit-review-retry-')) + const stateFile = join(cwd, 'retry-state') + try { + const run = spawnSync(process.execPath, [ + join(root, 'dist/src/cli.js'), '--provider', 'codex-cli', '--stdin', '--no-fail', + ], { + cwd: root, input: 'export const answer = 42\n', encoding: 'utf8', + env: { ...process.env, CODEX_FIXTURE_INVALID_ONCE_FILE: stateFile, PATH: `${fixtureBin}:${process.env.PATH ?? ''}` }, + }) + assert.equal(run.status, 0, run.stderr) + assert.match(run.stdout, /7\/7 lens executions succeeded/) + } finally { rmSync(cwd, { recursive: true, force: true }) } }) test('one reviewed file cannot hide a second file with zero successful lenses', () => { diff --git a/test/fixtures/bin/codex b/test/fixtures/bin/codex index a0189df..69271d8 100755 --- a/test/fixtures/bin/codex +++ b/test/fixtures/bin/codex @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { writeFileSync } from 'node:fs' +import { existsSync, writeFileSync } from 'node:fs' const prompt = process.argv.at(-1) ?? '' if (process.argv.includes('--version')) { @@ -19,4 +19,10 @@ if ( const outputIndex = process.argv.indexOf('-o') if (outputIndex < 0 || !process.argv[outputIndex + 1]) process.exit(2) +const invalidOnceFile = process.env.CODEX_FIXTURE_INVALID_ONCE_FILE +if (invalidOnceFile && !existsSync(invalidOnceFile)) { + writeFileSync(invalidOnceFile, 'used') + writeFileSync(process.argv[outputIndex + 1], '{}') + process.exit(0) +} writeFileSync(process.argv[outputIndex + 1], JSON.stringify({ findings: [] })) diff --git a/test/review-config.test.mjs b/test/review-config.test.mjs index 6ad65e1..268e847 100644 --- a/test/review-config.test.mjs +++ b/test/review-config.test.mjs @@ -35,6 +35,9 @@ test('merges independent lens policy and flags override file values', () => { assert.equal(config.votes, 1) assert.equal(config.thresholds.minSeverity, 'med') assert.equal(config.worker.timeoutMs, 120000) + assert.equal(config.budget.maxCalls, 1000) + assert.equal(resolveReviewConfig({ configVersion: 1, provider: 'codex-cli' }).budget.concurrency, 1) + assert.equal(resolveReviewConfig({ configVersion: 1, provider: 'openai' }).budget.concurrency, 4) }) test('rejects unknown fields, unsupported versions, and impossible required lenses', () => { @@ -43,6 +46,7 @@ test('rejects unknown fields, unsupported versions, and impossible required lens { configVersion: 2 }, { configVersion: 1, lenses: { security: { enabled: false, required: true } } }, ]) assert.throws(() => resolveReviewConfig(config), ReviewConfigError) + assert.throws(() => resolveReviewConfig({ configVersion: 1, budget: { maxCalls: 1001 } }), ReviewConfigError) }) test('requires an explicit local exception for an incomplete profile and rejects it in CI', () => { From 48f81551556727fc1aad853a40e54e009d4fd919 Mon Sep 17 00:00:00 2001 From: EmersonBraun Date: Tue, 25 Aug 2026 22:57:30 -0300 Subject: [PATCH 2/2] test: avoid retry fixture race --- test/fixtures/bin/codex | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/test/fixtures/bin/codex b/test/fixtures/bin/codex index 69271d8..4642ac7 100755 --- a/test/fixtures/bin/codex +++ b/test/fixtures/bin/codex @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { existsSync, writeFileSync } from 'node:fs' +import { closeSync, openSync, writeFileSync } from 'node:fs' const prompt = process.argv.at(-1) ?? '' if (process.argv.includes('--version')) { @@ -20,9 +20,14 @@ if ( const outputIndex = process.argv.indexOf('-o') if (outputIndex < 0 || !process.argv[outputIndex + 1]) process.exit(2) const invalidOnceFile = process.env.CODEX_FIXTURE_INVALID_ONCE_FILE -if (invalidOnceFile && !existsSync(invalidOnceFile)) { - writeFileSync(invalidOnceFile, 'used') - writeFileSync(process.argv[outputIndex + 1], '{}') - process.exit(0) +if (invalidOnceFile) { + try { + const fd = openSync(invalidOnceFile, 'wx') + try { writeFileSync(fd, 'used') } finally { closeSync(fd) } + writeFileSync(process.argv[outputIndex + 1], '{}') + process.exit(0) + } catch (error) { + if (error?.code !== 'EEXIST') throw error + } } writeFileSync(process.argv[outputIndex + 1], JSON.stringify({ findings: [] }))