llmlint 0.2.1

LLM-as-judge linter: enforce code-quality checks deterministic linters can't express, by driving real coding harnesses through oneharness.
Documentation
//! Config discovery, parsing (anchors + `<<` merge keys), and recursive
//! `plugins:` resolution — local files and remote/versioned URLs (see
//! [`crate::io::plugins`]).

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use crate::domain::config::Config;
use crate::domain::version::VersionReq;
use crate::errors::{io_err, Error, Result};
use crate::io::plugins::{self, ResolveOpts};

/// Config file names searched for, in priority order, when walking up the tree.
pub const CONFIG_NAMES: &[&str] = &[
    "llmlint.yml",
    "llmlint.yaml",
    ".llmlint.yml",
    ".llmlint.yaml",
];

/// The merged config plus the ordered list of sources that contributed to it
/// (file paths and plugin URLs), for provenance.
#[derive(Debug)]
pub struct Loaded {
    pub config: Config,
    pub sources: Vec<String>,
}

/// Walk up from `start` to the filesystem root, returning the nearest config.
pub fn discover(start: &Path) -> Option<PathBuf> {
    let mut dir = Some(start);
    while let Some(d) = dir {
        for name in CONFIG_NAMES {
            let p = d.join(name);
            if p.is_file() {
                return Some(p);
            }
        }
        dir = d.parent();
    }
    None
}

/// Parse one YAML document into a [`Config`], resolving anchors/aliases (done
/// by the parser) and `<<` merge keys (via `apply_merge`).
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)?;
    // The top-level config-include key was renamed `include` -> `plugins` (to
    // avoid confusion with `files.include`). Unknown top-level keys are allowed
    // (anchors live in throwaway keys), so a stale `include:` would silently do
    // nothing; catch it with a clear migration error instead.
    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)
}

/// Load and merge config from explicit entry files (from `--config`), or, when
/// `entries` is empty, the nearest discovered config above `cwd`. `plugins`
/// (local files or remote/versioned URLs) are merged recursively; the first
/// entry provides the top-level scalars, the rest contribute rules and agents.
/// Diamonds and cycles are de-duplicated by absolute path / plugin key.
pub fn load(entries: &[PathBuf], cwd: &Path) -> Result<Loaded> {
    let entry_paths: Vec<PathBuf> = if entries.is_empty() {
        match discover(cwd) {
            Some(p) => vec![p],
            None => {
                return Err(Error::ConfigNotFound {
                    names: CONFIG_NAMES.join(", "),
                    dir: cwd.display().to_string(),
                })
            }
        }
    } else {
        entries.iter().map(|p| absolutize(p, cwd)).collect()
    };

    let opts = ResolveOpts::from_env();
    let mut visited: BTreeSet<String> = BTreeSet::new();
    let mut sources: Vec<String> = Vec::new();
    let mut acc: Option<Config> = None;

    for path in &entry_paths {
        load_node(
            Node::File(path.clone()),
            &opts,
            &mut visited,
            &mut sources,
            &mut acc,
        )?;
    }

    Ok(Loaded {
        config: acc.unwrap_or_default(),
        sources,
    })
}

enum Node {
    File(PathBuf),
    /// A URL plugin: the bare URL, an optional version pin, and a stable dedup
    /// key (`url` or `url@pin`). The text is fetched in [`Node::read`] — after
    /// the visited check — so a diamond never refetches.
    Remote {
        url: String,
        req: Option<VersionReq>,
        key: String,
    },
}

impl Node {
    /// Parse a `plugins:` spec into a node. Pure: no I/O happens here (so a
    /// duplicate is skipped before any fetch).
    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()
    }

    /// Returns `(text, base_dir_for_relative_plugins)`.
    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)))
            }
            // Remote plugins can pull in further URL plugins, but not relative
            // file paths (there is no local base directory).
            Node::Remote { url, req, .. } => Ok((plugins::load_remote(url, req, opts)?, None)),
        }
    }
}

fn load_node(
    node: Node,
    opts: &ResolveOpts,
    visited: &mut BTreeSet<String>,
    sources: &mut Vec<String>,
    acc: &mut Option<Config>,
) -> Result<()> {
    let key = node.key();
    if !visited.insert(key.clone()) {
        return Ok(()); // already loaded (diamond/cycle) — skip
    }
    sources.push(key);

    let (text, base_dir) = node.read(opts)?;
    let cfg = parse(&text, &node.origin())?;
    let child_specs = cfg.plugins.clone();

    match acc {
        None => *acc = Some(cfg),
        Some(a) => a.merge_rules_and_agents(cfg),
    }

    for spec in child_specs {
        let child = Node::resolve(&spec, base_dir.as_deref())?;
        load_node(child, opts, visited, sources, acc)?;
    }
    Ok(())
}

fn absolutize(p: &Path, cwd: &Path) -> PathBuf {
    if p.is_absolute() {
        p.to_path_buf()
    } else {
        cwd.join(p)
    }
}

/// Lexically normalize a path (collapse `.`/`..`) without touching the
/// filesystem, so the dedup key is stable even for not-yet-read files.
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 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 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_matches_description")); // from the bundled plugin
        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 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();
        // Must terminate rather than recurse forever.
        let loaded = load(&[a], dir.path()).unwrap();
        assert_eq!(loaded.sources.len(), 2);
    }
}