use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use codelore_lib::test_support::{delivery_repo, tiny_repo};
use serde_json::{Value, json};
const LLM_ENV_VARS: &[&str] = &[
"CODELORE_LLM_PROVIDER",
"CODELORE_LLM_BASE_URL",
"CODELORE_LLM_API_KEY",
"CODELORE_LLM_MODEL",
"ANTHROPIC_API_KEY",
];
fn ndjson_line(msg: &Value) -> Vec<u8> {
let mut bytes = serde_json::to_vec(msg).unwrap();
bytes.push(b'\n');
bytes
}
fn read_ndjson(reader: &mut BufReader<impl std::io::Read>) -> Value {
let mut line = String::new();
reader.read_line(&mut line).expect("read JSON-RPC line");
serde_json::from_str(line.trim()).expect("parse JSON-RPC line")
}
fn spawn_mcp(
repo_path: &str,
) -> (
std::process::Child,
std::process::ChildStdin,
BufReader<std::process::ChildStdout>,
) {
spawn_mcp_with_args(repo_path, &[])
}
fn spawn_mcp_with_args(
repo_path: &str,
extra_args: &[&str],
) -> (
std::process::Child,
std::process::ChildStdin,
BufReader<std::process::ChildStdout>,
) {
let bin = assert_cmd::cargo::cargo_bin("codelore");
let mut builder = Command::new(&bin);
builder
.args(["mcp", "--repo", repo_path])
.args(extra_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null());
for var in LLM_ENV_VARS {
builder.env_remove(var);
}
let mut child = builder.spawn().expect("spawn codelore mcp");
let mut stdin = child.stdin.take().expect("stdin");
let stdout = child.stdout.take().expect("stdout");
let mut reader = BufReader::new(stdout);
let init_req = json!({
"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": { "name": "test-client", "version": "0.0.1" }
}
});
stdin.write_all(&ndjson_line(&init_req)).unwrap();
stdin.flush().unwrap();
let init_resp = read_ndjson(&mut reader);
let instructions = init_resp["result"]["instructions"]
.as_str()
.expect("initialize response carries an instructions string");
assert!(
instructions.contains("No network"),
"instructions must state the local-only positioning, got: {instructions}"
);
let notif = json!({ "jsonrpc": "2.0", "method": "notifications/initialized" });
stdin.write_all(&ndjson_line(¬if)).unwrap();
stdin.flush().unwrap();
(child, stdin, reader)
}
fn call_tool(
stdin: &mut std::process::ChildStdin,
reader: &mut BufReader<std::process::ChildStdout>,
id: u64,
name: &str,
arguments: &Value,
) -> Value {
let req = json!({
"jsonrpc": "2.0", "id": id, "method": "tools/call",
"params": { "name": name, "arguments": arguments }
});
stdin.write_all(&ndjson_line(&req)).unwrap();
stdin.flush().unwrap();
read_ndjson(reader)
}
fn assert_tool_ok(resp: &Value, tool_name: &str) -> Value {
assert_eq!(resp["jsonrpc"], "2.0", "{tool_name}: bad jsonrpc field");
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"{tool_name} returned MCP error: {resp}"
);
let content = resp["result"]["content"].as_array().expect("content array");
assert!(!content.is_empty(), "{tool_name}: content array is empty");
let text = content[0]["text"].as_str().expect("text field");
serde_json::from_str(text)
.unwrap_or_else(|e| panic!("{tool_name}: content text is not valid JSON ({e}): {text}"))
}
fn assert_tool_ok_text(resp: &Value, tool_name: &str) -> String {
assert_eq!(resp["jsonrpc"], "2.0", "{tool_name}: bad jsonrpc field");
assert!(
!resp["result"]["isError"].as_bool().unwrap_or(false),
"{tool_name} returned MCP error: {resp}"
);
let content = resp["result"]["content"].as_array().expect("content array");
assert!(!content.is_empty(), "{tool_name}: content array is empty");
content[0]["text"].as_str().expect("text field").to_string()
}
fn assert_rpc_error_code(resp: &Value, expected_code: i64, ctx: &str) {
let code = resp["error"]["code"].as_i64().unwrap_or_else(|| {
panic!("{ctx}: expected a JSON-RPC error object with a numeric code, got: {resp}")
});
assert_eq!(
code, expected_code,
"{ctx}: wrong JSON-RPC error code: {resp}"
);
}
#[test]
fn mcp_tools_list_and_repo_overview() {
let repo = tiny_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let list_req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} });
stdin.write_all(&ndjson_line(&list_req)).unwrap();
stdin.flush().unwrap();
let list_resp = read_ndjson(&mut reader);
assert_eq!(list_resp["id"], 1);
let tools = list_resp["result"]["tools"]
.as_array()
.expect("tools array");
assert_eq!(
tools.len(),
11,
"expected exactly 11 tools, got {}: {:?}",
tools.len(),
tools
.iter()
.filter_map(|t| t["name"].as_str())
.collect::<Vec<_>>()
);
let tool_names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
for expected in &[
"repo_overview",
"hotspots",
"code_health",
"delta_health",
"refactoring_targets",
"function_xray",
"check_gates",
"finding_hotspot_overlap",
"explain_file",
"change_context",
"gate_changes",
] {
assert!(
tool_names.contains(expected),
"{expected} missing from tools/list: {tool_names:?}"
);
}
for tool in tools {
assert!(
tool["inputSchema"].is_object(),
"tool {:?} missing inputSchema: {tool}",
tool["name"]
);
}
let resp = call_tool(&mut stdin, &mut reader, 2, "repo_overview", &json!({}));
let parsed = assert_tool_ok(&resp, "repo_overview");
assert!(
parsed["summary"].is_array(),
"expected `summary` array in repo_overview response: {parsed}"
);
assert!(
parsed["options"].is_object(),
"expected `options` object in repo_overview response: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_code_health_returns_scored_rows() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let parsed = assert_tool_ok(&resp, "code_health");
let rows = parsed.as_array().expect("code_health: expected JSON array");
assert!(
!rows.is_empty(),
"code_health returned no rows for delivery_repo"
);
let first = &rows[0];
assert!(
first["band"].is_string(),
"row missing `band` field: {first}"
);
assert!(
first["score"].is_number(),
"row missing numeric `score` field: {first}"
);
let mut any_corpus = false;
for row in rows {
if let Some(cp) = row.get("corpus_percentile") {
let p = cp
.as_f64()
.unwrap_or_else(|| panic!("corpus_percentile must be a number: {row}"));
assert!(
(0.0..=1.0).contains(&p),
"corpus_percentile must be in 0..=1: {row}"
);
any_corpus = true;
if let Some(beyond) = row.get("beyond_corpus") {
assert!(
beyond.is_boolean(),
"beyond_corpus must be a bool when present: {row}"
);
}
} else {
assert!(
row.get("beyond_corpus").is_none(),
"beyond_corpus must be absent when corpus_percentile is: {row}"
);
}
}
assert!(
any_corpus,
"the embedded world corpus covers Rust, so at least one delivery_repo row \
must carry corpus_percentile"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_hotspots_returns_capped_rows() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"hotspots",
&json!({ "limit": 1 }),
);
let parsed = assert_tool_ok(&resp, "hotspots");
let rows = parsed.as_array().expect("hotspots: expected JSON array");
assert!(
!rows.is_empty(),
"hotspots returned no rows for delivery_repo"
);
assert!(
rows.len() <= 1,
"limit=1 must cap hotspots rows at one, got {}: {parsed}",
rows.len()
);
let first = &rows[0];
assert!(
first["path"].is_string(),
"row missing `path` field: {first}"
);
assert!(
first["revisions"].is_number(),
"row missing numeric `revisions` field: {first}"
);
assert!(
first["hotspot_score"].is_number(),
"row missing numeric `hotspot_score` field: {first}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_refactoring_targets_returns_array() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"refactoring_targets",
&json!({ "limit": 5 }),
);
let parsed = assert_tool_ok(&resp, "refactoring_targets");
assert!(
parsed.is_array(),
"refactoring_targets: expected JSON array, got: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_function_xray_returns_rows_for_valid_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"function_xray",
&json!({ "path": "src/core.rs" }),
);
let parsed = assert_tool_ok(&resp, "function_xray");
assert!(
parsed.is_array(),
"function_xray: expected JSON array, got: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_returns_verdict() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let thresholds_path = repo.dir.path().join(".codelore-thresholds.toml");
std::fs::write(&thresholds_path, "[gates]\ncode_health_min = 0.0\n").unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let parsed = assert_tool_ok(&resp, "check_gates");
assert!(
parsed["verdict"].is_string(),
"check_gates: expected `verdict` string: {parsed}"
);
assert!(
parsed["violation_count"].is_number(),
"check_gates: expected `violation_count` number: {parsed}"
);
assert!(
parsed["violations"].is_array(),
"check_gates: expected `violations` array: {parsed}"
);
assert_eq!(
parsed["verdict"], "pass",
"check_gates: expected pass verdict with permissive threshold, got: {parsed}"
);
let skipped: Vec<&str> = parsed["skipped_gates"]
.as_array()
.expect("check_gates: skipped_gates array")
.iter()
.filter_map(|v| v["gate"].as_str())
.collect();
assert_eq!(
skipped,
vec!["fail_on_degraded"],
"check_gates: only the default-on degraded semantics may be skipped when \
every explicitly set gate is evaluated: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_discloses_skipped_gates() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[gates]\ncode_health_min = 0.0\nmax_findings_in_hot_files = 100\nhotspot_anchored_max = 9.0\nfail_on_degraded = false\n",
)
.unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let parsed = assert_tool_ok(&resp, "check_gates");
let skipped: Vec<&str> = parsed["skipped_gates"]
.as_array()
.expect("check_gates: skipped_gates array")
.iter()
.filter_map(|v| v["gate"].as_str())
.collect();
assert!(
skipped.contains(&"max_findings_in_hot_files"),
"a configured check-only gate must be disclosed under skipped_gates: {parsed}"
);
assert!(
skipped.contains(&"hotspot_anchored_max"),
"the corpus-dependent anchored gate is check-only here and must be disclosed: {parsed}"
);
assert!(
!skipped.contains(&"fail_on_degraded"),
"explicitly disabled degraded semantics must not be disclosed as skipped: {parsed}"
);
assert!(
parsed["skipped_gates"]
.as_array()
.expect("skipped_gates array")
.iter()
.all(|v| v["reason"].as_str().is_some_and(|r| !r.is_empty())),
"each skipped gate must carry a reason: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_check_gates_evaluates_corpus_percentile_max_for_real() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[gates]\ncorpus_percentile_max = 0.0\n",
)
.unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let parsed = assert_tool_ok(&resp, "check_gates");
assert_eq!(
parsed["verdict"], "fail",
"a 0.0 ceiling must fail against delivery_repo's real corpus percentiles: {parsed}"
);
let violations = parsed["violations"]
.as_array()
.expect("check_gates: violations array");
assert!(
violations
.iter()
.any(|v| v["gate"] == "corpus_percentile_max"),
"corpus_percentile_max must produce real violations, not a skip: {parsed}"
);
let skipped: Vec<&str> = parsed["skipped_gates"]
.as_array()
.expect("check_gates: skipped_gates array")
.iter()
.filter_map(|v| v["gate"].as_str())
.collect();
assert!(
!skipped.contains(&"corpus_percentile_max"),
"corpus_percentile_max must not be disclosed as skipped when it was actually evaluated: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_function_xray_errors_on_unknown_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"function_xray",
&json!({ "path": "src/does_not_exist.rs" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
assert_rpc_error_code(&resp, -32602, "function_xray unknown path");
assert!(
resp.to_string().contains("src/does_not_exist.rs"),
"the error must name the unknown path: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_code_health_errors_on_unknown_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"code_health",
&json!({ "path": "src/does_not_exist.rs" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
assert_rpc_error_code(&resp, -32602, "code_health unknown path");
assert!(
resp.to_string().contains("src/does_not_exist.rs"),
"the error must name the unknown path: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_explain_file_errors_on_unknown_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"explain_file",
&json!({ "path": "src/does_not_exist.rs" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
assert_rpc_error_code(&resp, -32602, "explain_file unknown path");
assert!(
resp.to_string().contains("src/does_not_exist.rs"),
"the error must name the unknown path: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_code_health_limit_is_honored() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"code_health",
&json!({ "limit": 1 }),
);
let parsed = assert_tool_ok(&resp, "code_health");
let arr = parsed
.as_array()
.expect("code_health returns an array when listing");
let real_rows = arr.iter().filter(|v| v.get("path").is_some()).count();
assert!(
real_rows <= 1,
"limit=1 must cap real rows at one: {parsed}"
);
assert!(
arr.iter().all(|v| v.get("omitted").is_none()),
"an untruncated list carries no omitted summary object: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_delta_health_description_discloses_diff_subset() {
let repo = tiny_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let list_req = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} });
stdin.write_all(&ndjson_line(&list_req)).unwrap();
stdin.flush().unwrap();
let list_resp = read_ndjson(&mut reader);
let tools = list_resp["result"]["tools"]
.as_array()
.expect("tools array");
let delta = tools
.iter()
.find(|t| t["name"] == "delta_health")
.expect("delta_health tool present");
let desc = delta["description"].as_str().unwrap_or_default();
assert!(
desc.contains("codelore diff"),
"delta_health must disclose it is a subset of `codelore diff`: {desc}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_delta_health_rejects_bad_rev() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"delta_health",
&json!({ "base": "nonexistent-branch-xyz", "head": "HEAD" }),
);
assert_eq!(resp["jsonrpc"], "2.0");
let is_rpc_error = resp["error"].is_object();
let is_tool_error = resp["result"]["isError"].as_bool().unwrap_or(false);
assert!(
is_rpc_error || is_tool_error,
"delta_health with bad rev should return an error, got: {resp}"
);
assert_rpc_error_code(&resp, -32602, "delta_health bad rev");
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_delta_health_returns_section_for_valid_revs() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"delta_health",
&json!({ "base": "HEAD~1", "head": "HEAD" }),
);
let parsed = assert_tool_ok(&resp, "delta_health");
assert!(
parsed["verdict"].is_string(),
"delta_health: expected `verdict` string: {parsed}"
);
assert!(
parsed["counts"].is_object(),
"delta_health: expected `counts` object: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_finding_hotspot_overlap_returns_note_when_sidecar_absent() {
let repo = tiny_repo::build();
let repo_path_buf = repo.dir.path().to_path_buf();
let repo_path = repo_path_buf.to_str().unwrap();
let cache_root = codelore_lib::cli_api::cache::default_cache_root();
let sidecar_path = codelore_lib::cli_api::cache::repo_cache_dir(&cache_root, &repo_path_buf)
.join("external-findings.duckdb-ext");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(
&mut stdin,
&mut reader,
1,
"finding_hotspot_overlap",
&json!({}),
);
let parsed = assert_tool_ok(&resp, "finding_hotspot_overlap");
assert!(
parsed["findings"].is_array(),
"finding_hotspot_overlap: expected `findings` array in note response: {parsed}"
);
assert_eq!(
parsed["findings"].as_array().unwrap().len(),
0,
"finding_hotspot_overlap: note response `findings` must be empty: {parsed}"
);
assert!(
parsed["note"].is_string(),
"finding_hotspot_overlap: expected `note` string in response: {parsed}"
);
assert!(
parsed["note"].as_str().unwrap().contains("ingest-sarif"),
"finding_hotspot_overlap: note must mention ingest-sarif: {parsed}"
);
drop(stdin);
let _ = child.wait();
assert!(
!sidecar_path.exists(),
"MCP read must not create the sidecar: {}",
sidecar_path.display()
);
}
#[test]
fn mcp_explain_file_returns_fact_sheet_and_narrative_error_without_llm() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"explain_file",
&json!({ "path": target }),
);
let parsed = assert_tool_ok(&resp, "explain_file");
let sections = parsed["fact_sheet"]
.as_array()
.expect("explain_file always returns a fact_sheet array");
assert!(
!sections.is_empty(),
"the fact sheet carries at least the mandatory code-health section: {parsed}"
);
assert!(
sections
.iter()
.any(|s| s["section"] == "code-health" && s["facts"].is_object()),
"the fact sheet includes a structured code-health section: {parsed}"
);
assert!(
parsed["narrative_error"].is_string(),
"without an LLM configured, explain_file sets narrative_error: {parsed}"
);
assert!(
parsed.get("narrative").is_none(),
"no narrative may be present when the LLM is unavailable: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
fn write_foreign_defect_artifact(dir: &std::path::Path) -> std::path::PathBuf {
use codelore_lib::defect_calibration::{
DEFECT_FORMAT_VERSION, DefectArtifact, MiningStats, OracleConfig, TuningDecision,
ValidationMetrics, save, validate::default_weights,
};
let artifact = DefectArtifact {
format_version: DEFECT_FORMAT_VERSION,
repo_identity: "0".repeat(64),
head_at_mining: "0".repeat(40),
vintage: "defects-2026-07-17".to_string(),
generated_at: "2026-07-17T00:00:00Z".to_string(),
oracle: OracleConfig::default(),
mining: MiningStats::default(),
validation: ValidationMetrics {
band_table: vec![("red".to_string(), 5, 1.0)],
auc_default: None,
precision_at_10: None,
precision_at_red: None,
implicated_files: 3,
linked_defects: 5,
sample_dates: vec!["2026-01-01".to_string()],
excluded_no_data: 0,
},
weights: default_weights(),
tuning: TuningDecision::DefaultsKept {
reason: "insufficient evidence for weight tuning".to_string(),
auc_validation_default: None,
auc_validation_tuned: None,
},
};
let path = dir.join("defects.calib.json");
save(&artifact, &path).expect("save artifact");
path
}
#[test]
fn mcp_explain_file_defect_calibration_adds_defect_evidence_section() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let artifact_dir = tempfile::tempdir().expect("artifact dir");
let artifact_path = write_foreign_defect_artifact(artifact_dir.path());
let (mut child, mut stdin, mut reader) = spawn_mcp_with_args(
repo_path,
&[
"--defect-calibration",
artifact_path.to_str().unwrap(),
"--allow-foreign-calibration",
],
);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"explain_file",
&json!({ "path": target }),
);
let parsed = assert_tool_ok(&resp, "explain_file");
let sections = parsed["fact_sheet"]
.as_array()
.expect("explain_file always returns a fact_sheet array");
assert!(
sections.iter().any(|s| s["section"] == "defect-evidence"),
"the fact sheet must carry a defect-evidence section when the server \
was started with --defect-calibration: {parsed}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_refuses_to_start_on_foreign_artifact_without_override() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let artifact_dir = tempfile::tempdir().expect("artifact dir");
let artifact_path = write_foreign_defect_artifact(artifact_dir.path());
let bin = assert_cmd::cargo::cargo_bin("codelore");
let mut builder = Command::new(&bin);
builder
.args([
"mcp",
"--repo",
repo_path,
"--defect-calibration",
artifact_path.to_str().unwrap(),
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for var in LLM_ENV_VARS {
builder.env_remove(var);
}
let output = builder.output().expect("spawn codelore mcp");
assert_eq!(
output.status.code(),
Some(4),
"expected exit code 4 for foreign artifact without override, got: {:?}",
output.status.code()
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("mined from a different repository"),
"stderr must mention 'mined from a different repository', got: {stderr}"
);
assert!(
stderr.contains("--allow-foreign-calibration"),
"stderr must mention '--allow-foreign-calibration', got: {stderr}"
);
}
#[test]
fn check_gates_honors_calibration_section() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let artifact_dir = tempfile::tempdir().expect("artifact dir");
let artifact_path = write_foreign_defect_artifact(artifact_dir.path());
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
format!(
"[gates]\ncode_health_min = 0.0\n\n[calibration]\ndefect_artifact = '{}'\n",
artifact_path.display()
),
)
.unwrap();
let (mut child, mut stdin, mut reader) =
spawn_mcp_with_args(repo_path, &["--allow-foreign-calibration"]);
std::fs::remove_file(&artifact_path).expect("delete artifact after startup");
let resp = call_tool(&mut stdin, &mut reader, 1, "check_gates", &json!({}));
let is_error =
resp["error"].is_object() || resp["result"]["isError"].as_bool().unwrap_or(false);
assert!(
is_error,
"check_gates must fail when the configured calibration artifact cannot be loaded: {resp}"
);
assert!(
resp.to_string().contains("defects.calib.json"),
"the error must name the missing artifact: {resp}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_change_context_returns_briefing_for_known_path() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"change_context",
&json!({ "paths": [target.clone()] }),
);
let text = assert_tool_ok_text(&resp, "change_context");
assert!(
text.contains(&target),
"briefing must name the requested path: {text}"
);
assert!(
text.contains("health "),
"briefing must carry a health line: {text}"
);
assert!(
text.contains("owner:"),
"briefing must carry an owner line: {text}"
);
assert!(
!text.contains("undefined"),
"briefing must not contain the literal 'undefined': {text}"
);
assert!(
!text.contains('{') && !text.contains('}'),
"briefing is plain text, not JSON: {text}"
);
drop(stdin);
let _ = child.wait();
}
fn head_sha(repo_path: &str) -> String {
let out = Command::new("git")
.args(["-C", repo_path, "rev-parse", "HEAD"])
.output()
.expect("git rev-parse HEAD");
assert!(out.status.success(), "git rev-parse HEAD failed");
String::from_utf8(out.stdout)
.expect("utf8")
.trim()
.to_string()
}
#[test]
fn mcp_change_context_reflects_mid_session_merge_state() {
const NOTE: &str = "merge/rebase in progress";
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let merge_head = repo.dir.path().join(".git").join("MERGE_HEAD");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let target = ch_rows
.as_array()
.and_then(|rows| rows.first())
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file")
.to_string();
let params = json!({ "paths": [target] });
let clean = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "change_context", ¶ms),
"change_context",
);
assert!(
!clean.contains(NOTE),
"clean tree must carry no merge note: {clean}"
);
std::fs::write(&merge_head, format!("{}\n", head_sha(repo_path))).expect("write MERGE_HEAD");
let merging = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 3, "change_context", ¶ms),
"change_context",
);
assert!(
merging.contains(NOTE),
"a merge started at unchanged HEAD must surface the note, not a memoized-stale briefing: {merging}"
);
std::fs::remove_file(&merge_head).expect("remove MERGE_HEAD");
let aborted = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 4, "change_context", ¶ms),
"change_context",
);
assert!(
!aborted.contains(NOTE),
"an aborted merge at unchanged HEAD must drop the note: {aborted}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_change_context_rejects_empty_and_oversized_path_lists() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let empty = call_tool(
&mut stdin,
&mut reader,
1,
"change_context",
&json!({ "paths": [] }),
);
assert_eq!(empty["jsonrpc"], "2.0");
let empty_is_error =
empty["error"].is_object() || empty["result"]["isError"].as_bool().unwrap_or(false);
assert!(
empty_is_error,
"an empty path list must be an error, got: {empty}"
);
assert!(
empty.to_string().contains("20"),
"the empty-list error must name the 20-path limit: {empty}"
);
assert_rpc_error_code(&empty, -32602, "change_context empty paths");
let too_many: Vec<String> = (0..21).map(|i| format!("src/file{i}.rs")).collect();
let oversized = call_tool(
&mut stdin,
&mut reader,
2,
"change_context",
&json!({ "paths": too_many }),
);
assert_eq!(oversized["jsonrpc"], "2.0");
let oversized_is_error =
oversized["error"].is_object() || oversized["result"]["isError"].as_bool().unwrap_or(false);
assert!(
oversized_is_error,
"an oversized path list must be an error, got: {oversized}"
);
assert!(
oversized.to_string().contains("20"),
"the oversized-list error must name the 20-path limit: {oversized}"
);
assert_rpc_error_code(&oversized, -32602, "change_context oversized paths");
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_change_context_stays_within_token_budget() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let ch = call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({}));
let ch_rows = assert_tool_ok(&ch, "code_health");
let rows = ch_rows
.as_array()
.expect("code_health returns an array for delivery_repo");
let first = rows
.first()
.and_then(|row| row["path"].as_str())
.expect("code_health yields at least one file for delivery_repo")
.to_string();
let second = rows
.get(1)
.and_then(|row| row["path"].as_str())
.unwrap_or(&first)
.to_string();
let resp = call_tool(
&mut stdin,
&mut reader,
2,
"change_context",
&json!({ "paths": [first, second] }),
);
let text = assert_tool_ok_text(&resp, "change_context");
let tokens = text.split_whitespace().count();
assert!(
tokens <= 300,
"budget is 150 tokens/path; got {tokens} for 2 paths: {text}"
);
drop(stdin);
let _ = child.wait();
}
const GATE_MONSTER_FN: &str = r"
fn monster(x: i32) -> i32 {
let mut acc = 0;
for a in 0..x {
if a % 2 == 0 && a % 3 == 0 || a % 5 == 0 {
for b in 0..a {
if b > 1 {
match b % 4 {
0 => { if b > 10 { acc += 1; } else { acc += 2; } }
1 => { while acc < 100 { acc += 1; if acc % 7 == 0 { break; } } }
2 => { for c in 0..b { if c > 3 && c < 9 || c == 5 { acc += c; } } }
_ => { if a > b { acc -= 1; } else { acc += 1; } }
}
}
}
}
}
acc
}
";
fn worsen_file(repo_root: &std::path::Path, rel_path: &str) {
let path = repo_root.join(rel_path);
let mut content = std::fs::read_to_string(&path).expect("read fixture file");
content.push_str(GATE_MONSTER_FN);
std::fs::write(&path, content).expect("write fixture file");
}
fn commit_cohort_fillers(repo_root: &std::path::Path, n: usize) {
for i in 0..n {
std::fs::write(
repo_root.join(format!("src/cohort_filler_{i}.rs")),
format!("pub fn cohort_filler_{i}() -> i32 {{ {i} }}\n"),
)
.expect("write cohort filler");
}
let git = |args: &[&str]| {
let out = Command::new("git")
.args(args)
.current_dir(repo_root)
.output()
.expect("run git");
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
};
git(&["add", "."]);
git(&[
"-c",
"user.email=cohort@example.com",
"-c",
"user.name=Cohort",
"commit",
"--quiet",
"-m",
"cohort fillers",
]);
}
#[test]
fn gate_changes_reports_clean_tree() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
assert!(
text.contains("no working-tree changes"),
"a fresh clone has nothing to gate: {text}"
);
assert!(
text.starts_with("PASS"),
"a clean tree is an explicit pass, not a skipped evaluation: {text}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn gate_changes_flags_working_tree_edit() {
let repo = delivery_repo::build();
commit_cohort_fillers(repo.dir.path(), 9);
let repo_path = repo.dir.path().to_str().unwrap();
std::fs::write(
repo.dir.path().join(".codelore-thresholds.toml"),
"[diff]\ndelta_code_health_min_per_file = 0.0\n",
)
.unwrap();
worsen_file(repo.dir.path(), "src/core.rs");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
assert!(
text.starts_with("FAIL — "),
"line 1 must be the FAIL verdict: {text}"
);
assert!(
text.contains(" - delta_code_health_min_per_file: src/core.rs — actual "),
"the violation row must use check's exact form and name the file: {text}"
);
assert!(
text.contains("[health-drop] src/core.rs:"),
"the health-drop finding must name the file: {text}"
);
assert!(
text.contains(" → "),
"the delta table must render baseline → projected: {text}"
);
assert!(
text.contains("\u{2192} fix src/core.rs first"),
"the FAIL action line must name the worst-delta file to fix first: {text}"
);
assert!(
text.contains("drives the delta_code_health_min_per_file violation"),
"the FAIL action line must name the driving gate: {text}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn gate_changes_token_budget_holds() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
worsen_file(repo.dir.path(), "src/core.rs");
worsen_file(repo.dir.path(), "src/stable.rs");
std::fs::write(
repo.dir.path().join("src/fresh.rs"),
"pub fn fresh() -> u32 { 1 }\n",
)
.unwrap();
let add = Command::new("git")
.args(["-C", repo_path, "add", "src/fresh.rs"])
.output()
.expect("git add");
assert!(add.status.success(), "git add failed: {add:?}");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
assert!(
text.starts_with("no thresholds configured — advisory only"),
"without thresholds the verdict line discloses advisory-only: {text}"
);
let findings = text.lines().filter(|l| l.starts_with('[')).count();
assert!(
findings >= 3,
"two worsened files plus a staged new file must produce at least \
three findings, got {findings}: {text}"
);
let tokens = text.split_whitespace().count();
assert!(
tokens <= 80 + 40 * findings,
"budget is 80 + 40·findings tokens; got {tokens} for {findings} findings: {text}"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn gate_changes_findings_render_capped_with_more_tail() {
const ADDED: usize = 13;
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
for i in 0..ADDED {
std::fs::write(
repo.dir.path().join(format!("src/gate_extra_{i}.rs")),
format!("pub fn extra_{i}() -> u32 {{ {i} }}\n"),
)
.unwrap();
}
let add = Command::new("git")
.args(["-C", repo_path, "add", "-A"])
.output()
.expect("git add");
assert!(add.status.success(), "git add failed: {add:?}");
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let resp = call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({}));
let text = assert_tool_ok_text(&resp, "gate_changes");
let finding_lines = text.lines().filter(|l| l.starts_with("[new-file]")).count();
assert_eq!(
finding_lines, 10,
"the findings render must cap at 10 rows: {text}"
);
assert!(
text.contains(&format!("(+{} more findings)", ADDED - 10)),
"a '(+n more findings)' tail must disclose the hidden rows: {text}"
);
drop(stdin);
let _ = child.wait();
}
fn commit_all(repo_root: &std::path::Path, message: &str) {
let status = Command::new("git")
.arg("-C")
.arg(repo_root)
.args(["-c", "user.email=codelore-test@example.com"])
.args(["-c", "user.name=CodeLore Test"])
.args(["commit", "-aqm", message])
.status()
.expect("spawn git commit");
assert!(status.success(), "git commit must succeed");
}
#[test]
fn mcp_committed_state_read_repeat_is_byte_identical() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let first = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({})),
"code_health",
);
let second = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "code_health", &json!({})),
"code_health",
);
assert_eq!(
first, second,
"a repeated committed-state read must return the memoized payload verbatim"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_committed_state_read_refreshes_after_new_commit() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let before = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 1, "code_health", &json!({})),
"code_health",
);
worsen_file(repo.dir.path(), "src/core.rs");
commit_all(repo.dir.path(), "worsen core.rs");
let after = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "code_health", &json!({})),
"code_health",
);
assert_ne!(
before, after,
"a read after a new commit must be recomputed for the new HEAD, not served \
from the pre-commit memo"
);
let after_again = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 3, "code_health", &json!({})),
"code_health",
);
assert_eq!(
after, after_again,
"the post-commit result must be memoized under the new HEAD"
);
drop(stdin);
let _ = child.wait();
}
#[test]
fn mcp_gate_changes_is_not_memoized_across_worktree_edit() {
let repo = delivery_repo::build();
let repo_path = repo.dir.path().to_str().unwrap();
let (mut child, mut stdin, mut reader) = spawn_mcp(repo_path);
let clean = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 1, "gate_changes", &json!({})),
"gate_changes",
);
assert!(
clean.contains("no working-tree changes"),
"a clean tree reports nothing to gate: {clean}"
);
worsen_file(repo.dir.path(), "src/core.rs");
let dirty = assert_tool_ok_text(
&call_tool(&mut stdin, &mut reader, 2, "gate_changes", &json!({})),
"gate_changes",
);
assert_ne!(
clean, dirty,
"an uncommitted edit must change gate_changes output — it is never memoized"
);
assert!(
!dirty.contains("no working-tree changes"),
"the second call must observe the uncommitted edit: {dirty}"
);
assert!(
dirty.contains("src/core.rs"),
"the worsened file must surface in the second call: {dirty}"
);
drop(stdin);
let _ = child.wait();
}