diff --git a/CLAUDE.md b/CLAUDE.md index 3a5c481..b7f1a6c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,7 @@ These were all learned by something going wrong. They are recorded in second, a policy that breaks everything looks like one that works. 5. **The instrument includes the harness.** Before believing something Thalyx claims is false, rule out that the thing that asked got it wrong. This has - now happened eighteen times: `curl -s`, bpffs permissions, a `pipefail` + now happened twenty times: `curl -s`, bpffs permissions, a `pipefail` pipeline, an unprepared cgroup arena, a test that inferred its own precondition, a stale local `main` read as the state of the repository, a test suite that raced with itself for an executable it had just written, and @@ -176,7 +176,16 @@ These were all learned by something going wrong. They are recorded in that had moved to the legacy surface; an unknown tool is refused before the wire, so the run made zero requests, wrote a metrics file of zeroes, and the stage reported NOT PROVEN with no number in it — a measurement that stopped - measuring looks exactly like a machine that could not be measured. The stale + measuring looks exactly like a machine that could not be measured. The + nineteenth and twentieth are 2026-08-31 and are the same instrument twice: + `dev/verify-agent-rust.sh` printed `PROVEN context('LanternRegistry') came + from rust-analyzer` about `{source: "rust-analyzer", resolution: "nothing", + entries: []}`, because `source` says **who answered** and has never said + **that the answer resolved anything**; and the control written to + demonstrate that very defect resolved the symbol anyway, because + rust-analyzer looks for `cargo` in `$CARGO`, `PATH` and `$CARGO_HOME/bin`, + the control closed one of the three, and a rustup machine has the third one + full — so it modelled a developer's laptop and not the guest. The stale `main` is the cheapest of them, and it came back on 2026-08-26 because the rule was written short: `main` and `origin/main` are different questions, **and `origin/main` is only a diff --git a/crates/thalyx-rust/src/analyzer.rs b/crates/thalyx-rust/src/analyzer.rs index 9662476..779800b 100644 --- a/crates/thalyx-rust/src/analyzer.rs +++ b/crates/thalyx-rust/src/analyzer.rs @@ -517,7 +517,20 @@ impl Analyzer { "window": {"workDoneProgress": true}, "workspace": {"workspaceEdit": {"documentChanges": true}}, "textDocument": { - "documentSymbol": {"hierarchicalDocumentSymbolSupport": false}, + // `true`, and the difference is a position rather + // than a shape. Flat `SymbolInformation` carries one + // range per entry and rust-analyzer fills it with the + // **whole item**, doc comment included — so the + // outline of `dev/rust-corpus` placed + // `LanternRegistry` at 3:1, where the `///` starts, + // and a caller that renamed at the place the outline + // gave it was pointing at a comment. The hierarchical + // answer carries `selectionRange`, which is the + // identifier: 8:12, the place it really is. Captured + // in `tests/samples/document-symbol-hierarchical.json` + // — rule 6, because the first version of this was + // written against a fixture somebody invented. + "documentSymbol": {"hierarchicalDocumentSymbolSupport": true}, "rename": {"prepareSupport": true} } } @@ -989,28 +1002,57 @@ fn spot_of(path: &Path, range: Option<&Value>) -> Option { }) } +/// Both shapes the protocol allows for a list of symbols, flattened. +/// +/// `workspace/symbol` answers with flat `SymbolInformation`, which carries a +/// `location` and a `containerName`. `textDocument/documentSymbol` answers with +/// a **tree** of `DocumentSymbol`, which carries neither and instead nests its +/// members under `children`. One reader for both, because a caller asking what +/// is declared in a file and a caller asking who is called `Config` are asking +/// the same question of the same server and must not get two different notions +/// of where a symbol is. +/// +/// ## Which range, and the defect that decided it +/// +/// `selectionRange` before `range`, and that order is the whole fix. A +/// `DocumentSymbol`'s `range` is the item — for a documented struct it starts +/// at the first `///`, which is how a booted Thalyx reported `LanternRegistry` +/// at 3:1 for a struct whose name is at 8:12. `selectionRange` is the +/// identifier. Flat entries have no `selectionRange` at all and their +/// `location.range` already is the identifier, so they are read first and are +/// unaffected. fn symbols(answer: &Value, file: Option<&Path>) -> Vec { let Value::Array(listed) = answer else { return Vec::new(); }; let mut found = Vec::new(); for entry in listed { - let Some(name) = entry.get("name").and_then(Value::as_str) else { - continue; - }; - let path = entry - .pointer("/location/uri") - .and_then(Value::as_str) - .and_then(path_of) - .or_else(|| file.map(Path::to_path_buf)); - let Some(path) = path else { continue }; - let range = entry - .pointer("/location/range") - .or_else(|| entry.get("selectionRange")) - .or_else(|| entry.get("range")); - let Some(at) = spot_of(&path, range) else { - continue; - }; + gather(entry, file, None, &mut found); + } + found +} + +/// One entry and everything nested under it. +/// +/// `inside` is the name of the item this was found in, used only for the +/// hierarchical shape: a flat entry brings its own `containerName` and a tree +/// entry has none, so without this a method would come back with no idea which +/// `impl` it belongs to and an outline of a file would list six `new`s. +fn gather(entry: &Value, file: Option<&Path>, inside: Option<&str>, found: &mut Vec) { + let Some(name) = entry.get("name").and_then(Value::as_str) else { + return; + }; + let path = entry + .pointer("/location/uri") + .and_then(Value::as_str) + .and_then(path_of) + .or_else(|| file.map(Path::to_path_buf)); + let Some(path) = path else { return }; + let range = entry + .pointer("/location/range") + .or_else(|| entry.get("selectionRange")) + .or_else(|| entry.get("range")); + if let Some(at) = spot_of(&path, range) { found.push(Symbol { name: name.to_string(), kind: kind_of(entry.get("kind").and_then(Value::as_u64).unwrap_or(0)), @@ -1019,10 +1061,20 @@ fn symbols(answer: &Value, file: Option<&Path>) -> Vec { .get("containerName") .and_then(Value::as_str) .filter(|container| !container.is_empty()) - .map(str::to_string), + .map(str::to_string) + .or_else(|| inside.map(str::to_string)), }); } - found + // Descended even when this entry had no usable range: a member whose + // parent the reader could not place is still a member somebody asked for. + for child in entry + .get("children") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + gather(child, file, Some(name), found); + } } /// The LSP `SymbolKind` numbers, spelled. @@ -1103,3 +1155,88 @@ pub fn why_no_analyzer() -> String { THALYX_RUST_ANALYZER", ) } + +#[cfg(test)] +mod tests { + use super::*; + + /// rust-analyzer's own answer for `dev/rust-corpus/lantern/src/lib.rs`, + /// captured verbatim on 2026-08-31. + /// + /// Rule 6, and it is the rule this defect was found under: the reader was + /// written against nothing, the outline reported `LanternRegistry` at 3:1, + /// and the sentence «the outline is probably taking the wrong range» could + /// not be settled by anything in the repository. A file the server wrote + /// settles it. + const DOCUMENT_SYMBOL: &str = + include_str!("../tests/samples/document-symbol-hierarchical.json"); + + #[test] + fn an_outline_places_a_documented_struct_at_its_name_and_not_at_its_comment() { + let answer: Value = serde_json::from_str(DOCUMENT_SYMBOL).expect("the captured answer"); + let file = Path::new("/w/lantern/src/lib.rs"); + let found = symbols(&answer, Some(file)); + + let registry = found + .iter() + .find(|symbol| symbol.name == "LanternRegistry") + .expect("the struct the corpus is about"); + // Zero-based, as the wire is. The item's own range starts at line 2, + // which is the first `///` — that is the position a booted Thalyx + // reported as 3:1 and then could not rename at. + assert_eq!( + (registry.at.line, registry.at.character), + (7, 11), + "the outline is pointing at the doc comment again: {registry:?}" + ); + assert_eq!(registry.kind, "struct"); + assert_eq!(registry.at.path, file); + } + + #[test] + fn the_members_nested_under_an_item_are_in_the_outline_too() { + // The hierarchical answer is a tree. A reader that took only its top + // level would have traded a wrong position for a missing half of the + // file — every method, every field, gone, and nothing would have said + // so because an outline with fewer entries still looks like an + // outline. + let answer: Value = serde_json::from_str(DOCUMENT_SYMBOL).expect("the captured answer"); + let found = symbols(&answer, Some(Path::new("/w/lantern/src/lib.rs"))); + let named: Vec<&str> = found.iter().map(|symbol| symbol.name.as_str()).collect(); + for member in ["lit", "new", "light", "default"] { + assert!(named.contains(&member), "{member} is not in {named:?}"); + } + let light = found + .iter() + .find(|symbol| symbol.name == "light") + .expect("a method"); + assert_eq!( + light.container.as_deref(), + Some("impl LanternRegistry"), + "a method came back without the item it belongs to: {light:?}" + ); + } + + #[test] + fn a_flat_answer_still_reads_out_of_its_location() { + // `workspace/symbol` answers in the other shape, and it is the shape + // every resolution goes through. A reader that started preferring + // `selectionRange` and stopped reading `location` would have moved the + // defect rather than fixed it. + let answer = json!([{ + "name": "LanternRegistry", + "kind": 23, + "containerName": "lantern", + "location": { + "uri": "file:///w/lantern/src/lib.rs", + "range": {"start": {"line": 7, "character": 11}, + "end": {"line": 7, "character": 26}} + } + }]); + let found = symbols(&answer, None); + assert_eq!(found.len(), 1); + assert_eq!((found[0].at.line, found[0].at.character), (7, 11)); + assert_eq!(found[0].container.as_deref(), Some("lantern")); + assert_eq!(found[0].at.path, Path::new("/w/lantern/src/lib.rs")); + } +} diff --git a/crates/thalyx-rust/src/toolchain.rs b/crates/thalyx-rust/src/toolchain.rs index 12e6597..108f7c0 100644 --- a/crates/thalyx-rust/src/toolchain.rs +++ b/crates/thalyx-rust/src/toolchain.rs @@ -410,19 +410,74 @@ pub fn cargo_command() -> PathBuf { /// directory outright makes the toolchain independent of whether whoever /// starts it remembered `/proc`. It reaches nothing new: the directory is /// inside the artifact [`readable`] already grants. +/// +/// ## `PATH`, which is the one that had never been here +/// +/// Thalyx finds its tools by absolute path, so nothing *Thalyx* runs needs a +/// `PATH`. **rust-analyzer is not the thing Thalyx runs — it is a thing that +/// runs things.** It shells out to `cargo metadata`, `cargo locate-project`, +/// `cargo --version` and `rustc --print cfg`, and it spells every one of them +/// as a bare program name, which the kernel resolves through `PATH` and +/// nothing else. +/// +/// Measured on 2026-08-31 against `dev/rust-corpus`, with an environment +/// holding exactly the three variables above and no more. rust-analyzer +/// started, parsed the file, and answered `textDocument/documentSymbol` with +/// every declaration in it — and then: +/// +/// ```text +/// ERROR FetchWorkspaceError: rust-analyzer failed to load workspace: +/// Failed to run `cargo metadata …`: No such file or directory (os error 2) +/// WARN failed to get rustc cfgs e=unable to fetch cfgs via `… "rustc" …` +/// Caused by: No such file or directory (os error 2) +/// ``` +/// +/// With no crate graph, `workspace/symbol` answered `[]`, +/// `textDocument/definition` answered `[]`, and `textDocument/rename` at the +/// identifier's own physical position answered +/// `No references found at position` — which is exactly what a booted Thalyx +/// had just reported about a struct its own outline could see. Syntax worked +/// and semantics did not, because syntax needs no subprocess. +/// +/// Adding `PATH=/bin` and nothing else turned all four answers +/// correct in the same fixture. `CARGO`, `RUSTC` and `RUST_SRC_PATH` are +/// **not** set: `PATH` alone was measured to be sufficient, and a variable +/// added because Rust usually has it is a variable nobody can remove later. +/// +/// One thing to know before rewriting the control that guards this: the server +/// looks for `cargo` in **three** places — `$CARGO`, `PATH`, and +/// `$CARGO_HOME/bin`. Inside Thalyx the third is `/state/cargo`, which +/// has no `bin`, so taking `PATH` away leaves it with nothing. On a rustup +/// machine the third one is full, and a control that only removed `PATH` +/// resolved the symbol anyway — see +/// `tests/a_toolchains_children_find_the_toolchain.rs`. +/// +/// ## And this is not the borrowing the decree forbids +/// +/// The `PATH` a managed toolchain gets is **built here, out of one directory +/// Thalyx staged itself**. Nothing is inherited: not the caller's `PATH`, not +/// `/usr/bin`, not `~/.cargo/bin`, not `~/.rustup`. What +/// `Runtime-Rust-Agente.md` forbids is Thalyx *finding* its tools among the +/// host's; what this does is tell Thalyx's own children where Thalyx's own +/// tools are. Move the disk and the value moves with it. +/// +/// An installed toolchain is the other machine and gets the other answer: its +/// `bin` goes in **front of** the inherited `PATH` rather than replacing it, +/// because that branch is a host toolchain by definition and its Cargo needs +/// the host's linker to build a proc macro. It is not decoration either — +/// `verify.sh` runs under `sudo`, whose `secure_path` contains no +/// `~/.rustup/toolchains/*/bin`, so on the machine that verifies all of this +/// rust-analyzer's `cargo` was as unreachable as it was inside Thalyx. pub fn environment() -> Vec<(&'static str, String)> { let mut environment: Vec<(&'static str, String)> = Vec::new(); if let Some(runtime) = managed_runtime() { - environment.push((LOADER_PATH_VARIABLE, runtime.lib().display().to_string())); // Under the store, so it survives a reboot and belongs to Thalyx. Made // here rather than left to Cargo: a directory a grant names has to // exist before the grant can be given. let home = crate::runtime::store_root().join("state").join("cargo"); let _ = std::fs::create_dir_all(&home); - environment.push(("CARGO_HOME", home.display().to_string())); - environment.push(("CARGO_NET_OFFLINE", "true".to_string())); - return environment; + return carried_by(&runtime, &home); } let rustup = std::env::var_os("RUSTUP_HOME") @@ -447,9 +502,70 @@ pub fn environment() -> Vec<(&'static str, String)> { if let Some(cargo_home) = cargo_home { environment.push(("CARGO_HOME", cargo_home.display().to_string())); } + if let Some(bin) = cargo().path.as_ref().and_then(|path| path.parent()) { + environment.push((SEARCH_PATH_VARIABLE, ahead_of_the_inherited_path(bin))); + } environment } +/// Everything a managed toolchain's children are told, and nothing else. +/// +/// Split out of [`environment`] so that the claim *«this environment names one +/// directory and it is Thalyx's»* can be asserted about a runtime a test +/// staged, rather than about whatever the machine running the test happens to +/// carry. Rule 11: the alternative is a test that sets `THALYX_ROOT` for the +/// whole process and changes what every other check thinks the machine is. +pub fn carried_by( + runtime: &crate::runtime::Runtime, + cargo_home: &Path, +) -> Vec<(&'static str, String)> { + vec![ + (LOADER_PATH_VARIABLE, runtime.lib().display().to_string()), + // Exactly one directory, and it is Thalyx's. See the note above: this + // is what rust-analyzer's own `cargo` and `rustc` are found through, + // and it is the whole reason a machine that could parse a file could + // not resolve a name in it. + ( + SEARCH_PATH_VARIABLE, + runtime.root.join("bin").display().to_string(), + ), + ("CARGO_HOME", cargo_home.display().to_string()), + ("CARGO_NET_OFFLINE", "true".to_string()), + ] +} + +/// A directory put in front of whatever `PATH` this process was given. +/// +/// Prepended and not appended: a `sudo` whose `secure_path` happens to hold a +/// `/usr/bin/cargo` from a distribution package would otherwise answer +/// rust-analyzer's subprocess with a different toolchain from the one +/// [`cargo`] resolved, and a machine whose `cargo metadata` and whose +/// `cargo --version` are two different compilers is two machines. +/// +/// Absent when the directory is already there, so a normal shell run keeps the +/// `PATH` it had rather than growing a duplicate on every start. +fn ahead_of_the_inherited_path(bin: &Path) -> String { + ahead_of( + bin, + &std::env::var_os(SEARCH_PATH_VARIABLE).unwrap_or_default(), + ) +} + +/// The same, told what the inherited value is rather than reading it. +/// +/// `PATH` is the process's, and a test that set it to assert this would be +/// rule 11 — a global switch with no owner, whose value is the precondition of +/// every other check in the same binary. +fn ahead_of(bin: &Path, inherited: &std::ffi::OsStr) -> String { + if std::env::split_paths(inherited).any(|entry| entry == bin) { + return inherited.to_string_lossy().into_owned(); + } + let joined = std::iter::once(bin.to_path_buf()).chain(std::env::split_paths(inherited)); + std::env::join_paths(joined) + .map(|path| path.to_string_lossy().into_owned()) + .unwrap_or_else(|_| bin.display().to_string()) +} + /// The runtime this machine's tools actually came out of, or `None`. /// /// Asked of [`cargo`] rather than of the store, and the difference matters: a @@ -468,6 +584,13 @@ pub fn managed_runtime() -> Option { /// The environment variable that names where the loader looks first. pub const LOADER_PATH_VARIABLE: &str = "LD_LIBRARY_PATH"; +/// The environment variable a bare program name is resolved through. +/// +/// Spelled once, because the thing it is for is a subprocess Thalyx does not +/// start and cannot see: rust-analyzer's `cargo`. A second spelling of it is a +/// second answer to where the toolchain is. +pub const SEARCH_PATH_VARIABLE: &str = "PATH"; + /// The directory a toolchain binary's own `RUNPATH` means, resolved from where /// the binary really is rather than from where it is executed. /// @@ -746,4 +869,75 @@ mod tests { assert_eq!(once.len(), 3); assert!(once[0].ends_with("beta-x86_64/bin"), "{once:?}"); } + + #[test] + fn a_managed_toolchain_hands_its_children_one_directory_and_it_is_thalyxs() { + // The bug of 2026-08-31: rust-analyzer started from an absolute path, + // parsed the file, and then ran `cargo metadata` — spelled as a bare + // program name — into a `PATH` that did not exist. `os error 2`, no + // crate graph, and a machine whose outline could see a struct its + // rename could not resolve. + let store = tempfile::tempdir().expect("a temp store"); + let root = a_runtime_that_answers(store.path(), "rust-1.90.0-x86_64-unknown-linux-musl"); + let runtime = crate::runtime::read(&root).expect("a staged runtime"); + let home = store.path().join("state/cargo"); + + let carried = carried_by(&runtime, &home); + let named: Vec<&str> = carried.iter().map(|(name, _)| *name).collect(); + assert!( + named.contains(&SEARCH_PATH_VARIABLE), + "the children of the managed toolchain are told nothing about where \ + its cargo is: {named:?}" + ); + + let path = carried + .iter() + .find(|(name, _)| *name == SEARCH_PATH_VARIABLE) + .map(|(_, value)| value.clone()) + .expect("a PATH"); + assert_eq!( + path, + root.join("bin").display().to_string(), + "the managed PATH is built by Thalyx out of its own bin and holds \ + nothing else" + ); + // The half that is the decree rather than the fix. A `PATH` with a + // second entry in it is a Thalyx whose compiler is whichever one the + // host put first, and it would work on every machine that has rustup. + assert_eq!(std::env::split_paths(&path).count(), 1, "{path:?}"); + for borrowed in ["/usr/bin", "/usr/local/bin", ".cargo", ".rustup"] { + assert!( + !path.contains(borrowed), + "the managed PATH reaches the host through {borrowed}: {path}" + ); + } + } + + #[test] + fn an_installed_toolchains_bin_goes_in_front_of_the_path_it_was_given() { + // The other machine, and the other answer. `verify.sh` runs under + // `sudo`, whose `secure_path` names no `~/.rustup/toolchains/*/bin`, + // so rust-analyzer's `cargo` was as unreachable there as it was inside + // Thalyx — and a host toolchain still needs the host's linker, so the + // rest of the value stays. + let bin = PathBuf::from("/home/somebody/.rustup/toolchains/stable/bin"); + let ahead = ahead_of(&bin, std::ffi::OsStr::new("/usr/sbin:/usr/bin")); + let entries: Vec = std::env::split_paths(&ahead).collect(); + assert_eq!(entries.first(), Some(&bin), "{ahead}"); + assert_eq!( + entries.len(), + 3, + "the inherited entries were dropped: {ahead}" + ); + } + + #[test] + fn a_path_that_already_names_the_toolchain_does_not_grow_a_second_copy() { + // Every start would otherwise append one more entry to a value that is + // inherited by everything the run spawns, and the way that shows up is + // an `E2BIG` weeks later on a machine nobody changed. + let bin = PathBuf::from("/opt/toolchain/bin"); + let already = "/opt/toolchain/bin:/usr/bin"; + assert_eq!(ahead_of(&bin, std::ffi::OsStr::new(already)), already); + } } diff --git a/crates/thalyx-rust/tests/a_toolchains_children_find_the_toolchain.rs b/crates/thalyx-rust/tests/a_toolchains_children_find_the_toolchain.rs new file mode 100644 index 0000000..38cf775 --- /dev/null +++ b/crates/thalyx-rust/tests/a_toolchains_children_find_the_toolchain.rs @@ -0,0 +1,392 @@ +//! rust-analyzer is not a program Thalyx runs. It is a program that runs +//! programs, and until 2026-08-31 nobody had told it where they are. +//! +//! ## What the machine actually did +//! +//! A booted Thalyx, asked over its agent channel about `dev/rust-corpus`: +//! +//! ```text +//! context('lantern/src/lib.rs') → { name: "LanternRegistry", kind: "struct", +//! crate: "lantern", source: "rust-analyzer" } +//! context('LanternRegistry') → { source: "rust-analyzer", +//! resolution: "nothing", entries: [] } +//! rename at lantern/src/lib.rs:8:12 +//! → "rust-analyzer refused: No references +//! found at position" +//! ``` +//! +//! rust-analyzer was alive, had opened the file and had parsed it — and could +//! not resolve a name declared in the file it had just outlined. The reason is +//! in its own log, reproduced under the environment Thalyx hands over: +//! +//! ```text +//! ERROR FetchWorkspaceError: rust-analyzer failed to load workspace: +//! Failed to run `cargo metadata …`: No such file or directory (os error 2) +//! WARN failed to get rustc cfgs e=unable to fetch cfgs via `… "rustc" …` +//! Caused by: No such file or directory (os error 2) +//! ``` +//! +//! It spells its subprocesses as bare program names, the kernel resolves those +//! through `PATH`, and Thalyx — which finds every tool by absolute path and is +//! right to — had never given its children one. Syntax survives that because +//! syntax needs no subprocess. Everything else is the crate graph, and there +//! was none. +//! +//! ## Why every test here empties the environment first +//! +//! `cargo test` runs with a `PATH` that has a Rust toolchain on it, so a check +//! that inherited the caller's environment would pass on this machine for a +//! reason that does not exist inside Thalyx. Rule 8: a fake must model the +//! property under test, and the property is *what a process is given*. +//! [`WithNothingButWhatThalyxHandsOver`] clears everything and applies exactly +//! the pairs [`thalyx_rust::toolchain::carried_by`] builds — which is the +//! guest's own environment, assembled by the guest's own function, on any +//! machine. +//! +//! ## And why there is a control +//! +//! Rule 4. Without the same fixture answered by an environment with the `PATH` +//! taken back out, "rust-analyzer resolves the symbol" is an assertion about a +//! machine rather than a comparison, and it would go on passing on the day +//! something else started supplying the toolchain. + +mod support; + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use thalyx_know::Knowledge; +use thalyx_rust::analyzer::{Launching, Spawn, Started}; +use thalyx_rust::{Provider, Resolution}; + +/// A spawner that gives the server the guest's environment and nothing else. +/// +/// `env_clear` is the whole test. Inside Thalyx there is no login shell, no +/// profile and no distribution `PATH`; a provider gets what Thalyx hands it, +/// and this is the only way to ask what that is worth without a booted +/// machine. +struct WithNothingButWhatThalyxHandsOver; + +impl Spawn for WithNothingButWhatThalyxHandsOver { + fn start(&self, asked: Launching<'_>) -> thalyx_rust::Result { + let mut command = Command::new(asked.program); + command.env_clear(); + if let Some(target) = asked.build_into { + command.env("CARGO_TARGET_DIR", target); + } + for (name, value) in asked.environment { + command.env(name, value); + } + let child = command + .current_dir(asked.root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + thalyx_rust::RustError::NoAnalyzer(format!("{}: {error}", asked.program.display())) + })?; + Ok(Started { + child, + release: None, + how: "an empty environment".to_string(), + confined: false, + }) + } +} + +/// The pairs a machine whose Rust is Thalyx's own hands its children, +/// assembled by the function that assembles them. +/// +/// Built with [`thalyx_rust::toolchain::carried_by`] over whatever toolchain +/// this machine has, laid out the way the artifact is: `bin` beside `lib`, +/// which is rustup's layout as well as the runtime's. So these run on a +/// developer's laptop and inside Thalyx and produce the same shape in both. +/// +/// The `CARGO_HOME` is an empty directory the caller owns, and that is not +/// tidiness. rust-analyzer looks for `cargo` in three places — `$CARGO`, +/// `PATH`, and `$CARGO_HOME/bin` — and on a rustup machine the third one is +/// full. A control that only took `PATH` away therefore *resolved the symbol +/// anyway*, and the first version of this file asserted a defect that its own +/// environment had repaired. Rule 5 again, and rule 8: on the store, +/// `CARGO_HOME` is `/state/cargo` and has no `bin` in it, so a fake +/// that models the guest has none either. +fn handed_over(cargo_home: &Path) -> Vec<(String, String)> { + let cargo = thalyx_rust::toolchain::cargo() + .path + .clone() + .expect("a cargo, which `cargo_or_skip` has already established"); + let root = cargo + .parent() + .and_then(Path::parent) + .expect("a toolchain laid out as bin beside lib"); + let runtime = thalyx_rust::runtime::Runtime { + root: root.to_path_buf(), + identity: "the toolchain this machine has".to_string(), + rust: None, + musl: None, + }; + std::fs::create_dir_all(cargo_home).expect("a cargo home with nothing in it"); + thalyx_rust::toolchain::carried_by(&runtime, cargo_home) + .into_iter() + .map(|(name, value)| (name.to_string(), value)) + .collect() +} + +/// A provider over the corpus, started with exactly these pairs and no others. +fn provider(root: &Path, target: &Path, environment: Vec<(String, String)>) -> Provider { + Provider::open(root, Knowledge::in_memory().expect("a knowledge store")) + .spawning(std::sync::Arc::new(WithNothingButWhatThalyxHandsOver)) + .building_into(target) + .reaching(thalyx_rust::toolchain::readable(), environment) +} + +fn lib_of(root: &Path) -> PathBuf { + root.join("lantern").join("src").join("lib.rs") +} + +#[test] +fn a_provider_with_no_path_parses_the_file_and_cannot_resolve_a_name_in_it() { + // The control, and the reproduction. It asserts the *defect*: with the one + // variable taken back out, the machine does exactly what the booted one + // did — the outline is right and the resolution is empty. + if !support::analyzer_or_skip("that a workspace does not load without a PATH") { + return; + } + if !support::unconfined_or_skip("that a workspace does not load without a PATH") { + return; + } + let (_held, root) = support::corpus(); + let target = tempfile::tempdir().expect("somewhere to build"); + + let blinded: Vec<(String, String)> = handed_over(&target.path().join("cargo-home")) + .into_iter() + .filter(|(name, _)| name != thalyx_rust::toolchain::SEARCH_PATH_VARIABLE) + .collect(); + let mut provider = provider(&root, target.path(), blinded); + + // Syntax. It works, and that it works is the thing that made the defect so + // hard to see: the machine answered a question about the file correctly. + let outline = provider.outline(&lib_of(&root)).expect("an outline"); + assert!( + outline.iter().any(|entry| entry.name == "LanternRegistry"), + "rust-analyzer could not even parse the file, so this run is not \ + measuring what it thinks it is: {outline:?}" + ); + + // Semantics. There is no crate graph, so there is nothing to resolve + // against. + let (resolution, _, source) = provider.known("LanternRegistry").expect("an answer"); + assert_eq!(source, "rust-analyzer"); + assert_eq!( + resolution, + Resolution::Nothing, + "a server with no toolchain on its PATH resolved a name. If that is \ + real, this control is measuring an environment that is no longer \ + empty — check what `handed_over` returned before believing it" + ); + + // And the rename, at the identifier's own physical position — the exact + // question the booted machine was asked when `documentSymbol` was ruled + // out as the cause. + let refused = provider.rename_plan(&lib_of(&root), 8, 12, "BeaconRegistry"); + assert!( + refused.is_err(), + "a rename resolved with no crate graph: {refused:?}" + ); +} + +#[test] +fn the_path_thalyx_builds_is_what_makes_a_name_resolve() { + if !support::analyzer_or_skip("that the toolchain's own PATH resolves a name") { + return; + } + if !support::unconfined_or_skip("that the toolchain's own PATH resolves a name") { + return; + } + let (_held, root) = support::corpus(); + let target = tempfile::tempdir().expect("somewhere to build"); + let handed = handed_over(&target.path().join("cargo-home")); + assert!( + handed + .iter() + .any(|(name, _)| name == thalyx_rust::toolchain::SEARCH_PATH_VARIABLE), + "nothing tells the toolchain's children where the toolchain is: {handed:?}" + ); + // And the machine's own answer, whichever branch it takes. The pairs above + // are the guest's shape; this is the wiring that has to carry it, and a + // `carried_by` that grew a `PATH` while `environment` did not would leave + // the booted machine exactly as broken as it was. + assert!( + thalyx_rust::toolchain::environment() + .iter() + .any(|(name, _)| *name == thalyx_rust::toolchain::SEARCH_PATH_VARIABLE), + "this machine's own toolchain environment names no PATH, so nothing it \ + starts can run `cargo metadata`" + ); + let mut provider = provider(&root, target.path(), handed); + + let (resolution, _, source) = provider.known("LanternRegistry").expect("an answer"); + assert_eq!(source, "rust-analyzer"); + let Resolution::One { known } = resolution else { + panic!( + "the corpus declares `LanternRegistry` exactly once and the machine \ + answered {resolution:?}" + ); + }; + assert_eq!(known.kind, "struct"); + assert_eq!( + known.package.as_deref(), + Some("lantern"), + "the package came from `cargo metadata`, so an answer without one is a \ + workspace that did not load: {known:?}" + ); + assert_eq!( + known.defined.len(), + 1, + "one declaration was expected: {known:?}" + ); + // 8:12, one-based — the identifier, not the doc comment three lines above + // it that the flat outline used to report. + assert_eq!( + ( + known.defined[0].path.as_str(), + known.defined[0].line, + known.defined[0].column + ), + ("lantern/src/lib.rs", 8, 12), + "the declaration is not where the file puts it: {:?}", + known.defined[0] + ); + // Uses in the other crate. A resolution that found the declaration and no + // references would be a file that parsed rather than a workspace that + // loaded. + assert!( + known + .used + .iter() + .any(|used| used.path.starts_with("harbour/")), + "nothing in the second crate refers to it, so the crate graph has one \ + crate in it: {:?}", + known.used + ); +} + +#[test] +fn a_rename_crosses_the_crate_that_only_the_compiler_knows_about() { + // The claim the machine makes and the index cannot: `harbour` mentions the + // name in a `use`, in a return type, in a call, in a reference and behind + // a type alias, and every one of them moves. `documentSymbol found the + // struct` is not this, which is the whole reason the verifier had to stop + // accepting it. + if !support::analyzer_or_skip("that a rename really crosses files") { + return; + } + if !support::unconfined_or_skip("that a rename really crosses files") { + return; + } + let (_held, root) = support::corpus(); + let target = tempfile::tempdir().expect("somewhere to build"); + let mut provider = provider( + &root, + target.path(), + handed_over(&target.path().join("cargo-home")), + ); + + let plan = provider + .rename_plan(&lib_of(&root), 8, 12, "BeaconRegistry") + .expect("a rename rust-analyzer resolved"); + let mut touched: Vec = plan + .iter() + .map(|change| { + change + .path + .strip_prefix(&root) + .unwrap_or(&change.path) + .display() + .to_string() + }) + .collect(); + touched.sort(); + assert_eq!( + touched, + vec![ + "harbour/src/lib.rs".to_string(), + "lantern/src/lib.rs".to_string() + ], + "a rename that stayed in one file is a rename that resolved nothing" + ); + let crossing = plan + .iter() + .find(|change| change.path.ends_with("harbour/src/lib.rs")) + .expect("the other crate"); + assert!( + crossing.edits.len() >= 4, + "the second crate mentions the name five times and {} moved: {:?}", + crossing.edits.len(), + crossing.edits + ); + + // What the files would say. Nothing is written — this crate describes and + // the authority above it decides — but the text is the thing a caller + // applies, so a plan that produced text nobody checked is a plan. + let texts = provider + .rename_texts(&lib_of(&root), 8, 12, "BeaconRegistry") + .expect("the text after"); + for written in &texts { + assert!( + written.text.contains("BeaconRegistry"), + "{} came back without the new name", + written.path.display() + ); + assert!( + !written.text.contains("LanternRegistry"), + "{} still carries the old name, so the rename is partial and \ + compiles nowhere", + written.path.display() + ); + } +} + +#[test] +fn the_toolchains_own_cargo_finds_the_toolchains_own_rustc() { + // The chain the whole change is about, one link further down than any + // other test reaches: `cargo --version` proves nothing, because that is + // the binary Thalyx already found by absolute path. This runs `cargo` as a + // **bare program name** with an empty environment, so it can only be + // resolved through the `PATH` Thalyx built — and then makes it compile, + // which it can only do by finding `rustc` the same way. + // + // A library workspace on purpose: an rlib needs no linker, so this asks + // about the toolchain rather than about whether the machine has a `cc`. + if !support::cargo_or_skip("that the toolchain's cargo finds its rustc") { + return; + } + let (_held, root) = support::corpus(); + let target = tempfile::tempdir().expect("somewhere to build"); + + let mut command = Command::new("cargo"); + command.env_clear(); + for (name, value) in handed_over(&target.path().join("cargo-home")) { + command.env(name, value); + } + command.env("CARGO_TARGET_DIR", target.path()); + let built = command + .arg("build") + .arg("--offline") + .arg("--manifest-path") + .arg(root.join("Cargo.toml")) + .output() + .expect("cargo could not be started by name at all — the PATH names no cargo"); + assert!( + built.status.success(), + "the staged cargo could not build the corpus with only what Thalyx \ + hands over:\n{}", + String::from_utf8_lossy(&built.stderr) + ); + assert!( + target.path().join("debug").exists(), + "cargo reported success and built nothing, so it was not the compiler \ + that answered" + ); +} diff --git a/crates/thalyx-rust/tests/samples/document-symbol-hierarchical.json b/crates/thalyx-rust/tests/samples/document-symbol-hierarchical.json new file mode 100644 index 0000000..545db89 --- /dev/null +++ b/crates/thalyx-rust/tests/samples/document-symbol-hierarchical.json @@ -0,0 +1,221 @@ +[ + { + "name": "LanternRegistry", + "kind": 23, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 2, + "character": 0 + }, + "end": { + "line": 9, + "character": 1 + } + }, + "selectionRange": { + "start": { + "line": 7, + "character": 11 + }, + "end": { + "line": 7, + "character": 26 + } + }, + "children": [ + { + "name": "lit", + "detail": "u32", + "kind": 8, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 8, + "character": 4 + }, + "end": { + "line": 8, + "character": 12 + } + }, + "selectionRange": { + "start": { + "line": 8, + "character": 4 + }, + "end": { + "line": 8, + "character": 7 + } + } + } + ] + }, + { + "name": "impl LanternRegistry", + "kind": 19, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 11, + "character": 0 + }, + "end": { + "line": 23, + "character": 1 + } + }, + "selectionRange": { + "start": { + "line": 11, + "character": 5 + }, + "end": { + "line": 11, + "character": 20 + } + }, + "children": [ + { + "name": "new", + "detail": "fn() -> Self", + "kind": 12, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 12, + "character": 4 + }, + "end": { + "line": 14, + "character": 5 + } + }, + "selectionRange": { + "start": { + "line": 12, + "character": 11 + }, + "end": { + "line": 12, + "character": 14 + } + } + }, + { + "name": "light", + "detail": "fn(&mut self)", + "kind": 6, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 16, + "character": 4 + }, + "end": { + "line": 18, + "character": 5 + } + }, + "selectionRange": { + "start": { + "line": 16, + "character": 11 + }, + "end": { + "line": 16, + "character": 16 + } + } + }, + { + "name": "lit", + "detail": "fn(&self) -> u32", + "kind": 6, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 20, + "character": 4 + }, + "end": { + "line": 22, + "character": 5 + } + }, + "selectionRange": { + "start": { + "line": 20, + "character": 11 + }, + "end": { + "line": 20, + "character": 14 + } + } + } + ] + }, + { + "name": "impl Default for LanternRegistry", + "kind": 19, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 25, + "character": 0 + }, + "end": { + "line": 29, + "character": 1 + } + }, + "selectionRange": { + "start": { + "line": 25, + "character": 17 + }, + "end": { + "line": 25, + "character": 32 + } + }, + "children": [ + { + "name": "default", + "detail": "fn() -> Self", + "kind": 12, + "tags": [], + "deprecated": false, + "range": { + "start": { + "line": 26, + "character": 4 + }, + "end": { + "line": 28, + "character": 5 + } + }, + "selectionRange": { + "start": { + "line": 26, + "character": 7 + }, + "end": { + "line": 26, + "character": 14 + } + } + } + ] + } +] \ No newline at end of file diff --git a/crates/thalyx-rust/tests/support/mod.rs b/crates/thalyx-rust/tests/support/mod.rs index d9679b6..c1a02cc 100644 --- a/crates/thalyx-rust/tests/support/mod.rs +++ b/crates/thalyx-rust/tests/support/mod.rs @@ -80,3 +80,42 @@ pub fn cargo_or_skip(what: &str) -> bool { eprintln!("{message}"); false } + +/// A copy of `dev/rust-corpus`, which is the workspace the machine's own Rust +/// is verified against. +/// +/// The same tree as `dev/verify-agent-rust.sh` and not a second one: the +/// booted machine and the unit tests have to be able to disagree about the +/// answer, which they cannot do if they are asked about different corpora. +/// Copied because a rename really rewrites files. +pub fn corpus() -> (tempfile::TempDir, PathBuf) { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../dev/rust-corpus") + .canonicalize() + .expect("dev/rust-corpus"); + let held = tempfile::tempdir().expect("a temporary directory"); + let root = held.path().join("rust-corpus"); + copy(&source, &root); + (held, root) +} + +/// Whether a test that deliberately runs an unconfined analyzer may run. +/// +/// `THALYX_REQUIRE_CONFINED_ANALYZER=1` is a demand about the machine, and on +/// 2026-08-30 it was typed on `verify.sh`'s command line and stayed in the +/// environment of `cargo test --workspace` — rule 5, where the harness *is* +/// the environment. A test whose whole subject is the environment a provider +/// is handed cannot also be the test that proves confinement, so it says so +/// and stops rather than quietly starting an unconfined server under a +/// variable that forbids one. +pub fn unconfined_or_skip(what: &str) -> bool { + if std::env::var("THALYX_REQUIRE_CONFINED_ANALYZER").as_deref() != Ok("1") { + return true; + } + eprintln!( + "NOT PROVEN: {what} — THALYX_REQUIRE_CONFINED_ANALYZER=1 and this check \ + starts an ordinary process on purpose. It measures the environment a \ + toolchain's children are handed, not the confinement around them." + ); + false +} diff --git a/dev/verify-agent-rust.sh b/dev/verify-agent-rust.sh index 8aa0ad3..798b928 100755 --- a/dev/verify-agent-rust.sh +++ b/dev/verify-agent-rust.sh @@ -205,20 +205,85 @@ if [ -z "$CONTEXT_LINE" ]; then sed 's/^/ /' "$WORK/semantic.txt" | head -30 else printf '%s' "$CONTEXT_LINE" > "$WORK/context.json" - SOURCE=$(python3 -c 'import json,sys;print((json.load(open(sys.argv[1])) or {}).get("source",""))' "$WORK/context.json") - STARTS=$(python3 -c 'import json,sys;print((json.load(open(sys.argv[1])) or {}).get("analyzer_starts",""))' "$WORK/context.json") - CONFINED=$(python3 -c 'import json,sys;print((json.load(open(sys.argv[1])) or {}).get("analyzer_confined",""))' "$WORK/context.json") - if [ "$SOURCE" = "rust-analyzer" ]; then - proven "context('$SYMBOL') came from rust-analyzer, not from the scan (source=rust-analyzer, analyzer_starts=$STARTS)" - else - failed "context('$SYMBOL') answered source=$SOURCE — the machine matched a name instead of resolving one" + # ── the check that used to lie ────────────────────────────────────────── + # + # This block read `source` and nothing else, so on 2026-08-31 it printed + # + # PROVEN context('LanternRegistry') came from rust-analyzer + # + # about the answer + # + # { "source": "rust-analyzer", "resolution": "nothing", "entries": [] } + # + # — a machine that had started the compiler, asked it, and been told the + # workspace declares no such thing. `source` says **who answered**; it has + # never said **that the answer resolved anything**, and turning the first + # into the second is rule 5 with the instrument standing on the wrong side + # of the question it exists to settle. Worse than a missing check: a + # PROVEN line that a person then reads as ground truth. + # + # So the verdict now needs all three — rust-analyzer answered, it resolved + # exactly one declaration, and that declaration carries the name that was + # asked about. + python3 - "$WORK/context.json" "$SYMBOL" > "$WORK/context.lines" <<'CONTEXT_PY' +import json, sys +answer = json.load(open(sys.argv[1])) or {} +symbol = sys.argv[2] +def proven(text): print(f" \033[32mPROVEN\033[0m {text}") +def failed(text): print(f" \033[31mFAILED\033[0m {text}") +def unproven(text): print(f" \033[33mNOT PROVEN\033[0m {text}") + +source = answer.get("source") +resolution = answer.get("resolution") +entries = answer.get("entries") + +if source != "rust-analyzer": + failed(f"context({symbol!r}) answered source={source!r} — the machine " + f"matched a name instead of resolving one") +elif resolution != "one": + # The shape of 2026-08-31: the compiler answered and found nothing, + # because its `cargo` was not on any PATH and the workspace never loaded. + failed(f"context({symbol!r}) reached rust-analyzer and resolved nothing: " + f"resolution={resolution!r}, entries={json.dumps(entries)[:200]}. " + f"A workspace that failed to load looks exactly like this — the " + f"outline of a file still works and every name resolves to nothing") +else: + named = [entry for entry in (entries or []) + if isinstance(entry, dict) and entry.get("name") == symbol] + if len(named) != 1: + failed(f"resolution=one and the entries do not carry exactly one " + f"{symbol}: {json.dumps(entries)[:300]}") + else: + # `handle` is `file:line:column`, which is the thing `renombrar` takes. + # Reported rather than merely counted: an entry that resolved and + # cannot say where it is would still be unusable, and the position is + # the field the outline had wrong. + found = named[0] + handle = found.get("handle") + if not handle or not found.get("file"): + failed(f"the resolved entry does not say where it is, so nothing " + f"can be asked about it: {json.dumps(found)[:300]}") + else: + proven(f"context({symbol!r}) was resolved by rust-analyzer to one " + f"declaration: {found.get('kind')} at {handle}" + f" ({found.get('uses')} use(s))") + +confined = answer.get("analyzer_confined") +if confined is True: + proven("and the provider that answered was confined by Thalyx") +elif confined is False: + unproven("the provider ran as an ordinary process on this machine — load " + "the LSM (make -C lsm load) to close that half") +else: + unproven("the answer did not say whether the provider was confined") +CONTEXT_PY + cat "$WORK/context.lines" + tally "$WORK/context.lines" + if grep -q 'FAILED' "$WORK/context.lines"; then + say + say " the machine's own answer, in full:" sed 's/^/ /' "$WORK/context.json" fi - case "$CONFINED" in - True|true) proven "and the provider that answered was confined by Thalyx" ;; - False|false) unproven "the provider ran as an ordinary process on this machine — load the LSM (make -C lsm load) to close that half" ;; - *) unproven "the answer did not say whether the provider was confined" ;; - esac fi if [ -z "$EXEC_LINE" ]; then diff --git a/vault/06-Pendientes/Punto-Actual.md b/vault/06-Pendientes/Punto-Actual.md index 1932368..7844acf 100644 --- a/vault/06-Pendientes/Punto-Actual.md +++ b/vault/06-Pendientes/Punto-Actual.md @@ -14,10 +14,112 @@ tags: [continuidad, punto-actual, sesiones] > > Para *cómo* trabajar en el proyecto, ver `CLAUDE.md` en la raíz del repo. -## La máquina agente lleva su propio Rust — 2026-08-31 +## Los hijos del toolchain ya encuentran el toolchain — 2026-08-31 **Éste es el estado actual.** Los bloques de abajo son cómo se llegó. +La VM real cerró el problema anterior: dentro de Thalyx, +`cargo 1.90.0 from: thalyx` y `rust-analyzer 1.90.0 from: thalyx`. Y aun así +`context('LanternRegistry')` contestaba `resolution: nothing` y el rename +`No references found at position`, incluso apuntando al identificador físico en +`lantern/src/lib.rs:8:12`. + +**Causa demostrada, con el log del propio servidor**, reproducida con un entorno +que tenía exactamente lo que Thalyx entrega y nada más: + +``` +ERROR FetchWorkspaceError: rust-analyzer failed to load workspace: + Failed to run `cargo metadata …`: No such file or directory (os error 2) +WARN failed to get rustc cfgs e=unable to fetch cfgs via `… "rustc" …` + Caused by: No such file or directory (os error 2) +``` + +rust-analyzer lanza sus subprocesos por **nombre pelado**, y un nombre pelado se +resuelve por `PATH`. Thalyx encuentra sus herramientas por ruta absoluta —y eso +no cambia— pero nunca le había dado un `PATH` a lo que ella misma arranca. Sin +grafo de crates no hay semántica; la sintaxis sobrevive porque no necesita +subproceso, que es por qué el outline funcionaba mientras todo lo demás +resolvía a nada. + +**El cambio, mínimo:** `toolchain::environment` agrega una variable. + +| variable | por qué | +|---|---| +| `PATH=/bin` | única vía por la que rust-analyzer encuentra **su** cargo y **su** rustc. Medido: agregarla y nada más vuelve correctas `workspace/symbol`, `definition` y `rename` en el mismo fixture donde las tres estaban vacías | + +No se agregaron `CARGO`, `RUSTC` ni `RUST_SRC_PATH`: `PATH` sola alcanzó, y una +variable puesta porque «Rust normalmente la usa» no se quita nunca. El `PATH` +del runtime administrado tiene **una** entrada, construida por Thalyx: no hereda +el del anfitrión, ni `/usr/bin`, ni `~/.cargo`, ni `~/.rustup`. Un toolchain +instalado —la otra rama— pone su `bin` **adelante** del heredado en vez de +reemplazarlo, que además arregla `verify.sh` bajo `sudo`, cuyo `secure_path` no +nombra ningún `~/.rustup/toolchains/*/bin`. + +Todo el razonamiento está en [[Runtime-Rust-Agente]], sección «El `PATH` que +Thalyx le arma a sus hijos». + +### Y dos defectos del instrumento, que eran peores que el bug + +- **`dev/verify-agent-rust.sh` daba un falso positivo.** Imprimía + `PROVEN context('LanternRegistry') came from rust-analyzer` sobre + `{source: "rust-analyzer", resolution: "nothing", entries: []}`, porque leía + `source` y nada más. `source` dice **quién contestó**; nunca dijo **que la + respuesta resolviera algo**. Ahora el veredicto exige las tres cosas: + `source == "rust-analyzer"`, `resolution == "one"`, y que las entradas traigan + exactamente la declaración preguntada con su `handle`. La comprobación + independiente del rename se mantiene. +- **El control de la regresión quedó reparado por su propio ambiente.** El + primer intento quitaba `PATH` y el símbolo se resolvía igual: rust-analyzer + busca `cargo` en `$CARGO`, `PATH` y `$CARGO_HOME/bin`, y en una máquina con + rustup el tercero está lleno. Dentro de Thalyx el `CARGO_HOME` es + `/state/cargo` y no tiene `bin`. El control ahora modela el guest. + +Las dos quedaron escritas como reglas en [[Estrategia-de-Pruebas]]; son la +vigésima y la vigésimo primera vez que el instrumento era el problema. + +### El outline apuntaba al comentario, y también se arregló + +`context('lantern/src/lib.rs')` ponía `LanternRegistry` en 3:1 cuando el +identificador está en 8:12. Thalyx pedía `hierarchicalDocumentSymbolSupport: +false`, y en esa forma el rango de cada entrada es el **ítem entero**, que para +una struct documentada arranca en el primer `///`. Se pide la forma jerárquica y +se lee `selectionRange`. **No era la causa del bloqueo** —8:12 fallaba igual— +pero sí una posición que un renombrado por handle habría usado. La respuesta real +del servidor quedó guardada en +`crates/thalyx-rust/tests/samples/document-symbol-hierarchical.json`, regla 6. + +### Qué está probado y qué falta + +Probado en el contenedor, con el entorno vaciado (`env_clear`) y sólo las +variables que Thalyx entrega, sobre `dev/rust-corpus`: + +- sin `PATH`: el outline encuentra `LanternRegistry` y `known(...)` contesta + `Resolution::Nothing` y el rename falla — el defecto, reproducido; +- con el `PATH` que Thalyx construye: `Resolution::One`, la declaración en + `lantern/src/lib.rs:8:12`, usos en el otro crate, y un rename real que reescribe + **dos archivos** con más de cuatro ediciones en el segundo; +- la cadena de abajo: `cargo` invocado como **nombre pelado** con el entorno + vaciado compila el workspace, o sea que encontró su `rustc` por el mismo + `PATH`. + +**Falta la prueba en la VM real**, que es de César. Es el mismo +`dev/verify-agent-rust.sh` de antes, ahora sin el falso positivo: + +```sh +git pull +cargo build --release -p thalyx-mcp +make -C image agent PROJECT="$PWD/dev/rust-corpus" # en otra terminal +dev/verify-agent-rust.sh +``` + +Y una cosa **no** se tocó a propósito: la VM reportó `analyzer_confined=false` +porque el kernel está attached pero observando. El proveedor cayó al proceso +ordinario **dentro de Thalyx** y la semántica falló igual, así que primero la +semántica. La misma propiedad bajo enforcement es la siguiente causa, con su +propia evidencia. Una causa a la vez. + +## La máquina agente lleva su propio Rust — 2026-08-31 + La corrida compacta perdió por una causa física, y ya está identificada exacta: dentro de la VM, Claude eligió la primitiva correcta —`context` y luego `rename`— y Thalyx contestó `source: index`, `analyzer_starts: 0` y diff --git a/vault/09-Notas-Tecnicas/Estado-de-Implementacion.md b/vault/09-Notas-Tecnicas/Estado-de-Implementacion.md index 711111d..426617b 100644 --- a/vault/09-Notas-Tecnicas/Estado-de-Implementacion.md +++ b/vault/09-Notas-Tecnicas/Estado-de-Implementacion.md @@ -320,6 +320,17 @@ por una máquina sin compilador. Entero en [[Runtime-Rust-Agente]]. **Lo que no hace:** compilar. No lleva enlazador, a propósito y explicado ahí. +Y desde el 2026-08-31, más tarde: `toolchain::environment` le entrega a los +hijos del toolchain un `PATH` **construido por Thalyx** con una sola entrada, la +del `bin` del artefacto. rust-analyzer lanza `cargo metadata` y `rustc` por +nombre pelado, y sin `PATH` el workspace no cargaba: la VM podía listar los +símbolos de un archivo y no resolver ninguno. Un toolchain instalado pone su +`bin` adelante del `PATH` heredado en vez de reemplazarlo, que es además lo que +hacía falta bajo `sudo`. Y `dev/verify-agent-rust.sh` ya no puede dar el falso +positivo que dio: para declarar PROVEN exige `resolution == "one"` y la +declaración esperada entre las entradas, no sólo que `source` diga +`rust-analyzer`. + ## No construido todavía | Pieza | Bloqueante para | diff --git a/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md b/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md index fb67e0e..31d5746 100644 --- a/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md +++ b/vault/09-Notas-Tecnicas/Estrategia-de-Pruebas.md @@ -6071,3 +6071,86 @@ La regla: cuando un `RPATH` relativo falla, la pregunta antes de tocar el artefacto es **qué sabe el proceso de sí mismo**. Y como no se debe depender de que quien arranque el proceso se acordara de montar `/proc`, el directorio se nombra además en `LD_LIBRARY_PATH`, que no depende de nada. +## Regla derivada: un programa que corre programas necesita que le digan dónde están, aunque a él se lo haya encontrado por ruta absoluta — 2026-08-31 + +Thalyx encuentra `cargo` y `rust-analyzer` por ruta absoluta, a propósito, y esa +decisión no cambia. Lo que no estaba escrito en ninguna parte es que +**rust-analyzer no es un programa que Thalyx corre: es un programa que corre +programas.** Lanza `cargo metadata`, `cargo locate-project`, `cargo --version` y +`rustc --print cfg`, y escribe cada uno como nombre pelado, que el kernel +resuelve por `PATH` y por nada más. + +La forma física del fallo, en la VM real: + +``` +context('lantern/src/lib.rs') → LanternRegistry, struct, source: rust-analyzer +context('LanternRegistry') → resolution: nothing, entries: [] +rename en 8:12 → "No references found at position" +``` + +El servidor estaba vivo, había parseado el archivo, y no resolvía un nombre +declarado en el archivo que acababa de listar. La sintaxis sobrevive porque no +necesita subproceso; todo lo demás es el grafo de crates, y no había ninguno. + +La regla, más allá de Rust: **una herramienta puesta a trabajar dentro de Thalyx +hereda el ambiente vacío de Thalyx, y lo que ella misma lance no lo hereda de +nadie.** Antes de creer que una herramienta ajena está rota, se pregunta qué +subprocesos lanza y con qué los nombra. `--version` no lo contesta: ése es el +binario que Thalyx ya encontró. La cadena que hay que probar es el eslabón de +más abajo — su cargo, su rustc — y con el entorno vacío alrededor. + +Y el corolario de diseño, que es lo que lo distingue del préstamo prohibido: el +`PATH` que se entrega **se construye**, no se hereda. Una entrada, la del +artefacto de Thalyx. Thalyx sigue descubriendo por ruta absoluta; lo que hace es +decirle a sus propios hijos dónde están sus propias herramientas. + +## Regla derivada: «contestó la herramienta» no es «la herramienta resolvió» — 2026-08-31 + +`dev/verify-agent-rust.sh` imprimió + +``` +PROVEN context('LanternRegistry') came from rust-analyzer +``` + +sobre esta respuesta: + +```json +{ "source": "rust-analyzer", "resolution": "nothing", "entries": [] } +``` + +Leía `source` y nada más. `source` dice **quién contestó**; no ha dicho nunca +**que la respuesta resolviera algo**. Un instrumento que convierte lo primero en +lo segundo no falla en silencio: imprime `PROVEN`, que es peor, porque después +alguien lo lee como verdad establecida. + +Es la regla 5 con el instrumento parado del lado equivocado de la pregunta que +existe para contestar, y es la vigésima vez. El arreglo es que el veredicto pida +las tres cosas: que contestara rust-analyzer, que `resolution` sea `one`, y que +las entradas traigan exactamente la declaración que se preguntó, con su posición. + +La regla general: **cuando un campo dice la procedencia y otro dice el +resultado, un PROVEN sobre el primero es un PROVEN sobre nada.** El instrumento +tiene que nombrar el campo que contiene la afirmación, no el que contiene la +firma. + +## Regla derivada: un control puede quedar reparado por el ambiente que se le olvidó vaciar — 2026-08-31 + +La regresión del `PATH` traía su control, como pide la regla 4: el mismo fixture +con la variable quitada, que tiene que **fallar**. Falló al revés: resolvió el +símbolo igual. + +La causa es que rust-analyzer busca `cargo` en tres lugares —`$CARGO`, `PATH` y +`$CARGO_HOME/bin`— y en una máquina con rustup el tercero está lleno. El control +quitaba uno de los tres. Dentro de Thalyx el `CARGO_HOME` es +`/state/cargo` y no tiene `bin`, así que el guest sí tenía los tres +cerrados y la máquina real fallaba mientras el control pasaba. + +Es la regla 8 —un doble tiene que modelar la propiedad bajo prueba— aplicada al +*control* en vez de al sujeto: el control modelaba una máquina de desarrollo, no +el guest. Y es la vigésimo primera vez que el instrumento era el problema. + +La regla: **un control que demuestra un defecto tiene que cerrar todas las vías, +no la que uno tenía en mente.** Antes de escribirlo, se enumeran las formas que +la herramienta tiene de conseguir lo que se le está quitando; si el ambiente de +pruebas deja una abierta, el control afirma un defecto que su propio entorno +acaba de reparar. diff --git a/vault/09-Notas-Tecnicas/Runtime-Rust-Agente.md b/vault/09-Notas-Tecnicas/Runtime-Rust-Agente.md index def284b..e59c4fd 100644 --- a/vault/09-Notas-Tecnicas/Runtime-Rust-Agente.md +++ b/vault/09-Notas-Tecnicas/Runtime-Rust-Agente.md @@ -156,6 +156,113 @@ pivot, y `toolchain::environment` nombra el directorio en `LD_LIBRARY_PATH`. La segunda es la que no depende de que quien arranque el proceso se acordara de la primera. +## El `PATH` que Thalyx le arma a sus hijos + +> Segunda evidencia física, 2026-08-31, en la VM real y sobre `dev/rust-corpus`. +> El problema «no hay Cargo dentro de Thalyx» ya estaba cerrado: `cargo 1.90.0 +> from: thalyx`, `rust-analyzer 1.90.0 from: thalyx`. Y aun así la máquina no +> resolvía un nombre. + +Lo que la máquina contestó: + +``` +context('lantern/src/lib.rs') → { name: "LanternRegistry", kind: "struct", + crate: "lantern", source: "rust-analyzer" } +context('LanternRegistry') → { source: "rust-analyzer", + resolution: "nothing", entries: [] } +rename en lantern/src/lib.rs:8:12 + → "No references found at position" +``` + +rust-analyzer estaba vivo, había abierto el archivo y lo había parseado, y no +podía resolver un nombre declarado en el archivo que acababa de listar. La +posición no era el problema: se probó la del identificador físico, 8:12, y +falló igual. + +**La causa, reproducida y capturada del log del propio servidor** con un entorno +que tenía exactamente lo que Thalyx entregaba y nada más: + +``` +ERROR FetchWorkspaceError: rust-analyzer failed to load workspace: + Failed to run `cargo metadata …`: No such file or directory (os error 2) +WARN failed to get rustc cfgs e=unable to fetch cfgs via `… "rustc" …` + Caused by: No such file or directory (os error 2) +``` + +### Por qué esto no lo tapaba nada + +Thalyx encuentra sus herramientas por ruta absoluta, y eso está bien y no +cambia. Pero **rust-analyzer no es un programa que Thalyx corre: es un programa +que corre programas.** Lanza `cargo metadata`, `cargo locate-project`, +`cargo --version` y `rustc --print cfg`, y escribe cada uno como nombre pelado. +Un nombre pelado lo resuelve el kernel por `PATH` y por nada más. La máquina +Thalyx, a propósito, no tiene el `PATH` de una distribución. + +Sin grafo de crates no hay semántica; la sintaxis sobrevive porque no necesita +subproceso. De ahí la forma exacta de la falla: el *outline* de un archivo +funciona y todos los nombres resuelven a nada. + +### El arreglo, y por qué no es el préstamo que el decreto prohíbe + +`toolchain::environment` le entrega a los hijos del toolchain administrado +cuatro variables, y esto es lo que hace cada una: + +| variable | por qué existe | +|---|---| +| `PATH=/bin` | es lo único a través de lo cual rust-analyzer puede encontrar **su** `cargo` y **su** `rustc`. Medido: agregarla —y nada más— vuelve correctas `workspace/symbol`, `definition` y `rename` en el mismo fixture donde las tres estaban vacías | +| `LD_LIBRARY_PATH=/lib` | el detalle de musl de la sección anterior | +| `CARGO_HOME=/state/cargo` | el registro es de Thalyx y está en el store, nunca el de quien armó el disco | +| `CARGO_NET_OFFLINE=true` | el proveedor semántico no tiene red por construcción, y un Cargo que no lo sabe se gasta su timeout en enterarse | + +**No** se pusieron `CARGO`, `RUSTC` ni `RUST_SRC_PATH`. `PATH` sola alcanzó en la +medición, y una variable agregada porque «Rust normalmente la usa» es una +variable que después nadie se atreve a quitar. + +La distinción que importa: + +- **mal**: Thalyx busca sus herramientas en el `PATH` que heredó de Fedora; +- **bien**: Thalyx descubre su runtime por ruta absoluta y le arma a sus hijos + un `PATH` construido por Thalyx que contiene únicamente su propio `bin`. + +Ese `PATH` tiene **una** entrada. No hereda nada: ni el `PATH` de quien arrancó, +ni `/usr/bin`, ni `~/.cargo`, ni `~/.rustup`. Nombra un directorio que el +confinamiento ya concede de todos modos, porque está dentro del artefacto. Se +mueve el disco y el valor se mueve con él, que es la propiedad entera. + +Un toolchain **instalado** es la otra máquina y recibe la otra respuesta: su +`bin` va **adelante** del `PATH` heredado en vez de reemplazarlo, porque en esa +rama el toolchain es del anfitrión por definición y su Cargo necesita el +enlazador del anfitrión. Tampoco es adorno: `dev/verify.sh` corre bajo `sudo`, y +el `secure_path` de sudo no nombra ningún `~/.rustup/toolchains/*/bin` — así que +en la máquina que verifica todo esto el `cargo` de rust-analyzer estaba tan +inalcanzable como dentro de Thalyx. + +### Lo que esto no arregla + +El confinamiento. La VM reportó `analyzer_confined=false` porque el kernel está +attached pero observando, y el proveedor cayó al proceso ordinario **dentro de +Thalyx** — y la semántica falló igual. Primero la semántica; la misma propiedad +bajo enforcement es la siguiente causa, con su propia evidencia. Una causa a la +vez. + +## El outline apuntaba al comentario + +Defecto aparte, encontrado en la misma corrida y arreglado con ella: +`context('lantern/src/lib.rs')` ponía `LanternRegistry` en 3:1 cuando el +identificador está en 8:12. + +Thalyx pedía `hierarchicalDocumentSymbolSupport: false`, y en esa forma +rust-analyzer contesta `SymbolInformation`, que trae **un solo rango por +entrada** y lo llena con el ítem entero — para una `struct` documentada eso +arranca en el primer `///`. La forma jerárquica trae además `selectionRange`, +que es el identificador. Se pide la jerárquica y se lee `selectionRange`. + +No era la causa del bloqueo —8:12 fallaba igual— pero sí era una posición que +un renombrado por handle habría usado. La respuesta real del servidor quedó +guardada en `crates/thalyx-rust/tests/samples/document-symbol-hierarchical.json`, +que es la regla 6 de [[Estrategia-de-Pruebas]]: un fixture inventado prueba que +el lector coincide con la idea que uno tiene del formato. + ## Cómo se descubre, y en qué orden `thalyx-rust::toolchain`: