huskies: merge 1011
This commit is contained in:
@@ -477,13 +477,15 @@ pub(crate) async fn tool_run_lint(args: &Value, ctx: &AppContext) -> Result<Stri
|
|||||||
|
|
||||||
// ── run_check ────────────────────────────────────────────────────────────────
|
// ── run_check ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Fast compile-only check (`script/check`).
|
/// Fast quality gate (`script/check`).
|
||||||
///
|
///
|
||||||
/// Runs `script/check` (expected to be `cargo check --tests --workspace`) in
|
/// Delegates to `script/check` verbatim — whatever checks that script runs
|
||||||
/// the agent's worktree and returns the **full, untruncated** stdout + stderr
|
/// (fmt, clippy, doc-coverage, etc.) are what this tool runs. Cargo
|
||||||
/// so that every compiler diagnostic is visible to the caller. Unlike
|
/// diagnostics are returned in structured `errors`/`warnings` arrays; the raw
|
||||||
/// `run_build` and `run_lint`, output is never truncated — compile errors must
|
/// `output` field (truncated to the last `MAX_OUTPUT_LINES` lines) is always
|
||||||
/// be readable in their entirety for fast iteration feedback.
|
/// included so that non-cargo failures (e.g. `source-map-check`) surface even
|
||||||
|
/// when they produce no cargo-format diagnostics. Pass `verbose: true` for the
|
||||||
|
/// full untruncated output.
|
||||||
pub(crate) async fn tool_run_check(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
pub(crate) async fn tool_run_check(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||||
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
|
let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
|
||||||
|
|
||||||
@@ -534,11 +536,14 @@ pub(crate) async fn tool_run_check(args: &Value, ctx: &AppContext) -> Result<Str
|
|||||||
.map_err(|e| format!("Serialization error: {e}"))
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared response builder for tools that wrap cargo / rustc output. By
|
/// Shared response builder for tools that wrap cargo / rustc output.
|
||||||
/// default returns parsed `errors` + `warnings` arrays plus a one-line
|
///
|
||||||
/// summary; the raw `output` is only included when `verbose` is true. This
|
/// Returns parsed `errors` + `warnings` arrays (cargo format) plus a one-line
|
||||||
/// keeps the MCP response under the token cap for runs with many errors
|
/// summary. The raw `output` is always included: truncated to the last
|
||||||
/// (bug 886).
|
/// `MAX_OUTPUT_LINES` lines by default (keeps the token budget sane for large
|
||||||
|
/// cargo runs — bug 886), or full and untruncated when `verbose` is true. The
|
||||||
|
/// always-present `output` field means non-cargo failures (e.g.
|
||||||
|
/// `source-map-check`) surface even when they produce no parseable diagnostics.
|
||||||
fn build_diagnostic_response(
|
fn build_diagnostic_response(
|
||||||
passed: bool,
|
passed: bool,
|
||||||
exit_code: i32,
|
exit_code: i32,
|
||||||
@@ -550,7 +555,12 @@ fn build_diagnostic_response(
|
|||||||
let summary = summarise(&diags);
|
let summary = summarise(&diags);
|
||||||
let errors: Vec<&_> = diags.iter().filter(|d| d.kind == "error").collect();
|
let errors: Vec<&_> = diags.iter().filter(|d| d.kind == "error").collect();
|
||||||
let warnings: Vec<&_> = diags.iter().filter(|d| d.kind == "warning").collect();
|
let warnings: Vec<&_> = diags.iter().filter(|d| d.kind == "warning").collect();
|
||||||
let mut payload = json!({
|
let output = if verbose {
|
||||||
|
raw_output.to_string()
|
||||||
|
} else {
|
||||||
|
truncate_output(raw_output, MAX_OUTPUT_LINES)
|
||||||
|
};
|
||||||
|
json!({
|
||||||
"passed": passed,
|
"passed": passed,
|
||||||
"exit_code": exit_code,
|
"exit_code": exit_code,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
@@ -559,11 +569,8 @@ fn build_diagnostic_response(
|
|||||||
"{} error(s), {} warning(s)",
|
"{} error(s), {} warning(s)",
|
||||||
summary.error_count, summary.warning_count
|
summary.error_count, summary.warning_count
|
||||||
),
|
),
|
||||||
});
|
"output": output,
|
||||||
if verbose {
|
})
|
||||||
payload["output"] = json!(raw_output);
|
|
||||||
}
|
|
||||||
payload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -815,10 +822,9 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tool_run_check_returns_parsed_errors_on_nonzero_exit() {
|
async fn tool_run_check_returns_parsed_errors_on_nonzero_exit() {
|
||||||
// Bug 886: rather than dumping the entire cargo log into `output`
|
// Cargo diagnostics are parsed into structured `errors`/`warnings`
|
||||||
// (which routinely exceeds the MCP token cap), tool_run_check now
|
// arrays. The raw `output` is always present (truncated at
|
||||||
// parses errors / warnings into structured arrays. Raw output is
|
// MAX_OUTPUT_LINES in default mode, full in verbose mode).
|
||||||
// available behind `verbose: true`.
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let script_dir = tmp.path().join("script");
|
let script_dir = tmp.path().join("script");
|
||||||
std::fs::create_dir_all(&script_dir).unwrap();
|
std::fs::create_dir_all(&script_dir).unwrap();
|
||||||
@@ -836,14 +842,14 @@ mod tests {
|
|||||||
|
|
||||||
let ctx = test_ctx(tmp.path());
|
let ctx = test_ctx(tmp.path());
|
||||||
|
|
||||||
// Default mode: no `output` field, structured `errors` array, summary.
|
// Default mode: truncated `output` is present, structured `errors` array, summary.
|
||||||
let result = tool_run_check(&json!({}), &ctx).await.unwrap();
|
let result = tool_run_check(&json!({}), &ctx).await.unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||||
assert_eq!(parsed["passed"], false);
|
assert_eq!(parsed["passed"], false);
|
||||||
assert_eq!(parsed["exit_code"], 1);
|
assert_eq!(parsed["exit_code"], 1);
|
||||||
assert!(
|
assert!(
|
||||||
parsed.get("output").map(|v| v.is_null()).unwrap_or(true),
|
parsed["output"].as_str().is_some(),
|
||||||
"default mode must not include raw `output`"
|
"default mode must include `output`"
|
||||||
);
|
);
|
||||||
let errors = parsed["errors"].as_array().expect("errors array");
|
let errors = parsed["errors"].as_array().expect("errors array");
|
||||||
assert_eq!(errors.len(), 150, "all 150 errors should be parsed");
|
assert_eq!(errors.len(), 150, "all 150 errors should be parsed");
|
||||||
@@ -862,14 +868,14 @@ mod tests {
|
|||||||
summary.contains("150 error"),
|
summary.contains("150 error"),
|
||||||
"summary mentions error count: {summary}"
|
"summary mentions error count: {summary}"
|
||||||
);
|
);
|
||||||
// Default response should be small even with 150 errors.
|
// Response should stay compact even with 150 errors + truncated output.
|
||||||
assert!(
|
assert!(
|
||||||
result.len() < 50_000,
|
result.len() < 50_000,
|
||||||
"default response should be compact (was {} bytes)",
|
"response should be compact (was {} bytes)",
|
||||||
result.len()
|
result.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verbose mode: raw output is included.
|
// Verbose mode: full untruncated output.
|
||||||
let result_v = tool_run_check(&json!({"verbose": true}), &ctx)
|
let result_v = tool_run_check(&json!({"verbose": true}), &ctx)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -881,6 +887,56 @@ mod tests {
|
|||||||
assert!(output.contains("error[E150]"), "verbose contains last line");
|
assert!(output.contains("error[E150]"), "verbose contains last line");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_run_check_surfaces_doc_coverage_error() {
|
||||||
|
// Parity test: when script/check fails because source-map-check finds
|
||||||
|
// missing doc comments, the failure message must be visible in the
|
||||||
|
// response even though it produces no cargo-format diagnostics.
|
||||||
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
let script_dir = tmp.path().join("script");
|
||||||
|
std::fs::create_dir_all(&script_dir).unwrap();
|
||||||
|
let script_path = script_dir.join("check");
|
||||||
|
std::fs::write(
|
||||||
|
&script_path,
|
||||||
|
"#!/usr/bin/env bash\n\
|
||||||
|
echo '=== Checking doc coverage on changed files ==='\n\
|
||||||
|
echo 'Doc coverage check failed. Add doc comments to the following items before committing:' >&2\n\
|
||||||
|
echo ' Add a `///` doc comment above `pub fn new` in server/src/foo.rs:10' >&2\n\
|
||||||
|
exit 1\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let ctx = test_ctx(tmp.path());
|
||||||
|
let result = tool_run_check(&json!({}), &ctx).await.unwrap();
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(parsed["passed"], false, "check must report failure");
|
||||||
|
assert_eq!(parsed["exit_code"], 1);
|
||||||
|
|
||||||
|
// No cargo-format diagnostics — errors array is empty.
|
||||||
|
let errors = parsed["errors"].as_array().expect("errors array");
|
||||||
|
assert!(
|
||||||
|
errors.is_empty(),
|
||||||
|
"no cargo diagnostics expected for a source-map-check failure"
|
||||||
|
);
|
||||||
|
|
||||||
|
// But the raw failure text must surface in `output`.
|
||||||
|
let output = parsed["output"].as_str().expect("output must be present");
|
||||||
|
assert!(
|
||||||
|
output.contains("Doc coverage check failed"),
|
||||||
|
"doc-coverage failure must be visible in output: {output}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
output.contains("pub fn new"),
|
||||||
|
"specific missing-doc item must be visible: {output}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn tool_run_check_passes_when_script_exits_zero() {
|
async fn tool_run_check_passes_when_script_exits_zero() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user