Skip to content

Commit 9bcdb6a

Browse files
test(hosts): prove OpenCode keeps one analyzer through the lifecycle
Plans 27/35 require proof that exactly one analyzer owns a language before, during, and after install, repair, rollback, and uninstall with a real pre-existing analyzer, while TraceDecay findings still project. The broker enforcement landed with host analyzer ownership; this is the conformance journey over it. The journey seeds the host's own configuration with a pre-existing rust-analyzer for `.rs`, drives the real CLI lifecycle in an isolated HOME (install, reinstall, a mutation killed mid-write recovered by host-bundle recover, uninstall), and at every stage proves single ownership through both consumption paths: the installer's duplicateAnalyzerAvoidance + retained-ownership registration, and the broker keeping the language admitted for projection while refusing mount, refresh, and semantic spawn with a typed reason naming the retaining analyzer. A second test proves the broker consumes the project-level registration at construction, without daemon adoption. Also lands the test target the committed Cargo.toml already declares (the [[test]] entry rode an earlier drift sweep).
1 parent 9d248f7 commit 9bcdb6a

1 file changed

Lines changed: 393 additions & 0 deletions

File tree

Lines changed: 393 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,393 @@
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

Comments
 (0)