From f9a974d47da6f01407ee7d18bdf2f52edad24630 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 07:09:51 +0000 Subject: [PATCH 1/2] test(mcp): prove tracedecay_diagnose behavior Drive tracedecay_diagnose through production MCP tools/call and assert the mapped diagnostic, filters, and missing-input refusal. Co-authored-by: Zack Jackson --- .../tests/mcp_suite/mcp_handler_test.rs | 1 + .../mcp_handler_test/diagnose_test.rs | 490 ++++++++++++++++++ 2 files changed, 491 insertions(+) create mode 100644 crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs index 0053aebca1..867e44b905 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs @@ -10,6 +10,7 @@ mod bounded_analysis_test; mod branch_sensitivity_test; mod context_test; mod dependency_hint_test; +mod diagnose_test; #[cfg(feature = "test-transport")] mod edit_test; mod graph_analysis_test; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs new file mode 100644 index 0000000000..4cb0fef00a --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs @@ -0,0 +1,490 @@ +//! `tracedecay_diagnose` as a caller sees it: production MCP `tools/call`, +//! not the parser. +//! +//! Compiler stderr is the input. The observable result is the mapped +//! diagnostic, the publication report, and the default markdown rendering. +//! Occurrence ids are generation-minted, so they are checked against the live +//! exact-symbol read rather than pinned. + +#![cfg(feature = "test-transport")] + +use std::fs; +use std::time::Duration; + +use serde_json::{Value, json}; +use tracedecay_mcp::JsonRpcResponse; + +use crate::support::{ProductionCompositionFixture, production_composition_fixture_with_sources}; + +const SOURCE: &str = "pub fn target() {}\npub fn caller() { target(); }\n"; + +const RUSTC_ERROR: &str = "\ +error[E0308]: mismatched types + --> src/lib.rs:1:10 + | +1 | pub fn target() {} + | ^^^^^^ expected `u32`, found `()` + +error: aborting due to 1 previous error +"; + +const RUSTC_ERROR_AND_WARNING: &str = "\ +error[E0308]: mismatched types + --> src/lib.rs:1:10 + | +warning: unused function + --> src/lib.rs:2:1 + | +error: aborting due to 1 previous error +"; + +const COLORED_SHORT_ERROR: &str = "\ +\u{1b}[1m\u{1b}[92m Checking\u{1b}[0m diag-fixture v0.1.0\n\ +src/lib.rs:1:10: \u{1b}[1m\u{1b}[91merror[E0308]\u{1b}[0m: mismatched types\n\ +\u{1b}[1m\u{1b}[91merror\u{1b}[0m: could not compile `diag-fixture` (lib) due to 1 previous error\n"; + +const UNMAPPED_ERROR: &str = "\ +error[E0425]: cannot find value `missing` in this scope + --> src/missing.rs:4:5 + | +"; + +#[tokio::test] +async fn diagnose_reports_literal_mapping_filters_and_refusals() { + let fixture = open_indexed_project().await; + let target_id = exact_symbol_id(&fixture, "target").await; + let caller_id = exact_symbol_id(&fixture, "caller").await; + + let mapped = diagnose_json( + &fixture, + json!({"cargo_output": RUSTC_ERROR, "format": "json"}), + ) + .await; + assert_eq!(mapped["diagnostics"][0]["node"]["node_id"], target_id); + assert_eq!(mapped["diagnostics"][0]["callers"][0]["node_id"], caller_id); + assert_eq!( + without_minted_ids(&mapped), + json!({ + "diagnostics_parsed": 1, + "diagnostics_returned": 1, + "mapped_to_node": 1, + "unmapped": 0, + "truncated": false, + "published": { + "status": "published", + "publication_revision": 1, + "inserted": 1, + "cleared": 0, + "unresolved": [], + "rejected": [] + }, + "diagnostics": [{ + "severity": "error", + "code": "E0308", + "message": "mismatched types", + "file": "src/lib.rs", + "line": 1, + "column": 10, + "node": { + "name": "target", + "kind": "function", + "qualified_name": "src/lib.rs::target", + "file": "src/lib.rs", + "line": 1, + "start_line": 0, + "end_line": 0 + }, + "callers": [{ + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }] + }] + }), + "mapped diagnose payload: {mapped}" + ); + + assert_eq!( + diagnose_text(&fixture, json!({"cargo_output": RUSTC_ERROR})).await, + "\ +## Diagnostics +**Diagnostics parsed:** 1 +**Diagnostics returned:** 1 +**Mapped to node:** 1 +**Unmapped:** 0 +**Truncated:** false + +### Findings +- **ERROR E0308 at src/lib.rs:1:10** + **Message:** mismatched types + **Node:** src/lib.rs::target + **Callers:** caller (src/lib.rs:2) +" + ); + + let colored = diagnose_json( + &fixture, + json!({"cargo_output": COLORED_SHORT_ERROR, "format": "json"}), + ) + .await; + assert_eq!( + without_minted_ids(&colored)["diagnostics"], + json!([{ + "severity": "error", + "code": "E0308", + "message": "mismatched types", + "file": "src/lib.rs", + "line": 1, + "column": 10, + "node": { + "name": "target", + "kind": "function", + "qualified_name": "src/lib.rs::target", + "file": "src/lib.rs", + "line": 1, + "start_line": 0, + "end_line": 0 + }, + "callers": [{ + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }] + }]), + "colored short cargo output must map the same diagnostic: {colored}" + ); + + let errors_only = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR_AND_WARNING, + "severity": "error", + "format": "json" + }), + ) + .await; + assert_eq!(errors_only["diagnostics_parsed"], 1); + assert_eq!(errors_only["diagnostics_returned"], 1); + assert_eq!(errors_only["truncated"], false); + assert_eq!( + without_minted_ids(&errors_only)["diagnostics"], + json!([{ + "severity": "error", + "code": "E0308", + "message": "mismatched types", + "file": "src/lib.rs", + "line": 1, + "column": 10, + "node": { + "name": "target", + "kind": "function", + "qualified_name": "src/lib.rs::target", + "file": "src/lib.rs", + "line": 1, + "start_line": 0, + "end_line": 0 + }, + "callers": [{ + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }] + }]), + "severity=error must keep only the error: {errors_only}" + ); + + let warnings_only = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR_AND_WARNING, + "severity": "warning", + "format": "json" + }), + ) + .await; + assert_eq!( + warnings_only["diagnostics"][0]["node"]["node_id"], + caller_id + ); + assert_eq!( + without_minted_ids(&warnings_only)["diagnostics"], + json!([{ + "severity": "warning", + "code": null, + "message": "unused function", + "file": "src/lib.rs", + "line": 2, + "column": 1, + "node": { + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }, + "callers": [] + }]), + "severity=warning must keep only the warning and no callers: {warnings_only}" + ); + + let truncated = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR_AND_WARNING, + "max_diagnostics": 1, + "format": "json" + }), + ) + .await; + assert_eq!( + ( + truncated["diagnostics_parsed"].as_u64(), + truncated["diagnostics_returned"].as_u64(), + truncated["truncated"].as_bool(), + truncated["diagnostics"][0]["code"].as_str(), + truncated["diagnostics"][0]["message"].as_str(), + ), + ( + Some(2), + Some(1), + Some(true), + Some("E0308"), + Some("mismatched types") + ), + "spanless summary is not a diagnostic, and the cap keeps the first spanned one: {truncated}" + ); + + let hidden_callers = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR, + "include_callers": false, + "format": "json" + }), + ) + .await; + assert_eq!( + hidden_callers["diagnostics"][0]["node"]["node_id"], + target_id + ); + assert_eq!(hidden_callers["diagnostics"][0]["callers"], Value::Null); + assert_eq!(hidden_callers["mapped_to_node"], 1); + + let unmapped = diagnose_json( + &fixture, + json!({"cargo_output": UNMAPPED_ERROR, "format": "json"}), + ) + .await; + assert_eq!( + without_minted_ids(&unmapped)["diagnostics"], + json!([{ + "severity": "error", + "code": "E0425", + "message": "cannot find value `missing` in this scope", + "file": "src/missing.rs", + "line": 4, + "column": 5, + "node": null, + "callers": [] + }]), + "a span outside the graph stays in the result with a null node: {unmapped}" + ); + assert_eq!(unmapped["diagnostics_parsed"], 1); + assert_eq!(unmapped["mapped_to_node"], 0); + assert_eq!(unmapped["unmapped"], 1); + + assert_eq!( + diagnose_text(&fixture, json!({"cargo_output": ""})).await, + "\ +## Diagnostics +**Diagnostics parsed:** 0 +**Diagnostics returned:** 0 +**Mapped to node:** 0 +**Unmapped:** 0 +**Truncated:** false + +_No diagnostics._ +" + ); + + let refused = diagnose_rpc(&fixture, json!({})).await; + let error = refused + .error + .as_ref() + .expect("missing cargo_output is a JSON-RPC error"); + assert_eq!(error.code, -32602); + assert_eq!(error.message, "missing required parameter: cargo_output"); + assert_eq!( + error.data, + Some(json!({ + "tool": "tracedecay_diagnose", + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: cargo_output" + })) + ); + + fixture.harness.shutdown().await; +} + +async fn open_indexed_project() -> ProductionCompositionFixture { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), SOURCE).unwrap(); + }) + .await; + wait_for_graph(&fixture).await; + fixture +} + +async fn wait_for_graph(fixture: &ProductionCompositionFixture) { + tokio::time::timeout(Duration::from_secs(20), async { + loop { + let response = fixture + .harness + .call_tool( + &fixture.project_root, + "tracedecay_status", + json!({ + "format": "json", + "include_branch_diagnostics": false, + "include_storage_health": false, + "include_session_ingest": false, + "include_staleness": false, + }), + ) + .await + .expect("status while the graph is publishing"); + assert!( + response.error.is_none(), + "status failed: {:?}", + response.error + ); + let status = json_text(&response); + let freshness = &status["code_index_freshness"]; + let serving = &freshness["worktree"]["code_graph_serving"]; + match ( + freshness["status"].as_str(), + serving["state"].as_str(), + serving["reason"].as_str(), + freshness["worktree"]["staleness_state"].as_str(), + ) { + (Some("current"), Some("ready"), _, _) => break, + (Some("warming"), _, _, _) + | (Some("stale"), Some("ready"), _, Some("verifying")) + | (_, Some("pending"), _, _) + | (_, Some("unavailable"), Some("generation_unavailable"), _) => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + (_, Some("refused"), _, _) | (_, _, Some("activation_disabled"), _) => { + panic!("graph readiness was refused: {status}"); + } + actual => panic!("graph readiness became {actual:?}: {status}"), + } + } + }) + .await + .expect("graph did not become current within the publication budget"); +} + +async fn exact_symbol_id(fixture: &ProductionCompositionFixture, name: &str) -> String { + let response = fixture + .harness + .call_tool( + &fixture.project_root, + "tracedecay_find_exact_symbol", + json!({"name": name, "limit": 20, "format": "json"}), + ) + .await + .expect("exact symbol read"); + assert!(response.error.is_none(), "{:?}", response.error); + let payload = json_text(&response); + payload["matches"] + .as_array() + .and_then(|matches| matches.iter().find(|item| item["name"] == name)) + .and_then(|item| item["id"].as_str()) + .unwrap_or_else(|| panic!("symbol {name} missing from {payload}")) + .to_owned() +} + +async fn diagnose_json(fixture: &ProductionCompositionFixture, arguments: Value) -> Value { + let response = diagnose_rpc(fixture, arguments).await; + assert!( + response.error.is_none(), + "diagnose failed: {:?}", + response.error + ); + json_text(&response) +} + +async fn diagnose_text(fixture: &ProductionCompositionFixture, arguments: Value) -> String { + let response = diagnose_rpc(fixture, arguments).await; + assert!( + response.error.is_none(), + "diagnose failed: {:?}", + response.error + ); + tool_text(&response) +} + +async fn diagnose_rpc(fixture: &ProductionCompositionFixture, arguments: Value) -> JsonRpcResponse { + fixture + .harness + .call_tool(&fixture.project_root, "tracedecay_diagnose", arguments) + .await + .expect("production MCP tools/call for tracedecay_diagnose") +} + +fn json_text(response: &JsonRpcResponse) -> Value { + let text = tool_text(response); + serde_json::from_str(&text).unwrap_or_else(|error| panic!("{error}\n{text}")) +} + +fn tool_text(response: &JsonRpcResponse) -> String { + response + .result + .as_ref() + .and_then(|result| result["content"][0]["text"].as_str()) + .unwrap_or_else(|| panic!("diagnose returned no text: {response:?}")) + .to_owned() +} + +/// Drop generation-minted occurrence ids so the remaining object is the +/// literal caller-visible diagnostic. +fn without_minted_ids(value: &Value) -> Value { + let mut value = value.clone(); + strip_minted_ids(&mut value); + value +} + +fn strip_minted_ids(value: &mut Value) { + match value { + Value::Array(items) => { + for item in items { + strip_minted_ids(item); + } + } + Value::Object(map) => { + map.remove("node_id"); + map.remove("generation"); + for child in map.values_mut() { + strip_minted_ids(child); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} From bb9642bdfcb46caf26839198a4997d188c209b23 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 18 Sep 2026 18:59:08 +0000 Subject: [PATCH 2/2] fix(ci): repair fmt, clippy, and typed probes Rustfmt was rejecting shared lock matches, the credential catalogue doc comment failed clippy, and three tests asserted the wrong typed outcome when CI has no Codex CLI, a cold dashboard, or the published credential sentence. Co-authored-by: Zack Jackson --- benchmark_data/runtime/tests/test_lifecycle.py | 2 +- .../src/agents/codex/tests.rs | 18 +++++++++++++++++- .../src/code_index_generations/locking.rs | 5 +---- .../src/delivery_api.rs | 2 +- crates/tracedecay-privacy/src/rules.rs | 9 ++++----- .../src/lifecycle_lease.rs | 15 +++------------ 6 files changed, 27 insertions(+), 24 deletions(-) diff --git a/benchmark_data/runtime/tests/test_lifecycle.py b/benchmark_data/runtime/tests/test_lifecycle.py index 9ffdbe3398..3619e395e4 100644 --- a/benchmark_data/runtime/tests/test_lifecycle.py +++ b/benchmark_data/runtime/tests/test_lifecycle.py @@ -160,7 +160,7 @@ def test_dashboard_http_variants_remain_typed_failures(self) -> None: url, request_timeout=0.05, ), - readiness_timeout=0.2, + readiness_timeout=2.0, poll_interval=0.01, termination_grace=0.05, ) diff --git a/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs b/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs index b146a5e75e..741f962bf6 100644 --- a/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs +++ b/crates/tracedecay-agent-hosts/src/agents/codex/tests.rs @@ -992,7 +992,23 @@ fn prepare_stages_the_source_and_returns_ready_for_cli_activation() { let outcome = CodexIntegration .prepare_non_interactive_install(&install_ctx(home.path())) .unwrap(); - assert!(matches!(outcome, NonInteractiveInstallOutcome::Ready)); + // Native activation is `codex plugin add`. Without that CLI the stage is + // still complete, but the typed outcome is the same deferral preflight + // returns rather than a false Ready. + if super::plugin_registry::require_codex_plugin_cli().is_ok() { + assert!(matches!(outcome, NonInteractiveInstallOutcome::Ready)); + } else { + let NonInteractiveInstallOutcome::DeferredUserAction(deferred) = outcome else { + panic!("missing Codex CLI must defer native activation, not {outcome:?}"); + }; + assert!( + deferred + .remediation + .contains("codex plugin add tracedecay@"), + "deferred remediation must name the native plugin add: {}", + deferred.remediation + ); + } assert!(codex_plugin_manifest_path(home.path()).is_file()); assert!(codex_personal_marketplace_path(home.path()).is_file()); assert_eq!( diff --git a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs index 8d53fed465..6bdc552abd 100644 --- a/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs +++ b/crates/tracedecay-code-index-retention/src/code_index_generations/locking.rs @@ -47,10 +47,7 @@ pub fn try_acquire_code_generation_store_read_lock( ) -> Result, CodeGenerationRetentionErrorV1> { let store_root = canonical_store_root(store_root)?; let lock = open_lock_file(&store_root.join(STORE_LOCK_FILE))?; - match lock - .try_lock_shared() - .map_err(std::io::Error::from) - { + match lock.try_lock_shared().map_err(std::io::Error::from) { Ok(()) => Ok(Some(CodeGenerationStoreLockV1 { file: lock, store_root, diff --git a/crates/tracedecay-dashboard-api/src/delivery_api.rs b/crates/tracedecay-dashboard-api/src/delivery_api.rs index df715d0121..4d76aea547 100644 --- a/crates/tracedecay-dashboard-api/src/delivery_api.rs +++ b/crates/tracedecay-dashboard-api/src/delivery_api.rs @@ -2474,7 +2474,7 @@ mod tests { panic!("a gated mount must project as typed unavailable"); }; assert!( - reason.contains("configure a token"), + reason.contains("Configure a token"), "the credential gate must tell the reader what to do: {reason}" ); diff --git a/crates/tracedecay-privacy/src/rules.rs b/crates/tracedecay-privacy/src/rules.rs index 131c2d3f15..82f9f66e4e 100644 --- a/crates/tracedecay-privacy/src/rules.rs +++ b/crates/tracedecay-privacy/src/rules.rs @@ -643,8 +643,7 @@ fn compile_regex( /// /// Gitleaks rules are authored for Go's RE2. RE2 and Rust's `regex` share the /// important restrictions, no backreferences, no lookaround, which is why the -/// catalogue transfers at all. They disagree in exactly two places, and both -/// are mechanical: +/// catalogue transfers at all. They disagree in three mechanical places: /// /// * **A literal `{`.** RE2 reads a brace that opens no valid repetition as a /// literal; Rust refuses it. Upstream depends on the RE2 reading, the global @@ -654,10 +653,10 @@ fn compile_regex( /// so it is *both* a different match and vastly larger to compile: three /// upstream rules that repeat `\w` over a wide bound /// (`pypi-...[\w-]{50,1000}`) blow past the compiler's 10 MB program limit. -/// Expanding `\w` to its RE2 meaning fixes the semantics and the size at once -/// , every rule in the catalogue then compiles under the default limit, with +/// Expanding `\w` to its RE2 meaning fixes the semantics and the size at once, +/// every rule in the catalogue then compiles under the default limit, with /// no memory headroom bought and no rule dropped. -////// * **`\b` / `\B`.** RE2's word boundary is ASCII. Rust's is Unicode-aware, +/// * **`\b` / `\B`.** RE2's word boundary is ASCII. Rust's is Unicode-aware, /// and a Unicode boundary is the one construct the lazy DFA gives up on the /// moment the haystack holds a non-ASCII byte: every file with an em-dash or /// an emoji in a comment was then scanned by the PikeVM, the slowest engine, diff --git a/crates/tracedecay-runtime-core/src/lifecycle_lease.rs b/crates/tracedecay-runtime-core/src/lifecycle_lease.rs index d7a88ae33d..05b95672bd 100644 --- a/crates/tracedecay-runtime-core/src/lifecycle_lease.rs +++ b/crates/tracedecay-runtime-core/src/lifecycle_lease.rs @@ -221,10 +221,7 @@ pub fn acquire_shared_or_inherited(operation: &str) -> Result { fn acquire_shared_or_inherited_at(path: &Path, operation: &str) -> Result { let mut file = open_lock_file(path)?; - match file - .try_lock_shared() - .map_err(std::io::Error::from) - { + match file.try_lock_shared().map_err(std::io::Error::from) { Ok(()) => Ok(LifecycleLease { hold: LeaseHold::File(file), token: None, @@ -384,10 +381,7 @@ fn acquire_exclusive_at_with_timeout( #[hotpath::measure(label = "runtime_core.lifecycle.acquire_shared")] fn acquire_shared_at(path: &Path, operation: &str) -> Result { let mut file = open_lock_file(path)?; - match file - .try_lock_shared() - .map_err(std::io::Error::from) - { + match file.try_lock_shared().map_err(std::io::Error::from) { Ok(()) => Ok(LifecycleLease { hold: LeaseHold::File(file), token: None, @@ -404,10 +398,7 @@ fn acquire_shared_at(path: &Path, operation: &str) -> Result { fn try_acquire_shared_at(path: &Path, operation: &str) -> Result { let file = open_lock_file(path)?; - match file - .try_lock_shared() - .map_err(std::io::Error::from) - { + match file.try_lock_shared().map_err(std::io::Error::from) { Ok(()) => Ok(SharedLeaseAttempt::Acquired(LifecycleLease { hold: LeaseHold::File(file), token: None,