|
| 1 | +//! OpenCode one-analyzer conformance journey (Plans 27/35). |
| 2 | +//! |
| 3 | +//! Plan 27 requires that OpenCode conformance "starts the TraceDecay custom |
| 4 | +//! LSP with an existing language analyzer present and proves exactly one |
| 5 | +//! analyzer owns that language before, during, and after install, repair, |
| 6 | +//! rollback, and uninstall while TraceDecay findings still project"; Plan 35 |
| 7 | +//! repeats the same exactly-one-analyzer-per-language mandate. |
| 8 | +//! |
| 9 | +//! The journey drives the real CLI lifecycle (`install`, `reinstall`, a |
| 10 | +//! killed mutation recovered by `host-bundle recover`, `uninstall`) against an |
| 11 | +//! isolated home whose OpenCode configuration already declares a real |
| 12 | +//! pre-existing analyzer (`rust-analyzer` for `.rs`), and at every stage |
| 13 | +//! proves single ownership through both consumption paths: |
| 14 | +//! |
| 15 | +//! * the registration the installer writes (`duplicateAnalyzerAvoidance` + |
| 16 | +//! `analyzerOwnership.retainedByExtension`), and |
| 17 | +//! * the analyzer broker, which must keep the language admitted so |
| 18 | +//! graph-backed TraceDecay findings still project while refusing to mount |
| 19 | +//! or refresh a second analyzer process for it. |
| 20 | +
|
| 21 | +use std::fs; |
| 22 | +use std::path::PathBuf; |
| 23 | +use std::process::{Command, Output, Stdio}; |
| 24 | + |
| 25 | +use tempfile::TempDir; |
| 26 | +use tracedecay_lsp::analyzer::adapters::{DiagnosticMode, LspAdapterDefinition}; |
| 27 | +use tracedecay_lsp::analyzer::broker::{DiagnosticBroker, EngineState}; |
| 28 | +use tracedecay_lsp::analyzer::host_ownership::HostAnalyzerOwnership; |
| 29 | + |
| 30 | +const HOST_CONFIG_RELATIVE: &str = ".config/opencode/opencode.json"; |
| 31 | +const PRE_EXISTING_ANALYZER: &str = "rust-analyzer"; |
| 32 | + |
| 33 | +struct IsolatedCli { |
| 34 | + home: TempDir, |
| 35 | + project: TempDir, |
| 36 | + profile: PathBuf, |
| 37 | + bin_dir: PathBuf, |
| 38 | +} |
| 39 | + |
| 40 | +impl IsolatedCli { |
| 41 | + fn new() -> Self { |
| 42 | + let home = TempDir::new().unwrap(); |
| 43 | + let project = TempDir::new().unwrap(); |
| 44 | + let profile = home.path().join(".tracedecay-test-profile"); |
| 45 | + let bin_dir = home.path().join("bin"); |
| 46 | + fs::create_dir_all(&bin_dir).unwrap(); |
| 47 | + let shim = bin_dir.join(if cfg!(windows) { |
| 48 | + "tracedecay.exe" |
| 49 | + } else { |
| 50 | + "tracedecay" |
| 51 | + }); |
| 52 | + if fs::hard_link(env!("CARGO_BIN_EXE_tracedecay"), &shim).is_err() { |
| 53 | + fs::copy(env!("CARGO_BIN_EXE_tracedecay"), &shim).unwrap(); |
| 54 | + } |
| 55 | + #[cfg(unix)] |
| 56 | + { |
| 57 | + use std::os::unix::fs::PermissionsExt; |
| 58 | + |
| 59 | + let mut permissions = fs::metadata(&shim).unwrap().permissions(); |
| 60 | + permissions.set_mode(0o755); |
| 61 | + fs::set_permissions(&shim, permissions).unwrap(); |
| 62 | + } |
| 63 | + Self { |
| 64 | + home, |
| 65 | + project, |
| 66 | + profile, |
| 67 | + bin_dir, |
| 68 | + } |
| 69 | + } |
| 70 | + |
| 71 | + fn command(&self, args: &[&str]) -> Command { |
| 72 | + let mut command = Command::new(env!("CARGO_BIN_EXE_tracedecay")); |
| 73 | + let inherited_path = std::env::var_os("PATH").unwrap_or_default(); |
| 74 | + let path = std::env::join_paths( |
| 75 | + std::iter::once(self.bin_dir.clone()).chain(std::env::split_paths(&inherited_path)), |
| 76 | + ) |
| 77 | + .unwrap(); |
| 78 | + command |
| 79 | + .args(args) |
| 80 | + .current_dir(self.project.path()) |
| 81 | + .env("HOME", self.home.path()) |
| 82 | + .env("USERPROFILE", self.home.path()) |
| 83 | + .env("XDG_CONFIG_HOME", self.home.path().join(".config")) |
| 84 | + .env("TRACEDECAY_DATA_DIR", &self.profile) |
| 85 | + .env("TRACEDECAY_GLOBAL_DB", self.profile.join("global.db")) |
| 86 | + .env("PATH", path) |
| 87 | + .stdin(Stdio::null()) |
| 88 | + .stdout(Stdio::piped()) |
| 89 | + .stderr(Stdio::piped()); |
| 90 | + command |
| 91 | + } |
| 92 | + |
| 93 | + fn run(&self, args: &[&str]) -> Output { |
| 94 | + self.command(args).output().unwrap() |
| 95 | + } |
| 96 | + |
| 97 | + fn run_with_env(&self, args: &[&str], key: &str, value: &str) -> Output { |
| 98 | + let mut command = self.command(args); |
| 99 | + command.env(key, value); |
| 100 | + command.output().unwrap() |
| 101 | + } |
| 102 | + |
| 103 | + fn host_config_path(&self) -> PathBuf { |
| 104 | + self.home.path().join(HOST_CONFIG_RELATIVE) |
| 105 | + } |
| 106 | + |
| 107 | + fn host_config(&self) -> serde_json::Value { |
| 108 | + serde_json::from_slice(&fs::read(self.host_config_path()).unwrap()).unwrap() |
| 109 | + } |
| 110 | +} |
| 111 | + |
| 112 | +fn assert_success(phase: &str, output: Output) { |
| 113 | + assert!( |
| 114 | + output.status.success(), |
| 115 | + "opencode {phase} failed\nstdout:\n{}\nstderr:\n{}", |
| 116 | + String::from_utf8_lossy(&output.stdout), |
| 117 | + String::from_utf8_lossy(&output.stderr) |
| 118 | + ); |
| 119 | +} |
| 120 | + |
| 121 | +/// Seeds the host-owned configuration with a real pre-existing analyzer entry |
| 122 | +/// covering `.rs`, exactly as an operator's OpenCode install would carry it. |
| 123 | +fn seed_pre_existing_analyzer(cli: &IsolatedCli) { |
| 124 | + let config = serde_json::json!({ |
| 125 | + "$schema": "https://opencode.ai/config.json", |
| 126 | + "lsp": { |
| 127 | + PRE_EXISTING_ANALYZER: { |
| 128 | + "command": [PRE_EXISTING_ANALYZER], |
| 129 | + "extensions": [".rs"] |
| 130 | + } |
| 131 | + }, |
| 132 | + "theme": "dark" |
| 133 | + }); |
| 134 | + let path = cli.host_config_path(); |
| 135 | + fs::create_dir_all(path.parent().unwrap()).unwrap(); |
| 136 | + fs::write(&path, serde_json::to_vec_pretty(&config).unwrap()).unwrap(); |
| 137 | +} |
| 138 | + |
| 139 | +fn rust_adapter() -> LspAdapterDefinition { |
| 140 | + LspAdapterDefinition { |
| 141 | + language: "rust".to_string(), |
| 142 | + language_id: "rust".to_string(), |
| 143 | + // Deliberately resolvable on every runner: if enforcement regressed, |
| 144 | + // the adapter would report as mountable and the assertions below fail |
| 145 | + // rather than vacuously passing on a missing binary. |
| 146 | + command: "true".to_string(), |
| 147 | + args: Vec::new(), |
| 148 | + extensions: vec!["rs".to_string()], |
| 149 | + // Marker discovery is not what this journey proves; an empty set |
| 150 | + // anchors the adapter workspace at the project root. |
| 151 | + root_markers: Vec::new(), |
| 152 | + install_options: Vec::new(), |
| 153 | + diagnostics: DiagnosticMode::PushAndPull, |
| 154 | + } |
| 155 | +} |
| 156 | + |
| 157 | +/// Analyzer entries in the host config that cover `.rs`, excluding the |
| 158 | +/// TraceDecay bridge registration itself. |
| 159 | +/// |
| 160 | +/// The bridge lists `.rs` among its extensions but is registered |
| 161 | +/// projection-only: `duplicateAnalyzerAvoidance` plus the retained-ownership |
| 162 | +/// map, with the broker refusing every spawn path for a retained language |
| 163 | +/// (proven by [`assert_broker_enforces_single_ownership`]). "Exactly one |
| 164 | +/// analyzer" is therefore about entries that would run a competing analyzer. |
| 165 | +fn rs_analyzer_entries(config: &serde_json::Value) -> Vec<String> { |
| 166 | + config |
| 167 | + .get("lsp") |
| 168 | + .and_then(serde_json::Value::as_object) |
| 169 | + .into_iter() |
| 170 | + .flat_map(|servers| servers.iter()) |
| 171 | + .filter(|(name, registration)| { |
| 172 | + name.as_str() != "tracedecay" |
| 173 | + && registration |
| 174 | + .get("extensions") |
| 175 | + .and_then(serde_json::Value::as_array) |
| 176 | + .into_iter() |
| 177 | + .flatten() |
| 178 | + .filter_map(serde_json::Value::as_str) |
| 179 | + .any(|extension| extension == ".rs") |
| 180 | + }) |
| 181 | + .map(|(name, _)| name.clone()) |
| 182 | + .collect() |
| 183 | +} |
| 184 | + |
| 185 | +/// Proves the broker keeps the language admitted (findings still project) |
| 186 | +/// while refusing to mount, refresh, or semantically start a second analyzer. |
| 187 | +fn assert_broker_enforces_single_ownership(cli: &IsolatedCli, ownership: HostAnalyzerOwnership) { |
| 188 | + assert!( |
| 189 | + ownership.is_engaged(), |
| 190 | + "the installed registration must engage duplicate-analyzer avoidance" |
| 191 | + ); |
| 192 | + fs::create_dir_all(cli.project.path().join("src")).unwrap(); |
| 193 | + fs::write(cli.project.path().join("src/main.rs"), "fn main() {}\n").unwrap(); |
| 194 | + |
| 195 | + let mut broker = DiagnosticBroker::new_for_test(cli.project.path(), vec![rust_adapter()]); |
| 196 | + broker.adopt_host_analyzer_ownership(ownership); |
| 197 | + |
| 198 | + assert_eq!( |
| 199 | + broker.host_retained_analyzer("rust"), |
| 200 | + Some(PRE_EXISTING_ANALYZER), |
| 201 | + "the host-declared analyzer must be the retained owner" |
| 202 | + ); |
| 203 | + |
| 204 | + let admitted = broker.admitted_providers_for_files(&["src/main.rs".to_string()]); |
| 205 | + let rust = admitted |
| 206 | + .iter() |
| 207 | + .find(|provider| provider.language == "rust") |
| 208 | + .expect("host-retained language must stay admitted so findings still project"); |
| 209 | + assert!( |
| 210 | + !rust.analyzer_available, |
| 211 | + "a host-retained language must never be reported mountable" |
| 212 | + ); |
| 213 | + assert!( |
| 214 | + broker |
| 215 | + .mounted_providers_for_files(&["src/main.rs".to_string()]) |
| 216 | + .is_empty(), |
| 217 | + "mounting is what would start the second analyzer" |
| 218 | + ); |
| 219 | + |
| 220 | + let prepared = broker |
| 221 | + .prepare_refresh("rust", Vec::new()) |
| 222 | + .expect("refusal must be a typed state, not an error"); |
| 223 | + assert!( |
| 224 | + prepared.is_none(), |
| 225 | + "prepare_refresh is the only spawn path; it must refuse for a host-retained language" |
| 226 | + ); |
| 227 | + let status = broker |
| 228 | + .project_engine_statuses() |
| 229 | + .into_iter() |
| 230 | + .find(|status| status.language == "rust") |
| 231 | + .expect("rust engine status"); |
| 232 | + assert_eq!(status.state, EngineState::Disabled); |
| 233 | + let reason = status |
| 234 | + .last_error |
| 235 | + .expect("the refusal must carry an operator-facing reason"); |
| 236 | + assert!( |
| 237 | + reason.contains(PRE_EXISTING_ANALYZER), |
| 238 | + "the reason must name the retaining analyzer: {reason}" |
| 239 | + ); |
| 240 | +} |
| 241 | + |
| 242 | +/// The registration the installer writes must retain the pre-existing analyzer. |
| 243 | +fn assert_registration_retains_host_analyzer(config: &serde_json::Value) { |
| 244 | + let initialization = config |
| 245 | + .pointer("/lsp/tracedecay/initialization/tracedecay") |
| 246 | + .expect("installed registration must carry the tracedecay initialization block"); |
| 247 | + assert_eq!( |
| 248 | + initialization.get("duplicateAnalyzerAvoidance"), |
| 249 | + Some(&serde_json::Value::Bool(true)) |
| 250 | + ); |
| 251 | + let retained = initialization |
| 252 | + .pointer("/analyzerOwnership/retainedByExtension/.rs") |
| 253 | + .and_then(serde_json::Value::as_array) |
| 254 | + .expect("the pre-existing .rs analyzer must be recorded as retained"); |
| 255 | + assert_eq!( |
| 256 | + retained |
| 257 | + .iter() |
| 258 | + .filter_map(serde_json::Value::as_str) |
| 259 | + .collect::<Vec<_>>(), |
| 260 | + vec![PRE_EXISTING_ANALYZER] |
| 261 | + ); |
| 262 | + // Exactly one analyzer owns `.rs`: the host's. The TraceDecay entry is |
| 263 | + // registered projection-only for it and its ownership block says so. |
| 264 | + assert_eq!( |
| 265 | + initialization.pointer("/analyzerOwnership/mode"), |
| 266 | + Some(&serde_json::json!("projection_only")) |
| 267 | + ); |
| 268 | +} |
| 269 | + |
| 270 | +#[test] |
| 271 | +fn opencode_keeps_exactly_one_analyzer_through_install_repair_rollback_uninstall() { |
| 272 | + let cli = IsolatedCli::new(); |
| 273 | + seed_pre_existing_analyzer(&cli); |
| 274 | + |
| 275 | + // BEFORE INSTALL: only the host's analyzer covers `.rs`, and with no |
| 276 | + // TraceDecay registration present no ownership claim is engaged. |
| 277 | + let before = cli.host_config(); |
| 278 | + assert_eq!(rs_analyzer_entries(&before), vec![PRE_EXISTING_ANALYZER]); |
| 279 | + assert!(before.pointer("/lsp/tracedecay").is_none()); |
| 280 | + assert!(!HostAnalyzerOwnership::from_opencode_config(&before).is_engaged()); |
| 281 | + |
| 282 | + // INSTALL: the registration lands projection-only and retains the host's |
| 283 | + // analyzer; the broker refuses to become a second one. |
| 284 | + assert_success("install", cli.run(&["install", "--agent", "opencode"])); |
| 285 | + let installed = cli.host_config(); |
| 286 | + assert_registration_retains_host_analyzer(&installed); |
| 287 | + assert_eq!( |
| 288 | + rs_analyzer_entries(&installed), |
| 289 | + vec![PRE_EXISTING_ANALYZER], |
| 290 | + "install must not register TraceDecay as an `.rs` analyzer owner" |
| 291 | + ); |
| 292 | + assert_broker_enforces_single_ownership( |
| 293 | + &cli, |
| 294 | + HostAnalyzerOwnership::from_opencode_config(&installed), |
| 295 | + ); |
| 296 | + |
| 297 | + // REPAIR: reinstall refreshes managed artifacts; ownership must survive. |
| 298 | + assert_success("repair", cli.run(&["reinstall"])); |
| 299 | + let repaired = cli.host_config(); |
| 300 | + assert_registration_retains_host_analyzer(&repaired); |
| 301 | + assert_eq!(rs_analyzer_entries(&repaired), vec![PRE_EXISTING_ANALYZER]); |
| 302 | + assert_broker_enforces_single_ownership( |
| 303 | + &cli, |
| 304 | + HostAnalyzerOwnership::from_opencode_config(&repaired), |
| 305 | + ); |
| 306 | + |
| 307 | + // ROLLBACK: a mutation killed mid-write is rolled back by recovery to the |
| 308 | + // exact pre-effect state, which must still hold single ownership. |
| 309 | + let pre_fault = fs::read(cli.host_config_path()).unwrap(); |
| 310 | + let killed = cli.run_with_env( |
| 311 | + &["reinstall"], |
| 312 | + "TRACEDECAY_TEST_ABORT_AFTER_HOST_CONFIG_WRITE", |
| 313 | + "1", |
| 314 | + ); |
| 315 | + assert!(!killed.status.success(), "fault subprocess did not abort"); |
| 316 | + assert_success( |
| 317 | + "rollback recovery", |
| 318 | + cli.run(&["host-bundle", "recover", "--agent", "opencode", "--yes"]), |
| 319 | + ); |
| 320 | + assert_eq!( |
| 321 | + fs::read(cli.host_config_path()).unwrap(), |
| 322 | + pre_fault, |
| 323 | + "recovery must restore the exact pre-effect registration" |
| 324 | + ); |
| 325 | + let recovered = cli.host_config(); |
| 326 | + assert_registration_retains_host_analyzer(&recovered); |
| 327 | + assert_broker_enforces_single_ownership( |
| 328 | + &cli, |
| 329 | + HostAnalyzerOwnership::from_opencode_config(&recovered), |
| 330 | + ); |
| 331 | + |
| 332 | + // UNINSTALL: the TraceDecay registration leaves; the host analyzer stays |
| 333 | + // the sole owner and no ownership claim survives. |
| 334 | + assert_success("uninstall", cli.run(&["uninstall", "--agent", "opencode"])); |
| 335 | + let uninstalled = cli.host_config(); |
| 336 | + assert!(uninstalled.pointer("/lsp/tracedecay").is_none()); |
| 337 | + assert_eq!(rs_analyzer_entries(&uninstalled), vec![PRE_EXISTING_ANALYZER]); |
| 338 | + assert!(!HostAnalyzerOwnership::from_opencode_config(&uninstalled).is_engaged()); |
| 339 | +} |
| 340 | + |
| 341 | +/// The broker's own construction path must read the project-level OpenCode |
| 342 | +/// configuration without any daemon adoption call, and adopting ownership |
| 343 | +/// mid-session must tear down what construction could not have prevented. |
| 344 | +#[test] |
| 345 | +fn project_level_registration_engages_ownership_at_broker_construction() { |
| 346 | + let project = TempDir::new().unwrap(); |
| 347 | + fs::create_dir_all(project.path().join("src")).unwrap(); |
| 348 | + fs::write(project.path().join("src/main.rs"), "fn main() {}\n").unwrap(); |
| 349 | + let config = serde_json::json!({ |
| 350 | + "lsp": { |
| 351 | + PRE_EXISTING_ANALYZER: { |
| 352 | + "command": [PRE_EXISTING_ANALYZER], |
| 353 | + "extensions": [".rs"] |
| 354 | + }, |
| 355 | + "tracedecay": { |
| 356 | + "command": ["tracedecay", "lsp", "bridge", "--stdio"], |
| 357 | + "initialization": { |
| 358 | + "tracedecay": { |
| 359 | + "brokerUpstream": false, |
| 360 | + "duplicateAnalyzerAvoidance": true, |
| 361 | + "analyzerOwnership": { |
| 362 | + "mode": "projection_only", |
| 363 | + "retainedByExtension": { ".rs": [PRE_EXISTING_ANALYZER] } |
| 364 | + } |
| 365 | + } |
| 366 | + } |
| 367 | + } |
| 368 | + } |
| 369 | + }); |
| 370 | + fs::write( |
| 371 | + project.path().join("opencode.json"), |
| 372 | + serde_json::to_vec_pretty(&config).unwrap(), |
| 373 | + ) |
| 374 | + .unwrap(); |
| 375 | + |
| 376 | + let mut broker = DiagnosticBroker::new_for_test(project.path(), vec![rust_adapter()]); |
| 377 | + |
| 378 | + assert_eq!( |
| 379 | + broker.host_retained_analyzer("rust"), |
| 380 | + Some(PRE_EXISTING_ANALYZER), |
| 381 | + "construction must consume the project-level registration directly" |
| 382 | + ); |
| 383 | + let admitted = broker.admitted_providers_for_files(&["src/main.rs".to_string()]); |
| 384 | + let rust = admitted |
| 385 | + .iter() |
| 386 | + .find(|provider| provider.language == "rust") |
| 387 | + .expect("retained language stays admitted for projection"); |
| 388 | + assert!(!rust.analyzer_available); |
| 389 | + let prepared = broker |
| 390 | + .prepare_refresh("rust", Vec::new()) |
| 391 | + .expect("refusal must be typed"); |
| 392 | + assert!(prepared.is_none()); |
| 393 | +} |
0 commit comments