use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use anyhow::Result;
use rmcp::{
handler::server::wrapper::{Json, Parameters},
model::ErrorData,
tool, tool_handler, tool_router,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use tokio::sync::Semaphore;
use codelore_lib::CodeLoreError;
use codelore_lib::change_context;
use codelore_lib::change_set;
use codelore_lib::cli_api::{
Options,
analyses::{
code_health,
delta_health::{DeltaHealthSection, compute_delta_health, run_function_metrics},
finding_hotspot_overlap, function_xray, hotspots, refactoring_targets, summary,
},
cache::default_cache_root,
enrichment::{
client::{LlmEnv, resolve_client},
engine,
fact_sheet::FileFactSheet,
prompt::Lens,
},
external::ExternalStore,
facts::FactsDb,
quality_gates::{
GateViolation, Gates, Thresholds, evaluate_clone_gate, evaluate_code_health_gate,
evaluate_corpus_percentile_rows, evaluate_full_tree, evaluate_gate_thresholds,
resolve_defect_calibration,
},
repo::{GixRepo, Repo as _},
};
use codelore_lib::complexity::Tier1Language;
use codelore_lib::defect_calibration;
fn internal(e: impl std::fmt::Display) -> ErrorData {
ErrorData::internal_error(e.to_string(), None)
}
fn map_lib_err(e: &CodeLoreError) -> ErrorData {
if e.exit_code() == 2 {
ErrorData::invalid_params(e.to_string(), None)
} else {
ErrorData::internal_error(e.to_string(), None)
}
}
const DEFAULT_ROW_CAP: usize = 50;
const MAX_ROW_CAP: usize = 500;
fn resolve_row_cap(limit: Option<u32>) -> usize {
limit.map_or(DEFAULT_ROW_CAP, |n| (n as usize).clamp(1, MAX_ROW_CAP))
}
fn serialize_capped_rows<T: Serialize>(
shown: &[T],
total: usize,
note: &str,
) -> std::result::Result<String, ErrorData> {
let omitted = total.saturating_sub(shown.len());
if omitted == 0 {
return serde_json::to_string(shown).map_err(internal);
}
let mut arr = shown
.iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(internal)?;
arr.push(serde_json::json!({
"omitted": omitted,
"total": total,
"note": note,
}));
serde_json::to_string(&arr).map_err(internal)
}
fn require_tracked_path(repo: &GixRepo, path: &str) -> std::result::Result<(), ErrorData> {
match repo
.read_blob_at("HEAD", path)
.map_err(|e| map_lib_err(&e))?
{
Some(_) => Ok(()),
None => Err(ErrorData::invalid_params(
format!(
"path not found among files tracked at HEAD: {path:?} — paths are \
repo-relative; try repo_overview or hotspots to list analyzed files"
),
None,
)),
}
}
fn resolve_rev(repo: &Path, rev: &str) -> std::result::Result<String, ErrorData> {
let out = Command::new("git")
.args([
"-C",
repo.to_str().unwrap_or("."),
"rev-parse",
"--verify",
rev,
])
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| ErrorData::internal_error(format!("git rev-parse: {e}"), None))?;
if out.status.success() {
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
} else {
Err(ErrorData::invalid_params(
format!("revision {rev:?} not found in this repository"),
None,
))
}
}
fn temp_worktree(
repo: &Path,
sha: &str,
) -> std::result::Result<(PathBuf, TempWorktree), ErrorData> {
let dir = tempfile::tempdir().map_err(|e| internal(format!("create temp dir: {e}")))?;
let wt_path = dir.path().to_path_buf();
let wt_path_str = wt_path.to_str().ok_or_else(|| {
internal(format!(
"worktree temp path is not valid UTF-8: {}",
wt_path.display()
))
})?;
let out = Command::new("git")
.args([
"-C",
repo.to_str().unwrap_or("."),
"worktree",
"add",
"--detach",
"--quiet",
wt_path_str,
sha,
])
.stdin(std::process::Stdio::null())
.output()
.map_err(|e| internal(format!("git worktree add: {e}")))?;
if !out.status.success() {
return Err(internal(format!(
"git worktree add failed for {sha}: {}",
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok((
wt_path,
TempWorktree {
repo: repo.to_path_buf(),
dir,
},
))
}
struct TempWorktree {
repo: PathBuf,
dir: tempfile::TempDir,
}
impl Drop for TempWorktree {
fn drop(&mut self) {
let path = self.dir.path().to_str().unwrap_or("").to_string();
let _ = Command::new("git")
.args([
"-C",
self.repo.to_str().unwrap_or("."),
"worktree",
"remove",
"--force",
&path,
])
.stdin(std::process::Stdio::null())
.output();
}
}
const MEMO_CAPACITY: usize = 512;
fn memo_key(tool: &str, params_json: &str) -> String {
format!("{tool}\u{1f}{params_json}")
}
fn calibration_key_fragment(path: Option<&Path>) -> String {
let Some(md) = path.and_then(|p| std::fs::metadata(p).ok()) else {
return "cal=none".to_string();
};
let mtime = md
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map_or(0, |d| d.as_nanos());
format!("cal={}:{mtime}", md.len())
}
#[derive(Default)]
struct ResultMemo {
state: Mutex<MemoState>,
}
#[derive(Default)]
struct MemoState {
head: String,
entries: HashMap<String, String>,
}
impl ResultMemo {
fn get(&self, head: &str, key: &str) -> Option<String> {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.head != head {
state.entries.clear();
head.clone_into(&mut state.head);
return None;
}
state.entries.get(key).cloned()
}
fn put(&self, head: &str, key: String, value: String) {
let mut state = self
.state
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if state.head != head {
return;
}
if state.entries.len() >= MEMO_CAPACITY && !state.entries.contains_key(&key) {
state.entries.clear();
}
state.entries.insert(key, value);
}
}
fn memoized<F>(
memo: &ResultMemo,
repo_path: &Path,
tool: &str,
params_json: &str,
compute: F,
) -> std::result::Result<String, ErrorData>
where
F: FnOnce(&GixRepo, &str) -> std::result::Result<String, ErrorData>,
{
let repo = GixRepo::open(repo_path).map_err(|e| map_lib_err(&e))?;
let head = repo.head_sha().map_err(|e| map_lib_err(&e))?;
let key = memo_key(tool, params_json);
if let Some(hit) = memo.get(&head, &key) {
return Ok(hit);
}
let out = compute(&repo, head.as_str())?;
memo.put(&head, key, out.clone());
Ok(out)
}
const MAX_CONCURRENT_CALLS: usize = 4;
#[derive(Clone)]
pub struct CodeLoreServer {
repo: PathBuf,
defect_calibration: Option<PathBuf>,
allow_foreign_calibration: bool,
memo: Arc<ResultMemo>,
limit: Arc<Semaphore>,
}
impl CodeLoreServer {
async fn blocking<T, W>(&self, work: W) -> Result<T, ErrorData>
where
W: FnOnce() -> Result<T, ErrorData> + Send + 'static,
T: Send + 'static,
{
let permit = Arc::clone(&self.limit)
.acquire_owned()
.await
.map_err(internal)?;
tokio::task::spawn_blocking(move || {
let _permit = permit;
work()
})
.await
.map_err(internal)?
}
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)]
pub struct RepoOverviewParams {}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)]
pub struct HotspotsParams {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)]
pub struct CodeHealthParams {
pub path: Option<String>,
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct DeltaHealthParams {
pub base: String,
pub head: String,
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema, Default)]
pub struct RefactoringTargetsParams {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct FunctionXrayParams {
pub path: String,
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct CheckGatesParams {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct FindingHotspotOverlapParams {
pub limit: Option<u32>,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ExplainFileParams {
pub path: String,
}
#[derive(Debug, Deserialize, Serialize, JsonSchema)]
pub struct ChangeContextParams {
pub paths: Vec<String>,
}
#[derive(Debug, Deserialize, JsonSchema, Default)]
pub struct GateChangesParams {}
#[derive(Debug, Serialize, JsonSchema)]
struct GateSummary {
verdict: String,
violation_count: usize,
violations: Vec<crate::diff::GateViolationOut>,
skipped_gates: Vec<SkippedGate>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct SkippedGate {
gate: &'static str,
reason: String,
}
fn skipped_check_gates(thresholds: &Thresholds) -> Vec<SkippedGate> {
const EVALUATED_HERE: &[&str] = &[
"cognitive_max",
"hotspot_score_max",
"code_health_min",
"disallow_clone_type_1",
"max_dependency_cycles",
"max_propagation_cost",
"max_red_effort_pct",
"code_familiarity_min",
"corpus_percentile_max",
];
let Gates {
cognitive_max,
code_health_min,
hotspot_score_max,
hotspot_anchored_max,
disallow_clone_type_1,
max_dependency_cycles,
max_propagation_cost,
max_red_effort_pct,
code_familiarity_min,
max_findings_in_hot_files,
corpus_percentile_max,
fail_on_degraded,
fail_on_skipped,
red_effort_exempt_improving: _,
} = &thresholds.gates;
let configured: [(&'static str, bool); 13] = [
("cognitive_max", cognitive_max.is_some()),
("hotspot_score_max", hotspot_score_max.is_some()),
("hotspot_anchored_max", hotspot_anchored_max.is_some()),
("code_health_min", code_health_min.is_some()),
("disallow_clone_type_1", *disallow_clone_type_1),
("max_dependency_cycles", max_dependency_cycles.is_some()),
("max_propagation_cost", max_propagation_cost.is_some()),
("max_red_effort_pct", max_red_effort_pct.is_some()),
("code_familiarity_min", code_familiarity_min.is_some()),
(
"max_findings_in_hot_files",
max_findings_in_hot_files.is_some(),
),
("corpus_percentile_max", corpus_percentile_max.is_some()),
("fail_on_degraded", *fail_on_degraded),
("fail_on_skipped", *fail_on_skipped),
];
configured
.into_iter()
.filter(|(name, set)| *set && !EVALUATED_HERE.contains(name))
.map(|(gate, _)| SkippedGate {
gate,
reason: structural_skip_reason(gate).to_owned(),
})
.collect()
}
fn structural_skip_reason(gate: &str) -> &'static str {
match gate {
"max_findings_in_hot_files" => {
"reads the external-findings sidecar, which is `codelore check`-only \
(run `codelore ingest-sarif`, then `codelore check`)"
}
"hotspot_anchored_max" => {
"depends on the calibration-corpus lens, which this tool does not carry \
(it uses the plain, unanchored hotspot scan); `codelore check` is authoritative"
}
"fail_on_degraded" => "degraded-gate handling is `codelore check`-only",
"fail_on_skipped" => "skipped-gate handling is `codelore check` / `gate` / `diff`-only",
_ => "evaluated only by `codelore check`, which is authoritative for it",
}
}
#[tool_router]
impl CodeLoreServer {
#[tool(
name = "repo_overview",
annotations(read_only_hint = true, open_world_hint = false),
description = "Return a JSON object with `summary` (commit count, authors, files, date range) \
and `options` (the active analysis options snapshot used for cache-keying). \
First call on a cold cache triggers history ingest; warm-cache calls are fast."
)]
async fn repo_overview(
&self,
params: Parameters<RepoOverviewParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let params_json = serde_json::to_string(¶ms.0).map_err(internal)?;
self.blocking(move || {
memoized(
&memo,
&repo_path,
"repo_overview",
¶ms_json,
|repo, head| {
let opts = Options {
repo_path: repo_path.clone(),
..Options::default()
};
let db =
FactsDb::open_or_ingest_with_cache_root(&opts, repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(head)
.map_err(|e| map_lib_err(&e))?;
let rows = summary::run_summary(&db, &opts).map_err(|e| map_lib_err(&e))?;
let out = serde_json::json!({
"summary": rows,
"options": opts.canonical_json(),
});
serde_json::to_string(&out).map_err(internal)
},
)
})
.await
}
#[tool(
name = "hotspots",
annotations(read_only_hint = true, open_world_hint = false),
description = "Return the top hotspot files ranked by revision count as JSON. \
Pass `limit` to cap rows (default 50, max 500). \
First call on a cold cache triggers history ingest."
)]
async fn hotspots(&self, params: Parameters<HotspotsParams>) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let cap = resolve_row_cap(params.0.limit);
let params_json = serde_json::to_string(¶ms.0).map_err(internal)?;
self.blocking(move || {
memoized(&memo, &repo_path, "hotspots", ¶ms_json, |repo, head| {
let opts = Options {
repo_path: repo_path.clone(),
..Options::default()
};
let db =
FactsDb::open_or_ingest_with_cache_root(&opts, repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(head)
.map_err(|e| map_lib_err(&e))?;
let rows = hotspots::run_hotspots(&db, &opts).map_err(|e| map_lib_err(&e))?;
let total = rows.len();
let shown = &rows[..total.min(cap)];
serialize_capped_rows(
shown,
total,
"hotspot rows beyond the row cap were omitted; raise `limit` to see more",
)
})
})
.await
}
#[tool(
name = "code_health",
annotations(read_only_hint = true, open_world_hint = false),
description = "Return per-file composite code-health scores (band: red/yellow/green, score 0–100) as JSON. \
Pass `path` to filter to a single file; an unknown path returns an error naming it, \
not an empty result. Otherwise the list is worst-health first, capped by `limit` \
(default 50, max 500), with a trailing `{omitted, total, note}` summary object when rows \
are suppressed. \
First call on a cold cache triggers history ingest."
)]
async fn code_health(&self, params: Parameters<CodeHealthParams>) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let filter_path = params.0.path.clone();
let cap = resolve_row_cap(params.0.limit);
let params_json = serde_json::to_string(¶ms.0).map_err(internal)?;
self.blocking(move || {
memoized(&memo, &repo_path, "code_health", ¶ms_json, |repo, head| {
let opts = Options {
repo_path: repo_path.clone(),
..Options::default()
};
if let Some(p) = &filter_path {
require_tracked_path(repo, p)?;
}
let db =
FactsDb::open_or_ingest_with_cache_root(&opts, repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(head).map_err(|e| map_lib_err(&e))?;
let mut rows =
code_health::run_code_health(&db, &opts).map_err(|e| map_lib_err(&e))?;
if let Some(p) = &filter_path {
rows.retain(|r| &r.path == p);
return serde_json::to_string(&rows).map_err(internal);
}
let total = rows.len();
rows.truncate(cap);
serialize_capped_rows(
&rows,
total,
"worst-health files first; raise limit (max 500) or pass a path for the rest",
)
})
})
.await
}
#[tool(
name = "delta_health",
// Not read-only: this is the one tool that writes outside the cache,
// checking each rev out into a throwaway `git worktree` it then
// removes. Additive and repeatable, hence the two hints below.
annotations(
read_only_hint = false,
destructive_hint = false,
idempotent_hint = true,
open_world_hint = false
),
description = "Return a function-level health delta between two revisions as JSON. \
`base` and `head` are any rev-parse-able strings (branch, tag, SHA). \
Returns verdict (improved/neutral/degraded), ratio, and per-function breakdown. \
This is a simplified subset of `codelore diff`: clone-group membership and \
base red-file context are not scored here — run `codelore diff` for the full report. \
Pass `limit` to cap the per-function rows (default 50, max 500); an `omitted_functions` \
count is added when rows are suppressed. \
Cost: ingests history twice (once per rev); expect 5–30 s on a cold cache."
)]
async fn delta_health(
&self,
params: Parameters<DeltaHealthParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let base_rev = params.0.base.clone();
let head_rev = params.0.head.clone();
let cap = resolve_row_cap(params.0.limit);
let base_sha = resolve_rev(&repo_path, &base_rev)?;
let head_sha = resolve_rev(&repo_path, &head_rev)?;
if base_sha == head_sha {
return Err(ErrorData::invalid_params(
format!("base and head both resolve to {base_sha}; nothing to diff"),
None,
));
}
self.blocking(move || {
let main_repo = GixRepo::open(&repo_path).map_err(|e| map_lib_err(&e))?;
let head = main_repo.head_sha().map_err(|e| map_lib_err(&e))?;
let key = memo_key(
"delta_health",
&format!("base={base_sha}\u{1f}head={head_sha}\u{1f}limit={cap}"),
);
if let Some(hit) = memo.get(&head, &key) {
return Ok(hit);
}
let (base_path, _base_wt) = temp_worktree(&repo_path, &base_sha)?;
let (head_path, _head_wt) = temp_worktree(&repo_path, &head_sha)?;
let ingest_at = |wt: &Path,
sha: &str|
-> std::result::Result<
Vec<codelore_lib::cli_api::analyses::delta_health::FunctionMetricRow>,
ErrorData,
> {
let opts = Options {
repo_path: wt.to_path_buf(),
..Options::default()
};
let repo = GixRepo::open(wt).map_err(|e| map_lib_err(&e))?;
let db = FactsDb::new_in_memory().map_err(|e| map_lib_err(&e))?;
db.ingest(&repo, &opts).map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(sha)
.map_err(|e| map_lib_err(&e))?;
run_function_metrics(&db).map_err(|e| map_lib_err(&e))
};
let base_fns = ingest_at(&base_path, &base_sha)?;
let head_fns = ingest_at(&head_path, &head_sha)?;
let pr_files: HashSet<String> = base_fns
.iter()
.chain(head_fns.iter())
.map(|r| r.path.clone())
.collect();
let clone_members: HashSet<(String, String)> = HashSet::new();
let base_red: HashSet<String> = HashSet::new();
let mut section: DeltaHealthSection =
compute_delta_health(&base_fns, &head_fns, &pr_files, &clone_members, &base_red);
let total_fns = section.functions.len();
let omitted_fns = total_fns.saturating_sub(cap);
if omitted_fns > 0 {
section.functions.truncate(cap);
}
let mut value = serde_json::to_value(§ion).map_err(internal)?;
if omitted_fns > 0 {
value["omitted_functions"] = serde_json::json!(omitted_fns);
}
let out = serde_json::to_string(&value).map_err(internal)?;
memo.put(&head, key, out.clone());
Ok(out)
})
.await
}
#[tool(
name = "refactoring_targets",
annotations(read_only_hint = true, open_world_hint = false),
description = "Return the highest-priority refactoring candidates ranked by risk÷LOC as JSON. \
Pass `limit` to cap rows (default 50, max 500); a trailing `{omitted, total, note}` \
summary object discloses any suppressed rows. \
First call on a cold cache triggers history ingest."
)]
async fn refactoring_targets(
&self,
params: Parameters<RefactoringTargetsParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let cap = resolve_row_cap(params.0.limit);
let params_json = serde_json::to_string(¶ms.0).map_err(internal)?;
self.blocking(move || {
memoized(
&memo,
&repo_path,
"refactoring_targets",
¶ms_json,
|repo, head| {
let opts = Options {
repo_path: repo_path.clone(),
..Options::default()
};
let db =
FactsDb::open_or_ingest_with_cache_root(&opts, repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(head).map_err(|e| map_lib_err(&e))?;
let mut rows = refactoring_targets::run_refactoring_targets(&db, &opts)
.map_err(|e| map_lib_err(&e))?;
let total = rows.len();
rows.truncate(cap);
serialize_capped_rows(
&rows,
total,
"highest-priority refactor targets first; raise limit (max 500) for the rest",
)
},
)
})
.await
}
#[tool(
name = "function_xray",
annotations(read_only_hint = true, open_world_hint = false),
description = "Return per-function change-frequency and complexity for a file as a JSON array. \
`path` is the file path relative to the repo root (e.g. \"src/main.rs\"). \
A path not tracked at HEAD returns an error naming it, not an empty array; \
a tracked file in a language without function analysis returns a `{note}` object; \
a tracked source file with no parsed functions returns []. \
First call on a cold cache triggers history ingest."
)]
async fn function_xray(
&self,
params: Parameters<FunctionXrayParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let target = params.0.path.clone();
let params_json = serde_json::to_string(¶ms.0).map_err(internal)?;
self.blocking(move || {
memoized(&memo, &repo_path, "function_xray", ¶ms_json, |repo, head| {
let opts = Options {
repo_path: repo_path.clone(),
..Options::default()
};
require_tracked_path(repo, &target)?;
if Tier1Language::from_path(&target).is_none() {
let note = serde_json::json!({
"functions": [],
"note": format!(
"{target} is tracked but not a Tier-1 source file (function analysis \
covers Rust, Python, Java, JavaScript, TypeScript); no per-function \
breakdown available"
),
});
return serde_json::to_string(¬e).map_err(internal);
}
let db =
FactsDb::open_or_ingest_with_cache_root(&opts, repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(head).map_err(|e| map_lib_err(&e))?;
let rows = function_xray::run_function_xray(&db, repo, &opts, &target)
.map_err(|e| map_lib_err(&e))?;
serde_json::to_string(&rows).map_err(internal)
})
})
.await
}
#[tool(
name = "check_gates",
annotations(read_only_hint = true, open_world_hint = false),
description = "Evaluate `.codelore-thresholds.toml` quality gates at HEAD and return a JSON \
summary with verdict (pass/fail/no_thresholds), violation count, individual violations, \
and a `skipped_gates` array of `{gate, reason}` for every configured gate that produced \
no verdict — so an empty `violations` list is distinguishable from a gate that did not run. \
This tool evaluates a subset of `codelore check`: the `max_findings_in_hot_files` and \
`hotspot_anchored_max` gates, `--ratchet`, and degraded-gate handling remain check-only, \
so a config using those can make this verdict diverge — `codelore check` is authoritative. \
A configured `[new_code]` gate that finds no pre-window baseline (a young repository, or a \
shallow fetch-depth checkout) is reported here too, with a reason that names fetch-depth \
when the checkout is shallow. \
Returns `no_thresholds` verdict when no config file is found. \
The `violations` array is capped (default: 50, raise `limit` for more) while \
`violation_count` always reports the true total, so the verdict and the count \
are never affected by the cap. \
First call on a cold cache triggers history ingest."
)]
async fn check_gates(
&self,
params: Parameters<CheckGatesParams>,
) -> Result<Json<GateSummary>, ErrorData> {
let cap = resolve_row_cap(params.0.limit);
let repo_path = self.repo.clone();
let defect_calibration = self.defect_calibration.clone();
let allow_foreign_calibration = self.allow_foreign_calibration;
self.blocking(move || {
let thresholds = Thresholds::discover(&repo_path).map_err(|e| map_lib_err(&e))?;
if thresholds.is_empty() {
let summary = GateSummary {
verdict: "no_thresholds".into(),
violation_count: 0,
violations: Vec::new(),
skipped_gates: Vec::new(),
};
return Ok(Json(summary));
}
let opts = Options {
repo_path: repo_path.clone(),
defect_calibration,
allow_foreign_calibration,
..Options::default()
};
let repo = GixRepo::open(&repo_path).map_err(|e| map_lib_err(&e))?;
let db = FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
let head_sha = repo.head_sha().map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(&head_sha)
.map_err(|e| map_lib_err(&e))?;
let mut violations: Vec<GateViolation> = Vec::new();
let hs = hotspots::run_hotspots(&db, &opts).map_err(|e| map_lib_err(&e))?;
violations.extend(evaluate_full_tree(&thresholds, &hs));
let ch = code_health::run_code_health(&db, &opts).map_err(|e| map_lib_err(&e))?;
violations.extend(evaluate_code_health_gate(&thresholds, &ch));
let mut corpus_skip: Option<SkippedGate> = None;
if let Some(max) = thresholds.gates.corpus_percentile_max {
let has_calibration = ch.iter().any(|r| r.corpus_percentile.is_some());
if has_calibration {
violations.extend(evaluate_corpus_percentile_rows(max, &ch));
} else {
corpus_skip = Some(SkippedGate {
gate: "corpus_percentile_max",
reason: crate::CORPUS_PERCENTILE_SKIP_REASON.into(),
});
}
}
violations.extend(evaluate_clone_gate(&thresholds, &db).map_err(|e| map_lib_err(&e))?);
if let Some(max) = thresholds.gates.max_red_effort_pct {
use codelore_lib::cli_api::analyses::effort_exposure;
let exempt = thresholds.gates.red_effort_exempt_improving;
let no_limit = opts.with_no_row_limit();
let rows = if exempt {
effort_exposure::run_effort_exposure_decomposed(&db, &repo, &no_limit, &ch)
} else {
effort_exposure::run_effort_exposure_with_health(&db, &no_limit, &ch)
}
.map_err(|e| map_lib_err(&e))?;
violations.extend(
codelore_lib::cli_api::quality_gates::evaluate_effort_exposure_rows_exempt(
max, exempt, &rows,
),
);
}
let mut new_code_skip: Option<SkippedGate> = None;
if let Some(nc) = &thresholds.new_code {
use codelore_lib::cli_api::analyses::new_code;
let scope = new_code::run_new_code_scope(&db, &repo, &opts, nc.window_days, &ch)
.map_err(|e| map_lib_err(&e))?;
if scope.window_start_present {
violations.extend(
codelore_lib::cli_api::quality_gates::evaluate_new_code_rows(nc, &scope),
);
} else {
new_code_skip = Some(SkippedGate {
gate: "new_code",
reason: crate::new_code_skip_reason(
f64::from(nc.window_days),
repo.is_shallow(),
),
});
}
}
violations.extend(
codelore_lib::cli_api::quality_gates::evaluate_architecture_gate(&thresholds, &db)
.map_err(|e| map_lib_err(&e))?,
);
violations.extend(
codelore_lib::cli_api::quality_gates::evaluate_familiarity_gate(
&thresholds,
&db,
&opts,
)
.map_err(|e| map_lib_err(&e))?,
);
let verdict = if violations.is_empty() {
"pass"
} else {
"fail"
};
let mut skipped_gates = skipped_check_gates(&thresholds);
skipped_gates.extend(new_code_skip);
skipped_gates.extend(corpus_skip);
let violation_count = violations.len();
violations.truncate(cap);
let summary = GateSummary {
verdict: verdict.into(),
violation_count,
violations: violations.into_iter().map(Into::into).collect(),
skipped_gates,
};
Ok(Json(summary))
})
.await
}
#[tool(
name = "finding_hotspot_overlap",
annotations(read_only_hint = true, open_world_hint = false),
description = "Return the behavioral×static fusion table: external scanner findings \
joined with hotspot rank and code-health band, producing an `act-now` / `plan` / `note` \
priority for each flagged file. Requires a prior `codelore ingest-sarif` run to populate \
the external findings sidecar; returns a structured note when the sidecar is absent or empty. \
Rows are highest-priority first, capped by `limit` (default 50, max 500), with a trailing \
`{omitted, total, note}` summary object when rows are suppressed. \
Cost: warm-cache fast after ingest; does not trigger history re-ingest."
)]
async fn finding_hotspot_overlap(
&self,
params: Parameters<FindingHotspotOverlapParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let cap = resolve_row_cap(params.0.limit);
self.blocking(move || {
let cache_root = default_cache_root();
let Some(store) = ExternalStore::open_nonempty(&cache_root, &repo_path)
.map_err(|e| map_lib_err(&e))?
else {
let note = serde_json::json!({
"findings": [],
"note": "run codelore ingest-sarif first"
});
return serde_json::to_string(¬e).map_err(internal);
};
let opts = Options {
repo_path: repo_path.clone(),
..Options::default()
};
let repo = GixRepo::open(&repo_path).map_err(|e| map_lib_err(&e))?;
let db = FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &cache_root)
.map_err(|e| map_lib_err(&e))?;
let head_sha = repo.head_sha().map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(&head_sha)
.map_err(|e| map_lib_err(&e))?;
let mut rows = finding_hotspot_overlap::run_finding_hotspot_overlap(&db, &opts, &store)
.map_err(|e| map_lib_err(&e))?;
let total = rows.len();
rows.truncate(cap);
serialize_capped_rows(
&rows,
total,
"act-now findings first; raise limit (max 500) for the rest",
)
})
.await
}
#[tool(
name = "explain_file",
// Open-world: the advisory narrative is off by default, but when
// CODELORE_LLM_* is configured this tool calls that endpoint.
annotations(read_only_hint = true, open_world_hint = true),
description = "Return a deterministic per-file evidence dossier for one repo-relative file \
path. `fact_sheet` is always present: the ordered analysis sections (code-health, \
biomarkers, hotspots, coupling, ownership, functions, and import cycles) as \
structured JSON. When the server was started with `--defect-calibration`, the fact \
sheet also carries a `defect-evidence` section. When the server environment has an \
LLM configured (the `CODELORE_LLM_*` variables), the response also carries a grounded \
advisory `narrative` with its `model` and a `grounded` citation-check verdict; when it \
does not, `narrative_error` is returned instead. The fact sheet is always returned and \
the call never fails because the LLM is unavailable. \
First call on a cold cache triggers history ingest."
)]
async fn explain_file(
&self,
params: Parameters<ExplainFileParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let target = params.0.path.clone();
let params_json = serde_json::to_string(¶ms.0).map_err(internal)?;
let defect_calibration = self.defect_calibration.clone();
let allow_foreign_calibration = self.allow_foreign_calibration;
self.blocking(move || {
let repo = GixRepo::open(&repo_path).map_err(|e| map_lib_err(&e))?;
let head = repo.head_sha().map_err(|e| map_lib_err(&e))?;
let key = memo_key(
"explain_file",
&format!(
"{params_json}\u{1f}{}",
calibration_key_fragment(defect_calibration.as_deref())
),
);
let client = resolve_client(&LlmEnv::from_process_env());
let memoizable = client.is_err();
if memoizable && let Some(hit) = memo.get(&head, &key) {
return Ok(hit);
}
require_tracked_path(&repo, &target)?;
let opts = Options {
repo_path: repo_path.clone(),
min_revs: 1,
defect_calibration,
allow_foreign_calibration,
..Options::default()
};
let db = FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(&head)
.map_err(|e| map_lib_err(&e))?;
let sheet =
FileFactSheet::build(&db, &repo, &opts, &target).map_err(|e| map_lib_err(&e))?;
let fact_sheet: Vec<serde_json::Value> = sheet
.sections
.iter()
.map(|(name, facts)| {
let facts_obj: serde_json::Map<String, serde_json::Value> = facts
.iter()
.map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
.collect();
serde_json::json!({ "section": name, "facts": facts_obj })
})
.collect();
let out = match client {
Ok(client) => {
let canonical = sheet.to_canonical_text();
let values = sheet.numeric_values();
match engine::narrate(
client.as_ref(),
Lens::FileDiagnosis,
&target,
engine::SheetFacts {
text: &canonical,
values: &values,
},
&default_cache_root(),
&repo_path,
false,
) {
Ok(result) => serde_json::json!({
"fact_sheet": fact_sheet,
"narrative": result.narrative,
"grounded": result.grounded,
"model": result.model,
}),
Err(e) => serde_json::json!({
"fact_sheet": fact_sheet,
"narrative_error": e.to_string(),
}),
}
}
Err(e) => serde_json::json!({
"fact_sheet": fact_sheet,
"narrative_error": e.to_string(),
}),
};
let serialized = serde_json::to_string(&out).map_err(internal)?;
if memoizable {
memo.put(&head, key, serialized.clone());
}
Ok(serialized)
})
.await
}
#[tool(
name = "change_context",
annotations(read_only_hint = true, open_world_hint = false),
description = "Temporal pre-write briefing for files you are about to modify: \
code-health band, hotspot standing, historically co-changed partners \
(edit those too), owner concentration incl. a departed-owner flag, \
calibrated structural risk, and recent churn — compact text, \
~150 tokens per file. 1-20 paths. Committed-history view; for \
gate evaluation of the committed tree use `check_gates`. \
First call on a cold cache triggers history ingest."
)]
async fn change_context(
&self,
params: Parameters<ChangeContextParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let memo = self.memo.clone();
let defect_calibration = self.defect_calibration.clone();
let allow_foreign_calibration = self.allow_foreign_calibration;
let paths = params.0.paths.clone();
let params_json = serde_json::to_string(¶ms.0).map_err(internal)?;
self.blocking(move || {
let repo = GixRepo::open(&repo_path).map_err(|e| map_lib_err(&e))?;
let head = repo.head_sha().map_err(|e| map_lib_err(&e))?;
let merge = repo.merge_or_rebase_in_progress();
let cal = calibration_key_fragment(defect_calibration.as_deref());
let key = memo_key(
"change_context",
&format!("{params_json}\u{1f}merge={merge}\u{1f}{cal}"),
);
if let Some(hit) = memo.get(&head, &key) {
return Ok(hit);
}
let opts = Options {
repo_path: repo_path.clone(),
min_revs: 1,
defect_calibration,
allow_foreign_calibration,
..Options::default()
};
let db = FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &default_cache_root())
.map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(&head)
.map_err(|e| map_lib_err(&e))?;
let out = change_context::build_change_context(&db, &repo, &opts, &paths)
.map_err(|e| map_lib_err(&e))?;
memo.put(&head, key, out.clone());
Ok(out)
})
.await
}
#[tool(
name = "gate_changes",
annotations(read_only_hint = true, open_world_hint = false),
description = "Working-tree quality verdict for the agent loop: projects what the \
current uncommitted edits do to code health and the import graph vs HEAD, \
evaluates the repo's working-tree `[diff]` gates against the projection, \
and returns compact text — verdict line, violations, advisory findings, \
and a per-file delta table. With no thresholds configured the verdict \
line reads `no thresholds configured — advisory only` and the advisory \
sections still render; a clean tree returns \
`PASS (no working-tree changes to gate)`. Reads the working tree; the \
committed-tree counterpart is `check_gates`. \
First call on a cold cache triggers history ingest."
)]
async fn gate_changes(
&self,
_params: Parameters<GateChangesParams>,
) -> Result<String, ErrorData> {
let repo_path = self.repo.clone();
let defect_calibration = self.defect_calibration.clone();
let allow_foreign_calibration = self.allow_foreign_calibration;
self.blocking(move || {
let opts = Options {
repo_path: repo_path.clone(),
defect_calibration,
allow_foreign_calibration,
..Options::default()
};
let repo = GixRepo::open(&repo_path).map_err(|e| map_lib_err(&e))?;
let changes = repo.worktree_changes().map_err(|e| map_lib_err(&e))?;
if changes.is_empty() {
return Ok("PASS (no working-tree changes to gate)".to_string());
}
let cache_root = default_cache_root();
let db = FactsDb::open_or_ingest_with_cache_root(&opts, &repo, &cache_root)
.map_err(|e| map_lib_err(&e))?;
let head_sha = repo.head_sha().map_err(|e| map_lib_err(&e))?;
db.ensure_ingest_witnessed(&head_sha)
.map_err(|e| map_lib_err(&e))?;
let report = change_set::build_change_set_report(&db, &repo, &opts, &cache_root)
.map_err(|e| map_lib_err(&e))?;
let thresholds = Thresholds::discover(&repo_path).map_err(|e| map_lib_err(&e))?;
let violations = if thresholds.is_empty() {
None
} else {
Some(evaluate_gate_thresholds(&thresholds, &report))
};
Ok(render_gate_changes(&report, violations.as_deref()))
})
.await
}
}
fn push_truncation_tail(lines: &mut Vec<String>, total: usize, cap: usize, noun: &str) {
let hidden = total.saturating_sub(cap);
if hidden > 0 {
lines.push(format!("(+{hidden} more {noun})"));
}
}
fn render_gate_changes(
report: &change_set::ChangeSetReport,
violations: Option<&[GateViolation]>,
) -> String {
let mut lines: Vec<String> = Vec::new();
match violations {
None => lines.push("no thresholds configured — advisory only".to_string()),
Some([]) => lines.push("PASS".to_string()),
Some(v) => lines.push(format!("FAIL — {} violation(s)", v.len())),
}
if report.merge_in_progress {
lines.push(
"note: merge/rebase in progress — projection reflects committed HEAD history"
.to_string(),
);
}
let violation_rows = violations.unwrap_or_default();
for v in violation_rows.iter().take(crate::GATE_VIOLATION_ROWS) {
lines.push(format!(
" - {gate}: {path} — actual {actual} vs threshold {threshold}",
gate = v.gate,
path = v.path,
actual = v.actual,
threshold = v.threshold,
));
}
push_truncation_tail(
&mut lines,
violation_rows.len(),
crate::GATE_VIOLATION_ROWS,
"violations",
);
for f in report.findings.iter().take(crate::GATE_FINDINGS_ROWS) {
lines.push(format!("[{}] {}: {}", f.kind, f.path, f.detail));
}
push_truncation_tail(
&mut lines,
report.findings.len(),
crate::GATE_FINDINGS_ROWS,
"findings",
);
for d in report
.health
.deltas
.iter()
.take(crate::GATE_DELTA_TABLE_ROWS)
{
match (d.baseline_score, d.projected_score, d.delta) {
(Some(b), Some(p), Some(delta)) => {
lines.push(format!("{} {b:.1} → {p:.1} ({delta:+.1})", d.path));
}
_ => lines.push(format!(
"{} — {}",
d.path,
d.reason.as_deref().unwrap_or("not scored")
)),
}
}
push_truncation_tail(
&mut lines,
report.health.deltas.len(),
crate::GATE_DELTA_TABLE_ROWS,
"files",
);
if let Some(action) = gate_changes_action(report, violations) {
lines.push(action);
}
lines.join("\n")
}
fn gate_changes_action(
report: &change_set::ChangeSetReport,
violations: Option<&[GateViolation]>,
) -> Option<String> {
match violations {
Some(v) if !v.is_empty() => {
let gate = &v[0].gate;
let worst = report
.health
.deltas
.iter()
.filter_map(|d| d.delta.map(|delta| (delta, d.path.as_str())))
.min_by(|a, b| a.0.total_cmp(&b.0));
Some(match worst {
Some((delta, path)) => format!(
"→ fix {path} first (health delta {delta:+.1}) — it drives the {gate} violation"
),
None => format!("→ address the {gate} violation — see the rows above"),
})
}
_ => report
.findings
.first()
.map(|f| format!("→ review {} ({}) before committing", f.path, f.kind)),
}
}
#[tool_handler(
instructions = "Local-only behavioral analysis of the git repository configured at startup. \
No tool modifies tracked content; delta_health creates and removes throwaway git \
worktrees to read two revisions, and every tool may populate the local cache. \
No network, no account, no telemetry — beyond the optional CODELORE_LLM_* \
endpoint you configure for explain_file's advisory narrative (off by default, and \
local-first when enabled). \
First call on a cold cache pays a one-time history ingest (5–30 s for typical repos); \
subsequent calls within the same server session are fast."
)]
impl rmcp::handler::server::ServerHandler for CodeLoreServer {}
pub fn run_mcp_server(
repo: PathBuf,
defect_calibration: Option<PathBuf>,
allow_foreign_calibration: bool,
) -> Result<()> {
let defect_calibration = if defect_calibration.is_some() {
defect_calibration
} else {
resolve_defect_calibration(None, &repo)?
};
if let Some(path) = &defect_calibration {
let artifact = defect_calibration::load(path)?;
defect_calibration::check_repo_identity(&artifact, &repo, allow_foreign_calibration)?;
}
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?
.block_on(async move {
let server = CodeLoreServer {
repo,
defect_calibration,
allow_foreign_calibration,
memo: Arc::new(ResultMemo::default()),
limit: Arc::new(Semaphore::new(MAX_CONCURRENT_CALLS)),
};
let transport = rmcp::transport::io::stdio();
let running = rmcp::service::serve_server(server, transport)
.await
.map_err(|e| anyhow::anyhow!("MCP init error: {e}"))?;
running
.waiting()
.await
.map(|_| ())
.map_err(|e| anyhow::anyhow!("MCP server error: {e}"))
})
}
#[cfg(test)]
mod tests {
use super::{
CodeHealthParams, DEFAULT_ROW_CAP, DeltaHealthParams, MAX_ROW_CAP, MEMO_CAPACITY,
ResultMemo, map_lib_err, memo_key, resolve_row_cap, serialize_capped_rows,
skipped_check_gates,
};
use crate::new_code_skip_reason;
use codelore_lib::CodeLoreError;
use codelore_lib::cli_api::quality_gates::Thresholds;
use serde_json::{Value, json};
fn key_for<T: serde::Serialize>(tool: &str, params: &T) -> String {
memo_key(tool, &serde_json::to_string(params).unwrap())
}
#[test]
fn memo_key_is_independent_of_json_field_order() {
let ch_ab: CodeHealthParams =
serde_json::from_str(r#"{"path":"src/a.rs","limit":5}"#).unwrap();
let ch_ba: CodeHealthParams =
serde_json::from_str(r#"{"limit":5,"path":"src/a.rs"}"#).unwrap();
assert_eq!(
key_for("code_health", &ch_ab),
key_for("code_health", &ch_ba)
);
let delta_ordered: DeltaHealthParams =
serde_json::from_str(r#"{"base":"x","head":"y","limit":3}"#).unwrap();
let delta_shuffled: DeltaHealthParams =
serde_json::from_str(r#"{"limit":3,"head":"y","base":"x"}"#).unwrap();
assert_eq!(
key_for("delta_health", &delta_ordered),
key_for("delta_health", &delta_shuffled),
);
let ch_other: CodeHealthParams =
serde_json::from_str(r#"{"path":"src/a.rs","limit":6}"#).unwrap();
assert_ne!(
key_for("code_health", &ch_ab),
key_for("code_health", &ch_other)
);
assert_ne!(key_for("code_health", &ch_ab), key_for("hotspots", &ch_ab));
}
#[test]
fn memo_serves_hit_at_same_head_and_clears_on_head_change() {
let memo = ResultMemo::default();
assert!(memo.get("head1", "k").is_none());
memo.put("head1", "k".to_string(), "v1".to_string());
assert_eq!(memo.get("head1", "k").as_deref(), Some("v1"));
assert!(memo.get("head2", "k").is_none());
memo.put("head2", "k".to_string(), "v2".to_string());
assert_eq!(memo.get("head2", "k").as_deref(), Some("v2"));
assert!(memo.get("head1", "k").is_none());
}
#[test]
fn memo_put_is_dropped_when_scope_advanced_during_compute() {
let memo = ResultMemo::default();
assert!(memo.get("h1", "k").is_none()); assert!(memo.get("h2", "k").is_none()); memo.put("h1", "k".to_string(), "stale".to_string());
assert!(
memo.get("h2", "k").is_none(),
"a put for a superseded head must not land in the current scope"
);
}
#[test]
fn memo_is_bounded_and_clears_when_full() {
let memo = ResultMemo::default();
assert!(memo.get("h", "seed").is_none()); for i in 0..=MEMO_CAPACITY {
memo.put("h", format!("key{i}"), "v".to_string());
}
assert!(
memo.get("h", "key0").is_none(),
"the oldest entry must be evicted once the cap is exceeded"
);
assert_eq!(
memo.get("h", &format!("key{MEMO_CAPACITY}")).as_deref(),
Some("v"),
"the entry that triggered the clear must itself be retained"
);
}
#[test]
fn row_cap_defaults_and_clamps() {
assert_eq!(resolve_row_cap(None), DEFAULT_ROW_CAP);
assert_eq!(resolve_row_cap(Some(10)), 10);
assert_eq!(resolve_row_cap(Some(0)), 1);
assert_eq!(resolve_row_cap(Some(10_000)), MAX_ROW_CAP);
}
#[test]
fn capped_rows_are_a_bare_array_when_complete() {
let rows = vec![json!({ "path": "a" }), json!({ "path": "b" })];
let out = serialize_capped_rows(&rows, rows.len(), "note").unwrap();
let parsed: Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().expect("bare array");
assert_eq!(
arr.len(),
2,
"no summary object when nothing omitted: {out}"
);
assert!(
arr.iter().all(|v| v.get("omitted").is_none()),
"an untruncated list carries no omitted summary: {out}"
);
}
#[test]
fn capped_rows_append_omitted_summary_when_truncated() {
let shown = vec![json!({ "path": "a" }), json!({ "path": "b" })];
let out = serialize_capped_rows(&shown, 5, "worst first").unwrap();
let parsed: Value = serde_json::from_str(&out).unwrap();
let arr = parsed.as_array().expect("array");
assert_eq!(arr.len(), 3, "two rows plus one summary object: {out}");
let summary = arr.last().unwrap();
assert_eq!(
summary["omitted"], 3,
"5 total − 2 shown = 3 omitted: {out}"
);
assert_eq!(summary["total"], 5);
assert_eq!(summary["note"], "worst first");
assert!(
summary.get("path").is_none(),
"the summary object is distinguishable from a row (no path): {out}"
);
}
#[test]
fn skipped_gates_lists_configured_but_unevaluated_gates() {
let thresholds = Thresholds::from_text(
"[gates]\ncode_health_min = 50.0\nmax_findings_in_hot_files = 5\nhotspot_anchored_max = 9.0\n",
)
.expect("parse thresholds");
let skipped = skipped_check_gates(&thresholds);
assert_eq!(
skipped.iter().map(|s| s.gate).collect::<Vec<_>>(),
vec![
"hotspot_anchored_max",
"max_findings_in_hot_files",
"fail_on_degraded"
],
);
assert!(
skipped.iter().all(|s| !s.reason.is_empty()),
"each skipped gate must disclose a reason"
);
}
#[test]
fn skipped_gates_empty_when_all_configured_gates_are_evaluated() {
let thresholds = Thresholds::from_text(
"[gates]\ncode_health_min = 50.0\ncognitive_max = 30.0\nfail_on_degraded = false\n",
)
.expect("parse thresholds");
assert!(
skipped_check_gates(&thresholds).is_empty(),
"gates this tool evaluates are not disclosed as skipped"
);
}
#[test]
fn skipped_gates_disclose_default_on_degraded_handling() {
let thresholds =
Thresholds::from_text("[gates]\ncode_health_min = 50.0\n").expect("parse thresholds");
let skipped = skipped_check_gates(&thresholds);
assert_eq!(
skipped.iter().map(|s| s.gate).collect::<Vec<_>>(),
vec!["fail_on_degraded"]
);
}
#[test]
fn new_code_skip_reason_names_fetch_depth_only_when_shallow() {
let shallow = new_code_skip_reason(90.0, true);
assert!(
shallow.contains("fetch-depth") && shallow.contains("shallow"),
"shallow-checkout reason must name fetch-depth: {shallow}"
);
let young = new_code_skip_reason(90.0, false);
assert!(
young.contains("young repository") && !young.contains("fetch-depth"),
"young-repository reason must not blame fetch-depth: {young}"
);
}
#[test]
fn lib_error_kind_follows_the_exit_code_bucket() {
let params = map_lib_err(&CodeLoreError::InvalidOptions("bad".into()));
assert_eq!(
params.code.0, -32602,
"InvalidOptions maps to invalid_params"
);
let internal = map_lib_err(&CodeLoreError::Analysis("boom".into()));
assert_eq!(internal.code.0, -32603, "Analysis maps to internal_error");
}
}