use std::io::{self, IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use alint_core::{Engine, RuleRegistry, WalkOptions, walk};
use alint_output::{ColorChoice, Format, GlyphSet, HumanOptions};
use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
mod export_agents_md;
mod init;
mod progress;
mod suggest;
#[derive(Parser, Debug)]
#[command(
name = "alint",
version,
about = "Language-agnostic linter for repository structure, existence, naming, and content rules",
long_about = None,
)]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
#[arg(long, short = 'c', global = true)]
config: Vec<PathBuf>,
#[arg(long, short = 'f', global = true, default_value = "human")]
format: String,
#[arg(long, global = true)]
no_gitignore: bool,
#[arg(long, global = true)]
fail_on_warning: bool,
#[arg(
long,
global = true,
value_name = "WHEN",
default_value = "auto",
value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
)]
color: String,
#[arg(long, global = true)]
ascii: bool,
#[arg(long, global = true)]
compact: bool,
#[arg(
long,
global = true,
value_name = "WHEN",
default_value = "auto",
value_parser = clap::builder::PossibleValuesParser::new(["auto", "always", "never"]),
)]
progress: String,
#[arg(long, short = 'q', global = true)]
quiet: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand, Debug)]
enum Command {
Check {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
changed: bool,
#[arg(long, value_name = "REF")]
base: Option<String>,
},
List,
Explain {
rule_id: String,
},
Fix {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
dry_run: bool,
#[arg(long)]
changed: bool,
#[arg(long, value_name = "REF")]
base: Option<String>,
},
Facts {
#[arg(default_value = ".")]
path: PathBuf,
},
Init {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
monorepo: bool,
},
ExportAgentsMd {
#[arg(long, value_name = "PATH")]
output: Option<PathBuf>,
#[arg(long, requires = "output")]
inline: bool,
#[arg(long, value_name = "TEXT")]
section_title: Option<String>,
#[arg(long)]
include_info: bool,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "markdown",
value_parser = clap::builder::PossibleValuesParser::new(["markdown", "json"]),
)]
format: String,
},
Suggest {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(
long,
short = 'f',
value_name = "FORMAT",
default_value = "human",
value_parser = clap::builder::PossibleValuesParser::new(["human", "yaml", "json"]),
)]
format: String,
#[arg(
long,
value_name = "LEVEL",
default_value = "medium",
value_parser = clap::builder::PossibleValuesParser::new(["low", "medium", "high"]),
)]
confidence: String,
#[arg(long)]
include_bundled: bool,
#[arg(long)]
explain: bool,
},
}
fn main() -> ExitCode {
init_tracing();
let cli = Cli::parse();
match run(cli) {
Ok(code) => code,
Err(e) => {
eprintln!("alint: {e:#}");
ExitCode::from(2)
}
}
}
fn init_tracing() {
use tracing_subscriber::{EnvFilter, fmt};
let filter = EnvFilter::try_from_env("ALINT_LOG").unwrap_or_else(|_| EnvFilter::new("warn"));
let _ = fmt().with_env_filter(filter).with_target(false).try_init();
}
fn run(mut cli: Cli) -> Result<ExitCode> {
let command = cli.command.take().unwrap_or(Command::Check {
path: PathBuf::from("."),
changed: false,
base: None,
});
match command {
Command::Check {
path,
changed,
base,
} => cmd_check(&path, &ChangedMode::new(changed, base), &cli),
Command::List => cmd_list(&cli),
Command::Explain { rule_id } => cmd_explain(&rule_id, &cli),
Command::Fix {
path,
dry_run,
changed,
base,
} => cmd_fix(&path, dry_run, &ChangedMode::new(changed, base), &cli),
Command::Facts { path } => cmd_facts(&path, &cli),
Command::Init { path, monorepo } => cmd_init(&path, monorepo),
Command::ExportAgentsMd {
output,
inline,
section_title,
include_info,
format,
} => cmd_export_agents_md(
&ExportAgentsMdOptions {
output,
inline,
section_title,
include_info,
format,
},
&cli,
),
Command::Suggest {
path,
format,
confidence,
include_bundled,
explain,
} => cmd_suggest(
&path,
&SuggestOptions {
format,
confidence,
include_bundled,
explain,
},
&cli,
),
}
}
#[derive(Debug)]
struct ExportAgentsMdOptions {
output: Option<PathBuf>,
inline: bool,
section_title: Option<String>,
include_info: bool,
format: String,
}
fn cmd_export_agents_md(opts: &ExportAgentsMdOptions, cli: &Cli) -> Result<ExitCode> {
use export_agents_md::{OutputFormat, RunOptions};
let format: OutputFormat = opts
.format
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let section_title = opts
.section_title
.clone()
.unwrap_or_else(|| "Lint rules enforced by alint".to_string());
let run_opts = RunOptions {
format,
output: opts.output.clone(),
inline: opts.inline,
section_title,
include_info: opts.include_info,
};
export_agents_md::run(cli.config.first().map(PathBuf::as_path), &run_opts)
}
#[derive(Debug)]
struct SuggestOptions {
format: String,
confidence: String,
include_bundled: bool,
explain: bool,
}
fn cmd_suggest(path: &Path, opts: &SuggestOptions, cli: &Cli) -> Result<ExitCode> {
use suggest::{Confidence, OutputFormat};
let format: OutputFormat = opts
.format
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let confidence: Confidence = opts
.confidence
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?;
let progress_mode = if cli.quiet {
progress::ProgressMode::Never
} else {
cli.progress
.parse()
.map_err(|e: String| anyhow::anyhow!(e))?
};
let progress = progress::Progress::new(progress_mode);
suggest::run(
path,
&suggest::RunOptions {
format,
confidence,
include_bundled: opts.include_bundled,
explain: opts.explain,
quiet: cli.quiet,
},
&progress,
)
}
fn cmd_init(path: &Path, monorepo: bool) -> Result<ExitCode> {
for name in [".alint.yml", ".alint.yaml", "alint.yml", "alint.yaml"] {
let candidate = path.join(name);
if candidate.is_file() {
bail!(
"{} already exists; refusing to overwrite. Delete it first if you really \
want to regenerate, or edit it directly.",
candidate.display()
);
}
}
let detection = init::detect(path, monorepo);
let body = init::render(&detection);
let target = path.join(".alint.yml");
std::fs::write(&target, &body).with_context(|| format!("writing {}", target.display()))?;
let summary = init::render_summary(&detection);
if summary.is_empty() {
println!(
"Wrote {} — extends `oss-baseline@v1` only.",
target.display()
);
println!(
" No language manifests detected. Add an `extends:` line for your stack \
(`alint://bundled/rust@v1`, `node@v1`, …) when ready."
);
} else {
println!("Wrote {} — detected: {}.", target.display(), summary);
println!(" Run `alint check` to lint against the generated config.");
}
Ok(ExitCode::SUCCESS)
}
#[derive(Debug)]
struct ChangedMode {
enabled: bool,
base: Option<String>,
}
impl ChangedMode {
fn new(changed_flag: bool, base: Option<String>) -> Self {
let enabled = changed_flag || base.is_some();
Self { enabled, base }
}
fn resolve(&self, root: &Path) -> Result<Option<std::collections::HashSet<PathBuf>>> {
if !self.enabled {
return Ok(None);
}
let set = alint_core::git::collect_changed_paths(root, self.base.as_deref()).ok_or_else(
|| {
let what = self.base.as_deref().map_or_else(
|| "git ls-files --modified --others --exclude-standard".to_string(),
|r| format!("git diff --name-only {r}...HEAD"),
);
anyhow::anyhow!(
"--changed requires a git repository (and `git` on PATH); \
`{what}` failed at {}. Run without --changed for a full check.",
root.display()
)
},
)?;
Ok(Some(set))
}
}
fn cmd_check(path: &Path, changed: &ChangedMode, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(path, cli)?;
let rule_count = loaded.entries.len();
let mut engine = Engine::from_entries(loaded.entries, loaded.registry)
.with_facts(loaded.facts)
.with_vars(loaded.vars);
if let Some(set) = changed.resolve(path)? {
engine = engine.with_changed_paths(set);
}
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores: loaded.extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
tracing::debug!(files = index.entries.len(), "walk complete");
let report = engine.run(path, &index).context("running rules")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let (mut out, opts) = render_env(cli)?;
format
.write_with_options(&report, &mut out, opts)
.context("writing output")?;
out.flush().ok();
tracing::debug!(rules = rule_count, "done");
let exit = if report.has_errors() || (cli.fail_on_warning && report.has_warnings()) {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
Ok(exit)
}
fn cmd_fix(path: &Path, dry_run: bool, changed: &ChangedMode, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(path, cli)?;
let mut engine = Engine::from_entries(loaded.entries, loaded.registry)
.with_facts(loaded.facts)
.with_vars(loaded.vars)
.with_fix_size_limit(loaded.fix_size_limit);
if let Some(set) = changed.resolve(path)? {
engine = engine.with_changed_paths(set);
}
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores: loaded.extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
let report = engine
.fix(path, &index, dry_run)
.context("applying fixes")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let (mut out, opts) = render_env(cli)?;
format
.write_fix_with_options(&report, &mut out, opts)
.context("writing output")?;
out.flush().ok();
let exit = if report.has_unfixable_errors()
|| (cli.fail_on_warning && report.has_unfixable_warnings())
{
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
Ok(exit)
}
fn cmd_list(cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(Path::new("."), cli)?;
if loaded.entries.is_empty() {
println!("(no rules loaded from config)");
} else {
for entry in &loaded.entries {
let rule = &entry.rule;
let gated = if entry.when.is_some() { " [when]" } else { "" };
println!(
"{:<8} {}{}{}",
rule.level().as_str(),
rule.id(),
gated,
rule.policy_url()
.map(|u| format!(" ({u})"))
.unwrap_or_default()
);
}
}
Ok(ExitCode::SUCCESS)
}
fn cmd_facts(path: &Path, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(path, cli)?;
let effective_gitignore = if cli.no_gitignore {
false
} else {
loaded.respect_gitignore
};
let walk_opts = WalkOptions {
respect_gitignore: effective_gitignore,
extra_ignores: loaded.extra_ignores,
};
let index = walk(path, &walk_opts).context("walking repository")?;
let values =
alint_core::evaluate_facts(&loaded.facts, path, &index).context("evaluating facts")?;
let format: Format = cli.format.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let stdout = io::stdout();
let mut out = stdout.lock();
render_facts(&loaded.facts, &values, format, &mut out)?;
out.flush().ok();
Ok(ExitCode::SUCCESS)
}
fn render_facts(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
format: Format,
out: &mut dyn Write,
) -> Result<()> {
match format {
Format::Json => render_facts_json(facts, values, out),
_ => render_facts_human(facts, values, out),
}
}
fn render_facts_human(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
out: &mut dyn Write,
) -> Result<()> {
if facts.is_empty() {
writeln!(out, "(no facts declared in config)")?;
return Ok(());
}
let id_width = facts.iter().map(|f| f.id.len()).max().unwrap_or(0);
let kind_width = facts.iter().map(|f| f.kind.name().len()).max().unwrap_or(0);
for spec in facts {
let value_str = values
.get(&spec.id)
.map_or_else(|| "(unresolved)".to_string(), fact_value_display);
writeln!(
out,
"{:<id_width$} {:<kind_width$} {}",
spec.id,
spec.kind.name(),
value_str,
)?;
}
Ok(())
}
fn render_facts_json(
facts: &[alint_core::FactSpec],
values: &alint_core::FactValues,
out: &mut dyn Write,
) -> Result<()> {
let entries: Vec<serde_json::Value> = facts
.iter()
.map(|spec| {
let value = values
.get(&spec.id)
.map_or(serde_json::Value::Null, fact_value_json);
serde_json::json!({
"id": spec.id,
"kind": spec.kind.name(),
"value": value,
})
})
.collect();
let doc = serde_json::json!({ "facts": entries });
writeln!(out, "{}", serde_json::to_string_pretty(&doc)?)?;
Ok(())
}
fn fact_value_display(v: &alint_core::FactValue) -> String {
match v {
alint_core::FactValue::Bool(b) => b.to_string(),
alint_core::FactValue::Int(n) => n.to_string(),
alint_core::FactValue::String(s) => {
format!("{s:?}")
}
}
}
fn fact_value_json(v: &alint_core::FactValue) -> serde_json::Value {
match v {
alint_core::FactValue::Bool(b) => serde_json::Value::Bool(*b),
alint_core::FactValue::Int(n) => serde_json::Value::Number((*n).into()),
alint_core::FactValue::String(s) => serde_json::Value::String(s.clone()),
}
}
fn cmd_explain(rule_id: &str, cli: &Cli) -> Result<ExitCode> {
let loaded = load_rules(Path::new("."), cli)?;
let Some(entry) = loaded.entries.iter().find(|e| e.rule.id() == rule_id) else {
bail!("no rule with id {rule_id:?} found in the effective config");
};
let rule = &entry.rule;
println!("id: {}", rule.id());
println!("level: {}", rule.level().as_str());
if let Some(url) = rule.policy_url() {
println!("policy_url: {url}");
}
if let Some(when) = &entry.when {
println!("when: {when:?}");
}
println!("debug: {rule:?}");
Ok(ExitCode::SUCCESS)
}
fn render_env(
cli: &Cli,
) -> Result<(
anstream::AutoStream<std::io::StdoutLock<'static>>,
HumanOptions,
)> {
let choice: ColorChoice = cli.color.parse().map_err(|e: String| anyhow::anyhow!(e))?;
let stdout = io::stdout();
let is_tty = stdout.is_terminal();
let lock = stdout.lock();
let stream = anstream::AutoStream::new(lock, choice.to_anstream());
let hyperlinks = is_tty && supports_hyperlinks::on(supports_hyperlinks::Stream::Stdout);
let width = if is_tty {
terminal_size::terminal_size().map(|(w, _)| usize::from(w.0))
} else {
None
};
let opts = HumanOptions {
glyphs: GlyphSet::detect(cli.ascii),
hyperlinks,
width,
compact: cli.compact,
};
Ok((stream, opts))
}
struct LoadedConfig {
entries: Vec<alint_core::RuleEntry>,
registry: RuleRegistry,
facts: Vec<alint_core::FactSpec>,
vars: std::collections::HashMap<String, String>,
respect_gitignore: bool,
extra_ignores: Vec<String>,
fix_size_limit: Option<u64>,
}
fn load_rules(cwd: &Path, cli: &Cli) -> Result<LoadedConfig> {
let config_path = if let Some(first) = cli.config.first() {
first.clone()
} else {
alint_dsl::discover(cwd).ok_or_else(|| {
anyhow::anyhow!("no .alint.yml found (searched from {})", cwd.display())
})?
};
tracing::debug!(?config_path, "loading config");
let config = alint_dsl::load(&config_path)?;
let registry: RuleRegistry = alint_rules::builtin_registry();
let mut entries: Vec<alint_core::RuleEntry> = Vec::with_capacity(config.rules.len());
for spec in &config.rules {
if matches!(spec.level, alint_core::Level::Off) {
continue;
}
let rule = registry
.build(spec)
.with_context(|| format!("building rule {:?}", spec.id))?;
let mut entry = alint_core::RuleEntry::new(rule);
if let Some(when_src) = &spec.when {
let expr = alint_core::when::parse(when_src)
.with_context(|| format!("rule {:?}: parsing `when`", spec.id))?;
entry = entry.with_when(expr);
}
entries.push(entry);
}
Ok(LoadedConfig {
entries,
registry,
facts: config.facts,
vars: config.vars,
respect_gitignore: config.respect_gitignore,
extra_ignores: config.ignore,
fix_size_limit: config.fix_size_limit,
})
}
#[cfg(test)]
mod tests {
use super::*;
use alint_core::{FactKind, FactSpec, FactValue, FactValues, facts::OneOrMany};
use alint_output::Format;
fn fact_spec(id: &str, kind: FactKind) -> FactSpec {
FactSpec {
id: id.to_string(),
kind,
}
}
fn any_file_exists_kind(glob: &str) -> FactKind {
FactKind::AnyFileExists {
any_file_exists: OneOrMany::One(glob.to_string()),
}
}
fn count_files_kind(glob: &str) -> FactKind {
FactKind::CountFiles {
count_files: glob.to_string(),
}
}
fn git_branch_kind() -> FactKind {
FactKind::GitBranch {
git_branch: alint_core::facts::GitBranchFact {},
}
}
fn render_to_string<F>(render: F) -> String
where
F: FnOnce(&mut dyn Write) -> Result<()>,
{
let mut buf = Vec::new();
render(&mut buf).expect("render should succeed");
String::from_utf8(buf).expect("output should be UTF-8")
}
#[test]
fn fact_kind_name_covers_every_variant() {
assert_eq!(any_file_exists_kind("X").name(), "any_file_exists");
assert_eq!(count_files_kind("**/*.rs").name(), "count_files");
assert_eq!(git_branch_kind().name(), "git_branch");
assert_eq!(
FactKind::AllFilesExist {
all_files_exist: OneOrMany::One("X".into()),
}
.name(),
"all_files_exist"
);
assert_eq!(
FactKind::FileContentMatches {
file_content_matches: alint_core::facts::FileContentMatchesFact {
paths: OneOrMany::One("X".into()),
pattern: ".".into(),
},
}
.name(),
"file_content_matches"
);
assert_eq!(
FactKind::Custom {
custom: alint_core::facts::CustomFact { argv: vec![] },
}
.name(),
"custom"
);
}
#[test]
fn fact_value_display_renders_each_variant() {
assert_eq!(fact_value_display(&FactValue::Bool(true)), "true");
assert_eq!(fact_value_display(&FactValue::Bool(false)), "false");
assert_eq!(fact_value_display(&FactValue::Int(0)), "0");
assert_eq!(fact_value_display(&FactValue::Int(42)), "42");
assert_eq!(fact_value_display(&FactValue::Int(-1)), "-1");
assert_eq!(
fact_value_display(&FactValue::String("main".into())),
"\"main\""
);
assert_eq!(
fact_value_display(&FactValue::String(String::new())),
"\"\""
);
}
#[test]
fn fact_value_json_preserves_native_types() {
assert_eq!(
fact_value_json(&FactValue::Bool(true)),
serde_json::json!(true)
);
assert_eq!(fact_value_json(&FactValue::Int(42)), serde_json::json!(42));
assert_eq!(
fact_value_json(&FactValue::String("main".into())),
serde_json::json!("main")
);
}
#[test]
fn human_render_aligns_columns_and_covers_each_value_kind() {
let facts = vec![
fact_spec("is_python", any_file_exists_kind("pyproject.toml")),
fact_spec("n_rs_files", count_files_kind("**/*.rs")),
fact_spec("branch", git_branch_kind()),
];
let mut values = FactValues::new();
values.insert("is_python".into(), FactValue::Bool(true));
values.insert("n_rs_files".into(), FactValue::Int(42));
values.insert("branch".into(), FactValue::String("main".into()));
let out = render_to_string(|w| render_facts_human(&facts, &values, w));
assert!(out.contains("is_python"), "output: {out}");
assert!(out.contains("n_rs_files"), "output: {out}");
assert!(out.contains("branch"), "output: {out}");
assert!(out.contains("true"));
assert!(out.contains("42"));
assert!(out.contains("\"main\""));
assert!(out.contains("any_file_exists"));
assert!(out.contains("count_files"));
assert!(out.contains("git_branch"));
assert_eq!(out.lines().count(), 3);
}
#[test]
fn human_render_reports_no_facts_message() {
let out = render_to_string(|w| render_facts_human(&[], &FactValues::new(), w));
assert_eq!(out.trim(), "(no facts declared in config)");
}
#[test]
fn human_render_marks_unresolved_facts_when_value_is_missing() {
let facts = vec![fact_spec("orphan", any_file_exists_kind("X"))];
let out = render_to_string(|w| render_facts_human(&facts, &FactValues::new(), w));
assert!(out.contains("(unresolved)"), "output: {out}");
}
#[test]
fn json_render_emits_versioned_document_shape() {
let facts = vec![
fact_spec("is_go", any_file_exists_kind("go.mod")),
fact_spec("n_py", count_files_kind("**/*.py")),
];
let mut values = FactValues::new();
values.insert("is_go".into(), FactValue::Bool(false));
values.insert("n_py".into(), FactValue::Int(5));
let out = render_to_string(|w| render_facts_json(&facts, &values, w));
let parsed: serde_json::Value =
serde_json::from_str(&out).expect("render should emit valid JSON");
let arr = parsed
.get("facts")
.and_then(|v| v.as_array())
.expect("facts: [...]");
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["id"], serde_json::json!("is_go"));
assert_eq!(arr[0]["kind"], serde_json::json!("any_file_exists"));
assert_eq!(arr[0]["value"], serde_json::json!(false));
assert_eq!(arr[1]["id"], serde_json::json!("n_py"));
assert_eq!(arr[1]["kind"], serde_json::json!("count_files"));
assert_eq!(arr[1]["value"], serde_json::json!(5));
}
#[test]
fn json_render_empty_list_is_empty_array_not_null() {
let out = render_to_string(|w| render_facts_json(&[], &FactValues::new(), w));
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["facts"], serde_json::json!([]));
}
#[test]
fn json_render_missing_value_becomes_null() {
let facts = vec![fact_spec("orphan", any_file_exists_kind("X"))];
let out = render_to_string(|w| render_facts_json(&facts, &FactValues::new(), w));
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["facts"][0]["value"], serde_json::Value::Null);
}
#[test]
fn render_facts_dispatches_on_format() {
let facts = vec![fact_spec("is_py", any_file_exists_kind("py"))];
let mut values = FactValues::new();
values.insert("is_py".into(), FactValue::Bool(true));
let human_out = render_to_string(|w| render_facts(&facts, &values, Format::Human, w));
assert!(human_out.contains("is_py"));
assert!(!human_out.contains("\"facts\""));
let json_out = render_to_string(|w| render_facts(&facts, &values, Format::Json, w));
assert!(json_out.contains("\"facts\""));
let sarif_out = render_to_string(|w| render_facts(&facts, &values, Format::Sarif, w));
assert!(sarif_out.contains("is_py"));
assert!(!sarif_out.contains("\"facts\""));
}
}