diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a49a08f..12b9f2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,12 +2,19 @@ ## Unreleased +- Improve Rust call-graph recall for source-proven `Arc`, `Rc`, and `Box` + receiver chains, typed chained-call results, and local evaluating + `macro_rules!` inputs. Rust universal evidence advances to producer version + 2 so cached Rust files rebuild; ambiguous and non-evaluating macro inputs + continue to fail closed. + - Add the bounded `compass.query.agent-view/1` projection for coding agents. Typed CLI and MCP query text now lead with result state, answer, and caveats; `--format agent-json` and MCP `agentView` expose the same deterministic source-linked view while raw query JSON remains unchanged. Discovery text keeps its v2 cursor ledger and adds only an answer-first fixed header. + ## 0.3.26 - 2026-09-15 - Make query failures and paths more trustworthy: exact-looking missing symbols diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 3bb597dc..2048c9f1 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -94,6 +94,14 @@ history profiles, and cache identities. ## Evolving contracts + +Rust structural evidence now uses producer version 2. The evidence and graph +schema majors are unchanged, but Rust extraction caches from producer version +1 are rebuilt so source-proven standard-library dereference chains and +evaluating local macro inputs can publish newly recovered exact calls. +Unsupported macro shapes, non-evaluating inputs, and ambiguous receiver owners +remain unresolved rather than being guessed. + ### Agent Query View Compass adds the additive strict projection `compass.query.agent-view/1` for diff --git a/MIGRATION.md b/MIGRATION.md index 1e2c7cf5..ee2ac733 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -132,6 +132,15 @@ registration edge is synthesized: the current descriptor vocabulary cannot advertise those registrations without incorrectly claiming bean-container semantics. +## Rust receiver and macro evidence rebuild + +Rust universal evidence now uses producer version 2. The first graph build +after upgrading re-extracts cached Rust files automatically. No graph schema +migration or manual artifact editing is required. The new producer follows +source-proven `Arc`, `Rc`, and `Box` field chains and recovers calls from a +bounded local `macro_rules!` shape only when the captured expression or +statement is proven to be evaluated; ambiguous cases remain unresolved. + ## Ruby universal evidence rebuild The current release publishes Ruby through the version-1 universal evidence diff --git a/crates/compass-languages/src/evidence/build.rs b/crates/compass-languages/src/evidence/build.rs index 14abbc98..8094ba4e 100644 --- a/crates/compass-languages/src/evidence/build.rs +++ b/crates/compass-languages/src/evidence/build.rs @@ -1027,6 +1027,7 @@ struct DirectEvidenceState<'source> { rust_call_result_bindings: HashMap<(String, String, usize), String>, rust_import_nodes: HashSet, rust_test_declarations: HashSet, + rust_evaluated_macros: HashMap, go_lexical_bindings: HashMap>, go_call_result_bindings: HashMap<(String, String, usize), String>, go_return_types: HashMap>>, @@ -1118,6 +1119,7 @@ impl<'source> DirectEvidenceState<'source> { rust_call_result_bindings: HashMap::new(), rust_import_nodes: HashSet::new(), rust_test_declarations: HashSet::new(), + rust_evaluated_macros: HashMap::new(), go_lexical_bindings: HashMap::new(), go_call_result_bindings: HashMap::new(), go_return_types: HashMap::new(), @@ -4143,6 +4145,12 @@ impl<'source> DirectEvidenceState<'source> { } "macro_definition" => { self.add_rust_named_declaration(node, owner, "macro")?; + if rust_macro_evaluates_single_code_fragment(node, self.source) + && let Some(context) = self.declarations.get(&node.id()) + { + self.rust_evaluated_macros + .insert(context.qualified_name.clone(), node.start_byte()); + } return Ok(()); } _ => {} @@ -4918,24 +4926,18 @@ impl<'source> DirectEvidenceState<'source> { let mut current = if first == "self" { rust_callable_owner(owner)?.to_owned() } else { - let raw = self.rust_value_type_for(owner, first, use_start, Some(use_node))?; - let nominal = rust_nominal_type_path(raw)?; - if rust_primitive_type(&nominal) { - nominal - } else { - rust_qualify_evidence_path(self, owner, &nominal, use_start)? - } + self.rust_value_type_for(owner, first, use_start, Some(use_node))? + .to_owned() }; for field in fields.map(str::trim).filter(|field| !field.is_empty()) { - let raw = self.rust_field_types.get(¤t)?.get(field)?; - let nominal = rust_nominal_type_path(raw)?; - current = if rust_primitive_type(&nominal) { - nominal - } else { - rust_qualify_evidence_path(self, owner, &nominal, use_start)? - }; + current = self.rust_field_type_for_access(owner, ¤t, field, use_start)?; + } + let nominal = rust_nominal_type_path(¤t)?; + if rust_primitive_type(&nominal) { + Some(nominal) + } else { + rust_qualify_evidence_path(self, owner, &nominal, use_start) } - Some(current) } fn rust_field_receiver_nominal_type( @@ -4954,13 +4956,41 @@ impl<'source> DirectEvidenceState<'source> { .to_owned() }; for field in fields.map(str::trim).filter(|field| !field.is_empty()) { - let nominal = rust_nominal_type_path(¤t)?; - let qualified = rust_qualify_evidence_path(self, owner, &nominal, use_start)?; - current = self.rust_field_types.get(&qualified)?.get(field)?.clone(); + current = self.rust_field_type_for_access(owner, ¤t, field, use_start)?; } rust_nominal_type_path(¤t) } + fn rust_field_type_for_access( + &self, + owner: &DeclarationContext, + raw_receiver: &str, + field: &str, + use_start: usize, + ) -> Option { + let mut raw_receiver = raw_receiver.trim(); + for _ in 0..16 { + let nominal = rust_nominal_type_path(raw_receiver)?; + let qualified = if rust_primitive_type(&nominal) { + nominal + } else { + rust_qualify_evidence_path(self, owner, &nominal, use_start)? + }; + if let Some(field_type) = self + .rust_field_types + .get(&qualified) + .and_then(|fields| fields.get(field)) + { + return Some(field_type.clone()); + } + if !rust_source_proven_deref_wrapper(&qualified) { + return None; + } + raw_receiver = rust_single_generic_type_argument(raw_receiver)?; + } + None + } + fn collect_rust_parameter_value_types(&mut self, parameters: Node<'_>, scope_id: &str) { let mut cursor = parameters.walk(); for parameter in parameters @@ -5604,28 +5634,42 @@ impl<'source> DirectEvidenceState<'source> { } else { None }; - let qualified_name = platform_reexport_bindings - .as_ref() - .map_or_else( - || { - self.rust_call_qualified_name( - owner, - qualifier, - spelling, - function.start_byte(), - function, - ) - }, - |_| None, - ) - .or_else(|| { - wildcard_binding.is_some().then(|| { - qualifier.map_or_else( - || spelling.to_owned(), - |qualifier| rust_join_qualified(qualifier, spelling), - ) + let call_result_qualified_name = call_result_binding.as_deref().and_then(|binding_id| { + self.builder + .batch + .bindings + .iter() + .find(|binding| binding.id == binding_id) + .and_then(|binding| binding.result_type_qualified_name.as_deref()) + .map(|receiver| { + self.rust_receiver_method_target(receiver, spelling) + .unwrap_or_else(|| rust_join_qualified(receiver, spelling)) }) - }); + }); + let qualified_name = call_result_qualified_name.or_else(|| { + platform_reexport_bindings + .as_ref() + .map_or_else( + || { + self.rust_call_qualified_name( + owner, + qualifier, + spelling, + function.start_byte(), + function, + ) + }, + |_| None, + ) + .or_else(|| { + wildcard_binding.is_some().then(|| { + qualifier.map_or_else( + || spelling.to_owned(), + |qualifier| rust_join_qualified(qualifier, spelling), + ) + }) + }) + }); let wildcard_bound = wildcard_binding.is_some(); let wildcard_external_target_is_explicit = qualifier.is_some_and(|value| { qualified_binding_head(value) @@ -6163,9 +6207,246 @@ impl<'source> DirectEvidenceState<'source> { }), }, )?; + if self.rust_macro_is_evaluated(owner, raw_path, node.start_byte()) + && let Some(token_tree) = first_named_child_of_kind(node, "token_tree") + { + self.walk_rust_evaluated_macro_tokens(token_tree, owner, 0)?; + } + Ok(()) + } + + fn rust_macro_is_evaluated( + &self, + owner: &DeclarationContext, + raw_path: &str, + use_start: usize, + ) -> bool { + let (qualifier, spelling) = split_qualified(raw_path); + let target = qualifier + .and_then(|qualifier| { + self.imported_qualified_target_for(owner, qualifier, use_start, true) + }) + .map(|target| rust_join_qualified(&target, spelling)) + .or_else(|| { + self.imported_target_for_occurrence(owner, spelling, use_start, true) + .cloned() + }) + .or_else(|| self.local_target_for(owner, spelling).cloned()); + target.is_some_and(|target| { + self.rust_evaluated_macros + .get(&target) + .is_some_and(|defined_at| *defined_at <= use_start) + }) + } + + fn walk_rust_evaluated_macro_tokens( + &mut self, + token_tree: Node<'_>, + owner: &DeclarationContext, + depth: usize, + ) -> Result<(), EvidenceError> { + if depth >= 64 { + return Err(EvidenceError::new( + EvidenceErrorCode::ResourceLimit, + "Rust evaluated macro token tree exceeds depth limit", + )); + } + let mut cursor = token_tree.walk(); + let children = token_tree.children(&mut cursor).collect::>(); + for (index, child) in children.iter().copied().enumerate() { + if child.kind() != "token_tree" { + continue; + } + let nested_macro = index >= 2 + && self.text(children[index.saturating_sub(1)]) == "!" + && rust_macro_token_identifier(children[index.saturating_sub(2)].kind()); + if nested_macro { + let nested_name = self.text(children[index.saturating_sub(2)]); + if self.rust_macro_is_evaluated(owner, &nested_name, child.start_byte()) { + self.walk_rust_evaluated_macro_tokens(child, owner, depth.saturating_add(1))?; + } + continue; + } + if self.text(child).starts_with('(') + && index > 0 + && rust_macro_token_identifier(children[index.saturating_sub(1)].kind()) + { + let function_end = children[index.saturating_sub(1)].end_byte(); + let mut function_start_index = index.saturating_sub(1); + while function_start_index >= 2 + && matches!( + self.text(children[function_start_index.saturating_sub(1)]) + .as_str(), + "." | "::" + ) + && rust_macro_token_identifier( + children[function_start_index.saturating_sub(2)].kind(), + ) + { + function_start_index = function_start_index.saturating_sub(2); + } + let function_start = children[function_start_index].start_byte(); + if let Some(raw) = self + .source + .get(function_start..function_end) + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) + { + let (qualifier, spelling) = split_qualified(&raw); + if !spelling.is_empty() { + self.add_rust_evaluated_macro_call( + owner, + children[index.saturating_sub(1)], + function_start, + function_end, + qualifier, + spelling, + )?; + } + } + } + self.walk_rust_evaluated_macro_tokens(child, owner, depth.saturating_add(1))?; + } + Ok(()) + } + + fn add_rust_evaluated_macro_call( + &mut self, + owner: &DeclarationContext, + use_node: Node<'_>, + function_start: usize, + function_end: usize, + qualifier: Option<&str>, + spelling: &str, + ) -> Result<(), EvidenceError> { + let binding_name = qualifier.map(qualified_binding_head).unwrap_or(spelling); + if self.import_binding_is_ambiguous(owner, binding_name) { + return Ok(()); + } + let binding = self + .binding_for_occurrence(owner, binding_name, function_start, true) + .or_else(|| self.rust_wildcard_binding(owner, function_start)) + .cloned(); + let qualified_name = qualifier + .filter(|qualifier| qualifier.contains('.')) + .and_then(|qualifier| { + self.rust_field_receiver_type(owner, qualifier, function_start, use_node) + .or_else(|| { + self.rust_unique_macro_field_receiver_type(owner, qualifier, function_start) + }) + }) + .map(|receiver| { + self.rust_receiver_method_target(&receiver, spelling) + .unwrap_or_else(|| rust_join_qualified(&receiver, spelling)) + }) + .or_else(|| { + self.rust_call_qualified_name(owner, qualifier, spelling, function_start, use_node) + }); + let occurrence_id = self.builder.occur_with_context( + SemanticRole::Call, + &owner.fact_id, + spelling, + qualifier, + Some(&owner.scope_id), + Some("rust-source-evaluated-macro-input"), + range_for_byte_span(self.source_file, self.source, function_start, function_end), + )?; + let constraints = ResolutionConstraint { + exact_target_declaration_id: None, + exact_language: Some(self.language.to_owned()), + module_or_package: qualified_name + .as_deref() + .and_then(|qualified| rust_qualified_parent(qualified).map(str::to_owned)) + .or_else(|| Some(self.module_or_package.clone())), + scope_id: Some(owner.scope_id.clone()), + qualified_name: qualified_name.clone(), + argument_count: None, + argument_types: Vec::new(), + allowed_target_kinds: vec![ + "enum_member".to_owned(), + "function".to_owned(), + "method".to_owned(), + "struct".to_owned(), + ], + hierarchy: None, + allow_external: qualified_name.as_deref().is_some_and(|qualified| { + !rust_identity_is_internal(&self.module_or_package, qualified) + }), + }; + self.builder.relate( + CandidateRelation::Calls, + &owner.fact_id, + Some(&occurrence_id), + binding.as_deref(), + spelling, + constraints.clone(), + )?; + if self.rust_test_declarations.contains(&owner.fact_id) { + self.builder.relate( + CandidateRelation::Tests, + &owner.fact_id, + Some(&occurrence_id), + binding.as_deref(), + spelling, + constraints, + )?; + } Ok(()) } + fn rust_unique_macro_field_receiver_type( + &self, + owner: &DeclarationContext, + qualifier: &str, + use_start: usize, + ) -> Option { + let fields = qualifier + .split('.') + .skip(1) + .map(str::trim) + .filter(|field| !field.is_empty()) + .collect::>(); + if fields.is_empty() { + return None; + } + let mut roots = self.rust_field_types.keys().cloned().collect::>(); + roots.sort_unstable(); + let mut receivers = Vec::new(); + for root in roots { + let mut current = root; + let mut complete = true; + for field in &fields { + let Some(next) = self.rust_field_type_for_access(owner, ¤t, field, use_start) + else { + complete = false; + break; + }; + current = next; + } + if !complete { + continue; + } + let Some(nominal) = rust_nominal_type_path(¤t) else { + continue; + }; + let receiver = if rust_primitive_type(&nominal) { + nominal + } else if let Some(receiver) = + rust_qualify_evidence_path(self, owner, &nominal, use_start) + { + receiver + } else { + continue; + }; + receivers.push(receiver); + } + receivers.sort_unstable(); + receivers.dedup(); + let [receiver] = receivers.as_slice() else { + return None; + }; + Some(receiver.clone()) + } + fn add_rust_path_candidate( &mut self, role: SemanticRole, @@ -9027,6 +9308,149 @@ fn rust_nominal_type_path(raw: &str) -> Option { .then_some(nominal) } +fn rust_single_generic_type_argument(raw: &str) -> Option<&str> { + let raw = raw.trim(); + let open = raw.find('<')?; + let mut depth = 0_u16; + let mut close = None; + for (offset, character) in raw.char_indices().skip_while(|(offset, _)| *offset < open) { + match character { + '<' => depth = depth.checked_add(1)?, + '>' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + close = Some(offset); + break; + } + } + ',' if depth == 1 => return None, + _ => {} + } + } + let close = close?; + if !raw.get(close.saturating_add(1)..)?.trim().is_empty() { + return None; + } + let argument = raw.get(open.saturating_add(1)..close)?.trim(); + (!argument.is_empty()).then_some(argument) +} + +fn rust_source_proven_deref_wrapper(qualified: &str) -> bool { + matches!( + qualified, + "std::sync::Arc" + | "alloc::sync::Arc" + | "std::rc::Rc" + | "alloc::rc::Rc" + | "std::boxed::Box" + | "alloc::boxed::Box" + ) +} + +fn rust_macro_token_identifier(kind: &str) -> bool { + matches!(kind, "identifier" | "self" | "super" | "crate" | "Self") +} + +fn rust_macro_evaluates_single_code_fragment(node: Node<'_>, source: &[u8]) -> bool { + let mut cursor = node.walk(); + let rules = node + .children(&mut cursor) + .filter(|child| child.kind() == "macro_rule") + .collect::>(); + let [rule] = rules.as_slice() else { + return false; + }; + let Some(pattern) = first_named_child_of_kind(*rule, "token_tree_pattern") else { + return false; + }; + let Some(expansion) = first_named_child_of_kind(*rule, "token_tree") else { + return false; + }; + let mut bindings = Vec::new(); + collect_rust_macro_fragment_bindings(pattern, source, &mut bindings); + let code_bindings = bindings + .iter() + .filter(|(_, fragment)| matches!(fragment.as_str(), "expr" | "stmt")) + .collect::>(); + let [code_binding] = code_bindings.as_slice() else { + return false; + }; + if bindings + .iter() + .any(|(_, fragment)| !matches!(fragment.as_str(), "expr" | "stmt" | "ident" | "pat_param")) + { + return false; + } + rust_macro_expansion_evaluates_metavariable(expansion, &code_binding.0, source) +} + +fn collect_rust_macro_fragment_bindings( + node: Node<'_>, + source: &[u8], + output: &mut Vec<(String, String)>, +) { + if node.kind() == "token_binding_pattern" { + let mut cursor = node.walk(); + let mut variable = None; + let mut fragment = None; + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + match child.kind() { + "metavariable" => variable = rust_node_text(source, child), + "fragment_specifier" => fragment = rust_node_text(source, child), + _ => {} + } + } + if let (Some(variable), Some(fragment)) = (variable, fragment) { + output.push((variable, fragment)); + } + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_rust_macro_fragment_bindings(child, source, output); + } +} + +fn rust_macro_expansion_evaluates_metavariable( + node: Node<'_>, + variable: &str, + source: &[u8], +) -> bool { + if node.kind() == "metavariable" + && rust_node_text(source, node).as_deref() == Some(variable) + && !rust_macro_metavariable_is_nested_macro_input(node) + { + return true; + } + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named()) + .any(|child| rust_macro_expansion_evaluates_metavariable(child, variable, source)) +} + +fn rust_macro_metavariable_is_nested_macro_input(mut node: Node<'_>) -> bool { + for _ in 0..64 { + let Some(parent) = node.parent() else { + return false; + }; + if parent.kind() == "token_tree" + && parent + .prev_sibling() + .is_some_and(|sibling| sibling.kind() == "!") + { + return true; + } + node = parent; + } + true +} + +fn rust_node_text(source: &[u8], node: Node<'_>) -> Option { + source + .get(node.start_byte()..node.end_byte()) + .map(|bytes| String::from_utf8_lossy(bytes).into_owned()) +} + fn rust_return_receiver_type_path(raw: &str) -> Option { let raw = raw.trim(); if raw.starts_with("*mut ") || raw.starts_with("*const ") { @@ -9394,6 +9818,7 @@ fn rust_qualify_evidence_path( } if qualifier.is_none() { let prelude_type = match binding_name { + "Box" => Some("std::boxed::Box"), "Option" => Some("std::option::Option"), "Result" => Some("std::result::Result"), _ => None, diff --git a/crates/compass-languages/src/evidence_pipeline.rs b/crates/compass-languages/src/evidence_pipeline.rs index 5b4cb60f..13863a56 100644 --- a/crates/compass-languages/src/evidence_pipeline.rs +++ b/crates/compass-languages/src/evidence_pipeline.rs @@ -511,7 +511,7 @@ const UNIVERSAL_EVIDENCE_PIPELINES: &[UniversalEvidencePipeline] = &[ producer: UniversalEvidenceProducer { id: "compass.rust", language: "rust", - version: 1, + version: 2, evidence_schema: crate::UNIVERSAL_EVIDENCE_SCHEMA, capabilities: RUST_CAPABILITIES, }, diff --git a/crates/compass-languages/tests/registry.rs b/crates/compass-languages/tests/registry.rs index 34b547f4..75ec5056 100644 --- a/crates/compass-languages/tests/registry.rs +++ b/crates/compass-languages/tests/registry.rs @@ -114,12 +114,12 @@ fn ids_match_python_unicode_casefold_contract() { } #[test] -fn rust_pipeline_is_version_one_and_qualified() { +fn rust_pipeline_is_version_two_and_qualified() { let rust = UniversalEvidenceRegistry::pipeline("rust").expect("Rust universal pipeline"); assert_eq!(rust.producer.id, "compass.rust"); assert_eq!(rust.producer.language, "rust"); assert_eq!(rust.producer.evidence_schema, UNIVERSAL_EVIDENCE_SCHEMA); - assert_eq!(rust.producer.version, 1); + assert_eq!(rust.producer.version, 2); assert_eq!( rust.qualification, UniversalEvidenceQualification::Qualified diff --git a/crates/compass-languages/tests/rust_universal_conformance.rs b/crates/compass-languages/tests/rust_universal_conformance.rs index 823faeeb..1f1869a8 100644 --- a/crates/compass-languages/tests/rust_universal_conformance.rs +++ b/crates/compass-languages/tests/rust_universal_conformance.rs @@ -32,7 +32,7 @@ fn build() { .ok_or("missing Rust semantic evidence")?; assert_eq!(evidence.pipeline.id, "compass.rust"); - assert_eq!(evidence.pipeline.version, 1); + assert_eq!(evidence.pipeline.version, 2); assert_eq!( evidence.pipeline.evidence_schema, "compass.languages.evidence/2" @@ -153,6 +153,145 @@ fn unknown(input: T) { input.transform().finish(); } Ok(()) } +#[test] +fn source_proven_arc_field_chains_reach_the_inner_receiver() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("arc_field.rs"); + let source = br#"use std::{rc::Rc, sync::Arc}; +struct Worker; +impl Worker { fn run(&self) {} } +struct Inner { worker: Worker } +struct Outer { arc: Arc, rc: Rc, boxed: Box } +impl Outer { + fn invoke(&self) { + self.arc.worker.run(); + self.rc.worker.run(); + self.boxed.worker.run(); + } +} +"#; + let extraction = Engine::default().extract_source(&path, source)?; + let evidence = extraction + .semantic_evidence + .as_ref() + .ok_or("missing Rust semantic evidence")?; + let calls = evidence + .candidates + .iter() + .filter(|candidate| { + candidate.relation == CandidateRelation::Calls && candidate.target_spelling == "run" + }) + .collect::>(); + assert_eq!(calls.len(), 3, "calls={calls:#?}"); + assert!(calls.iter().all(|call| { + call.constraints.qualified_name.as_deref() == Some("crate::arc_field::Worker::run") + })); + Ok(()) +} + +#[test] +fn source_proven_clone_results_keep_the_receiver_type() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("clone_chain.rs"); + let source = br#"use std::sync::Arc; +struct Worker; +impl Worker { fn run(self) {} } +impl Clone for Worker { fn clone(&self) -> Self { Worker } } +struct Inner { worker: Worker } +struct Outer { inner: Arc } +impl Outer { fn invoke(&self) { self.inner.worker.clone().run(); } } +"#; + let extraction = Engine::default().extract_source(&path, source)?; + let evidence = extraction + .semantic_evidence + .as_ref() + .ok_or("missing Rust semantic evidence")?; + let binding = evidence + .bindings + .iter() + .find(|binding| { + binding.kind == BindingKind::CallResult + && binding.spelling == "self.inner.worker.clone()" + }) + .ok_or("missing clone call-result binding")?; + assert_eq!( + binding.result_type_qualified_name.as_deref(), + Some("crate::clone_chain::Worker") + ); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Calls + && candidate.target_spelling == "run" + && candidate.constraints.qualified_name.as_deref() + == Some("crate::clone_chain::Worker::run") + })); + Ok(()) +} + +#[test] +fn source_proven_expression_macro_arguments_emit_nested_calls() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("macro_call.rs"); + let source = br#"struct PathRouter; +impl PathRouter { fn route(&self) {} } +struct RouterInner { path_router: PathRouter } +macro_rules! evaluate { ($expr:expr) => { match $expr { () => () } }; } +macro_rules! tap_inner { + ($self_:ident, mut $inner:ident => { $($stmt:stmt)* }) => {{ $($stmt)* }}; +} +fn invoke(owner: RouterInner) { + tap_inner!(owner, mut this => { evaluate!(this.path_router.route()); }); +} +"#; + let extraction = Engine::default().extract_source(&path, source)?; + let evidence = extraction + .semantic_evidence + .as_ref() + .ok_or("missing Rust semantic evidence")?; + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Calls + && candidate.target_spelling == "route" + && candidate.constraints.qualified_name.as_deref() + == Some("crate::macro_call::PathRouter::route") + })); + Ok(()) +} + +#[test] +fn non_evaluating_or_ambiguous_macro_inputs_fail_closed() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("macro_negative.rs"); + let source = br#"struct AlphaWorker; +impl AlphaWorker { fn run(&self) {} } +struct BetaWorker; +impl BetaWorker { fn run(&self) {} } +struct Alpha { worker: AlphaWorker } +struct Beta { worker: BetaWorker } +macro_rules! stringify_only { ($expr:expr) => { stringify!($expr) }; } +macro_rules! evaluate { ($name:ident, $stmt:stmt) => {{ $stmt }}; } +fn invoke(alpha: Alpha) { + stringify_only!(alpha.worker.run()); + evaluate!(unknown, unknown.worker.run()); +} +fn before_definition(alpha: Alpha) { later!(alpha.worker.run()); } +macro_rules! later { ($expr:expr) => {{ $expr }}; } +"#; + let extraction = Engine::default().extract_source(&path, source)?; + let evidence = extraction + .semantic_evidence + .as_ref() + .ok_or("missing Rust semantic evidence")?; + assert!(evidence.candidates.iter().all(|candidate| { + candidate.relation != CandidateRelation::Calls + || candidate.target_spelling != "run" + || !matches!( + candidate.constraints.qualified_name.as_deref(), + Some("crate::macro_negative::AlphaWorker::run") + | Some("crate::macro_negative::BetaWorker::run") + ) + })); + Ok(()) +} + #[test] fn raw_pointer_returns_are_not_retyped_as_the_pointee_receiver() -> Result<(), Box> { let directory = tempfile::tempdir()?; diff --git a/crates/compass-languages/tests/semantic_producers.rs b/crates/compass-languages/tests/semantic_producers.rs index 65f8fd96..1a8e8ade 100644 --- a/crates/compass-languages/tests/semantic_producers.rs +++ b/crates/compass-languages/tests/semantic_producers.rs @@ -197,7 +197,7 @@ fn build(mut graph: Graph) { "compass.languages.evidence/2" ); assert_eq!(evidence.pipeline.id, "compass.rust"); - assert_eq!(evidence.pipeline.version, 1); + assert_eq!(evidence.pipeline.version, 2); let calls = evidence .occurrences diff --git a/crates/compass-languages/tests/universal_evidence.rs b/crates/compass-languages/tests/universal_evidence.rs index 99a6d1ac..bb4a8fff 100644 --- a/crates/compass-languages/tests/universal_evidence.rs +++ b/crates/compass-languages/tests/universal_evidence.rs @@ -527,7 +527,7 @@ fn universal_evidence_pipelines_are_unique_sorted_and_truthful() { ); assert_eq!( UniversalEvidenceRegistry::pipeline("rust").map(|pipeline| pipeline.producer.version), - Some(1) + Some(2) ); assert_eq!( UniversalEvidenceRegistry::pipeline("javascript") diff --git a/docs/reference/universal-semantic-evidence.md b/docs/reference/universal-semantic-evidence.md index da58ca58..cd23aaa2 100644 --- a/docs/reference/universal-semantic-evidence.md +++ b/docs/reference/universal-semantic-evidence.md @@ -11,7 +11,7 @@ artifacts captured in the mounted qualification target for that decision. This is a hard-cutover interface. It has no raw-fact translation layer, shadow mode, terminal-name fallback, or runtime dependency on Graphify. -## Promotion decision (2026-08-28) +## Promotion decision (2026-09-15) The checked-in decision record is the machine-readable source of truth: [`tests/qualification/universal-evidence-promotion.json`](../../tests/qualification/universal-evidence-promotion.json). @@ -31,7 +31,7 @@ match it exactly. | PHP | `compass.php` | 1 | `Qualified` | | Python | `compass.python` | 1 | `Qualified` | | Ruby | `compass.ruby` | 1 | `Qualified` | -| Rust | `compass.rust` | 1 | `Qualified` | +| Rust | `compass.rust` | 2 | `Qualified` | | Scala | `compass.scala` | 1 | `Qualified` | | Swift | `compass.swift` | 1 | `Qualified` | | TypeScript | `compass.typescript` | 1 | `Qualified` | @@ -193,6 +193,15 @@ proves that ownership. Unshadowed `Option` and `Result` use their canonical standard-library identities; other unproven spellings remain unresolved rather than becoming crate-qualified placeholders. +Rust producer version 2 follows fields through source-proven standard-library +`Arc`, `Rc`, and `Box` dereference wrappers and carries a unique source-visible +call-result type into the next member call. It also inspects a local +`macro_rules!` input only when a single `expr` or `stmt` fragment is proven to +be substituted into an evaluating position. Nested non-evaluating macros, +multiple rules, unsupported fragment shapes, and ambiguous field owners remain +unresolved. Calls recovered from evaluated macro inputs retain their original +byte range and use the ordinary exact resolver. + ### Required invariants `validate_evidence` rejects a batch when any of these conditions is false: diff --git a/scripts/check_universal_evidence_promotion.py b/scripts/check_universal_evidence_promotion.py index 0ad2cc82..ff2465bc 100644 --- a/scripts/check_universal_evidence_promotion.py +++ b/scripts/check_universal_evidence_promotion.py @@ -23,7 +23,7 @@ REVIEW = { "status": "approved", "method": "source-oracle-audits;deterministic-conformance;registry-parity", - "reviewedAt": "2026-08-28", + "reviewedAt": "2026-09-15", } MAX_MANIFEST_BYTES = 1024 * 1024 @@ -38,7 +38,7 @@ ("compass.php", "php", 1), ("compass.python", "python", 1), ("compass.ruby", "ruby", 1), - ("compass.rust", "rust", 1), + ("compass.rust", "rust", 2), ("compass.scala", "scala", 1), ("compass.swift", "swift", 1), ("compass.typescript", "typescript", 1), diff --git a/scripts/tests/test_universal_evidence_promotion.py b/scripts/tests/test_universal_evidence_promotion.py index a5be39f5..ded3d79e 100644 --- a/scripts/tests/test_universal_evidence_promotion.py +++ b/scripts/tests/test_universal_evidence_promotion.py @@ -24,7 +24,13 @@ def test_checked_in_decision_promotes_every_registry_pipeline(self) -> None: self.assertEqual(document["review"]["status"], "approved") self.assertEqual(len(document["pipelines"]), 14) self.assertTrue(all(item["decision"] == "qualified" for item in document["pipelines"])) - self.assertTrue(all(item["producerVersion"] == 1 for item in document["pipelines"])) + versions = { + item["language"]: item["producerVersion"] for item in document["pipelines"] + } + self.assertEqual(versions["rust"], 2) + self.assertTrue( + all(version == 1 for language, version in versions.items() if language != "rust") + ) def test_pipeline_order_and_versions_are_contractual(self) -> None: document = load(MANIFEST) diff --git a/tests/qualification/universal-evidence-promotion.json b/tests/qualification/universal-evidence-promotion.json index 550dd0d4..6646ad24 100644 --- a/tests/qualification/universal-evidence-promotion.json +++ b/tests/qualification/universal-evidence-promotion.json @@ -1,13 +1,13 @@ { "schema": "compass.universal-evidence-promotion/1", "decision": "promote", - "decisionId": "universal-evidence-v1-reset-2026-08-28", - "decisionDate": "2026-08-28", + "decisionId": "rust-v2-receiver-and-macro-evidence-2026-09-15", + "decisionDate": "2026-09-15", "scope": "advertised-bounded-capabilities", "review": { "status": "approved", "method": "source-oracle-audits;deterministic-conformance;registry-parity", - "reviewedAt": "2026-08-28" + "reviewedAt": "2026-09-15" }, "evidenceSchema": "compass.languages.evidence/2", "requiredGates": { @@ -41,7 +41,7 @@ { "id": "compass.php", "language": "php", "producerVersion": 1, "decision": "qualified", "evidence": "accepted" }, { "id": "compass.python", "language": "python", "producerVersion": 1, "decision": "qualified", "evidence": "accepted" }, { "id": "compass.ruby", "language": "ruby", "producerVersion": 1, "decision": "qualified", "evidence": "accepted" }, - { "id": "compass.rust", "language": "rust", "producerVersion": 1, "decision": "qualified", "evidence": "accepted" }, + { "id": "compass.rust", "language": "rust", "producerVersion": 2, "decision": "qualified", "evidence": "accepted" }, { "id": "compass.scala", "language": "scala", "producerVersion": 1, "decision": "qualified", "evidence": "accepted" }, { "id": "compass.swift", "language": "swift", "producerVersion": 1, "decision": "qualified", "evidence": "accepted" }, { "id": "compass.typescript", "language": "typescript", "producerVersion": 1, "decision": "qualified", "evidence": "accepted" }