From 1b7f0dc2241b1228a9c79ecf15a9c59e2e1a748a Mon Sep 17 00:00:00 2001 From: JacobZyy Date: Tue, 29 Sep 2026 23:13:40 +0800 Subject: [PATCH] fix(nlab-api): broaden enum provenance analysis --- crates/nlab-api/src/java.rs | 21 +- crates/nlab-api/src/lib.rs | 6 +- crates/nlab-api/src/openapi.rs | 35 +- crates/nlab-api/src/semantic.rs | 413 ++++--- crates/nlab-api/src/semantic/candidates.rs | 4 +- crates/nlab-api/src/semantic/constants.rs | 134 +++ .../nlab-api/src/semantic/coverage_tests.rs | 1001 ++++++++++++++++ .../nlab-api/src/semantic/cross_repository.rs | 29 +- .../src/semantic/cross_repository/tests.rs | 19 +- crates/nlab-api/src/semantic/database.rs | 54 +- crates/nlab-api/src/semantic/lookup.rs | 184 ++- crates/nlab-api/src/semantic/primary.rs | 4 +- crates/nlab-api/src/semantic/request.rs | 19 +- crates/nlab-api/src/semantic/values.rs | 580 +++++++++ crates/nlab-api/src/semantic/writes.rs | 1044 +++++++++++++++++ crates/nlab-api/src/typescript.rs | 40 +- .../fixtures/enum-provenance/ConstKind.java | 11 + .../enum-provenance/DefaultPayload.java | 6 + .../tests/fixtures/enum-provenance/Kind.java | 12 + .../fixtures/enum-provenance/Payload.java | 19 + .../enum-provenance/ProbeController.java | 123 ++ .../tests/fixtures/enum-provenance/README.md | 28 + .../fixtures/enum-provenance/Source.java | 2 + 23 files changed, 3503 insertions(+), 285 deletions(-) create mode 100644 crates/nlab-api/src/semantic/constants.rs create mode 100644 crates/nlab-api/src/semantic/coverage_tests.rs create mode 100644 crates/nlab-api/src/semantic/values.rs create mode 100644 crates/nlab-api/src/semantic/writes.rs create mode 100644 crates/nlab-api/tests/fixtures/enum-provenance/ConstKind.java create mode 100644 crates/nlab-api/tests/fixtures/enum-provenance/DefaultPayload.java create mode 100644 crates/nlab-api/tests/fixtures/enum-provenance/Kind.java create mode 100644 crates/nlab-api/tests/fixtures/enum-provenance/Payload.java create mode 100644 crates/nlab-api/tests/fixtures/enum-provenance/ProbeController.java create mode 100644 crates/nlab-api/tests/fixtures/enum-provenance/README.md create mode 100644 crates/nlab-api/tests/fixtures/enum-provenance/Source.java diff --git a/crates/nlab-api/src/java.rs b/crates/nlab-api/src/java.rs index b79e1a7..8ae6839 100644 --- a/crates/nlab-api/src/java.rs +++ b/crates/nlab-api/src/java.rs @@ -195,16 +195,18 @@ impl<'a> JavaProject<'a> { return None; } let simple = type_ref.simple_name(); - if let Some(import) = self.imports.get(file_path).and_then(|imports| { - imports - .iter() - .find(|value| value.rsplit('.').next() == Some(simple)) - }) { + if !name.contains('.') + && let Some(import) = self.imports.get(file_path).and_then(|imports| { + imports + .iter() + .find(|value| value.rsplit('.').next() == Some(simple)) + }) + { return self.node_for_fqn(import); } let mut lexical = owner_fqn.replace("::", "."); loop { - let candidate = format!("{lexical}.{simple}"); + let candidate = format!("{lexical}.{name}"); if let Some(id) = self.type_by_fqn.get(&candidate) { return self.graph.nodes.get(id); } @@ -214,7 +216,7 @@ impl<'a> JavaProject<'a> { lexical = parent.to_owned(); } if let Some(package) = self.packages.get(file_path) { - let candidate = format!("{package}.{simple}"); + let candidate = format!("{package}.{name}"); if let Some(id) = self.type_by_fqn.get(&candidate) { return self.graph.nodes.get(id); } @@ -225,7 +227,7 @@ impl<'a> JavaProject<'a> { .into_iter() .flatten() .filter_map(|import| import.strip_suffix(".*")) - .map(|package| format!("{package}.{simple}")) + .map(|package| format!("{package}.{name}")) .collect::>(); let candidates = self .graph @@ -239,6 +241,9 @@ impl<'a> JavaProject<'a> { if !candidates.is_empty() { return (candidates.len() == 1).then(|| candidates[0]); } + if name.contains('.') { + return None; + } let candidates = self .graph .candidates(simple) diff --git a/crates/nlab-api/src/lib.rs b/crates/nlab-api/src/lib.rs index 5515cef..fa83111 100644 --- a/crates/nlab-api/src/lib.rs +++ b/crates/nlab-api/src/lib.rs @@ -405,11 +405,13 @@ fn semantic_diagnostics(ir: &ContractIr) -> Vec { matches!( patch.status, ProvenanceStatus::Known | ProvenanceStatus::External - ) + ) || (patch.status == ProvenanceStatus::Unresolved + && patch.evidence.iter().any(|item| item.starts_with("write:") || item.starts_with("copy-framework:"))) }) .map(|patch| { json!({ - "level": "info", + "level": if !patch.enum_associated && patch.enum_fqn.is_some() + && patch.enum_candidate.as_ref().is_none_or(|candidate| candidate.status != model::EnumCandidateStatus::Ignored) { "warning" } else { "info" }, "stage": "generate", "code": if patch.enum_associated { "ENUM_ASSOCIATED".to_owned() } else { format!("ENUM_{:?}", patch.status).to_ascii_uppercase() }, "enumAssociated": patch.enum_associated, diff --git a/crates/nlab-api/src/openapi.rs b/crates/nlab-api/src/openapi.rs index fa138c1..7773411 100644 --- a/crates/nlab-api/src/openapi.rs +++ b/crates/nlab-api/src/openapi.rs @@ -591,7 +591,29 @@ fn type_schema( } fn apply_enum(schema: &mut Value, values: &[CodedValue]) { + if let Some(items) = schema.get_mut("items") { + apply_enum(items, values); + return; + } + if let Some(items) = schema + .get_mut("additionalProperties") + .filter(|value| value.is_object()) + { + apply_enum(items, values); + return; + } let object = schema.as_object_mut().expect("enum schema object"); + if !object.contains_key("type") { + let kind = if values + .iter() + .all(|value| matches!(value.value, WireValue::String(_))) + { + "string" + } else { + "number" + }; + object.insert("type".to_owned(), json!(kind)); + } object.insert( "enum".to_owned(), Value::Array(values.iter().map(|item| wire_json(&item.value)).collect()), @@ -693,7 +715,7 @@ struct SchemaUse { } pub(crate) fn enum_identity(patch: &SemanticPatch) -> String { - format!( + let base = format!( "{}{}#{}", if patch.enum_fqn.is_none() { "comment:" @@ -708,7 +730,16 @@ pub(crate) fn enum_identity(patch: &SemanticPatch) -> String { .accessor .as_deref() .unwrap_or(&patch.target.field_name) - ) + ); + if patch.enum_fqn.is_some() + && let Some(values) = patch.associated_values() + && values.iter().any(|value| value.key.is_none()) + { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(serde_json::to_vec(values).expect("enum values serialize")); + return format!("{base}#{digest:.12x}"); + } + base } pub(crate) fn schema_plan(ir: &ContractIr) -> SchemaPlan { diff --git a/crates/nlab-api/src/semantic.rs b/crates/nlab-api/src/semantic.rs index 2d54322..34862f4 100644 --- a/crates/nlab-api/src/semantic.rs +++ b/crates/nlab-api/src/semantic.rs @@ -1,4 +1,5 @@ mod candidates; +mod constants; mod copy_origin; mod cross_repository; mod database; @@ -6,6 +7,11 @@ mod discovery; mod lookup; mod primary; mod request; +mod values; +mod writes; + +#[cfg(test)] +mod coverage_tests; pub(crate) use discovery::RemoteCall; @@ -64,8 +70,9 @@ struct InvocationSite { column: usize, } +#[derive(Clone)] struct ParsedFile { - source: String, + source: std::sync::Arc, tree: Tree, } @@ -81,6 +88,7 @@ pub struct SemanticAnalyzer<'a> { field_domain_cache: HashMap<(String, String, String), Domain>, lookup_forwarder_cache: HashMap<(String, usize), Option>, mapper_cache: HashMap>, + value_bindings: Vec>, } impl<'a> SemanticAnalyzer<'a> { @@ -97,6 +105,7 @@ impl<'a> SemanticAnalyzer<'a> { field_domain_cache: HashMap::new(), lookup_forwarder_cache: HashMap::new(), mapper_cache: HashMap::new(), + value_bindings: Vec::new(), } } @@ -493,7 +502,16 @@ impl<'a> SemanticAnalyzer<'a> { continue; }; for field in &schema.fields { - if !is_scalar(&field.java_type) { + if !is_scalar(value_type(&field.java_type)) + && self + .project + .resolve_type( + &class.file_path, + &class.qualified_name, + value_type(&field.java_type), + ) + .is_none_or(|node| node.kind != "enum") + { continue; } let field_path = if prefix.is_empty() { @@ -517,13 +535,7 @@ impl<'a> SemanticAnalyzer<'a> { field_path: String, reachable: &Reachability, ) -> Result { - let graph = self.project.graph(); let field_name = &field.name; - let setter_name = format!("set{}", uppercase_first(field_name)); - let setter = graph - .contained(&class.id, "method") - .into_iter() - .find(|method| method.name == setter_name); let target = FieldTarget { source: FieldSource::Response, operation_key: operation.key.clone(), @@ -531,76 +543,22 @@ impl<'a> SemanticAnalyzer<'a> { field_path, field_name: field_name.to_owned(), }; - let Some(setter) = setter else { - return Ok(unresolved_patch(target, "setter not indexed")); - }; - - let mut domains = Vec::new(); - let write_edges = graph - .incoming_calls(&setter.id) - .filter(|edge| reachable.nodes.contains(&edge.source)) + if let Some(enum_node) = self + .project + .resolve_type( + &class.file_path, + &class.qualified_name, + value_type(&field.java_type), + ) + .filter(|node| node.kind == "enum") .cloned() - .collect::>(); - let mut known_sites = BTreeMap::new(); - for edge in &write_edges { - *known_sites - .entry((edge.source.clone(), edge.line)) - .or_insert(0usize) += 1; - } - for edge in write_edges { - let writer = graph - .nodes - .get(&edge.source) - .context("CodeGraph writer disappeared")?; - let Some((expression, source, offset)) = self.setter_argument(&edge, &setter.name)? - else { - let mut domain = Domain::default(); - domain.unknown.insert(format!( - "unindexed setter argument at {}:{}:{}", - writer.file_path, edge.line, edge.column - )); - domains.push(domain); - continue; - }; - let mut domain = self.analyze_expression( - operation, - writer, - expression, - offset, - reachable, - &mut BTreeSet::new(), - )?; - push_unique( - &mut domain.evidence, - format!( - "write:{}:{}:{}:{}", - writer.file_path, edge.line, edge.column, source - ), - ); - push_unique( - &mut domain.evidence, - format!("chain:{}", render_path(graph, reachable, &writer.id)), - ); - domains.push(domain); - } - for gap in self.unindexed_setter_calls(reachable, setter, &known_sites)? { - let mut domain = Domain::default(); - domain.unknown.insert(gap); - domains.push(domain); - } - for unresolved in reachable - .nodes - .iter() - .flat_map(|id| graph.unresolved(id)) - .filter(|item| item.name == setter.name) { - let mut domain = Domain::default(); - domain.unknown.insert(format!( - "unresolved setter call:{}:{}:{}", - unresolved.file_path, unresolved.line, unresolved.column + return Ok(classify_patch( + target, + vec![self.serialized_enum_domain(&enum_node)?], )); - domains.push(domain); } + let domains = self.field_write_domains(operation, class, field_name, reachable)?; if domains.is_empty() { return Ok(unresolved_patch( target, @@ -612,40 +570,6 @@ impl<'a> SemanticAnalyzer<'a> { Ok(patch) } - fn unindexed_setter_calls( - &mut self, - reachable: &Reachability, - setter: &GraphNode, - known_sites: &BTreeMap<(String, usize), usize>, - ) -> Result> { - let mut gaps = BTreeSet::new(); - let mut seen = BTreeMap::<(String, usize), usize>::new(); - for method_id in &reachable.nodes { - let Some(method) = self.project.graph().nodes.get(method_id) else { - continue; - }; - for invocation in self.method_invocations(method)? { - if invocation.name != setter.name { - continue; - } - let targets = self.resolve_invocation(method, &invocation)?; - if !targets.is_empty() && !targets.contains(&setter.id) { - continue; - } - let site = (method.id.clone(), invocation.line); - let count = seen.entry(site.clone()).or_default(); - *count += 1; - if *count > known_sites.get(&site).copied().unwrap_or(0) { - gaps.insert(format!( - "unindexed setter call:{}:{}:{}", - method.file_path, invocation.line, invocation.column - )); - } - } - } - Ok(gaps.into_iter().collect()) - } - fn setter_argument( &mut self, edge: &GraphEdge, @@ -689,6 +613,32 @@ impl<'a> SemanticAnalyzer<'a> { reachable: &Reachability, visiting: &mut BTreeSet<(String, usize)>, ) -> Result { + if let Expression::Identifier(name) = &expression + && let Some(domain) = self + .value_bindings + .iter() + .rev() + .find_map(|bindings| bindings.get(&(writer.id.clone(), name.clone()))) + .cloned() + { + let parsed = self.parsed(&writer.file_path)?.clone(); + if lookup::method_declaration(&parsed, writer).is_some_and(|declaration| { + descendants(declaration).into_iter().any(|node| { + node.start_byte() < offset + && ((node.kind() == "assignment_expression" + && node + .child_by_field_name("left") + .is_some_and(|left| text_of(&parsed.source, left) == name)) + || (node.kind() == "update_expression" + && named_children(node) + .iter() + .any(|child| text_of(&parsed.source, *child) == name))) + }) + }) { + return Ok(values::unknown(format!("rewritten parameter:{name}"))); + } + return Ok(domain); + } if let Some(domain) = self.request_expression_domain( operation, writer, @@ -698,11 +648,11 @@ impl<'a> SemanticAnalyzer<'a> { )? { return Ok(domain); } + if let Some(domain) = self.enum_projection(writer, &expression, offset)? { + return Ok(domain); + } match expression { Expression::Getter { receiver, accessor } => { - if let Some(enum_node) = self.enum_for_receiver(writer, &receiver, offset)? { - return self.enum_domain(&enum_node, &accessor); - } if let Some(domain) = self.lookup_field_domain(writer, &receiver, &accessor, offset)? { @@ -713,6 +663,20 @@ impl<'a> SemanticAnalyzer<'a> { )? { return Ok(domain); } + if let Some(domain) = self.computed_domain( + operation, + writer, + &if receiver.is_empty() { + format!("{accessor}()") + } else { + format!("{receiver}.{accessor}()") + }, + offset, + reachable, + visiting, + )? { + return Ok(domain); + } let mut domain = Domain::default(); domain .unknown @@ -720,6 +684,11 @@ impl<'a> SemanticAnalyzer<'a> { Ok(domain) } Expression::Call { name, source } => { + if let Some(domain) = + self.computed_domain(operation, writer, &source, offset, reachable, visiting)? + { + return Ok(domain); + } let external = self .project .graph() @@ -766,6 +735,22 @@ impl<'a> SemanticAnalyzer<'a> { .push(format!("constant:{}:{value}", writer.file_path)); return Ok(domain); } + if let Some(class) = self.expression_class(writer, &value, offset)? + && class.kind == "enum" + { + return self.serialized_enum_domain(&class); + } + if let Some(kind) = self + .receiver_type(writer, &value, offset)? + .and_then(|kind| parse_java_type(&kind)) + && let Some(class) = self + .project + .resolve_type(&writer.file_path, &writer.qualified_name, value_type(&kind)) + .filter(|node| node.kind == "enum") + .cloned() + { + return self.serialized_enum_domain(&class); + } let mut domain = Domain::default(); domain.unknown.insert(value); Ok(domain) @@ -879,23 +864,9 @@ impl<'a> SemanticAnalyzer<'a> { receiver: &str, offset: usize, ) -> Result> { - let owner = writer - .qualified_name - .rsplit_once("::") - .map(|(owner, _)| owner) - .unwrap_or(&writer.qualified_name); - let receiver_root = receiver.split('.').next().unwrap_or(receiver); - let type_name = self - .receiver_type(writer, receiver_root, offset)? - .unwrap_or_else(|| receiver_root.to_owned()); - let Some(type_ref) = parse_java_type(&type_name) else { - return Ok(None); - }; Ok(self - .project - .resolve_type(&writer.file_path, owner, &type_ref) - .filter(|node| node.kind == "enum") - .cloned()) + .expression_class(writer, receiver, offset)? + .filter(|node| node.kind == "enum")) } fn enum_domain(&mut self, enum_node: &GraphNode, accessor: &str) -> Result { @@ -909,64 +880,8 @@ impl<'a> SemanticAnalyzer<'a> { } fn constant_literal(&mut self, method: &GraphNode, name: &str) -> Result> { - let (qualifier, name) = name - .rsplit_once('.') - .map_or((None, name), |(owner, name)| (Some(owner), name)); - let owners = match qualifier { - None | Some("this") => self.lexical_owners(method), - Some(owner) => parse_java_type(owner) - .and_then(|kind| { - self.project - .resolve_type(&method.file_path, &method.qualified_name, &kind) - }) - .into_iter() - .collect(), - }; - let field = owners.into_iter().find_map(|owner| { - self.project - .graph() - .contained(&owner.id, "field") - .into_iter() - .chain(self.project.graph().contained(&owner.id, "constant")) - .find(|field| field.name == name) - .cloned() - }); - let Some(field) = field else { - return Ok(None); - }; - let parsed = self.parsed(&field.file_path)?; - let declaration = descendants(parsed.tree.root_node()) - .into_iter() - .find(|node| { - node.kind() == "field_declaration" - && node.start_position().row + 1 == field.start_line - }); - let Some(declaration) = declaration else { - return Ok(None); - }; - let modifiers = named_children(declaration) - .into_iter() - .find(|node| node.kind() == "modifiers") - .map(|node| { - text_of(&parsed.source, node) - .split_whitespace() - .collect::>() - }) - .unwrap_or_default(); - if !modifiers.contains("static") || !modifiers.contains("final") { - return Ok(None); - } - let value = named_children(declaration) - .into_iter() - .filter(|node| node.kind() == "variable_declarator") - .find(|node| { - node.child_by_field_name("name") - .is_some_and(|node| text_of(&parsed.source, node) == name) - }) - .and_then(|node| node.child_by_field_name("value")) - .and_then(|node| wire_value(&parsed.source, node)); - // Literal initializers only: do not evaluate methods or configuration lookups. - Ok(value.map(|value| serde_json::to_string(&value).expect("wire value is serializable"))) + Ok(constants::value(self.project, method, name)? + .map(|value| serde_json::to_string(&value).expect("wire value is serializable"))) } fn parsed(&mut self, file_path: &str) -> Result<&ParsedFile> { @@ -977,8 +892,13 @@ impl<'a> SemanticAnalyzer<'a> { let tree = parser .parse(&source, None) .with_context(|| format!("parse Java source {file_path}"))?; - self.parsed_files - .insert(file_path.to_owned(), ParsedFile { source, tree }); + self.parsed_files.insert( + file_path.to_owned(), + ParsedFile { + source: source.into(), + tree, + }, + ); } Ok(&self.parsed_files[file_path]) } @@ -1150,7 +1070,11 @@ fn expression_from_node(source: &str, node: Node<'_>) -> Expression { .child_by_field_name("name") .map(|name| text_of(source, name).to_owned()) .unwrap_or_default(); - if getter_signal(&name).is_some() { + if getter_signal(&name).is_some() + && node + .child_by_field_name("arguments") + .is_some_and(|args| named_children(args).is_empty()) + { let receiver = node .child_by_field_name("object") .map(|object| text_of(source, object).trim().to_owned()) @@ -1164,6 +1088,9 @@ fn expression_from_node(source: &str, node: Node<'_>) -> Expression { } } "identifier" | "field_access" => Expression::Identifier(text), + "unary_expression" if serde_json::from_str::(&text).is_ok() => { + Expression::Literal(text) + } kind if kind.ends_with("_literal") || matches!(kind, "true" | "false" | "null_literal") => { Expression::Literal(text) } @@ -1179,6 +1106,16 @@ fn expression_from_node(source: &str, node: Node<'_>) -> Expression { Expression::Branch(branches) } } + "array_creation_expression" => node + .child_by_field_name("value") + .map(|value| expression_from_node(source, value)) + .unwrap_or(Expression::Unknown(text)), + "array_initializer" => Expression::Branch( + named_children(node) + .into_iter() + .map(|value| expression_from_node(source, value)) + .collect(), + ), "parenthesized_expression" | "cast_expression" => named_children(node) .last() .copied() @@ -1213,6 +1150,30 @@ fn extract_enum_domain( .child_by_field_name("body") .context("enum body not found")?; let fields = primary::instance_fields(source, declaration); + if accessor == "name" { + let values = named_children(body) + .into_iter() + .filter(|node| node.kind() == "enum_constant") + .filter_map(|node| node.child_by_field_name("name")) + .map(|node| { + let name = text_of(source, node).to_owned(); + CodedValue { + value: WireValue::String(name.clone()), + key: Some(name.clone()), + label: name, + } + }) + .collect::>(); + return Ok(Domain { + enum_fqn: Some(enum_node.qualified_name.replace("::", ".")), + enum_source: Some(enum_node.file_path.clone()), + accessor: Some("name".to_owned()), + complete: !values.is_empty(), + primary_enum_value: true, + values, + ..Domain::default() + }); + } let Some(signal) = primary::accessor_field(source, declaration, accessor, &fields) else { return Ok(incomplete_enum_domain( enum_node, @@ -1256,7 +1217,9 @@ fn extract_enum_domain( .unwrap_or_default(); let value = primary::argument_index(source, declaration, &signal, &fields, arguments.len()) .and_then(|index| arguments.get(index)) - .and_then(|node| wire_value(source, *node)); + .map(|node| constants::value(project, enum_node, text_of(source, *node))) + .transpose()? + .flatten(); let label = label_index .and_then(|index| { primary::argument_index( @@ -1416,6 +1379,29 @@ fn classify_patch(target: FieldTarget, domains: Vec) -> SemanticPatch { for domain in &domains { merge_domain(&mut merged, domain.clone()); } + if merged.complete && merged.primary_enum_value { + for literal in &merged.literals { + if literal == "null" { + continue; + } + match serde_json::from_str::(literal) { + Ok(value) => { + if !merged.values.iter().any(|member| member.value == value) { + merged.values.push(CodedValue { + label: render_wire_value(&value), + key: None, + value, + }); + } + } + Err(_) => { + merged + .unknown + .insert(format!("non-scalar enum alternative:{literal}")); + } + } + } + } let identities = domains .iter() .filter_map(|domain| { @@ -1427,12 +1413,10 @@ fn classify_patch(target: FieldTarget, domains: Vec) -> SemanticPatch { }) .collect::>(); let all_closed = domains.iter().all(|domain| { - domain.enum_fqn.is_some() - && domain.complete + ((domain.enum_fqn.is_some() && domain.complete) || !domain.literals.is_empty()) && domain.external.is_empty() && domain.unknown.is_empty() && domain.closure_gaps.is_empty() - && domain.literals.is_empty() && !domain.transformed }); let status = if all_closed && identities.len() == 1 { @@ -1683,19 +1667,6 @@ fn split_top_level(value: &str) -> Vec<&str> { result } -fn wire_value(source: &str, node: Node<'_>) -> Option { - string_literal(source, node) - .map(WireValue::String) - .or_else(|| { - text_of(source, node) - .trim() - .replace('_', "") - .parse::() - .ok() - .map(WireValue::Number) - }) -} - fn string_literal(source: &str, node: Node<'_>) -> Option { let value = text_of(source, node).trim(); if !(value.starts_with('"') && value.ends_with('"')) { @@ -1756,6 +1727,25 @@ fn is_scalar(type_ref: &TypeRef) -> bool { ) } +fn value_type(mut kind: &TypeRef) -> &TypeRef { + loop { + let argument = if is_collection(kind.simple_name()) { + kind.arguments.first() + } else if matches!( + kind.simple_name(), + "Map" | "HashMap" | "LinkedHashMap" | "TreeMap" + ) { + kind.arguments.last() + } else { + None + }; + match argument { + Some(value) => kind = value, + None => return kind, + } + } +} + fn is_collection(name: &str) -> bool { matches!( name, @@ -1870,7 +1860,7 @@ mod tests { } #[test] - fn literal_constants_preserve_enum_membership_checks() { + fn literal_constants_extend_enum_values_but_dynamic_constants_stay_unknown() { let repo = tempfile::tempdir().unwrap(); let source = "package p;\nclass Writer {\n static final String DEFAULT_COLOR = \"gray\";\n static String mutable = \"gray\";\n static final String dynamic = loadColor();\n static final String outside = \"other\";\n void render() {}\n}\n"; write(repo.path(), "Writer.java", source); @@ -1929,7 +1919,7 @@ mod tests { for name in ["mutable", "dynamic", "missing"] { assert_eq!(analyzer.constant_literal(method, name).unwrap(), None); } - for (name, associated) in [("DEFAULT_COLOR", true), ("outside", false)] { + for (name, count) in [("DEFAULT_COLOR", 1), ("outside", 2)] { let domain = Domain { enum_fqn: Some("p.Color".to_owned()), accessor: Some("getColor".to_owned()), @@ -1953,10 +1943,9 @@ mod tests { field_path: "color".to_owned(), field_name: "color".to_owned(), }; - assert_eq!( - classify_patch(target, vec![domain]).enum_associated, - associated - ); + let patch = classify_patch(target, vec![domain]); + assert!(patch.enum_associated); + assert_eq!(patch.associated_values().unwrap().len(), count); } } @@ -2435,7 +2424,7 @@ mod tests { ( "Payload result = new Payload(); result.setCode(10); result.setCode(20); return result;", "状态:10-待上传 19-备用 90-取消", - EnumCandidateStatus::Unverified, + EnumCandidateStatus::Conflict, false, ), ] { diff --git a/crates/nlab-api/src/semantic/candidates.rs b/crates/nlab-api/src/semantic/candidates.rs index d7bfaa9..08c2401 100644 --- a/crates/nlab-api/src/semantic/candidates.rs +++ b/crates/nlab-api/src/semantic/candidates.rs @@ -111,7 +111,9 @@ impl SemanticAnalyzer<'_> { Some(node.qualified_name.replace("::", ".")).as_ref() != patch.enum_fqn.as_ref() }) || field.linked_enum.as_ref().is_some_and(|linked| { Some(&linked.enum_fqn) != patch.enum_fqn.as_ref() - || Some(&linked.accessor) != patch.accessor.as_ref() + // An inferred comment projection must not override a proven name() wire value. + || (patch.accessor.as_deref() != Some("name") + && Some(&linked.accessor) != patch.accessor.as_ref()) }) { return decision( EnumCandidateStatus::Conflict, diff --git a/crates/nlab-api/src/semantic/constants.rs b/crates/nlab-api/src/semantic/constants.rs new file mode 100644 index 0000000..13fc207 --- /dev/null +++ b/crates/nlab-api/src/semantic/constants.rs @@ -0,0 +1,134 @@ +use super::*; + +/// Evaluate literals and immutable source-backed aliases, never execute Java code. +pub(super) fn value( + project: &JavaProject<'_>, + context: &GraphNode, + expression: &str, +) -> Result> { + resolve(project, context, expression.trim(), &mut BTreeSet::new()) +} + +fn resolve( + project: &JavaProject<'_>, + context: &GraphNode, + expression: &str, + visiting: &mut BTreeSet<(String, String)>, +) -> Result> { + if let Ok(value) = serde_json::from_str::(expression) { + return Ok(Some(value)); + } + let integer = expression.trim_end_matches(['l', 'L']).replace('_', ""); + if let Ok(value) = integer.parse::() { + return Ok(Some(WireValue::Number(value))); + } + if visiting.len() >= 24 + || !expression + .chars() + .all(|ch| ch.is_alphanumeric() || matches!(ch, '.' | '_' | '$')) + || !visiting.insert((context.id.clone(), expression.to_owned())) + { + return Ok(None); + } + let (qualifier, name) = expression + .rsplit_once('.') + .map_or((None, expression), |(owner, name)| (Some(owner), name)); + let mut owners = Vec::new(); + if let Some(qualifier) = qualifier.filter(|qualifier| *qualifier != "this") { + if let Some(kind) = parse_java_type(qualifier) + && let Some(owner) = + project.resolve_type(&context.file_path, &context.qualified_name, &kind) + { + owners.push(owner); + } else if name == "EMPTY" + && project + .imported_type(&context.file_path, qualifier) + .is_some_and(|name| { + matches!( + name.as_str(), + "org.apache.commons.lang3.StringUtils" + | "org.apache.commons.lang.StringUtils" + ) + }) + { + // This library constant has fixed semantics; a local shadowing type wins above. + return Ok(Some(WireValue::String(String::new()))); + } + } else { + let mut scope = context.qualified_name.replace("::", "."); + loop { + if let Some(owner) = project.node_for_fqn(&scope) { + owners.push(owner); + } + let Some((parent, _)) = scope.rsplit_once('.') else { + break; + }; + scope = parent.to_owned(); + } + } + for owner in owners { + let source = project.source(&owner.file_path)?; + let mut parser = Parser::new(); + parser.set_language(&tree_sitter_java::LANGUAGE.into())?; + let tree = parser + .parse(source, None) + .context("parse constant source")?; + let Some(declaration) = descendants(tree.root_node()).into_iter().find(|node| { + matches!( + node.kind(), + "class_declaration" | "interface_declaration" | "enum_declaration" + ) && node.start_position().row + 1 == owner.start_line + && node + .child_by_field_name("name") + .is_some_and(|node| text_of(source, node) == owner.name) + }) else { + continue; + }; + let Some(body) = declaration.child_by_field_name("body") else { + continue; + }; + for field in named_children(body) + .into_iter() + .flat_map(|node| { + if node.kind() == "enum_body_declarations" { + named_children(node) + } else { + vec![node] + } + }) + .filter(|node| matches!(node.kind(), "field_declaration" | "constant_declaration")) + { + let modifiers = text_of(source, field) + .split_whitespace() + .collect::>(); + if declaration.kind() != "interface_declaration" + && !(modifiers.contains("static") && modifiers.contains("final")) + { + continue; + } + if let Some(initializer) = named_children(field).into_iter().find_map(|node| { + (node.kind() == "variable_declarator" + && node + .child_by_field_name("name") + .is_some_and(|node| text_of(source, node) == name)) + .then(|| node.child_by_field_name("value")) + .flatten() + }) { + return resolve(project, owner, text_of(source, initializer), visiting); + } + } + } + if qualifier.is_none() { + let imports = project + .source(&context.file_path)? + .lines() + .filter_map(|line| line.trim().strip_prefix("import static ")) + .map(|line| line.trim_end_matches(';').trim()) + .filter(|import| import.rsplit('.').next() == Some(name)) + .collect::>(); + if let [import] = imports.as_slice() { + return resolve(project, context, import, visiting); + } + } + Ok(None) +} diff --git a/crates/nlab-api/src/semantic/coverage_tests.rs b/crates/nlab-api/src/semantic/coverage_tests.rs new file mode 100644 index 0000000..b3adac0 --- /dev/null +++ b/crates/nlab-api/src/semantic/coverage_tests.rs @@ -0,0 +1,1001 @@ +use super::*; +use crate::graph::test_snapshot; +use crate::model::{ContractIr, RouteSource, TargetIdentity}; +use crate::routes::HttpRouteKey; + +const KIND: &str = r#"package p; +public enum Kind { + A(Numbers.ONE, "First"), B(2, "Second"); + public final int code; + private final String desc; + Kind(int code, String desc) { this.code = code; this.desc = desc; } + public int getCode() { return code; } + public int val() { return code; } + public int code() { return code; } + public String getDesc() { return desc; } + public String toString() { return "prefix-" + code; } +} +class Numbers { + static final int BASE = 1; + static final int ONE = BASE; +} +"#; + +const PAYLOAD: &str = r#"package p; +public class Payload { + private Integer code; + private String token; + public Payload() {} + public Payload(Integer code) { this.code = code; } + public void setCode(Integer code) { this.code = code; } + public void setToken(String token) { this.token = token; } +} +"#; + +/// Supply an index boundary without depending on a globally installed CodeGraph executable. +/// All value analysis and generated artifacts below use the production pipeline and Java AST. +fn contract(files: &[(&str, &str)], methods: &[&str]) -> ContractIr { + let root = tempfile::tempdir().unwrap(); + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + for (path, source) in files { + let path = format!("src/main/java/p/{path}"); + tests::write(root.path(), &path, source); + let mut parser = Parser::new(); + parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .unwrap(); + let tree = parser.parse(source, None).unwrap(); + assert!(!tree.root_node().has_error(), "{path}"); + index_symbols( + source, + &path, + tree.root_node(), + "p", + None, + &mut nodes, + &mut edges, + ); + } + let graph = test_snapshot(nodes.clone(), edges.clone()); + let project = JavaProject::load(root.path(), &graph).unwrap(); + let mut analyzer = SemanticAnalyzer::new(&project); + for method in nodes.iter().filter(|node| node.kind == "method") { + for site in analyzer.method_invocations(method).unwrap() { + for target in analyzer.resolve_invocation(method, &site).unwrap() { + edges.push(GraphEdge { + source: method.id.clone(), + target, + kind: "calls".into(), + line: site.line, + column: site.column, + metadata: String::new(), + provenance: String::new(), + }); + } + } + } + // Run the same assertions against a fresh real index with + // NLAB_API_TEST_REAL_CODEGRAPH=1 cargo test -p nlab-api semantic::coverage_tests + let graph = if std::env::var_os("NLAB_API_TEST_REAL_CODEGRAPH").is_some() { + for (program, args) in [ + ("git", vec!["init", "-q"]), + ("codegraph", vec!["init", "-y"]), + ] { + let output = std::process::Command::new(program) + .args(args) + .current_dir(root.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "{program}: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Snapshot::load(root.path()).unwrap() + } else { + test_snapshot(nodes, edges) + }; + let project = JavaProject::load(root.path(), &graph).unwrap(); + let routes = methods + .iter() + .map(|name| HttpRouteKey { + interface_name: "p.Facade".into(), + method_name: (*name).into(), + signature: None, + method: "GET".into(), + path: format!("/{name}"), + host: None, + source: RouteSource::Controller, + request_bindings: Some( + graph + .nodes + .values() + .find(|node| { + node.kind == "method" + && node.name == *name + && node.qualified_name == format!("p::Facade::{name}") + }) + .map(|method| { + method_parameters(&method.signature) + .into_iter() + .enumerate() + .map(|(index, _)| crate::routes::RequestBinding { + index, + source: crate::routes::BindingSource::Input( + crate::model::InputLocation::Body, + None, + ), + }) + .collect() + }) + .unwrap_or_default(), + ), + }) + .collect::>(); + let (mut operations, mut schemas) = project.build_contracts(&["".into()], &routes).unwrap(); + assert_eq!(operations.len(), methods.len()); + let mut analyzer = SemanticAnalyzer::new(&project); + analyzer.enrich_linked_enums(&mut schemas).unwrap(); + analyzer.enrich(&mut operations, &schemas).unwrap(); + ContractIr { + target: TargetIdentity { + app_name: "coverage".into(), + branch: "test".into(), + commit: "test".into(), + codegraph_version: "fixture".into(), + codegraph_extraction_version: "fixture".into(), + }, + operations, + schemas, + } +} + +fn index_symbols( + source: &str, + path: &str, + node: Node<'_>, + owner: &str, + parent: Option<&str>, + nodes: &mut Vec, + edges: &mut Vec, +) { + let kind = match node.kind() { + "class_declaration" => "class", + "interface_declaration" => "interface", + "enum_declaration" => "enum", + "method_declaration" | "constructor_declaration" => "method", + "enum_constant" => "constant", + _ => "", + }; + let mut next_owner = owner.to_owned(); + let mut next_parent = parent.map(ToOwned::to_owned); + if !kind.is_empty() { + let name = text_of(source, node.child_by_field_name("name").unwrap()); + let id = format!("{path}:{}", node.start_byte()); + let fqn = format!("{owner}::{name}"); + let return_type = node + .child_by_field_name("type") + .map(|node| text_of(source, node)) + .unwrap_or(name); + let signature = if kind == "method" { + format!( + "{return_type} {}", + node.child_by_field_name("parameters") + .map(|node| text_of(source, node)) + .unwrap_or("()") + ) + } else { + String::new() + }; + let mut symbol = tests::node( + &id, + kind, + name, + &fqn, + path, + node.start_position().row + 1, + &signature, + ); + symbol.return_type = return_type.to_owned(); + symbol.decorators = named_children(node) + .into_iter() + .find(|node| node.kind() == "modifiers") + .map(|node| text_of(source, node).to_owned()) + .unwrap_or_default(); + nodes.push(symbol); + if let Some(parent) = parent { + edges.push(tests::contains(parent, &id)); + } + next_owner = fqn; + next_parent = Some(id); + } + if node.kind() == "field_declaration" { + for variable in named_children(node) + .into_iter() + .filter(|node| node.kind() == "variable_declarator") + { + let name = text_of(source, variable.child_by_field_name("name").unwrap()); + let id = format!("{path}:{}", variable.start_byte()); + let signature = format!( + "{} {name}", + text_of(source, node.child_by_field_name("type").unwrap()) + ); + let mut field = tests::node( + &id, + "field", + name, + &format!("{owner}::{name}"), + path, + node.start_position().row + 1, + &signature, + ); + field.decorators = text_of(source, node).to_owned(); + nodes.push(field); + if let Some(parent) = parent { + edges.push(tests::contains(parent, &id)); + } + } + return; + } + if kind == "method" || kind == "constant" { + return; + } + for child in named_children(node) { + index_symbols( + source, + path, + child, + &next_owner, + next_parent.as_deref(), + nodes, + edges, + ); + } +} + +fn patch<'a>(ir: &'a ContractIr, method: &str, field: &str) -> &'a SemanticPatch { + ir.operations + .iter() + .find(|operation| operation.method_name == method) + .unwrap() + .semantic_patches + .iter() + .find(|patch| { + patch.target.source == FieldSource::Response && patch.target.field_name == field + }) + .unwrap() +} + +fn values(patch: &SemanticPatch) -> Vec { + patch + .associated_values() + .unwrap_or_else(|| panic!("unassociated: {patch:#?}")) + .iter() + .map(|value| value.value.clone()) + .collect() +} + +#[test] +fn enum_projections_and_extra_members_reach_generated_types() { + let source = r#"package p; +import org.apache.commons.lang3.StringUtils; +public class Facade { + public Payload getter() { Payload v = new Payload(); v.setCode(Kind.A.getCode()); return v; } + public Payload val() { Payload v = new Payload(); v.setCode(Kind.A.val()); return v; } + public Payload code() { Payload v = new Payload(); v.setCode(Kind.A.code()); return v; } + public Payload field() { Payload v = new Payload(); v.setCode(Kind.A.code); return v; } + public Payload name() { Payload v = new Payload(); v.setToken(Kind.A.name()); return v; } + public Payload extra() { Payload v = new Payload(); v.setToken(flag() ? Kind.A.name() : "ALL_MARKET"); return v; } + public Payload empty() { Payload v = new Payload(); v.setToken(flag() ? Kind.A.name() : StringUtils.EMPTY); return v; } + public Payload label() { Payload v = new Payload(); v.setToken(Kind.A.getDesc()); return v; } + public Payload transformed() { Payload v = new Payload(); v.setToken(Kind.A.toString()); return v; } + boolean flag() { return Boolean.getBoolean("flag"); } +} + +"#; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", PAYLOAD), + ("Facade.java", source), + ], + &[ + "getter", + "val", + "code", + "field", + "name", + "extra", + "empty", + "label", + "transformed", + ], + ); + for method in ["getter", "val", "code", "field"] { + assert_eq!( + values(patch(&ir, method, "code")), + vec![WireValue::Number(1), WireValue::Number(2)] + ); + } + assert_eq!( + values(patch(&ir, "name", "token")), + vec![WireValue::String("A".into()), WireValue::String("B".into())] + ); + assert!(values(patch(&ir, "extra", "token")).contains(&WireValue::String("ALL_MARKET".into()))); + assert!(values(patch(&ir, "empty", "token")).contains(&WireValue::String(String::new()))); + for method in ["label", "transformed"] { + assert!(patch(&ir, method, "token").associated_values().is_none()); + } + assert_ne!( + crate::openapi::enum_identity(patch(&ir, "name", "token")), + crate::openapi::enum_identity(patch(&ir, "extra", "token")) + ); + let mut config = crate::typescript::tests::config(); + config.backend.contract_roots = vec!["src/main/java/p".into()]; + let artifact = crate::typescript::generate(&ir, &config).unwrap(); + let enums = artifact + .enum_files + .iter() + .map(|path| &artifact.files[path]) + .collect::>(); + assert_eq!(enums.len(), 4, "{enums:#?}"); + assert_eq!( + enums + .iter() + .filter(|source| source.contains("ALL_MARKET")) + .count(), + 1 + ); + assert!( + artifact + .type_files + .iter() + .any(|path| artifact.files[path].contains("token: KindName")) + ); +} + +#[test] +fn writes_returns_callbacks_and_defaults_reach_enum_members() { + let payload = PAYLOAD + .replace("private Integer code;", "public Integer code = 99;") + .replace( + "public class Payload", + "@lombok.Builder public class Payload", + ); + let source = r#"package p; +import java.util.Optional; +public class Facade { + public Payload direct() { Payload v = new Payload(); v.code = Kind.A.code; return v; } + public Payload constructor() { return new Payload(Kind.A.code()); } + public Payload builder() { return Payload.builder().code(Kind.A.val()).build(); } + public Payload helper() { Payload v = new Payload(); v.setCode(identity(Kind.A.getCode())); return v; } + public Payload mapper() { Payload v = new Payload(); v.setCode(convert(500)); return v; } + public Payload optional() { Payload v = new Payload(); v.setCode(Optional.ofNullable(Kind.A).map(Kind::getCode).orElse(7)); return v; } + public Payload callback() { Payload v = new Payload(); Optional.ofNullable(Kind.A).map(k -> k.val()).ifPresent(v::setCode); return v; } + public Payload conditional() { Payload v = new Payload(); if (Boolean.getBoolean("replace")) v.setCode(Kind.A.getCode()); return v; } + public Payload isolated() { Payload v = new Payload(); Payload scratch = new Payload(); scratch.setCode(dynamic()); v.setCode(Kind.A.getCode()); return v; } + public Payload callBindings() { Payload v = new Payload(); v.setCode(identity(Kind.A.getCode())); identity(dynamic()); return v; } + public Payload unknown() { Payload v = new Payload(); v.setCode(identity(dynamic())); return v; } + public Payload transformedHelper() { Payload v = new Payload(); v.setCode(transform(Kind.A.code)); return v; } + public Payload rewrittenHelper() { Payload v = new Payload(); v.setCode(rewrite(Kind.A.code)); return v; } + int identity(int code) { return code; } + int convert(int code) { if (code == 500) return Kind.A.val(); return Kind.B.code(); } + int transform(int code) { return code + 100; } + int rewrite(int code) { code += 100; return code; } + int dynamic() { return Integer.parseInt(System.getenv("CODE")); } +} +"#; + let methods = [ + "direct", + "constructor", + "builder", + "helper", + "mapper", + "optional", + "callback", + "conditional", + "isolated", + "callBindings", + "unknown", + "transformedHelper", + "rewrittenHelper", + ]; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", &payload), + ("Facade.java", source), + ], + &methods, + ); + for method in &methods[..10] { + let members = values(patch(&ir, method, "code")); + assert!(members.contains(&WireValue::Number(1)), "{method}"); + assert!(members.contains(&WireValue::Number(2)), "{method}"); + assert!( + members.contains(&WireValue::Number(99)), + "{method}: initializer must survive conditional writes" + ); + } + assert!(values(patch(&ir, "optional", "code")).contains(&WireValue::Number(7))); + for method in ["unknown", "transformedHelper", "rewrittenHelper"] { + assert!( + patch(&ir, method, "code").associated_values().is_none(), + "{method}" + ); + } +} + +#[test] +fn declared_enums_and_collection_elements_preserve_wire_shape() { + let payload = r#"package p; +import java.util.List; +public class Payload { + public Kind state; + public List codes; + public Integer[] array; + public JsonKind json; + public DynamicKind dynamic; + public java.util.Map states; + public void setCodes(List codes) { this.codes = codes; } + public void setArray(Integer[] array) { this.array = array; } +} +enum JsonKind { A(3), B(4); @com.fasterxml.jackson.annotation.JsonValue private final int code; JsonKind(int code) { this.code = code; } } +@com.fasterxml.jackson.databind.annotation.JsonSerialize(using=Serializer.class) +enum DynamicKind { A, B; } +"#; + let source = r#"package p; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; +public class Facade { + public Payload query(Payload input) { + Payload v = new Payload(); + v.setCodes(Arrays.asList(Kind.A, Kind.B).stream().map(Kind::getCode).collect(Collectors.toList())); + v.setArray(new Integer[] { Kind.A.getCode(), Kind.B.code }); + return v; + } +} +"#; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", payload), + ("Facade.java", source), + ], + &["query"], + ); + assert_eq!( + values(patch(&ir, "query", "state")), + vec![WireValue::String("A".into()), WireValue::String("B".into())] + ); + assert_eq!( + values(patch(&ir, "query", "json")), + vec![WireValue::Number(3), WireValue::Number(4)] + ); + assert!(patch(&ir, "query", "dynamic").associated_values().is_none()); + assert!( + ir.operations[0] + .semantic_patches + .iter() + .any(|patch| patch.target.source == FieldSource::Request + && patch.target.field_name == "state" + && patch.associated_values().is_some()) + ); + assert_eq!( + values(patch(&ir, "query", "codes")), + vec![WireValue::Number(1), WireValue::Number(2)] + ); + assert_eq!( + values(patch(&ir, "query", "array")), + vec![WireValue::Number(1), WireValue::Number(2)] + ); + let mut config = crate::typescript::tests::config(); + config.backend.contract_roots = vec!["src/main/java/p".into()]; + let artifact = crate::typescript::generate(&ir, &config).unwrap(); + assert!( + artifact + .type_files + .iter() + .any(|path| artifact.files[path].contains("codes: KindCode[]") + && artifact.files[path].contains("array: KindCode[]")) + ); + let artifact = crate::openapi::generate(&ir, &config).unwrap(); + let document: serde_json::Value = serde_json::from_str(&artifact.source).unwrap(); + let schemas = document["components"]["schemas"].as_object().unwrap(); + assert!( + schemas + .values() + .any(|schema| schema["properties"]["codes"]["items"]["enum"] + == serde_json::json!([1, 2])) + ); + assert!( + schemas + .values() + .all(|schema| schema["properties"]["codes"].get("enum").is_none()) + ); + assert!(schemas.values().any( + |schema| schema["properties"]["states"]["additionalProperties"]["enum"] + == serde_json::json!(["A", "B"]) + )); + assert!( + schemas + .values() + .any(|schema| schema["properties"]["json"]["type"] == "number" + && schema["properties"]["json"]["enum"] == serde_json::json!([3, 4])) + ); +} + +#[test] +fn constructor_builder_and_implicit_defaults_are_not_lost() { + let builder = r#"package p; +public class Payload { + public int code; + public Payload() {} + public Payload(int code) { this.code = code; } + public void setCode(int code) { this.code = code; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private int code; + public Builder code(int code) { this.code = code; return this; } + public Payload build() { return new Payload(code); } + } +} +"#; + let source = r#"package p; +public class Facade { + public Payload builder() { return Payload.builder().code(Kind.A.getCode()).build(); } + public Payload conditional() { Payload v = new Payload(); if (System.currentTimeMillis() > 0) v.setCode(Kind.A.val()); return v; } + public Payload early() { Payload v = new Payload(); if (System.currentTimeMillis() > 0) return v; v.setCode(Kind.A.val()); return v; } + public Payload aliasWrite() { Payload v = new Payload(); Payload alias = v; v.setCode(Kind.A.val()); alias.setCode(Integer.parseInt(System.getenv("CODE"))); return v; } + public Payload unusedConstructor() { Payload scratch = new Payload(Kind.A.code); return unknownObject(); } + public Payload unusedSetter() { Payload scratch = new Payload(); scratch.setCode(Kind.A.code); return unknownObject(); } + private Payload unknownObject() { return External.load(); } + public Payload constructor() { return new Payload(Kind.A.code()); } +} +"#; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", builder), + ("Facade.java", source), + ], + &[ + "builder", + "conditional", + "constructor", + "early", + "aliasWrite", + "unusedConstructor", + "unusedSetter", + ], + ); + assert_eq!( + values(patch(&ir, "builder", "code")), + vec![WireValue::Number(1), WireValue::Number(2)] + ); + assert!(values(patch(&ir, "conditional", "code")).contains(&WireValue::Number(0))); + assert!(values(patch(&ir, "early", "code")).contains(&WireValue::Number(0))); + assert!( + patch(&ir, "unusedConstructor", "code") + .associated_values() + .is_none() + ); + assert!( + patch(&ir, "unusedSetter", "code") + .associated_values() + .is_none() + ); + assert!( + patch(&ir, "aliasWrite", "code") + .associated_values() + .is_none() + ); + let lombok = "package p;\n@lombok.Data @lombok.AllArgsConstructor public class Payload { private int code; }"; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", lombok), + ("Facade.java", source), + ], + &["constructor"], + ); + assert_eq!( + values(patch(&ir, "constructor", "code")), + vec![WireValue::Number(1), WireValue::Number(2)] + ); + let rewritten = builder.replace( + "this.code = code; return this;", + "this.code = code + 100; return this;", + ); + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", &rewritten), + ("Facade.java", source), + ], + &["builder"], + ); + assert!(patch(&ir, "builder", "code").associated_values().is_none()); +} + +#[test] +fn lookup_variants_associate_inputs_without_confusing_output_projection() { + let kind = r#"package p; +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.HashMap; +import java.util.stream.Collectors; +import java.util.function.Function; +public enum Kind { + A(1), B(2); + private final int code; + Kind(int code) { this.code = code; } + public int val() { return code; } + private static final Map INDEX = Arrays.stream(values()).collect(Collectors.toMap(Kind::val, Function.identity())); + private static final Map NAMES; + static { + Map names = new HashMap<>(); + for (Kind item : values()) { names.put(item.name(), item); } + NAMES = Collections.unmodifiableMap(names); + } + public static Kind fromMap(int input) { return INDEX.getOrDefault(input, A); } + public static Kind fromName(String input) { return NAMES.get(input); } + public static Kind fromAlias(int input) { Kind[] kinds = values(); for (Kind item : kinds) { if (item.val() == input) return item; } return null; } + public static Kind convert(int input) { switch (input) { case 200: return A; case 300: return B; default: return null; } } +} +"#; + let dto = "package p;\npublic class Source { private Integer code; private String token; public Integer getCode() { return code; } public String getToken() { return token; } }"; + let source = r#"package p; +public class Facade { + public Payload map(Source source) { Kind.fromMap(source.getCode()); Payload v = new Payload(); v.setCode(source.getCode()); return v; } + public Payload alias(Source source) { Kind.fromAlias(source.getCode()); Payload v = new Payload(); v.setCode(source.getCode()); return v; } + public Payload name(Source source) { Kind.fromName(source.getToken()); Payload v = new Payload(); v.setToken(source.getToken()); return v; } + public Payload convert(Source source) { Kind.convert(source.getCode()); Payload v = new Payload(); v.setCode(source.getCode()); v.setToken(Kind.convert(source.getCode()).name()); return v; } +} +"#; + let files = [ + ("Kind.java", kind), + ("Payload.java", PAYLOAD), + ("Source.java", dto), + ("Facade.java", source), + ]; + let ir = contract(&files, &["map", "alias", "name", "convert"]); + for method in ["map", "alias"] { + let patch = patch(&ir, method, "code"); + assert_eq!( + values(patch), + vec![WireValue::Number(1), WireValue::Number(2)] + ); + assert_eq!( + patch.status, + ProvenanceStatus::Known, + "unvalidated input stays open" + ); + } + assert_eq!( + values(patch(&ir, "name", "token")), + vec![WireValue::String("A".into()), WireValue::String("B".into())] + ); + assert!(patch(&ir, "convert", "code").associated_values().is_none()); + assert_eq!( + values(patch(&ir, "convert", "token")), + vec![WireValue::String("A".into()), WireValue::String("B".into())] + ); + let mutated = kind.replace( + "return INDEX.getOrDefault(input, A);", + "INDEX.put(999, A); return INDEX.getOrDefault(input, A);", + ); + let ir = contract( + &[ + ("Kind.java", &mutated), + ("Payload.java", PAYLOAD), + ("Source.java", dto), + ("Facade.java", source), + ], + &["map"], + ); + assert!(patch(&ir, "map", "code").associated_values().is_none()); +} + +#[test] +fn copy_frameworks_respect_sources_ignores_conversions_and_overwrites() { + let dto = r#"package p; +public class Source { + private Integer code; + private Integer alternate; + public void setCode(Integer code) { this.code = code; } + public void setAlternate(Integer alternate) { this.alternate = alternate; } + public Integer getCode() { return code; } + public Integer getAlternate() { return alternate; } +} +"#; + let mapper = r#"package p; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +@Mapper public interface Converter { + Payload map(Source source); + @Mapping(target="code", source="alternate") Payload rename(Source source); + @Mapping(target="code", ignore=true) Payload ignore(Source source); + @Mapping(target="code", expression="java(source.getCode() + 1)") Payload custom(Source source); +} +"#; + let source = r#"package p; +import org.springframework.beans.BeanUtils; +public class Facade { + private Converter converter; + public Payload bean() { Source s = source(); Payload v = new Payload(); BeanUtils.copyProperties(s, v); return v; } + public Payload ignored() { Source s = source(); Payload v = new Payload(); BeanUtils.copyProperties(s, v, "code"); return v; } + public Payload overwritten() { Source s = source(); Payload v = new Payload(); BeanUtils.copyProperties(s, v); v.setCode(Integer.parseInt(System.getenv("CODE"))); return v; } + public Payload map() { Source s = source(); return converter.map(s); } + public Payload rename() { Source s = source(); return converter.rename(s); } + public Payload ignore() { Source s = source(); return converter.ignore(s); } + public Payload custom() { Source s = source(); return converter.custom(s); } + private Source source() { Source s = new Source(); s.setCode(Kind.A.getCode()); s.setAlternate(Kind.A.val()); return s; } +} +"#; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", PAYLOAD), + ("Source.java", dto), + ("Converter.java", mapper), + ("Facade.java", source), + ], + &[ + "bean", + "ignored", + "overwritten", + "map", + "rename", + "ignore", + "custom", + ], + ); + for method in ["bean", "map", "rename"] { + assert_eq!( + values(patch(&ir, method, "code")), + vec![WireValue::Number(1), WireValue::Number(2)], + "{method}" + ); + } + for method in ["ignored", "overwritten", "ignore", "custom"] { + assert!( + patch(&ir, method, "code").associated_values().is_none(), + "{method}" + ); + } + let shadow = source.replace("import org.springframework.beans.BeanUtils;", ""); + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", PAYLOAD), + ("Source.java", dto), + ("Converter.java", mapper), + ("Facade.java", &shadow), + ( + "BeanUtils.java", + "package p; class BeanUtils { static void copyProperties(Source s, Payload v) { v.setCode(100); } }", + ), + ], + &["bean"], + ); + assert!(patch(&ir, "bean", "code").associated_values().is_none()); +} + +#[test] +fn all_eighteen_audit_probes_have_expected_generated_members() { + let facade = include_str!("../../tests/fixtures/enum-provenance/ProbeController.java") + .replace("ProbeController", "Facade"); + let files = [ + ("Facade.java", facade.as_str()), + ( + "Kind.java", + include_str!("../../tests/fixtures/enum-provenance/Kind.java"), + ), + ( + "ConstKind.java", + include_str!("../../tests/fixtures/enum-provenance/ConstKind.java"), + ), + ( + "Payload.java", + include_str!("../../tests/fixtures/enum-provenance/Payload.java"), + ), + ( + "DefaultPayload.java", + include_str!("../../tests/fixtures/enum-provenance/DefaultPayload.java"), + ), + ( + "Source.java", + include_str!("../../tests/fixtures/enum-provenance/Source.java"), + ), + ]; + let methods = [ + "getter", + "name", + "val", + "codeMethod", + "directEnumField", + "helper", + "literalExtra", + "optional", + "methodReference", + "directWrite", + "constructor", + "builder", + "collection", + "enumField", + "loopLookup", + "mapLookup", + "enumConstantArgument", + "fieldInitializer", + ]; + let ir = contract(&files, &methods); + for method in methods { + let field = match method { + "name" => "token", + "collection" => "codes", + "enumField" => "kind", + _ => "code", + }; + let expected = match method { + "name" | "enumField" => { + vec![WireValue::String("A".into()), WireValue::String("B".into())] + } + "literalExtra" | "optional" | "fieldInitializer" => vec![ + WireValue::Number(1), + WireValue::Number(2), + WireValue::Number(99), + ], + _ => vec![WireValue::Number(1), WireValue::Number(2)], + }; + assert_eq!(values(patch(&ir, method, field)), expected, "{method}"); + } + let mut config = crate::typescript::tests::config(); + config.backend.contract_roots = vec!["src/main/java/p".into()]; + let typescript = crate::typescript::generate(&ir, &config).unwrap(); + assert_eq!(typescript.enum_files.len(), 4); + assert!( + typescript + .type_files + .iter() + .any(|file| typescript.files[file].contains("codes: KindCode[]")) + ); + let openapi = crate::openapi::generate(&ir, &config).unwrap(); + let document: serde_json::Value = serde_json::from_str(&openapi.source).unwrap(); + assert_eq!(document["paths"].as_object().unwrap().len(), 18); +} + +#[test] +fn unresolved_sources_transforms_and_conflicting_enums_never_narrow() { + let facade = r#"package p; +import missing.ExpressCompany; +import static p.Numbers.ONE; +public class Facade { + public Payload missing() { return new Payload(ExpressCompany.SF.code()); } + public Payload mixed() { Payload v = new Payload(); v.setCode(flag() ? Kind.A.code : Other.A.code); return v; } + public Payload dynamic() { Payload v = new Payload(); v.setToken(flag() ? Kind.A.name() : System.getenv("MARKET")); return v; } + public Payload statics() { Payload v = new Payload(); v.setCode(flag() ? Kind.A.code : ONE); return v; } + public Payload cycle() { Payload v = new Payload(); v.setCode(flag() ? Kind.A.code : Cycle.A); return v; } + boolean flag() { return Boolean.getBoolean("flag"); } +} +enum Other { A(1), B(2); final int code; Other(int code) { this.code = code; } } +class Cycle { static final int A = B; static final int B = A; } +"#; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", PAYLOAD), + ("Facade.java", facade), + ], + &["missing", "mixed", "dynamic", "statics", "cycle"], + ); + for (method, field) in [ + ("missing", "code"), + ("mixed", "code"), + ("dynamic", "token"), + ("cycle", "code"), + ] { + assert!( + patch(&ir, method, field).associated_values().is_none(), + "{method}" + ); + } + assert_eq!( + values(patch(&ir, "statics", "code")), + vec![WireValue::Number(1), WireValue::Number(2)] + ); + assert!(crate::semantic_diagnostics(&ir).iter().any(|diagnostic| { + diagnostic["operationKey"] == "Facade#missing" + && diagnostic["message"] + .as_str() + .unwrap_or_default() + .contains("ExpressCompany") + })); + let broken = KIND.replace("Numbers.ONE", "MissingNumber.ONE"); + let ir = contract( + &[ + ("Kind.java", &broken), + ("Payload.java", PAYLOAD), + ("Facade.java", facade), + ], + &["statics"], + ); + assert!(patch(&ir, "statics", "code").associated_values().is_none()); + assert!( + crate::semantic_diagnostics(&ir) + .iter() + .any(|diagnostic| diagnostic["level"] == "warning") + ); +} + +#[test] +fn same_name_nested_receivers_never_share_enum_domains() { + let one = "package p;\npublic class One { public static class MarketVo { private String token; public void setToken(String token) { this.token = token; } } }"; + let two = one.replace("class One", "class Two"); + let facade = r#"package p; +public class Facade { + public One.MarketVo query() { + One.MarketVo v = new One.MarketVo(); + Two.MarketVo scratch = new Two.MarketVo(); + scratch.setToken("UNRELATED"); + v.setToken(Kind.A.name()); + return v; + } +} + +"#; + let ir = contract( + &[ + ("Kind.java", KIND), + ("One.java", one), + ("Two.java", &two), + ("Facade.java", facade), + ], + &["query"], + ); + assert_eq!( + values(patch(&ir, "query", "token")), + vec![WireValue::String("A".into()), WireValue::String("B".into())] + ); +} + +#[test] +fn custom_setters_and_mapper_lifecycle_keep_unknown_values_visible() { + let setter = PAYLOAD.replace("this.code = code;", "this.code = code + 100;"); + let facade = "package p;\npublic class Facade { public Payload query() { Payload v = new Payload(); v.setCode(Kind.A.code); return v; } }"; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", &setter), + ("Facade.java", facade), + ], + &["query"], + ); + assert!(patch(&ir, "query", "code").associated_values().is_none()); + let mapper = r#"package p; +import org.mapstruct.Mapper; +import org.mapstruct.AfterMapping; +import org.mapstruct.MappingTarget; +@Mapper public interface Converter { + Payload map(Source source); + @AfterMapping default void rewrite(@MappingTarget Payload v) { v.setCode(100); } +} +"#; + let source = "package p;\npublic class Source { private int code; public void setCode(int code) { this.code = code; } public int getCode() { return code; } }"; + let facade = "package p;\npublic class Facade { private Converter converter; public Payload query() { Source s = new Source(); s.setCode(Kind.A.code); return converter.map(s); } }"; + let ir = contract( + &[ + ("Kind.java", KIND), + ("Payload.java", PAYLOAD), + ("Source.java", source), + ("Converter.java", mapper), + ("Facade.java", facade), + ], + &["query"], + ); + assert!(patch(&ir, "query", "code").associated_values().is_none()); +} diff --git a/crates/nlab-api/src/semantic/cross_repository.rs b/crates/nlab-api/src/semantic/cross_repository.rs index 098ceca..3e7c54c 100644 --- a/crates/nlab-api/src/semantic/cross_repository.rs +++ b/crates/nlab-api/src/semantic/cross_repository.rs @@ -352,23 +352,9 @@ impl SemanticAnalyzer<'_> { } let mut domain = Domain::default(); let setter_name = format!("set{}", uppercase_first(&field_name)); - let setters = self - .project - .graph() - .contained(&class.id, "method") - .into_iter() - .filter(|method| method.name == setter_name) - .cloned() - .collect::>(); - if setters.len() == 1 { - let setter = &setters[0]; - let edges = self - .project - .graph() - .incoming_calls(&setter.id) - .filter(|edge| reachable.nodes.contains(&edge.source)) - .cloned() - .collect::>(); + { + let methods = reachable.nodes.iter().cloned().collect(); + let edges = self.typed_setter_edges(&class, &field_name, &methods)?; let mut proven_edges = Vec::new(); for edge in &edges { if self.copied_setter_edge_matches( @@ -378,7 +364,7 @@ impl SemanticAnalyzer<'_> { offset, reachable, edge, - &setter.name, + &setter_name, )? { proven_edges.push(edge.clone()); } @@ -388,7 +374,7 @@ impl SemanticAnalyzer<'_> { for edge in edges { let source = self.project.graph().nodes[&edge.source].clone(); let Some((expression, _, value_offset)) = - self.setter_argument(&edge, &setter.name)? + self.setter_argument(&edge, &setter_name)? else { domain .unknown @@ -418,6 +404,11 @@ impl SemanticAnalyzer<'_> { .insert("copied field object origin is not proven".to_owned()); } } + for initializer in + self.field_initializer_domains(operation, &class, &field_name, reachable, visiting)? + { + merge_domain(&mut domain, initializer); + } visiting.remove(&visit_key); let documented = self.copied_field_enum_reference(&class, &field_name, &mut domain); if domain.enum_fqn.is_none() && !documented { diff --git a/crates/nlab-api/src/semantic/cross_repository/tests.rs b/crates/nlab-api/src/semantic/cross_repository/tests.rs index 36e4e3c..47d1825 100644 --- a/crates/nlab-api/src/semantic/cross_repository/tests.rs +++ b/crates/nlab-api/src/semantic/cross_repository/tests.rs @@ -825,6 +825,21 @@ fn enum_association_rejects_conflicts_transforms_and_unknown_writes_but_keeps_nu }; let patch = classify_patch(target.clone(), vec![domain.clone(), nullable]); assert!(patch.enum_associated && patch.nullable); + let extra = Domain { + literals: BTreeSet::from(["999".to_owned()]), + ..Domain::default() + }; + let patch = classify_patch(target.clone(), vec![domain.clone(), extra]); + assert!(patch.enum_associated); + assert_eq!( + patch + .associated_values() + .unwrap() + .iter() + .map(|member| member.value.clone()) + .collect::>(), + vec![WireValue::Number(1), WireValue::Number(999)] + ); for bad in [ Domain { enum_fqn: Some("p.Other".to_owned()), @@ -842,10 +857,6 @@ fn enum_association_rejects_conflicts_transforms_and_unknown_writes_but_keeps_nu transformed: true, ..Domain::default() }, - Domain { - literals: BTreeSet::from(["999".to_owned()]), - ..Domain::default() - }, ] { assert!(!classify_patch(target.clone(), vec![domain.clone(), bad]).enum_associated); } diff --git a/crates/nlab-api/src/semantic/database.rs b/crates/nlab-api/src/semantic/database.rs index 4c7a2a1..76e2313 100644 --- a/crates/nlab-api/src/semantic/database.rs +++ b/crates/nlab-api/src/semantic/database.rs @@ -32,22 +32,23 @@ impl SemanticAnalyzer<'_> { return Ok(None); }; let setter_name = format!("set{}", uppercase_first(field_name)); - let setters = self + let methods = self .project .graph() - .contained(&class.id, "method") - .into_iter() - .filter(|method| method.name == setter_name) - .collect::>(); - let [setter] = setters.as_slice() else { - return Ok(None); - }; - let edges = self - .project - .graph() - .incoming_calls(&setter.id) - .cloned() - .collect::>(); + .edges + .iter() + .filter(|edge| { + edge.kind == "calls" + && self + .project + .graph() + .nodes + .get(&edge.target) + .is_some_and(|node| node.name == setter_name) + }) + .map(|edge| edge.source.clone()) + .collect(); + let edges = self.typed_setter_edges(class, field_name, &methods)?; if edges.is_empty() || edges.len() > MAX_FIELD_WRITES { return Ok(None); } @@ -91,26 +92,21 @@ impl SemanticAnalyzer<'_> { .insert("database setter value unresolved".to_owned()); continue; }; - let value = match expression { - Expression::Getter { receiver, accessor } => { - if let Some(enum_node) = self.enum_for_receiver(&writer, &receiver, offset)? { - self.enum_domain(&enum_node, &accessor)? - } else { + let value = if let Some(domain) = self.enum_projection(&writer, &expression, offset)? { + domain + } else { + match expression { + Expression::Literal(value) => { + let mut domain = Domain::default(); + domain.literals.insert(value); + domain + } + _ => { let mut value = Domain::default(); value.unknown.insert(source.clone()); value } } - Expression::Literal(value) => { - let mut domain = Domain::default(); - domain.literals.insert(value); - domain - } - _ => { - let mut value = Domain::default(); - value.unknown.insert(source.clone()); - value - } }; merge_domain(&mut domain, value); push_unique( diff --git a/crates/nlab-api/src/semantic/lookup.rs b/crates/nlab-api/src/semantic/lookup.rs index ffa92e2..c1535e3 100644 --- a/crates/nlab-api/src/semantic/lookup.rs +++ b/crates/nlab-api/src/semantic/lookup.rs @@ -107,6 +107,39 @@ fn arguments(node: Node<'_>) -> Vec> { } fn values_call(source: &str, node: Node<'_>, enum_name: &str) -> bool { + values_source(source, node, enum_name, 0) +} + +fn values_source(source: &str, node: Node<'_>, enum_name: &str, depth: usize) -> bool { + if depth >= 8 { + return false; + } + if node.kind() == "identifier" { + let name = text_of(source, node); + let Some(scope) = ancestors(node).find(|node| node.kind() == "method_declaration") else { + return false; + }; + let nodes = descendants(scope); + if nodes.iter().any(|node| { + matches!(node.kind(), "assignment_expression" | "update_expression") + && text_of(source, *node).starts_with(name) + }) { + return false; + } + let initializers = nodes + .into_iter() + .filter(|variable| { + variable.kind() == "variable_declarator" + && variable.start_byte() < node.start_byte() + && variable + .child_by_field_name("name") + .is_some_and(|variable| text_of(source, variable) == name) + }) + .filter_map(|variable| variable.child_by_field_name("value")) + .collect::>(); + return initializers.len() == 1 + && values_source(source, initializers[0], enum_name, depth + 1); + } node.kind() == "method_invocation" && invocation_name(source, node) == "values" && arguments(node).is_empty() @@ -138,14 +171,14 @@ fn projection(source: &str, node: Node<'_>, item: &str) -> Option { .map(|field| text_of(source, field).to_owned()), "method_invocation" if arguments(node).is_empty() => { let name = invocation_name(source, node); - getter_signal(name).map(|_| name.to_owned()) + Some(name.to_owned()) } _ => None, } } fn canonical_projection(source: &str, method: Node<'_>, accessor: String) -> String { - if getter_signal(&accessor).is_some() { + if getter_signal(&accessor).is_some() || accessor == "name" { return accessor; } let Some(declaration) = ancestors(method).find(|node| node.kind() == "enum_declaration") else { @@ -388,6 +421,89 @@ fn map_projection(source: &str, body: Node<'_>, map: &str, enum_name: &str) -> O }) .copied() .collect::>(); + if writes.is_empty() { + let initializer = nodes.iter().find_map(|node| { + (node.kind() == "variable_declarator" + && node + .child_by_field_name("name") + .is_some_and(|name| text_of(source, name) == map)) + .then(|| node.child_by_field_name("value")) + .flatten() + }); + if let Some(initializer) = initializer { + return collected_map_projection(source, initializer, enum_name); + } + // A local map is filled once, then wrapped without exposing the mutable alias. + let assignments = nodes + .iter() + .filter(|node| { + node.kind() == "assignment_expression" + && node + .child_by_field_name("left") + .is_some_and(|left| text_of(source, left) == map) + }) + .copied() + .collect::>(); + let [assignment] = assignments.as_slice() else { + return None; + }; + let value = assignment.child_by_field_name("right")?; + if invocation_name(source, value) != "unmodifiableMap" + || !value.child_by_field_name("object").is_some_and(|object| { + matches!( + text_of(source, object), + "Collections" | "java.util.Collections" + ) + }) + { + return None; + } + let args = arguments(value); + let [local] = args.as_slice() else { + return None; + }; + let local = text_of(source, *local); + let block = ancestors(*assignment).find(|node| node.kind() == "static_initializer")?; + let local_nodes = descendants(block); + let calls = local_nodes + .iter() + .filter(|node| { + node.kind() == "method_invocation" + && node + .child_by_field_name("object") + .is_some_and(|object| text_of(source, object) == local) + }) + .copied() + .collect::>(); + let [put] = calls.as_slice() else { + return None; + }; + if invocation_name(source, *put) != "put" { + return None; + } + let loop_node = ancestors(*put).find(|node| node.kind() == "enhanced_for_statement")?; + if !values_call(source, loop_node.child_by_field_name("value")?, enum_name) { + return None; + } + let item = text_of(source, loop_node.child_by_field_name("name")?); + let args = arguments(*put); + if args.len() != 2 || text_of(source, args[1]) != item { + return None; + } + let statements = statements(loop_node.child_by_field_name("body")?); + if statements.len() != 1 || statements[0].kind() != "expression_statement" { + return None; + } + let uses = local_nodes + .iter() + .filter(|node| node.kind() == "identifier" && text_of(source, **node) == local) + .count(); + // Declaration, put receiver, wrapper argument; any other use could leak or mutate it. + if uses != 3 { + return None; + } + return projection(source, args[0], item); + } if writes.len() != 1 || invocation_name(source, writes[0]) != "put" { return None; } @@ -417,6 +533,70 @@ fn map_projection(source: &str, body: Node<'_>, map: &str, enum_name: &str) -> O projection(source, args[0], item) } +fn collected_map_projection( + source: &str, + initializer: Node<'_>, + enum_name: &str, +) -> Option { + if invocation_name(source, initializer) != "collect" + || !values_stream( + source, + initializer.child_by_field_name("object")?, + enum_name, + ) + { + return None; + } + let args = arguments(initializer); + let [collector] = args.as_slice() else { + return None; + }; + if invocation_name(source, *collector) != "toMap" + || !collector + .child_by_field_name("object") + .is_some_and(|object| { + matches!( + text_of(source, object), + "Collectors" | "java.util.stream.Collectors" + ) + }) + { + return None; + } + let args = arguments(*collector); + let [key, value] = args.as_slice() else { + return None; + }; + let identity = if value.kind() == "lambda_expression" { + let parameter = + text_of(source, value.child_by_field_name("parameters")?).trim_matches(['(', ')']); + text_of(source, value.child_by_field_name("body")?) == parameter + } else { + invocation_name(source, *value) == "identity" + && arguments(*value).is_empty() + && value.child_by_field_name("object").is_some_and(|object| { + matches!( + text_of(source, object), + "Function" | "java.util.function.Function" + ) + }) + }; + if !identity { + return None; + } + if key.kind() == "method_reference" { + let parts = named_children(*key); + if parts.len() == 2 && text_of(source, parts[0]) == enum_name { + return Some(text_of(source, parts[1]).to_owned()); + } + } else if key.kind() == "lambda_expression" { + let parameter = + text_of(source, key.child_by_field_name("parameters")?).trim_matches(['(', ')']); + return projection(source, key.child_by_field_name("body")?, parameter); + } + None +} + pub(super) fn ancestors(node: Node<'_>) -> impl Iterator> { std::iter::successors(node.parent(), |node| node.parent()) } diff --git a/crates/nlab-api/src/semantic/primary.rs b/crates/nlab-api/src/semantic/primary.rs index accbcc4..1efac9a 100644 --- a/crates/nlab-api/src/semantic/primary.rs +++ b/crates/nlab-api/src/semantic/primary.rs @@ -276,7 +276,7 @@ fn has_modifier(source: &str, node: Node<'_>, expected: &str) -> bool { }) } -fn has_annotation(source: &str, node: Node<'_>, expected: &str) -> bool { +pub(super) fn has_annotation(source: &str, node: Node<'_>, expected: &str) -> bool { named_children(node) .into_iter() .filter(|node| node.kind() == "modifiers") @@ -300,7 +300,7 @@ fn has_annotation(source: &str, node: Node<'_>, expected: &str) -> bool { }) } -fn owned_nodes<'a>(declaration: Node<'a>, kind: &str) -> Vec> { +pub(super) fn owned_nodes<'a>(declaration: Node<'a>, kind: &str) -> Vec> { descendants(declaration) .into_iter() .filter(|node| node.kind() == kind) diff --git a/crates/nlab-api/src/semantic/request.rs b/crates/nlab-api/src/semantic/request.rs index 0fcfd32..642584b 100644 --- a/crates/nlab-api/src/semantic/request.rs +++ b/crates/nlab-api/src/semantic/request.rs @@ -91,7 +91,17 @@ impl SemanticAnalyzer<'_> { let mut patches = Vec::new(); for (schema_fqn, prefix) in response_schema_paths(request, schemas) { for field in &schemas[&schema_fqn].fields { - if !is_scalar(&field.java_type) { + let schema = &schemas[&schema_fqn]; + let declared_enum = self + .project + .resolve_type( + &schema.source_path, + &schema_fqn, + value_type(&field.java_type), + ) + .filter(|node| node.kind == "enum") + .cloned(); + if !is_scalar(value_type(&field.java_type)) && declared_enum.is_none() { continue; } let path = join_path(&prefix, &field.name); @@ -102,6 +112,13 @@ impl SemanticAnalyzer<'_> { field_path: path.clone(), field_name: field.name.clone(), }; + if let Some(enum_node) = declared_enum { + let domain = self.serialized_enum_domain(&enum_node)?; + self.request_domains + .insert((operation.key.clone(), path), domain.clone()); + patches.push(classify_patch(target, vec![domain])); + continue; + } let Some(candidates) = sites.get(&path) else { patches.push(unresolved_patch( target, diff --git a/crates/nlab-api/src/semantic/values.rs b/crates/nlab-api/src/semantic/values.rs new file mode 100644 index 0000000..8efdfe7 --- /dev/null +++ b/crates/nlab-api/src/semantic/values.rs @@ -0,0 +1,580 @@ +use super::*; + +impl SemanticAnalyzer<'_> { + pub(super) fn enum_projection( + &mut self, + method: &GraphNode, + expression: &Expression, + offset: usize, + ) -> Result> { + let projection = match expression { + Expression::Getter { receiver, accessor } => { + Some((receiver.as_str(), accessor.as_str(), false)) + } + Expression::Call { name, source } => source + .strip_suffix(&format!(".{name}()")) + .map(|receiver| (receiver, name.as_str(), false)), + Expression::Identifier(value) => value + .rsplit_once('.') + .map(|(receiver, member)| (receiver, member, true)), + _ => None, + }; + let Some((receiver, accessor, field)) = projection else { + return Ok(None); + }; + let Some(class) = self.enum_for_receiver(method, receiver, offset)? else { + return Ok(None); + }; + if (field && self.enum_member(&class, accessor)?) || (!field && accessor == "values") { + return self.serialized_enum_domain(&class).map(Some); + } + if field + && let Expression::Identifier(value) = expression + && let Some(literal) = self.constant_literal(method, value)? + { + return Ok(Some(Domain { + literals: BTreeSet::from([literal]), + ..Domain::default() + })); + } + self.enum_domain(&class, accessor).map(Some) + } + + /// Resolve the declared receiver type, including enum factories and chained calls. + pub(super) fn expression_class( + &mut self, + method: &GraphNode, + text: &str, + offset: usize, + ) -> Result> { + let text = text.trim(); + if text == "this" { + return Ok(self.lexical_owners(method).first().copied().cloned()); + } + if let Some(domain) = self + .value_bindings + .iter() + .rev() + .find_map(|bindings| bindings.get(&(method.id.clone(), text.to_owned()))) + && let Some(fqn) = &domain.enum_fqn + && !method_parameters(&method.signature) + .iter() + .any(|(kind, name)| { + name == text && parse_java_type(kind).is_some_and(|kind| is_scalar(&kind)) + }) + { + return Ok(self.project.node_for_fqn(fqn).cloned()); + } + if !text.contains(['(', ')']) { + let owner = method + .qualified_name + .rsplit_once("::") + .map(|(owner, _)| owner) + .unwrap_or(&method.qualified_name); + let direct = parse_java_type(text) + .and_then(|kind| self.project.resolve_type(&method.file_path, owner, &kind)) + .cloned(); + if direct.is_some() { + return Ok(direct); + } + if let Some((base, member)) = text.rsplit_once('.') + && let Some(owner) = self.expression_class(method, base, offset)? + { + if owner.kind == "enum" && self.enum_member(&owner, member)? { + return Ok(Some(owner)); + } + if let Some(field) = self + .project + .graph() + .contained(&owner.id, "field") + .into_iter() + .find(|node| node.name == member) + && let Some(kind) = declared_variable_type(&field.signature, &field.name) + .and_then(|kind| parse_java_type(&kind)) + { + return Ok(self + .project + .resolve_type(&field.file_path, &owner.qualified_name, &kind) + .cloned()); + } + return Ok(None); + } + let root = text.strip_prefix("this.").unwrap_or(text); + let kind = self + .receiver_type(method, root, offset)? + .and_then(|kind| parse_java_type(&kind)); + return Ok(kind + .and_then(|kind| self.project.resolve_type(&method.file_path, owner, &kind)) + .cloned()); + } + let parsed = self.parsed(&method.file_path)?.clone(); + let Some(node) = expression_node(&parsed, method, text, offset) else { + return Ok(None); + }; + if node.kind() == "object_creation_expression" { + return Ok(node + .child_by_field_name("type") + .and_then(|kind| parse_java_type(text_of(&parsed.source, kind))) + .and_then(|kind| { + self.project + .resolve_type(&method.file_path, &method.qualified_name, &kind) + }) + .cloned()); + } + if node.kind() != "method_invocation" { + return Ok(None); + } + let site = invocation(&parsed.source, node); + let targets = self.resolve_invocation(method, &site)?; + let Some(target) = targets + .first() + .filter(|_| targets.len() == 1) + .and_then(|id| self.project.graph().nodes.get(id)) + else { + return Ok(None); + }; + Ok(parse_java_type(&target.return_type) + .and_then(|kind| { + self.project + .resolve_type(&target.file_path, &target.qualified_name, &kind) + }) + .cloned()) + } + + pub(super) fn enum_member(&mut self, class: &GraphNode, member: &str) -> Result { + let parsed = self.parsed(&class.file_path)?.clone(); + Ok(type_declaration(&parsed, class) + .and_then(|node| node.child_by_field_name("body")) + .is_some_and(|body| { + named_children(body) + .into_iter() + .filter(|node| node.kind() == "enum_constant") + .any(|node| { + node.child_by_field_name("name") + .is_some_and(|name| text_of(&parsed.source, name) == member) + }) + })) + } + + pub(super) fn serialized_enum_domain(&mut self, class: &GraphNode) -> Result { + let parsed = self.parsed(&class.file_path)?.clone(); + let Some(declaration) = type_declaration(&parsed, class) else { + return Ok(incomplete_enum_domain( + class, + "", + "enum declaration missing", + )); + }; + if primary::has_annotation(&parsed.source, declaration, "JsonSerialize") + || primary::has_annotation(&parsed.source, declaration, "JsonFormat") + || descendants(declaration).into_iter().any(|node| { + ["JsonProperty", "JsonAlias", "JsonCreator"] + .iter() + .any(|annotation| primary::has_annotation(&parsed.source, node, annotation)) + }) + { + return Ok(incomplete_enum_domain( + class, + "", + "custom enum serialization is not statically proven", + )); + } + let annotated = ["field_declaration", "method_declaration"] + .into_iter() + .flat_map(|kind| primary::owned_nodes(declaration, kind)) + .filter(|node| primary::has_annotation(&parsed.source, *node, "JsonValue")) + .collect::>(); + if annotated.is_empty() { + return self.enum_domain(class, "name"); + } + let fields = primary::instance_fields(&parsed.source, declaration); + if let Some(field) = primary::field_name(&parsed.source, declaration, &fields) { + return self.enum_domain(class, &field); + } + Ok(incomplete_enum_domain( + class, + "", + "JsonValue projection is not statically proven", + )) + } + + pub(super) fn computed_domain( + &mut self, + operation: &Operation, + method: &GraphNode, + text: &str, + offset: usize, + reachable: &Reachability, + visiting: &mut BTreeSet<(String, usize)>, + ) -> Result> { + let parsed = self.parsed(&method.file_path)?.clone(); + let Some(node) = expression_node(&parsed, method, text, offset) else { + return Ok(None); + }; + if node.kind() != "method_invocation" { + return Ok(None); + } + let site = invocation(&parsed.source, node); + let args = node + .child_by_field_name("arguments") + .map(named_children) + .unwrap_or_default(); + if self.standard_pipeline(method, node, &parsed.source)? { + match site.name.as_str() { + "of" | "ofNullable" | "asList" | "singletonList" | "singleton" => { + let mut result = Domain::default(); + for arg in args { + merge_domain( + &mut result, + self.analyze_expression( + operation, + method, + expression_from_node(&parsed.source, arg), + arg.start_byte(), + reachable, + visiting, + )?, + ); + } + return Ok(Some(result)); + } + "map" if args.len() == 1 => { + let object = node + .child_by_field_name("object") + .expect("pipeline receiver"); + let input = self.analyze_expression( + operation, + method, + expression_from_node(&parsed.source, object), + object.start_byte(), + reachable, + visiting, + )?; + return self + .callback_domain( + operation, + method, + args[0], + &parsed.source, + input, + reachable, + visiting, + ) + .map(Some); + } + "orElse" | "orElseGet" => { + let object = node + .child_by_field_name("object") + .expect("pipeline receiver"); + let mut result = self.analyze_expression( + operation, + method, + expression_from_node(&parsed.source, object), + object.start_byte(), + reachable, + visiting, + )?; + if let Some(arg) = args.first() { + let fallback = if site.name == "orElseGet" { + self.callback_domain( + operation, + method, + *arg, + &parsed.source, + Domain::default(), + reachable, + visiting, + )? + } else { + self.analyze_expression( + operation, + method, + expression_from_node(&parsed.source, *arg), + arg.start_byte(), + reachable, + visiting, + )? + }; + merge_domain(&mut result, fallback); + } + return Ok(Some(result)); + } + "collect" | "toList" | "stream" | "filter" | "distinct" | "findFirst" | "get" + | "orElseThrow" => { + let object = node + .child_by_field_name("object") + .expect("pipeline receiver"); + let input = if site.name == "stream" && args.len() == 1 { + args[0] + } else { + object + }; + return self + .analyze_expression( + operation, + method, + expression_from_node(&parsed.source, input), + input.start_byte(), + reachable, + visiting, + ) + .map(Some); + } + _ => {} + } + } + let targets = self.resolve_invocation(method, &site)?; + let Some(target) = targets + .first() + .filter(|_| targets.len() == 1) + .and_then(|id| self.project.graph().nodes.get(id)) + .cloned() + else { + return Ok(None); + }; + let key = (format!("return:{}", target.id), 0); + if visiting.len() >= 32 || !visiting.insert(key.clone()) { + return Ok(Some(unknown(format!( + "return cycle:{}", + target.qualified_name + )))); + } + let result = (|| { + let mut bindings = HashMap::new(); + for ((_, parameter), argument) in method_parameters(&target.signature).iter().zip(&args) + { + bindings.insert( + (target.id.clone(), parameter.clone()), + self.analyze_expression( + operation, + method, + expression_from_node(&parsed.source, *argument), + argument.start_byte(), + reachable, + visiting, + )?, + ); + } + let target_source = self.parsed(&target.file_path)?.clone(); + let Some(declaration) = lookup::method_declaration(&target_source, &target) else { + return Ok(None); + }; + let returns = descendants(declaration) + .into_iter() + .filter(|node| node.kind() == "return_statement") + .filter(|node| { + lookup::ancestors(*node).find(|ancestor| { + matches!( + ancestor.kind(), + "method_declaration" | "lambda_expression" | "constructor_declaration" + ) + }) == Some(declaration) + }) + .filter_map(|node| named_children(node).into_iter().next()) + .collect::>(); + if returns.is_empty() { + return Ok(None); + } + self.value_bindings.push(bindings); + let result = (|| { + let mut result = Domain::default(); + for value in returns { + merge_domain( + &mut result, + self.analyze_expression( + operation, + &target, + expression_from_node(&target_source.source, value), + value.start_byte(), + reachable, + visiting, + )?, + ); + } + result + .evidence + .push(format!("return:{}:{}", target.file_path, target.start_line)); + Ok(Some(result)) + })(); + self.value_bindings.pop(); + result + })(); + visiting.remove(&key); + result + } + + #[allow(clippy::too_many_arguments)] + pub(super) fn callback_domain( + &mut self, + operation: &Operation, + method: &GraphNode, + callback: Node<'_>, + source: &str, + input: Domain, + reachable: &Reachability, + visiting: &mut BTreeSet<(String, usize)>, + ) -> Result { + if callback.kind() == "method_reference" { + let parts = named_children(callback); + if let [receiver, accessor] = parts.as_slice() + && let Some(class) = self.expression_class( + method, + text_of(source, *receiver), + callback.start_byte(), + )? + && class.kind == "enum" + { + return self.enum_domain(&class, text_of(source, *accessor)); + } + return Ok(unknown(format!( + "unresolved callback:{}", + text_of(source, callback) + ))); + } + if callback.kind() != "lambda_expression" { + return Ok(unknown(text_of(source, callback))); + } + let Some(body) = callback.child_by_field_name("body") else { + return Ok(unknown("missing lambda body")); + }; + let parameter = callback + .child_by_field_name("parameters") + .map(|node| { + text_of(source, node) + .trim_matches(['(', ')']) + .trim() + .to_owned() + }) + .unwrap_or_default(); + let value = if body.kind() == "block" { + let statements = lookup::statements(body); + if statements.len() != 1 || statements[0].kind() != "return_statement" { + return Ok(unknown("callback control flow is not proven")); + } + named_children(statements[0]) + .into_iter() + .next() + .unwrap_or(body) + } else { + body + }; + self.value_bindings + .push(HashMap::from([((method.id.clone(), parameter), input)])); + let result = self.analyze_expression( + operation, + method, + expression_from_node(source, value), + value.start_byte(), + reachable, + visiting, + ); + self.value_bindings.pop(); + result + } + + /// Only apply library propagation to a proven java.util pipeline, never a same-name business API. + pub(super) fn standard_pipeline( + &mut self, + method: &GraphNode, + mut node: Node<'_>, + source: &str, + ) -> Result { + while node.kind() == "method_invocation" { + let Some(object) = node.child_by_field_name("object") else { + return Ok(false); + }; + if object.kind() == "method_invocation" { + node = object; + continue; + } + let name = text_of(source, object); + let kind = self + .receiver_type(method, name, node.start_byte())? + .unwrap_or_else(|| name.to_owned()); + let Some(kind) = parse_java_type(&kind) else { + return Ok(false); + }; + let imported = self.project.imported_type(&method.file_path, &kind.name); + return Ok(imported.is_some_and(|fqn| { + matches!( + fqn.as_str(), + "java.util.Optional" + | "java.util.Arrays" + | "java.util.List" + | "java.util.Set" + | "java.util.Collection" + | "java.util.Collections" + | "java.util.stream.Stream" + ) + }) && self + .project + .resolve_type(&method.file_path, &method.qualified_name, &kind) + .is_none()); + } + Ok(false) + } +} + +pub(super) fn invocation(source: &str, node: Node<'_>) -> InvocationSite { + let arguments = node + .child_by_field_name("arguments") + .map(named_children) + .unwrap_or_default(); + InvocationSite { + name: node + .child_by_field_name("name") + .map(|name| text_of(source, name).to_owned()) + .unwrap_or_default(), + receiver: node + .child_by_field_name("object") + .map(|object| text_of(source, object).to_owned()), + arity: arguments.len(), + exact_arity: true, + arguments: arguments + .into_iter() + .map(|arg| expression_from_node(source, arg)) + .collect(), + offset: node.start_byte(), + line: node.start_position().row + 1, + column: source[..node.start_byte()] + .rsplit('\n') + .next() + .unwrap_or_default() + .chars() + .count(), + } +} + +pub(super) fn expression_node<'a>( + parsed: &'a ParsedFile, + method: &GraphNode, + text: &str, + offset: usize, +) -> Option> { + let declaration = lookup::method_declaration(parsed, method)?; + descendants(declaration) + .into_iter() + .filter(|node| text_of(&parsed.source, *node).trim() == text) + .min_by_key(|node| node.start_byte().abs_diff(offset)) +} + +pub(super) fn type_declaration<'a>(parsed: &'a ParsedFile, class: &GraphNode) -> Option> { + descendants(parsed.tree.root_node()) + .into_iter() + .find(|node| { + matches!( + node.kind(), + "class_declaration" | "interface_declaration" | "enum_declaration" + ) && node.start_position().row + 1 == class.start_line + && node + .child_by_field_name("name") + .is_some_and(|name| text_of(&parsed.source, name) == class.name) + }) +} + +pub(super) fn unknown(reason: impl Into) -> Domain { + Domain { + unknown: BTreeSet::from([reason.into()]), + ..Domain::default() + } +} diff --git a/crates/nlab-api/src/semantic/writes.rs b/crates/nlab-api/src/semantic/writes.rs new file mode 100644 index 0000000..0d8b1cb --- /dev/null +++ b/crates/nlab-api/src/semantic/writes.rs @@ -0,0 +1,1044 @@ +use super::values::{invocation, type_declaration, unknown}; +use super::*; + +impl SemanticAnalyzer<'_> { + pub(super) fn field_write_domains( + &mut self, + operation: &Operation, + class: &GraphNode, + field: &str, + reachable: &Reachability, + ) -> Result> { + let class_source = self.parsed(&class.file_path)?.clone(); + let Some(class_declaration) = type_declaration(&class_source, class) else { + return Ok(Vec::new()); + }; + let instance_fields = primary::instance_fields(&class_source.source, class_declaration); + let setter = format!("set{}", uppercase_first(field)); + let rewritten_setter = primary::owned_nodes(class_declaration, "method_declaration").into_iter() + .filter(|node| node.child_by_field_name("name").is_some_and(|name| text_of(&class_source.source, name) == setter)) + .any(|declaration| { + let parameter = declaration.child_by_field_name("parameters").and_then(|params| params.named_child(0)) + .and_then(|param| param.child_by_field_name("name")).map(|name| text_of(&class_source.source, name)); + descendants(declaration).into_iter().filter(|node| node.kind() == "assignment_expression" + && node.child_by_field_name("left").is_some_and(|node| matches!(text_of(&class_source.source, node), name if name == field || name == format!("this.{field}")))) + .any(|node| node.child_by_field_name("right").is_none_or(|node| Some(text_of(&class_source.source, node)) != parameter)) + }); + let builder = primary::has_annotation(&class_source.source, class_declaration, "Builder"); + let methods = reachable + .nodes + .iter() + .filter_map(|id| self.project.graph().nodes.get(id)) + .cloned() + .collect::>(); + let mut domains = Vec::new(); + for method in methods { + // The call site already supplies this parameter, including method-reference callbacks. + if method.name == setter + && self + .project + .graph() + .contained(&class.id, "method") + .iter() + .any(|candidate| candidate.id == method.id) + { + continue; + } + let parsed = self.parsed(&method.file_path)?.clone(); + let Some(declaration) = lookup::method_declaration(&parsed, &method) else { + continue; + }; + let mut proven_sites = BTreeSet::new(); + for node in descendants(declaration) { + let mut expression = None; + let mut receiver = None; + match node.kind() { + "method_invocation" => { + let site = invocation(&parsed.source, node); + if site.receiver.as_deref().is_some_and(|receiver| { + unused_receiver(&parsed.source, declaration, class, receiver, &setter) + }) { + continue; + } + if let Some(domain) = self.framework_copy_domain( + operation, &method, class, field, &site, reachable, + )? { + domains.push(domain); + } + if site.name == setter && site.arguments.len() == 1 { + let text = site.receiver.as_deref().unwrap_or("this"); + if self + .expression_class(&method, text, node.start_byte())? + .is_some_and(|owner| owner.id == class.id) + { + if rewritten_setter { + domains + .push(unknown(format!("setter transforms field:{setter}"))); + continue; + } + expression = node + .child_by_field_name("arguments") + .and_then(|args| args.named_child(0)); + receiver = Some(text.to_owned()); + } else if self + .expression_class(&method, text, node.start_byte())? + .is_none() + && self.project.graph().outgoing(&method.id).any(|edge| { + edge.kind == "calls" + && edge.line == site.line + && self.project.graph().nodes.get(&edge.target).is_some_and( + |target| { + target.name == setter + && self + .project + .graph() + .contained(&class.id, "method") + .iter() + .any(|method| method.id == target.id) + }, + ) + }) + { + domains.push(unknown(format!( + "unproven setter receiver:{text}.{setter}" + ))); + } + } else if site.name == "build" && site.arguments.is_empty() { + let mut chain = node.child_by_field_name("object"); + let mut value = None; + while let Some(call) = + chain.filter(|call| call.kind() == "method_invocation") + { + let call_site = invocation(&parsed.source, call); + if call_site.name == field + && call_site.arguments.len() == 1 + && value.is_none() + { + value = call + .child_by_field_name("arguments") + .and_then(|args| args.named_child(0)); + } + if call_site.name == "builder" + && call_site.arguments.is_empty() + && let Some(owner) = call_site.receiver.as_deref() + && self + .expression_class(&method, owner, call.start_byte())? + .is_some_and(|owner| owner.id == class.id) + { + expression = if builder { + value + } else { + self.explicit_builder_value( + &method, + class, + field, + node, + &parsed.source, + )? + }; + break; + } + chain = call.child_by_field_name("object"); + } + } + } + "object_creation_expression" => { + let Some(kind) = node + .child_by_field_name("type") + .and_then(|kind| parse_java_type(text_of(&parsed.source, kind))) + else { + continue; + }; + if self + .project + .resolve_type(&method.file_path, &method.qualified_name, &kind) + .is_none_or(|owner| owner.id != class.id) + { + continue; + } + if node + .parent() + .filter(|node| node.kind() == "variable_declarator") + .and_then(|node| node.child_by_field_name("name")) + .is_some_and(|name| { + unused_receiver( + &parsed.source, + declaration, + class, + text_of(&parsed.source, name), + &setter, + ) + }) + { + continue; + } + let args = node + .child_by_field_name("arguments") + .map(named_children) + .unwrap_or_default(); + if args.is_empty() + && !has_definite_write( + &parsed.source, + declaration, + node, + field, + &setter, + ) + { + let field_node = self + .project + .graph() + .contained(&class.id, "field") + .into_iter() + .find(|node| node.name == field); + let primitive = field_node + .and_then(|node| { + declared_variable_type(&node.signature, &node.name) + }) + .is_some_and(|kind| { + matches!( + kind.as_str(), + "int" + | "short" + | "long" + | "byte" + | "float" + | "double" + | "char" + ) + }); + domains.push(Domain { + literals: BTreeSet::from([ + if primitive { "0" } else { "null" }.into() + ]), + ..Domain::default() + }); + for constructor in self + .project + .graph() + .contained(&class.id, "method") + .into_iter() + .filter(|method| { + method.name == class.name + && method_parameters(&method.signature).is_empty() + }) + { + if let Some(declaration) = + lookup::method_declaration(&class_source, constructor) + { + for assignment in + descendants(declaration).into_iter().filter(|node| { + node.kind() == "assignment_expression" + && node.child_by_field_name("left").is_some_and( + |left| { + text_of(&class_source.source, left) + == format!("this.{field}") + }, + ) + }) + { + if let Some(value) = assignment.child_by_field_name("right") + { + domains.push(self.analyze_expression( + operation, + constructor, + expression_from_node(&class_source.source, value), + value.start_byte(), + reachable, + &mut BTreeSet::new(), + )?); + } + } + } + } + } + if let Some(index) = primary::argument_index( + &class_source.source, + class_declaration, + field, + &instance_fields, + args.len(), + ) { + expression = args.get(index).copied(); + } else if !args.is_empty() + && primary::owned_nodes(class_declaration, "constructor_declaration") + .iter() + .any(|constructor| { + text_of(&class_source.source, *constructor) + .contains(&format!("this.{field}")) + }) + { + domains.push(unknown(format!( + "constructor field binding is not proven:{}.{}", + class.name, field + ))); + } + } + "assignment_expression" => { + let Some(left) = node.child_by_field_name("left") else { + continue; + }; + let Some((object, member)) = text_of(&parsed.source, left).rsplit_once('.') + else { + continue; + }; + if member == field + && self + .expression_class(&method, object, node.start_byte())? + .is_some_and(|owner| owner.id == class.id) + { + receiver = Some(object.to_owned()); + expression = node.child_by_field_name("right"); + if node + .child_by_field_name("operator") + .is_none_or(|operator| text_of(&parsed.source, operator) != "=") + { + domains.push(unknown(format!( + "compound field write:{}", + text_of(&parsed.source, node) + ))); + continue; + } + } + } + "method_reference" => { + let parts = named_children(node); + if let [object, name] = parts.as_slice() + && text_of(&parsed.source, *name) == setter + && self + .expression_class( + &method, + text_of(&parsed.source, *object), + node.start_byte(), + )? + .is_some_and(|owner| owner.id == class.id) + { + let call = node.parent().and_then(|args| args.parent()); + if let Some(call) = + call.filter(|call| call.kind() == "method_invocation") + && invocation(&parsed.source, call).name == "ifPresent" + && self.standard_pipeline(&method, call, &parsed.source)? + { + expression = call.child_by_field_name("object"); + receiver = Some(text_of(&parsed.source, *object).to_owned()); + } else { + domains.push(unknown(format!( + "unbound setter callback:{}", + text_of(&parsed.source, node) + ))); + } + } + } + _ => {} + } + let Some(expression) = expression else { + continue; + }; + proven_sites.insert(node.start_position().row + 1); + if receiver.as_deref().is_some_and(|receiver| { + unused_receiver(&parsed.source, declaration, class, receiver, &setter) + }) { + continue; + } + if let Some(receiver) = receiver + && let Some(returned) = returned_local(&parsed.source, declaration, class) + && receiver != returned + { + let expected = self.copied_field_origins( + operation, + &method, + &returned, + declaration.end_byte(), + reachable, + )?; + let actual = self.copied_field_origins( + operation, + &method, + &receiver, + node.start_byte(), + reachable, + )?; + if !expected.is_empty() && !actual.is_empty() && expected.is_disjoint(&actual) { + continue; + } + } + let mut domain = self.analyze_expression( + operation, + &method, + expression_from_node(&parsed.source, expression), + expression.start_byte(), + reachable, + &mut BTreeSet::new(), + )?; + push_unique( + &mut domain.evidence, + format!( + "write:{}:{}:{}", + method.file_path, + node.start_position().row + 1, + text_of(&parsed.source, expression) + ), + ); + push_unique( + &mut domain.evidence, + format!( + "chain:{}", + render_path(self.project.graph(), reachable, &method.id) + ), + ); + domains.push(domain); + } + for unresolved in self + .project + .graph() + .unresolved(&method.id) + .iter() + .filter(|site| site.name == setter && !proven_sites.contains(&site.line)) + { + domains.push(unknown(format!( + "unresolved setter call:{}:{}:{}", + unresolved.file_path, unresolved.line, unresolved.column + ))); + } + } + domains.extend(self.field_initializer_domains( + operation, + class, + field, + reachable, + &mut BTreeSet::new(), + )?); + Ok(domains) + } + + pub(super) fn field_initializer_domains( + &mut self, + operation: &Operation, + class: &GraphNode, + field: &str, + reachable: &Reachability, + visiting: &mut BTreeSet<(String, usize)>, + ) -> Result> { + let class_source = self.parsed(&class.file_path)?.clone(); + let Some(class_declaration) = type_declaration(&class_source, class) else { + return Ok(Vec::new()); + }; + let mut domains = Vec::new(); + // Initializers remain possible on branches that never execute an observed write. + for declaration in primary::owned_nodes(class_declaration, "field_declaration") { + for variable in named_children(declaration) + .into_iter() + .filter(|node| node.kind() == "variable_declarator") + { + if variable + .child_by_field_name("name") + .is_none_or(|name| text_of(&class_source.source, name) != field) + { + continue; + } + if let Some(value) = variable.child_by_field_name("value") { + domains.push(self.analyze_expression( + operation, + class, + expression_from_node(&class_source.source, value), + value.start_byte(), + reachable, + visiting, + )?); + } + } + } + Ok(domains) + } + + pub(super) fn typed_setter_edges( + &mut self, + class: &GraphNode, + field: &str, + methods: &BTreeSet, + ) -> Result> { + let name = format!("set{}", uppercase_first(field)); + let mut edges = Vec::new(); + for id in methods { + let Some(method) = self.project.graph().nodes.get(id).cloned() else { + continue; + }; + for site in self.method_invocations(&method)? { + if site.name != name || !site.exact_arity || site.arguments.len() != 1 { + continue; + } + let receiver = site.receiver.as_deref().unwrap_or("this"); + if self + .expression_class(&method, receiver, site.offset)? + .is_some_and(|owner| owner.id == class.id) + { + edges.push(GraphEdge { + source: id.clone(), + target: class.id.clone(), + kind: "calls".into(), + line: site.line, + column: site.column, + metadata: String::new(), + provenance: String::new(), + }); + } + } + } + Ok(edges) + } + + #[allow(clippy::too_many_arguments)] + fn framework_copy_domain( + &mut self, + operation: &Operation, + method: &GraphNode, + class: &GraphNode, + field: &str, + site: &InvocationSite, + reachable: &Reachability, + ) -> Result> { + let mut source_field = field.to_owned(); + let source = if site.name == "copyProperties" && site.arguments.len() >= 2 { + let Some(receiver) = site.receiver.as_deref() else { + return Ok(None); + }; + let imported = self.project.imported_type(&method.file_path, receiver); + let pair = match imported.as_deref() { + Some("org.springframework.beans.BeanUtils") => (0, 1), + Some("org.apache.commons.beanutils.BeanUtils") => (1, 0), + _ => return Ok(None), + }; + if self + .expression_class(method, receiver, site.offset)? + .is_some() + { + return Ok(None); + } + let Expression::Identifier(target) = &site.arguments[pair.1] else { + return Ok(None); + }; + if self + .expression_class(method, target, site.offset)? + .is_none_or(|owner| owner.id != class.id) + { + return Ok(None); + } + for ignore in site.arguments.iter().skip(2) { + match ignore { + Expression::Literal(name) + if serde_json::from_str::(name).is_ok_and(|name| name == field) => + { + return Ok(None); + } + Expression::Literal(name) if serde_json::from_str::(name).is_ok() => {} + _ => { + return Ok(Some(unknown( + "BeanUtils ignore list or editable class is not statically proven", + ))); + } + } + } + site.arguments[pair.0].clone() + } else { + let targets = self.resolve_invocation(method, site)?; + let Some(target) = targets + .first() + .filter(|_| targets.len() == 1) + .and_then(|id| self.project.graph().nodes.get(id)) + .cloned() + else { + return Ok(None); + }; + let Some(owner) = self.lexical_owners(&target).first().copied().cloned() else { + return Ok(None); + }; + let parsed = self.parsed(&owner.file_path)?.clone(); + let Some(owner_declaration) = type_declaration(&parsed, &owner) else { + return Ok(None); + }; + if !primary::has_annotation(&parsed.source, owner_declaration, "Mapper") + || self + .project + .imported_type(&owner.file_path, "Mapper") + .as_deref() + != Some("org.mapstruct.Mapper") + || parse_java_type(&target.return_type) + .and_then(|kind| { + self.project + .resolve_type(&target.file_path, &owner.qualified_name, &kind) + }) + .is_none_or(|result| result.id != class.id) + { + return Ok(None); + } + if self.implementation_method(&target).is_some() { + return Ok(None); + } + if site.arguments.len() != 1 { + return Ok(Some(unknown( + "MapStruct multiple source mapping is not proven", + ))); + } + let Some(declaration) = lookup::method_declaration(&parsed, &target) else { + return Ok(None); + }; + if declaration.child_by_field_name("body").is_some() { + return Ok(None); + } + if primary::has_annotation(&parsed.source, declaration, "BeanMapping") + || descendants(owner_declaration).into_iter().any(|node| { + ["AfterMapping", "BeforeMapping", "ObjectFactory"] + .iter() + .any(|annotation| primary::has_annotation(&parsed.source, node, annotation)) + }) + || named_children(owner_declaration) + .into_iter() + .filter(|node| node.kind() == "modifiers") + .flat_map(descendants) + .any(|node| { + node.kind() == "element_value_pair" + && node.child_by_field_name("key").is_some_and(|key| { + matches!(text_of(&parsed.source, key), "uses" | "config") + }) + }) + { + return Ok(Some(unknown( + "MapStruct lifecycle or mapper configuration is not statically proven", + ))); + } + for annotation in descendants(declaration) + .into_iter() + .filter(|node| node.kind() == "annotation") + { + let name = annotation + .child_by_field_name("name") + .map(|node| text_of(&parsed.source, node)) + .unwrap_or_default(); + if name.rsplit('.').next() != Some("Mapping") { + continue; + } + let mut attributes = HashMap::new(); + for pair in descendants(annotation) + .into_iter() + .filter(|node| node.kind() == "element_value_pair") + { + if let (Some(key), Some(value)) = ( + pair.child_by_field_name("key"), + pair.child_by_field_name("value"), + ) { + attributes + .insert(text_of(&parsed.source, key), text_of(&parsed.source, value)); + } + } + if attributes + .get("target") + .and_then(|value| serde_json::from_str::(value).ok()) + .as_deref() + != Some(field) + { + continue; + } + if attributes.get("ignore") == Some(&"true") { + return Ok(None); + } + if attributes + .keys() + .any(|key| !matches!(*key, "target" | "source" | "ignore")) + { + return Ok(Some(unknown( + "MapStruct custom conversion is not statically proven", + ))); + } + if let Some(name) = attributes + .get("source") + .and_then(|value| serde_json::from_str::(value).ok()) + { + source_field = name; + } + } + if source_field.contains('.') { + return Ok(Some(unknown( + "MapStruct nested source mapping is not proven", + ))); + } + site.arguments[0].clone() + }; + let Expression::Identifier(receiver) = source else { + return Ok(Some(unknown("copy source object identity is not proven"))); + }; + let source_class = self.expression_class(method, &receiver, site.offset)?; + let field_type = |owner: &GraphNode, name: &str| { + self.project + .graph() + .contained(&owner.id, "field") + .into_iter() + .find(|field| field.name == name) + .and_then(|field| declared_variable_type(&field.signature, &field.name)) + .and_then(|kind| parse_java_type(&kind)) + }; + if let Some(source_type) = source_class + .as_ref() + .and_then(|owner| field_type(owner, &source_field)) + && let Some(target_type) = field_type(class, field) + && !same_copy_type(&source_type, &target_type) + { + return Ok(Some(unknown( + "copy field type conversion is not statically proven", + ))); + } + let domain = self.copied_field_domain( + operation, + method, + &receiver, + &format!("get{}", uppercase_first(&source_field)), + site.offset, + reachable, + &mut BTreeSet::new(), + )?; + let mut domain = domain.unwrap_or_else(|| { + unknown(format!( + "copy source field is unresolved:{receiver}.{source_field}" + )) + }); + domain.evidence.push(format!( + "copy-framework:{}:{}:{}:{source_field}", + method.file_path, site.line, site.name + )); + Ok(Some(domain)) + } + + fn explicit_builder_value<'n>( + &mut self, + method: &GraphNode, + class: &GraphNode, + field: &str, + build: Node<'n>, + source: &str, + ) -> Result>> { + let mut chain = Vec::new(); + let mut node = build.child_by_field_name("object"); + while let Some(call) = node.filter(|call| call.kind() == "method_invocation") { + chain.push(call); + node = call.child_by_field_name("object"); + } + let Some(factory) = chain.last().copied() else { + return Ok(None); + }; + let targets = self.resolve_invocation(method, &invocation(source, factory))?; + let Some(factory_method) = targets + .first() + .and_then(|id| self.project.graph().nodes.get(id)) + .cloned() + else { + return Ok(None); + }; + let factory_source = self.parsed(&factory_method.file_path)?.clone(); + let Some(factory_declaration) = + lookup::method_declaration(&factory_source, &factory_method) + else { + return Ok(None); + }; + let Some(creation) = single_return(factory_declaration) else { + return Ok(None); + }; + if creation.kind() != "object_creation_expression" { + return Ok(None); + } + let Some(builder_class) = creation + .child_by_field_name("type") + .and_then(|kind| parse_java_type(text_of(&factory_source.source, kind))) + .and_then(|kind| { + self.project.resolve_type( + &factory_method.file_path, + &factory_method.qualified_name, + &kind, + ) + }) + .cloned() + else { + return Ok(None); + }; + let builds = self + .project + .graph() + .contained(&builder_class.id, "method") + .into_iter() + .filter(|method| { + method.name == "build" && method_parameters(&method.signature).is_empty() + }) + .cloned() + .collect::>(); + let [build_method] = builds.as_slice() else { + return Ok(None); + }; + let parsed = self.parsed(&build_method.file_path)?.clone(); + let Some(build_declaration) = lookup::method_declaration(&parsed, build_method) else { + return Ok(None); + }; + let Some(creation) = single_return(build_declaration) + .filter(|node| node.kind() == "object_creation_expression") + else { + return Ok(None); + }; + if creation + .child_by_field_name("type") + .and_then(|kind| parse_java_type(text_of(&parsed.source, kind))) + .and_then(|kind| { + self.project.resolve_type( + &build_method.file_path, + &build_method.qualified_name, + &kind, + ) + }) + .is_none_or(|owner| owner.id != class.id) + { + return Ok(None); + } + let args = creation + .child_by_field_name("arguments") + .map(named_children) + .unwrap_or_default(); + let class_source = self.parsed(&class.file_path)?.clone(); + let Some(declaration) = type_declaration(&class_source, class) else { + return Ok(None); + }; + let Some(index) = primary::argument_index( + &class_source.source, + declaration, + field, + &primary::instance_fields(&class_source.source, declaration), + args.len(), + ) else { + return Ok(None); + }; + let Some(stored) = args + .get(index) + .filter(|node| matches!(node.kind(), "identifier" | "field_access")) + else { + return Ok(None); + }; + let stored = text_of(&parsed.source, *stored).trim_start_matches("this."); + for call in chain { + let site = invocation(source, call); + if site.arguments.len() != 1 { + continue; + } + let setters = self + .project + .graph() + .contained(&builder_class.id, "method") + .into_iter() + .filter(|method| { + method.name == site.name && method_parameters(&method.signature).len() == 1 + }) + .cloned() + .collect::>(); + let [setter] = setters.as_slice() else { + return Ok(None); + }; + let setter_source = self.parsed(&setter.file_path)?.clone(); + let Some(setter_declaration) = lookup::method_declaration(&setter_source, setter) + else { + return Ok(None); + }; + let Some(body) = setter_declaration.child_by_field_name("body") else { + return Ok(None); + }; + let statements = lookup::statements(body); + if statements.len() != 2 + || statements[0].kind() != "expression_statement" + || statements[1].kind() != "return_statement" + || statements[1] + .named_child(0) + .is_none_or(|node| text_of(&setter_source.source, node) != "this") + { + return Ok(None); + } + let Some(assignment) = statements[0] + .named_child(0) + .filter(|node| node.kind() == "assignment_expression") + else { + return Ok(None); + }; + if assignment.child_by_field_name("left").is_some_and(|node| { + text_of(&setter_source.source, node) == format!("this.{stored}") + }) { + let parameter = &method_parameters(&setter.signature)[0].1; + if assignment + .child_by_field_name("right") + .is_some_and(|node| text_of(&setter_source.source, node) == parameter) + && assignment + .child_by_field_name("operator") + .is_some_and(|node| text_of(&setter_source.source, node) == "=") + { + return Ok(call + .child_by_field_name("arguments") + .and_then(|args| args.named_child(0))); + } + return Ok(None); + } + } + Ok(None) + } +} + +fn single_return(declaration: Node<'_>) -> Option> { + let statements = lookup::statements(declaration.child_by_field_name("body")?); + if statements.len() != 1 || statements[0].kind() != "return_statement" { + return None; + } + statements[0].named_child(0) +} + +fn same_copy_type(left: &TypeRef, right: &TypeRef) -> bool { + let primitive = |kind: &TypeRef| match kind.simple_name() { + "Integer" | "int" => "int", + "Long" | "long" => "long", + "Short" | "short" => "short", + "Byte" | "byte" => "byte", + "Double" | "double" => "double", + "Float" | "float" => "float", + "Character" | "char" => "char", + "Boolean" | "boolean" => "boolean", + _ => "", + }; + (left.simple_name() == right.simple_name() + || (!primitive(left).is_empty() && primitive(left) == primitive(right))) + && left.array_depth == right.array_depth + && left.arguments.len() == right.arguments.len() + && left + .arguments + .iter() + .zip(&right.arguments) + .all(|(left, right)| same_copy_type(left, right)) +} + +fn has_definite_write( + source: &str, + method: Node<'_>, + creation: Node<'_>, + field: &str, + setter: &str, +) -> bool { + let Some(variable) = creation + .parent() + .filter(|node| node.kind() == "variable_declarator") + else { + return false; + }; + let Some(name) = variable.child_by_field_name("name") else { + return false; + }; + let name = text_of(source, name); + descendants(method).into_iter().any(|node| { + if node.start_byte() <= creation.start_byte() { + return false; + } + if descendants(method).into_iter().any(|earlier| { + earlier.kind() == "return_statement" + && earlier.start_byte() > creation.start_byte() + && earlier.start_byte() < node.start_byte() + }) { + return false; + } + if lookup::ancestors(node) + .take_while(|node| *node != method) + .any(|node| { + matches!( + node.kind(), + "if_statement" + | "switch_expression" + | "for_statement" + | "enhanced_for_statement" + | "while_statement" + | "do_statement" + | "lambda_expression" + | "catch_clause" + ) + }) + { + return false; + } + (node.kind() == "method_invocation" + && node + .child_by_field_name("name") + .is_some_and(|node| text_of(source, node) == setter) + && node + .child_by_field_name("object") + .is_some_and(|node| text_of(source, node) == name)) + || (node.kind() == "assignment_expression" + && node + .child_by_field_name("left") + .is_some_and(|node| text_of(source, node) == format!("{name}.{field}"))) + }) +} + +fn returned_local(source: &str, method: Node<'_>, class: &GraphNode) -> Option { + if method + .child_by_field_name("type") + .is_none_or(|kind| text_of(source, kind) != class.name) + { + return None; + } + let returns = descendants(method) + .into_iter() + .filter(|node| node.kind() == "return_statement") + .map(|node| named_children(node).into_iter().next()) + .collect::>>()?; + if returns.is_empty() || returns.iter().any(|node| node.kind() != "identifier") { + return None; + } + let names = returns + .into_iter() + .map(|node| text_of(source, node)) + .collect::>(); + (names.len() == 1).then(|| names.into_iter().next().unwrap().to_owned()) +} + +fn unused_receiver( + source: &str, + method: Node<'_>, + class: &GraphNode, + receiver: &str, + setter: &str, +) -> bool { + if method + .child_by_field_name("type") + .and_then(|kind| parse_java_type(text_of(source, kind))) + .is_none_or(|kind| kind.simple_name() != class.name) + { + return false; + } + let identifiers = descendants(method) + .into_iter() + .filter(|node| node.kind() == "identifier" && text_of(source, *node) == receiver) + .collect::>(); + let local = identifiers.iter().any(|node| { + node.parent().is_some_and(|parent| { + parent.kind() == "variable_declarator" + && parent.child_by_field_name("name") == Some(*node) + && parent + .child_by_field_name("value") + .is_some_and(|value| value.kind() == "object_creation_expression") + }) + }); + local + && identifiers.iter().all(|node| { + node.parent().is_some_and(|parent| { + (parent.kind() == "variable_declarator" + && parent.child_by_field_name("name") == Some(*node)) + || (parent.kind() == "method_invocation" + && parent.child_by_field_name("object") == Some(*node) + && invocation(source, parent).name == setter + && parent + .parent() + .is_some_and(|statement| statement.kind() == "expression_statement")) + || (parent.kind() == "field_access" + && parent.child_by_field_name("object") == Some(*node) + && parent.parent().is_some_and(|assignment| { + assignment.kind() == "assignment_expression" + && assignment.child_by_field_name("left") == Some(parent) + })) + }) + }) +} diff --git a/crates/nlab-api/src/typescript.rs b/crates/nlab-api/src/typescript.rs index bf26446..6f88d9f 100644 --- a/crates/nlab-api/src/typescript.rs +++ b/crates/nlab-api/src/typescript.rs @@ -237,13 +237,21 @@ fn enum_seed(patch: &SemanticPatch) -> Vec { .enum_fqn .as_deref() .unwrap_or(&patch.target.schema_fqn); - enum_seed_from_parts( + let mut seed = enum_seed_from_parts( owner, patch .accessor .as_deref() .unwrap_or(&patch.target.field_name), - ) + ); + if patch.enum_fqn.is_some() + && patch + .associated_values() + .is_some_and(|values| values.iter().any(|value| value.key.is_none())) + { + seed.push(format!("{}Values", upper_camel(&patch.target.field_name))); + } + seed } fn enum_seed_from_parts(owner: &str, accessor: &str) -> Vec { @@ -423,7 +431,7 @@ fn render_interface( .entry(import_specifier(&target.path, &enum_target.path, config)) .or_default() .insert(enum_target.name.clone()); - enum_target.name.clone() + enum_type_expression(&field.java_type, &enum_target.name) } else { type_expression( &field.java_type, @@ -808,6 +816,32 @@ fn type_expression( result } +fn enum_type_expression(kind: &TypeRef, name: &str) -> String { + let base = if is_collection(kind.simple_name()) { + format!( + "{}[]", + kind.arguments + .first() + .map(|item| enum_type_expression(item, name)) + .unwrap_or_else(|| name.to_owned()) + ) + } else if matches!( + kind.simple_name(), + "Map" | "HashMap" | "LinkedHashMap" | "TreeMap" + ) { + format!( + "Record", + kind.arguments + .last() + .map(|item| enum_type_expression(item, name)) + .unwrap_or_else(|| name.to_owned()) + ) + } else { + name.to_owned() + }; + format!("{base}{}", "[]".repeat(kind.array_depth)) +} + fn render_enum(name: &str, values: &[CodedValue], identity: &str, erasable: bool) -> String { let (origin, reference) = match identity.strip_prefix("comment:") { Some(field) => ("字段注释/注解", field), diff --git a/crates/nlab-api/tests/fixtures/enum-provenance/ConstKind.java b/crates/nlab-api/tests/fixtures/enum-provenance/ConstKind.java new file mode 100644 index 0000000..9026354 --- /dev/null +++ b/crates/nlab-api/tests/fixtures/enum-provenance/ConstKind.java @@ -0,0 +1,11 @@ +package p; +public enum ConstKind { + A(Numbers.ONE), B(Numbers.TWO); + private final int code; + ConstKind(int code) { this.code = code; } + public int getCode() { return code; } +} +class Numbers { + static final int ONE = 1; + static final int TWO = 2; +} diff --git a/crates/nlab-api/tests/fixtures/enum-provenance/DefaultPayload.java b/crates/nlab-api/tests/fixtures/enum-provenance/DefaultPayload.java new file mode 100644 index 0000000..226fa68 --- /dev/null +++ b/crates/nlab-api/tests/fixtures/enum-provenance/DefaultPayload.java @@ -0,0 +1,6 @@ +package p; +public class DefaultPayload { + private Integer code = 99; + public Integer getCode() { return code; } + public void setCode(Integer code) { this.code = code; } +} diff --git a/crates/nlab-api/tests/fixtures/enum-provenance/Kind.java b/crates/nlab-api/tests/fixtures/enum-provenance/Kind.java new file mode 100644 index 0000000..26fa202 --- /dev/null +++ b/crates/nlab-api/tests/fixtures/enum-provenance/Kind.java @@ -0,0 +1,12 @@ +package p; +public enum Kind { + A(1), B(2); + public final int code; + Kind(int code) { this.code = code; } + public int getCode() { return code; } + public int val() { return code; } + public int code() { return code; } + private static final java.util.Map INDEX = java.util.Arrays.stream(values()).collect(java.util.stream.Collectors.toMap(Kind::getCode, x -> x)); + public static Kind fromMap(Integer input) { return INDEX.get(input); } + public static Kind fromLoop(Integer input) { for (Kind item : values()) { if (java.util.Objects.equals(item.getCode(), input)) { return item; } } return null; } +} diff --git a/crates/nlab-api/tests/fixtures/enum-provenance/Payload.java b/crates/nlab-api/tests/fixtures/enum-provenance/Payload.java new file mode 100644 index 0000000..e2e7ef9 --- /dev/null +++ b/crates/nlab-api/tests/fixtures/enum-provenance/Payload.java @@ -0,0 +1,19 @@ +package p; +public class Payload { + public Integer code; + private String token; + private java.util.List codes; + private Kind kind; + public Payload() {} + public Payload(Integer code) { this.code = code; } + public void setCode(Integer code) { this.code = code; } + public void setToken(String token) { this.token = token; } + public void setCodes(java.util.List codes) { this.codes = codes; } + public void setKind(Kind kind) { this.kind = kind; } + public static Builder builder() { return new Builder(); } + public static class Builder { + private Integer code; + public Builder code(Integer code) { this.code = code; return this; } + public Payload build() { return new Payload(code); } + } +} diff --git a/crates/nlab-api/tests/fixtures/enum-provenance/ProbeController.java b/crates/nlab-api/tests/fixtures/enum-provenance/ProbeController.java new file mode 100644 index 0000000..fc1336e --- /dev/null +++ b/crates/nlab-api/tests/fixtures/enum-provenance/ProbeController.java @@ -0,0 +1,123 @@ +package p; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; +@RestController +public class ProbeController { + @GetMapping("/getter") + public Payload getter() { + Payload v = new Payload(); + v.setCode(Kind.A.getCode()); + return v; + } + @GetMapping("/name") + public Payload name() { + Payload v = new Payload(); + v.setToken(Kind.A.name()); + return v; + } + @GetMapping("/val") + public Payload val() { + Payload v = new Payload(); + v.setCode(Kind.A.val()); + return v; + } + @GetMapping("/codeMethod") + public Payload codeMethod() { + Payload v = new Payload(); + v.setCode(Kind.A.code()); + return v; + } + @GetMapping("/directEnumField") + public Payload directEnumField() { + Payload v = new Payload(); + v.setCode(Kind.A.code); + return v; + } + @GetMapping("/helper") + public Payload helper() { + Payload v = new Payload(); + v.setCode(project(Kind.A)); + return v; + } + @GetMapping("/literalExtra") + public Payload literalExtra() { + Payload v = new Payload(); + if (System.currentTimeMillis() > 0) { v.setCode(Kind.A.getCode()); } else { v.setCode(99); } + return v; + } + @GetMapping("/optional") + public Payload optional() { + Payload v = new Payload(); + v.setCode(java.util.Optional.of(Kind.A).map(Kind::getCode).orElse(99)); + return v; + } + @GetMapping("/methodReference") + public Payload methodReference() { + Payload v = new Payload(); + java.util.Optional.of(Kind.A).map(Kind::getCode).ifPresent(v::setCode); + return v; + } + @GetMapping("/directWrite") + public Payload directWrite() { + Payload v = new Payload(); + v.code = Kind.A.getCode(); + return v; + } + @GetMapping("/constructor") + public Payload constructor() { + Payload v = new Payload(); + return new Payload(Kind.A.getCode()); + } + @GetMapping("/builder") + public Payload builder() { + Payload v = new Payload(); + return Payload.builder().code(Kind.A.getCode()).build(); + } + @GetMapping("/collection") + public Payload collection() { + Payload v = new Payload(); + v.setCodes(java.util.Arrays.stream(Kind.values()).map(Kind::getCode).collect(java.util.stream.Collectors.toList())); + return v; + } + @GetMapping("/enumField") + public Payload enumField() { + Payload v = new Payload(); + v.setKind(Kind.A); + return v; + } + @GetMapping("/loopLookup") + public Payload loopLookup() { + Payload v = new Payload(); + return loopLookupFrom(new Source()); + } + @GetMapping("/mapLookup") + public Payload mapLookup() { + Payload v = new Payload(); + return mapLookupFrom(new Source()); + } + private Integer project(Kind kind) { return kind.getCode(); } + private Payload loopLookupFrom(Source s) { + Kind from = Kind.fromLoop(s.getCode()); + Payload v = new Payload(); + v.setCode(s.getCode()); + return v; + } + private Payload mapLookupFrom(Source s) { + Kind from = Kind.fromMap(s.getCode()); + Payload v = new Payload(); + v.setCode(s.getCode()); + return v; + } + @GetMapping("/enumConstantArgument") + public Payload enumConstantArgument() { + Payload v = new Payload(); + v.setCode(ConstKind.A.getCode()); + return v; + } + @GetMapping("/fieldInitializer") + public DefaultPayload fieldInitializer(@org.springframework.web.bind.annotation.RequestParam boolean replace) { + DefaultPayload v = new DefaultPayload(); + if (replace) { v.setCode(Kind.A.getCode()); } + return v; + } +} diff --git a/crates/nlab-api/tests/fixtures/enum-provenance/README.md b/crates/nlab-api/tests/fixtures/enum-provenance/README.md new file mode 100644 index 0000000..f370707 --- /dev/null +++ b/crates/nlab-api/tests/fixtures/enum-provenance/README.md @@ -0,0 +1,28 @@ +# 枚举关联回归样例 + +这些 Java 文件保存 2026-09-29 调研中的 18 个隔离探针。它们是测试输入,不是业务仓库代码或待集成的前端生成物。断言位于 `src/semantic/coverage_tests.rs`。 + +普通测试用 Java AST 构造索引边界,随后调用正式契约分析、OpenAPI 和 TypeScript 生成流程: + +```sh +cargo test --locked -p nlab-api semantic::coverage_tests +``` + +安装 CodeGraph 后,同一组断言可改用临时 Git 仓库和全新真实索引。此模式不会读取或改写开发仓库的索引: + +```sh +NLAB_API_TEST_REAL_CODEGRAPH=1 cargo test --locked -p nlab-api semantic::coverage_tests +``` + +覆盖范围: + +- `name()`、直接字段、自定义无参取值方法、常量别名,以及 code/name 投影隔离。 +- 额外字符串、空字符串、null、初始化值、基本类型默认值和提前返回。额外成员只进入对应字段的枚举版本。 +- setter、直接赋值、显式及 Lombok 构造器、可证明的手写及 Lombok Builder。 +- 普通方法返回值、按调用点绑定参数、Optional、方法引用、数组和集合元素;生成的容器形状保持不变。 +- values 别名、Collectors.toMap、局部 Map 包装的反查。未验证的输入保留开放状态,状态转换不会被当成反查。 +- BeanUtils、MapStruct 同名及重命名映射;忽略字段、后续未知覆盖、转换表达式及生命周期回调不产生错误关联。 +- 声明为枚举的请求和响应字段,默认名称及可证明的 JsonValue 投影。 +- 同名嵌套类、不同实例及对象别名;缺失来源、循环常量、参数改写、动态字符串、辅助属性及自定义序列化的反例。 + +边界:这是有界静态分析,不执行 Java,不推测缺失依赖的成员。复杂 Builder、转换器、反射和运行时配置不能证明时保留原类型与诊断。全局序列化策略及业务依赖仍需源码或明确配置支持。已知枚举关联与完整闭合值域是不同证据,反查数据库值不会自动证明数据库中不存在其他值。 diff --git a/crates/nlab-api/tests/fixtures/enum-provenance/Source.java b/crates/nlab-api/tests/fixtures/enum-provenance/Source.java new file mode 100644 index 0000000..afc1ae8 --- /dev/null +++ b/crates/nlab-api/tests/fixtures/enum-provenance/Source.java @@ -0,0 +1,2 @@ +package p; +public class Source { private Integer code; public Integer getCode() { return code; } }