mod analyze;
mod args;
mod calibrate;
mod calibrate_defects;
mod check;
mod diff;
mod diff_output;
mod explain;
mod gate;
mod mcp;
mod suggest;
use std::io::Write;
use anyhow::{Context, Result};
use clap::Parser;
use codelore_lib::cli_api::{AnalysisName, CodeLoreError, Options};
use tracing_subscriber::EnvFilter;
use tracing_subscriber::fmt::format::FmtSpan;
use crate::args::{Cli, Command, DiffArgs, IngestSarifArgs, McpArgs};
fn main() {
if let Err(e) = run() {
if is_broken_pipe(&e) {
std::process::exit(0);
}
eprintln!("error: {e:#}");
let code = e
.chain()
.find_map(|cause| cause.downcast_ref::<codelore_lib::cli_api::CodeLoreError>())
.map_or(1, codelore_lib::cli_api::CodeLoreError::exit_code);
std::process::exit(code);
}
}
fn is_broken_pipe(err: &anyhow::Error) -> bool {
use std::io::ErrorKind::BrokenPipe;
err.chain().any(|cause| {
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
io.kind() == BrokenPipe
} else if let Some(cle) = cause.downcast_ref::<CodeLoreError>() {
matches!(
cle,
CodeLoreError::Io(io) | CodeLoreError::RepoIo(io) if io.kind() == BrokenPipe
)
} else {
false
}
})
}
fn run() -> Result<()> {
let cli = Cli::parse();
init_logging(cli.verbose);
match cli.command {
Command::Analyze(args) => analyze::analyze(&args, cli.no_banner),
Command::Diff(args) => run_diff_cmd(&args),
Command::Completions(args) => {
run_completions_cmd(&args);
Ok(())
}
Command::Explain(args) => explain::run_explain_cmd(&args),
Command::Schema(args) => run_schema_cmd(&args),
Command::Profile => run_profile_cmd(),
Command::Docs => run_docs_cmd(),
Command::Check(args) => check::run_check_cmd(&args),
Command::Gate(args) => gate::run_gate_cmd(&args),
Command::Mcp(args) => run_mcp_cmd(&args),
Command::IngestSarif(args) => run_ingest_sarif_cmd(&args),
Command::Calibrate(args) => calibrate::run_calibrate_cmd(&args),
Command::CalibrateDefects(args) => calibrate_defects::run_calibrate_defects_cmd(&args),
}
}
fn run_mcp_cmd(args: &McpArgs) -> Result<()> {
mcp::run_mcp_server(
args.repo.clone(),
args.defect_calibration.clone(),
args.allow_foreign_calibration,
)
}
fn run_ingest_sarif_cmd(args: &IngestSarifArgs) -> Result<()> {
use codelore_lib::cli_api::cache::default_cache_root;
use codelore_lib::external::{
ExternalStore, group_findings_by_engine, parse_sarif_with_engines,
};
let cache_root = args.cache_dir.clone().unwrap_or_else(default_cache_root);
let store = ExternalStore::open_or_create(&cache_root, &args.repo)
.context("open external findings store")?;
let mut all_findings = Vec::new();
let mut all_engines: Vec<String> = Vec::new();
for path in &args.file {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read SARIF file {}", path.display()))?;
let (findings, engines) = parse_sarif_with_engines(&raw)
.with_context(|| format!("parse SARIF file {}", path.display()))?;
all_findings.extend(findings);
for engine in engines {
if !all_engines.contains(&engine) {
all_engines.push(engine);
}
}
}
let mut by_engine = group_findings_by_engine(all_findings);
for engine in all_engines {
by_engine.entry(engine).or_default();
}
let engine_count = by_engine.len();
let mut total_ingested: usize = 0;
for (engine, findings) in &by_engine {
let n = store
.replace_engine(engine, findings)
.with_context(|| format!("ingest findings for engine {engine}"))?;
total_ingested += n;
}
println!(
"ingested {} finding(s) from {} engine(s) → {}",
total_ingested,
engine_count,
store.path().display(),
);
Ok(())
}
pub(crate) fn notice_corpus_lens_absent(opts: &Options, quiet: bool) {
use std::io::IsTerminal as _;
if quiet || !std::io::stderr().is_terminal() {
return;
}
let embedded_absent = codelore_lib::cli_api::calibration::embedded_world().is_none();
if opts.calibration.is_none() && embedded_absent {
eprintln!(
"note: corpus-percentile lens inactive — no calibration artifact (pass --calibration <path> or build one with `codelore calibrate`)."
);
}
}
pub(crate) const GATE_DELTA_TABLE_ROWS: usize = 10;
pub(crate) const GATE_FINDINGS_ROWS: usize = 10;
pub(crate) const GATE_VIOLATION_ROWS: usize = 10;
pub(crate) fn write_github_output(key: &str, value: &str) {
let Ok(path) = std::env::var("GITHUB_OUTPUT") else {
return;
};
let mut f = match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
Ok(f) => f,
Err(e) => {
tracing::warn!("write_github_output: could not open {path}: {e}");
return;
}
};
let line = format!("{key}={value}\n");
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::warn!("write_github_output: write failed for key={key}: {e}");
}
}
pub(crate) fn vacuous_pass_notice(command: &str) -> String {
format!(
"codelore {command}: no thresholds configured (no `.codelore-thresholds.toml` at repo \
root); vacuously passing. Add a [gates] section with code_health_min / \
max_dependency_cycles / max_red_effort_pct to make it bind on regressions."
)
}
pub(crate) fn skipped_gate_violations(
records: &[codelore_lib::cli_api::quality_gates::ledger::GateRunRecord],
fail_on_skipped: bool,
) -> Vec<codelore_lib::cli_api::quality_gates::GateViolation> {
use codelore_lib::cli_api::quality_gates::GateViolation;
if !fail_on_skipped {
return Vec::new();
}
records
.iter()
.filter(|r| r.verdict == "skipped")
.map(|r| GateViolation {
gate: r.gate.clone(),
path: "(skipped)".into(),
actual: "skipped".into(),
threshold: "fail_on_skipped".into(),
})
.collect()
}
pub(crate) fn new_code_skip_reason(window_days: f64, shallow_checkout: bool) -> String {
if shallow_checkout {
format!(
"the checkout is shallow (fetch-depth): its history is truncated to within the \
{window_days:.0}-day window, so there is no pre-window baseline to contrast the \
working set against. This is the checkout, not the repository — re-run against \
full history (fetch-depth: 0)."
)
} else {
format!(
"the repository's history is shallower than the {window_days:.0}-day window (a \
genuinely young repository), so there is no pre-window baseline to contrast the \
working set against."
)
}
}
pub(crate) const CORPUS_PERCENTILE_SKIP_REASON: &str = "no corpus percentile data (no calibration artifact active, or no analyzed file resolved a percentile)";
fn human_bytes(n: u64) -> String {
const MIB: u64 = 1024 * 1024;
const GIB: u64 = 1024 * MIB;
if n >= GIB {
format!("{}.{} GiB", n / GIB, (n % GIB) * 10 / GIB)
} else {
format!("{}.{} MiB", n / MIB, (n % MIB) * 10 / MIB)
}
}
fn run_profile_cmd() -> Result<()> {
use codelore_lib::cli_api::analysis::AnalysisName;
let mut out = std::io::stdout().lock();
writeln!(out, "# CodeLore profile\n")?;
writeln!(out, "**Version**: {}", env!("CARGO_PKG_VERSION"))?;
writeln!(
out,
"**Schema**: schema_v{} (`facts/schema_v1.sql`)",
codelore_lib::cli_api::facts::schema::CURRENT_SCHEMA_VERSION
)?;
writeln!(
out,
"**Analyses**: {} registered",
AnalysisName::all().len()
)?;
writeln!(
out,
"**Output formats**: {}",
args::analyze_format_names().join(" | ")
)?;
writeln!(
out,
"**Pinned third-party**:\n - gix {gix}\n - DuckDB {duckdb}\n - tree-sitter 0.25.x (Rust/Python/Java/JS/TS/TSX/C++)",
gix = codelore_lib::cli_api::provenance::GIX_VERSION,
duckdb = codelore_lib::cli_api::provenance::DUCKDB_VERSION,
)?;
let cache_root = codelore_lib::cli_api::cache::default_cache_root();
writeln!(out, "\n**Cache root**:")?;
writeln!(out, " {}/codelore/", cache_root.display())?;
writeln!(
out,
"**Cache size**: {} / {} cap ({} fact stores kept per repository, oldest evicted first)",
human_bytes(codelore_lib::cli_api::cache::cached_bytes(&cache_root)),
human_bytes(codelore_lib::cli_api::cache::GLOBAL_CACHE_MAX_BYTES),
codelore_lib::cli_api::cache::MAX_REPO_CACHE_ENTRIES,
)?;
writeln!(
out,
"\n**SPA feature**: {}",
if cfg!(feature = "spa") {
"ENABLED"
} else {
"disabled (build with --features spa to opt in)"
}
)?;
writeln!(
out,
"\n_For per-analysis SQL + citations, run `codelore explain <topic>`._"
)?;
Ok(())
}
fn run_docs_cmd() -> Result<()> {
use codelore_lib::cli_api::analysis::AnalysisName;
let mut out = std::io::stdout().lock();
writeln!(out, "# CodeLore — Analysis catalogue\n")?;
writeln!(
out,
"Auto-generated from `AnalysisName::all()`. Run `codelore explain <topic>` for per-analysis citations and formulas. The full citation chain lives in `docs/research-foundations.md`.\n"
)?;
writeln!(out, "## Supported analyses\n")?;
for analysis in AnalysisName::all() {
writeln!(out, "- `{}`", analysis.as_str())?;
}
writeln!(out, "\n## Output formats\n")?;
for (name, description) in args::ANALYZE_FORMATS {
writeln!(out, "- `{name}` — {description}")?;
}
writeln!(out, "\n## Conventions\n")?;
writeln!(
out,
"- Files alive at HEAD only (deleted files excluded from path-aggregating analyses)"
)?;
writeln!(
out,
"- Mailmap + `.codelore-teams` + `.codelorebots` consulted at ingest time"
)?;
writeln!(out, "- `.gitignore` / `.codeloreignore` honoured")?;
writeln!(
out,
"- `--time-bucket` supported on: hotspots, coupling, soc, code-health"
)?;
writeln!(
out,
"\n## Reproducibility\n\nEvery file output is paired with a `.provenance.json` sidecar capturing the run's full `Options` shape. SQLite outputs embed the equivalent inside the `provenance` table."
)?;
writeln!(
out,
"\n_See also: `codelore profile` for operational telemetry, `codelore schema <type>` for row schemas, `docs/research-foundations.md` for citations._"
)?;
Ok(())
}
fn run_completions_cmd(args: &args::CompletionsArgs) {
use clap::CommandFactory;
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
clap_complete::generate(args.shell, &mut cmd, bin_name, &mut std::io::stdout());
}
fn run_schema_cmd(args: &args::SchemaArgs) -> Result<()> {
let row_types: Vec<&str> = AnalysisName::all().iter().map(|a| a.as_str()).collect();
let mut out = std::io::stdout().lock();
match &args.row_type {
None => {
writeln!(out, "Supported row types ({}):", row_types.len())?;
for name in &row_types {
writeln!(out, " {name}")?;
}
writeln!(
out,
"\nUsage: codelore schema <row-type>\n\nNote: today's emitter ships the row-type catalogue and a minimal envelope. The full JSON Schema documents populate the `items` shape once `schemars` derive is applied to every analyses/* row type."
)?;
Ok(())
}
Some(name) => {
if row_types.contains(&name.as_str()) {
writeln!(
out,
"{{\n \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n \"$id\": \"https://codelore.dev/schemas/{name}.json\",\n \"title\": \"{name}\",\n \"type\": \"array\",\n \"items\": {{\n \"$comment\": \"Full row-shape schema populates once schemars derive is applied.\"\n }}\n}}"
)?;
Ok(())
} else {
let hint = suggest::nearest(name, row_types.iter().copied())
.map(|s| format!(" (did you mean `{s}`?)"))
.unwrap_or_default();
Err(CodeLoreError::Analysis(format!(
"unknown row type `{name}`{hint} — run `codelore schema` (no arg) to list supported row types"
))
.into())
}
}
}
}
fn run_diff_cmd(args: &DiffArgs) -> Result<()> {
let (output, head_db, head_opts) = diff::run_diff(args).context("codelore diff")?;
let format = args.format.as_str();
let narrative: Option<(String, String)> = if args.llm {
match format {
"text" | "markdown" => diff_llm_narrative(args, &output),
_ => {
eprintln!("note: --llm applies to text/markdown output only; ignored for {format}");
None
}
}
} else {
None
};
let mut out: Box<dyn Write> = match args.output.as_ref() {
Some(path) => Box::new(std::fs::File::create(path)?),
None => Box::new(std::io::stdout().lock()),
};
diff_output::emit(
&mut out,
&output,
format,
&args.repo,
Some((&head_db, &head_opts)),
narrative,
)?;
drop(out);
if diff::should_fail(args, &output) {
std::process::exit(1);
}
Ok(())
}
fn diff_llm_narrative(args: &DiffArgs, output: &diff::DiffOutput) -> Option<(String, String)> {
use codelore_lib::cli_api::cache::default_cache_root;
use codelore_lib::cli_api::enrichment::client::{LlmEnv, resolve_client};
use codelore_lib::cli_api::enrichment::engine;
use codelore_lib::cli_api::enrichment::fact_sheet::DiffFactSheet;
use codelore_lib::cli_api::enrichment::prompt::Lens;
let sheet = DiffFactSheet::from_sections(diff_fact_sections(output));
let canonical = sheet.to_canonical_text();
let values = sheet.numeric_values();
let cache_root = default_cache_root();
let result = resolve_client(&LlmEnv::from_process_env()).and_then(|client| {
engine::narrate(
client.as_ref(),
Lens::DiffNarrative,
"diff",
engine::SheetFacts {
text: &canonical,
values: &values,
},
&cache_root,
&args.repo,
args.llm_refresh,
)
});
match result {
Ok(result) => Some((result.narrative.clone(), engine::stamp(&result))),
Err(e) => {
eprintln!("warning: llm narrative unavailable: {e}");
None
}
}
}
fn diff_fact_sections(output: &diff::DiffOutput) -> Vec<(String, Vec<(String, String)>)> {
use codelore_lib::cli_api::enrichment::fact_sheet::fmt_num;
let mut sections: Vec<(String, Vec<(String, String)>)> = Vec::new();
if let Some(dh) = &output.delta_health {
let mut facts = vec![("verdict".to_string(), dh.verdict.clone())];
if let Some(ratio) = dh.ratio {
facts.push(("ratio".to_string(), fmt_num(ratio)));
}
facts.push(("added".to_string(), dh.counts.added.to_string()));
facts.push(("modified".to_string(), dh.counts.modified.to_string()));
facts.push(("removed".to_string(), dh.counts.removed.to_string()));
facts.push(("skipped".to_string(), dh.counts.skipped.to_string()));
sections.push(("verdict".to_string(), facts));
}
if !output.gate_violations.is_empty() {
let mut facts = Vec::new();
for (i, v) in output.gate_violations.iter().enumerate() {
let n = i + 1;
facts.push((format!("{n}.gate"), v.gate.clone()));
facts.push((format!("{n}.path"), v.path.clone()));
facts.push((format!("{n}.actual"), v.actual.clone()));
facts.push((format!("{n}.threshold"), v.threshold.clone()));
}
sections.push(("gates".to_string(), facts));
}
if !output.hotspots.rank_entrants.is_empty() {
let mut facts = Vec::new();
for (i, h) in output.hotspots.rank_entrants.iter().enumerate() {
let n = i + 1;
facts.push((format!("{n}.path"), h.path.clone()));
facts.push((format!("{n}.hotspot_score"), fmt_num(h.hotspot_score)));
facts.push((format!("{n}.revisions"), h.revisions.to_string()));
facts.push((format!("{n}.cognitive"), fmt_num(h.cognitive)));
facts.push((format!("{n}.cognitive_health"), fmt_num(h.cognitive_health)));
}
sections.push(("entrants".to_string(), facts));
}
if !output.hotspots.score_increased.is_empty() {
let mut facts = Vec::new();
for (i, s) in output.hotspots.score_increased.iter().enumerate() {
let n = i + 1;
facts.push((format!("{n}.path"), s.path.clone()));
facts.push((format!("{n}.base_score"), fmt_num(s.base_score)));
facts.push((format!("{n}.head_score"), fmt_num(s.head_score)));
facts.push((format!("{n}.delta"), fmt_num(s.delta)));
}
sections.push(("score-increased".to_string(), facts));
}
if !output.coupling_absences.is_empty() {
let mut facts = Vec::new();
for (i, a) in output.coupling_absences.iter().enumerate() {
let n = i + 1;
facts.push((format!("{n}.touched_file"), a.touched_file.clone()));
facts.push((format!("{n}.expected_partner"), a.expected_partner.clone()));
facts.push((
format!("{n}.historical_coupling"),
fmt_num(a.historical_coupling),
));
facts.push((format!("{n}.fisher_p"), fmt_num(a.fisher_p)));
facts.push((
format!("{n}.historical_shared_revs"),
a.historical_shared_revs.to_string(),
));
}
sections.push(("absences".to_string(), facts));
}
if !output.clones.new_families.is_empty() {
let mut facts = Vec::new();
for (i, c) in output.clones.new_families.iter().enumerate() {
let n = i + 1;
facts.push((format!("{n}.clone_group_id"), c.clone_group_id.to_string()));
facts.push((format!("{n}.entity"), c.entity.clone()));
facts.push((format!("{n}.function"), c.function.clone()));
facts.push((format!("{n}.start_line"), c.start_line.to_string()));
facts.push((format!("{n}.end_line"), c.end_line.to_string()));
facts.push((format!("{n}.node_count"), c.node_count.to_string()));
}
sections.push(("clones".to_string(), facts));
}
sections
}
fn init_logging(verbose: bool) {
let filter = if verbose {
EnvFilter::new("info,codelore=debug")
} else {
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"))
};
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_span_events(FmtSpan::CLOSE)
.init();
}
#[cfg(test)]
mod tests {
use super::is_broken_pipe;
use anyhow::Context as _;
use codelore_lib::cli_api::CodeLoreError;
use std::io::{Error as IoError, ErrorKind};
#[test]
fn detects_bare_broken_pipe() {
let err = anyhow::Error::new(IoError::from(ErrorKind::BrokenPipe));
assert!(is_broken_pipe(&err));
}
#[test]
fn detects_broken_pipe_behind_context() {
let err = std::result::Result::<(), _>::Err(IoError::from(ErrorKind::BrokenPipe))
.context("write csv")
.unwrap_err();
assert!(is_broken_pipe(&err));
}
#[test]
fn detects_broken_pipe_wrapped_in_codelore_io() {
let err = anyhow::Error::new(CodeLoreError::Io(IoError::from(ErrorKind::BrokenPipe)));
assert!(is_broken_pipe(&err));
}
#[test]
fn ignores_other_io_kinds() {
let err = anyhow::Error::new(CodeLoreError::Io(IoError::from(
ErrorKind::PermissionDenied,
)));
assert!(!is_broken_pipe(&err));
}
#[test]
fn ignores_unrelated_errors() {
let err = anyhow::Error::new(CodeLoreError::Analysis("boom".into()));
assert!(!is_broken_pipe(&err));
}
}