use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::domain::config::{Config, FileFilter, Provenance, ProvenanceBuilder, Rule};
use crate::domain::version::VersionReq;
use crate::errors::{io_err, Error, Result};
use crate::io::assets;
use crate::io::plugins::{self, ResolveOpts};
const MAX_PLUGIN_DEPTH: usize = 100;
pub const CONFIG_NAMES: &[&str] = &[
"llmlint.yml",
"llmlint.yaml",
".llmlint.yml",
".llmlint.yaml",
];
#[derive(Debug)]
pub struct Loaded {
pub config: Config,
pub sources: Vec<String>,
pub provenance: Provenance,
pub scopes: BTreeMap<String, RuleScope>,
}
#[derive(Debug, Clone)]
pub struct RuleScope {
pub dir: PathBuf,
pub files: FileFilter,
}
pub fn discover(start: &Path) -> Option<PathBuf> {
discover_all(start).into_iter().next()
}
pub fn discover_all(start: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut dir = Some(start);
while let Some(d) = dir {
for name in CONFIG_NAMES {
let p = d.join(name);
if p.is_file() {
found.push(p);
break; }
}
dir = d.parent();
}
found
}
pub fn discover_subtree(cwd: &Path) -> Vec<PathBuf> {
let prio = |p: &Path| {
p.file_name()
.and_then(|n| n.to_str())
.and_then(|n| CONFIG_NAMES.iter().position(|c| *c == n))
};
let mut by_dir: BTreeMap<PathBuf, PathBuf> = BTreeMap::new();
for entry in WalkBuilder::new(cwd).hidden(false).build().flatten() {
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let path = entry.path();
let Some(p) = prio(path) else { continue };
let Some(dir) = path.parent() else { continue };
if dir == cwd {
continue; }
match by_dir.get(dir) {
Some(existing) if prio(existing).unwrap_or(usize::MAX) <= p => {}
_ => {
by_dir.insert(dir.to_path_buf(), path.to_path_buf());
}
}
}
by_dir.into_values().collect()
}
pub fn parse(text: &str, origin: &str) -> Result<Config> {
let err = |e: serde_yaml_ng::Error| Error::ConfigParse {
path: origin.to_string(),
message: e.to_string(),
};
let mut value: serde_yaml_ng::Value = serde_yaml_ng::from_str(text).map_err(err)?;
value.apply_merge().map_err(err)?;
if let serde_yaml_ng::Value::Mapping(m) = &value {
if m.contains_key(serde_yaml_ng::Value::from("include")) {
return Err(Error::ConfigParse {
path: origin.to_string(),
message: "top-level `include` was renamed to `plugins` (it pulls in other \
configs; `files.include` is the file glob). Rename the key to `plugins`."
.to_string(),
});
}
}
serde_yaml_ng::from_value(value).map_err(err)
}
pub fn load(entries: &[PathBuf], cwd: &Path) -> Result<Loaded> {
load_with_targets(entries, cwd, &[])
}
pub fn load_with_targets(
entries: &[PathBuf],
cwd: &Path,
explicit_files: &[PathBuf],
) -> Result<Loaded> {
if entries.is_empty() {
load_discovered(cwd, explicit_files)
} else {
load_explicit(entries, cwd)
}
}
pub fn load_config_lint(cwd: &Path) -> Result<Loaded> {
let mut config = parse(assets::CONFIG_LINT_PLUGIN, assets::CONFIG_LINT_URL)?;
crate::domain::config::resolve_overrides(&mut config)?;
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: config.files.clone(),
};
let scopes = config
.rules
.iter()
.map(|r| (r.name.clone(), scope.clone()))
.collect();
let mut prov = ProvenanceBuilder::default();
prov.record(&config, assets::CONFIG_LINT_URL);
Ok(Loaded {
config,
sources: vec![assets::CONFIG_LINT_URL.to_string()],
provenance: prov.finish(),
scopes,
})
}
fn file_under(cwd: &Path, dir: &Path, file: &Path) -> bool {
let abs = if file.is_absolute() {
file.to_path_buf()
} else {
cwd.join(file)
};
normalize(&abs).starts_with(dir)
}
fn load_explicit(entries: &[PathBuf], cwd: &Path) -> Result<Loaded> {
let opts = ResolveOpts::from_env();
let mut visited: BTreeSet<String> = BTreeSet::new();
let mut sources: Vec<String> = Vec::new();
let mut prov = ProvenanceBuilder::default();
let mut acc: Option<Config> = None;
for path in entries {
load_node(
Node::File(absolutize(path, cwd)),
0,
&opts,
&mut visited,
&mut sources,
&mut prov,
true, &mut acc,
)?;
}
let mut config = acc.unwrap_or_default();
crate::domain::config::resolve_overrides(&mut config)?;
let scope = RuleScope {
dir: cwd.to_path_buf(),
files: config.files.clone(),
};
let scopes = config
.rules
.iter()
.map(|r| (r.name.clone(), scope.clone()))
.collect();
Ok(Loaded {
config,
sources,
provenance: prov.finish(),
scopes,
})
}
struct Unit {
path: PathBuf,
dir: PathBuf,
distance: usize,
is_descendant: bool,
}
fn load_discovered(cwd: &Path, explicit_files: &[PathBuf]) -> Result<Loaded> {
let ancestors = discover_all(cwd);
let all_descendants = discover_subtree(cwd);
if ancestors.is_empty() && all_descendants.is_empty() {
return Err(Error::ConfigNotFound {
names: CONFIG_NAMES.join(", "),
dir: cwd.display().to_string(),
});
}
let descendants: Vec<PathBuf> = if explicit_files.is_empty() {
all_descendants
} else {
all_descendants
.into_iter()
.filter(|path| {
let dir = path.parent().unwrap_or(cwd);
explicit_files.iter().any(|f| file_under(cwd, dir, f))
})
.collect()
};
let mut units: Vec<Unit> = Vec::new();
for path in &ancestors {
let dir = path.parent().unwrap_or(cwd).to_path_buf();
let distance = cwd.strip_prefix(&dir).map_or(0, |r| r.components().count());
units.push(Unit {
path: path.clone(),
dir,
distance,
is_descendant: false,
});
}
for path in &descendants {
let dir = path.parent().unwrap_or(cwd).to_path_buf();
let distance = dir.strip_prefix(cwd).map_or(0, |r| r.components().count());
units.push(Unit {
path: path.clone(),
dir,
distance,
is_descendant: true,
});
}
units.sort_by(|a, b| {
a.distance
.cmp(&b.distance)
.then(a.is_descendant.cmp(&b.is_descendant))
.then_with(|| a.path.cmp(&b.path))
});
let opts = ResolveOpts::from_env();
let mut visited: BTreeSet<String> = BTreeSet::new();
let mut sources: Vec<String> = Vec::new();
let mut prov = ProvenanceBuilder::default();
let mut session = Config::default();
let mut scoped: Vec<(Rule, RuleScope)> = Vec::new();
for unit in &units {
let mut acc: Option<Config> = None;
load_node(
Node::File(unit.path.clone()),
0,
&opts,
&mut visited,
&mut sources,
&mut prov,
!unit.is_descendant,
&mut acc,
)?;
let Some(unit_cfg) = acc else { continue }; let scope = RuleScope {
dir: unit.dir.clone(),
files: unit_cfg.files.clone(),
};
for (name, agent) in &unit_cfg.agents {
session
.agents
.entry(name.clone())
.or_insert_with(|| agent.clone());
}
if !unit.is_descendant {
fold_session_settings(&mut session, &unit_cfg);
}
for rule in &unit_cfg.rules {
scoped.push((rule.clone(), scope.clone()));
}
}
let mut scopes: BTreeMap<String, RuleScope> = BTreeMap::new();
for (rule, scope) in scoped.iter().filter(|(r, _)| !r.r#override) {
scopes
.entry(rule.name.clone())
.or_insert_with(|| scope.clone());
}
for (rule, scope) in &scoped {
scopes
.entry(rule.name.clone())
.or_insert_with(|| scope.clone());
}
session.rules = scoped.into_iter().map(|(rule, _)| rule).collect();
crate::domain::config::resolve_overrides(&mut session)?;
scopes.retain(|name, _| session.rules.iter().any(|r| &r.name == name));
Ok(Loaded {
config: session,
sources,
provenance: prov.finish(),
scopes,
})
}
fn fold_session_settings(session: &mut Config, unit: &Config) {
session.version = session.version.take().or_else(|| unit.version.clone());
session.prompt_template = session
.prompt_template
.take()
.or_else(|| unit.prompt_template.clone());
if session.files.is_empty() {
session.files = unit.files.clone();
}
session.oneharness.merge_under(unit.oneharness.clone());
session.rationales = session.rationales.or(unit.rationales);
session.diff_base = session.diff_base.take().or_else(|| unit.diff_base.clone());
}
enum Node {
File(PathBuf),
Remote {
url: String,
req: Option<VersionReq>,
key: String,
},
}
impl Node {
fn resolve(spec: &str, base_dir: Option<&Path>) -> Result<Node> {
match plugins::parse_spec(spec)? {
plugins::PluginRef::Local(p) => {
let abs = if p.is_absolute() {
p
} else {
match base_dir {
Some(d) => d.join(p),
None => {
return Err(Error::InvalidConfig(format!(
"cannot resolve relative plugin {spec:?} from a remote plugin"
)))
}
}
};
Ok(Node::File(abs))
}
plugins::PluginRef::Remote { url, req } => {
let key = match &req {
Some(r) => format!("{url}@{r}"),
None => url.clone(),
};
Ok(Node::Remote { url, req, key })
}
}
}
fn key(&self) -> String {
match self {
Node::File(p) => normalize(p).display().to_string(),
Node::Remote { key, .. } => key.clone(),
}
}
fn origin(&self) -> String {
self.key()
}
fn read(&self, opts: &ResolveOpts) -> Result<(String, Option<PathBuf>)> {
match self {
Node::File(p) => {
let text = std::fs::read_to_string(p)
.map_err(|e| io_err(format!("reading config {}", p.display()), e))?;
Ok((text, p.parent().map(Path::to_path_buf)))
}
Node::Remote { url, req, .. } => Ok((plugins::load_remote(url, req, opts)?, None)),
}
}
}
#[allow(clippy::too_many_arguments)]
fn load_node(
node: Node,
depth: usize,
opts: &ResolveOpts,
visited: &mut BTreeSet<String>,
sources: &mut Vec<String>,
prov: &mut ProvenanceBuilder,
record_settings: bool,
acc: &mut Option<Config>,
) -> Result<()> {
if depth > MAX_PLUGIN_DEPTH {
return Err(Error::PluginDepthExceeded {
max: MAX_PLUGIN_DEPTH,
});
}
let key = node.key();
if !visited.insert(key.clone()) {
return Ok(()); }
sources.push(key);
let (text, base_dir) = node.read(opts)?;
let origin = node.origin();
let cfg = parse(&text, &origin)?;
let child_specs = cfg.plugins.clone();
if record_settings {
prov.record(&cfg, &origin);
} else {
prov.record_items(&cfg, &origin);
}
match acc {
None => *acc = Some(cfg),
Some(a) => a.merge_plugin(cfg),
}
for spec in child_specs {
let child = Node::resolve(&spec, base_dir.as_deref())?;
load_node(
child,
depth + 1,
opts,
visited,
sources,
prov,
record_settings,
acc,
)?;
}
Ok(())
}
fn absolutize(p: &Path, cwd: &Path) -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
}
}
pub(crate) fn normalize(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn parse_resolves_anchors_and_merge_keys() {
let yaml = r#"
x-prompts:
shared: &shared "be terse"
agents:
a:
prompt_template: *shared
b:
<<: &defaults { harness: claude-code }
model: opus
rules:
- name: only_rule
description: "true when ok; false otherwise."
"#;
let cfg = parse(yaml, "test").unwrap();
assert_eq!(cfg.agents["a"].prompt_template.as_deref(), Some("be terse"));
assert_eq!(cfg.agents["b"].harness.as_deref(), Some("claude-code"));
assert_eq!(cfg.agents["b"].model.as_deref(), Some("opus"));
}
#[test]
fn parse_rejects_unknown_nested_field() {
let yaml = "rules:\n - name: r\n description: d\n bogus: 1\n";
assert!(matches!(parse(yaml, "t"), Err(Error::ConfigParse { .. })));
}
#[test]
fn load_config_lint_yields_the_bundled_rules_rooted_at_cwd() {
let dir = tempdir().unwrap();
let loaded = load_config_lint(dir.path()).unwrap();
assert_eq!(loaded.sources, vec![assets::CONFIG_LINT_URL.to_string()]);
assert!(loaded
.config
.rules
.iter()
.any(|r| r.name == "name_describes_what_the_rule_checks"));
let scope = &loaded.scopes["name_describes_what_the_rule_checks"];
assert_eq!(scope.dir, dir.path());
crate::domain::config::validate(&loaded.config).unwrap();
}
#[test]
fn discover_walks_up() {
let dir = tempdir().unwrap();
let nested = dir.path().join("a/b/c");
fs::create_dir_all(&nested).unwrap();
fs::write(dir.path().join("llmlint.yml"), "version: 1\n").unwrap();
let found = discover(&nested).unwrap();
assert_eq!(found, dir.path().join("llmlint.yml"));
}
#[test]
fn discover_all_collects_every_ancestor_nearest_first() {
let dir = tempdir().unwrap();
let mid = dir.path().join("a");
let leaf = mid.join("b");
fs::create_dir_all(&leaf).unwrap();
fs::write(dir.path().join("llmlint.yml"), "version: 1\n").unwrap();
fs::write(mid.join("llmlint.yml"), "version: 1\n").unwrap();
fs::write(leaf.join("llmlint.yml"), "version: 1\n").unwrap();
let found = discover_all(&leaf);
assert_eq!(
found,
vec![
leaf.join("llmlint.yml"),
mid.join("llmlint.yml"),
dir.path().join("llmlint.yml"),
]
);
}
#[test]
fn nested_configs_merge_with_most_local_winning() {
let dir = tempdir().unwrap();
let proj = dir.path().join("proj");
let leaf = proj.join("src");
fs::create_dir_all(&leaf).unwrap();
fs::write(
dir.path().join("llmlint.yml"),
"version: 1\nrationales: true\noneharness:\n model: user-model\n timeout: 9\n\
rules:\n - name: user_rule\n description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
fs::write(
proj.join("llmlint.yml"),
"oneharness:\n model: proj-model\nrules:\n - name: proj_rule\n \
description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
fs::write(
leaf.join("llmlint.yml"),
"rationales: false\nrules:\n - name: local_rule\n \
description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
let cfg = load(&[], &leaf).unwrap().config;
assert_eq!(cfg.rationales, Some(false));
assert_eq!(cfg.oneharness.model.as_deref(), Some("proj-model"));
assert_eq!(cfg.oneharness.timeout, Some(9));
let names: Vec<&str> = cfg.rules.iter().map(|r| r.name.as_str()).collect();
assert_eq!(names, ["local_rule", "proj_rule", "user_rule"]);
}
#[test]
fn discover_subtree_finds_descendant_configs_one_per_dir() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("a/b")).unwrap();
fs::write(dir.path().join("llmlint.yml"), "rules: []\n").unwrap(); fs::write(dir.path().join("a/llmlint.yml"), "rules: []\n").unwrap();
fs::write(dir.path().join("a/b/llmlint.yaml"), "rules: []\n").unwrap();
fs::write(dir.path().join("a/b/.llmlint.yml"), "rules: []\n").unwrap();
let found: BTreeSet<PathBuf> = discover_subtree(dir.path()).into_iter().collect();
let expected: BTreeSet<PathBuf> = [
dir.path().join("a/llmlint.yml"),
dir.path().join("a/b/llmlint.yaml"), ]
.into_iter()
.collect();
assert_eq!(found, expected);
}
#[test]
fn cascade_scopes_each_rule_to_its_config_directory() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("llmlint.yml"),
"version: 1\nfiles:\n include: [\"**/*.rs\"]\nrules:\n - name: root_rule\n \
description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
fs::write(
dir.path().join("sub/llmlint.yml"),
"files:\n include: [\"*.txt\"]\nrules:\n - name: sub_rule\n \
description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
let loaded = load(&[], dir.path()).unwrap();
let names: Vec<&str> = loaded
.config
.rules
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(names, ["root_rule", "sub_rule"]);
assert_eq!(loaded.scopes["root_rule"].dir, dir.path());
assert_eq!(loaded.scopes["sub_rule"].dir, dir.path().join("sub"));
assert_eq!(loaded.scopes["sub_rule"].files.include, vec!["*.txt"]);
}
#[test]
fn descendant_configs_scope_rules_but_do_not_retune_session_settings() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("llmlint.yml"),
"version: 1\ndiff_base: main\noneharness:\n model: root-model\nrules:\n \
- name: root_rule\n description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
fs::write(
dir.path().join("sub/llmlint.yml"),
"diff_base: develop\noneharness:\n model: sub-model\nagents:\n scoped:\n model: x\n\
rules:\n - name: sub_rule\n agent: scoped\n \
description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
let loaded = load(&[], dir.path()).unwrap();
assert_eq!(
loaded.config.oneharness.model.as_deref(),
Some("root-model")
);
assert_eq!(loaded.config.diff_base.as_deref(), Some("main"));
assert!(loaded.config.agents.contains_key("scoped"));
assert_eq!(loaded.scopes["sub_rule"].dir, dir.path().join("sub"));
}
#[test]
fn discovery_succeeds_with_only_a_descendant_config() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("sub/llmlint.yml"),
"rules:\n - name: only_sub\n description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
let loaded = load(&[], dir.path()).unwrap();
let names: Vec<&str> = loaded
.config
.rules
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(names, ["only_sub"]);
assert_eq!(loaded.scopes["only_sub"].dir, dir.path().join("sub"));
}
#[test]
fn explicit_files_relevance_gate_the_subtree_cascade() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("frontend")).unwrap();
fs::create_dir_all(dir.path().join("backend")).unwrap();
fs::write(
dir.path().join("llmlint.yml"),
"version: 1\nrules:\n - name: root_rule\n description: \"d\"\n",
)
.unwrap();
fs::write(
dir.path().join("frontend/llmlint.yml"),
"rules:\n - name: front_rule\n description: \"d\"\n",
)
.unwrap();
fs::write(
dir.path().join("backend/llmlint.yml"),
"rules:\n - name: back_rule\n description: \"d\"\n",
)
.unwrap();
let names = |l: &Loaded| -> BTreeSet<String> {
l.config.rules.iter().map(|r| r.name.clone()).collect()
};
let loaded =
load_with_targets(&[], dir.path(), &[PathBuf::from("frontend/app.js")]).unwrap();
assert_eq!(
names(&loaded),
["front_rule", "root_rule"].map(String::from).into()
);
let loaded =
load_with_targets(&[], dir.path(), &[dir.path().join("backend/svc.py")]).unwrap();
assert_eq!(
names(&loaded),
["back_rule", "root_rule"].map(String::from).into()
);
let loaded = load(&[], dir.path()).unwrap();
assert_eq!(
names(&loaded),
["back_rule", "front_rule", "root_rule"]
.map(String::from)
.into()
);
}
#[test]
fn explicit_file_with_only_an_unrelated_subtree_is_a_zero_rule_run() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("backend")).unwrap();
fs::write(
dir.path().join("backend/llmlint.yml"),
"rules:\n - name: back_rule\n description: \"d\"\n",
)
.unwrap();
let loaded = load_with_targets(&[], dir.path(), &[PathBuf::from("app.rs")]).unwrap();
assert!(loaded.config.rules.is_empty());
}
#[test]
fn load_missing_config_errors() {
let dir = tempdir().unwrap();
let err = load(&[], dir.path()).unwrap_err();
assert!(matches!(err, Error::ConfigNotFound { .. }));
}
#[test]
fn load_merges_file_and_bundled_plugins() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("team.yml"),
"rules:\n - name: team_rule\n description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
let plugin = format!("{}@1", crate::io::assets::CONFIG_LINT_URL);
let root = dir.path().join("llmlint.yml");
fs::write(
&root,
format!(
"version: 1\nplugins:\n - ./team.yml\n - {plugin}\nrules:\n \
- name: root_rule\n description: \"true when ok; false otherwise.\"\n"
),
)
.unwrap();
let loaded = load(&[root], dir.path()).unwrap();
let names: Vec<&str> = loaded
.config
.rules
.iter()
.map(|r| r.name.as_str())
.collect();
assert!(names.contains(&"root_rule"));
assert!(names.contains(&"team_rule"));
assert!(names.contains(&"name_describes_what_the_rule_checks")); assert!(loaded.sources.iter().any(|s| s == &plugin));
}
#[test]
fn removed_llmlint_scheme_errors() {
let dir = tempdir().unwrap();
let root = dir.path().join("llmlint.yml");
fs::write(&root, "plugins:\n - llmlint:config-lint\n").unwrap();
assert!(matches!(
load(&[root], dir.path()),
Err(Error::PluginSpec(_))
));
}
#[test]
fn renamed_include_key_is_a_clear_error() {
let dir = tempdir().unwrap();
let root = dir.path().join("llmlint.yml");
fs::write(&root, "include:\n - ./team.yml\n").unwrap();
let err = load(&[root], dir.path()).unwrap_err();
assert!(err.to_string().contains("renamed to `plugins`"));
}
#[test]
fn plugins_resolve_transitively() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("leaf.yml"),
"rules:\n - name: leaf_rule\n description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
fs::write(
dir.path().join("mid.yml"),
"plugins:\n - ./leaf.yml\nrules:\n - name: mid_rule\n \
description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
let root = dir.path().join("llmlint.yml");
fs::write(
&root,
"version: 1\nplugins:\n - ./mid.yml\nrules:\n - name: root_rule\n \
description: \"true when ok; false otherwise.\"\n",
)
.unwrap();
let loaded = load(&[root], dir.path()).unwrap();
let names: Vec<&str> = loaded
.config
.rules
.iter()
.map(|r| r.name.as_str())
.collect();
assert_eq!(names, ["root_rule", "mid_rule", "leaf_rule"]);
}
#[test]
fn top_level_scalars_resolve_nearest_root_wins() {
let dir = tempdir().unwrap();
fs::write(
dir.path().join("leaf.yml"),
"rationales: true\noneharness:\n model: leaf-model\n timeout: 7\n\
prompt_template: leaf-tmpl\nrules: []\n",
)
.unwrap();
fs::write(
dir.path().join("mid.yml"),
"plugins:\n - ./leaf.yml\noneharness:\n model: mid-model\nrules: []\n",
)
.unwrap();
let root = dir.path().join("llmlint.yml");
fs::write(
&root,
"version: 1\nplugins:\n - ./mid.yml\nrationales: false\nrules: []\n",
)
.unwrap();
let cfg = load(&[root], dir.path()).unwrap().config;
assert_eq!(cfg.rationales, Some(false));
assert_eq!(cfg.oneharness.model.as_deref(), Some("mid-model"));
assert_eq!(cfg.oneharness.timeout, Some(7));
assert_eq!(cfg.prompt_template.as_deref(), Some("leaf-tmpl"));
}
#[test]
fn provenance_traces_each_item_to_its_source() {
let desc = " description: \"true when ok; false otherwise.\"\n";
let dir = tempdir().unwrap();
let leaf = dir.path().join("leaf.yml");
fs::write(
&leaf,
format!(
"rationales: true\nagents:\n shared:\n harness: codex\nrules:\n \
- name: leaf_rule\n{desc} - name: shared_rule\n{desc}"
),
)
.unwrap();
let mid = dir.path().join("mid.yml");
fs::write(
&mid,
format!(
"plugins:\n - ./leaf.yml\noneharness:\n model: mid-model\nagents:\n \
shared:\n harness: claude-code\nrules:\n - name: mid_rule\n{desc}"
),
)
.unwrap();
let root = dir.path().join("llmlint.yml");
fs::write(
&root,
format!(
"version: 1\nplugins:\n - ./mid.yml\nrationales: false\nrules:\n \
- name: root_rule\n{desc} - name: shared_rule\n override: true\n judges: 3\n"
),
)
.unwrap();
let prov = load(std::slice::from_ref(&root), dir.path())
.unwrap()
.provenance;
let key = |p: &Path| normalize(p).display().to_string();
assert_eq!(prov.settings["version"], key(&root));
assert_eq!(prov.settings["rationales"], key(&root));
assert_eq!(prov.settings["oneharness.model"], key(&mid));
assert_eq!(prov.agents["shared"], key(&mid));
assert_eq!(prov.rules["root_rule"].source, key(&root));
assert!(prov.rules["root_rule"].fields.is_empty());
assert_eq!(prov.rules["mid_rule"].source, key(&mid));
assert_eq!(prov.rules["leaf_rule"].source, key(&leaf));
let shared = &prov.rules["shared_rule"];
assert_eq!(shared.source, key(&leaf));
assert_eq!(shared.fields["judges"], key(&root));
assert!(!shared.fields.contains_key("description"));
}
#[test]
fn plugin_chain_deeper_than_max_depth_errors() {
let dir = tempdir().unwrap();
let total = MAX_PLUGIN_DEPTH + 2;
for i in 0..total {
let path = dir.path().join(format!("c{i}.yml"));
let body = if i + 1 < total {
format!("plugins:\n - ./c{}.yml\nrules: []\n", i + 1)
} else {
"rules: []\n".to_string()
};
fs::write(&path, body).unwrap();
}
let err = load(&[dir.path().join("c0.yml")], dir.path()).unwrap_err();
assert!(matches!(err, Error::PluginDepthExceeded { .. }));
}
#[test]
fn plugin_cycle_is_safe() {
let dir = tempdir().unwrap();
let a = dir.path().join("a.yml");
let b = dir.path().join("b.yml");
fs::write(&a, "plugins:\n - ./b.yml\nrules: []\n").unwrap();
fs::write(&b, "plugins:\n - ./a.yml\nrules: []\n").unwrap();
let loaded = load(&[a], dir.path()).unwrap();
assert_eq!(loaded.sources.len(), 2);
}
}