redline review
Reviews a change against only the rules that apply to the files it touches — locally, in either engine, before you push.
What this is
Reviews a change against ONLY the rules that apply to the files it touches. Anyone can ask an assistant to review a diff; what this adds is the bound — a model handed the composed standard for an eighteen-stack profile spends most of its attention on languages the diff never touches, and the findings get worse rather than better.
How to onboard it
Nothing to install. It reads the profile from the repository's .redline.json, or takes --profile. The embedded engine is the default and calls no model at all: it emits the bounded prompt for the assistant already running the command, which is the common case in Claude Code, Copilot or Cursor. --engine api makes the CLI call an endpoint itself.
How to use it
$ npx redlinegate@latest review # working tree against the merge base $ npx redlinegate@latest review --staged # staged changes, before you commit $ npx redlinegate@latest review --diff-file change.diff # any unified diff $ npx redlinegate@latest review --engine api --model qwen2.5-coder:14b # a local model, no data leaves the machine $ npx redlinegate@latest review --engine api --provider anthropic --model <id>
The flags that change behaviour materially:
--engine embedded | api— embedded (the default) hands the prompt back for the assistant running the command to apply. That is the design, not a stub: the CLI is usually being run BY an assistant that already has a model and a context, and calling a second model from inside that session pays twice for a worse answer. api makes the CLI call an endpoint — OpenAI-compatible or Anthropic.--provider openai | anthropic, --model, --base-url— openai covers every OpenAI-compatible endpoint, which is the fully local case for free: Ollama, LM Studio and vLLM all expose it, and a local endpoint needs no API key. That matters — a review that has to send a diff to a third party is a review several markets cannot run at all. The model is never baked in: one that is would be a model nobody can change when it is deprecated or when a regulator objects.--base <ref>— What to diff against, the repository's default branch otherwise. The comparison is a three-dot merge-base diff: two dots would hand the model every commit that landed on the base branch since yours started, and it would dutifully review someone else's work.
Expected output
One line per finding in the output contract, with the file and line. The CLI renders that line itself from the validated rule id and severity — a model that writes the prefix will eventually write a severity that does not exist or an id it invented, and every aggregate keyed on that line becomes fiction. A finding citing a rule the prompt did not carry is discarded with the reason said out loud. A changed file no stack covers is reported too, because that is a gap in the standard and reviewing it against core alone while saying nothing hides it. It always exits 0: a non-zero exit would invite someone to wire this into CI as a second gate, where it would enforce nothing while looking like it did.
How to edit it
cli/review/ — scope.ts resolves the applicable rules, prompt.ts builds the bounded prompt, schema.ts is the published findings contract, engines/ holds the two engines. Local findings are excluded from rule-tuning telemetry by construction and the report says so on every run: a local run has no thread to resolve and no reviewer to attribute, so counting it would compute acted-on rate partly from runs nobody can verify.
Run npm test and npm run typecheck before pushing: the command surface is unit-tested against a fake host client, so a behaviour change shows up as a failing assertion rather than as a surprise on someone's repository.
The full file
import { readFileSync } from 'node:fs';
import { RedlineError } from '../core/errors.ts';
import { createGit } from '../core/git.ts';
import { readConfig } from '../config/redline-json.ts';
import { loadManifest } from '../render/manifest.ts';
import { loadRuleIds } from '../review/rules.ts';
import { buildPrompt } from '../review/prompt.ts';
import { resolveScope, type Scope } from '../review/scope.ts';
import { parseReview, renderFinding, type ReviewFinding } from '../review/schema.ts';
import type { ReviewEngine } from '../review/engines/types.ts';
export interface ReviewOptions {
cwd: string;
root: string;
// Where the change comes from. Working tree against the merge base by default.
source?: { kind: 'worktree' } | { kind: 'staged' } | { kind: 'diff-file'; path: string };
base?: string;
profile?: string;
}
export interface ReviewReport {
scope: Scope;
// Set when the engine handed the prompt back for the caller's own model.
prompt: string | null;
note: string | null;
findings: ReviewFinding[];
rejected: { raw: unknown; reason: string }[];
rendered: string[];
// Always true, and stated in the report rather than assumed by the caller.
//
// A local review is opt-in and therefore enforces nothing — the pull request
// review remains the system of record. Its findings must be excluded from the
// telemetry that drives rule tuning, or acted-on rate is computed partly from
// runs nobody can verify: a local run has no thread to resolve, no reviewer to
// attribute, and no way to tell a finding that was fixed from one the author
// never read.
excludedFromTelemetry: true;
}
function readDiff(opts: ReviewOptions): string {
const source = opts.source ?? { kind: 'worktree' };
if (source.kind === 'diff-file') {
try {
return readFileSync(source.path, 'utf8');
} catch (error) {
throw new RedlineError(
'usage',
`cannot read the diff from ${source.path}: ${error instanceof Error ? error.message : String(error)}`
);
}
}
const git = createGit(opts.cwd);
const base = opts.base ?? git.defaultBranch();
if (source.kind === 'staged') return git.diffStaged();
// Against the merge base itself, not `base...HEAD`. The three-dot form diffs
// two commits, so it cannot see work that is not committed yet — which made
// the default mode review nothing at all in the case the command exists for:
// "check this before I commit it". Diffing the working tree against the merge
// base keeps the property three dots was chosen for (never review commits that
// landed on the base branch since this one started) and includes uncommitted
// and staged work as well.
return git.diff(git.mergeBase(base));
}
export async function review(engine: ReviewEngine, opts: ReviewOptions): Promise<ReviewReport> {
const diff = readDiff(opts);
if (diff.trim() === '') {
throw new RedlineError(
'usage',
'there is nothing to review — the diff is empty',
'stage something, or pass --base to compare against a different branch'
);
}
const manifest = loadManifest(opts.root);
const config = readConfig(opts.cwd);
const profile = opts.profile ?? config?.profile;
if (!profile) {
throw new RedlineError(
'usage',
'no profile: this repository has no .redline.json and none was given',
'run: npx redlinegate init — or pass --profile <name>'
);
}
const files = [...new Set([...diff.matchAll(/^\+\+\+ b\/(.+)$/gm)].map((m) => m[1]!))].filter(
(f) => f !== '/dev/null'
);
const scope = resolveScope(manifest, profile, files);
const prompt = buildPrompt({ root: opts.root, manifest, scope, diff });
const response = await engine.review({ prompt });
if (response.kind === 'prompt') {
return {
scope,
prompt: response.prompt,
note: response.note,
findings: [],
rejected: [],
rendered: [],
excludedFromTelemetry: true,
};
}
const known = loadRuleIds(opts.root, manifest, scope.stacks);
const { findings, rejected } = parseReview(response.raw, known);
// Where this repository publishes the standard, so a rendered finding carries
// the address of the rule it cites. Absent config, and a repository that has
// not set one, both render exactly what they rendered before.
const docsBaseUrl = readConfig(opts.cwd)?.docsBaseUrl ?? '';
return {
scope,
prompt: null,
note: null,
findings,
rejected,
// Rendered by code. The comment contract is never free-typed by a model.
// The arrow is not decoration: `map(renderFinding)` hands map's index in as
// the second argument, which would put an array position where the docs
// base URL goes.
rendered: findings.map((finding) => renderFinding(finding, docsBaseUrl)),
excludedFromTelemetry: true,
};
}