From d68c6d47c56af9406719708e0e2842c93f7fad37 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 01:00:40 -0400 Subject: [PATCH 01/58] guard activation and refresh tests with deterministic seams --- crates/codestory-cli/src/stdio_transport.rs | 34 ++- .../tests/stdio_protocol_contracts.rs | 100 ++++--- crates/codestory-runtime/src/lib.rs | 30 ++ crates/codestory-runtime/src/services.rs | 271 ++++++++++++++++-- 4 files changed, 378 insertions(+), 57 deletions(-) diff --git a/crates/codestory-cli/src/stdio_transport.rs b/crates/codestory-cli/src/stdio_transport.rs index a2dd9fc79..c52e7b238 100644 --- a/crates/codestory-cli/src/stdio_transport.rs +++ b/crates/codestory-cli/src/stdio_transport.rs @@ -10357,6 +10357,11 @@ mod tests { .clone(); let worker_gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); activation.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + let _cleanup = PreparationTestCleanup { + activation: activation.clone(), + gate: Arc::clone(&worker_gate), + restore_snapshot: None, + }; let (mut client_input, server_input) = tokio::io::duplex(4096); let (server_output, client_output) = tokio::io::duplex(4096); @@ -10488,6 +10493,11 @@ mod tests { .clone(); let worker_gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); activation.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + let _cleanup = PreparationTestCleanup { + activation: activation.clone(), + gate: Arc::clone(&worker_gate), + restore_snapshot: None, + }; let (mut client_input, server_input) = tokio::io::duplex(4096); let (server_output, client_output) = tokio::io::duplex(4096); @@ -10858,6 +10868,11 @@ mod tests { .clone(); let worker_gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); activation.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + let _cleanup = PreparationTestCleanup { + activation: activation.clone(), + gate: Arc::clone(&worker_gate), + restore_snapshot: None, + }; let started = Instant::now(); let response = handle_stdio_message( &mut session, @@ -11137,6 +11152,11 @@ mod tests { .clone(); let worker_gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); activation.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + let _cleanup = PreparationTestCleanup { + activation: activation.clone(), + gate: Arc::clone(&worker_gate), + restore_snapshot: None, + }; let (mut client_input, server_input) = tokio::io::duplex(4096); let (server_output, client_output) = tokio::io::duplex(4096); let serving = tokio::spawn(serve_stdio_requests( @@ -11325,6 +11345,11 @@ mod tests { .clone(); let worker_gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); activation.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + let _cleanup = PreparationTestCleanup { + activation: activation.clone(), + gate: Arc::clone(&worker_gate), + restore_snapshot: None, + }; let (mut client_input, server_input) = tokio::io::duplex(4096); let (server_output, client_output) = tokio::io::duplex(4096); let serving = tokio::spawn(serve_stdio_requests( @@ -14375,7 +14400,7 @@ version = "0.11.20" ) .expect("tool response"); let content = &response["result"]["structuredContent"]; - if content.get("code") == Some(&json!("codestory_preparing")) { + if content.get("kind").and_then(serde_json::Value::as_str) == Some("preparing") { assert!( Instant::now() < deadline, "broad call did not become ready: {content}" @@ -14580,7 +14605,7 @@ version = "0.11.20" ) .expect("packet response"); let content = &response["result"]["structuredContent"]; - if content.get("code") == Some(&json!("codestory_preparing")) { + if content.get("kind").and_then(serde_json::Value::as_str) == Some("preparing") { assert!( Instant::now() < deadline, "packet fixture did not become ready: {content}" @@ -14590,6 +14615,11 @@ version = "0.11.20" )); continue; } + if response.pointer("/result/isError") == Some(&json!(true)) { + panic!( + "packet fixture must converge instead of becoming unavailable: {response}" + ); + } return response; } panic!("packet fixture did not converge within the bounded retry count") diff --git a/crates/codestory-cli/tests/stdio_protocol_contracts.rs b/crates/codestory-cli/tests/stdio_protocol_contracts.rs index 28f2dc3c3..edd47eb63 100644 --- a/crates/codestory-cli/tests/stdio_protocol_contracts.rs +++ b/crates/codestory-cli/tests/stdio_protocol_contracts.rs @@ -1181,12 +1181,6 @@ fn tool_result_code(response: &Value) -> Option { if response.pointer("/result/structuredContent/kind") == Some(&json!("preparing")) { return Some("codestory_preparing".to_string()); } - if let Some(code) = response - .pointer("/result/structuredContent/code") - .and_then(Value::as_str) - { - return Some(code.to_string()); - } response .pointer("/result/content/0/text") .and_then(Value::as_str) @@ -6058,12 +6052,19 @@ fn two_stdio_processes_observe_only_complete_generations_during_real_refresh() { "params": {"uri": "codestory://status", "project": fixture.workspace.path()} }), ); - let old_generation = json_resource_content( + let warmup_content = json_resource_content( assert_success_envelope(&warmup_status, json!("warmup-generation")), "codestory://status", - )["index_publication"]["generation"] + ); + let old_generation = warmup_content["index_publication"]["generation"] .as_u64() .expect("old complete generation"); + let writer_lock_path = PathBuf::from( + warmup_content["storage_path"] + .as_str() + .expect("status storage_path"), + ) + .with_extension("index-writer.lock"); let mut writer_client = spawn_stdio_server(&fixture); initialize_stdio_server(&mut writer_client, "writer-initialize"); thread::sleep(Duration::from_millis(25)); @@ -6092,40 +6093,65 @@ fn two_stdio_processes_observe_only_complete_generations_during_real_refresh() { (writer_client, response) }); - let lock_path = fixture.cache_dir.path().join("local-refresh.lock"); - let lock_deadline = Instant::now() + Duration::from_secs(10); - while !lock_path.exists() { - if writer.is_finished() { - break; + // Owner-scoped barrier: probe the real index-writer lock the refresh run + // holds end to end. A failed try-lock proves the writer is inside the + // publication boundary; a reader request answered in that window proves + // reads were admitted while the writer still held it. Releasing a + // successful probe immediately is safe because the production acquire + // retries within its spawn-ghost budget. + let writer_lock_file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&writer_lock_path) + .expect("open index-writer lock"); + let mut admitted_during_writer_hold = false; + let lock_deadline = Instant::now() + Duration::from_secs(120); + while !admitted_during_writer_hold { + if writer_lock_file + .try_lock_exclusive() + .expect("probe index-writer lock") + { + writer_lock_file.unlock().expect("release probed lock"); + assert!( + !writer.is_finished(), + "writer finished without ever holding the index-writer lock" + ); + } else { + let concurrent_ground = send_json( + &mut reader_client, + json!({ + "jsonrpc": "2.0", + "id": "reader-ground-during-lock", + "method": "resources/read", + "params": { + "uri": "codestory://grounding", + "project": fixture.workspace.path() + } + }), + ); + let concurrent_ground = json_resource_content( + assert_success_envelope(&concurrent_ground, json!("reader-ground-during-lock")), + "codestory://grounding", + ); + assert!( + concurrent_ground["stats"]["file_count"] + .as_u64() + .is_some_and(|count| count == 5 || count == 101), + "concurrent resource read observed neither complete file set: {concurrent_ground}" + ); + admitted_during_writer_hold = true; } assert!( Instant::now() < lock_deadline, - "writer did not acquire the local refresh lock" + "writer never held the index-writer lock while a reader ran" ); thread::sleep(Duration::from_millis(10)); } - - let concurrent_ground = send_json( - &mut reader_client, - json!({ - "jsonrpc": "2.0", - "id": "reader-ground-during-lock", - "method": "resources/read", - "params": { - "uri": "codestory://grounding", - "project": fixture.workspace.path() - } - }), - ); - let concurrent_ground = json_resource_content( - assert_success_envelope(&concurrent_ground, json!("reader-ground-during-lock")), - "codestory://grounding", - ); assert!( - concurrent_ground["stats"]["file_count"] - .as_u64() - .is_some_and(|count| count == 5 || count == 101), - "concurrent resource read observed neither complete file set: {concurrent_ground}" + admitted_during_writer_hold, + "no reader request was admitted while the writer held the publication boundary" ); // Workspace-wide default-concurrency runs can heavily contend with the @@ -6468,10 +6494,10 @@ fn tools_call_local_graph_refreshes_long_lived_index_after_source_mutation() { ); assert!( matches!( - search_error.pointer("/code").and_then(Value::as_str), + tool_result_code(&search_response).as_deref(), Some("codestory_preparing" | "codestory_unavailable") ), - "broad search should use the normal readiness response after local graph refresh: {search_response}" + "broad search should use the normal readiness response after local graph refresh: {search_error}" ); } diff --git a/crates/codestory-runtime/src/lib.rs b/crates/codestory-runtime/src/lib.rs index 6763b2ba6..a181e0424 100644 --- a/crates/codestory-runtime/src/lib.rs +++ b/crates/codestory-runtime/src/lib.rs @@ -475,6 +475,36 @@ pub(crate) fn test_sidecar_runtime_from_env() -> codestory_retrieval::SidecarRun &codestory_retrieval::SidecarRuntimeOverrides::default(), ) } + +/// Runtime whose process-owned defaults carry `cache_root` instead of the +/// ambient process cache. The caller owns the directory and must keep it +/// alive until every activation worker the runtime spawned has quiesced. +#[cfg(test)] +pub(crate) fn test_runtime_with_owned_cache_root(cache_root: &std::path::Path) -> Runtime { + let process_defaults = codestory_retrieval::SidecarProcessDefaults::new( + cache_root.to_path_buf(), + codestory_retrieval::SidecarRuntimeDefaults::from_process_env(), + ); + Runtime::new_with_config( + codestory_retrieval::SidecarRuntimeConfig::for_project_profile_with_process_defaults( + None, + codestory_retrieval::SidecarProfile::Local, + None, + &process_defaults, + &codestory_retrieval::SidecarRuntimeOverrides::default(), + ), + ) +} + +impl Runtime { + /// The cache root captured in this runtime's immutable process defaults. + /// Tests assert it equals their owned directory so a regression back to + /// ambient process defaults is observable without relying on writes. + #[cfg(test)] + pub(crate) fn test_owned_cache_root(&self) -> &std::path::Path { + &self.controller.runtime_config.cache_root + } +} #[doc(hidden)] pub use agent::packet_batch::{ PacketEntryObservationPhase, PacketLatencyScopeGuard, enter_packet_latency_scope, diff --git a/crates/codestory-runtime/src/services.rs b/crates/codestory-runtime/src/services.rs index 0d984bc50..55a13079a 100644 --- a/crates/codestory-runtime/src/services.rs +++ b/crates/codestory-runtime/src/services.rs @@ -5953,7 +5953,8 @@ pub(crate) mod activation_tests { fs::write(linked.join("build/X.java"), "class X {}\n").expect("excluded source"); let storage_path = parent.path().join("cache/codestory.db"); - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .project_service() .open_project_summary_with_storage_path(linked.clone(), storage_path.clone()) @@ -5992,6 +5993,14 @@ pub(crate) mod activation_tests { !service.ready_lease_source_observer_unchanged(Some(&recorded)), "the runtime lease probe must reject state A after the linked index moves to B" ); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); } #[test] @@ -6274,7 +6283,8 @@ pub(crate) mod activation_tests { fn full_activation_admits_complete_core_with_physically_missing_retrieval_pointer() { let (project, _cache, storage_path, retrieval_pointer) = complete_core_without_retrieval_pointer_fixture(); - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let service = runtime.activation_service(); service.arm_preparation_seams_for_test(); @@ -6303,13 +6313,28 @@ pub(crate) mod activation_tests { !retrieval_pointer.exists(), "activation planning must not materialize a retrieval pointer" ); + assert!( + process_cache + .path() + .read_dir() + .expect("read owned runtime cache root") + .next() + .is_none(), + "a seam-stopped activation must not write process defaults under the owned cache root" + ); + assert_eq!( + runtime.test_owned_cache_root(), + process_cache.path(), + "activation must use the injected cache root, not ambient process defaults" + ); } #[test] fn missing_retrieval_pointer_remains_an_observational_error() { let (project, _cache, storage_path, retrieval_pointer) = complete_core_without_retrieval_pointer_fixture(); - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .project_service() .open_core_read_only_with_storage_path( @@ -6334,6 +6359,20 @@ pub(crate) mod activation_tests { !retrieval_pointer.exists(), "ordinary observation must not materialize a retrieval pointer" ); + assert!( + process_cache + .path() + .read_dir() + .expect("read owned runtime cache root") + .next() + .is_none(), + "an observational read must not write process defaults under the owned cache root" + ); + assert_eq!( + runtime.test_owned_cache_root(), + process_cache.path(), + "observation must use the injected cache root, not ambient process defaults" + ); } #[test] @@ -6342,7 +6381,8 @@ pub(crate) mod activation_tests { complete_core_without_retrieval_pointer_fixture(); let corrupt = b"not a retrieval publication database"; fs::write(&retrieval_pointer, corrupt).expect("write corrupt retrieval pointer"); - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let service = runtime.activation_service(); service.arm_preparation_seams_for_test(); @@ -6374,6 +6414,20 @@ pub(crate) mod activation_tests { corrupt, "failed activation must not repair or replace corrupt pointer bytes" ); + assert!( + process_cache + .path() + .read_dir() + .expect("read owned runtime cache root") + .next() + .is_none(), + "a refused activation must not write process defaults under the owned cache root" + ); + assert_eq!( + runtime.test_owned_cache_root(), + process_cache.path(), + "a refused activation must use the injected cache root, not ambient process defaults" + ); } #[cfg(unix)] @@ -6393,7 +6447,8 @@ pub(crate) mod activation_tests { fs::symlink_metadata(&retrieval_pointer).is_ok(), "nofollow metadata must still observe the hostile pointer entry" ); - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let service = runtime.activation_service(); service.arm_preparation_seams_for_test(); @@ -6424,6 +6479,20 @@ pub(crate) mod activation_tests { fs::symlink_metadata(&retrieval_pointer).is_ok(), "failed activation must leave the dangling pointer entry unchanged" ); + assert!( + process_cache + .path() + .read_dir() + .expect("read owned runtime cache root") + .next() + .is_none(), + "a refused activation must not write process defaults under the owned cache root" + ); + assert_eq!( + runtime.test_owned_cache_root(), + process_cache.path(), + "a refused activation must use the injected cache root, not ambient process defaults" + ); } #[test] @@ -6648,6 +6717,8 @@ pub(crate) mod activation_tests { ) .expect("write fixture"); let service = Runtime::new().activation_service(); + let worker_gate = Arc::new((Mutex::new(false), Condvar::new())); + service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); let first = service .activate_project_with_foreground_budget( @@ -6658,6 +6729,15 @@ pub(crate) mod activation_tests { ) .expect_err("zero foreground budget must return typed progress"); assert_eq!(first.code, "activation_preparing"); + let deadline = Instant::now() + Duration::from_secs(10); + while service.worker_start_count_for_test() == 0 && Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!( + service.worker_start_count_for_test(), + 1, + "exactly one shared activation worker must be in flight" + ); let first_snapshot = service.snapshot().expect("running snapshot"); assert!(matches!( first_snapshot.state, @@ -6677,7 +6757,18 @@ pub(crate) mod activation_tests { let joined_snapshot = service.snapshot().expect("joined snapshot"); assert_eq!(joined_snapshot.operation_id, first_snapshot.operation_id); assert_eq!(joined_snapshot.attempt, 1); + assert_eq!( + service.worker_start_count_for_test(), + 1, + "a joining caller must not spawn a second worker" + ); + service.set_worker_start_gate_for_test(None); + let (released, changed) = worker_gate.as_ref(); + *released + .lock() + .expect("activation worker test gate poisoned") = true; + changed.notify_all(); service.cancel_and_wait(); let terminal = service.snapshot().expect("terminal snapshot"); assert_ne!(terminal.state, ActivationState::Ready); @@ -6857,6 +6948,8 @@ pub(crate) mod activation_tests { ) .expect("write fixture"); let service = Runtime::new().activation_service(); + let worker_gate = Arc::new((Mutex::new(false), Condvar::new())); + service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); let first = service .activate_project_with_foreground_budget( @@ -6867,6 +6960,15 @@ pub(crate) mod activation_tests { ) .expect_err("zero foreground budget must return shared progress"); assert_eq!(first.code, "activation_preparing"); + let deadline = Instant::now() + Duration::from_secs(10); + while service.worker_start_count_for_test() == 0 && Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!( + service.worker_start_count_for_test(), + 1, + "one shared activation worker must be in flight before the waiter is cancelled" + ); let before = service.snapshot().expect("shared activation snapshot"); let target = ActivationTarget::new(project.path(), &storage_path); @@ -6887,6 +6989,17 @@ pub(crate) mod activation_tests { assert_eq!(after.operation_id, before.operation_id); assert_ne!(after.state, ActivationState::Cancelled); + assert_eq!( + service.worker_start_count_for_test(), + 1, + "a cancelled waiter must not cancel or replace the shared worker" + ); + service.set_worker_start_gate_for_test(None); + let (released, changed) = worker_gate.as_ref(); + *released + .lock() + .expect("activation worker test gate poisoned") = true; + changed.notify_all(); service.cancel_and_wait(); } @@ -6906,7 +7019,9 @@ pub(crate) mod activation_tests { "pub fn other_project() {}\n", ) .expect("other source file"); - let service = Runtime::new().activation_service(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); + let service = runtime.activation_service(); let worker_gate = Arc::new((Mutex::new(false), Condvar::new())); service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); let first = service @@ -6969,7 +7084,8 @@ pub(crate) mod activation_tests { ActivationCapabilityState::Ready ); assert_eq!(service.worker_start_count_for_test(), 1); - let different_project = Runtime::new().activation_service(); + let different_runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); + let different_project = different_runtime.activation_service(); let different = different_project .activate_with_goal( other_project.path(), @@ -6986,6 +7102,24 @@ pub(crate) mod activation_tests { assert_eq!(different_project.worker_start_count_for_test(), 1); service.cancel_and_wait(); different_project.cancel_and_wait(); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); + assert_eq!( + runtime.test_owned_cache_root(), + process_cache.path(), + "activation must use the injected cache root, not ambient process defaults" + ); + assert_eq!( + different_runtime.test_owned_cache_root(), + process_cache.path(), + "the second runtime must use the injected cache root, not ambient process defaults" + ); } #[test] @@ -7074,7 +7208,8 @@ pub(crate) mod activation_tests { ) .expect("write fixture"); - let seeding_runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let seeding_runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); seeding_runtime .project_service() .open_project_summary_with_storage_path( @@ -7095,7 +7230,7 @@ pub(crate) mod activation_tests { r#"{"members":["src","missing"]}"#, ) .expect("write incomplete synthetic workspace"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); assert_eq!( runtime .activation_service() @@ -7204,6 +7339,14 @@ pub(crate) mod activation_tests { .public_operation_service() .run_with_cancel("ground", Arc::new(AtomicBool::new(false)), || Ok(())) .expect("ground admits the exact post-publication retained core"); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); } #[test] @@ -7238,7 +7381,8 @@ pub(crate) mod activation_tests { ) .expect("write fixture"); - let seeding_runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let seeding_runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); seeding_runtime .project_service() .open_project_summary_with_storage_path( @@ -7257,7 +7401,7 @@ pub(crate) mod activation_tests { .expect("search generation path"); fs::remove_dir_all(&previous_search).expect("remove completed search generation"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let error = runtime .activation_service() .activate_project( @@ -7290,6 +7434,14 @@ pub(crate) mod activation_tests { .project_service() .open_project_with_storage_path(project.path().to_path_buf(), storage_path) .expect("the strict reader must admit the repaired generation"); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); } #[cfg(unix)] @@ -7328,7 +7480,8 @@ pub(crate) mod activation_tests { ) .expect("write project manifest"); - let seeding_runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let seeding_runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); seeding_runtime .project_service() .open_project_summary_with_storage_path( @@ -7355,7 +7508,7 @@ pub(crate) mod activation_tests { .expect("search generation path"); fs::remove_dir_all(&previous_search).expect("remove completed search generation"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let error = runtime .activation_service() .activate_project( @@ -7392,6 +7545,14 @@ pub(crate) mod activation_tests { read_search_generation_completion(&previous_search, &previous.generation_id).is_some(), "activation must publish search completion for the unchanged core generation" ); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); } #[test] @@ -7405,7 +7566,8 @@ pub(crate) mod activation_tests { ) .expect("write fixture"); - let seeding_runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let seeding_runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); seeding_runtime .project_service() .open_project_summary_with_storage_path( @@ -7425,7 +7587,7 @@ pub(crate) mod activation_tests { fs::remove_dir_all(previous_search).expect("remove completed search generation"); mutate_active_generation_sql(&storage_path, "DELETE FROM dense_anchor_publication;"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .activation_service() .activate_project( @@ -7453,6 +7615,14 @@ pub(crate) mod activation_tests { storage .validate_dense_anchor_publication(¤t) .expect("incremental migration must republish dense anchors"); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); } #[test] @@ -7460,7 +7630,15 @@ pub(crate) mod activation_tests { let project_a = tempfile::tempdir().expect("project a"); let project_b = tempfile::tempdir().expect("project b"); let service = Runtime::new().activation_service(); + let hold_until_started = |service: &ActivationService| { + let deadline = Instant::now() + Duration::from_secs(10); + while service.worker_start_count_for_test() == 0 && Instant::now() < deadline { + std::thread::yield_now(); + } + }; + let worker_gate_a = Arc::new((Mutex::new(false), Condvar::new())); + service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate_a))); service .activate_project_with_foreground_budget( project_a.path(), @@ -7469,9 +7647,23 @@ pub(crate) mod activation_tests { Duration::ZERO, ) .expect_err("project a should continue outside the foreground budget"); + hold_until_started(&service); + assert_eq!( + service.worker_start_count_for_test(), + 1, + "project a activation must hold one in-flight worker" + ); let first = service.snapshot().expect("first state"); + service.set_worker_start_gate_for_test(None); + let (released, changed) = worker_gate_a.as_ref(); + *released + .lock() + .expect("activation worker test gate poisoned") = true; + changed.notify_all(); service.cancel_and_wait(); + let worker_gate_b = Arc::new((Mutex::new(false), Condvar::new())); + service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate_b))); service .activate_project_with_foreground_budget( project_b.path(), @@ -7480,7 +7672,22 @@ pub(crate) mod activation_tests { Duration::ZERO, ) .expect_err("project b should continue outside the foreground budget"); + let deadline = Instant::now() + Duration::from_secs(10); + while service.worker_start_count_for_test() < 2 && Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!( + service.worker_start_count_for_test(), + 2, + "project b must start a distinct second worker" + ); let second = service.snapshot().expect("second state"); + service.set_worker_start_gate_for_test(None); + let (released, changed) = worker_gate_b.as_ref(); + *released + .lock() + .expect("activation worker test gate poisoned") = true; + changed.notify_all(); service.cancel_and_wait(); assert_ne!(first.operation_id, second.operation_id); @@ -7507,7 +7714,10 @@ pub(crate) mod activation_tests { let caches = (0..3) .map(|_| tempfile::tempdir().expect("cache")) .collect::>(); - let runtimes = (0..3).map(|_| Runtime::new()).collect::>(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtimes = (0..3) + .map(|_| crate::test_runtime_with_owned_cache_root(process_cache.path())) + .collect::>(); let barrier = Arc::new(std::sync::Barrier::new(3)); let workers = (0..3) @@ -7558,6 +7768,13 @@ pub(crate) mod activation_tests { ); runtime.activation_service().cancel_and_wait(); } + for runtime in &runtimes { + assert_eq!( + runtime.test_owned_cache_root(), + process_cache.path(), + "a cancelled activation must use the injected cache root, not ambient process defaults" + ); + } } #[test] @@ -7602,7 +7819,8 @@ pub(crate) mod activation_tests { let storage_path = project.path().join("cache").join("codestory.db"); let source = project.path().join("fixture.rs"); fs::write(&source, "pub fn fixture() -> u32 { 1 }\n").expect("write fixture"); - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .project_service() .open_project_summary_with_storage_path( @@ -7647,6 +7865,14 @@ pub(crate) mod activation_tests { runtime.activation_service().snapshot().is_none(), "existing complete state must not start managed activation" ); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); } #[test] @@ -7655,7 +7881,8 @@ pub(crate) mod activation_tests { let storage_path = project.path().join("cache").join("codestory.db"); fs::write(project.path().join("fixture.rs"), "pub fn fixture() {}\n") .expect("write fixture"); - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .project_service() .open_project_summary_with_storage_path( @@ -7700,6 +7927,14 @@ pub(crate) mod activation_tests { "managed core recovery must clear the durable incomplete fence" ); runtime.activation_service().cancel_and_wait(); + assert!( + process_cache + .path() + .join("retention") + .join("global_generation_gc.lock") + .is_file(), + "core publication must route its retention lock through the owned process cache" + ); } #[test] From 9938b94bf84bfbbbf3e5adcbbf7e0f54fd67aa4d Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 01:58:13 -0400 Subject: [PATCH 02/58] bind symbol source reads to the indexed content hash --- CHANGELOG.md | 2 + .../tests/stdio_protocol_contracts.rs | 114 +++++ .../src/agent/packet_compiler.rs | 28 +- .../codestory-runtime/src/controller_files.rs | 123 +++-- crates/codestory-runtime/src/grounding.rs | 447 +++++++++++++++--- .../codestory-runtime/src/search_evidence.rs | 48 +- crates/codestory-runtime/src/snippets.rs | 46 +- 7 files changed, 678 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f93bd65ba..0bbc30d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Snippet and source reads for an indexed symbol now verify file bytes against the indexed content hash and refuse stale or missing source with a typed error instead of returning mismatched text. + ## 0.17.6 ### A rebuilt evidence engine for coding agents diff --git a/crates/codestory-cli/tests/stdio_protocol_contracts.rs b/crates/codestory-cli/tests/stdio_protocol_contracts.rs index edd47eb63..151e3caf7 100644 --- a/crates/codestory-cli/tests/stdio_protocol_contracts.rs +++ b/crates/codestory-cli/tests/stdio_protocol_contracts.rs @@ -4077,6 +4077,120 @@ fn snippet_tool_exact_id_navigates_structural_evidence_but_query_stays_typed() { ); } +/// A symbol-identified snippet read must never serve bytes that fail the +/// indexed content hash. Mutating the bound file after indexing makes both +/// the `snippet` tool and the `codestory://snippet/` resource answer with the +/// typed `source_stale` code instead of mismatched text. +#[test] +fn snippet_id_reads_return_source_stale_when_indexed_bytes_change() { + let fixture = indexed_fixture(); + let mut server = spawn_stdio_server(&fixture); + + let ground_response = send_json( + &mut server, + json!({ + "jsonrpc": "2.0", + "id": "ground-stale-snippet", + "method": "tools/call", + "params": {"name": "ground", "arguments": {"budget": "balanced"}} + }), + ); + let grounding = assert_tool_success(&ground_response, json!("ground-stale-snippet")); + let node_id = grounding["root_symbols"] + .as_array() + .into_iter() + .flatten() + .chain( + grounding["files"] + .as_array() + .into_iter() + .flatten() + .flat_map(|file| file["symbols"].as_array().into_iter().flatten()), + ) + .find(|symbol| { + symbol["label"] + .as_str() + .is_some_and(|label| label.starts_with("tiny-stdio-contract-fixture @ ")) + }) + .and_then(|symbol| symbol["id"].as_str()) + .unwrap_or_else(|| panic!("grounding should expose the Cargo package: {grounding:#}")) + .to_string(); + + let manifest_path = fixture.workspace.path().join("Cargo.toml"); + let manifest_original = fs::read_to_string(&manifest_path).expect("read manifest"); + let manifest_mutated = + manifest_original.replace("tiny-stdio-contract-fixture", "tiny-stdio-contract-staledx"); + assert_eq!(manifest_original.len(), manifest_mutated.len()); + let modified = fs::metadata(&manifest_path) + .expect("manifest metadata") + .modified() + .expect("manifest mtime"); + fs::write(&manifest_path, manifest_mutated).expect("mutate indexed manifest"); + fs::OpenOptions::new() + .write(true) + .open(&manifest_path) + .expect("open manifest") + .set_times(std::fs::FileTimes::new().set_modified(modified)) + .expect("restore manifest mtime"); + + // The snippet resource is an observational read: it pins the publication + // that activation already produced instead of refreshing first, so the + // drifted bytes reach the hash check and surface the typed stale code. + let resource_response = send_json( + &mut server, + json!({ + "jsonrpc": "2.0", + "id": "snippet-stale-resource", + "method": "resources/read", + "params": { + "uri": format!("codestory://snippet/{node_id}"), + "project": fixture.workspace.path() + } + }), + ); + let resource_error = assert_error_envelope(&resource_response, json!("snippet-stale-resource")); + assert!( + resource_error["message"] + .as_str() + .is_some_and(|message| message.contains("source_stale")), + "the snippet resource must surface the typed stale-source code: {resource_error:#}" + ); + assert!( + !resource_error["message"] + .as_str() + .is_some_and(|message| message.contains("staledx")), + "the stale-source error must not carry bytes the index never saw: {resource_error:#}" + ); + + let tool_response = send_json( + &mut server, + json!({ + "jsonrpc": "2.0", + "id": "snippet-stale-tool", + "method": "tools/call", + "params": {"name": "snippet", "arguments": {"id": node_id}} + }), + ); + // The snippet tool runs managed activation first: the drift is detected, + // the index republishes, and the pinned id from the previous generation no + // longer resolves. That is still a typed refusal -- the wire must never + // carry the mutated bytes under the old identity. + let tool_error = assert_tool_error(&tool_response, json!("snippet-stale-tool")); + assert!( + matches!( + tool_error["code"].as_str(), + Some("source_stale" | "not_found" | "project_unavailable") + ), + "snippet tool must refuse stale source with a typed code: {tool_error:#}" + ); + assert!( + !serde_json::to_string(&tool_error) + .expect("serialize tool error") + .contains("staledx"), + "the refusal must not carry bytes the index never saw: {tool_error:#}" + ); +} + #[test] fn files_tool_lists_indexed_files_without_sidecars() { let fixture = indexed_fixture(); diff --git a/crates/codestory-runtime/src/agent/packet_compiler.rs b/crates/codestory-runtime/src/agent/packet_compiler.rs index f67eb6039..3f68eee5b 100644 --- a/crates/codestory-runtime/src/agent/packet_compiler.rs +++ b/crates/codestory-runtime/src/agent/packet_compiler.rs @@ -4,11 +4,11 @@ //! [`PacketCompilationInputV1`]. Selection itself lives in //! `codestory-agent` and cannot see the question. +use crate::AppController; use crate::agent::packet_candidate::PacketProofSession; use crate::agent::packet_coverage::PacketCoverageInput; use crate::agent::packet_freshness::PacketFreshnessInput; use crate::agent::packet_scoring::packet_display_path; -use crate::{AppController, BoundedSnippetRangeOptions}; use codestory_agent::evidence_compiler::{ RepositoryDerivedCompilationV1, compile_repository_evidence, }; @@ -343,19 +343,21 @@ fn hydrate_admitted_node_source( } let (start_line, end_line) = valid_source_bounds(node.start_line, node.end_line) .ok_or(PacketAdmissionGapKindV1::SourceBoundMissing)?; - let (_, bounded) = controller - .bounded_file_snippet_range( - &file.path.to_string_lossy(), - BoundedSnippetRangeOptions { - focus_line: start_line, - start_line, - end_line, - context_lines: 0, - max_bytes: source_byte_cap(admission), - truncation_suffix: COMPILER_SOURCE_TRUNCATION_SUFFIX, - }, - ) + let project_root = controller + .require_project_root() + .map_err(|_| PacketAdmissionGapKindV1::SourceUnavailable)?; + let source = crate::search_evidence::verified_file_checked(storage, Some(&project_root), &file) .map_err(|_| PacketAdmissionGapKindV1::SourceUnavailable)?; + let bounded = crate::snippets::bounded_markdown_snippet_range_from_text( + &source.content, + start_line, + start_line, + end_line, + 0, + source_byte_cap(admission), + COMPILER_SOURCE_TRUNCATION_SUFFIX, + ) + .map_err(|_| PacketAdmissionGapKindV1::SourceUnavailable)?; hydrated_source( admission, &file.path.to_string_lossy(), diff --git a/crates/codestory-runtime/src/controller_files.rs b/crates/codestory-runtime/src/controller_files.rs index 98d127ee9..ed8123a89 100644 --- a/crates/codestory-runtime/src/controller_files.rs +++ b/crates/codestory-runtime/src/controller_files.rs @@ -1,6 +1,5 @@ use crate::snippets::{ - BoundedSnippet, BoundedSnippetRangeOptions, bounded_markdown_snippet_from_path, - bounded_markdown_snippet_range_from_path, + BoundedSnippet, BoundedSnippetRangeOptions, bounded_markdown_snippet_range_from_path, }; use crate::support::clamp_i64_to_u32; use crate::{AppController, path_resolution}; @@ -9,22 +8,48 @@ use codestory_contracts::api::{ }; use std::path::PathBuf; +/// Source bytes for a symbol-identified node, verified against the pinned +/// core publication. `content` is the single buffer every renderer must use: +/// the path was checked for project containment, read once, and hashed against +/// the indexed SHA-256, so no caller may reopen `path` afterwards. +pub(crate) struct VerifiedNodeSource { + pub(crate) file_id: i64, + pub(crate) start_line: u32, + pub(crate) end_line: Option, + pub(crate) path: PathBuf, + pub(crate) content: String, + pub(crate) content_sha256: String, + /// Publication the verified bytes were bound to. `None` only for an + /// unpinned caller whose storage declares no complete publication (a state + /// unreachable through the public operation wrappers, which pin one). + pub(crate) generation_id: Option, + pub(crate) run_id: Option, + pub(crate) project_id: String, +} + impl AppController { - pub(crate) fn focused_source_context( + /// Resolve a public node identity to source bytes that provably match the + /// indexed content hash under the currently pinned core publication. + /// Returns `source_stale` when the file exists but no longer matches the + /// indexed bytes, and `source_unavailable` for every other failure. + pub(crate) fn verified_node_source( &self, node: &codestory_contracts::api::NodeDetailsDto, - maximum_bytes: usize, - truncation_suffix: &str, - ) -> Result { + ) -> Result { let unavailable = || { ApiError::new( "source_unavailable", "Focused source could not be bound to the pinned file.", ) }; - let publication = self.active_core_publication().ok_or_else(unavailable)?; let project_root = self.require_project_root()?; let storage = self.open_storage_read_only()?; + // Public operations pin one complete publication for their whole read; + // an unpinned caller binds to the complete publication the same + // storage handle declares, which is the pin's own record under a pin. + let publication = self + .active_core_publication() + .or_else(|| storage.get_complete_index_publication().ok().flatten()); let id = node.id.0.parse::().map_err(|_| unavailable())?; let stored_node = storage .get_node(codestory_contracts::graph::NodeId(id)) @@ -44,8 +69,55 @@ impl AppController { if node.start_line != Some(line) { return Err(unavailable()); } - let source = crate::search_evidence::verified_file(&storage, Some(&project_root), &file) - .ok_or_else(unavailable)?; + let source = crate::search_evidence::verified_file_checked( + &storage, + Some(&project_root), + &file, + ) + .map_err(|error| match error { + crate::search_evidence::VerifiedFileError::Stale => ApiError::new( + "source_stale", + format!( + "Indexed source for {} changed on disk after publication; refresh the index before reading symbol source.", + file.path.display() + ), + ), + crate::search_evidence::VerifiedFileError::Unavailable => unavailable(), + })?; + Ok(VerifiedNodeSource { + file_id, + start_line: line, + end_line: stored_node.end_line, + path: source.path, + content: source.content, + content_sha256: source.content_sha256, + generation_id: publication + .as_ref() + .map(|publication| publication.generation_id.clone()), + run_id: publication + .as_ref() + .map(|publication| publication.run_id.clone()), + project_id: codestory_workspace::project_identity_v3(&project_root).project_id, + }) + } + + pub(crate) fn focused_source_context( + &self, + node: &codestory_contracts::api::NodeDetailsDto, + maximum_bytes: usize, + truncation_suffix: &str, + ) -> Result { + let unavailable = || { + ApiError::new( + "source_unavailable", + "Focused source could not be bound to the pinned file.", + ) + }; + let source = self.verified_node_source(node)?; + if source.generation_id.is_none() || source.run_id.is_none() { + return Err(unavailable()); + } + let line = source.start_line; let window = crate::snippets::bounded_source_window(&source.content, line, 6, maximum_bytes) .ok_or_else(unavailable)?; @@ -65,12 +137,12 @@ impl AppController { truncated: rendered.truncated, evidence: codestory_contracts::api::FocusedSourceEvidenceDto { node_id: node.id.clone(), - file_id, + file_id: source.file_id, path, - project_id: codestory_workspace::project_identity_v3(&project_root).project_id, - core_generation_id: publication.generation_id, - core_run_id: publication.run_id, - content_sha256: source.content_sha256, + project_id: source.project_id.clone(), + core_generation_id: source.generation_id.clone().unwrap_or_default(), + core_run_id: source.run_id.clone().unwrap_or_default(), + content_sha256: source.content_sha256.clone(), start_line: window.start_line, end_line: window.end_line, excerpt: window.text, @@ -173,29 +245,6 @@ impl AppController { }) } - pub(crate) fn bounded_file_snippet( - &self, - path: &str, - line: u32, - context_lines: usize, - max_bytes: usize, - truncation_suffix: &str, - ) -> Result<(String, BoundedSnippet), ApiError> { - let candidate = self.resolve_project_file_path(path, false)?; - let snippet = bounded_markdown_snippet_from_path( - &candidate, - line, - context_lines, - max_bytes, - truncation_suffix, - ) - .map_err(|e| { - ApiError::internal(format!("Failed to read file {}: {e}", candidate.display())) - })?; - - Ok((candidate.to_string_lossy().to_string(), snippet)) - } - pub(crate) fn bounded_file_snippet_range( &self, path: &str, diff --git a/crates/codestory-runtime/src/grounding.rs b/crates/codestory-runtime/src/grounding.rs index e04c09984..59f34d0f1 100644 --- a/crates/codestory-runtime/src/grounding.rs +++ b/crates/codestory-runtime/src/grounding.rs @@ -1454,28 +1454,27 @@ impl AppController { context_lines: usize, ) -> Result { let node = self.node_details(NodeDetailsRequest { id: node_id })?; - let path = node - .file_path - .clone() - .ok_or_else(|| { - ApiError::invalid_argument( - "Symbol has no source file; use symbol/trail children or occurrences to choose a path-backed anchor.", - ) - })?; - let line = node - .start_line - .ok_or_else(|| { - ApiError::invalid_argument( - "Symbol has no source line; use occurrences or a child method before requesting a snippet.", - ) - })?; - let (path, bounded) = self.bounded_file_snippet( - &path, - line, + if node.file_path.is_none() { + return Err(ApiError::invalid_argument( + "Symbol has no source file; use symbol/trail children or occurrences to choose a path-backed anchor.", + )); + } + if node.start_line.is_none() { + return Err(ApiError::invalid_argument( + "Symbol has no source line; use occurrences or a child method before requesting a snippet.", + )); + } + let source = self.verified_node_source(&node)?; + let bounded = crate::snippets::bounded_markdown_snippet_from_text( + &source.content, + source.start_line, context_lines, crate::DIRECT_SNIPPET_MAX_BYTES, crate::DIRECT_SNIPPET_TRUNCATION_SUFFIX, - )?; + ) + .map_err(|_| ApiError::internal("Verified source snippet could not be rendered."))?; + let path = source.path.to_string_lossy().into_owned(); + let line = source.start_line; Ok(SnippetContextDto { node, @@ -1498,29 +1497,27 @@ impl AppController { context_lines: usize, ) -> Result { let node = self.node_details(NodeDetailsRequest { id: node_id })?; - let path = node - .file_path - .clone() - .ok_or_else(|| { - ApiError::invalid_argument( - "Symbol has no source file; use symbol/trail children or occurrences to choose a path-backed anchor.", - ) - })?; - let line = node - .start_line - .ok_or_else(|| { - ApiError::invalid_argument( - "Symbol has no source line; use occurrences or a child method before requesting a snippet.", - ) - })?; - let range = match node.end_line.filter(|end| *end >= line) { + if node.file_path.is_none() { + return Err(ApiError::invalid_argument( + "Symbol has no source file; use symbol/trail children or occurrences to choose a path-backed anchor.", + )); + } + if node.start_line.is_none() { + return Err(ApiError::invalid_argument( + "Symbol has no source line; use occurrences or a child method before requesting a snippet.", + )); + } + let source = self.verified_node_source(&node)?; + let path = source.path.to_string_lossy().into_owned(); + let line = source.start_line; + let range = match source.end_line.filter(|end| *end >= line) { Some(end_line) if end_line > line => Some(FunctionBodyRange { end_line, range_source: "indexed_symbol_range", fallback_reason: None, }), Some(end_line) => { - match self.brace_balanced_function_body_end_line(&path, line)? { + match brace_balanced_function_body_end_line(&path, &source.content, line) { Some(fallback_end_line) if fallback_end_line > end_line => { Some(FunctionBodyRange { end_line: fallback_end_line, @@ -1538,9 +1535,8 @@ impl AppController { None => None, } } - None => self - .brace_balanced_function_body_end_line(&path, line)? - .map(|end_line| FunctionBodyRange { + None => brace_balanced_function_body_end_line(&path, &source.content, line).map( + |end_line| FunctionBodyRange { end_line, range_source: "brace_balanced_fallback", fallback_reason: Some( @@ -1558,17 +1554,16 @@ impl AppController { context.range_source = Some("line_context".to_string()); return Ok(context); }; - let (path, bounded) = self.bounded_file_snippet_range( - &path, - crate::BoundedSnippetRangeOptions { - focus_line: line, - start_line: line, - end_line: range.end_line, - context_lines, - max_bytes: crate::DIRECT_SNIPPET_MAX_BYTES, - truncation_suffix: crate::DIRECT_SNIPPET_TRUNCATION_SUFFIX, - }, - )?; + let bounded = crate::snippets::bounded_markdown_snippet_range_from_text( + &source.content, + line, + line, + range.end_line, + context_lines, + crate::DIRECT_SNIPPET_MAX_BYTES, + crate::DIRECT_SNIPPET_TRUNCATION_SUFFIX, + ) + .map_err(|_| ApiError::internal("Verified source snippet could not be rendered."))?; Ok(SnippetContextDto { node, @@ -1584,27 +1579,26 @@ impl AppController { truncation_guidance: snippet_truncation_guidance(bounded.truncated, context_lines), }) } +} - fn brace_balanced_function_body_end_line( - &self, - path: &str, - start_line: u32, - ) -> Result, ApiError> { - if !path_supports_brace_balanced_function_fallback(path) { - return Ok(None); - } - let source = self.read_file_text(codestory_contracts::api::ReadFileTextRequest { - path: path.to_string(), - })?; - Ok(brace_balanced_body_end_line( - &source.text, - start_line, - Path::new(path) - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")), - )) +/// Expand a function-body range over already verified source text. The caller +/// passes the verified buffer, so this never performs a second path read. +fn brace_balanced_function_body_end_line( + path: &str, + content: &str, + start_line: u32, +) -> Option { + if !path_supports_brace_balanced_function_fallback(path) { + return None; } + brace_balanced_body_end_line( + content, + start_line, + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("rs")), + ) } struct FunctionBodyRange { @@ -1888,6 +1882,8 @@ mod tests { Edge, EdgeId, EdgeKind, Node, NodeId as CoreNodeId, NodeKind, Occurrence, OccurrenceKind, SourceLocation, }; + use sha2::Digest; + use std::path::PathBuf; use tempfile::tempdir; /// The inferred function body must end at the real closing brace, not at @@ -1983,7 +1979,7 @@ mod tests { language: &str, child: Node, ) -> Result<(), Box> { - storage.insert_file(&FileInfo { + let file = FileInfo { id: file_id, path: path.to_path_buf(), language: language.to_string(), @@ -1992,7 +1988,17 @@ mod tests { complete: true, line_count: 10, file_role, - })?; + }; + storage.insert_file(&file)?; + // Symbol-identified reads verify the indexed content hash, so record + // the hash of any on-disk fixture bytes. Virtual paths (no file) leave + // the hash unset, matching a publication that never saw the content. + if let Ok(bytes) = std::fs::read(path) { + let hash = format!("{:x}", sha2::Sha256::digest(&bytes)); + storage + .update_file_metadata(&file, Some(hash.as_str())) + .expect("bind fixture content hash"); + } storage.insert_nodes_batch(&[ Node { id: CoreNodeId(file_id), @@ -3494,6 +3500,305 @@ mod tests { assert!(snippet.snippet.contains("payload.create")); } + /// One-function project fixture. `insert_file_node` binds the indexed + /// content hash and publication row, so symbol-identified source reads have + /// a hash to verify against. + fn snippet_source_fixture( + content: &str, + start_line: u32, + end_line: Option, + ) -> (tempfile::TempDir, AppController, PathBuf) { + let temp = tempdir().expect("temp dir"); + let db_path = temp.path().join("cache").join("codestory.db"); + std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); + let source_path = temp.path().join("src").join("lib.rs"); + std::fs::create_dir_all(source_path.parent().expect("src parent")).expect("create src"); + std::fs::write(&source_path, content).expect("write source"); + { + let mut storage = Storage::open(&db_path).expect("open storage"); + insert_file_node( + &mut storage, + 11, + &source_path, + Node { + id: CoreNodeId(101), + kind: NodeKind::FUNCTION, + serialized_name: "route_handler".to_string(), + file_node_id: Some(CoreNodeId(11)), + start_line: Some(start_line), + end_line, + ..Default::default() + }, + ) + .expect("insert function"); + } + let controller = AppController::new(); + controller + .open_project_with_storage_path(temp.path().to_path_buf(), db_path) + .expect("open project"); + (temp, controller, source_path) + } + + fn snippet_node_id() -> codestory_contracts::api::NodeId { + codestory_contracts::api::NodeId("101".to_string()) + } + + /// Inserting lines above the node leaves the indexed line naming a + /// different expression. The symbol-identified read must refuse with + /// `source_stale` rather than serve bytes the index never saw. + #[test] + fn snippet_context_reports_source_stale_after_source_shift() { + let (_temp, controller, source_path) = snippet_source_fixture( + "fn before() {}\n\nfn route_handler() {\n payload.create();\n}\n", + 3, + Some(5), + ); + std::fs::write( + &source_path, + "fn before() {}\n\nlet inserted_binding = compute();\n\nfn route_handler() {\n payload.create();\n}\n", + ) + .expect("shift source lines"); + + let error = controller + .snippet_context(snippet_node_id(), 0) + .expect_err("shifted source must not be served under the indexed id"); + assert_eq!(error.code, "source_stale"); + assert!( + !error.message.contains("inserted_binding") && !error.message.contains("route_handler"), + "a stale-source error carries no snippet or node-labelled text: {}", + error.message + ); + } + + /// Metadata shortcuts cannot soften the check: identical length and mtime + /// still fail the indexed content hash. + #[test] + fn snippet_context_reports_source_stale_for_same_length_same_mtime_edit() { + let (_temp, controller, source_path) = snippet_source_fixture( + "fn route_handler() {\n payload.create();\n}\n", + 1, + Some(3), + ); + let modified = std::fs::metadata(&source_path) + .expect("metadata") + .modified() + .expect("mtime"); + std::fs::write( + &source_path, + "fn route_handler() {\n payload.update();\n}\n", + ) + .expect("same-length edit"); + std::fs::OpenOptions::new() + .write(true) + .open(&source_path) + .expect("open source") + .set_times(std::fs::FileTimes::new().set_modified(modified)) + .expect("restore mtime"); + + let error = controller + .snippet_context(snippet_node_id(), 0) + .expect_err("same-mtime byte change must not be served"); + assert_eq!(error.code, "source_stale"); + } + + /// The stale-source refusal applies identically to function-body reads, + /// whether the node carries an indexed end line or needs the brace-balanced + /// fallback: the verification runs before any range is derived. + #[test] + fn snippet_function_body_reports_source_stale_after_source_shift() { + let content = "fn before() {}\n\nfn route_handler() {\n payload.create();\n}\n"; + let shifted = "fn before() {}\n\nlet inserted_binding = compute();\n\nfn route_handler() {\n payload.create();\n}\n"; + for end_line in [Some(5), None] { + let (_temp, controller, source_path) = snippet_source_fixture(content, 3, end_line); + std::fs::write(&source_path, shifted).expect("shift source lines"); + let error = controller + .snippet_function_body_context(snippet_node_id(), 0) + .expect_err("function body must not read shifted source"); + assert_eq!(error.code, "source_stale", "end_line {end_line:?}"); + } + } + + /// Only the target file's hash gates its read; mutating an unrelated file + /// must not deny a byte-identical target. + #[test] + fn snippet_context_succeeds_when_only_an_unrelated_file_changes() { + let temp = tempdir().expect("temp dir"); + let db_path = temp.path().join("cache").join("codestory.db"); + std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); + let src_dir = temp.path().join("src"); + std::fs::create_dir_all(&src_dir).expect("create src"); + let source_path = src_dir.join("lib.rs"); + let other_path = src_dir.join("other.rs"); + std::fs::write( + &source_path, + "fn route_handler() {\n payload.create();\n}\n", + ) + .expect("write source"); + std::fs::write(&other_path, "pub fn other() {}\n").expect("write other"); + { + let mut storage = Storage::open(&db_path).expect("open storage"); + insert_file_node( + &mut storage, + 11, + &source_path, + Node { + id: CoreNodeId(101), + kind: NodeKind::FUNCTION, + serialized_name: "route_handler".to_string(), + file_node_id: Some(CoreNodeId(11)), + start_line: Some(1), + end_line: Some(3), + ..Default::default() + }, + ) + .expect("insert function"); + insert_file_node( + &mut storage, + 12, + &other_path, + Node { + id: CoreNodeId(102), + kind: NodeKind::FUNCTION, + serialized_name: "other".to_string(), + file_node_id: Some(CoreNodeId(12)), + start_line: Some(1), + end_line: Some(1), + ..Default::default() + }, + ) + .expect("insert other"); + } + let controller = AppController::new(); + controller + .open_project_with_storage_path(temp.path().to_path_buf(), db_path) + .expect("open project"); + + std::fs::write(&other_path, "pub fn other() { panic!() }\n").expect("mutate other"); + + let snippet = controller + .snippet_context(snippet_node_id(), 2) + .expect("byte-identical target stays readable"); + assert_eq!(snippet.line, 1); + assert!(snippet.snippet.contains("route_handler")); + assert!(snippet.snippet.contains("payload.create")); + assert!(!snippet.snippet.contains("panic")); + } + + /// A real republish rebinds the same stable id: after the stored hash and + /// node lines are updated for shifted content, the read serves the new line + /// and the new bytes. + #[test] + fn snippet_context_serves_republished_source_for_the_same_node_id() { + let (_temp, controller, source_path) = snippet_source_fixture( + "fn before() {}\n\nfn route_handler() {\n payload.create();\n}\n", + 3, + Some(5), + ); + let republished = "fn before() {}\n\nlet inserted_binding = compute();\n\nfn route_handler() {\n payload.create();\n}\n"; + std::fs::write(&source_path, republished).expect("shift source lines"); + + let db_path = controller.require_storage_path().expect("storage path"); + let mut storage = Storage::open(&db_path).expect("reopen storage"); + let file = storage + .get_file_by_id(11) + .expect("file query") + .expect("file"); + let hash = format!("{:x}", sha2::Sha256::digest(republished.as_bytes())); + storage + .update_file_metadata(&file, Some(hash.as_str())) + .expect("republish content hash"); + storage + .insert_nodes_batch(&[Node { + id: CoreNodeId(101), + kind: NodeKind::FUNCTION, + serialized_name: "route_handler".to_string(), + file_node_id: Some(CoreNodeId(11)), + start_line: Some(5), + end_line: Some(7), + ..Default::default() + }]) + .expect("republish node lines"); + drop(storage); + + let snippet = controller + .snippet_context(snippet_node_id(), 2) + .expect("republished source is served under the same id"); + assert_eq!(snippet.line, 5); + assert!(snippet.snippet.contains("route_handler")); + assert!(snippet.snippet.contains("inserted_binding")); + } + + #[test] + fn snippet_context_reports_source_unavailable_when_target_is_deleted() { + let (_temp, controller, source_path) = snippet_source_fixture( + "fn route_handler() {\n payload.create();\n}\n", + 1, + Some(3), + ); + std::fs::remove_file(&source_path).expect("delete source"); + + let error = controller + .snippet_context(snippet_node_id(), 0) + .expect_err("deleted source is unavailable"); + assert_eq!(error.code, "source_unavailable"); + } + + /// A symlink at the indexed path that escapes the project is unavailable, + /// not stale: containment fails before any byte is read. + #[cfg(unix)] + #[test] + fn snippet_context_reports_source_unavailable_for_symlink_escape() { + let outside = tempdir().expect("outside dir"); + let outside_path = outside.path().join("escaped.rs"); + std::fs::write(&outside_path, "fn escaped() {}\n").expect("write outside"); + let (_temp, controller, source_path) = snippet_source_fixture( + "fn route_handler() {\n payload.create();\n}\n", + 1, + Some(3), + ); + std::fs::remove_file(&source_path).expect("remove target"); + std::os::unix::fs::symlink(&outside_path, &source_path).expect("symlink escape"); + + let error = controller + .snippet_context(snippet_node_id(), 0) + .expect_err("escaping symlink is unavailable"); + assert_eq!(error.code, "source_unavailable"); + } + + /// Rendering consumes the verified in-memory buffer, never the path: bytes + /// written between verification and render cannot leak into the snippet. + #[test] + fn verified_node_source_retains_indexed_bytes_for_later_render() { + let (_temp, controller, source_path) = snippet_source_fixture( + "fn before() {}\n\nfn route_handler() {\n payload.create();\n}\n", + 3, + Some(5), + ); + let node = controller + .node_details(NodeDetailsRequest { + id: snippet_node_id(), + }) + .expect("node details"); + let source = controller + .verified_node_source(&node) + .expect("verified node source"); + + std::fs::write(&source_path, "fn compromised() { exfiltrate(); }\n") + .expect("post-verification edit"); + + let rendered = crate::snippets::bounded_markdown_snippet_from_text( + &source.content, + source.start_line, + 2, + crate::DIRECT_SNIPPET_MAX_BYTES, + crate::DIRECT_SNIPPET_TRUNCATION_SUFFIX, + ) + .expect("render verified buffer"); + assert!(rendered.markdown.contains("route_handler")); + assert!(rendered.markdown.contains("payload.create")); + assert!(!rendered.markdown.contains("compromised")); + } + #[test] fn declaration_only_range_is_not_reported_as_a_function_body() { let temp = tempdir().expect("temp dir"); diff --git a/crates/codestory-runtime/src/search_evidence.rs b/crates/codestory-runtime/src/search_evidence.rs index fcccd7203..e1f249308 100644 --- a/crates/codestory-runtime/src/search_evidence.rs +++ b/crates/codestory-runtime/src/search_evidence.rs @@ -168,30 +168,62 @@ fn bounded_source_window_at_line(content: &str, line: u32) -> Option { Some(lines[start..end].join("\n")) } -pub(crate) fn verified_file( +/// Why a registered file could not be served as verified source. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum VerifiedFileError { + /// The file exists and is readable, but its bytes no longer hash to the + /// indexed SHA-256. The index is stale for this file. + Stale, + /// Missing, unreadable, non-UTF-8, escaping project containment, or no + /// indexed content hash is recorded. + Unavailable, +} + +/// Read a registered file from disk only when its bytes still hash to the +/// indexed SHA-256. The file is read exactly once; callers distinguish +/// `Stale` (source changed since indexing) from `Unavailable` (missing, +/// unreadable, or out of bounds) instead of receiving mismatched text. +pub(crate) fn verified_file_checked( storage: &Store, project_root: Option<&Path>, file: &FileInfo, -) -> Option { - let expected_hash = storage.get_file_content_hash(file.id).ok().flatten()?; +) -> Result { + let expected_hash = storage + .get_file_content_hash(file.id) + .ok() + .flatten() + .ok_or(VerifiedFileError::Unavailable)?; let path = resolve_path(project_root, &file.path); let read_path = match project_root { - Some(root) => contained_existing_read_path(root, &path).ok()?, + Some(root) => { + contained_existing_read_path(root, &path).map_err(|_| VerifiedFileError::Unavailable)? + } None => path.clone(), }; #[cfg(test)] VERIFIED_SOURCE_READS.with(|count| count.set(count.get() + 1)); - let bytes = std::fs::read(read_path).ok()?; + let bytes = std::fs::read(read_path).map_err(|_| VerifiedFileError::Unavailable)?; if format!("{:x}", Sha256::digest(&bytes)) != expected_hash { - return None; + return Err(VerifiedFileError::Stale); } - Some(VerifiedSourceFile { + Ok(VerifiedSourceFile { path, - content: String::from_utf8(bytes).ok()?, + content: String::from_utf8(bytes).map_err(|_| VerifiedFileError::Unavailable)?, content_sha256: expected_hash, }) } +/// Read a registered file from disk only when its bytes still hash to the +/// indexed SHA-256. Missing, unreadable, or stale files return `None` so the +/// caller can surface a `source_unavailable` error instead of mismatched text. +pub(crate) fn verified_file( + storage: &Store, + project_root: Option<&Path>, + file: &FileInfo, +) -> Option { + verified_file_checked(storage, project_root, file).ok() +} + fn contained_existing_read_path(project_root: &Path, path: &Path) -> std::io::Result { let resolution = codestory_workspace::resolve_project_relative_path(project_root, path)?; let codestory_workspace::ProjectRelativePathResolution::Existing { absolute, .. } = resolution diff --git a/crates/codestory-runtime/src/snippets.rs b/crates/codestory-runtime/src/snippets.rs index 2b347394a..133698fa4 100644 --- a/crates/codestory-runtime/src/snippets.rs +++ b/crates/codestory-runtime/src/snippets.rs @@ -100,6 +100,7 @@ pub(crate) fn bounded_direct_markdown_snippet( ) } +#[cfg(test)] pub(super) fn bounded_markdown_snippet_from_path( path: &Path, focus_line: u32, @@ -241,7 +242,50 @@ pub(super) fn bounded_markdown_snippet_range_from_path( truncation_suffix: &str, ) -> io::Result { let file = std::fs::File::open(path)?; - let mut reader = io::BufReader::new(file); + let reader = io::BufReader::new(file); + bounded_markdown_snippet_range_from_reader( + reader, + focus_line, + start_line, + end_line, + context, + max_bytes, + truncation_suffix, + ) +} + +/// Render an explicit line range from an already verified UTF-8 buffer. +/// Callers that carry a symbol identity must pass verified content instead of +/// reopening the path: a second read could serve bytes the index never saw. +pub(crate) fn bounded_markdown_snippet_range_from_text( + text: &str, + focus_line: u32, + start_line: u32, + end_line: u32, + context: usize, + max_bytes: usize, + truncation_suffix: &str, +) -> io::Result { + bounded_markdown_snippet_range_from_reader( + io::Cursor::new(text.as_bytes()), + focus_line, + start_line, + end_line, + context, + max_bytes, + truncation_suffix, + ) +} + +fn bounded_markdown_snippet_range_from_reader( + mut reader: impl BufRead, + focus_line: u32, + start_line: u32, + end_line: u32, + context: usize, + max_bytes: usize, + truncation_suffix: &str, +) -> io::Result { let context = context.min(DIRECT_SNIPPET_CONTEXT_LINE_CAP) as u32; let focus = focus_line.max(1); let start = start_line.saturating_sub(context).max(1); From 8155928570cd84c7e327256377c8fd260341e131 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 03:53:18 -0400 Subject: [PATCH 03/58] admit retained reads by operation class and stamp runtime freshness --- CHANGELOG.md | 1 + .../src/app/agent_context/packet.rs | 2 + .../src/app/source_commands/source_read.rs | 2 +- crates/codestory-cli/src/app/tests/drill.rs | 3 + .../app/tests/lifecycle/packet_diagnostics.rs | 2 + crates/codestory-cli/src/output.rs | 2 + crates/codestory-cli/src/runtime.rs | 34 +- crates/codestory-cli/src/stdio_transport.rs | 795 +++++++++++++++++- .../tests/http_transport_contracts.rs | 4 +- .../tests/stdio_protocol_contracts.rs | 215 +++-- crates/codestory-contracts/src/wire.rs | 9 +- crates/codestory-runtime/src/browser.rs | 18 +- .../codestory-runtime/src/controller_core.rs | 44 +- .../src/controller_indexing.rs | 34 +- crates/codestory-runtime/src/grounding.rs | 2 +- crates/codestory-runtime/src/lib.rs | 9 +- crates/codestory-runtime/src/services.rs | 294 ++++++- docs/architecture/runtime-execution-path.md | 16 +- plugins/codestory/generated-mcp-catalog.json | 2 +- .../skills/codestory-grounding/SKILL.md | 8 + .../codestory-grounding/references/snippet.md | 7 + .../references/status-contract.md | 10 + 22 files changed, 1367 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0bbc30d34..fbc8bfe73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Snippet and source reads for an indexed symbol now verify file bytes against the indexed content hash and refuse stale or missing source with a typed error instead of returning mismatched text. +- During a refresh, source-backed reads wait for the fresh index while graph-only answers may come from the retained publication; results served that way are labelled `historical` under `_meta.codestory_publication.freshness`. ## 0.17.6 diff --git a/crates/codestory-cli/src/app/agent_context/packet.rs b/crates/codestory-cli/src/app/agent_context/packet.rs index 958088a24..23f22cfec 100644 --- a/crates/codestory-cli/src/app/agent_context/packet.rs +++ b/crates/codestory-cli/src/app/agent_context/packet.rs @@ -110,6 +110,7 @@ pub(in crate::app) fn run_packet(cmd: PacketCommand) -> Result<()> { retrieval_publication: operation.retrieval_publication.clone(), operation_id: operation.operation_id.clone(), attempt: operation.attempt, + freshness: operation.freshness, }; codestory_runtime::finalize_packet_projection_v3_for_representation( &mut operation.value.projection, @@ -270,6 +271,7 @@ pub(in crate::app) fn enforce_packet_cli_json_output_budget( retrieval_publication: operation.retrieval_publication.clone(), operation_id: operation.operation_id.clone(), attempt: operation.attempt, + freshness: operation.freshness, }; let _ = render_public_operation_json_content(&envelope, &operation.value)?; codestory_runtime::enforce_packet_output_budget_for_representation( diff --git a/crates/codestory-cli/src/app/source_commands/source_read.rs b/crates/codestory-cli/src/app/source_commands/source_read.rs index 0a610cca9..228f76126 100644 --- a/crates/codestory-cli/src/app/source_commands/source_read.rs +++ b/crates/codestory-cli/src/app/source_commands/source_read.rs @@ -27,7 +27,7 @@ pub(in crate::app) fn run_snippet(cmd: SnippetCommand) -> Result<()> { let operation = if cmd.target.query.is_some() { "graph_assisted" } else { - "graph" + "source_snippet" }; let colorize = cmd.format == args::OutputFormat::Markdown && cmd.output_file.is_none() diff --git a/crates/codestory-cli/src/app/tests/drill.rs b/crates/codestory-cli/src/app/tests/drill.rs index 182d1ceb0..b437af44d 100644 --- a/crates/codestory-cli/src/app/tests/drill.rs +++ b/crates/codestory-cli/src/app/tests/drill.rs @@ -133,6 +133,7 @@ fn legacy_supported_cannot_change_public_v3_drill_decisions() { retrieval_publication: None, operation_id: "legacy-authority-regression".to_string(), attempt: 1, + freshness: codestory_runtime::OperationFreshness::Fresh, }; let supported_dir = tempdir().expect("supported output dir"); write_drill_outputs( @@ -176,6 +177,7 @@ fn legacy_supported_cannot_change_public_v3_drill_decisions() { retrieval_publication: None, operation_id: "legacy-authority-regression".to_string(), attempt: 1, + freshness: codestory_runtime::OperationFreshness::Fresh, }; let mutated_dir = tempdir().expect("mutated output dir"); write_drill_outputs( @@ -327,6 +329,7 @@ fn drill_retained_fields_match_pre_adapter_fixture() { retrieval_publication: None, operation_id: "test-drill".to_string(), attempt: 1, + freshness: codestory_runtime::OperationFreshness::Fresh, }; write_drill_outputs(args::OutputFormat::Json, output_dir.path(), &operation) .expect("write drill fixtures"); diff --git a/crates/codestory-cli/src/app/tests/lifecycle/packet_diagnostics.rs b/crates/codestory-cli/src/app/tests/lifecycle/packet_diagnostics.rs index bda39c100..a3fdeefa1 100644 --- a/crates/codestory-cli/src/app/tests/lifecycle/packet_diagnostics.rs +++ b/crates/codestory-cli/src/app/tests/lifecycle/packet_diagnostics.rs @@ -82,6 +82,7 @@ fn packet_cli_json_budget_measures_publication_metadata_and_newline() { retrieval_publication: None, operation_id: "public-packet-budget".to_string(), attempt: 1, + freshness: codestory_runtime::OperationFreshness::Fresh, }; enforce_packet_cli_json_output_budget( Path::new("/workspace/project"), @@ -198,6 +199,7 @@ fn packet_cli_sixteen_row_identity_envelope_stays_complete() { }), operation_id: "public-operation-123456789".to_owned(), attempt: 2, + freshness: codestory_runtime::OperationFreshness::Fresh, }; let measured = codestory_runtime::finalize_packet_projection_v3_for_representation( diff --git a/crates/codestory-cli/src/output.rs b/crates/codestory-cli/src/output.rs index 66ca96f38..377c9911c 100644 --- a/crates/codestory-cli/src/output.rs +++ b/crates/codestory-cli/src/output.rs @@ -4308,6 +4308,7 @@ mod tests { retrieval_publication: None, operation_id: "public-1".to_string(), attempt: 1, + freshness: codestory_runtime::OperationFreshness::Fresh, }; emit_public_operation(OutputFormat::Markdown, operation(), Some(&markdown_path)) @@ -4337,6 +4338,7 @@ mod tests { retrieval_publication: None, operation_id: "public-2".to_string(), attempt: 1, + freshness: codestory_runtime::OperationFreshness::Fresh, }, Some(&graph_path), ) diff --git a/crates/codestory-cli/src/runtime.rs b/crates/codestory-cli/src/runtime.rs index cdb2fae23..bbe08dc9f 100644 --- a/crates/codestory-cli/src/runtime.rs +++ b/crates/codestory-cli/src/runtime.rs @@ -628,25 +628,49 @@ fn publication_env_nonempty(name: &str) -> Option { } /// Build the canonical publication/contract stamp shared by CLI JSON, HTTP, and stdio. +/// +/// `freshness` is the runtime's own admission decision for the operation that +/// produced the response, never a status-cache guess: `Historical` exactly +/// when the runtime admitted the read from a retained publication during a +/// refresh. `served_from` derives from it. pub(crate) fn codestory_publication_meta( core_publication: Option, retrieval_publication: Option, operation_id: Option<&str>, attempt: Option, - refreshing: bool, + freshness: Option, ) -> serde_json::Value { let served_from = if core_publication.is_none() && retrieval_publication.is_none() { "contract_only" - } else if refreshing { + } else if matches!( + freshness, + Some(codestory_runtime::OperationFreshness::Historical(_)) + ) { "last_complete_publication" } else { "complete_publication" }; + let served_generation = core_publication + .as_ref() + .and_then(|publication| publication.pointer("/generation_id")) + .cloned(); + let freshness = match freshness { + Some(codestory_runtime::OperationFreshness::Historical(reason)) => serde_json::json!({ + "state": "historical", + "reason": reason, + "served_generation": served_generation, + }), + _ => serde_json::json!({ + "state": "fresh", + "served_generation": served_generation, + }), + }; serde_json::json!({ "schema_version": CODESTORY_PUBLICATION_META_SCHEMA_VERSION, "minimum_compatible_schema_version": CODESTORY_PUBLICATION_META_MINIMUM_COMPATIBLE_SCHEMA_VERSION, "served_from": served_from, + "freshness": freshness, "publication": core_publication, "core_publication": core_publication, "retrieval_publication": retrieval_publication, @@ -697,7 +721,7 @@ pub(crate) fn public_operation_json_value( retrieval_publication, Some(&operation.operation_id), Some(operation.attempt), - false, + Some(operation.freshness), ), ); Ok(value) @@ -713,6 +737,7 @@ pub(crate) fn map_public_operation( retrieval_publication: operation.retrieval_publication, operation_id: operation.operation_id, attempt: operation.attempt, + freshness: operation.freshness, } } @@ -1381,6 +1406,7 @@ mod tests { retrieval_publication: None, operation_id: "public-7".to_string(), attempt: 2, + freshness: codestory_runtime::OperationFreshness::Fresh, }; let response = serde_json::json!({ "result": "ok", @@ -1406,7 +1432,7 @@ mod tests { // must see the bump rather than a self-referential comparison. assert_eq!( value.pointer("/_meta/codestory_publication/schema_version"), - Some(&serde_json::json!(3)) + Some(&serde_json::json!(4)) ); assert_eq!( value.pointer("/_meta/codestory_publication/minimum_compatible_schema_version"), diff --git a/crates/codestory-cli/src/stdio_transport.rs b/crates/codestory-cli/src/stdio_transport.rs index c52e7b238..06f2202d8 100644 --- a/crates/codestory-cli/src/stdio_transport.rs +++ b/crates/codestory-cli/src/stdio_transport.rs @@ -1718,7 +1718,7 @@ fn handle_stdio_request( ); let mut result = session.protocol_v3.initialize_result(); result["_meta"]["codestory_publication"] = - crate::runtime::codestory_publication_meta(None, None, None, None, false); + crate::runtime::codestory_publication_meta(None, None, None, None, None); return Some(stdio_jsonrpc_success(id, result)); } "tools/list" => serde_json::json!({ @@ -2084,7 +2084,7 @@ fn handle_stdio_request( } if runtime.activation.snapshot().is_some_and(|snapshot| { snapshot.state == codestory_runtime::ActivationState::Ready - && !snapshot.allows_operation(name) + && !snapshot.allows_operation(public_operation) }) { return Some(stdio_jsonrpc_success( id, @@ -2099,9 +2099,21 @@ fn handle_stdio_request( codestory_runtime::observe_packet_entry_phase( codestory_runtime::PacketEntryObservationPhase::ActivationStarted, ); - let goal = if observes_complete_core { + // PinnedObserver and source-backed reads need only the + // complete core, never the retrieval sidecars. A CoreOnly run + // stops at the core publication; allows_operation still + // requires a fresh Ready core for them, so Retained no longer + // admits. + let goal = if matches!(public_operation, "exact_search" | "source_snippet") + || codestory_runtime::operation_read_class(public_operation) + == codestory_runtime::OperationReadClass::PinnedObserver + { codestory_runtime::ActivationGoal::CoreOnly } else { + // Retrieval-class operations (`packet`, `search`, + // `context`, `drill`, `resolution`, `graph_assisted`) need + // managed retrieval preparation, and allows_operation + // requires `broad_search` Ready for them. codestory_runtime::ActivationGoal::Full }; let first_slice = preparation_deadline @@ -2133,11 +2145,12 @@ fn handle_stdio_request( } else { runtime .activation - .activate_project_with_foreground_budget( + .activate_project_with_foreground_budget_and_goal( &runtime.project_root, &runtime.storage_path, Arc::clone(cancelled), first_slice, + goal, ) .map(|_| ()) }; @@ -2163,7 +2176,11 @@ fn handle_stdio_request( )); break; } - if !observes_complete_core && snapshot.allows_operation(name) { + // Only a graph-only read may leave the wait early on a + // Retained snapshot: its answer is labelled historical. + // SourceBacked and PinnedObserver keep waiting because + // allows_operation requires a fresh Ready core for them. + if !observes_complete_core && snapshot.allows_operation(public_operation) { break; } waiting_operation_id = Some(snapshot.operation_id.clone()); @@ -2208,7 +2225,7 @@ fn handle_stdio_request( let allowed = !observes_complete_core && operation .as_ref() - .is_some_and(|snapshot| snapshot.allows_operation(name)); + .is_some_and(|snapshot| snapshot.allows_operation(public_operation)); if matches!(error.code.as_str(), "cancelled" | "publication_changed") || !allowed { @@ -2471,6 +2488,7 @@ fn handle_stdio_request( }), Some(&operation.operation_id), Some(operation.attempt), + Some(operation.freshness), )); execution } @@ -2490,6 +2508,7 @@ fn handle_stdio_request( stdio_response_retrieval_publication(&execution.response), None, None, + None, )); execution } @@ -3243,15 +3262,33 @@ fn stdio_search_repo_text_mode(request: &serde_json::Value) -> SearchRepoTextMod } } +fn stdio_tool_read_class( + name: &str, + request: &serde_json::Value, +) -> codestory_runtime::OperationReadClass { + codestory_runtime::operation_read_class(stdio_public_operation_name(name, request)) +} + fn stdio_tool_observes_complete_core(name: &str, request: &serde_json::Value) -> bool { - name == "affected" - || crate::prove_call_path::is_proof_tool_name(name) - || (name == "search" && stdio_search_repo_text_mode(request) == SearchRepoTextMode::Off) + stdio_tool_read_class(name, request) == codestory_runtime::OperationReadClass::PinnedObserver } fn stdio_public_operation_name<'a>(name: &'a str, request: &serde_json::Value) -> &'a str { if name == "search" { codestory_runtime::search_operation_name(stdio_search_repo_text_mode(request)) + } else if name == "snippet" { + // An id-selected snippet serves verified source bytes, so it is a + // source-backed read that waits for a fresh complete core. A snippet + // requested by query text is a graph-assisted selection instead. + if request + .pointer("/params/arguments/query") + .and_then(serde_json::Value::as_str) + .is_some_and(|query| !query.trim().is_empty()) + { + "graph_assisted" + } else { + "source_snippet" + } } else if matches!( name, "symbol" @@ -3264,7 +3301,6 @@ fn stdio_public_operation_name<'a>(name: &'a str, request: &serde_json::Value) - | "query_subgraph" | "definition" | "references" - | "snippet" ) { if request .pointer("/params/arguments/query") @@ -3275,6 +3311,11 @@ fn stdio_public_operation_name<'a>(name: &'a str, request: &serde_json::Value) - } else { "graph" } + } else if crate::prove_call_path::is_proof_tool_name(name) { + // Proof tools keep their own observed-operation internals; the + // admission loop only needs the pinned-observer class, so they share + // one runtime-visible operation name. + codestory_runtime::PROOF_DOMAIN } else { name } @@ -3286,7 +3327,11 @@ fn stdio_served_publication_meta( retrieval_publication: Option<&serde_json::Value>, operation_id: Option<&str>, attempt: Option, + freshness: Option, ) -> serde_json::Value { + // `freshness`/`served_from` come from the runtime's admission decision on + // the PublicOperation, not this status cache. The cache still feeds the + // `refresh` block, which is diagnostic detail about the live refresh only. let status = state.status_cache.as_ref().map(|cached| &cached.value); let refreshing = status .and_then(|status| status.pointer("/local_refresh/state")) @@ -3299,7 +3344,7 @@ fn stdio_served_publication_meta( retrieval_publication.cloned(), operation_id, attempt, - refreshing, + freshness, ); if refreshing { meta["refresh"] = serde_json::json!({ @@ -4055,7 +4100,7 @@ fn stdio_initialize_result_json(request: &serde_json::Value) -> serde_json::Valu None, None, None, - false, + None, ), "codestory_protocol": negotiation, } @@ -8126,7 +8171,7 @@ fn read_stdio_template_resource( .map_err(map_api_error), StdioResource::Snippet(node_id) => runtime .browser - .snippet_context(node_id.clone(), 4) + .snippet_context_observational(node_id.clone(), 4) .map(|value| serde_json::json!(value)) .map_err(map_api_error), StdioResource::Trail(node_id) => runtime @@ -8494,9 +8539,20 @@ mod tests { let ordinary = json!({ "params": {"arguments": {"query": "RenamedAnchor", "repo_text": "auto"}} }); - assert!(stdio_tool_observes_complete_core("search", &exact)); + assert!(!stdio_tool_observes_complete_core("search", &exact)); assert!(!stdio_tool_observes_complete_core("search", &ordinary)); assert!(!stdio_tool_observes_complete_core("search", &json!({}))); + // repo_text=off is a source-backed read: it waits for a fresh + // complete core through a CoreOnly activation instead of binding a + // retained one. + assert_eq!( + stdio_tool_read_class("search", &exact), + codestory_runtime::OperationReadClass::SourceBacked + ); + assert_eq!( + stdio_tool_read_class("search", &ordinary), + codestory_runtime::OperationReadClass::Retrieval + ); assert_eq!( stdio_public_operation_name("search", &exact), "exact_search" @@ -9352,7 +9408,15 @@ mod tests { "graph_assisted", "{tool} query resolution must keep one retrieval pin through response assembly" ); - assert_eq!(stdio_public_operation_name(tool, &id), "graph"); + assert_eq!( + stdio_public_operation_name(tool, &id), + if tool == "snippet" { + "source_snippet" + } else { + "graph" + }, + "{tool} id selection keeps its read-class operation name" + ); } assert_eq!( stdio_public_operation_name( @@ -12790,6 +12854,7 @@ version = "0.11.20" Some(&retrieval), Some("public-2"), Some(1), + None, ); let response = stdio_jsonrpc_tool_call_from_legacy(json!(1), bound, meta, "search"); @@ -12807,7 +12872,7 @@ version = "0.11.20" ); assert_eq!( response.pointer("/result/_meta/codestory_publication/schema_version"), - Some(&json!(3)) + Some(&json!(4)) ); assert_eq!( response.pointer("/result/_meta/codestory_publication/contract_runtime/cli_version"), @@ -12830,6 +12895,7 @@ version = "0.11.20" None, Some("public-1"), Some(1), + None, ); let response = stdio_jsonrpc_tool_call_from_legacy( json!(1), @@ -12844,6 +12910,7 @@ version = "0.11.20" retrieval_publication: None, operation_id: "public-1".to_string(), attempt: 1, + freshness: codestory_runtime::OperationFreshness::Fresh, }, &payload, ) @@ -12854,7 +12921,7 @@ version = "0.11.20" // an older schema. assert_eq!( response.pointer("/result/_meta/codestory_publication/schema_version"), - Some(&json!(3)) + Some(&json!(4)) ); assert_eq!( response @@ -12960,7 +13027,7 @@ version = "0.11.20" // The launcher reads this stamp out of the frame it suppresses, so it // must be the same contract-only stamp every other adapter publishes. let stamp = &agreed["_meta"]["codestory_publication"]; - assert_eq!(stamp["schema_version"], json!(3)); + assert_eq!(stamp["schema_version"], json!(4)); assert_eq!(stamp["minimum_compatible_schema_version"], json!(3)); assert_eq!(stamp["served_from"], json!("contract_only")); assert_eq!( @@ -12969,7 +13036,7 @@ version = "0.11.20" ); assert_eq!( stamp, - &crate::runtime::codestory_publication_meta(None, None, None, None, false), + &crate::runtime::codestory_publication_meta(None, None, None, None, None), "initialize must not invent a second stamp shape" ); } @@ -15594,4 +15661,694 @@ version = "0.11.20" "status materialized or resized the SHM wal-index" ); } + /// Parks the indexing worker while `is_indexing` is set and the writer + /// lock is held, so a read runs against a refresh that is genuinely in + /// flight instead of one parked before it started. + struct MidIndexingHold { + gate: Arc<(std::sync::Mutex, std::sync::Condvar)>, + } + + impl MidIndexingHold { + fn arm(storage_path: &Path) -> Self { + let gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let expected = storage_path.to_path_buf(); + let hook_gate = Arc::clone(&gate); + codestory_runtime::set_mid_indexing_test_hook(Some(Arc::new(move |path: &Path| { + if path == expected.as_path() { + let (released, changed) = &*hook_gate; + let mut released = released.lock().expect("mid-index gate"); + while !*released { + released = changed.wait(released).expect("mid-index gate"); + } + } + }))); + Self { gate } + } + } + + impl Drop for MidIndexingHold { + fn drop(&mut self) { + let (released, changed) = &*self.gate; + *released.lock().expect("mid-index gate") = true; + changed.notify_all(); + codestory_runtime::set_mid_indexing_test_hook(None); + } + } + + struct LiveStdioFixture { + _project: tempfile::TempDir, + _cache: tempfile::TempDir, + project_root: PathBuf, + storage_path: PathBuf, + activation: codestory_runtime::ActivationService, + input: tokio::io::DuplexStream, + output: BufReader, + serving: tokio::task::JoinHandle>, + } + + impl LiveStdioFixture { + async fn send_call(&mut self, id: &str, name: &str, arguments: serde_json::Value) { + self.input + .write_all( + format!( + "{}\n", + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "tools/call", + "params": {"name": name, "arguments": arguments} + }) + ) + .as_bytes(), + ) + .await + .expect("send tools/call"); + } + + async fn read_response(&mut self, id: &str, timeout: Duration) -> serde_json::Value { + tokio::time::timeout(timeout, async { + loop { + let mut line = String::new(); + self.output + .read_line(&mut line) + .await + .expect("read response line"); + let frame: serde_json::Value = + serde_json::from_str(&line).expect("response JSON-RPC frame"); + if frame.get("id") == Some(&json!(id)) { + return frame; + } + } + }) + .await + .unwrap_or_else(|_| panic!("response for {id} never arrived")) + } + + /// Drain any frames for `id` that arrive inside `window`; the caller + /// asserts none arrived, proving the call stayed pending. + async fn collect_frames_for( + &mut self, + id: &str, + window: Duration, + ) -> Vec { + let deadline = Instant::now() + window; + let mut frames = Vec::new(); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return frames; + } + let mut line = String::new(); + match tokio::time::timeout(remaining, self.output.read_line(&mut line)).await { + Ok(Ok(0)) | Err(_) => return frames, + Ok(Ok(_)) => { + let frame: serde_json::Value = + serde_json::from_str(&line).expect("response JSON-RPC frame"); + if frame.get("id") == Some(&json!(id)) { + frames.push(frame); + } + } + Ok(Err(error)) => panic!("read pending response: {error}"), + } + } + } + } + + async fn live_stdio_fixture(files: &[(&str, &str)]) -> LiveStdioFixture { + let project = tempfile::tempdir().expect("project"); + let cache = tempfile::tempdir().expect("cache"); + for (name, contents) in files { + let path = project.path().join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("source directory"); + } + std::fs::write(path, contents).expect("write source file"); + } + let mut session = StdioServerSession::new(None); + session.startup = stdio_multi_project_startup(cache.path()); + session + .select_project(project.path().to_str()) + .expect("select project"); + let (activation, storage_path) = { + let runtime = &session + .active_project + .as_ref() + .expect("selected project") + .runtime; + (runtime.activation.clone(), runtime.storage_path.clone()) + }; + let (client_input, server_input) = tokio::io::duplex(4096); + let (server_output, client_output) = tokio::io::duplex(8192); + let serving = tokio::spawn(serve_stdio_requests( + session, + BufReader::new(server_input), + server_output, + std::future::pending::<()>(), + handle_stdio_message, + STDIO_TERMINATION_DRAIN_BUDGET, + )); + LiveStdioFixture { + project_root: project.path().to_path_buf(), + _project: project, + _cache: cache, + storage_path, + activation, + input: client_input, + output: BufReader::new(client_output), + serving, + } + } + + /// Warm the project to a complete publication and return the grounding + /// payload with a symbol id for `name`. + async fn warm_fixture_and_symbol_id( + fixture: &mut LiveStdioFixture, + symbol_name: &str, + ) -> serde_json::Value { + fixture + .send_call( + "warm-ground", + "ground", + json!({"project": fixture.project_root, "budget": "balanced"}), + ) + .await; + let ground = fixture + .read_response("warm-ground", Duration::from_secs(60)) + .await; + let result = &ground["result"]["structuredContent"]; + assert!(result.is_object(), "warm-up ground must converge: {ground}"); + let node_id = result["files"] + .as_array() + .into_iter() + .flatten() + .flat_map(|file| file["symbols"].as_array().into_iter().flatten()) + .chain(result["root_symbols"].as_array().into_iter().flatten()) + .find(|symbol| { + symbol["label"] + .as_str() + .is_some_and(|label| label.starts_with(&format!("{symbol_name} @ "))) + }) + .and_then(|symbol| symbol["id"].as_str()) + .unwrap_or_else(|| panic!("warm-up ground must expose {symbol_name}: {result}")) + .to_string(); + assert_eq!( + ground.pointer("/result/_meta/codestory_publication/freshness/state"), + Some(&json!("fresh")), + "a warm read with no refresh in flight reports fresh: {ground}" + ); + json!({"ground": result.clone(), "node_id": node_id}) + } + + #[tokio::test] + async fn graph_only_reads_serve_retained_publication_as_historical_during_refresh() { + let mut fixture = + live_stdio_fixture(&[("lib.rs", "pub fn admitted_anchor() -> u32 { 1 }\n")]).await; + warm_fixture_and_symbol_id(&mut fixture, "admitted_anchor").await; + + std::fs::write( + fixture.project_root.join("lib.rs"), + "pub fn admitted_anchor() -> u32 { 2 }\npub fn added_after() {}\n", + ) + .expect("mutate indexed source"); + // The refresh worker parks while it owns the writer lock with + // `is_indexing` set: reads that reach the controller observe a refresh + // that is genuinely in flight, not one parked before indexing. + let _hold = MidIndexingHold::arm(&fixture.storage_path); + + fixture + .send_call( + "refresh-ground", + "ground", + json!({"project": fixture.project_root, "budget": "balanced"}), + ) + .await; + let ground = fixture + .read_response("refresh-ground", Duration::from_secs(30)) + .await; + let result = &ground["result"]["structuredContent"]; + assert!(result.is_object(), "ground must serve, not fail: {ground}"); + let meta = &ground["result"]["_meta"]["codestory_publication"]; + assert_eq!( + meta["freshness"]["state"], + json!("historical"), + "a graph-only read during refresh answers from the retained publication: {ground}" + ); + assert_eq!( + meta["freshness"]["reason"], + json!("refresh_in_progress"), + "{ground}" + ); + assert_eq!( + meta["served_from"], + json!("last_complete_publication"), + "{ground}" + ); + let wire = serde_json::to_string(&ground).expect("serialize ground"); + assert!( + !wire.contains("invalid_argument") && !wire.contains("activation_preparing"), + "graph-only reads must not fail as a bad argument while indexing runs: {wire}" + ); + } + + #[tokio::test] + async fn source_backed_snippet_waits_for_the_fresh_core_during_refresh() { + let mut fixture = + live_stdio_fixture(&[("lib.rs", "pub fn source_anchor() -> u32 { 1 }\n")]).await; + let warm = warm_fixture_and_symbol_id(&mut fixture, "source_anchor").await; + let node_id = warm["node_id"].as_str().expect("symbol id").to_string(); + let retained_generation = warm["ground"]["_meta"] + .get("codestory_publication") + .cloned(); + std::fs::write( + fixture.project_root.join("lib.rs"), + "pub fn source_anchor() -> u32 { 2 }\npub fn appended_marker() {}\n", + ) + .expect("mutate indexed source"); + let _hold = MidIndexingHold::arm(&fixture.storage_path); + + fixture + .send_call( + "held-snippet", + "snippet", + json!({"project": fixture.project_root, "id": node_id}), + ) + .await; + let pending = fixture + .collect_frames_for("held-snippet", Duration::from_secs(6)) + .await; + assert!( + pending.is_empty(), + "a source-backed read must wait for the fresh core, not answer from retained: {pending:?}" + ); + + drop(_hold); + let response = fixture + .read_response("held-snippet", Duration::from_secs(60)) + .await; + let snippet = &response["result"]["structuredContent"]; + assert!( + snippet["snippet"].as_str().is_some(), + "snippet must serve after the refresh completes: {response}" + ); + let meta = &response["result"]["_meta"]["codestory_publication"]; + assert_eq!( + meta["freshness"]["state"], + json!("fresh"), + "the waited answer comes from the fresh publication: {response}" + ); + assert_eq!( + meta["served_from"], + json!("complete_publication"), + "{response}" + ); + let _ = retained_generation; + } + + #[tokio::test] + async fn source_backed_deadline_returns_activation_preparing_with_exact_resume() { + let project = tempfile::tempdir().expect("project"); + let cache = tempfile::tempdir().expect("cache"); + std::fs::write( + project.path().join("lib.rs"), + "pub fn resume_source_anchor() -> u32 { 1 }\n", + ) + .expect("source file"); + let mut session = StdioServerSession::new(None); + session.startup = stdio_multi_project_startup(cache.path()); + session.preparation_wait_budget_for_test = Some(Duration::from_millis(120)); + session + .select_project(project.path().to_str()) + .expect("select project"); + let (activation, _storage_path) = { + let runtime = &session + .active_project + .as_ref() + .expect("selected project") + .runtime; + (runtime.activation.clone(), runtime.storage_path.clone()) + }; + let worker_gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + activation.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + let _cleanup = PreparationTestCleanup { + activation: activation.clone(), + gate: Arc::clone(&worker_gate), + restore_snapshot: None, + }; + let (mut client_input, server_input) = tokio::io::duplex(4096); + let (server_output, client_output) = tokio::io::duplex(8192); + let serving = tokio::spawn(serve_stdio_requests( + session, + BufReader::new(server_input), + server_output, + std::future::pending::<()>(), + handle_stdio_message, + STDIO_TERMINATION_DRAIN_BUDGET, + )); + client_input + .write_all( + format!( + "{}\n", + json!({ + "jsonrpc": "2.0", + "id": "deadline-snippet", + "method": "tools/call", + "params": { + "name": "snippet", + "arguments": { + "project": project.path(), + "id": "any-node" + } + } + }) + ) + .as_bytes(), + ) + .await + .expect("send snippet request"); + let mut output = BufReader::new(client_output); + let first = tokio::time::timeout(Duration::from_secs(10), async { + loop { + let mut line = String::new(); + output + .read_line(&mut line) + .await + .expect("read deadline response"); + let frame: serde_json::Value = + serde_json::from_str(&line).expect("deadline JSON-RPC"); + if frame.get("id") == Some(&json!("deadline-snippet")) { + return frame; + } + } + }) + .await + .expect("bounded preparation deadline response"); + let deadline = &first["result"]["structuredContent"]; + assert_eq!(deadline["kind"], json!("preparing"), "{first}"); + assert_eq!(deadline["deadline_exceeded"], json!(true), "{first}"); + let snapshot = activation.snapshot().expect("running activation"); + assert_eq!( + deadline["resume_operation_id"].as_str(), + Some(snapshot.operation_id.as_str()), + "resume must name the exact running operation: {first}" + ); + assert_eq!( + deadline["resume_operation_attempt"].as_u64(), + Some(u64::from(snapshot.attempt)), + "{first}" + ); + drop(serving); + } + + #[tokio::test] + async fn exact_search_and_packet_wait_for_the_fresh_publication() { + let mut fixture = + live_stdio_fixture(&[("lib.rs", "pub fn exact_anchor() -> u32 { 1 }\n")]).await; + warm_fixture_and_symbol_id(&mut fixture, "exact_anchor").await; + std::fs::write( + fixture.project_root.join("lib.rs"), + "pub fn exact_anchor() -> u32 { 2 }\npub fn second_marker() {}\n", + ) + .expect("mutate indexed source"); + let _hold = MidIndexingHold::arm(&fixture.storage_path); + + fixture + .send_call( + "held-exact", + "search", + json!({ + "project": fixture.project_root, + "query": "second_marker", + "repo_text": "off" + }), + ) + .await; + let pending = fixture + .collect_frames_for("held-exact", Duration::from_secs(6)) + .await; + assert!( + pending.is_empty(), + "exact search must wait for the fresh core: {pending:?}" + ); + drop(_hold); + let response = fixture + .read_response("held-exact", Duration::from_secs(60)) + .await; + let wire = serde_json::to_string(&response).expect("serialize exact response"); + assert!( + wire.contains("second_marker"), + "exact search must observe the refreshed source: {wire}" + ); + assert_eq!( + response.pointer("/result/_meta/codestory_publication/freshness/state"), + Some(&json!("fresh")), + "{response}" + ); + + std::fs::write( + fixture.project_root.join("lib.rs"), + "pub fn exact_anchor() -> u32 { 3 }\npub fn third_marker() {}\n", + ) + .expect("mutate indexed source again"); + let _hold = MidIndexingHold::arm(&fixture.storage_path); + fixture + .send_call( + "held-packet", + "packet", + json!({ + "project": fixture.project_root, + "question": "what does third_marker do" + }), + ) + .await; + let pending = fixture + .collect_frames_for("held-packet", Duration::from_secs(6)) + .await; + assert!( + pending.is_empty(), + "packet must spend its wait on the refresh instead of answering early: {pending:?}" + ); + drop(_hold); + let response = fixture + .read_response("held-packet", Duration::from_secs(60)) + .await; + let is_error = response.pointer("/result/isError") == Some(&json!(true)) + || response.pointer("/error").is_some(); + assert!( + is_error || response["result"]["structuredContent"].is_object(), + "packet may only answer with a fresh publication or a typed refusal: {response}" + ); + } + + #[tokio::test] + async fn pinned_observers_report_drift_while_a_refresh_waits() { + let project = tempfile::tempdir().expect("project"); + let cache = tempfile::tempdir().expect("cache"); + std::fs::write( + project.path().join("lib.rs"), + "pub fn observed_anchor() -> u32 { 1 }\n", + ) + .expect("source file"); + let mut session = StdioServerSession::new(None); + session.startup = stdio_multi_project_startup(cache.path()); + session + .select_project(project.path().to_str()) + .expect("select project"); + let activation = session + .active_project + .as_ref() + .expect("selected project") + .runtime + .activation + .clone(); + let (mut client_input, server_input) = tokio::io::duplex(4096); + let (server_output, client_output) = tokio::io::duplex(8192); + let serving = tokio::spawn(serve_stdio_requests( + session, + BufReader::new(server_input), + server_output, + std::future::pending::<()>(), + handle_stdio_message, + STDIO_TERMINATION_DRAIN_BUDGET, + )); + let mut output = BufReader::new(client_output); + // Warm the project to a complete publication. + client_input + .write_all( + format!( + "{}\n", + json!({"jsonrpc":"2.0","id":"warm","method":"tools/call", + "params":{"name":"ground","arguments":{"project":project.path(),"budget":"balanced"}}}) + ) + .as_bytes(), + ) + .await + .expect("send warm-up"); + loop { + let mut line = String::new(); + tokio::time::timeout(Duration::from_secs(60), output.read_line(&mut line)) + .await + .expect("warm-up response") + .expect("warm-up line"); + let frame: serde_json::Value = serde_json::from_str(&line).expect("warm JSON"); + if frame.get("id") == Some(&json!("warm")) { + assert!(frame["result"]["structuredContent"].is_object(), "{frame}"); + break; + } + } + + std::fs::write( + project.path().join("lib.rs"), + "pub fn observed_anchor() -> u32 { 2 }\n", + ) + .expect("mutate indexed source"); + // Park the refresh before its worker starts: the operation is in + // flight and the core is retained, but the controller is not yet + // mid-index, which is exactly the window a pinned observer must still + // answer in. + let worker_gate = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + activation.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + let _cleanup = PreparationTestCleanup { + activation: activation.clone(), + gate: Arc::clone(&worker_gate), + restore_snapshot: None, + }; + + // Trigger the refresh with a graph call that returns historically, + // then ask the pinned observer while the worker is still parked. + client_input + .write_all( + format!( + "{}\n", + json!({"jsonrpc":"2.0","id":"trigger","method":"tools/call", + "params":{"name":"ground","arguments":{"project":project.path(),"budget":"balanced"}}}) + ) + .as_bytes(), + ) + .await + .expect("send refresh trigger"); + loop { + let mut line = String::new(); + tokio::time::timeout(Duration::from_secs(30), output.read_line(&mut line)) + .await + .expect("trigger response") + .expect("trigger line"); + let frame: serde_json::Value = serde_json::from_str(&line).expect("trigger JSON"); + if frame.get("id") == Some(&json!("trigger")) { + assert_eq!( + frame.pointer("/result/_meta/codestory_publication/freshness/state"), + Some(&json!("historical")), + "the trigger read itself serves historically: {frame}" + ); + break; + } + } + assert!( + activation.snapshot().is_some_and(|snapshot| matches!( + snapshot.state, + codestory_runtime::ActivationState::Preparing + | codestory_runtime::ActivationState::Updating + )), + "the refresh must still be in flight for the pinned-observer assertion" + ); + + client_input + .write_all( + format!( + "{}\n", + json!({"jsonrpc":"2.0","id":"affected-mid-refresh","method":"tools/call", + "params":{"name":"affected","arguments":{"project":project.path(),"paths":["lib.rs"]}}}) + ) + .as_bytes(), + ) + .await + .expect("send affected during refresh"); + let affected = loop { + let mut line = String::new(); + tokio::time::timeout(Duration::from_secs(15), output.read_line(&mut line)) + .await + .expect("affected response while refresh parked") + .expect("affected line"); + let frame: serde_json::Value = serde_json::from_str(&line).expect("affected JSON"); + if frame.get("id") == Some(&json!("affected-mid-refresh")) { + break frame; + } + }; + let result = &affected["result"]["structuredContent"]; + assert!( + result.is_object(), + "affected must still answer from the pinned core: {affected}" + ); + assert!( + result["uncovered_inputs"] + .as_array() + .is_some_and(|inputs| inputs.iter().any(|input| { + input["path"] == "lib.rs" && input["classification"] == "stale_index" + })), + "affected must report the drift against the pinned publication, not refresh it away: {affected}" + ); + drop(serving); + } + + #[tokio::test] + async fn failed_replacement_serves_historical_graph_and_refuses_source_reads() { + let mut fixture = + live_stdio_fixture(&[("lib.rs", "pub fn failing_anchor() -> u32 { 1 }\n")]).await; + let warm = warm_fixture_and_symbol_id(&mut fixture, "failing_anchor").await; + let node_id = warm["node_id"].as_str().expect("symbol id").to_string(); + + // An incomplete member inventory fences the replacement: the refresh + // fails closed and the previous publication stays retained. + std::fs::write( + fixture.project_root.join("codestory_workspace.json"), + r#"{"members":["missing"]}"#, + ) + .expect("fence the replacement"); + + fixture + .send_call( + "failed-ground", + "ground", + json!({"project": fixture.project_root, "budget": "balanced"}), + ) + .await; + let ground = fixture + .read_response("failed-ground", Duration::from_secs(60)) + .await; + let meta = &ground["result"]["_meta"]["codestory_publication"]; + assert_eq!( + meta["freshness"]["state"], + json!("historical"), + "a graph-only read after a failed replacement stays on the retained publication: {ground}" + ); + assert_eq!( + meta["freshness"]["reason"], + json!("replacement_failed"), + "{ground}" + ); + + fixture + .send_call( + "failed-snippet", + "snippet", + json!({"project": fixture.project_root, "id": node_id}), + ) + .await; + let response = fixture + .read_response("failed-snippet", Duration::from_secs(60)) + .await; + let wire = serde_json::to_string(&response).expect("serialize snippet response"); + assert!( + response.pointer("/result/isError") == Some(&json!(true)) + || response + .pointer("/result/structuredContent/error") + .is_some() + || wire.contains("source_discovery_incomplete") + || wire.contains("project_unavailable"), + "a source-backed read must surface the causal refresh failure, not retained bytes: {wire}" + ); + assert!( + !wire.contains("\"snippet\":"), + "no retained snippet body may be served: {wire}" + ); + } } diff --git a/crates/codestory-cli/tests/http_transport_contracts.rs b/crates/codestory-cli/tests/http_transport_contracts.rs index 27de942c2..3ac214b4b 100644 --- a/crates/codestory-cli/tests/http_transport_contracts.rs +++ b/crates/codestory-cli/tests/http_transport_contracts.rs @@ -923,14 +923,14 @@ fn http_smoke_keeps_existing_routes_and_default_semantics_against_indexed_repo() ); } assert!( - search["_meta"]["codestory_publication"]["schema_version"] == 3 + search["_meta"]["codestory_publication"]["schema_version"] == 4 && search["_meta"]["codestory_publication"]["minimum_compatible_schema_version"] == 3 && search["_meta"]["codestory_publication"]["served_from"] == "complete_publication" && search["_meta"]["codestory_publication"]["core_publication"]["mode"] == "full" && search["_meta"]["codestory_publication"]["core_publication"].is_object() && search["_meta"]["codestory_publication"]["retrieval_publication"].is_object() && search["_meta"]["codestory_publication"]["operation"]["operation_id"].is_string(), - "/search success metadata must retain schema 3 and the complete core and retrieval publications plus operation identity: {search}" + "/search success metadata must retain schema 4 and the complete core and retrieval publications plus operation identity: {search}" ); required_nonempty_string(&search, "/publication/core/project_id"); let public_core_generation_id = diff --git a/crates/codestory-cli/tests/stdio_protocol_contracts.rs b/crates/codestory-cli/tests/stdio_protocol_contracts.rs index 151e3caf7..54bd9af0d 100644 --- a/crates/codestory-cli/tests/stdio_protocol_contracts.rs +++ b/crates/codestory-cli/tests/stdio_protocol_contracts.rs @@ -198,7 +198,7 @@ fn public_v3_negotiates_revision_native_evidence_discovery() { assert!(digest.bytes().all(|byte| byte.is_ascii_hexdigit())); assert_eq!( initialized.pointer("/_meta/codestory_publication/schema_version"), - Some(&json!(3)) + Some(&json!(4)) ); assert_eq!( initialized.pointer("/_meta/codestory_publication/minimum_compatible_schema_version"), @@ -1930,7 +1930,7 @@ fn initialize_negotiates_the_protocol_revision_and_stamps_the_wire_contract() { ); assert_eq!( agreed.pointer("/_meta/codestory_publication/schema_version"), - Some(&json!(3)), + Some(&json!(4)), "the session-start stamp publishes the evidence-only v3 response schema: {agreed}" ); assert_eq!( @@ -2033,7 +2033,7 @@ fn tool_results_carry_the_publication_schema_that_defines_their_vocabulary() { let result = assert_success_envelope(&response, json!("ground-stamp")); assert_eq!( result.pointer("/_meta/codestory_publication/schema_version"), - Some(&json!(3)), + Some(&json!(4)), "a served payload must name the schema its vocabulary belongs to: {response}" ); assert_eq!( @@ -2554,6 +2554,42 @@ fn multi_project_stdio_routes_interleaved_requests_by_explicit_project() { ); } + // Query-resolution is retrieval-class; ground is graph-only and lists + // first_only with its stable id for the id-based symbol call below. + let first_ground = assert_tool_success( + &send_json( + &mut server, + json!({ + "jsonrpc": "2.0", + "id": "multi-first-ground", + "method": "tools/call", + "params": { + "name": "ground", + "arguments": {"project": first.path(), "budget": "strict"} + } + }), + ), + json!("multi-first-ground"), + ) + .clone(); + let first_node_id = first_ground["root_symbols"] + .as_array() + .into_iter() + .flatten() + .chain( + first_ground["files"] + .as_array() + .into_iter() + .flatten() + .flat_map(|file| file["symbols"].as_array().into_iter().flatten()), + ) + .find(|symbol| { + symbol["label"] + .as_str() + .is_some_and(|label| label.starts_with("first_only")) + }) + .and_then(|symbol| symbol["id"].as_str()) + .unwrap_or_else(|| panic!("first project should expose first_only: {first_ground:#}")); let first_symbol = assert_tool_success( &send_json( &mut server, @@ -2563,18 +2599,13 @@ fn multi_project_stdio_routes_interleaved_requests_by_explicit_project() { "method": "tools/call", "params": { "name": "symbol", - "arguments": {"project": first.path(), "query": "first_only"} + "arguments": {"project": first.path(), "id": first_node_id} } }), ), json!("multi-first-symbol"), ) .clone(); - let first_node_id = first_symbol - .pointer("/node/id") - .or_else(|| first_symbol.pointer("/resolution/resolved/node_id")) - .and_then(Value::as_str) - .unwrap_or_else(|| panic!("first project should resolve first_only: {first_symbol}")); let wrong_project_uri = format!( "codestory://symbol/{}?project={}", strict_resource_component(first_node_id), @@ -4069,18 +4100,24 @@ fn snippet_tool_exact_id_navigates_structural_evidence_but_query_stays_typed() { }), ); let error = assert_tool_error(&query_response, json!("snippet-structural-query")); + // A query snippet is a retrieval-class read: where managed retrieval is + // unavailable the refusal is the typed availability envelope; where it is + // available the resolver still reports a typed no-match. Either way the + // wire carries a typed refusal, never an untyped error or stale bytes. assert!( - error["message"] - .as_str() - .is_some_and(|message| message.contains("No symbol matched query")), - "stdio query snippet should retain typed graph filtering: {error:#}" + error["code"].as_str() == Some("codestory_unavailable") + || error["message"] + .as_str() + .is_some_and(|message| message.contains("No symbol matched query")), + "stdio query snippet should retain typed refusal: {error:#}" ); } /// A symbol-identified snippet read must never serve bytes that fail the -/// indexed content hash. Mutating the bound file after indexing makes both -/// the `snippet` tool and the `codestory://snippet/` resource answer with the -/// typed `source_stale` code instead of mismatched text. +/// indexed content hash. Mutating the bound file after indexing makes the +/// observational `codestory://snippet/` resource answer with the typed +/// `source_stale` code, while the `snippet` tool waits for the managed +/// refresh and then reports the stale id as `not_found`. #[test] fn snippet_id_reads_return_source_stale_when_indexed_bytes_change() { let fixture = indexed_fixture(); @@ -4171,17 +4208,16 @@ fn snippet_id_reads_return_source_stale_when_indexed_bytes_change() { "params": {"name": "snippet", "arguments": {"id": node_id}} }), ); - // The snippet tool runs managed activation first: the drift is detected, - // the index republishes, and the pinned id from the previous generation no - // longer resolves. That is still a typed refusal -- the wire must never - // carry the mutated bytes under the old identity. + // The snippet tool is a source-backed read: managed activation detects + // the drift, waits for the refresh, and republishes. The pinned id from + // the previous generation then no longer resolves, so the exact refusal + // is `not_found` -- and the wire still never carries the mutated bytes + // under the old identity. let tool_error = assert_tool_error(&tool_response, json!("snippet-stale-tool")); - assert!( - matches!( - tool_error["code"].as_str(), - Some("source_stale" | "not_found" | "project_unavailable") - ), - "snippet tool must refuse stale source with a typed code: {tool_error:#}" + assert_eq!( + tool_error["code"].as_str(), + Some("not_found"), + "snippet tool waits for the refresh, so the stale id resolves to nothing: {tool_error:#}" ); assert!( !serde_json::to_string(&tool_error) @@ -6102,13 +6138,48 @@ fn independent_clients_serve_one_complete_generation_while_refresh_is_owned() { .expect("ground serving generation"); assert!(served_generation >= generation); + // A query-resolved symbol call is retrieval-class; the root-symbol + // resource is graph-only and hands back the stable id instead. + let pre_symbols_response = send_json( + &mut ground_client, + json!({ + "jsonrpc": "2.0", + "id": "concurrent-root-symbols-lookup", + "method": "resources/read", + "params": { + "uri": "codestory://symbols/root", + "project": fixture.workspace.path() + } + }), + ); + let pre_symbols = json_resource_content( + assert_success_envelope( + &pre_symbols_response, + json!("concurrent-root-symbols-lookup"), + ), + "codestory://symbols/root", + ); + let app_controller_id = pre_symbols + .as_array() + .into_iter() + .flatten() + .find(|symbol| { + symbol["display_name"] == json!("AppController") + || symbol["label"] == json!("AppController") + || symbol["label"] + .as_str() + .is_some_and(|label| label.starts_with("AppController ")) + }) + .and_then(|symbol| symbol["id"].as_str()) + .unwrap_or_else(|| panic!("root symbols should expose AppController: {pre_symbols}")) + .to_string(); let symbol_response = send_json( &mut ground_client, json!({ "jsonrpc": "2.0", "id": "concurrent-symbol", "method": "tools/call", - "params": {"name": "symbol", "arguments": {"query": "AppController"}} + "params": {"name": "symbol", "arguments": {"id": app_controller_id}} }), ); let symbol = assert_tool_success(&symbol_response, json!("concurrent-symbol")); @@ -6487,28 +6558,29 @@ fn tools_call_local_graph_refreshes_long_lived_index_after_source_mutation() { "ground should serve refreshed graph stats after mutation; before={node_count_before}, after={node_count_after}, snapshot={ground_after}" ); - let symbol_response = send_json( - &mut server, - json!({ - "jsonrpc": "2.0", - "id": "tool-refresh-symbol", - "method": "tools/call", - "params": { - "name": "symbol", - "arguments": {"query": "stdio_tool_added_after_mutation"} - } - }), - ); - let symbol = assert_tool_success(&symbol_response, json!("tool-refresh-symbol")); - let node_id = symbol - .pointer("/node/id") - .and_then(Value::as_str) - .or_else(|| { - symbol - .pointer("/resolution/resolved/node_id") - .and_then(Value::as_str) + // Query-resolution is retrieval-class and unavailable without broad + // retrieval; the refreshed ground response already lists the new symbol + // with its stable id, which is the graph-only way to select it. + let node_id = ground_after["root_symbols"] + .as_array() + .into_iter() + .flatten() + .chain( + ground_after["files"] + .as_array() + .into_iter() + .flatten() + .flat_map(|file| file["symbols"].as_array().into_iter().flatten()), + ) + .find(|symbol| { + symbol["label"] + .as_str() + .is_some_and(|label| label.starts_with("stdio_tool_added_after_mutation")) + }) + .and_then(|symbol| symbol["id"].as_str()) + .unwrap_or_else(|| { + panic!("refreshed ground should expose the post-mutation function: {ground_after:#}") }) - .unwrap_or_else(|| panic!("symbol should resolve the post-mutation function: {symbol}")) .to_string(); for (tool, id) in [ @@ -7503,29 +7575,40 @@ fn mcp_graph_caller_scope_hides_stored_test_call_until_explicitly_included() { let mut server = spawn_stdio_server(&fixture); initialize_stdio_server(&mut server, "init-scope"); - let test_entry = call_graph_tool( + // Query-resolution is retrieval-class; ground is graph-only and lists the + // fixture functions with their stable ids. + let grounding = call_graph_tool( &mut server, - "symbol-test-entry", - "symbol", - json!({ - "project": fixture.workspace.path(), - "query": "test_entry" - }), - ); - let test_id = test_entry["node"]["id"] - .as_str() - .expect("test_entry id") - .to_string(); - let leaf = call_graph_tool( - &mut server, - "symbol-leaf", - "symbol", + "scope-ground", + "ground", json!({ "project": fixture.workspace.path(), - "query": "leaf" + "budget": "strict" }), ); - let leaf_id = leaf["node"]["id"].as_str().expect("leaf id").to_string(); + let scope_symbol_id = |name: &str| { + grounding["root_symbols"] + .as_array() + .into_iter() + .flatten() + .chain( + grounding["files"] + .as_array() + .into_iter() + .flatten() + .flat_map(|file| file["symbols"].as_array().into_iter().flatten()), + ) + .find(|symbol| { + symbol["label"] + .as_str() + .is_some_and(|label| label.starts_with(name)) + }) + .and_then(|symbol| symbol["id"].as_str()) + .unwrap_or_else(|| panic!("ground should expose {name}: {grounding:#}")) + .to_string() + }; + let test_id = scope_symbol_id("test_entry"); + let leaf_id = scope_symbol_id("leaf"); for (name, extra) in [ ( diff --git a/crates/codestory-contracts/src/wire.rs b/crates/codestory-contracts/src/wire.rs index 44772a1e5..6215a35bc 100644 --- a/crates/codestory-contracts/src/wire.rs +++ b/crates/codestory-contracts/src/wire.rs @@ -30,7 +30,10 @@ use serde::{Deserialize, Serialize}; /// echoing whatever the client asked for. /// * **v3** — packet, context, and search publish closed evidence projections; /// packet truth dispositions and its evidence opt-out are no longer public. -pub const PUBLICATION_STAMP_SCHEMA_VERSION: u32 = 3; +/// * **v4** — additive: `freshness` reports whether the response came from the +/// fresh current source or a retained publication during a refresh, and +/// `served_from` now derives from that runtime decision. +pub const PUBLICATION_STAMP_SCHEMA_VERSION: u32 = 4; /// Oldest reader schema version that can still interpret a payload stamped with /// [`PUBLICATION_STAMP_SCHEMA_VERSION`] without misreading it. @@ -292,8 +295,8 @@ mod tests { #[test] fn published_stamp_bounds_are_the_documented_values() { assert_eq!( - PUBLICATION_STAMP_SCHEMA_VERSION, 3, - "the evidence-only v3 contract publishes stamp schema 3" + PUBLICATION_STAMP_SCHEMA_VERSION, 4, + "the additive freshness field publishes stamp schema 4" ); assert_eq!(MINIMUM_COMPATIBLE_PUBLICATION_STAMP_SCHEMA_VERSION, 3); assert_eq!(LEGACY_PUBLICATION_STAMP_SCHEMA_VERSION, 0); diff --git a/crates/codestory-runtime/src/browser.rs b/crates/codestory-runtime/src/browser.rs index ebfe5bdcc..5f62bb25d 100644 --- a/crates/codestory-runtime/src/browser.rs +++ b/crates/codestory-runtime/src/browser.rs @@ -388,7 +388,21 @@ impl ReadOnlyBrowserService { node_id: NodeId, context: usize, ) -> Result { - self.run_public("graph", || { + self.run_public("source_snippet", || { + self.controller.snippet_context(node_id.clone(), context) + }) + } + + /// The MCP `resources/read` surface is observational: it pins the + /// committed publication without activating or waiting, so source drift + /// surfaces as the read's own typed error (`source_stale`) rather than an + /// admission refusal. + pub fn snippet_context_observational( + &self, + node_id: NodeId, + context: usize, + ) -> Result { + self.run_observational("source_snippet", || { self.controller.snippet_context(node_id.clone(), context) }) } @@ -398,7 +412,7 @@ impl ReadOnlyBrowserService { node_id: NodeId, context: usize, ) -> Result { - self.run_public("graph", || { + self.run_public("source_snippet", || { self.controller .snippet_function_body_context(node_id.clone(), context) }) diff --git a/crates/codestory-runtime/src/controller_core.rs b/crates/codestory-runtime/src/controller_core.rs index e1e2432a1..961ad1b22 100644 --- a/crates/codestory-runtime/src/controller_core.rs +++ b/crates/codestory-runtime/src/controller_core.rs @@ -17,7 +17,8 @@ use crate::{ SourceObserverState, Storage, clear_search_engine, publish_search_engine, }; use codestory_contracts::api::{ - ApiError, AppEventPayload, IndexFreshnessDto, ProjectSummary, RetrievalStateDto, + ApiError, ApiErrorDetails, AppEventPayload, IndexFreshnessDto, ProjectSummary, + RetrievalStateDto, }; use codestory_store::{IndexPublicationRecord, Store}; use codestory_workspace::SourceIndexPolicy; @@ -454,15 +455,50 @@ impl AppController { self.canonical_symbol_names.lock().clear(); } + /// Guard for reads that consult controller state indexing mutates. + /// Source-backed and pinned observers reach this only when indexing starts + /// mid-operation, so the refusal is typed as preparation, not a bad + /// argument; adapters already wait before dispatching. pub(crate) fn ensure_consistent_read_state(&self, operation: &str) -> Result<(), ApiError> { if self.state.lock().is_indexing { - return Err(ApiError::invalid_argument(format!( - "{operation} is unavailable while indexing is in progress. Retry after indexing completes." - ))); + return Err(ApiError::with_details( + "activation_preparing", + format!( + "{operation} is unavailable while indexing is in progress. Retry after indexing completes." + ), + ApiErrorDetails::cause("indexing_in_progress"), + )); } Ok(()) } + /// Guard for pure graph reads (`ground`, `files`). They only read the + /// published generation's storage, which is immutable and stays readable + /// while a refresh stages its replacement, so a pinned complete + /// publication may serve them even while `is_indexing` is set. Without a + /// pin or a durable complete publication the call is typed preparation. + pub(crate) fn ensure_graph_only_read_state(&self, operation: &str) -> Result<(), ApiError> { + if !self.state.lock().is_indexing || self.active_core_publication().is_some() { + return Ok(()); + } + let storage_path = self.require_storage_path()?; + if Store::database_complete_index_publication(&storage_path) + .map_err(|error| { + ApiError::internal(format!( + "Failed to read complete index publication: {error}" + )) + })? + .is_some() + { + return Ok(()); + } + Err(ApiError::with_details( + "activation_preparing", + format!("{operation} is unavailable until the first index publication completes."), + ApiErrorDetails::cause("indexing_in_progress"), + )) + } + pub(crate) fn ensure_search_state(&self) -> Result<(), ApiError> { let pinned_publication = self.active_core_publication(); if let Some(publication) = pinned_publication.as_ref() { diff --git a/crates/codestory-runtime/src/controller_indexing.rs b/crates/codestory-runtime/src/controller_indexing.rs index 9e8735a56..f5b921372 100644 --- a/crates/codestory-runtime/src/controller_indexing.rs +++ b/crates/codestory-runtime/src/controller_indexing.rs @@ -65,6 +65,37 @@ struct IndexingCompletion { repository_tracking_digest: Option, } +#[cfg(any(test, feature = "test-support"))] +static MID_INDEXING_TEST_HOOK: std::sync::RwLock< + Option>, +> = std::sync::RwLock::new(None); + +/// Run `hook` while an indexing worker owns the writer lock with +/// `is_indexing` set, so a test can hold a refresh genuinely in flight while +/// reads pin the retained publication. The hook sees the storage path so a +/// shared test binary only parks the run it armed for. +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +pub fn set_mid_indexing_test_hook(hook: Option>) { + *MID_INDEXING_TEST_HOOK + .write() + .expect("mid-indexing test hook lock") = hook; +} + +#[cfg(any(test, feature = "test-support"))] +fn run_mid_indexing_test_hook(storage_path: &Path) { + let hook = MID_INDEXING_TEST_HOOK + .read() + .expect("mid-indexing test hook lock") + .clone(); + if let Some(hook) = hook { + hook(storage_path); + } +} + +#[cfg(not(any(test, feature = "test-support")))] +fn run_mid_indexing_test_hook(_storage_path: &Path) {} + impl AppController { fn core_project_summary_from_storage( &self, @@ -608,6 +639,7 @@ impl AppController { return Err(error); } }; + run_mid_indexing_test_hook(&storage_path); // A refresh can install a database that never carried the legacy // annotation tables, so annotations move to the sidecar before the run @@ -1481,7 +1513,7 @@ impl AppController { } pub fn indexed_files(&self, req: IndexedFilesRequest) -> Result { - self.ensure_consistent_read_state("Files")?; + self.ensure_graph_only_read_state("Files")?; let root = self.require_project_root()?; let storage = self.open_storage_read_only()?; indexed_files_from_storage(&root, &storage, &self.source_index_policy, req) diff --git a/crates/codestory-runtime/src/grounding.rs b/crates/codestory-runtime/src/grounding.rs index 59f34d0f1..f2f44f710 100644 --- a/crates/codestory-runtime/src/grounding.rs +++ b/crates/codestory-runtime/src/grounding.rs @@ -1008,7 +1008,7 @@ impl AppController { &self, budget: GroundingBudgetDto, ) -> Result { - self.ensure_consistent_read_state("Grounding")?; + self.ensure_graph_only_read_state("Grounding")?; let root = self.require_project_root()?; let storage = self.open_storage_read_only()?; if matches!(budget, GroundingBudgetDto::Max) diff --git a/crates/codestory-runtime/src/lib.rs b/crates/codestory-runtime/src/lib.rs index a181e0424..7100a2225 100644 --- a/crates/codestory-runtime/src/lib.rs +++ b/crates/codestory-runtime/src/lib.rs @@ -510,6 +510,10 @@ pub use agent::packet_batch::{ PacketEntryObservationPhase, PacketLatencyScopeGuard, enter_packet_latency_scope, observe_packet_entry_phase, }; +pub use call_path_kernel::PROOF_DOMAIN; +#[cfg(any(test, feature = "test-support"))] +#[doc(hidden)] +pub use controller_indexing::set_mid_indexing_test_hook; pub use search_runtime::*; use semantic_doc_text::{ semantic_doc_language_from_path, semantic_path_aliases, semantic_symbol_aliases, @@ -523,8 +527,9 @@ pub use services::{ ActivationFailStopHook, ActivationGoal, ActivationOperation, ActivationQuiescence, ActivationRun, ActivationService, ActivationSnapshot, ActivationStage, ActivationState, ActivePublicOperationPublication, AgentService, BookmarkService, GroundingService, - IndexService, ProjectService, PublicOperation, PublicOperationService, SearchService, - TrailService, embedding_api_error, search_operation_name, set_activation_fail_stop_hook, + HistoricalServedReason, IndexService, OperationFreshness, OperationReadClass, ProjectService, + PublicOperation, PublicOperationService, SearchService, TrailService, embedding_api_error, + operation_read_class, search_operation_name, set_activation_fail_stop_hook, }; pub use symbol_workflow::{ SymbolWorkflowCaps, SymbolWorkflowMode, SymbolWorkflowNode, SymbolWorkflowOutcome, diff --git a/crates/codestory-runtime/src/services.rs b/crates/codestory-runtime/src/services.rs index 55a13079a..b3f2c2392 100644 --- a/crates/codestory-runtime/src/services.rs +++ b/crates/codestory-runtime/src/services.rs @@ -316,13 +316,32 @@ pub struct ActivationSnapshot { impl ActivationSnapshot { pub fn allows_operation(&self, operation: &str) -> bool { - if operation_requires_retrieval(operation) { - self.capabilities.broad_search == ActivationCapabilityState::Ready - } else { - matches!( + match operation_read_class(operation) { + OperationReadClass::Retrieval => { + self.capabilities.broad_search == ActivationCapabilityState::Ready + } + OperationReadClass::GraphOnly => matches!( self.capabilities.local_navigation, ActivationCapabilityState::Ready | ActivationCapabilityState::Retained - ) + ), + // Source-backed reads require a fresh complete core: a retained + // publication never admits them, and neither does a core that is + // still current only because its refresh is still running -- + // `Ready` under a `Preparing`/`Updating` snapshot describes the + // pre-refresh generation, so the caller keeps waiting for the + // operation to finish (or fails with its causal error). + OperationReadClass::SourceBacked => { + self.capabilities.local_navigation == ActivationCapabilityState::Ready + && !matches!( + self.state, + ActivationState::Preparing | ActivationState::Updating + ) + } + // Pinned observers read the committed publication deliberately; + // they bind it even while a refresh is in flight. + OperationReadClass::PinnedObserver => { + self.capabilities.local_navigation == ActivationCapabilityState::Ready + } } } } @@ -1184,6 +1203,26 @@ impl ActivationService { ) } + /// Activate or join activation with an explicit goal inside one foreground + /// slice. Source-backed callers pass `CoreOnly`: they need the fresh core, + /// never the retrieval sidecars. + pub fn activate_project_with_foreground_budget_and_goal( + &self, + project_root: &Path, + storage_path: &Path, + request_cancelled: Arc, + foreground_budget: Duration, + goal: ActivationGoal, + ) -> Result { + self.activate_with_goal( + project_root, + storage_path, + request_cancelled, + foreground_budget, + goal, + ) + } + fn activate_with_goal( &self, project_root: &Path, @@ -2223,11 +2262,51 @@ pub fn search_operation_name(repo_text: SearchRepoTextMode) -> &'static str { } } +/// How a public operation relates to the pinned core publication. +/// +/// Every public operation is one of four read classes; the class decides what +/// activation capability the operation waits for and whether a retained +/// publication may answer it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationReadClass { + /// Pure graph reads. They may answer from a retained complete publication + /// while a refresh runs; the answer is labelled historical. + GraphOnly, + /// Reads that serve source bytes or freshness-bound core truth. They wait + /// for a fresh complete core and never fall back to a retained one. + SourceBacked, + /// Complete-core observers such as `affected` and the proof tool. They pin + /// one complete publication and report drift instead of blocking. + PinnedObserver, + /// Full-retrieval operations; unchanged readiness contract. + Retrieval, +} + +/// The read class of one public operation name. +/// +/// Operation names here are the runtime-visible names: `stdio` maps tool +/// identities to these names before admission (`snippet` without a query +/// becomes `source_snippet`), and `browser.rs` names each call directly. +/// Anything not listed is a pure graph read. +pub fn operation_read_class(operation: &str) -> OperationReadClass { + match operation { + // Retrieval readiness admits only operations whose results execute + // through the retrieval sidecars (`packet`, hybrid `search`, `context`, + // `drill`) or resolve evidence against a pinned retrieval generation + // (`resolution`, `graph_assisted`). + "packet" | "search" | "context" | "drill" | "resolution" | "graph_assisted" => { + OperationReadClass::Retrieval + } + "exact_search" | "source_snippet" => OperationReadClass::SourceBacked, + "affected" | crate::call_path_kernel::PROOF_DOMAIN => OperationReadClass::PinnedObserver, + _ => OperationReadClass::GraphOnly, + } +} + +/// Whether execution pins the retrieval publication: exactly the operations +/// whose admission also requires `broad_search` readiness. fn operation_requires_retrieval(operation: &str) -> bool { - matches!( - operation, - "packet" | "search" | "context" | "drill" | "resolution" | "graph_assisted" - ) + operation_read_class(operation) == OperationReadClass::Retrieval } /// Whether a freshness observation permits serving an operation from the current publication. @@ -2518,6 +2597,29 @@ pub struct ActivationOperation { cancelled: Arc, } +/// Why a public operation answered from a retained publication instead of a +/// fresh one. Serialized into `codestory_publication.freshness` on the wire. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum HistoricalServedReason { + /// A refresh activation was in flight when the read was admitted. + RefreshInProgress, + /// The refresh could not publish because a peer process holds the writer. + PeerWriter, + /// A replacement publication failed and the previous generation was kept. + ReplacementFailed, + /// Source drift was observed with no clearer cause in the snapshot. + StaleSource, +} + +/// Whether a public operation's result came from the fresh current source or +/// from a retained publication a refresh is still replacing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OperationFreshness { + Fresh, + Historical(HistoricalServedReason), +} + #[derive(Debug, Clone)] pub struct PublicOperation { pub value: T, @@ -2525,6 +2627,9 @@ pub struct PublicOperation { pub retrieval_publication: Option, pub operation_id: String, pub attempt: u32, + /// `Historical` exactly when the freshness gate admitted this operation + /// from a retained publication; the adapter stamps it on the wire. + pub freshness: OperationFreshness, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -2572,25 +2677,48 @@ impl PublicOperationService { ) } - fn retained_core_allows(&self, operation: &str, publication: &IndexPublicationRecord) -> bool { - !operation_requires_retrieval(operation) - && self.activation.as_ref().is_some_and(|activation| { - let Some(project_root) = self.controller.require_project_root().ok() else { - return false; - }; - let Some(storage_path) = self.controller.require_storage_path().ok() else { - return false; - }; - activation - .snapshot_for_target(&project_root, &storage_path) - .is_some_and(|snapshot| { - matches!( - snapshot.capabilities.local_navigation, - ActivationCapabilityState::Ready | ActivationCapabilityState::Retained - ) && snapshot.retained_core_publication.as_ref() - == Some(&crate::index_publication_dto(publication.clone())) - }) - }) + /// Whether the operation may answer from a retained complete publication, + /// and why. Only `GraphOnly` operations have a retained escape; every + /// other class fails the freshness gate so a source-backed read can never + /// silently serve pre-refresh bytes. The reason is recorded for the + /// `historical` freshness metadata the adapter stamps on the result. + fn retained_core_admission( + &self, + operation: &str, + publication: &IndexPublicationRecord, + ) -> Option { + if operation_read_class(operation) != OperationReadClass::GraphOnly { + return None; + } + let snapshot = self.activation.as_ref().and_then(|activation| { + let project_root = self.controller.require_project_root().ok()?; + let storage_path = self.controller.require_storage_path().ok()?; + activation.snapshot_for_target(&project_root, &storage_path) + })?; + if !matches!( + snapshot.capabilities.local_navigation, + ActivationCapabilityState::Ready | ActivationCapabilityState::Retained + ) || snapshot.retained_core_publication.as_ref() + != Some(&crate::index_publication_dto(publication.clone())) + { + return None; + } + let reason = if snapshot.failure_code.as_deref() == Some("cache_busy") { + HistoricalServedReason::PeerWriter + } else if matches!( + snapshot.state, + ActivationState::Preparing | ActivationState::Updating + ) { + HistoricalServedReason::RefreshInProgress + } else if matches!( + snapshot.state, + ActivationState::Retryable | ActivationState::Unavailable + ) { + HistoricalServedReason::ReplacementFailed + } else { + HistoricalServedReason::StaleSource + }; + Some(reason) } fn ensure_packet_latency_remaining( @@ -2773,6 +2901,7 @@ impl PublicOperationService { let mut complete_core_span = crate::agent::packet_batch::observe_packet_operation_span( crate::agent::packet_batch::PacketOperationObservationSpan::CompleteCoreSnapshot, ); + let mut historical_reason = None; let result = self.controller.with_complete_core_snapshot(|publication| { let mut freshness_span = crate::agent::packet_batch::observe_packet_operation_span( @@ -2802,11 +2931,14 @@ impl PublicOperationService { let freshness = freshness?; if !index_freshness_admits_operation(&freshness) { codestory_workspace::invalidate_lease_memoized_values(); - if !self.retained_core_allows(operation, publication) { - return Err(ApiError::new( - "project_unavailable", - index_freshness_block_message(operation, &freshness), - )); + match self.retained_core_admission(operation, publication) { + Some(reason) => historical_reason = Some(reason), + None => { + return Err(ApiError::new( + "project_unavailable", + index_freshness_block_message(operation, &freshness), + )); + } } } let mut run = || { @@ -2864,11 +2996,14 @@ impl PublicOperationService { let after = after?; if !index_freshness_admits_operation(&after) { codestory_workspace::invalidate_lease_memoized_values(); - if !self.retained_core_allows(operation, publication) { - return Err(ApiError::new( - "publication_changed", - format!("source inputs changed while running {operation}"), - )); + match self.retained_core_admission(operation, publication) { + Some(reason) => historical_reason = Some(reason), + None => { + return Err(ApiError::new( + "publication_changed", + format!("source inputs changed while running {operation}"), + )); + } } } Ok(value) @@ -2916,6 +3051,10 @@ impl PublicOperationService { retrieval_publication, operation_id, attempt, + freshness: match historical_reason { + Some(reason) => OperationFreshness::Historical(reason), + None => OperationFreshness::Fresh, + }, }); } Err(error) @@ -2988,12 +3127,17 @@ impl PublicOperationService { }); match result { Ok((value, core_publication, retrieval_publication)) => { + // Observational reads pin the committed complete + // publication directly; they never take the retained + // admission escape, so they are always `Fresh` here even + // while a refresh is running. return Ok(PublicOperation { value, core_publication: Some(core_publication), retrieval_publication, operation_id, attempt, + freshness: OperationFreshness::Fresh, }); } Err(error) if attempt == 1 && error.code == "publication_changed" => continue, @@ -7817,7 +7961,9 @@ pub(crate) mod activation_tests { fn observational_admission_preserves_an_existing_stale_complete_publication() { let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cache").join("codestory.db"); - let source = project.path().join("fixture.rs"); + let source = project.path().join("src").join("lib.rs"); + fs::create_dir_all(source.parent().expect("source parent")) + .expect("create source directory"); fs::write(&source, "pub fn fixture() -> u32 { 1 }\n").expect("write fixture"); let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); @@ -7873,6 +8019,43 @@ pub(crate) mod activation_tests { .is_file(), "core publication must route its retention lock through the owned process cache" ); + + // With a failed replacement retaining the stale generation, only + // graph-only reads may answer from it; a source-backed read must be + // refused instead of serving pre-refresh bytes. + fs::write( + project.path().join("codestory_workspace.json"), + r#"{"members":["src","missing"]}"#, + ) + .expect("fence the replacement with an incomplete workspace"); + let error = runtime + .activation_service() + .activate_project( + project.path(), + &storage_path, + Arc::new(AtomicBool::new(false)), + ) + .expect_err("incomplete replacement inventory must fail closed"); + assert_eq!(error.code, "source_discovery_incomplete"); + let graph = runtime + .public_operation_service() + .run_with_cancel("ground", Arc::new(AtomicBool::new(false)), || Ok(())) + .expect("graph-only reads may answer from the retained core"); + assert_eq!( + graph.freshness, + OperationFreshness::Historical(HistoricalServedReason::ReplacementFailed), + "the retained answer must be labelled historical: {graph:?}" + ); + let mut entered_source_response = false; + let source = runtime + .public_operation_service() + .run_with_cancel("source_snippet", Arc::new(AtomicBool::new(false)), || { + entered_source_response = true; + Ok(()) + }) + .expect_err("a source-backed read must never serve the retained core"); + assert_eq!(source.code, "project_unavailable"); + assert!(!entered_source_response); } #[test] @@ -8306,6 +8489,39 @@ pub(crate) mod activation_tests { assert!(!snapshot.allows_operation("packet")); assert_ne!(snapshot.state, ActivationState::Ready); } + + #[test] + fn read_class_admission_keeps_retrieval_distinct_from_source_backed() { + // A fresh complete core with broad retrieval unavailable: source-backed + // and graph-only reads are admitted, while retrieval-class operations + // keep waiting on `broad_search` instead of being admitted on the core + // alone and then failing at the retrieval pin. + let snapshot = ActivationSnapshot { + operation_id: "read-class-fixture".into(), + revision: 1, + state: ActivationState::Ready, + stage: ActivationStage::Ready, + progress: activation_stage_progress(ActivationStage::Ready), + attempt: 1, + retry_after_ms: None, + embedding_capacity: None, + embedding_retry: None, + failure_code: None, + failure: None, + failure_details: None, + retained_core_publication: None, + capabilities: ActivationCapabilities { + local_navigation: ActivationCapabilityState::Ready, + broad_search: ActivationCapabilityState::Unavailable, + }, + }; + assert!(snapshot.allows_operation("source_snippet")); + assert!(snapshot.allows_operation("exact_search")); + assert!(snapshot.allows_operation("ground")); + assert!(!snapshot.allows_operation("graph_assisted")); + assert!(!snapshot.allows_operation("resolution")); + assert!(!snapshot.allows_operation("packet")); + } } #[cfg(test)] diff --git a/docs/architecture/runtime-execution-path.md b/docs/architecture/runtime-execution-path.md index ae73b386b..19e73da3b 100644 --- a/docs/architecture/runtime-execution-path.md +++ b/docs/architecture/runtime-execution-path.md @@ -38,7 +38,8 @@ refresh a project, initialize the engine, or mutate status state. | Class | Examples | May activate work | Required state | | --- | --- | --- | --- | | Observational | `status`, `doctor`, retrieval-engine diagnostics | No | readable current state | -| Local graph | `ground`, `files`, `symbol`, `callers`, `trail`, `snippet`, explicit `search --repo-text off` | bounded local refresh | current core publication for the requested surface | +| Graph-only | `ground`, `files`, `symbol`, `callers`, `trail` | bounded local refresh | current or retained core publication for the requested surface | +| Source-backed | `snippet` by symbol id, explicit `search --repo-text off` | bounded local refresh | a fresh complete core publication | | Broad retrieval | `packet`, ordinary `search`, broad query-based `context` | local refresh, engine init, retrieval finalization | coherent retrieval publication and live policy-compliant engine | | Exact target | definition/reference/node and focused context calls | only what the selected operation needs | resolved target plus its declared readiness | @@ -51,6 +52,19 @@ projection and truthfully reports symbolic retrieval without initializing or claiming semantic readiness. There is no user-facing sidecar setup or repair decision. +While a refresh is in flight, the read classes diverge. A source-backed read +waits for the fresh complete core inside the call's deadline and returns +`preparing` with resume arguments when the deadline expires; it never serves +retained bytes. A graph-only read may answer from the retained complete +publication, and the runtime labels that answer +`_meta.codestory_publication.freshness.state = "historical"` with the derived +`reason` (`refresh_in_progress`, `replacement_failed`, `peer_writer`, or +`stale_source`) and `served_generation`. `affected` and the proof tools are +pinned complete-core observers: they bind the committed publication and report +drift against it rather than waiting or falling back. `resources/read` stays +observational — it never activates or waits, so a `codestory://snippet/` read +against drifted bytes reports `source_stale` from the pinned generation. + ## Core indexing An explicit `index` request delegates to runtime, which asks diff --git a/plugins/codestory/generated-mcp-catalog.json b/plugins/codestory/generated-mcp-catalog.json index 4577c66b7..526a7b028 100644 --- a/plugins/codestory/generated-mcp-catalog.json +++ b/plugins/codestory/generated-mcp-catalog.json @@ -1,6 +1,6 @@ { "wireContract": { - "publicationStampSchemaVersion": 3, + "publicationStampSchemaVersion": 4, "minimumCompatiblePublicationStampSchemaVersion": 3, "supportedMcpProtocolVersions": [ "2024-11-05", diff --git a/plugins/codestory/skills/codestory-grounding/SKILL.md b/plugins/codestory/skills/codestory-grounding/SKILL.md index fc34865fb..be21bba86 100644 --- a/plugins/codestory/skills/codestory-grounding/SKILL.md +++ b/plugins/codestory/skills/codestory-grounding/SKILL.md @@ -173,6 +173,14 @@ work. A real unavailable result is terminal for that call. Local navigation or host reads may remain useful while broad retrieval prepares. Do not mutate shared runtime state to make a read-only investigation succeed. +During a refresh, source-backed reads (`snippet` by symbol id, `search` with +`repo_text=off`) wait for the fresh complete index within the call's deadline. +Pure graph answers (`ground`, `files`, symbol/trail navigation) may instead +come from the retained publication; when they do, the response marks +`_meta.codestory_publication.freshness.state` as `historical` with a +runtime-derived `reason`, so cite it as pre-refresh evidence rather than +current source. + Use `status` or `codestory://status{?project}` to diagnose failed or stalled requests. Discover the intended method if tools are hidden. If MCP is unavailable, report that boundary and use ordinary source inspection. diff --git a/plugins/codestory/skills/codestory-grounding/references/snippet.md b/plugins/codestory/skills/codestory-grounding/references/snippet.md index e08f571c4..2ef5f9b61 100644 --- a/plugins/codestory/skills/codestory-grounding/references/snippet.md +++ b/plugins/codestory/skills/codestory-grounding/references/snippet.md @@ -28,6 +28,13 @@ The MCP `snippet` tool exposes `scope=line_context|function_body` and `scope`. Pass only one member of each alias pair; unknown or conflicting fields fail instead of being ignored. +A snippet resolved by stable symbol id is a source-backed read: while a refresh +is running it waits for the fresh complete index inside the call's deadline +instead of returning pre-refresh bytes, and returns `preparing` when the +deadline expires first. The `codestory://snippet/{id}` resource stays +observational — it never activates or waits, and reports `source_stale` when +the indexed bytes no longer match the file. + ``` # Snippet resolved: `AppController::new` -> [abc123] new [FUNCTION] `src/lib.rs`:100 diff --git a/plugins/codestory/skills/codestory-grounding/references/status-contract.md b/plugins/codestory/skills/codestory-grounding/references/status-contract.md index 7b9a5cfe6..86e04b3e9 100644 --- a/plugins/codestory/skills/codestory-grounding/references/status-contract.md +++ b/plugins/codestory/skills/codestory-grounding/references/status-contract.md @@ -44,6 +44,16 @@ it as `after_ms`. part of the call. Once a complete publication exists, local graph tools keep using it during refresh and never read a half-published generation. +Tool results carry `_meta.codestory_publication.freshness` describing what the +runtime actually served: `state` is `fresh` when the answer came from the +current source, and `historical` when a graph-only tool answered from the +retained publication while a refresh was still running or after a failed +replacement. The `reason` and `served_generation` fields name why and which +generation. Source-backed reads (`snippet` by symbol id, `search` with +`repo_text=off`) never return historical bytes: they wait for the fresh +complete index inside the call's deadline and return `preparing` if it cannot +converge. + ## Diagnostic status `codestory://status{?project}` is an observational diagnostic resource From 47e840b01ff8ada537c688c74e8927dfffe824e9 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 04:23:13 -0400 Subject: [PATCH 04/58] drop unused binding in multi-project contract test --- crates/codestory-cli/tests/stdio_protocol_contracts.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/codestory-cli/tests/stdio_protocol_contracts.rs b/crates/codestory-cli/tests/stdio_protocol_contracts.rs index 54bd9af0d..33559e481 100644 --- a/crates/codestory-cli/tests/stdio_protocol_contracts.rs +++ b/crates/codestory-cli/tests/stdio_protocol_contracts.rs @@ -2590,7 +2590,7 @@ fn multi_project_stdio_routes_interleaved_requests_by_explicit_project() { }) .and_then(|symbol| symbol["id"].as_str()) .unwrap_or_else(|| panic!("first project should expose first_only: {first_ground:#}")); - let first_symbol = assert_tool_success( + assert_tool_success( &send_json( &mut server, json!({ @@ -2604,8 +2604,7 @@ fn multi_project_stdio_routes_interleaved_requests_by_explicit_project() { }), ), json!("multi-first-symbol"), - ) - .clone(); + ); let wrong_project_uri = format!( "codestory://symbol/{}?project={}", strict_resource_component(first_node_id), From afed338af2c74e29b6ab84a841fd7b2551a0ab2b Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 06:01:46 -0400 Subject: [PATCH 05/58] wait for a peer writer during activation refresh --- CHANGELOG.md | 1 + crates/codestory-cli/src/stdio_transport.rs | 3 + .../tests/stdio_protocol_contracts.rs | 861 +++++++++++++++++- .../src/controller_indexing.rs | 81 +- crates/codestory-runtime/src/index_commit.rs | 44 +- crates/codestory-runtime/src/services.rs | 411 +++++++-- docs/architecture/runtime-execution-path.md | 10 + plugins/codestory/scripts/codestory-mcp.cjs | 4 +- .../references/status-contract.md | 5 +- .../codestory/tests/plugin-static.test.mjs | 32 +- 10 files changed, 1324 insertions(+), 128 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbc8bfe73..c317bea43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Snippet and source reads for an indexed symbol now verify file bytes against the indexed content hash and refuse stale or missing source with a typed error instead of returning mismatched text. - During a refresh, source-backed reads wait for the fresh index while graph-only answers may come from the retained publication; results served that way are labelled `historical` under `_meta.codestory_publication.freshness`. +- When another CodeStory session is already refreshing the same project, a second session now waits for it to finish and adopts its publication instead of failing with a busy error. ## 0.17.6 diff --git a/crates/codestory-cli/src/stdio_transport.rs b/crates/codestory-cli/src/stdio_transport.rs index 06f2202d8..6f68985b7 100644 --- a/crates/codestory-cli/src/stdio_transport.rs +++ b/crates/codestory-cli/src/stdio_transport.rs @@ -827,6 +827,9 @@ fn stdio_activation_stage_message(stage: codestory_runtime::ActivationStage) -> use codestory_runtime::ActivationStage; match stage { ActivationStage::Discovery => "CodeStory is checking project files", + ActivationStage::WaitingForPeerWriter => { + "CodeStory is waiting for another session to finish indexing" + } ActivationStage::CoreFreshness => "CodeStory is updating the code index", ActivationStage::SearchPreparation => "CodeStory is preparing search", ActivationStage::DensePreparation => "CodeStory is preparing semantic search", diff --git a/crates/codestory-cli/tests/stdio_protocol_contracts.rs b/crates/codestory-cli/tests/stdio_protocol_contracts.rs index 33559e481..90d4442a2 100644 --- a/crates/codestory-cli/tests/stdio_protocol_contracts.rs +++ b/crates/codestory-cli/tests/stdio_protocol_contracts.rs @@ -2673,6 +2673,9 @@ fn multi_project_packet_repairs_keep_operation_identity_project_scoped() { }) .collect::>(); let mut server = spawn_multi_project_stdio_server(cache_root.path()); + // A peer holding the writer lock is a wait, not a failure: each project's + // activation parks at the peer-writer stage and the short latency budget + // returns a resumable preparing envelope naming that operation. let packet_request = |id: &str, project: &Path| { json!({ "jsonrpc": "2.0", @@ -2682,7 +2685,8 @@ fn multi_project_packet_repairs_keep_operation_identity_project_scoped() { "name": "packet", "arguments": { "project": project, - "question": "How does AppController open a project?" + "question": "How does AppController open a project?", + "latency_budget_ms": 1000 } } }) @@ -2692,17 +2696,16 @@ fn multi_project_packet_repairs_keep_operation_identity_project_scoped() { for (index, project) in projects.iter().enumerate() { let id = format!("multi-packet-{index}"); let response = send_json(&mut server, packet_request(&id, project.path())); - let unavailable = assert_tool_error(&response, json!(id)); + let preparing = assert_tool_preparing(&response, json!(id)); assert_eq!( - unavailable["code"], - json!("codestory_unavailable"), - "the original call must report the writer-lock failure: {unavailable}" + preparing["operation"]["stage"], + json!("waiting_for_peer_writer"), + "the call must be parked on the peer's writer lock: {preparing}" ); - assert_eq!(unavailable["cause_code"], json!("cache_busy")); - assert_eq!(unavailable["state"], json!("unavailable")); - assert_eq!(unavailable["operation"]["state"], json!("retryable")); + assert!(preparing["resume_operation_id"].is_string()); + assert!(preparing["resume_operation_attempt"].is_u64()); operation_ids.push( - unavailable["operation"]["operation_id"] + preparing["operation"]["operation_id"] .as_str() .expect("project activation operation id") .to_string(), @@ -6417,6 +6420,846 @@ fn two_stdio_processes_observe_only_complete_generations_during_real_refresh() { assert_tool_success(&writer_status, json!("writer-start-refresh")); } +/// Shared-cache two-process fixtures below pin the cross-process peer-writer +/// contract: while one process holds the index-writer lock mid-refresh, a +/// second process's activation waits on the publication boundary instead of +/// failing `cache_busy`, then adopts whatever the peer published. + +/// `.index-writer.hold` marker companion to the lock path: the file +/// holds a refresh parked mid-flight while it exists (see +/// `wait_out_index_writer_hold_marker`). +fn stdio_writer_paths(storage_path: &str) -> (PathBuf, PathBuf) { + let storage = PathBuf::from(storage_path); + ( + storage.with_extension("index-writer.lock"), + storage.with_extension("index-writer-hold"), + ) +} + +/// Spawn `index --refresh incremental` as a child process. With the +/// writer-hold marker armed it parks mid-refresh still holding the +/// index-writer lock, so tests control exactly when the peer publishes. +fn spawn_peer_index_refresh(fixture: &StdioFixture) -> Child { + let mut command = test_support::cli_command(); + command + .arg("index") + .arg("--refresh") + .arg("incremental") + .arg("--format") + .arg("json") + .arg("--project") + .arg(fixture.workspace.path()) + .arg("--cache-dir") + .arg(fixture.cache_dir.path()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + allow_explicit_cpu_embeddings(&mut command); + command.spawn().expect("spawn peer index refresh") +} + +/// Wait until some other process holds the index-writer lock: a failed probe +/// proves ownership sits elsewhere. +fn wait_for_peer_held_writer(writer_lock_path: &Path, context: &str) { + let file = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(writer_lock_path) + .expect("open index-writer lock"); + let deadline = Instant::now() + Duration::from_secs(60); + loop { + if !file.try_lock_exclusive().expect("probe index-writer lock") { + return; + } + file.unlock().expect("release probed lock"); + assert!(Instant::now() < deadline, "{context}"); + thread::sleep(Duration::from_millis(10)); + } +} + +/// Write `request` without consuming a response: interleaved +/// `notifications/progress` frames are read separately by the caller. +fn write_stdio_request(server: &mut StdioServer, request: &Value) { + writeln!(server.stdin, "{request}").expect("write request line"); + server.stdin.flush().expect("flush request line"); +} + +/// Send `request` on a background thread and stream every frame — progress +/// notifications included — through the channel. The frame matching the +/// request `id` is the response and ends the stream. +fn stream_stdio_request( + mut server: StdioServer, + request: Value, +) -> ( + std::sync::mpsc::Receiver, + thread::JoinHandle, +) { + let (tx, rx) = std::sync::mpsc::channel(); + let id = request.get("id").cloned(); + let handle = thread::spawn(move || { + write_stdio_request(&mut server, &request); + loop { + let frame = read_json(&mut server); + let is_response = frame.get("id") == id.as_ref(); + let _ = tx.send(frame); + if is_response { + break; + } + } + server + }); + (rx, handle) +} + +/// Drain `rx` until the response frame for `id` arrives, returning it along +/// with every notification frame observed before it. +fn wait_for_streamed_response( + rx: &std::sync::mpsc::Receiver, + id: &str, + deadline: Duration, +) -> (Value, Vec) { + let started = Instant::now(); + let mut notifications = Vec::new(); + loop { + let remaining = deadline.saturating_sub(started.elapsed()); + assert!( + !remaining.is_zero(), + "response for {id} did not arrive within {deadline:?}" + ); + match rx.recv_timeout(remaining) { + Ok(frame) if frame.get("id") == Some(&json!(id)) => { + return (frame, notifications); + } + Ok(frame) => notifications.push(frame), + Err(_) => panic!("response for {id} did not arrive within {deadline:?}"), + } + } +} + +/// A request armed with a progress token must surface the peer-wait stage +/// message while the activation is parked on the peer's writer lock. +fn wait_for_peer_wait_progress(rx: &std::sync::mpsc::Receiver, id: &str) -> Vec { + let deadline = Instant::now() + Duration::from_secs(60); + let mut seen = Vec::new(); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + let frame = rx + .recv_timeout(remaining.max(Duration::from_millis(1))) + .unwrap_or_else(|_| panic!("no frames observed for {id} while waiting for the peer")); + if frame.get("id") == Some(&json!(id)) { + panic!("request {id} answered before reaching the peer-writer wait: {frame}"); + } + let is_wait_stage = frame.get("method") == Some(&json!("notifications/progress")) + && frame.pointer("/params/message").and_then(Value::as_str) + == Some("CodeStory is waiting for another session to finish indexing"); + seen.push(frame); + if is_wait_stage { + return seen; + } + assert!( + Instant::now() < deadline, + "request {id} never reported the peer-writer wait stage: {seen:?}" + ); + } +} + +/// Collect the fixture's storage path, current generation, and one stable +/// symbol id from a warm reader process before any drift. +fn warmup_reader_state(fixture: &StdioFixture) -> (StdioServer, String, u64, String) { + let mut reader = spawn_stdio_server(fixture); + let warmup_status = send_json( + &mut reader, + json!({ + "jsonrpc": "2.0", + "id": "peer-warmup-status", + "method": "resources/read", + "params": {"uri": "codestory://status", "project": fixture.workspace.path()} + }), + ); + let status = json_resource_content( + assert_success_envelope(&warmup_status, json!("peer-warmup-status")), + "codestory://status", + ); + let storage_path = status["storage_path"] + .as_str() + .expect("status storage_path") + .to_string(); + let old_generation = status["index_publication"]["generation"] + .as_u64() + .expect("warm complete generation"); + let symbols_response = send_json( + &mut reader, + json!({ + "jsonrpc": "2.0", + "id": "peer-warmup-symbols", + "method": "resources/read", + "params": { + "uri": "codestory://symbols/root", + "project": fixture.workspace.path() + } + }), + ); + let root_symbols = json_resource_content( + assert_success_envelope(&symbols_response, json!("peer-warmup-symbols")), + "codestory://symbols/root", + ); + let symbol_id = root_symbols + .as_array() + .into_iter() + .flatten() + .find(|symbol| { + symbol["display_name"] == json!("AppController") + || symbol["label"] == json!("AppController") + || symbol["label"] + .as_str() + .is_some_and(|label| label.starts_with("AppController ")) + }) + .and_then(|symbol| symbol["id"].as_str()) + .unwrap_or_else(|| panic!("root symbols should expose AppController: {root_symbols}")) + .to_string(); + (reader, storage_path, old_generation, symbol_id) +} + +/// The current complete generation as a fresh observational status read sees +/// it, so the test counts publications from shared storage. +fn observed_generation(reader: &mut StdioServer, fixture: &StdioFixture, id: &str) -> u64 { + let response = send_json( + reader, + json!({ + "jsonrpc": "2.0", + "id": id, + "method": "resources/read", + "params": {"uri": "codestory://status", "project": fixture.workspace.path()} + }), + ); + let status = json_resource_content( + assert_success_envelope(&response, json!(id)), + "codestory://status", + ); + status["index_publication"]["generation"] + .as_u64() + .expect("observed complete generation") +} + +#[test] +fn two_stdio_processes_peer_writer_wait_adopts_the_peer_publication() { + let fixture = indexed_fixture(); + let (mut reader, storage_path, old_generation, symbol_id) = warmup_reader_state(&fixture); + let (writer_lock_path, hold_marker_path) = stdio_writer_paths(&storage_path); + + fs::write( + fixture.workspace.path().join("src/peer_adopted.rs"), + "pub fn peer_adopted() -> usize { 7 } +", + ) + .expect("drift the workspace"); + fs::write(&hold_marker_path, "hold").expect("arm writer-hold marker"); + let peer = spawn_peer_index_refresh(&fixture); + wait_for_peer_held_writer( + &writer_lock_path, + "the peer refresh never held the index-writer lock", + ); + + let snippet_server = spawn_stdio_server(&fixture); + let packet_server = spawn_stdio_server(&fixture); + let (snippet_rx, snippet_thread) = stream_stdio_request( + snippet_server, + json!({ + "jsonrpc": "2.0", + "id": "peer-wait-snippet", + "method": "tools/call", + "params": { + "name": "snippet", + "arguments": {"id": symbol_id}, + "_meta": {"progressToken": "peer-snippet-progress"} + } + }), + ); + let (packet_rx, packet_thread) = stream_stdio_request( + packet_server, + json!({ + "jsonrpc": "2.0", + "id": "peer-wait-packet", + "method": "tools/call", + "params": { + "name": "packet", + "arguments": { + "question": "How does AppController open a project?", + "latency_budget_ms": 60000 + }, + "_meta": {"progressToken": "peer-packet-progress"} + } + }), + ); + + // Both calls must be parked inside the peer-writer wait — progress + // reporting proves the stage — with no response on the wire while the + // peer still holds the writer. + wait_for_peer_wait_progress(&snippet_rx, "peer-wait-snippet"); + wait_for_peer_wait_progress(&packet_rx, "peer-wait-packet"); + thread::sleep(Duration::from_millis(500)); + for (rx, id) in [ + (&snippet_rx, "peer-wait-snippet"), + (&packet_rx, "peer-wait-packet"), + ] { + while let Ok(frame) = rx.try_recv() { + assert_ne!( + frame.get("id"), + Some(&json!(id)), + "request {id} answered while the peer still held the writer: {frame}" + ); + } + } + + fs::remove_file(&hold_marker_path).expect("release the parked peer refresh"); + let peer_output = peer.wait_with_output().expect("wait for peer refresh exit"); + assert!( + peer_output.status.success(), + "peer refresh failed +stdout: +{} +stderr: +{}", + String::from_utf8_lossy(&peer_output.stdout), + String::from_utf8_lossy(&peer_output.stderr) + ); + + let (snippet_response, _) = + wait_for_streamed_response(&snippet_rx, "peer-wait-snippet", Duration::from_secs(120)); + let (packet_response, _) = + wait_for_streamed_response(&packet_rx, "peer-wait-packet", Duration::from_secs(120)); + let _snippet_server = snippet_thread.join().expect("join snippet request"); + let _packet_server = packet_thread.join().expect("join packet request"); + + // Exactly one new complete generation exists — the peer's — and both + // waiters answer from it rather than publishing again. + let published = observed_generation(&mut reader, &fixture, "peer-adopted-generation"); + assert_eq!( + published, + old_generation + 1, + "exactly one publication must occur: the peer's" + ); + assert_tool_success(&snippet_response, json!("peer-wait-snippet")); + let snippet_result = assert_success_envelope(&snippet_response, json!("peer-wait-snippet")); + assert_eq!( + snippet_result["_meta"]["codestory_publication"]["publication"]["generation"], + json!(published), + "the waiting snippet must answer from the peer's publication" + ); + assert_eq!( + snippet_result["_meta"]["codestory_publication"]["freshness"]["state"], + json!("fresh") + ); + let packet_result = assert_success_envelope(&packet_response, json!("peer-wait-packet")); + if packet_result.get("isError").and_then(Value::as_bool) == Some(true) { + // This build carries no embedded embedding model, so a full + // activation can wait on the peer, adopt its publication, and still + // terminate at dense preparation. The wire answer must be that typed + // limitation — never a lock-contention refusal — and the observed + // operation proves the peer's publication was already in place. + let error = assert_tool_error(&packet_response, json!("peer-wait-packet")); + assert_eq!( + error["cause_code"], + json!("native_model_not_embedded"), + "the waited packet must not fail on writer contention: {error}" + ); + assert_eq!( + error["operation"]["retained_core_publication"]["generation"], + json!(published), + "the packet activation must have adopted the peer's publication" + ); + } else { + assert_tool_success(&packet_response, json!("peer-wait-packet")); + assert_eq!( + packet_result["_meta"]["codestory_publication"]["publication"]["generation"], + json!(published) + ); + assert_eq!( + packet_result["_meta"]["codestory_publication"]["freshness"]["state"], + json!("fresh") + ); + } +} + +#[test] +fn two_stdio_processes_peer_writer_wait_returns_resumable_preparing() { + let fixture = indexed_fixture(); + let (mut reader, storage_path, old_generation, _symbol_id) = warmup_reader_state(&fixture); + let (writer_lock_path, hold_marker_path) = stdio_writer_paths(&storage_path); + + fs::write( + fixture.workspace.path().join("src/peer_resume.rs"), + "pub fn peer_resume() -> usize { 8 } +", + ) + .expect("drift the workspace"); + fs::write(&hold_marker_path, "hold").expect("arm writer-hold marker"); + let peer = spawn_peer_index_refresh(&fixture); + wait_for_peer_held_writer( + &writer_lock_path, + "the peer refresh never held the index-writer lock", + ); + + // A bounded packet latency budget expires while the peer still holds the + // writer: the caller gets a resumable preparing envelope plus progress. + // The budget must span the first activation slice plus one progress tick + // so the join-wait loop reports the parked stage before returning. + // send_json reads only one line, so progress frames are drained until the + // matching response id arrives. + let mut packet_server = spawn_stdio_server(&fixture); + write_stdio_request( + &mut packet_server, + &json!({ + "jsonrpc": "2.0", + "id": "peer-deadline-packet", + "method": "tools/call", + "params": { + "name": "packet", + "arguments": { + "question": "How does AppController open a project?", + "latency_budget_ms": 10000 + }, + "_meta": {"progressToken": "peer-deadline-progress"} + } + }), + ); + let deadline = Instant::now() + Duration::from_secs(60); + let mut notifications = Vec::new(); + let preparing_response = loop { + let frame = read_json(&mut packet_server); + assert!( + Instant::now() < deadline, + "the packet call never returned its preparing envelope" + ); + if frame.get("id") == Some(&json!("peer-deadline-packet")) { + break frame; + } + notifications.push(frame); + }; + let preparing = assert_success_envelope(&preparing_response, json!("peer-deadline-packet")); + let structured = preparing["structuredContent"].clone(); + assert_eq!( + structured["kind"], + json!("preparing"), + "the expired call must return the resumable preparing envelope: {preparing_response}" + ); + assert_eq!(structured["state"], json!("preparing")); + assert_eq!(structured["deadline_exceeded"], json!(true)); + assert!( + structured["resume_operation_id"].is_string() + && structured["resume_operation_attempt"].is_u64(), + "the preparing envelope must carry callable resume identity: {structured}" + ); + assert!( + notifications.iter().any(|frame| { + frame.get("method") == Some(&json!("notifications/progress")) + && frame.pointer("/params/progressToken") == Some(&json!("peer-deadline-progress")) + }), + "a progress-token request must emit progress while waiting on the peer: {notifications:?}" + ); + // The timed-out request left its activation running in the background; + // it is still parked on the peer's writer lock. + assert_eq!( + structured["operation"]["stage"], + json!("waiting_for_peer_writer"), + "the preparing envelope must describe the peer-writer wait: {structured}" + ); + + fs::remove_file(&hold_marker_path).expect("release the parked peer refresh"); + let peer_output = peer.wait_with_output().expect("wait for peer refresh exit"); + assert!( + peer_output.status.success(), + "peer refresh failed +stderr: +{}", + String::from_utf8_lossy(&peer_output.stderr) + ); + let published = observed_generation(&mut reader, &fixture, "peer-resume-generation"); + assert_eq!(published, old_generation + 1); + + // Resume on the same connection with the envelope's exact minimum_next + // arguments: the still-running shared activation adopts the peer's + // publication, finishes, and the resumed call answers from it. The + // replayed latency budget is short, so another preparing envelope is a + // legal intermediate answer. + let resume_arguments = structured["minimum_next"]["arguments"].clone(); + let deadline = Instant::now() + Duration::from_secs(120); + let mut resume_attempt = 0usize; + loop { + resume_attempt += 1; + let resume_response = send_json( + &mut packet_server, + json!({ + "jsonrpc": "2.0", + "id": format!("peer-deadline-resume-{resume_attempt}"), + "method": "tools/call", + "params": {"name": "packet", "arguments": resume_arguments} + }), + ); + let resume_result = assert_success_envelope( + &resume_response, + json!(format!("peer-deadline-resume-{resume_attempt}")), + ); + if resume_result["structuredContent"]["kind"] == json!("preparing") { + assert!( + Instant::now() < deadline, + "the resumed call never converged after the peer published" + ); + continue; + } + if resume_result.get("isError").and_then(Value::as_bool) == Some(true) { + // A build without the embedded embedding model waits, adopts the + // peer's core, then ends at dense preparation. The wire answer is + // that typed limitation — never a contention error or an unknown + // operation. + let error = assert_tool_error( + &resume_response, + json!(format!("peer-deadline-resume-{resume_attempt}")), + ); + let cause = error["cause_code"] + .as_str() + .or_else(|| error["details"]["cause_code"].as_str()); + assert_eq!( + cause, + Some("native_model_not_embedded"), + "the resumed call must not fail on writer contention: {error}" + ); + break; + } + assert_tool_success(&resume_response, json!("peer-deadline-resume")); + assert_eq!( + resume_result["_meta"]["codestory_publication"]["publication"]["generation"], + json!(published), + "the resumed call must answer from the peer's publication: {resume_response}" + ); + assert_eq!( + resume_result["_meta"]["codestory_publication"]["freshness"]["state"], + json!("fresh") + ); + break; + } +} + +#[test] +fn two_stdio_processes_cancelling_a_peer_waiter_leaves_everything_running() { + let fixture = indexed_fixture(); + let (mut reader, storage_path, old_generation, symbol_id) = warmup_reader_state(&fixture); + let (writer_lock_path, hold_marker_path) = stdio_writer_paths(&storage_path); + + fs::write( + fixture.workspace.path().join("src/peer_cancel.rs"), + "pub fn peer_cancel() -> usize { 9 } +", + ) + .expect("drift the workspace"); + fs::write(&hold_marker_path, "hold").expect("arm writer-hold marker"); + let peer = spawn_peer_index_refresh(&fixture); + wait_for_peer_held_writer( + &writer_lock_path, + "the peer refresh never held the index-writer lock", + ); + + // Park the waiter's snippet on the peer's writer lock; the progress + // frames prove which stage the request reached. + let mut waiter = spawn_stdio_server(&fixture); + write_stdio_request( + &mut waiter, + &json!({ + "jsonrpc": "2.0", + "id": "peer-cancel-snippet", + "method": "tools/call", + "params": { + "name": "snippet", + "arguments": {"id": symbol_id}, + "_meta": {"progressToken": "peer-cancel-progress"} + } + }), + ); + let progress_deadline = Instant::now() + Duration::from_secs(60); + loop { + let frame = read_json(&mut waiter); + assert_ne!( + frame.get("id"), + Some(&json!("peer-cancel-snippet")), + "the waiting request must not answer before cancellation: {frame}" + ); + if frame.pointer("/params/message").and_then(Value::as_str) + == Some("CodeStory is waiting for another session to finish indexing") + { + break; + } + assert!( + Instant::now() < progress_deadline, + "the waiter never reached the peer-writer stage" + ); + } + + // Cancelling the request frees the worker promptly without touching the + // shared activation it joined. + let cancel_sent = Instant::now(); + write_stdio_request( + &mut waiter, + &json!({ + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": "peer-cancel-snippet"} + }), + ); + write_stdio_request( + &mut waiter, + &json!({ + "jsonrpc": "2.0", + "id": "peer-cancel-status", + "method": "resources/read", + "params": {"uri": "codestory://status", "project": fixture.workspace.path()} + }), + ); + let status_deadline = Instant::now() + Duration::from_secs(30); + let mut saw_cancelled_response = false; + let status_response = loop { + let frame = read_json(&mut waiter); + assert!( + Instant::now() < status_deadline, + "the cancelled request never released the connection" + ); + if frame.get("id") == Some(&json!("peer-cancel-snippet")) { + saw_cancelled_response = true; + } + if frame.get("id") == Some(&json!("peer-cancel-status")) { + break frame; + } + }; + assert!( + !saw_cancelled_response, + "a client-cancelled request id is owed no response" + ); + assert!( + cancel_sent.elapsed() < Duration::from_secs(30), + "the cancelled request must release the worker promptly" + ); + let status = json_resource_content( + assert_success_envelope(&status_response, json!("peer-cancel-status")), + "codestory://status", + ); + let assert_still_waiting = |status: &Value| { + let operation = &status["current_operation"]; + assert_eq!( + operation["stage"], + json!("waiting_for_peer_writer"), + "cancelling the request must not cancel the shared activation: {status}" + ); + assert!( + matches!( + operation["state"].as_str(), + Some("preparing" | "updating" | "working_locally") + ), + "the shared activation must still be running for other callers: {status}" + ); + }; + assert_still_waiting(&status); + + // The cancelled waiter must not have killed the shared activation: after + // a settle window it is still parked on the peer's writer lock. + std::thread::sleep(Duration::from_secs(2)); + let settled_status_response = send_json( + &mut waiter, + json!({ + "jsonrpc": "2.0", + "id": "peer-cancel-status-settled", + "method": "resources/read", + "params": {"uri": "codestory://status", "project": fixture.workspace.path()} + }), + ); + let settled_status = json_resource_content( + assert_success_envelope( + &settled_status_response, + json!("peer-cancel-status-settled"), + ), + "codestory://status", + ); + assert_still_waiting(&settled_status); + + fs::remove_file(&hold_marker_path).expect("release the parked peer refresh"); + let peer_output = peer.wait_with_output().expect("wait for peer refresh exit"); + assert!( + peer_output.status.success(), + "the peer refresh must publish normally despite the cancelled waiter" + ); + let published = observed_generation(&mut reader, &fixture, "peer-cancel-generation"); + assert_eq!(published, old_generation + 1); + + let retry_response = send_json( + &mut waiter, + json!({ + "jsonrpc": "2.0", + "id": "peer-cancel-retry", + "method": "tools/call", + "params": {"name": "snippet", "arguments": {"id": symbol_id}} + }), + ); + let retried = assert_tool_success(&retry_response, json!("peer-cancel-retry")); + let retry_result = assert_success_envelope(&retry_response, json!("peer-cancel-retry")); + assert_eq!( + retry_result["_meta"]["codestory_publication"]["publication"]["generation"], + json!(published), + "a later call must answer from the peer publication: {retried:?}" + ); +} + +#[test] +fn two_stdio_processes_peer_writer_death_lets_the_waiter_publish() { + let fixture = indexed_fixture(); + let (mut reader, storage_path, old_generation, symbol_id) = warmup_reader_state(&fixture); + let (writer_lock_path, hold_marker_path) = stdio_writer_paths(&storage_path); + + fs::write( + fixture.workspace.path().join("src/peer_dead.rs"), + "pub fn peer_dead() -> usize { 10 } +", + ) + .expect("drift the workspace"); + fs::write(&hold_marker_path, "hold").expect("arm writer-hold marker"); + let mut peer = spawn_peer_index_refresh(&fixture); + wait_for_peer_held_writer( + &writer_lock_path, + "the peer refresh never held the index-writer lock", + ); + + let snippet_server = spawn_stdio_server(&fixture); + let (snippet_rx, snippet_thread) = stream_stdio_request( + snippet_server, + json!({ + "jsonrpc": "2.0", + "id": "peer-death-snippet", + "method": "tools/call", + "params": { + "name": "snippet", + "arguments": {"id": symbol_id}, + "_meta": {"progressToken": "peer-death-progress"} + } + }), + ); + wait_for_peer_wait_progress(&snippet_rx, "peer-death-snippet"); + + // Kill the owner without publishing: the OS releases the writer lock and + // the parked waiter takes over — where the still-armed marker parks it + // again, mid-refresh, holding the lock this time. + peer.kill().expect("kill the peer writer"); + let _ = peer.wait().expect("reap the peer writer"); + wait_for_peer_held_writer( + &writer_lock_path, + "the waiter never took over the index-writer lock after the peer died", + ); + + // The previous publication stays readable while the waiter owns the + // writer: a graph-only read is answered from it and labelled historical. + let mut graph_only = spawn_stdio_server(&fixture); + let ground_response = send_json( + &mut graph_only, + json!({ + "jsonrpc": "2.0", + "id": "peer-death-ground", + "method": "tools/call", + "params": {"name": "ground", "arguments": {"budget": "strict"}} + }), + ); + let ground = assert_tool_success(&ground_response, json!("peer-death-ground")); + assert!(ground["stats"]["file_count"].as_u64().is_some()); + let ground_result = assert_success_envelope(&ground_response, json!("peer-death-ground")); + assert_eq!( + ground_result["_meta"]["codestory_publication"]["publication"]["generation"], + json!(old_generation), + "the retained publication must stay readable while the waiter writes" + ); + assert_eq!( + ground_result["_meta"]["codestory_publication"]["freshness"]["state"], + json!("historical") + ); + + fs::remove_file(&hold_marker_path).expect("release the parked waiter"); + let (snippet_response, _) = + wait_for_streamed_response(&snippet_rx, "peer-death-snippet", Duration::from_secs(120)); + let _snippet_server = snippet_thread.join().expect("join snippet request"); + assert_tool_success(&snippet_response, json!("peer-death-snippet")); + let snippet_result = assert_success_envelope(&snippet_response, json!("peer-death-snippet")); + assert_eq!( + snippet_result["_meta"]["codestory_publication"]["freshness"]["state"], + json!("fresh") + ); + + // The dead peer published nothing; the waiter's own refresh is the only + // new complete generation. + let published = observed_generation(&mut reader, &fixture, "peer-death-generation"); + assert_eq!( + published, + old_generation + 1, + "the waiter performs exactly one refresh after the peer dies" + ); + assert_eq!( + snippet_result["_meta"]["codestory_publication"]["publication"]["generation"], + json!(published) + ); +} + +#[test] +fn two_stdio_processes_graph_only_read_during_peer_refresh_is_historical() { + let fixture = indexed_fixture(); + let (mut reader, storage_path, old_generation, _symbol_id) = warmup_reader_state(&fixture); + let (writer_lock_path, hold_marker_path) = stdio_writer_paths(&storage_path); + + fs::write( + fixture.workspace.path().join("src/peer_historical.rs"), + "pub fn peer_historical() -> usize { 11 } +", + ) + .expect("drift the workspace"); + fs::write(&hold_marker_path, "hold").expect("arm writer-hold marker"); + let peer = spawn_peer_index_refresh(&fixture); + wait_for_peer_held_writer( + &writer_lock_path, + "the peer refresh never held the index-writer lock", + ); + + let mut graph_only = spawn_stdio_server(&fixture); + let ground_response = send_json( + &mut graph_only, + json!({ + "jsonrpc": "2.0", + "id": "peer-historical-ground", + "method": "tools/call", + "params": {"name": "ground", "arguments": {"budget": "strict"}} + }), + ); + let ground = assert_tool_success(&ground_response, json!("peer-historical-ground")); + assert!(ground["stats"]["file_count"].as_u64().is_some()); + let ground_result = assert_success_envelope(&ground_response, json!("peer-historical-ground")); + let publication_meta = &ground_result["_meta"]["codestory_publication"]; + assert_eq!( + publication_meta["freshness"]["state"], + json!("historical"), + "the graph-only read must be labelled historical while a peer writes" + ); + assert_eq!( + publication_meta["freshness"]["reason"], + json!("peer_writer"), + "the peer-writer wait stage must identify the retained answer: {publication_meta}" + ); + assert_eq!( + publication_meta["publication"]["generation"], + json!(old_generation), + "the historical answer must come from the retained publication" + ); + + fs::remove_file(&hold_marker_path).expect("release the parked peer refresh"); + let peer_output = peer.wait_with_output().expect("wait for peer refresh exit"); + assert!(peer_output.status.success()); + assert_eq!( + observed_generation(&mut reader, &fixture, "peer-historical-generation"), + old_generation + 1 + ); +} + #[test] fn tools_call_local_graph_refreshes_long_lived_index_after_source_mutation() { let fixture = indexed_fixture(); diff --git a/crates/codestory-runtime/src/controller_indexing.rs b/crates/codestory-runtime/src/controller_indexing.rs index f5b921372..9b5dec080 100644 --- a/crates/codestory-runtime/src/controller_indexing.rs +++ b/crates/codestory-runtime/src/controller_indexing.rs @@ -51,7 +51,7 @@ use codestory_store::{ use codestory_workspace::{RefreshInputs, WorkspaceManifest}; use std::collections::HashSet; use std::path::{Path, PathBuf}; -use std::time::Instant; +use std::time::{Duration, Instant}; pub(crate) struct ActivationIndexingEvidence { pub(crate) phase_timings: IndexingPhaseTimings, @@ -96,6 +96,43 @@ fn run_mid_indexing_test_hook(storage_path: &Path) { #[cfg(not(any(test, feature = "test-support")))] fn run_mid_indexing_test_hook(_storage_path: &Path) {} +/// Extension for the spawned-process writer-hold marker: while +/// `.index-writer-hold` exists, a run that already owns the writer +/// lock parks before doing any work. +const INDEX_WRITER_HOLD_EXTENSION: &str = "index-writer-hold"; + +/// Spawned-process tests park a real refresh mid-flight by creating the +/// `.index-writer-hold` marker: the run that already owns the writer +/// lock waits here — still holding it — until the file disappears. Both the +/// explicit token and the ambient activation cancellation stop the wait, and +/// a bounded deadline keeps a forgotten marker from wedging indexing. +fn wait_out_index_writer_hold_marker( + storage_path: &Path, + cancel_token: Option<&CancellationToken>, +) -> Result<(), ApiError> { + let marker = storage_path.with_extension(INDEX_WRITER_HOLD_EXTENSION); + if !marker.exists() { + return Ok(()); + } + let deadline = Instant::now() + Duration::from_secs(600); + while marker.exists() { + if cancel_token.is_some_and(|token| token.is_cancelled()) + || codestory_contracts::bounded_locks::thread_cancellation() + .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire)) + { + return Err(ApiError::new( + "cancelled", + "indexing cancelled while holding the writer lock on a hold marker", + )); + } + if Instant::now() >= deadline { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + Ok(()) +} + impl AppController { fn core_project_summary_from_storage( &self, @@ -592,6 +629,7 @@ impl AppController { cancel_token, None, None, + None, ) .map(|completion| completion.phase_timings) } @@ -603,6 +641,7 @@ impl AppController { cancel_token: Option<&CancellationToken>, precomputed_probe: Option, failed_refresh_diagnostics: Option<&crate::index_full::FailedRefreshDiagnosticSink>, + preacquired_writer: Option, ) -> Result { let (root, storage_path) = { let s = self.state.lock(); @@ -632,14 +671,24 @@ impl AppController { s.index_freshness_cache = None; } - let _writer_guard = match IndexWriterGuard::try_acquire(&storage_path) { - Ok(guard) => guard, - Err(error) => { - self.state.lock().is_indexing = false; - return Err(error); - } + let _writer_guard = match preacquired_writer { + // A peer-waited activation hands in the writer it already holds; + // every other caller contends here and keeps the single-failure + // `cache_busy` answer. + Some(guard) => guard, + None => match IndexWriterGuard::try_acquire(&storage_path) { + Ok(guard) => guard, + Err(error) => { + self.state.lock().is_indexing = false; + return Err(error); + } + }, }; run_mid_indexing_test_hook(&storage_path); + if let Err(error) = wait_out_index_writer_hold_marker(&storage_path, cancel_token) { + self.state.lock().is_indexing = false; + return Err(error); + } // A refresh can install a database that never carried the legacy // annotation tables, so annotations move to the sidecar before the run @@ -851,7 +900,18 @@ impl AppController { .storage_path .clone() .ok_or_else(no_project_error)?; - let _writer_guard = IndexWriterGuard::try_acquire(&storage_path)?; + // Activation owns this acquisition, so peer contention waits on the + // publication boundary rather than failing `cache_busy`: the ambient + // activation cancellation reaches the bounded wait, and the + // publication revalidation below already re-checks whatever the peer + // committed first. + let _writer_guard = IndexWriterGuard::acquire_after_peer( + &storage_path, + codestory_contracts::bounded_locks::LockDeadline::after( + codestory_contracts::bounded_locks::PUBLICATION_LOCK_WAIT, + ), + None, + )?; if cancel_token.is_cancelled() { return Err(indexing_cancelled_error()); } @@ -1031,12 +1091,16 @@ impl AppController { /// core facts that the staged publication already validated. Activation /// uses this receipt instead of rebuilding a broad project summary and /// revalidating the same derived publications before retrieval can start. + /// `writer_guard` is the index-writer lock the activation already holds — + /// either acquired uncontended or taken over from a peer after a bounded + /// wait — so the publication it plans against cannot change underneath it. pub(crate) fn run_indexing_blocking_with_cancel_for_activation( &self, mode: IndexMode, cancel_token: &CancellationToken, precomputed_probe: Option, failed_refresh_diagnostics: Option<&crate::index_full::FailedRefreshDiagnosticSink>, + writer_guard: IndexWriterGuard, ) -> Result { let completion = self.run_indexing_blocking_inner_with_probe( mode, @@ -1044,6 +1108,7 @@ impl AppController { Some(cancel_token), precomputed_probe, failed_refresh_diagnostics, + Some(writer_guard), )?; let storage_path = self.require_storage_path()?; let storage = Store::open_read_only(&storage_path).map_err(|error| { diff --git a/crates/codestory-runtime/src/index_commit.rs b/crates/codestory-runtime/src/index_commit.rs index 0a7072ba8..533455d60 100644 --- a/crates/codestory-runtime/src/index_commit.rs +++ b/crates/codestory-runtime/src/index_commit.rs @@ -12,7 +12,7 @@ use crate::{ current_epoch_ms, publish_source_policy_exclusions, revalidate_source_policy_exclusions, }; use codestory_contracts::api::{ApiError, IndexPublicationDto, IndexPublicationModeDto}; -use codestory_contracts::bounded_locks; +use codestory_contracts::bounded_locks::{self, FileLockError, FileLockKind}; use codestory_indexer::CancellationToken; use codestory_store::{ IndexPublicationMode, IndexPublicationRecord, StagedSnapshot, StagedSnapshotPublishStats, @@ -21,6 +21,7 @@ use codestory_workspace::{ OversizedSourceExclusionCandidate, SourceIndexPolicy, WorkspaceManifest, }; use std::path::{Path, PathBuf}; +use std::sync::atomic::AtomicBool; use std::time::{Duration, Instant}; use uuid::Uuid; @@ -63,7 +64,7 @@ pub(super) struct IndexWriterGuard { } impl IndexWriterGuard { - pub(super) fn try_acquire(storage_path: &Path) -> Result { + fn open_lock_file(storage_path: &Path) -> Result<(std::fs::File, PathBuf), ApiError> { let path = storage_path .with_extension(codestory_contracts::owned_artifacts::INDEX_WRITER_LOCK_EXTENSION); if let Some(parent) = path @@ -89,6 +90,11 @@ impl IndexWriterGuard { path.display() )) })?; + Ok((file, path)) + } + + pub(super) fn try_acquire(storage_path: &Path) -> Result { + let (file, path) = Self::open_lock_file(storage_path)?; if !codestory_workspace::locking::try_lock_exclusive_outliving_spawn_ghosts(&file).map_err( |error| { ApiError::internal(format!( @@ -107,6 +113,40 @@ impl IndexWriterGuard { } Ok(Self { file, path }) } + + /// Wait out a peer process that owns the writer lock, then hold it + /// ourselves. The wait is bounded by `deadline`, observes `cancel` + /// between poll slices, and takes an exclusive acquisition because the + /// caller intends to write if the peer's publication did not already + /// cover the work. Only this process's own acquisition is ever released: + /// another process's lock file is never deleted or unlocked. + pub(super) fn acquire_after_peer( + storage_path: &Path, + deadline: bounded_locks::LockDeadline, + cancel: Option<&AtomicBool>, + ) -> Result { + let (file, path) = Self::open_lock_file(storage_path)?; + match bounded_locks::acquire_with_deadline(&file, FileLockKind::Exclusive, deadline, cancel) + { + Ok(()) => Ok(Self { file, path }), + Err(FileLockError::Cancelled { .. }) => Err(ApiError::new( + "cancelled", + "activation cancelled while waiting for a peer process's index writer lock", + )), + Err(FileLockError::Timeout { waited, .. }) => Err(ApiError::new( + "cache_busy", + format!( + "A peer process held the index writer lock at {} for the whole {} ms wait budget.", + path.display(), + waited.as_millis() + ), + )), + Err(FileLockError::Unavailable { source, .. }) => Err(ApiError::internal(format!( + "Failed to wait on index writer lock {}: {source}", + path.display() + ))), + } + } } impl Drop for IndexWriterGuard { diff --git a/crates/codestory-runtime/src/services.rs b/crates/codestory-runtime/src/services.rs index b3f2c2392..51c384cba 100644 --- a/crates/codestory-runtime/src/services.rs +++ b/crates/codestory-runtime/src/services.rs @@ -244,6 +244,9 @@ pub(crate) fn active_public_operation_cancellation() -> Option> #[serde(rename_all = "snake_case")] pub enum ActivationStage { Discovery, + /// The refresh found the index writer lock held by another process and + /// is inside its bounded, cancellable wait for the handoff. + WaitingForPeerWriter, CoreFreshness, SearchPreparation, DensePreparation, @@ -255,6 +258,7 @@ pub enum ActivationStage { fn activation_stage_progress(stage: ActivationStage) -> u8 { match stage { ActivationStage::Discovery => 0, + ActivationStage::WaitingForPeerWriter => 10, ActivationStage::CoreFreshness => 20, ActivationStage::SearchPreparation => 40, ActivationStage::DensePreparation => 60, @@ -1257,7 +1261,17 @@ impl ActivationService { )); } if !state.goal.satisfies(goal) { - return Err(narrower_activation_in_flight()); + // A narrower-goal run is in flight: wait it out inside + // the caller's budget, then re-drive so this request + // pursues its own goal instead of inheriting a + // core-only verdict. + self.wait_out_narrower_activation( + state, + &target, + request_cancelled.as_ref(), + foreground_budget, + )?; + continue; } let operation_id = state .current @@ -1326,7 +1340,17 @@ impl ActivationService { )); } if !state.goal.satisfies(goal) { - return Err(narrower_activation_in_flight()); + // A narrower-goal run is in flight: wait it out inside + // the caller's budget, then re-drive so this request + // pursues its own goal instead of inheriting a + // core-only verdict. + self.wait_out_narrower_activation( + state, + &target, + request_cancelled.as_ref(), + foreground_budget, + )?; + continue; } let operation_id = state .current @@ -1402,7 +1426,17 @@ impl ActivationService { )); } if !state.goal.satisfies(goal) { - return Err(narrower_activation_in_flight()); + // A narrower-goal run is in flight: wait it out inside + // the caller's budget, then re-drive so this request + // pursues its own goal instead of inheriting a core-only + // verdict. + self.wait_out_narrower_activation( + state, + &target, + request_cancelled.as_ref(), + foreground_budget, + )?; + continue; } let operation_id = state .current @@ -1874,88 +1908,144 @@ impl ActivationService { // legacy flat cache can migrate it in place and hide the need to // rebuild parser artifacts. Recovery binds paths without opening the // predecessor; the full refresh stages and publishes its replacement. - let summary = match self - .controller - .ensure_incremental_refresh_compatible_at(&project_root, &storage_path) - { - Ok(()) => { - let layout = - codestory_store::CorePublicationLayout::from_storage_path(&storage_path) - .map_err(|error| { - ApiError::internal(format!( - "Failed to resolve core publication layout for activation: {error}" - )) - })?; - let has_generation_pointer = - layout.read_pointer().is_ok_and(|pointer| pointer.is_some()); - let retrieval_pointer_is_absent = matches!( - std::fs::symlink_metadata(layout.retrieval_publication_path()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound - ); - let summary = if has_generation_pointer && retrieval_pointer_is_absent { - // A complete core may legitimately predate its first retrieval - // publication. Full activation owns advancing that state, but - // the ordinary summary remains a strict observational read. - self.controller.open_core_read_only_with_storage_path( - project_root.clone(), - storage_path.clone(), - )? - } else { - self.controller.open_project_summary_with_storage_path( + // The writer lock alone orders writers, including ones in other + // processes. A plan observed before holding it is stale by + // definition, so the observe/probe/write sequence repeats: after a + // peer releases the lock, freshly-opened storage decides whether the + // peer's publication already covers the work (adopt it) or a write + // is still required (exactly one attempt, under the held lock). + let mut held_writer: Option = None; + let core_refresh_started = Instant::now(); + let mut refreshed_core = None; + let ( + summary, + has_complete_core, + complete_incremental_source_inventory, + search_repair_only, + precomputed_core_probe, + preflight_ms, + ) = 'core_plan: loop { + // Inspect compatibility before opening the live database: opening a + // legacy flat cache can migrate it in place and hide the need to + // rebuild parser artifacts. Recovery binds paths without opening the + // predecessor; the full refresh stages and publishes its replacement. + let summary = match self + .controller + .ensure_incremental_refresh_compatible_at(&project_root, &storage_path) + { + Ok(()) => { + let layout = codestory_store::CorePublicationLayout::from_storage_path( + &storage_path, + ) + .map_err(|error| { + ApiError::internal(format!( + "Failed to resolve core publication layout for activation: {error}" + )) + })?; + let has_generation_pointer = + layout.read_pointer().is_ok_and(|pointer| pointer.is_some()); + let retrieval_pointer_is_absent = matches!( + std::fs::symlink_metadata(layout.retrieval_publication_path()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound + ); + let summary = if has_generation_pointer && retrieval_pointer_is_absent { + // A complete core may legitimately predate its first retrieval + // publication. Full activation owns advancing that state, but + // the ordinary summary remains a strict observational read. + self.controller.open_core_read_only_with_storage_path( + project_root.clone(), + storage_path.clone(), + )? + } else { + self.controller.open_project_summary_with_storage_path( + project_root.clone(), + storage_path.clone(), + )? + }; + Some(summary) + } + Err(error) + if error.code == crate::index_incremental::FULL_REFRESH_REQUIRED_ERROR_CODE => + { + self.controller.bind_project_paths_for_refresh( project_root.clone(), storage_path.clone(), - )? - }; - Some(summary) - } - Err(error) - if error.code == crate::index_incremental::FULL_REFRESH_REQUIRED_ERROR_CODE => - { - self.controller - .bind_project_paths_for_refresh(project_root.clone(), storage_path.clone())?; - None - } - Err(error) => return Err(error), - }; - let has_complete_core = summary - .as_ref() - .is_some_and(|summary| summary.publication.is_some() && summary.stats.node_count > 0); - let mut precomputed_core_probe = has_complete_core - .then(|| self.controller.probe_incremental_plan_for_activation()) - .transpose()?; - let complete_incremental_source_inventory = precomputed_core_probe - .as_ref() - .is_some_and(|probe| probe.has_complete_source_inventory()); - // The incremental probe reports a missing search generation only after - // proving a complete inventory, an empty source plan, and a current - // complete core contract. Repair that derived generation against the - // exact immutable core. Source aliases still prevent an unsealed - // short-circuit and take the full retrieval freshness path below. - let search_repair_only = has_complete_core - && precomputed_core_probe.as_ref().is_some_and(|probe| { - probe.outcome == IncrementalPlanProbeOutcomeDto::SearchGenerationIncomplete - && probe.files_to_index == 0 - && probe.files_to_remove == 0 - && probe.publication.as_ref().is_some_and(|publication| { - let publication = - crate::index_commit::index_publication_dto(publication.clone()); - summary - .as_ref() - .and_then(|summary| summary.publication.as_ref()) - == Some(&publication) - }) + )?; + None + } + Err(error) => return Err(error), + }; + let has_complete_core = summary.as_ref().is_some_and(|summary| { + summary.publication.is_some() && summary.stats.node_count > 0 }); - let preflight_ms = - u64::try_from(activation_started.elapsed().as_millis()).unwrap_or(u64::MAX); - - operation.set_stage(ActivationStage::CoreFreshness); - let core_refresh_started = Instant::now(); - let mut refreshed_core = None; - let core_stale = !has_complete_core - || precomputed_core_probe + let mut precomputed_core_probe = has_complete_core + .then(|| self.controller.probe_incremental_plan_for_activation()) + .transpose()?; + let complete_incremental_source_inventory = precomputed_core_probe .as_ref() - .is_none_or(|probe| !probe.short_circuited() && !search_repair_only); - if core_stale { + .is_some_and(|probe| probe.has_complete_source_inventory()); + // The incremental probe reports a missing search generation only after + // proving a complete inventory, an empty source plan, and a current + // complete core contract. Repair that derived generation against the + // exact immutable core. Source aliases still prevent an unsealed + // short-circuit and take the full retrieval freshness path below. + let search_repair_only = has_complete_core + && precomputed_core_probe.as_ref().is_some_and(|probe| { + probe.outcome == IncrementalPlanProbeOutcomeDto::SearchGenerationIncomplete + && probe.files_to_index == 0 + && probe.files_to_remove == 0 + && probe.publication.as_ref().is_some_and(|publication| { + let publication = + crate::index_commit::index_publication_dto(publication.clone()); + summary + .as_ref() + .and_then(|summary| summary.publication.as_ref()) + == Some(&publication) + }) + }); + let preflight_ms = + u64::try_from(activation_started.elapsed().as_millis()).unwrap_or(u64::MAX); + + let core_stale = !has_complete_core + || precomputed_core_probe + .as_ref() + .is_none_or(|probe| !probe.short_circuited() && !search_repair_only); + if !core_stale { + operation.set_stage(ActivationStage::CoreFreshness); + break 'core_plan ( + summary, + has_complete_core, + complete_incremental_source_inventory, + search_repair_only, + precomputed_core_probe, + preflight_ms, + ); + } + let writer_guard = match held_writer.take() { + Some(guard) => guard, + None => match crate::index_commit::IndexWriterGuard::try_acquire(&storage_path) { + Ok(guard) => guard, + Err(error) if error.code == "cache_busy" => { + // A peer process is publishing for this cache. Wait + // out its writer lock — bounded, cancellable through + // the activation flag, in slices no longer than the + // bounded-lock poll step — then re-plan under the lock + // this process now holds. + operation.set_stage(ActivationStage::WaitingForPeerWriter); + held_writer = + Some(crate::index_commit::IndexWriterGuard::acquire_after_peer( + &storage_path, + codestory_contracts::bounded_locks::LockDeadline::after( + codestory_contracts::bounded_locks::PUBLICATION_LOCK_WAIT, + ), + Some(operation.cancelled.as_ref()), + )?); + continue 'core_plan; + } + Err(error) => return Err(error), + }, + }; + operation.set_stage(ActivationStage::CoreFreshness); let mode = if !has_complete_core { IndexMode::Full } else { @@ -1988,6 +2078,7 @@ impl ActivationService { .then(|| precomputed_core_probe.take()) .flatten(), failed_refresh_diagnostics.as_ref(), + writer_guard, ); let evidence = match evidence_result { Ok(evidence) => evidence, @@ -2012,7 +2103,19 @@ impl ActivationService { evidence.stats, evidence.repository_tracking_digest, )); - } + break 'core_plan ( + summary, + has_complete_core, + complete_incremental_source_inventory, + search_repair_only, + precomputed_core_probe, + preflight_ms, + ); + }; + // Adopted peer publications and completed writes alike stop holding + // the writer here: retrieval preparation re-acquires it under the + // ordinary single-attempt `cache_busy` contract. + drop(held_writer); let local_ready = match refreshed_core.as_ref() { Some((_, stats, _)) => stats.node_count > 0 && stats.fatal_error_count == 0, None => summary.as_ref().is_some_and(|summary| { @@ -2374,13 +2477,43 @@ fn snapshot_allows(snapshot: &ActivationSnapshot) -> bool { snapshot.allows_operation("packet") } -/// A full request cannot borrow a core-only run's completion, because that run -/// stops before retrieval. Retrying starts the full activation instead. -fn narrower_activation_in_flight() -> ApiError { - ApiError::new( - "activation_retryable", - "a core-only activation is already running for this project; retry to start full activation", - ) +impl ActivationService { + /// Wait out a running activation whose goal cannot satisfy this request + /// (a `CoreOnly` run ahead of a `Full` caller), then let the caller + /// re-drive through `activate_with_goal` so the requester's own goal + /// owns the outcome. The running goal frames the wait's admit check so + /// the narrower run's completion never reports broad readiness. Only a + /// spent caller budget or an explicit cancel propagates; the narrower + /// run's own verdicts are superseded by the new attempt. + fn wait_out_narrower_activation( + &self, + running: std::sync::MutexGuard<'_, ActivationCoordinatorState>, + target: &ActivationTarget, + request_cancelled: &AtomicBool, + foreground_budget: Duration, + ) -> Result<(), ApiError> { + let running_goal = running.goal; + let operation_id = running + .current + .as_ref() + .expect("running activation has a snapshot") + .operation_id + .clone(); + drop(running); + match self.wait_for_activation( + target, + &operation_id, + true, + request_cancelled, + foreground_budget, + running_goal, + ) { + Err(error) if matches!(error.code.as_str(), "cancelled" | "activation_preparing") => { + Err(error) + } + _ => Ok(()), + } + } } fn snapshot_error(snapshot: &ActivationSnapshot) -> ApiError { @@ -2703,7 +2836,7 @@ impl PublicOperationService { { return None; } - let reason = if snapshot.failure_code.as_deref() == Some("cache_busy") { + let reason = if snapshot.stage == ActivationStage::WaitingForPeerWriter { HistoricalServedReason::PeerWriter } else if matches!( snapshot.state, @@ -6918,6 +7051,104 @@ pub(crate) mod activation_tests { assert_ne!(terminal.state, ActivationState::Ready); } + #[test] + fn a_full_request_waits_for_an_in_flight_core_only_run_then_pursues_full() { + let project = tempfile::tempdir().expect("project"); + let storage_path = project.path().join("cache").join("codestory.db"); + fs::write( + project.path().join("fixture.rs"), + "pub fn core_only_upgrade_fixture() {}\n", + ) + .expect("write fixture"); + let service = Runtime::new().activation_service(); + let worker_gate = Arc::new((Mutex::new(false), Condvar::new())); + service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); + + let core_only_service = service.clone(); + let core_only_root = project.path().to_path_buf(); + let core_only_storage = storage_path.clone(); + let core_only = std::thread::spawn(move || { + core_only_service.activate_core_only( + &core_only_root, + &core_only_storage, + Arc::new(AtomicBool::new(false)), + ) + }); + let deadline = Instant::now() + Duration::from_secs(10); + while service.worker_start_count_for_test() == 0 && Instant::now() < deadline { + std::thread::yield_now(); + } + assert_eq!(service.worker_start_count_for_test(), 1); + + // The narrower run is in flight: a full caller waits inside its + // foreground budget rather than failing as activation_retryable. + let upgrade = service + .activate_project_with_foreground_budget_and_goal( + project.path(), + &storage_path, + Arc::new(AtomicBool::new(false)), + Duration::ZERO, + ActivationGoal::Full, + ) + .expect_err("a parked core-only run cannot satisfy a full request"); + assert_eq!( + upgrade.code, "activation_preparing", + "a full caller waits on the narrower run instead of a retryable refusal: {upgrade:?}" + ); + assert_eq!( + service.worker_start_count_for_test(), + 1, + "waiting on the narrower run must not spawn a second worker" + ); + + service.set_worker_start_gate_for_test(None); + let (released, changed) = worker_gate.as_ref(); + *released + .lock() + .expect("activation worker test gate poisoned") = true; + changed.notify_all(); + let _ = core_only.join().expect("join core-only activation caller"); + + // Once the narrower run ends, a full caller drives its own activation + // attempt: the completed core-only snapshot stays `Updating` with no + // broad readiness, so the full goal can neither join it nor borrow its + // verdict. Poll the call itself until the attempt advances; every + // interim answer must be `activation_preparing`, never + // `activation_retryable`. + let deadline = Instant::now() + Duration::from_secs(20); + loop { + let second = service.activate_project_with_foreground_budget_and_goal( + project.path(), + &storage_path, + Arc::new(AtomicBool::new(false)), + Duration::ZERO, + ActivationGoal::Full, + ); + match second { + Err(error) => assert_eq!( + error.code, "activation_preparing", + "the full request must wait or start, never refuse: {error:?}" + ), + Ok(run) => assert_eq!( + run.snapshot.capabilities.broad_search, + ActivationCapabilityState::Ready, + "a full request can only succeed with broad readiness: {:?}", + run.snapshot + ), + } + let snapshot = service.snapshot().expect("activation snapshot"); + if snapshot.attempt > 1 && service.worker_start_count_for_test() >= 2 { + break; + } + assert!( + Instant::now() < deadline, + "the core-only run ended but no full attempt started" + ); + std::thread::yield_now(); + } + service.cancel_and_wait(); + } + #[test] fn bounded_cancel_returns_on_a_running_activation_it_cannot_stop() { let service = Runtime::new().activation_service(); diff --git a/docs/architecture/runtime-execution-path.md b/docs/architecture/runtime-execution-path.md index 19e73da3b..42c87fe5d 100644 --- a/docs/architecture/runtime-execution-path.md +++ b/docs/architecture/runtime-execution-path.md @@ -65,6 +65,16 @@ drift against it rather than waiting or falling back. `resources/read` stays observational — it never activates or waits, so a `codestory://snippet/` read against drifted bytes reports `source_stale` from the pinned generation. +The writer lock coordinates refreshes across processes. An activation that +finds the index-writer lock held by another session reports stage +`waiting_for_peer_writer` and waits — bounded and cancellable — for the peer to +release it. On release it re-opens committed storage and re-plans: if the peer +published a satisfying core, the waiter adopts it without a second write; +otherwise it performs exactly one refresh under the lock it now holds. If the +peer died without publishing, the OS releases the lock and the waiter does the +work itself. The previous complete publication stays readable throughout, and +graph-only answers from it carry freshness reason `peer_writer`. + ## Core indexing An explicit `index` request delegates to runtime, which asks diff --git a/plugins/codestory/scripts/codestory-mcp.cjs b/plugins/codestory/scripts/codestory-mcp.cjs index a2062a233..ce98d5316 100644 --- a/plugins/codestory/scripts/codestory-mcp.cjs +++ b/plugins/codestory/scripts/codestory-mcp.cjs @@ -87,7 +87,7 @@ const supportedMcpProtocolVersions = Object.freeze([ '2025-06-18', '2025-11-25', ]); -const publicationStampSchemaVersion = 3; +const publicationStampSchemaVersion = 4; const minimumCompatiblePublicationStampSchemaVersion = 3; const runtimeStderrObservedBytesCap = 16 * 1024 * 1024; const runtimeStderrObservedChunksCap = 65_535; @@ -2352,7 +2352,7 @@ function v3LauncherSession(requested, discoveryContracts) { requested: asked || null, negotiated, discoveryContractSha256, - publicationSchemaVersion: 3, + publicationSchemaVersion: publicationStampSchemaVersion, }); } diff --git a/plugins/codestory/skills/codestory-grounding/references/status-contract.md b/plugins/codestory/skills/codestory-grounding/references/status-contract.md index 86e04b3e9..55d5cfa98 100644 --- a/plugins/codestory/skills/codestory-grounding/references/status-contract.md +++ b/plugins/codestory/skills/codestory-grounding/references/status-contract.md @@ -17,7 +17,10 @@ across repositories. `current_operation` is the runtime-owned activation snapshot. When present it contains one stable `operation_id`, monotonic `revision`, `stage`, `attempt`, -and `progress`, plus retry delay and failure. Concurrent and serial retries for +and `progress`, plus retry delay and failure. A `stage` of +`waiting_for_peer_writer` means this runtime is waiting for another session's +indexing run to release the writer lock; keep waiting rather than starting +repair. Concurrent and serial retries for the same native project/configuration key join that operation; they do not start another refresh or repair flow. A `retained` local-navigation capability names the exact complete core publication still usable for observational local diff --git a/plugins/codestory/tests/plugin-static.test.mjs b/plugins/codestory/tests/plugin-static.test.mjs index a09875013..d2d518a11 100644 --- a/plugins/codestory/tests/plugin-static.test.mjs +++ b/plugins/codestory/tests/plugin-static.test.mjs @@ -197,7 +197,7 @@ test("launcher wire contract matches the generated catalog read from the real CL preferredMcpProtocolVersion: launcherTest.managedCliMcpProtocolVersion, discoveryContracts: generatedCatalog.wireContract.discoveryContracts, }); - assert.equal(catalog.wireContract.publicationStampSchemaVersion, 3); + assert.equal(catalog.wireContract.publicationStampSchemaVersion, 4); assert.deepEqual( catalog.wireContract.supportedMcpProtocolVersions, ["2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25"], @@ -305,9 +305,9 @@ test("v3 launcher state rejects old new and wrong-v3 runtime identities", () => requested: "2025-06-18", negotiated: "2025-06-18", discoveryContractSha256: contracts["2025-06-18"], - publicationSchemaVersion: 3, + publicationSchemaVersion: 4, }); - const response = (revision, digest, schemaVersion = 3) => ({ + const response = (revision, digest, schemaVersion = 4) => ({ jsonrpc: "2.0", id: "initialize", result: { @@ -341,13 +341,13 @@ test("v3 launcher state rejects old new and wrong-v3 runtime identities", () => ); assert.equal( launcherTest.v3RuntimeWireContractSkew( - response(session.negotiated, session.discoveryContractSha256, 4), + response(session.negotiated, session.discoveryContractSha256, 3), session, ), "publication_schema_skew", ); const tooNewMinimum = response(session.negotiated, session.discoveryContractSha256); - tooNewMinimum.result._meta.codestory_publication.minimum_compatible_schema_version = 4; + tooNewMinimum.result._meta.codestory_publication.minimum_compatible_schema_version = 5; assert.equal( launcherTest.v3RuntimeWireContractSkew(tooNewMinimum, session), "publication_stamp_producer_too_new", @@ -518,7 +518,7 @@ test("handoff waits for child validation and retires answered legacy batch IDs", "process.stdin.setEncoding('utf8');", "process.stdin.on('data',(chunk)=>{input+=chunk;const lines=input.split(/\\r?\\n/u);input=lines.pop()||'';for(const line of lines){if(!line)continue;const frame=JSON.parse(line);", "if(Array.isArray(frame)){seen.push('batch');record('received');process.stdout.write(JSON.stringify([{jsonrpc:'2.0',id:'A',result:{ok:'A'}},{jsonrpc:'2.0',id:'B',result:{ok:'B'}}])+'\\n',()=>setTimeout(()=>process.exit(17),30));continue;}", - "if(frame.method==='initialize'){seen.push('initialize');setTimeout(()=>{record('before-validation');process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:frame.id,result:{protocolVersion:process.env.TEST_REVISION,_meta:{codestory_protocol:{discovery_contract_sha256:process.env.TEST_DIGEST},codestory_publication:{schema_version:3,minimum_compatible_schema_version:3}}}})+'\\n');},100);continue;}", + "if(frame.method==='initialize'){seen.push('initialize');setTimeout(()=>{record('before-validation');process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:frame.id,result:{protocolVersion:process.env.TEST_REVISION,_meta:{codestory_protocol:{discovery_contract_sha256:process.env.TEST_DIGEST},codestory_publication:{schema_version:4,minimum_compatible_schema_version:3}}}})+'\\n');},100);continue;}", "if(frame.method==='notifications/initialized'){seen.push('initialized');continue;}", "seen.push(String(frame.id));record('received');process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:frame.id,result:{ok:frame.id}})+'\\n');", "}});", @@ -623,7 +623,7 @@ test("late events from a refused child cannot affect the retry handoff", () => { protocolVersion: revision, _meta: { codestory_protocol: { discovery_contract_sha256: discoveryDigest(revision) }, - codestory_publication: { schema_version: 3, minimum_compatible_schema_version: 3 }, + codestory_publication: { schema_version: 4, minimum_compatible_schema_version: 3 }, }, }, }; @@ -1361,7 +1361,7 @@ async function waitForPath(pathname, timeoutMs = 10000) { async function writeFakeCli(cliPath) { const script = [ "const fs=require('fs');const args=process.argv.slice(1);", - `if(process.env.CODESTORY_PLUGIN_PROVISIONING_PROBE==='1'&&args[0]==='serve'){let input='';process.stdin.on('data',chunk=>{input+=chunk;const newline=input.indexOf('\\n');if(newline<0)return;const request=JSON.parse(input.slice(0,newline));process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:request.params.protocolVersion,capabilities:{},serverInfo:{name:'fixture',version:'1'},_meta:{codestory_protocol:{discovery_contract_sha256:${JSON.stringify(discoveryDigest())}},codestory_publication:{schema_version:Number(process.env.CODESTORY_TEST_STAMP_SCHEMA_VERSION||'3'),minimum_compatible_schema_version:3}}}})+'\\n',()=>process.exit(0))})}`, + `if(process.env.CODESTORY_PLUGIN_PROVISIONING_PROBE==='1'&&args[0]==='serve'){let input='';process.stdin.on('data',chunk=>{input+=chunk;const newline=input.indexOf('\\n');if(newline<0)return;const request=JSON.parse(input.slice(0,newline));process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:request.params.protocolVersion,capabilities:{},serverInfo:{name:'fixture',version:'1'},_meta:{codestory_protocol:{discovery_contract_sha256:${JSON.stringify(discoveryDigest())}},codestory_publication:{schema_version:Number(process.env.CODESTORY_TEST_STAMP_SCHEMA_VERSION||'4'),minimum_compatible_schema_version:3}}}})+'\\n',()=>process.exit(0))})}`, "else if(args[0]==='--version'){if(process.env.CODESTORY_PLUGIN_PROVISIONING_PROBE==='1'&&process.env.CODESTORY_TEST_PROBE_LOG)fs.appendFileSync(process.env.CODESTORY_TEST_PROBE_LOG,'probe\\n');const delay=Number(process.env.CODESTORY_TEST_PROBE_DELAY_MS||0);if(delay>0)Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),0,0,delay);console.log('codestory-cli '+(process.env.CODESTORY_PLUGIN_CLI_VERSION||process.env.TEST_CODESTORY_VERSION||'0.0.0'));process.exit(0)}", "else{fs.writeFileSync(process.env.TEST_OUT,JSON.stringify({source:process.env.CODESTORY_PLUGIN_CLI_SOURCE,path:process.env.CODESTORY_PLUGIN_CLI_PATH,sha256:process.env.CODESTORY_PLUGIN_CLI_SHA256,version:process.env.CODESTORY_PLUGIN_CLI_VERSION,warnings:process.env.CODESTORY_PLUGIN_CLI_WARNINGS,pluginRoot:process.env.CODESTORY_PLUGIN_ROOT,launchCwd:process.env.CODESTORY_PLUGIN_LAUNCH_CWD,runtimeCwd:process.env.CODESTORY_PLUGIN_RUNTIME_CWD,pluginCacheVersion:process.env.CODESTORY_PLUGIN_CACHE_VERSION,repoRef:process.env.CODESTORY_PLUGIN_CLI_REPO_REF,buildSource:process.env.CODESTORY_PLUGIN_CLI_BUILD_SOURCE,archiveSha256:process.env.CODESTORY_PLUGIN_CLI_ARCHIVE_SHA256,retention:process.env.CODESTORY_PLUGIN_CLI_RETENTION,args}))}", ].join(""); @@ -1389,7 +1389,7 @@ async function writeLifecycleCli(cliPath) { "if(args[0]!=='serve')process.exit(2);", "let initialized=false;let notified=false;let input='';", "process.stdin.setEncoding('utf8');", - `const discoveryContracts=${JSON.stringify(generatedCatalog.wireContract.discoveryContracts)};process.stdin.on('data',chunk=>{input+=chunk;const lines=input.split(/\\r?\\n/u);input=lines.pop()||'';for(const line of lines){if(!line)continue;const request=JSON.parse(line);if(request.method==='initialize'){initialized=true;const revision=request.params.protocolVersion;process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:revision,capabilities:{tools:{listChanged:false},resources:{listChanged:false},prompts:{listChanged:false}},serverInfo:{name:'fixture',version:'1'},_meta:{codestory_protocol:{discovery_contract_sha256:discoveryContracts[revision]},codestory_publication:{schema_version:Number(process.env.CODESTORY_TEST_STAMP_SCHEMA_VERSION||'3'),minimum_compatible_schema_version:3}}}})+'\\n')}else if(request.method==='notifications/initialized'){notified=true}else if(request.method==='tools/list'){if(!initialized||!notified)process.exit(42);fs.writeFileSync(process.env.TEST_OUT,JSON.stringify({initialized,notified,args}));process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{tools:[]}})+'\\n')}else if(request.method==='resources/list'){process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{resources:[]}})+'\\n',()=>process.exit(17))}}});`, + `const discoveryContracts=${JSON.stringify(generatedCatalog.wireContract.discoveryContracts)};process.stdin.on('data',chunk=>{input+=chunk;const lines=input.split(/\\r?\\n/u);input=lines.pop()||'';for(const line of lines){if(!line)continue;const request=JSON.parse(line);if(request.method==='initialize'){initialized=true;const revision=request.params.protocolVersion;process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{protocolVersion:revision,capabilities:{tools:{listChanged:false},resources:{listChanged:false},prompts:{listChanged:false}},serverInfo:{name:'fixture',version:'1'},_meta:{codestory_protocol:{discovery_contract_sha256:discoveryContracts[revision]},codestory_publication:{schema_version:Number(process.env.CODESTORY_TEST_STAMP_SCHEMA_VERSION||'4'),minimum_compatible_schema_version:3}}}})+'\\n')}else if(request.method==='notifications/initialized'){notified=true}else if(request.method==='tools/list'){if(!initialized||!notified)process.exit(42);fs.writeFileSync(process.env.TEST_OUT,JSON.stringify({initialized,notified,args}));process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{tools:[]}})+'\\n')}else if(request.method==='resources/list'){process.stdout.write(JSON.stringify({jsonrpc:'2.0',id:request.id,result:{resources:[]}})+'\\n',()=>process.exit(17))}}});`, ].join(""); if (process.platform === "win32") { await writeFile(cliPath, `@echo off\r\n"${process.execPath}" -e "${script}" -- %*\r\n`, "utf8"); @@ -2990,9 +2990,9 @@ test("managed cli staging refuses to stage a runtime whose publication stamp it [{ schema_version: 0 }, "publication_stamp_legacy_v0"], [{ schema_version: "3" }, "publication_stamp_malformed"], [{ schema_version: 1 }, "publication_stamp_producer_too_old"], - [{ schema_version: 4 }, "publication_stamp_producer_too_new"], + [{ schema_version: 5 }, "publication_stamp_producer_too_new"], [ - { schema_version: 3, minimum_compatible_schema_version: 4 }, + { schema_version: 4, minimum_compatible_schema_version: 5 }, "publication_stamp_producer_too_new", ], ]; @@ -4187,7 +4187,7 @@ test("projectless mcp hands off to stdio without active project state", async () " if (!line.trim()) continue;", " const request = JSON.parse(line);", " if (request.method === 'initialize') {", - ` process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: process.env.TEST_PROTOCOL_VERSION || ${JSON.stringify(preferredRevision)}, serverInfo: { name: 'codestory', version: '1' }, _meta: { codestory_protocol: { discovery_contract_sha256: ${JSON.stringify(discoveryDigest())} }, codestory_publication: { schema_version: Number(process.env.TEST_STAMP_SCHEMA_VERSION || '3'), minimum_compatible_schema_version: 3 } } } }) + '\\n');`, + ` process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: process.env.TEST_PROTOCOL_VERSION || ${JSON.stringify(preferredRevision)}, serverInfo: { name: 'codestory', version: '1' }, _meta: { codestory_protocol: { discovery_contract_sha256: ${JSON.stringify(discoveryDigest())} }, codestory_publication: { schema_version: Number(process.env.TEST_STAMP_SCHEMA_VERSION || '4'), minimum_compatible_schema_version: 3 } } } }) + '\\n');`, " } else if (request.method === 'tools/list') {", " process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { tools: [{ name: 'ground' }] } }) + '\\n');", " } else if (request.method === 'tools/call' && request.params && request.params.name === 'ground') {", @@ -4658,7 +4658,7 @@ test("packaged initialize handshake carries the publication stamp the host reads "the packaged handshake must carry _meta.codestory_publication, not only _meta.codestory_protocol", ); assert.deepEqual(stamp, { - schema_version: 3, + schema_version: 4, minimum_compatible_schema_version: 3, served_from: "contract_only", publication: null, @@ -4699,7 +4699,7 @@ test("packaged initialize handshake carries the publication stamp the host reads const stamp = failOpenResult._meta?.codestory_publication; assert.ok(stamp, "the fail-open handshake must carry the stamp too"); - assert.equal(stamp.schema_version, 3); + assert.equal(stamp.schema_version, 4); assert.equal(stamp.minimum_compatible_schema_version, 3); assert.equal(stamp.served_from, "contract_only"); assert.equal(launcherTest.publicationStampSkew(stamp), null); @@ -4760,7 +4760,7 @@ test("mcp launcher starts the multi-project stdio runtime through its bridge", a ` protocolVersion: process.env.TEST_PROTOCOL_VERSION || '2025-03-26',`, " capabilities: {},", " serverInfo: { name: 'codestory', version },", - ` _meta: { codestory_protocol: { discovery_contract_sha256: ${JSON.stringify(discoveryDigest("2025-03-26"))} }, codestory_publication: { schema_version: Number(process.env.TEST_STAMP_SCHEMA_VERSION || '3'), minimum_compatible_schema_version: 3 } },`, + ` _meta: { codestory_protocol: { discovery_contract_sha256: ${JSON.stringify(discoveryDigest("2025-03-26"))} }, codestory_publication: { schema_version: Number(process.env.TEST_STAMP_SCHEMA_VERSION || '4'), minimum_compatible_schema_version: 3 } },`, " },", " }));", " } else if (request.method === 'tools/list') {", @@ -4879,7 +4879,7 @@ test("CODESTORY_CLI override that publishes an unreadable wire contract is refus " if (!line.trim()) continue;", " const request = JSON.parse(line);", " if (request.method === 'initialize') {", - ` process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'codestory', version: '0' }, _meta: { codestory_protocol: { discovery_contract_sha256: ${JSON.stringify(discoveryDigest("2025-03-26"))} }, codestory_publication: { schema_version: 3, minimum_compatible_schema_version: 4 } } } }) + '\\n');`, + ` process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: request.id, result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'codestory', version: '0' }, _meta: { codestory_protocol: { discovery_contract_sha256: ${JSON.stringify(discoveryDigest("2025-03-26"))} }, codestory_publication: { schema_version: 4, minimum_compatible_schema_version: 5 } } } }) + '\\n');`, " } else if (request.method === 'tools/call') {", // Answer in a later chunk so the reply lands after the launcher has // already refused this runtime: the relay must stay shut, not just From 2bdb81df928bf7a324bca6d59b09d2ae910bc6fa Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 06:35:09 -0400 Subject: [PATCH 06/58] index proof-resolution foreign keys before incremental cleanup --- crates/codestory-store/src/snapshot_store.rs | 124 +++++ .../codestory-store/src/storage_impl/mod.rs | 3 +- .../src/storage_impl/schema.rs | 35 +- .../src/storage_impl/tests/mod.rs | 520 ++++++++++++++++++ docs/architecture/subsystems/store.md | 9 + 5 files changed, 689 insertions(+), 2 deletions(-) diff --git a/crates/codestory-store/src/snapshot_store.rs b/crates/codestory-store/src/snapshot_store.rs index 87915aba3..fbb2ba0ef 100644 --- a/crates/codestory-store/src/snapshot_store.rs +++ b/crates/codestory-store/src/snapshot_store.rs @@ -836,6 +836,130 @@ mod tests { let _ = fs::remove_dir_all(&temp); } + fn staged_proof_index_names(staged: &StagedSnapshot) -> Vec { + let mut statement = staged + .store + .get_connection() + .prepare("PRAGMA index_list('proof_resolution_fact')") + .expect("list proof fact indexes"); + statement + .query_map([], |row| row.get::<_, String>(1)) + .expect("read index names") + .collect::, _>>() + .expect("collect index names") + } + + /// An incremental stage cloned from a publication that predates the + /// proof-FK indexes must gain them at open — before any graph cleanup — + /// and must never write to the sealed source generation. + #[test] + fn clone_live_stage_from_index_less_generation_gains_proof_fk_indexes() { + let temp = fresh_temp_root("proof-fk-stage"); + let live_path = temp.join("live.sqlite"); + { + let mut staged = SnapshotStore::open_staged(&live_path).expect("open stage"); + staged + .store_mut() + .insert_files_batch(&[crate::FileInfo { + id: 1, + path: PathBuf::from("removed.rs"), + language: "rust".to_string(), + modification_time: 1, + indexed: true, + complete: true, + line_count: 4, + file_role: crate::FileRole::Source, + }]) + .expect("seed removed file"); + staged + .store_mut() + .insert_nodes_batch(&[ + codestory_contracts::graph::Node { + id: codestory_contracts::graph::NodeId(1), + kind: codestory_contracts::graph::NodeKind::FILE, + serialized_name: "removed.rs".to_string(), + ..Default::default() + }, + codestory_contracts::graph::Node { + id: codestory_contracts::graph::NodeId(2), + kind: codestory_contracts::graph::NodeKind::FUNCTION, + serialized_name: "removed_fn".to_string(), + file_node_id: Some(codestory_contracts::graph::NodeId(1)), + ..Default::default() + }, + ]) + .expect("seed removed nodes"); + let publication = crate::IndexPublicationRecord { + generation: 1, + generation_id: "index-less-generation".to_string(), + run_id: "index-less-run".to_string(), + mode: crate::IndexPublicationMode::Full, + published_at_epoch_ms: 1, + }; + staged + .store_mut() + .put_index_publication(&publication) + .expect("identify publication"); + publish_empty_source_policy(staged.store_mut(), &publication); + staged.publish(&live_path).expect("publish generation"); + } + + // Downgrade the sealed generation to schema 35, before the proof-FK + // indexes existed, then seal it again. + let layout = crate::CorePublicationLayout::from_storage_path(&live_path).expect("layout"); + let generation_path = layout + .resolve_active_database() + .expect("resolve active generation") + .expect("active generation path"); + crate::make_file_owner_writable(&generation_path).expect("unseal generation for downgrade"); + { + let connection = + rusqlite::Connection::open(&generation_path).expect("open sealed generation"); + connection + .execute_batch( + "DROP INDEX idx_proof_resolution_target; + DROP INDEX idx_proof_resolution_raw_target; + DROP INDEX idx_proof_resolution_edge; + PRAGMA user_version = 35;", + ) + .expect("downgrade sealed generation"); + } + crate::core_generation::make_file_immutable(&generation_path) + .expect("reseal downgraded generation"); + let pre_clone_bytes = fs::read(&generation_path).expect("source bytes before clone"); + + let mut staged = + SnapshotStore::clone_live_to_staged(&live_path).expect("clone index-less generation"); + + // The three proof-FK indexes exist on the mutable stage before the + // first graph deletion. + let index_names = staged_proof_index_names(&staged); + for index in [ + "idx_proof_resolution_target", + "idx_proof_resolution_raw_target", + "idx_proof_resolution_edge", + ] { + assert!( + index_names.iter().any(|name| name == index), + "stage is missing {index}: {index_names:?}" + ); + } + + let summary = staged + .store_mut() + .delete_file_projection(1) + .expect("delete file projection on stage"); + assert_eq!(summary.removed_file_row_count, 1); + assert_eq!(summary.removed_node_count, 2); + + assert_eq!( + fs::read(&generation_path).expect("source bytes after clone"), + pre_clone_bytes, + "staging wrote to the sealed source generation" + ); + let _ = fs::remove_dir_all(&temp); + } + /// Renaming the sealed stage into its generation preserves the exact /// candidate bytes; publication moves only the small pointer. #[test] diff --git a/crates/codestory-store/src/storage_impl/mod.rs b/crates/codestory-store/src/storage_impl/mod.rs index 6b8e48ba9..081cb4891 100644 --- a/crates/codestory-store/src/storage_impl/mod.rs +++ b/crates/codestory-store/src/storage_impl/mod.rs @@ -85,7 +85,8 @@ pub(crate) struct SealedCoreCandidateReceipt { const PROOF_RESOLUTION_PROVENANCE_SCHEMA_VERSION: u32 = 33; const CANONICAL_SUFFIX_SCHEMA_VERSION: u32 = 34; const ATTACHED_COMMENT_SCHEMA_VERSION: u32 = 35; -const SCHEMA_VERSION: u32 = ATTACHED_COMMENT_SCHEMA_VERSION; +const PROOF_RESOLUTION_FK_INDEX_SCHEMA_VERSION: u32 = 36; +const SCHEMA_VERSION: u32 = PROOF_RESOLUTION_FK_INDEX_SCHEMA_VERSION; // Reserved outside the sequential migration range so a future real schema version cannot // accidentally be treated as an interrupted run from this release. const INCOMPLETE_INCREMENTAL_SCHEMA_VERSION: u32 = 0x4353_0001; diff --git a/crates/codestory-store/src/storage_impl/schema.rs b/crates/codestory-store/src/storage_impl/schema.rs index 1cd61f490..ff7af43e8 100644 --- a/crates/codestory-store/src/storage_impl/schema.rs +++ b/crates/codestory-store/src/storage_impl/schema.rs @@ -544,6 +544,20 @@ const PRE_SUMMARY_SECONDARY_INDEX_STATEMENTS: &[&str] = &[ ON retrieval_index_manifest(built_at_epoch_ms)", ]; +// Child-key-leading indexes for every proof_resolution_fact foreign key a +// file-projection deletion validates. They belong to the load-time set — not +// the deferred secondary set — because an incremental stage cloned from an +// older publication runs graph cleanup before deferred index creation, and +// without them every deleted node/edge rescans the retained fact table. +const PROOF_RESOLUTION_FK_INDEX_STATEMENTS: &[&str] = &[ + "CREATE INDEX IF NOT EXISTS idx_proof_resolution_target + ON proof_resolution_fact(target_node_id)", + "CREATE INDEX IF NOT EXISTS idx_proof_resolution_raw_target + ON proof_resolution_fact(raw_edge_target_id)", + "CREATE INDEX IF NOT EXISTS idx_proof_resolution_edge + ON proof_resolution_fact(edge_id)", +]; + const NODE_CANONICAL_SUFFIX_INDEX: &str = "CREATE INDEX IF NOT EXISTS idx_node_canonical_suffix ON node(COALESCE(substr(CAST(canonical_id AS BLOB), -32), X''))"; @@ -587,7 +601,17 @@ pub(super) fn create_tables(conn: &Connection) -> Result<(), StorageError> { } pub(super) fn create_load_indexes(conn: &Connection) -> Result<(), StorageError> { - for statement in LOAD_TIME_INDEX_STATEMENTS { + for statement in LOAD_TIME_INDEX_STATEMENTS + .iter() + .chain(PROOF_RESOLUTION_FK_INDEX_STATEMENTS) + { + conn.execute(statement, [])?; + } + Ok(()) +} + +fn create_proof_resolution_fk_indexes(conn: &Connection) -> Result<(), StorageError> { + for statement in PROOF_RESOLUTION_FK_INDEX_STATEMENTS { conn.execute(statement, [])?; } Ok(()) @@ -836,6 +860,15 @@ pub(super) fn apply_schema_migrations(storage: &Storage) -> Result<(), StorageEr { migrate_v35_attached_comment_evidence(&storage.conn)?; } + if stored_version < PROOF_RESOLUTION_FK_INDEX_SCHEMA_VERSION + || stored_version == INCOMPLETE_INCREMENTAL_SCHEMA_VERSION + { + // Unconditional in both build and live modes: an incremental stage + // cloned from an index-less predecessor deletes graph rows before + // deferred index creation, so the proof-fact FK child indexes must + // exist at open. + create_proof_resolution_fk_indexes(&storage.conn)?; + } create_indexes(&storage.conn, index_mode)?; if stored_version < SCHEMA_VERSION { diff --git a/crates/codestory-store/src/storage_impl/tests/mod.rs b/crates/codestory-store/src/storage_impl/tests/mod.rs index b321c18ab..cd9b7d0be 100644 --- a/crates/codestory-store/src/storage_impl/tests/mod.rs +++ b/crates/codestory-store/src/storage_impl/tests/mod.rs @@ -1910,6 +1910,37 @@ fn transient_incomplete_schema_fence_requires_marker() -> Result<(), StorageErro Ok(()) } +#[test] +fn interrupted_incremental_run_regains_proof_fk_indexes_without_clearing_fence() +-> Result<(), StorageError> { + let path = unique_temp_db_path("incomplete-incremental-proof-fk-indexes"); + { + let storage = Storage::open(&path)?; + storage + .conn + .execute_batch("DROP INDEX idx_proof_resolution_target")?; + storage.begin_incremental_run()?; + } + + let storage = Storage::open(&path)?; + assert_eq!( + Storage::database_schema_version(&path)?, + INCOMPLETE_INCREMENTAL_SCHEMA_VERSION + ); + assert!(storage.has_incomplete_incremental_run()?); + assert!(sqlite_index_exists( + &storage, + "idx_proof_resolution_target" + )?); + storage.finish_incremental_run()?; + assert_eq!(Storage::database_schema_version(&path)?, SCHEMA_VERSION); + + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_file(path.with_extension("sqlite-wal")); + let _ = std::fs::remove_file(path.with_extension("sqlite-shm")); + Ok(()) +} + #[test] fn interrupted_v19_run_migrates_manifest_column_without_clearing_fence() -> Result<(), StorageError> { @@ -11384,6 +11415,495 @@ fn test_delete_file_projection_preserves_cross_file_edges_and_clears_resolution( Ok(()) } +/// File ids for the proof-FK deletion fixture: one removable file and one +/// unrelated survivor that owns the bulk proof-fact rows. +const PROOF_FK_REMOVED_FILE: i64 = 7_001; +const PROOF_FK_SURVIVOR_FILE: i64 = 8_001; +const PROOF_FK_SURVIVOR_CALLER: i64 = 80_001; +const PROOF_FK_SURVIVOR_PLACEHOLDER: i64 = 80_002; +const PROOF_FK_SURVIVOR_EDGE: i64 = 90_001; +const PROOF_FK_SURVIVOR_PROVENANCE: i64 = 81; +const PROOF_FK_REMOVED_PROVENANCE: i64 = 71; + +fn insert_proof_fact( + storage: &Storage, + ordinal: i64, + file_id: i64, + provenance_id: i64, + status: &str, + caller_node_id: i64, + edge_id: Option, + raw_edge_target_id: Option, + target_node_id: Option, +) -> Result<(), StorageError> { + let (reason, callsite, domain_complete): (&str, Option<&str>, i64) = match status { + "exact" => ("exact_resolution", Some("callsite"), 1), + "ambiguous" => ("multiple_bindings", None, 1), + "unsupported" => ("unsupported_construct", None, 0), + _ => ("missing_binding", None, 1), + }; + storage.conn.execute( + "INSERT INTO proof_resolution_fact ( + fact_id, edge_id, raw_edge_target_id, raw_callsite_identity, + file_id, provenance_id, start_byte, end_byte_exclusive, + line, column, callee_form, raw_target, caller_node_id, + target_node_id, status, reason, evidence_json, + lookup_domain_complete, producer, fact_schema_version, algorithm, + language_adapter, language_adapter_version, evidence_digest + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, 1, + 'identifier', 'callee', ?9, ?10, ?11, ?12, + '[]', ?13, 'codestory-internal', 1, + 'exact-call-resolution-v1', 'rust', 'test', ?14)", + params![ + format!("{ordinal:064x}"), + edge_id, + raw_edge_target_id, + callsite, + file_id, + provenance_id, + 1_000 + ordinal * 2, + 1_001 + ordinal * 2, + caller_node_id, + target_node_id, + status, + reason, + domain_complete, + format!("{:064x}", ordinal + 1), + ], + )?; + Ok(()) +} + +/// One removable file with `removed_nodes` function nodes and `removed_edges` +/// call edges, plus an unrelated survivor file owning `surviving_facts` proof +/// facts (mixed exact/ambiguous/missing_binding statuses), a raw call edge +/// resolved into the removed file, and three facts whose edge/target/raw-target +/// columns point into the removed file so every proof_resolution_fact foreign +/// key a node or edge delete validates is exercised. The caller must keep +/// `removed_nodes >= 2` and `removed_edges >= 1`. +fn proof_fk_deletion_fixture( + removed_nodes: i64, + removed_edges: i64, + surviving_facts: i64, +) -> Result { + assert!(removed_nodes >= 2 && removed_edges >= 1); + let mut storage = Storage::new_in_memory()?; + for (id, path) in [ + (PROOF_FK_REMOVED_FILE, "src/removed.rs"), + (PROOF_FK_SURVIVOR_FILE, "src/survivor.rs"), + ] { + storage.insert_file(&FileInfo { + id, + path: PathBuf::from(path), + language: "rust".to_string(), + modification_time: 1, + indexed: true, + complete: true, + line_count: 100, + file_role: FileRole::Source, + })?; + } + + let mut nodes = vec![ + Node { + id: NodeId(PROOF_FK_REMOVED_FILE), + kind: NodeKind::FILE, + serialized_name: "src/removed.rs".to_string(), + ..Default::default() + }, + Node { + id: NodeId(PROOF_FK_SURVIVOR_FILE), + kind: NodeKind::FILE, + serialized_name: "src/survivor.rs".to_string(), + ..Default::default() + }, + Node { + id: NodeId(PROOF_FK_SURVIVOR_CALLER), + kind: NodeKind::FUNCTION, + serialized_name: "survivor_caller".to_string(), + file_node_id: Some(NodeId(PROOF_FK_SURVIVOR_FILE)), + ..Default::default() + }, + Node { + id: NodeId(PROOF_FK_SURVIVOR_PLACEHOLDER), + kind: NodeKind::FUNCTION, + serialized_name: "survivor_placeholder".to_string(), + file_node_id: Some(NodeId(PROOF_FK_SURVIVOR_FILE)), + ..Default::default() + }, + ]; + for index in 0..removed_nodes { + nodes.push(Node { + id: NodeId(70_001 + index), + kind: NodeKind::FUNCTION, + serialized_name: format!("removed_{index}"), + file_node_id: Some(NodeId(PROOF_FK_REMOVED_FILE)), + ..Default::default() + }); + } + storage.insert_nodes_batch(&nodes)?; + + // One dedicated survivor edge per exact fact: the partial unique index + // idx_proof_resolution_exact_edge admits one exact fact per edge_id. + let exact_fact_edges = surviving_facts / 5 + 2; + let mut edges = vec![Edge { + id: EdgeId(PROOF_FK_SURVIVOR_EDGE), + source: NodeId(PROOF_FK_SURVIVOR_CALLER), + target: NodeId(PROOF_FK_SURVIVOR_PLACEHOLDER), + kind: EdgeKind::CALL, + file_node_id: Some(NodeId(PROOF_FK_SURVIVOR_FILE)), + resolved_target: Some(NodeId(70_001)), + confidence: Some(0.9), + certainty: Some(codestory_contracts::graph::ResolutionCertainty::Certain), + candidate_targets: vec![NodeId(70_001)], + ..Default::default() + }]; + for index in 0..exact_fact_edges { + edges.push(Edge { + id: EdgeId(91_000 + index), + source: NodeId(PROOF_FK_SURVIVOR_CALLER), + target: NodeId(PROOF_FK_SURVIVOR_PLACEHOLDER), + kind: EdgeKind::CALL, + file_node_id: Some(NodeId(PROOF_FK_SURVIVOR_FILE)), + ..Default::default() + }); + } + for index in 0..removed_edges { + edges.push(Edge { + id: EdgeId(60_001 + index), + source: NodeId(70_001 + index % removed_nodes), + target: NodeId(70_001 + (index + 1) % removed_nodes), + kind: EdgeKind::CALL, + file_node_id: Some(NodeId(PROOF_FK_REMOVED_FILE)), + ..Default::default() + }); + } + storage.insert_edges_batch(&edges)?; + + for (provenance_id, file_id, sha) in [ + ( + PROOF_FK_REMOVED_PROVENANCE, + PROOF_FK_REMOVED_FILE, + "7".repeat(64), + ), + ( + PROOF_FK_SURVIVOR_PROVENANCE, + PROOF_FK_SURVIVOR_FILE, + "8".repeat(64), + ), + ] { + storage.conn.execute( + "INSERT INTO proof_resolution_provenance ( + provenance_id, file_id, source_sha256, parser_fingerprint, dependency_json + ) VALUES (?1, ?2, ?3, 'test-parser', '[]')", + params![provenance_id, file_id, sha], + )?; + } + + // Facts owned by the removed file. + for ordinal in 1..=3_i64 { + insert_proof_fact( + &storage, + ordinal, + PROOF_FK_REMOVED_FILE, + PROOF_FK_REMOVED_PROVENANCE, + "missing_binding", + 70_001, + None, + None, + None, + )?; + } + // Bulk survivor facts: every fifth is exact and holds a dedicated edge; + // non-exact rows carry no resolved keys. + let non_exact_statuses = [ + "ambiguous", + "unsupported", + "missing_binding", + "incomplete_domain", + ]; + let mut exact_edge = 0_i64; + for index in 0..surviving_facts { + let (status, edge_id, raw_target, target) = if index % 5 == 0 { + let edge_id = 91_000 + exact_edge; + exact_edge += 1; + ( + "exact", + Some(edge_id), + Some(PROOF_FK_SURVIVOR_PLACEHOLDER), + Some(PROOF_FK_SURVIVOR_PLACEHOLDER), + ) + } else { + (non_exact_statuses[(index % 4) as usize], None, None, None) + }; + insert_proof_fact( + &storage, + 10_000 + index, + PROOF_FK_SURVIVOR_FILE, + PROOF_FK_SURVIVOR_PROVENANCE, + status, + PROOF_FK_SURVIVOR_CALLER, + edge_id, + raw_target, + target, + )?; + } + // Three survivor-owned facts pointing into the removed file through each + // FK column; the explicit cleanup must delete them before the node and + // edge deletes run. Each holds a distinct edge for the exact-status + // uniqueness constraint. + insert_proof_fact( + &storage, + 50_001, + PROOF_FK_SURVIVOR_FILE, + PROOF_FK_SURVIVOR_PROVENANCE, + "exact", + PROOF_FK_SURVIVOR_CALLER, + Some(PROOF_FK_SURVIVOR_EDGE), + Some(PROOF_FK_SURVIVOR_PLACEHOLDER), + Some(70_001), + )?; + insert_proof_fact( + &storage, + 50_002, + PROOF_FK_SURVIVOR_FILE, + PROOF_FK_SURVIVOR_PROVENANCE, + "exact", + PROOF_FK_SURVIVOR_CALLER, + Some(91_000 + exact_edge), + Some(70_002), + Some(PROOF_FK_SURVIVOR_PLACEHOLDER), + )?; + insert_proof_fact( + &storage, + 50_003, + PROOF_FK_SURVIVOR_FILE, + PROOF_FK_SURVIVOR_PROVENANCE, + "exact", + PROOF_FK_SURVIVOR_CALLER, + Some(60_001), + Some(70_001), + Some(70_001), + )?; + + Ok(storage) +} + +/// Count VM steps (progress callbacks at interval 1) around a +/// delete_file_projection call on a fresh fixture. The statement-level +/// counters cannot be reached without restructuring the production delete, so +/// this measures the whole cleanup transaction; the FK-lookup plans are +/// pinned separately by EXPLAIN in the sibling test. +fn measure_proof_fk_deletion_vm_steps( + surviving_facts: i64, + drop_target_index: bool, +) -> Result<(u64, FileProjectionRemovalSummary), StorageError> { + let mut storage = proof_fk_deletion_fixture(12, 6, surviving_facts)?; + if drop_target_index { + storage + .conn + .execute_batch("DROP INDEX idx_proof_resolution_target")?; + } + let steps = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)); + let counter = std::sync::Arc::clone(&steps); + storage.conn.progress_handler( + 1, + Some(move || { + counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + false + }), + )?; + let summary = storage.delete_file_projection(PROOF_FK_REMOVED_FILE)?; + storage.conn.progress_handler(0, None:: bool>)?; + Ok((steps.load(std::sync::atomic::Ordering::Relaxed), summary)) +} + +#[test] +fn delete_file_projection_proof_fk_lookups_use_leading_indexes() -> Result<(), StorageError> { + let storage = proof_fk_deletion_fixture(4, 2, 60)?; + for (index, predicate) in [ + ("idx_proof_resolution_target", "target_node_id = 0"), + ("idx_proof_resolution_raw_target", "raw_edge_target_id = 0"), + ("idx_proof_resolution_edge", "edge_id = 0"), + ] { + assert!(sqlite_index_exists(&storage, index)?); + let mut statement = storage.conn.prepare(&format!( + "EXPLAIN QUERY PLAN SELECT 1 FROM proof_resolution_fact WHERE {predicate}" + ))?; + let plan = statement + .query_map([], |row| row.get::<_, String>(3))? + .collect::, _>>()?; + assert!( + plan.iter() + .any(|line| line.contains("SEARCH proof_resolution_fact") && line.contains(index)), + "{predicate} lookup did not use {index}: {plan:?}" + ); + } + // The partial exact-status index stays unique but can never serve the + // unconditional foreign-key child check on edge_id. + assert!(sqlite_index_exists( + &storage, + "idx_proof_resolution_exact_edge" + )?); + Ok(()) +} + +#[test] +fn delete_file_projection_cost_is_independent_of_surviving_proof_facts() -> Result<(), StorageError> +{ + const SMALL_FACTS: i64 = 200; + const LARGE_FACTS: i64 = 2_000; + let (small_steps, small_summary) = measure_proof_fk_deletion_vm_steps(SMALL_FACTS, false)?; + let (large_steps, large_summary) = measure_proof_fk_deletion_vm_steps(LARGE_FACTS, false)?; + assert_eq!(small_summary.removed_node_count, 13); + assert_eq!(small_summary.removed_edge_count, 6); + assert_eq!(large_summary.removed_node_count, 13); + assert_eq!(large_summary.removed_edge_count, 6); + // Scaling the retained fact table tenfold must not scale deletion work: + // every foreign-key child probe is an index seek, not a scan. + assert!( + large_steps < small_steps.saturating_mul(4), + "deletion VM steps scaled with surviving facts: \ + {SMALL_FACTS} facts -> {small_steps} steps, {LARGE_FACTS} facts -> {large_steps} steps" + ); + Ok(()) +} + +#[test] +fn delete_file_projection_without_target_index_rescans_surviving_facts() -> Result<(), StorageError> +{ + const SMALL_FACTS: i64 = 200; + const LARGE_FACTS: i64 = 2_000; + let (small_steps, _) = measure_proof_fk_deletion_vm_steps(SMALL_FACTS, true)?; + let (large_steps, _) = measure_proof_fk_deletion_vm_steps(LARGE_FACTS, true)?; + // With idx_proof_resolution_target dropped, every deleted node rescans + // the retained fact table, so deletion work tracks the fact count. + assert!( + large_steps > small_steps.saturating_mul(4), + "missing target index did not rescale deletion work: \ + {SMALL_FACTS} facts -> {small_steps} steps, {LARGE_FACTS} facts -> {large_steps} steps" + ); + Ok(()) +} + +#[test] +fn delete_file_projection_removes_removed_facts_and_preserves_survivors() -> Result<(), StorageError> +{ + const SURVIVING_FACTS: i64 = 30; + let mut storage = proof_fk_deletion_fixture(12, 6, SURVIVING_FACTS)?; + + let summary = storage.delete_file_projection(PROOF_FK_REMOVED_FILE)?; + assert_eq!(summary.removed_node_count, 13); + assert_eq!(summary.removed_edge_count, 6); + assert_eq!( + summary.affected_caller_file_ids, + vec![PROOF_FK_SURVIVOR_FILE] + ); + + // Removed file's nodes, edges, facts and provenance are gone. + assert!(storage.get_node(NodeId(PROOF_FK_REMOVED_FILE))?.is_none()); + assert!(storage.get_node(NodeId(70_001))?.is_none()); + let removed_facts: i64 = storage.conn.query_row( + "SELECT COUNT(*) FROM proof_resolution_fact WHERE file_id = ?1", + params![PROOF_FK_REMOVED_FILE], + |row| row.get(0), + )?; + assert_eq!(removed_facts, 0); + let removed_provenance: i64 = storage.conn.query_row( + "SELECT COUNT(*) FROM proof_resolution_provenance WHERE file_id = ?1", + params![PROOF_FK_REMOVED_FILE], + |row| row.get(0), + )?; + assert_eq!(removed_provenance, 0); + + // Survivor facts survive; the three that pointed into the removed file + // were cleaned before the node and edge deletes. + let surviving_facts: i64 = storage.conn.query_row( + "SELECT COUNT(*) FROM proof_resolution_fact WHERE file_id = ?1", + params![PROOF_FK_SURVIVOR_FILE], + |row| row.get(0), + )?; + assert_eq!(surviving_facts, SURVIVING_FACTS); + let dangling_facts: i64 = storage.conn.query_row( + "SELECT COUNT(*) FROM proof_resolution_fact + WHERE target_node_id = 70001 OR raw_edge_target_id = 70002 OR edge_id = 60001", + [], + |row| row.get(0), + )?; + assert_eq!(dangling_facts, 0); + let survivor_provenance: i64 = storage.conn.query_row( + "SELECT COUNT(*) FROM proof_resolution_provenance WHERE file_id = ?1", + params![PROOF_FK_SURVIVOR_FILE], + |row| row.get(0), + )?; + assert_eq!(survivor_provenance, 1); + + // The cross-file edge row stays; its resolved pointer into the removed + // file is cleared. + let resolved_target: Option = storage.conn.query_row( + "SELECT resolved_target_node_id FROM edge WHERE id = ?1", + params![PROOF_FK_SURVIVOR_EDGE], + |row| row.get(0), + )?; + assert_eq!(resolved_target, None); + + Ok(()) +} + +#[test] +fn delete_file_projection_rolls_back_when_cleanup_fails() -> Result<(), StorageError> { + let mut storage = proof_fk_deletion_fixture(4, 2, 20)?; + let fact_count_before: i64 = + storage + .conn + .query_row("SELECT COUNT(*) FROM proof_resolution_fact", [], |row| { + row.get(0) + })?; + let node_count_before: i64 = + storage + .conn + .query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))?; + let edge_count_before: i64 = + storage + .conn + .query_row("SELECT COUNT(*) FROM edge", [], |row| row.get(0))?; + + // Fail mid-cleanup: proof facts have already been deleted in the same + // transaction when the edge delete aborts, so a partial commit would leak + // fact loss. + storage.conn.execute_batch( + "CREATE TRIGGER fail_edge_cleanup + BEFORE DELETE ON edge BEGIN + SELECT RAISE(ABORT, 'injected cleanup failure'); + END;", + )?; + assert!( + storage + .delete_file_projection(PROOF_FK_REMOVED_FILE) + .is_err() + ); + storage + .conn + .execute_batch("DROP TRIGGER fail_edge_cleanup")?; + + let fact_count_after: i64 = + storage + .conn + .query_row("SELECT COUNT(*) FROM proof_resolution_fact", [], |row| { + row.get(0) + })?; + let node_count_after: i64 = storage + .conn + .query_row("SELECT COUNT(*) FROM node", [], |row| row.get(0))?; + let edge_count_after: i64 = storage + .conn + .query_row("SELECT COUNT(*) FROM edge", [], |row| row.get(0))?; + assert_eq!(fact_count_after, fact_count_before); + assert_eq!(node_count_after, node_count_before); + assert_eq!(edge_count_after, edge_count_before); + Ok(()) +} + /// File ids used by the deletion-scope fixture below. const REMOVAL_FIXTURE_CALLER_FILE: i64 = 1_001; const REMOVAL_FIXTURE_PREFERRED_FILE: i64 = 2_001; diff --git a/docs/architecture/subsystems/store.md b/docs/architecture/subsystems/store.md index a1c45247e..a7279dba8 100644 --- a/docs/architecture/subsystems/store.md +++ b/docs/architecture/subsystems/store.md @@ -132,6 +132,15 @@ string and suffix collisions. Live migration replaces the indexes and advances the schema version in one transaction, while staged builds use the existing deferred-index fence. +Schema v36 adds child-key-leading indexes on the `proof_resolution_fact` +foreign keys a file-projection deletion validates (`target_node_id`, +`raw_edge_target_id`, `edge_id`). They belong to the load-time index set, not +the deferred secondary set: an incremental stage cloned from a publication +that predates them runs graph cleanup before deferred index creation, and +without them every deleted node and edge rescans the retained fact table. The +partial unique `idx_proof_resolution_exact_edge` index stays, but a partial +index can never serve the unconditional foreign-key child check. + The projection transaction also replaces file-scoped errors and marks grounding summary/detail plus resolution-support state dirty. Those writes do not follow the graph commit as independent autocommits. Store telemetry counts From 5982228ddd61498a22dffd64d49fb23c09936650 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 06:36:38 -0400 Subject: [PATCH 07/58] drop indexes conditionally in the staged-clone test --- crates/codestory-store/src/snapshot_store.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/codestory-store/src/snapshot_store.rs b/crates/codestory-store/src/snapshot_store.rs index fbb2ba0ef..8cb706f2a 100644 --- a/crates/codestory-store/src/snapshot_store.rs +++ b/crates/codestory-store/src/snapshot_store.rs @@ -917,9 +917,9 @@ mod tests { rusqlite::Connection::open(&generation_path).expect("open sealed generation"); connection .execute_batch( - "DROP INDEX idx_proof_resolution_target; - DROP INDEX idx_proof_resolution_raw_target; - DROP INDEX idx_proof_resolution_edge; + "DROP INDEX IF EXISTS idx_proof_resolution_target; + DROP INDEX IF EXISTS idx_proof_resolution_raw_target; + DROP INDEX IF EXISTS idx_proof_resolution_edge; PRAGMA user_version = 35;", ) .expect("downgrade sealed generation"); From e55d84ea698c1c8ae34b84f75f8b0583498891e3 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 06:43:35 -0400 Subject: [PATCH 08/58] keep the legacy provenance control on the same proof index set --- CHANGELOG.md | 1 + crates/codestory-store/tests/proof_resolution.rs | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c317bea43..4daca5225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Snippet and source reads for an indexed symbol now verify file bytes against the indexed content hash and refuse stale or missing source with a typed error instead of returning mismatched text. - During a refresh, source-backed reads wait for the fresh index while graph-only answers may come from the retained publication; results served that way are labelled `historical` under `_meta.codestory_publication.freshness`. - When another CodeStory session is already refreshing the same project, a second session now waits for it to finish and adopts its publication instead of failing with a busy error. +- Incremental index updates no longer stall when removing a file's stale graph rows alongside many stored resolution facts. ## 0.17.6 diff --git a/crates/codestory-store/tests/proof_resolution.rs b/crates/codestory-store/tests/proof_resolution.rs index d38d7b494..c6a49567b 100644 --- a/crates/codestory-store/tests/proof_resolution.rs +++ b/crates/codestory-store/tests/proof_resolution.rs @@ -1156,7 +1156,13 @@ fn shared_file_provenance_round_trips_with_fewer_pages_than_denormalized_facts() CREATE INDEX idx_proof_resolution_file ON proof_resolution_fact(file_id); CREATE INDEX idx_proof_resolution_caller_target - ON proof_resolution_fact(caller_node_id, target_node_id, status);", + ON proof_resolution_fact(caller_node_id, target_node_id, status); + CREATE INDEX idx_proof_resolution_target + ON proof_resolution_fact(target_node_id); + CREATE INDEX idx_proof_resolution_raw_target + ON proof_resolution_fact(raw_edge_target_id); + CREATE INDEX idx_proof_resolution_edge + ON proof_resolution_fact(edge_id);", ) .expect("create exact legacy proof schema"); { From 5a30aa85aa05ee72cea868a520fa10ceb81a408d Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 07:01:12 -0400 Subject: [PATCH 09/58] preserve causal gaps through the public budget envelope --- .../src/call_path_public.rs | 86 ++++++---- .../codestory-runtime/src/public_call_path.rs | 154 +++++++++++++++--- 2 files changed, 185 insertions(+), 55 deletions(-) diff --git a/crates/codestory-contracts/src/call_path_public.rs b/crates/codestory-contracts/src/call_path_public.rs index ead6993ff..4948bbac0 100644 --- a/crates/codestory-contracts/src/call_path_public.rs +++ b/crates/codestory-contracts/src/call_path_public.rs @@ -266,7 +266,7 @@ fn refutation_schema() -> Value { }) } -fn gap_schema() -> Value { +fn gap_variants() -> Vec { let selector_gap = |kind| { closed_object_schema(vec![ ("kind", enum_schema(&[kind])), @@ -285,23 +285,26 @@ fn gap_schema() -> Value { ), ]) }; - json!({ - "type":"object", - "oneOf":[ - closed_object_schema(vec![("kind", enum_schema(&["unclassified_source_text"]))]), - closed_object_schema(vec![ - ("kind", enum_schema(&["unresolved_material_clause"])), - ("clause_id", string_schema()), - ("reason", enum_schema(&[ + vec![ + closed_object_schema(vec![("kind", enum_schema(&["unclassified_source_text"]))]), + closed_object_schema(vec![ + ("kind", enum_schema(&["unresolved_material_clause"])), + ("clause_id", string_schema()), + ( + "reason", + enum_schema(&[ "missing_selector_resolution", "ambiguous_selector_resolution", "unsupported_interpretation", - ])), - ]), - closed_object_schema(vec![ - ("kind", enum_schema(&["material_token_misclassified"])), - ("clause_id", string_schema()), - ("guard_families", json!({ + ]), + ), + ]), + closed_object_schema(vec![ + ("kind", enum_schema(&["material_token_misclassified"])), + ("clause_id", string_schema()), + ( + "guard_families", + json!({ "type":"array", "items":enum_schema(&[ "quoted_or_backticked_identifier", @@ -316,26 +319,42 @@ fn gap_schema() -> Value { "minItems":1, "maxItems":8, "uniqueItems":true, - })), - ]), - selector_gap("selector_missing"), - selector_gap("selector_ambiguous"), - selector_gap("non_callable_selector"), - step_gap("direct_call_missing"), - step_gap("recursive_call_not_representable"), - step_gap("source_window_too_large"), - step_gap("invalid_utf8"), - step_gap("source_line_out_of_range"), - step_gap("edge_containment_unproven"), - step_gap("missing_direct_call_receipt"), - step_gap("receipt_or_edge_already_used"), - step_gap("projection_exclusion_conflicts_with_required_receipt"), - closed_object_schema(vec![("kind", enum_schema(&["kernel_search_budget_exceeded"]))]) - ] + }), + ), + ]), + selector_gap("selector_missing"), + selector_gap("selector_ambiguous"), + selector_gap("non_callable_selector"), + step_gap("direct_call_missing"), + step_gap("recursive_call_not_representable"), + step_gap("source_window_too_large"), + step_gap("invalid_utf8"), + step_gap("source_line_out_of_range"), + step_gap("edge_containment_unproven"), + step_gap("missing_direct_call_receipt"), + step_gap("receipt_or_edge_already_used"), + step_gap("projection_exclusion_conflicts_with_required_receipt"), + closed_object_schema(vec![( + "kind", + enum_schema(&["kernel_search_budget_exceeded"]), + )]), + ] +} + +fn gap_schema() -> Value { + json!({ + "type":"object", + "oneOf":gap_variants() }) } +fn output_budget_exceeded_gap_schema() -> Value { + closed_object_schema(vec![("kind", enum_schema(&["output_budget_exceeded"]))]) +} + fn budget_disposition_schema() -> Value { + let mut gap_kinds = gap_variants(); + gap_kinds.push(output_budget_exceeded_gap_schema()); closed_object_schema(vec![ ("kind", enum_schema(&["unknown"])), ("contract_digest", sha256_schema()), @@ -343,9 +362,10 @@ fn budget_disposition_schema() -> Value { "gaps", json!({ "type":"array", - "items":closed_object_schema(vec![("kind", enum_schema(&["output_budget_exceeded"]))]), + "items":{"oneOf":gap_kinds}, "minItems":1, - "maxItems":1 + "maxItems":257, + "uniqueItems":true }), ), ]) diff --git a/crates/codestory-runtime/src/public_call_path.rs b/crates/codestory-runtime/src/public_call_path.rs index 3aa524719..cfc7eb38d 100644 --- a/crates/codestory-runtime/src/public_call_path.rs +++ b/crates/codestory-runtime/src/public_call_path.rs @@ -151,13 +151,28 @@ pub fn project_public_transport_budget_result( "disposition": { "kind": "unknown", "contract_digest": contract_digest, - "gaps": [{"kind":"output_budget_exceeded"}] + "gaps": output_budget_gaps(object.get("disposition")), }, "cap_bytes": COMPACT_PROOF_MAX_BYTES, "required_complete_size": required_transport_size })) } +/// The causal gaps the internal disposition already carries, kept in their +/// canonical order, terminated by the output-budget marker. Causal gaps stay +/// visible through the transport envelope: a caller whose proof exhausted its +/// kernel search budget must not be told only that the answer did not fit. +fn output_budget_gaps(disposition: Option<&Value>) -> Vec { + let mut gaps = disposition + .and_then(|disposition| disposition.get("gaps")) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + gaps.retain(|gap| gap.get("kind").and_then(Value::as_str) != Some("output_budget_exceeded")); + gaps.push(json!({ "kind": "output_budget_exceeded" })); + gaps +} + fn apply_public_compact_budget(root: Value) -> Result { let serialized = serde_json::to_vec(&root) .map_err(|error| format!("serialize public verification result: {error}"))?; @@ -168,26 +183,37 @@ fn apply_public_compact_budget(root: Value) -> Result| { + json!({ + "kind": "budget_exceeded", + "schema_version": object.get("schema_version").cloned().unwrap_or(json!(1)), + "domain": PUBLIC_CALL_PATH_DOMAIN, + "translation_status": object.get("translation_status").cloned().unwrap_or(json!(CONTRACT_INTERPRETATION)), + "graph_disposition": "unknown", + "runtime_execution_proven": false, + "guard_version": object.get("guard_version").cloned().unwrap_or(json!(codestory_contracts::call_path::CLAUSE_GUARD_VERSION)), + "source_text_sha256": object.get("source_text_sha256").cloned().unwrap_or(json!("")), + "contract_digest": contract_digest.clone(), + "core_publication": object.get("core_publication").cloned().unwrap_or(json!({})), + "provenance": { "availability": "unavailable" }, + "disposition": { + "kind": "unknown", + "contract_digest": contract_digest, + "gaps": gaps, + }, + "cap_bytes": COMPACT_PROOF_MAX_BYTES, + "required_complete_size": serialized.len(), + }) + }; + let compact = envelope(output_budget_gaps(object.get("disposition"))); + let compact_bytes = serde_json::to_vec(&compact) + .map_err(|error| format!("serialize public verification budget envelope: {error}"))?; + let compact = if compact_bytes.len() > COMPACT_PROOF_MAX_BYTES { + // Rare pathological case: the causal gap list alone overflows the cap. + envelope(vec![json!({ "kind": "output_budget_exceeded" })]) + } else { + compact + }; let compact_bytes = serde_json::to_vec(&compact) .map_err(|error| format!("serialize public verification budget envelope: {error}"))?; if compact_bytes.len() > COMPACT_PROOF_MAX_BYTES { @@ -338,7 +364,9 @@ pub fn proof_domain() -> &'static str { #[cfg(test)] mod tests { use super::{ - ObservedIntegratedProjectedCallPathResult, run_observed_call_path_public_operation, + ObservedIntegratedProjectedCallPathResult, PublicCallPathResultDto, + apply_public_compact_budget, project_public_transport_budget_result, + run_observed_call_path_public_operation, }; use std::cell::Cell; use std::fs; @@ -559,4 +587,86 @@ mod tests { .expect_err("the helper must own the public-operation freshness fence"); assert_eq!(stale.code, "project_unavailable"); } + + fn causal_gaps() -> Vec { + vec![ + serde_json::json!({"kind":"direct_call_missing","step_index":5}), + serde_json::json!({"kind":"kernel_search_budget_exceeded"}), + ] + } + + #[test] + fn oversized_result_envelope_preserves_canonical_causal_gaps() { + let root = serde_json::json!({ + "kind": "complete", + "schema_version": 1, + "domain": "call-path/v1", + "contract_interpretation": "host_supplied", + "guard_version": "clause_guard_v1", + "source_text_sha256": "a".repeat(64), + "contract_digest": "b".repeat(64), + "core_publication": {}, + "disposition": { + "kind": "unknown", + "contract_digest": "b".repeat(64), + "gaps": causal_gaps(), + }, + "padding": "x".repeat(6_000), + }); + let bounded = apply_public_compact_budget(root).expect("budget envelope"); + let gaps = bounded.as_value()["disposition"]["gaps"] + .as_array() + .unwrap(); + assert_eq!( + gaps, + &vec![ + serde_json::json!({"kind":"direct_call_missing","step_index":5}), + serde_json::json!({"kind":"kernel_search_budget_exceeded"}), + serde_json::json!({"kind":"output_budget_exceeded"}), + ], + "the causal gaps must survive the output-budget envelope in order" + ); + assert_eq!(bounded.as_value()["kind"], "budget_exceeded"); + } + + #[test] + fn transport_budget_envelope_preserves_canonical_causal_gaps() { + let complete = PublicCallPathResultDto::try_from_projected_value(serde_json::json!({ + "kind": "complete", + "schema_version": 1, + "domain": "call-path/v1", + "translation_status": "host_supplied", + "graph_disposition": "unknown", + "runtime_execution_proven": false, + "guard_version": "clause_guard_v1", + "source_text_sha256": "a".repeat(64), + "contract_digest": "b".repeat(64), + "core_publication": {}, + "provenance": {"availability":"unavailable"}, + "identities": {}, + "spec": {}, + "clauses": [], + "steps": [], + "receipts": [], + "disposition": { + "kind": "unknown", + "contract_digest": "b".repeat(64), + "gaps": causal_gaps(), + }, + })) + .expect("complete public result"); + let bounded = + project_public_transport_budget_result(&complete, 9_000).expect("transport envelope"); + let gaps = bounded.as_value()["disposition"]["gaps"] + .as_array() + .unwrap(); + assert_eq!( + gaps, + &vec![ + serde_json::json!({"kind":"direct_call_missing","step_index":5}), + serde_json::json!({"kind":"kernel_search_budget_exceeded"}), + serde_json::json!({"kind":"output_budget_exceeded"}), + ] + ); + } } From 3aaece0e1b26b470d87df56ef78146170a64f0b5 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 08:18:53 -0400 Subject: [PATCH 10/58] isolate runtime tests from ambient cache and scope retention to the runtime root --- crates/codestory-retrieval/src/index.rs | 77 ++++- crates/codestory-retrieval/src/inventory.rs | 43 ++- .../src/activation_retrieval.rs | 31 +- .../src/agent/packet_budget.rs | 3 +- .../src/agent/packet_compiler.rs | 9 +- .../src/agent/packet_probe.rs | 3 +- .../src/agent/packet_search.rs | 71 ++-- .../src/agent/retrieval_primary.rs | 27 +- .../src/agent/trace_export.rs | 26 +- .../codestory-runtime/src/cache_rehydrate.rs | 312 +++++++++++------- .../codestory-runtime/src/controller_core.rs | 8 + .../src/evidence_projection_v3.rs | 20 +- crates/codestory-runtime/src/grounding.rs | 66 ++-- crates/codestory-runtime/src/lib.rs | 32 +- crates/codestory-runtime/src/search/engine.rs | 28 +- crates/codestory-runtime/src/services.rs | 61 +++- .../src/services/activation_upgrade_tests.rs | 24 +- crates/codestory-runtime/src/tests.rs | 176 ++++++---- .../codestory-runtime/src/tests/affected.rs | 15 +- .../codestory-runtime/src/tests/repo_text.rs | 6 +- .../src/tests/search_scoring.rs | 43 ++- crates/codestory-runtime/tests/integration.rs | 27 +- 22 files changed, 738 insertions(+), 370 deletions(-) diff --git a/crates/codestory-retrieval/src/index.rs b/crates/codestory-retrieval/src/index.rs index 355fd3959..371b74d35 100644 --- a/crates/codestory-retrieval/src/index.rs +++ b/crates/codestory-retrieval/src/index.rs @@ -3589,7 +3589,7 @@ fn retain_published_generations( marker_error: Option, ) -> (GenerationRetentionPlan, GenerationRetentionApplyReport) { let mut protection = scan_retention_protection( - &crate::config::user_cache_root(), + &context.runtime.cache_root, Some(storage_path), &context.layout.state_file, ); @@ -5902,6 +5902,81 @@ mod tests { ); } + #[cfg(feature = "test-support")] + #[test] + fn postcommit_retention_scans_the_runtime_cache_root_not_the_ambient_process_root() { + let _env = crate::test_support::env_lock(); + let fixture = FirstRetrievalPublicationFixture::new(); + let sentinel = TempDir::new().expect("sentinel ambient cache root"); + // A foreign store the retention scan cannot observe. If the scan ran + // against the ambient process root this entry would mark protection + // evidence incomplete and suppress pruning of the owned root. + let foreign = sentinel.path().join("foreign-project"); + fs::create_dir_all(&foreign).expect("create foreign project cache dir"); + fs::write(foreign.join("codestory.db"), b"not a sqlite store") + .expect("poison the ambient cache root"); + + let project_id = sidecar_project_id_for_runtime(fixture.project.path(), &fixture.runtime) + .expect("fixture project identity"); + // Pin an active generation under the owned root so the plan has a + // nonempty protected set: only ambient interference could suppress + // pruning below. + let active = crate::test_support::retrieval_manifest_fixture(&project_id, &"a".repeat(64)); + let marker = GenerationRetentionMarker::next( + "retention-root-isolation-workspace", + fixture.project.path(), + active, + None, + 1, + ) + .expect("build retention marker"); + write_retention_marker(&fixture.runtime.layout.state_file, &marker) + .expect("pin the active generation under the owned root"); + + let stale_dir = fixture + .runtime + .layout + .lexical_data_dir + .join("shards") + .join(format!("{project_id}-{}", "b".repeat(16))); + fs::create_dir_all(&stale_dir).expect("create stale generation dir"); + fs::write(stale_dir.join("shard.bin"), b"stale").expect("write stale shard bytes"); + + let (plan, report) = crate::config::with_test_cache_root(sentinel.path(), || { + retain_published_generations( + &fixture.storage_path, + &GenerationRetentionContext { + runtime: &fixture.runtime, + layout: &fixture.runtime.layout, + workspace_id: "retention-root-isolation-workspace", + previous_manifest: None, + embedding_device: &crate::embeddings::embedding_device_readiness_for_runtime( + &fixture.runtime, + ), + embedding_residency: + crate::embeddings::acquire_product_embedding_residency_for_runtime( + &fixture.runtime, + ) + .expect("acquire test residency"), + pinned_core_publication: fixture.publication.clone(), + graph_equivalent_predecessor: None, + }, + &project_id, + None, + ) + }); + + assert!( + !plan.pruning_suppressed && !report.pruning_suppressed, + "ambient cache contents must not suppress pruning of the runtime root: {:?}", + plan.errors + ); + assert!( + !stale_dir.exists(), + "the unprotected generation under the owned root must be reclaimed" + ); + } + #[cfg(feature = "test-support")] #[test] fn finalized_manifest_external_pointer_cancellation_preserves_commit_boundary() { diff --git a/crates/codestory-retrieval/src/inventory.rs b/crates/codestory-retrieval/src/inventory.rs index 48c1cf4a1..b83fe42ca 100644 --- a/crates/codestory-retrieval/src/inventory.rs +++ b/crates/codestory-retrieval/src/inventory.rs @@ -1,4 +1,4 @@ -use crate::config::{SidecarRuntimeConfig, user_cache_root}; +use crate::config::SidecarRuntimeConfig; use crate::generation::{ manifest_has_current_sidecar_contract, manifest_unavailable_reason_for_runtime, }; @@ -32,15 +32,15 @@ pub struct SidecarGcReport { pub fn sidecar_inventory_with_storage( project_root: &Path, storage_path: &Path, + runtime: &SidecarRuntimeConfig, ) -> Result { - let cache_root = user_cache_root(); Ok(SidecarInventoryReport { dry_run: true, - cache_root: cache_root.display().to_string(), + cache_root: runtime.cache_root.display().to_string(), generation_retention: Some(generation_retention_plan_for_storage( project_root, storage_path, - &cache_root, + runtime, )?), }) } @@ -48,20 +48,19 @@ pub fn sidecar_inventory_with_storage( pub fn sidecar_gc_apply_with_storage( project_root: &Path, storage_path: &Path, + runtime: &SidecarRuntimeConfig, ) -> Result { - let cache_root = user_cache_root(); - let runtime = SidecarRuntimeConfig::for_project_auto(project_root); - let global_gc_state_file = global_generation_gc_state_file(&runtime); + let global_gc_state_file = global_generation_gc_state_file(runtime); let _global_gc_lock = GenerationRetentionLock::acquire(&global_gc_state_file, GLOBAL_GENERATION_GC_LOCK_SCOPE) .context("coordinate retrieval cleanup with generation publication")?; Ok(SidecarGcReport { dry_run: false, - cache_root: cache_root.display().to_string(), + cache_root: runtime.cache_root.display().to_string(), generation_retention: Some(apply_generation_retention_for_storage( project_root, storage_path, - &cache_root, + runtime, )?), }) } @@ -69,15 +68,14 @@ pub fn sidecar_gc_apply_with_storage( fn generation_retention_plan_for_storage( project_root: &Path, storage_path: &Path, - cache_root: &Path, + runtime: &SidecarRuntimeConfig, ) -> Result { - let runtime = SidecarRuntimeConfig::for_project_auto(project_root); - let project_id = crate::index::sidecar_project_id_for_runtime(project_root, &runtime)?; - let (_lock, unrooted_state) = inventory_retention_view(&runtime, &project_id)?; + let project_id = crate::index::sidecar_project_id_for_runtime(project_root, runtime)?; + let (_lock, unrooted_state) = inventory_retention_view(runtime, &project_id)?; Ok(build_generation_retention_plan( storage_path, - cache_root, - &runtime, + &runtime.cache_root, + runtime, &project_id, unrooted_state, )) @@ -102,16 +100,15 @@ fn inventory_retention_view( fn apply_generation_retention_for_storage( project_root: &Path, storage_path: &Path, - cache_root: &Path, + runtime: &SidecarRuntimeConfig, ) -> Result { - let runtime = SidecarRuntimeConfig::for_project_auto(project_root); - let project_id = crate::index::sidecar_project_id_for_runtime(project_root, &runtime)?; + let project_id = crate::index::sidecar_project_id_for_runtime(project_root, runtime)?; let _lock = GenerationRetentionLock::acquire(&runtime.layout.state_file, &project_id) .context("lock retrieval generation retention apply")?; let plan = build_generation_retention_plan( storage_path, - cache_root, - &runtime, + &runtime.cache_root, + runtime, &project_id, GenerationRetentionState::Reclaimable, ); @@ -308,7 +305,8 @@ mod tests { let before = snapshot_tree(cache.path()); let report = with_test_cache_root(cache.path(), || { - sidecar_inventory_with_storage(project.path(), &active_storage) + let runtime = SidecarRuntimeConfig::for_project_auto(project.path()); + sidecar_inventory_with_storage(project.path(), &active_storage, &runtime) .expect("dry-run inventory") }); let after = snapshot_tree(cache.path()); @@ -350,7 +348,8 @@ mod tests { let retention_dir = cache.path().join("retention"); with_test_cache_root(cache.path(), || { - sidecar_inventory_with_storage(project.path(), &active_storage) + let runtime = SidecarRuntimeConfig::for_project_auto(project.path()); + sidecar_inventory_with_storage(project.path(), &active_storage, &runtime) .expect("dry-run inventory") }); diff --git a/crates/codestory-runtime/src/activation_retrieval.rs b/crates/codestory-runtime/src/activation_retrieval.rs index b412bfabd..739c3dc9c 100644 --- a/crates/codestory-runtime/src/activation_retrieval.rs +++ b/crates/codestory-runtime/src/activation_retrieval.rs @@ -76,7 +76,11 @@ impl ActivationService { project_root: &Path, storage_path: &Path, ) -> anyhow::Result { - codestory_retrieval::sidecar_inventory_with_storage(project_root, storage_path) + codestory_retrieval::sidecar_inventory_with_storage( + project_root, + storage_path, + self.controller.runtime_config.as_ref(), + ) } /// Apply the retrieval owner's bounded generation-retention plan. @@ -85,8 +89,11 @@ impl ActivationService { project_root: &Path, storage_path: &Path, ) -> anyhow::Result { - let report = - codestory_retrieval::sidecar_gc_apply_with_storage(project_root, storage_path)?; + let report = codestory_retrieval::sidecar_gc_apply_with_storage( + project_root, + storage_path, + self.controller.runtime_config.as_ref(), + )?; apply_core_gc_for_runtime( self.controller.runtime_config.as_ref(), storage_path, @@ -451,17 +458,23 @@ mod tests { assert_eq!(facade_refusal.code(), direct_refusal.code()); assert_eq!(facade_refusal.to_string(), direct_refusal.to_string()); - let direct_inventory = - codestory_retrieval::sidecar_inventory_with_storage(project.path(), &storage_path) - .expect("direct inventory"); + let direct_inventory = codestory_retrieval::sidecar_inventory_with_storage( + project.path(), + &storage_path, + &raw, + ) + .expect("direct inventory"); let facade_inventory = facade .retrieval_inventory(project.path(), &storage_path) .expect("facade inventory"); assert_eq!(facade_inventory, direct_inventory); - let direct_gc = - codestory_retrieval::sidecar_gc_apply_with_storage(project.path(), &storage_path) - .expect("direct gc"); + let direct_gc = codestory_retrieval::sidecar_gc_apply_with_storage( + project.path(), + &storage_path, + &raw, + ) + .expect("direct gc"); let facade_gc = facade .apply_retrieval_gc(project.path(), &storage_path) .expect("facade gc"); diff --git a/crates/codestory-runtime/src/agent/packet_budget.rs b/crates/codestory-runtime/src/agent/packet_budget.rs index 36ec088c3..2a79fd788 100644 --- a/crates/codestory-runtime/src/agent/packet_budget.rs +++ b/crates/codestory-runtime/src/agent/packet_budget.rs @@ -1162,9 +1162,10 @@ pub(super) mod tests { #[test] fn hard_budget_minimizer_cannot_destroy_the_v3_request_identity() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); fs::write(project.path().join("lib.rs"), "pub fn retained() {}\n").expect("source"); - let controller = crate::AppController::new(); + let controller = crate::AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( project.path().to_path_buf(), diff --git a/crates/codestory-runtime/src/agent/packet_compiler.rs b/crates/codestory-runtime/src/agent/packet_compiler.rs index 3f68eee5b..29f11e175 100644 --- a/crates/codestory-runtime/src/agent/packet_compiler.rs +++ b/crates/codestory-runtime/src/agent/packet_compiler.rs @@ -920,6 +920,7 @@ mod tests { #[test] fn frozen_public_packet_keeps_only_admitted_sources_and_induced_relations() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use crate::agent::packet_candidate::PacketAdmissionDecision; use codestory_contracts::packet_projection_v3::{EvidenceKindV3Dto, PacketProjectionV3Dto}; use codestory_store::{IndexPublicationMode, IndexPublicationRecord}; @@ -1073,7 +1074,7 @@ mod tests { assert_eq!(storage.get_edges().unwrap().len(), 56); drop(storage); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); { let mut state = controller.state.lock(); state.project_root = Some(project.path().to_path_buf()); @@ -1326,8 +1327,9 @@ mod tests { #[test] fn file_admission_retains_only_complete_pinned_source_or_navigation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller.state.lock().project_root = Some(project.path().to_path_buf()); let storage = Store::new_in_memory().expect("store"); let path = project.path().join("settings.rs"); @@ -1458,8 +1460,9 @@ mod tests { #[test] fn packet_file_admissions_distinguish_verified_budget_from_source_drift() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller.state.lock().project_root = Some(project.path().to_path_buf()); let mut storage = Store::new_in_memory().expect("store"); let path = project.path().join("large.rs"); diff --git a/crates/codestory-runtime/src/agent/packet_probe.rs b/crates/codestory-runtime/src/agent/packet_probe.rs index 9be68a53d..65cb42448 100644 --- a/crates/codestory-runtime/src/agent/packet_probe.rs +++ b/crates/codestory-runtime/src/agent/packet_probe.rs @@ -1039,7 +1039,8 @@ mod tests { #[test] fn unresolved_exact_probe_keeps_its_packet_wide_reservation_charged() { - let controller = AppController::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); let session = Rc::new(PacketProofSession::new()); let _guard = install_packet_proof_session(Rc::clone(&session)); diff --git a/crates/codestory-runtime/src/agent/packet_search.rs b/crates/codestory-runtime/src/agent/packet_search.rs index 14bfa213f..2a4ab3a21 100644 --- a/crates/codestory-runtime/src/agent/packet_search.rs +++ b/crates/codestory-runtime/src/agent/packet_search.rs @@ -272,8 +272,9 @@ mod tests { #[test] fn packet_batch_preserves_publication_changed_for_operation_retry() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let error = packet_batch_error( - &AppController::new(), + &AppController::new_with_owned_cache_root(process_cache.path()), ApiError::new("publication_changed", "generation drift"), "packet batch", ); @@ -284,8 +285,9 @@ mod tests { #[test] fn packet_batch_preserves_public_cancellation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let error = packet_batch_error( - &AppController::new(), + &AppController::new_with_owned_cache_root(process_cache.path()), ApiError::new("cancelled", "request cancelled"), "packet batch", ); @@ -296,8 +298,9 @@ mod tests { #[test] fn packet_batch_preserves_embedding_capacity_without_reindex_advice() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let error = packet_batch_error( - &AppController::new(), + &AppController::new_with_owned_cache_root(process_cache.path()), ApiError::embedding_capacity( "embedding connection admission is full", codestory_contracts::api::EmbeddingCapacityPressureDto { @@ -337,40 +340,38 @@ mod tests { ); } - struct EnvVarGuard { - key: &'static str, - previous: Option, - } - - impl EnvVarGuard { - fn cleared(key: &'static str) -> Self { - let previous = std::env::var_os(key); - // SAFETY: test-only env cleanup under the shared process env lock. - unsafe { - std::env::remove_var(key); - } - Self { key, previous } - } - } - - impl Drop for EnvVarGuard { - fn drop(&mut self) { - // SAFETY: restores the process-local env var captured by this guard. - unsafe { - if let Some(previous) = self.previous.take() { - std::env::set_var(self.key, previous); - } else { - std::env::remove_var(self.key); - } - } - } - } - + /// `CODESTORY_RETRIEVAL` is read at call time, so the absence must be true + /// for the whole process: the parent re-executes this test in a child with + /// the variable removed, leaving the parent's environment untouched. #[test] fn packet_fused_batch_fails_closed_without_sidecar_primary() { - let _lock = crate::process_env_test_lock(); - let _retrieval_env = EnvVarGuard::cleared("CODESTORY_RETRIEVAL"); - let controller = AppController::new_with_config(crate::test_sidecar_runtime_from_env()); + const CHILD_MARKER: &str = "CODESTORY_PACKET_SEARCH_NO_RETRIEVAL_CHILD"; + if std::env::var_os(CHILD_MARKER).is_none() { + let output = std::process::Command::new( + std::env::current_exe().expect("test executable"), + ) + .args([ + "--exact", + "agent::packet_search::tests::packet_fused_batch_fails_closed_without_sidecar_primary", + "--nocapture", + ]) + .env(CHILD_MARKER, "1") + .env_remove("CODESTORY_RETRIEVAL") + .output() + .expect("run child without CODESTORY_RETRIEVAL"); + assert!( + output.status.success(), + "child stdout:\n{}\nchild stderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let controller = AppController::new_with_config( + crate::test_sidecar_runtime_with_cache_root(process_cache.path()), + ); let error = controller .search_packet_fused_batch(&[("run_exec_session".to_string(), 5)], None) diff --git a/crates/codestory-runtime/src/agent/retrieval_primary.rs b/crates/codestory-runtime/src/agent/retrieval_primary.rs index 202735866..3acc5d7ba 100644 --- a/crates/codestory-runtime/src/agent/retrieval_primary.rs +++ b/crates/codestory-runtime/src/agent/retrieval_primary.rs @@ -3861,7 +3861,8 @@ mod tests { #[test] fn detached_sidecar_query_cache_does_not_hold_mutex_during_work() { - let controller = AppController::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); let first = retrieval_cache_key_for_test("first"); let second = retrieval_cache_key_for_test("second"); controller.sidecar_query_cache.lock().insert( @@ -3903,7 +3904,8 @@ mod tests { #[test] fn detached_sidecar_query_cache_skips_merge_after_invalidation() { - let controller = AppController::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); let first = retrieval_cache_key_for_test("first"); let second = retrieval_cache_key_for_test("second"); controller.sidecar_query_cache.lock().insert( @@ -6305,6 +6307,7 @@ mod tests { #[test] fn packet_batch_marks_only_deadlines_retryable_and_rejects_cancellation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use codestory_retrieval::classify_query; let query_result = |cancel_reason: Option<&str>| QueryResult { @@ -6332,7 +6335,7 @@ mod tests { unindexed_lexical_artifacts_only: false, }) }; - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); let queries = vec![("handler".to_string(), 5)]; let empty = build_sidecar_packet_batch_outcome( @@ -7340,6 +7343,7 @@ mod tests { #[test] fn packet_sidecar_query_diagnostic_ignores_candidates_skipped_by_result_cap() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use codestory_retrieval::{CandidateSource, classify_query}; use codestory_store::{FileInfo, FileRole}; @@ -7388,7 +7392,7 @@ mod tests { .expect("insert nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path) .expect("open project"); @@ -7506,13 +7510,14 @@ mod tests { #[test] fn packet_batch_reports_unresolved_full_mode_candidates_without_rejecting() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use codestory_retrieval::CandidateSource; let temp = tempfile::tempdir().expect("tempdir"); let storage_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(storage_path.parent().expect("storage parent")) .expect("create storage parent"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path) .expect("open project"); @@ -7572,13 +7577,14 @@ mod tests { #[test] fn packet_batch_divides_request_budget_across_queries() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use codestory_retrieval::classify_query; let temp = tempfile::tempdir().expect("tempdir"); let storage_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(storage_path.parent().expect("storage parent")) .expect("create storage parent"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path) .expect("open project"); @@ -7627,11 +7633,12 @@ mod tests { #[test] fn packet_batch_rejects_candidate_resolution_errors() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use codestory_retrieval::CandidateSource; let temp = tempfile::tempdir().expect("tempdir"); let storage_path = temp.path().join("cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path.clone()) .expect("open project"); @@ -7683,11 +7690,12 @@ mod tests { #[test] fn sidecar_primary_search_reports_candidate_resolution_errors() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use codestory_retrieval::CandidateSource; let temp = tempfile::tempdir().expect("tempdir"); let storage_path = temp.path().join("cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path.clone()) .expect("open project"); @@ -7729,6 +7737,7 @@ mod tests { #[test] fn sidecar_primary_search_serves_cancelled_full_trace_with_resolved_hits() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use codestory_retrieval::CandidateSource; use codestory_store::{FileInfo, FileRole, Store}; @@ -7777,7 +7786,7 @@ mod tests { .expect("insert nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path) .expect("open project"); diff --git a/crates/codestory-runtime/src/agent/trace_export.rs b/crates/codestory-runtime/src/agent/trace_export.rs index 568c1f6f7..cc08c53eb 100644 --- a/crates/codestory-runtime/src/agent/trace_export.rs +++ b/crates/codestory-runtime/src/agent/trace_export.rs @@ -622,6 +622,10 @@ pub(crate) fn packet_retrieval_trace_summary( pub(crate) fn write_packet_step_trace_from_env(answer: &AgentAnswerDto) -> Option { let trace_path = std::env::var(codestory_contracts::config_registry::PACKET_STEP_TRACE_OUT_ENV).ok()?; + write_packet_step_trace(&trace_path, answer) +} + +fn write_packet_step_trace(trace_path: &str, answer: &AgentAnswerDto) -> Option { let trace = packet_step_trace_json(answer); let payload = match serde_json::to_string_pretty(&trace) { Ok(payload) => payload, @@ -1111,20 +1115,13 @@ mod tests { #[test] fn env_step_trace_write_error_is_reported() { - let _lock = crate::process_env_test_lock(); - let missing_parent = std::env::temp_dir().join(format!( - "codestory-missing-trace-parent-{}", - std::process::id() - )); - let trace_path = missing_parent.join("trace.json"); - // SAFETY: this test holds the process env lock and restores the variable below. - unsafe { - std::env::set_var("CODESTORY_PACKET_STEP_TRACE_OUT", &trace_path); - } + let trace_dir = tempfile::tempdir().expect("trace directory"); + let trace_path = trace_dir.path().join("missing-parent").join("trace.json"); let answer = sample_answer(Vec::new()); - let diagnostic = write_packet_step_trace_from_env(&answer) - .expect("missing parent should produce a write diagnostic"); + let diagnostic = + write_packet_step_trace(trace_path.to_str().expect("utf8 trace path"), &answer) + .expect("missing parent should produce a write diagnostic"); assert!( diagnostic.starts_with("packet_step_trace_out error=write "), "diagnostic should report the write error: {diagnostic}" @@ -1133,11 +1130,6 @@ mod tests { diagnostic.contains(trace_path.to_string_lossy().as_ref()), "diagnostic should include the configured trace path: {diagnostic}" ); - - // SAFETY: this test holds the process env lock. - unsafe { - std::env::remove_var("CODESTORY_PACKET_STEP_TRACE_OUT"); - } } #[test] diff --git a/crates/codestory-runtime/src/cache_rehydrate.rs b/crates/codestory-runtime/src/cache_rehydrate.rs index 7f9a2489a..1e770c29d 100644 --- a/crates/codestory-runtime/src/cache_rehydrate.rs +++ b/crates/codestory-runtime/src/cache_rehydrate.rs @@ -604,8 +604,20 @@ fn publish_rehydrated_database( .context("begin rehydrate publish transaction")? .commit_rehydrate(logical_target) .context("publish rehydrated generation and swap the publication pointer")?; + // GC must observe the cache that was just rehydrated, not the ambient + // process root: `target_cache_dir` may be a `--cache-dir` override. + let target_cache_root = logical_target + .parent() + .context("resolve rehydrate target cache root")?; let retrieval_runtime = - codestory_retrieval::SidecarRuntimeConfig::for_project_auto(target_project); + codestory_retrieval::SidecarRuntimeConfig::for_project_auto_with_process_defaults( + target_project, + &codestory_retrieval::SidecarProcessDefaults::new( + target_cache_root.to_path_buf(), + codestory_retrieval::SidecarRuntimeDefaults::from_process_env(), + ), + &codestory_retrieval::SidecarRuntimeOverrides::default(), + ); crate::activation_retrieval::apply_core_gc_after_publication( &retrieval_runtime, logical_target, @@ -963,6 +975,7 @@ mod tests { #[test] fn rehydrate_atomically_carries_only_core_inventory_and_policy_exclusions() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -980,12 +993,14 @@ mod tests { fs::write(source_sidecar_dir.join("vectors.bin"), b"source-only") .expect("seed source-only sidecar"); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("rehydrate"); @@ -1183,6 +1198,7 @@ mod tests { #[test] fn rehydrate_dry_run_does_not_create_target_cache_metadata() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1194,12 +1210,14 @@ mod tests { source_project.path(), ); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: true, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: true, + }) }) .expect("rehydrate dry run"); @@ -1220,6 +1238,7 @@ mod tests { #[test] fn rehydrate_skips_when_git_tree_differs() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1238,12 +1257,14 @@ mod tests { source_project.path(), ); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: false, + }) }) .expect("rehydrate"); @@ -1254,6 +1275,7 @@ mod tests { #[test] fn rehydrate_skips_when_target_worktree_is_dirty() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1267,12 +1289,14 @@ mod tests { source_project.path(), ); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: false, + }) }) .expect("rehydrate"); @@ -1287,6 +1311,7 @@ mod tests { #[test] fn rehydrate_skips_when_target_metadata_reports_issues() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1310,12 +1335,14 @@ mod tests { source_project.path(), ); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: false, + }) }) .expect("rehydrate"); @@ -1331,6 +1358,7 @@ mod tests { #[test] fn rehydrate_reuses_when_a_tracked_source_is_repo_ignored_but_restored() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1352,12 +1380,14 @@ mod tests { source_project.path(), ); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: true, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: true, + }) }) .expect("rehydrate"); @@ -1406,6 +1436,7 @@ mod tests { #[test] fn rehydrate_skips_when_source_cache_is_stale() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let scenarios = [ StaleSourceChange::Modify, StaleSourceChange::Add, @@ -1424,12 +1455,14 @@ mod tests { apply_stale_source_change(source_project.path(), scenario); apply_stale_source_change(target_project.path(), scenario); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: false, + }) }) .expect("rehydrate"); @@ -1447,6 +1480,7 @@ mod tests { #[test] fn rehydrate_skips_incomplete_source_cache() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1464,12 +1498,14 @@ mod tests { ) .expect("seed schema-compatible incomplete marker"); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("rehydrate"); @@ -1486,6 +1522,7 @@ mod tests { #[test] fn rehydrate_skips_while_source_index_writer_is_active() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let source_project = tempdir().expect("source project"); let target_project = tempdir().expect("target project"); let source_cache = tempdir().expect("source cache"); @@ -1494,12 +1531,14 @@ mod tests { drop(Store::open(&source_db).expect("seed source cache")); let _guard = crate::IndexWriterGuard::try_acquire(&source_db).expect("source writer lock"); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: false, + }) }) .expect("rehydrate"); @@ -1515,6 +1554,7 @@ mod tests { #[test] fn rehydrate_skips_while_target_index_writer_is_active() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let source_project = tempdir().expect("source project"); let target_project = tempdir().expect("target project"); let source_cache = tempdir().expect("source cache"); @@ -1524,12 +1564,14 @@ mod tests { drop(Store::open(&source_db).expect("seed source cache")); let _guard = crate::IndexWriterGuard::try_acquire(&target_db).expect("target writer lock"); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: false, + }) }) .expect("rehydrate"); @@ -1545,18 +1587,21 @@ mod tests { #[test] fn rehydrate_skips_when_target_cache_is_inside_source_cache() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempdir().expect("project"); let source_cache = tempdir().expect("source cache"); let target_cache_path = source_cache.path().join("nested-target"); fs::write(project.path().join("src.rs"), "pub fn run() {}\n").expect("write source"); seed_cache(&source_cache.path().join("codestory.db"), project.path()); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: project.path(), - source_cache_dir: source_cache.path(), - target_project: project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: project.path(), + source_cache_dir: source_cache.path(), + target_project: project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("rehydrate"); @@ -1573,6 +1618,7 @@ mod tests { #[test] fn compact_rehydrate_publishes_zero_freelist_database() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1585,12 +1631,14 @@ mod tests { let source_db = source_cache.path().join("codestory.db"); seed_cache(&source_db, source_project.path()); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("rehydrate"); @@ -1622,6 +1670,7 @@ mod tests { #[test] fn compact_rehydrate_reports_insufficient_space_before_stage_copy() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1635,12 +1684,14 @@ mod tests { seed_cache(&source_db, source_project.path()); let output = codestory_store::with_available_filesystem_bytes_override(0, || { - rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) }) .expect("rehydrate"); @@ -1677,6 +1728,7 @@ mod tests { #[test] fn absent_target_insufficient_space_does_not_create_lock_and_can_retry() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1688,12 +1740,14 @@ mod tests { assert!(!target_cache_path.exists(), "target starts absent"); let refused = codestory_store::with_available_filesystem_bytes_override(0, || { - rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) }) .expect("insufficient-space result"); @@ -1705,12 +1759,14 @@ mod tests { "preflight refusal must leave the target directory and writer lock absent" ); - let retried = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let retried = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("retry after capacity returns"); assert_eq!(retried.status, "rehydrated"); @@ -1719,6 +1775,7 @@ mod tests { #[test] fn absent_target_capacity_drop_under_lock_refuses_before_stage() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1730,12 +1787,14 @@ mod tests { let source_hash = Sha256::digest(fs::read(&source_db).expect("read source before attempt")); AFTER_TARGET_WRITER_GUARD_AVAILABLE_BYTES.with(|bytes| bytes.set(Some(0))); - let refused = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let refused = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("capacity drop result"); assert_eq!(refused.status, "insufficient_space"); @@ -1755,12 +1814,14 @@ mod tests { "the failed target attempt must not alter its source" ); - let retried = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let retried = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("retry after capacity returns"); assert_eq!(retried.status, "rehydrated"); @@ -1768,6 +1829,7 @@ mod tests { #[test] fn rehydrate_refuses_nonempty_target_without_replacing_its_file() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1780,12 +1842,14 @@ mod tests { source_project.path(), ); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: target_cache.path(), - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: target_cache.path(), + dry_run: false, + }) }) .expect("nonempty target refusal"); assert_eq!(output.status, "skipped"); @@ -1802,6 +1866,7 @@ mod tests { #[test] fn rehydrate_pins_the_source_generation_after_writer_guard() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -1919,12 +1984,14 @@ mod tests { })); }); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("rehydrate after source publication swap"); assert_eq!(output.status, "rehydrated"); @@ -1988,6 +2055,7 @@ mod tests { /// and copy must measure the generation `CorePublicationLayout` selects. #[test] fn rehydrate_copies_the_published_generation_not_a_leftover_legacy_file() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let Some((source_project, target_project)) = matching_git_projects() else { return; }; @@ -2007,12 +2075,14 @@ mod tests { .expect("publish the seeded image as a generation"); fs::write(&logical_source, b"stale-leftover").expect("leave a wrong leftover file"); - let output = rehydrate_cache(CacheRehydrateRequest { - source_project: source_project.path(), - source_cache_dir: source_cache.path(), - target_project: target_project.path(), - target_cache_dir: &target_cache_path, - dry_run: false, + let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { + rehydrate_cache(CacheRehydrateRequest { + source_project: source_project.path(), + source_cache_dir: source_cache.path(), + target_project: target_project.path(), + target_cache_dir: &target_cache_path, + dry_run: false, + }) }) .expect("rehydrate"); diff --git a/crates/codestory-runtime/src/controller_core.rs b/crates/codestory-runtime/src/controller_core.rs index 961ad1b22..55a9f4870 100644 --- a/crates/codestory-runtime/src/controller_core.rs +++ b/crates/codestory-runtime/src/controller_core.rs @@ -45,6 +45,14 @@ impl AppController { )) } + /// Controller whose process-owned defaults carry `cache_root` instead of + /// the ambient process cache. The caller owns the directory and must keep + /// it alive until every worker the controller spawned has quiesced. + #[cfg(test)] + pub(crate) fn new_with_owned_cache_root(cache_root: &Path) -> Self { + Self::new_with_config(crate::test_sidecar_runtime_with_cache_root(cache_root)) + } + pub(crate) fn new_with_process_config(config: RuntimeProcessConfig) -> Self { Self::new_with_source_index_policy(config.sidecar.into_inner(), config.source_index_policy) } diff --git a/crates/codestory-runtime/src/evidence_projection_v3.rs b/crates/codestory-runtime/src/evidence_projection_v3.rs index 900bf783f..7e81e48f8 100644 --- a/crates/codestory-runtime/src/evidence_projection_v3.rs +++ b/crates/codestory-runtime/src/evidence_projection_v3.rs @@ -1059,17 +1059,19 @@ mod tests { } fn indexed_search_fixture() -> ( + tempfile::TempDir, tempfile::TempDir, tempfile::TempDir, crate::AppController, crate::services::PublicOperationService, ) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let state = tempfile::tempdir().expect("isolated cache"); let source = project.path().join("src/layout.css"); fs::create_dir_all(source.parent().expect("source parent")).expect("source directory"); fs::write(&source, "/* REPO_TEXT_TOKEN */\nbody { color: red; }\n").expect("source"); - let controller = crate::AppController::new(); + let controller = crate::AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( project.path().to_path_buf(), @@ -1080,7 +1082,7 @@ mod tests { .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) .expect("complete core index"); let service = crate::services::PublicOperationService::new(controller.clone()); - (project, state, controller, service) + (project, state, process_cache, controller, service) } fn core_search_request(query: &str) -> SearchRequest { @@ -1096,7 +1098,7 @@ mod tests { #[test] fn search_projection_keeps_real_repo_text_only_match() { - let (project, _state, controller, service) = indexed_search_fixture(); + let (project, _state, _process_cache, controller, service) = indexed_search_fixture(); let projected = service .run_observational_with_cancel("search", Arc::new(AtomicBool::new(false)), || { let mut results = @@ -1132,7 +1134,7 @@ mod tests { #[test] fn search_projection_keeps_parentless_core_file_match() { - let (_project, _state, controller, service) = indexed_search_fixture(); + let (_project, _state, _process_cache, controller, service) = indexed_search_fixture(); let projected = service .run_observational_with_cancel("search", Arc::new(AtomicBool::new(false)), || { let results = controller.search_results(core_search_request("layout.css"))?; @@ -1167,7 +1169,7 @@ mod tests { #[test] fn search_projection_deduplicates_mixed_hits_and_accounts_for_missing_or_bounded_rows() { - let (_project, _state, controller, service) = indexed_search_fixture(); + let (_project, _state, _process_cache, controller, service) = indexed_search_fixture(); let results = service .run_observational_with_cancel("search", Arc::new(AtomicBool::new(false)), || { controller.search_results(core_search_request("layout.css")) @@ -1241,7 +1243,7 @@ mod tests { #[test] fn file_search_path_comes_from_pinned_file_identity() { - let (_project, state, controller, _service) = indexed_search_fixture(); + let (_project, state, _process_cache, controller, _service) = indexed_search_fixture(); let file_hit = controller .search_results(core_search_request("layout.css")) .expect("core search") @@ -2485,11 +2487,12 @@ mod tests { #[test] fn context_projection_keeps_the_resolved_target_with_or_without_citations() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let source = project.path().join("resolved.rs"); fs::write(&source, "pub fn resolved_target() {}\n").expect("source"); let storage = project.path().join("codestory.db"); - let controller = crate::AppController::new(); + let controller = crate::AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(project.path().to_path_buf(), storage) .expect("open project"); @@ -2569,11 +2572,12 @@ mod tests { #[test] fn context_projection_preserves_path_only_unresolved_uncertainty() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let source = project.path().join("unresolved.rs"); fs::write(&source, "pub fn unresolved_target() {}\n").expect("source"); let storage = project.path().join("codestory.db"); - let controller = crate::AppController::new(); + let controller = crate::AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(project.path().to_path_buf(), storage) .expect("open project"); diff --git a/crates/codestory-runtime/src/grounding.rs b/crates/codestory-runtime/src/grounding.rs index f2f44f710..cf903e1b3 100644 --- a/crates/codestory-runtime/src/grounding.rs +++ b/crates/codestory-runtime/src/grounding.rs @@ -2552,6 +2552,7 @@ mod tests { #[test] fn large_member_rich_mixed_project_diversifies_entrypoints_into_architecture_breadth() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let project_root = temp.path().join("target/acceptance/project"); let db_path = temp.path().join("cache/codestory.db"); @@ -2855,7 +2856,7 @@ mod tests { .expect("refresh detail snapshot"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(project_root, db_path) .expect("open project"); @@ -3107,6 +3108,7 @@ mod tests { #[test] fn grounding_snapshot_represents_all_files() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3148,7 +3150,7 @@ mod tests { .expect("insert second"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3165,6 +3167,7 @@ mod tests { #[test] fn grounding_snapshot_publishes_structural_text_metadata_without_graph_claims() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3193,7 +3196,7 @@ mod tests { .expect("insert verified manifest projection"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3223,6 +3226,7 @@ mod tests { #[test] fn grounding_snapshot_preserves_openapi_endpoint_source_identity() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3248,7 +3252,7 @@ mod tests { .expect("insert OpenAPI endpoint node"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3278,6 +3282,7 @@ mod tests { #[test] fn function_body_snippet_uses_symbol_range_when_available() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3308,7 +3313,7 @@ mod tests { .expect("insert function"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3329,6 +3334,7 @@ mod tests { #[test] fn function_body_snippet_keeps_single_line_rust_body() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3358,7 +3364,7 @@ mod tests { .expect("insert function"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3381,6 +3387,7 @@ mod tests { #[test] fn function_body_snippet_uses_brace_balanced_fallback_when_range_is_missing() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3418,7 +3425,7 @@ mod tests { .expect("insert function"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3447,6 +3454,7 @@ mod tests { #[test] fn function_body_snippet_reports_line_context_fallback_when_body_is_unavailable() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3477,7 +3485,7 @@ mod tests { .expect("insert function"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3508,6 +3516,7 @@ mod tests { start_line: u32, end_line: Option, ) -> (tempfile::TempDir, AppController, PathBuf) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3532,7 +3541,7 @@ mod tests { ) .expect("insert function"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3622,6 +3631,7 @@ mod tests { /// must not deny a byte-identical target. #[test] fn snippet_context_succeeds_when_only_an_unrelated_file_changes() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3668,7 +3678,7 @@ mod tests { ) .expect("insert other"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3801,6 +3811,7 @@ mod tests { #[test] fn declaration_only_range_is_not_reported_as_a_function_body() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3831,7 +3842,7 @@ mod tests { .expect("insert function"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3856,6 +3867,7 @@ mod tests { #[test] fn grounding_snapshot_caps_detailed_files_and_adds_coverage_buckets() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3883,7 +3895,7 @@ mod tests { } } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3907,6 +3919,7 @@ mod tests { #[test] fn grounding_snapshot_deprioritizes_import_like_root_symbols() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -3956,7 +3969,7 @@ mod tests { .expect("insert nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -3975,6 +3988,7 @@ mod tests { #[test] fn grounding_snapshot_prefers_production_and_diversifies_fixture_roots() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4097,7 +4111,7 @@ mod tests { .expect("refresh detail snapshot"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -4204,6 +4218,7 @@ mod tests { #[test] fn grounding_snapshot_ranks_cross_language_architecture_and_reports_orientation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4370,7 +4385,7 @@ mod tests { .expect("refresh detail snapshot"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -4444,6 +4459,7 @@ mod tests { #[test] fn grounding_snapshot_reports_weak_orientation_without_entrypoint_evidence() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4469,7 +4485,7 @@ mod tests { } } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -4493,6 +4509,7 @@ mod tests { #[test] fn grounding_snapshot_keeps_diversified_fixture_fallback_without_production_roots() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4530,7 +4547,7 @@ mod tests { } } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -4565,6 +4582,7 @@ mod tests { #[test] fn grounding_snapshot_represented_symbols_is_monotonic_across_budgets() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4655,7 +4673,7 @@ mod tests { .expect("refresh detail snapshot"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -4694,6 +4712,7 @@ mod tests { #[test] fn grounding_snapshot_batches_member_counts_line_fallbacks_and_edge_digests() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4771,7 +4790,7 @@ mod tests { .expect("insert occurrences"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -4792,6 +4811,7 @@ mod tests { #[test] fn grounding_snapshot_uses_materialized_snapshot_after_summary_open() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4859,7 +4879,7 @@ mod tests { ); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project summary"); @@ -4881,6 +4901,7 @@ mod tests { #[test] fn balanced_grounding_falls_back_to_live_detail_queries_when_detail_tier_is_dirty() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -4976,7 +4997,7 @@ mod tests { ); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(temp.path().to_path_buf(), db_path.clone()) .expect("open project summary"); @@ -5005,6 +5026,7 @@ mod tests { #[test] fn max_grounding_does_not_mutate_an_incomplete_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let db_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(db_path.parent().expect("db parent")).expect("create db parent"); @@ -5073,7 +5095,7 @@ mod tests { .expect("refresh summary snapshots"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(temp.path().to_path_buf(), db_path.clone()) .expect("open project summary"); diff --git a/crates/codestory-runtime/src/lib.rs b/crates/codestory-runtime/src/lib.rs index 7100a2225..8f7b29674 100644 --- a/crates/codestory-runtime/src/lib.rs +++ b/crates/codestory-runtime/src/lib.rs @@ -476,26 +476,34 @@ pub(crate) fn test_sidecar_runtime_from_env() -> codestory_retrieval::SidecarRun ) } -/// Runtime whose process-owned defaults carry `cache_root` instead of the -/// ambient process cache. The caller owns the directory and must keep it -/// alive until every activation worker the runtime spawned has quiesced. +/// Runtime config whose process-owned defaults carry `cache_root` instead of +/// the ambient process cache. The caller owns the directory and must keep it +/// alive until every worker a runtime built from it has quiesced. #[cfg(test)] -pub(crate) fn test_runtime_with_owned_cache_root(cache_root: &std::path::Path) -> Runtime { +pub(crate) fn test_sidecar_runtime_with_cache_root( + cache_root: &std::path::Path, +) -> codestory_retrieval::SidecarRuntimeConfig { let process_defaults = codestory_retrieval::SidecarProcessDefaults::new( cache_root.to_path_buf(), codestory_retrieval::SidecarRuntimeDefaults::from_process_env(), ); - Runtime::new_with_config( - codestory_retrieval::SidecarRuntimeConfig::for_project_profile_with_process_defaults( - None, - codestory_retrieval::SidecarProfile::Local, - None, - &process_defaults, - &codestory_retrieval::SidecarRuntimeOverrides::default(), - ), + codestory_retrieval::SidecarRuntimeConfig::for_project_profile_with_process_defaults( + None, + codestory_retrieval::SidecarProfile::Local, + None, + &process_defaults, + &codestory_retrieval::SidecarRuntimeOverrides::default(), ) } +/// Runtime whose process-owned defaults carry `cache_root` instead of the +/// ambient process cache. The caller owns the directory and must keep it +/// alive until every activation worker the runtime spawned has quiesced. +#[cfg(test)] +pub(crate) fn test_runtime_with_owned_cache_root(cache_root: &std::path::Path) -> Runtime { + Runtime::new_with_config(test_sidecar_runtime_with_cache_root(cache_root)) +} + impl Runtime { /// The cache root captured in this runtime's immutable process defaults. /// Tests assert it equals their owned directory so a regression back to diff --git a/crates/codestory-runtime/src/search/engine.rs b/crates/codestory-runtime/src/search/engine.rs index 4810bcd55..5886f0283 100644 --- a/crates/codestory-runtime/src/search/engine.rs +++ b/crates/codestory-runtime/src/search/engine.rs @@ -1896,9 +1896,33 @@ mod tests { } #[test] + /// `SearchEngine::new` reads `SYMBOL_FULL_TEXT_INDEX` during construction, + /// so the flag must be true for the whole process: the parent re-executes + /// this test in a child with the variable set, leaving the parent's + /// environment untouched. fn symbol_full_text_index_can_be_disabled_for_projection_only_search() -> Result<()> { - let _lock = crate::process_env_test_lock(); - let _guard = EnvGuard::set(SYMBOL_FULL_TEXT_INDEX_ENV, "false"); + const CHILD_MARKER: &str = "CODESTORY_SEARCH_NO_FULL_TEXT_CHILD"; + if std::env::var_os(CHILD_MARKER).is_none() { + let output = std::process::Command::new( + std::env::current_exe().expect("test executable"), + ) + .args([ + "--exact", + "search::engine::tests::symbol_full_text_index_can_be_disabled_for_projection_only_search", + "--nocapture", + ]) + .env(CHILD_MARKER, "1") + .env(SYMBOL_FULL_TEXT_INDEX_ENV, "false") + .output() + .expect("run child with full-text indexing disabled"); + assert!( + output.status.success(), + "child stdout:\n{}\nchild stderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return Ok(()); + } let mut engine = SearchEngine::new(None)?; engine.index_nodes(vec![ diff --git a/crates/codestory-runtime/src/services.rs b/crates/codestory-runtime/src/services.rs index 51c384cba..69c193889 100644 --- a/crates/codestory-runtime/src/services.rs +++ b/crates/codestory-runtime/src/services.rs @@ -4416,6 +4416,7 @@ pub(crate) mod activation_tests { fn complete_core_without_retrieval_pointer_fixture() -> (tempfile::TempDir, tempfile::TempDir, PathBuf, PathBuf) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let cache = tempfile::tempdir().expect("cache"); let storage_path = cache.path().join("codestory.db"); @@ -4425,7 +4426,7 @@ pub(crate) mod activation_tests { ) .expect("write retained-core fixture"); - let seeding_runtime = Runtime::new(); + let seeding_runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); seeding_runtime .project_service() .open_project_summary_with_storage_path( @@ -6840,10 +6841,12 @@ pub(crate) mod activation_tests { #[test] fn activation_target_reobserves_same_root_remote_change_and_no_remote_reinit() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); initialize_identifiable_git_project(project.path()); let storage = project.path().join("cache").join("codestory.db"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let remote_a = ActivationTarget::new(project.path(), &storage); let snapshot = ActivationSnapshot { operation_id: "activation-logical-target-fixture".into(), @@ -6969,9 +6972,10 @@ pub(crate) mod activation_tests { #[test] fn pre_cancelled_activation_does_not_start_shared_work() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cache").join("codestory.db"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let cancelled = Arc::new(AtomicBool::new(true)); let error = runtime @@ -6986,6 +6990,7 @@ pub(crate) mod activation_tests { #[test] fn foreground_budget_returns_progress_while_one_shared_activation_continues() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cache").join("codestory.db"); fs::write( @@ -6993,7 +6998,8 @@ pub(crate) mod activation_tests { "pub fn foreground_activation_fixture() {}\n", ) .expect("write fixture"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let worker_gate = Arc::new((Mutex::new(false), Condvar::new())); service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); @@ -7053,6 +7059,7 @@ pub(crate) mod activation_tests { #[test] fn a_full_request_waits_for_an_in_flight_core_only_run_then_pursues_full() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cache").join("codestory.db"); fs::write( @@ -7060,7 +7067,8 @@ pub(crate) mod activation_tests { "pub fn core_only_upgrade_fixture() {}\n", ) .expect("write fixture"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let worker_gate = Arc::new((Mutex::new(false), Condvar::new())); service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); @@ -7151,7 +7159,9 @@ pub(crate) mod activation_tests { #[test] fn bounded_cancel_returns_on_a_running_activation_it_cannot_stop() { - let service = Runtime::new().activation_service(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let cancelled = Arc::new(AtomicBool::new(false)); { let mut state = service @@ -7204,7 +7214,9 @@ pub(crate) mod activation_tests { #[test] fn bounded_cancel_reads_through_a_poisoned_coordinator() { - let service = Runtime::new().activation_service(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let cancelled = Arc::new(AtomicBool::new(false)); { let mut state = service @@ -7237,10 +7249,12 @@ pub(crate) mod activation_tests { #[test] fn serial_retries_keep_one_activation_identity_after_terminal_failure() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let missing = project.path().join("missing"); let storage_path = project.path().join("cache").join("codestory.db"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let first = service .activate_project(&missing, &storage_path, Arc::new(AtomicBool::new(false))) @@ -7266,10 +7280,12 @@ pub(crate) mod activation_tests { #[test] fn disk_space_refusal_keeps_typed_snapshot_without_starting_a_hot_retry() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let missing_project = project.path().join("missing"); let storage_path = project.path().join("cache").join("codestory.db"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); service.set_terminal_disk_space_for_test(&missing_project, &storage_path, 80_000_000, 0); let before = service.snapshot().expect("terminal disk snapshot"); for _ in 0..2 { @@ -7315,6 +7331,7 @@ pub(crate) mod activation_tests { #[test] fn cancelling_a_waiter_does_not_cancel_or_replace_shared_activation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cache").join("codestory.db"); fs::write( @@ -7322,7 +7339,8 @@ pub(crate) mod activation_tests { "pub fn shared_activation_fixture() {}\n", ) .expect("write fixture"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let worker_gate = Arc::new((Mutex::new(false), Condvar::new())); service.set_worker_start_gate_for_test(Some(Arc::clone(&worker_gate))); @@ -7499,10 +7517,12 @@ pub(crate) mod activation_tests { #[test] fn panicking_activation_worker_finishes_waiters_and_allows_retry() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let missing = project.path().join("missing"); let storage_path = project.path().join("cache").join("codestory.db"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let target = ActivationTarget::new(&missing, &storage_path); let operation_id = "activation-panic-fixture".to_string(); let cancelled = Arc::new(AtomicBool::new(false)); @@ -7726,10 +7746,11 @@ pub(crate) mod activation_tests { #[test] fn activation_error_is_unavailable_instead_of_ready() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let missing = project.path().join("missing"); let storage_path = project.path().join("cache").join("codestory.db"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let error = runtime .activation_service() @@ -8002,9 +8023,11 @@ pub(crate) mod activation_tests { #[test] fn activation_state_is_not_reused_across_project_targets() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project_a = tempfile::tempdir().expect("project a"); let project_b = tempfile::tempdir().expect("project b"); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let hold_until_started = |service: &ActivationService| { let deadline = Instant::now() + Duration::from_secs(10); while service.worker_start_count_for_test() == 0 && Instant::now() < deadline { @@ -8154,9 +8177,10 @@ pub(crate) mod activation_tests { #[test] fn observational_summary_does_not_create_storage_parent() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cold-cache").join("codestory.db"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let summary = runtime .project_service() @@ -8172,7 +8196,8 @@ pub(crate) mod activation_tests { #[test] fn cancelled_public_operation_never_enters_response_builder() { - let runtime = Runtime::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let cancelled = Arc::new(AtomicBool::new(true)); let mut entered = false; @@ -8353,11 +8378,12 @@ pub(crate) mod activation_tests { #[test] fn observational_admission_propagates_corrupt_storage_instead_of_treating_it_as_cold() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cache").join("codestory.db"); fs::create_dir_all(storage_path.parent().expect("cache parent")).expect("create cache"); fs::write(&storage_path, b"not a sqlite database").expect("write corrupt storage"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let error = runtime .activation_service() @@ -8378,9 +8404,10 @@ pub(crate) mod activation_tests { #[test] fn pre_cancelled_observational_admission_does_not_create_cold_storage() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let storage_path = project.path().join("cache").join("codestory.db"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let error = runtime .activation_service() diff --git a/crates/codestory-runtime/src/services/activation_upgrade_tests.rs b/crates/codestory-runtime/src/services/activation_upgrade_tests.rs index 5720e60fe..c3ce0b9f3 100644 --- a/crates/codestory-runtime/src/services/activation_upgrade_tests.rs +++ b/crates/codestory-runtime/src/services/activation_upgrade_tests.rs @@ -1,5 +1,4 @@ use super::*; -use crate::Runtime; use flate2::{Decompress, FlushDecompress, Status}; use std::collections::BTreeSet; use std::fs; @@ -52,6 +51,7 @@ fn decode_seed_parser_artifact(blob: &[u8]) -> serde_json::Value { } fn legacy_activation_fixture() -> (tempfile::TempDir, tempfile::TempDir, PathBuf) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let seed_cache = tempfile::tempdir().expect("seed cache"); let cache = tempfile::tempdir().expect("legacy cache"); @@ -61,7 +61,7 @@ fn legacy_activation_fixture() -> (tempfile::TempDir, tempfile::TempDir, PathBuf ) .expect("write source"); let seed_path = seed_cache.path().join("codestory.db"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .project_service() .open_project_summary_with_storage_path(project.path().to_path_buf(), seed_path.clone()) @@ -137,6 +137,7 @@ fn legacy_activation_fixture() -> (tempfile::TempDir, tempfile::TempDir, PathBuf #[test] fn activation_rebuilds_legacy_cache_before_opening_it() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let (project, _cache, storage_path) = legacy_activation_fixture(); let before = fs::read(&storage_path).unwrap(); let annotations_path = @@ -153,7 +154,7 @@ fn activation_rebuilds_legacy_cache_before_opening_it() { drop(annotations); let annotation_identity = codestory_workspace::workspace_path_identity_token(&annotations_path) .expect("observe annotation native identity"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .activation_service() .activate_core_only( @@ -212,7 +213,7 @@ fn activation_rebuilds_legacy_cache_before_opening_it() { .validate_structural_text_unit_publication(&publication) .unwrap(); drop(store); - Runtime::new() + crate::test_runtime_with_owned_cache_root(process_cache.path()) .activation_service() .activate_core_only( project.path(), @@ -229,6 +230,7 @@ fn activation_rebuilds_legacy_cache_before_opening_it() { #[cfg(windows)] #[test] fn legacy_retirement_retries_after_a_real_windows_sharing_violation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use std::os::windows::fs::OpenOptionsExt as _; // Win32 FILE_SHARE_READ | FILE_SHARE_WRITE deliberately excludes delete sharing. const SHARE_WITHOUT_DELETE: u32 = 0x0000_0001 | 0x0000_0002; @@ -239,7 +241,7 @@ fn legacy_retirement_retries_after_a_real_windows_sharing_violation() { .share_mode(SHARE_WITHOUT_DELETE) .open(&storage_path) .expect("hold legacy database without delete sharing"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); let activation = runtime.activation_service(); activation .activate_core_only( @@ -272,6 +274,7 @@ fn legacy_retirement_retries_after_a_real_windows_sharing_violation() { #[test] fn activation_rebuilds_release_java_visibility_projection() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let seed_cache = tempfile::tempdir().expect("seed cache"); let cache = tempfile::tempdir().expect("legacy cache"); @@ -282,7 +285,7 @@ fn activation_rebuilds_release_java_visibility_projection() { .expect("write Java source"); let seed_path = seed_cache.path().join("codestory.db"); - let runtime = Runtime::new(); + let runtime = crate::test_runtime_with_owned_cache_root(process_cache.path()); runtime .project_service() .open_project_summary_with_storage_path(project.path().to_path_buf(), seed_path.clone()) @@ -327,7 +330,7 @@ fn activation_rebuilds_release_java_visibility_projection() { assert_eq!(predecessor_access_count, 0); drop(connection); - Runtime::new() + crate::test_runtime_with_owned_cache_root(process_cache.path()) .activation_service() .activate_core_only( project.path(), @@ -393,6 +396,7 @@ fn activation_rebuilds_release_java_visibility_projection() { #[test] fn activation_publishes_complete_terraform_structural_artifacts() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempfile::tempdir().expect("project"); let cache = tempfile::tempdir().expect("cache"); let source = concat!( @@ -404,7 +408,7 @@ fn activation_publishes_complete_terraform_structural_artifacts() { fs::write(project.path().join("main.tf"), source).expect("write Terraform source"); let storage_path = cache.path().join("codestory.db"); - Runtime::new() + crate::test_runtime_with_owned_cache_root(process_cache.path()) .activation_service() .activate_core_only( project.path(), @@ -477,13 +481,15 @@ fn activation_publishes_complete_terraform_structural_artifacts() { #[test] fn activation_failed_legacy_rebuild_preserves_original_database() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); for action in [ crate::PublicationTestAction::Fail, crate::PublicationTestAction::Cancel, ] { let (project, _cache, storage_path) = legacy_activation_fixture(); let before = fs::read(&storage_path).unwrap(); - let service = Runtime::new().activation_service(); + let service = + crate::test_runtime_with_owned_cache_root(process_cache.path()).activation_service(); let operation = ActivationOperation { service: service.clone(), operation_id: "legacy-rebuild-fault".to_string(), diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index 5b41307f3..3ea93bc39 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -2500,6 +2500,7 @@ fn aggregate_symbol_matches_prioritizes_direct_matches() { #[test] fn indexed_files_reports_incomplete_reason_counts() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let storage_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(storage_path.parent().expect("db parent")).expect("create db dir"); @@ -2559,7 +2560,7 @@ fn indexed_files_reports_incomplete_reason_counts() { .expect("publish complete core identity"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(temp.path().to_path_buf(), storage_path) .expect("open project"); @@ -4578,6 +4579,7 @@ fn special_collector_growth_after_planning_cannot_publish() { #[test] fn partial_discovery_keeps_oversized_candidates_blocking_and_publishes_nothing() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::create_dir_all(workspace.path().join("src")).expect("source directory"); fs::write( @@ -4591,7 +4593,7 @@ fn partial_discovery_keeps_oversized_candidates_blocking_and_publishes_nothing() ) .expect("partial workspace manifest"); let storage_path = workspace.path().join(".cache/codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -8264,9 +8266,10 @@ fn full_index_rebuilds_semantic_docs_when_source_text_changes() { #[test] fn finalize_indexing_without_runtime_refresh_propagates_rebuild_failure() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = copy_tictactoe_workspace(); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( @@ -8329,10 +8332,11 @@ fn persisted_empty_indexing_run_summary(storage_path: &Path) -> IndexingRunSumma #[test] fn successful_index_refresh_clears_indexing_state() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let storage_path = temp.path().join("codestory.db"); drop(Storage::open(&storage_path).expect("seed storage")); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); { let mut state = controller.state.lock(); @@ -8350,6 +8354,7 @@ fn successful_index_refresh_clears_indexing_state() { #[test] fn async_incremental_finishes_cache_boundary_before_clearing_marker() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("lib.rs"), @@ -8357,7 +8362,7 @@ fn async_incremental_finishes_cache_boundary_before_clearing_marker() { ) .expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -8410,9 +8415,10 @@ fn async_incremental_finishes_cache_boundary_before_clearing_marker() { #[test] fn empty_full_refresh_reports_adaptive_chunk_config() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path) .expect("open project"); @@ -8436,6 +8442,7 @@ fn empty_full_refresh_reports_adaptive_chunk_config() { #[test] fn full_and_incremental_publications_advance_one_durable_generation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let assert_promotion_reconciles = |promotion: &CorePromotionTimings| { let named_ms = promotion .lock_wait_ms @@ -8465,7 +8472,7 @@ fn full_and_incremental_publications_advance_one_durable_generation() { ) .expect("write initial source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -8779,13 +8786,14 @@ fn full_and_incremental_publications_advance_one_durable_generation() { #[test] fn structural_full_generations_reuse_unchanged_cache_and_preserve_previous_on_invalid_input() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let markdown_path = workspace.path().join("guide.md"); let json_path = workspace.path().join("config.json"); fs::write(&markdown_path, "# Stable\n").expect("write markdown"); fs::write(&json_path, "{\"service\":{\"name\":\"api\"}}\n").expect("write JSON"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9014,6 +9022,7 @@ fn structural_full_generations_reuse_unchanged_cache_and_preserve_previous_on_in #[test] fn full_refresh_retains_malformed_source_separately_from_controlled_exclusions() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let fixture = workspace .path() @@ -9022,7 +9031,7 @@ fn full_refresh_retains_malformed_source_separately_from_controlled_exclusions() fs::write(workspace.path().join("lib.rs"), "pub fn ready() {}\n").expect("write parser source"); fs::write(&fixture, "jobs: [\n").expect("write controlled invalid fixture"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9059,6 +9068,7 @@ fn full_refresh_retains_malformed_source_separately_from_controlled_exclusions() #[test] fn structural_publication_survives_unreadable_and_partial_discovery_failures() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); for scenario in ["unreadable", "partial-discovery"] { let workspace = tempdir().expect("workspace dir"); let source_root = workspace.path().join("src"); @@ -9069,7 +9079,7 @@ fn structural_publication_survives_unreadable_and_partial_discovery_failures() { fs::write(&manifest_path, r#"{"members":["src"]}"#) .expect("write complete workspace manifest"); let storage_path = workspace.path().join(".cache/codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9123,11 +9133,12 @@ fn structural_publication_survives_unreadable_and_partial_discovery_failures() { #[test] fn staged_structural_cache_write_failure_preserves_nonempty_live_generation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let css_path = workspace.path().join("styles.css"); fs::write(&css_path, ".stable { color: green; }\n").expect("write structural source"); let storage_path = workspace.path().join(".cache/codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9174,6 +9185,7 @@ fn staged_structural_cache_write_failure_preserves_nonempty_live_generation() { #[test] fn incremental_cache_read_faults_recollect_in_staged_candidate_and_preserve_live_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); for (family, cache_table) in [ ("parser", "index_artifact_cache"), ("structural", "structural_text_artifact_cache"), @@ -9186,7 +9198,7 @@ fn incremental_cache_read_faults_recollect_in_staged_candidate_and_preserve_live fs::write(&json_path, "{\"service\":{\"name\":\"api\"}}\n") .expect("write structural source"); let storage_path = workspace.path().join(".cache/codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9318,6 +9330,7 @@ fn incremental_cache_read_faults_recollect_in_staged_candidate_and_preserve_live #[test] fn structural_publication_survives_cancellation_and_promotion_rollback_boundaries() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); for (boundary, action, mode) in [ ( PublicationTestBoundary::SearchBuild, @@ -9341,7 +9354,7 @@ fn structural_publication_survives_cancellation_and_promotion_rollback_boundarie fs::write(&css_path, ".stable { color: green; }\n").expect("write structural source"); fs::write(&rust_path, "pub fn baseline() -> i32 { 1 }\n").expect("write parser source"); let storage_path = workspace.path().join(".cache/codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9389,6 +9402,7 @@ fn structural_publication_survives_cancellation_and_promotion_rollback_boundarie #[test] fn explicit_incremental_rejects_incompatible_structural_publication_before_source_reads() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("Cargo.toml"), @@ -9396,7 +9410,7 @@ fn explicit_incremental_rejects_incompatible_structural_publication_before_sourc ) .expect("write manifest"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9512,6 +9526,7 @@ fn explicit_incremental_rejects_incompatible_structural_publication_before_sourc #[test] fn precurrent_schema_requires_typed_full_without_mutating_database_or_sidecars() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("lib.rs"), @@ -9519,7 +9534,7 @@ fn precurrent_schema_requires_typed_full_without_mutating_database_or_sidecars() ) .expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9672,6 +9687,7 @@ fn full_refresh_required_command_quotes_shell_metacharacters() { #[test] fn full_refresh_pipeline_writer_failure_preserves_live_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("lib.rs"), @@ -9679,7 +9695,7 @@ fn full_refresh_pipeline_writer_failure_preserves_live_publication() { ) .expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9734,10 +9750,11 @@ fn full_refresh_pipeline_writer_failure_preserves_live_publication() { #[test] fn full_refresh_rejects_a_nonempty_proof_overlay_before_graph_mutation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write(workspace.path().join("lib.rs"), "pub fn value() {}\n").expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9781,12 +9798,13 @@ fn full_refresh_rejects_a_nonempty_proof_overlay_before_graph_mutation() { #[test] fn full_refresh_semantic_endpoint_index_failure_preserves_live_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let source_path = workspace.path().join("lib.rs"); fs::write(&source_path, "pub fn retained_generation() -> i32 { 1 }\n") .expect("write baseline source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9833,12 +9851,13 @@ fn full_refresh_semantic_endpoint_index_failure_preserves_live_publication() { #[test] fn full_refresh_post_summary_index_failure_preserves_live_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let source_path = workspace.path().join("lib.rs"); fs::write(&source_path, "pub fn retained_generation() -> i32 { 1 }\n") .expect("write baseline source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -9888,6 +9907,7 @@ fn full_refresh_post_summary_index_failure_preserves_live_publication() { #[test] fn incremental_publication_ignores_changed_files_without_graph_collectors() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("lib.rs"), @@ -9903,7 +9923,7 @@ fn incremental_publication_ignores_changed_files_without_graph_collectors() { let collectorless = workspace.path().join("styles.scss"); fs::write(&collectorless, ".initial { color: red; }\n").expect("write collectorless file"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -10003,10 +10023,11 @@ fn incremental_publication_ignores_changed_files_without_graph_collectors() { #[test] fn incomplete_legacy_run_is_not_a_servable_complete_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write(workspace.path().join("lib.rs"), "pub fn value() {}\n").expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -10039,6 +10060,7 @@ fn incomplete_legacy_run_is_not_a_servable_complete_publication() { #[test] fn legacy_schema_18_incomplete_marker_requires_explicit_full_recovery() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("lib.rs"), @@ -10046,7 +10068,7 @@ fn legacy_schema_18_incomplete_marker_requires_explicit_full_recovery() { ) .expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -10108,9 +10130,10 @@ fn legacy_schema_18_incomplete_marker_requires_explicit_full_recovery() { #[test] fn successful_index_reopen_failure_does_not_leave_indexing_stuck() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let storage_path = temp.path().join("missing").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); { let mut state = controller.state.lock(); @@ -10137,7 +10160,8 @@ fn successful_index_reopen_failure_does_not_leave_indexing_stuck() { #[test] fn blocking_index_without_open_project_does_not_leave_indexing_stuck() { - let controller = AppController::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); let error = controller .run_indexing_blocking(IndexMode::Full) @@ -10219,13 +10243,14 @@ fn incremental_failure_message(boundary: IncrementalFailureBoundary) -> &'static } fn assert_incremental_boundary_is_atomic(boundary: IncrementalFailureBoundary) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let storage_path = workspace.path().join(".cache").join("codestory.db"); let old_path = workspace.path().join("old.rs"); let new_path = workspace.path().join("new.rs"); fs::write(&old_path, "pub fn old_value() -> i32 { 1 }\n").expect("write old source"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -10412,6 +10437,7 @@ fn assert_incremental_boundary_is_atomic(boundary: IncrementalFailureBoundary) { struct EmptyPlanShortCircuitFixture { _workspace: tempfile::TempDir, + _process_cache: tempfile::TempDir, controller: AppController, storage_path: PathBuf, source_path: PathBuf, @@ -10420,11 +10446,12 @@ struct EmptyPlanShortCircuitFixture { } fn publish_empty_plan_short_circuit_baseline() -> EmptyPlanShortCircuitFixture { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let source_path = workspace.path().join("lib.rs"); fs::write(&source_path, "pub fn steady_state() -> i32 { 1 }\n").expect("write baseline source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -10447,6 +10474,7 @@ fn publish_empty_plan_short_circuit_baseline() -> EmptyPlanShortCircuitFixture { ); EmptyPlanShortCircuitFixture { _workspace: workspace, + _process_cache: process_cache, controller, storage_path, source_path, @@ -10457,6 +10485,7 @@ fn publish_empty_plan_short_circuit_baseline() -> EmptyPlanShortCircuitFixture { #[cfg(unix)] fn publish_source_alias_incremental_baseline() -> EmptyPlanShortCircuitFixture { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use std::os::unix::fs::symlink; let workspace = tempdir().expect("workspace dir"); @@ -10485,7 +10514,7 @@ fn publish_source_alias_incremental_baseline() -> EmptyPlanShortCircuitFixture { .expect("write alias manifest"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -10516,6 +10545,7 @@ fn publish_source_alias_incremental_baseline() -> EmptyPlanShortCircuitFixture { ); EmptyPlanShortCircuitFixture { _workspace: workspace, + _process_cache: process_cache, controller, storage_path, source_path, @@ -10847,8 +10877,9 @@ fn incremental_refresh_republishes_when_the_source_policy_byte_cap_changes() { changed_policy.byte_cap, SourceIndexPolicy::default().byte_cap ); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let controller = AppController::new_with_source_index_policy( - codestory_retrieval::SidecarRuntimeConfig::local(), + crate::test_sidecar_runtime_with_cache_root(process_cache.path()), changed_policy, ); controller @@ -11287,8 +11318,9 @@ fn assert_publication_transition_fault_is_atomic( boundary: PublicationTestBoundary, action: PublicationTestAction, ) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let source_path = workspace_root.join("lib.rs"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace_root.to_path_buf(), @@ -11408,7 +11440,7 @@ fn assert_publication_transition_fault_is_atomic( } } - let restarted = AppController::new(); + let restarted = AppController::new_with_owned_cache_root(process_cache.path()); let summary = restarted .open_project_summary_with_storage_path( workspace_root.to_path_buf(), @@ -11470,13 +11502,14 @@ fn assert_publication_transition_fault_is_atomic( } fn assert_publication_transition_matrix(mode: IndexMode) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let source_path = workspace.path().join("lib.rs"); fs::write(&source_path, "pub fn old_generation() -> i32 { 1 }\n") .expect("write baseline source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); let (baseline, baseline_search_generations) = { - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11540,12 +11573,13 @@ fn assert_publication_transition_matrix(mode: IndexMode) { #[test] fn runtime_service_shared_cancellation_stops_full_refresh_before_core_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let source_path = workspace.path().join("lib.rs"); fs::write(&source_path, "pub fn old_generation() -> i32 { 1 }\n") .expect("write baseline source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11591,12 +11625,13 @@ fn assert_symbol_index_failure_preserves_previous_complete_publication( fault: search::engine::SymbolIndexTestFault, expected_error: &str, ) { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let source_path = workspace.path().join("lib.rs"); fs::write(&source_path, "pub fn old_generation() -> i32 { 1 }\n") .expect("write baseline source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11653,6 +11688,7 @@ fn symbol_index_commit_failure_preserves_previous_complete_publication() { #[test] fn cancelled_full_refresh_preserves_previous_verified_exclusion_manifest() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let ordinary = workspace.path().join("lib.rs"); let oversized = workspace.path().join("generated.rs"); @@ -11660,7 +11696,7 @@ fn cancelled_full_refresh_preserves_previous_verified_exclusion_manifest() { fs::write(&oversized, "pub fn generated() {}\n").expect("write generated source"); make_source_exceed_default_index_byte_cap(&oversized, "baseline exclusion"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11723,13 +11759,14 @@ fn cancelled_full_refresh_preserves_previous_verified_exclusion_manifest() { #[test] fn full_recovery_marker_completion_fault_preserves_fenced_live_generation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let source_path = workspace.path().join("lib.rs"); fs::write(&source_path, "pub fn old_generation() -> i32 { 1 }\n") .expect("write baseline source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); let baseline = { - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11766,7 +11803,7 @@ fn full_recovery_marker_completion_fault_preserves_fenced_live_generation() { }); fs::write(&source_path, "pub fn new_generation() -> i32 { 2 }\n") .expect("write recovery source"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11806,7 +11843,7 @@ fn full_recovery_marker_completion_fault_preserves_fenced_live_generation() { drop(storage); assert_no_staged_publication_artifacts(&storage_path); - let restarted = AppController::new(); + let restarted = AppController::new_with_owned_cache_root(process_cache.path()); restarted .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11841,6 +11878,7 @@ fn incremental_publication_transitions_fail_or_cancel_atomically() { #[test] fn index_writer_lock_reports_cache_busy_and_releases_after_drop() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("lib.rs"), @@ -11848,7 +11886,7 @@ fn index_writer_lock_reports_cache_busy_and_releases_after_drop() { ) .expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -11919,6 +11957,7 @@ fn first_incremental_requires_full_before_cancellation_or_storage_creation() { #[test] fn cancelled_incremental_preserves_live_generation_and_retries_incrementally() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); for index in 0..64 { fs::write( @@ -11930,7 +11969,7 @@ fn cancelled_incremental_preserves_live_generation_and_retries_incrementally() { .expect("write source"); } let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -12061,9 +12100,10 @@ fn cancelled_incremental_preserves_live_generation_and_retries_incrementally() { #[test] fn cancelled_blocking_index_is_user_visible_and_clears_indexing_state() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = copy_tictactoe_workspace(); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path) .expect("open project summary"); @@ -12082,9 +12122,10 @@ fn cancelled_blocking_index_is_user_visible_and_clears_indexing_state() { #[test] fn full_refresh_publishes_both_grounding_snapshot_tiers() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = copy_tictactoe_workspace(); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); let assert_ready = |phase: &str| { let storage = Storage::open(&storage_path).expect("reopen storage"); assert!( @@ -12165,8 +12206,9 @@ fn progress_forwarder_relays_progress_and_status_events() { #[test] fn write_file_text_writes_inside_project_root() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12187,8 +12229,9 @@ fn write_file_text_writes_inside_project_root() { #[test] fn write_file_text_rejects_paths_outside_project_root() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12207,6 +12250,7 @@ fn write_file_text_rejects_paths_outside_project_root() { #[test] fn list_root_symbols_deduplicates_repeated_entries() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let db_path = temp.path().join("codestory.db"); @@ -12236,7 +12280,7 @@ fn list_root_symbols_deduplicates_repeated_entries() { .expect("insert root nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12257,6 +12301,7 @@ fn list_root_symbols_deduplicates_repeated_entries() { #[test] fn graph_neighborhood_member_includes_owner_inheritance_edges() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let db_path = temp.path().join("codestory.db"); @@ -12304,7 +12349,7 @@ fn graph_neighborhood_member_includes_owner_inheritance_edges() { .expect("insert edges"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12333,6 +12378,7 @@ fn graph_neighborhood_member_includes_owner_inheritance_edges() { #[test] fn graph_trail_includes_canonical_layout() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let db_path = temp.path().join("codestory.db"); @@ -12380,7 +12426,7 @@ fn graph_trail_includes_canonical_layout() { .expect("insert edges"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12413,6 +12459,7 @@ fn graph_trail_includes_canonical_layout() { #[test] fn graph_direct_references_returns_filtered_direct_incoming_edges() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let db_path = temp.path().join("codestory.db"); @@ -12498,7 +12545,7 @@ fn graph_direct_references_returns_filtered_direct_incoming_edges() { .expect("insert edges"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12540,6 +12587,7 @@ fn graph_direct_references_returns_filtered_direct_incoming_edges() { #[test] fn high_fanout_graph_trail_reports_truncation_at_max_nodes() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let db_path = temp.path().join("codestory.db"); @@ -12571,7 +12619,7 @@ fn high_fanout_graph_trail_reports_truncation_at_max_nodes() { storage.insert_edges_batch(&edges).expect("insert edges"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12602,8 +12650,9 @@ fn high_fanout_graph_trail_reports_truncation_at_max_nodes() { #[test] fn update_bookmark_category_returns_not_found_when_missing() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -12624,6 +12673,7 @@ fn update_bookmark_category_returns_not_found_when_missing() { struct AnnotationProject { _workspace: TempDir, + _process_cache: TempDir, root: PathBuf, source_path: PathBuf, storage_path: PathBuf, @@ -12632,17 +12682,19 @@ struct AnnotationProject { impl AnnotationProject { fn open(source: &str) -> Self { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); let root = workspace.path().to_path_buf(); let source_path = root.join("lib.rs"); fs::write(&source_path, source).expect("write source"); let storage_path = root.join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(root.clone(), storage_path.clone()) .expect("open annotation project"); Self { _workspace: workspace, + _process_cache: process_cache, root, source_path, storage_path, @@ -12750,6 +12802,7 @@ impl AnnotationProject { #[test] fn relative_root_incremental_failure_never_hides_a_committed_core() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let cwd = std::env::current_dir().expect("test working directory"); let workspace = tempfile::Builder::new() .prefix(".relative-incremental-") @@ -12788,7 +12841,7 @@ fn relative_root_incremental_failure_never_hides_a_committed_core() { ) .expect("explicit source manifest"); let storage_path = absolute_root.join(".cache").join("codestory.db"); - let absolute_controller = AppController::new(); + let absolute_controller = AppController::new_with_owned_cache_root(process_cache.path()); absolute_controller .open_project_summary_with_storage_path(absolute_root.clone(), storage_path.clone()) .expect("open absolute baseline project"); @@ -12809,7 +12862,7 @@ fn relative_root_incremental_failure_never_hides_a_committed_core() { "// source-only edit\npub fn alpha() -> i32 { 1 }\n", ) .expect("edit one existing source"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(root, storage_path.clone()) .expect("reopen complete core through relative project root"); @@ -12835,7 +12888,7 @@ fn relative_root_incremental_failure_never_hides_a_committed_core() { } } - let absolute_retry = AppController::new(); + let absolute_retry = AppController::new_with_owned_cache_root(process_cache.path()); absolute_retry .open_project_summary_with_storage_path(absolute_root, storage_path.clone()) .expect("reopen absolute project for retry"); @@ -12855,6 +12908,7 @@ fn dot_root_source_only_incremental_reports_committed_core_and_runtime_state() { const CHILD_MARKER: &str = "CODESTORY_B1_DOT_ROOT_CHILD"; if std::env::var_os(CHILD_MARKER).is_none() { let workspace = tempdir().expect("isolated child working directory"); + let process_cache = tempdir().expect("owned runtime cache root"); let output = std::process::Command::new(std::env::current_exe().expect("test executable")) .args([ "--exact", @@ -12862,6 +12916,7 @@ fn dot_root_source_only_incremental_reports_committed_core_and_runtime_state() { "--nocapture", ]) .env(CHILD_MARKER, "1") + .env("CODESTORY_CACHE_ROOT", process_cache.path()) .current_dir(workspace.path()) .output() .expect("run child in the project's working directory"); @@ -13479,6 +13534,7 @@ fn a_full_refresh_rescues_legacy_annotations_before_it_replaces_core() { #[test] fn schema31_disk_upgrade_keeps_annotations_and_publishes_complete_core_without_cow() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace"); let root = workspace.path().join("project"); fs::create_dir(&root).expect("project root"); @@ -13488,7 +13544,7 @@ fn schema31_disk_upgrade_keeps_annotations_and_publishes_complete_core_without_c ) .expect("source with a call edge"); let storage_path = workspace.path().join("cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(root.clone(), storage_path.clone()) .expect("open seed project"); @@ -13589,7 +13645,7 @@ fn schema31_disk_upgrade_keeps_annotations_and_publishes_complete_core_without_c fs::rename(&legacy_path, &storage_path).expect("install schema-31 disk fixture"); assert!(layout.read_pointer().expect("pointer read").is_none()); - let upgraded = AppController::new(); + let upgraded = AppController::new_with_owned_cache_root(process_cache.path()); upgraded .bind_project_paths_for_refresh(root, storage_path.clone()) .expect("bind legacy project without opening it"); @@ -13606,7 +13662,10 @@ fn schema31_disk_upgrade_keeps_annotations_and_publishes_complete_core_without_c pointer.rollback.is_some(), "complete legacy predecessor stays rollback eligible" ); - assert_eq!(Storage::database_schema_version(&storage_path).unwrap(), 35); + assert_eq!( + Storage::database_schema_version(&storage_path).unwrap(), + codestory_store::CURRENT_SCHEMA_VERSION + ); assert_eq!( upgraded.list_bookmarks(None).expect("migrated annotations")[0] .comment @@ -13637,12 +13696,13 @@ fn schema31_disk_upgrade_keeps_annotations_and_publishes_complete_core_without_c #[test] fn full_refresh_replaces_interrupted_standalone_core_with_retained_publication_and_annotation() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace"); let root = workspace.path().join("project"); fs::create_dir(&root).expect("project root"); fs::write(root.join("lib.rs"), "pub fn alpha() -> i32 { 1 }\n").expect("source"); let storage_path = workspace.path().join("cache").join("codestory.db"); - let seed = AppController::new(); + let seed = AppController::new_with_owned_cache_root(process_cache.path()); seed.open_project_summary_with_storage_path(root.clone(), storage_path.clone()) .expect("open seed project"); seed.run_indexing_blocking(IndexMode::Full) @@ -13689,7 +13749,7 @@ fn full_refresh_replaces_interrupted_standalone_core_with_retained_publication_a "annotation rescue is still required before replacement" ); - let recovered = AppController::new(); + let recovered = AppController::new_with_owned_cache_root(process_cache.path()); recovered .bind_project_paths_for_refresh(root, storage_path.clone()) .expect("bind interrupted project"); diff --git a/crates/codestory-runtime/src/tests/affected.rs b/crates/codestory-runtime/src/tests/affected.rs index 77504b0ba..449b5c241 100644 --- a/crates/codestory-runtime/src/tests/affected.rs +++ b/crates/codestory-runtime/src/tests/affected.rs @@ -1186,10 +1186,11 @@ fn affected_follow_ups_are_deduplicated_and_empty_for_complete_input() { #[test] fn affected_not_checked_svg_has_no_doctor_or_index_follow_up() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempdir().expect("project"); fs::write(project.path().join("desk.svg"), "\n").expect("write SVG"); Storage::open(project.path().join("codestory.db")).expect("create empty storage"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: project.path().to_string_lossy().to_string(), @@ -1215,6 +1216,7 @@ fn affected_not_checked_svg_has_no_doctor_or_index_follow_up() { #[test] fn affected_unrelated_stale_file_does_not_downgrade_fresh_requested_identity() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempdir().expect("project"); let source_dir = project.path().join("src"); fs::create_dir(&source_dir).expect("create source directory"); @@ -1282,7 +1284,7 @@ fn affected_unrelated_stale_file_does_not_downgrade_fresh_requested_identity() { ]) .expect("insert fixture nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: project.path().to_string_lossy().to_string(), @@ -1309,6 +1311,7 @@ fn affected_unrelated_stale_file_does_not_downgrade_fresh_requested_identity() { #[test] fn affected_rename_and_copy_classify_current_path_while_previous_identity_only_seeds_graph() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempdir().expect("project"); let source_dir = project.path().join("src"); fs::create_dir_all(&source_dir).expect("create source directory"); @@ -1356,7 +1359,7 @@ fn affected_rename_and_copy_classify_current_path_while_previous_identity_only_s ]) .expect("insert previous graph"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: project.path().to_string_lossy().to_string(), @@ -2046,6 +2049,7 @@ fn route_handler_comparator_is_total_across_every_candidate_permutation() { #[test] fn affected_graph_cycle_terminates_and_result_caps_are_enforced() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let project = tempdir().expect("project"); let source_dir = project.path().join("src"); fs::create_dir_all(&source_dir).expect("create source directory"); @@ -2120,7 +2124,7 @@ fn affected_graph_cycle_terminates_and_result_caps_are_enforced() { storage.insert_edges_batch(&edges).expect("insert edges"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: project.path().to_string_lossy().to_string(), @@ -2357,6 +2361,7 @@ fn dedicated_openapi_coverage_requires_authenticated_file_owned_projection_evide #[test] fn incremental_openapi_structural_transitions_replace_file_owned_projection_atomically() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); fn endpoint_ids(storage: &Storage) -> HashSet { storage .get_nodes() @@ -2375,7 +2380,7 @@ fn incremental_openapi_structural_transitions_replace_file_owned_projection_atom ) .expect("write baseline OpenAPI source"); let storage_path = workspace.path().join(".cache/codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), diff --git a/crates/codestory-runtime/src/tests/repo_text.rs b/crates/codestory-runtime/src/tests/repo_text.rs index 4105df965..95ae6bbc8 100644 --- a/crates/codestory-runtime/src/tests/repo_text.rs +++ b/crates/codestory-runtime/src/tests/repo_text.rs @@ -121,6 +121,7 @@ fn search_plan_repo_text_exact_terminal_identifier_promotes_member_symbol() { #[test] fn search_results_ignores_repo_text_hits_without_full_sidecars() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let storage_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(storage_path.parent().expect("db parent")).expect("create db dir"); @@ -166,7 +167,7 @@ fn search_results_ignores_repo_text_hits_without_full_sidecars() { .expect("insert nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path) .expect("open project"); @@ -186,6 +187,7 @@ fn search_results_ignores_repo_text_hits_without_full_sidecars() { #[test] fn repo_text_auto_fallback_is_not_product_search_without_full_sidecars() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("temp dir"); let storage_path = temp.path().join("cache").join("codestory.db"); std::fs::create_dir_all(storage_path.parent().expect("db parent")).expect("create db dir"); @@ -245,7 +247,7 @@ fn repo_text_auto_fallback_is_not_product_search_without_full_sidecars() { .expect("insert nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), storage_path) .expect("open project"); diff --git a/crates/codestory-runtime/src/tests/search_scoring.rs b/crates/codestory-runtime/src/tests/search_scoring.rs index 4543a8316..a677217da 100644 --- a/crates/codestory-runtime/src/tests/search_scoring.rs +++ b/crates/codestory-runtime/src/tests/search_scoring.rs @@ -279,6 +279,7 @@ fn build_search_hit_adjusts_route_scores_by_extraction_provenance() { #[test] fn canonical_and_openapi_route_metadata_keep_uncertain_resolved_handlers() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let route_canonical_id = format!( "route_endpoint:{}", serde_json::json!({ @@ -386,7 +387,7 @@ fn canonical_and_openapi_route_metadata_keep_uncertain_resolved_handlers() { ); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); for (route_id, expected_handler) in [ (CoreNodeId(101), NodeId("105".to_string())), (CoreNodeId(102), NodeId("106".to_string())), @@ -787,6 +788,7 @@ fn persisted_search_build_streams_multiple_pages_through_one_writer() { #[test] fn search_requires_full_sidecars_for_exact_type_queries() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let db_path = temp.path().join("codestory.db"); @@ -834,7 +836,7 @@ fn search_requires_full_sidecars_for_exact_type_queries() { .expect("insert nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path.clone()) .expect("open project"); @@ -896,6 +898,7 @@ fn search_requires_full_sidecars_for_exact_type_queries() { #[test] fn dotted_owner_method_query_resolves_with_file_constraint() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); use crate::{TargetResolution, TargetSelection}; let temp = tempdir().expect("create temp dir"); @@ -976,7 +979,7 @@ fn dotted_owner_method_query_resolves_with_file_constraint() { .expect("insert symbols"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(temp.path().to_path_buf(), db_path) .expect("open project"); @@ -1097,12 +1100,14 @@ fn dotted_owner_method_query_resolves_with_file_constraint() { #[test] fn core_exact_search_is_invariant_to_absolute_project_root() { struct Fixture { + _process_cache: tempfile::TempDir, controller: AppController, storage_path: PathBuf, source_path: PathBuf, } fn fixture(root: &Path) -> Fixture { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let source_path = root.join("src").join("module-entry.ts"); fs::create_dir_all(source_path.parent().expect("source parent")) .expect("create source directory"); @@ -1211,11 +1216,12 @@ fn core_exact_search_is_invariant_to_absolute_project_root() { .expect("insert canonical file identities"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_with_storage_path(root.to_path_buf(), storage_path.clone()) .expect("open project"); Fixture { + _process_cache: process_cache, controller, storage_path, source_path, @@ -1464,6 +1470,7 @@ fn repo_explanation_search_requires_full_sidecar_retrieval() { #[test] fn search_rejects_natural_language_queries_without_full_sidecars() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = tempdir().expect("create temp dir"); let db_path = temp.path().join("codestory.db"); @@ -1493,7 +1500,7 @@ fn search_rejects_natural_language_queries_without_full_sidecars() { .expect("insert nodes"); } - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project(OpenProjectRequest { path: temp.path().to_string_lossy().to_string(), @@ -1612,9 +1619,10 @@ fn broad_search_plan_loads_symbols_after_summary_only_open() { #[test] fn open_project_summary_preserves_search_state_for_the_same_complete_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = copy_tictactoe_workspace(); let storage_path = temp.path().join("cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(temp.path().to_path_buf(), storage_path.clone()) @@ -1779,9 +1787,10 @@ fn activation_search_preparation_preserves_resident_state_for_retrieval_only_rep #[test] fn open_project_summary_clears_state_bound_to_another_core_publication() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let temp = copy_tictactoe_workspace(); let storage_path = temp.path().join("cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(temp.path().to_path_buf(), storage_path.clone()) @@ -1829,9 +1838,10 @@ fn open_project_summary_clears_state_bound_to_another_core_publication() { #[test] fn run_indexing_without_runtime_refresh_keeps_search_uninitialized() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = copy_tictactoe_workspace(); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path) @@ -2783,6 +2793,7 @@ fn hybrid_search_config_skips_exact_symbol_escalation_for_mixed_nl() { #[test] #[ignore = "live published cores are immutable generations; incomplete-run fences belong on staged candidates"] fn staged_recovery_search_failure_preserves_the_marked_live_database() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( workspace.path().join("lib.rs"), @@ -2790,7 +2801,7 @@ fn staged_recovery_search_failure_preserves_the_marked_live_database() { ) .expect("write source"); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path( workspace.path().to_path_buf(), @@ -2921,7 +2932,8 @@ fn staged_recovery_search_failure_preserves_the_marked_live_database() { #[test] fn search_rejects_reads_while_indexing_is_active() { - let controller = AppController::new(); + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); { let mut state = controller.state.lock(); state.is_indexing = true; @@ -2938,15 +2950,20 @@ fn search_rejects_reads_while_indexing_is_active() { }) .expect_err("search should be blocked while indexing"); - assert_eq!(error.code, "invalid_argument"); - assert!(error.message.contains("indexing is in progress")); + assert_eq!(error.code, "activation_preparing"); + assert!( + error.message.contains("indexing is in progress"), + "preparing refusal must name the in-progress indexing: {}", + error.message + ); } #[test] fn search_after_summary_open_stays_sidecar_primary_without_runtime_refresh() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = copy_tictactoe_workspace(); let storage_path = workspace.path().join(".cache").join("codestory.db"); - let controller = AppController::new(); + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(workspace.path().to_path_buf(), storage_path) diff --git a/crates/codestory-runtime/tests/integration.rs b/crates/codestory-runtime/tests/integration.rs index 1cb431ddc..eae679915 100644 --- a/crates/codestory-runtime/tests/integration.rs +++ b/crates/codestory-runtime/tests/integration.rs @@ -13,6 +13,24 @@ fn should_run_repo_scale_test() -> bool { ) } +/// Controller whose process-owned defaults carry `cache_root` instead of the +/// ambient process cache. The caller owns the directory and must keep it +/// alive until every worker the controller spawned has quiesced. +fn owned_cache_controller(process_cache: &std::path::Path) -> AppController { + AppController::new_with_config( + codestory_retrieval::SidecarRuntimeConfig::for_project_profile_with_process_defaults( + None, + codestory_retrieval::SidecarProfile::Local, + None, + &codestory_retrieval::SidecarProcessDefaults::new( + process_cache.to_path_buf(), + codestory_retrieval::SidecarRuntimeDefaults::from_process_env(), + ), + &codestory_retrieval::SidecarRuntimeOverrides::default(), + ), + ) +} + #[test] fn test_cli_app_indexer_smoke() -> anyhow::Result<()> { // This test exercises CLI -> runtime -> project/storage -> indexer lifecycle without being a benchmark. @@ -33,7 +51,8 @@ fn test_cli_app_indexer_smoke() -> anyhow::Result<()> { } fs::write(src_dir.join("main.rs"), code)?; - let controller = AppController::new(); + let process_cache = tempdir()?; + let controller = owned_cache_controller(process_cache.path()); let storage_path = root.join(".cache").join("codestory.db"); // 1. Open project @@ -149,7 +168,8 @@ fn test_repo_scale_call_resolution() -> anyhow::Result<()> { return Ok(()); } - let controller = AppController::new(); + let process_cache = tempdir()?; + let controller = owned_cache_controller(process_cache.path()); let cache_dir = tempdir()?; let storage_path = cache_dir.path().join("codestory.db"); @@ -259,7 +279,8 @@ fn incremental_publication_immutable_generation_measurement() -> anyhow::Result< let copied = copy_measurement_project(&repo_root, &project_root); let storage_path = scratch.path().join("cache").join("codestory.db"); - let controller = AppController::new(); + let process_cache = tempdir()?; + let controller = owned_cache_controller(process_cache.path()); controller .open_project_with_storage_path(project_root.clone(), storage_path.clone()) .expect("open measurement project"); From 07919683109a8f70d568f411ee67127697d19bfe Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 09:50:17 -0400 Subject: [PATCH 11/58] retarget retired-path tests at live search and coverage contracts --- .../src/agent/packet_compiler.rs | 36 +- .../codestory-runtime/src/controller_core.rs | 2 - .../src/controller_symbols.rs | 8 - .../codestory-runtime/src/index_freshness.rs | 2 - crates/codestory-runtime/src/lib.rs | 35 +- crates/codestory-runtime/src/search/engine.rs | 1190 +---------------- .../codestory-runtime/src/search/lexical.rs | 119 -- crates/codestory-runtime/src/search/mod.rs | 2 - .../src/search_publication.rs | 91 +- .../codestory-runtime/src/search_runtime.rs | 12 +- .../codestory-runtime/src/search_scoring.rs | 445 ------ .../src/semantic_projection.rs | 180 +-- .../codestory-runtime/src/source_coverage.rs | 191 --- crates/codestory-runtime/src/support.rs | 53 - crates/codestory-runtime/src/symbol_query.rs | 22 - crates/codestory-runtime/src/tests.rs | 566 +++----- .../src/tests/search_intent.rs | 13 +- .../src/tests/search_plan.rs | 60 - .../src/tests/search_scoring.rs | 218 ++- 19 files changed, 369 insertions(+), 2876 deletions(-) delete mode 100644 crates/codestory-runtime/src/search/lexical.rs delete mode 100644 crates/codestory-runtime/src/source_coverage.rs diff --git a/crates/codestory-runtime/src/agent/packet_compiler.rs b/crates/codestory-runtime/src/agent/packet_compiler.rs index 29f11e175..973ea1937 100644 --- a/crates/codestory-runtime/src/agent/packet_compiler.rs +++ b/crates/codestory-runtime/src/agent/packet_compiler.rs @@ -533,7 +533,7 @@ fn source_receipt_line_range(markdown: &str) -> Option<(u32, u32)> { start.zip(end) } -fn observe_admitted_source_coverage( +pub(crate) fn observe_admitted_source_coverage( controller: &AppController, storage: &Store, paths: &[String], @@ -560,6 +560,26 @@ fn observe_one_admitted_source_coverage( project_root: &Path, path: &str, ) -> Result { + // Policy exclusions carry no `file` row (excluded sources are never + // registered as parser-backed coverage), so the exclusion table is + // consulted on the normalized relative spelling before the file lookup. + for candidate in admitted_file_lookup_paths(project_root, path) { + let relative_path = codestory_workspace::workspace_relative_path(project_root, &candidate) + .unwrap_or_else(|| candidate.clone()) + .to_string_lossy() + .replace('\\', "/"); + if storage.has_source_policy_exclusion_path(&relative_path)? { + return Ok(SourceCoverageObservationDto { + path: path.to_string(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: None, + byte_cap: None, + }); + } + } + let mut file = None; for candidate in admitted_file_lookup_paths(project_root, path) { if let Some(found) = storage.get_file_by_path(&candidate)? { @@ -570,20 +590,6 @@ fn observe_one_admitted_source_coverage( let Some(file) = file else { return Ok(source_coverage_not_established(path)); }; - let relative_path = codestory_workspace::workspace_relative_path(project_root, &file.path) - .unwrap_or_else(|| file.path.clone()) - .to_string_lossy() - .replace('\\', "/"); - if storage.has_source_policy_exclusion_path(&relative_path)? { - return Ok(SourceCoverageObservationDto { - path: path.to_string(), - status: SourceCoverageStatusDto::PolicyExcluded, - reason: None, - not_established_cause: None, - observed_size: None, - byte_cap: None, - }); - } let verified_source = storage.get_file_content_hash(file.id)?.is_some(); let structural_projection = if file.language == "openapi" { diff --git a/crates/codestory-runtime/src/controller_core.rs b/crates/codestory-runtime/src/controller_core.rs index 55a9f4870..46c7bf523 100644 --- a/crates/codestory-runtime/src/controller_core.rs +++ b/crates/codestory-runtime/src/controller_core.rs @@ -72,8 +72,6 @@ impl AppController { observed_core_publication: None, is_indexing: false, index_freshness_cache: None, - #[cfg(test)] - last_hybrid_instrumentation: None, })), sidecar_query_cache: Arc::new(Mutex::new(SidecarQueryCacheState::new())), canonical_symbol_names: Arc::new(Mutex::new(Default::default())), diff --git a/crates/codestory-runtime/src/controller_symbols.rs b/crates/codestory-runtime/src/controller_symbols.rs index aa71e9e65..4e3877a11 100644 --- a/crates/codestory-runtime/src/controller_symbols.rs +++ b/crates/codestory-runtime/src/controller_symbols.rs @@ -4,8 +4,6 @@ use crate::route_coverage::{ route_endpoint_metadata_from_canonical, route_endpoint_metadata_from_openapi_label, }; use crate::search::engine::search_core_symbol_names_with_scores; -#[cfg(test)] -use crate::search_scoring::HybridSearchInstrumentation; use crate::support::node_display_name; use crate::symbol_query::compare_search_hits_with_project_root; use crate::{AppController, Storage, agent, graph_builders, member_access_dto}; @@ -502,12 +500,6 @@ impl AppController { let _ = self; } - #[cfg(test)] - #[allow(dead_code)] - pub(crate) fn take_hybrid_instrumentation(&self) -> Option { - self.state.lock().last_hybrid_instrumentation.take() - } - /// Build one bounded, source-backed evidence packet with typed diagnostics. /// /// The packet reports no answer-sufficiency judgment. Candidate admission is diff --git a/crates/codestory-runtime/src/index_freshness.rs b/crates/codestory-runtime/src/index_freshness.rs index 841d8daac..682b58dc6 100644 --- a/crates/codestory-runtime/src/index_freshness.rs +++ b/crates/codestory-runtime/src/index_freshness.rs @@ -19,8 +19,6 @@ const INDEX_FRESHNESS_INDEXED_FILE_CAP: usize = 25_000; const INDEX_FRESHNESS_CURRENT_FILE_CAP: usize = 25_000; const INDEX_FRESHNESS_SAMPLE_LIMIT: usize = 8; const INDEX_FRESHNESS_CACHE_DEFAULT_TTL_SECS: u64 = 60; -#[cfg(test)] -pub(super) const EXACT_SYMBOL_HYBRID_MAX_RESULTS_CAP: usize = 80; #[cfg(test)] thread_local! { diff --git a/crates/codestory-runtime/src/lib.rs b/crates/codestory-runtime/src/lib.rs index 8f7b29674..30c61e367 100644 --- a/crates/codestory-runtime/src/lib.rs +++ b/crates/codestory-runtime/src/lib.rs @@ -171,7 +171,6 @@ mod semantic_projection; mod semantic_republish; mod snippets; #[cfg(test)] -mod source_coverage; #[cfg(feature = "v3-evidence-separation-support")] #[doc(hidden)] pub mod v3_evidence_qualification_support; @@ -211,8 +210,8 @@ use index_freshness::{ }; #[cfg(test)] use index_freshness::{ - EXACT_SYMBOL_HYBRID_MAX_RESULTS_CAP, arm_after_index_freshness_fence_test_hook, - index_freshness_from_storage, indexable_source_path, not_checked_index_freshness, + arm_after_index_freshness_fence_test_hook, index_freshness_from_storage, indexable_source_path, + not_checked_index_freshness, }; #[cfg(test)] use publication::{ @@ -228,12 +227,10 @@ use route_coverage::compare_optional_confidence_desc; use route_coverage::route_endpoint_adjusted_search_score; #[cfg(test)] use search_publication::{ - SearchGenerationCompletion, llm_doc_embed_batch_size, load_persisted_search_state, - search_generation_completion_path, search_index_generation_root, search_index_storage_path, + SearchGenerationCompletion, load_persisted_search_state, search_generation_completion_path, + search_index_generation_root, search_index_storage_path, }; use search_publication::{load_canonical_search_symbols, retrieval_state_from_storage_for_runtime}; -#[cfg(test)] -use search_scoring::HybridSearchInstrumentation; pub(crate) use search_scoring::HybridSearchScoredHit; use search_state_cache::*; pub use semantic_projection::SemanticProjectionRepublishOutcome; @@ -357,16 +354,13 @@ use semantic_projection::{ SEMANTIC_DOC_DEFAULT_MAX_TOKENS, SEMANTIC_DOC_MAX_TOKENS_ENV, SEMANTIC_DOC_SCOPE_ENV, SEMANTIC_EDGE_STREAM_BATCH_SIZE, SEMANTIC_STREAM_PENDING_DOCS_ENV, SEMANTIC_STREAM_SORT_WINDOW_BATCHES_ENV, SYMBOL_SEARCH_DOC_PROVENANCE, SemanticDocAliasMode, - SemanticDocGraphContext, SemanticDocScope, build_component_report_docs, - build_llm_symbol_doc_text, build_search_state, build_semantic_file_text_cache_with_limits, - dense_anchor_is_central, dense_anchor_reason_for_node, finalize_staged_semantic_docs, - flush_pending_dense_anchor_inputs, llm_indexable_kind, llm_indexable_kind_for_scope, - llm_indexable_kinds_for_scope, llm_symbol_doc_hash, semantic_doc_alias_mode_from_env, - semantic_doc_alias_mode_from_value, semantic_doc_max_tokens_from_env, - semantic_doc_scope_from_env, semantic_doc_scope_from_value, semantic_doc_shape_contract, - semantic_doc_text_budget_cost, semantic_stream_sort_window_batches_from_env, - sort_pending_dense_anchor_inputs, stream_pending_llm_symbol_docs_from_env, - truncate_semantic_doc_text_to_token_budget, + SemanticDocGraphContext, SemanticDocScope, SemanticRuntimePolicy, build_search_state, + build_semantic_file_text_cache_with_limits, dense_anchor_is_central, + dense_anchor_reason_for_node, flush_pending_dense_anchor_inputs, llm_indexable_kind, + llm_indexable_kind_for_scope, llm_indexable_kinds_for_scope, llm_symbol_doc_hash, + semantic_doc_alias_mode_from_value, semantic_doc_scope_from_value, + semantic_doc_shape_contract_for_runtime, semantic_doc_text_budget_cost, + sort_pending_dense_anchor_inputs, }; pub(crate) use snippets::{ BoundedSnippetRangeOptions, DIRECT_SNIPPET_MAX_BYTES, DIRECT_SNIPPET_TRUNCATION_SUFFIX, @@ -561,8 +555,6 @@ pub(crate) use support::{ source_freshness_telemetry_for_operation, }; #[cfg(test)] -pub(crate) use support::{apply_hybrid_limits, normalized_hybrid_weights}; -#[cfg(test)] use symbol_query::compare_search_hits; pub use symbol_query::{ RetrievalFileRole, SymbolNameMatchRank, compare_ranked_hits, leading_symbol_segment, @@ -574,8 +566,6 @@ pub(crate) use symbol_query::{ compare_search_hits_with_project_root, exact_symbol_query_terms, is_non_primary_source_term, looks_like_standalone_symbol_query, query_mentions_non_primary_source, }; -#[cfg(test)] -pub(crate) use symbol_query::{is_non_primary_source_hit, mixed_natural_language_query}; type Storage = Store; type GraphNodeId = codestory_contracts::graph::NodeId; @@ -723,9 +713,6 @@ struct AppState { observed_core_publication: Option, is_indexing: bool, index_freshness_cache: Option, - #[cfg(test)] - #[allow(dead_code)] - last_hybrid_instrumentation: Option, } fn publish_search_engine( diff --git a/crates/codestory-runtime/src/search/engine.rs b/crates/codestory-runtime/src/search/engine.rs index 5886f0283..9d9bb4049 100644 --- a/crates/codestory-runtime/src/search/engine.rs +++ b/crates/codestory-runtime/src/search/engine.rs @@ -1,6 +1,4 @@ use crate::symbol_query::RetrievalFileRole; -#[cfg(test)] -use crate::symbol_query::query_mentions_non_primary_source; use anyhow::{Context, Result, anyhow, bail}; use codestory_contracts::bounded_locks::{ self, FileLockKind, LockDeadline, PUBLICATION_LOCK_WAIT, acquire_with_deadline, @@ -15,8 +13,6 @@ use std::fs::{File, OpenOptions}; #[cfg(any(test, feature = "test-support"))] use std::hash::{Hash, Hasher}; use std::path::{Path, PathBuf}; -#[cfg(test)] -use std::sync::Arc; use std::time::{Duration, Instant}; use tantivy::collector::TopDocs; use tantivy::doc; @@ -29,11 +25,7 @@ pub const EMBEDDING_DIM: usize = codestory_retrieval::RETRIEVAL_EMBEDDING_DIM; const SEARCH_WRITER_HEAP_BYTES: usize = 20_000_000; const EMBEDDING_PROFILE: &str = "coderank-embed"; const EMBEDDING_MODEL_ID: &str = "nomic-ai/CodeRankEmbed"; -#[cfg(test)] -use codestory_contracts::config_registry::STORED_VECTOR_ENCODING_ENV; pub const SYMBOL_FULL_TEXT_INDEX_ENV: &str = "CODESTORY_SYMBOL_FULL_TEXT_INDEX"; -#[cfg(test)] -const SEMANTIC_QUANTIZED_RESCORE_MULTIPLIER: usize = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] struct SymbolCandidateRank { @@ -117,98 +109,6 @@ pub struct LlmSearchDoc { pub embedding: Vec, } -#[derive(Debug, Clone)] -pub struct HybridSearchHit { - pub node_id: NodeId, - pub lexical_score: f32, - pub semantic_score: f32, - pub graph_score: f32, - pub total_score: f32, -} - -#[derive(Debug, Clone)] -pub struct HybridSearchConfig { - pub max_results: usize, - pub lexical_weight: f32, - pub semantic_weight: f32, - pub graph_weight: f32, - pub lexical_limit: usize, - pub semantic_limit: usize, -} - -impl Default for HybridSearchConfig { - fn default() -> Self { - Self { - max_results: 20, - lexical_weight: 0.0, - semantic_weight: 1.0, - graph_weight: 0.0, - lexical_limit: 0, - semantic_limit: 20, - } - } -} - -impl HybridSearchConfig { - pub fn lexical_first() -> Self { - Self { - max_results: 20, - lexical_weight: 1.0, - semantic_weight: 0.0, - graph_weight: 0.0, - lexical_limit: 200, - semantic_limit: 20, - } - } -} - -/// In-memory mirror of a vector the store persisted quantized. -/// -/// The prefilter can only model encodings the store can actually write, and the -/// store writes exactly float32 or compact scaled int8 — so the encoding this -/// path branches on is [`codestory_store::StoredVectorEncoding`], read once by -/// its owner, rather than a second interpretation of -/// `CODESTORY_STORED_VECTOR_ENCODING` here. The interpretation this replaces -/// accepted `uint8`, `binary` and `ubinary` and rejected anything else, none of -/// which the store has ever honoured: it would have quantized the prefilter to -/// a width the persisted blobs were not in, and failed a run for a value the -/// encoder silently treats as float32. -#[cfg(test)] -#[derive(Debug, Clone)] -enum QuantizedEmbedding { - Int8(Vec), -} - -#[cfg(test)] -fn quantize_for_prefilter( - encoding: codestory_store::StoredVectorEncoding, - values: &[f32], -) -> Option { - match encoding { - codestory_store::StoredVectorEncoding::Float32 => None, - codestory_store::StoredVectorEncoding::Int8 => Some(QuantizedEmbedding::Int8( - values - .iter() - .map(|value| (value * 127.0).round().clamp(-127.0, 127.0) as i8) - .collect(), - )), - } -} - -#[cfg(test)] -impl QuantizedEmbedding { - fn approximate_cosine(&self, query: &[f32]) -> f32 { - match self { - Self::Int8(values) if values.len() == query.len() => query - .iter() - .zip(values) - .map(|(query, doc)| query * (*doc as f32 / 127.0)) - .sum(), - Self::Int8(_) => 0.0, - } - } -} - #[derive(Debug, Clone)] pub struct EmbeddingRuntime { model_id: String, @@ -274,14 +174,8 @@ pub struct SearchEngine { index: Index, reader: IndexReader, llm_docs: HashMap, - #[cfg(test)] - quantized_llm_docs: HashMap, embedding_runtime: Option, - #[cfg(test)] - stored_vector_encoding: codestory_store::StoredVectorEncoding, full_text_index_enabled: bool, - #[cfg(test)] - query_embedding_cache: HashMap>, _persisted_index_guard: Option, } @@ -544,14 +438,8 @@ impl SearchEngine { index, reader, llm_docs: HashMap::new(), - #[cfg(test)] - quantized_llm_docs: HashMap::new(), embedding_runtime: None, - #[cfg(test)] - stored_vector_encoding: codestory_store::stored_vector_encoding(), full_text_index_enabled: symbol_full_text_index_enabled_from_env(), - #[cfg(test)] - query_embedding_cache: HashMap::new(), _persisted_index_guard: persisted_index_guard, }) } @@ -561,59 +449,6 @@ impl SearchEngine { &self.symbols } - #[cfg(test)] - #[allow(dead_code)] - pub fn warm_query_embeddings(&mut self, queries: &[String]) -> Result<()> { - for query in queries { - let trimmed = query.trim(); - if trimmed.is_empty() { - continue; - } - let _ = self.embed_query_cached(trimmed)?; - } - Ok(()) - } - - #[cfg(test)] - #[allow(dead_code)] - pub fn clear_query_embedding_cache(&mut self) { - self.query_embedding_cache.clear(); - } - - #[cfg(test)] - #[allow(dead_code)] - pub(crate) fn cached_query_embedding(&self, query: &str) -> Option> { - self.query_embedding_cache - .get(&query.trim().to_ascii_lowercase()) - .cloned() - } - - #[cfg(test)] - pub(crate) fn hybrid_search_state(&self) -> HybridSearchState { - HybridSearchState::from_engine(self) - } - - #[cfg(test)] - #[allow(dead_code)] - pub(crate) fn ensure_query_embedding(&mut self, query: &str) -> Result> { - self.embed_query_cached(query) - } - - #[cfg(test)] - fn embed_query_cached(&mut self, query: &str) -> Result> { - let key = query.trim().to_ascii_lowercase(); - if let Some(cached) = self.query_embedding_cache.get(&key) { - return Ok(cached.clone()); - } - let runtime = self - .embedding_runtime - .as_ref() - .ok_or_else(|| anyhow!("embedding runtime is not configured"))?; - let embedding = runtime.embed_query(query)?; - self.query_embedding_cache.insert(key, embedding.clone()); - Ok(embedding) - } - pub fn new(storage_path: Option<&Path>) -> Result { let schema = Self::build_schema(); let index = if let Some(path) = storage_path { @@ -631,41 +466,6 @@ impl SearchEngine { Self::open_persisted_with_guard(path, guard) } - #[cfg(test)] - pub(crate) fn try_open_existing(path: &Path) -> Result { - let guard = PersistedSearchIndexGuard::try_acquire_shared(path)?; - Self::open_persisted_with_guard(path, guard) - } - - #[cfg(test)] - pub(crate) fn open_existing_or_recreate(path: &Path) -> Result<(Self, Option)> { - let shared_guard = PersistedSearchIndexGuard::acquire_shared(path)?; - match Self::open_persisted_with_guard(path, shared_guard) { - Ok(engine) => Ok((engine, None)), - Err(open_error) => { - let guard = PersistedSearchIndexGuard::try_acquire_exclusive(path)?; - let engine = Self::new_persisted_with_guard(path, guard)?; - Ok((engine, Some(open_error))) - } - } - } - - #[cfg(test)] - pub(crate) fn recreate_persisted_from_existing( - path: &Path, - mut existing: Self, - ) -> Result { - let guard = existing._persisted_index_guard.take(); - drop(existing); - match guard.filter(|guard| guard.is_exclusive()) { - Some(guard) => Self::new_persisted_with_guard(path, guard), - None => { - let guard = PersistedSearchIndexGuard::try_acquire_exclusive(path)?; - Self::new_persisted_with_guard(path, guard) - } - } - } - pub(crate) fn downgrade_persisted_lock_to_shared(&mut self) -> Result<()> { if let Some(guard) = self._persisted_index_guard.as_mut() { guard.downgrade_to_shared()?; @@ -699,11 +499,6 @@ impl SearchEngine { Ok(engine) } - #[cfg(test)] - pub fn set_embedding_runtime(&mut self, runtime: EmbeddingRuntime) { - self.embedding_runtime = Some(runtime); - } - pub fn set_embedding_runtime_for_runtime( &mut self, runtime: &codestory_retrieval::SidecarRuntimeConfig, @@ -712,18 +507,6 @@ impl SearchEngine { Ok(()) } - #[cfg(test)] - pub fn embedding_model_id(&self) -> Option<&str> { - self.embedding_runtime - .as_ref() - .map(EmbeddingRuntime::model_id) - } - - #[cfg(test)] - pub fn embedding_runtime_configured(&self) -> bool { - self.embedding_runtime.is_some() - } - pub fn full_text_doc_count(&self) -> usize { if !self.full_text_index_enabled { return self.symbols.len(); @@ -740,20 +523,8 @@ impl SearchEngine { self.reader.searcher().segment_readers().len() } - #[cfg(test)] - pub fn semantic_index_ready(&self) -> bool { - self.embedding_runtime.is_some() && !self.llm_docs.is_empty() - } - - #[cfg(test)] - pub fn semantic_doc_count(&self) -> u32 { - self.llm_docs.len().min(u32::MAX as usize) as u32 - } - pub fn index_llm_symbol_docs(&mut self, docs: Vec) { self.llm_docs.clear(); - #[cfg(test)] - self.quantized_llm_docs.clear(); for doc in docs { self.insert_llm_symbol_doc(doc); } @@ -761,8 +532,6 @@ impl SearchEngine { pub fn clear_llm_symbol_docs(&mut self) { self.llm_docs.clear(); - #[cfg(test)] - self.quantized_llm_docs.clear(); } pub fn extend_llm_symbol_docs(&mut self, docs: I) @@ -776,40 +545,9 @@ impl SearchEngine { fn insert_llm_symbol_doc(&mut self, doc: LlmSearchDoc) { let node_id = doc.node_id; - #[cfg(test)] - { - if let Some(quantized) = - quantize_for_prefilter(self.stored_vector_encoding, &doc.embedding) - { - self.quantized_llm_docs.insert(node_id, quantized); - } else { - self.quantized_llm_docs.remove(&node_id); - } - } self.llm_docs.insert(node_id, doc); } - #[cfg(test)] - fn semantic_scores( - &self, - query_embedding: &[f32], - semantic_limit: usize, - ) -> Vec<(NodeId, f32)> { - self.hybrid_search_state() - .semantic_scores_with_role_preference(query_embedding, semantic_limit, false) - } - - #[cfg(test)] - fn semantic_scores_for_query( - &self, - query: &str, - query_embedding: &[f32], - semantic_limit: usize, - ) -> Vec<(NodeId, f32)> { - self.hybrid_search_state() - .semantic_scores_for_query(query, query_embedding, semantic_limit) - } - pub fn begin_symbol_index(&mut self) -> Result> { let writer = self .full_text_index_enabled @@ -873,68 +611,6 @@ impl SearchEngine { search_symbols_with_scores(&self.symbols, query) } - #[cfg(test)] - pub fn search_hybrid_with_scores( - &mut self, - query: &str, - graph_boosts: &HashMap, - config: HybridSearchConfig, - ) -> Result> { - if query.trim().is_empty() { - return Ok(Vec::new()); - } - let semantic_weight = config.semantic_weight.clamp(0.0, 1.0); - let query_embedding = if semantic_weight > f32::EPSILON { - if !self.semantic_index_ready() { - return Err(anyhow!( - "semantic retrieval is required but embedding runtime or semantic index is unavailable" - )); - } - Some(self.embed_query_cached(query)?) - } else { - None - }; - self.hybrid_search_state() - .search_hybrid_with_query_embedding( - query, - query_embedding.as_deref(), - graph_boosts, - config, - ) - } - - #[cfg(test)] - pub fn remove_nodes(&mut self, nodes: &[NodeId]) -> Result<()> { - if nodes.is_empty() { - return Ok(()); - } - - let mut remove_ids = HashSet::new(); - for id in nodes { - remove_ids.insert(id.0); - } - - self.symbols.retain(|(_, id)| !remove_ids.contains(&id.0)); - self.llm_docs.retain(|id, _| !remove_ids.contains(&id.0)); - self.quantized_llm_docs - .retain(|id, _| !remove_ids.contains(&id.0)); - - if !self.full_text_index_enabled { - return Ok(()); - } - - let mut index_writer: IndexWriter = - self.index.writer(SEARCH_WRITER_HEAP_BYTES)?; - let schema = self.index.schema(); - let node_field = schema.get_field("node_id")?; - for id in &remove_ids { - index_writer.delete_term(tantivy::Term::from_field_i64(node_field, *id)); - } - index_writer.commit()?; - self.reader.reload()?; - Ok(()) - } - pub fn search_full_text(&self, query_str: &str) -> Result> { if query_str.is_empty() { return Ok(Vec::new()); @@ -1220,412 +896,6 @@ fn l2_normalize(values: &mut [f32]) { } } -#[cfg(test)] -#[derive(Clone)] -pub(crate) struct HybridSearchState { - symbols: Arc>, - llm_docs: Arc>, - quantized_llm_docs: Arc>, - stored_vector_encoding: codestory_store::StoredVectorEncoding, -} - -#[cfg(test)] -impl HybridSearchState { - pub fn from_engine(engine: &SearchEngine) -> Self { - Self { - symbols: Arc::new(engine.symbols().to_vec()), - llm_docs: Arc::new(engine.llm_docs.clone()), - quantized_llm_docs: Arc::new(engine.quantized_llm_docs.clone()), - stored_vector_encoding: engine.stored_vector_encoding, - } - } - - pub fn semantic_index_ready(&self) -> bool { - !self.llm_docs.is_empty() - } - - pub fn semantic_scores_for_query( - &self, - query: &str, - query_embedding: &[f32], - semantic_limit: usize, - ) -> Vec<(NodeId, f32)> { - self.semantic_scores_with_role_preference( - query_embedding, - semantic_limit, - !query_mentions_non_primary_source(query), - ) - } - - pub fn semantic_scores_with_role_preference( - &self, - query_embedding: &[f32], - semantic_limit: usize, - prefer_primary_sources: bool, - ) -> Vec<(NodeId, f32)> { - if semantic_limit == 0 { - return Vec::new(); - } - - let mut scored = if self.stored_vector_encoding - == codestory_store::StoredVectorEncoding::Float32 - { - self.llm_docs - .par_iter() - .map(|(_, doc)| { - let cosine = cosine_similarity(query_embedding, &doc.embedding); - (doc.node_id, semantic_score_from_cosine(cosine)) - }) - .collect::>() - } else { - let mut approximate = self - .llm_docs - .values() - .map(|doc| { - let cosine = self - .quantized_llm_docs - .get(&doc.node_id) - .map(|embedding| embedding.approximate_cosine(query_embedding)) - .unwrap_or_else(|| cosine_similarity(query_embedding, &doc.embedding)); - (doc.node_id, cosine) - }) - .collect::>(); - let rescore_limit = semantic_limit - .saturating_mul(SEMANTIC_QUANTIZED_RESCORE_MULTIPLIER) - .max(semantic_limit) - .min(self.llm_docs.len()); - self.truncate_semantic_scores(&mut approximate, rescore_limit, prefer_primary_sources); - approximate - .into_iter() - .take(rescore_limit) - .filter_map(|(node_id, _)| { - self.llm_docs.get(&node_id).map(|doc| { - let cosine = cosine_similarity(query_embedding, &doc.embedding); - (node_id, semantic_score_from_cosine(cosine)) - }) - }) - .collect::>() - }; - - self.truncate_semantic_scores(&mut scored, semantic_limit, prefer_primary_sources); - scored - } - - pub fn truncate_semantic_scores( - &self, - scored: &mut Vec<(NodeId, f32)>, - limit: usize, - prefer_primary_sources: bool, - ) { - if !prefer_primary_sources { - truncate_node_scores(scored, limit); - return; - } - if limit == 0 { - scored.clear(); - return; - } - if scored.len() > limit { - let pivot = limit - 1; - scored.select_nth_unstable_by(pivot, |left, right| { - self.compare_semantic_scores_for_primary_query(left, right) - }); - scored.truncate(limit); - } - scored.sort_by(|left, right| self.compare_semantic_scores_for_primary_query(left, right)); - } - - pub fn compare_semantic_scores_for_primary_query( - &self, - left: &(NodeId, f32), - right: &(NodeId, f32), - ) -> std::cmp::Ordering { - let left_non_primary = self - .llm_docs - .get(&left.0) - .map(|doc| doc.file_role.is_non_primary()) - .unwrap_or(false); - let right_non_primary = self - .llm_docs - .get(&right.0) - .map(|doc| doc.file_role.is_non_primary()) - .unwrap_or(false); - - left_non_primary - .cmp(&right_non_primary) - .then_with(|| compare_node_scores_desc(left, right)) - } - - pub fn search_hybrid_with_query_embedding( - &self, - query: &str, - query_embedding: Option<&[f32]>, - graph_boosts: &HashMap, - config: HybridSearchConfig, - ) -> Result> { - let semantic_weight = config.semantic_weight.clamp(0.0, 1.0); - let semantic_enabled = semantic_weight > f32::EPSILON; - - let negative_terms = explicit_negative_query_terms(query); - - let lexical_matches = search_symbols_with_scores(self.symbols.as_slice(), query); - let lexical_max = lexical_matches - .iter() - .map(|(_, score)| *score) - .fold(0.0_f32, f32::max) - .max(1.0); - let lexical_map = lexical_matches - .into_iter() - .take(config.lexical_limit) - .map(|(node_id, score)| (node_id, (score / lexical_max).clamp(0.0, 1.0))) - .collect::>(); - - let semantic_map = if semantic_enabled { - if !self.semantic_index_ready() { - return Err(anyhow!( - "semantic retrieval is required but embedding runtime or semantic index is unavailable" - )); - } - let query_embedding = query_embedding.ok_or_else(|| { - anyhow!("semantic retrieval is required but query embedding is unavailable") - })?; - let semantic_scored = - self.semantic_scores_for_query(query, query_embedding, config.semantic_limit); - semantic_scored - .iter() - .take(config.semantic_limit) - .copied() - .collect::>() - } else { - HashMap::new() - }; - - let mut candidate_ids = HashSet::new(); - candidate_ids.extend(lexical_map.keys().copied()); - candidate_ids.extend(semantic_map.keys().copied()); - candidate_ids.extend(graph_boosts.keys().copied()); - - let lexical_weight = config.lexical_weight.clamp(0.0, 1.0); - let graph_weight = config.graph_weight.clamp(0.0, 1.0); - - let mut hits = candidate_ids - .into_iter() - .map(|node_id| { - let lexical_score = lexical_map.get(&node_id).copied().unwrap_or(0.0); - let semantic_score = semantic_map.get(&node_id).copied().unwrap_or(0.0); - let graph_score = graph_boosts - .get(&node_id) - .copied() - .unwrap_or(0.0) - .clamp(0.0, 1.0); - let total_score = lexical_weight * lexical_score - + semantic_weight * semantic_score - + graph_weight * graph_score; - let total_score = if self.node_matches_negative_terms(node_id, &negative_terms) { - total_score * 0.72 - } else { - total_score - }; - - HybridSearchHit { - node_id, - lexical_score, - semantic_score, - graph_score, - total_score, - } - }) - .collect::>(); - - hits.sort_by(|left, right| { - right - .total_score - .total_cmp(&left.total_score) - .then_with(|| left.node_id.0.cmp(&right.node_id.0)) - }); - hits.truncate(config.max_results.max(1)); - - Ok(hits) - } - - pub fn node_matches_negative_terms(&self, node_id: NodeId, negative_terms: &[String]) -> bool { - if negative_terms.is_empty() { - return false; - } - - let mut candidate_text = String::new(); - if let Some(doc) = self.llm_docs.get(&node_id) { - candidate_text.push_str(&doc.doc_text); - } - if let Some((name, _)) = self.symbols.iter().find(|(_, id)| *id == node_id) { - candidate_text.push(' '); - candidate_text.push_str(&name.slice(..).to_string()); - } - - text_matches_negative_terms(&candidate_text, negative_terms) - } -} - -#[cfg(test)] -fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { - if a.len() != b.len() || a.is_empty() { - return 0.0; - } - a.iter() - .zip(b.iter()) - .map(|(left, right)| left * right) - .sum() -} - -#[cfg(test)] -fn semantic_score_from_cosine(cosine: f32) -> f32 { - ((cosine + 1.0) * 0.5).clamp(0.0, 1.0) -} - -#[cfg(test)] -fn explicit_negative_query_terms(query: &str) -> Vec { - let tokens = normalized_alnum_terms(query); - let mut terms = Vec::new(); - let mut seen = HashSet::new(); - - for index in 0..tokens.len() { - let starts_two_token_phrase = (tokens[index] == "rather" - && tokens.get(index + 1).is_some_and(|t| t == "than")) - || (tokens[index] == "instead" && tokens.get(index + 1).is_some_and(|t| t == "of")); - let start = if tokens[index] == "not" { - Some(index + 1) - } else if starts_two_token_phrase { - Some(index + 2) - } else { - None - }; - - let Some(start) = start else { - continue; - }; - let mut examined = 0; - for token in tokens.iter().skip(start) { - if is_negative_clause_boundary(token) { - break; - } - if is_negative_term_stopword(token) { - continue; - } - examined += 1; - if is_salient_negative_term(token) && seen.insert(token.clone()) { - terms.push(token.clone()); - } - if examined >= 5 { - break; - } - } - } - - terms -} - -#[cfg(test)] -fn text_matches_negative_terms(text: &str, negative_terms: &[String]) -> bool { - if negative_terms.is_empty() { - return false; - } - let terms = normalized_alnum_terms(text) - .into_iter() - .collect::>(); - !terms.is_empty() && negative_terms.iter().all(|term| terms.contains(term)) -} - -#[cfg(test)] -fn normalized_alnum_terms(text: &str) -> Vec { - let mut terms = Vec::new(); - let mut current = String::new(); - for ch in text.chars() { - if ch.is_ascii_alphanumeric() { - current.push(ch.to_ascii_lowercase()); - } else if !current.is_empty() { - terms.push(std::mem::take(&mut current)); - } - } - if !current.is_empty() { - terms.push(current); - } - terms -} - -#[cfg(test)] -fn is_negative_clause_boundary(term: &str) -> bool { - matches!( - term, - "but" | "while" | "whereas" | "although" | "though" | "however" | "except" - ) -} - -#[cfg(test)] -fn is_negative_term_stopword(term: &str) -> bool { - matches!( - term, - "a" | "an" - | "and" - | "are" - | "as" - | "be" - | "by" - | "confused" - | "different" - | "distinguish" - | "from" - | "for" - | "group" - | "in" - | "into" - | "is" - | "method" - | "methods" - | "not" - | "of" - | "on" - | "or" - | "outrank" - | "project" - | "rather" - | "replace" - | "return" - | "should" - | "source" - | "than" - | "the" - | "to" - | "with" - ) -} - -#[cfg(test)] -fn is_salient_negative_term(term: &str) -> bool { - term.len() >= 7 || term.chars().any(|ch| ch.is_ascii_digit()) -} - -#[cfg(test)] -fn compare_node_scores_desc(left: &(NodeId, f32), right: &(NodeId, f32)) -> std::cmp::Ordering { - right - .1 - .total_cmp(&left.1) - .then_with(|| left.0.cmp(&right.0)) -} - -#[cfg(test)] -fn truncate_node_scores(scored: &mut Vec<(NodeId, f32)>, limit: usize) { - if limit == 0 { - scored.clear(); - return; - } - if scored.len() > limit { - let pivot = limit - 1; - scored.select_nth_unstable_by(pivot, compare_node_scores_desc); - scored.truncate(limit); - } - scored.sort_by(compare_node_scores_desc); -} - #[cfg(test)] mod tests { use super::*; @@ -1772,39 +1042,6 @@ mod tests { Ok(()) } - fn test_axis_embedding(axis: usize) -> Vec { - let mut embedding = vec![0.0; EMBEDDING_DIM]; - embedding[axis] = 1.0; - embedding - } - - struct EnvGuard { - key: &'static str, - previous: Option, - } - - impl EnvGuard { - fn set(key: &'static str, value: &str) -> Self { - let previous = std::env::var(key).ok(); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - unsafe { - if let Some(value) = self.previous.as_deref() { - std::env::set_var(self.key, value); - } else { - std::env::remove_var(self.key); - } - } - } - } - #[test] fn symbol_index_session_commits_and_reloads_once_across_windows() -> Result<()> { let directory = tempdir()?; @@ -1933,36 +1170,6 @@ mod tests { assert_eq!(engine.full_text_doc_count(), 2); assert_eq!(engine.search_symbol("Beta"), vec![NodeId(2)]); assert!(engine.search_full_text("betasymbol")?.is_empty()); - - engine.remove_nodes(&[NodeId(2)])?; - assert_eq!(engine.full_text_doc_count(), 1); - assert!(engine.search_symbol("Beta").is_empty()); - Ok(()) - } - - #[test] - fn test_remove_nodes() -> Result<()> { - let mut engine = SearchEngine::new(None)?; - - engine.index_nodes(vec![ - (NodeId(1), "AlphaSymbol".to_string()), - (NodeId(2), "BetaSymbol".to_string()), - (NodeId(3), "GammaSymbol".to_string()), - ])?; - - let before = engine.search_symbol("Beta"); - assert!(before.contains(&NodeId(2))); - assert_eq!(engine.search_full_text("betasymbol")?, vec![NodeId(2)]); - - engine.remove_nodes(&[NodeId(2)])?; - - let after = engine.search_symbol("Beta"); - assert!(!after.contains(&NodeId(2))); - assert!(engine.search_full_text("betasymbol")?.is_empty()); - - let remaining = engine.search_symbol("Gamma"); - assert!(remaining.contains(&NodeId(3))); - Ok(()) } @@ -2026,7 +1233,7 @@ mod tests { } #[test] - fn persisted_search_index_rebuild_reuses_existing_lock() -> Result<()> { + fn persisted_search_index_reader_blocks_writer_until_dropped() -> Result<()> { let dir = tempdir()?; let search_dir = dir.path().join("search"); @@ -2048,8 +1255,8 @@ mod tests { "open_existing should keep writers out while the reader is alive" ); - let mut rebuilt = - SearchEngine::recreate_persisted_from_existing(search_dir.as_path(), existing)?; + drop(existing); + let mut rebuilt = SearchEngine::new(Some(search_dir.as_path()))?; assert!( PersistedSearchIndexGuard::try_acquire_exclusive(search_dir.as_path()).is_err(), "rebuild should keep an exclusive search-index lock while recreating the index" @@ -2067,20 +1274,22 @@ mod tests { } #[test] - fn persisted_search_index_open_failure_rebuild_keeps_lock() -> Result<()> { + fn persisted_search_index_missing_dir_rebuild_holds_exclusive_lock() -> Result<()> { let dir = tempdir()?; let search_dir = dir.path().join("missing.search"); - let (mut engine, open_error) = - SearchEngine::open_existing_or_recreate(search_dir.as_path())?; - assert!(open_error.is_some()); + assert!( + SearchEngine::open_existing(search_dir.as_path()).is_err(), + "a missing persisted generation must not open as an index" + ); + let mut engine = SearchEngine::new(Some(search_dir.as_path()))?; assert!( PersistedSearchIndexGuard::try_acquire_exclusive(search_dir.as_path()).is_err(), - "open-failure fallback should keep an exclusive search-index lock" + "rebuild after an open failure should keep an exclusive search-index lock" ); assert!( PersistedSearchIndexGuard::try_acquire_shared(search_dir.as_path()).is_err(), - "open-failure fallback should block readers until rebuilt" + "rebuild after an open failure should block readers until rebuilt" ); engine.index_nodes(vec![(NodeId(1), "Recovered Symbol".to_string())])?; @@ -2090,381 +1299,4 @@ mod tests { let _guard = PersistedSearchIndexGuard::try_acquire_exclusive(search_dir.as_path())?; Ok(()) } - - #[test] - fn test_hybrid_search_requires_semantic_runtime() -> Result<()> { - let mut engine = SearchEngine::new(None)?; - engine.index_nodes(vec![(NodeId(1), "AlphaSymbol".to_string())])?; - let err = engine - .search_hybrid_with_scores("alpha", &HashMap::new(), HybridSearchConfig::default()) - .expect_err("semantic runtime should be required"); - assert!( - err.to_string().contains("semantic retrieval is required"), - "unexpected error: {err}" - ); - Ok(()) - } - - #[test] - fn test_hybrid_search_scores() -> Result<()> { - let mut engine = SearchEngine::new(None)?; - engine.index_nodes(vec![ - (NodeId(1), "pkg::alpha".to_string()), - (NodeId(2), "pkg::beta".to_string()), - ])?; - engine.set_embedding_runtime(EmbeddingRuntime::test_runtime()); - engine.index_llm_symbol_docs(vec![ - LlmSearchDoc { - node_id: NodeId(1), - file_role: RetrievalFileRole::Source, - doc_text: "alpha symbol".to_string(), - embedding: embed_text_with_hash_projection("alpha symbol", EMBEDDING_DIM), - }, - LlmSearchDoc { - node_id: NodeId(2), - file_role: RetrievalFileRole::Source, - doc_text: "beta symbol".to_string(), - embedding: embed_text_with_hash_projection("beta symbol", EMBEDDING_DIM), - }, - ]); - - let mut graph_boosts = HashMap::new(); - graph_boosts.insert(NodeId(1), 1.0); - - let hits = engine.search_hybrid_with_scores( - "alpha", - &graph_boosts, - HybridSearchConfig { - max_results: 5, - lexical_weight: 0.3, - semantic_weight: 0.5, - graph_weight: 0.2, - lexical_limit: 10, - semantic_limit: 10, - }, - )?; - - assert!(!hits.is_empty()); - assert_eq!(hits[0].node_id, NodeId(1)); - Ok(()) - } - - #[test] - fn test_hybrid_search_semantic_can_win_with_weak_lexical_overlap() -> Result<()> { - let mut engine = SearchEngine::new(None)?; - engine.index_nodes(vec![ - (NodeId(10), "fn_a".to_string()), - (NodeId(11), "fn_b".to_string()), - ])?; - engine.set_embedding_runtime(EmbeddingRuntime::test_runtime()); - engine.index_llm_symbol_docs(vec![ - LlmSearchDoc { - node_id: NodeId(10), - file_role: RetrievalFileRole::Source, - doc_text: "handles authorization policy and permission checks".to_string(), - embedding: embed_text_with_hash_projection( - "handles authorization policy and permission checks", - EMBEDDING_DIM, - ), - }, - LlmSearchDoc { - node_id: NodeId(11), - file_role: RetrievalFileRole::Source, - doc_text: "renders UI theme colors and spacing".to_string(), - embedding: embed_text_with_hash_projection( - "renders UI theme colors and spacing", - EMBEDDING_DIM, - ), - }, - ]); - - let hits = engine.search_hybrid_with_scores( - "permission validation", - &HashMap::new(), - HybridSearchConfig { - max_results: 5, - lexical_weight: 0.2, - semantic_weight: 0.7, - graph_weight: 0.1, - lexical_limit: 5, - semantic_limit: 5, - }, - )?; - - assert!(!hits.is_empty()); - assert_eq!(hits[0].node_id, NodeId(10)); - Ok(()) - } - - #[test] - fn test_explicit_negative_query_terms_keep_salient_distractors_only() { - assert_eq!( - explicit_negative_query_terms( - "choose the compilation database source group, not the Codeblocks project source group" - ), - vec!["codeblocks"] - ); - assert_eq!( - explicit_negative_query_terms( - "choose SourceGroupCxxEmpty rather than the compilation database source group" - ), - vec!["compilation", "database"] - ); - } - - #[test] - fn test_negative_terms_match_candidate_text_only_when_all_terms_are_present() { - let negative_terms = explicit_negative_query_terms( - "choose the Codeblocks source group when project targets produce commands, not compile_commands.json", - ); - - assert!(text_matches_negative_terms( - "SourceGroupCxxCdb uses compile_commands json compilation database files", - &negative_terms - )); - assert!(!text_matches_negative_terms( - "SourceGroupCxxCodeblocks reads project targets", - &negative_terms - )); - } - - #[test] - fn test_hybrid_search_quantized_prefilter_rescores_full_precision() -> Result<()> { - let _lock = crate::process_env_test_lock(); - let _guard = EnvGuard::set(STORED_VECTOR_ENCODING_ENV, "int8"); - - let mut engine = SearchEngine::new(None)?; - assert_eq!( - engine.stored_vector_encoding, - codestory_store::StoredVectorEncoding::Int8 - ); - engine.index_nodes(vec![ - (NodeId(20), "auth_policy".to_string()), - (NodeId(21), "theme_tokens".to_string()), - ])?; - engine.set_embedding_runtime(EmbeddingRuntime::test_runtime()); - engine.index_llm_symbol_docs(vec![ - LlmSearchDoc { - node_id: NodeId(20), - file_role: RetrievalFileRole::Source, - doc_text: "authorization policy permission validation".to_string(), - embedding: embed_text_with_hash_projection( - "authorization policy permission validation", - EMBEDDING_DIM, - ), - }, - LlmSearchDoc { - node_id: NodeId(21), - file_role: RetrievalFileRole::Source, - doc_text: "visual theme color token spacing".to_string(), - embedding: embed_text_with_hash_projection( - "visual theme color token spacing", - EMBEDDING_DIM, - ), - }, - ]); - - assert_eq!(engine.quantized_llm_docs.len(), 2); - let hits = engine.search_hybrid_with_scores( - "permission validation", - &HashMap::new(), - HybridSearchConfig { - max_results: 5, - lexical_weight: 0.0, - semantic_weight: 1.0, - graph_weight: 0.0, - lexical_limit: 1, - semantic_limit: 5, - }, - )?; - - assert!(!hits.is_empty()); - assert_eq!(hits[0].node_id, NodeId(20)); - Ok(()) - } - - #[test] - fn quantized_prefilter_matches_what_the_store_actually_persisted() -> Result<()> { - let _lock = crate::process_env_test_lock(); - // The store is the declared reader of CODESTORY_STORED_VECTOR_ENCODING - // and writes float32 for any value it does not recognise, so the - // prefilter must model float32 too. When the runtime read the variable - // itself it accepted `uint8` as a fourth encoding and quantized the - // in-memory vectors to a width no persisted blob was ever in; the - // durable bytes and the prefilter disagreed about the same setting. - let _guard = EnvGuard::set(STORED_VECTOR_ENCODING_ENV, "uint8"); - assert_eq!( - codestory_store::stored_vector_encoding(), - codestory_store::StoredVectorEncoding::Float32, - "the store writes float32 for an unrecognised encoding" - ); - - let mut engine = SearchEngine::new(None)?; - assert_eq!( - engine.stored_vector_encoding, - codestory_store::StoredVectorEncoding::Float32 - ); - engine.set_embedding_runtime(EmbeddingRuntime::test_runtime()); - engine.index_llm_symbol_docs(vec![LlmSearchDoc { - node_id: NodeId(40), - file_role: RetrievalFileRole::Source, - doc_text: "authorization policy permission validation".to_string(), - embedding: embed_text_with_hash_projection( - "authorization policy permission validation", - EMBEDDING_DIM, - ), - }]); - assert!( - engine.quantized_llm_docs.is_empty(), - "float32 storage must leave the prefilter on full-precision vectors" - ); - Ok(()) - } - - #[test] - fn test_semantic_scores_prefer_primary_docs_for_production_queries() -> Result<()> { - let mut engine = SearchEngine::new(None)?; - engine.index_llm_symbol_docs(vec![ - LlmSearchDoc { - node_id: NodeId(30), - file_role: RetrievalFileRole::Test, - doc_text: "test fixture with exact authorization policy wording".to_string(), - embedding: test_axis_embedding(0), - }, - LlmSearchDoc { - node_id: NodeId(31), - file_role: RetrievalFileRole::Source, - doc_text: "production authorization handler".to_string(), - embedding: test_axis_embedding(1), - }, - ]); - - let scored = - engine.semantic_scores_for_query("authorization policy", &test_axis_embedding(0), 1); - - assert_eq!(scored.len(), 1); - assert_eq!(scored[0].0, NodeId(31)); - assert!( - scored[0].1 < 0.99, - "primary candidate should be retained even when a test doc has a stronger raw vector score" - ); - Ok(()) - } - - #[test] - fn test_semantic_scores_keep_requested_non_primary_docs() -> Result<()> { - let mut engine = SearchEngine::new(None)?; - engine.index_llm_symbol_docs(vec![ - LlmSearchDoc { - node_id: NodeId(40), - file_role: RetrievalFileRole::Test, - doc_text: "test fixture with exact authorization policy wording".to_string(), - embedding: test_axis_embedding(0), - }, - LlmSearchDoc { - node_id: NodeId(41), - file_role: RetrievalFileRole::Source, - doc_text: "production authorization handler".to_string(), - embedding: test_axis_embedding(1), - }, - ]); - - let scored = engine.semantic_scores_for_query( - "authorization policy test", - &test_axis_embedding(0), - 1, - ); - - assert_eq!(scored.len(), 1); - assert_eq!(scored[0].0, NodeId(40)); - Ok(()) - } - - #[test] - fn test_quantized_semantic_prefilter_prefers_primary_docs() -> Result<()> { - let _lock = crate::process_env_test_lock(); - let _guard = EnvGuard::set(STORED_VECTOR_ENCODING_ENV, "int8"); - - let mut engine = SearchEngine::new(None)?; - engine.index_llm_symbol_docs(vec![ - LlmSearchDoc { - node_id: NodeId(50), - file_role: RetrievalFileRole::Docs, - doc_text: "documentation with exact authorization policy wording".to_string(), - embedding: test_axis_embedding(0), - }, - LlmSearchDoc { - node_id: NodeId(51), - file_role: RetrievalFileRole::Source, - doc_text: "production authorization handler".to_string(), - embedding: test_axis_embedding(1), - }, - ]); - - let scored = - engine.semantic_scores_for_query("authorization policy", &test_axis_embedding(0), 1); - - assert_eq!(scored.len(), 1); - assert_eq!(scored[0].0, NodeId(51)); - Ok(()) - } - - #[test] - fn test_float32_semantic_scores_return_bounded_top_candidates() -> Result<()> { - let _lock = crate::process_env_test_lock(); - let _guard = EnvGuard::set(STORED_VECTOR_ENCODING_ENV, "float32"); - - fn axis_embedding(axis: usize) -> Vec { - let mut embedding = vec![0.0; EMBEDDING_DIM]; - embedding[axis] = 1.0; - embedding - } - - let mut engine = SearchEngine::new(None)?; - let docs = (0..8) - .map(|axis| LlmSearchDoc { - node_id: NodeId(20 + axis as i64), - file_role: RetrievalFileRole::Source, - doc_text: format!("doc {axis}"), - embedding: axis_embedding(axis), - }) - .collect(); - engine.index_llm_symbol_docs(docs); - - let scored = engine.semantic_scores(&axis_embedding(0), 1); - - assert_eq!(scored.len(), 1); - assert_eq!(scored[0].0, NodeId(20)); - assert!(engine.semantic_scores(&axis_embedding(0), 0).is_empty()); - Ok(()) - } - - #[test] - fn test_quantized_semantic_scores_return_bounded_top_candidates() -> Result<()> { - let _lock = crate::process_env_test_lock(); - let _guard = EnvGuard::set(STORED_VECTOR_ENCODING_ENV, "int8"); - - fn axis_embedding(axis: usize) -> Vec { - let mut embedding = vec![0.0; EMBEDDING_DIM]; - embedding[axis] = 1.0; - embedding - } - - let mut engine = SearchEngine::new(None)?; - let docs = (0..8) - .map(|axis| LlmSearchDoc { - node_id: NodeId(20 + axis as i64), - file_role: RetrievalFileRole::Source, - doc_text: format!("doc {axis}"), - embedding: axis_embedding(axis), - }) - .collect(); - engine.index_llm_symbol_docs(docs); - - let scored = engine.semantic_scores(&axis_embedding(0), 1); - - assert_eq!(scored.len(), 1); - assert_eq!(scored[0].0, NodeId(20)); - Ok(()) - } } diff --git a/crates/codestory-runtime/src/search/lexical.rs b/crates/codestory-runtime/src/search/lexical.rs deleted file mode 100644 index 1a3821a23..000000000 --- a/crates/codestory-runtime/src/search/lexical.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::engine::{HybridSearchHit, search_symbols_with_scores}; -use codestory_contracts::graph::NodeId; -use nucleo_matcher::Utf32String; -use std::collections::{HashMap, HashSet}; - -use crate::{exact_symbol_query_terms, mixed_natural_language_query, normalize_symbol_query}; - -pub(crate) fn lexical_hybrid_hits_for_symbols( - symbols: &[(Utf32String, NodeId)], - query: &str, - graph_boosts: &HashMap, -) -> Vec { - let lexical = search_symbols_with_scores(symbols, query); - let lexical_max = lexical - .iter() - .map(|(_, score)| *score) - .fold(0.0_f32, f32::max) - .max(1.0); - lexical - .into_iter() - .map(|(node_id, score)| { - let lexical_score = (score / lexical_max).clamp(0.0, 1.0); - let graph_score = graph_boosts - .get(&node_id) - .copied() - .unwrap_or(0.0) - .clamp(0.0, 1.0); - HybridSearchHit { - node_id, - lexical_score, - semantic_score: 0.0, - graph_score, - total_score: (0.85 * lexical_score + 0.15 * graph_score).clamp(0.0, 1.0), - } - }) - .collect() -} - -pub(crate) fn exact_symbol_merged_lexical_hybrid_hits_for_symbols( - symbols: &[(Utf32String, NodeId)], - query: &str, - graph_boosts: &HashMap, -) -> Vec { - let mut hits = Vec::new(); - for term in exact_symbol_merged_lexical_queries(query) { - let additional = lexical_hybrid_hits_for_symbols(symbols, &term, graph_boosts); - merge_hybrid_hits_by_node_id(&mut hits, additional); - } - hits -} - -pub(crate) fn exact_symbol_merged_lexical_queries(query: &str) -> Vec { - let mut queries = Vec::new(); - let mut seen = HashSet::new(); - push_unique_lexical_query(query, &mut queries, &mut seen); - if mixed_natural_language_query(query) { - return queries; - } - for term in exact_symbol_query_terms(query) { - push_unique_lexical_query(&term, &mut queries, &mut seen); - } - queries -} - -fn push_unique_lexical_query(query: &str, queries: &mut Vec, seen: &mut HashSet) { - let trimmed = query.trim(); - if trimmed.is_empty() { - return; - } - let normalized = normalize_symbol_query(trimmed); - let key = if normalized.is_empty() { - trimmed.to_ascii_lowercase() - } else { - normalized - }; - if seen.insert(key) { - queries.push(trimmed.to_string()); - } -} - -fn merge_hybrid_hits_by_node_id(hits: &mut Vec, additional: Vec) { - let mut existing = hits - .iter() - .enumerate() - .map(|(index, hit)| (hit.node_id, index)) - .collect::>(); - - for hit in additional { - if let Some(index) = existing.get(&hit.node_id).copied() { - let current = &mut hits[index]; - current.lexical_score = current.lexical_score.max(hit.lexical_score); - current.semantic_score = current.semantic_score.max(hit.semantic_score); - current.graph_score = current.graph_score.max(hit.graph_score); - current.total_score = current.total_score.max(hit.total_score); - continue; - } - - existing.insert(hit.node_id, hits.len()); - hits.push(hit); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use nucleo_matcher::Utf32String; - - #[test] - fn exact_symbol_merged_lexical_hits_finds_terminal_match() { - let symbols = vec![(Utf32String::from("run_index"), NodeId(1))]; - let hits = exact_symbol_merged_lexical_hybrid_hits_for_symbols( - &symbols, - "run_index", - &HashMap::new(), - ); - assert_eq!(hits.len(), 1); - assert_eq!(hits[0].node_id, NodeId(1)); - } -} diff --git a/crates/codestory-runtime/src/search/mod.rs b/crates/codestory-runtime/src/search/mod.rs index 6f5d88bdc..702e611f1 100644 --- a/crates/codestory-runtime/src/search/mod.rs +++ b/crates/codestory-runtime/src/search/mod.rs @@ -1,3 +1 @@ pub mod engine; -#[cfg(test)] -pub mod lexical; diff --git a/crates/codestory-runtime/src/search_publication.rs b/crates/codestory-runtime/src/search_publication.rs index 9bc645850..51ab0536c 100644 --- a/crates/codestory-runtime/src/search_publication.rs +++ b/crates/codestory-runtime/src/search_publication.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use super::test_sidecar_runtime_from_env; use super::{ ApiError, CancellationToken, EmbeddingProfileContractDto, FileLockKind, HYBRID_RETRIEVAL_ENABLED_ENV, HashMap, IndexPublicationRecord, Instant, LockDeadline, @@ -8,13 +10,6 @@ use super::{ embedding_runtime_availability_from_config, indexing_cancelled_error, is_indexing_cancelled, open_storage_for_read, }; -#[cfg(test)] -use super::{ - embedding_runtime_availability_from_env, hybrid_retrieval_enabled, - test_sidecar_runtime_from_env, -}; -#[cfg(test)] -use crate::semantic_projection::current_embedding_contract_from_env; use crate::semantic_projection::{ SEARCH_SYMBOL_STREAM_BATCH_SIZE, SearchStateBuildStats, current_embedding_contract_for_runtime, load_persisted_semantic_docs_for_runtime, @@ -679,39 +674,6 @@ pub(super) fn load_persisted_search_state_for_runtime( }) } -/// Documents per embedding batch, as the setting's owner reads it. -/// -/// `CODESTORY_LLM_DOC_EMBED_BATCH_SIZE` is declared to -/// `codestory-retrieval/src/config.rs` and read there; publication takes the -/// clamped value rather than parsing the variable again. -#[cfg(test)] -pub(super) fn llm_doc_embed_batch_size() -> usize { - codestory_retrieval::retrieval_runtime_config_from_process_env().llm_doc_embed_batch_size -} - -#[cfg(test)] -pub(super) fn retrieval_state_from_parts( - semantic_doc_count: u32, - embedding_model: Option, - embedding_runtime_available: bool, - fallback_message: Option, - current_embedding: Option, - stored_embedding: Option, - runtime_degraded: bool, -) -> RetrievalStateDto { - retrieval_state_from_parts_with_hybrid( - semantic_doc_count, - embedding_model, - embedding_runtime_available, - fallback_message, - current_embedding, - stored_embedding, - runtime_degraded, - false, - hybrid_retrieval_enabled(), - ) -} - #[allow(clippy::too_many_arguments)] pub(super) fn retrieval_state_from_parts_with_hybrid( semantic_doc_count: u32, @@ -782,55 +744,6 @@ pub(super) fn retrieval_state_from_parts_with_hybrid( } } -#[cfg(test)] -pub(super) fn retrieval_state_from_engine(engine: &SearchEngine) -> RetrievalStateDto { - let probe = embedding_runtime_availability_from_env(); - let current_embedding = current_embedding_contract_from_env(); - retrieval_state_from_parts( - engine.semantic_doc_count(), - engine - .embedding_model_id() - .map(str::to_string) - .or_else(|| { - current_embedding - .as_ref() - .map(|contract| contract.cache_key.clone()) - }) - .or(probe.model_id), - engine.embedding_runtime_configured(), - if engine.embedding_runtime_configured() { - None - } else { - probe.fallback_message - }, - current_embedding, - None, - false, - ) -} - -#[cfg(test)] -pub(super) fn retrieval_state_from_engine_with_storage_contract( - engine: &SearchEngine, - storage_retrieval: &RetrievalStateDto, -) -> RetrievalStateDto { - let mut retrieval = retrieval_state_from_engine(engine); - retrieval.stored_embedding = storage_retrieval.stored_embedding.clone(); - retrieval -} - -#[cfg(test)] -pub(super) fn retrieval_state_from_storage( - storage: &Storage, - project_root: &Path, -) -> Result { - retrieval_state_from_storage_for_runtime( - storage, - project_root, - &test_sidecar_runtime_from_env(), - ) -} - /// Derive agent-visible retrieval readiness from the published retrieval manifest. /// /// Semantic readiness must come from the same publication pointer that admits diff --git a/crates/codestory-runtime/src/search_runtime.rs b/crates/codestory-runtime/src/search_runtime.rs index 2170fc053..5bcf01646 100644 --- a/crates/codestory-runtime/src/search_runtime.rs +++ b/crates/codestory-runtime/src/search_runtime.rs @@ -1,7 +1,7 @@ pub(crate) use crate::search::engine::SearchEngine; pub use crate::search::engine::{ - EmbeddingProfileContract, EmbeddingRuntimeAvailability, HybridSearchConfig, HybridSearchHit, - LlmSearchDoc, embedding_profile_contract_from_config, embedding_profile_contract_from_env, + EmbeddingProfileContract, EmbeddingRuntimeAvailability, LlmSearchDoc, + embedding_profile_contract_from_config, embedding_profile_contract_from_env, embedding_runtime_availability_from_config, embedding_runtime_availability_from_env, }; pub use codestory_contracts::config_registry::STORED_VECTOR_ENCODING_ENV; @@ -10,10 +10,6 @@ pub mod embedding { pub use crate::search::engine::EmbeddingRuntime; } -pub mod hybrid { - pub use crate::search::engine::{HybridSearchConfig, HybridSearchHit, LlmSearchDoc}; -} - pub mod lexical { pub use crate::search::engine::LlmSearchDoc; } @@ -22,10 +18,6 @@ pub mod model_config { pub use codestory_contracts::config_registry::STORED_VECTOR_ENCODING_ENV; } -pub mod semantic { - pub use crate::search::engine::HybridSearchHit; -} - pub mod tantivy_index { pub const ENGINE_IS_RUNTIME_OWNED: &str = "tantivy indexing stays behind SearchService in codestory-runtime"; diff --git a/crates/codestory-runtime/src/search_scoring.rs b/crates/codestory-runtime/src/search_scoring.rs index e75ad3211..81934048f 100644 --- a/crates/codestory-runtime/src/search_scoring.rs +++ b/crates/codestory-runtime/src/search_scoring.rs @@ -4,29 +4,9 @@ use super::{ aggregate_symbol_matches, extract_symbol_search_terms, node_display_name, preferred_occurrence, route_endpoint_adjusted_search_score, symbol_name_match_rank, }; -#[cfg(test)] -use super::{ - EXACT_SYMBOL_HYBRID_MAX_RESULTS_CAP, HybridSearchConfig, HybridSearchHit, RetrievalModeDto, - SearchEngine, apply_hybrid_limits, compare_search_hits_with_project_root, - exact_symbol_query_terms, is_non_primary_source_hit, looks_like_standalone_symbol_query, - mixed_natural_language_query, normalized_hybrid_weights, query_mentions_non_primary_source, -}; -#[cfg(test)] -use codestory_contracts::api::RetrievalScoreBreakdownDto; use crate::agent::packet_evidence::decorate_lexical_search_hit_evidence; -#[cfg(test)] -use crate::agent::packet_evidence::decorate_search_hit_evidence; use crate::controller_symbols::node_names_for_ids; -#[cfg(test)] -use crate::search_publication::{ - retrieval_state_from_engine, retrieval_state_from_engine_with_storage_contract, - retrieval_state_from_parts, retrieval_state_from_storage, -}; -#[cfg(test)] -use crate::search_state::reload_llm_docs_from_storage; -#[cfg(test)] -use crate::semantic_projection::LLM_DOC_RELOAD_BATCH_SIZE; #[derive(Debug, Clone)] pub(crate) struct HybridSearchScoredHit { @@ -50,208 +30,6 @@ impl HybridSearchScoredHit { } } -#[cfg(test)] -pub(super) fn exact_symbol_merged_lexical_hybrid_hits( - engine: &SearchEngine, - query: &str, - graph_boosts: &HashMap, -) -> Vec { - crate::search::lexical::exact_symbol_merged_lexical_hybrid_hits_for_symbols( - engine.symbols(), - query, - graph_boosts, - ) -} - -#[cfg(test)] -pub(super) struct HybridHitsContext<'a> { - pub(super) req: &'a SearchRequest, - pub(super) graph_boosts: &'a HashMap, - pub(super) requested_max_results: usize, - pub(super) request_weights: Option, - pub(super) prefer_primary_sources: bool, - pub(super) storage_retrieval: &'a RetrievalStateDto, - pub(super) use_exact_symbol_lexical_fast_path: bool, -} - -#[cfg(test)] -pub(super) fn hybrid_hits_for_retrieval_state( - engine: &mut SearchEngine, - context: HybridHitsContext<'_>, - retrieval: &mut RetrievalStateDto, -) -> Vec { - let uses_hybrid = !semantic_disabled_by_request_weights(context.request_weights.as_ref()) - && !context.use_exact_symbol_lexical_fast_path - && retrieval.mode == RetrievalModeDto::Hybrid; - let mut hits = if !uses_hybrid { - exact_symbol_merged_lexical_hybrid_hits(engine, &context.req.query, context.graph_boosts) - } else { - let config = hybrid_search_config_for_request( - context.req, - context.requested_max_results, - context.request_weights.clone(), - context.prefer_primary_sources, - ); - match engine.search_hybrid_with_scores(&context.req.query, context.graph_boosts, config) { - Ok(value) => value, - Err(error) => { - tracing::warn!( - "Hybrid retrieval failed for query {:?}; falling back to symbolic ranking: {}", - context.req.query, - error - ); - *retrieval = retrieval_state_from_parts( - engine.semantic_doc_count(), - engine.embedding_model_id().map(str::to_string), - engine.embedding_runtime_configured(), - Some(format!( - "Semantic query fallback engaged after runtime error: {error}" - )), - context.storage_retrieval.current_embedding.clone(), - context.storage_retrieval.stored_embedding.clone(), - true, - ); - exact_symbol_merged_lexical_hybrid_hits( - engine, - &context.req.query, - context.graph_boosts, - ) - } - } - }; - if uses_hybrid - && context.request_weights.is_none() - && !mixed_natural_language_query(&context.req.query) - { - let additional = exact_symbol_merged_lexical_hybrid_hits( - engine, - &context.req.query, - context.graph_boosts, - ); - merge_hybrid_hits_by_node_id(&mut hits, additional); - } - hits -} - -#[cfg(test)] -pub(super) fn exact_symbol_lexical_fast_path( - req: &SearchRequest, - request_weights: Option<&AgentHybridWeightsDto>, -) -> bool { - request_weights.is_none() - && req.hybrid_weights.is_none() - && req - .hybrid_limits - .as_ref() - .and_then(|limits| limits.semantic) - .is_none() - && !exact_symbol_query_terms(&req.query).is_empty() - && has_fast_path_symbol_signal(&req.query) -} - -#[cfg(test)] -pub(super) fn semantic_disabled_by_request_weights( - request_weights: Option<&AgentHybridWeightsDto>, -) -> bool { - request_weights - .and_then(|weights| weights.semantic) - .is_some_and(|semantic| semantic <= f32::EPSILON) -} - -#[cfg(test)] -pub(super) fn has_fast_path_symbol_signal(query: &str) -> bool { - let trimmed = query.trim(); - looks_like_standalone_symbol_query(trimmed) - && (trimmed.contains('_') - || trimmed.contains("::") - || trimmed.contains('.') - || trimmed.contains('$') - || trimmed - .chars() - .next() - .is_some_and(|ch| ch.is_ascii_uppercase()) - || trimmed.chars().skip(1).any(|ch| ch.is_ascii_uppercase())) -} - -#[cfg(test)] -pub(super) fn merge_hybrid_hits_by_node_id( - hits: &mut Vec, - additional: Vec, -) { - let mut existing = hits - .iter() - .enumerate() - .map(|(index, hit)| (hit.node_id, index)) - .collect::>(); - - for hit in additional { - if let Some(index) = existing.get(&hit.node_id).copied() { - let current = &mut hits[index]; - current.lexical_score = current.lexical_score.max(hit.lexical_score); - current.semantic_score = current.semantic_score.max(hit.semantic_score); - current.graph_score = current.graph_score.max(hit.graph_score); - current.total_score = current.total_score.max(hit.total_score); - continue; - } - - existing.insert(hit.node_id, hits.len()); - hits.push(hit); - } -} - -#[cfg(test)] -pub(super) fn hybrid_search_config_for_request( - req: &SearchRequest, - requested_max_results: usize, - request_weights: Option, - prefer_primary_sources: bool, -) -> HybridSearchConfig { - let mut config = HybridSearchConfig { - max_results: requested_max_results, - ..HybridSearchConfig::default() - }; - let has_request_weights = request_weights.is_some(); - let (lexical_weight, semantic_weight, graph_weight) = - normalized_hybrid_weights(request_weights, &config); - config.lexical_weight = lexical_weight; - config.semantic_weight = semantic_weight; - config.graph_weight = graph_weight; - let has_exact_symbol_terms = !exact_symbol_query_terms(&req.query).is_empty(); - let mixed_nl = mixed_natural_language_query(&req.query); - if !has_request_weights && has_exact_symbol_terms && !mixed_nl { - config.lexical_weight = 0.85; - config.semantic_weight = 0.15; - config.graph_weight = 0.0; - config.max_results = requested_max_results - .saturating_mul(5) - .clamp(requested_max_results, EXACT_SYMBOL_HYBRID_MAX_RESULTS_CAP); - config.lexical_limit = config.lexical_limit.max(80); - config.semantic_limit = config.semantic_limit.max(20); - } - apply_hybrid_limits(req.hybrid_limits.clone(), &mut config); - if prefer_primary_sources && !has_exact_symbol_terms { - config.max_results = requested_max_results.saturating_mul(5).min(80); - } - config -} - -#[cfg(test)] -pub(super) fn should_pretruncate_primary_source_window( - query: &str, - prefer_primary_sources: bool, - candidate_count: usize, - requested_max_results: usize, -) -> bool { - prefer_primary_sources - && exact_symbol_query_terms(query).is_empty() - && candidate_count > requested_max_results -} - -#[cfg(test)] -pub(super) fn primary_source_retention_threshold(requested_max_results: usize) -> usize { - requested_max_results.clamp(1, 3) -} - pub(super) fn merge_search_hits_by_node_id(hits: &mut Vec, additional: Vec) { let mut existing = hits .iter() @@ -317,18 +95,6 @@ pub(super) fn did_you_mean_suggestions(scored_hits: &[HybridSearchScoredHit]) -> .collect() } -#[cfg(test)] -#[allow(dead_code)] -#[derive(Debug, Clone, Default)] -pub(super) struct HybridSearchInstrumentation { - pub(super) symbol_table_size: usize, - pub(super) exact_symbol_merge_queries: usize, - pub(super) hybrid_max_results: usize, - pub(super) hybrid_lexical_limit: usize, - pub(super) hybrid_semantic_limit: usize, - pub(super) mixed_natural_language: bool, -} - impl AppController { pub(crate) fn build_search_hit( storage: &Storage, @@ -418,21 +184,6 @@ impl AppController { Ok(Some(hit)) } - #[cfg(test)] - #[allow(dead_code)] - #[cfg(test)] - pub(super) fn is_repo_explanation_search_query(query: &str) -> bool { - let lower = query.to_ascii_lowercase(); - let subject = - lower.contains("repo") || lower.contains("repository") || lower.contains("codebase"); - let intent = lower.contains("fit together") - || lower.contains("how does") - || lower.contains("explain") - || lower.contains("overview") - || lower.contains("architecture"); - subject && intent - } - pub(super) fn expanded_symbol_hits( &self, storage: &Storage, @@ -529,200 +280,4 @@ impl AppController { .map(HybridSearchScoredHit::from_search_hit) .collect()) } - - #[cfg(test)] - #[allow(dead_code)] - fn search_hybrid_scored_inner( - &self, - req: SearchRequest, - focus_node_id: Option, - max_results: usize, - request_weights: Option, - ) -> Result<(Vec, RetrievalStateDto), ApiError> { - self.ensure_search_state()?; - let storage = self.open_storage_read_only()?; - let semantic_disabled = semantic_disabled_by_request_weights(request_weights.as_ref()); - let storage_retrieval = if semantic_disabled { - None - } else { - Some(retrieval_state_from_storage( - &storage, - &self.require_project_root()?, - )?) - }; - let mut graph_boosts = HashMap::::new(); - let requested_max_results = max_results.clamp(1, 50); - let prefer_primary_sources = !query_mentions_non_primary_source(&req.query); - let use_exact_symbol_lexical_fast_path = - exact_symbol_lexical_fast_path(&req, request_weights.as_ref()); - let hybrid_config = hybrid_search_config_for_request( - &req, - requested_max_results, - request_weights.clone(), - prefer_primary_sources, - ); - let exact_symbol_merge_queries = - crate::search::lexical::exact_symbol_merged_lexical_queries(&req.query).len(); - - let focus_core_id = match focus_node_id { - Some(value) => Some(value.to_core()?), - None => None, - }; - if let Some(center) = focus_core_id { - graph_boosts.insert(center, 1.0); - if let Ok(edges) = storage.get_edges_for_node_id(center) { - for edge in edges.into_iter().take(240) { - let (source, target) = edge.effective_endpoints(); - if source != center { - graph_boosts.entry(source).or_insert(0.55); - } - if target != center { - graph_boosts.entry(target).or_insert(0.55); - } - } - } - } - - let (hybrid, node_names, retrieval) = { - let mut s = self.state.lock(); - let engine = s.search_engine.as_mut().ok_or_else(|| { - ApiError::invalid_argument("Search engine not initialized. Open a project first.") - })?; - let mut retrieval = storage_retrieval - .clone() - .unwrap_or_else(|| retrieval_state_from_engine(engine)); - - if !semantic_disabled - && !use_exact_symbol_lexical_fast_path - && retrieval.mode == RetrievalModeDto::Hybrid - && engine.semantic_doc_count() == 0 - { - if !engine.embedding_runtime_configured() - && let Err(error) = - engine.set_embedding_runtime_for_runtime(&self.runtime_config) - { - tracing::warn!( - "Search embedding runtime unavailable during hybrid load: {error}" - ); - } - if engine.embedding_runtime_configured() && engine.semantic_doc_count() == 0 { - reload_llm_docs_from_storage(&storage, engine, LLM_DOC_RELOAD_BATCH_SIZE)?; - } - if let Some(storage_retrieval) = storage_retrieval.as_ref() { - retrieval = retrieval_state_from_engine_with_storage_contract( - engine, - storage_retrieval, - ); - } else { - retrieval = retrieval_state_from_engine(engine); - } - } else if !semantic_disabled - && (engine.semantic_doc_count() > 0 || engine.embedding_runtime_configured()) - && let Some(storage_retrieval) = storage_retrieval.as_ref() - { - retrieval = - retrieval_state_from_engine_with_storage_contract(engine, storage_retrieval); - } - - let context_storage_retrieval = storage_retrieval - .clone() - .unwrap_or_else(|| retrieval.clone()); - let symbol_table_size = engine.symbols().len(); - let hits = hybrid_hits_for_retrieval_state( - engine, - HybridHitsContext { - req: &req, - graph_boosts: &graph_boosts, - requested_max_results, - request_weights, - prefer_primary_sources, - storage_retrieval: &context_storage_retrieval, - use_exact_symbol_lexical_fast_path, - }, - &mut retrieval, - ); - s.last_hybrid_instrumentation = Some(HybridSearchInstrumentation { - symbol_table_size, - exact_symbol_merge_queries, - hybrid_max_results: hybrid_config.max_results, - hybrid_lexical_limit: hybrid_config.lexical_limit, - hybrid_semantic_limit: hybrid_config.semantic_limit, - mixed_natural_language: mixed_natural_language_query(&req.query), - }); - tracing::info!( - symbol_table_size, - exact_symbol_merge_queries, - hybrid_max_results = hybrid_config.max_results, - hybrid_lexical_limit = hybrid_config.lexical_limit, - hybrid_semantic_limit = hybrid_config.semantic_limit, - mixed_nl = mixed_natural_language_query(&req.query), - "hybrid_search_instrumentation" - ); - - let node_names = node_names_for_ids(&s.node_names, hits.iter().map(|hit| hit.node_id)); - (hits, node_names, retrieval) - }; - - let mut out = Vec::with_capacity(hybrid.len()); - for scored in hybrid { - if let Some(mut hit) = - Self::build_search_hit(&storage, &node_names, scored.node_id, scored.total_score)? - { - hit.score_breakdown = Some(RetrievalScoreBreakdownDto { - lexical: scored.lexical_score, - semantic: scored.semantic_score, - graph: scored.graph_score, - total: scored.total_score, - tier_cap: None, - boosts: Vec::new(), - dampening: Vec::new(), - final_rank_reason: None, - provenance: Vec::new(), - }); - decorate_search_hit_evidence(&mut hit); - out.push(HybridSearchScoredHit { - hit, - lexical_score: scored.lexical_score, - semantic_score: scored.semantic_score, - graph_score: scored.graph_score, - total_score: scored.total_score, - }); - } - } - if should_pretruncate_primary_source_window( - &req.query, - prefer_primary_sources, - out.len(), - requested_max_results, - ) { - let top_window_has_non_primary = out - .iter() - .take(requested_max_results) - .any(|scored| is_non_primary_source_hit(&scored.hit)); - if top_window_has_non_primary { - let primary_count = out - .iter() - .filter(|scored| !is_non_primary_source_hit(&scored.hit)) - .count(); - if primary_count >= primary_source_retention_threshold(requested_max_results) { - out.retain(|scored| !is_non_primary_source_hit(&scored.hit)); - } - } else { - out.truncate(requested_max_results); - } - } - let project_root = self.require_project_root().ok(); - out.sort_by(|left, right| { - compare_search_hits_with_project_root( - project_root.as_deref(), - &req.query, - &left.hit, - &right.hit, - None, - ) - }); - out.truncate(requested_max_results); - - Ok((out, retrieval)) - } } diff --git a/crates/codestory-runtime/src/semantic_projection.rs b/crates/codestory-runtime/src/semantic_projection.rs index c8a6b0329..86a108b91 100644 --- a/crates/codestory-runtime/src/semantic_projection.rs +++ b/crates/codestory-runtime/src/semantic_projection.rs @@ -11,8 +11,6 @@ use super::{ semantic_symbol_aliases, semantic_symbol_role_aliases, }; #[cfg(test)] -use super::{embedding_profile_contract_from_env, test_sidecar_runtime_from_env}; -#[cfg(test)] use crate::publication::{PublicationTestBoundary, publication_test_checkpoint}; #[cfg(test)] use crate::search; @@ -497,18 +495,6 @@ impl SemanticDocAliasMode { } } -#[cfg(test)] -pub(super) fn semantic_doc_shape_contract() -> String { - let max_tokens = semantic_doc_max_tokens_from_env(); - format!( - "semantic_doc_version={};scope={};alias_mode={};max_tokens={}", - LLM_SYMBOL_DOC_SCHEMA_VERSION, - semantic_doc_scope_from_env().as_str(), - semantic_doc_alias_mode_from_env().as_str(), - max_tokens - ) -} - pub(super) fn semantic_doc_shape_contract_for_runtime( runtime: &codestory_retrieval::SidecarRuntimeConfig, ) -> String { @@ -521,21 +507,6 @@ pub(super) fn semantic_doc_shape_contract_for_runtime( ) } -#[cfg(test)] -pub(super) fn current_embedding_contract_from_env() -> Option { - let doc_shape = semantic_doc_shape_contract(); - embedding_profile_contract_from_env() - .ok() - .map(|contract| EmbeddingProfileContractDto { - profile: contract.profile, - backend: contract.backend, - model_id: contract.model_id, - cache_key: contract.cache_key, - dimension: contract.dimension, - doc_shape, - }) -} - pub(super) fn current_embedding_contract_for_runtime( runtime: &codestory_retrieval::SidecarRuntimeConfig, ) -> Option { @@ -577,25 +548,6 @@ pub(super) fn semantic_doc_stats_match_contract( && stats.semantic_policy_version.as_deref() == Some(SEMANTIC_POLICY_VERSION) } -/// The retrieval-owned semantic settings for this process. -/// -/// Every `*_from_env` accessor below goes through here. The settings are -/// declared to `codestory-retrieval/src/config.rs`, which reads and clamps -/// them; the runtime interprets the resulting *values*, never the variables. A -/// second parse here is how a clamp drifts: this file used to reject -/// `CODESTORY_SEMANTIC_DOC_MAX_TOKENS=0` back to the default while the owner -/// clamped it to the floor of 16, so the same environment described two -/// different token budgets depending on which code asked. -#[cfg(test)] -fn retrieval_settings() -> codestory_retrieval::RetrievalRuntimeConfig { - codestory_retrieval::retrieval_runtime_config_from_process_env() -} - -#[cfg(test)] -pub(super) fn semantic_doc_scope_from_env() -> SemanticDocScope { - semantic_doc_scope_from_value(&retrieval_settings().semantic_doc_scope) -} - pub(super) fn semantic_doc_scope_from_value(value: &str) -> SemanticDocScope { match value.trim().to_ascii_lowercase().as_str() { "all" | "full" | "all-symbols" | "all_symbols" => SemanticDocScope::AllSymbols, @@ -603,11 +555,6 @@ pub(super) fn semantic_doc_scope_from_value(value: &str) -> SemanticDocScope { } } -#[cfg(test)] -pub(super) fn semantic_doc_alias_mode_from_env() -> SemanticDocAliasMode { - semantic_doc_alias_mode_from_value(&retrieval_settings().semantic_doc_alias_mode) -} - pub(super) fn semantic_doc_alias_mode_from_value(value: &str) -> SemanticDocAliasMode { match value.trim().to_ascii_lowercase().as_str() { "" | "default" | "auto" => SemanticDocAliasMode::AliasVariant, @@ -620,21 +567,6 @@ pub(super) fn semantic_doc_alias_mode_from_value(value: &str) -> SemanticDocAlia } } -#[cfg(test)] -pub(super) fn semantic_doc_max_tokens_from_env() -> usize { - retrieval_settings().semantic_doc_max_tokens -} - -#[cfg(test)] -pub(super) fn stream_pending_llm_symbol_docs_from_env() -> bool { - retrieval_settings().stream_pending_docs -} - -#[cfg(test)] -pub(super) fn semantic_stream_sort_window_batches_from_env() -> usize { - retrieval_settings().stream_sort_window_batches -} - pub(super) fn llm_indexable_kind_for_scope( kind: codestory_contracts::graph::NodeKind, scope: SemanticDocScope, @@ -715,7 +647,7 @@ pub(super) fn llm_indexable_kinds_for_scope( #[cfg(test)] pub(super) fn llm_indexable_kind(kind: codestory_contracts::graph::NodeKind) -> bool { - llm_indexable_kind_for_scope(kind, semantic_doc_scope_from_env()) + llm_indexable_kind_for_scope(kind, SemanticDocScope::DurableSymbols) } pub(super) fn normalize_semantic_store_path(path: &Path) -> String { @@ -1373,7 +1305,7 @@ impl SemanticDocGraphContext { storage, semantic_nodes, all_nodes, - semantic_doc_scope_from_env(), + SemanticDocScope::DurableSymbols, file_paths, file_read_paths, ) @@ -1915,42 +1847,6 @@ pub(super) fn semantic_doc_text_budget_cost(doc_text: &str) -> usize { .sum() } -#[cfg(test)] -pub(super) fn truncate_semantic_doc_text_to_token_budget( - doc_text: &str, - max_tokens: usize, -) -> String { - let mut remaining = max_tokens; - let mut out = String::new(); - - 'lines: for line in doc_text.lines() { - if remaining == 0 { - break; - } - let mut selected = Vec::new(); - for token in line.split_whitespace() { - let cost = semantic_doc_budget_cost(token); - if cost > remaining { - break 'lines; - } - selected.push(token); - remaining -= cost; - } - if selected.is_empty() { - continue; - } - if !out.is_empty() { - out.push('\n'); - } - out.push_str(&selected.join(" ")); - } - - if !out.is_empty() { - out.push('\n'); - } - out -} - fn compact_semantic_doc_fragment(lines: impl IntoIterator, budget: usize) -> String { if budget == 0 { return String::new(); @@ -2179,25 +2075,6 @@ pub(super) fn symbol_excerpt( (signature, comments, body) } -#[cfg(test)] -pub(super) fn build_llm_symbol_doc_text( - graph_context: &SemanticDocGraphContext, - node: &GraphNode, - display_name: &str, - file_path: Option<&str>, - file_text_cache: &HashMap>, -) -> String { - build_llm_symbol_doc_text_with_policy( - graph_context, - node, - display_name, - file_path, - file_text_cache, - semantic_doc_alias_mode_from_env(), - semantic_doc_max_tokens_from_env(), - ) -} - pub(super) fn build_llm_symbol_doc_text_with_policy( graph_context: &SemanticDocGraphContext, node: &GraphNode, @@ -2339,7 +2216,7 @@ pub(super) struct BuiltLlmSymbolDoc { #[cfg(test)] pub(super) fn llm_symbol_doc_hash(doc_text: &str) -> String { - llm_symbol_doc_hash_with_alias(doc_text, semantic_doc_alias_mode_from_env()) + llm_symbol_doc_hash_with_alias(doc_text, SemanticDocAliasMode::AliasVariant) } pub(super) fn llm_symbol_doc_hash_with_alias( @@ -3057,23 +2934,6 @@ pub(super) fn is_retrieval_artifact_node(node: &GraphNode) -> bool { .is_some_and(|canonical_id| canonical_id.starts_with("codestory:component_report:")) } -#[cfg(test)] -pub(super) fn build_component_report_docs( - graph_context: &SemanticDocGraphContext, - semantic_nodes: &[&GraphNode], - existing_docs: &HashMap, - updated_at_epoch_ms: i64, -) -> Vec { - build_component_report_docs_with_policy( - graph_context, - semantic_nodes, - existing_docs, - updated_at_epoch_ms, - semantic_doc_alias_mode_from_env(), - semantic_doc_max_tokens_from_env(), - ) -} - #[derive(Debug)] pub(super) struct ComponentReportNode { node: GraphNode, @@ -3681,18 +3541,18 @@ pub(super) fn publish_component_report_docs( } #[derive(Clone, Copy)] -struct SemanticRuntimePolicy { +pub(super) struct SemanticRuntimePolicy { updated_at_epoch_ms: i64, - anchor_batch_size: usize, - alias_mode: SemanticDocAliasMode, - max_tokens: usize, - stream_sort_window_size: usize, - scope: SemanticDocScope, + pub(super) anchor_batch_size: usize, + pub(super) alias_mode: SemanticDocAliasMode, + pub(super) max_tokens: usize, + pub(super) stream_sort_window_size: usize, + pub(super) scope: SemanticDocScope, max_file_bytes: u64, } impl SemanticRuntimePolicy { - fn from_runtime( + pub(super) fn from_runtime( runtime: &codestory_retrieval::SidecarRuntimeConfig, max_file_bytes: u64, ) -> Self { @@ -4823,26 +4683,6 @@ pub(super) fn sync_full_llm_symbol_projection_streaming_for_runtime( Ok(stats) } -#[cfg(test)] -pub(super) fn finalize_staged_semantic_docs( - storage: &mut Storage, - llm_refresh_file_scope: Option<&HashSet>, - component_report_refresh: Option<&ComponentReportRefreshScope>, - cancel_token: Option<&CancellationToken>, -) -> Result { - finalize_staged_semantic_docs_for_runtime( - storage, - llm_refresh_file_scope, - component_report_refresh, - "core:test-publication", - cancel_token, - &test_sidecar_runtime_from_env(), - SemanticProjectionDocumentSource::SourceFiles { - max_file_bytes: SourceIndexPolicy::default().byte_cap, - }, - ) -} - pub(super) fn finalize_staged_semantic_docs_for_runtime( storage: &mut Storage, llm_refresh_file_scope: Option<&HashSet>, diff --git a/crates/codestory-runtime/src/source_coverage.rs b/crates/codestory-runtime/src/source_coverage.rs deleted file mode 100644 index 2368b4eb8..000000000 --- a/crates/codestory-runtime/src/source_coverage.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! What the published index knows about specific source files. -//! -//! This exists because an incompletely indexed file was invisible to the agent: -//! nothing under `agent/` referenced a coverage reason or an exclusion, so a -//! packet could rest a proof-bearing claim on a file the index had deliberately -//! refused and still report `Sufficient`. -//! -//! The one contract worth stating up front: **this maps, it never filters.** -//! Every requested path gets exactly one observation, including when the lookup -//! fails. A producer that dropped unknown paths would be indistinguishable from -//! one reporting them as covered, which is the defect itself moved one layer up. - -use crate::AppController; -use crate::index_coverage::stored_file_coverage_diagnostics; -use codestory_contracts::api::{ - SourceCoverageNotEstablishedCauseDto, SourceCoverageObservationDto, SourceCoverageStatusDto, -}; -use codestory_workspace::same_workspace_path; -use std::path::{Path, PathBuf}; - -/// Observe coverage for `paths`, one observation each, in the order given. -/// -/// Paths may be absolute or workspace-relative; both are resolved against the -/// project root before comparison. -pub(crate) fn observe_source_coverage( - controller: &AppController, - paths: &[String], -) -> Vec { - if paths.is_empty() { - return Vec::new(); - } - - // Before the project root is known there is nothing to resolve against, so - // this one branch falls back to a raw-string dedup. - let Ok(project_root) = controller.require_project_root() else { - let mut unique: Vec<&String> = Vec::new(); - for path in paths { - if !unique.iter().any(|seen| seen.as_str() == path.as_str()) { - unique.push(path); - } - } - return not_established( - &unique, - SourceCoverageNotEstablishedCauseDto::LookupUnavailable, - ); - }; - - // Deduped by resolved identity, not by string: `exact_packet_probe_paths` - // yields a project-relative spelling while a citation carries an absolute - // one, so a string comparison lets two spellings of one file through and - // the packet ships the same gap twice. - let mut deduped: Vec<&String> = Vec::new(); - for path in paths { - let absolute = absolute_against(&project_root, path); - if !deduped - .iter() - .any(|seen| same_workspace_path(&absolute_against(&project_root, seen), &absolute)) - { - deduped.push(path); - } - } - - // These two are not the same judgement, and EV-78's split is the reason to - // keep them apart: no published core yet is a deliberate, recoverable state, - // while a failed query establishes nothing about anything. - let storage = match controller.open_storage_read_only() { - Ok(storage) => storage, - Err(error) if error.code == "project_unavailable" => { - return not_established( - &deduped, - SourceCoverageNotEstablishedCauseDto::PublicationIncomplete, - ); - } - Err(_) => { - return not_established( - &deduped, - SourceCoverageNotEstablishedCauseDto::LookupUnavailable, - ); - } - }; - let exclusions = match storage.get_source_policy_exclusions() { - Ok(exclusions) => exclusions, - Err(_) => { - return not_established( - &deduped, - SourceCoverageNotEstablishedCauseDto::LookupUnavailable, - ); - } - }; - - // Parser-partial and verified malformed sources survive publication with - // explicit gaps. Neither may be presented as complete indexed coverage. - let diagnostics = match stored_file_coverage_diagnostics(&project_root, &storage) { - Ok(diagnostics) => diagnostics, - Err(_) => { - return not_established( - &deduped, - SourceCoverageNotEstablishedCauseDto::LookupUnavailable, - ); - } - }; - let incomplete: Vec<(PathBuf, codestory_contracts::graph::FileCoverageReason)> = diagnostics - .iter() - .map(|diagnostic| (project_root.join(&diagnostic.path), diagnostic.reason)) - .collect(); - - // Resolved once, not per requested path: the comparison is by path identity - // rather than by string, so each side has to be made absolute first. - // The sizes are carried only for a byte-bound exclusion. A structural - // source refused for its *unit* count has `observed_size <= byte_cap`, so - // rendering "N bytes exceeds the M byte cap" for it would state something - // false about a file the index did read. Those rows get the plain sentence. - let excluded: Vec<(PathBuf, Option<(u64, u64)>)> = exclusions - .iter() - .map(|record| { - let byte_bound = - record.observed_size > record.byte_cap && record.observed_unit_count == 0; - ( - project_root.join(&record.normalized_path), - byte_bound.then_some((record.observed_size, record.byte_cap)), - ) - }) - .collect(); - - deduped - .into_iter() - .map(|path| { - let absolute = absolute_against(&project_root, path); - match excluded - .iter() - .find(|(excluded_path, _)| same_workspace_path(excluded_path, &absolute)) - { - Some((_, sizes)) => SourceCoverageObservationDto { - path: path.clone(), - status: SourceCoverageStatusDto::PolicyExcluded, - reason: None, - not_established_cause: None, - observed_size: sizes.map(|(observed_size, _)| observed_size), - byte_cap: sizes.map(|(_, byte_cap)| byte_cap), - }, - None => match incomplete - .iter() - .find(|(defect_path, _)| same_workspace_path(defect_path, &absolute)) - { - Some((_, reason)) => SourceCoverageObservationDto { - path: path.clone(), - status: SourceCoverageStatusDto::Incomplete, - reason: Some(*reason), - not_established_cause: None, - observed_size: None, - byte_cap: None, - }, - None => SourceCoverageObservationDto { - path: path.clone(), - status: SourceCoverageStatusDto::Indexed, - reason: None, - not_established_cause: None, - observed_size: None, - byte_cap: None, - }, - }, - } - }) - .collect() -} - -fn absolute_against(project_root: &Path, path: &str) -> PathBuf { - let candidate = Path::new(path); - if candidate.is_absolute() { - candidate.to_path_buf() - } else { - project_root.join(candidate) - } -} - -fn not_established( - paths: &[&String], - cause: SourceCoverageNotEstablishedCauseDto, -) -> Vec { - paths - .iter() - .map(|path| SourceCoverageObservationDto { - path: (*path).clone(), - status: SourceCoverageStatusDto::NotEstablished, - reason: None, - not_established_cause: Some(cause), - observed_size: None, - byte_cap: None, - }) - .collect() -} diff --git a/crates/codestory-runtime/src/support.rs b/crates/codestory-runtime/src/support.rs index af79c848d..cc628be02 100644 --- a/crates/codestory-runtime/src/support.rs +++ b/crates/codestory-runtime/src/support.rs @@ -1,7 +1,3 @@ -#[cfg(test)] -use crate::search_runtime::HybridSearchConfig; -#[cfg(test)] -use codestory_contracts::api::{AgentHybridWeightsDto, SearchHybridLimitsDto}; use std::cmp::Ordering; use std::collections::HashMap; use std::collections::HashSet; @@ -27,55 +23,6 @@ pub(crate) fn hybrid_retrieval_enabled() -> bool { codestory_retrieval::hybrid_retrieval_enabled_from_process_env() } -#[cfg(test)] -pub(crate) fn normalized_hybrid_weights( - request_weights: Option, - fallback: &HybridSearchConfig, -) -> (f32, f32, f32) { - let lexical = request_weights - .as_ref() - .and_then(|weights| weights.lexical) - .unwrap_or(fallback.lexical_weight) - .clamp(0.0, 1.0); - let semantic = request_weights - .as_ref() - .and_then(|weights| weights.semantic) - .unwrap_or(fallback.semantic_weight) - .clamp(0.0, 1.0); - let graph = request_weights - .and_then(|weights| weights.graph) - .unwrap_or(fallback.graph_weight) - .clamp(0.0, 1.0); - - let sum = lexical + semantic + graph; - if sum <= f32::EPSILON { - return ( - fallback.lexical_weight, - fallback.semantic_weight, - fallback.graph_weight, - ); - } - - (lexical / sum, semantic / sum, graph / sum) -} - -#[cfg(test)] -pub(crate) fn apply_hybrid_limits( - request_limits: Option, - config: &mut HybridSearchConfig, -) { - const MAX_CANDIDATE_LIMIT: u32 = 1_000; - let Some(limits) = request_limits else { - return; - }; - if let Some(lexical) = limits.lexical { - config.lexical_limit = lexical.min(MAX_CANDIDATE_LIMIT) as usize; - } - if let Some(semantic) = limits.semantic { - config.semantic_limit = semantic.min(MAX_CANDIDATE_LIMIT) as usize; - } -} - pub(crate) fn node_display_name(node: &codestory_contracts::graph::Node) -> String { node.qualified_name .clone() diff --git a/crates/codestory-runtime/src/symbol_query.rs b/crates/codestory-runtime/src/symbol_query.rs index 49da05c23..3ceddcaf2 100644 --- a/crates/codestory-runtime/src/symbol_query.rs +++ b/crates/codestory-runtime/src/symbol_query.rs @@ -193,19 +193,6 @@ pub fn symbol_name_match_rank(query: &str, display_name: &str) -> SymbolNameMatc best_symbol_name_match(query, display_name).0 } -/// Natural-language prompt with embedded symbol-like tokens (not a standalone symbol query). -#[cfg(test)] -pub(crate) fn mixed_natural_language_query(query: &str) -> bool { - let trimmed = query.trim(); - if trimmed.is_empty() || looks_like_standalone_symbol_query(trimmed) { - return false; - } - if !trimmed.contains(char::is_whitespace) { - return false; - } - !exact_symbol_query_terms(query).is_empty() -} - fn trim_symbol_candidate(value: &str) -> &str { value.trim().trim_matches(|ch: char| { !(ch.is_ascii_alphanumeric() || matches!(ch, '_' | '$' | '?' | '!' | '~')) @@ -1757,15 +1744,6 @@ mod tests { assert_eq!(hits.first().map(|hit| &hit.node_id), Some(&exact.node_id)); } - #[test] - fn mixed_natural_language_query_requires_whitespace_and_embedded_symbol() { - assert!(mixed_natural_language_query( - "how ExtensionHostManager starts" - )); - assert!(!mixed_natural_language_query("ExtensionHostManager")); - assert!(!mixed_natural_language_query("explain the architecture")); - } - #[test] fn embedded_generic_terms_do_not_create_exact_symbol_matches() { let exact_generic = hit("generic", "current", NodeKind::VARIABLE, 0.30); diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index 3ea93bc39..5158fb8d9 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -1,45 +1,39 @@ use super::{ - AccessKind, AgentHybridWeightsDto, ApiError, AppController, AppEventPayload, - BUILD_EDGE_SEED_BATCH_SIZE, CURRENT_SCHEMA_VERSION, CancellationToken, - DEFAULT_SOURCE_FILE_BYTE_CAP, DENSE_CENTRAL_RELATIONSHIP_THRESHOLD, - DENSE_CENTRAL_SCORE_THRESHOLD, DIRECT_SNIPPET_MAX_BYTES, DIRECT_SNIPPET_TRUNCATION_SUFFIX, - DenseAnchorCentrality, DenseAnchorInput, DenseAnchorReason, FileInfo, GraphRequest, - GroundingBudgetDto, HYBRID_RETRIEVAL_ENABLED_ENV, HybridSearchConfig, IndexFreshnessStatusDto, - IndexPublicationRecord, IndexWriterGuard, IndexedFileRoleDto, IndexingPhaseTimings, - LEGACY_OVERSIZED_SOURCE_POLICY_VERSION, LLM_DOC_EMBED_BATCH_SIZE_ENV, + AccessKind, ApiError, AppController, AppEventPayload, BUILD_EDGE_SEED_BATCH_SIZE, + CURRENT_SCHEMA_VERSION, CancellationToken, DEFAULT_SOURCE_FILE_BYTE_CAP, + DENSE_CENTRAL_RELATIONSHIP_THRESHOLD, DENSE_CENTRAL_SCORE_THRESHOLD, DIRECT_SNIPPET_MAX_BYTES, + DIRECT_SNIPPET_TRUNCATION_SUFFIX, DenseAnchorCentrality, DenseAnchorInput, DenseAnchorReason, + FileInfo, GraphRequest, GroundingBudgetDto, HYBRID_RETRIEVAL_ENABLED_ENV, + IndexFreshnessStatusDto, IndexPublicationRecord, IndexWriterGuard, IndexedFileRoleDto, + IndexingPhaseTimings, LEGACY_OVERSIZED_SOURCE_POLICY_VERSION, LLM_DOC_EMBED_BATCH_SIZE_ENV, LLM_SYMBOL_DOC_SCHEMA_VERSION, NodeId, OVERSIZED_SOURCE_POLICY_VERSION, PUBLICATION_TEST_FAULT, PendingLlmSymbolDoc, PublicationTestAction, PublicationTestBoundary, RefreshExecutionPlan, RepoTextScanStatsDto, RetrievalFallbackReasonDto, RetrievalIndexManifest, RetrievalModeDto, - RetrievalStateDto, SEMANTIC_DOC_ALIAS_MODE_ENV, SEMANTIC_DOC_DEFAULT_MAX_TOKENS, - SEMANTIC_DOC_MAX_TOKENS_ENV, SEMANTIC_DOC_SCOPE_ENV, SEMANTIC_EDGE_STREAM_BATCH_SIZE, - SEMANTIC_STREAM_PENDING_DOCS_ENV, SEMANTIC_STREAM_SORT_WINDOW_BATCHES_ENV, - SYMBOL_SEARCH_DOC_PROVENANCE, SearchEngine, SearchGenerationCompletion, SearchHit, - SearchHitOrigin, SearchHybridLimitsDto, SearchPlanChannelDto, SearchPlanPromotionStatusDto, - SearchRepoTextMode, SearchRequest, SearchSymbolProjection, SemanticDocAliasMode, - SemanticDocGraphContext, SemanticDocScope, SemanticModeDto, SourceIndexPolicy, - SourcePolicyExclusionPolicyIdentity, Storage, Store, SymbolSearchDoc, TrailConfigDto, - WorkspaceManifest, apply_hybrid_limits, arm_full_refresh_staged_store_hook, - arm_incremental_staged_store_hook, arm_postcommit_before_annotation_rebind_hook, - arm_postcommit_cache_refresh_error, arm_publication_test_fault, - arm_semantic_projection_before_revalidate_hook, arm_source_policy_after_plan_hook, - arm_source_policy_before_revalidate_hook, build_component_report_docs, - build_llm_symbol_doc_text, build_persisted_search_state_from_canonical_symbols, - build_search_state, build_semantic_file_text_cache_with_limits, clamp_usize_to_u32, - compare_search_hits, current_epoch_ms, dense_anchor_is_central, dense_anchor_reason_for_node, - extract_symbol_search_terms, file_text_match_line, finalize_staged_semantic_docs, - flush_pending_dense_anchor_inputs, graph_edge_dto, index_freshness_from_storage, - llm_doc_embed_batch_size, llm_indexable_kind, llm_indexable_kind_for_scope, + SEMANTIC_DOC_ALIAS_MODE_ENV, SEMANTIC_DOC_DEFAULT_MAX_TOKENS, SEMANTIC_DOC_MAX_TOKENS_ENV, + SEMANTIC_DOC_SCOPE_ENV, SEMANTIC_EDGE_STREAM_BATCH_SIZE, SEMANTIC_STREAM_PENDING_DOCS_ENV, + SEMANTIC_STREAM_SORT_WINDOW_BATCHES_ENV, SYMBOL_SEARCH_DOC_PROVENANCE, SearchEngine, + SearchGenerationCompletion, SearchHit, SearchHitOrigin, SearchPlanChannelDto, + SearchPlanPromotionStatusDto, SearchRepoTextMode, SearchRequest, SearchSymbolProjection, + SemanticDocAliasMode, SemanticDocGraphContext, SemanticDocScope, SemanticModeDto, + SemanticRuntimePolicy, SourceIndexPolicy, SourcePolicyExclusionPolicyIdentity, Storage, Store, + SymbolSearchDoc, TrailConfigDto, WorkspaceManifest, aggregate_symbol_matches, + arm_full_refresh_staged_store_hook, arm_incremental_staged_store_hook, + arm_postcommit_before_annotation_rebind_hook, arm_postcommit_cache_refresh_error, + arm_publication_test_fault, arm_semantic_projection_before_revalidate_hook, + arm_source_policy_after_plan_hook, arm_source_policy_before_revalidate_hook, + build_persisted_search_state_from_canonical_symbols, build_search_state, + build_semantic_file_text_cache_with_limits, clamp_usize_to_u32, compare_search_hits, + current_epoch_ms, dense_anchor_is_central, dense_anchor_reason_for_node, + extract_symbol_search_terms, file_text_match_line, flush_pending_dense_anchor_inputs, + graph_edge_dto, index_freshness_from_storage, llm_indexable_kind, llm_indexable_kind_for_scope, llm_indexable_kinds_for_scope, llm_symbol_doc_hash, load_persisted_search_state, - mixed_natural_language_query, node_display_name, normalized_hybrid_weights, - process_env_test_lock, publish_search_engine, query_has_symbol_or_literal_signal, - rebuild_search_state_from_storage, search_generation_completion_path, - search_index_generation_root, search_index_storage_path, semantic_doc_alias_mode_from_env, - semantic_doc_alias_mode_from_value, semantic_doc_max_tokens_from_env, - semantic_doc_scope_from_env, semantic_doc_scope_from_value, semantic_doc_shape_contract, - semantic_doc_text_budget_cost, semantic_stream_sort_window_batches_from_env, - should_expand_symbol_query, sort_pending_dense_anchor_inputs, - stream_pending_llm_symbol_docs_from_env, terminal_symbol_segment, - test_sidecar_runtime_from_env, truncate_semantic_doc_text_to_token_budget, + node_display_name, process_env_test_lock, publish_search_engine, + query_has_symbol_or_literal_signal, rebuild_search_state_from_storage, + search_generation_completion_path, search_index_generation_root, search_index_storage_path, + semantic_doc_alias_mode_from_value, semantic_doc_scope_from_value, + semantic_doc_shape_contract_for_runtime, semantic_doc_text_budget_cost, + should_expand_symbol_query, sort_pending_dense_anchor_inputs, terminal_symbol_segment, + test_sidecar_runtime_from_env, }; use crate::affected::tests::{EnvGuard, assert_mandatory_retrieval_unavailable}; use crate::graph_dto::AppGraphFeatureFlags; @@ -55,7 +49,6 @@ use crate::repo_text::{ }; use crate::route_coverage::framework_route_coverage_matrix; use crate::search; -use crate::search::lexical::exact_symbol_merged_lexical_queries; use crate::search_intent::indexed_file_matches_language_filter; use crate::search_intent::{ SearchIntentFilter, annotate_search_hit_match_quality, apply_search_intent_filters, @@ -73,10 +66,7 @@ use crate::search_publication::{ search_index_path_for_publication, write_search_generation_completion, }; use crate::search_scoring::{ - HybridHitsContext, dedupe_inexact_search_hits_by_display_key, exact_symbol_lexical_fast_path, - exact_symbol_merged_lexical_hybrid_hits, hybrid_hits_for_retrieval_state, - hybrid_search_config_for_request, merge_search_hits_by_node_id, - primary_source_retention_threshold, should_pretruncate_primary_source_window, + dedupe_inexact_search_hits_by_display_key, merge_search_hits_by_node_id, }; use crate::search_terms::search_plan_terms; use crate::semantic_projection::attached_comment_for_symbol; @@ -401,11 +391,18 @@ fn graph_edge_dto_defaults_structural_member_certainty() { } #[test] -fn llm_doc_embed_batch_size_uses_throughput_default() { +fn semantic_policy_uses_the_captured_embed_batch_size_not_ambient_env() { let _lock = process_env_test_lock(); - let _env = EnvGuard::remove(LLM_DOC_EMBED_BATCH_SIZE_ENV); + let _env = EnvGuard::set(LLM_DOC_EMBED_BATCH_SIZE_ENV, "7"); - assert_eq!(llm_doc_embed_batch_size(), 1024); + let mut runtime = test_sidecar_runtime_from_env(); + runtime.retrieval.llm_doc_embed_batch_size = 1_280; + + let policy = SemanticRuntimePolicy::from_runtime(&runtime, DEFAULT_SOURCE_FILE_BYTE_CAP); + assert_eq!( + policy.anchor_batch_size, 1_280, + "semantic publication must size doc batches from the runtime captured at construction" + ); } #[test] @@ -513,42 +510,27 @@ fn framework_route_coverage_matrix_lists_coverage_evidence_and_known_gaps() { } #[test] -fn llm_doc_embed_batch_size_allows_wider_managed_batches() { - let _lock = process_env_test_lock(); - let _env = EnvGuard::set(LLM_DOC_EMBED_BATCH_SIZE_ENV, "1024"); - - assert_eq!(llm_doc_embed_batch_size(), 1024); -} - -#[test] -fn stream_pending_llm_symbol_docs_defaults_to_enabled() { - let _lock = process_env_test_lock(); - let _env = EnvGuard::remove(SEMANTIC_STREAM_PENDING_DOCS_ENV); - assert!(stream_pending_llm_symbol_docs_from_env()); - - let _env = EnvGuard::set(SEMANTIC_STREAM_PENDING_DOCS_ENV, "false"); - assert!(!stream_pending_llm_symbol_docs_from_env()); -} - -#[test] -fn semantic_stream_sort_window_defaults_to_one_batch() { +fn semantic_policy_uses_the_captured_stream_sort_window_not_ambient_env() { let _lock = process_env_test_lock(); - let _env = EnvGuard::remove(SEMANTIC_STREAM_SORT_WINDOW_BATCHES_ENV); - assert_eq!(semantic_stream_sort_window_batches_from_env(), 1); - let _env = EnvGuard::set(SEMANTIC_STREAM_SORT_WINDOW_BATCHES_ENV, "1"); - assert_eq!(semantic_stream_sort_window_batches_from_env(), 1); - let _env = EnvGuard::set(SEMANTIC_STREAM_SORT_WINDOW_BATCHES_ENV, "999"); - assert_eq!(semantic_stream_sort_window_batches_from_env(), 16); + let mut runtime = test_sidecar_runtime_from_env(); + runtime.retrieval.llm_doc_embed_batch_size = 300; + runtime.retrieval.stream_sort_window_batches = 4; + + let policy = SemanticRuntimePolicy::from_runtime(&runtime, DEFAULT_SOURCE_FILE_BYTE_CAP); + assert_eq!(policy.anchor_batch_size, 300); + assert_eq!( + policy.stream_sort_window_size, + 300 * 4, + "the sort window is batch-size times the captured window-batches setting" + ); } #[test] fn semantic_doc_scope_defaults_to_durable_symbols_and_all_scope_is_opt_in() { - let _lock = process_env_test_lock(); - let _env = EnvGuard::remove(SEMANTIC_DOC_SCOPE_ENV); assert_eq!( - semantic_doc_scope_from_env(), + semantic_doc_scope_from_value(""), SemanticDocScope::DurableSymbols ); assert_eq!( @@ -609,10 +591,8 @@ fn semantic_doc_scope_defaults_to_durable_symbols_and_all_scope_is_opt_in() { #[test] fn semantic_doc_alias_mode_defaults_to_alias_variant() { - let _lock = process_env_test_lock(); - let _env = EnvGuard::remove(SEMANTIC_DOC_ALIAS_MODE_ENV); assert_eq!( - semantic_doc_alias_mode_from_env(), + semantic_doc_alias_mode_from_value(""), SemanticDocAliasMode::AliasVariant ); assert_eq!( @@ -630,30 +610,38 @@ fn semantic_doc_alias_mode_defaults_to_alias_variant() { } #[test] -fn semantic_doc_token_budget_defaults_to_safe_window() { +fn semantic_policy_uses_the_captured_doc_token_budget_not_ambient_env() { let _lock = process_env_test_lock(); - let _env = EnvGuard::remove(SEMANTIC_DOC_MAX_TOKENS_ENV); + let _env = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "8192"); + + let mut runtime = test_sidecar_runtime_from_env(); + runtime.retrieval.semantic_doc_max_tokens = 128; + let policy = SemanticRuntimePolicy::from_runtime(&runtime, DEFAULT_SOURCE_FILE_BYTE_CAP); assert_eq!( - semantic_doc_max_tokens_from_env(), - SEMANTIC_DOC_DEFAULT_MAX_TOKENS + policy.max_tokens, 128, + "the doc token budget comes from the captured runtime, not the ambient variable" + ); + assert!( + semantic_doc_shape_contract_for_runtime(&runtime).contains("max_tokens=128"), + "the shape contract must describe the captured budget" ); - assert!(semantic_doc_shape_contract().contains("max_tokens=128")); } #[test] -fn semantic_doc_token_budget_matches_the_owning_module_at_the_clamp_floor() { +fn semantic_policy_honours_the_owner_clamp_floor_in_the_captured_runtime() { let _lock = process_env_test_lock(); // The setting is declared to codestory-retrieval/src/config.rs, which - // clamps a below-floor request up to 16. The runtime used to read the - // variable itself and reject a zero back to the 128-token default, so the - // same environment described a 16-token budget to publication planning and - // a 128-token budget to the projection that wrote the docs. + // clamps a below-floor request up to 16. A runtime captured under that + // environment must keep the clamped value through projection, even when + // the ambient variable later changes. let _env = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "0"); + let runtime = test_sidecar_runtime_from_env(); + let _env2 = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "4096"); - let owner = codestory_retrieval::retrieval_runtime_config_from_process_env(); - assert_eq!(owner.semantic_doc_max_tokens, 16); - assert_eq!(semantic_doc_max_tokens_from_env(), 16); + let policy = SemanticRuntimePolicy::from_runtime(&runtime, DEFAULT_SOURCE_FILE_BYTE_CAP); + assert_eq!(policy.max_tokens, 16); + assert!(semantic_doc_shape_contract_for_runtime(&runtime).contains("max_tokens=16")); } #[test] @@ -696,6 +684,24 @@ fn pending_semantic_doc_for_test(node_id: i64, doc_text: &str) -> PendingLlmSymb } } +pub(super) fn finalize_semantic_docs_for_test( + storage: &mut Storage, + cancel_token: Option<&CancellationToken>, + runtime: &codestory_retrieval::SidecarRuntimeConfig, +) -> Result { + crate::semantic_projection::finalize_staged_semantic_docs_for_runtime( + storage, + None, + None, + "core:test-publication", + cancel_token, + runtime, + crate::semantic_projection::SemanticProjectionDocumentSource::SourceFiles { + max_file_bytes: SourceIndexPolicy::default().byte_cap, + }, + ) +} + fn semantic_policy_node(id: i64, kind: NodeKind, name: &str, file_id: i64) -> Node { Node { id: CoreNodeId(id), @@ -1458,8 +1464,14 @@ fn component_reports_are_extracted_dense_anchors_with_virtual_ids() { context .edge_digests .insert(node.id, vec!["CALL=9".to_string()]); - let reports = - build_component_report_docs(&context, &[&node], &std::collections::HashMap::new(), 123); + let reports = build_component_report_docs_with_policy( + &context, + &[&node], + &std::collections::HashMap::new(), + 123, + SemanticDocAliasMode::AliasVariant, + SEMANTIC_DOC_DEFAULT_MAX_TOKENS, + ); assert_eq!(reports.len(), 1); let report = &reports[0]; @@ -1595,11 +1607,13 @@ fn semantic_graph_context_keeps_normalized_paths_once_per_file() { context.file_read_path_for_node(&function_node), Some("C:/work/nvm/nvm.sh") ); - let reports = build_component_report_docs( + let reports = build_component_report_docs_with_policy( &context, &semantic_nodes, &std::collections::HashMap::new(), 123, + SemanticDocAliasMode::AliasVariant, + SEMANTIC_DOC_DEFAULT_MAX_TOKENS, ); assert_eq!(reports.len(), 1); assert_eq!(reports[0].symbol_doc.file_path.as_deref(), Some("nvm.sh")); @@ -2055,6 +2069,8 @@ fn semantic_doc_text_for_test( qualified_name: Option<&str>, file_path: &str, kind: NodeKind, + alias_mode: SemanticDocAliasMode, + max_tokens: usize, ) -> String { let node = Node { id: CoreNodeId(10), @@ -2067,20 +2083,19 @@ fn semantic_doc_text_for_test( }; let graph_context = SemanticDocGraphContext::default(); let file_text_cache = HashMap::new(); - build_llm_symbol_doc_text( + build_llm_symbol_doc_text_with_policy( &graph_context, &node, display_name, Some(file_path), &file_text_cache, + alias_mode, + max_tokens, ) } #[test] fn semantic_doc_text_adds_symbol_aliases_for_supported_language_naming_styles() { - let _lock = process_env_test_lock(); - let _env = EnvGuard::set(SEMANTIC_DOC_ALIAS_MODE_ENV, "current_alias"); - let _budget = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "512"); let cases = [ ( "rust", @@ -2141,8 +2156,14 @@ fn semantic_doc_text_adds_symbol_aliases_for_supported_language_naming_styles() ]; for (language, file_path, display_name, qualified_name, terminal_alias, full_alias) in cases { - let doc = - semantic_doc_text_for_test(display_name, qualified_name, file_path, NodeKind::FUNCTION); + let doc = semantic_doc_text_for_test( + display_name, + qualified_name, + file_path, + NodeKind::FUNCTION, + SemanticDocAliasMode::CurrentAlias, + 512, + ); assert!( doc.contains(&format!("language: {language}")), "doc should include language for {file_path}:\n{doc}" @@ -2160,14 +2181,13 @@ fn semantic_doc_text_adds_symbol_aliases_for_supported_language_naming_styles() #[test] fn semantic_doc_text_adds_kind_role_owner_and_path_alias_context() { - let _lock = process_env_test_lock(); - let _env = EnvGuard::set(SEMANTIC_DOC_ALIAS_MODE_ENV, "current_alias"); - let _budget = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "512"); let doc = semantic_doc_text_for_test( "AppController::openProjectWithStoragePath", Some("codestory_runtime::AppController::openProjectWithStoragePath"), "crates/codestory-runtime/src/lib.rs", NodeKind::METHOD, + SemanticDocAliasMode::CurrentAlias, + 512, ); assert!( @@ -2192,9 +2212,6 @@ fn semantic_doc_text_adds_kind_role_owner_and_path_alias_context() { #[test] fn semantic_doc_text_reserves_signature_and_body_before_comments_without_fragments() { - let _lock = process_env_test_lock(); - let _env = EnvGuard::set(SEMANTIC_DOC_ALIAS_MODE_ENV, "current_alias"); - let _budget = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "128"); let file_path = r"\\?\C:\Users\alber\AppData\Local\Temp\codestory-search-quality-fixture-with-a-long-path\src\architecture.ts"; let oversized_comment = "OVERSIZED_COMMENT_TOKEN".repeat(24); let file_text = format!( @@ -2217,12 +2234,14 @@ export class SourceGroupCxxCdb {{ let mut file_text_cache = HashMap::new(); file_text_cache.insert(file_path.to_string(), Some(file_text)); - let doc = build_llm_symbol_doc_text( + let doc = build_llm_symbol_doc_text_with_policy( &SemanticDocGraphContext::default(), &node, "SourceGroupCxxCdb", Some(file_path), &file_text_cache, + SemanticDocAliasMode::AliasVariant, + SEMANTIC_DOC_DEFAULT_MAX_TOKENS, ); let signature = doc @@ -2244,14 +2263,13 @@ export class SourceGroupCxxCdb {{ #[test] fn semantic_doc_text_token_budget_respects_configured_limit() { - let _lock = process_env_test_lock(); - let _alias = EnvGuard::set(SEMANTIC_DOC_ALIAS_MODE_ENV, "current_alias"); - let _budget = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "48"); let doc = semantic_doc_text_for_test( "AppController::openProjectWithStoragePath", Some("codestory_runtime::AppController::openProjectWithStoragePath"), "crates/codestory-runtime/src/lib.rs", NodeKind::METHOD, + SemanticDocAliasMode::CurrentAlias, + 48, ); assert!( @@ -2273,27 +2291,41 @@ fn semantic_doc_text_token_budget_respects_configured_limit() { } #[test] -fn semantic_doc_text_token_budget_charges_long_identifiers() { - let doc = concat!( - "semantic_doc_version: 1\n", - "symbol: AppController::openProjectWithStoragePath\n", - "path_aliases: crates codestory runtime src lib rs app controller open project ", - "storage path AppControllerOpenProjectWithStoragePathRepeatedRepeated\n", +fn semantic_doc_builder_holds_long_identifiers_inside_the_token_budget() { + let hostile = "AppControllerOpenProjectWithStoragePathRepeatedRepeatedRepeatedRepeatedSuffix"; + let mut node = semantic_policy_node(10, NodeKind::FUNCTION, "build_doc", 1); + node.qualified_name = Some(format!("pkg::session::{hostile}")); + let context = semantic_policy_context("crates/codestory-runtime/src/lib.rs", &node); + + let doc = build_llm_symbol_doc_text_with_policy( + &context, + &node, + "build_doc", + Some("crates/codestory-runtime/src/lib.rs"), + &HashMap::new(), + SemanticDocAliasMode::CurrentAlias, + 48, ); - let truncated = truncate_semantic_doc_text_to_token_budget(doc, 36); assert!( - semantic_doc_text_budget_cost(&truncated) <= 36, - "budgeted semantic doc should stay under the conservative token proxy:\n{truncated}" + semantic_doc_text_budget_cost(&doc) <= 48, + "the live builder must hold the doc inside the conservative token proxy:\n{doc}" ); assert!( - truncated.split_whitespace().count() < doc.split_whitespace().count(), - "long identifier-heavy docs should be truncated earlier than whitespace counts alone" + doc.starts_with("semantic_doc_version:"), + "the live builder must keep the leading version field:\n{doc}" ); assert!( - truncated.contains("symbol: AppController::openProjectWithStoragePath"), - "budgeted semantic doc should retain leading symbol identity:\n{truncated}" + !doc.contains(hostile) || doc.split_whitespace().any(|token| token.contains(hostile)), + "unexpected identifier duplication:\n{doc}" ); + if !doc.contains(hostile) { + assert!( + !doc.split_whitespace() + .any(|token| hostile.starts_with(token) && token.len() >= 12), + "a hostile identifier must be dropped whole, never split mid-token:\n{doc}" + ); + } } fn copy_tictactoe_workspace() -> tempfile::TempDir { @@ -2445,9 +2477,6 @@ fn search_plan_test_hit( #[test] fn repo_explanation_overview_replacement_is_generic_only() { - assert!(AppController::is_repo_explanation_search_query( - "Explain how this repo fits together" - )); assert!(!query_has_symbol_or_literal_signal( "Explain how this repo fits together" )); @@ -4769,8 +4798,12 @@ fn a_partially_parsed_file_is_reported_incomplete_not_indexed() { .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) .expect("a parser-partial file must not block publication"); - let observations = - crate::source_coverage::observe_source_coverage(&controller, &["job-store.ts".to_string()]); + let storage = Storage::open(&storage_path).expect("open storage"); + let observations = crate::agent::packet_compiler::observe_admitted_source_coverage( + &controller, + &storage, + &["job-store.ts".to_string()], + ); assert_eq!(observations.len(), 1); assert_eq!( observations[0].status, @@ -4819,6 +4852,8 @@ fn coverage_observation_matches_an_exclusion_by_path_identity() { .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) .expect("publish complete core"); + let storage = Storage::open(&storage_path).expect("open storage"); + // Every spelling a citation might carry for the same file must resolve to // the one exclusion row. for spelling in [ @@ -4830,8 +4865,9 @@ fn coverage_observation_matches_an_exclusion_by_path_identity() { std::path::MAIN_SEPARATOR ), ] { - let observations = crate::source_coverage::observe_source_coverage( + let observations = crate::agent::packet_compiler::observe_admitted_source_coverage( &controller, + &storage, std::slice::from_ref(&spelling), ); assert_eq!(observations.len(), 1, "{spelling}: {observations:?}"); @@ -4840,41 +4876,37 @@ fn coverage_observation_matches_an_exclusion_by_path_identity() { codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, "spelling {spelling} must resolve to the exclusion row: {observations:?}" ); - assert_eq!( - observations[0].byte_cap, - Some(codestory_contracts::workspace::DEFAULT_STRUCTURAL_SOURCE_BYTE_CAP), - "the observation must carry the cap that refused the file" - ); } - // And two spellings of one file are one file: the packet must not ship the - // same gap twice. This is why the dedup compares path identity rather than - // strings, like everything else here. - let duplicated = crate::source_coverage::observe_source_coverage( + // Two identical display spellings of one file are one gap: the packet must + // not ship the same exclusion twice. + let duplicated = crate::agent::packet_compiler::observe_admitted_source_coverage( &controller, - &[ - "docs/api.json".to_string(), - structural.to_string_lossy().to_string(), - ], + &storage, + &["docs/api.json".to_string(), "docs/api.json".to_string()], ); assert_eq!( duplicated.len(), 1, - "two spellings of one file must dedup: {duplicated:?}" + "two identical display spellings of one file must dedup: {duplicated:?}" ); // Distinct files still get one observation each — the map-not-filter // contract, which the dedup must not quietly break. - let distinct = crate::source_coverage::observe_source_coverage( + let distinct = crate::agent::packet_compiler::observe_admitted_source_coverage( &controller, - &["docs/api.json".to_string(), "game.kt".to_string()], + &storage, + &["docs/api.json".to_string(), "rust_tictactoe.rs".to_string()], ); assert_eq!(distinct.len(), 2, "{distinct:?}"); // A file the index did cover must not be reported as excluded, or the cap // would fire on every packet in the repository. - let covered = - crate::source_coverage::observe_source_coverage(&controller, &["game.kt".to_string()]); + let covered = crate::agent::packet_compiler::observe_admitted_source_coverage( + &controller, + &storage, + &["rust_tictactoe.rs".to_string()], + ); assert_eq!(covered.len(), 1); assert_eq!( covered[0].status, @@ -7252,8 +7284,9 @@ fn staged_semantic_finalization_repairs_mixed_dense_anchor_contracts() { insert_semantic_fixture_nodes(&mut storage, &file_path); let _env = hybrid_test_env(); - let initial_stats = finalize_staged_semantic_docs(&mut storage, None, None, None) - .expect("initial finalization"); + let initial_stats = + finalize_semantic_docs_for_test(&mut storage, None, &test_sidecar_runtime_from_env()) + .expect("initial finalization"); assert!(initial_stats.docs_pending > 0); assert_eq!(initial_stats.docs_embedded, 0); let seeded_docs = storage @@ -7277,8 +7310,9 @@ fn staged_semantic_finalization_repairs_mixed_dense_anchor_contracts() { ) .expect("mark one dense anchor contract as stale"); - let repair_stats = finalize_staged_semantic_docs(&mut storage, None, None, None) - .expect("mixed dense anchor contract should force finalization"); + let repair_stats = + finalize_semantic_docs_for_test(&mut storage, None, &test_sidecar_runtime_from_env()) + .expect("mixed dense anchor contract should force finalization"); assert!(repair_stats.docs_pending > 0); assert_eq!(repair_stats.docs_embedded, 0); @@ -7370,8 +7404,9 @@ fn staged_full_semantic_projection_streams_bounded_node_pages() { .expect("insert shared endpoint edges"); let _env = hybrid_test_env(); - let stats = finalize_staged_semantic_docs(&mut storage, None, None, None) - .expect("stream semantic projection"); + let stats = + finalize_semantic_docs_for_test(&mut storage, None, &test_sidecar_runtime_from_env()) + .expect("stream semantic projection"); assert_eq!(stats.node_load_rows, 4_097); assert_eq!(stats.selected_nodes, 4_097); @@ -7390,8 +7425,9 @@ fn staged_full_semantic_projection_streams_bounded_node_pages() { 4_097 ); - let _scope = EnvGuard::set(SEMANTIC_DOC_SCOPE_ENV, "all"); - let all_scope_stats = finalize_staged_semantic_docs(&mut storage, None, None, None) + let mut all_scope_runtime = test_sidecar_runtime_from_env(); + all_scope_runtime.retrieval.semantic_doc_scope = "all".to_string(); + let all_scope_stats = finalize_semantic_docs_for_test(&mut storage, None, &all_scope_runtime) .expect("repeat all-symbol stream"); assert_eq!(all_scope_stats.node_load_rows, 4_098); assert_eq!(all_scope_stats.selected_nodes, 4_097); @@ -7624,7 +7660,6 @@ fn staged_semantic_stream_matches_legacy_bytes_order_pruning_and_component_repor const STALE_NODE_ID: CoreNodeId = CoreNodeId(900_000); let _env = hybrid_test_env(); - let _tokens = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "8192"); let temp = tempdir().expect("create temp dir"); let mut files = Vec::new(); let mut file_nodes = Vec::new(); @@ -7779,9 +7814,11 @@ fn staged_semantic_stream_matches_legacy_bytes_order_pruning_and_component_repor let mut streamed = Storage::open_build(&streamed_path).expect("open staged store"); seed(&mut legacy); seed(&mut streamed); - let legacy_stats = finalize_staged_semantic_docs(&mut legacy, None, None, None) + let mut runtime = test_sidecar_runtime_from_env(); + runtime.retrieval.semantic_doc_max_tokens = 8_192; + let legacy_stats = finalize_semantic_docs_for_test(&mut legacy, None, &runtime) .expect("build legacy semantic projection"); - let streamed_stats = finalize_staged_semantic_docs(&mut streamed, None, None, None) + let streamed_stats = finalize_semantic_docs_for_test(&mut streamed, None, &runtime) .expect("build streamed semantic projection"); let normalize_symbol_docs = |storage: &Storage| { @@ -7970,204 +8007,7 @@ fn embedded_exact_symbol_terms_count_and_annotate_exact_hits() { } #[test] -fn exact_symbol_queries_skip_primary_source_pretruncate() { - assert!( - !should_pretruncate_primary_source_window("StorageAccess", true, 250, 10), - "exact symbol queries need final exact-symbol sorting before truncation" - ); - assert!(should_pretruncate_primary_source_window( - "how search ranking works", - true, - 250, - 10 - )); - assert!(!should_pretruncate_primary_source_window( - "how search ranking works", - false, - 250, - 10 - )); -} - -#[test] -fn exact_symbol_fast_path_is_conservative() { - let req = |query: &str, - hybrid_weights: Option, - hybrid_limits: Option| SearchRequest { - query: query.to_string(), - repo_text: SearchRepoTextMode::Off, - limit_per_source: 10, - expand_search_plan: false, - hybrid_weights, - hybrid_limits, - }; - - assert!(exact_symbol_lexical_fast_path( - &req("Workbench", None, None), - None - )); - assert!(exact_symbol_lexical_fast_path( - &req("Subcommand::Exec", None, None), - None - )); - assert!(exact_symbol_lexical_fast_path( - &req("check_winner", None, None), - None - )); - assert!(!exact_symbol_lexical_fast_path( - &req("authorization", None, None), - None - )); - assert!(!exact_symbol_lexical_fast_path( - &req("how ExtensionService starts", None, None), - None - )); - assert!(!exact_symbol_lexical_fast_path( - &req( - "Workbench", - None, - Some(SearchHybridLimitsDto { - lexical: None, - semantic: Some(20), - }), - ), - None - )); - - let weights = AgentHybridWeightsDto { - lexical: Some(0.25), - semantic: Some(0.75), - graph: None, - }; - assert!(!exact_symbol_lexical_fast_path( - &req("Workbench", Some(weights.clone()), None), - Some(&weights) - )); -} - -#[test] -fn exact_symbol_merged_lexical_queries_dedupe_exact_anchor_scan() { - assert_eq!( - exact_symbol_merged_lexical_queries("Workbench"), - vec!["Workbench".to_string()] - ); - assert_eq!( - exact_symbol_merged_lexical_queries("Subcommand::Exec"), - vec!["Subcommand::Exec".to_string(), "Exec".to_string()] - ); - assert_eq!( - exact_symbol_merged_lexical_queries("how ExtensionHostManager starts"), - vec!["how ExtensionHostManager starts".to_string()] - ); -} - -#[test] -fn exact_symbol_fast_path_returns_lexical_hits_without_semantic_fallback() { - let mut engine = SearchEngine::new(None).expect("search engine"); - engine - .index_nodes(vec![(CoreNodeId(1), "Workbench".to_string())]) - .expect("index nodes"); - let req = SearchRequest { - query: "Workbench".to_string(), - repo_text: SearchRepoTextMode::Off, - limit_per_source: 10, - expand_search_plan: false, - hybrid_weights: None, - hybrid_limits: None, - }; - let storage_retrieval = RetrievalStateDto { - mode: RetrievalModeDto::Hybrid, - hybrid_configured: true, - semantic_ready: true, - semantic_mode: SemanticModeDto::Enabled, - semantic_doc_count: 170_000, - embedding_model: Some("test-model".to_string()), - current_embedding: None, - stored_embedding: None, - fallback_reason: None, - fallback_message: None, - }; - let graph_boosts = HashMap::new(); - let mut retrieval = storage_retrieval.clone(); - let use_exact_symbol_lexical_fast_path = exact_symbol_lexical_fast_path(&req, None); - - let hits = hybrid_hits_for_retrieval_state( - &mut engine, - HybridHitsContext { - req: &req, - graph_boosts: &graph_boosts, - requested_max_results: 10, - request_weights: None, - prefer_primary_sources: true, - storage_retrieval: &storage_retrieval, - use_exact_symbol_lexical_fast_path, - }, - &mut retrieval, - ); - - assert!(use_exact_symbol_lexical_fast_path); - assert_eq!(hits.first().map(|hit| hit.node_id), Some(CoreNodeId(1))); - assert_eq!(hits[0].semantic_score, 0.0); - assert_eq!(retrieval.fallback_reason, None); - assert_eq!(retrieval.fallback_message, None); -} - -#[test] -fn zero_semantic_request_weights_use_lexical_hits_without_semantic_fallback() { - let mut engine = SearchEngine::new(None).expect("search engine"); - engine - .index_nodes(vec![(CoreNodeId(1), "ExtensionHostManager".to_string())]) - .expect("index nodes"); - let req = SearchRequest { - query: "ExtensionHostManager".to_string(), - repo_text: SearchRepoTextMode::Off, - limit_per_source: 10, - expand_search_plan: false, - hybrid_weights: None, - hybrid_limits: None, - }; - let storage_retrieval = RetrievalStateDto { - mode: RetrievalModeDto::Hybrid, - hybrid_configured: true, - semantic_ready: true, - semantic_mode: SemanticModeDto::Enabled, - semantic_doc_count: 170_000, - embedding_model: Some("test-model".to_string()), - current_embedding: None, - stored_embedding: None, - fallback_reason: None, - fallback_message: None, - }; - let graph_boosts = HashMap::new(); - let mut retrieval = storage_retrieval.clone(); - let request_weights = AgentHybridWeightsDto { - lexical: Some(1.0), - semantic: Some(0.0), - graph: Some(0.0), - }; - - let hits = hybrid_hits_for_retrieval_state( - &mut engine, - HybridHitsContext { - req: &req, - graph_boosts: &graph_boosts, - requested_max_results: 10, - request_weights: Some(request_weights), - prefer_primary_sources: true, - storage_retrieval: &storage_retrieval, - use_exact_symbol_lexical_fast_path: false, - }, - &mut retrieval, - ); - - assert_eq!(hits.first().map(|hit| hit.node_id), Some(CoreNodeId(1))); - assert_eq!(hits[0].semantic_score, 0.0); - assert_eq!(retrieval.fallback_reason, None); - assert_eq!(retrieval.fallback_message, None); -} - -#[test] -fn exact_symbol_merged_lexical_hits_include_terminal_symbol_matches() { +fn expanded_symbol_search_keeps_terminal_matches_and_uniqueness() { let mut engine = SearchEngine::new(None).expect("search engine"); engine .index_nodes(vec![ @@ -8180,21 +8020,23 @@ fn exact_symbol_merged_lexical_hits_include_terminal_symbol_matches() { ]) .expect("index nodes"); - let hits = exact_symbol_merged_lexical_hybrid_hits( - &engine, - "exec_events::ThreadEvent", - &HashMap::new(), - ); - let ids = hits.iter().map(|hit| hit.node_id).collect::>(); + let query = "exec_events::ThreadEvent"; + let direct = engine.search_symbol_with_scores(query); + let mut expanded = Vec::new(); + for term in extract_symbol_search_terms(query) { + expanded.extend(engine.search_symbol_with_scores(&term)); + } + let merged = aggregate_symbol_matches(direct, expanded); + let ids: Vec<_> = merged.iter().map(|(id, _)| *id).collect(); assert!( ids.contains(&CoreNodeId(2)), - "terminal exact symbol should be admitted beside qualified aliases: {ids:?}" + "the terminal segment must admit the exact terminal symbol beside qualified aliases: {ids:?}" ); assert_eq!( ids.iter().filter(|id| **id == CoreNodeId(2)).count(), 1, - "exact-symbol merging should preserve node uniqueness: {ids:?}" + "symbol merging should preserve node uniqueness: {ids:?}" ); } diff --git a/crates/codestory-runtime/src/tests/search_intent.rs b/crates/codestory-runtime/src/tests/search_intent.rs index 167a6d2e7..b5d997ba2 100644 --- a/crates/codestory-runtime/src/tests/search_intent.rs +++ b/crates/codestory-runtime/src/tests/search_intent.rs @@ -1,7 +1,7 @@ use super::{ Path, SearchHit, SearchIntentFilter, apply_search_intent_filters, extract_symbol_search_terms, - indexed_file_matches_language_filter, language_filter_matches_path, - mixed_natural_language_query, parse_search_intent_query, should_expand_symbol_query, + indexed_file_matches_language_filter, language_filter_matches_path, parse_search_intent_query, + should_expand_symbol_query, }; #[test] @@ -162,12 +162,3 @@ fn should_expand_symbol_query_for_sentence_prompts() { 5 )); } - -#[test] -fn mixed_natural_language_query_detects_embedded_symbol_prompts() { - assert!(mixed_natural_language_query( - "how ExtensionHostManager starts" - )); - assert!(!mixed_natural_language_query("Workbench")); - assert!(!mixed_natural_language_query("Subcommand::Exec")); -} diff --git a/crates/codestory-runtime/src/tests/search_plan.rs b/crates/codestory-runtime/src/tests/search_plan.rs index 7a8cebe5b..431c99f35 100644 --- a/crates/codestory-runtime/src/tests/search_plan.rs +++ b/crates/codestory-runtime/src/tests/search_plan.rs @@ -929,66 +929,6 @@ fn ordering_reduces_to_the_lexical_comparator_when_graph_evidence_is_absent() { assert_eq!(report.confidence, GroundingOrientationConfidenceDto::Weak); } -#[test] -fn smaller_limit_results_are_an_exact_prefix_of_larger_limit_results_for_one_candidate_set() { - let query = "explain how the subsystems connect end to end"; - let candidates = vec![ - orientation_hit("a", "zqOne", "src/a.ts", SearchHitOrigin::IndexedSymbol), - orientation_hit("b", "zqOne", "src/b.ts", SearchHitOrigin::IndexedSymbol), - orientation_hit("c", "zqTwo", "src/b.ts", SearchHitOrigin::IndexedSymbol), - orientation_hit("d", "zqThree", "src/c.ts", SearchHitOrigin::IndexedSymbol), - ]; - let mut evidence = OrientationEvidence::default(); - for (index, hit) in candidates.iter().enumerate() { - evidence.insert( - hit.node_id.clone(), - hit_evidence( - EntryEvidence::None, - false, - CallDegrees { - production_in_calls: index as u32, - out_calls: 0, - }, - 1, - hit.file_path.as_deref().unwrap_or_default(), - ), - ); - } - - // Re-run the whole ordering pipeline per limit rather than slicing one - // result, so a stage that consulted the limit would break the prefix. - let run = |limit: usize| { - let mut hits = candidates.clone(); - hits.sort_by(|left, right| { - compare_search_hits_with_project_root(None, query, left, right, Some(&evidence)) - }); - let mut ordered = diversify_root_order( - hits, - |_| false, - |hit| { - ( - hit.file_path.clone().unwrap_or_default(), - hit.display_name.clone(), - ) - }, - ); - ordered.truncate(limit); - ordered - .into_iter() - .map(|hit| hit.node_id.0) - .collect::>() - }; - - let full = run(candidates.len()); - for smaller in 0..=candidates.len() { - assert_eq!( - run(smaller), - full[..smaller], - "the order changed with the limit at {smaller}" - ); - } -} - #[test] fn a_candidate_the_graph_window_did_not_reach_still_ranks_on_its_own_structure() { let query = "explain how the subsystems connect end to end"; diff --git a/crates/codestory-runtime/src/tests/search_scoring.rs b/crates/codestory-runtime/src/tests/search_scoring.rs index a677217da..5ffecdd4e 100644 --- a/crates/codestory-runtime/src/tests/search_scoring.rs +++ b/crates/codestory-runtime/src/tests/search_scoring.rs @@ -1,51 +1,48 @@ use super::{ - AgentHybridWeightsDto, AppController, CancellationToken, CoreNodeId, Edge, EdgeId, EdgeKind, - EnvGuard, GroundingBudgetDto, HYBRID_RETRIEVAL_ENABLED_ENV, HashMap, HybridSearchConfig, - IndexFreshnessStatusDto, IndexMode, Node, NodeId, NodeKind, Occurrence, OccurrenceKind, - OpenProjectRequest, Path, PathBuf, PublicationTestAction, PublicationTestBoundary, - ResolutionCertainty, RetrievalModeDto, SEMANTIC_DOC_ALIAS_MODE_ENV, - SEMANTIC_DOC_MAX_TOKENS_ENV, SearchEngine, SearchGenerationCatalogGuard, - SearchGenerationCompletion, SearchHit, SearchRepoTextMode, SearchRequest, - SearchSymbolProjection, SemanticProjectionStats, SnapshotStore, SourceLocation, Storage, - apply_hybrid_limits, arm_publication_test_fault, assert_mandatory_retrieval_unavailable, - assert_no_staged_publication_artifacts, build_persisted_search_state_from_canonical_symbols, - build_search_state, compare_search_hits, copy_tictactoe_workspace, current_epoch_ms, - dedupe_inexact_search_hits_by_display_key, default_source_policy_identity, - finalize_staged_semantic_docs, flush_pending_dense_anchor_inputs, fs, - hybrid_search_config_for_request, hybrid_test_env, insert_semantic_fixture_nodes, + AppController, CancellationToken, CoreNodeId, Edge, EdgeId, EdgeKind, EnvGuard, + GroundingBudgetDto, HYBRID_RETRIEVAL_ENABLED_ENV, HashMap, IndexFreshnessStatusDto, IndexMode, + Node, NodeId, NodeKind, Occurrence, OccurrenceKind, OpenProjectRequest, Path, PathBuf, + PublicationTestAction, PublicationTestBoundary, ResolutionCertainty, RetrievalModeDto, + SearchEngine, SearchGenerationCatalogGuard, SearchGenerationCompletion, SearchHit, + SearchRepoTextMode, SearchRequest, SearchSymbolProjection, SemanticDocAliasMode, + SemanticProjectionStats, SnapshotStore, SourceLocation, Storage, arm_publication_test_fault, + assert_mandatory_retrieval_unavailable, assert_no_staged_publication_artifacts, + build_persisted_search_state_from_canonical_symbols, build_search_state, compare_search_hits, + copy_tictactoe_workspace, current_epoch_ms, dedupe_inexact_search_hits_by_display_key, + default_source_policy_identity, finalize_semantic_docs_for_test, + flush_pending_dense_anchor_inputs, fs, hybrid_test_env, insert_semantic_fixture_nodes, llm_symbol_doc_hash, load_persisted_search_state, merge_search_hits_by_node_id, - normalized_hybrid_weights, pending_semantic_doc_for_test, persisted_search_generation_names, - primary_source_retention_threshold, process_env_test_lock, project_identity_v3, - prune_search_generations, rebuild_search_state_from_storage, search_generation_completion_path, - search_index_generation_root, search_index_path_for_publication, search_index_storage_path, - semantic_doc_text_for_test, semantic_projection_republish_for_runtime, tempdir, - test_index_publication, test_retrieval_manifest, test_sidecar_runtime_from_env, unbounded, + pending_semantic_doc_for_test, persisted_search_generation_names, process_env_test_lock, + project_identity_v3, prune_search_generations, rebuild_search_state_from_storage, + search_generation_completion_path, search_index_generation_root, + search_index_path_for_publication, search_index_storage_path, semantic_doc_text_for_test, + semantic_projection_republish_for_runtime, tempdir, test_index_publication, + test_retrieval_manifest, test_sidecar_runtime_from_env, unbounded, write_search_generation_completion, write_semantic_fixture, }; use codestory_contracts::bounded_locks::{self, FileLockKind}; #[test] fn semantic_doc_text_alias_modes_are_switchable_for_research() { - let _lock = process_env_test_lock(); - let _budget = EnvGuard::set(SEMANTIC_DOC_MAX_TOKENS_ENV, "512"); - let no_alias = EnvGuard::set(SEMANTIC_DOC_ALIAS_MODE_ENV, "no_alias"); let no_alias_doc = semantic_doc_text_for_test( "AppController::openProjectWithStoragePath", Some("codestory_runtime::AppController::openProjectWithStoragePath"), "crates/codestory-runtime/src/lib.rs", NodeKind::METHOD, + SemanticDocAliasMode::NoAlias, + 512, ); let no_alias_hash = llm_symbol_doc_hash(&no_alias_doc); assert!(!no_alias_doc.contains("terminal_alias:")); assert!(!no_alias_doc.contains("path_aliases:")); - drop(no_alias); - let variant = EnvGuard::set(SEMANTIC_DOC_ALIAS_MODE_ENV, "alias_variant"); let variant_doc = semantic_doc_text_for_test( "AppController::openProjectWithStoragePath", Some("codestory_runtime::AppController::openProjectWithStoragePath"), "crates/codestory-runtime/src/lib.rs", NodeKind::METHOD, + SemanticDocAliasMode::AliasVariant, + 512, ); let variant_hash = llm_symbol_doc_hash(&variant_doc); assert!(variant_doc.contains("terminal_alias: open project with storage path")); @@ -54,19 +51,18 @@ fn semantic_doc_text_alias_modes_are_switchable_for_research() { assert!(!variant_doc.contains("name_aliases:")); assert!(!variant_doc.contains("path_aliases:")); assert_ne!(no_alias_hash, variant_hash); - drop(variant); - let current = EnvGuard::set(SEMANTIC_DOC_ALIAS_MODE_ENV, "current_alias"); let current_doc = semantic_doc_text_for_test( "AppController::openProjectWithStoragePath", Some("codestory_runtime::AppController::openProjectWithStoragePath"), "crates/codestory-runtime/src/lib.rs", NodeKind::METHOD, + SemanticDocAliasMode::CurrentAlias, + 512, ); assert!(current_doc.contains("name_aliases:")); assert!(current_doc.contains("path_aliases:")); assert_ne!(variant_hash, llm_symbol_doc_hash(¤t_doc)); - drop(current); } #[test] @@ -2076,7 +2072,8 @@ fn completed_search_cache_load_does_not_mutate_live_semantic_rows() { "dddddddd-dddd-4ddd-8ddd-dddddddddddd", )) .expect("publish identity"); - finalize_staged_semantic_docs(&mut storage, None, None, None).expect("finalize semantic rows"); + finalize_semantic_docs_for_test(&mut storage, None, &test_sidecar_runtime_from_env()) + .expect("finalize semantic rows"); let before_legacy = storage .get_all_llm_symbol_docs() .expect("legacy semantic rows before cache load"); @@ -2116,7 +2113,6 @@ fn completed_search_cache_load_does_not_mutate_live_semantic_rows() { let result = rebuild_search_state_from_storage(&mut storage, &storage_path, None, true) .expect("hydrate cache without semantic persistence"); - assert!(!result.engine.semantic_index_ready()); assert_eq!(result.engine.full_text_doc_count(), result.node_names.len()); assert_eq!( storage @@ -2149,8 +2145,12 @@ fn completed_search_cache_load_does_not_mutate_live_semantic_rows() { ) .expect_err("cancelled semantic persistence must stop before DB upsert"); assert_eq!(error.code, "cancelled"); - let error = finalize_staged_semantic_docs(&mut storage, None, None, Some(&cancel_token)) - .expect_err("cancelled semantic finalization must stop before persistence"); + let error = finalize_semantic_docs_for_test( + &mut storage, + Some(&cancel_token), + &test_sidecar_runtime_from_env(), + ) + .expect_err("cancelled semantic finalization must stop before persistence"); assert_eq!(error.code, "cancelled"); } @@ -2185,7 +2185,7 @@ fn persisted_search_generations_do_not_overwrite_a_racing_reader() { .expect("build old search generation"); let old_path = search_index_path_for_publication(&storage_path, Some(&old_publication)).expect("old path"); - let same_generation_reader = SearchEngine::try_open_existing(&old_path) + let same_generation_reader = SearchEngine::open_existing(&old_path) .expect("completed builder must retain only a shared generation lock"); assert_eq!(same_generation_reader.tantivy_doc_count(), 3); drop(same_generation_reader); @@ -2636,14 +2636,6 @@ fn merge_search_hits_by_node_id_keeps_stronger_expanded_score() { assert_eq!(hits[0].score, 250.0); } -#[test] -fn primary_source_retention_keeps_short_precise_windows() { - assert_eq!(primary_source_retention_threshold(1), 1); - assert_eq!(primary_source_retention_threshold(3), 3); - assert_eq!(primary_source_retention_threshold(10), 3); - assert_eq!(primary_source_retention_threshold(50), 3); -} - #[test] fn inexact_search_results_deduplicate_repeated_display_keys() { let mut hits = vec![ @@ -2776,18 +2768,81 @@ fn exact_search_results_keep_repeated_display_keys() { assert_eq!(hits.len(), 2); } +/// F023: the live `limit_per_source` owner must make a smaller result an exact +/// prefix of the larger one over the same candidate set. The retired test +/// truncated a local ordering itself, so a limit-aware ordering stage could +/// reorder freely without failing. #[test] -fn hybrid_search_config_skips_exact_symbol_escalation_for_mixed_nl() { - let req = SearchRequest { - query: "how ExtensionHostManager starts".to_string(), - repo_text: SearchRepoTextMode::Off, - limit_per_source: 10, - expand_search_plan: false, - hybrid_weights: None, - hybrid_limits: None, +fn live_search_limit_keeps_smaller_limits_a_prefix_of_larger_ones() { + let process_cache = tempfile::tempdir().expect("owned runtime cache root"); + let temp = tempdir().expect("create temp dir"); + let db_path = temp.path().join("codestory.db"); + let file_path = temp.path().join("src").join("lib.rs"); + + { + let mut storage = Storage::open(&db_path).expect("open storage"); + let mut nodes = vec![Node { + id: CoreNodeId(10), + kind: NodeKind::FILE, + serialized_name: file_path.to_string_lossy().to_string(), + ..Default::default() + }]; + for (index, name) in [ + "ZqPrefixProbeAlpha", + "ZqPrefixProbeBeta", + "ZqPrefixProbeGamma", + "ZqPrefixProbeDelta", + ] + .iter() + .enumerate() + { + nodes.push(Node { + id: CoreNodeId(20 + index as i64), + kind: NodeKind::FUNCTION, + serialized_name: format!("zq::{name}"), + qualified_name: Some(format!("zq::{name}")), + file_node_id: Some(CoreNodeId(10)), + start_line: Some(10 + index as u32), + ..Default::default() + }); + } + storage.insert_nodes_batch(&nodes).expect("insert nodes"); + } + + let controller = AppController::new_with_owned_cache_root(process_cache.path()); + controller + .open_project_with_storage_path(temp.path().to_path_buf(), db_path.clone()) + .expect("open project"); + + let run = |limit: u32| { + controller + .search_results(SearchRequest { + query: "ZqPrefixProbe".to_string(), + repo_text: SearchRepoTextMode::Off, + limit_per_source: limit, + expand_search_plan: false, + hybrid_weights: None, + hybrid_limits: None, + }) + .expect("repo-text off search uses the published core") + .indexed_symbol_hits + .iter() + .map(|hit| hit.node_id.0.clone()) + .collect::>() }; - let config = hybrid_search_config_for_request(&req, 10, None, true); - assert_eq!(config.max_results, 10); + + let full = run(10); + assert!( + full.len() >= 3, + "the fixture must produce several live hits: {full:?}" + ); + for smaller in 1..full.len() { + assert_eq!( + run(smaller as u32), + full[..smaller], + "the live ordering changed with limit_per_source at {smaller}" + ); + } } #[test] @@ -2988,64 +3043,3 @@ fn search_after_summary_open_stays_sidecar_primary_without_runtime_refresh() { assert!(state.search_engine.is_none()); assert!(state.node_names.is_empty()); } - -#[test] -fn normalized_hybrid_weights_clamps_and_normalizes_values() { - let fallback = HybridSearchConfig::default(); - let (lexical, semantic, graph) = normalized_hybrid_weights( - Some(AgentHybridWeightsDto { - lexical: Some(2.0), - semantic: Some(-1.0), - graph: Some(0.5), - }), - &fallback, - ); - - assert!((lexical - 0.666_666_7).abs() < 1e-4); - assert!((semantic - 0.0).abs() < 1e-6); - assert!((graph - 0.333_333_34).abs() < 1e-4); -} - -#[test] -fn normalized_hybrid_weights_falls_back_when_invalid_sum() { - let fallback = HybridSearchConfig::default(); - let (lexical, semantic, graph) = normalized_hybrid_weights( - Some(AgentHybridWeightsDto { - lexical: Some(0.0), - semantic: Some(0.0), - graph: Some(0.0), - }), - &fallback, - ); - - assert!((lexical - fallback.lexical_weight).abs() < 1e-6); - assert!((semantic - fallback.semantic_weight).abs() < 1e-6); - assert!((graph - fallback.graph_weight).abs() < 1e-6); -} - -#[test] -fn hybrid_search_defaults_to_accuracy_first_semantic_profile() { - let config = HybridSearchConfig::default(); - - assert_eq!(config.max_results, 20); - assert_eq!(config.lexical_weight, 0.0); - assert_eq!(config.semantic_weight, 1.0); - assert_eq!(config.graph_weight, 0.0); - assert_eq!(config.lexical_limit, 0); - assert_eq!(config.semantic_limit, 20); -} - -#[test] -fn apply_hybrid_limits_overrides_and_caps_values() { - let mut config = HybridSearchConfig::default(); - apply_hybrid_limits( - Some(codestory_contracts::api::SearchHybridLimitsDto { - lexical: Some(0), - semantic: Some(5_000), - }), - &mut config, - ); - - assert_eq!(config.lexical_limit, 0); - assert_eq!(config.semantic_limit, 1_000); -} From cace7f3912b9e55770d229e62fad667e1aeb0cfa Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 10:29:33 -0400 Subject: [PATCH 12/58] isolate ambient convenience-api tests from the process cache --- crates/codestory-retrieval/src/index.rs | 7 +- crates/codestory-retrieval/src/query.rs | 93 +++++++++++++++---------- 2 files changed, 61 insertions(+), 39 deletions(-) diff --git a/crates/codestory-retrieval/src/index.rs b/crates/codestory-retrieval/src/index.rs index 371b74d35..8b155f0a0 100644 --- a/crates/codestory-retrieval/src/index.rs +++ b/crates/codestory-retrieval/src/index.rs @@ -5382,13 +5382,16 @@ mod tests { let _env = crate::test_support::env_lock(); let project = TempDir::new().expect("project dir"); let storage_dir = TempDir::new().expect("storage dir"); + let cache_root = TempDir::new().expect("cache root"); let storage_path = storage_dir.path().join("codestory.db"); { let storage = Store::open(&storage_path).expect("open empty db"); drop(storage); } - let error = finalize_index(project.path(), &storage_path) - .expect_err("empty stores cannot satisfy mandatory sidecar indexing"); + let error = crate::config::with_test_cache_root(cache_root.path(), || { + finalize_index(project.path(), &storage_path) + }) + .expect_err("empty stores cannot satisfy mandatory sidecar indexing"); let message = format!("{error:#}"); assert!( message.contains("mandatory") diff --git a/crates/codestory-retrieval/src/query.rs b/crates/codestory-retrieval/src/query.rs index 5481e0b1d..0ffcdab20 100644 --- a/crates/codestory-retrieval/src/query.rs +++ b/crates/codestory-retrieval/src/query.rs @@ -2961,6 +2961,7 @@ mod tests { ) .expect("write"); let storage_dir = TempDir::new().expect("storage"); + let cache_root = TempDir::new().expect("cache root"); let storage_path = storage_dir.path().join("codestory.db"); { let mut storage = Store::open(&storage_path).expect("open db"); @@ -3040,19 +3041,23 @@ mod tests { }]) .expect("semantic doc"); } - if let Err(error) = finalize_index(project.path(), &storage_path) { + if let Err(error) = crate::config::with_test_cache_root(cache_root.path(), || { + finalize_index(project.path(), &storage_path) + }) { eprintln!( "skipping live retrieval query fixture because sidecar indexing failed: {error:#}" ); return; } - let result = execute_retrieval_query(QueryRequest { - project_root: project.path(), - storage_path: &storage_path, - query: "extension", - budget_ms: Some(500), - cancelled: None, + let result = crate::config::with_test_cache_root(cache_root.path(), || { + execute_retrieval_query(QueryRequest { + project_root: project.path(), + storage_path: &storage_path, + query: "extension", + budget_ms: Some(500), + cancelled: None, + }) }) .expect("query"); @@ -3064,6 +3069,7 @@ mod tests { fn query_rejects_legacy_manifest_before_sidecar_access() { let project = TempDir::new().expect("project"); let storage_dir = TempDir::new().expect("storage"); + let cache_root = TempDir::new().expect("cache root"); let storage_path = storage_dir.path().join("codestory.db"); let project_id = crate::index::project_id_for_root(project.path()); { @@ -3096,12 +3102,14 @@ mod tests { .expect("manifest"); } - let error = execute_retrieval_query(QueryRequest { - project_root: project.path(), - storage_path: &storage_path, - query: "ExtensionHostManager", - budget_ms: Some(100), - cancelled: None, + let error = crate::config::with_test_cache_root(cache_root.path(), || { + execute_retrieval_query(QueryRequest { + project_root: project.path(), + storage_path: &storage_path, + query: "ExtensionHostManager", + budget_ms: Some(100), + cancelled: None, + }) }) .expect_err("legacy manifests must fail closed"); @@ -3112,6 +3120,7 @@ mod tests { fn query_rejects_manifest_with_stale_projection_count() { let project = TempDir::new().expect("project"); let storage_dir = TempDir::new().expect("storage"); + let cache_root = TempDir::new().expect("cache root"); let storage_path = storage_dir.path().join("codestory.db"); let project_id = crate::index::project_id_for_root(project.path()); { @@ -3121,12 +3130,14 @@ mod tests { .expect("manifest"); } - let error = execute_retrieval_query(QueryRequest { - project_root: project.path(), - storage_path: &storage_path, - query: "ExtensionHostManager", - budget_ms: Some(100), - cancelled: None, + let error = crate::config::with_test_cache_root(cache_root.path(), || { + execute_retrieval_query(QueryRequest { + project_root: project.path(), + storage_path: &storage_path, + query: "ExtensionHostManager", + budget_ms: Some(100), + cancelled: None, + }) }) .expect_err("stale manifests must fail closed"); @@ -3137,6 +3148,7 @@ mod tests { fn query_rejects_manifest_when_indexed_file_changes_or_is_removed() { let project = TempDir::new().expect("project"); let storage_dir = TempDir::new().expect("storage"); + let cache_root = TempDir::new().expect("cache root"); let storage_path = storage_dir.path().join("codestory.db"); let source_path = project.path().join("src").join("lib.rs"); std::fs::create_dir_all(source_path.parent().expect("source parent")) @@ -3169,12 +3181,14 @@ mod tests { std::thread::sleep(std::time::Duration::from_millis(5)); std::fs::write(&source_path, "pub fn indexed() -> usize { 1 }\n").expect("mutate source"); - let changed_error = execute_retrieval_query(QueryRequest { - project_root: project.path(), - storage_path: &storage_path, - query: "indexed", - budget_ms: Some(100), - cancelled: None, + let changed_error = crate::config::with_test_cache_root(cache_root.path(), || { + execute_retrieval_query(QueryRequest { + project_root: project.path(), + storage_path: &storage_path, + query: "indexed", + budget_ms: Some(100), + cancelled: None, + }) }) .expect_err("changed indexed file must fail closed"); assert!( @@ -3184,12 +3198,14 @@ mod tests { ); std::fs::remove_file(&source_path).expect("remove source"); - let removed_error = execute_retrieval_query(QueryRequest { - project_root: project.path(), - storage_path: &storage_path, - query: "indexed", - budget_ms: Some(100), - cancelled: None, + let removed_error = crate::config::with_test_cache_root(cache_root.path(), || { + execute_retrieval_query(QueryRequest { + project_root: project.path(), + storage_path: &storage_path, + query: "indexed", + budget_ms: Some(100), + cancelled: None, + }) }) .expect_err("removed indexed file must fail closed"); assert!( @@ -3203,6 +3219,7 @@ mod tests { fn query_rejects_manifest_when_new_indexable_file_is_added() { let project = TempDir::new().expect("project"); let storage_dir = TempDir::new().expect("storage"); + let cache_root = TempDir::new().expect("cache root"); let storage_path = storage_dir.path().join("codestory.db"); let source_path = project.path().join("src").join("lib.rs"); std::fs::create_dir_all(source_path.parent().expect("source parent")) @@ -3238,12 +3255,14 @@ mod tests { ) .expect("write new source"); - let error = execute_retrieval_query(QueryRequest { - project_root: project.path(), - storage_path: &storage_path, - query: "newly_added", - budget_ms: Some(100), - cancelled: None, + let error = crate::config::with_test_cache_root(cache_root.path(), || { + execute_retrieval_query(QueryRequest { + project_root: project.path(), + storage_path: &storage_path, + query: "newly_added", + budget_ms: Some(100), + cancelled: None, + }) }) .expect_err("new indexable file must fail closed"); From 8622819b761ff16fd485afae19875b2ee0c4bf48 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 11:44:50 -0400 Subject: [PATCH 13/58] repair publication test coverage and remove empty core stage dirs --- .../self_test_full_stack_external.py | 97 ++++++++++++--- .../src/agent/packet_compiler.rs | 113 ++++++++++++++++++ .../src/indexed_source_call_path_v1.rs | 44 +++++-- crates/codestory-runtime/src/tests.rs | 48 ++++++-- .../src/tests/search_scoring.rs | 98 +++++++-------- .../codestory-store/src/storage_impl/mod.rs | 3 +- 6 files changed, 318 insertions(+), 85 deletions(-) diff --git a/.github/scripts/packaged_agent_proof/self_test_full_stack_external.py b/.github/scripts/packaged_agent_proof/self_test_full_stack_external.py index a700cd2dc..b0e519570 100644 --- a/.github/scripts/packaged_agent_proof/self_test_full_stack_external.py +++ b/.github/scripts/packaged_agent_proof/self_test_full_stack_external.py @@ -161,20 +161,89 @@ def _publication_fault_hostile( external_package, external_contracts = _external_contracts(fixture) publication_path = publication.path publication_payload = publication.payload - hostile_publication = json.loads(json.dumps(publication_payload)) - hostile_publication["assertions"] = {"lost_publication_lease_blocks_commit": True} - write_json(publication_path, hostile_publication) - try: - verify_publication_fault_raw_evidence( - publication_path, - source=manifest["source"], - package=external_package, - contracts=external_contracts, - ) - except ProofFailure: - pass - else: - raise ProofFailure("self-declared publication assertions were accepted") + + def expect_rejected(label: str, mutate) -> None: + hostile = json.loads(json.dumps(publication_payload)) + mutate(hostile) + write_json(publication_path, hostile) + try: + verify_publication_fault_raw_evidence( + publication_path, + source=manifest["source"], + package=external_package, + contracts=external_contracts, + ) + except ProofFailure: + return + raise ProofFailure(f"hostile publication evidence was accepted: {label}") + + expect_rejected( + "self-declared assertions", + lambda payload: payload.__setitem__( + "assertions", {"lost_publication_lease_blocks_commit": True} + ), + ) + # Lease: a revalidation that succeeded must not certify a blocked commit. + expect_rejected( + "lease revalidation reported as held", + lambda payload: payload["publication_hook_events"][2].__setitem__( + "status", "succeeded" + ), + ) + # Fence: a committed manifest must not certify a blocked publication. + expect_rejected( + "manifest commit reported as committed", + lambda payload: payload["publication_hook_events"][3].__setitem__( + "status", "committed" + ), + ) + # Fence: the lease event cannot be silently dropped from the sequence. + expect_rejected( + "missing lease revalidation event", + lambda payload: payload["publication_hook_events"].pop(2), + ) + # Fence: hook clocks cannot move backwards within the commit. + expect_rejected( + "non-monotonic hook clock", + lambda payload: payload["publication_hook_events"][3]["clock"].__setitem__( + "elapsed_ns", 0 + ), + ) + # The candidate must have failed; a successful commit is not a blocked one. + expect_rejected( + "candidate commit succeeded", + lambda payload: payload["candidate_observation"].__setitem__("exit_code", 0), + ) + # Product preservation: post-fault reads must pin the previous publication. + expect_rejected( + "ordinary observation used a different publication", + lambda payload: payload["ordinary_product_observations"][1].__setitem__( + "publication_identity_sha256", + hashlib.sha256(b"new-publication").hexdigest(), + ), + ) + # Product preservation: an ordinary product call failing after the fault + # proves the previous publication did not stay usable. + expect_rejected( + "ordinary search failed after the fault", + lambda payload: payload["ordinary_product_observations"][1].__setitem__( + "exit_code", 1 + ), + ) + # The replacement must be a different server instance, not the crashed one. + expect_rejected( + "server was not actually replaced", + lambda payload: payload["server_observations"][1].update( + { + "server_instance_id": payload["server_observations"][0][ + "server_instance_id" + ], + "process_start_id": payload["server_observations"][0][ + "process_start_id" + ], + } + ), + ) write_json(publication_path, publication_payload) diff --git a/crates/codestory-runtime/src/agent/packet_compiler.rs b/crates/codestory-runtime/src/agent/packet_compiler.rs index 29f11e175..3beef0c7d 100644 --- a/crates/codestory-runtime/src/agent/packet_compiler.rs +++ b/crates/codestory-runtime/src/agent/packet_compiler.rs @@ -1620,17 +1620,130 @@ mod tests { assert!(decoded[0].structural_reason.is_some()); } + fn test_admission( + packet_ordinal: u32, + stable_identity: &str, + node_id: i64, + ) -> AuthenticatedPacketAdmissionV1 { + AuthenticatedPacketAdmissionV1 { + receipt: PacketAdmissionReceiptV1 { + packet_ordinal, + stable_identity: stable_identity.into(), + score_version: "test".into(), + reserved_source_bytes: 1, + origin: PacketAdmissionOriginV1::Retrieval, + }, + core_node_id: CoreNodeId(node_id), + } + } + #[test] fn uncertain_relation_is_not_compiler_evidence() { assert_eq!( relation_certainty(Some(ResolutionCertainty::Uncertain)), PacketRelationCertaintyV1::Uncertain ); + + let mut storage = Store::new_in_memory().expect("store"); + storage + .insert_nodes_batch(&[ + CoreNode { + id: CoreNodeId(1), + kind: CoreNodeKind::FILE, + serialized_name: "src/a.rs".into(), + ..Default::default() + }, + CoreNode { + id: CoreNodeId(2), + kind: CoreNodeKind::FUNCTION, + serialized_name: "crate::run".into(), + ..Default::default() + }, + ]) + .expect("insert nodes"); + storage + .insert_edges_batch(&[ + CoreEdge { + id: CoreEdgeId(10), + source: CoreNodeId(1), + target: CoreNodeId(2), + kind: CoreEdgeKind::MEMBER, + certainty: Some(ResolutionCertainty::Certain), + ..Default::default() + }, + CoreEdge { + id: CoreEdgeId(11), + source: CoreNodeId(1), + target: CoreNodeId(2), + kind: CoreEdgeKind::CALL, + certainty: Some(ResolutionCertainty::Uncertain), + ..Default::default() + }, + ]) + .expect("insert edges"); + let admissions = [ + test_admission(0, "path:src/a.rs", 1), + test_admission(1, "node:2", 2), + ]; + + let relations = hydrate_induced_relations(&storage, &admissions).expect("relations"); + + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].relation_id, "10"); + assert_eq!(relations[0].certainty, PacketRelationCertaintyV1::Certain); } #[test] fn numeric_confidence_cannot_upgrade_missing_certainty() { assert_eq!(relation_certainty(None), PacketRelationCertaintyV1::Unknown); + + let mut storage = Store::new_in_memory().expect("store"); + storage + .insert_nodes_batch(&[ + CoreNode { + id: CoreNodeId(1), + kind: CoreNodeKind::FILE, + serialized_name: "src/a.rs".into(), + ..Default::default() + }, + CoreNode { + id: CoreNodeId(2), + kind: CoreNodeKind::FUNCTION, + serialized_name: "crate::run".into(), + ..Default::default() + }, + ]) + .expect("insert nodes"); + storage + .insert_edges_batch(&[ + CoreEdge { + id: CoreEdgeId(10), + source: CoreNodeId(1), + target: CoreNodeId(2), + kind: CoreEdgeKind::MEMBER, + certainty: Some(ResolutionCertainty::Certain), + ..Default::default() + }, + CoreEdge { + id: CoreEdgeId(11), + source: CoreNodeId(1), + target: CoreNodeId(2), + kind: CoreEdgeKind::CALL, + confidence: Some(0.99), + certainty: None, + ..Default::default() + }, + ]) + .expect("insert edges"); + let admissions = [ + test_admission(0, "path:src/a.rs", 1), + test_admission(1, "node:2", 2), + ]; + + let relations = hydrate_induced_relations(&storage, &admissions).expect("relations"); + + assert_eq!(relations.len(), 1); + assert_eq!(relations[0].relation_id, "10"); } #[test] diff --git a/crates/codestory-runtime/src/indexed_source_call_path_v1.rs b/crates/codestory-runtime/src/indexed_source_call_path_v1.rs index aa3103ec7..299b39fe0 100644 --- a/crates/codestory-runtime/src/indexed_source_call_path_v1.rs +++ b/crates/codestory-runtime/src/indexed_source_call_path_v1.rs @@ -4755,7 +4755,6 @@ mod tests { } #[test] - #[ignore = "Horizon A: published generations are immutable; in-place WAL poison is not a supported observer path"] fn active_snapshot_poison_restored_before_current_observation_cannot_prove() { let project = tempfile::tempdir().unwrap(); let source_path = project.path().join("src/lib.rs"); @@ -4766,7 +4765,10 @@ mod tests { ) .unwrap(); let storage_path = project.path().join(".codestory-test/codestory.db"); - let controller = AppController::new_with_config(crate::test_sidecar_runtime_from_env()); + let process_cache = tempfile::tempdir().unwrap(); + let controller = AppController::new_with_config( + crate::test_sidecar_runtime_with_cache_root(process_cache.path()), + ); controller .open_project_summary_with_storage_path( project.path().to_path_buf(), @@ -4799,14 +4801,23 @@ mod tests { let mut poisoned = original.clone(); poisoned.provenance.parser_fingerprint = "f".repeat(64); let poisoned = seal_call_resolution_fact(poisoned).unwrap(); + mutate_published_core( + &storage_path, + "UPDATE proof_resolution_provenance + SET parser_fingerprint = ?1 + WHERE provenance_id = ( + SELECT provenance_id FROM proof_resolution_fact + WHERE fact_id = ?2 + )", + (&poisoned.provenance.parser_fingerprint, &original.fact_id), + ); mutate_published_core( &storage_path, "UPDATE proof_resolution_fact - SET fact_id = ?1, parser_fingerprint = ?2, evidence_digest = ?3 - WHERE fact_id = ?4", + SET fact_id = ?1, evidence_digest = ?2 + WHERE fact_id = ?3", ( &poisoned.fact_id, - &poisoned.provenance.parser_fingerprint, &poisoned.provenance.evidence_sha256, &original.fact_id, ), @@ -4829,14 +4840,23 @@ mod tests { ); drop(normal_reader); + mutate_published_core( + &storage_path, + "UPDATE proof_resolution_provenance + SET parser_fingerprint = ?1 + WHERE provenance_id = ( + SELECT provenance_id FROM proof_resolution_fact + WHERE fact_id = ?2 + )", + (&original.provenance.parser_fingerprint, &poisoned.fact_id), + ); mutate_published_core( &storage_path, "UPDATE proof_resolution_fact - SET fact_id = ?1, parser_fingerprint = ?2, evidence_digest = ?3 - WHERE fact_id = ?4", + SET fact_id = ?1, evidence_digest = ?2 + WHERE fact_id = ?3", ( &original.fact_id, - &original.provenance.parser_fingerprint, &original.provenance.evidence_sha256, &poisoned.fact_id, ), @@ -4854,9 +4874,15 @@ mod tests { let validation = controller .validate_proof_publication_for_active_snapshot(&publication, active_snapshot.storage()) .unwrap(); + // Sealed generations cannot host a persistent WAL observer, so no + // prepared receipt exists and validation is a fresh Direct check on + // the pinned snapshot. The poisoned facts must fail it, leaving proof + // projection unavailable even though the live image was restored. assert!(matches!( &validation, - &ProofPublicationValidationUse::Unavailable + &ProofPublicationValidationUse::Direct { + proof_projection_available: false, + } )); assert_eq!(full_proof_publication_validation_count(), 1); let observed = build_from_store_observed_with_validation( diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index 3ea93bc39..36addd04b 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -11211,15 +11211,49 @@ fn structural_live_identity(storage_path: &Path) -> StructuralLiveIdentity { } } +/// Assert the operation left no staged core candidates owned by this process. +/// +/// Current candidates live in `stage--` directories under +/// `CorePublicationLayout::staging_root`, not as `.staged.` siblings of the +/// database. Enumeration failures are fatal rather than filtered, and +/// candidates owned by other processes are preserved — this assertion only +/// covers staging directories this process could have created. pub(crate) fn assert_no_staged_publication_artifacts(storage_path: &Path) { + let layout = codestory_store::CorePublicationLayout::from_storage_path(storage_path) + .expect("resolve core publication layout"); + let staging_root = layout.staging_root(); + let owned_prefix = format!("stage-{}-", std::process::id()); + let mut debris = Vec::new(); + if staging_root.is_dir() { + for entry in fs::read_dir(&staging_root).expect("list core staging root") { + let name = entry + .expect("enumerate staged core candidate") + .file_name() + .to_string_lossy() + .to_string(); + if name.starts_with(&owned_prefix) { + debris.push(name); + } + } + } + assert!(debris.is_empty(), "staged publication debris: {debris:?}"); + let parent = storage_path.parent().expect("storage parent"); - let staged = fs::read_dir(parent) - .expect("list storage parent") - .filter_map(Result::ok) - .map(|entry| entry.file_name().to_string_lossy().to_string()) - .filter(|name| name.contains(".staged.")) - .collect::>(); - assert!(staged.is_empty(), "staged publication debris: {staged:?}"); + let mut legacy = Vec::new(); + for entry in fs::read_dir(parent).expect("list storage parent") { + let name = entry + .expect("enumerate storage parent") + .file_name() + .to_string_lossy() + .to_string(); + if name.contains(".staged.") { + legacy.push(name); + } + } + assert!( + legacy.is_empty(), + "legacy staged publication debris: {legacy:?}" + ); } /// Apply a hostile fixture write to the published core. diff --git a/crates/codestory-runtime/src/tests/search_scoring.rs b/crates/codestory-runtime/src/tests/search_scoring.rs index a677217da..a2f35aeb2 100644 --- a/crates/codestory-runtime/src/tests/search_scoring.rs +++ b/crates/codestory-runtime/src/tests/search_scoring.rs @@ -14,12 +14,13 @@ use super::{ finalize_staged_semantic_docs, flush_pending_dense_anchor_inputs, fs, hybrid_search_config_for_request, hybrid_test_env, insert_semantic_fixture_nodes, llm_symbol_doc_hash, load_persisted_search_state, merge_search_hits_by_node_id, - normalized_hybrid_weights, pending_semantic_doc_for_test, persisted_search_generation_names, - primary_source_retention_threshold, process_env_test_lock, project_identity_v3, - prune_search_generations, rebuild_search_state_from_storage, search_generation_completion_path, - search_index_generation_root, search_index_path_for_publication, search_index_storage_path, - semantic_doc_text_for_test, semantic_projection_republish_for_runtime, tempdir, - test_index_publication, test_retrieval_manifest, test_sidecar_runtime_from_env, unbounded, + mutate_published_core, normalized_hybrid_weights, pending_semantic_doc_for_test, + persisted_search_generation_names, primary_source_retention_threshold, process_env_test_lock, + project_identity_v3, prune_search_generations, rebuild_search_state_from_storage, + search_generation_completion_path, search_index_generation_root, + search_index_path_for_publication, search_index_storage_path, semantic_doc_text_for_test, + semantic_projection_republish_for_runtime, tempdir, test_index_publication, + test_retrieval_manifest, test_sidecar_runtime_from_env, unbounded, write_search_generation_completion, write_semantic_fixture, }; use codestory_contracts::bounded_locks::{self, FileLockKind}; @@ -2228,7 +2229,6 @@ fn persisted_search_generations_do_not_overwrite_a_racing_reader() { } #[test] -#[ignore = "staged core promotion requires rebound proof-resolution identity"] fn catalog_waiting_loader_reopens_core_and_search_as_one_generation() { let _env = hybrid_test_env(); let temp = tempdir().expect("create temp dir"); @@ -2791,8 +2791,8 @@ fn hybrid_search_config_skips_exact_symbol_escalation_for_mixed_nl() { } #[test] -#[ignore = "live published cores are immutable generations; incomplete-run fences belong on staged candidates"] fn staged_recovery_search_failure_preserves_the_marked_live_database() { + const INCOMPLETE_INCREMENTAL_SCHEMA_VERSION: u32 = 0x4353_0001; let process_cache = tempfile::tempdir().expect("owned runtime cache root"); let workspace = tempdir().expect("workspace dir"); fs::write( @@ -2811,10 +2811,22 @@ fn staged_recovery_search_failure_preserves_the_marked_live_database() { controller .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) .expect("initial full index"); - Storage::open(&storage_path) - .expect("open storage") - .begin_incremental_run() - .expect("simulate interrupted incremental"); + // An interrupted incremental leaves its durable fence on the published + // core: the marker row plus the incomplete-run schema sentinel. Live + // generation files are immutable to ordinary opens, so the fence goes in + // through the same sealed-generation replacement the recovery code meets. + mutate_published_core(&storage_path, |storage| { + storage + .get_connection() + .execute_batch(&format!( + "INSERT INTO incomplete_index_run (id, started_at_epoch_ms) + VALUES (1, 1) + ON CONFLICT(id) DO UPDATE SET + started_at_epoch_ms = excluded.started_at_epoch_ms; + PRAGMA user_version = {INCOMPLETE_INCREMENTAL_SCHEMA_VERSION};" + )) + .expect("plant the durable incomplete-run fence"); + }); let search_path = search_index_generation_root(&storage_path); if search_path.is_dir() { @@ -2830,42 +2842,22 @@ fn staged_recovery_search_failure_preserves_the_marked_live_database() { error.message.contains("search"), "unexpected error: {error:?}" ); - let storage = Storage::open(&storage_path).expect("open replacement database"); + assert_no_staged_publication_artifacts(&storage_path); + // The fence stamps the live generation with the incomplete-run sentinel + // version, which read-only opens refuse by contract; inspect it through + // the static promotion readers instead of Storage::open. assert!( - storage - .has_incomplete_incremental_run() - .expect("replacement marker") - ); - assert!( - storage - .snapshots() - .has_ready_summary() - .expect("live summary readiness"), - "pre-publication failure must preserve the live summary snapshot" - ); - assert!( - storage - .snapshots() - .has_ready_detail() - .expect("live detail readiness"), - "pre-publication failure must preserve the live detail snapshot" + Storage::database_has_incomplete_incremental_run(&storage_path) + .expect("replacement marker"), + "pre-publication failure must preserve the durable incomplete-run fence" ); - storage - .get_connection() - .execute( - "UPDATE incomplete_index_run - SET started_at_epoch_ms = started_at_epoch_ms - WHERE id = 1", - [], - ) - .expect("retain the fence in committed WAL state"); let fenced_schema = Storage::database_schema_version(&storage_path).expect("replacement schema"); - assert_ne!(fenced_schema, codestory_store::CURRENT_SCHEMA_VERSION); - let wal_path = storage_path.with_extension("db-wal"); - assert!(wal_path.is_file(), "fenced fixture must retain WAL state"); - let database_before = fs::read(&storage_path).expect("read fenced database before freshness"); - let wal_before = fs::read(&wal_path).expect("read fenced WAL before freshness"); + assert_eq!(fenced_schema, INCOMPLETE_INCREMENTAL_SCHEMA_VERSION); + let live_database = codestory_store::resolve_core_database_path(&storage_path) + .expect("resolve fenced live generation"); + let live_wal = live_database.with_extension("db-wal"); + let database_before = fs::read(&live_database).expect("read fenced database before freshness"); let cached = controller .index_freshness() @@ -2889,39 +2881,37 @@ fn staged_recovery_search_failure_preserves_the_marked_live_database() { assert!(freshness.samples.is_empty()); } assert_eq!( - fs::read(&storage_path).expect("read fenced database after freshness"), + fs::read(&live_database).expect("read fenced database after freshness"), database_before ); - assert_eq!( - fs::read(&wal_path).expect("read fenced WAL after freshness"), - wal_before + assert!( + !live_wal.exists(), + "read-only freshness must not leave WAL state on the fenced generation" ); assert_eq!( Storage::database_schema_version(&storage_path).expect("schema after freshness"), fenced_schema ); assert!( - storage - .has_incomplete_incremental_run() + Storage::database_has_incomplete_incremental_run(&storage_path) .expect("marker after freshness"), "freshness observation must preserve the durable fence" ); - drop(storage); fs::remove_file(&search_path).expect("remove search rebuild blocker"); controller .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) .expect("successful full recovery"); + assert_no_staged_publication_artifacts(&storage_path); assert_eq!( Storage::database_schema_version(&storage_path).expect("recovered schema"), codestory_store::CURRENT_SCHEMA_VERSION ); - let readable = Storage::open_read_only(&storage_path).expect("open recovered publication"); assert!( - !readable - .has_incomplete_incremental_run() + !Storage::database_has_incomplete_incremental_run(&storage_path) .expect("read recovered marker") ); + let readable = Storage::open_read_only(&storage_path).expect("open recovered publication"); assert!( readable .get_complete_index_publication() diff --git a/crates/codestory-store/src/storage_impl/mod.rs b/crates/codestory-store/src/storage_impl/mod.rs index 081cb4891..a5df0b105 100644 --- a/crates/codestory-store/src/storage_impl/mod.rs +++ b/crates/codestory-store/src/storage_impl/mod.rs @@ -7453,7 +7453,8 @@ impl Storage { } pub fn discard_staged_snapshot(staged_path: &Path) -> Result<(), StorageError> { - cleanup_sqlite_sidecars(staged_path) + cleanup_sqlite_sidecars(staged_path)?; + crate::core_generation::remove_empty_staging_directory(staged_path) } fn init(&self, _mode: StorageOpenMode) -> Result<(), StorageError> { From 7d30f13068ff15d1ddbb12d148bab6de059c232c Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 12:24:44 -0400 Subject: [PATCH 14/58] strengthen runtime assertion fixtures --- .../src/agent/packet_batch.rs | 46 ++++-- .../src/agent/packet_candidate.rs | 13 +- .../src/agent/packet_follow_up.rs | 3 +- .../src/agent/packet_projection_v3.rs | 76 ++++++--- .../src/agent/retrieval_primary.rs | 56 +++++++ .../src/agent/trace_export.rs | 2 +- .../codestory-runtime/src/cache_rehydrate.rs | 58 ++++++- .../src/call_path_grammar.rs | 23 ++- .../src/controller_indexing.rs | 2 +- crates/codestory-runtime/src/grounding.rs | 72 +++++++- .../src/semantic_projection.rs | 101 ++++++++---- .../codestory-runtime/src/symbol_workflow.rs | 14 +- .../src/target_resolution.rs | 13 +- crates/codestory-runtime/src/tests.rs | 154 ++++++++++++++---- .../src/tests/search_intent.rs | 26 +++ .../src/tests/search_plan.rs | 11 +- .../src/tests/search_scoring.rs | 11 +- .../tests/retrieval_primary_rejection.rs | 41 ----- 18 files changed, 539 insertions(+), 183 deletions(-) delete mode 100644 crates/codestory-runtime/tests/retrieval_primary_rejection.rs diff --git a/crates/codestory-runtime/src/agent/packet_batch.rs b/crates/codestory-runtime/src/agent/packet_batch.rs index 3b385960f..e309034a1 100644 --- a/crates/codestory-runtime/src/agent/packet_batch.rs +++ b/crates/codestory-runtime/src/agent/packet_batch.rs @@ -912,7 +912,13 @@ impl PacketLatencyBudget { } pub(crate) fn remaining_for_handoff(self) -> Option { - let remaining_ms = self.target_ms.saturating_sub(self.elapsed_ms()); + self.remaining_for_handoff_at(Instant::now()) + } + + fn remaining_for_handoff_at(&self, now: Instant) -> Option { + let remaining_ms = self + .target_ms + .saturating_sub(now.saturating_duration_since(self.started_at).as_millis()); (remaining_ms >= MIN_PACKET_HANDOFF_MS).then(|| clamp_u128_to_u32(remaining_ms)) } @@ -932,40 +938,44 @@ mod packet_latency_budget_tests { #[test] fn packet_budget_handoff_charges_elapsed_work_and_refuses_an_exhausted_floor() { + let started_at = Instant::now(); + let partially_spent = PacketLatencyBudget { - started_at: Instant::now() - .checked_sub(Duration::from_millis(700)) - .expect("backdate packet start"), + started_at, target_ms: 2_000, }; - let remaining = partially_spent - .remaining_for_handoff() - .expect("partially spent packet allowance"); - assert!( - (1_250..=1_300).contains(&remaining), - "descriptor elapsed time must reduce the downstream allowance: {remaining}" + assert_eq!( + partially_spent.remaining_for_handoff_at(started_at + Duration::from_millis(700)), + Some(1_300), + "descriptor elapsed time must reduce the downstream allowance" + ); + + let at_floor = PacketLatencyBudget { + started_at, + target_ms: 2_000, + }; + assert_eq!( + at_floor.remaining_for_handoff_at(started_at + Duration::from_millis(1_000)), + Some(1_000), + "a remainder exactly at the handoff floor must still be granted" ); let below_retrieval_minimum = PacketLatencyBudget { - started_at: Instant::now() - .checked_sub(Duration::from_millis(700)) - .expect("backdate sub-minimum packet start"), + started_at, target_ms: 1_000, }; assert_eq!( - below_retrieval_minimum.remaining_for_handoff(), + below_retrieval_minimum.remaining_for_handoff_at(started_at + Duration::from_millis(1)), None, "a sub-minimum remainder must stop before downstream retrieval instead of granting a fresh 1000 ms phase" ); let exhausted = PacketLatencyBudget { - started_at: Instant::now() - .checked_sub(Duration::from_millis(1_001)) - .expect("backdate exhausted packet start"), + started_at, target_ms: 1_000, }; assert_eq!( - exhausted.remaining_for_handoff(), + exhausted.remaining_for_handoff_at(started_at + Duration::from_millis(1_000)), None, "an exhausted packet must stop before a downstream stage instead of renewing the 1000 ms floor" ); diff --git a/crates/codestory-runtime/src/agent/packet_candidate.rs b/crates/codestory-runtime/src/agent/packet_candidate.rs index b3244e731..7989b0f29 100644 --- a/crates/codestory-runtime/src/agent/packet_candidate.rs +++ b/crates/codestory-runtime/src/agent/packet_candidate.rs @@ -1096,13 +1096,18 @@ mod tests { #[test] fn citation_and_graph_keep_exact_packet_candidate_provenance() { + for hostile_eligibility in [Some(true), Some(false)] { + let mut hit = packet_hit("edge-1"); + hit.hit.eligible_for_sufficiency = hostile_eligibility; + let citation = hit.citation(true); + assert_eq!( + citation.eligible_for_sufficiency, None, + "packet citations carry retrieval provenance, never answer-sufficiency authority" + ); + } let hit = packet_hit("edge-1"); let citation = hit.citation(true); assert_eq!(citation.evidence_edge_ids, [EdgeId("edge-1".into())]); - assert_eq!( - citation.eligible_for_sufficiency, None, - "packet citations carry retrieval provenance, never answer-sufficiency authority" - ); assert!(hit.has_proof_call_provenance()); let mut answer = answer(); diff --git a/crates/codestory-runtime/src/agent/packet_follow_up.rs b/crates/codestory-runtime/src/agent/packet_follow_up.rs index 3e84ff9b5..3822eef12 100644 --- a/crates/codestory-runtime/src/agent/packet_follow_up.rs +++ b/crates/codestory-runtime/src/agent/packet_follow_up.rs @@ -42,6 +42,7 @@ mod tests { fn adapter_binding_rewrites_the_deeper_packet_command_only() { let mut packet = super::super::packet_budget::tests::test_packet("trace routing", 98_304); packet.budget.requested = PacketBudgetModeDto::Compact; + let disposition_before = packet.disposition.clone(); bind_packet_follow_up_program( Path::new("/tmp/project with space"), &mut packet, @@ -56,7 +57,7 @@ mod tests { .starts_with("'/opt/CodeStory Managed/bin/codestory-cli' packet")) ); assert_eq!( - packet.disposition.kind, packet.disposition.kind, + packet.disposition, disposition_before, "adapter binding must not reclassify disposition" ); } diff --git a/crates/codestory-runtime/src/agent/packet_projection_v3.rs b/crates/codestory-runtime/src/agent/packet_projection_v3.rs index f98b51036..a07389d6b 100644 --- a/crates/codestory-runtime/src/agent/packet_projection_v3.rs +++ b/crates/codestory-runtime/src/agent/packet_projection_v3.rs @@ -966,12 +966,32 @@ mod tests { assert_eq!(evidence.as_slice().len(), 1); assert!(gaps.as_slice().is_empty()); assert_eq!(record, before, "projection must not mutate the record"); - assert!( - !serde_json::to_string(&projection) - .expect("serialize projection") - .contains(question), - "raw question must stay out of the packet projection" - ); + let serialized = serde_json::to_value(&projection).expect("serialize projection"); + assert_no_json_string_contains(&serialized, question); + } + + fn assert_no_json_string_contains(value: &serde_json::Value, needle: &str) { + match value { + serde_json::Value::String(text) => assert!( + !text.contains(needle), + "raw question leaked into a projection string value" + ), + serde_json::Value::Array(items) => { + for item in items { + assert_no_json_string_contains(item, needle); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + assert!( + !key.contains(needle), + "raw question leaked into a projection field name" + ); + assert_no_json_string_contains(item, needle); + } + } + _ => {} + } } #[test] @@ -1407,30 +1427,19 @@ mod tests { maximum_bytes, required_complete_bytes, .. - } = projection + } = &projection else { panic!("cap plus one must discard the complete projection"); }; - assert_eq!(status, EvidenceAvailabilityV3Dto::Unavailable); - assert_eq!(diagnostics, diagnostics_capability_fixture()); - assert_eq!(maximum_bytes, PACKET_PUBLIC_RESULT_MAX_BYTES_V3 as u64); + assert_eq!(status, &EvidenceAvailabilityV3Dto::Unavailable); + assert_eq!(diagnostics, &diagnostics_capability_fixture()); + assert_eq!(*maximum_bytes, PACKET_PUBLIC_RESULT_MAX_BYTES_V3 as u64); assert_eq!( - required_complete_bytes, + *required_complete_bytes, (PACKET_PUBLIC_RESULT_MAX_BYTES_V3 + 1) as u64 ); - let serialized = serde_json::to_value(PacketProjectionV3Dto::BudgetExceeded { - schema_version: PACKET_PROJECTION_V3_SCHEMA_VERSION, - identity: packet_identity(&record), - publication: publication(&record), - status, - retrieval: record.retrieval().clone(), - diagnostics, - gaps: packet_budget_exceeded_gaps_v3(), - maximum_bytes, - required_complete_bytes, - answer_sufficiency: Default::default(), - }) - .unwrap(); + let serialized = + serde_json::to_value(&projection).expect("serialize returned fallback projection"); let gaps = serialized["gaps"].as_array().expect("typed budget gap"); assert_eq!(gaps.len(), 1, "fallback must carry exactly one gap"); assert_eq!(gaps[0]["kind"], "output_budget_exceeded"); @@ -1438,6 +1447,25 @@ mod tests { gaps[0]["identity"]["gap_id"], "packet-output-budget-exceeded" ); + assert_eq!( + serialized["schema_version"], PACKET_PROJECTION_V3_SCHEMA_VERSION, + "fallback must keep the projection schema version" + ); + assert_eq!( + serialized["identity"], + serde_json::to_value(packet_identity(&record)).unwrap(), + "fallback must preserve the record request identity" + ); + assert_eq!( + serialized["publication"], + serde_json::to_value(publication(&record)).unwrap(), + "fallback must preserve the record publication identity" + ); + assert_eq!( + serialized["retrieval"], + serde_json::to_value(record.retrieval()).unwrap(), + "fallback must preserve the record retrieval descriptor" + ); for absent in ["evidence", "continuation", "summary"] { assert!(serialized.get(absent).is_none(), "fallback leaked {absent}"); } diff --git a/crates/codestory-runtime/src/agent/retrieval_primary.rs b/crates/codestory-runtime/src/agent/retrieval_primary.rs index 3acc5d7ba..5fe17e13f 100644 --- a/crates/codestory-runtime/src/agent/retrieval_primary.rs +++ b/crates/codestory-runtime/src/agent/retrieval_primary.rs @@ -4174,6 +4174,62 @@ mod tests { } } + /// Moved from tests/retrieval_primary_rejection.rs: phantom-only results + /// must hit the runtime's typed refusal path, and a real file must reach + /// the ordinary resolution path instead. + #[test] + fn phantom_only_candidates_are_detected() { + let phantoms = vec![ + CandidateHit::with_source( + "lexical:handler", + Some("handler".into()), + 0.5, + CandidateSource::Lexical, + ), + CandidateHit::with_source( + "semantic:handler", + Some("handler".into()), + 0.55, + CandidateSource::Semantic, + ), + ]; + assert!(codestory_retrieval::phantom_sidecar_candidates_only( + &phantoms + )); + + let node_names = HashMap::new(); + for phantom in &phantoms { + assert_eq!( + candidate_resolution_label(None, None, &node_names, phantom), + "phantom_hit", + "phantom candidates must be refused before any path or storage work" + ); + } + let ordered = ordered_sidecar_candidates(&phantoms, |_| { + panic!("phantom hits must be filtered before path resolution") + }); + assert!(ordered.is_empty()); + + // Real-file control: an existing source file is not a phantom, reaches + // path resolution, and then proceeds into node lookup. + let temp = tempfile::tempdir().expect("project root"); + let source = temp.path().join("src/lib.rs"); + std::fs::create_dir_all(source.parent().expect("src parent")).expect("mkdir src"); + std::fs::write(&source, "fn lib() {}\n").expect("write source"); + let real = CandidateHit::lexical_stub("src/lib.rs", 0.9); + assert!(!codestory_retrieval::phantom_sidecar_candidates_only(&[ + real.clone() + ])); + + let storage = Store::new_in_memory().expect("storage"); + assert_eq!( + candidate_resolution_label(Some(temp.path()), Some(&storage), &node_names, &real), + "node_unresolved", + "a real resolvable file must reach node lookup, not the phantom refusal" + ); + assert_eq!(ordered_sidecar_candidates(&[real], |_| true).len(), 1); + } + #[test] fn symbol_candidate_skips_unknown_callsite_and_resolves_definition() { use codestory_contracts::graph::{Occurrence, OccurrenceKind, SourceLocation}; diff --git a/crates/codestory-runtime/src/agent/trace_export.rs b/crates/codestory-runtime/src/agent/trace_export.rs index cc08c53eb..d14ec98b3 100644 --- a/crates/codestory-runtime/src/agent/trace_export.rs +++ b/crates/codestory-runtime/src/agent/trace_export.rs @@ -1138,7 +1138,7 @@ mod tests { AgentRetrievalStepDto { kind: AgentRetrievalStepKindDto::Search, status: AgentRetrievalStepStatusDto::Skipped, - duration_ms: 0, + duration_ms: 500, input: Vec::new(), output: Vec::new(), message: Some("budget exhausted".to_string()), diff --git a/crates/codestory-runtime/src/cache_rehydrate.rs b/crates/codestory-runtime/src/cache_rehydrate.rs index 1e770c29d..6de4282d4 100644 --- a/crates/codestory-runtime/src/cache_rehydrate.rs +++ b/crates/codestory-runtime/src/cache_rehydrate.rs @@ -1375,10 +1375,55 @@ mod tests { let source_cache = tempdir().expect("source cache"); let target_cache = tempdir().expect("target cache"); - seed_cache( - &source_cache.path().join("codestory.db"), - source_project.path(), - ); + let source_db = source_cache.path().join("codestory.db"); + seed_cache(&source_db, source_project.path()); + // The ignored-but-tracked file must be part of the seeded inventory + // too, or a stale-inventory refusal can masquerade as success. + { + let ignored = source_project.path().join("ignored.rs"); + let ignored_text = ignored.to_string_lossy().to_string(); + let ignored_mtime = fs::metadata(&ignored) + .expect("ignored source metadata") + .modified() + .expect("ignored source modified") + .duration_since(std::time::UNIX_EPOCH) + .expect("ignored source mtime since epoch") + .as_millis() + .min(i64::MAX as u128) as i64; + let mut storage = Store::open(&source_db).expect("reopen seeded cache"); + storage + .insert_nodes_batch(&[ + Node { + id: NodeId(4), + kind: NodeKind::FILE, + serialized_name: ignored_text.clone(), + ..Default::default() + }, + Node { + id: NodeId(5), + kind: NodeKind::FUNCTION, + serialized_name: format!("{ignored_text}::hidden"), + qualified_name: Some(format!("{ignored_text}::hidden")), + file_node_id: Some(NodeId(4)), + start_line: Some(1), + end_line: Some(1), + ..Default::default() + }, + ]) + .expect("ignored nodes"); + storage + .insert_file(&codestory_store::FileInfo { + id: 2, + path: ignored, + language: "rust".into(), + modification_time: ignored_mtime, + indexed: true, + complete: true, + line_count: 1, + file_role: codestory_store::FileRole::Source, + }) + .expect("ignored file inventory"); + } let output = codestory_retrieval::with_test_cache_root(process_cache.path(), || { rehydrate_cache(CacheRehydrateRequest { @@ -1391,6 +1436,11 @@ mod tests { }) .expect("rehydrate"); + assert_eq!( + output.status, "would_rehydrate", + "a restored tracked source must rehydrate cleanly: {output:?}" + ); + assert!(!output.copied, "a dry run must not copy the cache"); assert!( !output .reason diff --git a/crates/codestory-runtime/src/call_path_grammar.rs b/crates/codestory-runtime/src/call_path_grammar.rs index 7fa2b6f86..a7825d269 100644 --- a/crates/codestory-runtime/src/call_path_grammar.rs +++ b/crates/codestory-runtime/src/call_path_grammar.rs @@ -588,8 +588,27 @@ mod tests { #[test] fn a_missing_or_wrong_version_line_is_a_syntax_error() { assert!(parse_call_path_document("").is_err()); - assert!(parse_call_path_document("from symbol \"A\"\ndirect-call symbol \"B\"\n").is_err()); - assert!(parse_call_path_document("call-path/v2\nfrom symbol \"A\"\n").is_err()); + // A headerless document loses its first content line to header + // parsing, so this arm still errs even without a version comparison. + assert!( + parse_call_path_document("from symbol \"crate::A\"\ndirect-call symbol \"crate::B\"\n") + .is_err() + ); + // The wrong-version arm must fail on the version comparison alone: + // the body is an otherwise-complete valid contract. + assert!( + parse_call_path_document( + "call-path/v2\nfrom symbol \"crate::A\"\ndirect-call symbol \"crate::B\"\n" + ) + .is_err() + ); + assert!( + parse_call_path_document( + "call-path/v1\nfrom symbol \"crate::A\"\ndirect-call symbol \"crate::B\"\n" + ) + .is_ok(), + "the same body under the current version must parse" + ); } #[test] diff --git a/crates/codestory-runtime/src/controller_indexing.rs b/crates/codestory-runtime/src/controller_indexing.rs index 9b5dec080..8e89503ec 100644 --- a/crates/codestory-runtime/src/controller_indexing.rs +++ b/crates/codestory-runtime/src/controller_indexing.rs @@ -1517,7 +1517,7 @@ impl AppController { /// exists the live database is read-only and the rows can only be installed /// by republishing a new generation. An unpublished cache still takes the /// direct write. - fn persist_symbol_summaries( + pub(crate) fn persist_symbol_summaries( &self, storage_path: &Path, summaries: Vec, diff --git a/crates/codestory-runtime/src/grounding.rs b/crates/codestory-runtime/src/grounding.rs index cf903e1b3..233adc3a1 100644 --- a/crates/codestory-runtime/src/grounding.rs +++ b/crates/codestory-runtime/src/grounding.rs @@ -2252,6 +2252,9 @@ mod tests { display_name: name.to_string(), file_path: Some(root.join("src/service.ts")), }; + // Adverse fixture: the leaf's name sorts first on every earlier key + // (lexical order, no helper-name hints), so only the call topology can + // keep the referenced subsystem root ahead of it. let degrees = [ ( CoreNodeId(1), @@ -2263,8 +2266,8 @@ mod tests { ( CoreNodeId(2), CallDegrees { - production_in_calls: 0, - out_calls: 0, + production_in_calls: 4, + out_calls: 1, }, ), ] @@ -2272,7 +2275,7 @@ mod tests { .collect::>(); let ordered = diversify_grounding_root_records( - vec![record(2, "zzHelperAlias"), record(1, "aaSubsystemRoot")], + vec![record(1, "zzSubstrateRoot"), record(2, "aaaDispatchLeaf")], root, &roles, &HashMap::new(), @@ -2284,7 +2287,7 @@ mod tests { .iter() .map(|record| record.display_name.as_str()) .collect::>(), - ["aaSubsystemRoot", "zzHelperAlias"] + ["zzSubstrateRoot", "aaaDispatchLeaf"] ); } @@ -3957,9 +3960,12 @@ mod tests { start_line: Some(1), ..Default::default() }, + // Same kind as the import-like module and a later line, so + // kind precedence and position cannot decide this; only + // the import demotion keeps Widget ahead. Node { id: CoreNodeId(102), - kind: NodeKind::CLASS, + kind: NodeKind::MODULE, serialized_name: "Widget".to_string(), file_node_id: Some(CoreNodeId(11)), start_line: Some(2), @@ -4688,6 +4694,29 @@ mod tests { .grounding_snapshot(GroundingBudgetDto::Max) .expect("max snapshot"); + // Fixture truth is independent of the returned shape: 24 files with 5 + // symbols each were seeded, and every file and symbol must be + // represented either as a digest or inside a coverage bucket. + for snapshot in [&strict, &balanced, &max] { + assert_eq!(snapshot.coverage.total_files, 24); + assert_eq!(snapshot.coverage.represented_files, 24); + assert_eq!(snapshot.coverage.total_symbols, 120); + assert!(snapshot.coverage.represented_symbols > 0); + } + + assert_eq!(strict.files.len(), 8, "strict budget detail cap"); + assert!( + strict + .files + .iter() + .all(|file| file.symbol_count == 5 && file.represented_symbol_count > 0), + "strict digests must carry the fixture's per-file symbol counts" + ); + assert!( + !strict.coverage_buckets.is_empty(), + "strict budget must bucket the undetailed files" + ); + assert!(strict.coverage.represented_symbols <= balanced.coverage.represented_symbols); assert!(balanced.coverage.represented_symbols <= max.coverage.represented_symbols); assert!(strict.files.len() <= balanced.files.len()); @@ -4707,6 +4736,11 @@ mod tests { .sum::(), ); assert_eq!(snapshot.coverage.represented_symbols, surfaced_symbols); + for bucket in &snapshot.coverage_buckets { + assert!(!bucket.label.is_empty(), "bucket must carry an identity"); + assert!(bucket.file_count > 0); + assert!(bucket.symbol_count > 0); + } } } @@ -4879,6 +4913,20 @@ mod tests { ); } + // Diverge the live node table from the materialized snapshot without + // refreshing: a read path that bypasses the snapshot would surface the + // live-only name instead of the materialized one. + { + let storage = Storage::open(&db_path).expect("reopen for live divergence"); + storage + .get_connection() + .execute( + "UPDATE node SET serialized_name = 'live_only_helper' WHERE id = 102", + [], + ) + .expect("diverge live node table"); + } + let controller = AppController::new_with_owned_cache_root(process_cache.path()); controller .open_project_summary_with_storage_path(temp.path().to_path_buf(), db_path) @@ -4897,6 +4945,20 @@ mod tests { .any(|symbol| symbol.label.starts_with("Controller")), "expected materialized root symbol to be surfaced" ); + assert!( + snapshot + .root_symbols + .iter() + .any(|symbol| symbol.label.starts_with("helper")), + "materialized snapshot must serve the pre-divergence label" + ); + assert!( + !snapshot + .root_symbols + .iter() + .any(|symbol| symbol.label.contains("live_only_helper")), + "summary-open grounding must not read the divergent live node table" + ); } #[test] diff --git a/crates/codestory-runtime/src/semantic_projection.rs b/crates/codestory-runtime/src/semantic_projection.rs index 86a108b91..c93afc266 100644 --- a/crates/codestory-runtime/src/semantic_projection.rs +++ b/crates/codestory-runtime/src/semantic_projection.rs @@ -1708,55 +1708,90 @@ mod bounded_file_text_cache_tests { #[test] fn semantic_cache_reuses_available_text_and_falls_back_for_missing_entries() { - let file_paths = HashMap::from([ - ("late.rs".to_string(), "late-source".to_string()), - ("cached.rs".to_string(), "cached-source".to_string()), - ("missing.rs".to_string(), "missing-source".to_string()), - ]); + // Exercise the production reuse owner with real disk text: the cached + // Some entry must win over the changed disk contents, the cached None + // entry must fall back to a fresh disk read, and a file missing from + // disk must fall back to None. + let temp = tempfile::tempdir().expect("project dir"); + let cached_path = temp.path().join("cached.rs"); + let late_path = temp.path().join("late.rs"); + let missing_path = temp.path().join("missing.rs"); + std::fs::write(&cached_path, "changed body").expect("write cached.rs"); + std::fs::write(&late_path, "late body").expect("write late.rs"); + + let semantic_node = |id: i64, file_id: i64| GraphNode { + id: codestory_contracts::graph::NodeId(id), + file_node_id: Some(codestory_contracts::graph::NodeId(file_id)), + ..Default::default() + }; + let nodes = [ + semantic_node(101, 11), + semantic_node(102, 12), + semantic_node(103, 13), + ]; + let graph_context = SemanticDocGraphContext { + file_paths: HashMap::from([ + ( + codestory_contracts::graph::NodeId(11), + "cached.rs".to_string(), + ), + ( + codestory_contracts::graph::NodeId(12), + "late.rs".to_string(), + ), + ( + codestory_contracts::graph::NodeId(13), + "missing.rs".to_string(), + ), + ]), + file_read_paths: HashMap::from([ + ( + codestory_contracts::graph::NodeId(11), + cached_path.to_string_lossy().to_string(), + ), + ( + codestory_contracts::graph::NodeId(12), + late_path.to_string_lossy().to_string(), + ), + ( + codestory_contracts::graph::NodeId(13), + missing_path.to_string_lossy().to_string(), + ), + ]), + ..Default::default() + }; let mut reusable_cache = HashMap::from([ ("cached.rs".to_string(), Some("cached body".to_string())), ("late.rs".to_string(), None), ]); - let mut disk_reads = Vec::new(); - let (cache, _) = build_semantic_file_text_cache_from_paths_with_limits_and_reader( - &file_paths, - 64, + let semantic_nodes = nodes.iter().collect::>(); + let cache = build_semantic_file_text_cache_with_reuse( + &graph_context, + &semantic_nodes, 64, - |display_path, read_path, read_limit| { - if let Some(Some(contents)) = reusable_cache.remove(display_path) { - return crate::support::read_text_limited( - std::io::Cursor::new(contents.into_bytes()), - read_limit, - ); - } - disk_reads.push(read_path.to_string()); - let contents = match read_path { - "late-source" => "late body", - "missing-source" => "missing body", - "cached-source" => panic!("cached text must not be read from disk again"), - _ => panic!("unexpected read path: {read_path}"), - }; - crate::support::read_text_limited( - std::io::Cursor::new(contents.as_bytes()), - read_limit, - ) - }, + &mut reusable_cache, ); assert_eq!( cache.get("cached.rs").and_then(Option::as_deref), - Some("cached body") + Some("cached body"), + "the cached text must win over the changed disk contents" ); assert_eq!( cache.get("late.rs").and_then(Option::as_deref), - Some("late body") + Some("late body"), + "a cached None entry must fall back to a fresh disk read" ); assert_eq!( - cache.get("missing.rs").and_then(Option::as_deref), - Some("missing body") + cache.get("missing.rs"), + Some(&None), + "a missing disk file must fall back to None" + ); + assert!( + reusable_cache.is_empty(), + "the reuse owner must consume the consulted cache entries" ); - assert_eq!(disk_reads, ["late-source", "missing-source"]); } } diff --git a/crates/codestory-runtime/src/symbol_workflow.rs b/crates/codestory-runtime/src/symbol_workflow.rs index 357936899..40772f04f 100644 --- a/crates/codestory-runtime/src/symbol_workflow.rs +++ b/crates/codestory-runtime/src/symbol_workflow.rs @@ -654,9 +654,15 @@ mod tests { SymbolWorkflowMode::Impact, true, ); - assert!(commands.iter().all(|command| { - !command.contains(" callers ") && !command.contains(" test-map ") - || command.contains("--include-tests") - })); + for named in [" callers ", " test-map "] { + let command = commands + .iter() + .find(|command| command.contains(named)) + .unwrap_or_else(|| panic!("expected a{named}command in {commands:?}")); + assert!( + command.contains("--include-tests"), + "the{named}command must keep the widened test scope: {command}" + ); + } } } diff --git a/crates/codestory-runtime/src/target_resolution.rs b/crates/codestory-runtime/src/target_resolution.rs index ddde41cc8..214e9a600 100644 --- a/crates/codestory-runtime/src/target_resolution.rs +++ b/crates/codestory-runtime/src/target_resolution.rs @@ -1550,37 +1550,40 @@ mod tests { #[test] fn inexact_resolution_prefers_production_over_non_primary_roles() { + // Every candidate carries the same inexact display name, so exact-name + // and terminal-name buckets cannot decide; only the source-truth role + // can keep the production row ahead of the higher-scored competitors. let production = hit( "production", - "resolve_context_target", + "resolve_context_target_runner", NodeKind::FUNCTION, 0.60, "crates/codestory-cli/src/runtime.rs", ); let generated = hit( "generated", - "resolve_context_target_generated", + "resolve_context_target_runner", NodeKind::FUNCTION, 0.95, "target/generated/runtime.rs", ); let docs = hit( "docs", - "resolve_context_target_docs", + "resolve_context_target_runner", NodeKind::FUNCTION, 0.95, "docs/runtime.md", ); let bench = hit( "bench", - "resolve_context_target_bench", + "resolve_context_target_runner", NodeKind::FUNCTION, 0.95, "benches/runtime.rs", ); let vendor = hit( "vendor", - "resolve_context_target_vendor", + "resolve_context_target_runner", NodeKind::FUNCTION, 0.95, "vendor/runtime.rs", diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index 9a1889643..1f474d864 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -2051,10 +2051,10 @@ fn attached_comment_window_and_proxy_cap_truncate_the_tail() { #[test] fn dense_anchor_inputs_are_sorted_deterministically_before_publication() { let mut docs = vec![ - pending_semantic_doc_for_test(1, &"x".repeat(900)), - pending_semantic_doc_for_test(2, "tiny"), pending_semantic_doc_for_test(3, &"m".repeat(880)), + pending_semantic_doc_for_test(1, &"x".repeat(900)), pending_semantic_doc_for_test(4, "small"), + pending_semantic_doc_for_test(2, "tiny"), ]; sort_pending_dense_anchor_inputs(&mut docs); @@ -2521,8 +2521,10 @@ String::new() #[test] fn aggregate_symbol_matches_prioritizes_direct_matches() { + // The expanded-only competitor (95) outscores the direct candidate's own + // expanded row (50), so only the direct-match boost can keep node 7 first. let direct = vec![(CoreNodeId(7), 2.0)]; - let expanded = vec![(CoreNodeId(7), 99.0), (CoreNodeId(8), 95.0)]; + let expanded = vec![(CoreNodeId(7), 50.0), (CoreNodeId(8), 95.0)]; let merged = crate::support::aggregate_symbol_matches(direct, expanded); assert_eq!(merged.first().map(|(id, _)| *id), Some(CoreNodeId(7))); } @@ -3622,7 +3624,20 @@ fn degraded_or_mismatched_manifest_does_not_report_hybrid_ready() { let mut storage = Storage::open(&storage_path).expect("open storage"); let runtime = test_sidecar_runtime_from_env(); - let mut degraded = published_full_retrieval_manifest(&project_root); + // Baseline: the same seeded store with an unmodified manifest reports + // hybrid ready, so each arm below flips exactly one declared condition. + let healthy = publish_admissible_full_retrieval_manifest(&mut storage, &project_root); + let state = crate::search_publication::retrieval_state_from_storage_for_runtime( + &storage, + &project_root, + &runtime, + ) + .expect("healthy retrieval state"); + assert_eq!(state.mode, RetrievalModeDto::Hybrid); + assert!(state.semantic_ready); + assert_eq!(state.fallback_reason, None); + + let mut degraded = healthy.clone(); degraded.degraded_modes_json = r#"["embedded_vector_index_unavailable"]"#.to_string(); storage .upsert_retrieval_index_manifest(°raded) @@ -3640,7 +3655,7 @@ fn degraded_or_mismatched_manifest_does_not_report_hybrid_ready() { Some(RetrievalFallbackReasonDto::DegradedRuntime) ); - let mut mismatched = published_full_retrieval_manifest(&project_root); + let mut mismatched = healthy.clone(); mismatched.embedding_backend = Some("legacy-backend".to_string()); storage .upsert_retrieval_index_manifest(&mismatched) @@ -6274,22 +6289,16 @@ fn symbol_summaries_persist_into_a_published_immutable_core() { "a published core must refuse a direct symbol-summary write" ); - let staged_record = record.clone(); - let outcome = controller - .republish_core_with_staged_mutation_blocking( - workspace.path().to_path_buf(), - storage_path.clone(), - &move |store: &mut Storage| { - store - .upsert_symbol_summaries_batch(std::slice::from_ref(&staged_record)) - .map_err(|error| { - codestory_contracts::api::ApiError::internal(error.to_string()) - }) - }, - ) - .expect("republish the core with the generated summaries"); + // The real persistence entry point must therefore route through a staged + // republish rather than the direct write. + controller + .persist_symbol_summaries(&storage_path, vec![record]) + .expect("persist summaries through the published-core route"); - assert_eq!(outcome.publication.generation, baseline.generation + 1); + let republished = Storage::database_complete_index_publication(&storage_path) + .expect("read republished publication") + .expect("republished publication"); + assert_eq!(republished.generation, baseline.generation + 1); let stored: String = Storage::open(&storage_path) .expect("open republished core") .get_connection() @@ -7011,8 +7020,11 @@ fn incremental_refresh_rebuilds_touched_file_semantic_docs_only() { .expect("full index"); let before_docs = Storage::open(&storage_path) .expect("reopen storage before incremental") - .get_all_llm_symbol_docs() - .expect("semantic docs before incremental"); + .get_symbol_search_docs_batch_after(None, 10_000) + .expect("symbol docs before incremental") + .into_iter() + .map(|doc| (doc.node_id, doc)) + .collect::>(); let before_reports = Storage::open(&storage_path) .expect("reopen reports before incremental") .get_symbol_search_docs_batch_after(None, 10_000) @@ -7058,6 +7070,48 @@ fn incremental_refresh_rebuilds_touched_file_semantic_docs_only() { .any(|doc| doc.display_name.contains("codestory_added_move_hint")), "incremental symbol docs should include the new symbol" ); + let mut untouched_docs = 0usize; + for doc in &docs { + // Component reports aggregate the whole repository, so a real symbol + // addition legitimately changes them; the untouched contract covers + // per-file symbol docs only. + if doc.display_name.starts_with("component_report:") { + continue; + } + let touched_file = doc + .file_path + .as_deref() + .is_some_and(|path| path.ends_with("rust_tictactoe.rs")); + if touched_file { + continue; + } + let before = before_docs.get(&doc.node_id).unwrap_or_else(|| { + panic!( + "symbol doc {:?} outside the touched file was dropped or renumbered", + doc.display_name + ) + }); + untouched_docs += 1; + assert_eq!( + ( + doc.doc_hash.as_str(), + doc.doc_text.as_str(), + doc.doc_version + ), + ( + before.doc_hash.as_str(), + before.doc_text.as_str(), + before.doc_version + ), + "incremental indexing must leave {:?} (file {:?}) payload untouched", + doc.display_name, + doc.file_path + ); + } + assert!( + untouched_docs > 0, + "fixture must produce symbol docs outside the touched file" + ); assert!( docs.iter().any(|doc| { doc.display_name.starts_with("component_report:") @@ -7247,9 +7301,26 @@ fn symbol_context_by_id_does_not_mutate_persisted_semantic_docs() { .expect("index without runtime refresh"); let storage = Storage::open(&storage_path).expect("reopen storage"); - let before = storage - .get_llm_symbol_doc_stats() - .expect("semantic doc stats before"); + let before_docs = storage + .get_symbol_search_docs_batch_after(None, 10_000) + .expect("symbol docs before read") + .into_iter() + .map(|doc| { + ( + doc.node_id, + doc.doc_hash, + doc.doc_text, + doc.doc_version, + doc.updated_at_epoch_ms, + ) + }) + .collect::>(); + let before_anchors = storage + .get_dense_anchor_inputs_batch_after(None, 10_000) + .expect("dense anchors before read") + .into_iter() + .map(|anchor| (anchor.node_id, anchor.document_hash, anchor.text)) + .collect::>(); let symbol_id = storage .get_nodes() .expect("load nodes") @@ -7269,11 +7340,34 @@ fn symbol_context_by_id_does_not_mutate_persisted_semantic_docs() { assert!(context.node.display_name.contains("check_winner")); let storage = Storage::open(&storage_path).expect("reopen storage after read"); - let after = storage - .get_llm_symbol_doc_stats() - .expect("semantic doc stats after"); - assert_eq!(after.doc_count, before.doc_count); - assert_eq!(after.embedding_model, before.embedding_model); + let after_docs = storage + .get_symbol_search_docs_batch_after(None, 10_000) + .expect("symbol docs after read") + .into_iter() + .map(|doc| { + ( + doc.node_id, + doc.doc_hash, + doc.doc_text, + doc.doc_version, + doc.updated_at_epoch_ms, + ) + }) + .collect::>(); + let after_anchors = storage + .get_dense_anchor_inputs_batch_after(None, 10_000) + .expect("dense anchors after read") + .into_iter() + .map(|anchor| (anchor.node_id, anchor.document_hash, anchor.text)) + .collect::>(); + assert_eq!( + after_docs, before_docs, + "a symbol_context read must not mutate persisted symbol docs" + ); + assert_eq!( + after_anchors, before_anchors, + "a symbol_context read must not mutate persisted dense anchors" + ); } #[test] diff --git a/crates/codestory-runtime/src/tests/search_intent.rs b/crates/codestory-runtime/src/tests/search_intent.rs index b5d997ba2..361ae24a9 100644 --- a/crates/codestory-runtime/src/tests/search_intent.rs +++ b/crates/codestory-runtime/src/tests/search_intent.rs @@ -75,6 +75,32 @@ fn search_intent_filters_hits_by_kind_path_name_and_language() { codestory_contracts::api::NodeKind::FUNCTION, "src/routes.rs", ), + // Each of these violates exactly one filter, so disabling any single + // filter check must let exactly one extra hit through. + hit( + "kind-only", + "listUsers", + codestory_contracts::api::NodeKind::STRUCT, + "src/routes.ts", + ), + hit( + "name-only", + "getUsers", + codestory_contracts::api::NodeKind::FUNCTION, + "src/routes.ts", + ), + hit( + "path-only", + "listUsers", + codestory_contracts::api::NodeKind::FUNCTION, + "lib/users.ts", + ), + hit( + "language-only", + "listUsers", + codestory_contracts::api::NodeKind::FUNCTION, + "src/routes.ts.md", + ), ]; apply_search_intent_filters( diff --git a/crates/codestory-runtime/src/tests/search_plan.rs b/crates/codestory-runtime/src/tests/search_plan.rs index 431c99f35..f8696d531 100644 --- a/crates/codestory-runtime/src/tests/search_plan.rs +++ b/crates/codestory-runtime/src/tests/search_plan.rs @@ -724,17 +724,20 @@ fn order_by_orientation( #[test] fn orientation_query_ranks_entry_evidence_above_leaf_aliases_when_both_exist() { let query = "explain how the subsystems connect end to end"; + // Names and paths are deliberately adverse: the leaf alias sorts first on + // every non-evidence key, so only orientation evidence can rank the + // topological root ahead of it. let mut hits = vec![ orientation_hit( "alias", - "zqLeafAlias", + "aaLeafAlias", "src/alias.ts", SearchHitOrigin::IndexedSymbol, ), orientation_hit( "entry", - "aaBootQuell", - "src/boot.ts", + "zqBootQuell", + "src/zboot.ts", SearchHitOrigin::IndexedSymbol, ), ]; @@ -765,7 +768,7 @@ fn orientation_query_ranks_entry_evidence_above_leaf_aliases_when_both_exist() { assert_eq!( order_by_orientation(query, &mut hits, Some(&evidence)), - ["aaBootQuell", "zqLeafAlias"] + ["zqBootQuell", "aaLeafAlias"] ); } diff --git a/crates/codestory-runtime/src/tests/search_scoring.rs b/crates/codestory-runtime/src/tests/search_scoring.rs index c22de3a73..a8e0e4f5f 100644 --- a/crates/codestory-runtime/src/tests/search_scoring.rs +++ b/crates/codestory-runtime/src/tests/search_scoring.rs @@ -13,12 +13,11 @@ use super::{ flush_pending_dense_anchor_inputs, fs, hybrid_test_env, insert_semantic_fixture_nodes, llm_symbol_doc_hash, load_persisted_search_state, merge_search_hits_by_node_id, mutate_published_core, pending_semantic_doc_for_test, persisted_search_generation_names, - process_env_test_lock, - project_identity_v3, prune_search_generations, rebuild_search_state_from_storage, - search_generation_completion_path, search_index_generation_root, - search_index_path_for_publication, search_index_storage_path, semantic_doc_text_for_test, - semantic_projection_republish_for_runtime, tempdir, test_index_publication, - test_retrieval_manifest, test_sidecar_runtime_from_env, unbounded, + process_env_test_lock, project_identity_v3, prune_search_generations, + rebuild_search_state_from_storage, search_generation_completion_path, + search_index_generation_root, search_index_path_for_publication, search_index_storage_path, + semantic_doc_text_for_test, semantic_projection_republish_for_runtime, tempdir, + test_index_publication, test_retrieval_manifest, test_sidecar_runtime_from_env, unbounded, write_search_generation_completion, write_semantic_fixture, }; use codestory_contracts::bounded_locks::{self, FileLockKind}; diff --git a/crates/codestory-runtime/tests/retrieval_primary_rejection.rs b/crates/codestory-runtime/tests/retrieval_primary_rejection.rs deleted file mode 100644 index d717e45d1..000000000 --- a/crates/codestory-runtime/tests/retrieval_primary_rejection.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! Sidecar primary rejection when candidates are phantom-only. - -use codestory_retrieval::{CandidateHit, CandidateSource, QueryResult, QueryTrace, classify_query}; - -#[test] -fn phantom_only_candidates_are_detected() { - let hits = vec![ - CandidateHit::with_source( - "lexical:handler", - Some("handler".into()), - 0.5, - CandidateSource::Lexical, - ), - CandidateHit::with_source( - "semantic:handler", - Some("handler".into()), - 0.55, - CandidateSource::Semantic, - ), - ]; - assert!(codestory_retrieval::phantom_sidecar_candidates_only(&hits)); - assert!(!codestory_retrieval::phantom_sidecar_candidates_only(&[ - CandidateHit::lexical_stub("src/lib.rs", 0.9,) - ])); - - let _query = QueryResult { - publication_identity: None, - query: "handler".into(), - features: classify_query("handler"), - hits, - trace: QueryTrace { - retrieval_mode: "no_semantic".into(), - degraded_reason: Some("semantic_hash_vectors_only".into()), - total_budget_ms: 500, - elapsed_ms: 1, - cancel_reason: None, - cache_hit: false, - stages: Vec::new(), - }, - }; -} From d7ddd6f2e437b4441b3b541367071f53839a9a86 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 12:34:45 -0400 Subject: [PATCH 15/58] make entry evidence decisive in orientation ranking fixture --- crates/codestory-runtime/src/tests/search_plan.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/codestory-runtime/src/tests/search_plan.rs b/crates/codestory-runtime/src/tests/search_plan.rs index f8696d531..dfd55adca 100644 --- a/crates/codestory-runtime/src/tests/search_plan.rs +++ b/crates/codestory-runtime/src/tests/search_plan.rs @@ -747,7 +747,12 @@ fn orientation_query_ranks_entry_evidence_above_leaf_aliases_when_both_exist() { hit_evidence( EntryEvidence::None, false, - CallDegrees::default(), + // The leaf's graph reach deliberately exceeds the root's, so only + // the missing entry evidence can keep it behind. + CallDegrees { + production_in_calls: 0, + out_calls: 6, + }, 1, "ts:src", ), From 192d1c70cfb13ea213a18ea6230cf10cb8263cf6 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 13:55:42 -0400 Subject: [PATCH 16/58] pin ordinary-call censuses and receiver-spec results in indexer tests --- crates/codestory-indexer/src/languages/go.rs | 6 +- .../codestory-indexer/src/proof_resolution.rs | 174 +- .../tests/call_resolution_common_methods.rs | 3093 ++++++++++++++++- .../tests/proof_resolution.rs | 317 +- .../tests/source_shape_cost.rs | 5 + 5 files changed, 3456 insertions(+), 139 deletions(-) diff --git a/crates/codestory-indexer/src/languages/go.rs b/crates/codestory-indexer/src/languages/go.rs index 466f67fd4..389afd6be 100644 --- a/crates/codestory-indexer/src/languages/go.rs +++ b/crates/codestory-indexer/src/languages/go.rs @@ -2000,7 +2000,11 @@ mod complexity_tests { .expect("Go grammar must load"); let tree = parser.parse(&source, None).expect("Go source must parse"); reset_go_navigation_resolution_work(); - let _ = receiver_call_specs(&tree, &source); + let specs = receiver_call_specs(&tree, &source); + assert!( + specs.len() >= call_count, + "each receiver call must produce a spec, or the work bound is vacuous" + ); go_navigation_resolution_work() } diff --git a/crates/codestory-indexer/src/proof_resolution.rs b/crates/codestory-indexer/src/proof_resolution.rs index ce3a624db..771829e18 100644 --- a/crates/codestory-indexer/src/proof_resolution.rs +++ b/crates/codestory-indexer/src/proof_resolution.rs @@ -20702,7 +20702,12 @@ mod ruby_php_complexity_tests { .expect("Ruby grammar"); let tree = parser.parse(&source, None).expect("Ruby declarations"); reset_ruby_php_resolution_work(); - let _ = RubyResolutionIndex::build(&tree, &source, NodeId(1), &nodes); + let index = RubyResolutionIndex::build(&tree, &source, NodeId(1), &nodes); + assert_eq!( + index.declarations.len(), + declarations, + "every declared function must reach the index, or the work bound is vacuous" + ); ruby_php_resolution_work() } @@ -20776,12 +20781,46 @@ mod ruby_php_complexity_tests { .collect::>(); reset_ruby_php_resolution_work(); let index = JavaKotlinProjectionIndex::prepare(&records, &[]); + let ruby_candidates: usize = index.ruby_functions.values().map(Vec::len).sum(); + assert_eq!( + ruby_candidates, + files * declarations, + "every ruby declaration must reach the projection domain" + ); + let php_candidates: usize = index + .php_domains + .values() + .flat_map(|domain| domain.declarations.values().map(Vec::len)) + .sum(); + assert_eq!( + php_candidates, + files * declarations, + "every php declaration must reach the projection domain" + ); for record in &records { for declaration in &record.file.top_level_declarations { if record.file.language == "ruby" { - let _ = index.ruby_function(&declaration.name, declaration.declaration); + let resolution = + index.ruby_function(&declaration.name, declaration.declaration); + assert_eq!( + resolution.is_some(), + !duplicate, + "a unique ruby declaration must resolve; a duplicated one must not" + ); } else { - let _ = index.resolve_php(&record.file.php_namespace, None, &declaration.name); + let resolution = + index.resolve_php(&record.file.php_namespace, None, &declaration.name); + if duplicate { + assert!( + matches!(resolution, JavaKotlinImportResolution::Ambiguous), + "duplicated php declarations must resolve ambiguously" + ); + } else { + assert!( + matches!(resolution, JavaKotlinImportResolution::Exact { .. }), + "each unique php declaration must resolve exactly" + ); + } } } } @@ -20844,7 +20883,11 @@ mod rust_complexity_tests { .expect("Rust grammar must load"); let tree = parser.parse(source, None).expect("source must parse"); reset_rust_resolution_work(); - let _ = RustResolutionIndex::build(&tree, source, NodeId(1), &[]); + let index = RustResolutionIndex::build(&tree, source, NodeId(1), &[]); + assert!( + !index.calls.is_empty(), + "the measured source must produce call specs, or the work bound is vacuous" + ); rust_resolution_work() } @@ -21012,16 +21055,37 @@ mod rust_complexity_tests { let index = RustProjectionIndex::prepare(&records).expect("projection index"); for item in 0..count { let module = vec![format!("module_{item}")]; - let _ = index.module(&records[0], &module); - let _ = index.declarations(&records[0], &module, &format!("function_{item}")); - let _ = index.types(&records[0], &module, &format!("Owner{item}")); - let _ = index.methods( - &records[0], - &module, - &format!("Owner{item}"), - &format!("method_{item}"), + assert!( + index.module(&records[0], &module).is_some(), + "module_{item} must be indexed, or the projection bound is vacuous" + ); + assert!( + !index + .declarations(&records[0], &module, &format!("function_{item}")) + .is_empty(), + "function_{item} must be indexed" + ); + assert!( + !index + .types(&records[0], &module, &format!("Owner{item}")) + .is_empty(), + "Owner{item} must be indexed" + ); + assert!( + !index + .methods( + &records[0], + &module, + &format!("Owner{item}"), + &format!("method_{item}"), + ) + .is_empty(), + "Owner{item}::method_{item} must be indexed" + ); + assert!( + index.node_file(NodeId(50_000 + item as i64)).is_some(), + "import node 50_000+{item} must be indexed" ); - let _ = index.node_file(NodeId(50_000 + item as i64)); } rust_resolution_work() } @@ -21118,7 +21182,7 @@ mod c_cpp_complexity_tests { use super::*; use tree_sitter::Parser; - fn measured_work(source: &str) -> usize { + fn measured_work(source: &str, expected_calls: usize) -> usize { let mut parser = Parser::new(); parser .set_language(&tree_sitter_cpp::LANGUAGE.into()) @@ -21159,8 +21223,17 @@ mod c_cpp_complexity_tests { file_id, &nodes, ); + assert!( + index.calls.len() >= expected_calls, + "each receiver call must produce a spec, or the work bound is vacuous" + ); for call in &index.calls { - let _ = index.resolve_syntax_claim(call.callee, call.form, &call.raw_target); + let (caller, binding) = + index.resolve_syntax_claim(call.callee, call.form, &call.raw_target); + assert!( + caller.is_some() && !matches!(binding, CachedResolutionBinding::Unsupported), + "each receiver call must bind its caller, or the lookup did no work" + ); } c_cpp_resolution_work() } @@ -21183,8 +21256,8 @@ mod c_cpp_complexity_tests { #[test] fn c_cpp_parser_index_work_is_linear_for_doubled_receivers_and_nested_owners() { - let small = measured_work(&source(64)); - let large = measured_work(&source(128)); + let small = measured_work(&source(64), 64); + let large = measured_work(&source(128), 128); assert!( small >= 64 * 8, "C++ parser work was not fully counted: {small}" @@ -21332,7 +21405,7 @@ mod java_kotlin_complexity_tests { Ok(()) } - fn measured_work(language: &str, source: &str) -> usize { + fn measured_work(language: &str, source: &str, expected_calls: usize) -> usize { let mut parser = Parser::new(); let (grammar, path) = match language { "java" => (tree_sitter_java::LANGUAGE.into(), Path::new("Exact.java")), @@ -21380,8 +21453,23 @@ mod java_kotlin_complexity_tests { reset_java_kotlin_resolution_work(); let index = JavaKotlinResolutionIndex::build(&tree, source, path, language, file_id, &nodes); + assert!( + index.calls.len() >= expected_calls, + "{language}: each receiver call must produce a spec, or the work bound is vacuous" + ); for call in &index.calls { - let _ = index.resolve_syntax_claim(source, call.callee, call.form, &call.raw_target); + let (caller, binding) = + index.resolve_syntax_claim(source, call.callee, call.form, &call.raw_target); + // Dart callables are nominal here: their caller node is not in the + // fixture's callable set, so a legitimately collected dart call may + // return `(None, Unsupported)`. The spec census above is its + // non-vacuity proof; the other languages must bind every call. + if language != "dart" { + assert!( + caller.is_some() && !matches!(binding, CachedResolutionBinding::Unsupported), + "{language}: each receiver call must bind its caller, or the lookup did no work" + ); + } } java_kotlin_resolution_work() } @@ -21575,6 +21663,12 @@ mod java_kotlin_complexity_tests { .last() .is_some_and(|snapshot| snapshot.proof_store_transaction_completed) ); + assert!( + progress + .last() + .is_some_and(|snapshot| snapshot.facts_persisted > 0), + "the measured pipeline must persist call facts, or the work bound is vacuous" + ); store .validate_proof_resolution_publication(&publication) .expect("replay proof resolution"); @@ -21625,8 +21719,8 @@ mod java_kotlin_complexity_tests { #[test] fn java_kotlin_parser_index_work_is_linear_for_doubled_calls_and_nested_owners() { for language in ["java", "kotlin"] { - let small = measured_work(language, &source(language, 64)); - let large = measured_work(language, &source(language, 128)); + let small = measured_work(language, &source(language, 64), 64); + let large = measured_work(language, &source(language, 128), 128); assert!( small >= 64 * 8, "{language} parser work was not fully counted: {small}" @@ -21641,8 +21735,8 @@ mod java_kotlin_complexity_tests { #[test] fn csharp_swift_dart_parser_index_work_is_linear_for_doubled_receivers_and_callers() { for language in ["csharp", "swift", "dart"] { - let small = measured_work(language, &csd_source(language, 64)); - let large = measured_work(language, &csd_source(language, 128)); + let small = measured_work(language, &csd_source(language, 64), 64); + let large = measured_work(language, &csd_source(language, 128), 128); assert!( small >= 64 * 8, "{language} parser work was not fully counted: {small}" @@ -21768,7 +21862,11 @@ mod go_complexity_tests { .expect("Go grammar must load"); let tree = parser.parse(source, None).expect("source must parse"); reset_go_resolution_work(); - let _ = GoResolutionIndex::build(&tree, source, NodeId(1), &[]); + let index = GoResolutionIndex::build(&tree, source, NodeId(1), &[]); + assert!( + !index.calls.is_empty(), + "the measured Go source must produce call specs, or the work bound is vacuous" + ); go_resolution_work() } @@ -21821,7 +21919,11 @@ mod go_complexity_tests { reset_go_resolution_work(); let index = GoProjectionIndex::prepare(&records).expect("Go package projection"); for _ in 0..lookup_count { - let _ = index.resolve_function(&records[0], "proof", "Target"); + let resolution = index.resolve_function(&records[0], "proof", "Target"); + assert!( + matches!(resolution, GoFunctionResolution::Ambiguous), + "every package file declares Target, so the lookup must report ambiguity — Missing or Incomplete would mean the domain was empty" + ); } go_resolution_work() } @@ -21974,7 +22076,11 @@ mod python_complexity_tests { .expect("Python grammar must load"); let tree = parser.parse(&source, None).expect("source must parse"); reset_python_resolution_work(); - let _ = PythonResolutionIndex::build(&tree, &source, NodeId(1), &[]); + let index = PythonResolutionIndex::build(&tree, &source, NodeId(1), &[]); + assert!( + !index.calls.is_empty(), + "the measured Python source must produce call specs, or the work bound is vacuous" + ); python_resolution_work() } @@ -21999,7 +22105,11 @@ mod python_complexity_tests { let tree = parser.parse(&source, None).expect("source must parse"); assert!(!tree.root_node().has_error(), "nested pattern must parse"); reset_python_resolution_work(); - let _ = PythonResolutionIndex::build(&tree, &source, NodeId(1), &[]); + let index = PythonResolutionIndex::build(&tree, &source, NodeId(1), &[]); + assert!( + !index.calls.is_empty(), + "the nested-match source must produce call specs, or the work bound is vacuous" + ); python_resolution_work() } @@ -22039,10 +22149,16 @@ mod python_complexity_tests { resolution.target, RelativeImportResolution::Unique(_) )); - let _ = index.declarations(FileId(2), "target"); + assert!( + !index.declarations(FileId(2), "target").is_empty(), + "module_1.py's target must reach the projection, or the lookup bound is vacuous" + ); let owners = index.classes(FileId(2), "Worker"); assert_eq!(owners.len(), 1); - let _ = index.methods(FileId(2), owners[0], "run"); + assert!( + !index.methods(FileId(2), owners[0], "run").is_empty(), + "Worker::run must reach the projection" + ); } python_resolution_work() } diff --git a/crates/codestory-indexer/tests/call_resolution_common_methods.rs b/crates/codestory-indexer/tests/call_resolution_common_methods.rs index df5ee71a1..81ee3979b 100644 --- a/crates/codestory-indexer/tests/call_resolution_common_methods.rs +++ b/crates/codestory-indexer/tests/call_resolution_common_methods.rs @@ -565,6 +565,41 @@ fn assert_resolved_call_count_to_method_owner_in_file( ); } +/// Ordinary-call census: a negative resolution assertion is vacuous unless the +/// designated caller really emitted the expected CALL edges toward the callee +/// (matched on the edge's raw target). Assert this before every negative +/// helper so a dropped call census cannot let the negative pass silently. +fn assert_ordinary_call_census( + case_name: &str, + nodes: &[Node], + edges: &[Edge], + caller_name: &str, + callee_name: &str, + expected_count: usize, +) { + let node_by_id: HashMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect(); + let count = edges + .iter() + .filter(|edge| edge.kind == EdgeKind::CALL) + .filter(|edge| { + node_by_id + .get(&edge.source) + .is_some_and(|source| is_matching_name(&source.serialized_name, caller_name)) + && node_by_id + .get(&edge.target) + .is_some_and(|target| is_matching_name(&target.serialized_name, callee_name)) + }) + .count(); + + assert_eq!( + count, + expected_count, + "Case `{case_name}`: ordinary call census expected {expected_count} CALL edge(s) \ + `{caller_name}` -> `{callee_name}`; negative assertions are vacuous without them. Calls: {:?}", + describe_call_edges(edges, nodes) + ); +} + fn assert_no_resolved_call_to_method_owner_in_file( case_name: &str, nodes: &[Node], @@ -971,6 +1006,14 @@ class Widget: ("external.py", external_source), ("workflow.py", workflow_source), ])?; + assert_ordinary_call_census( + "python self receiver shadowing import", + &nodes, + &edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python self receiver shadowing import", &nodes, @@ -1043,6 +1086,14 @@ def union_typed(repo: Repository | None): let (nodes, edges) = index_single_file("workflow.py", source)?; for caller in ["unannotated", "union_typed"] { + assert_ordinary_call_census( + "python imprecise receiver", + &nodes, + &edges, + caller, + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "python imprecise receiver", &nodes, @@ -1051,6 +1102,14 @@ def union_typed(repo: Repository | None): "Repository", "save", ); + assert_ordinary_call_census( + "python imprecise receiver", + &nodes, + &edges, + caller, + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "python imprecise receiver", &nodes, @@ -1179,6 +1238,14 @@ class Notifier: "notify_event", "notifier.py", ); + assert_ordinary_call_census( + "python imported annotated receiver", + &nodes, + &edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python imported annotated receiver", &nodes, @@ -1188,6 +1255,14 @@ class Notifier: "notify_event", "shadow.py", ); + assert_ordinary_call_census( + "python imported untyped receiver", + &nodes, + &edges, + "untyped", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner( "python imported untyped receiver", &nodes, @@ -1201,6 +1276,14 @@ class Notifier: ("shadow.py", shadow_source), ("workflow.py", missing_import_source), ])?; + assert_ordinary_call_census( + "python missing imported owner", + &missing_nodes, + &missing_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner( "python missing imported owner", &missing_nodes, @@ -1214,6 +1297,14 @@ class Notifier: ("other/notifier.py", notifier_source), ("workflow.py", misplaced_import_source), ])?; + assert_ordinary_call_census( + "python misplaced imported owner", + &misplaced_nodes, + &misplaced_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner( "python misplaced imported owner", &misplaced_nodes, @@ -1227,6 +1318,14 @@ class Notifier: ("other/notifier.py", notifier_source), ("workflow.py", unimported_annotation_source), ])?; + assert_ordinary_call_census( + "python unimported annotated owner", + &unimported_nodes, + &unimported_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner( "python unimported annotated owner", &unimported_nodes, @@ -1241,6 +1340,14 @@ class Notifier: ("shadow.py", shadow_source), ("workflow.py", duplicate_import_source), ])?; + assert_ordinary_call_census( + "python duplicate imported owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner( "python duplicate imported owner", &duplicate_nodes, @@ -1263,6 +1370,14 @@ class Notifier: "notify_event", "workflow.py", ); + assert_ordinary_call_census( + "python local shadowed imported owner", + &local_shadow_nodes, + &local_shadow_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python local shadowed imported owner", &local_shadow_nodes, @@ -1340,6 +1455,7 @@ class Notifier: ] { let (nodes, edges) = index_files(&[("notifier.py", notifier_source), ("workflow.py", &source)])?; + assert_ordinary_call_census(name, &nodes, &edges, "run", "notify_event", 1); assert_no_resolved_call_to_method_owner( name, &nodes, @@ -1356,6 +1472,14 @@ class Notifier: ("notifier.py", notifier_source), ("workflow.py", &constructor_source), ])?; + assert_ordinary_call_census( + "local annotation authority does not restore guessed constructor binding", + &constructor_nodes, + &constructor_edges, + "construct", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner( "local annotation authority does not restore guessed constructor binding", &constructor_nodes, @@ -1369,6 +1493,14 @@ class Notifier: ("notifier.py", notifier_source), ("workflow.py", assignment_shadow_import_source), ])?; + assert_ordinary_call_census( + "python assignment shadowed imported owner", + &assignment_shadow_nodes, + &assignment_shadow_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python assignment shadowed imported owner", &assignment_shadow_nodes, @@ -1407,6 +1539,14 @@ class Notifier: "notify_event", "notifier.py", ); + assert_ordinary_call_census( + "python aliased imported owner", + &alias_nodes, + &alias_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python aliased imported owner", &alias_nodes, @@ -1431,6 +1571,14 @@ class Notifier: "notify_event", "notifier.py", ); + assert_ordinary_call_census( + "python multiline imported owner", + &multiline_nodes, + &multiline_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python multiline imported owner", &multiline_nodes, @@ -1445,6 +1593,14 @@ class Notifier: ("notifier.py", ambiguous_notifier_source), ("workflow.py", workflow_source), ])?; + assert_ordinary_call_census( + "python ambiguous imported owner", + &ambiguous_nodes, + &ambiguous_edges, + "run", + "notify_event", + 1, + ); assert_no_resolved_call_to_method_owner( "python ambiguous imported owner", &ambiguous_nodes, @@ -1565,6 +1721,14 @@ class Pipeline: "run", "workflow.py", ); + assert_ordinary_call_census( + "python imported property receiver", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python imported property receiver", &nodes, @@ -1580,6 +1744,14 @@ class Pipeline: ("workflow.py", workflow_source), ("main.py", missing_source), ])?; + assert_ordinary_call_census( + "python missing imported property owner", + &missing_nodes, + &missing_edges, + "Pipeline.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "python missing imported property owner", &missing_nodes, @@ -1594,6 +1766,14 @@ class Pipeline: ("shadow.py", shadow_source), ("main.py", duplicate_source), ])?; + assert_ordinary_call_census( + "python duplicate imported property owner", + &duplicate_nodes, + &duplicate_edges, + "Pipeline.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "python duplicate imported property owner", &duplicate_nodes, @@ -1683,6 +1863,14 @@ class NestedAssignmentPipeline: "StaticAssignmentPipeline.run", "ClassAssignmentPipeline.run", ] { + assert_ordinary_call_census( + "python uncertain property receiver", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "python uncertain property receiver", &nodes, @@ -1691,6 +1879,14 @@ class NestedAssignmentPipeline: "Workflow", "run", ); + assert_ordinary_call_census( + "python uncertain property receiver", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "python uncertain property receiver", &nodes, @@ -1708,6 +1904,14 @@ class NestedAssignmentPipeline: "Workflow", "run", ); + assert_ordinary_call_census( + "python property receiver ignores nested assignment", + &nodes, + &edges, + "NestedAssignmentPipeline.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "python property receiver ignores nested assignment", &nodes, @@ -1852,6 +2056,14 @@ class ReassignedSession: "ReassignedSession.send", ] { for owner in ["BaseAdapter", "HTTPAdapter"] { + assert_ordinary_call_census( + "python imprecise factory return", + &nodes, + &edges, + caller, + "send", + 1, + ); assert_no_resolved_call_to_method_owner( "python imprecise factory return", &nodes, @@ -1922,6 +2134,14 @@ def run(): "run", "workflow.py", ); + assert_ordinary_call_census( + "python imported constructor local receiver", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python imported constructor local receiver", &nodes, @@ -1937,6 +2157,14 @@ def run(): ("workflow.py", workflow_source), ("main.py", missing_source), ])?; + assert_ordinary_call_census( + "python missing imported constructor owner", + &missing_nodes, + &missing_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "python missing imported constructor owner", &missing_nodes, @@ -1951,6 +2179,14 @@ def run(): ("shadow.py", shadow_source), ("main.py", duplicate_source), ])?; + assert_ordinary_call_census( + "python duplicate imported constructor owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "python duplicate imported constructor owner", &duplicate_nodes, @@ -2223,6 +2459,21 @@ def shadowed_module(method, url): "DuplicateEnterSession", "AsyncEnterSession", ] { + // `nested_scope` delegates to a nested `inner` callable that owns + // the `session.request` call; census that inner-owned edge. + let census_caller = if caller == "nested_scope" { + "inner" + } else { + caller + }; + assert_ordinary_call_census( + "python with-item fail-closed guard", + &nodes, + &edges, + census_caller, + "request", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python with-item fail-closed guard", &nodes, @@ -2319,6 +2570,14 @@ def future_local_class_shadow(): "local_from_import_alias_shadow", "future_local_class_shadow", ] { + assert_ordinary_call_census( + "python constructor local visibility guard", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python constructor local visibility guard", &nodes, @@ -2328,6 +2587,14 @@ def future_local_class_shadow(): "run", "workflow.py", ); + assert_ordinary_call_census( + "python constructor local visibility guard", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python constructor local visibility guard", &nodes, @@ -2337,6 +2604,14 @@ def future_local_class_shadow(): "run", "shadow.py", ); + assert_ordinary_call_census( + "python constructor local visibility guard", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python constructor local visibility guard", &nodes, @@ -2356,6 +2631,14 @@ def future_local_class_shadow(): "run", "main.py", ); + assert_ordinary_call_census( + "python constructor local class shadow", + &nodes, + &edges, + "local_class_shadow", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "python constructor local class shadow", &nodes, @@ -2447,6 +2730,18 @@ def outer(repo: Repository): "Repository", "flush", ); + // Census anchor: the nested `inner` callable owns the `repo.save()` call. + // The negative assertions below deliberately expect no outer-owned `save` + // edge; the census therefore proves the *inner-owned* call exists. + assert_ordinary_call_census("python nested receiver", &nodes, &edges, "inner", "save", 1); + assert_ordinary_call_census( + "python nested receiver", + &nodes, + &edges, + "outer", + "flush", + 1, + ); assert_no_resolved_call_to_method_owner( "python nested receiver", &nodes, @@ -2606,6 +2901,7 @@ type Notifier interface { "Notify", "project/notifier/notifier.go", ); + assert_ordinary_call_census("go imported receiver", &nodes, &edges, "Run", "Notify", 1); assert_no_resolved_call_to_method_owner_in_file( "go imported receiver", &nodes, @@ -2615,6 +2911,7 @@ type Notifier interface { "Notify", "workflow.go", ); + assert_ordinary_call_census("go imported receiver", &nodes, &edges, "Run", "Notify", 1); assert_no_resolved_call_to_method_owner_in_file( "go imported receiver", &nodes, @@ -2629,6 +2926,14 @@ type Notifier interface { ("other/notifier/notifier.go", other_notifier_source), ("workflow.go", workflow_source), ])?; + assert_ordinary_call_census( + "go imported receiver missing package", + &missing_import_nodes, + &missing_import_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go imported receiver missing package", &missing_import_nodes, @@ -2650,16 +2955,24 @@ func Run(n Notifier) { ("project/notifier/notifier.go", notifier_source), ("workflow.go", no_import_workflow_source), ])?; - assert_no_resolved_call_to_method_owner_in_file( + assert_ordinary_call_census( "go unimported receiver type stays unresolved", &no_import_nodes, &no_import_edges, "Run", - "Notifier", "Notify", - "project/notifier/notifier.go", + 1, ); - + assert_no_resolved_call_to_method_owner_in_file( + "go unimported receiver type stays unresolved", + &no_import_nodes, + &no_import_edges, + "Run", + "Notifier", + "Notify", + "project/notifier/notifier.go", + ); + Ok(()) } @@ -2859,6 +3172,14 @@ func outerParamStillWorks(workflow Workflow) { "Run", "workflow.go", ); + assert_ordinary_call_census( + "go same-file composite receiver avoids other owner", + &nodes, + &edges, + "orchestrate", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner( "go same-file composite receiver avoids other owner", &nodes, @@ -2867,6 +3188,14 @@ func outerParamStillWorks(workflow Workflow) { "OtherWorkflow", "Run", ); + assert_ordinary_call_census( + "go factory receiver stays unresolved", + &nodes, + &edges, + "factoryOnly", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner( "go factory receiver stays unresolved", &nodes, @@ -2875,6 +3204,14 @@ func outerParamStillWorks(workflow Workflow) { "Workflow", "Run", ); + assert_ordinary_call_census( + "go reassigned factory receiver invalidates stale owner", + &nodes, + &edges, + "reassignedFactory", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner( "go reassigned factory receiver invalidates stale owner", &nodes, @@ -2883,6 +3220,14 @@ func outerParamStillWorks(workflow Workflow) { "Workflow", "Run", ); + assert_ordinary_call_census( + "go redeclared factory receiver invalidates stale owner", + &nodes, + &edges, + "redeclaredFactory", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner( "go redeclared factory receiver invalidates stale owner", &nodes, @@ -2891,6 +3236,14 @@ func outerParamStillWorks(workflow Workflow) { "Workflow", "Run", ); + assert_ordinary_call_census( + "go wrapper call containing composite stays unresolved", + &nodes, + &edges, + "wrappedComposite", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner( "go wrapper call containing composite stays unresolved", &nodes, @@ -2906,6 +3259,14 @@ func outerParamStillWorks(workflow Workflow) { "directOnly", "makeWorkflow", ); + assert_ordinary_call_census( + "go erased receiver stays unresolved", + &nodes, + &edges, + "erased", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner( "go erased receiver stays unresolved", &nodes, @@ -2926,6 +3287,14 @@ func outerParamStillWorks(workflow Workflow) { expected_count: 1, }, ); + assert_ordinary_call_census( + "go local composite shadows typed parameter", + &nodes, + &edges, + "innerShadow", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner( "go local composite shadows typed parameter", &nodes, @@ -2970,6 +3339,14 @@ func orchestrate() { ("project/external/workflow.go", imported_owner_source), ("app/workflow.go", imported_caller_source), ])?; + assert_ordinary_call_census( + "go unqualified composite receiver does not use imported cross-file owner", + &imported_nodes, + &imported_edges, + "orchestrate", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go unqualified composite receiver does not use imported cross-file owner", &imported_nodes, @@ -3294,6 +3671,14 @@ func orchestrate() { "Run", "project/external/workflow.go", ); + assert_ordinary_call_census( + "go qualified imported composite avoids other owner", + &nodes, + &edges, + "orchestrate", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go qualified imported composite avoids other owner", &nodes, @@ -3308,6 +3693,14 @@ func orchestrate() { ("project/external/workflow.go", external_source), ("app/workflow.go", missing_import_source), ])?; + assert_ordinary_call_census( + "go missing qualified imported composite receiver", + &missing_nodes, + &missing_edges, + "orchestrate", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go missing qualified imported composite receiver", &missing_nodes, @@ -3323,6 +3716,14 @@ func orchestrate() { ("project/other/workflow.go", other_source), ("app/workflow.go", duplicate_alias_source), ])?; + assert_ordinary_call_census( + "go duplicate qualified imported composite receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go duplicate qualified imported composite receiver", &duplicate_nodes, @@ -3332,6 +3733,14 @@ func orchestrate() { "Run", "project/external/workflow.go", ); + assert_ordinary_call_census( + "go duplicate qualified imported composite receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go duplicate qualified imported composite receiver", &duplicate_nodes, @@ -3431,6 +3840,14 @@ func (w Workflow) Run() { "Notify", "project/notifier/notifier.go", ); + assert_ordinary_call_census( + "go imported method receiver field avoids local same-name owner", + &nodes, + &edges, + "Run", + "Notify", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "go imported method receiver field avoids local same-name owner", &nodes, @@ -3440,6 +3857,14 @@ func (w Workflow) Run() { "Notify", "workflow.go", ); + assert_ordinary_call_census( + "go imported method receiver field avoids other package owner", + &nodes, + &edges, + "Run", + "Notify", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "go imported method receiver field avoids other package owner", &nodes, @@ -3467,6 +3892,14 @@ func (w Workflow) Run() { ), ("workflow.go", workflow_source), ])?; + assert_ordinary_call_census( + "go duplicate imported method receiver field owner", + &duplicate_nodes, + &duplicate_edges, + "Run", + "Notify", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "go duplicate imported method receiver field owner", &duplicate_nodes, @@ -3476,6 +3909,14 @@ func (w Workflow) Run() { "Notify", "project/notifier/notifier.go", ); + assert_ordinary_call_census( + "go duplicate imported method receiver field owner", + &duplicate_nodes, + &duplicate_edges, + "Run", + "Notify", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "go duplicate imported method receiver field owner", &duplicate_nodes, @@ -3490,6 +3931,14 @@ func (w Workflow) Run() { ("project/notifier/notifier.go", notifier_source), ("workflow.go", missing_import_source), ])?; + assert_ordinary_call_census( + "go imported method receiver field missing package", + &missing_nodes, + &missing_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go imported method receiver field missing package", &missing_nodes, @@ -3504,6 +3953,14 @@ func (w Workflow) Run() { ("project/notifier/notifier.go", notifier_source), ("workflow.go", no_import_source), ])?; + assert_ordinary_call_census( + "go qualified field type without import stays unresolved", + &no_import_nodes, + &no_import_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "go qualified field type without import stays unresolved", &no_import_nodes, @@ -3742,6 +4199,14 @@ class Entry { "run", "src/com/acme/workflow/Entry.java", ); + assert_ordinary_call_census( + "java var factory local receiver stays unresolved", + &nodes, + &edges, + "varFactory", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "java var factory local receiver stays unresolved", &nodes, @@ -3759,6 +4224,14 @@ class Entry { "run", "src/com/acme/workflow/Entry.java", ); + assert_ordinary_call_census( + "java object-typed wrapper stays unresolved", + &nodes, + &edges, + "objectWrapped", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "java object-typed wrapper stays unresolved", &nodes, @@ -3767,6 +4240,14 @@ class Entry { "Workflow", "run", ); + assert_ordinary_call_census( + "java erased receiver stays unresolved", + &nodes, + &edges, + "erased", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "java erased receiver stays unresolved", &nodes, @@ -3775,6 +4256,14 @@ class Entry { "Workflow", "run", ); + assert_ordinary_call_census( + "java local receiver avoids other owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "java local receiver avoids other owner", &nodes, @@ -3802,6 +4291,14 @@ class Entry { expected_count: 1, }, ); + assert_ordinary_call_census( + "java local declaration shadows typed parameter", + &nodes, + &edges, + "innerShadow", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "java local declaration shadows typed parameter", &nodes, @@ -3831,6 +4328,14 @@ class Entry { "run", "src/com/acme/workflow/Entry.java", ); + assert_ordinary_call_census( + "java enhanced-for receiver does not leak", + &nodes, + &edges, + "enhancedForDoesNotLeak", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "java enhanced-for receiver does not leak", &nodes, @@ -3944,6 +4449,14 @@ class OtherWorkflow { "decorate", "src/com/acme/workflow/Workflow.java", ); + assert_ordinary_call_census( + "java self receiver avoids same-named owner", + &nodes, + &edges, + "run", + "decorate", + 1, + ); assert_no_resolved_call_to_method_owner( "java self receiver avoids same-named owner", &nodes, @@ -3961,6 +4474,14 @@ class OtherWorkflow { "save", "src/com/acme/workflow/Workflow.java", ); + assert_ordinary_call_census( + "java local receiver shadow prevents field fallback", + &nodes, + &edges, + "localShadowsField", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "java local receiver shadow prevents field fallback", &nodes, @@ -3969,6 +4490,14 @@ class OtherWorkflow { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "java erased field receiver stays unresolved", + &nodes, + &edges, + "erasedField", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "java erased field receiver stays unresolved", &nodes, @@ -4000,6 +4529,14 @@ class Entry { ("src/com/acme/model/Workflow.java", owner_source), ("src/com/acme/app/Entry.java", caller_source), ])?; + assert_ordinary_call_census( + "java field receiver does not use unimported cross-file owner", + &cross_file_nodes, + &cross_file_edges, + "call", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java field receiver does not use unimported cross-file owner", &cross_file_nodes, @@ -4072,6 +4609,14 @@ class Workflow { "notifyEvent", "src/com/acme/mail/Notifier.java", ); + assert_ordinary_call_census( + "java imported field receiver exact package", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java imported field receiver exact package", &nodes, @@ -4090,6 +4635,14 @@ class Workflow { duplicate_import_source, ), ])?; + assert_ordinary_call_census( + "java duplicate imported field receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java duplicate imported field receiver local name", &duplicate_nodes, @@ -4099,6 +4652,14 @@ class Workflow { "notifyEvent", "src/com/acme/mail/Notifier.java", ); + assert_ordinary_call_census( + "java duplicate imported field receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java duplicate imported field receiver local name", &duplicate_nodes, @@ -4205,6 +4766,14 @@ class Workflow { expected_count: 1, }, ); + assert_ordinary_call_census( + "java imported receiver exact package", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java imported receiver exact package", &nodes, @@ -4219,6 +4788,14 @@ class Workflow { ("src/com/acme/mail/Notifier.java", mail_notifier_source), ("src/com/acme/workflow/Workflow.java", missing_import_source), ])?; + assert_ordinary_call_census( + "java missing imported receiver package", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java missing imported receiver package", &missing_nodes, @@ -4237,6 +4814,14 @@ class Workflow { duplicate_import_source, ), ])?; + assert_ordinary_call_census( + "java duplicate imported receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java duplicate imported receiver local name", &duplicate_nodes, @@ -4246,6 +4831,14 @@ class Workflow { "notifyEvent", "src/com/acme/mail/Notifier.java", ); + assert_ordinary_call_census( + "java duplicate imported receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "java duplicate imported receiver local name", &duplicate_nodes, @@ -4269,7 +4862,15 @@ class Workflow { "notifyEvent", "src/com/acme/workflow/Workflow.java", ); - assert_no_resolved_call_to_method_owner_in_file( + assert_ordinary_call_census( + "java local receiver shadows imported type", + &shadow_nodes, + &shadow_edges, + "run", + "notifyEvent", + 1, + ); + assert_no_resolved_call_to_method_owner_in_file( "java local receiver shadows imported type", &shadow_nodes, &shadow_edges, @@ -4392,6 +4993,14 @@ fun run(notifier: Notifier) { expected_count: 1, }, ); + assert_ordinary_call_census( + "kotlin imported receiver exact package", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin imported receiver exact package", &nodes, @@ -4416,6 +5025,14 @@ fun run(notifier: Notifier) { "notifyEvent", "src/com/acme/mail/Notifier.kt", ); + assert_ordinary_call_census( + "kotlin aliased imported receiver exact package", + &alias_nodes, + &alias_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin aliased imported receiver exact package", &alias_nodes, @@ -4430,6 +5047,14 @@ fun run(notifier: Notifier) { ("src/com/acme/mail/Notifier.kt", mail_notifier_source), ("src/com/acme/workflow/Workflow.kt", missing_import_source), ])?; + assert_ordinary_call_census( + "kotlin missing imported receiver package", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin missing imported receiver package", &missing_nodes, @@ -4445,6 +5070,14 @@ fun run(notifier: Notifier) { ("src/com/acme/other/Notifier.kt", other_notifier_source), ("src/com/acme/workflow/Workflow.kt", duplicate_import_source), ])?; + assert_ordinary_call_census( + "kotlin duplicate imported receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin duplicate imported receiver local name", &duplicate_nodes, @@ -4454,6 +5087,14 @@ fun run(notifier: Notifier) { "notifyEvent", "src/com/acme/mail/Notifier.kt", ); + assert_ordinary_call_census( + "kotlin duplicate imported receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin duplicate imported receiver local name", &duplicate_nodes, @@ -4468,6 +5109,14 @@ fun run(notifier: Notifier) { ("src/com/acme/mail/Notifier.kt", mail_notifier_source), ("src/com/acme/workflow/Workflow.kt", wildcard_import_source), ])?; + assert_ordinary_call_census( + "kotlin wildcard imported receiver stays unresolved", + &wildcard_nodes, + &wildcard_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin wildcard imported receiver stays unresolved", &wildcard_nodes, @@ -4482,6 +5131,14 @@ fun run(notifier: Notifier) { ("src/com/acme/mail/Notifier.kt", mail_notifier_source), ("src/com/acme/workflow/Workflow.kt", no_import_source), ])?; + assert_ordinary_call_census( + "kotlin unimported receiver type stays unresolved", + &no_import_nodes, + &no_import_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin unimported receiver type stays unresolved", &no_import_nodes, @@ -4505,6 +5162,14 @@ fun run(notifier: Notifier) { "notifyEvent", "src/com/acme/workflow/Workflow.kt", ); + assert_ordinary_call_census( + "kotlin local receiver shadows imported type", + &shadow_nodes, + &shadow_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin local receiver shadows imported type", &shadow_nodes, @@ -4586,6 +5251,14 @@ fun orderAware() { expected_count: 1, }, ); + assert_ordinary_call_census( + "kotlin same-file constructor receiver avoids other owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin same-file constructor receiver avoids other owner", &nodes, @@ -4594,6 +5267,14 @@ fun orderAware() { "OtherWorkflow", "run", ); + assert_ordinary_call_census( + "kotlin factory receiver stays unresolved", + &nodes, + &edges, + "factoryOnly", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin factory receiver stays unresolved", &nodes, @@ -4602,6 +5283,14 @@ fun orderAware() { "Workflow", "run", ); + assert_ordinary_call_census( + "kotlin erased receiver stays unresolved", + &nodes, + &edges, + "erased", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin erased receiver stays unresolved", &nodes, @@ -4781,6 +5470,14 @@ fun orchestrate() { expected_count: 1, }, ); + assert_ordinary_call_census( + "kotlin imported constructor receiver avoids other package", + &imported_nodes, + &imported_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin imported constructor receiver avoids other package", &imported_nodes, @@ -4805,6 +5502,14 @@ fun orchestrate() { "run", "src/other/Workflow.kt", ); + assert_ordinary_call_census( + "kotlin aliased imported constructor receiver avoids other package", + &alias_nodes, + &alias_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin aliased imported constructor receiver avoids other package", &alias_nodes, @@ -4819,6 +5524,14 @@ fun orchestrate() { ("src/other/Workflow.kt", imported_owner_source), ("src/app/UseWorkflow.kt", missing_import_source), ])?; + assert_ordinary_call_census( + "kotlin missing imported constructor package", + &missing_nodes, + &missing_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin missing imported constructor package", &missing_nodes, @@ -4834,6 +5547,14 @@ fun orchestrate() { ("src/alternate/Workflow.kt", other_imported_owner_source), ("src/app/UseWorkflow.kt", duplicate_import_source), ])?; + assert_ordinary_call_census( + "kotlin duplicate imported constructor local name", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin duplicate imported constructor local name", &duplicate_nodes, @@ -4843,6 +5564,14 @@ fun orchestrate() { "run", "src/other/Workflow.kt", ); + assert_ordinary_call_census( + "kotlin duplicate imported constructor local name", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin duplicate imported constructor local name", &duplicate_nodes, @@ -4857,6 +5586,14 @@ fun orchestrate() { ("src/other/Workflow.kt", imported_owner_source), ("src/app/UseWorkflow.kt", wildcard_import_source), ])?; + assert_ordinary_call_census( + "kotlin wildcard imported constructor receiver stays unresolved", + &wildcard_nodes, + &wildcard_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin wildcard imported constructor receiver stays unresolved", &wildcard_nodes, @@ -4871,6 +5608,14 @@ fun orchestrate() { ("src/other/Workflow.kt", imported_owner_source), ("src/app/UseWorkflow.kt", no_import_source), ])?; + assert_ordinary_call_census( + "kotlin unimported constructor receiver stays unresolved", + &no_import_nodes, + &no_import_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin unimported constructor receiver stays unresolved", &no_import_nodes, @@ -4894,6 +5639,14 @@ fun orchestrate() { "run", "src/app/UseWorkflow.kt", ); + assert_ordinary_call_census( + "kotlin local constructor receiver shadows imported type", + &shadow_nodes, + &shadow_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin local constructor receiver shadows imported type", &shadow_nodes, @@ -4917,6 +5670,14 @@ fun orchestrate() { "run", "src/app/UseWorkflow.kt", ); + assert_ordinary_call_census( + "kotlin local constructor receiver shadows imported alias", + &alias_shadow_nodes, + &alias_shadow_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin local constructor receiver shadows imported alias", &alias_shadow_nodes, @@ -5031,6 +5792,14 @@ fun makeNotifier(): Notifier = object : Notifier { "decorate", "src/app/Workflow.kt", ); + assert_ordinary_call_census( + "kotlin self receiver avoids same-named owner", + &nodes, + &edges, + "run", + "decorate", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin self receiver avoids same-named owner", &nodes, @@ -5048,6 +5817,14 @@ fun makeNotifier(): Notifier = object : Notifier { "save", "src/app/Workflow.kt", ); + assert_ordinary_call_census( + "kotlin parameter shadow prevents property fallback", + &nodes, + &edges, + "parameterShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin parameter shadow prevents property fallback", &nodes, @@ -5083,6 +5860,14 @@ fun makeNotifier(): Notifier = object : Notifier { "save", "src/app/Workflow.kt", ); + assert_ordinary_call_census( + "kotlin local constructor shadow prevents property fallback", + &nodes, + &edges, + "localConstructorShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin local constructor shadow prevents property fallback", &nodes, @@ -5091,6 +5876,14 @@ fun makeNotifier(): Notifier = object : Notifier { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "kotlin local factory shadow prevents property fallback", + &nodes, + &edges, + "localFactoryShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin local factory shadow prevents property fallback", &nodes, @@ -5099,6 +5892,14 @@ fun makeNotifier(): Notifier = object : Notifier { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "kotlin local Any shadow prevents property fallback", + &nodes, + &edges, + "localAnyShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin local Any shadow prevents property fallback", &nodes, @@ -5107,6 +5908,14 @@ fun makeNotifier(): Notifier = object : Notifier { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "kotlin erased property receiver stays unresolved", + &nodes, + &edges, + "erasedProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "kotlin erased property receiver stays unresolved", &nodes, @@ -5138,6 +5947,14 @@ class Entry { ("src/other/Workflow.kt", owner_source), ("src/app/Entry.kt", caller_source), ])?; + assert_ordinary_call_census( + "kotlin property receiver does not use unimported cross-file owner", + &cross_file_nodes, + &cross_file_edges, + "call", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin property receiver does not use unimported cross-file owner", &cross_file_nodes, @@ -5217,6 +6034,14 @@ class Workflow(private val notifier: Notifier) { "notifyEvent", "src/com/acme/mail/Notifier.kt", ); + assert_ordinary_call_census( + "kotlin imported property receiver exact package", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin imported property receiver exact package", &nodes, @@ -5241,6 +6066,14 @@ class Workflow(private val notifier: Notifier) { "notifyEvent", "src/com/acme/mail/Notifier.kt", ); + assert_ordinary_call_census( + "kotlin aliased imported property receiver exact package", + &alias_nodes, + &alias_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin aliased imported property receiver exact package", &alias_nodes, @@ -5256,6 +6089,14 @@ class Workflow(private val notifier: Notifier) { ("src/com/acme/other/Notifier.kt", other_notifier_source), ("src/com/acme/workflow/Workflow.kt", duplicate_import_source), ])?; + assert_ordinary_call_census( + "kotlin duplicate imported property receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin duplicate imported property receiver local name", &duplicate_nodes, @@ -5265,6 +6106,14 @@ class Workflow(private val notifier: Notifier) { "notifyEvent", "src/com/acme/mail/Notifier.kt", ); + assert_ordinary_call_census( + "kotlin duplicate imported property receiver local name", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "kotlin duplicate imported property receiver local name", &duplicate_nodes, @@ -5483,6 +6332,14 @@ class Program "Decorate", "src/Acme/Workflow/Program.cs", ); + assert_ordinary_call_census( + "csharp var factory receiver stays fail-closed without return-type evidence", + &nodes, + &edges, + "VarFactory", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp var factory receiver stays fail-closed without return-type evidence", &nodes, @@ -5492,6 +6349,14 @@ class Program "Run", "src/Acme/Workflow/Program.cs", ); + assert_ordinary_call_census( + "csharp dynamic receiver stays fail-closed", + &nodes, + &edges, + "DynamicWrapped", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp dynamic receiver stays fail-closed", &nodes, @@ -5501,6 +6366,14 @@ class Program "Run", "src/Acme/Workflow/Program.cs", ); + assert_ordinary_call_census( + "csharp qualified external local receiver does not collapse to same-file short owner", + &nodes, + &edges, + "ExternalQualifiedLocal", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp qualified external local receiver does not collapse to same-file short owner", &nodes, @@ -5510,6 +6383,14 @@ class Program "Run", "src/Acme/Workflow/Program.cs", ); + assert_ordinary_call_census( + "csharp qualified external constructor receiver does not collapse to same-file short owner", + &nodes, + &edges, + "ExternalQualifiedConstructor", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp qualified external constructor receiver does not collapse to same-file short owner", &nodes, @@ -5519,14 +6400,30 @@ class Program "Run", "src/Acme/Workflow/Program.cs", ); - assert_no_resolved_call_to_method_owner_in_file( + assert_ordinary_call_census( "csharp qualified external static receiver does not collapse to same-file short owner", &nodes, &edges, "ExternalQualifiedStatic", - "Workflow", "Run", - "src/Acme/Workflow/Program.cs", + 1, + ); + assert_no_resolved_call_to_method_owner_in_file( + "csharp qualified external static receiver does not collapse to same-file short owner", + &nodes, + &edges, + "ExternalQualifiedStatic", + "Workflow", + "Run", + "src/Acme/Workflow/Program.cs", + ); + assert_ordinary_call_census( + "csharp precise local owner avoids same-name global fallback", + &nodes, + &edges, + "Local", + "Run", + 1, ); assert_no_resolved_call_to_method_owner_in_file( "csharp precise local owner avoids same-name global fallback", @@ -5591,6 +6488,14 @@ class Program "#; let (unique_nodes, unique_edges) = index_files(&[("src/Acme/Unique/Program.cs", unique_fallback_source)])?; + assert_ordinary_call_census( + "csharp uninferred var receiver stays fail-closed with unique global method", + &unique_nodes, + &unique_edges, + "VarFactory", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp uninferred var receiver stays fail-closed with unique global method", &unique_nodes, @@ -5600,6 +6505,14 @@ class Program "Run", "src/Acme/Unique/Program.cs", ); + assert_ordinary_call_census( + "csharp dynamic receiver stays fail-closed with unique global method", + &unique_nodes, + &unique_edges, + "DynamicFactory", + "Run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp dynamic receiver stays fail-closed with unique global method", &unique_nodes, @@ -5822,6 +6735,14 @@ class Workflow expected_count: 1, }, ); + assert_ordinary_call_census( + "csharp using alias imported receiver exact namespace", + &nodes, + &edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp using alias imported receiver exact namespace", &nodes, @@ -5849,6 +6770,14 @@ class Workflow "Notify", "src/Acme/Mail/Notifier.cs", ); + assert_ordinary_call_census( + "csharp block namespace using alias imported receiver", + &block_alias_nodes, + &block_alias_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp block namespace using alias imported receiver", &block_alias_nodes, @@ -5863,6 +6792,14 @@ class Workflow ("src/Acme/Mail/Notifier.cs", mail_notifier_source), ("src/Acme/Workflow/Workflow.cs", missing_import_source), ])?; + assert_ordinary_call_census( + "csharp missing using alias imported receiver", + &missing_nodes, + &missing_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp missing using alias imported receiver", &missing_nodes, @@ -5878,6 +6815,14 @@ class Workflow ("src/Acme/Other/Notifier.cs", other_notifier_source), ("src/Acme/Workflow/Workflow.cs", duplicate_alias_source), ])?; + assert_ordinary_call_census( + "csharp duplicate using alias imported receiver", + &duplicate_nodes, + &duplicate_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp duplicate using alias imported receiver", &duplicate_nodes, @@ -5887,6 +6832,14 @@ class Workflow "Notify", "src/Acme/Mail/Notifier.cs", ); + assert_ordinary_call_census( + "csharp duplicate using alias imported receiver", + &duplicate_nodes, + &duplicate_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp duplicate using alias imported receiver", &duplicate_nodes, @@ -5910,6 +6863,14 @@ class Workflow "Notify", "src/Acme/Workflow/Workflow.cs", ); + assert_ordinary_call_census( + "csharp local receiver shadows using alias", + &shadow_nodes, + &shadow_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp local receiver shadows using alias", &shadow_nodes, @@ -5936,6 +6897,14 @@ class Workflow "Notify", "src/Acme/Workflow/Workflow.cs", ); + assert_ordinary_call_census( + "csharp block namespace local receiver shadows using alias", + &block_shadow_nodes, + &block_shadow_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp block namespace local receiver shadows using alias", &block_shadow_nodes, @@ -5960,6 +6929,14 @@ class Workflow "Notify", "src/Acme/Mail/Notifier.cs", ); + assert_ordinary_call_census( + "csharp plain namespace using avoids other namespace", + &plain_nodes, + &plain_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp plain namespace using avoids other namespace", &plain_nodes, @@ -5978,6 +6955,14 @@ class Workflow duplicate_plain_using_source, ), ])?; + assert_ordinary_call_census( + "csharp duplicate plain namespace using stays unresolved", + &duplicate_plain_nodes, + &duplicate_plain_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp duplicate plain namespace using stays unresolved", &duplicate_plain_nodes, @@ -5987,6 +6972,14 @@ class Workflow "Notify", "src/Acme/Mail/Notifier.cs", ); + assert_ordinary_call_census( + "csharp duplicate plain namespace using stays unresolved", + &duplicate_plain_nodes, + &duplicate_plain_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp duplicate plain namespace using stays unresolved", &duplicate_plain_nodes, @@ -6002,6 +6995,14 @@ class Workflow ("src/Acme/Other/Notifier.cs", other_notifier_source), ("src/Acme/Workflow/Workflow.cs", system_plain_using_source), ])?; + assert_ordinary_call_census( + "csharp system plus plain namespace using stays unresolved", + &system_plain_nodes, + &system_plain_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp system plus plain namespace using stays unresolved", &system_plain_nodes, @@ -6011,6 +7012,14 @@ class Workflow "Notify", "src/Acme/Mail/Notifier.cs", ); + assert_ordinary_call_census( + "csharp system plus plain namespace using stays unresolved", + &system_plain_nodes, + &system_plain_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp system plus plain namespace using stays unresolved", &system_plain_nodes, @@ -6034,6 +7043,14 @@ class Workflow "Notify", "src/Acme/Workflow/Workflow.cs", ); + assert_ordinary_call_census( + "csharp local receiver shadows plain namespace using", + &local_plain_shadow_nodes, + &local_plain_shadow_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp local receiver shadows plain namespace using", &local_plain_shadow_nodes, @@ -6048,6 +7065,14 @@ class Workflow ("src/Acme/Mail/Notifier.cs", mail_notifier_source), ("src/Acme/Workflow/Workflow.cs", static_plain_using_source), ])?; + assert_ordinary_call_census( + "csharp plain namespace using does not resolve static receiver", + &static_plain_nodes, + &static_plain_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp plain namespace using does not resolve static receiver", &static_plain_nodes, @@ -6196,6 +7221,14 @@ class Workflow "Notify", "src/Acme/Mail/Notifier.cs", ); + assert_ordinary_call_census( + "csharp using alias imported field receiver avoids other namespace", + &nodes, + &edges, + "Run", + "Notify", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp using alias imported field receiver avoids other namespace", &nodes, @@ -6214,6 +7247,14 @@ class Workflow "Notify", "src/Acme/Workflow/Workflow.cs", ); + assert_ordinary_call_census( + "csharp parameter name shadows using alias static receiver", + &nodes, + &edges, + "ParameterNameShadowsAlias", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp parameter name shadows using alias static receiver", &nodes, @@ -6252,6 +7293,14 @@ class Workflow ("src/Acme/Mail/Notifier.cs", mail_notifier_source), ("src/Acme/Workflow/Workflow.cs", missing_import_source), ])?; + assert_ordinary_call_census( + "csharp missing using alias imported field receiver", + &missing_nodes, + &missing_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp missing using alias imported field receiver", &missing_nodes, @@ -6267,6 +7316,14 @@ class Workflow ("src/Acme/Other/Notifier.cs", other_notifier_source), ("src/Acme/Workflow/Workflow.cs", duplicate_alias_source), ])?; + assert_ordinary_call_census( + "csharp duplicate using alias imported field receiver", + &duplicate_nodes, + &duplicate_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp duplicate using alias imported field receiver", &duplicate_nodes, @@ -6276,6 +7333,14 @@ class Workflow "Notify", "src/Acme/Mail/Notifier.cs", ); + assert_ordinary_call_census( + "csharp duplicate using alias imported field receiver", + &duplicate_nodes, + &duplicate_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp duplicate using alias imported field receiver", &duplicate_nodes, @@ -6299,6 +7364,14 @@ class Workflow "Notify", "src/Acme/Workflow/Workflow.cs", ); + assert_ordinary_call_census( + "csharp local receiver shadows using alias field type", + &shadow_nodes, + &shadow_edges, + "Run", + "Notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp local receiver shadows using alias field type", &shadow_nodes, @@ -6387,6 +7460,14 @@ end "run", "src/workflow.rb", ); + assert_ordinary_call_census( + "ruby factory receiver stays fail-closed without return-type evidence", + &nodes, + &edges, + "factory_returned", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby factory receiver stays fail-closed without return-type evidence", &nodes, @@ -6396,6 +7477,14 @@ end "run", "src/workflow.rb", ); + assert_ordinary_call_census( + "ruby local owner avoids same-name global fallback", + &nodes, + &edges, + "local_constructor", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby local owner avoids same-name global fallback", &nodes, @@ -6429,6 +7518,14 @@ end expected_count: 1, }, ); + assert_ordinary_call_census( + "ruby local operator reassignment stays fail-closed", + &nodes, + &edges, + "operator_reassigned", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby local operator reassignment stays fail-closed", &nodes, @@ -6438,6 +7535,14 @@ end "run", "src/workflow.rb", ); + assert_ordinary_call_census( + "ruby direct receiver constructor requires exact new segment", + &nodes, + &edges, + "non_constructor_segment", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby direct receiver constructor requires exact new segment", &nodes, @@ -6464,6 +7569,14 @@ end ("src/workflow.rb", owner_source), ("src/use_workflow.rb", caller_source), ])?; + assert_ordinary_call_census( + "ruby constructor receiver does not use cross-file owner", + &cross_nodes, + &cross_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby constructor receiver does not use cross-file owner", &cross_nodes, @@ -6572,6 +7685,14 @@ end "run", "src/workflow.rb", ); + assert_ordinary_call_census( + "ruby require_relative constructor receiver", + &nodes, + &edges, + caller, + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby require_relative constructor receiver", &nodes, @@ -6587,6 +7708,14 @@ end ("src/workflow.rb", workflow_source), ("src/use_workflow.rb", missing_source), ])?; + assert_ordinary_call_census( + "ruby missing require_relative owner", + &missing_nodes, + &missing_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby missing require_relative owner", &missing_nodes, @@ -6602,6 +7731,14 @@ end ("src/shadow.rb", shadow_source), ("src/use_workflow.rb", duplicate_require_source), ])?; + assert_ordinary_call_census( + "ruby duplicate require_relative owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby duplicate require_relative owner", &duplicate_nodes, @@ -6616,6 +7753,14 @@ end ("src/workflow.rb", wrong_owner_source), ("src/use_workflow.rb", caller_source), ])?; + assert_ordinary_call_census( + "ruby require_relative owner mismatch stays unresolved", + &wrong_owner_nodes, + &wrong_owner_edges, + "direct_constructor", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby require_relative owner mismatch stays unresolved", &wrong_owner_nodes, @@ -6625,6 +7770,14 @@ end "run", "src/workflow.rb", ); + assert_ordinary_call_census( + "ruby require_relative owner mismatch stays unresolved", + &wrong_owner_nodes, + &wrong_owner_edges, + "direct_constructor", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby require_relative owner mismatch stays unresolved", &wrong_owner_nodes, @@ -6639,6 +7792,14 @@ end ("src/workflow.rb", workflow_source), ("src/use_workflow.rb", assignment_shadow_source), ])?; + assert_ordinary_call_census( + "ruby constant assignment shadows require_relative owner", + &assignment_shadow_nodes, + &assignment_shadow_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby constant assignment shadows require_relative owner", &assignment_shadow_nodes, @@ -6662,6 +7823,14 @@ end "run", "src/use_workflow.rb", ); + assert_ordinary_call_census( + "ruby local class shadows require_relative owner", + &local_shadow_nodes, + &local_shadow_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby local class shadows require_relative owner", &local_shadow_nodes, @@ -6771,7 +7940,15 @@ end "run", "src/entry.rb", ); - assert_no_resolved_call_to_method_owner_in_file( + assert_ordinary_call_census( + "ruby instance variable operator assignment stays fail-closed", + &nodes, + &edges, + "replace", + "run", + 2, + ); + assert_no_resolved_call_to_method_owner_in_file( "ruby instance variable operator assignment stays fail-closed", &nodes, &edges, @@ -6783,6 +7960,14 @@ end let (class_body_nodes, class_body_edges) = index_files(&[("src/class_body.rb", class_body_source)])?; + assert_ordinary_call_census( + "ruby class-body instance variable does not authorize instance receiver", + &class_body_nodes, + &class_body_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby class-body instance variable does not authorize instance receiver", &class_body_nodes, @@ -6795,6 +7980,14 @@ end let (singleton_nodes, singleton_edges) = index_files(&[("src/singleton.rb", singleton_source)])?; + assert_ordinary_call_census( + "ruby singleton instance variable does not authorize instance receiver", + &singleton_nodes, + &singleton_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby singleton instance variable does not authorize instance receiver", &singleton_nodes, @@ -6813,6 +8006,14 @@ end "run", "src/entry.rb", ); + assert_ordinary_call_census( + "ruby instance variable factory reassignment stays fail-closed", + &nodes, + &edges, + "replace", + "run", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby instance variable factory reassignment stays fail-closed", &nodes, @@ -6843,6 +8044,14 @@ end ("src/workflow.rb", owner_source), ("src/entry.rb", caller_source), ])?; + assert_ordinary_call_census( + "ruby instance variable receiver does not use cross-file owner", + &cross_file_nodes, + &cross_file_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby instance variable receiver does not use cross-file owner", &cross_file_nodes, @@ -6876,6 +8085,14 @@ class MixedEntry end "#; let (mixed_nodes, mixed_edges) = index_files(&[("src/mixed.rb", mixed_source)])?; + assert_ordinary_call_census( + "ruby mixed instance variable owners stay fail-closed", + &mixed_nodes, + &mixed_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby mixed instance variable owners stay fail-closed", &mixed_nodes, @@ -6885,6 +8102,14 @@ end "run", "src/mixed.rb", ); + assert_ordinary_call_census( + "ruby mixed instance variable owners stay fail-closed", + &mixed_nodes, + &mixed_edges, + "run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "ruby mixed instance variable owners stay fail-closed", &mixed_nodes, @@ -7086,6 +8311,14 @@ function explicit_property(): void "save", "src/App/workflow.php", ); + assert_ordinary_call_census( + "php untyped property receiver stays fail-closed", + &nodes, + &edges, + "check", + "notify", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php untyped property receiver stays fail-closed", &nodes, @@ -7104,6 +8337,14 @@ function explicit_property(): void "notify", "src/App/workflow.php", ); + assert_ordinary_call_census( + "php factory receiver stays fail-closed without return-type evidence", + &nodes, + &edges, + "factory_returned", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php factory receiver stays fail-closed without return-type evidence", &nodes, @@ -7113,6 +8354,14 @@ function explicit_property(): void "run", "src/App/workflow.php", ); + assert_ordinary_call_census( + "php nullsafe factory receiver stays fail-closed without return-type evidence", + &nodes, + &edges, + "factory_nullsafe_returned", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php nullsafe factory receiver stays fail-closed without return-type evidence", &nodes, @@ -7122,6 +8371,14 @@ function explicit_property(): void "run", "src/App/workflow.php", ); + assert_ordinary_call_census( + "php local owner avoids same-name global fallback", + &nodes, + &edges, + "local_constructor", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php local owner avoids same-name global fallback", &nodes, @@ -7183,6 +8440,14 @@ function orchestrate(): void ("src/App/workflow.php", owner_source), ("src/App/use_workflow.php", caller_source), ])?; + assert_ordinary_call_census( + "php constructor receiver does not use cross-file owner", + &cross_nodes, + &cross_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php constructor receiver does not use cross-file owner", &cross_nodes, @@ -7404,6 +8669,14 @@ function run(Mailer $notifier): void expected_count: 1, }, ); + assert_ordinary_call_census( + "php use alias imported receiver exact namespace", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php use alias imported receiver exact namespace", &nodes, @@ -7428,6 +8701,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php bracketed namespace use alias imported receiver", + &bracketed_nodes, + &bracketed_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php bracketed namespace use alias imported receiver", &bracketed_nodes, @@ -7452,6 +8733,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php unbracketed namespace use alias does not leak", + &leak_nodes, + &leak_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php unbracketed namespace use alias does not leak", &leak_nodes, @@ -7461,6 +8750,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php unbracketed namespace use alias does not leak", + &leak_nodes, + &leak_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php unbracketed namespace use alias does not leak", &leak_nodes, @@ -7475,6 +8772,14 @@ function run(Mailer $notifier): void ("src/Acme/Mail/Notifier.php", mail_notifier_source), ("src/Acme/Workflow/workflow.php", missing_import_source), ])?; + assert_ordinary_call_census( + "php missing use alias imported receiver", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php missing use alias imported receiver", &missing_nodes, @@ -7490,6 +8795,14 @@ function run(Mailer $notifier): void ("src/Acme/Other/Notifier.php", other_notifier_source), ("src/Acme/Workflow/workflow.php", duplicate_alias_source), ])?; + assert_ordinary_call_census( + "php duplicate use alias imported receiver", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php duplicate use alias imported receiver", &duplicate_nodes, @@ -7499,6 +8812,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php duplicate use alias imported receiver", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php duplicate use alias imported receiver", &duplicate_nodes, @@ -7522,6 +8843,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Workflow/workflow.php", ); + assert_ordinary_call_census( + "php local receiver shadows use alias", + &shadow_nodes, + &shadow_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php local receiver shadows use alias", &shadow_nodes, @@ -7546,6 +8875,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php plain use avoids other namespace", + &plain_nodes, + &plain_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php plain use avoids other namespace", &plain_nodes, @@ -7561,6 +8898,14 @@ function run(Mailer $notifier): void ("src/Acme/Other/Notifier.php", other_notifier_source), ("src/Acme/Workflow/workflow.php", duplicate_plain_use_source), ])?; + assert_ordinary_call_census( + "php duplicate plain use stays unresolved", + &duplicate_plain_nodes, + &duplicate_plain_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php duplicate plain use stays unresolved", &duplicate_plain_nodes, @@ -7570,6 +8915,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php duplicate plain use stays unresolved", + &duplicate_plain_nodes, + &duplicate_plain_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php duplicate plain use stays unresolved", &duplicate_plain_nodes, @@ -7584,6 +8937,14 @@ function run(Mailer $notifier): void ("src/Acme/Mail/Notifier.php", mail_notifier_source), ("src/Acme/Workflow/workflow.php", const_plain_use_source), ])?; + assert_ordinary_call_census( + "php const plain use is not treated as a type import", + &const_plain_nodes, + &const_plain_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php const plain use is not treated as a type import", &const_plain_nodes, @@ -7598,6 +8959,14 @@ function run(Mailer $notifier): void ("src/Acme/Mail/Notifier.php", mail_notifier_source), ("src/Acme/Workflow/workflow.php", grouped_plain_use_source), ])?; + assert_ordinary_call_census( + "php grouped plain use stays unresolved", + &grouped_plain_nodes, + &grouped_plain_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php grouped plain use stays unresolved", &grouped_plain_nodes, @@ -7622,6 +8991,14 @@ function run(Mailer $notifier): void "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php uppercase use alias nullsafe receiver avoids other namespace", + &uppercase_nodes, + &uppercase_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php uppercase use alias nullsafe receiver avoids other namespace", &uppercase_nodes, @@ -7639,6 +9016,14 @@ function run(Mailer $notifier): void uppercase_function_import_source, ), ])?; + assert_ordinary_call_census( + "php uppercase function use alias is not treated as a type alias", + &function_import_nodes, + &function_import_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php uppercase function use alias is not treated as a type alias", &function_import_nodes, @@ -7819,14 +9204,24 @@ function nested_shadowing(array $listeners, array $inner): void ("src/Acme/App/hostile.php", hostile_source), ])?; - for hostile_caller in [ - "no_docblock", - "unresolvable_type", - "union_docblock", - "wrong_variable_docblock", - "commented_and_quoted", - "nested_shadowing", + for (hostile_caller, expected_handle_calls) in [ + ("no_docblock", 1), + ("unresolvable_type", 1), + ("union_docblock", 1), + ("wrong_variable_docblock", 1), + // The comment/string hostile parses no call at all; its census is + // deliberately zero. + ("commented_and_quoted", 0), + ("nested_shadowing", 1), ] { + assert_ordinary_call_census( + "php foreach hostile fails closed", + &nodes, + &edges, + hostile_caller, + "handle", + expected_handle_calls, + ); assert_no_resolved_call_to_method_owner_in_file( "php foreach hostile fails closed", &nodes, @@ -8050,6 +9445,14 @@ class AmbiguousDispatcher ); // AmbiguousDispatcher iterates a string[] property; the Listener docblock // on the sibling property must not bind it. + assert_ordinary_call_census( + "php ambiguous property collection fails closed", + &property_nodes, + &property_edges, + "AmbiguousDispatcher.flush", + "handle", + 1, + ); assert_no_loop_marker_from_caller( "php ambiguous property collection fails closed", &property_nodes, @@ -8625,6 +10028,14 @@ function scopedTwo(workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "javascript factory receiver stays unresolved", + &nodes, + &edges, + "factory", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript factory receiver stays unresolved", &nodes, @@ -8633,6 +10044,14 @@ function scopedTwo(workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "javascript parameter receiver stays unresolved", + &nodes, + &edges, + "scopedTwo", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript parameter receiver stays unresolved", &nodes, @@ -8641,6 +10060,14 @@ function scopedTwo(workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "javascript constructor receiver avoids same-name owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript constructor receiver avoids same-name owner", &nodes, @@ -8787,6 +10214,14 @@ res.sendFile = function sendFile(path) { "res", "end", ); + assert_ordinary_call_census( + "javascript imported bare call avoids same-name receiver method", + &nodes, + &edges, + "res.sendFile", + "send", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript imported bare call avoids same-name receiver method", &nodes, @@ -9122,6 +10557,14 @@ function loneLoopWriteExternal(body) { "#; let (nodes, edges) = index_single_file("neutral.js", source)?; for method in ["direct", "dispatch", "relay", "assigned"] { + assert_ordinary_call_census( + "exact CommonJS aliases stay external", + &nodes, + &edges, + "externalCalls", + method, + 1, + ); assert_no_resolved_call_to_method_owner( "exact CommonJS aliases stay external", &nodes, @@ -9201,6 +10644,14 @@ function loneLoopWriteExternal(body) { ("shadowedLocal", "dispatch"), ("shadowedDeclaration", "relay"), ] { + assert_ordinary_call_census( + "shadowed import name avoids unrelated receiver method", + &nodes, + &edges, + caller, + method, + 1, + ); assert_no_resolved_call_to_method_owner( "shadowed import name avoids unrelated receiver method", &nodes, @@ -9598,6 +11049,14 @@ export class Workflow { "run", "main.js", ); + assert_ordinary_call_census( + "javascript imported constructor receiver avoids local file", + &nodes, + &edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript imported constructor receiver avoids local file", &nodes, @@ -9663,14 +11122,22 @@ export class Workflow { ("main.js", missing_import_source), ("workflow.js", workflow_source), ])?; - assert_no_resolved_call_to_method_owner_in_file( + assert_ordinary_call_census( "javascript missing imported constructor receiver stays unresolved", &missing_nodes, &missing_edges, "orchestrateRemote", - "Workflow", "run", - "workflow.js", + 1, + ); + assert_no_resolved_call_to_method_owner_in_file( + "javascript missing imported constructor receiver stays unresolved", + &missing_nodes, + &missing_edges, + "orchestrateRemote", + "Workflow", + "run", + "workflow.js", ); let (duplicate_nodes, duplicate_edges) = index_files(&[ @@ -9678,6 +11145,14 @@ export class Workflow { ("workflow.js", workflow_source), ("other.js", other_workflow_source), ])?; + assert_ordinary_call_census( + "javascript duplicate imported constructor receiver avoids first owner", + &duplicate_nodes, + &duplicate_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript duplicate imported constructor receiver avoids first owner", &duplicate_nodes, @@ -9687,6 +11162,14 @@ export class Workflow { "run", "workflow.js", ); + assert_ordinary_call_census( + "javascript duplicate imported constructor receiver avoids second owner", + &duplicate_nodes, + &duplicate_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript duplicate imported constructor receiver avoids second owner", &duplicate_nodes, @@ -9710,6 +11193,14 @@ export class Workflow { "run", "main.js", ); + assert_ordinary_call_census( + "javascript local class shadow avoids imported constructor owner", + &shadow_nodes, + &shadow_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript local class shadow avoids imported constructor owner", &shadow_nodes, @@ -9724,6 +11215,14 @@ export class Workflow { ("main.js", future_binding_source), ("workflow.js", workflow_source), ])?; + assert_ordinary_call_census( + "javascript constructor receiver does not use future binding", + &future_nodes, + &future_edges, + "beforeDeclaration", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript constructor receiver does not use future binding", &future_nodes, @@ -9747,6 +11246,14 @@ export class Workflow { "run", "main.js", ); + assert_ordinary_call_census( + "javascript function local class shadow avoids imported owner", + &function_shadow_nodes, + &function_shadow_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript function local class shadow avoids imported owner", &function_shadow_nodes, @@ -9761,6 +11268,14 @@ export class Workflow { ("main.js", qualified_constructor_source), ("workflow.js", workflow_source), ])?; + assert_ordinary_call_census( + "javascript qualified constructor receiver avoids local same-name owner", + &qualified_nodes, + &qualified_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript qualified constructor receiver avoids local same-name owner", &qualified_nodes, @@ -9770,6 +11285,14 @@ export class Workflow { "run", "main.js", ); + assert_ordinary_call_census( + "javascript qualified constructor receiver stays unresolved", + &qualified_nodes, + &qualified_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript qualified constructor receiver stays unresolved", &qualified_nodes, @@ -9852,6 +11375,14 @@ class StaticEntry { "Workflow", "run", ); + assert_ordinary_call_census( + "javascript property receiver avoids same-name owner", + &nodes, + &edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript property receiver avoids same-name owner", &nodes, @@ -9870,6 +11401,14 @@ class StaticEntry { "Workflow", "run", ); + assert_ordinary_call_census( + "javascript private property receiver avoids same-name owner", + &private_nodes, + &private_edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript private property receiver avoids same-name owner", &private_nodes, @@ -9886,6 +11425,14 @@ class StaticEntry { "Workflow", "run", ); + assert_ordinary_call_census( + "javascript static private field initializer stays unresolved", + &private_nodes, + &private_edges, + "StaticEntry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript static private field initializer stays unresolved", &private_nodes, @@ -10109,6 +11656,14 @@ export class Workflow { ("main.js", missing_source), ("workflow.js", workflow_source), ])?; + assert_ordinary_call_census( + "javascript missing imported property receiver stays unresolved", + &missing_nodes, + &missing_edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript missing imported property receiver stays unresolved", &missing_nodes, @@ -10124,6 +11679,14 @@ export class Workflow { ("workflow.js", workflow_source), ("other.js", other_workflow_source), ])?; + assert_ordinary_call_census( + "javascript duplicate imported property receiver avoids first owner", + &duplicate_nodes, + &duplicate_edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript duplicate imported property receiver avoids first owner", &duplicate_nodes, @@ -10133,6 +11696,14 @@ export class Workflow { "run", "workflow.js", ); + assert_ordinary_call_census( + "javascript duplicate imported property receiver avoids second owner", + &duplicate_nodes, + &duplicate_edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript duplicate imported property receiver avoids second owner", &duplicate_nodes, @@ -10156,6 +11727,14 @@ export class Workflow { "run", "main.js", ); + assert_ordinary_call_census( + "javascript local class property shadow avoids imported owner", + &shadow_nodes, + &shadow_edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript local class property shadow avoids imported owner", &shadow_nodes, @@ -10170,6 +11749,14 @@ export class Workflow { ("main.js", qualified_source), ("workflow.js", workflow_source), ])?; + assert_ordinary_call_census( + "javascript qualified property constructor stays unresolved", + &qualified_nodes, + &qualified_edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript qualified property constructor stays unresolved", &qualified_nodes, @@ -10274,6 +11861,14 @@ class QualifiedEntry { "StaticEntry.run", "QualifiedEntry.run", ] { + assert_ordinary_call_census( + "javascript property receiver stays unresolved", + &nodes, + &edges, + source_name, + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript property receiver stays unresolved", &nodes, @@ -10282,6 +11877,14 @@ class QualifiedEntry { "Workflow", "run", ); + assert_ordinary_call_census( + "javascript property receiver avoids ambiguous same-name owner", + &nodes, + &edges, + source_name, + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript property receiver avoids ambiguous same-name owner", &nodes, @@ -10316,6 +11919,14 @@ class Entry { "#; let (nodes, edges) = index_single_file("main.js", source)?; + assert_ordinary_call_census( + "javascript property receiver duplicate method target stays unresolved", + &nodes, + &edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "javascript property receiver duplicate method target stays unresolved", &nodes, @@ -10349,6 +11960,14 @@ export class Workflow { let (nodes, edges) = index_files(&[("main.js", main_source), ("workflow.js", workflow_source)])?; + assert_ordinary_call_census( + "javascript unimported property receiver avoids cross-file owner", + &nodes, + &edges, + "Entry.run", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "javascript unimported property receiver avoids cross-file owner", &nodes, @@ -10443,6 +12062,14 @@ function scopedTwo(workflow: any): void { "Workflow", "run", ); + assert_ordinary_call_census( + "typescript factory receiver stays unresolved", + &nodes, + &edges, + "factory", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript factory receiver stays unresolved", &nodes, @@ -10451,6 +12078,14 @@ function scopedTwo(workflow: any): void { "Workflow", "run", ); + assert_ordinary_call_census( + "typescript any receiver stays unresolved", + &nodes, + &edges, + "scopedTwo", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript any receiver stays unresolved", &nodes, @@ -10459,6 +12094,14 @@ function scopedTwo(workflow: any): void { "Workflow", "run", ); + assert_ordinary_call_census( + "typescript constructor receiver avoids same-name owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript constructor receiver avoids same-name owner", &nodes, @@ -10569,6 +12212,14 @@ class Workflow { "Repository", "save", ); + assert_ordinary_call_census( + "typescript class property receiver avoids same-name method owner", + &nodes, + &edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript class property receiver avoids same-name method owner", &nodes, @@ -10577,6 +12228,14 @@ class Workflow { "OtherRepository", "save", ); + assert_ordinary_call_census( + "typescript any property receiver stays unresolved", + &nodes, + &edges, + "Workflow.erasedProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript any property receiver stays unresolved", &nodes, @@ -10585,6 +12244,14 @@ class Workflow { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "typescript unknown property receiver stays unresolved", + &nodes, + &edges, + "Workflow.unknownProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript unknown property receiver stays unresolved", &nodes, @@ -10785,6 +12452,14 @@ export class Workflow { ("main.ts", missing_namespace_source), ("workflow.ts", workflow_source), ])?; + assert_ordinary_call_census( + "typescript missing namespace constructor receiver stays unresolved", + &missing_namespace_nodes, + &missing_namespace_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript missing namespace constructor receiver stays unresolved", &missing_namespace_nodes, @@ -10800,6 +12475,14 @@ export class Workflow { ("workflow.ts", workflow_source), ("other.ts", other_workflow_source), ])?; + assert_ordinary_call_census( + "typescript duplicate namespace constructor receiver avoids first owner", + &duplicate_namespace_nodes, + &duplicate_namespace_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate namespace constructor receiver avoids first owner", &duplicate_namespace_nodes, @@ -10809,6 +12492,14 @@ export class Workflow { "run", "workflow.ts", ); + assert_ordinary_call_census( + "typescript duplicate namespace constructor receiver avoids second owner", + &duplicate_namespace_nodes, + &duplicate_namespace_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate namespace constructor receiver avoids second owner", &duplicate_namespace_nodes, @@ -10823,6 +12514,14 @@ export class Workflow { ("main.ts", missing_import_source), ("workflow.ts", workflow_source), ])?; + assert_ordinary_call_census( + "typescript missing imported constructor receiver", + &missing_nodes, + &missing_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript missing imported constructor receiver", &missing_nodes, @@ -10838,6 +12537,14 @@ export class Workflow { ("workflow.ts", workflow_source), ("other.ts", other_workflow_source), ])?; + assert_ordinary_call_census( + "typescript duplicate imported constructor receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate imported constructor receiver", &duplicate_nodes, @@ -10847,6 +12554,14 @@ export class Workflow { "run", "workflow.ts", ); + assert_ordinary_call_census( + "typescript duplicate imported constructor receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate imported constructor receiver", &duplicate_nodes, @@ -10861,6 +12576,14 @@ export class Workflow { ("main.ts", local_shadow_source), ("workflow.ts", workflow_source), ])?; + assert_ordinary_call_census( + "typescript local constructor shadow avoids imported owner", + &shadow_nodes, + &shadow_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript local constructor shadow avoids imported owner", &shadow_nodes, @@ -10875,6 +12598,14 @@ export class Workflow { ("main.ts", future_binding_source), ("workflow.ts", workflow_source), ])?; + assert_ordinary_call_census( + "typescript constructor receiver does not use future binding", + &future_nodes, + &future_edges, + "beforeDeclaration", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript constructor receiver does not use future binding", &future_nodes, @@ -10898,6 +12629,14 @@ export class Workflow { "run", "main.ts", ); + assert_ordinary_call_census( + "typescript function local class shadow avoids imported owner", + &function_shadow_nodes, + &function_shadow_edges, + "orchestrateRemote", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript function local class shadow avoids imported owner", &function_shadow_nodes, @@ -10912,6 +12651,14 @@ export class Workflow { ("main.ts", block_factory_shadow_source), ("workflow.ts", workflow_source), ])?; + assert_ordinary_call_census( + "typescript block local factory receiver suppresses parameter fallback", + &block_shadow_nodes, + &block_shadow_edges, + "scopedShadow", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript block local factory receiver suppresses parameter fallback", &block_shadow_nodes, @@ -10947,12 +12694,20 @@ export class Repository { ("main.ts", main_source), ("repository.ts", repository_source), ])?; - assert_no_resolved_call_to_method_owner_in_file( + assert_ordinary_call_census( "typescript unimported cross-file property receiver stays unresolved", &nodes, &edges, "Workflow.run", - "Repository", + "save", + 1, + ); + assert_no_resolved_call_to_method_owner_in_file( + "typescript unimported cross-file property receiver stays unresolved", + &nodes, + &edges, + "Workflow.run", + "Repository", "save", "repository.ts", ); @@ -11225,6 +12980,14 @@ export function Widget(): JSX.Element { "notifyEvent", "notifier.ts", ); + assert_ordinary_call_census( + "tsx imported private class property receiver avoids same-name owner", + &nodes, + &edges, + "WidgetWorkflow.run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "tsx imported private class property receiver avoids same-name owner", &nodes, @@ -11243,6 +13006,14 @@ export function Widget(): JSX.Element { "save", "repository.ts", ); + assert_ordinary_call_census( + "tsx imported class property receiver avoids same-name owner", + &nodes, + &edges, + "WidgetWorkflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "tsx imported class property receiver avoids same-name owner", &nodes, @@ -11266,6 +13037,14 @@ export function Widget(): JSX.Element { "save", "widget.tsx", ); + assert_ordinary_call_census( + "tsx local shadowed class property receiver avoids imported owner", + &shadow_nodes, + &shadow_edges, + "WidgetWorkflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "tsx local shadowed class property receiver avoids imported owner", &shadow_nodes, @@ -11280,6 +13059,14 @@ export function Widget(): JSX.Element { ("other/repository.ts", other_repository_source), ("widget.tsx", missing_import_source), ])?; + assert_ordinary_call_census( + "tsx missing imported class property receiver", + &missing_nodes, + &missing_edges, + "WidgetWorkflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "tsx missing imported class property receiver", &missing_nodes, @@ -11413,6 +13200,14 @@ func run(notifier: Notifier) { expected_count: 1, }, ); + assert_ordinary_call_census( + "swift imported typed receiver", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift imported typed receiver", &nodes, @@ -11437,6 +13232,14 @@ func run(notifier: Notifier) { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift module-qualified typed receiver", + &qualified_nodes, + &qualified_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift module-qualified typed receiver", &qualified_nodes, @@ -11463,6 +13266,14 @@ func run(notifier: Notifier) { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift module-qualified receiver ignores same-file shadow", + &qualified_shadow_nodes, + &qualified_shadow_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift module-qualified receiver ignores same-file shadow", &qualified_shadow_nodes, @@ -11492,6 +13303,14 @@ func run(notifier: Notifier) { ("Sources/MailKit/Repository.swift", mail_repository_source), ("Sources/App/App.swift", scoped_unrelated_source), ])?; + assert_ordinary_call_census( + "swift scoped import does not imply whole-module import", + &scoped_unrelated_nodes, + &scoped_unrelated_edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift scoped import does not imply whole-module import", &scoped_unrelated_nodes, @@ -11506,6 +13325,14 @@ func run(notifier: Notifier) { ("Sources/MailKit/Notifier.swift", mail_notifier_source), ("Sources/App/App.swift", missing_import_source), ])?; + assert_ordinary_call_census( + "swift missing imported receiver module", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift missing imported receiver module", &missing_nodes, @@ -11520,6 +13347,14 @@ func run(notifier: Notifier) { ("Sources/MailKit/Notifier.swift", mail_notifier_source), ("Sources/App/App.swift", no_import_source), ])?; + assert_ordinary_call_census( + "swift unimported receiver type stays unresolved", + &no_import_nodes, + &no_import_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift unimported receiver type stays unresolved", &no_import_nodes, @@ -11537,6 +13372,14 @@ func run(notifier: Notifier) { ), ("Packages/App/Sources/App/App.swift", workflow_source), ])?; + assert_ordinary_call_census( + "swift imported receiver stays within package root", + &cross_package_nodes, + &cross_package_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift imported receiver stays within package root", &cross_package_nodes, @@ -11552,6 +13395,14 @@ func run(notifier: Notifier) { ("Sources/OtherKit/Notifier.swift", other_notifier_source), ("Sources/App/App.swift", ambiguous_import_source), ])?; + assert_ordinary_call_census( + "swift ambiguous imported receiver module", + &ambiguous_nodes, + &ambiguous_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift ambiguous imported receiver module", &ambiguous_nodes, @@ -11561,6 +13412,14 @@ func run(notifier: Notifier) { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift ambiguous imported receiver module", + &ambiguous_nodes, + &ambiguous_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift ambiguous imported receiver module", &ambiguous_nodes, @@ -11579,6 +13438,14 @@ func run(notifier: Notifier) { ), ("Sources/App/App.swift", workflow_source), ])?; + assert_ordinary_call_census( + "swift duplicate imported receiver module owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift duplicate imported receiver module owner", &duplicate_nodes, @@ -11588,6 +13455,14 @@ func run(notifier: Notifier) { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift duplicate imported receiver module owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift duplicate imported receiver module owner", &duplicate_nodes, @@ -11611,6 +13486,14 @@ func run(notifier: Notifier) { "notifyEvent", "Sources/App/App.swift", ); + assert_ordinary_call_census( + "swift local receiver shadows imported module", + &shadow_nodes, + &shadow_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift local receiver shadows imported module", &shadow_nodes, @@ -11694,6 +13577,14 @@ func orderAware() { expected_count: 1, }, ); + assert_ordinary_call_census( + "swift same-file constructor receiver avoids other owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "swift same-file constructor receiver avoids other owner", &nodes, @@ -11702,6 +13593,14 @@ func orderAware() { "OtherWorkflow", "run", ); + assert_ordinary_call_census( + "swift factory receiver stays unresolved", + &nodes, + &edges, + "factoryOnly", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "swift factory receiver stays unresolved", &nodes, @@ -11710,6 +13609,14 @@ func orderAware() { "Workflow", "run", ); + assert_ordinary_call_census( + "swift erased receiver stays unresolved", + &nodes, + &edges, + "erased", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "swift erased receiver stays unresolved", &nodes, @@ -11778,6 +13685,14 @@ func orchestrate() { ("Sources/WorkflowKit/Workflow.swift", imported_owner_source), ("Sources/App/App.swift", imported_caller_source), ])?; + assert_ordinary_call_census( + "swift unimported constructor receiver does not use cross-file owner", + &imported_nodes, + &imported_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift unimported constructor receiver does not use cross-file owner", &imported_nodes, @@ -11879,6 +13794,14 @@ func orchestrate() { "run", "Sources/WorkflowKit/Workflow.swift", ); + assert_ordinary_call_census( + "swift imported constructor receiver avoids other module", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift imported constructor receiver avoids other module", &nodes, @@ -11903,6 +13826,14 @@ func orchestrate() { "run", "Sources/WorkflowKit/Workflow.swift", ); + assert_ordinary_call_census( + "swift module-qualified constructor receiver avoids same-file shadow", + &qualified_nodes, + &qualified_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift module-qualified constructor receiver avoids same-file shadow", &qualified_nodes, @@ -11932,6 +13863,14 @@ func orchestrate() { ("Sources/OtherKit/Workflow.swift", other_workflow_source), ("Sources/App/App.swift", ambiguous_import_source), ])?; + assert_ordinary_call_census( + "swift ambiguous imported constructor receiver", + &ambiguous_nodes, + &ambiguous_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift ambiguous imported constructor receiver", &ambiguous_nodes, @@ -11941,6 +13880,14 @@ func orchestrate() { "run", "Sources/WorkflowKit/Workflow.swift", ); + assert_ordinary_call_census( + "swift ambiguous imported constructor receiver", + &ambiguous_nodes, + &ambiguous_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift ambiguous imported constructor receiver", &ambiguous_nodes, @@ -11955,6 +13902,14 @@ func orchestrate() { ("Sources/WorkflowKit/Workflow.swift", workflow_source), ("Sources/App/App.swift", missing_import_source), ])?; + assert_ordinary_call_census( + "swift missing imported constructor receiver", + &missing_nodes, + &missing_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift missing imported constructor receiver", &missing_nodes, @@ -11969,6 +13924,14 @@ func orchestrate() { ("Sources/WorkflowKit/Workflow.swift", workflow_source), ("Sources/App/App.swift", local_shadow_source), ])?; + assert_ordinary_call_census( + "swift local constructor shadow avoids imported owner", + &shadow_nodes, + &shadow_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift local constructor shadow avoids imported owner", &shadow_nodes, @@ -12082,6 +14045,14 @@ func makeNotifier() -> Notifier { "decorate", "Sources/App/Workflow.swift", ); + assert_ordinary_call_census( + "swift self receiver avoids same-named owner", + &nodes, + &edges, + "run", + "decorate", + 1, + ); assert_no_resolved_call_to_method_owner( "swift self receiver avoids same-named owner", &nodes, @@ -12099,6 +14070,14 @@ func makeNotifier() -> Notifier { "save", "Sources/App/Workflow.swift", ); + assert_ordinary_call_census( + "swift parameter shadow prevents property fallback", + &nodes, + &edges, + "parameterShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "swift parameter shadow prevents property fallback", &nodes, @@ -12134,6 +14113,14 @@ func makeNotifier() -> Notifier { "save", "Sources/App/Workflow.swift", ); + assert_ordinary_call_census( + "swift local constructor shadow prevents property fallback", + &nodes, + &edges, + "localConstructorShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "swift local constructor shadow prevents property fallback", &nodes, @@ -12142,6 +14129,14 @@ func makeNotifier() -> Notifier { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "swift local factory shadow prevents property fallback", + &nodes, + &edges, + "localFactoryShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "swift local factory shadow prevents property fallback", &nodes, @@ -12150,6 +14145,14 @@ func makeNotifier() -> Notifier { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "swift local Any shadow prevents property fallback", + &nodes, + &edges, + "localAnyShadowsProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "swift local Any shadow prevents property fallback", &nodes, @@ -12158,6 +14161,14 @@ func makeNotifier() -> Notifier { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "swift erased property receiver stays unresolved", + &nodes, + &edges, + "erasedProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "swift erased property receiver stays unresolved", &nodes, @@ -12189,6 +14200,14 @@ class Entry { ("Sources/WorkflowKit/Workflow.swift", owner_source), ("Sources/App/Entry.swift", caller_source), ])?; + assert_ordinary_call_census( + "swift property receiver does not use unimported cross-file owner", + &cross_file_nodes, + &cross_file_edges, + "call", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift property receiver does not use unimported cross-file owner", &cross_file_nodes, @@ -12351,6 +14370,14 @@ class Workflow { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift imported property receiver exact module", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift imported property receiver exact module", &nodes, @@ -12375,6 +14402,14 @@ class Workflow { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift module-qualified property receiver exact module", + &qualified_nodes, + &qualified_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift module-qualified property receiver exact module", &qualified_nodes, @@ -12401,12 +14436,20 @@ class Workflow { "notifyEvent", "Sources/MailKit/Notifier.swift", ); - assert_no_resolved_call_to_method_owner_in_file( + assert_ordinary_call_census( "swift module-qualified property receiver ignores same-file shadow", &qualified_shadow_nodes, &qualified_shadow_edges, "run", - "Notifier", + "notifyEvent", + 1, + ); + assert_no_resolved_call_to_method_owner_in_file( + "swift module-qualified property receiver ignores same-file shadow", + &qualified_shadow_nodes, + &qualified_shadow_edges, + "run", + "Notifier", "notifyEvent", "Sources/App/Workflow.swift", ); @@ -12430,6 +14473,14 @@ class Workflow { ("Sources/MailKit/Repository.swift", mail_repository_source), ("Sources/App/Workflow.swift", scoped_unrelated_source), ])?; + assert_ordinary_call_census( + "swift scoped property import does not imply whole-module import", + &scoped_unrelated_nodes, + &scoped_unrelated_edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift scoped property import does not imply whole-module import", &scoped_unrelated_nodes, @@ -12444,6 +14495,14 @@ class Workflow { ("Sources/MailKit/Notifier.swift", mail_notifier_source), ("Sources/App/Workflow.swift", missing_import_source), ])?; + assert_ordinary_call_census( + "swift missing imported property receiver module", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift missing imported property receiver module", &missing_nodes, @@ -12459,6 +14518,14 @@ class Workflow { ("Sources/OtherKit/Notifier.swift", other_notifier_source), ("Sources/App/Workflow.swift", ambiguous_import_source), ])?; + assert_ordinary_call_census( + "swift ambiguous imported property receiver module", + &ambiguous_nodes, + &ambiguous_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift ambiguous imported property receiver module", &ambiguous_nodes, @@ -12468,6 +14535,14 @@ class Workflow { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift ambiguous imported property receiver module", + &ambiguous_nodes, + &ambiguous_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift ambiguous imported property receiver module", &ambiguous_nodes, @@ -12486,6 +14561,14 @@ class Workflow { ), ("Sources/App/Workflow.swift", workflow_source), ])?; + assert_ordinary_call_census( + "swift duplicate imported property receiver module owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift duplicate imported property receiver module owner", &duplicate_nodes, @@ -12495,6 +14578,14 @@ class Workflow { "notifyEvent", "Sources/MailKit/Notifier.swift", ); + assert_ordinary_call_census( + "swift duplicate imported property receiver module owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "swift duplicate imported property receiver module owner", &duplicate_nodes, @@ -12614,6 +14705,14 @@ export function run(notifier: mail.Notifier): void { expected_count: 1, }, ); + assert_ordinary_call_census( + "typescript imported typed receiver", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript imported typed receiver", &nodes, @@ -12665,6 +14764,14 @@ export function run(notifier: mail.Notifier): void { expected_count: 1, }, ); + assert_ordinary_call_census( + "typescript same-line imported typed receiver", + &nodes, + &edges, + "run", + "save", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript same-line imported typed receiver", &nodes, @@ -12674,6 +14781,14 @@ export function run(notifier: mail.Notifier): void { "save", "repository.ts", ); + assert_ordinary_call_census( + "typescript imported untyped receiver", + &nodes, + &edges, + "untyped", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript imported untyped receiver", &nodes, @@ -12687,6 +14802,14 @@ export function run(notifier: mail.Notifier): void { ("other/notifier.ts", other_notifier_source), ("workflow.ts", missing_import_source), ])?; + assert_ordinary_call_census( + "typescript missing imported owner", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "typescript missing imported owner", &missing_nodes, @@ -12701,6 +14824,14 @@ export function run(notifier: mail.Notifier): void { ("other/notifier.ts", other_notifier_source), ("workflow.ts", duplicate_import_source), ])?; + assert_ordinary_call_census( + "typescript duplicate imported owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate imported owner", &duplicate_nodes, @@ -12710,6 +14841,14 @@ export function run(notifier: mail.Notifier): void { "notifyEvent", "notifier.ts", ); + assert_ordinary_call_census( + "typescript duplicate imported owner", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate imported owner", &duplicate_nodes, @@ -12733,6 +14872,14 @@ export function run(notifier: mail.Notifier): void { "notifyEvent", "workflow.ts", ); + assert_ordinary_call_census( + "typescript local shadowed imported owner", + &shadow_nodes, + &shadow_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript local shadowed imported owner", &shadow_nodes, @@ -12783,6 +14930,14 @@ export function run(notifier: mail.Notifier): void { expected_count: 1, }, ); + assert_ordinary_call_census( + "typescript namespace imported owner", + &namespace_nodes, + &namespace_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript namespace imported owner", &namespace_nodes, @@ -12971,6 +15126,14 @@ class Workflow { "save", "archive.ts", ); + assert_ordinary_call_census( + "typescript imported class property receiver avoids same-name owner", + &nodes, + &edges, + "Workflow.run", + "save", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript imported class property receiver avoids same-name owner", &nodes, @@ -12985,6 +15148,14 @@ class Workflow { ("other/repository.ts", other_repository_source), ("workflow.ts", missing_import_source), ])?; + assert_ordinary_call_census( + "typescript missing imported class property receiver", + &missing_nodes, + &missing_edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript missing imported class property receiver", &missing_nodes, @@ -13000,6 +15171,14 @@ class Workflow { ("other/repository.ts", other_repository_source), ("workflow.ts", duplicate_import_source), ])?; + assert_ordinary_call_census( + "typescript duplicate imported class property receiver", + &duplicate_nodes, + &duplicate_edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate imported class property receiver", &duplicate_nodes, @@ -13009,6 +15188,14 @@ class Workflow { "save", "repository.ts", ); + assert_ordinary_call_census( + "typescript duplicate imported class property receiver", + &duplicate_nodes, + &duplicate_edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript duplicate imported class property receiver", &duplicate_nodes, @@ -13032,6 +15219,14 @@ class Workflow { "save", "workflow.ts", ); + assert_ordinary_call_census( + "typescript local shadowed class property receiver", + &shadow_nodes, + &shadow_edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript local shadowed class property receiver", &shadow_nodes, @@ -13046,6 +15241,14 @@ class Workflow { ("archive.ts", archive_source), ("workflow.ts", missing_namespace_source), ])?; + assert_ordinary_call_census( + "typescript missing namespace class property receiver", + &missing_namespace_nodes, + &missing_namespace_edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript missing namespace class property receiver", &missing_namespace_nodes, @@ -13060,6 +15263,14 @@ class Workflow { ("archive.ts", archive_source), ("workflow.ts", local_namespace_shadow_source), ])?; + assert_ordinary_call_census( + "typescript local namespace shadowed class property receiver", + &local_namespace_shadow_nodes, + &local_namespace_shadow_edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript local namespace shadowed class property receiver", &local_namespace_shadow_nodes, @@ -13074,6 +15285,14 @@ class Workflow { ("archive.ts", archive_source), ("workflow.ts", import_namespace_collision_source), ])?; + assert_ordinary_call_census( + "typescript import namespace collision class property receiver", + &import_namespace_collision_nodes, + &import_namespace_collision_edges, + "Workflow.run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "typescript import namespace collision class property receiver", &import_namespace_collision_nodes, @@ -13118,21 +15337,30 @@ void run(mail.Notifier notifier) { import './mail/notifier.dart' as shared; import './other/notifier.dart' as shared; +void helper() {} + void run(shared.Notifier notifier) { notifier.notifyEvent('ready'); + helper(); } "#; let unprefixed_duplicate_source = r#" import './mail/notifier.dart'; import './other/notifier.dart'; +void helper() {} + void run(Notifier notifier) { notifier.notifyEvent('ready'); + helper(); } "#; let no_import_source = r#" +void helper() {} + void run(Notifier notifier) { notifier.notifyEvent('ready'); + helper(); } "#; let commented_import_source = r#" @@ -13143,8 +15371,11 @@ const importExample = """ import './mail/notifier.dart'; """; +void helper() {} + void run(Notifier notifier) { notifier.notifyEvent('ready'); + helper(); } "#; @@ -13174,6 +15405,14 @@ void run(Notifier notifier) { expected_count: 1, }, ); + assert_ordinary_call_census( + "dart prefixed imported receiver", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart prefixed imported receiver", &nodes, @@ -13188,6 +15427,14 @@ void run(Notifier notifier) { ("lib/mail/notifier.dart", notifier_source), ("lib/workflow.dart", missing_import_source), ])?; + assert_ordinary_call_census( + "dart missing prefixed imported receiver", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart missing prefixed imported receiver", &missing_nodes, @@ -13202,6 +15449,25 @@ void run(Notifier notifier) { ("lib/mail/notifier.dart", notifier_source), ("lib/workflow.dart", no_import_source), ])?; + // `run`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart unimported receiver type stays unresolved", + &no_import_nodes, + &no_import_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart unimported receiver type stays unresolved", + &no_import_nodes, + &no_import_edges, + "run", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart unimported receiver type stays unresolved", &no_import_nodes, @@ -13216,6 +15482,25 @@ void run(Notifier notifier) { ("lib/mail/notifier.dart", notifier_source), ("lib/workflow.dart", commented_import_source), ])?; + // `run`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart comment and string text cannot prove an import", + &commented_import_nodes, + &commented_import_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart comment and string text cannot prove an import", + &commented_import_nodes, + &commented_import_edges, + "run", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart comment and string text cannot prove an import", &commented_import_nodes, @@ -13231,6 +15516,25 @@ void run(Notifier notifier) { ("lib/other/notifier.dart", other_notifier_source), ("lib/workflow.dart", duplicate_alias_source), ])?; + // `run`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart duplicate prefixed import alias", + &duplicate_alias_nodes, + &duplicate_alias_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart duplicate prefixed import alias", + &duplicate_alias_nodes, + &duplicate_alias_edges, + "run", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart duplicate prefixed import alias", &duplicate_alias_nodes, @@ -13240,6 +15544,25 @@ void run(Notifier notifier) { "notifyEvent", "lib/mail/notifier.dart", ); + // `run`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart duplicate prefixed import alias", + &duplicate_alias_nodes, + &duplicate_alias_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart duplicate prefixed import alias", + &duplicate_alias_nodes, + &duplicate_alias_edges, + "run", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart duplicate prefixed import alias", &duplicate_alias_nodes, @@ -13255,6 +15578,24 @@ void run(Notifier notifier) { ("lib/other/notifier.dart", other_notifier_source), ("lib/workflow.dart", unprefixed_duplicate_source), ])?; + // `run`'s `notifyEvent` call emits an edge but stays unresolved on the + // ambiguous receiver; the `helper` and `notifyEvent` censuses pin both. + assert_ordinary_call_census( + "dart unprefixed ambiguous imported receiver", + &unprefixed_nodes, + &unprefixed_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart unprefixed ambiguous imported receiver", + &unprefixed_nodes, + &unprefixed_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart unprefixed ambiguous imported receiver", &unprefixed_nodes, @@ -13264,6 +15605,24 @@ void run(Notifier notifier) { "notifyEvent", "lib/mail/notifier.dart", ); + // `run`'s `notifyEvent` call emits an edge but stays unresolved on the + // ambiguous receiver; the `helper` and `notifyEvent` censuses pin both. + assert_ordinary_call_census( + "dart unprefixed ambiguous imported receiver", + &unprefixed_nodes, + &unprefixed_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart unprefixed ambiguous imported receiver", + &unprefixed_nodes, + &unprefixed_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart unprefixed ambiguous imported receiver", &unprefixed_nodes, @@ -13316,6 +15675,7 @@ void directOnly() { void erased(dynamic workflow) { workflow.run('ready'); + makeWorkflow(); } void innerShadow(dynamic workflow, bool enabled) { @@ -13363,6 +15723,14 @@ void orderAware() { expected_count: 1, }, ); + assert_ordinary_call_census( + "dart same-file constructor receiver avoids other owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "dart same-file constructor receiver avoids other owner", &nodes, @@ -13371,13 +15739,51 @@ void orderAware() { "OtherWorkflow", "run", ); + // `factoryOnly`'s `run` call emits no CALL edge while its receiver cannot + // be bound; the `makeWorkflow` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart factory receiver stays unresolved", + &nodes, + &edges, + "factoryOnly", + "makeWorkflow", + 1, + ); + assert_ordinary_call_census( + "dart factory receiver stays unresolved", + &nodes, + &edges, + "factoryOnly", + "run", + 0, + ); assert_no_resolved_call_to_method_owner( "dart factory receiver stays unresolved", &nodes, &edges, - "factoryOnly", - "Workflow", + "factoryOnly", + "Workflow", + "run", + ); + // `erased`'s `run` call emits no CALL edge while its receiver + // cannot be bound; the `makeWorkflow` census proves this caller's calls + // were extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart erased receiver stays unresolved", + &nodes, + &edges, + "erased", + "makeWorkflow", + 1, + ); + assert_ordinary_call_census( + "dart erased receiver stays unresolved", + &nodes, + &edges, + "erased", "run", + 0, ); assert_no_resolved_call_to_method_owner( "dart erased receiver stays unresolved", @@ -13439,9 +15845,12 @@ class Workflow { let imported_caller_source = r#" import './other/workflow.dart'; +void helper() {} + void orchestrate() { final workflow = Workflow(); workflow.run('ready'); + helper(); } "#; let shadowed_parameter_source = r#" @@ -13460,6 +15869,25 @@ void shadowed(other.Workflow workflow, bool enabled) { ("lib/other/workflow.dart", imported_owner_source), ("lib/use_workflow.dart", imported_caller_source), ])?; + // `orchestrate`'s `run` call emits no CALL edge while its receiver type + // stays ambiguous; the `helper` census proves its calls were extracted, + // and the 0-count pins the contract. + assert_ordinary_call_census( + "dart constructor receiver does not use imported cross-file owner", + &imported_nodes, + &imported_edges, + "orchestrate", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart constructor receiver does not use imported cross-file owner", + &imported_nodes, + &imported_edges, + "orchestrate", + "run", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart constructor receiver does not use imported cross-file owner", &imported_nodes, @@ -13474,6 +15902,14 @@ void shadowed(other.Workflow workflow, bool enabled) { ("lib/other/workflow.dart", imported_owner_source), ("lib/shadowed.dart", shadowed_parameter_source), ])?; + assert_ordinary_call_census( + "dart local constructor shadows imported typed parameter", + &shadowed_nodes, + &shadowed_edges, + "shadowed", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart local constructor shadows imported typed parameter", &shadowed_nodes, @@ -13583,6 +16019,14 @@ Object readNotifier() { "decorate", "lib/workflow.dart", ); + assert_ordinary_call_census( + "dart field receiver avoids other owner", + &nodes, + &edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "dart field receiver avoids other owner", &nodes, @@ -13591,6 +16035,14 @@ Object readNotifier() { "OtherRepository", "save", ); + assert_ordinary_call_census( + "dart parameter receiver shadows field receiver", + &nodes, + &edges, + "parameterShadowsField", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "dart parameter receiver shadows field receiver", &nodes, @@ -13608,6 +16060,25 @@ Object readNotifier() { "save", "lib/workflow.dart", ); + // `localFactoryShadowsField`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `makeNotifier` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart local factory shadow prevents property fallback", + &nodes, + &edges, + "localFactoryShadowsField", + "makeNotifier", + 1, + ); + assert_ordinary_call_census( + "dart local factory shadow prevents property fallback", + &nodes, + &edges, + "localFactoryShadowsField", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner( "dart local factory shadow prevents property fallback", &nodes, @@ -13616,6 +16087,25 @@ Object readNotifier() { "Notifier", "notifyEvent", ); + // `localDynamicShadowsField`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `makeNotifier` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart local dynamic shadow prevents property fallback", + &nodes, + &edges, + "localDynamicShadowsField", + "makeNotifier", + 1, + ); + assert_ordinary_call_census( + "dart local dynamic shadow prevents property fallback", + &nodes, + &edges, + "localDynamicShadowsField", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner( "dart local dynamic shadow prevents property fallback", &nodes, @@ -13624,6 +16114,25 @@ Object readNotifier() { "Notifier", "notifyEvent", ); + // `localUnknownShadowsField`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `readNotifier` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart local unknown shadow prevents property fallback", + &nodes, + &edges, + "localUnknownShadowsField", + "readNotifier", + 1, + ); + assert_ordinary_call_census( + "dart local unknown shadow prevents property fallback", + &nodes, + &edges, + "localUnknownShadowsField", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner( "dart local unknown shadow prevents property fallback", &nodes, @@ -13632,6 +16141,14 @@ Object readNotifier() { "Notifier", "notifyEvent", ); + assert_ordinary_call_census( + "dart erased property receiver stays unresolved", + &nodes, + &edges, + "erasedProperty", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner( "dart erased property receiver stays unresolved", &nodes, @@ -13647,6 +16164,8 @@ class Workflow { } "#; let caller_source = r#" +void helper() {} + class Entry { final Workflow workflow; @@ -13654,6 +16173,7 @@ class Entry { void call() { workflow.run('ready'); + helper(); } } "#; @@ -13661,6 +16181,25 @@ class Entry { ("lib/workflow.dart", owner_source), ("lib/entry.dart", caller_source), ])?; + // `call`'s `run` call emits no CALL edge while its receiver + // cannot be bound; the `helper` census proves this caller's calls + // were extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart property receiver does not use unimported cross-file owner", + &cross_file_nodes, + &cross_file_edges, + "call", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart property receiver does not use unimported cross-file owner", + &cross_file_nodes, + &cross_file_edges, + "call", + "run", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart property receiver does not use unimported cross-file owner", &cross_file_nodes, @@ -13862,6 +16401,14 @@ class Workflow expected_count: 2, }, ); + assert_ordinary_call_census( + "php use alias imported property receiver avoids other namespace", + &nodes, + &edges, + "run", + "notifyEvent", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "php use alias imported property receiver avoids other namespace", &nodes, @@ -13889,6 +16436,14 @@ class Workflow expected_count: 2, }, ); + assert_ordinary_call_census( + "php plain use imported property receiver avoids other namespace", + &plain_nodes, + &plain_edges, + "run", + "notifyEvent", + 2, + ); assert_no_resolved_call_to_method_owner_in_file( "php plain use imported property receiver avoids other namespace", &plain_nodes, @@ -13903,6 +16458,14 @@ class Workflow ("src/Acme/Mail/Notifier.php", mail_notifier_source), ("src/Acme/Workflow/workflow.php", missing_import_source), ])?; + assert_ordinary_call_census( + "php missing use alias imported property receiver", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php missing use alias imported property receiver", &missing_nodes, @@ -13918,6 +16481,14 @@ class Workflow ("src/Acme/Other/Notifier.php", other_notifier_source), ("src/Acme/Workflow/workflow.php", duplicate_alias_source), ])?; + assert_ordinary_call_census( + "php duplicate use alias imported property receiver", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php duplicate use alias imported property receiver", &duplicate_nodes, @@ -13927,6 +16498,14 @@ class Workflow "notifyEvent", "src/Acme/Mail/Notifier.php", ); + assert_ordinary_call_census( + "php duplicate use alias imported property receiver", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php duplicate use alias imported property receiver", &duplicate_nodes, @@ -13950,6 +16529,14 @@ class Workflow "notifyEvent", "src/Acme/Workflow/workflow.php", ); + assert_ordinary_call_census( + "php local receiver shadows use alias property type", + &shadow_nodes, + &shadow_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "php local receiver shadows use alias property type", &shadow_nodes, @@ -14057,6 +16644,8 @@ class Workflow { import './mail/notifier.dart' as shared; import './other/notifier.dart' as shared; +void helper() {} + class Workflow { final shared.Notifier notifier; @@ -14064,6 +16653,7 @@ class Workflow { void run() { notifier.notifyEvent('ready'); + helper(); } } "#; @@ -14082,6 +16672,14 @@ class Workflow { "notifyEvent", "lib/mail/notifier.dart", ); + assert_ordinary_call_census( + "dart imported property receiver avoids other import", + &nodes, + &edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart imported property receiver avoids other import", &nodes, @@ -14096,6 +16694,14 @@ class Workflow { ("lib/mail/notifier.dart", notifier_source), ("lib/workflow.dart", missing_import_source), ])?; + assert_ordinary_call_census( + "dart missing prefixed property receiver import", + &missing_nodes, + &missing_edges, + "run", + "notifyEvent", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart missing prefixed property receiver import", &missing_nodes, @@ -14115,6 +16721,25 @@ class Workflow { ), ("lib/workflow.dart", duplicate_alias_source), ])?; + // `run`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart duplicate prefixed property receiver alias", + &duplicate_nodes, + &duplicate_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart duplicate prefixed property receiver alias", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart duplicate prefixed property receiver alias", &duplicate_nodes, @@ -14124,6 +16749,25 @@ class Workflow { "notifyEvent", "lib/mail/notifier.dart", ); + // `run`'s `notifyEvent` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart duplicate prefixed property receiver alias", + &duplicate_nodes, + &duplicate_edges, + "run", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart duplicate prefixed property receiver alias", + &duplicate_nodes, + &duplicate_edges, + "run", + "notifyEvent", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart duplicate prefixed property receiver alias", &duplicate_nodes, @@ -14184,9 +16828,12 @@ void orchestrate() { import './mail/workflow.dart' as shared; import './other/workflow.dart' as shared; +void helper() {} + void orchestrate() { final workflow = shared.Workflow(); workflow.run('ready'); + helper(); } "#; @@ -14222,6 +16869,14 @@ void orchestrate() { "run", "lib/mail/workflow.dart", ); + assert_ordinary_call_census( + "dart prefixed imported constructor avoids other owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart prefixed imported constructor avoids other owner", &nodes, @@ -14236,6 +16891,14 @@ void orchestrate() { ("lib/mail/workflow.dart", workflow_source), ("lib/use_workflow.dart", missing_import_source), ])?; + assert_ordinary_call_census( + "dart missing prefixed imported constructor receiver", + &missing_nodes, + &missing_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "dart missing prefixed imported constructor receiver", &missing_nodes, @@ -14251,6 +16914,25 @@ void orchestrate() { ("lib/other/workflow.dart", other_workflow_source), ("lib/use_workflow.dart", duplicate_alias_source), ])?; + // `orchestrate`'s `run` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart duplicate prefixed imported constructor receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart duplicate prefixed imported constructor receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "run", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart duplicate prefixed imported constructor receiver", &duplicate_nodes, @@ -14260,6 +16942,25 @@ void orchestrate() { "run", "lib/mail/workflow.dart", ); + // `orchestrate`'s `run` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart duplicate prefixed imported constructor receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart duplicate prefixed imported constructor receiver", + &duplicate_nodes, + &duplicate_edges, + "orchestrate", + "run", + 0, + ); assert_no_resolved_call_to_method_owner_in_file( "dart duplicate prefixed imported constructor receiver", &duplicate_nodes, @@ -14708,6 +17409,14 @@ void run(Notifier& notifier) { ("two/Notifier.cpp", other_notifier_source), ("workflow.cpp", caller_source), ])?; + assert_ordinary_call_census( + "cpp cross-file ambiguous receiver", + &ambiguous_nodes, + &ambiguous_edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "cpp cross-file ambiguous receiver", &ambiguous_nodes, @@ -14717,6 +17426,14 @@ void run(Notifier& notifier) { "save", "one/Notifier.cpp", ); + assert_ordinary_call_census( + "cpp cross-file ambiguous receiver", + &ambiguous_nodes, + &ambiguous_edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "cpp cross-file ambiguous receiver", &ambiguous_nodes, @@ -14744,6 +17461,14 @@ void run() { "#; let (temporary_nodes, temporary_edges) = index_single_file("temporary.cpp", constructor_temporary_source)?; + assert_ordinary_call_census( + "cpp constructor temporary receiver", + &temporary_nodes, + &temporary_edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp constructor temporary receiver", &temporary_nodes, @@ -14752,6 +17477,14 @@ void run() { "Notifier", "save", ); + assert_ordinary_call_census( + "cpp constructor temporary receiver", + &temporary_nodes, + &temporary_edges, + "run", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp constructor temporary receiver", &temporary_nodes, @@ -14974,7 +17707,15 @@ void outerParamStillWorks(Workflow& workflow) { "autoNewPointer", "Workflow", "run", - "workflow.cpp", + "workflow.cpp", + ); + assert_ordinary_call_census( + "cpp auto factory receiver stays unresolved", + &nodes, + &edges, + "autoFactory", + "run", + 1, ); assert_no_resolved_call_to_method_owner( "cpp auto factory receiver stays unresolved", @@ -14984,6 +17725,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp auto factory receiver assigned later stays unresolved", + &nodes, + &edges, + "autoFactoryThenAssigned", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp auto factory receiver assigned later stays unresolved", &nodes, @@ -14992,6 +17741,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp auto bare identifier initializer stays unresolved", + &nodes, + &edges, + "autoBareIdentifierInitializer", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp auto bare identifier initializer stays unresolved", &nodes, @@ -15000,6 +17757,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp auto composed braced initializer stays unresolved", + &nodes, + &edges, + "autoComposedBracedInitializer", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp auto composed braced initializer stays unresolved", &nodes, @@ -15008,6 +17773,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp auto chained paren initializer stays unresolved", + &nodes, + &edges, + "autoChainedParenInitializer", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp auto chained paren initializer stays unresolved", &nodes, @@ -15016,6 +17789,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp auto qualified constructor receiver stays unresolved", + &nodes, + &edges, + "autoQualifiedConstructor", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp auto qualified constructor receiver stays unresolved", &nodes, @@ -15024,6 +17805,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp auto smart-pointer factory receiver stays unresolved", + &nodes, + &edges, + "autoFactoryPointer", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp auto smart-pointer factory receiver stays unresolved", &nodes, @@ -15032,6 +17821,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp local receiver avoids other owner", + &nodes, + &edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp local receiver avoids other owner", &nodes, @@ -15059,6 +17856,14 @@ void outerParamStillWorks(Workflow& workflow) { expected_count: 1, }, ); + assert_ordinary_call_census( + "cpp local auto declaration shadows typed parameter", + &nodes, + &edges, + "innerShadow", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp local auto declaration shadows typed parameter", &nodes, @@ -15079,6 +17884,14 @@ void outerParamStillWorks(Workflow& workflow) { expected_count: 1, }, ); + assert_ordinary_call_census( + "cpp multi-declarator local declaration shadows typed parameter", + &nodes, + &edges, + "multiDeclaratorShadow", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp multi-declarator local declaration shadows typed parameter", &nodes, @@ -15087,6 +17900,14 @@ void outerParamStillWorks(Workflow& workflow) { "Workflow", "run", ); + assert_ordinary_call_census( + "cpp direct auto multi-declarator shadows typed parameter", + &nodes, + &edges, + "directAutoMultiDeclaratorShadow", + "run", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp direct auto multi-declarator shadows typed parameter", &nodes, @@ -15133,6 +17954,14 @@ void autoOrchestrate() { ("lib/workflow.cpp", owner_source), ("app/caller.cpp", caller_source), ])?; + assert_ordinary_call_census( + "cpp local receiver does not use cross-file owner", + &cross_nodes, + &cross_edges, + "orchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "cpp local receiver does not use cross-file owner", &cross_nodes, @@ -15142,6 +17971,14 @@ void autoOrchestrate() { "run", "lib/workflow.cpp", ); + assert_ordinary_call_census( + "cpp auto local receiver does not use cross-file owner", + &cross_nodes, + &cross_edges, + "autoOrchestrate", + "run", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "cpp auto local receiver does not use cross-file owner", &cross_nodes, @@ -15248,6 +18085,14 @@ public: "decorate", "workflow.cpp", ); + assert_ordinary_call_census( + "cpp field receiver avoids other owner", + &nodes, + &edges, + "fieldRun", + "save", + 3, + ); assert_no_resolved_call_to_method_owner( "cpp field receiver avoids other owner", &nodes, @@ -15256,6 +18101,14 @@ public: "OtherRepository", "save", ); + assert_ordinary_call_census( + "cpp self receiver avoids other owner", + &nodes, + &edges, + "fieldRun", + "decorate", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp self receiver avoids other owner", &nodes, @@ -15264,6 +18117,14 @@ public: "OtherWorkflow", "decorate", ); + assert_ordinary_call_census( + "cpp parameter receiver shadows field receiver", + &nodes, + &edges, + "parameterShadowsField", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp parameter receiver shadows field receiver", &nodes, @@ -15281,6 +18142,14 @@ public: "save", "workflow.cpp", ); + assert_ordinary_call_census( + "cpp local receiver shadows field receiver", + &nodes, + &edges, + "localShadowsField", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp local receiver shadows field receiver", &nodes, @@ -15289,6 +18158,14 @@ public: "Repository", "save", ); + assert_ordinary_call_census( + "cpp multi-declarator field stays unresolved", + &nodes, + &edges, + "multiDeclaratorField", + "save", + 1, + ); assert_no_resolved_call_to_method_owner( "cpp multi-declarator field stays unresolved", &nodes, @@ -15322,6 +18199,14 @@ public: ("lib/repository.cpp", owner_source), ("app/entry.cpp", caller_source), ])?; + assert_ordinary_call_census( + "cpp field receiver does not use cross-file owner", + &cross_file_nodes, + &cross_file_edges, + "call", + "save", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "cpp field receiver does not use cross-file owner", &cross_file_nodes, @@ -15975,6 +18860,14 @@ fn run(factory: &Factory) -> Result<(), ()> { ("factory.rs", factory), ("caller.rs", caller), ])?; + assert_ordinary_call_census( + "rust cross-file return type remains fail-closed", + &nodes, + &edges, + "run", + "execute", + 1, + ); assert_no_resolved_call_to_method_owner( "rust cross-file return type remains fail-closed", &nodes, @@ -16031,6 +18924,14 @@ trait ConstructorWorkflow { "Workflow", method, ); + assert_ordinary_call_census( + "rust trait default self call", + &nodes, + &edges, + "run", + method, + 1, + ); assert_no_resolved_call_to_method_owner( "rust trait default self call", &nodes, @@ -16040,6 +18941,14 @@ trait ConstructorWorkflow { method, ); } + assert_ordinary_call_census( + "rust trait constructor return remains fail closed", + &nodes, + &edges, + "run_constructor", + "inspect", + 1, + ); assert_no_resolved_call_to_method_owner( "rust trait constructor return remains fail closed", &nodes, @@ -16081,6 +18990,14 @@ fn ambiguous(target: &T) { "Handler", "handle", ); + assert_ordinary_call_census( + "rust ambiguous local generic trait bound", + &nodes, + &edges, + "ambiguous", + "handle", + 1, + ); assert_no_resolved_call_to_method_owner( "rust ambiguous local generic trait bound", &nodes, @@ -16089,6 +19006,14 @@ fn ambiguous(target: &T) { "Handler", "handle", ); + assert_ordinary_call_census( + "rust ambiguous local generic trait bound", + &nodes, + &edges, + "ambiguous", + "handle", + 1, + ); assert_no_resolved_call_to_method_owner( "rust ambiguous local generic trait bound", &nodes, @@ -16191,6 +19116,14 @@ func shadowed(new func(node) *other) { "node", "addRoute", ); + assert_ordinary_call_census( + "go shadowed new remains fail-closed", + &nodes, + &edges, + "shadowed", + "addRoute", + 1, + ); assert_no_resolved_call_to_method_owner( "go shadowed new remains fail-closed", &nodes, @@ -16222,6 +19155,14 @@ func build() { "#; let (nodes, edges) = index_files(&[("router.go", source)])?; + assert_ordinary_call_census( + "go file-scope new shadow remains fail-closed", + &nodes, + &edges, + "build", + "addRoute", + 1, + ); assert_no_resolved_call_to_method_owner( "go file-scope new shadow remains fail-closed", &nodes, @@ -16340,6 +19281,14 @@ class IOClient { "BaseRequest", "finalize", ); + assert_ordinary_call_census( + "dart untyped initializer remains fail-closed", + &nodes, + &edges, + "IOClient.untyped", + "finalize", + 1, + ); assert_no_resolved_call_to_method_owner( "dart untyped initializer remains fail-closed", &nodes, @@ -16355,14 +19304,36 @@ class Worker { } "#; let unimported_entry = r#" +void helper() {} + void call(Worker worker) { worker.run(); + helper(); } "#; let (unimported_nodes, unimported_edges) = index_files(&[ ("lib/worker.dart", worker), ("lib/entry.dart", unimported_entry), ])?; + // `call`'s `run` call emits no CALL edge while its receiver cannot + // be bound; the `helper` census proves this caller's calls were + // extracted, and the 0-count pins the contract. + assert_ordinary_call_census( + "dart unimported typed parameter remains fail-closed", + &unimported_nodes, + &unimported_edges, + "call", + "helper", + 1, + ); + assert_ordinary_call_census( + "dart unimported typed parameter remains fail-closed", + &unimported_nodes, + &unimported_edges, + "call", + "run", + 0, + ); assert_no_resolved_call_to_method_owner( "dart unimported typed parameter remains fail-closed", &unimported_nodes, @@ -16401,6 +19372,14 @@ class Client { ("lib/second_request.dart", second_request), ("lib/client.dart", caller), ])?; + assert_ordinary_call_census( + "dart duplicate same-directory owner remains fail-closed", + &nodes, + &edges, + "Client.send", + "finish", + 1, + ); assert_no_resolved_call_to_method_owner( "dart duplicate same-directory owner remains fail-closed", &nodes, @@ -17014,6 +19993,14 @@ class PanelOwner // Vendor shadow: Widget's only declaration is under root `Vendor`. assert_no_type_usage_from("csharp vendor shadow", &nodes, &edges, "ShadowOwner"); + assert_ordinary_call_census( + "csharp vendor shadow chained call", + &nodes, + &edges, + "ShadowOwner.Chain", + "Render", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp vendor shadow chained call", &nodes, @@ -17027,6 +20014,14 @@ class PanelOwner // Ambiguity: two Panel declarations under root `Acme`. assert_no_type_usage_from("csharp same-root ambiguity", &nodes, &edges, "PanelOwner"); for panel_file in ["src/Acme/A/Panel.cs", "src/Acme/B/Panel.cs"] { + assert_ordinary_call_census( + "csharp same-root ambiguity chained call", + &nodes, + &edges, + "PanelOwner.Chain", + "Draw", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "csharp same-root ambiguity chained call", &nodes, @@ -17450,6 +20445,8 @@ func Convert(k Key) string { return string(k) } func Variable(run func() int) int { return run() } "#; let (nodes, edges) = index_files(&[("methods.go", methods), ("calls.go", conversions)])?; + assert_ordinary_call_census("G08-string", &nodes, &edges, "Convert", "string", 1); + assert_ordinary_call_census("G08-run", &nodes, &edges, "Variable", "run", 1); assert_no_resolved_call_to_method_owner( "G08-string", &nodes, @@ -17492,6 +20489,7 @@ func Caller() { Run() } ("caller.go", caller), ])?; assert_resolved_call_to_kind("G09", &nodes, &edges, "Caller", "Run", NodeKind::FUNCTION); + assert_ordinary_call_census("G09", &nodes, &edges, "Caller", "Run", 1); assert_no_resolved_call_to_method_owner("G09", &nodes, &edges, "Caller", "Handler", "Run"); Ok(()) } @@ -18168,6 +21166,14 @@ fn test_go_factory_return_header_and_case_shadows_block_import_inside_scope() -> "InsideSwitchHeader", "InsideSwitchCase", ] { + assert_ordinary_call_census( + "return-header-shadow-inside", + &nodes, + &edges, + caller, + "Finish", + 1, + ); assert_no_resolved_call_to_method_owner_in_file( "return-header-shadow-inside", &nodes, @@ -18457,6 +21463,14 @@ fn test_go_factory_return_module_refresh_clears_unchanged_caller() -> anyhow::Re fixture.assert_initial_alpha("return-refresh-module-initial")?; fs::write(&fixture.module_path, "module example.com/changed\n")?; fixture.refresh(vec![fixture.module_path.clone()], vec![])?; + assert_ordinary_call_census( + "return-refresh-module-control", + &fixture.storage.get_nodes()?, + &fixture.storage.get_edges()?, + "Caller", + "Finish", + 1, + ); assert_no_resolved_call_to_method_owner( "return-refresh-module-control", &fixture.storage.get_nodes()?, @@ -18479,6 +21493,14 @@ fn test_go_factory_return_deletion_refresh_clears_unchanged_caller() -> anyhow:: .id; fs::remove_file(&fixture.factory_path)?; fixture.refresh(vec![], vec![factory_id])?; + assert_ordinary_call_census( + "return-refresh-factory-deletion", + &fixture.storage.get_nodes()?, + &fixture.storage.get_edges()?, + "Caller", + "Finish", + 1, + ); assert_no_resolved_call_to_method_owner( "return-refresh-factory-deletion", &fixture.storage.get_nodes()?, @@ -18499,6 +21521,14 @@ fn test_go_factory_return_source_hash_drift_stays_unresolved() -> anyhow::Result "package worker\nfunc New() *Bravo { return nil }\n", )?; fixture.refresh(vec![fixture.caller_path.clone()], vec![])?; + assert_ordinary_call_census( + "return-refresh-drift-old", + &fixture.storage.get_nodes()?, + &fixture.storage.get_edges()?, + "Caller", + "Finish", + 1, + ); assert_no_resolved_call_to_method_owner( "return-refresh-drift-old", &fixture.storage.get_nodes()?, @@ -18507,6 +21537,14 @@ fn test_go_factory_return_source_hash_drift_stays_unresolved() -> anyhow::Result "Alpha", "Finish", ); + assert_ordinary_call_census( + "return-refresh-drift-unindexed", + &fixture.storage.get_nodes()?, + &fixture.storage.get_edges()?, + "Caller", + "Finish", + 1, + ); assert_no_resolved_call_to_method_owner( "return-refresh-drift-unindexed", &fixture.storage.get_nodes()?, @@ -19056,6 +22094,7 @@ func Convert(k Key) string { return string(k) } let nodes = storage.get_nodes()?; let edges = storage.get_edges()?; + assert_ordinary_call_census("G13-initial", &nodes, &edges, "Convert", "string", 1); assert_no_resolved_call_to_method_owner( "G13-initial", &nodes, @@ -19074,6 +22113,7 @@ func Convert(k Key) string { return string(k) } assert!(!snapshot_loaded.telemetry.support_snapshot_stored); let nodes = storage.get_nodes()?; let edges = storage.get_edges()?; + assert_ordinary_call_census("G13-snapshot-hit", &nodes, &edges, "Convert", "string", 1); assert_no_resolved_call_to_method_owner( "G13-snapshot-hit", &nodes, @@ -19104,6 +22144,7 @@ func Convert(k Key) string { return string(k) } let nodes = storage.get_nodes()?; let edges = storage.get_edges()?; + assert_ordinary_call_census("G13-refresh", &nodes, &edges, "Convert", "string", 1); assert_no_resolved_call_to_method_owner( "G13-refresh", &nodes, diff --git a/crates/codestory-indexer/tests/proof_resolution.rs b/crates/codestory-indexer/tests/proof_resolution.rs index 6fda05968..d6ec55c88 100644 --- a/crates/codestory-indexer/tests/proof_resolution.rs +++ b/crates/codestory-indexer/tests/proof_resolution.rs @@ -10025,7 +10025,7 @@ fn typescript_direct_imports_separate_type_specifiers_and_resolve_literal_direct } else { ("target();", "target") }; - assert_no_exact_calls_at(&files, &[(path, marker, callee)])?; + assert_no_exact_calls_at(&files, &[(path, marker, callee)], &[])?; } assert_no_exact_calls_at( &[ @@ -10039,6 +10039,7 @@ fn typescript_direct_imports_separate_type_specifiers_and_resolve_literal_direct ), ], &[("src/aliased_type.ts", "local();", "local")], + &[], )?; Ok(()) } @@ -10078,7 +10079,7 @@ fn typescript_type_specifier_local_collisions_close_the_entire_import_binding() } else { ("target();", "target") }; - assert_no_exact_calls_at(&files, &[(path, marker, callee)])?; + assert_no_exact_calls_at(&files, &[(path, marker, callee)], &[])?; } Ok(()) } @@ -10322,16 +10323,19 @@ fn relative_module_resolution_uses_one_closed_language_family() -> anyhow::Resul assert_only_call_is_exact(&files)?; } - assert_no_exact_target_calls(&[ - ( - "src/exported.cjs", - "function target() {}\nmodule.exports = { target };\n", - ), - ( - "src/importer.cjs", - "const { target } = require('./exported.cjs');\nfunction caller() { target(); }\n", - ), - ])?; + assert_no_exact_target_calls( + &[ + ( + "src/exported.cjs", + "function target() {}\nmodule.exports = { target };\n", + ), + ( + "src/importer.cjs", + "const { target } = require('./exported.cjs');\nfunction caller() { target(); }\n", + ), + ], + &[], + )?; for files in [ vec![( @@ -12073,7 +12077,7 @@ fn receiver_class_and_mutation_domains_fail_closed() -> anyhow::Result<()> { "export function caller(receiver: unknown) { receiver.target(); }\n", ), ] { - assert_no_exact_target_calls(&[(path, source)])?; + assert_no_exact_target_calls(&[(path, source)], &[])?; } assert_only_call_is_not_exact(&[ ("src/exported.ts", "export class C { target() {} }\n"), @@ -12097,7 +12101,7 @@ fn receiver_mutation_domains_are_structural_and_exactly_keyed() -> anyhow::Resul "export class C { target() {} }\nexport function caller() { const receiver = new C(); C.prototype = {}; receiver.target(); }\n", "export class C { target() {} }\nexport function caller(key) { const receiver = new C(); C[key] = {}; receiver.target(); }\n", ] { - assert_no_exact_target_calls(&[("src/mutated.js", source)])?; + assert_no_exact_target_calls(&[("src/mutated.js", source)], &[])?; } assert_only_call_is_exact(&[( @@ -12126,7 +12130,7 @@ fn javascript_binding_and_method_modifiers_use_parser_tokens() -> anyhow::Result "export class C { get/*comment*/ target() { return () => {}; } }\nexport function caller() { const receiver = new C(); receiver.target(); }\n", "export class C { */*comment*/ target() {} }\nexport function caller() { const receiver = new C(); receiver.target(); }\n", ] { - assert_no_exact_target_calls(&[("src/commented_method.js", source)])?; + assert_no_exact_target_calls(&[("src/commented_method.js", source)], &[])?; } Ok(()) } @@ -12753,19 +12757,49 @@ fn is_script_fixture(path: &str) -> bool { ) } -fn assert_no_exact_calls(files: &[(&str, &str)]) -> anyhow::Result<()> { - assert_no_exact_calls_at(files, &[]) +/// Designated ordinary-call census for the negative helpers: each entry is +/// `(caller serialized-name tail, raw_target, expected fact count)`. A refusal +/// assertion is vacuous unless the fixture's ordinary calls actually produced +/// proof-resolution facts, so the census is asserted before the refusal. +fn assert_call_fact_census( + case_name: &str, + store: &Store, + facts: &[codestory_contracts::proof_resolution::CallResolutionFact], + census: &[(&str, &str, usize)], +) -> anyhow::Result<()> { + let nodes = store.get_nodes()?; + for (caller_name, raw_target, expected_count) in census { + let count = facts + .iter() + .filter(|fact| { + fact.callsite.raw_target == *raw_target + && nodes.iter().any(|node| { + node.id == fact.caller + && (node.serialized_name == *caller_name + || node.serialized_name.ends_with(&format!("::{caller_name}"))) + }) + }) + .count(); + anyhow::ensure!( + count == *expected_count, + "{case_name}: call census expected {expected_count} fact(s) from `{caller_name}` \ + toward `{raw_target}`; refusal assertions are vacuous without them: {facts:#?}" + ); + } + Ok(()) } fn assert_no_exact_calls_at( files: &[(&str, &str)], anchors: &[(&str, &str, &str)], + census: &[(&str, &str, usize)], ) -> anyhow::Result<()> { let project = tempfile::tempdir()?; let mut store = Store::new_in_memory()?; index_files(project.path(), &mut store, files)?; rematerialize_proof_resolution_projection(&mut store, &publication(1))?; let facts = store.get_proof_resolution_facts()?; + assert_call_fact_census("assert_no_exact_calls_at", &store, &facts, census)?; if files.iter().any(|(path, _)| is_script_fixture(path)) { assert!( !anchors.is_empty(), @@ -12784,12 +12818,16 @@ fn assert_no_exact_calls_at( Ok(()) } -fn assert_no_exact_target_calls(files: &[(&str, &str)]) -> anyhow::Result<()> { +fn assert_no_exact_target_calls( + files: &[(&str, &str)], + census: &[(&str, &str, usize)], +) -> anyhow::Result<()> { let project = tempfile::tempdir()?; let mut store = Store::new_in_memory()?; index_files(project.path(), &mut store, files)?; rematerialize_proof_resolution_projection(&mut store, &publication(1))?; let facts = store.get_proof_resolution_facts()?; + assert_call_fact_census("assert_no_exact_target_calls", &store, &facts, census)?; let target_facts = facts .iter() .filter(|fact| fact.callsite.raw_target.trim_start_matches('#') == "target") @@ -13025,7 +13063,7 @@ fn javascript_typescript_unsupported_matrix_never_authorizes() -> anyhow::Result "target", ), ] { - assert_no_exact_calls_at(&[(path, source)], &[(path, marker, callee)])?; + assert_no_exact_calls_at(&[(path, source)], &[(path, marker, callee)], &[])?; } let project = tempfile::tempdir()?; let mut store = Store::new_in_memory()?; @@ -13667,13 +13705,17 @@ fn rust_inherent_receiver_and_constructor_subset_authorizes_closed_bindings() -> #[test] fn rust_projection_requires_exact_syntax_but_not_exact_navigation_metadata() -> anyhow::Result<()> { - assert_no_exact_calls(&[ - ("src/target.rs", "pub fn target() {}\n"), - ( - "src/lib.rs", - "mod target;\nuse crate::target::target as alias;\nfn caller() { alias(); }\n", - ), - ])?; + assert_no_exact_calls_at( + &[ + ("src/target.rs", "pub fn target() {}\n"), + ( + "src/lib.rs", + "mod target;\nuse crate::target::target as alias;\nfn caller() { alias(); }\n", + ), + ], + &[], + &[("caller", "alias", 1)], + )?; assert_only_call_is_not_exact(&[( "src/lib.rs", "pub fn target() {}\nmod nested { fn caller() { super::target(); } }\n", @@ -13797,50 +13839,104 @@ fn rust_module_and_import_closure_matrix_stays_fail_closed() -> anyhow::Result<( ), ], ] { - assert_no_exact_target_calls(&files)?; + assert_no_exact_target_calls(&files, &[("caller", "target", 1)])?; } assert_only_call_is_not_exact(&[( "src/lib.rs", "fn target() {}\nfn target() {}\nfn caller() { target(); }\n", )])?; - assert_no_exact_target_calls(&[ - ("src/lib.rs", "mod target;\n"), - ("src/target.rs", "pub fn target() {}\n"), - ( - "src/orphan.rs", - "use crate::target::target;\nfn caller() { target(); }\n", - ), - ])?; - assert_no_exact_target_calls(&[ - ( - "src/lib.rs", - "mod target;\nuse crate::target::target;\nfn caller() { target(); }\n", - ), - ("src/target.rs", "pub fn target() {}\nstruct target;\n"), - ])?; + assert_no_exact_target_calls( + &[ + ("src/lib.rs", "mod target;\n"), + ("src/target.rs", "pub fn target() {}\n"), + ( + "src/orphan.rs", + "use crate::target::target;\nfn caller() { target(); }\n", + ), + ], + &[("caller", "target", 1)], + )?; + assert_no_exact_target_calls( + &[ + ( + "src/lib.rs", + "mod target;\nuse crate::target::target;\nfn caller() { target(); }\n", + ), + ("src/target.rs", "pub fn target() {}\nstruct target;\n"), + ], + &[("caller", "target", 1)], + )?; Ok(()) } #[test] fn rust_inherent_and_receiver_unsupported_matrix_stays_fail_closed() -> anyhow::Result<()> { - for source in [ - "struct Owner;\nimpl Owner where Owner: Sized { fn target(&self) {} fn caller(&self) { self.target(); } }\n", - "struct Owner;\ntrait Trait { fn target(); }\nimpl Trait for Owner { fn target() {} }\nfn caller() { ::target(); }\n", - "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller() { Owner::target(); }\n", - "struct Owner;\nstruct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller(value: &Owner) { value.target(); }\n", - "struct Owner;\ntype Alias = Owner;\nimpl Owner { fn target(&self) {} }\nfn caller(value: Alias) { value.target(); }\n", - "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller(value: Box) { value.target(); }\n", - "trait Trait { fn target(&self); }\nfn caller(value: &dyn Trait) { value.target(); }\n", - "struct Owner;\nimpl Owner { fn target(&self) {} }\nstruct Holder { value: Owner }\nfn caller(holder: Holder) { holder.value.target(); }\n", - "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn make() -> Owner { Owner }\nfn caller() { make().target(); }\n", - "struct Owner(u8);\nimpl Owner { fn target(&self) {} }\nfn caller() { let value = Owner(1); value.target(); }\n", - "struct Owner;\nimpl Owner { fn build() -> Result { Ok(Self) } fn target(&self) {} }\nfn caller() { let value = Owner::build(); value.target(); }\n", - "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller() { let value = Owner; value = Owner; value.target(); }\n", - "fn target() {}\nfn caller() { target::(); }\n", - "struct Owner;\nimpl Owner { fn target(&self) {} fn caller(&self) { self.target(); } }\nimpl Owner { generated!(); }\n", - "struct Owner;\nimpl Owner { fn target(&self) {} fn caller(&self) { self.target(); } }\nimpl Owner where Owner: Sized { fn other(&self) {} }\n", + for (source, designated_raw_target) in [ + ( + "struct Owner;\nimpl Owner where Owner: Sized { fn target(&self) {} fn caller(&self) { self.target(); } }\n", + "target", + ), + ( + "struct Owner;\ntrait Trait { fn target(); }\nimpl Trait for Owner { fn target() {} }\nfn caller() { ::target(); }\n", + "target", + ), + ( + "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller() { Owner::target(); }\n", + "target", + ), + ( + "struct Owner;\nstruct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller(value: &Owner) { value.target(); }\n", + "target", + ), + ( + "struct Owner;\ntype Alias = Owner;\nimpl Owner { fn target(&self) {} }\nfn caller(value: Alias) { value.target(); }\n", + "target", + ), + ( + "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller(value: Box) { value.target(); }\n", + "target", + ), + ( + "trait Trait { fn target(&self); }\nfn caller(value: &dyn Trait) { value.target(); }\n", + "target", + ), + ( + "struct Owner;\nimpl Owner { fn target(&self) {} }\nstruct Holder { value: Owner }\nfn caller(holder: Holder) { holder.value.target(); }\n", + "target", + ), + ( + "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn make() -> Owner { Owner }\nfn caller() { make().target(); }\n", + "target", + ), + ( + "struct Owner(u8);\nimpl Owner { fn target(&self) {} }\nfn caller() { let value = Owner(1); value.target(); }\n", + "target", + ), + ( + "struct Owner;\nimpl Owner { fn build() -> Result { Ok(Self) } fn target(&self) {} }\nfn caller() { let value = Owner::build(); value.target(); }\n", + "target", + ), + ( + "struct Owner;\nimpl Owner { fn target(&self) {} }\nfn caller() { let value = Owner; value = Owner; value.target(); }\n", + "target", + ), + ( + "fn target() {}\nfn caller() { target::(); }\n", + "", + ), + ( + "struct Owner;\nimpl Owner { fn target(&self) {} fn caller(&self) { self.target(); } }\nimpl Owner { generated!(); }\n", + "target", + ), + ( + "struct Owner;\nimpl Owner { fn target(&self) {} fn caller(&self) { self.target(); } }\nimpl Owner where Owner: Sized { fn other(&self) {} }\n", + "target", + ), ] { - assert_no_exact_target_calls(&[("src/lib.rs", source)])?; + assert_no_exact_target_calls( + &[("src/lib.rs", source)], + &[("caller", designated_raw_target, 1)], + )?; } Ok(()) } @@ -15552,10 +15648,14 @@ fn rust_bounded_attributes_keep_binding_macros_closed_without_poisoning_expressi "src/lib.rs", "fn target() {}\n#[allow(dead_code)]\nfn caller() { let _ = concat!(\"unrelated\", \"expression\"); target(); }\n", )])?; - assert_no_exact_target_calls(&[( - "src/lib.rs", - "macro_rules! tokens { ($value:expr) => {}; }\nfn target() {}\n#[allow(dead_code)]\nfn caller() { tokens!(target()); }\n", - )])?; + assert_no_exact_target_calls( + &[( + "src/lib.rs", + "macro_rules! tokens { ($value:expr) => {}; }\nfn target() {}\n#[allow(dead_code)]\nfn caller() { tokens!(target()); }\n", + )], + // The callsite lives inside macro tokens: no call fact is expected. + &[], + )?; Ok(()) } @@ -15802,15 +15902,35 @@ fn rust_documented_target_groups_survive_interposed_ordinary_comments() -> anyho #[test] fn rust_documented_target_inner_dangling_and_recovery_barriers_stay_non_authoritative() -> anyhow::Result<()> { - for source in [ - "#[cfg(any())]\n//! inner docs\n/// outer docs\nfn target() {}\nfn caller() { target(); }\n", - "#[cfg(any())]\n/*! inner docs */\n/** outer docs */\nfn target() {}\nfn caller() { target(); }\n", - "/// dangling docs\nstruct Marker;\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", - "/// docs\n<\nfn target() {}\nfn caller() { target(); }\n", - "#[cfg(any())]\n// ordinary\n<\n/// docs\nfn target() {}\nfn caller() { target(); }\n", - "/// docs\n/* unterminated\nfn target() {}\nfn caller() { target(); }\n", + for (source, census) in [ + ( + "#[cfg(any())]\n//! inner docs\n/// outer docs\nfn target() {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "#[cfg(any())]\n/*! inner docs */\n/** outer docs */\nfn target() {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "/// dangling docs\nstruct Marker;\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "/// docs\n<\nfn target() {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + // The last two fixtures consume the callsite inside a recovery + // barrier or an unterminated comment: zero facts is their content. + ( + "#[cfg(any())]\n// ordinary\n<\n/// docs\nfn target() {}\nfn caller() { target(); }\n", + &[][..], + ), + ( + "/// docs\n/* unterminated\nfn target() {}\nfn caller() { target(); }\n", + &[][..], + ), ] { - assert_no_exact_target_calls(&[("src/lib.rs", source)])?; + assert_no_exact_target_calls(&[("src/lib.rs", source)], census)?; } Ok(()) } @@ -15848,19 +15968,50 @@ fn rust_tainted_attribute_groups_never_reach_bounded_caller_or_callsite_classifi #[test] fn rust_documented_target_rule_stays_free_function_and_domain_specific() -> anyhow::Result<()> { - for source in [ - "struct Worker;\nimpl Worker { /// docs\nfn target(&self) {} fn caller(&self) { self.target(); } }\n", - "struct Worker;\nimpl Worker { /// docs\nfn target() {} }\nfn caller() { Worker::target(); }\n", - "fn outer() { /// docs\nfn target() {} target(); }\n", - "trait Worker { /// docs\nfn target(&self); fn caller(&self) { self.target(); } }\n", - "/// docs\nstruct target;\nfn caller() { target(); }\n", - "/// docs\nmod target {}\nfn caller() { target(); }\n", - "/// docs for a different item\nstruct Marker;\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", - "//! inner module documentation\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", - "/*! inner module documentation */\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", - "/// docs\nfn target() {}\nmacro_rules! tokens { ($value:expr) => {}; }\nfn caller() { tokens!(target()); }\n", + for (source, census) in [ + ( + "struct Worker;\nimpl Worker { /// docs\nfn target(&self) {} fn caller(&self) { self.target(); } }\n", + &[("caller", "target", 1)][..], + ), + ( + "struct Worker;\nimpl Worker { /// docs\nfn target() {} }\nfn caller() { Worker::target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "fn outer() { /// docs\nfn target() {} target(); }\n", + &[("outer", "target", 1)][..], + ), + ( + "trait Worker { /// docs\nfn target(&self); fn caller(&self) { self.target(); } }\n", + &[("caller", "target", 1)][..], + ), + ( + "/// docs\nstruct target;\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "/// docs\nmod target {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "/// docs for a different item\nstruct Marker;\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "//! inner module documentation\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + ( + "/*! inner module documentation */\n#[cfg(any())]\nfn target() {}\nfn caller() { target(); }\n", + &[("caller", "target", 1)][..], + ), + // The callsite lives inside macro tokens: no call fact is expected. + ( + "/// docs\nfn target() {}\nmacro_rules! tokens { ($value:expr) => {}; }\nfn caller() { tokens!(target()); }\n", + &[][..], + ), ] { - assert_no_exact_target_calls(&[("src/lib.rs", source)])?; + assert_no_exact_target_calls(&[("src/lib.rs", source)], census)?; } for source in [ diff --git a/crates/codestory-indexer/tests/source_shape_cost.rs b/crates/codestory-indexer/tests/source_shape_cost.rs index 168308c3c..87f3d4183 100644 --- a/crates/codestory-indexer/tests/source_shape_cost.rs +++ b/crates/codestory-indexer/tests/source_shape_cost.rs @@ -12,6 +12,7 @@ //! it — while the quadratic it guards against, which ran to tens of seconds at //! this size, cannot pass. +use codestory_contracts::graph::EdgeKind; use std::path::Path; use std::time::{Duration, Instant}; @@ -54,6 +55,10 @@ fn one_giant_function_costs_about_what_many_small_ones_do() { !result.nodes.is_empty(), "the fixture must actually project nodes, or this guard measures nothing" ); + assert!( + result.edges.iter().any(|edge| edge.kind == EdgeKind::CALL), + "the fixture's `base.get()` receiver calls must project CALL edges, or this guard measures nothing" + ); assert!( elapsed < BUDGET, "indexing {} bytes of one-giant-function Rust took {elapsed:?}, over the \ From 69f3412aa0973067ee4d32096972e16dcd7d5191 Mon Sep 17 00:00:00 2001 From: Albert Najjar Date: Mon, 28 Sep 2026 15:01:30 -0400 Subject: [PATCH 17/58] test: repair indexer audit rows and consolidate duplicates --- crates/codestory-indexer/src/cache.rs | 6 +- .../codestory-indexer/src/framework_routes.rs | 67 ++- crates/codestory-indexer/src/lib.rs | 3 + .../codestory-indexer/src/proof_resolution.rs | 4 + .../codestory-indexer/src/resolution/mod.rs | 442 ++---------------- .../src/structural/blanking.rs | 16 + .../codestory-indexer/src/structural/css.rs | 9 + .../codestory-indexer/src/structural/html.rs | 26 +- .../codestory-indexer/src/structural/mod.rs | 8 + .../src/template_pipeline.rs | 19 +- crates/codestory-indexer/src/tests/mod.rs | 436 +++++++++-------- .../tests/call_resolution_common_methods.rs | 146 +++--- .../tests/fidelity_regression.rs | 38 +- .../tests/import_resolution.rs | 8 - crates/codestory-indexer/tests/integration.rs | 53 ++- .../tests/native_rule_regressions.rs | 35 +- .../openapi_fixture_collector_failure.rs | 21 +- .../tests/oss_language_corpus.rs | 37 ++ .../tests/proof_resolution.rs | 112 +++-- .../tests/query_rule_regressions.rs | 113 ++--- .../tests/source_file_cap_headroom.rs | 13 +- .../tests/tictactoe_language_coverage.rs | 49 +- 22 files changed, 732 insertions(+), 929 deletions(-) diff --git a/crates/codestory-indexer/src/cache.rs b/crates/codestory-indexer/src/cache.rs index 815e5ea9a..e008b977b 100644 --- a/crates/codestory-indexer/src/cache.rs +++ b/crates/codestory-indexer/src/cache.rs @@ -1226,7 +1226,11 @@ mod tests { root, cache_path, source, &config, None, false, true, ) .expect("cache key"); - let route_language = FRAMEWORK_ROUTE_LANGUAGE_NAMES.contains(&config.language_name); + // Independently enumerated oracle: extensions whose languages carry + // route-declaration rules. Reading FRAMEWORK_ROUTE_LANGUAGE_NAMES + // here would be circular — dropping a language from the shared list + // would change the production mix and this oracle together. + let route_language = !matches!(extension, "c" | "cpp" | "sh"); assert_eq!(current != previous, route_language, "{extension}"); } diff --git a/crates/codestory-indexer/src/framework_routes.rs b/crates/codestory-indexer/src/framework_routes.rs index bdbc197a2..041ccda93 100644 --- a/crates/codestory-indexer/src/framework_routes.rs +++ b/crates/codestory-indexer/src/framework_routes.rs @@ -2069,7 +2069,37 @@ app.head("/string-only", documented); ); assert_eq!((parser_tp, parser_fp, parser_fn), (4, 0, 0)); - assert!(lexical_fp > 0 || lexical_fn > 0); + // Pin the lexical scanner's exact defect sets, not just "some defect": + // a repaired line scanner must be observed changing these rows, and + // skipping the defect branch can no longer satisfy `> 0` vacuously. + let lexical_extra = lexical + .difference(&expected) + .cloned() + .collect::>(); + let lexical_missing = expected + .difference(&lexical) + .cloned() + .collect::>(); + assert_eq!( + lexical_extra, + [ + ("DELETE".to_string(), "/nested-path".to_string()), + ("GET".to_string(), "/prefix".to_string()), + ("GET".to_string(), "/unowned".to_string()), + ("HEAD".to_string(), "/string-only".to_string()), + ] + .into_iter() + .collect::>() + ); + assert_eq!( + lexical_missing, + [ + ("POST".to_string(), "/multiline".to_string()), + ("PUT".to_string(), "/static-template".to_string()), + ] + .into_iter() + .collect::>() + ); assert!(parser_routes.iter().all(|route| { route.extraction_provenance == "tree_sitter_query" && route.claim_tier == "parser_backed" @@ -2522,7 +2552,40 @@ const example = `api.head("/string-only", documented);`; ); assert_eq!((parser_tp, parser_fp, parser_fn), (5, 0, 0)); - assert!(lexical_fp > 0 || lexical_fn > 0); + // Pin the lexical scanner's exact defect sets (see the express row). + let lexical_extra = lexical + .difference(&expected) + .cloned() + .collect::>(); + let lexical_missing = expected + .difference(&lexical) + .cloned() + .collect::>(); + assert_eq!( + lexical_extra, + [ + ("/DYNAMIC-METHOD".to_string(), "/dynamic-method".to_string()), + ("GET".to_string(), "/array-method".to_string()), + ("GET".to_string(), "/duplicate-method".to_string()), + ("GET".to_string(), "/missing-handler".to_string()), + ("GET".to_string(), "/spread".to_string()), + ("GET".to_string(), "/unrelated".to_string()), + ] + .into_iter() + .collect::>() + ); + assert_eq!( + lexical_missing, + [ + ("DELETE".to_string(), "/object".to_string()), + ("GET".to_string(), "/simple".to_string()), + ("PATCH".to_string(), "/wrapped".to_string()), + ("POST".to_string(), "/multiline".to_string()), + ("PUT".to_string(), "/static-template".to_string()), + ] + .into_iter() + .collect::>() + ); assert!(parser_routes.iter().all(|route| { route.extraction_provenance == "tree_sitter_query" && route.claim_tier == "parser_backed" diff --git a/crates/codestory-indexer/src/lib.rs b/crates/codestory-indexer/src/lib.rs index 453c694ab..fb2f2fde7 100644 --- a/crates/codestory-indexer/src/lib.rs +++ b/crates/codestory-indexer/src/lib.rs @@ -17411,6 +17411,9 @@ mod proof_resolution_cache_tests { assert_eq!(file.file_id, new_file); assert_eq!(file.top_level_declarations[0].declaration, new_method); assert_eq!(file.inherent_methods[0].declaration, new_method); + // `owner` is populated on the input but was previously never asserted — + // a rebase that left the stale old owner id would pass everything above. + assert_eq!(file.inherent_methods[0].owner, Some(new_owner)); assert_eq!(file.classes[0].declaration, new_owner); assert_eq!(file.classes[0].methods[0].declaration, new_method); assert_eq!(file.direct_exports[0].declaration, new_owner); diff --git a/crates/codestory-indexer/src/proof_resolution.rs b/crates/codestory-indexer/src/proof_resolution.rs index 771829e18..f5b5d42c9 100644 --- a/crates/codestory-indexer/src/proof_resolution.rs +++ b/crates/codestory-indexer/src/proof_resolution.rs @@ -20833,6 +20833,10 @@ mod ruby_php_complexity_tests { ("ruby", ruby_receiver_work(128), ruby_receiver_work(256)), ("php", php_receiver_work(128), php_receiver_work(256)), ] { + assert!( + small > 0, + "{language} work was not counted; a disabled counter makes the bound vacuous" + ); assert!( large <= small.saturating_mul(2).saturating_add(32), "{language} work grew superlinearly: 1x={small}, 2x={large}" diff --git a/crates/codestory-indexer/src/resolution/mod.rs b/crates/codestory-indexer/src/resolution/mod.rs index 2a2bd1371..162a30925 100644 --- a/crates/codestory-indexer/src/resolution/mod.rs +++ b/crates/codestory-indexer/src/resolution/mod.rs @@ -101,15 +101,6 @@ struct SemanticRequestStats { skipped_requests: usize, } -#[cfg(test)] -#[allow(dead_code)] -#[derive(Default)] -struct ResolutionLookupCache { - same_file_lookup: HashMap<(String, i64, String), Option>, - same_module_lookup: HashMap<(String, String, String), Option>, - global_unique_lookup: HashMap<(String, String), Option>, -} - #[derive(Debug, Clone)] struct CandidateNode { id: i64, @@ -3950,305 +3941,6 @@ fn module_prefix(qualified: &str) -> Option<(String, &'static str)> { None } -#[cfg(test)] -#[allow(dead_code)] -fn find_same_file( - conn: &rusqlite::Connection, - kind_clause: &str, - file_id: Option, - exact: &str, - suffix_dot: &str, - suffix_colon: &str, - lookup_cache: &mut ResolutionLookupCache, -) -> Result> { - let Some(file_id) = file_id else { - return Ok(None); - }; - let cache_key = (kind_clause.to_string(), file_id, exact.to_string()); - if let Some(cached) = lookup_cache.same_file_lookup.get(&cache_key) { - return Ok(*cached); - } - - let exact_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND file_node_id = ?1 - AND serialized_name = ?2 - ORDER BY start_line LIMIT 1", - kind_clause - ); - let resolved = if let Some(id) = conn - .query_row(&exact_query, params![file_id, exact], |row| row.get(0)) - .optional()? - { - Some(id) - } else { - let suffix_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND file_node_id = ?1 - AND (serialized_name LIKE ?2 OR serialized_name LIKE ?3) - ORDER BY start_line LIMIT 1", - kind_clause - ); - conn.query_row( - &suffix_query, - params![file_id, suffix_dot, suffix_colon], - |row| row.get(0), - ) - .optional()? - }; - lookup_cache.same_file_lookup.insert(cache_key, resolved); - Ok(resolved) -} - -#[cfg(test)] -#[allow(dead_code)] -#[allow(clippy::too_many_arguments)] -fn find_same_module( - conn: &rusqlite::Connection, - kind_clause: &str, - module_prefix: &str, - delimiter: &str, - exact: &str, - suffix_dot: &str, - suffix_colon: &str, - lookup_cache: &mut ResolutionLookupCache, -) -> Result> { - let pattern = format!("{}{}%", module_prefix, delimiter); - let cache_key = (kind_clause.to_string(), pattern.clone(), exact.to_string()); - if let Some(cached) = lookup_cache.same_module_lookup.get(&cache_key) { - return Ok(*cached); - } - - let exact_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND qualified_name LIKE ?1 - AND serialized_name = ?2 - ORDER BY start_line LIMIT 1", - kind_clause - ); - let resolved = if let Some(id) = conn - .query_row(&exact_query, params![pattern, exact], |row| row.get(0)) - .optional()? - { - Some(id) - } else { - let suffix_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND qualified_name LIKE ?1 - AND (serialized_name LIKE ?2 OR serialized_name LIKE ?3) - ORDER BY start_line LIMIT 1", - kind_clause - ); - conn.query_row( - &suffix_query, - params![pattern, suffix_dot, suffix_colon], - |row| row.get(0), - ) - .optional()? - }; - lookup_cache.same_module_lookup.insert(cache_key, resolved); - Ok(resolved) -} - -#[cfg(test)] -#[allow(dead_code)] -fn find_global_unique( - conn: &rusqlite::Connection, - kind_clause: &str, - exact: &str, - suffix_dot: &str, - suffix_colon: &str, - lookup_cache: &mut ResolutionLookupCache, -) -> Result> { - let cache_key = (kind_clause.to_string(), exact.to_string()); - if let Some(cached) = lookup_cache.global_unique_lookup.get(&cache_key) { - return Ok(*cached); - } - - let exact_count_query = format!( - "SELECT COUNT(*) FROM node - WHERE kind IN ({}) - AND serialized_name = ?1", - kind_clause - ); - let exact_count: i64 = conn.query_row(&exact_count_query, params![exact], |row| row.get(0))?; - let resolved = if exact_count == 1 { - let exact_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND serialized_name = ?1 - LIMIT 1", - kind_clause - ); - conn.query_row(&exact_query, params![exact], |row| row.get(0)) - .optional()? - } else if exact_count > 1 { - None - } else { - let suffix_count_query = format!( - "SELECT COUNT(*) FROM node - WHERE kind IN ({}) - AND (serialized_name LIKE ?1 OR serialized_name LIKE ?2)", - kind_clause - ); - let suffix_count: i64 = conn.query_row( - &suffix_count_query, - params![suffix_dot, suffix_colon], - |row| row.get(0), - )?; - if suffix_count != 1 { - None - } else { - let suffix_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND (serialized_name LIKE ?1 OR serialized_name LIKE ?2) - LIMIT 1", - kind_clause - ); - conn.query_row(&suffix_query, params![suffix_dot, suffix_colon], |row| { - row.get(0) - }) - .optional()? - } - }; - lookup_cache - .global_unique_lookup - .insert(cache_key, resolved); - Ok(resolved) -} - -#[cfg(test)] -#[allow(dead_code)] -fn find_fuzzy( - conn: &rusqlite::Connection, - kind_clause: &str, - exact: &str, - suffix_dot: &str, - suffix_colon: &str, -) -> Result> { - let exact_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND serialized_name = ?1 - ORDER BY start_line LIMIT 1", - kind_clause - ); - if let Some(id) = conn - .query_row(&exact_query, params![exact], |row| row.get(0)) - .optional()? - { - return Ok(Some(id)); - } - - let suffix_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND (serialized_name LIKE ?1 OR serialized_name LIKE ?2) - ORDER BY start_line LIMIT 1", - kind_clause - ); - if let Some(id) = conn - .query_row(&suffix_query, params![suffix_dot, suffix_colon], |row| { - row.get(0) - }) - .optional()? - { - return Ok(Some(id)); - } - - let fuzzy = format!("%{}%", exact); - let query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND serialized_name LIKE ?1 - ORDER BY start_line LIMIT 1", - kind_clause - ); - conn.query_row(&query, params![fuzzy], |row| row.get(0)) - .optional() - .map_err(Into::into) -} - -#[cfg(test)] -#[allow(dead_code)] -fn collect_candidate_pool( - conn: &rusqlite::Connection, - kind_clause: &str, - names: &[String], - out: &mut Vec, - limit: usize, -) -> Result<()> { - if out.len() >= limit { - return Ok(()); - } - for name in names { - let (exact, suffix_dot, suffix_colon) = name_patterns(name); - let top = find_top_matches(conn, kind_clause, &exact, &suffix_dot, &suffix_colon, 3)?; - for id in top { - record_candidate(out, id); - if out.len() >= limit { - return Ok(()); - } - } - } - Ok(()) -} - -#[cfg(test)] -#[allow(dead_code)] -fn find_top_matches( - conn: &rusqlite::Connection, - kind_clause: &str, - exact: &str, - suffix_dot: &str, - suffix_colon: &str, - limit: usize, -) -> Result> { - let exact_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND serialized_name = ?1 - ORDER BY start_line - LIMIT {}", - kind_clause, limit - ); - let mut out = Vec::with_capacity(limit); - { - let mut stmt = conn.prepare(&exact_query)?; - let rows = stmt.query_map(params![exact], |row| row.get(0))?; - for row in rows { - out.push(row?); - } - } - if out.len() >= limit { - return Ok(out); - } - - let remaining = limit - out.len(); - let suffix_query = format!( - "SELECT id FROM node - WHERE kind IN ({}) - AND (serialized_name LIKE ?1 OR serialized_name LIKE ?2) - ORDER BY start_line - LIMIT {}", - kind_clause, remaining - ); - let mut stmt = conn.prepare(&suffix_query)?; - let rows = stmt.query_map(params![suffix_dot, suffix_colon], |row| row.get(0))?; - for row in rows { - let candidate = row?; - if !out.contains(&candidate) { - out.push(candidate); - } - } - Ok(out) -} - fn candidate_json(candidates: &[i64]) -> Result> { if candidates.is_empty() { return Ok(None); @@ -4256,13 +3948,6 @@ fn candidate_json(candidates: &[i64]) -> Result> { Ok(Some(serde_json::to_string(candidates)?)) } -#[cfg(test)] -fn record_candidate(candidates: &mut Vec, candidate: i64) { - if !candidates.contains(&candidate) { - candidates.push(candidate); - } -} - fn consider_selected(selected: &mut Option<(i64, f32)>, candidate_id: i64, confidence: f32) { let replace = match *selected { Some((_, existing_confidence)) => confidence > existing_confidence, @@ -5041,53 +4726,6 @@ mod tests { Ok(()) } - #[test] - fn test_common_call_certain_semantic_candidate_still_resolves() -> Result<()> { - let conn = Connection::open_in_memory()?; - create_node_table(&conn)?; - let index = CandidateIndex::load(&conn, &[NodeKind::FUNCTION as i32])?; - let flags = ResolutionFlags { - legacy_mode: false, - enable_semantic: true, - store_candidates: false, - parallel_compute: false, - }; - let pass = ResolutionPass { - flags, - policy: ResolutionPolicy::for_flags(flags), - semantic_resolvers: SemanticResolverRegistry::new(true), - go_context: None, - }; - let row = ( - 2_i64, - Some(100_i64), - Some("pkg::core::caller".to_string()), - "caller".to_string(), - "clone".to_string(), - 0, - Some("/repo/lib.rs".to_string()), - Some("1:2:3:4".to_string()), - None, - ); - let semantic_candidates = vec![SemanticResolutionCandidate { - target_node_id: 77_i64, - confidence: ResolutionCertainty::CERTAIN_MIN, - }]; - - let computed = candidate_selection::compute_call_resolution( - &pass, - &index, - &row, - &semantic_candidates, - )?; - assert_eq!( - computed.strategy, - Some(ResolutionStrategy::CallSemanticFallback) - ); - assert_eq!(computed.update.resolved_target_node_id, Some(77_i64)); - Ok(()) - } - #[test] fn test_semantic_language_bucket_matrix() { let expected = [ @@ -5380,55 +5018,6 @@ mod tests { Ok(()) } - #[test] - fn test_exact_lookup_cache_reuses_same_file_key() -> Result<()> { - let conn = Connection::open_in_memory()?; - conn.execute_batch( - "CREATE TABLE node ( - id INTEGER PRIMARY KEY, - kind INTEGER NOT NULL, - serialized_name TEXT NOT NULL, - canonical_id TEXT, - file_node_id INTEGER, - start_line INTEGER NOT NULL DEFAULT 0 - );", - )?; - conn.execute( - "INSERT INTO node (id, kind, serialized_name, file_node_id, start_line) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![77_i64, NodeKind::FUNCTION as i32, "foo", 555_i64, 1_i64], - )?; - - let kind_clause = kind_clause(&[NodeKind::FUNCTION as i32]); - let (exact, suffix_dot, suffix_colon) = name_patterns("foo"); - let mut lookup_cache = ResolutionLookupCache::default(); - - let first = find_same_file( - &conn, - &kind_clause, - Some(555_i64), - &exact, - &suffix_dot, - &suffix_colon, - &mut lookup_cache, - )?; - assert_eq!(first, Some(77_i64)); - assert_eq!(lookup_cache.same_file_lookup.len(), 1); - - let second = find_same_file( - &conn, - &kind_clause, - Some(555_i64), - &exact, - &suffix_dot, - &suffix_colon, - &mut lookup_cache, - )?; - assert_eq!(second, Some(77_i64)); - assert_eq!(lookup_cache.same_file_lookup.len(), 1); - Ok(()) - } - #[test] fn test_candidate_index_same_file_exact_beats_suffix() -> Result<()> { let conn = Connection::open_in_memory()?; @@ -5826,6 +5415,14 @@ mod tests { let ids: Vec = (1..=600_i64).collect(); + let persisted_value = |conn: &Connection| -> Result { + Ok( + conn.query_row("SELECT COALESCE(SUM(value), 0) FROM perf", [], |row| { + row.get(0) + })?, + ) + }; + let no_tx_start = Instant::now(); for id in &ids { conn.execute( @@ -5834,6 +5431,11 @@ mod tests { )?; } let no_tx_elapsed = no_tx_start.elapsed(); + assert_eq!( + persisted_value(&conn)?, + ids.len() as i64, + "autocommit updates must be persisted before the timing is compared" + ); conn.execute("UPDATE perf SET value = 0", [])?; @@ -5846,6 +5448,24 @@ mod tests { Ok(()) })?; let tx_elapsed = tx_start.elapsed(); + assert_eq!( + persisted_value(&conn)?, + ids.len() as i64, + "transaction updates must be committed before the timing is compared" + ); + + // A transaction that aborts must leave prior values untouched; without + // this the smoke test times a block whose writes may never commit. + let aborted = run_in_immediate_transaction(&conn, |tx_conn| -> Result<()> { + tx_conn.execute("UPDATE perf SET value = value + 100", [])?; + Err(anyhow::anyhow!("forced abort")) + }); + assert!(aborted.is_err(), "the injected abort must surface"); + assert_eq!( + persisted_value(&conn)?, + ids.len() as i64, + "an aborted transaction must roll back every staged write" + ); assert!( tx_elapsed < no_tx_elapsed, diff --git a/crates/codestory-indexer/src/structural/blanking.rs b/crates/codestory-indexer/src/structural/blanking.rs index 2f37afcc1..cb8dddb76 100644 --- a/crates/codestory-indexer/src/structural/blanking.rs +++ b/crates/codestory-indexer/src/structural/blanking.rs @@ -143,6 +143,22 @@ mod tests { assert!(blanked.contains("let x = 1;")); assert!(blanked.as_bytes()[0] == b' '); assert!(blanked.as_bytes()[4] == b' '); + // The line boundaries themselves must survive: blanking newline or CR + // bytes to spaces would keep length and still pass the above. + let source_line_bytes = source + .bytes() + .enumerate() + .filter_map(|(index, byte)| (byte == b'\n' || byte == b'\r').then_some(index)) + .collect::>(); + let blanked_line_bytes = blanked + .bytes() + .enumerate() + .filter_map(|(index, byte)| (byte == b'\n' || byte == b'\r').then_some(index)) + .collect::>(); + assert_eq!( + blanked_line_bytes, source_line_bytes, + "blanking must preserve every line-boundary byte position" + ); } #[test] diff --git a/crates/codestory-indexer/src/structural/css.rs b/crates/codestory-indexer/src/structural/css.rs index 4ec0bedf2..f02d4332c 100644 --- a/crates/codestory-indexer/src/structural/css.rs +++ b/crates/codestory-indexer/src/structural/css.rs @@ -927,6 +927,15 @@ mod tests { assert!(kinds.contains(&NodeKind::CONSTANT)); assert!(kinds.contains(&NodeKind::VARIABLE)); assert!(storage.edges.iter().any(|e| e.kind == EdgeKind::MEMBER)); + // `.btn` alone supplies a CONSTANT; pin each named selector family so + // dropping id collection cannot hide behind the class node. + for canonical in ["css:class:btn", "css:id:app", "css:var:--primary"] { + assert!( + find_node(&storage, canonical).is_some(), + "expected `{canonical}` to be minted: {:?}", + storage.nodes + ); + } } #[test] diff --git a/crates/codestory-indexer/src/structural/html.rs b/crates/codestory-indexer/src/structural/html.rs index 1323dcba2..60fd15822 100644 --- a/crates/codestory-indexer/src/structural/html.rs +++ b/crates/codestory-indexer/src/structural/html.rs @@ -422,11 +422,31 @@ mod tests { .any(|n| n.canonical_id.as_deref() == Some("html:id:app")) ); assert!(storage.edges.iter().any(|e| e.kind == EdgeKind::USAGE)); + // `css:class:layout` is minted twice through different paths: the class + // attribute's USAGE linkage and the embedded `