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, diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs index 629adc480e..ee2865e70f 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs @@ -14,6 +14,7 @@ mod branch_search_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(_) => {} + } +}