1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
//! Command-line interface (clap). `lint` is the default when no subcommand is
//! given, so `llmlint [FILES...]` works like any other linter.
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use crate::io::diff::DiffBackend;
#[derive(Parser, Debug)]
#[command(
name = "llmlint",
version,
about = "LLM-as-judge linter for checks deterministic linters can't express.",
args_conflicts_with_subcommands = true
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<Command>,
/// Default (no subcommand): run the lint.
#[command(flatten)]
pub lint: LintArgs,
}
#[derive(Subcommand, Debug)]
// `LintArgs` is the largest variant by design (it carries every lint flag) and
// `Cli` flattens the same struct for the default path, so boxing it here would
// just add indirection to a value parsed once at startup — and clap's derive
// doesn't flatten through a `Box`. The size gap is harmless for a short-lived
// CLI enum.
#[allow(clippy::large_enum_variant)]
pub enum Command {
/// Run the LLM-as-judge lint (this is the default).
Lint(LintArgs),
/// Lint llmlint config files with the bundled config-lint rules. This is the
/// `lint` command with the bundled config-lint plugin included by default, so
/// you don't have to add it to your own config: it first runs the
/// deterministic ignore-directive (comment) check, then judges each config's
/// rules for clear, unambiguous names and descriptions. Targets the discovered
/// llmlint config files unless FILES are given.
#[command(name = "lint-config")]
LintConfig(LintConfigArgs),
/// Validate inline `llmlint: ignore` directives (deterministic, no model
/// call). Runs as part of `lint`; split out so it can sit in the fast
/// static-check loop next to fmt/clippy.
#[command(name = "check-ignores")]
CheckIgnores(CheckIgnoresArgs),
/// Write a starter llmlint config file.
Init(InitArgs),
/// Print the effective merged config as JSON (add `--sources` to trace where
/// each rule, agent, and setting is defined).
Config(ConfigArgs),
/// Show which file (or plugin URL) a config item comes from — the place to
/// edit it. Pass a path like `oneharness.model`, `agents.<name>`,
/// `rules.<name>`, or `rules.<name>.<field>`; prints the source and nothing
/// else, for scripting. The broad view is `config --sources`.
Where(WhereArgs),
/// Check that oneharness is installed and reachable.
Doctor,
}
#[derive(Args, Debug, Default)]
pub struct LintArgs {
/// Files to lint. When given, overrides the config's file globs (per-rule
/// and per-agent `files` still take precedence).
pub files: Vec<PathBuf>,
/// llmlint config file(s); repeatable. Replaces nested upward discovery.
#[arg(long = "config", short = 'c', value_name = "PATH")]
pub config: Vec<PathBuf>,
/// oneharness config file to forward via `--config` (single-file; extras warn).
#[arg(long = "oneharness-config", value_name = "PATH")]
pub oneharness_config: Vec<PathBuf>,
/// Override the oneharness binary (else `$LLMLINT_ONEHARNESS_BIN` or PATH).
#[arg(long = "oneharness-bin", value_name = "PATH")]
pub oneharness_bin: Option<String>,
/// Override the master prompt template with this file's contents (wins over
/// the config's `prompt_template`).
#[arg(long = "prompt-template", value_name = "PATH")]
pub prompt_template: Option<PathBuf>,
/// Default judge model, forwarded to oneharness (overrides config
/// `oneharness.model`; a per-agent `model` still wins for that agent).
#[arg(long = "model", value_name = "NAME")]
pub model: Option<String>,
/// Schema-validation re-prompt budget (oneharness `--schema-max-retries`;
/// overrides config `oneharness.schema_max_retries`).
#[arg(long = "schema-max-retries", value_name = "N")]
pub schema_max_retries: Option<u32>,
/// Require a `rationale` for every rule's verdict (the default). Overrides
/// the config's `rationales`; a per-rule `rationale` still wins. Use
/// `--no-rationales` to turn rationales off.
#[arg(long = "rationales", overrides_with = "no_rationales", action = clap::ArgAction::SetTrue)]
pub rationales: bool,
/// Disable rationales for this run (overrides config; a per-rule `rationale`
/// still wins). The inverse of `--rationales`.
#[arg(long = "no-rationales", overrides_with = "rationales", action = clap::ArgAction::SetTrue)]
pub no_rationales: bool,
/// Only run rules assigned to this agent (`default` for unassigned rules).
#[arg(long = "agent", value_name = "NAME")]
pub agent: Option<String>,
/// Only run these named rules; repeatable.
#[arg(long = "rule", value_name = "NAME")]
pub rule: Vec<String>,
/// Output format.
#[arg(long = "format", value_enum, default_value_t = OutputFormat::Human)]
pub format: OutputFormat,
/// When to colorize the human report: `auto` (default) colors only when
/// stdout is a terminal and `NO_COLOR` is unset, `always` forces color
/// (e.g. through a pager or for a screenshot), `never` disables it. Has no
/// effect on `--format json`.
#[arg(long = "color", value_enum, default_value_t = ColorChoice::Auto)]
pub color: ColorChoice,
/// Increase output detail. By default, failing rules (with their locations)
/// and the summary line are shown. `-v` additionally itemizes every passed
/// and skipped rule, and prints the oneharness debug view (exact command +
/// result) to stderr. Ignored for `--format json`.
#[arg(long = "verbose", short = 'v', action = clap::ArgAction::Count)]
pub verbose: u8,
/// Maximum judges to run in parallel.
#[arg(long = "max-parallel", value_name = "N")]
pub max_parallel: Option<usize>,
/// Per-judge timeout in seconds (default 120).
#[arg(long = "timeout", value_name = "SECS")]
pub timeout: Option<u64>,
/// Directory to lint from (config discovery + the harness cwd). Default: cwd.
#[arg(long = "cwd", value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Add each target file's diff to the judge prompt so it reviews only the
/// changed lines. Bare `--diff` uses the `git` backend (compared against
/// `HEAD`); pass a backend (`--diff git`) to choose one explicitly. Omitted:
/// the whole file is reviewed as before.
#[arg(
long = "diff",
value_name = "BACKEND",
num_args = 0..=1,
default_missing_value = "git",
)]
pub diff: Option<DiffBackend>,
/// Base the `--diff` git backend compares target files against, instead of
/// the default `HEAD`. Accepts any git revision — a branch, tag, commit, or
/// an `A..B`/`A...B` range — so `--diff-base main` reviews exactly what the
/// current branch changed versus `main`. Overrides the config `diff_base`.
/// Requires `--diff`.
#[arg(long = "diff-base", value_name = "REF", requires = "diff")]
pub diff_base: Option<String>,
}
impl LintArgs {
/// The rationale choice from the CLI, or `None` when neither
/// `--rationales`/`--no-rationales` was given (so the config decides). The
/// two flags `overrides_with` each other, so the last one on the command
/// line wins and at most one bool is set.
pub fn rationales(&self) -> Option<bool> {
if self.no_rationales {
Some(false)
} else if self.rationales {
Some(true)
} else {
None
}
}
}
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum OutputFormat {
#[default]
Human,
Json,
}
/// When to apply ANSI color to the human report.
#[derive(ValueEnum, Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ColorChoice {
/// Color only when stdout is a terminal and `NO_COLOR` is unset.
#[default]
Auto,
/// Always emit color, even when stdout is not a terminal.
Always,
/// Never emit color.
Never,
}
impl ColorChoice {
/// Resolve to a concrete on/off decision. `Auto` honors the `NO_COLOR`
/// convention (any non-empty value disables color) and otherwise colors
/// only when `stdout` is a terminal. `is_tty` is injected so the pure
/// resolution stays testable without a real terminal.
pub fn resolve(self, is_tty: bool, no_color: bool) -> bool {
match self {
ColorChoice::Always => true,
ColorChoice::Never => false,
ColorChoice::Auto => is_tty && !no_color,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn color_always_and_never_ignore_tty_and_no_color() {
for &tty in &[true, false] {
for &no_color in &[true, false] {
assert!(ColorChoice::Always.resolve(tty, no_color));
assert!(!ColorChoice::Never.resolve(tty, no_color));
}
}
}
#[test]
fn color_auto_needs_a_tty_and_an_unset_no_color() {
assert!(ColorChoice::Auto.resolve(true, false));
// A terminal but NO_COLOR set: off (the convention wins).
assert!(!ColorChoice::Auto.resolve(true, true));
// Not a terminal (piped/redirected): off regardless of NO_COLOR.
assert!(!ColorChoice::Auto.resolve(false, false));
assert!(!ColorChoice::Auto.resolve(false, true));
}
}
#[derive(Args, Debug)]
pub struct InitArgs {
/// Write to the user-global config dir instead of `./llmlint.yml`.
#[arg(long)]
pub global: bool,
/// Embed the default prompt template in the config for customization.
#[arg(long = "with-template")]
pub with_template: bool,
/// Overwrite an existing config instead of refusing.
#[arg(long)]
pub force: bool,
/// Write to this path instead of the default.
#[arg(long = "output", short = 'o', value_name = "PATH")]
pub output: Option<PathBuf>,
}
#[derive(Args, Debug, Default)]
pub struct CheckIgnoresArgs {
/// Files to scan. When given, overrides the config's file globs (per-rule
/// and per-agent `files` still take precedence) — pass the changed files to
/// scope the check in a pre-commit hook.
pub files: Vec<PathBuf>,
/// llmlint config file(s); repeatable. Replaces upward config discovery.
#[arg(long = "config", short = 'c', value_name = "PATH")]
pub config: Vec<PathBuf>,
/// Directory to scan from (config discovery + glob root). Default: cwd.
#[arg(long = "cwd", value_name = "DIR")]
pub cwd: Option<PathBuf>,
}
#[derive(Args, Debug, Default)]
pub struct LintConfigArgs {
/// Config files to lint. When given, overrides the bundled config-lint globs
/// (which otherwise discover every llmlint config in the tree).
pub files: Vec<PathBuf>,
/// oneharness config file to forward via `--config` (single-file; extras warn).
#[arg(long = "oneharness-config", value_name = "PATH")]
pub oneharness_config: Vec<PathBuf>,
/// Override the oneharness binary (else `$LLMLINT_ONEHARNESS_BIN` or PATH).
#[arg(long = "oneharness-bin", value_name = "PATH")]
pub oneharness_bin: Option<String>,
/// Default judge model, forwarded to oneharness.
#[arg(long = "model", value_name = "NAME")]
pub model: Option<String>,
/// Schema-validation re-prompt budget (oneharness `--schema-max-retries`).
#[arg(long = "schema-max-retries", value_name = "N")]
pub schema_max_retries: Option<u32>,
/// Require a `rationale` for every rule's verdict (the default). Use
/// `--no-rationales` to turn rationales off.
#[arg(long = "rationales", overrides_with = "no_rationales", action = clap::ArgAction::SetTrue)]
pub rationales: bool,
/// Disable rationales for this run. The inverse of `--rationales`.
#[arg(long = "no-rationales", overrides_with = "rationales", action = clap::ArgAction::SetTrue)]
pub no_rationales: bool,
/// Output format.
#[arg(long = "format", value_enum, default_value_t = OutputFormat::Human)]
pub format: OutputFormat,
/// When to colorize the human report (`auto`/`always`/`never`).
#[arg(long = "color", value_enum, default_value_t = ColorChoice::Auto)]
pub color: ColorChoice,
/// Increase output detail (repeatable). `-v` itemizes passed/skipped rules and
/// prints the oneharness debug view to stderr.
#[arg(long = "verbose", short = 'v', action = clap::ArgAction::Count)]
pub verbose: u8,
/// Maximum judges to run in parallel.
#[arg(long = "max-parallel", value_name = "N")]
pub max_parallel: Option<usize>,
/// Per-judge timeout in seconds (default 120).
#[arg(long = "timeout", value_name = "SECS")]
pub timeout: Option<u64>,
/// Directory to lint from (config discovery + the harness cwd). Default: cwd.
#[arg(long = "cwd", value_name = "DIR")]
pub cwd: Option<PathBuf>,
/// Add each target config's diff to the judge prompt so it reviews only the
/// changed lines. Bare `--diff` uses the `git` backend (compared against
/// `HEAD`); pass a backend (`--diff git`) to choose one explicitly.
#[arg(
long = "diff",
value_name = "BACKEND",
num_args = 0..=1,
default_missing_value = "git",
)]
pub diff: Option<DiffBackend>,
/// Base the `--diff` git backend compares against, instead of `HEAD`. Any git
/// revision — a branch, tag, commit, or `A..B`/`A...B` range. Requires `--diff`.
#[arg(long = "diff-base", value_name = "REF", requires = "diff")]
pub diff_base: Option<String>,
}
impl LintConfigArgs {
/// Project onto the shared [`LintArgs`] so the `lint-config` subcommand can
/// reuse the full lint engine. The config source is fixed (the bundled
/// config-lint plugin, loaded separately), so `--config`, `--prompt-template`,
/// and the `--agent`/`--rule` selectors are intentionally not exposed here.
pub fn into_lint_args(self) -> LintArgs {
LintArgs {
files: self.files,
oneharness_config: self.oneharness_config,
oneharness_bin: self.oneharness_bin,
model: self.model,
schema_max_retries: self.schema_max_retries,
rationales: self.rationales,
no_rationales: self.no_rationales,
format: self.format,
color: self.color,
verbose: self.verbose,
max_parallel: self.max_parallel,
timeout: self.timeout,
cwd: self.cwd,
diff: self.diff,
diff_base: self.diff_base,
..Default::default()
}
}
}
#[derive(Args, Debug, Default)]
pub struct ConfigArgs {
/// llmlint config file(s); repeatable. Replaces nested upward discovery.
#[arg(long = "config", short = 'c', value_name = "PATH")]
pub config: Vec<PathBuf>,
/// Also emit a `sources` block mapping every rule, agent, and setting to the
/// file (or plugin URL) it comes from — the path to edit it (a rule also
/// names any field an `override` pulled from elsewhere). This is the way to
/// discover where to change something; for one item, `llmlint where <path>`
/// is more direct.
#[arg(long = "sources")]
pub sources: bool,
/// Directory to resolve config discovery from. Default: cwd.
#[arg(long = "cwd", value_name = "DIR")]
pub cwd: Option<PathBuf>,
}
#[derive(Args, Debug, Default)]
pub struct WhereArgs {
/// The config path to locate. A top-level setting (`version`,
/// `oneharness.model`, `files`, …), `agents.<name>`, `rules.<name>`, or a
/// single field of a rule, `rules.<name>.<field>` (e.g.
/// `rules.no_secrets.judges`) to find the file an `override` set it in.
#[arg(value_name = "PATH")]
pub path: String,
/// llmlint config file(s); repeatable. Replaces upward discovery.
#[arg(long = "config", short = 'c', value_name = "PATH")]
pub config: Vec<PathBuf>,
/// Directory to resolve config discovery from. Default: cwd.
#[arg(long = "cwd", value_name = "DIR")]
pub cwd: Option<PathBuf>,
}