diff --git a/CHANGELOG.md b/CHANGELOG.md index d975fb22..0571ba5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## Unreleased +- Hard-cut C# production extraction to the version-1 universal candidate with + bounded AST-backed declarations, namespaces, imports and aliases, overload + signatures, typed members and parameters, construction and call evidence, + base types, ownership, and explicit overrides. Remove the replaced C# raw + graph, namespace canonicalizer, and member type-table paths. Convert ASP.NET + MVC routing to the universal `aspnet-csharp` pack with evidence-backed + attributes, aliases, `AcceptVerbs`, controller/action token composition, + absolute templates, overload-safe handler resolution, and `[NonAction]`. + Extend universal evidence with typed override roles and relationships, and + move XAML code-behind lookup onto the same exact C# ownership facts. Keep + sourceless external import identities scoped to their exact wiring sites so + imports in different files cannot merge their provenance. + ## 0.3.12 - 2026-08-13 - Remove the 2 GiB aggregate canonical-payload limit from current SQLite diff --git a/crates/compass-graph/src/v1.rs b/crates/compass-graph/src/v1.rs index bb17eef0..9f42e4b8 100644 --- a/crates/compass-graph/src/v1.rs +++ b/crates/compass-graph/src/v1.rs @@ -4520,6 +4520,8 @@ fn node_identity( domain_id(kind, source_path, &positional_name) } NodeKind::Import | NodeKind::Export => { + let identity_source = + node_identity_source(attributes, source_path, record, identity_site); let mut binding = match details { Some(NodeDetails::ImportExport(details)) => format!( "{}:{}:{}", @@ -4535,7 +4537,7 @@ fn node_identity( } symbol_id( language.unwrap_or("unknown"), - source_path, + &identity_source, kind, qualified_name, &binding, @@ -4551,20 +4553,8 @@ fn node_identity( domain_id(kind, &namespace, qualified_name) } _ => { - let unresolved_scope; - let identity_source = if source_path.is_empty() { - unresolved_scope = optional_string(attributes, "external_identity_scope") - .filter(|scope| !scope.trim().is_empty()) - .unwrap_or_else(|| { - identity_site.map_or_else( - || format!("unresolved:{record}"), - |site| format!("{}#{}:{}", site.file, site.start_byte, site.end_byte), - ) - }); - unresolved_scope.as_str() - } else { - source_path - }; + let identity_source = + node_identity_source(attributes, source_path, record, identity_site); let overload = optional_string(attributes, "overload_discriminator"); let lexical_owner = optional_any_string(attributes, &["lexical_owner", "declaring_scope"]); @@ -4576,7 +4566,7 @@ fn node_identity( }; symbol_id( language.unwrap_or("unknown"), - identity_source, + &identity_source, kind, qualified_name, &disambiguator, @@ -4586,6 +4576,25 @@ fn node_identity( Ok(id) } +fn node_identity_source( + attributes: &Map, + source_path: &str, + record: &str, + identity_site: Option<&SourceAnchor>, +) -> String { + if !source_path.is_empty() { + return source_path.to_owned(); + } + optional_string(attributes, "external_identity_scope") + .filter(|scope| !scope.trim().is_empty()) + .unwrap_or_else(|| { + identity_site.map_or_else( + || format!("unresolved:{record}"), + |site| format!("{}#{}:{}", site.file, site.start_byte, site.end_byte), + ) + }) +} + fn raw_markdown_heading(attributes: &Map) -> bool { optional_any_string(attributes, &["language", "lang"]).as_deref() == Some("markdown") && optional_string(attributes, "document_kind").as_deref() == Some("heading") diff --git a/crates/compass-graph/tests/graph_v1_normalization.rs b/crates/compass-graph/tests/graph_v1_normalization.rs index e952cb87..5e5ee437 100644 --- a/crates/compass-graph/tests/graph_v1_normalization.rs +++ b/crates/compass-graph/tests/graph_v1_normalization.rs @@ -2046,6 +2046,90 @@ fn sourceless_placeholder_identity_never_merges_same_name_across_source_files() Ok(()) } +#[test] +fn sourceless_import_placeholder_identity_never_merges_across_source_files() +-> Result<(), Box> { + let directory = tempfile::tempdir()?; + let root = directory.path(); + let external_name = "Microsoft.AspNetCore.Mvc"; + let mut external = raw_external_node("raw:mvc", external_name); + external.attributes.extend(Map::from_iter([ + ("symbol_kind".to_owned(), json!("import")), + ("language".to_owned(), json!("csharp")), + ( + "extractor".to_owned(), + json!("compass.resolve.csharp.universal"), + ), + ("_origin".to_owned(), json!("ast")), + ("confidence".to_owned(), json!("EXTRACTED")), + ("_canonical_external_symbol".to_owned(), json!(true)), + ])); + let graph = Extraction { + nodes: vec![ + raw_file_node(root, "raw:first", "src/FirstController.cs"), + raw_file_node(root, "raw:second", "src/SecondController.cs"), + external, + ], + edges: vec![ + raw_php_edge( + root, + "src/FirstController.cs", + "raw:first", + "raw:mvc", + "imports_from", + 0, + ), + raw_php_edge( + root, + "src/SecondController.cs", + "raw:second", + "raw:mvc", + "imports_from", + 0, + ), + ], + ..Extraction::default() + }; + let mut evidence = build_evidence(root)?; + add_inventory_file( + root, + &mut evidence, + "src/FirstController.cs", + "csharp", + b'f', + )?; + add_inventory_file( + root, + &mut evidence, + "src/SecondController.cs", + "csharp", + b's', + )?; + + let document = normalize_v1(graph, evidence)?; + let external = document + .nodes + .iter() + .filter(|node| node.qualified_name == external_name) + .collect::>(); + assert_eq!(external.len(), 2, "nodes={:#?}", document.nodes); + assert!(external.iter().all(|node| { + node.evidence + .iter() + .filter(|evidence| evidence.rule.as_deref() == Some("external-symbol-placeholder")) + .count() + == 1 + })); + let targets = document + .links + .iter() + .map(|edge| edge.target.as_str()) + .collect::>(); + assert_eq!(targets.len(), 2); + assert!(document.links.iter().all(|edge| edge.deferred)); + Ok(()) +} + #[test] fn sourceless_implemented_placeholder_infers_a_deferred_interface() -> Result<(), Box> { diff --git a/crates/compass-languages/src/adapters.rs b/crates/compass-languages/src/adapters.rs index e92063d7..d05df3fa 100644 --- a/crates/compass-languages/src/adapters.rs +++ b/crates/compass-languages/src/adapters.rs @@ -120,6 +120,24 @@ const GO_CAPABILITIES: &[LanguageCapability] = &[ LanguageCapability::ExternalReferences, ]; +const CSHARP_CAPABILITIES: &[LanguageCapability] = &[ + LanguageCapability::Declarations, + LanguageCapability::LexicalScopes, + LanguageCapability::Namespaces, + LanguageCapability::Imports, + LanguageCapability::Aliases, + LanguageCapability::Calls, + LanguageCapability::Construction, + LanguageCapability::Decorators, + LanguageCapability::TypeReferences, + LanguageCapability::BaseTypes, + LanguageCapability::HierarchyDispatch, + LanguageCapability::Members, + LanguageCapability::Ownership, + LanguageCapability::Receivers, + LanguageCapability::ExternalReferences, +]; + const PYTHON_CAPABILITIES: &[LanguageCapability] = &[ LanguageCapability::Declarations, LanguageCapability::LexicalScopes, @@ -213,6 +231,14 @@ const TYPESCRIPT_CAPABILITIES: &[LanguageCapability] = &[ ]; const UNIVERSAL_ADAPTERS: &[AdapterProfile] = &[ + AdapterProfile { + id: "compass.csharp.candidate", + language: "csharp", + version: 1, + evidence_schema: crate::UNIVERSAL_EVIDENCE_SCHEMA, + profile: UniversalAdapterProfile::UniversalCandidate, + capabilities: CSHARP_CAPABILITIES, + }, AdapterProfile { id: "compass.go", language: "go", diff --git a/crates/compass-languages/src/csharp.rs b/crates/compass-languages/src/csharp.rs deleted file mode 100644 index d57f3a24..00000000 --- a/crates/compass-languages/src/csharp.rs +++ /dev/null @@ -1,980 +0,0 @@ -use std::collections::{HashMap, HashSet}; -use std::path::Path; - -use crate::{RawEdgeRecord as EdgeRecord, RawNodeRecord as NodeRecord}; -use serde_json::{Map, Value, json}; -use sha1::{Digest, Sha1}; -use tree_sitter::Node; - -use crate::{Extraction, RawCall, file_stem, make_id}; - -const TYPE_DECLARATIONS: &[&str] = &[ - "class_declaration", - "interface_declaration", - "enum_declaration", - "struct_declaration", - "record_declaration", -]; - -pub(crate) fn extract(path: &Path, source: &[u8], root: Node<'_>) -> Extraction { - let source_file = path.to_string_lossy().into_owned(); - let stem = file_stem(path); - let file_id = make_id(&[&source_file]); - let interface_names = collect_interface_names(root, source); - let mut state = State { - source, - source_file: source_file.clone(), - stem, - file_id: file_id.clone(), - extraction: Extraction::default(), - seen_nodes: HashSet::new(), - namespace_stack: Vec::new(), - scope_stack: Vec::new(), - interface_names, - function_bodies: Vec::new(), - }; - state.add_node( - file_id, - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or_default(), - 1, - None, - None, - ); - let mut cursor = root.walk(); - for child in root.children(&mut cursor) { - state.walk(child, None); - } - state.add_calls(); - let type_table = member_type_table(root, source); - if !type_table.is_empty() { - state.extraction.extensions.insert( - "csharp_type_table".to_owned(), - json!({"path": source_file, "table": type_table}), - ); - } - state.extraction -} - -struct FunctionBody<'tree> { - id: String, - node: Node<'tree>, -} - -struct State<'source, 'tree> { - source: &'source [u8], - source_file: String, - stem: String, - file_id: String, - extraction: Extraction, - seen_nodes: HashSet, - namespace_stack: Vec, - scope_stack: Vec, - interface_names: HashSet, - function_bodies: Vec>, -} - -impl<'tree> State<'_, 'tree> { - fn walk(&mut self, node: Node<'tree>, parent_type: Option<&str>) { - let kind = node.kind(); - if kind == "using_directive" { - self.add_using(node); - return; - } - if matches!( - kind, - "namespace_declaration" | "file_scoped_namespace_declaration" - ) { - self.add_namespace(node, parent_type); - return; - } - if TYPE_DECLARATIONS.contains(&kind) { - self.add_type(node, parent_type); - return; - } - if kind == "field_declaration" && parent_type.is_some() { - self.add_field_reference(node, parent_type.unwrap_or_default()); - return; - } - if kind == "property_declaration" && parent_type.is_some() { - self.add_property_references(node, parent_type.unwrap_or_default()); - return; - } - if kind == "method_declaration" { - self.add_method(node, parent_type); - return; - } - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - self.walk(child, parent_type); - } - } - - fn add_namespace(&mut self, node: Node<'tree>, parent_type: Option<&str>) { - let Some(name_node) = node - .child_by_field_name("name") - .or_else(|| first_child_matching(node, &["identifier", "qualified_name"])) - else { - return; - }; - let name = self.text(name_node).trim().to_owned(); - if name.is_empty() { - return; - } - self.namespace_stack.push(name); - self.scope_stack.push(format!("s{}", node.start_byte())); - let label = self.namespace_stack.join("."); - let id = namespace_id(&label); - let mut metadata = Map::new(); - metadata.insert( - "kind".to_owned(), - Value::String("csharp_namespace".to_owned()), - ); - self.add_node( - id.clone(), - &label, - node.start_position().row + 1, - Some("namespace"), - Some(metadata), - ); - self.add_edge( - &self.file_id.clone(), - &id, - "contains", - node.start_position().row + 1, - None, - None, - ); - if node.kind() == "file_scoped_namespace_declaration" { - return; - } - if let Some(body) = node.child_by_field_name("body") { - let mut cursor = body.walk(); - for child in body.children(&mut cursor) { - self.walk(child, parent_type); - } - } - self.namespace_stack.pop(); - self.scope_stack.pop(); - } - - fn add_using(&mut self, node: Node<'tree>) { - let mut text = self.text(node).trim().trim_end_matches(';').trim(); - if let Some(rest) = text.strip_prefix("global ") { - text = rest.trim(); - } - let Some(mut body) = text.strip_prefix("using") else { - return; - }; - body = body.trim(); - let (using_kind, alias, target) = if let Some(target) = body.strip_prefix("static ") { - ("static", None, target.trim()) - } else if let Some((alias, target)) = body.split_once('=') { - ("alias", Some(alias.trim()), target.trim()) - } else { - ("namespace", None, body) - }; - if target.is_empty() { - return; - } - let mut metadata = Map::new(); - metadata.insert( - "using_kind".to_owned(), - Value::String(using_kind.to_owned()), - ); - if let Some(alias) = alias { - metadata.insert("alias".to_owned(), Value::String(alias.to_owned())); - } - metadata.insert("target_fqn".to_owned(), Value::String(target.to_owned())); - metadata.insert( - "scope_kind".to_owned(), - Value::String( - if self.scope_stack.is_empty() { - "file" - } else { - "namespace" - } - .to_owned(), - ), - ); - if let Some(scope) = self.scope_stack.last() { - metadata.insert("scope_id".to_owned(), Value::String(scope.clone())); - } - self.add_edge( - &self.file_id.clone(), - &make_id(&[target]), - "imports", - node.start_position().row + 1, - Some("import"), - Some(metadata), - ); - } - - fn add_type(&mut self, node: Node<'tree>, parent_type: Option<&str>) { - let Some(name_node) = node.child_by_field_name("name") else { - return; - }; - let name = self.text(name_node).to_owned(); - let namespace = self.namespace_stack.join("."); - let id = make_id(&[&self.stem, &namespace, &name]); - let mut metadata = Map::new(); - if parent_type.is_some() { - metadata.insert("is_nested_type".to_owned(), Value::Bool(true)); - } - self.add_node( - id.clone(), - &name, - node.start_position().row + 1, - None, - Some(metadata), - ); - self.mark_callable(&id); - self.add_edge( - &self.file_id.clone(), - &id, - "contains", - node.start_position().row + 1, - None, - None, - ); - let type_parameters = type_parameters_in_scope(node, self.source); - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - if child.kind() != "base_list" { - continue; - } - let mut base_cursor = child.walk(); - for base in child.children(&mut base_cursor).filter(|base| { - matches!( - base.kind(), - "identifier" | "generic_name" | "qualified_name" - ) - }) { - let Some(reference) = read_type_name(base, self.source) else { - continue; - }; - if type_parameters.contains(&reference.name) { - continue; - } - let target = self.ensure_base(&reference.name); - let relation = if self.interface_names.contains(&reference.name) - || is_interface_convention(&reference.name) - { - "implements" - } else { - "inherits" - }; - self.add_edge( - &id, - &target, - relation, - node.start_position().row + 1, - None, - Some(reference.metadata()), - ); - if base.kind() == "generic_name" { - let mut references = Vec::new(); - collect_type_references( - base, - self.source, - true, - &type_parameters, - &mut references, - ); - for reference in references.into_iter().skip(1) { - let target = - self.ensure_named(&reference.name, node.start_position().row + 1); - self.add_edge( - &id, - &target, - "references", - node.start_position().row + 1, - Some("generic_arg"), - Some(reference.metadata()), - ); - } - } - } - } - if let Some(body) = node - .child_by_field_name("body") - .or_else(|| first_child_matching(node, &["declaration_list"])) - { - let mut cursor = body.walk(); - for child in body.children(&mut cursor) { - self.walk(child, Some(&id)); - } - } - } - - fn add_field_reference(&mut self, node: Node<'tree>, owner: &str) { - let type_node = node.child_by_field_name("type").or_else(|| { - first_child_matching(node, &["variable_declaration"]) - .and_then(|declaration| declaration.child_by_field_name("type")) - }); - let Some(reference) = type_node.and_then(|node| read_type_name(node, self.source)) else { - return; - }; - if type_parameters_in_scope(node, self.source).contains(&reference.name) { - return; - } - let target = self.ensure_named(&reference.name, node.start_position().row + 1); - self.add_edge( - owner, - &target, - "references", - node.start_position().row + 1, - Some("field"), - Some(reference.metadata()), - ); - } - - fn add_property_references(&mut self, node: Node<'tree>, owner: &str) { - let Some(type_node) = node.child_by_field_name("type") else { - return; - }; - let mut references = Vec::new(); - collect_type_references( - type_node, - self.source, - false, - &type_parameters_in_scope(node, self.source), - &mut references, - ); - for reference in references { - let target = self.ensure_named(&reference.name, node.start_position().row + 1); - if target != owner { - self.add_edge( - owner, - &target, - "references", - node.start_position().row + 1, - Some(if reference.generic { - "generic_arg" - } else { - "field" - }), - Some(reference.metadata()), - ); - } - } - } - - fn add_method(&mut self, node: Node<'tree>, parent_type: Option<&str>) { - let Some(name_node) = node.child_by_field_name("name") else { - return; - }; - let name = self.text(name_node).to_owned(); - let id = parent_type.map_or_else( - || make_id(&[&self.stem, &name]), - |owner| make_id(&[owner, &name]), - ); - self.add_node( - id.clone(), - &if parent_type.is_some() { - format!(".{name}()") - } else { - format!("{name}()") - }, - node.start_position().row + 1, - None, - None, - ); - self.mark_callable(&id); - self.add_edge( - parent_type.unwrap_or(&self.file_id.clone()), - &id, - if parent_type.is_some() { - "method" - } else { - "contains" - }, - node.start_position().row + 1, - None, - None, - ); - let skip = type_parameters_in_scope(node, self.source); - if let Some(parameters) = node.child_by_field_name("parameters") { - let mut cursor = parameters.walk(); - for parameter in parameters - .children(&mut cursor) - .filter(|parameter| parameter.kind() == "parameter") - { - let mut references = Vec::new(); - collect_type_references( - parameter.child_by_field_name("type").unwrap_or(parameter), - self.source, - false, - &skip, - &mut references, - ); - for reference in references { - let target = self.ensure_named(&reference.name, node.start_position().row + 1); - self.add_edge( - &id, - &target, - "references", - node.start_position().row + 1, - Some(if reference.generic { - "generic_arg" - } else { - "parameter_type" - }), - Some(reference.metadata()), - ); - } - } - } - if let Some(returns) = node.child_by_field_name("returns") { - let mut references = Vec::new(); - collect_type_references(returns, self.source, false, &skip, &mut references); - for reference in references { - let target = self.ensure_named(&reference.name, node.start_position().row + 1); - self.add_edge( - &id, - &target, - "references", - node.start_position().row + 1, - Some(if reference.generic { - "generic_arg" - } else { - "return_type" - }), - Some(reference.metadata()), - ); - } - } - for reference in attribute_references(node, self.source, &skip) { - let target = self.ensure_named(&reference.name, node.start_position().row + 1); - self.add_edge( - &id, - &target, - "references", - node.start_position().row + 1, - Some("attribute"), - Some(reference.metadata()), - ); - } - self.function_bodies.push(FunctionBody { id, node }); - } - - fn add_calls(&mut self) { - let mut callables: HashMap> = HashMap::new(); - for node in &self.extraction.nodes { - let label = node.label(); - if label.ends_with("()") { - callables - .entry(label.trim_matches(['.', '(', ')']).to_owned()) - .or_default() - .push(node.id.clone()); - } - } - let mut seen = HashSet::new(); - let functions = std::mem::take(&mut self.function_bodies); - for function in functions { - self.walk_calls(function.node, &function.id, &callables, &mut seen, true); - } - } - - fn walk_calls( - &mut self, - node: Node<'tree>, - caller: &str, - callables: &HashMap>, - seen: &mut HashSet<(String, String, usize, usize)>, - root: bool, - ) { - if !root && node.kind() == "method_declaration" { - return; - } - if node.kind() == "invocation_expression" { - let function = node.child_by_field_name("function"); - let mut callee = None; - let mut member = false; - let mut receiver = None; - if let Some(function) = function { - if function.kind() == "member_access_expression" { - member = true; - callee = function - .child_by_field_name("name") - .map(|name| self.text(name).to_owned()); - receiver = function - .child_by_field_name("expression") - .filter(|receiver| { - matches!(receiver.kind(), "identifier" | "this_expression") - }) - .map(|receiver| { - if receiver.kind() == "this_expression" { - "this".to_owned() - } else { - self.text(receiver).to_owned() - } - }); - } else if function.kind() == "identifier" { - callee = Some(self.text(function).to_owned()); - } - } - if let Some(callee) = callee.filter(|callee| !callee.is_empty()) { - let target = (!member || receiver.is_none()) - .then(|| callables.get(&callee)) - .flatten() - .filter(|targets| targets.len() == 1) - .and_then(|targets| targets.first()) - .filter(|target| target.as_str() != caller) - .cloned(); - if let Some(target) = target { - let pair = ( - caller.to_owned(), - target.clone(), - node.start_byte(), - node.end_byte(), - ); - if seen.insert(pair) { - self.add_edge( - caller, - &target, - "calls", - node.start_position().row + 1, - Some("call"), - None, - ); - crate::facts::stamp_last_edge_range(&mut self.extraction, node); - } - } else { - self.extraction.raw_calls_mut().push(RawCall { - caller_nid: caller.to_owned(), - callee, - is_member_call: Some(member), - source_file: self.source_file.clone(), - source_location: format!("L{}", node.start_position().row + 1), - receiver: Some(receiver), - receiver_type: None, - lang: Some("csharp".to_owned()), - extensions: crate::facts::node_range(node), - }); - } - } - } - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - self.walk_calls(child, caller, callables, seen, false); - } - } - - fn ensure_named(&mut self, name: &str, line: usize) -> String { - let local = make_id(&[&self.stem, &self.namespace_stack.join("."), name]); - if self.seen_nodes.contains(&local) { - return local; - } - let id = make_id(&[name]); - if !self.seen_nodes.contains(&id) { - let mut attributes = Map::new(); - attributes.insert("label".to_owned(), Value::String(name.to_owned())); - attributes.insert("file_type".to_owned(), Value::String("code".to_owned())); - attributes.insert("type".to_owned(), Value::String("class".to_owned())); - attributes.insert("source_file".to_owned(), Value::String(String::new())); - attributes.insert("source_location".to_owned(), Value::String(String::new())); - attributes.insert( - "origin_file".to_owned(), - Value::String(self.source_file.clone()), - ); - self.seen_nodes.insert(id.clone()); - self.extraction.nodes.push(NodeRecord { - id: id.clone(), - attributes, - }); - } - let _ = line; - id - } - - fn ensure_base(&mut self, name: &str) -> String { - let local = make_id(&[&self.stem, &self.namespace_stack.join("."), name]); - if self.seen_nodes.contains(&local) { - return local; - } - let id = make_id(&[name]); - if self.seen_nodes.insert(id.clone()) { - let mut attributes = Map::new(); - attributes.insert("label".to_owned(), Value::String(name.to_owned())); - attributes.insert("file_type".to_owned(), Value::String("code".to_owned())); - attributes.insert("type".to_owned(), Value::String("class".to_owned())); - attributes.insert("source_file".to_owned(), Value::String(String::new())); - attributes.insert("source_location".to_owned(), Value::String(String::new())); - attributes.insert( - "origin_file".to_owned(), - Value::String(self.source_file.clone()), - ); - self.extraction.nodes.push(NodeRecord { - id: id.clone(), - attributes, - }); - } - id - } - - fn add_node( - &mut self, - id: String, - label: &str, - line: usize, - node_type: Option<&str>, - metadata: Option>, - ) { - if !self.seen_nodes.insert(id.clone()) { - return; - } - let mut merged = metadata.unwrap_or_default(); - if !self.namespace_stack.is_empty() { - merged - .entry("namespace".to_owned()) - .or_insert_with(|| Value::String(self.namespace_stack.join("."))); - } - if !self.scope_stack.is_empty() && node_type != Some("namespace") { - merged - .entry("scope_chain".to_owned()) - .or_insert_with(|| json!(self.scope_stack)); - } - let mut attributes = Map::new(); - attributes.insert("label".to_owned(), Value::String(label.to_owned())); - attributes.insert("file_type".to_owned(), Value::String("code".to_owned())); - attributes.insert( - "source_file".to_owned(), - Value::String(self.source_file.clone()), - ); - attributes.insert( - "source_location".to_owned(), - Value::String(format!("L{line}")), - ); - if let Some(node_type) = node_type { - attributes.insert("type".to_owned(), Value::String(node_type.to_owned())); - } - if !merged.is_empty() { - attributes.insert("metadata".to_owned(), Value::Object(merged)); - } - self.extraction.nodes.push(NodeRecord { id, attributes }); - } - - fn mark_callable(&mut self, id: &str) { - if let Some(node) = self.extraction.nodes.iter_mut().find(|node| node.id == id) { - node.attributes - .insert("_callable".to_owned(), Value::Bool(true)); - } - } - - fn add_edge( - &mut self, - source: &str, - target: &str, - relation: &str, - line: usize, - context: Option<&str>, - metadata: Option>, - ) { - let mut attributes = Map::new(); - attributes.insert("relation".to_owned(), Value::String(relation.to_owned())); - attributes.insert( - "confidence".to_owned(), - Value::String("EXTRACTED".to_owned()), - ); - attributes.insert( - "source_file".to_owned(), - Value::String(self.source_file.clone()), - ); - attributes.insert( - "source_location".to_owned(), - Value::String(format!("L{line}")), - ); - attributes.insert("weight".to_owned(), json!(1.0)); - if let Some(context) = context { - attributes.insert("context".to_owned(), Value::String(context.to_owned())); - } - if let Some(metadata) = metadata.filter(|metadata| !metadata.is_empty()) { - attributes.insert("metadata".to_owned(), Value::Object(metadata)); - } - self.extraction.edges.push(EdgeRecord { - source: source.to_owned(), - target: target.to_owned(), - attributes, - }); - } - - fn text(&self, node: Node<'_>) -> &str { - node.utf8_text(self.source).unwrap_or_default() - } -} - -#[derive(Clone)] -struct TypeReference { - name: String, - generic: bool, - qualified: bool, - qualifier: String, -} - -impl TypeReference { - fn metadata(&self) -> Map { - let mut metadata = Map::new(); - metadata.insert("ref_token".to_owned(), Value::String(self.name.clone())); - if self.qualified { - metadata.insert("qualified".to_owned(), Value::Bool(true)); - } - if !self.qualifier.is_empty() { - metadata.insert( - "ref_qualifier".to_owned(), - Value::String(self.qualifier.clone()), - ); - } - metadata - } -} - -fn namespace_id(name: &str) -> String { - let digest = Sha1::digest(name.as_bytes()); - format!("csharp_namespace:{:x}", digest)[..33].to_owned() -} - -fn collect_interface_names(root: Node<'_>, source: &[u8]) -> HashSet { - let mut output = HashSet::new(); - let mut stack = vec![root]; - while let Some(node) = stack.pop() { - if node.kind() == "interface_declaration" - && let Some(name) = node.child_by_field_name("name") - { - output.insert(text(name, source).to_owned()); - } - let mut cursor = node.walk(); - stack.extend(node.children(&mut cursor)); - } - output -} - -fn is_interface_convention(name: &str) -> bool { - let mut characters = name.chars(); - characters.next() == Some('I') && characters.next().is_some_and(char::is_uppercase) -} - -fn read_type_name(node: Node<'_>, source: &[u8]) -> Option { - match node.kind() { - "identifier" | "predefined_type" => Some(TypeReference { - name: text(node, source).to_owned(), - generic: false, - qualified: false, - qualifier: String::new(), - }), - "qualified_name" => { - let raw = text(node, source); - let (qualifier, name) = raw.rsplit_once('.').unwrap_or(("", raw)); - Some(TypeReference { - name: name.split('<').next().unwrap_or_default().to_owned(), - generic: false, - qualified: true, - qualifier: qualifier.to_owned(), - }) - } - "generic_name" => { - let name_node = node - .child_by_field_name("name") - .or_else(|| first_child_matching(node, &["identifier", "qualified_name"]))?; - let raw = text(name_node, source); - let (qualifier, name) = raw.rsplit_once('.').unwrap_or(("", raw)); - Some(TypeReference { - name: name.to_owned(), - generic: false, - qualified: name_node.kind() == "qualified_name", - qualifier: qualifier.to_owned(), - }) - } - _ => { - let mut cursor = node.walk(); - node.children(&mut cursor) - .find_map(|child| read_type_name(child, source)) - } - } -} - -fn collect_type_references( - node: Node<'_>, - source: &[u8], - generic: bool, - skip: &HashSet, - output: &mut Vec, -) { - match node.kind() { - "predefined_type" => {} - "identifier" | "qualified_name" => { - if let Some(mut reference) = read_type_name(node, source) - && !skip.contains(&reference.name) - { - reference.generic = generic; - output.push(reference); - } - } - "generic_name" => { - if let Some(mut reference) = read_type_name(node, source) - && !skip.contains(&reference.name) - { - reference.generic = generic; - output.push(reference); - } - let mut cursor = node.walk(); - for arguments in node - .children(&mut cursor) - .filter(|child| child.kind() == "type_argument_list") - { - let mut argument_cursor = arguments.walk(); - for argument in arguments - .children(&mut argument_cursor) - .filter(|child| child.is_named()) - { - collect_type_references(argument, source, true, skip, output); - } - } - } - _ => { - let mut cursor = node.walk(); - for child in node.children(&mut cursor).filter(|child| child.is_named()) { - collect_type_references(child, source, generic, skip, output); - } - } - } -} - -fn type_parameters_in_scope(node: Node<'_>, source: &[u8]) -> HashSet { - let mut output = HashSet::new(); - let mut scope = Some(node); - while let Some(node) = scope { - if TYPE_DECLARATIONS.contains(&node.kind()) || node.kind() == "method_declaration" { - let mut cursor = node.walk(); - for list in node - .children(&mut cursor) - .filter(|child| child.kind() == "type_parameter_list") - { - let mut list_cursor = list.walk(); - for parameter in list - .children(&mut list_cursor) - .filter(|child| child.is_named()) - { - if let Some(identifier) = if parameter.kind() == "identifier" { - Some(parameter) - } else { - first_child_matching(parameter, &["identifier"]) - } { - output.insert(text(identifier, source).to_owned()); - } - } - } - } - scope = node.parent(); - } - output -} - -fn attribute_references( - node: Node<'_>, - source: &[u8], - skip: &HashSet, -) -> Vec { - let mut output = Vec::new(); - let mut cursor = node.walk(); - for list in node - .children(&mut cursor) - .filter(|child| child.kind() == "attribute_list") - { - let mut list_cursor = list.walk(); - for attribute in list - .children(&mut list_cursor) - .filter(|child| child.kind() == "attribute") - { - if let Some(name) = attribute - .child_by_field_name("name") - .or_else(|| first_child_matching(attribute, &["identifier", "qualified_name"])) - && let Some(reference) = read_type_name(name, source) - && !skip.contains(&reference.name) - { - output.push(reference); - } - } - } - output -} - -fn member_type_table(root: Node<'_>, source: &[u8]) -> Map { - let mut table = Map::new(); - let mut stack = vec![root]; - while let Some(node) = stack.pop() { - match node.kind() { - "field_declaration" | "local_declaration_statement" => { - if let Some(declaration) = first_descendant_of_kind(node, "variable_declaration") { - let declared = declaration - .child_by_field_name("type") - .and_then(|node| typed_name(node, source)); - let mut cursor = declaration.walk(); - for declarator in declaration - .children(&mut cursor) - .filter(|child| child.kind() == "variable_declarator") - { - if let Some(name) = declarator - .child_by_field_name("name") - .or_else(|| first_child_matching(declarator, &["identifier"])) - { - let resolved = declared.clone().or_else(|| { - first_descendant_of_kind(declarator, "object_creation_expression") - .and_then(|creation| creation.child_by_field_name("type")) - .and_then(|node| typed_name(node, source)) - }); - if let Some(resolved) = resolved { - table - .entry(text(name, source).to_owned()) - .or_insert(Value::String(resolved)); - } - } - } - } - } - "property_declaration" | "parameter" => { - if let (Some(name), Some(resolved)) = ( - node.child_by_field_name("name"), - node.child_by_field_name("type") - .and_then(|node| typed_name(node, source)), - ) { - table - .entry(text(name, source).to_owned()) - .or_insert(Value::String(resolved)); - } - } - _ => {} - } - let mut cursor = node.walk(); - stack.extend(node.children(&mut cursor)); - } - table -} - -fn typed_name(node: Node<'_>, source: &[u8]) -> Option { - let name = read_type_name(node, source)?.name; - name.chars() - .next() - .is_some_and(char::is_uppercase) - .then_some(name) -} - -fn first_child_matching<'tree>(node: Node<'tree>, kinds: &[&str]) -> Option> { - let mut cursor = node.walk(); - node.children(&mut cursor) - .find(|child| kinds.contains(&child.kind())) -} - -fn first_descendant_of_kind<'tree>(node: Node<'tree>, kind: &str) -> Option> { - if node.kind() == kind { - return Some(node); - } - let mut cursor = node.walk(); - node.children(&mut cursor) - .find_map(|child| first_descendant_of_kind(child, kind)) -} - -fn text<'source>(node: Node<'_>, source: &'source [u8]) -> &'source str { - node.utf8_text(source).unwrap_or_default() -} diff --git a/crates/compass-languages/src/engine.rs b/crates/compass-languages/src/engine.rs index 2e75e27d..b3b9fb8f 100644 --- a/crates/compass-languages/src/engine.rs +++ b/crates/compass-languages/src/engine.rs @@ -213,7 +213,11 @@ impl Engine { program: None, }); } - if spec.kind == ExtractorKind::Generic && matches!(spec.name, "go" | "java") { + let universal_profile = Registry::universal_profile_for_spec(spec); + if spec.kind == ExtractorKind::Generic + && universal_profile.is_some() + && !crate::program::supports_language(spec.name) + { let tree = self.parse(path, spec, source)?; let mut graph = self.extract_generic_from_tree( path, @@ -230,12 +234,7 @@ impl Engine { program: None, }); } - if spec.kind != ExtractorKind::Generic - || !matches!( - spec.name, - "python" | "rust" | "typescript" | "tsx" | "javascript" - ) - { + if spec.kind != ExtractorKind::Generic || !crate::program::supports_language(spec.name) { return self .extract_source(path, source) .map(|graph| CombinedExtraction { @@ -372,7 +371,6 @@ impl Engine { match spec.name { "go" => crate::go::extract(path, source, root), "bash" => crate::bash::extract(path, source, root), - "csharp" => crate::csharp::extract(path, source, root), "cpp" => crate::cpp::extract(path, source, root), "php" => crate::php::extract(path, source, root), "swift" => crate::swift::extract(path, source, root), @@ -4643,6 +4641,47 @@ export class OrdersConsumer { Ok(()) } + #[test] + fn explicit_source_identity_controls_csharp_universal_evidence() + -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("Controllers/OrdersController.cs"); + let source = br#"using Microsoft.AspNetCore.Mvc; +namespace Store.Controllers; +[ApiController] +[Route("api/[controller]")] +public class OrdersController : ControllerBase { + [HttpGet("{id:int}")] + public string Get(int id) => id.ToString(); +} +"#; + + let extraction = Engine::default().extract_source_graph_only( + &path, + "Controllers/OrdersController.cs", + source, + )?; + let evidence = extraction + .semantic_evidence + .ok_or("C# universal evidence was not emitted")?; + + assert!( + evidence + .declarations + .iter() + .all(|fact| { fact.range.source_file == "Controllers/OrdersController.cs" }) + ); + assert!(extraction.framework_facts.iter().all(|fact| { + let anchor = match fact { + crate::RawFrameworkFact::Route(route) => &route.anchor, + crate::RawFrameworkFact::Domain(domain) => &domain.anchor, + crate::RawFrameworkFact::Annotation(annotation) => &annotation.anchor, + }; + anchor.source_file == "Controllers/OrdersController.cs" + })); + Ok(()) + } + #[test] fn c_function_declarators_prefer_callable_names_over_types() -> Result<(), Box> { diff --git a/crates/compass-languages/src/evidence/build.rs b/crates/compass-languages/src/evidence/build.rs index b5e861a9..a37886a8 100644 --- a/crates/compass-languages/src/evidence/build.rs +++ b/crates/compass-languages/src/evidence/build.rs @@ -169,6 +169,87 @@ impl EvidenceBuilder { ) } + /// Add a callable declaration with the source-proven signature shape used + /// by deterministic overload selection. + /// + /// Language adapters retain responsibility for canonicalizing their own + /// parameter spellings. The builder only publishes the bounded, typed + /// fields already present in the universal evidence schema. + #[allow(clippy::too_many_arguments)] + pub(crate) fn declare_callable( + &mut self, + kind: &str, + graph_node_id: &str, + name: &str, + qualified_name: &str, + module_or_package: Option<&str>, + scope_id: Option<&str>, + namespace: Option, + signature: Option<&str>, + parameter_types: Vec, + variadic: bool, + range: EvidenceRange, + ) -> Result { + let metadata = DeclarationMetadata { + signature: signature.map(str::to_owned), + parameter_count: Some(u32::try_from(parameter_types.len()).map_err(|_| { + EvidenceError::new( + EvidenceErrorCode::ResourceLimit, + "callable parameter count exceeds the evidence schema limit", + ) + })?), + parameter_types, + variadic, + ..DeclarationMetadata::default() + }; + self.declare_with_metadata_and_namespace( + kind, + graph_node_id, + name, + qualified_name, + module_or_package, + scope_id, + range, + namespace, + metadata, + ) + } + + /// Add a nominal type declaration and state whether its complete direct + /// base list was parsed. This is shared resolver input, not a claim that + /// every base target is locally resolvable. + #[allow(clippy::too_many_arguments)] + pub(crate) fn declare_type( + &mut self, + kind: &str, + graph_node_id: &str, + name: &str, + qualified_name: &str, + module_or_package: Option<&str>, + scope_id: Option<&str>, + namespace: Option, + signature: Option<&str>, + direct_bases_complete: bool, + range: EvidenceRange, + ) -> Result { + let metadata = DeclarationMetadata { + signature: signature.map(str::to_owned), + direct_bases_complete, + ..DeclarationMetadata::default() + }; + self.declare_with_metadata_and_namespace( + kind, + graph_node_id, + name, + qualified_name, + module_or_package, + scope_id, + range, + namespace, + metadata, + ) + } + #[allow(clippy::too_many_arguments)] fn declare_with_metadata( &mut self, @@ -744,6 +825,9 @@ pub(crate) fn extract_tree_evidence( root: Node<'_>, profile: &'static AdapterProfile, ) -> Result { + if profile.language == "csharp" { + return super::csharp::extract_candidate_tree_evidence(path, source_file, source, root); + } if matches!(profile.language, "javascript" | "typescript") { return super::typescript::extract_candidate_tree_evidence( path, @@ -10232,7 +10316,10 @@ fn rust_is_lexical_scope_node(kind: &str) -> bool { fn target_kinds_for_relation(relation: CandidateRelation) -> Vec { match relation { - CandidateRelation::Calls | CandidateRelation::IndirectCalls | CandidateRelation::Tests => { + CandidateRelation::Calls + | CandidateRelation::IndirectCalls + | CandidateRelation::Overrides + | CandidateRelation::Tests => { vec!["function".to_owned(), "method".to_owned()] } CandidateRelation::Constructs => { @@ -10413,6 +10500,7 @@ const fn semantic_role_name(role: SemanticRole) -> &'static str { SemanticRole::Decorator => "decorator", SemanticRole::Annotation => "annotation", SemanticRole::BaseType => "base_type", + SemanticRole::Override => "override", SemanticRole::TypeReference => "type_reference", SemanticRole::MemberAccess => "member_access", SemanticRole::Ownership => "ownership", @@ -10432,6 +10520,7 @@ const fn candidate_relation_name(relation: CandidateRelation) -> &'static str { CandidateRelation::Annotates => "annotates", CandidateRelation::Extends => "extends", CandidateRelation::Implements => "implements", + CandidateRelation::Overrides => "overrides", CandidateRelation::References => "references", CandidateRelation::TypeOf => "type_of", CandidateRelation::Returns => "returns", diff --git a/crates/compass-languages/src/evidence/csharp.rs b/crates/compass-languages/src/evidence/csharp.rs new file mode 100644 index 00000000..f869bb6a --- /dev/null +++ b/crates/compass-languages/src/evidence/csharp.rs @@ -0,0 +1,1405 @@ +//! Direct universal evidence for C# and .NET source. +//! +//! The adapter is deliberately AST-first and project-neutral. It emits exact +//! declarations, scopes, bindings, occurrences, and constrained relationship +//! candidates; cross-file target choice remains owned by `compass-resolve`. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::Path; + +use tree_sitter::Node; + +use super::build::{EvidenceBuilder, range_for_node}; +use super::model::{ + BindingKind, CandidateRelation, HierarchyConstraint, ReceiverDispatchStrategy, + ResolutionConstraint, SemanticEvidenceBatch, SemanticRole, SymbolNamespace, +}; +use super::validate::{EvidenceError, EvidenceErrorCode, EvidenceLimits}; +use crate::{AdapterRegistry, file_stem, make_id}; + +const PRODUCER: &str = "compass.languages.csharp.universal"; +const MAX_TRAVERSAL_DEPTH: usize = 512; + +const TYPE_KINDS: &[&str] = &[ + "class_declaration", + "delegate_declaration", + "enum_declaration", + "interface_declaration", + "record_declaration", + "struct_declaration", +]; + +const CALLABLE_KINDS: &[&str] = &[ + "constructor_declaration", + "conversion_operator_declaration", + "destructor_declaration", + "local_function_statement", + "method_declaration", + "operator_declaration", +]; + +#[derive(Clone, Debug)] +struct Decl { + id: String, + qualified: String, + kind: String, + scope_id: String, + start: usize, + end: usize, + enclosing_type: Option, +} + +#[derive(Clone, Debug)] +struct Import { + spelling: String, + target: String, + alias: bool, + scope_id: String, +} + +struct State<'source> { + source: &'source [u8], + source_file: &'source str, + builder: EvidenceBuilder, + file: Decl, + declarations: Vec, + types: BTreeMap>, + imports: Vec, + value_types: HashMap<(String, String), String>, + direct_class_bases: BTreeMap>, +} + +pub(super) fn extract_candidate_tree_evidence( + path: &Path, + source_file: &str, + source: &[u8], + root: Node<'_>, +) -> Result { + let profile = AdapterRegistry::universal_profile("csharp").ok_or_else(|| { + EvidenceError::new( + EvidenceErrorCode::InvalidAdapter, + "C# universal adapter is not registered", + ) + })?; + let mut builder = + EvidenceBuilder::new(profile, PRODUCER, source_file, EvidenceLimits::default()); + let file_graph_id = make_id(&[source_file]); + let file_module = file_stem(Path::new(source_file)); + let file_id = builder.declare( + "file", + &file_graph_id, + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(source_file), + source_file, + Some(&file_module), + None, + range_for_node(source_file, root), + )?; + let file_scope = builder.open_scope( + "file", + Some(&file_id), + None, + range_for_node(source_file, root), + )?; + let file = Decl { + id: file_id, + qualified: source_file.to_owned(), + kind: "file".to_owned(), + scope_id: file_scope, + start: root.start_byte(), + end: root.end_byte(), + enclosing_type: None, + }; + let mut state = State { + source, + source_file, + builder, + file, + declarations: Vec::new(), + types: BTreeMap::new(), + imports: Vec::new(), + value_types: HashMap::new(), + direct_class_bases: BTreeMap::new(), + }; + state.capture_errors(root, 0)?; + let file_namespace = file_scoped_namespace(root, source).unwrap_or_default(); + state.collect_declarations(root, &file_namespace, None, None, 0)?; + state.collect_imports(root, 0)?; + state.collect_semantics(root, 0)?; + if root.has_error() { + state.builder.diagnose( + "partial_parser_recovery", + None, + Some(range_for_node(source_file, root)), + "parser recovered from malformed C# source; emitted evidence remains source-bounded", + )?; + } + state.builder.finish() +} + +impl<'source> State<'source> { + fn collect_declarations( + &mut self, + node: Node<'_>, + namespace: &str, + owner: Option, + parent_scope: Option<&str>, + depth: usize, + ) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(node); + } + let scope = parent_scope.unwrap_or(&self.file.scope_id).to_owned(); + if matches!( + node.kind(), + "namespace_declaration" | "file_scoped_namespace_declaration" + ) { + let Some(name) = node.child_by_field_name("name") else { + return Ok(()); + }; + let segment = self.text(name).trim(); + let nested = + if node.kind() == "file_scoped_namespace_declaration" && namespace == segment { + namespace.to_owned() + } else { + join_qualified(namespace, segment, ".") + }; + let namespace_graph = make_id(&["csharp", "namespace", &nested]); + let namespace_id = self.builder.declare_with_namespace( + "namespace", + &namespace_graph, + segment, + &nested, + Some(&nested), + Some(&scope), + Some(SymbolNamespace::Namespace), + range_for_node(self.source_file, name), + )?; + let namespace_scope = self.builder.open_scope( + "namespace", + Some(&namespace_id), + Some(&scope), + range_for_node(self.source_file, node), + )?; + let namespace_index = self.declarations.len(); + self.declarations.push(Decl { + id: namespace_id.clone(), + qualified: nested.clone(), + kind: "namespace".to_owned(), + scope_id: namespace_scope.clone(), + start: node.start_byte(), + end: node.end_byte(), + enclosing_type: None, + }); + let owner_id = owner + .map(|owner| self.declarations[owner].id.clone()) + .unwrap_or_else(|| self.file.id.clone()); + self.own(&owner_id, &namespace_id)?; + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + if child.id() != name.id() { + self.collect_declarations( + child, + &nested, + Some(namespace_index), + Some(&namespace_scope), + depth + 1, + )?; + } + } + return Ok(()); + } + if TYPE_KINDS.contains(&node.kind()) { + let Some(name_node) = node.child_by_field_name("name") else { + return Ok(()); + }; + let name = self.text(name_node).trim().to_owned(); + if name.is_empty() { + return Ok(()); + } + let owner_qualified = owner.map(|index| self.declarations[index].qualified.as_str()); + let qualified = owner_qualified.map_or_else( + || join_qualified(namespace, &name, "."), + |owner| join_qualified(owner, &name, "."), + ); + let kind = csharp_type_kind(node.kind()); + let graph_id = make_id(&["csharp", kind, &qualified]); + let direct_bases_complete = matches!(kind, "class" | "interface" | "record" | "struct") + && first_named_child(node, &["base_list"]).is_none_or(|base| !base.has_error()); + let declaration_id = self.builder.declare_type( + kind, + &graph_id, + &name, + &qualified, + (!namespace.is_empty()).then_some(namespace), + Some(&scope), + Some(SymbolNamespace::ValueAndType), + type_parameter_signature(node, self.source).as_deref(), + direct_bases_complete, + range_for_node(self.source_file, name_node), + )?; + let type_scope = self.builder.open_scope( + kind, + Some(&declaration_id), + Some(&scope), + range_for_node(self.source_file, node), + )?; + let index = self.declarations.len(); + self.declarations.push(Decl { + id: declaration_id.clone(), + qualified: qualified.clone(), + kind: kind.to_owned(), + scope_id: type_scope.clone(), + start: node.start_byte(), + end: node.end_byte(), + enclosing_type: Some(qualified.clone()), + }); + self.types.entry(name).or_default().push(index); + let owner_id = owner + .map(|owner| self.declarations[owner].id.clone()) + .unwrap_or_else(|| self.file.id.clone()); + self.own(&owner_id, &declaration_id)?; + self.collect_base_types(node, index)?; + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + if child.id() != name_node.id() && child.kind() != "base_list" { + self.collect_declarations( + child, + namespace, + Some(index), + Some(&type_scope), + depth + 1, + )?; + } + } + return Ok(()); + } + if CALLABLE_KINDS.contains(&node.kind()) { + self.add_callable(node, namespace, owner, &scope)?; + return Ok(()); + } + if matches!( + node.kind(), + "property_declaration" | "indexer_declaration" | "event_declaration" + ) { + self.add_named_member(node, namespace, owner, &scope)?; + return Ok(()); + } + if matches!(node.kind(), "field_declaration" | "event_field_declaration") { + self.add_fields(node, namespace, owner, &scope)?; + return Ok(()); + } + if node.kind() == "enum_member_declaration" { + self.add_enum_member(node, owner, &scope)?; + return Ok(()); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + self.collect_declarations(child, namespace, owner, Some(&scope), depth + 1)?; + } + Ok(()) + } + + fn add_callable( + &mut self, + node: Node<'_>, + namespace: &str, + owner: Option, + parent_scope: &str, + ) -> Result<(), EvidenceError> { + let name_node = node.child_by_field_name("name").or_else(|| { + (node.kind() == "constructor_declaration") + .then(|| first_named_child(node, &["identifier"])) + .flatten() + }); + let Some(name_node) = name_node else { + return Ok(()); + }; + let name = self.text(name_node).trim().to_owned(); + let owner_qualified = owner.map(|index| self.declarations[index].qualified.clone()); + let qualified = owner_qualified.as_deref().map_or_else( + || join_qualified(namespace, &name, "::"), + |owner| join_qualified(owner, &name, "::"), + ); + let parameter_types = parameter_types(node, self.source); + let signature = callable_signature(node, &name, ¶meter_types, self.source); + let kind = callable_kind(node.kind()); + let graph_id = make_id(&["csharp", kind, &qualified]); + let declaration_id = self.builder.declare_callable( + kind, + &graph_id, + &name, + &qualified, + (!namespace.is_empty()).then_some(namespace), + Some(parent_scope), + Some(SymbolNamespace::Value), + Some(&signature), + parameter_types.clone(), + has_params_parameter(node, self.source), + range_for_node(self.source_file, name_node), + )?; + let callable_scope = self.builder.open_scope( + kind, + Some(&declaration_id), + Some(parent_scope), + range_for_node(self.source_file, node), + )?; + let index = self.declarations.len(); + self.declarations.push(Decl { + id: declaration_id.clone(), + qualified, + kind: kind.to_owned(), + scope_id: callable_scope.clone(), + start: node.start_byte(), + end: node.end_byte(), + enclosing_type: owner_qualified, + }); + let owner_id = owner + .map(|owner| self.declarations[owner].id.clone()) + .unwrap_or_else(|| self.file.id.clone()); + self.own(&owner_id, &declaration_id)?; + self.collect_override(node, index)?; + self.collect_parameter_types(node, index, &callable_scope)?; + self.collect_return_type(node, index, &callable_scope)?; + self.collect_local_value_types(node, &callable_scope); + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + if child.kind() == "local_function_statement" { + self.collect_declarations(child, namespace, Some(index), Some(&callable_scope), 1)?; + } + } + Ok(()) + } + + fn add_named_member( + &mut self, + node: Node<'_>, + namespace: &str, + owner: Option, + scope: &str, + ) -> Result<(), EvidenceError> { + let Some(owner) = owner else { return Ok(()) }; + let Some(name_node) = node.child_by_field_name("name") else { + return Ok(()); + }; + let name = self.text(name_node).trim(); + let qualified = join_qualified(&self.declarations[owner].qualified, name, "::"); + let kind = match node.kind() { + "event_declaration" => "event", + "indexer_declaration" => "indexer", + _ => "property", + }; + let graph_id = make_id(&["csharp", kind, &qualified]); + let type_node = node.child_by_field_name("type"); + let signature = type_node.map(|node| canonical_type(self.text(node))); + let id = self.builder.declare_with_signature( + kind, + &graph_id, + name, + &qualified, + (!namespace.is_empty()).then_some(namespace), + Some(scope), + Some(SymbolNamespace::Value), + signature.as_deref(), + range_for_node(self.source_file, name_node), + )?; + self.own(&self.declarations[owner].id.clone(), &id)?; + if let Some(value_type) = signature { + self.value_types + .insert((scope.to_owned(), name.to_owned()), value_type.clone()); + if let Some(type_node) = type_node { + self.type_relation( + &id, + scope, + type_node, + &value_type, + "property_type", + CandidateRelation::TypeOf, + )?; + } + } + Ok(()) + } + + fn add_fields( + &mut self, + node: Node<'_>, + namespace: &str, + owner: Option, + scope: &str, + ) -> Result<(), EvidenceError> { + let Some(owner) = owner else { return Ok(()) }; + let Some(variable) = first_descendant(node, "variable_declaration") else { + return Ok(()); + }; + let value_type = variable + .child_by_field_name("type") + .map(|node| canonical_type(self.text(node))); + let mut stack = vec![variable]; + while let Some(current) = stack.pop() { + if current.kind() == "variable_declarator" { + let Some(name_node) = current + .child_by_field_name("name") + .or_else(|| first_named_child(current, &["identifier"])) + else { + continue; + }; + let name = self.text(name_node).trim(); + let qualified = join_qualified(&self.declarations[owner].qualified, name, "::"); + let kind = if node.kind() == "event_field_declaration" { + "event" + } else if self + .text(node) + .split_whitespace() + .any(|token| token == "const") + { + "constant" + } else { + "field" + }; + let graph_id = make_id(&["csharp", kind, &qualified]); + let id = self.builder.declare_with_signature( + kind, + &graph_id, + name, + &qualified, + (!namespace.is_empty()).then_some(namespace), + Some(scope), + Some(SymbolNamespace::Value), + value_type.as_deref(), + range_for_node(self.source_file, name_node), + )?; + self.own(&self.declarations[owner].id.clone(), &id)?; + if let Some(value_type) = value_type.as_ref() { + self.value_types + .insert((scope.to_owned(), name.to_owned()), value_type.clone()); + self.type_relation( + &id, + scope, + current, + value_type, + "field_type", + CandidateRelation::TypeOf, + )?; + } + continue; + } + let mut cursor = current.walk(); + stack.extend( + current + .children(&mut cursor) + .filter(|child| child.is_named()), + ); + } + Ok(()) + } + + fn add_enum_member( + &mut self, + node: Node<'_>, + owner: Option, + scope: &str, + ) -> Result<(), EvidenceError> { + let Some(owner) = owner else { return Ok(()) }; + let Some(name_node) = node + .child_by_field_name("name") + .or_else(|| first_named_child(node, &["identifier"])) + else { + return Ok(()); + }; + let name = self.text(name_node).trim(); + let qualified = join_qualified(&self.declarations[owner].qualified, name, "::"); + let graph_id = make_id(&["csharp", "enum_member", &qualified]); + let id = self.builder.declare_with_namespace( + "enum_member", + &graph_id, + name, + &qualified, + None, + Some(scope), + Some(SymbolNamespace::Value), + range_for_node(self.source_file, name_node), + )?; + self.own(&self.declarations[owner].id.clone(), &id) + } + + fn collect_imports(&mut self, root: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(root); + } + if root.kind() == "using_directive" { + let raw = self.text(root).trim().trim_end_matches(';').trim(); + let raw = raw.strip_prefix("global ").unwrap_or(raw).trim(); + let Some(body) = raw.strip_prefix("using") else { + return Ok(()); + }; + let body = body.trim(); + let scope = self.enclosing_scope(root.start_byte()); + let (kind, spelling, target, alias) = if let Some(target) = body.strip_prefix("static ") + { + ( + BindingKind::Import, + terminal(target.trim()), + target.trim(), + false, + ) + } else if let Some((alias, target)) = body.split_once('=') { + (BindingKind::ImportAlias, alias.trim(), target.trim(), true) + } else { + (BindingKind::Import, terminal(body), body, false) + }; + if !target.is_empty() { + let binding = self.builder.bind_with_identity( + kind, + spelling, + target, + None, + Some(&scope), + Some(if alias { + SymbolNamespace::ValueAndType + } else { + SymbolNamespace::Namespace + }), + false, + range_for_node(self.source_file, root), + )?; + let occurrence = self.builder.occur( + SemanticRole::Import, + &self.file.id, + target, + None, + Some(&scope), + range_for_node(self.source_file, root), + )?; + self.builder.relate( + CandidateRelation::Imports, + &self.file.id, + Some(&occurrence), + Some(&binding), + target, + ResolutionConstraint { + exact_language: Some("csharp".to_owned()), + qualified_name: Some(target.to_owned()), + allow_external: true, + ..ResolutionConstraint::default() + }, + )?; + self.imports.push(Import { + spelling: spelling.to_owned(), + target: target.to_owned(), + alias, + scope_id: scope, + }); + } + return Ok(()); + } + let mut cursor = root.walk(); + for child in root.children(&mut cursor).filter(|child| child.is_named()) { + self.collect_imports(child, depth + 1)?; + } + Ok(()) + } + + fn collect_semantics(&mut self, root: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(root); + } + match root.kind() { + "attribute" => self.add_attribute(root)?, + "invocation_expression" => self.add_invocation(root)?, + "object_creation_expression" | "implicit_object_creation_expression" => { + self.add_construction(root)? + } + _ => {} + } + let mut cursor = root.walk(); + for child in root.children(&mut cursor).filter(|child| child.is_named()) { + self.collect_semantics(child, depth + 1)?; + } + Ok(()) + } + + fn add_attribute(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(owner) = self.enclosing_declaration(node.start_byte()) else { + return Ok(()); + }; + let Some(name_node) = node.child_by_field_name("name").or_else(|| { + first_named_child( + node, + &["identifier", "qualified_name", "alias_qualified_name"], + ) + }) else { + return Ok(()); + }; + let spelling = self.text(name_node).trim(); + let scope = self.declarations[owner].scope_id.clone(); + let occurrence = self.builder.occur_with_context( + SemanticRole::Annotation, + &self.declarations[owner].id, + spelling, + spelling.rsplit_once('.').map(|(qualifier, _)| qualifier), + Some(&scope), + Some("attribute"), + range_for_node(self.source_file, node), + )?; + let qualified = self.resolve_type_name(spelling, &scope, true); + self.builder.relate( + CandidateRelation::Annotates, + &self.declarations[owner].id, + Some(&occurrence), + None, + terminal(spelling), + ResolutionConstraint { + exact_language: Some("csharp".to_owned()), + scope_id: Some(scope), + qualified_name: qualified, + allowed_target_kinds: vec!["class".to_owned(), "record".to_owned()], + allow_external: true, + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn add_invocation(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(owner) = self.enclosing_callable(node.start_byte()) else { + return Ok(()); + }; + let Some(function) = node.child_by_field_name("function") else { + return Ok(()); + }; + let (spelling, qualifier) = if function.kind() == "member_access_expression" { + let Some(name) = function.child_by_field_name("name") else { + return Ok(()); + }; + let qualifier = function + .child_by_field_name("expression") + .map(|node| self.text(node).trim().to_owned()); + (self.text(name).trim().to_owned(), qualifier) + } else { + (terminal(self.text(function).trim()).to_owned(), None) + }; + if spelling.is_empty() { + return Ok(()); + } + let scope = self.declarations[owner].scope_id.clone(); + let occurrence = self.builder.occur( + SemanticRole::Call, + &self.declarations[owner].id, + &spelling, + qualifier.as_deref(), + Some(&scope), + range_for_node(self.source_file, node), + )?; + let receiver_type = qualifier + .as_deref() + .and_then(|receiver| self.receiver_type(receiver, owner)); + let hierarchy = qualifier + .as_deref() + .filter(|receiver| matches!(*receiver, "this" | "base")) + .and(receiver_type.clone()) + .map( + |receiver_qualified_name| HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name, + strategy: ReceiverDispatchStrategy::C3FromReceiver, + }, + ); + let qualified = if hierarchy.is_none() { + receiver_type + .as_deref() + .map(|receiver| join_qualified(receiver, &spelling, "::")) + } else { + None + }; + self.builder.relate( + CandidateRelation::Calls, + &self.declarations[owner].id, + Some(&occurrence), + None, + &spelling, + ResolutionConstraint { + exact_language: Some("csharp".to_owned()), + scope_id: Some(scope), + qualified_name: qualified, + argument_count: Some(argument_count(node)), + allowed_target_kinds: vec![ + "constructor".to_owned(), + "local_function".to_owned(), + "method".to_owned(), + "operator".to_owned(), + ], + hierarchy, + allow_external: true, + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn add_construction(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + let Some(owner) = self.enclosing_callable(node.start_byte()) else { + return Ok(()); + }; + let Some(type_node) = node.child_by_field_name("type") else { + return Ok(()); + }; + let spelling = canonical_type(self.text(type_node)); + if spelling.is_empty() { + return Ok(()); + } + let scope = self.declarations[owner].scope_id.clone(); + let occurrence = self.builder.occur( + SemanticRole::Construction, + &self.declarations[owner].id, + terminal(&spelling), + spelling.rsplit_once('.').map(|(qualifier, _)| qualifier), + Some(&scope), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + CandidateRelation::Constructs, + &self.declarations[owner].id, + Some(&occurrence), + None, + terminal(&spelling), + ResolutionConstraint { + exact_language: Some("csharp".to_owned()), + scope_id: Some(scope.clone()), + qualified_name: self.resolve_type_name(&spelling, &scope, false), + argument_count: Some(argument_count(node)), + allowed_target_kinds: vec![ + "class".to_owned(), + "record".to_owned(), + "struct".to_owned(), + ], + allow_external: true, + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn collect_base_types(&mut self, node: Node<'_>, owner: usize) -> Result<(), EvidenceError> { + let Some(base_list) = first_named_child(node, &["base_list"]) else { + return Ok(()); + }; + let mut base_nodes = Vec::new(); + collect_direct_type_children(base_list, &mut base_nodes); + let complete = !base_list.has_error(); + for base in base_nodes { + let spelling = canonical_type(self.text(base)); + if spelling.is_empty() || is_predefined(&spelling) { + continue; + } + let scope = self.declarations[owner].scope_id.clone(); + let occurrence = self.builder.occur_with_context( + SemanticRole::BaseType, + &self.declarations[owner].id, + terminal(&spelling), + spelling.rsplit_once('.').map(|(qualifier, _)| qualifier), + Some(&scope), + Some("base_type"), + range_for_node(self.source_file, base), + )?; + let qualified = self.resolve_type_name(&spelling, &scope, false); + let local_kind = self + .unique_local_type(&spelling) + .map(|index| self.declarations[index].kind.as_str()); + let relation = match local_kind { + Some("interface") => CandidateRelation::Implements, + Some(_) => CandidateRelation::Extends, + None => CandidateRelation::References, + }; + self.builder.relate( + relation, + &self.declarations[owner].id, + Some(&occurrence), + None, + terminal(&spelling), + ResolutionConstraint { + exact_language: Some("csharp".to_owned()), + scope_id: Some(scope.clone()), + qualified_name: qualified, + allowed_target_kinds: vec![ + "class".to_owned(), + "interface".to_owned(), + "record".to_owned(), + "struct".to_owned(), + ], + hierarchy: (relation != CandidateRelation::References).then_some( + HierarchyConstraint::DirectBase { + base_set_complete: complete, + }, + ), + allow_external: true, + ..ResolutionConstraint::default() + }, + )?; + if relation == CandidateRelation::Extends + && let Some(qualified) = self.resolve_type_name(&spelling, &scope, false) + { + self.direct_class_bases + .entry(self.declarations[owner].qualified.clone()) + .or_default() + .push(qualified); + } + } + Ok(()) + } + + fn collect_override(&mut self, node: Node<'_>, owner: usize) -> Result<(), EvidenceError> { + if !self + .text(node) + .split(|character: char| !character.is_alphanumeric() && character != '_') + .any(|token| token == "override") + { + return Ok(()); + } + let Some(enclosing_type) = self.declarations[owner].enclosing_type.clone() else { + return Ok(()); + }; + let bases = self + .direct_class_bases + .get(&enclosing_type) + .cloned() + .unwrap_or_default(); + let method_name = terminal(&self.declarations[owner].qualified).to_owned(); + for base in bases { + let occurrence = self.builder.occur_with_context( + SemanticRole::Override, + &self.declarations[owner].id, + &method_name, + Some(&base), + Some(&self.declarations[owner].scope_id), + Some("override_modifier"), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + CandidateRelation::Overrides, + &self.declarations[owner].id, + Some(&occurrence), + None, + &method_name, + ResolutionConstraint { + exact_language: Some("csharp".to_owned()), + scope_id: Some(self.declarations[owner].scope_id.clone()), + qualified_name: Some(join_qualified(&base, &method_name, "::")), + argument_count: Some( + u32::try_from(parameter_types(node, self.source).len()).unwrap_or(u32::MAX), + ), + allowed_target_kinds: vec!["method".to_owned()], + allow_external: false, + ..ResolutionConstraint::default() + }, + )?; + } + Ok(()) + } + + fn collect_parameter_types( + &mut self, + node: Node<'_>, + owner: usize, + scope: &str, + ) -> Result<(), EvidenceError> { + let Some(parameters) = node + .child_by_field_name("parameters") + .or_else(|| first_named_child(node, &["parameter_list", "bracketed_parameter_list"])) + else { + return Ok(()); + }; + let mut parameter_nodes = Vec::new(); + collect_descendants(parameters, "parameter", &mut parameter_nodes); + parameter_nodes.sort_unstable_by_key(Node::start_byte); + for current in parameter_nodes { + if let (Some(name), Some(value_type)) = ( + current.child_by_field_name("name"), + current.child_by_field_name("type"), + ) { + let spelling = canonical_type(self.text(value_type)); + let parameter_name = self.text(name).trim(); + let qualified = + join_qualified(&self.declarations[owner].qualified, parameter_name, "::"); + let graph_id = make_id(&["csharp", "parameter", &qualified]); + let parameter_id = self.builder.declare_with_signature( + "parameter", + &graph_id, + parameter_name, + &qualified, + None, + Some(scope), + Some(SymbolNamespace::Value), + Some(&spelling), + range_for_node(self.source_file, name), + )?; + self.own(&self.declarations[owner].id.clone(), ¶meter_id)?; + self.value_types.insert( + (scope.to_owned(), parameter_name.to_owned()), + spelling.clone(), + ); + self.type_relation( + ¶meter_id, + scope, + value_type, + &spelling, + "parameter_type", + CandidateRelation::TypeOf, + )?; + } + } + Ok(()) + } + + fn collect_return_type( + &mut self, + node: Node<'_>, + owner: usize, + _scope: &str, + ) -> Result<(), EvidenceError> { + let result = node + .child_by_field_name("returns") + .or_else(|| node.child_by_field_name("type")); + if let Some(result) = result { + let spelling = canonical_type(self.text(result)); + if !spelling.is_empty() && spelling != "void" { + let declaration_id = self.declarations[owner].id.clone(); + let scope = self.declarations[owner].scope_id.clone(); + self.type_relation( + &declaration_id, + &scope, + result, + &spelling, + "return_type", + CandidateRelation::Returns, + )?; + } + } + Ok(()) + } + + fn type_relation( + &mut self, + source_declaration_id: &str, + scope: &str, + node: Node<'_>, + spelling: &str, + context: &str, + relation: CandidateRelation, + ) -> Result<(), EvidenceError> { + if is_predefined(spelling) { + return Ok(()); + } + let occurrence = self.builder.occur_with_context( + SemanticRole::TypeReference, + source_declaration_id, + terminal(spelling), + spelling.rsplit_once('.').map(|(qualifier, _)| qualifier), + Some(scope), + Some(context), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + relation, + source_declaration_id, + Some(&occurrence), + None, + terminal(spelling), + ResolutionConstraint { + exact_language: Some("csharp".to_owned()), + scope_id: Some(scope.to_owned()), + qualified_name: self.resolve_type_name(spelling, scope, false), + allowed_target_kinds: vec![ + "class".to_owned(), + "delegate".to_owned(), + "enum".to_owned(), + "interface".to_owned(), + "record".to_owned(), + "struct".to_owned(), + ], + allow_external: true, + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn collect_local_value_types(&mut self, node: Node<'_>, scope: &str) { + let mut stack = vec![node]; + while let Some(current) = stack.pop() { + if current.kind() == "local_declaration_statement" + && let Some(variable) = first_descendant(current, "variable_declaration") + { + let declared = variable + .child_by_field_name("type") + .map(|node| canonical_type(self.text(node))); + let mut cursor = variable.walk(); + for declarator in variable + .children(&mut cursor) + .filter(|child| child.kind() == "variable_declarator") + { + let Some(name) = declarator + .child_by_field_name("name") + .or_else(|| first_named_child(declarator, &["identifier"])) + else { + continue; + }; + let inferred = declared.clone().filter(|value| value != "var").or_else(|| { + first_descendant(declarator, "object_creation_expression") + .and_then(|creation| creation.child_by_field_name("type")) + .map(|node| canonical_type(self.text(node))) + }); + if let Some(inferred) = inferred { + self.value_types.insert( + (scope.to_owned(), self.text(name).trim().to_owned()), + inferred, + ); + } + } + } + let mut cursor = current.walk(); + stack.extend( + current + .children(&mut cursor) + .filter(|child| child.is_named()), + ); + } + } + + fn own(&mut self, owner: &str, child: &str) -> Result<(), EvidenceError> { + self.builder.relate( + CandidateRelation::Owns, + owner, + None, + None, + child, + ResolutionConstraint { + exact_target_declaration_id: Some(child.to_owned()), + exact_language: Some("csharp".to_owned()), + ..ResolutionConstraint::default() + }, + )?; + Ok(()) + } + + fn resolve_type_name(&self, spelling: &str, scope: &str, attribute: bool) -> Option { + let normalized = canonical_type(spelling); + if normalized.contains('.') { + return Some(normalized); + } + for import in self.imports.iter().rev() { + if import.scope_id == scope && import.alias && import.spelling == normalized { + return Some(import.target.clone()); + } + } + if let Some(index) = self.unique_local_type(&normalized) { + return Some(self.declarations[index].qualified.clone()); + } + if attribute { + let attribute_name = if normalized.ends_with("Attribute") { + normalized + } else { + format!("{normalized}Attribute") + }; + let namespaces = self + .imports + .iter() + .filter(|import| !import.alias) + .map(|import| import.target.as_str()) + .collect::>(); + if namespaces.len() == 1 { + return namespaces + .first() + .map(|namespace| format!("{namespace}.{attribute_name}")); + } + } + None + } + + fn unique_local_type(&self, spelling: &str) -> Option { + let values = self.types.get(terminal(spelling))?; + (values.len() == 1).then_some(values[0]) + } + + fn receiver_type(&self, receiver: &str, owner: usize) -> Option { + if receiver == "this" { + return self.declarations[owner].enclosing_type.clone(); + } + if receiver == "base" { + return self.declarations[owner].enclosing_type.clone(); + } + self.value_types + .get(&( + self.declarations[owner].scope_id.clone(), + receiver.to_owned(), + )) + .and_then(|value_type| { + self.resolve_type_name(value_type, &self.declarations[owner].scope_id, false) + .or_else(|| { + self.declarations[owner] + .enclosing_type + .as_deref() + .and_then(|enclosing| enclosing.rsplit_once('.')) + .map(|(namespace, _)| join_qualified(namespace, value_type, ".")) + }) + .or_else(|| Some(value_type.clone())) + }) + .or_else(|| { + self.unique_local_type(receiver) + .map(|index| self.declarations[index].qualified.clone()) + }) + } + + fn enclosing_declaration(&self, byte: usize) -> Option { + self.declarations + .iter() + .enumerate() + .filter(|(_, declaration)| declaration.start <= byte && byte < declaration.end) + .min_by_key(|(_, declaration)| declaration.end.saturating_sub(declaration.start)) + .map(|(index, _)| index) + } + + fn enclosing_callable(&self, byte: usize) -> Option { + self.declarations + .iter() + .enumerate() + .filter(|(_, declaration)| { + is_callable_kind(&declaration.kind) + && declaration.start <= byte + && byte < declaration.end + }) + .min_by_key(|(_, declaration)| declaration.end.saturating_sub(declaration.start)) + .map(|(index, _)| index) + } + + fn enclosing_scope(&self, byte: usize) -> String { + self.enclosing_declaration(byte) + .map(|index| self.declarations[index].scope_id.clone()) + .unwrap_or_else(|| self.file.scope_id.clone()) + } + + fn capture_errors(&mut self, node: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(node); + } + if node.is_error() || node.is_missing() { + self.builder.diagnose( + "parser_error", + None, + Some(range_for_node(self.source_file, node)), + "tree-sitter reported an error or missing C# syntax node", + )?; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.capture_errors(child, depth + 1)?; + } + Ok(()) + } + + fn depth_diagnostic(&mut self, node: Node<'_>) -> Result { + self.builder.diagnose( + "traversal_depth_limit", + None, + Some(range_for_node(self.source_file, node)), + "C# syntax traversal exceeded the bounded depth limit", + )?; + Err(EvidenceError::new( + super::validate::EvidenceErrorCode::ResourceLimit, + "C# syntax traversal exceeded the bounded depth limit", + )) + } + + fn text(&self, node: Node<'_>) -> &'source str { + node.utf8_text(self.source).unwrap_or_default() + } +} + +fn csharp_type_kind(kind: &str) -> &'static str { + match kind { + "class_declaration" => "class", + "delegate_declaration" => "delegate", + "enum_declaration" => "enum", + "interface_declaration" => "interface", + "record_declaration" => "record", + "struct_declaration" => "struct", + _ => "type", + } +} + +fn callable_kind(kind: &str) -> &'static str { + match kind { + "constructor_declaration" => "constructor", + "destructor_declaration" => "destructor", + "local_function_statement" => "local_function", + "operator_declaration" | "conversion_operator_declaration" => "operator", + _ => "method", + } +} + +fn is_callable_kind(kind: &str) -> bool { + matches!( + kind, + "constructor" | "destructor" | "local_function" | "method" | "operator" + ) +} + +fn join_qualified(owner: &str, name: &str, separator: &str) -> String { + if owner.is_empty() { + name.to_owned() + } else { + format!("{owner}{separator}{name}") + } +} + +fn terminal(value: &str) -> &str { + value + .rsplit(['.', ':']) + .find(|part| !part.is_empty()) + .unwrap_or(value) +} + +fn canonical_type(value: &str) -> String { + value + .chars() + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn is_predefined(value: &str) -> bool { + matches!( + value.trim_end_matches('?'), + "bool" + | "byte" + | "char" + | "decimal" + | "double" + | "dynamic" + | "float" + | "int" + | "long" + | "nint" + | "nuint" + | "object" + | "sbyte" + | "short" + | "string" + | "uint" + | "ulong" + | "ushort" + | "void" + ) +} + +fn first_named_child<'tree>(node: Node<'tree>, kinds: &[&str]) -> Option> { + let mut cursor = node.walk(); + node.children(&mut cursor) + .find(|child| kinds.contains(&child.kind())) +} + +fn first_descendant<'tree>(node: Node<'tree>, kind: &str) -> Option> { + if node.kind() == kind { + return Some(node); + } + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named()) + .find_map(|child| first_descendant(child, kind)) +} + +fn collect_descendants<'tree>(node: Node<'tree>, kind: &str, output: &mut Vec>) { + if node.kind() == kind { + output.push(node); + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_descendants(child, kind, output); + } +} + +fn file_scoped_namespace(root: Node<'_>, source: &[u8]) -> Option { + let mut cursor = root.walk(); + root.children(&mut cursor) + .find(|child| child.kind() == "file_scoped_namespace_declaration") + .and_then(|declaration| declaration.child_by_field_name("name")) + .and_then(|name| name.utf8_text(source).ok()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_owned) +} + +fn collect_direct_type_children<'tree>(node: Node<'tree>, output: &mut Vec>) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + if matches!( + child.kind(), + "identifier" + | "generic_name" + | "qualified_name" + | "alias_qualified_name" + | "nullable_type" + ) { + output.push(child); + } + } +} + +fn parameter_types(node: Node<'_>, source: &[u8]) -> Vec { + let Some(parameters) = node + .child_by_field_name("parameters") + .or_else(|| first_named_child(node, &["parameter_list", "bracketed_parameter_list"])) + else { + return Vec::new(); + }; + let mut nodes = Vec::new(); + collect_descendants(parameters, "parameter", &mut nodes); + nodes.sort_unstable_by_key(Node::start_byte); + nodes + .into_iter() + .map(|parameter| { + parameter.child_by_field_name("type").map_or_else( + || "?".to_owned(), + |value_type| canonical_type(value_type.utf8_text(source).unwrap_or_default()), + ) + }) + .collect() +} + +fn has_params_parameter(node: Node<'_>, source: &[u8]) -> bool { + node.child_by_field_name("parameters") + .or_else(|| first_named_child(node, &["parameter_list", "bracketed_parameter_list"])) + .is_some_and(|parameters| { + parameters + .utf8_text(source) + .unwrap_or_default() + .contains("params ") + }) +} + +fn callable_signature(node: Node<'_>, name: &str, parameters: &[String], source: &[u8]) -> String { + let type_parameters = type_parameter_signature(node, source).unwrap_or_default(); + format!("{name}{type_parameters}({})", parameters.join(",")) +} + +fn type_parameter_signature(node: Node<'_>, source: &[u8]) -> Option { + first_named_child(node, &["type_parameter_list"]) + .and_then(|parameters| parameters.utf8_text(source).ok()) + .map(canonical_type) + .filter(|value| !value.is_empty()) +} + +fn argument_count(node: Node<'_>) -> u32 { + let arguments = node + .child_by_field_name("arguments") + .or_else(|| first_named_child(node, &["argument_list"])); + let Some(arguments) = arguments else { return 0 }; + let mut cursor = arguments.walk(); + u32::try_from( + arguments + .children(&mut cursor) + .filter(|child| child.is_named()) + .count(), + ) + .unwrap_or(u32::MAX) +} diff --git a/crates/compass-languages/src/evidence/mod.rs b/crates/compass-languages/src/evidence/mod.rs index 36132382..a58fee0b 100644 --- a/crates/compass-languages/src/evidence/mod.rs +++ b/crates/compass-languages/src/evidence/mod.rs @@ -1,4 +1,5 @@ mod build; +mod csharp; mod model; mod typescript; mod validate; diff --git a/crates/compass-languages/src/evidence/model.rs b/crates/compass-languages/src/evidence/model.rs index 27e0a822..3f753a02 100644 --- a/crates/compass-languages/src/evidence/model.rs +++ b/crates/compass-languages/src/evidence/model.rs @@ -72,6 +72,7 @@ pub enum SemanticRole { Decorator, Annotation, BaseType, + Override, TypeReference, MemberAccess, Ownership, @@ -93,6 +94,7 @@ impl SemanticRole { Self::Decorator => LanguageCapability::Decorators, Self::Annotation | Self::TypeReference => LanguageCapability::TypeReferences, Self::BaseType => LanguageCapability::BaseTypes, + Self::Override => LanguageCapability::HierarchyDispatch, Self::MemberAccess => LanguageCapability::Members, Self::Ownership => LanguageCapability::Ownership, Self::Receiver => LanguageCapability::Receivers, @@ -153,6 +155,7 @@ pub enum CandidateRelation { Annotates, Extends, Implements, + Overrides, References, TypeOf, Returns, @@ -179,6 +182,7 @@ impl CandidateRelation { | Self::Returns | Self::Implements => LanguageCapability::TypeReferences, Self::Extends => LanguageCapability::BaseTypes, + Self::Overrides => LanguageCapability::HierarchyDispatch, Self::AccessesMember => LanguageCapability::Members, Self::Contains | Self::Owns => LanguageCapability::Ownership, Self::Embeds => LanguageCapability::Embedding, diff --git a/crates/compass-languages/src/evidence/typescript.rs b/crates/compass-languages/src/evidence/typescript.rs index 1c9b735c..3d29117d 100644 --- a/crates/compass-languages/src/evidence/typescript.rs +++ b/crates/compass-languages/src/evidence/typescript.rs @@ -13281,6 +13281,7 @@ fn role_name(role: SemanticRole) -> &'static str { SemanticRole::Decorator => "decorator", SemanticRole::Annotation => "annotation", SemanticRole::BaseType => "base_type", + SemanticRole::Override => "override", SemanticRole::TypeReference => "type_reference", SemanticRole::MemberAccess => "member_access", SemanticRole::Ownership => "ownership", diff --git a/crates/compass-languages/src/evidence/validate.rs b/crates/compass-languages/src/evidence/validate.rs index de355988..1d77390b 100644 --- a/crates/compass-languages/src/evidence/validate.rs +++ b/crates/compass-languages/src/evidence/validate.rs @@ -324,12 +324,13 @@ fn validate_fact( ( "java", "class" | "interface" | "enum" | "record" | "annotation_type" - ) | ("rust", "trait") + ) | ("csharp", "class" | "interface" | "record" | "struct") + | ("rust", "trait") ) { return Err(invalid_fact( &fact.id, - "complete direct-base evidence requires a Java type or Rust trait declaration", + "complete direct-base evidence requires a qualified nominal type declaration", )); } } @@ -557,16 +558,18 @@ fn validate_fact( .and_then(|id| occurrences.get(id)); match hierarchy { HierarchyConstraint::DirectBase { .. } - if fact.relation != CandidateRelation::Extends - || occurrence.is_none_or(|occurrence| { - occurrence.role != SemanticRole::BaseType - && !(fact.language == "rust" - && occurrence.role == SemanticRole::TraitBound) - }) => + if !matches!( + fact.relation, + CandidateRelation::Extends | CandidateRelation::Implements + ) || occurrence.is_none_or(|occurrence| { + occurrence.role != SemanticRole::BaseType + && !(fact.language == "rust" + && occurrence.role == SemanticRole::TraitBound) + }) => { return Err(invalid_fact( &fact.id, - "direct-base hierarchy evidence requires an extends/base-type occurrence", + "direct-base hierarchy evidence requires an extends-or-implements/base-type occurrence", )); } HierarchyConstraint::ReceiverDispatch { diff --git a/crates/compass-languages/src/frameworks/csharp.rs b/crates/compass-languages/src/frameworks/csharp.rs index 198f87a9..99c67219 100644 --- a/crates/compass-languages/src/frameworks/csharp.rs +++ b/crates/compass-languages/src/frameworks/csharp.rs @@ -1,148 +1,127 @@ -use std::collections::HashMap; -use std::path::Path; +//! ASP.NET MVC framework evidence derived from universal C# facts. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; use regex::Regex; use serde_json::{Map, Value}; use tree_sitter::Node; -use super::evidence::{EvidenceKind, EvidenceSet}; -use super::text::{anchor, join_route_path, line_anchor, normalize_route_path, text}; -use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; +use crate::SemanticRole; -pub(super) fn detect(path: &Path, source: &[u8], root: Node<'_>) -> Vec { - let mut masked = source.to_vec(); - mask_comments(root, &mut masked); - let body = text(&masked); - let evidence = EvidenceSet::new() - .direct_if( - body.contains("Microsoft.AspNetCore.Mvc"), - "aspnet", - EvidenceKind::Import, - "Microsoft.AspNetCore.Mvc", - ) - .supporting_if( - body.contains("[ApiController]"), - "aspnet", - EvidenceKind::DecoratorOrAttribute, - "ApiController", - ) - .direct_if( - body.contains("WebApplication.CreateBuilder") && body.contains(".Map"), - "aspnet", - EvidenceKind::Receiver, - "ASP.NET Core WebApplication minimal route receiver", - ); - if !evidence.activates("aspnet") { +use super::text::{anchor as source_anchor, join_route_path, text}; +use super::{ + DetectionContext, RawFrameworkAnchor, RawFrameworkAnnotationFact, RawFrameworkFact, + RawFrameworkOrigin, RawRouteFact, UniversalDetectionContext, +}; + +const PACK_ID: &str = "aspnet-csharp"; +const FRAMEWORK: &str = "aspnet"; +const MVC_NAMESPACE: &str = "Microsoft.AspNetCore.Mvc"; + +pub(super) fn detect(context: &UniversalDetectionContext<'_, '_>) -> Vec { + if context.evidence.adapter.language != "csharp" { return Vec::new(); } - let Ok(route_attribute) = Regex::new(r#"\[Route\(\s*"([^"]*)"\s*\)\]"#) else { - return Vec::new(); - }; - let Ok(http_attribute) = - Regex::new(r#"\[Http(Get|Post|Put|Patch|Delete|Head|Options)(?:\(\s*"([^"]*)"\s*\))?\]"#) - else { + let activated = context.project.is_some_and(|project| { + project.has_any_dependency(super::pack::ASPNET_CSHARP_DESCRIPTOR.dependency_markers) + }) || context.evidence.bindings.iter().any(|binding| { + binding.qualified_target == MVC_NAMESPACE + || binding + .qualified_target + .starts_with(&format!("{MVC_NAMESPACE}.")) + }); + if !activated { return Vec::new(); - }; - let Ok(class) = Regex::new(r"\bclass\s+([A-Za-z_][A-Za-z0-9_]*)") else { - return Vec::new(); - }; - let Ok(method) = Regex::new( - r"\b(?:public|protected|private|internal|static|virtual|override|async|\s)+[A-Za-z0-9_<>,.?\[\]\s]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", - ) else { - return Vec::new(); - }; - let mut class_name = None::; - let mut class_prefix = String::new(); - let mut pending_route = None::; - let mut pending_action_route = None::; - let mut pending_http = Vec::<(String, String, usize, String)>::new(); - let mut facts = collect_minimal_api_routes(path, source, body); - let mut offset = 0_usize; - for line in body.split_inclusive('\n') { - if let Some(capture) = route_attribute.captures(line) - && let Some(value) = capture.get(1) - { - if class_name.is_some() { - pending_action_route = Some(value.as_str().to_owned()); - } else { - pending_route = Some(value.as_str().to_owned()); + } + let declarations = context + .evidence + .declarations + .iter() + .map(|declaration| (declaration.id.as_str(), declaration)) + .collect::>(); + let candidates = context + .evidence + .candidates + .iter() + .filter_map(|candidate| { + candidate + .occurrence_id + .as_deref() + .map(|occurrence| (occurrence, candidate)) + }) + .collect::>(); + let mut attributes = BTreeMap::new(); + collect_attributes(context.root, &mut attributes); + let unique_bindings = unique_binding_map(context); + let mut facts = context + .evidence + .occurrences + .iter() + .filter(|occurrence| occurrence.role == SemanticRole::Annotation) + .filter_map(|occurrence| { + let declaration = declarations.get(occurrence.owner_declaration_id.as_str())?; + let attribute = attributes.get(&occurrence.range.start_byte).copied(); + let candidate = candidates.get(occurrence.id.as_str()); + let qualified = qualified_attribute(context, &occurrence.spelling).or_else(|| { + candidate.and_then(|candidate| candidate.constraints.qualified_name.clone()) + }); + if !is_mvc_attribute(&occurrence.spelling, qualified.as_deref()) { + return None; } - } - for capture in http_attribute.captures_iter(line) { - let Some(operation) = capture.get(1) else { - continue; - }; - pending_http.push(( - operation.as_str().to_ascii_uppercase(), - capture - .get(2) - .map(|value| value.as_str().to_owned()) - .unwrap_or_default(), - offset, - line.to_owned(), - )); - } - if let Some(name) = class.captures(line).and_then(|capture| capture.get(1)) { - class_name = Some(name.as_str().to_owned()); - class_prefix = pending_route - .take() - .unwrap_or_default() - .replace("[controller]", name.as_str().trim_end_matches("Controller")); - pending_http.clear(); - offset = offset.saturating_add(line.len()); - continue; - } - if (!pending_http.is_empty() || pending_action_route.is_some()) - && let (Some(class_name), Some(method_name)) = ( - class_name.as_deref(), - method - .captures(line) - .and_then(|capture| capture.get(1)) - .map(|value| value.as_str()), - ) - { - let action_route = pending_action_route.take(); - let pending_http = if pending_http.is_empty() { - vec![("ANY".to_owned(), String::new(), offset, line.to_owned())] - } else { - std::mem::take(&mut pending_http) - }; - for (operation, action_path, anchor_offset, anchor_line) in pending_http { - let template = action_route.as_deref().unwrap_or(&action_path); - let expanded_action = template - .replace("[controller]", class_name.trim_end_matches("Controller")) - .replace("[action]", method_name); - let normalized_path = if let Some(absolute) = expanded_action.strip_prefix("~/") { - normalize_route_path(absolute) - } else if expanded_action.starts_with('/') || class_prefix.is_empty() { - normalize_route_path(&expanded_action) - } else { - join_route_path(&class_prefix, &expanded_action) - }; - facts.push(RawFrameworkFact::Route(RawRouteFact { - framework: "aspnet".to_owned(), - operation, - raw_path: template.to_owned(), - normalized_path, - declaring_scope: class_name.to_owned(), - anchor: line_anchor(path, source, anchor_offset, &anchor_line), - handler_reference: format!("{class_name}.{method_name}"), - middleware_references: Vec::new(), - origin: RawFrameworkOrigin::Ast, - rule: Some(if action_route.is_some() { - "aspnet-action-route-attribute".to_owned() - } else { - "aspnet-http-attribute".to_owned() - }), - detail: Map::new(), - })); + let mut detail = Map::from_iter([( + "occurrenceId".to_owned(), + Value::String(occurrence.id.clone()), + )]); + if !unique_bindings.is_empty() { + detail.insert( + "bindings".to_owned(), + Value::Object(unique_bindings.clone()), + ); } - } - offset = offset.saturating_add(line.len()); - } + Some(RawFrameworkFact::Annotation(RawFrameworkAnnotationFact { + pack_id: PACK_ID.to_owned(), + framework: FRAMEWORK.to_owned(), + annotation_name: qualified + .as_deref() + .map(terminal_attribute) + .unwrap_or_else(|| terminal_attribute(&occurrence.spelling)) + .trim_end_matches("Attribute") + .to_owned(), + annotation_qualified_name: qualified, + owner_declaration_id: declaration.id.clone(), + owner_graph_node_id: declaration.graph_node_id.clone(), + owner_qualified_name: declaration.qualified_name.clone(), + owner_kind: declaration.kind.clone(), + owner_signature: declaration.signature.clone(), + anchor: anchor(&occurrence.range), + arguments: attribute + .map(|node| attribute_arguments(node, context.source)) + .unwrap_or_default(), + detail, + })) + }) + .collect::>(); + facts.sort_by(|left, right| fact_key(left).cmp(&fact_key(right))); facts } +/// Preserve ASP.NET Minimal API registrations while MVC routing moves to the +/// universal evidence pack. Minimal API receivers and inline handlers do not +/// yet have a universal C# relationship contract, so they remain behind the +/// established bounded, source-anchored detector. +pub(super) fn detect_minimal( + context: &DetectionContext<'_, '_>, + _extraction: &mut crate::Extraction, +) -> Vec { + let mut masked = context.source.to_vec(); + mask_comments(context.root, &mut masked); + let body = text(&masked); + if !body.contains("WebApplication.CreateBuilder") || !body.contains(".Map") { + return Vec::new(); + } + collect_minimal_api_routes(context, body) +} + fn mask_comments(node: Node<'_>, source: &mut [u8]) { if node.kind() == "comment" { for byte in source @@ -161,10 +140,10 @@ fn mask_comments(node: Node<'_>, source: &mut [u8]) { } } -fn collect_minimal_api_routes(path: &Path, source: &[u8], body: &str) -> Vec { - if !body.contains("WebApplication.CreateBuilder") { - return Vec::new(); - } +fn collect_minimal_api_routes( + context: &DetectionContext<'_, '_>, + body: &str, +) -> Vec { let Ok(group_pattern) = Regex::new( r#"(?m)\b(?:var|RouteGroupBuilder)\s+([A-Za-z_]\w*)\s*=\s*([A-Za-z_]\w*)\.MapGroup\(\s*"([^"]*)"\s*\)"#, ) else { @@ -220,12 +199,12 @@ fn collect_minimal_api_routes(path: &Path, source: &[u8], body: &str) -> Vec Vec, + spelling: &str, +) -> Option { + let terminal = terminal_attribute(spelling); + if spelling.contains('.') { + return Some(ensure_attribute_suffix(spelling)); + } + for binding in &context.evidence.bindings { + if binding.spelling == terminal && binding.qualified_target != MVC_NAMESPACE { + return Some(ensure_attribute_suffix(&binding.qualified_target)); + } + } + let namespaces = context + .evidence + .bindings + .iter() + .filter(|binding| binding.qualified_target == MVC_NAMESPACE) + .map(|binding| binding.qualified_target.as_str()) + .collect::>(); + (namespaces.len() == 1).then(|| { + format!( + "{MVC_NAMESPACE}.{}Attribute", + terminal.trim_end_matches("Attribute") + ) + }) +} + +fn is_mvc_attribute(spelling: &str, qualified: Option<&str>) -> bool { + let terminal = qualified + .map(terminal_attribute) + .unwrap_or_else(|| terminal_attribute(spelling)) + .trim_end_matches("Attribute"); + let supported = matches!( + terminal, + "AcceptVerbs" + | "ApiController" + | "Controller" + | "HttpDelete" + | "HttpGet" + | "HttpHead" + | "HttpOptions" + | "HttpPatch" + | "HttpPost" + | "HttpPut" + | "NonAction" + | "Route" + ); + supported + && qualified.is_some_and(|qualified| qualified.starts_with(&format!("{MVC_NAMESPACE}."))) +} + +fn terminal_attribute(value: &str) -> &str { + value + .rsplit(['.', ':']) + .find(|part| !part.is_empty()) + .unwrap_or(value) +} + +fn ensure_attribute_suffix(value: &str) -> String { + if value.ends_with("Attribute") { + value.to_owned() + } else { + format!("{value}Attribute") + } +} + +fn collect_attributes<'tree>(node: Node<'tree>, output: &mut BTreeMap>) { + if node.kind() == "attribute" { + output.insert(u64::try_from(node.start_byte()).unwrap_or(u64::MAX), node); + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_attributes(child, output); + } +} + +fn attribute_arguments(node: Node<'_>, source: &[u8]) -> Map { + let Some(arguments) = node + .child_by_field_name("arguments") + .or_else(|| first_child(node, "attribute_argument_list")) + else { + return Map::new(); + }; + let mut output = Map::new(); + let mut position = 0_u32; + let mut cursor = arguments.walk(); + for argument in arguments + .children(&mut cursor) + .filter(|child| child.is_named()) + { + let raw = argument.utf8_text(source).unwrap_or_default().trim(); + let (key, expression) = raw.split_once('=').map_or_else( + || (position.to_string(), raw), + |(key, value)| (key.trim().to_owned(), value.trim()), + ); + if let Some(value) = string_literal(expression) { + output.insert(key, Value::String(value)); + } else if !expression.is_empty() { + output.insert(key, Value::String(expression.to_owned())); + } + position = position.saturating_add(1); + } + output +} + +fn string_literal(value: &str) -> Option { + let value = value.trim(); + let value = value.strip_prefix('@').unwrap_or(value); + let value = value.strip_prefix('"')?.strip_suffix('"')?; + Some(value.replace("\"\"", "\"").replace("\\\"", "\"")) +} + +fn unique_binding_map(context: &UniversalDetectionContext<'_, '_>) -> Map { + let mut grouped = BTreeMap::<&str, Vec<&str>>::new(); + for binding in &context.evidence.bindings { + grouped + .entry(&binding.spelling) + .or_default() + .push(&binding.qualified_target); + } + grouped + .into_iter() + .filter_map(|(spelling, mut targets)| { + targets.sort_unstable(); + targets.dedup(); + (targets.len() == 1) + .then(|| (spelling.to_owned(), Value::String(targets[0].to_owned()))) + }) + .collect() +} + +fn anchor(range: &crate::EvidenceRange) -> RawFrameworkAnchor { + RawFrameworkAnchor { + source_file: range.source_file.clone(), + start_byte: range.start_byte, + end_byte: range.end_byte, + start_line: range.start_line, + start_column: range.start_column, + end_line: range.end_line, + end_column: range.end_column, + } +} + +fn fact_key(fact: &RawFrameworkFact) -> (&str, u64, &str) { + match fact { + RawFrameworkFact::Annotation(annotation) => ( + annotation.anchor.source_file.as_str(), + annotation.anchor.start_byte, + annotation.annotation_name.as_str(), + ), + RawFrameworkFact::Route(route) => ( + route.anchor.source_file.as_str(), + route.anchor.start_byte, + route.operation.as_str(), + ), + RawFrameworkFact::Domain(domain) => ( + domain.anchor.source_file.as_str(), + domain.anchor.start_byte, + domain.kind.as_str(), + ), + } +} + +fn first_child<'tree>(node: Node<'tree>, kind: &str) -> Option> { + let mut cursor = node.walk(); + node.children(&mut cursor) + .find(|child| child.kind() == kind) +} diff --git a/crates/compass-languages/src/frameworks/mod.rs b/crates/compass-languages/src/frameworks/mod.rs index adb85777..fa2cc9c1 100644 --- a/crates/compass-languages/src/frameworks/mod.rs +++ b/crates/compass-languages/src/frameworks/mod.rs @@ -277,6 +277,13 @@ impl FrameworkPack { /// intentionally static: pack identity, ordering, activation policy, and /// adapter ownership remain deterministic and do not require a plugin ABI. const FRAMEWORK_PACKS: &[FrameworkPack] = &[ + FrameworkPack::universal(&pack::ASPNET_CSHARP_DESCRIPTOR, csharp::detect), + FrameworkPack::source( + "aspnet-minimal-csharp", + &["csharp"], + &["microsoft.aspnetcore.app"], + csharp::detect_minimal, + ), FrameworkPack::universal(&pack::SPRING_JAVA_DESCRIPTOR, spring::detect), FrameworkPack::source("python-web", &["python"], &[], detect_python), FrameworkPack::source( @@ -298,12 +305,6 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ FrameworkPack::source("go-web", &["go"], &[], detect_go), FrameworkPack::source("axum-web", &["rust"], &["axum"], detect_axum), FrameworkPack::source("rust-web", &["rust"], &[], detect_rust), - FrameworkPack::source( - "aspnet-web", - &["csharp"], - &["microsoft.aspnetcore.app"], - detect_csharp, - ), FrameworkPack::source("vapor-routes", &["swift"], &["vapor"], detect_swift), FrameworkPack::source( "express-web", @@ -579,13 +580,6 @@ fn detect_axum( axum::detect(context.path, context.source, context.root) } -fn detect_csharp( - context: &DetectionContext<'_, '_>, - _extraction: &mut Extraction, -) -> Vec { - csharp::detect(context.path, context.source, context.root) -} - fn detect_swift( context: &DetectionContext<'_, '_>, _extraction: &mut Extraction, @@ -704,7 +698,7 @@ mod tests { "go-web", "axum-web", "rust-web", - "aspnet-web", + "aspnet-csharp", "vapor-routes", "express-web", "fastify-web", diff --git a/crates/compass-languages/src/frameworks/pack.rs b/crates/compass-languages/src/frameworks/pack.rs index cc666312..6b61bb77 100644 --- a/crates/compass-languages/src/frameworks/pack.rs +++ b/crates/compass-languages/src/frameworks/pack.rs @@ -435,4 +435,42 @@ pub(super) const SPRING_JAVA_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPack }, }; -const UNIVERSAL_FRAMEWORK_PACKS: &[FrameworkPackDescriptor] = &[SPRING_JAVA_DESCRIPTOR]; +pub(super) const ASPNET_CSHARP_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { + id: "aspnet-csharp", + kind: FrameworkPackKind::Source, + languages: &["csharp"], + required_capabilities: &[ + LanguageCapability::Declarations, + LanguageCapability::LexicalScopes, + LanguageCapability::Namespaces, + LanguageCapability::Imports, + LanguageCapability::Aliases, + LanguageCapability::Decorators, + LanguageCapability::TypeReferences, + LanguageCapability::BaseTypes, + LanguageCapability::Members, + LanguageCapability::Ownership, + ], + framework_capabilities: &[FrameworkCapability::HttpRoutes], + dependency_markers: &["microsoft.aspnetcore.app"], + manifest_policy: FrameworkManifestPolicy::Advisory, + activation_rules: &["aspnet-mvc-attribute-binding", "aspnet-project-dependency"], + accepted_roles: &[ + SemanticRole::Import, + SemanticRole::Annotation, + SemanticRole::BaseType, + SemanticRole::TypeReference, + SemanticRole::Ownership, + ], + emitted_relation_families: &[FrameworkRelation::RoutesTo], + occurrence_policy: FrameworkOccurrencePolicy::ExactEvidence, + limits: FrameworkLimits { + max_candidates: 64, + max_include_depth: 32, + max_alias_expansions: 1_000, + max_facts_per_file: 100_000, + }, +}; + +const UNIVERSAL_FRAMEWORK_PACKS: &[FrameworkPackDescriptor] = + &[ASPNET_CSHARP_DESCRIPTOR, SPRING_JAVA_DESCRIPTOR]; diff --git a/crates/compass-languages/src/lib.rs b/crates/compass-languages/src/lib.rs index d0190fe9..efdddfdc 100644 --- a/crates/compass-languages/src/lib.rs +++ b/crates/compass-languages/src/lib.rs @@ -6,7 +6,6 @@ mod bash; mod builtins; mod config; mod cpp; -mod csharp; mod dart; mod dm; mod dotnet_project; diff --git a/crates/compass-languages/src/program/mod.rs b/crates/compass-languages/src/program/mod.rs index 02880aa0..0df03ee1 100644 --- a/crates/compass-languages/src/program/mod.rs +++ b/crates/compass-languages/src/program/mod.rs @@ -62,6 +62,13 @@ pub(crate) fn extract_from_tree( Ok(batch) } +pub(crate) fn supports_language(language: &str) -> bool { + matches!( + language, + "python" | "rust" | "typescript" | "tsx" | "javascript" + ) +} + impl SyntaxProvider for TreeSitterSyntaxProvider { fn descriptor(&self, input: &FileInput<'_>) -> ProviderDescriptor { syntax_descriptor(input) diff --git a/crates/compass-languages/src/xaml.rs b/crates/compass-languages/src/xaml.rs index 073699c7..3f9ccd86 100644 --- a/crates/compass-languages/src/xaml.rs +++ b/crates/compass-languages/src/xaml.rs @@ -8,7 +8,9 @@ use crate::{RawEdgeRecord as EdgeRecord, RawNodeRecord as NodeRecord}; use regex::Regex; use serde_json::{Map, Value, json}; -use crate::{Engine, ExtractError, Extraction, file_stem, make_id}; +use crate::{ + CandidateRelation, DeclarationFact, Engine, ExtractError, Extraction, file_stem, make_id, +}; const MAX_BYTES: u64 = 2 * 1024 * 1024; const NON_EVENT_ATTRIBUTES: &[&str] = &[ @@ -161,24 +163,25 @@ pub(crate) fn extract(engine: &mut Engine, path: &Path) -> Result = viewmodels + let candidates: HashMap = viewmodels .iter() .flat_map(|name| classes.get(name).into_iter().flatten()) - .map(|node| (node.id.clone(), node.clone())) + .map(|viewmodel| (viewmodel.node.id.clone(), viewmodel.clone())) .collect(); if candidates.len() == 1 && let Some(viewmodel) = candidates.values().next() { - state.add_existing_node(viewmodel); + state.add_existing_node(&viewmodel.node); state.add_edge( &root_id, - &viewmodel.id, + &viewmodel.node.id, "references", - state.line_for(Some(viewmodel.label())), + state.line_for(Some(viewmodel.node.label())), Some("view_model"), viewmodel_confidence, ); - let (members, edges) = community_toolkit_members(viewmodel); + let (members, edges) = + community_toolkit_members(&viewmodel.node, &viewmodel.source_path); for member in members.values() { state.add_existing_node(member); } @@ -372,25 +375,55 @@ fn codebehind_symbols(engine: &mut Engine, path: &Path, class_name: Option<&str> method_edges: Vec::new(), }; }; + let Some(evidence) = extraction.semantic_evidence.as_ref() else { + return Codebehind { + class_node: None, + methods: HashMap::new(), + method_edges: Vec::new(), + }; + }; let simple = class_name.and_then(|name| name.rsplit('.').next()); - let class_node = simple - .and_then(|name| extraction.nodes.iter().find(|node| node.label() == name)) - .cloned(); - let method_edges: Vec = class_node.as_ref().map_or_else(Vec::new, |class_node| { - extraction - .edges + let class_declaration = simple.and_then(|name| { + let matches = evidence + .declarations .iter() - .filter(|edge| { - edge.source == class_node.id - && edge.attributes.get("relation").and_then(Value::as_str) == Some("method") + .filter(|declaration| { + matches!(declaration.kind.as_str(), "class" | "record" | "struct") + && declaration.name == name + && class_name.is_none_or(|qualified| { + !qualified.contains('.') || declaration.qualified_name == qualified + }) }) - .cloned() - .collect() + .collect::>(); + if matches.len() == 1 { + Some(matches[0]) + } else { + None + } }); - let method_ids: Option> = class_node.as_ref().map(|_| { - method_edges + let class_node = class_declaration.map(csharp_declaration_node); + let owned_method_ids = class_declaration.map_or_else(HashSet::new, |class_declaration| { + evidence + .candidates + .iter() + .filter(|candidate| { + candidate.relation == CandidateRelation::Owns + && candidate.source_declaration_id == class_declaration.id + }) + .filter_map(|candidate| candidate.constraints.exact_target_declaration_id.as_deref()) + .collect::>() + }); + let owned_methods = evidence + .declarations + .iter() + .filter(|declaration| { + declaration.kind == "method" && owned_method_ids.contains(declaration.id.as_str()) + }) + .collect::>(); + let method_edges = class_node.as_ref().map_or_else(Vec::new, |class_node| { + owned_methods .iter() - .map(|edge| edge.target.as_str()) + .map(|method| csharp_method_edge(class_node, method)) .collect() }); let lines = fs::read(&codebehind_path).map_or_else( @@ -403,19 +436,19 @@ fn codebehind_symbols(engine: &mut Engine, path: &Path, class_name: Option<&str> }, ); let mut methods = HashMap::new(); - for node in extraction.nodes { - if method_ids - .as_ref() - .is_some_and(|ids| !ids.contains(node.id.as_str())) - { + let mut ambiguous = HashSet::new(); + for declaration in owned_methods { + let node = csharp_declaration_node(declaration); + if !has_event_signature(&node, &lines) { continue; } - let label = node.label(); - if !label.starts_with('.') || !label.ends_with("()") || !has_event_signature(&node, &lines) - { - continue; + let name = declaration.name.clone(); + if methods.insert(name.clone(), node).is_some() { + ambiguous.insert(name); } - methods.insert(label.trim_matches(['.', '(', ')']).to_owned(), node); + } + for name in ambiguous { + methods.remove(&name); } Codebehind { class_node, @@ -445,6 +478,67 @@ fn has_event_signature(node: &NodeRecord, lines: &[String]) -> bool { ) } +fn csharp_declaration_node(declaration: &DeclarationFact) -> NodeRecord { + let mut attributes = Map::new(); + let label = if declaration.kind == "method" { + format!(".{}()", declaration.name) + } else { + declaration.name.clone() + }; + attributes.insert("label".to_owned(), Value::String(label)); + attributes.insert("file_type".to_owned(), Value::String("code".to_owned())); + attributes.insert( + "symbol_kind".to_owned(), + Value::String(declaration.kind.clone()), + ); + attributes.insert( + "qualified_name".to_owned(), + Value::String(declaration.qualified_name.clone()), + ); + attributes.insert( + "source_file".to_owned(), + Value::String(declaration.range.source_file.clone()), + ); + attributes.insert( + "source_location".to_owned(), + Value::String(format!("L{}", declaration.range.start_line)), + ); + attributes.insert("start_byte".to_owned(), json!(declaration.range.start_byte)); + attributes.insert("end_byte".to_owned(), json!(declaration.range.end_byte)); + if let Some(signature) = declaration.signature.as_ref() { + attributes.insert("signature".to_owned(), Value::String(signature.clone())); + } + NodeRecord { + id: declaration.graph_node_id.clone(), + attributes, + } +} + +fn csharp_method_edge(class_node: &NodeRecord, method: &DeclarationFact) -> EdgeRecord { + let mut attributes = Map::new(); + attributes.insert("relation".to_owned(), Value::String("method".to_owned())); + attributes.insert( + "confidence".to_owned(), + Value::String("EXTRACTED".to_owned()), + ); + attributes.insert( + "source_file".to_owned(), + Value::String(method.range.source_file.clone()), + ); + attributes.insert( + "source_location".to_owned(), + Value::String(format!("L{}", method.range.start_line)), + ); + attributes.insert("start_byte".to_owned(), json!(method.range.start_byte)); + attributes.insert("end_byte".to_owned(), json!(method.range.end_byte)); + attributes.insert("weight".to_owned(), json!(1.0)); + EdgeRecord { + source: class_node.id.clone(), + target: method.graph_node_id.clone(), + attributes, + } +} + fn codebehind_path(path: &Path) -> Option { let expected = PathBuf::from(format!("{}.cs", path.to_string_lossy())); if expected.exists() { @@ -740,23 +834,36 @@ fn inferred_viewmodel_names(view: Option<&str>) -> Vec { names } -fn csharp_viewmodels(engine: &mut Engine, path: &Path) -> HashMap> { +#[derive(Clone)] +struct CsharpViewModel { + node: NodeRecord, + source_path: PathBuf, +} + +fn csharp_viewmodels(engine: &mut Engine, path: &Path) -> HashMap> { let root = project_root(path); let mut files = Vec::new(); collect_csharp_files(&root, &mut files); files.sort(); - let mut classes: HashMap> = HashMap::new(); + let mut classes: HashMap> = HashMap::new(); for file in files { let Ok(extraction) = engine.extract(&file) else { continue; }; - for node in extraction.nodes { - let label = node.label(); - if label.ends_with("ViewModel") - && IDENTIFIER.is_match(label) - && !node.string("source_file").is_empty() - { - classes.entry(label.to_owned()).or_default().push(node); + let Some(evidence) = extraction.semantic_evidence.as_ref() else { + continue; + }; + for declaration in evidence.declarations.iter().filter(|declaration| { + matches!(declaration.kind.as_str(), "class" | "record" | "struct") + }) { + if declaration.name.ends_with("ViewModel") && IDENTIFIER.is_match(&declaration.name) { + classes + .entry(declaration.name.clone()) + .or_default() + .push(CsharpViewModel { + node: csharp_declaration_node(declaration), + source_path: file.clone(), + }); } } } @@ -821,12 +928,13 @@ fn collect_csharp_files(directory: &Path, output: &mut Vec) { fn community_toolkit_members( viewmodel: &NodeRecord, + source_path: &Path, ) -> (HashMap, Vec) { let source_file = viewmodel.string("source_file"); if source_file.is_empty() { return (HashMap::new(), Vec::new()); } - let Ok(bytes) = fs::read(&source_file) else { + let Ok(bytes) = fs::read(source_path) else { return (HashMap::new(), Vec::new()); }; let text = String::from_utf8_lossy(&bytes); diff --git a/crates/compass-languages/tests/csharp_universal_conformance.rs b/crates/compass-languages/tests/csharp_universal_conformance.rs new file mode 100644 index 00000000..8657bf53 --- /dev/null +++ b/crates/compass-languages/tests/csharp_universal_conformance.rs @@ -0,0 +1,194 @@ +use std::error::Error; +use std::path::Path; + +use compass_languages::{ + AdapterRegistry, BindingKind, CandidateRelation, Engine, EvidenceLimits, LanguageCapability, + SemanticRole, UniversalAdapterProfile, validate_evidence, +}; + +#[test] +fn csharp_emits_bounded_full_language_evidence_without_a_replaced_raw_graph() +-> Result<(), Box> { + let source = br#"global using Text = System.String; +using Microsoft.AspNetCore.Mvc; +using Demo.Data; + +namespace Demo.Api; + +public interface IWorker { Result Run(Input input); } +public record Input(string Value); +public record Result(string Value); + +[ApiController] +[Route("api/[controller]")] +public partial class UsersController : ControllerBase, IWorker +{ + public const int Limit = 4; + private readonly Repository repository; + public Repository Repository { get; init; } + + public UsersController(Repository repository) { this.repository = repository; } + + [HttpGet("{id}")] + public Result Run(Input input) + { + Repository local = new Repository(); + local.Load(input.Value); + return new Result(input.Value); + } + + public Result Run(Input input, int limit) => Run(input); +} +"#; + let extraction = Engine::default().extract_source_combined( + Path::new("/repo/src/Demo.Api/UsersController.cs"), + "src/Demo.Api/UsersController.cs", + source, + )?; + assert_eq!( + extraction.graph.error, None, + "graph={:#?}", + extraction.graph + ); + assert!(extraction.graph.nodes.is_empty()); + assert!(extraction.graph.edges.is_empty()); + assert!( + extraction.graph.raw_calls.is_none(), + "graph={:#?}", + extraction.graph + ); + let evidence = extraction + .graph + .semantic_evidence + .as_ref() + .ok_or("missing C# universal evidence")?; + validate_evidence(evidence, EvidenceLimits::default())?; + assert_eq!(evidence.adapter.id, "compass.csharp.candidate"); + assert_eq!(evidence.adapter.version, 1); + assert_eq!( + evidence.adapter.profile, + UniversalAdapterProfile::UniversalCandidate + ); + for capability in [ + LanguageCapability::Namespaces, + LanguageCapability::Imports, + LanguageCapability::Aliases, + LanguageCapability::Calls, + LanguageCapability::Construction, + LanguageCapability::Decorators, + LanguageCapability::BaseTypes, + LanguageCapability::HierarchyDispatch, + LanguageCapability::Members, + LanguageCapability::Receivers, + ] { + assert!(evidence.adapter.capabilities.contains(&capability)); + } + let controller = evidence + .declarations + .iter() + .find(|declaration| declaration.qualified_name == "Demo.Api.UsersController") + .ok_or_else(|| format!("missing controller: {:#?}", evidence.declarations))?; + assert_eq!(controller.kind, "class"); + assert!(controller.direct_bases_complete); + for kind in ["constructor", "property", "field", "constant", "parameter"] { + assert!( + evidence + .declarations + .iter() + .any(|declaration| declaration.kind == kind), + "missing {kind}: {:#?}", + evidence.declarations + ); + } + let overloads = evidence + .declarations + .iter() + .filter(|declaration| declaration.qualified_name == "Demo.Api.UsersController::Run") + .collect::>(); + assert_eq!(overloads.len(), 2); + assert_eq!( + overloads + .iter() + .map(|declaration| declaration.parameter_count) + .collect::>(), + [Some(1), Some(2)].into_iter().collect() + ); + assert!(evidence.bindings.iter().any(|binding| { + binding.kind == BindingKind::ImportAlias + && binding.spelling == "Text" + && binding.qualified_target == "System.String" + })); + assert!(evidence.occurrences.iter().any(|occurrence| { + occurrence.role == SemanticRole::Annotation && occurrence.spelling == "HttpGet" + })); + for relation in [ + CandidateRelation::Calls, + CandidateRelation::Constructs, + CandidateRelation::Annotates, + CandidateRelation::Implements, + CandidateRelation::TypeOf, + CandidateRelation::Returns, + CandidateRelation::Owns, + ] { + assert!( + evidence + .candidates + .iter() + .any(|candidate| candidate.relation == relation), + "missing {relation:?}: {:#?}", + evidence.candidates + ); + } + for occurrence in &evidence.occurrences { + let start = usize::try_from(occurrence.range.start_byte)?; + let end = usize::try_from(occurrence.range.end_byte)?; + assert!(start < end && end <= source.len()); + } + Ok(()) +} + +#[test] +fn csharp_evidence_is_deterministic_and_parser_recovery_is_explicit() -> Result<(), Box> +{ + let source = b"namespace Demo; class Broken { void Run() { Missing() } }"; + let mut engine = Engine::default(); + let first = engine.extract_source(Path::new("Broken.cs"), source)?; + let second = engine.extract_source(Path::new("Broken.cs"), source)?; + assert_eq!(first.semantic_evidence, second.semantic_evidence); + assert_eq!(first.error, None, "extraction={first:#?}"); + let evidence = first.semantic_evidence.ok_or("missing C# evidence")?; + assert!(evidence.diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.code.as_str(), + "parser_error" | "partial_parser_recovery" + ) + })); + Ok(()) +} + +#[test] +fn csharp_evidence_identity_is_independent_of_checkout_root() -> Result<(), Box> { + let source = b"using Microsoft.AspNetCore.Mvc; class Controller { [HttpGet] void Run() {} }"; + let first = Engine::default().extract_source_combined( + Path::new("/checkout-a/src/Controller.cs"), + "src/Controller.cs", + source, + )?; + let second = Engine::default().extract_source_combined( + Path::new("/checkout-b/src/Controller.cs"), + "src/Controller.cs", + source, + )?; + assert_eq!( + first.graph.semantic_evidence, second.graph.semantic_evidence, + "C# evidence identities must derive from the portable source identity" + ); + Ok(()) +} + +#[test] +fn csharp_profile_is_registered_as_a_universal_candidate() -> Result<(), Box> { + let profile = AdapterRegistry::universal_profile("csharp").ok_or("missing C# profile")?; + assert_eq!(profile.profile, UniversalAdapterProfile::UniversalCandidate); + Ok(()) +} diff --git a/crates/compass-languages/tests/engine_edge_coverage.rs b/crates/compass-languages/tests/engine_edge_coverage.rs index 10aaa9d8..daf989cd 100644 --- a/crates/compass-languages/tests/engine_edge_coverage.rs +++ b/crates/compass-languages/tests/engine_edge_coverage.rs @@ -33,8 +33,9 @@ fn universal_framework_pack_registry_accepts_only_cut_over_language_evidence() { FrameworkPackRegistry::validate_descriptors(&[descriptor]), Ok(()) ); - assert_eq!(FrameworkPackRegistry::descriptors().len(), 1); - assert_eq!(FrameworkPackRegistry::descriptors()[0].id, "spring-java"); + assert_eq!(FrameworkPackRegistry::descriptors().len(), 2); + assert_eq!(FrameworkPackRegistry::descriptors()[0].id, "aspnet-csharp"); + assert_eq!(FrameworkPackRegistry::descriptors()[1].id, "spring-java"); assert_eq!(FrameworkPackRegistry::validate(), Ok(())); let rust = FrameworkPackDescriptor { @@ -2064,7 +2065,12 @@ const rows = items.map(formatRow); let path = directory.path().join(name); fs::write(&path, source)?; let extraction = engine.extract(&path)?; - assert!(!extraction.nodes.is_empty(), "{name}"); + if let Some(evidence) = extraction.semantic_evidence.as_ref() { + assert!(!evidence.declarations.is_empty(), "{name}"); + assert!(!evidence.candidates.is_empty(), "{name}"); + continue; + } + assert!(!extraction.nodes.is_empty(), "{name}: {extraction:#?}"); assert!( extraction.edges.iter().any(|edge| matches!( edge.string("relation").as_str(), diff --git a/crates/compass-languages/tests/semantic_producers.rs b/crates/compass-languages/tests/semantic_producers.rs index 803df7b3..655e44d5 100644 --- a/crates/compass-languages/tests/semantic_producers.rs +++ b/crates/compass-languages/tests/semantic_producers.rs @@ -20,206 +20,6 @@ fn relations(extraction: &Extraction) -> HashSet { .collect() } -fn assert_exact_containment( - extraction: &Extraction, - target_qualified_prefix: &str, - owner_qualified_prefix: Option<&str>, -) -> Result<(), Box> { - let target = extraction - .nodes - .iter() - .find(|node| { - let qualified = node.string("qualified_name"); - if target_qualified_prefix.ends_with('@') { - qualified.starts_with(target_qualified_prefix) - } else { - qualified == target_qualified_prefix - } - }) - .ok_or_else(|| { - format!( - "missing target {target_qualified_prefix}: {:?}", - extraction - .nodes - .iter() - .map(|node| node.string("qualified_name")) - .collect::>() - ) - })?; - let owner = match owner_qualified_prefix { - Some(prefix) => extraction - .nodes - .iter() - .find(|node| { - let qualified = node.string("qualified_name"); - if prefix.ends_with('@') { - qualified.starts_with(prefix) - } else { - qualified == prefix - } - }) - .ok_or_else(|| format!("missing owner {prefix}"))?, - None => extraction - .nodes - .iter() - .find(|node| { - matches!(node.string("symbol_kind").as_str(), "file" | "source_file") - || (node.string("qualified_name").is_empty() && node.label().contains('.')) - }) - .ok_or_else(|| { - format!( - "missing file owner: {:?}", - extraction - .nodes - .iter() - .map(|node| (node.label(), node.string("symbol_kind"))) - .collect::>() - ) - })?, - }; - let start = target.attributes["start_byte"] - .as_u64() - .ok_or("missing target start byte")?; - let end = target.attributes["end_byte"] - .as_u64() - .ok_or("missing target end byte")?; - let occurrences = extraction - .edges - .iter() - .filter(|edge| { - edge.string("relation") == "contains" - && edge - .attributes - .get("start_byte") - .and_then(serde_json::Value::as_u64) - == Some(start) - && edge - .attributes - .get("end_byte") - .and_then(serde_json::Value::as_u64) - == Some(end) - }) - .collect::>(); - assert_eq!( - occurrences.len(), - 1, - "containment site {start}..{end} for {target_qualified_prefix}: {occurrences:#?}" - ); - assert_eq!(occurrences[0].source, owner.id); - assert_eq!(occurrences[0].target, target.id); - Ok(()) -} - -fn assert_unique_node_ids(extraction: &Extraction) { - let ids = extraction - .nodes - .iter() - .map(|node| node.id.as_str()) - .collect::>(); - assert_eq!( - ids.len(), - extraction.nodes.len(), - "nodes={:#?}", - extraction.nodes - ); -} - -fn assert_containment_sites_belong_to_targets(extraction: &Extraction) { - for edge in extraction.edges.iter().filter(|edge| { - matches!( - edge.string("relation").as_str(), - "contains" | "defines" | "method" - ) - }) { - let Some(target) = extraction.nodes.iter().find(|node| node.id == edge.target) else { - continue; - }; - if target.string("qualified_name").is_empty() { - continue; - } - assert!( - target.attributes.contains_key("start_byte") - && target.attributes.contains_key("end_byte"), - "legacy semantic alias retained containment: target={target:#?} edge={edge:#?}" - ); - assert_eq!( - ( - edge.attributes["start_byte"].as_u64(), - edge.attributes["end_byte"].as_u64() - ), - ( - target.attributes["start_byte"].as_u64(), - target.attributes["end_byte"].as_u64() - ), - "public containment alias must use its target declaration site: target={target:#?} edge={edge:#?}" - ); - } - for target in extraction.nodes.iter().filter(|node| { - !node.string("qualified_name").is_empty() - && node.attributes.contains_key("start_byte") - && node.attributes.contains_key("end_byte") - && matches!( - node.string("symbol_kind").as_str(), - "module" - | "namespace" - | "trait" - | "struct" - | "enum" - | "type_alias" - | "class" - | "interface" - | "record" - | "field" - | "property" - | "constant" - | "enum_member" - | "function" - | "method" - | "constructor" - | "parameter" - | "macro" - | "export" - | "annotation" - ) - }) { - let occurrences = extraction - .edges - .iter() - .filter(|edge| { - matches!(edge.string("relation").as_str(), "contains" | "method") - && edge.target == target.id - }) - .collect::>(); - assert_eq!( - occurrences.len(), - 1, - "managed target must have exactly one containment: target={target:#?} edges={occurrences:#?}" - ); - let edge = occurrences[0]; - let Some(site_start) = edge - .attributes - .get("start_byte") - .and_then(serde_json::Value::as_u64) - else { - continue; - }; - let Some(site_end) = edge - .attributes - .get("end_byte") - .and_then(serde_json::Value::as_u64) - else { - continue; - }; - let target_start = target.attributes["start_byte"].as_u64().unwrap_or_default(); - let target_end = target.attributes["end_byte"].as_u64().unwrap_or_default(); - assert_eq!( - (site_start, site_end), - (target_start, target_end), - "managed containment site must equal target declaration: target={target:#?} edge={edge:#?}" - ); - } -} - #[test] fn javascript_prototype_and_fn_assignments_publish_bounded_methods() -> Result<(), Box> { let directory = tempfile::tempdir()?; @@ -634,20 +434,32 @@ class Service : Base { } "#; let csharp_extraction = Engine::default().extract_source(&csharp, csharp_source)?; - let node_kinds = kinds(&csharp_extraction); - let edge_kinds = relations(&csharp_extraction); + let csharp_evidence = csharp_extraction + .semantic_evidence + .as_ref() + .ok_or("missing C# universal evidence")?; for expected in ["constructor", "property", "field", "constant", "parameter"] { assert!( - node_kinds.contains(expected), - "missing {expected}: nodes={:?}", - csharp_extraction.nodes + csharp_evidence + .declarations + .iter() + .any(|declaration| declaration.kind == expected), + "missing {expected}: declarations={:?}", + csharp_evidence.declarations ); } - for expected in ["type_of", "returns", "overrides"] { + for expected in [ + CandidateRelation::TypeOf, + CandidateRelation::Returns, + CandidateRelation::Overrides, + ] { assert!( - edge_kinds.contains(expected), - "missing {expected}: edges={:?}", - csharp_extraction.edges + csharp_evidence + .candidates + .iter() + .any(|candidate| candidate.relation == expected), + "missing {expected:?}: candidates={:?}", + csharp_evidence.candidates ); } @@ -940,33 +752,67 @@ class Service { } "#; let csharp = Engine::default().extract_source(&csharp_path, csharp_source)?; - let csharp_items = csharp - .nodes + let csharp_evidence = csharp + .semantic_evidence + .as_ref() + .ok_or("missing C# universal evidence")?; + let csharp_items = csharp_evidence + .declarations .iter() - .filter(|node| node.string("symbol_kind") == "class" && node.label() == "Item") + .filter(|declaration| declaration.kind == "class" && declaration.name == "Item") .collect::>(); - assert_eq!(csharp_items.len(), 2, "nodes={:?}", csharp.nodes); assert_eq!( - csharp - .nodes + csharp_items.len(), + 2, + "declarations={:?}", + csharp_evidence.declarations + ); + assert!( + csharp_items + .iter() + .any(|declaration| declaration.qualified_name == "Alpha.Item") + ); + assert!( + csharp_items .iter() - .filter(|node| node.string("symbol_kind") == "constructor") + .any(|declaration| declaration.qualified_name == "Beta.Item") + ); + assert_eq!( + csharp_evidence + .declarations + .iter() + .filter(|declaration| declaration.kind == "constructor") .count(), 2, - "nodes={:?}", - csharp.nodes + "declarations={:?}", + csharp_evidence.declarations ); assert_eq!( - csharp - .nodes + csharp_evidence + .declarations .iter() - .filter(|node| { - node.string("symbol_kind") == "method" && node.label().contains("Run") - }) + .filter(|declaration| { declaration.kind == "method" && declaration.name == "Run" }) .count(), 2, - "nodes={:?}", - csharp.nodes + "declarations={:?}", + csharp_evidence.declarations + ); + let overloaded = csharp_evidence + .declarations + .iter() + .filter(|declaration| { + declaration.kind == "constructor" + || (declaration.kind == "method" && declaration.name == "Run") + }) + .collect::>(); + assert_eq!( + overloaded + .iter() + .map(|declaration| declaration.id.as_str()) + .collect::>() + .len(), + overloaded.len(), + "C# overload declarations must retain distinct evidence identities" ); Ok(()) } @@ -1114,32 +960,65 @@ namespace Two { let csharp_path = directory.path().join("Ownership.cs"); let csharp_source = b"class Shared {} namespace One { class Item { void Run(int value) {} } class Outer { class Leaf {} } } namespace Two { class Item { void Run(int value) {} } class Shared {} }\n"; let csharp = Engine::default().extract_source(&csharp_path, csharp_source)?; + let csharp_evidence = csharp + .semantic_evidence + .as_ref() + .ok_or("missing C# universal evidence")?; for (target, owner) in [ - ("Shared@", None), - ("One::Item@", Some("One")), - ("One::Outer@", Some("One")), - ("One::Outer::Leaf@", Some("One::Outer@")), - ("Two::Item@", Some("Two")), - ("Two::Shared@", Some("Two")), + ("Shared", None), + ("One", None), + ("One.Item", Some("One")), + ("One.Outer", Some("One")), + ("One.Outer.Leaf", Some("One.Outer")), + ("Two", None), + ("Two.Item", Some("Two")), + ("Two.Shared", Some("Two")), ] { - assert_exact_containment(&csharp, target, owner)?; + let declaration = csharp_evidence + .declarations + .iter() + .find(|declaration| declaration.qualified_name == target) + .ok_or_else(|| format!("missing C# declaration {target}: {csharp_evidence:#?}"))?; + let owner_declaration = match owner { + Some(owner) => csharp_evidence + .declarations + .iter() + .find(|declaration| declaration.qualified_name == owner) + .ok_or_else(|| format!("missing C# owner {owner}"))?, + None => csharp_evidence + .declarations + .iter() + .find(|candidate| candidate.kind == "file") + .ok_or("missing C# file declaration")?, + }; + assert!( + csharp_evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Owns + && candidate.source_declaration_id == owner_declaration.id + && candidate.constraints.exact_target_declaration_id.as_deref() + == Some(declaration.id.as_str()) + }), + "missing exact C# ownership {owner:?} -> {target}: {csharp_evidence:#?}" + ); } - assert_unique_node_ids(&csharp); - assert_containment_sites_belong_to_targets(&csharp); + let declaration_ids = csharp_evidence + .declarations + .iter() + .map(|declaration| declaration.id.as_str()) + .collect::>(); assert_eq!( - csharp - .edges + declaration_ids.len(), + csharp_evidence.declarations.len(), + "C# declarations must retain unique evidence identities" + ); + assert_eq!( + csharp_evidence + .declarations .iter() - .filter(|edge| { - edge.string("relation") == "method" - && csharp.nodes.iter().any(|node| { - node.id == edge.target && node.string("symbol_kind") == "method" - }) - }) + .filter(|declaration| declaration.kind == "method" && declaration.name == "Run") .count(), 2, - "C# semantic methods must retain exact raw method ownership for XAML consumers: edges={:#?}", - csharp.edges + "C# universal evidence must retain both scoped method occurrences: {csharp_evidence:#?}" ); Ok(()) } diff --git a/crates/compass-languages/tests/universal_evidence.rs b/crates/compass-languages/tests/universal_evidence.rs index c37e72b3..15474f97 100644 --- a/crates/compass-languages/tests/universal_evidence.rs +++ b/crates/compass-languages/tests/universal_evidence.rs @@ -471,7 +471,15 @@ fn universal_adapter_profiles_are_unique_sorted_and_truthful() { .iter() .map(|profile| profile.language) .collect::>(), - ["go", "java", "javascript", "python", "rust", "typescript"] + [ + "csharp", + "go", + "java", + "javascript", + "python", + "rust", + "typescript", + ] ); assert!( profiles diff --git a/crates/compass-resolve/src/evidence/projection/edges.rs b/crates/compass-resolve/src/evidence/projection/edges.rs index d6ba384e..f4222e0e 100644 --- a/crates/compass-resolve/src/evidence/projection/edges.rs +++ b/crates/compass-resolve/src/evidence/projection/edges.rs @@ -275,6 +275,7 @@ const fn candidate_relation_name(relation: CandidateRelation) -> &'static str { CandidateRelation::Annotates => "annotation", CandidateRelation::Extends => "extends", CandidateRelation::Implements => "implements", + CandidateRelation::Overrides => "override", CandidateRelation::References => "reference", CandidateRelation::TypeOf => "type-of", CandidateRelation::Returns => "return-type", diff --git a/crates/compass-resolve/src/evidence/projection/nodes.rs b/crates/compass-resolve/src/evidence/projection/nodes.rs index 86851a3a..d5365cdc 100644 --- a/crates/compass-resolve/src/evidence/projection/nodes.rs +++ b/crates/compass-resolve/src/evidence/projection/nodes.rs @@ -180,9 +180,9 @@ pub(super) fn relation_name(relation: CandidateRelation) -> &'static str { CandidateRelation::Returns => "returns", CandidateRelation::Extends => "inherits", CandidateRelation::Implements => "implements", + CandidateRelation::Overrides => "overrides", CandidateRelation::AccessesMember => "accesses", - CandidateRelation::Contains => "contains", - CandidateRelation::Owns => "owns", + CandidateRelation::Contains | CandidateRelation::Owns => "contains", CandidateRelation::Embeds => "embeds", CandidateRelation::Imports => "imports_from", CandidateRelation::Reexports => "re_exports", @@ -341,6 +341,7 @@ pub(super) fn external_kind(candidate: &RelationshipCandidate) -> &'static str { | CandidateRelation::TypeOf | CandidateRelation::Returns => "type_alias", CandidateRelation::Implements => "interface", + CandidateRelation::Overrides => "function", CandidateRelation::AccessesMember => "variable", CandidateRelation::Calls | CandidateRelation::IndirectCalls => "function", CandidateRelation::Constructs => "class", diff --git a/crates/compass-resolve/src/frameworks/aspnet.rs b/crates/compass-resolve/src/frameworks/aspnet.rs new file mode 100644 index 00000000..a29f7fca --- /dev/null +++ b/crates/compass-resolve/src/frameworks/aspnet.rs @@ -0,0 +1,366 @@ +//! Project-wide ASP.NET MVC route expansion from universal C# annotations. + +use std::collections::{BTreeMap, BTreeSet}; + +use compass_languages::{ + Extraction, RawFrameworkAnnotationFact, RawFrameworkFact, RawFrameworkOrigin, RawRouteFact, +}; +use serde_json::{Map, Value}; + +use super::FrameworkResolutionError; + +const PACK_ID: &str = "aspnet-csharp"; + +#[derive(Clone, Debug)] +struct Mapping { + operations: Vec, + paths: Vec, + rule: &'static str, +} + +pub(super) fn expand(extraction: &mut Extraction) -> Result<(), FrameworkResolutionError> { + let annotations = extraction + .framework_facts + .iter() + .filter_map(|fact| match fact { + RawFrameworkFact::Annotation(annotation) if annotation.pack_id == PACK_ID => { + Some(annotation.clone()) + } + _ => None, + }) + .collect::>(); + if annotations.is_empty() { + return Ok(()); + } + let by_owner = annotations.iter().fold( + BTreeMap::>::new(), + |mut grouped, annotation| { + grouped + .entry(annotation.owner_declaration_id.clone()) + .or_default() + .push(annotation); + grouped + }, + ); + let controllers = controller_types(&annotations); + let class_routes = class_route_templates(&annotations); + let mut routes = Vec::new(); + let mut seen = BTreeSet::new(); + for annotation in &annotations { + if annotation.owner_kind != "method" || terminal(annotation) == "NonAction" { + continue; + } + let owner_type = annotation + .owner_qualified_name + .rsplit_once("::") + .map(|(owner, _)| owner) + .unwrap_or_default(); + if !controllers.contains(owner_type) { + continue; + } + let Some(owner_annotations) = by_owner.get(&annotation.owner_declaration_id) else { + continue; + }; + if owner_annotations + .iter() + .any(|annotation| terminal(annotation) == "NonAction") + { + continue; + } + let http_mappings = owner_annotations + .iter() + .filter_map(|annotation| http_mapping(annotation)) + .collect::>(); + if http_mappings.is_empty() { + continue; + } + let action_routes = owner_annotations + .iter() + .filter(|annotation| terminal(annotation) == "Route") + .flat_map(|annotation| argument_strings(annotation, "0", "Template")) + .collect::>(); + let prefixes = class_routes + .get(owner_type) + .cloned() + .filter(|routes| !routes.is_empty()) + .unwrap_or_else(|| vec![String::new()]); + for mapping in http_mappings { + let paths = if action_routes.is_empty() { + mapping.paths.clone() + } else { + action_routes.clone() + }; + for prefix in &prefixes { + for path in &paths { + let expanded_prefix = + expand_tokens(prefix, owner_type, &annotation.owner_qualified_name); + let expanded_path = + expand_tokens(path, owner_type, &annotation.owner_qualified_name); + let normalized = compose_route(&expanded_prefix, &expanded_path); + for operation in &mapping.operations { + let key = ( + annotation.anchor.source_file.clone(), + annotation.anchor.start_byte, + operation.clone(), + normalized.clone(), + annotation.owner_graph_node_id.clone(), + ); + if !seen.insert(key) { + continue; + } + let mut detail = Map::from_iter([ + ( + "frameworkPack".to_owned(), + Value::String(PACK_ID.to_owned()), + ), + ( + "target_qualified_name".to_owned(), + Value::String(annotation.owner_qualified_name.clone()), + ), + ]); + if let Some(signature) = annotation.owner_signature.as_deref() { + detail.insert( + "target_signature_qualified".to_owned(), + Value::String(format!( + "{}{}", + annotation.owner_qualified_name, + signature + .find('(') + .map(|offset| &signature[offset..]) + .unwrap_or_default() + )), + ); + } + routes.push(RawFrameworkFact::Route(RawRouteFact { + framework: "aspnet".to_owned(), + operation: operation.clone(), + raw_path: path.clone(), + normalized_path: normalized.clone(), + declaring_scope: owner_type.to_owned(), + anchor: annotation.anchor.clone(), + handler_reference: format!( + "{}.{}", + owner_type.rsplit('.').next().unwrap_or(owner_type), + annotation + .owner_qualified_name + .rsplit("::") + .next() + .unwrap_or(&annotation.owner_qualified_name) + ), + middleware_references: Vec::new(), + origin: RawFrameworkOrigin::Ast, + rule: Some(if action_routes.is_empty() { + mapping.rule.to_owned() + } else { + "aspnet-action-route-attribute".to_owned() + }), + detail, + })); + } + } + } + } + } + extraction.framework_facts.retain(|fact| { + !matches!(fact, RawFrameworkFact::Annotation(annotation) if annotation.pack_id == PACK_ID) + }); + extraction.framework_facts.extend(routes); + Ok(()) +} + +fn controller_types(annotations: &[RawFrameworkAnnotationFact]) -> BTreeSet { + let mut controllers = annotations + .iter() + .filter(|annotation| matches!(annotation.owner_kind.as_str(), "class" | "record")) + .filter(|annotation| { + matches!( + terminal(annotation), + "ApiController" | "Controller" | "Route" + ) || annotation.owner_qualified_name.ends_with("Controller") + }) + .map(|annotation| annotation.owner_qualified_name.clone()) + .collect::>(); + controllers.extend( + annotations + .iter() + .filter(|annotation| annotation.owner_kind == "method") + .filter_map(|annotation| { + annotation + .owner_qualified_name + .rsplit_once("::") + .map(|(owner, _)| owner) + .filter(|owner| { + owner + .rsplit('.') + .next() + .is_some_and(|name| name.ends_with("Controller")) + }) + .map(str::to_owned) + }), + ); + controllers +} + +fn class_route_templates( + annotations: &[RawFrameworkAnnotationFact], +) -> BTreeMap> { + let mut output = BTreeMap::new(); + for annotation in annotations { + if matches!(annotation.owner_kind.as_str(), "class" | "record") + && terminal(annotation) == "Route" + { + output + .entry(annotation.owner_qualified_name.clone()) + .or_insert_with(Vec::new) + .extend(argument_strings(annotation, "0", "Template")); + } + } + output +} + +fn http_mapping(annotation: &RawFrameworkAnnotationFact) -> Option { + let (operations, rule) = match terminal(annotation) { + "HttpDelete" => (vec!["DELETE".to_owned()], "aspnet-http-attribute"), + "HttpGet" => (vec!["GET".to_owned()], "aspnet-http-attribute"), + "HttpHead" => (vec!["HEAD".to_owned()], "aspnet-http-attribute"), + "HttpOptions" => (vec!["OPTIONS".to_owned()], "aspnet-http-attribute"), + "HttpPatch" => (vec!["PATCH".to_owned()], "aspnet-http-attribute"), + "HttpPost" => (vec!["POST".to_owned()], "aspnet-http-attribute"), + "HttpPut" => (vec!["PUT".to_owned()], "aspnet-http-attribute"), + "AcceptVerbs" => ( + accept_verbs(annotation) + .into_iter() + .flat_map(|value| { + value + .split(',') + .map(|part| part.trim().trim_matches('"').to_ascii_uppercase()) + .collect::>() + }) + .filter(|value| !value.is_empty()) + .collect(), + "aspnet-accept-verbs-attribute", + ), + _ => return None, + }; + let paths = if terminal(annotation) == "AcceptVerbs" { + annotation + .arguments + .get("Route") + .or_else(|| annotation.arguments.get("Template")) + .and_then(Value::as_str) + .map(|value| vec![value.to_owned()]) + .unwrap_or_default() + } else { + argument_strings(annotation, "0", "Template") + }; + Some(Mapping { + operations, + paths: if paths.is_empty() { + vec![String::new()] + } else { + paths + }, + rule, + }) +} + +fn accept_verbs(annotation: &RawFrameworkAnnotationFact) -> Vec { + let mut positional = annotation + .arguments + .iter() + .filter_map(|(key, value)| key.parse::().ok().map(|position| (position, value))) + .collect::>(); + positional.sort_unstable_by_key(|(position, _)| *position); + let mut output = positional + .into_iter() + .flat_map(|(_, value)| match value { + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>(), + Value::String(value) => vec![value.clone()], + _ => Vec::new(), + }) + .collect::>(); + if let Some(value) = annotation.arguments.get("HttpMethods") { + output.extend(match value { + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + Value::String(value) => vec![value.clone()], + _ => Vec::new(), + }); + } + output +} + +fn argument_strings( + annotation: &RawFrameworkAnnotationFact, + positional: &str, + named: &str, +) -> Vec { + annotation + .arguments + .get(named) + .or_else(|| annotation.arguments.get(positional)) + .map(|value| match value { + Value::Array(values) => values + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(), + Value::String(value) => vec![value.clone()], + _ => Vec::new(), + }) + .unwrap_or_default() +} + +fn expand_tokens(template: &str, owner_type: &str, method: &str) -> String { + let controller = owner_type + .rsplit('.') + .next() + .unwrap_or(owner_type) + .trim_end_matches("Controller"); + let action = method.rsplit("::").next().unwrap_or(method); + template + .replace("[controller]", controller) + .replace("[action]", action) +} + +fn compose_route(prefix: &str, action: &str) -> String { + if let Some(absolute) = action.strip_prefix("~/") { + return normalize(absolute); + } + if action.starts_with('/') || prefix.is_empty() { + return normalize(action); + } + normalize(&format!("{prefix}/{action}")) +} + +fn normalize(value: &str) -> String { + let mut output = String::with_capacity(value.len().saturating_add(1)); + output.push('/'); + let mut slash = true; + for character in value.trim().trim_matches('/').chars() { + if character == '/' { + if !slash { + output.push('/'); + } + slash = true; + } else { + output.push(character); + slash = false; + } + } + if output.len() > 1 && output.ends_with('/') { + output.pop(); + } + output +} + +fn terminal(annotation: &RawFrameworkAnnotationFact) -> &str { + annotation.annotation_name.trim_end_matches("Attribute") +} diff --git a/crates/compass-resolve/src/frameworks/mod.rs b/crates/compass-resolve/src/frameworks/mod.rs index 11a56ee1..46fcb89b 100644 --- a/crates/compass-resolve/src/frameworks/mod.rs +++ b/crates/compass-resolve/src/frameworks/mod.rs @@ -1,3 +1,4 @@ +mod aspnet; mod axum; mod domain; mod jvm; @@ -72,10 +73,16 @@ struct UniversalFrameworkPack { /// Project-wide expansion adapters are registered by pack identity rather /// than selected through a language-specific match. Adding a universal pack /// therefore changes one registry entry and leaves the lifecycle unchanged. -const UNIVERSAL_FRAMEWORK_PACKS: &[UniversalFrameworkPack] = &[UniversalFrameworkPack { - id: "spring-java", - expand: spring::expand, -}]; +const UNIVERSAL_FRAMEWORK_PACKS: &[UniversalFrameworkPack] = &[ + UniversalFrameworkPack { + id: "aspnet-csharp", + expand: aspnet::expand, + }, + UniversalFrameworkPack { + id: "spring-java", + expand: spring::expand, + }, +]; pub use domain::{ ResolvedDomainFact, publish_resolved_domains, resolve_and_publish_framework_domains, @@ -199,11 +206,12 @@ pub(crate) fn resolve_framework_facts( fn universal_framework_targets_are_materialized( extraction: &compass_languages::Extraction, ) -> bool { - let Some(batch) = extraction - .semantic_evidence - .as_ref() - .filter(|batch| matches!(batch.adapter.language.as_str(), "javascript" | "typescript")) - else { + let Some(batch) = extraction.semantic_evidence.as_ref().filter(|batch| { + matches!( + batch.adapter.language.as_str(), + "csharp" | "javascript" | "typescript" + ) + }) else { return true; }; let existing = extraction @@ -231,7 +239,7 @@ fn universal_framework_targets_are_materialized( }) } -/// Universal TypeScript/JavaScript extraction publishes declaration evidence +/// Universal C#/TypeScript/JavaScript extraction publishes declaration evidence /// first and lets the project resolver materialize graph nodes. Framework /// route/domain resolution can also be invoked directly on a single-file /// extraction, so provide the target index with source-backed declaration @@ -239,11 +247,12 @@ fn universal_framework_targets_are_materialized( pub(super) fn materialize_universal_framework_targets( extraction: &compass_languages::Extraction, ) -> compass_languages::Extraction { - let Some(batches) = extraction - .semantic_evidence - .as_ref() - .filter(|batch| matches!(batch.adapter.language.as_str(), "javascript" | "typescript")) - else { + let Some(batches) = extraction.semantic_evidence.as_ref().filter(|batch| { + matches!( + batch.adapter.language.as_str(), + "csharp" | "javascript" | "typescript" + ) + }) else { return extraction.clone(); }; let mut enriched = extraction.clone(); diff --git a/crates/compass-resolve/src/lib.rs b/crates/compass-resolve/src/lib.rs index 56d33956..a9a94f23 100644 --- a/crates/compass-resolve/src/lib.rs +++ b/crates/compass-resolve/src/lib.rs @@ -1025,9 +1025,6 @@ fn finish_resolution( "js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts" ) }); - let has_csharp = sources - .keys() - .any(|source| matches!(extension(source).as_str(), "cs" | "razor" | "cshtml")); let has_php = sources.keys().any(|source| extension(source) == "php"); let mut project_resolution = (!project_edges.is_empty()).then(|| Extraction { nodes: merged @@ -1153,10 +1150,6 @@ fn finish_resolution( "resolver JavaScript workspace symbols", &mut profile_started, ); - if has_csharp { - canonicalize_csharp_namespace_nodes(&mut merged); - } - profile_internal("resolver C# namespace normalization", &mut profile_started); if has_php { resolve_php_type_references(&mut merged, sources); } @@ -3829,73 +3822,6 @@ fn resolve_javascript_reexports(extraction: &mut Extraction) { extraction.edges.extend(additions); } -/// Match Python's last-writer graph semantics without making the retained C# -/// namespace depend on filesystem traversal order. Namespace IDs are label -/// based, so declarations from multiple files intentionally collide; the -/// lexicographically earliest source/location is the canonical representative. -fn canonicalize_csharp_namespace_nodes(extraction: &mut Extraction) { - let mut by_label = HashMap::>::new(); - for (index, node) in extraction.nodes.iter().enumerate() { - if string_attribute(node, "type") == "namespace" { - by_label - .entry(node.label().to_owned()) - .or_default() - .push(index); - } - } - - let mut dropped = HashSet::new(); - let mut remap = HashMap::new(); - for indexes in by_label.values().filter(|indexes| indexes.len() > 1) { - let canonical = indexes - .iter() - .copied() - .min_by_key(|index| { - let node = &extraction.nodes[*index]; - ( - string_attribute(node, "source_file"), - string_attribute(node, "source_location"), - node.id.clone(), - ) - }) - .unwrap_or(indexes[0]); - let canonical_id = extraction.nodes[canonical].id.clone(); - for &index in indexes { - if index != canonical { - dropped.insert(index); - remap.insert(extraction.nodes[index].id.clone(), canonical_id.clone()); - } - } - } - if dropped.is_empty() { - return; - } - for edge in &mut extraction.edges { - let mut rewritten = false; - if let Some(target) = remap.get(&edge.source) { - edge.source.clone_from(target); - rewritten = true; - } - if let Some(target) = remap.get(&edge.target) { - edge.target.clone_from(target); - rewritten = true; - } - if rewritten { - stamp_endpoint_rewrite( - edge, - EndpointRewriteRule::CsharpNamespaceCanonicalization, - 1.0, - ); - } - } - let mut index = 0_usize; - extraction.nodes.retain(|_| { - let keep = !dropped.contains(&index); - index += 1; - keep - }); -} - /// Resolve a sourceless type stub inside the language family of the edge that /// references it. A globally common name such as `Processor` is ambiguous, but /// a JVM edge can still have exactly one JVM definition. This is the same @@ -6577,42 +6503,6 @@ mod tests { assert!(extraction.nodes.iter().all(|node| node.id != "base")); } - #[test] - fn csharp_namespace_canonicalization_keeps_lexicographically_earliest_source() { - let mut later = node( - "namespace-id", - "Demo.ViewModels", - "views/ToolkitViewModel.cs", - "namespace", - ); - later - .attributes - .insert("source_location".to_owned(), Value::String("L4".to_owned())); - let mut earlier = node( - "namespace-id", - "Demo.ViewModels", - "views/DesignViewModel.cs", - "namespace", - ); - earlier - .attributes - .insert("source_location".to_owned(), Value::String("L1".to_owned())); - let mut extraction = Extraction { - nodes: vec![later, earlier], - edges: vec![edge("consumer", "namespace-id", "imports", "views/App.cs")], - ..Extraction::default() - }; - - canonicalize_csharp_namespace_nodes(&mut extraction); - - assert_eq!(extraction.nodes.len(), 1); - assert_eq!( - extraction.nodes[0].string("source_file"), - "views/DesignViewModel.cs" - ); - assert_eq!(extraction.edges[0].target, "namespace-id"); - } - #[test] fn family_stub_rewiring_does_not_conflate_same_named_cross_language_types() { let mut extraction = Extraction { diff --git a/crates/compass-resolve/src/members.rs b/crates/compass-resolve/src/members.rs index fb56f0fe..32a45e2b 100644 --- a/crates/compass-resolve/src/members.rs +++ b/crates/compass-resolve/src/members.rs @@ -302,7 +302,6 @@ struct TypeTables { swift: HashMap>, typescript: HashMap>, cpp: HashMap>, - csharp: HashMap>, objc: HashMap>, } @@ -316,7 +315,6 @@ impl TypeTables { collect_table(extraction, "swift_type_table", &mut tables.swift); collect_table(extraction, "ts_type_table", &mut tables.typescript); collect_table(extraction, "cpp_type_table", &mut tables.cpp); - collect_table(extraction, "csharp_type_table", &mut tables.csharp); collect_table(extraction, "objc_type_table", &mut tables.objc); } tables @@ -335,7 +333,6 @@ fn indexed_families(calls: &[RawCall], tables: &TypeTables) -> (HashSet<&'static (!tables.swift.is_empty(), "swift"), (!tables.typescript.is_empty(), "javascript"), (!tables.cpp.is_empty(), "cpp"), - (!tables.csharp.is_empty(), "csharp"), (!tables.objc.is_empty(), "objc"), ] { if present { @@ -413,7 +410,7 @@ fn resolve_typed_members( .cloned() .map(|owner| (owner, true)) } else { - typed_owner(receiver, source, &tables.csharp, indexes, None) + None } } MemberFamily::Objc => { diff --git a/crates/compass-resolve/tests/native_routes.rs b/crates/compass-resolve/tests/native_routes.rs index 632df2ba..d6df5a09 100644 --- a/crates/compass-resolve/tests/native_routes.rs +++ b/crates/compass-resolve/tests/native_routes.rs @@ -174,12 +174,15 @@ fn multiline() {} #[test] fn aspnet_composes_controller_and_action_templates() -> Result<(), Box> { let routes = resolved("csharp/AspNetController.cs")?; - assert!(routes.iter().any(|route| { - route.route.operation == "GET" - && route.route.normalized_path == "/api/Users/{id}" - && route.route.handler_reference == "UsersController.Show" - && route.state == ResolutionState::Exact - })); + assert!( + routes.iter().any(|route| { + route.route.operation == "GET" + && route.route.normalized_path == "/api/Users/{id}" + && route.route.handler_reference == "UsersController.Show" + && route.state == ResolutionState::Exact + }), + "routes={routes:#?}" + ); assert!(routes.iter().any(|route| { route.route.operation == "POST" && route.route.normalized_path == "/api/Users" @@ -253,6 +256,37 @@ var app = builder.Build(); Ok(()) } +#[test] +fn aspnet_universal_pack_handles_aliases_verbs_absolute_routes_and_non_actions() +-> Result<(), Box> { + let raw = extract("csharp/AdvancedController.cs")?; + assert!(raw.framework_facts.iter().any(|fact| { + matches!(fact, RawFrameworkFact::Annotation(annotation) if annotation.owner_qualified_name.ends_with("::List")) + }), "raw facts={:#?} evidence={:#?}", raw.framework_facts, raw.semantic_evidence); + let routes = resolved("csharp/AdvancedController.cs")?; + for (operation, path, handler) in [ + ("GET", "/v2/Plain/List", "PlainController.List"), + ("GET", "/v2/Plain/multi", "PlainController.Multi"), + ("POST", "/v2/Plain/multi", "PlainController.Multi"), + ("GET", "/ready", "PlainController.Ready"), + ] { + assert!( + routes.iter().any(|route| { + route.route.operation == operation + && route.route.normalized_path == path + && route.route.handler_reference == handler + && route.state == ResolutionState::Exact + }), + "missing {operation} {path}: routes={routes:#?}" + ); + } + assert!(routes.iter().all(|route| { + route.route.handler_reference != "PlainController.Hidden" + && route.route.normalized_path != "/v2/Plain/hidden" + })); + Ok(()) +} + #[test] fn vapor_segmented_routes_resolve_explicit_handlers() -> Result<(), Box> { let routes = resolved("swift/VaporRoutes.swift")?; diff --git a/crates/compass-resolve/tests/semantic_producers.rs b/crates/compass-resolve/tests/semantic_producers.rs index 0cf7d190..d593f93f 100644 --- a/crates/compass-resolve/tests/semantic_producers.rs +++ b/crates/compass-resolve/tests/semantic_producers.rs @@ -440,16 +440,12 @@ namespace Two { "ts_ownership.Two.Shared", Some("ts_ownership.Two"), ), - ("CsharpOwnership.cs", "Shared@", None), - ("CsharpOwnership.cs", "One::Item@", Some("One")), - ("CsharpOwnership.cs", "One::Outer@", Some("One")), - ( - "CsharpOwnership.cs", - "One::Outer::Leaf@", - Some("One::Outer@"), - ), - ("CsharpOwnership.cs", "Two::Item@", Some("Two")), - ("CsharpOwnership.cs", "Two::Shared@", Some("Two")), + ("CsharpOwnership.cs", "Shared", None), + ("CsharpOwnership.cs", "One.Item", Some("One")), + ("CsharpOwnership.cs", "One.Outer", Some("One")), + ("CsharpOwnership.cs", "One.Outer.Leaf", Some("One.Outer")), + ("CsharpOwnership.cs", "Two.Item", Some("Two")), + ("CsharpOwnership.cs", "Two.Shared", Some("Two")), ] { assert_public_containment(&graph, file, target, owner)?; } diff --git a/crates/compass-resolve/tests/universal_resolution.rs b/crates/compass-resolve/tests/universal_resolution.rs index ef82b367..847b4d29 100644 --- a/crates/compass-resolve/tests/universal_resolution.rs +++ b/crates/compass-resolve/tests/universal_resolution.rs @@ -3,6 +3,7 @@ //! Universal resolver integration characterization, grouped by ownership. include!("universal_resolution/core.rs"); +include!("universal_resolution/csharp.rs"); include!("universal_resolution/rust.rs"); include!("universal_resolution/python.rs"); include!("universal_resolution/go.rs"); diff --git a/crates/compass-resolve/tests/universal_resolution/csharp.rs b/crates/compass-resolve/tests/universal_resolution/csharp.rs new file mode 100644 index 00000000..4a818a59 --- /dev/null +++ b/crates/compass-resolve/tests/universal_resolution/csharp.rs @@ -0,0 +1,122 @@ +#[test] +fn csharp_cross_file_calls_overloads_and_overrides_use_the_shared_index() +-> Result<(), Box> { + let service_source = br#"namespace Demo; +public class BaseService { public virtual int Run(string value) => 1; } +public class Service : BaseService { + public override int Run(string value) => 2; + public int Find(string value) => 1; + public int Find(string value, int limit) => 2; +} +"#; + let caller_source = br#"namespace Demo; +public class Caller { + public int Execute(Service service) { + service.Find("one"); + service.Find("two", 2); + return service.Run("value"); + } +} +"#; + let service = extract("src/Service.cs", service_source); + let caller = extract("src/Caller.cs", caller_source); + let sources = HashMap::from([ + ( + "src/Service.cs".to_owned(), + String::from_utf8_lossy(service_source).into_owned(), + ), + ( + "src/Caller.cs".to_owned(), + String::from_utf8_lossy(caller_source).into_owned(), + ), + ]); + let resolved = compass_resolve::resolve(&[caller.clone(), service.clone()], &sources); + let reversed = compass_resolve::resolve(&[service, caller], &sources); + assert_eq!(universal_edges(&resolved), universal_edges(&reversed)); + + let finds = resolved + .nodes + .iter() + .filter(|node| node.string("qualified_name") == "Demo.Service::Find") + .collect::>(); + assert_eq!(finds.len(), 2, "nodes={:#?}", resolved.nodes); + assert_eq!( + finds + .iter() + .map(|node| node.string("overload_discriminator")) + .collect::>(), + BTreeSet::from(["overload:0".to_owned(), "overload:1".to_owned()]) + ); + for find in &finds { + assert!(resolved.edges.iter().any(|edge| { + edge.target == find.id + && edge.string("relation") == "calls" + && edge.string("language") == "csharp" + }), "find={find:#?} edges={:#?}", resolved.edges); + } + let base_run = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "Demo.BaseService::Run") + .ok_or("missing base Run")?; + let override_run = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "Demo.Service::Run") + .ok_or("missing override Run")?; + assert!(resolved.edges.iter().any(|edge| { + edge.source == override_run.id + && edge.target == base_run.id + && edge.string("relation") == "overrides" + })); + Ok(()) +} + +#[test] +fn csharp_same_named_cross_namespace_targets_remain_ambiguous() +-> Result<(), Box> { + let left = extract( + "src/Left.cs", + b"namespace Left; public class Worker { public void Run() {} }", + ); + let right = extract( + "src/Right.cs", + b"namespace Right; public class Worker { public void Run() {} }", + ); + let caller = extract( + "src/Caller.cs", + b"namespace App; public class Caller { public void Execute(Worker worker) { worker.Run(); } }", + ); + let sources = HashMap::from([ + ( + "src/Left.cs".to_owned(), + "namespace Left; public class Worker { public void Run() {} }".to_owned(), + ), + ( + "src/Right.cs".to_owned(), + "namespace Right; public class Worker { public void Run() {} }".to_owned(), + ), + ( + "src/Caller.cs".to_owned(), + "namespace App; public class Caller { public void Execute(Worker worker) { worker.Run(); } }".to_owned(), + ), + ]); + let resolved = compass_resolve::resolve(&[left, right, caller], &sources); + let caller = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == "App.Caller::Execute") + .ok_or("missing caller")?; + assert!(resolved.edges.iter().all(|edge| { + !(edge.source == caller.id + && edge.string("relation") == "calls" + && resolved.nodes.iter().any(|node| { + node.id == edge.target + && matches!( + node.string("qualified_name").as_str(), + "Left.Worker::Run" | "Right.Worker::Run" + ) + })) + })); + Ok(()) +} diff --git a/docs/design/language-architecture.md b/docs/design/language-architecture.md index 34629597..54764ffe 100644 --- a/docs/design/language-architecture.md +++ b/docs/design/language-architecture.md @@ -344,7 +344,7 @@ supported languages. ## Language-by-language transitions An established language keeps its direct implementation until its universal -candidate proves the transition is safe. TypeScript and JavaScript have +candidate proves the transition is safe. C#, TypeScript, and JavaScript have completed the route switch and remain candidates while their completion gates run. @@ -374,16 +374,19 @@ Framework detection is downstream of language parsing but upstream of final Code Graph v1 publication. Packs emit anchored route or domain facts; the framework resolver validates targets and materializes typed relationships. -The Java Spring source pack is the first production universal framework pack. -It consumes exact Java annotation, call, import, type, ownership, and hierarchy +The Java Spring and C# ASP.NET source packs are production universal framework +packs. +The Spring pack consumes exact Java annotation, call, import, type, ownership, and hierarchy evidence and derives HTTP, bean, injection, messaging, scheduling, persistence, transaction, and security meaning before framework resolution. Its Java legacy detectors are removed atomically; Kotlin Spring routing remains on its explicit established pack until Kotlin has a universal language adapter. Established source, config, and template adapters execute through the same static runtime, which owns selection, activation, limits, and publication without requiring a -runtime plugin ABI. Other packs retain their established semantics until their -own qualification and hard cut. +runtime plugin ABI. The ASP.NET pack consumes exact C# attribute, alias, import, +ownership, and overload evidence, then composes controller/action templates in +the project resolver; its former regex/line scanner is removed. Other packs +retain their established semantics until their own qualification and hard cut. ## Quality and failure boundaries diff --git a/docs/implementation/universal-evidence.md b/docs/implementation/universal-evidence.md index 1422265e..a0e0cf57 100644 --- a/docs/implementation/universal-evidence.md +++ b/docs/implementation/universal-evidence.md @@ -30,10 +30,10 @@ future work. | Status | Implementation | | --- | --- | | Available now | `compass-languages` owns the source registry, parsers, established adapters, and semantic evidence version 1 | -| Available now | Python, Go, Rust, Java, TypeScript, and JavaScript are entries in the hard-cut `AdapterRegistry`; Go and Java are at adapter version 3, Python is at version 11, Rust is at version 15, and the ECMAScript candidates are at version 5 | -| Available now | `EvidenceBuilder` emits bounded `SemanticEvidenceBatch` values for all registered universal languages; TypeScript and JavaScript share a source-grounded emitter and retain distinct adapter identities | +| Available now | C#, Python, Go, Rust, Java, TypeScript, and JavaScript are entries in the hard-cut `AdapterRegistry`; C# is at adapter version 1, Go and Java are at version 3, Python is at version 11, Rust is at version 15, and the ECMAScript candidates are at version 5 | +| Available now | `EvidenceBuilder` emits bounded `SemanticEvidenceBatch` values for all registered universal languages; C# and the ECMAScript family use dedicated source-grounded emitters, while TypeScript and JavaScript retain distinct adapter identities | | Available now | `UniversalResolutionIndex` resolves and projects hard-cut evidence without a language-name branch | -| Available now | Rust has passed Phase 2 qualification; Java, TypeScript, and JavaScript remain `UniversalCandidate` while their respective completion gates run | +| Available now | Rust has passed Phase 2 qualification; C#, Java, TypeScript, and JavaScript remain `UniversalCandidate` while their respective completion gates run | | Planned | `GrammarProvider`, grammar provenance, and producer-registry validation | | Planned | Independently qualified hard cuts for the remaining registered languages | diff --git a/docs/reference/framework-routes.md b/docs/reference/framework-routes.md index 015e8090..90d22271 100644 --- a/docs/reference/framework-routes.md +++ b/docs/reference/framework-routes.md @@ -85,7 +85,7 @@ HTTP endpoints. imports or qualified macros. Ambiguous or cyclic Axum module factory targets remain local and uncomposed. - **Vapor**: grouped literal and path-component prefixes, closure groups, `app.on(...)`, and HTTP registrations with explicit `use:` handlers; opaque closures remain visible as unresolved handlers -- **ASP.NET Core**: MVC controller and action templates, `[controller]` and `[action]` tokens, HTTP method attributes, and absolute `/` or `~/` action-template overrides; Minimal API `MapGet`, `MapPost`, `MapPut`, `MapPatch`, `MapDelete`, `MapOptions`, and `MapHead` registrations; and nested literal `MapGroup` prefixes +- **ASP.NET Core**: universal C# evidence-backed MVC controller and action templates, `[controller]` and `[action]` tokens, aliased HTTP method attributes, `AcceptVerbs`, `[NonAction]`, and absolute `/` or `~/` action-template overrides; Minimal API `MapGet`, `MapPost`, `MapPut`, `MapPatch`, `MapDelete`, `MapOptions`, and `MapHead` registrations; and nested literal `MapGroup` prefixes ## Special route contracts diff --git a/docs/reference/universal-semantic-evidence.md b/docs/reference/universal-semantic-evidence.md index 9c7d340b..6c14fdf9 100644 --- a/docs/reference/universal-semantic-evidence.md +++ b/docs/reference/universal-semantic-evidence.md @@ -698,13 +698,17 @@ Python or Go has met the production qualification gates. ## Current qualification boundary -Python and Go are hard-cut universal language adapters. Rust, Java, +Python and Go are hard-cut universal language adapters. C#, Rust, Java, TypeScript, and JavaScript remain `UniversalCandidate`; the latter two share a bounded ECMAScript emitter but retain distinct adapter identities. TSX uses the -TypeScript candidate profile. Candidate status means the universal route is -active while complete capability and corpus qualification remain in progress. -`spring-java` is the first production universal framework pack and advertises +TypeScript candidate profile. C# uses a dedicated bounded AST emitter and no +longer publishes or resolves through the replaced C# raw-graph/type-table path. +Candidate status means the universal route is active while complete capability +and corpus qualification remain in progress. `spring-java` and +`aspnet-csharp` are production universal framework packs. Spring advertises typed HTTP, bean, injection, messaging, scheduling, persistence, transaction, -and security capabilities. Kotlin Spring remains on its established detector. +and security capabilities; ASP.NET consumes exact C# imports, attributes, +ownership, callable signatures, and source ranges to derive MVC routes. Kotlin +Spring remains on its established detector. Do not infer support for another language or framework from file extensions, raw graph output, or total node and edge counts. diff --git a/fixtures/code-graph/routes/csharp/AdvancedController.cs b/fixtures/code-graph/routes/csharp/AdvancedController.cs new file mode 100644 index 00000000..787322e9 --- /dev/null +++ b/fixtures/code-graph/routes/csharp/AdvancedController.cs @@ -0,0 +1,19 @@ +using Microsoft.AspNetCore.Mvc; +using Get = Microsoft.AspNetCore.Mvc.HttpGetAttribute; + +[Route("v2/[controller]")] +public class PlainController +{ + [Get("[action]")] + public object List() => new(); + + [AcceptVerbs("GET", "POST", Route = "multi")] + public object Multi() => new(); + + [HttpGet("~/ready")] + public object Ready() => new(); + + [NonAction] + [HttpGet("hidden")] + public object Hidden() => new(); +} diff --git a/tests/qualification/code-graph-v1-semantic.json b/tests/qualification/code-graph-v1-semantic.json index 688f08d0..dc900b8c 100644 --- a/tests/qualification/code-graph-v1-semantic.json +++ b/tests/qualification/code-graph-v1-semantic.json @@ -549,7 +549,7 @@ "path": "/api/Users/{id}", "routeSource": "fixtures/code-graph/routes/csharp/AspNetController.cs", "handler": { - "qualifiedName": "UsersController::Show(string)@129" + "qualifiedName": "UsersController::Show" }, "handlerSource": "fixtures/code-graph/routes/csharp/AspNetController.cs", "relationship": "routes_to", @@ -916,7 +916,7 @@ "kind": "namespace", "source": "fixtures/code-graph/qualification/Rich.cs", "qualifiedName": "Qualification", - "producer": "compass.languages.csharp", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -926,8 +926,8 @@ "id": "node-class", "kind": "class", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "BaseService@84", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.BaseService", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -948,8 +948,8 @@ "id": "node-interface", "kind": "interface", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "IService@26", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.IService", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -1025,8 +1025,8 @@ "id": "node-method", "kind": "method", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "BaseService::Run(int)@115", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.BaseService::Run", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -1036,8 +1036,8 @@ "id": "node-constructor", "kind": "constructor", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "Service::Service(int)@327", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.Service::Service", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -1047,8 +1047,8 @@ "id": "node-property", "kind": "property", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "Service@176::Count@291", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.Service::Count", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -1058,8 +1058,8 @@ "id": "node-field", "kind": "field", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "Service@176::count@280", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.Service::count", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -1080,8 +1080,8 @@ "id": "node-constant", "kind": "constant", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "Service@176::Limit@244", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.Service::Limit", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -1091,8 +1091,8 @@ "id": "node-parameter", "kind": "parameter", "source": "fixtures/code-graph/qualification/Rich.cs", - "qualifiedName": "BaseService::Run(int)@115::value@141", - "producer": "compass.languages.csharp", + "qualifiedName": "Qualification.BaseService::Run::value", + "producer": "compass.languages.csharp.universal", "origins": [ "ast" ], @@ -1380,7 +1380,7 @@ "kind": "contains", "source": "fixtures/code-graph/qualification/Rich.cs", "qualifiedName": "*", - "producer": "compass.languages.csharp", + "producer": "compass.resolve.csharp.universal", "origins": [ "ast" ], @@ -1424,7 +1424,7 @@ "kind": "extends", "source": "fixtures/code-graph/qualification/Rich.cs", "qualifiedName": "*", - "producer": "compass.languages.csharp", + "producer": "compass.resolve.csharp.universal", "origins": [ "ast" ], @@ -1435,7 +1435,7 @@ "kind": "implements", "source": "fixtures/code-graph/qualification/Rich.cs", "qualifiedName": "*", - "producer": "compass.languages.csharp", + "producer": "compass.resolve.csharp.universal", "origins": [ "ast" ], @@ -1444,9 +1444,9 @@ { "id": "edge-references", "kind": "references", - "source": "fixtures/code-graph/qualification/Rich.cs", + "source": "fixtures/code-graph/routes/csharp/AspNetController.cs", "qualifiedName": "*", - "producer": "compass.languages.csharp", + "producer": "compass.resolve.csharp.universal", "origins": [ "ast" ], @@ -1457,7 +1457,7 @@ "kind": "type_of", "source": "fixtures/code-graph/routes/csharp/AspNetController.cs", "qualifiedName": "*", - "producer": "compass.languages.csharp", + "producer": "compass.resolve.csharp.universal", "origins": [ "ast" ], @@ -1468,7 +1468,7 @@ "kind": "returns", "source": "fixtures/code-graph/routes/csharp/AspNetController.cs", "qualifiedName": "*", - "producer": "compass.languages.csharp", + "producer": "compass.resolve.csharp.universal", "origins": [ "ast" ], @@ -1490,7 +1490,7 @@ "kind": "overrides", "source": "fixtures/code-graph/qualification/Rich.cs", "qualifiedName": "*", - "producer": "compass.languages.csharp", + "producer": "compass.resolve.csharp.universal", "origins": [ "ast" ],