diff --git a/CHANGELOG.md b/CHANGELOG.md index 4807ead8..b98279d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## Unreleased +- Make `compass update` use the fact-neutral incremental path for metadata-only + source edits, refresh full-file source envelopes without moving exact symbol + anchors, point-update immutable node values, reuse unchanged graph JSON + records, and amortize bounded snapshot garbage collection. Exact preflights + fall back to full publication whenever topology or secondary indexes change. + +- Hard-cut Kotlin onto a version-1 universal candidate adapter with packages, + declarations, extension functions, annotations, generic/nullable types, and + named/default argument resolution; convert Spring Kotlin to the universal + framework pack and require exact compiler evidence for Java/Kotlin calls. + - Allow `compass review` on `0.3.x` to rebuild comparable realizations from any repository profile or preferred realization whose persisted user-option shape remains reconstructable, including when both compared revisions are diff --git a/PERFORMANCE.md b/PERFORMANCE.md index 2d980175..ee916f5c 100644 --- a/PERFORMANCE.md +++ b/PERFORMANCE.md @@ -24,6 +24,36 @@ Compare a proposed change with a previously approved Compass result captured on the same runner and corpus. A median regression above 10% requires explicit review and evidence explaining the tradeoff. +## Incremental code-graph qualification + +Fact-neutral updates may bypass project-wide resolution only after the changed +files reproduce the prior normalized extraction-fact digests. The publisher +then refreshes inventory and full-file envelopes, validates the complete graph, +proves that node IDs, relationships, file keys, names, search terms, and +communities are unchanged, and point-updates only graph metadata and changed +node values. Any failed proof uses the complete graph publisher. Immutable-store +garbage collection remains bounded but is amortized across eight manifests so +ordinary one-file edits do not perform a full mark-and-sweep. + +The 2026-08-15 release-build qualification used copied, read-only-derived +corpora from real repositories with SQLite storage, maximum inference, and +clustering/visualization disabled. Each observation appended or removed one +language comment and reported zero graph-assembly time: + +| Language / corpus | Indexed files | Nodes / edges | Observed incremental wall | Extracted / cached | +| --- | ---: | ---: | ---: | ---: | +| Kotlin / Spring Framework Kotlin corpus | 388 | 10,708 / 15,650 | 1.01 s | 1 / 387 | +| Rust / ripgrep | 142 | 12,185 / 32,040 | 2.50 s | 1 / 141 | +| Go / go-git | 709 | 21,578 / 67,522 | 3.31 s | 1 / 708 | +| TypeScript / NestJS | 2,017 | 75,298 / 116,515 | 10.33 s | 25 / 1,992 | +| Python / FastAPI production package | 56 | 1,707 / 6,622 | 1.17 s | 1 / 55 | +| C# / ASP.NET Core Http.Extensions source | 35 | 1,130 / 1,291 | 0.61 s | 1 / 34 | + +These are single observations, not medians. NestJS still exposes a separate +large-repository cost: 24 successful empty/unsupported inputs are not portable +AST cache entries and are rechecked with the edited file. The fact-neutral +proof nevertheless avoids resolution and complete index reconstruction. + Real-repository natural-query qualification materializes one SQLite-backed query artifact per repository. Fresh latency/RSS is one direct `compass query` process per observation; warm latency is measured inside one persistent MCP diff --git a/crates/compass-core/src/pipeline.rs b/crates/compass-core/src/pipeline.rs index b78ac564..a02e82fa 100644 --- a/crates/compass-core/src/pipeline.rs +++ b/crates/compass-core/src/pipeline.rs @@ -87,6 +87,11 @@ const PIPELINE_RAYON_WORKER_CAP: usize = 12; // Keep the bound explicit and portable; stack pages remain demand-paged. const PIPELINE_RAYON_STACK_SIZE_BYTES: usize = 8 * 1024 * 1024; const PARALLEL_AST_FACT_DIGEST_MIN_FILES: usize = 32; +// Full mark-and-sweep walks every immutable graph object and can dominate a +// one-file update. Keep a small, explicit number of unreachable manifests so +// ordinary edits pay only point-update publication; the next sweep still +// retains exactly the staging and active snapshots. +const SHARED_STORE_GC_MANIFEST_THRESHOLD: usize = 8; const STORE_SNAPSHOT_EXCLUSIONS: [&str; 3] = [STORE_FILE_NAME, "store.sqlite3-wal", "store.sqlite3-shm"]; const ROOT_ARTIFACTS: [&str; 7] = [ @@ -769,6 +774,27 @@ struct FactDigestFrameworkFacts<'a> { facts: &'a [RawFrameworkFact], } +fn normalize_framework_fact_digest_value(value: &mut Value) { + match value { + Value::Array(values) => values + .iter_mut() + .for_each(normalize_framework_fact_digest_value), + Value::Object(values) => { + // Universal evidence IDs include source ranges. Framework facts + // already retain stable graph owners, qualified names, bindings, + // and exact anchors, so these redundant internal IDs must not turn + // a file-envelope edit into a semantic graph change. + for key in ["ownerDeclarationId", "occurrenceId", "scopeId"] { + values.remove(key); + } + values + .values_mut() + .for_each(normalize_framework_fact_digest_value); + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} + impl Serialize for FactDigestFrameworkFacts<'_> { fn serialize(&self, serializer: S) -> Result where @@ -779,13 +805,16 @@ impl Serialize for FactDigestFrameworkFacts<'_> { .iter() .map(|fact| { serde_json::to_value(fact) - .map(|value| (value, fact)) + .map(|mut value| { + normalize_framework_fact_digest_value(&mut value); + value + }) .map_err(S::Error::custom) }) .collect::, _>>()?; - facts.sort_unstable_by(|left, right| compare_fact_values(&left.0, &right.0)); + facts.sort_unstable_by(compare_fact_values); let mut sequence = serializer.serialize_seq(Some(self.facts.len()))?; - for (value, _) in facts { + for value in facts { sequence.serialize_element(&FactDigestValue(&value))?; } sequence.end() @@ -929,16 +958,17 @@ impl Serialize for FactDigestDeclaration<'_, '_> { map.serialize_entry("directBasesComplete", &true)?; } map.serialize_entry("variadic", &declaration.variadic)?; - if let Some(value) = &declaration.signature_hash { - map.serialize_entry("signatureHash", value)?; - } - if let Some(value) = &declaration.implementation_hash { - map.serialize_entry("implementationHash", value)?; - } - if let Some(value) = &declaration.source_hash { - map.serialize_entry("sourceHash", value)?; - } - if !self.context.declaration_is_file(&declaration.id) { + let file_declaration = self.context.declaration_is_file(&declaration.id); + if !file_declaration { + if let Some(value) = &declaration.signature_hash { + map.serialize_entry("signatureHash", value)?; + } + if let Some(value) = &declaration.implementation_hash { + map.serialize_entry("implementationHash", value)?; + } + if let Some(value) = &declaration.source_hash { + map.serialize_entry("sourceHash", value)?; + } map.serialize_entry("range", &declaration.range)?; } map.end() @@ -1001,7 +1031,9 @@ impl Serialize for FactDigestScope<'_, '_> { .owner_declaration_id .as_deref() .is_some_and(|owner| self.context.declaration_is_file(owner)); - if !file_scope { + let file_envelope_scope = + file_scope || matches!(self.scope.kind.as_str(), "module" | "package"); + if !file_envelope_scope { map.serialize_entry("range", &self.scope.range)?; } map.end() @@ -1140,7 +1172,11 @@ impl Serialize for FactDigestCandidate<'_, '_> { .context .declaration_key(&candidate.source_declaration_id), )?; - map.serialize_entry("targetSpelling", &candidate.target_spelling)?; + if let Some(target) = &candidate.constraints.exact_target_declaration_id { + map.serialize_entry("targetSpelling", &self.context.declaration_key(target))?; + } else { + map.serialize_entry("targetSpelling", &candidate.target_spelling)?; + } map.serialize_entry( "constraints", &FactDigestResolutionConstraint { @@ -1463,7 +1499,7 @@ fn fact_neutral_pre_cache_sources( ) -> Option> { let has_nonempty_semantic = semantic.is_some_and(|layer| !semantic_layer_is_empty(layer)); if options.force - || options.purpose != BuildPurpose::Extract + || !supports_fact_neutral_incremental(options.purpose) || options.program_analysis || has_nonempty_semantic || !supplemental.is_empty() @@ -1581,7 +1617,7 @@ fn fact_neutral_incremental_candidate( ) -> bool { let has_nonempty_semantic = semantic.is_some_and(|layer| !semantic_layer_is_empty(layer)); if options.force - || options.purpose != BuildPurpose::Extract + || !supports_fact_neutral_incremental(options.purpose) || options.program_analysis || has_nonempty_semantic || !supplemental.is_empty() @@ -1608,6 +1644,10 @@ fn fact_neutral_incremental_candidate( }) } +const fn supports_fact_neutral_incremental(purpose: BuildPurpose) -> bool { + matches!(purpose, BuildPurpose::Update | BuildPurpose::Extract) +} + fn extraction_file_anchors( extractions: &[Extraction], root: &Path, @@ -1727,34 +1767,34 @@ fn prepare_fact_neutral_document( } else { (None, previous) }; - let mut changed_file_node_ids = BTreeSet::new(); + let mut changed_node_ids = BTreeSet::new(); let mut evidence = BuildEvidence::new(root.to_path_buf(), current.graph.build.clone()); evidence.files = current.graph.files.clone(); - evidence.coverage = current + let changed_file_ids = current .graph - .coverage + .files .iter() - .filter(|coverage| coverage.capability != "file_inventory") - .cloned() - .collect(); - evidence.diagnostics = current + .filter(|file| source_digests.contains_key(&file.path)) + .map(|file| file.id.clone()) + .collect::>(); + evidence.coverage = current .graph - .diagnostics + .coverage .iter() - .filter(|diagnostic| { - !matches!( - diagnostic.code.as_str(), - "parser_recovery" - | "partial_extraction" - | "extractor_failure" - | "unsupported_input" - | "excluded_input" - | "generated_input" - | "binary_input" - ) + .filter(|coverage| { + coverage.capability != "file_inventory" + || coverage + .file_id + .as_ref() + .is_none_or(|file_id| !changed_file_ids.contains(file_id)) }) .cloned() .collect(); + // The fact-neutral proof covers only successfully and completely parsed + // changed files. Preserve extraction diagnostics for every unchanged file; + // rebuilding inventory from the changed batch alone would otherwise erase + // unrelated parser-recovery and partial-coverage evidence. + evidence.diagnostics = current.graph.diagnostics.clone(); for file in &mut evidence.files { if let Some(digest) = source_digests.get(&file.path) { file.content_digest.clone_from(&digest.content_digest); @@ -1762,10 +1802,19 @@ fn prepare_fact_neutral_document( } } evidence.build.configuration_digest = configuration_digest; - evidence.include_inventory(inventory)?; + let changed_inventory = inventory + .into_iter() + .filter(|item| source_digests.contains_key(&relative_fact_path(&item.path, root))); + evidence.include_inventory(changed_inventory)?; canonicalize_fact_neutral_metadata(&mut evidence.coverage, &mut evidence.diagnostics); let anchors = extraction_file_anchors(extractions, root); + let previous_file_sizes = current + .graph + .files + .iter() + .map(|file| (file.path.clone(), file.byte_size)) + .collect::>(); current.graph.build = evidence.build; current.graph.files = evidence.files; current.graph.coverage = evidence.coverage; @@ -1777,9 +1826,6 @@ fn prepare_fact_neutral_document( .map(|file| (file.path.as_str(), file)) .collect::>(); for node in &mut current.nodes { - if node.kind != NodeKind::File { - continue; - } let before = node.clone(); let Some(source) = node.source_file() else { continue; @@ -1788,36 +1834,59 @@ fn prepare_fact_neutral_document( let Some(file) = files.get(source.as_str()) else { continue; }; - node.details = Some(NodeDetails::File(FileNodeDetails { - content_digest: file.content_digest.clone(), - byte_size: file.byte_size, - generated: file.generated, - })); - let anchor = source_digests.contains_key(&source).then(|| { + if node.kind == NodeKind::File { + node.details = Some(NodeDetails::File(FileNodeDetails { + content_digest: file.content_digest.clone(), + byte_size: file.byte_size, + generated: file.generated, + })); + } + let refreshed_envelope = source_digests.contains_key(&source).then(|| { anchors .get(&source) .cloned() .or_else(|| full_file_source_anchor(&root.join(&source), &source, max_source_bytes)) }); - if let Some(anchor) = anchor.flatten() { - node.source = Some(anchor.clone()); + if let Some(anchor) = refreshed_envelope.flatten() { + let source_was_file_envelope = node.source.as_ref().is_some_and(|previous| { + previous.start_byte == 0 + && previous_file_sizes + .get(&source) + .is_some_and(|size| previous.end_byte == *size) + }); + if node.kind == NodeKind::File || source_was_file_envelope { + node.source = Some(anchor.clone()); + } for provenance in &mut node.evidence { for candidate in &mut provenance.anchors { - if candidate.file == source { + let candidate_was_file_envelope = candidate.file == source + && candidate.start_byte == 0 + && previous_file_sizes + .get(&source) + .is_some_and(|size| candidate.end_byte == *size); + if candidate.file == source + && (node.kind == NodeKind::File || candidate_was_file_envelope) + { candidate.clone_from(&anchor); } } - if provenance - .wiring_site - .as_ref() - .is_some_and(|candidate| candidate.file == source) + let wiring_was_file_envelope = + provenance.wiring_site.as_ref().is_some_and(|candidate| { + candidate.file == source + && candidate.start_byte == 0 + && previous_file_sizes + .get(&source) + .is_some_and(|size| candidate.end_byte == *size) + }); + if provenance.wiring_site.is_some() + && (node.kind == NodeKind::File || wiring_was_file_envelope) { provenance.wiring_site = Some(anchor.clone()); } } } if *node != before { - changed_file_node_ids.insert(node.id.clone()); + changed_node_ids.insert(node.id.clone()); } } compass_model::validate_code_graph(¤t).map_err(|error| { @@ -1825,7 +1894,7 @@ fn prepare_fact_neutral_document( "fact-neutral incremental graph failed validation: {error}" )) })?; - Ok(Some((previous_for_delta, current, changed_file_node_ids))) + Ok(Some((previous_for_delta, current, changed_node_ids))) } /// Keep the byte-preserving JSON delta bounded independently from the graph @@ -1854,7 +1923,7 @@ fn publish_fact_neutral_incremental( empty_files: Vec, previous: Option<&V1GraphDocument>, current: &V1GraphDocument, - changed_file_node_ids: &BTreeSet, + changed_node_ids: &BTreeSet, fact_state: &AstFactDigestState, timings: &mut BuildTimings, ) -> Result { @@ -1870,7 +1939,8 @@ fn publish_fact_neutral_incremental( "fact-neutral SQLite publication is missing its prior graph".to_owned(), ) })?; - let (metrics, seal) = publish_graph_and_store_delta(&output_dir, previous, current)?; + let (metrics, seal) = + publish_graph_and_store_delta(&output_dir, previous, current, Some(changed_node_ids))?; (Some(metrics), Some(seal)) } else { let previous_bytes = read_fact_neutral_delta_source(&graph_path); @@ -1880,7 +1950,7 @@ fn publish_fact_neutral_incremental( write_fact_neutral_graph_json_delta_prevalidated( bytes, current, - changed_file_node_ids, + changed_node_ids, writer, ) .map_err(|source| compass_files::FileError::Io { @@ -3465,12 +3535,13 @@ fn build_graph_inner_unscoped( let published_edges = published.document.links.len(); let no_cluster_graph_write_started = Instant::now(); let (store_metrics, graph_seal) = if options.graph_storage.publishes_store() { - let (metrics, seal) = - if let Some(previous) = load_graph_delta_base(&output_dir, &published.document) { - publish_graph_and_store_delta(&output_dir, &previous, &published.document)? - } else { - publish_graph_and_store_from_canonical(&output_dir, &published.document)? - }; + let (metrics, seal) = if let Some(previous) = + load_graph_delta_base(&output_dir, &published.document) + { + publish_graph_and_store_delta(&output_dir, &previous, &published.document, None)? + } else { + publish_graph_and_store_from_canonical(&output_dir, &published.document)? + }; (Some(metrics), Some(seal)) } else { let graph_path = output_dir.join("graph.json"); @@ -4021,7 +4092,7 @@ fn build_graph_inner_unscoped( let (store_metrics, graph_seal) = if options.graph_storage.publishes_store() { let (metrics, seal) = if let Some(previous) = load_graph_delta_base(&output_dir, &published_document) { - publish_graph_and_store_delta(&output_dir, &previous, &published_document)? + publish_graph_and_store_delta(&output_dir, &previous, &published_document, None)? } else { publish_graph_and_store_from_canonical(&output_dir, &published_document)? }; @@ -4559,7 +4630,9 @@ fn garbage_collect_shared_store( .collect::>(); let graph_path = staging_directory.join("graph.json"); let store = SqliteStore::open(local_sqlite_store_path(&graph_path))?; - if all_references.len() <= 2 && !graph_snapshot_needs_gc(&store, 2)? { + if all_references.len() <= 2 + && !graph_snapshot_needs_gc(&store, SHARED_STORE_GC_MANIFEST_THRESHOLD)? + { return Ok(GraphSnapshotGcStats::default()); } let stats = garbage_collect_graph_snapshots( @@ -4738,6 +4811,7 @@ fn publish_graph_and_store_delta( output_dir: &Path, previous: &V1GraphDocument, graph: &V1GraphDocument, + changed_node_ids: Option<&BTreeSet>, ) -> Result<(StorePublishMetrics, ArtifactSeal), CoreError> { if graph.graph.schema != GRAPH_SCHEMA_V1 { return Err(CoreError::InvalidBuildState(format!( @@ -4746,30 +4820,69 @@ fn publish_graph_and_store_delta( ))); } let graph_path = output_dir.join("graph.json"); + let previous_bytes = changed_node_ids.and_then(|_| read_fact_neutral_delta_source(&graph_path)); let store = SqliteStore::open(local_sqlite_store_path(&graph_path))?; let builder = GraphSnapshotBuilder::new(); let (graph_receipt, content) = rayon::join( || { - write_atomic_with_digest(&graph_path, |writer| { - write_canonical_graph_json(graph, writer).map_err(|source| { - compass_files::FileError::Io { - path: graph_path.clone(), - source, + let started = Instant::now(); + let result = write_atomic_with_digest(&graph_path, |writer| { + let used_delta = match (previous_bytes.as_deref(), changed_node_ids) { + (Some(bytes), Some(changed)) => { + write_fact_neutral_graph_json_delta_prevalidated( + bytes, graph, changed, writer, + ) + .map_err(|source| { + compass_files::FileError::Io { + path: graph_path.clone(), + source, + } + })? } - }) - }) + _ => false, + }; + if used_delta { + Ok(()) + } else { + write_canonical_graph_json(graph, writer).map_err(|source| { + compass_files::FileError::Io { + path: graph_path.clone(), + source, + } + }) + } + }); + profile_internal_duration("graph JSON delta publication", started.elapsed()); + result + }, + || { + let started = Instant::now(); + let result = if let Some(changed) = changed_node_ids { + builder.prepare_node_value_delta(&store, previous, graph, changed) + } else { + builder.prepare_graph_delta(&store, previous, graph) + }; + profile_internal_duration("immutable store graph delta", started.elapsed()); + result }, - || builder.prepare_graph_delta(&store, previous, graph), ); let graph_receipt = graph_receipt?; let graph_seal = ArtifactSeal { bytes: graph_receipt.bytes, sha256: graph_receipt.sha256.clone(), }; - let content = content.or_else(|_| builder.prepare_content(&store, graph))?; + let content = match content { + Ok(content) => content, + Err(error) => { + profile_internal_message(&format!("immutable store delta fallback: {error}")); + builder.prepare_content(&store, graph)? + } + }; + let finish_started = Instant::now(); let prepared = builder.finish_content(&store, content, graph_receipt.sha256, graph_receipt.bytes)?; let metrics = finish_store_snapshot(output_dir, &store, &builder, prepared)?; + profile_internal_duration("immutable store delta activation", finish_started.elapsed()); Ok((metrics, graph_seal)) } @@ -7078,6 +7191,12 @@ fn profile_internal_duration(label: &str, elapsed: Duration) { } } +fn profile_internal_message(message: &str) { + if std::env::var_os("COMPASS_PROFILE_INTERNAL").is_some() { + eprintln!("[compass internal] {message}"); + } +} + fn profile_extraction_inventory(extractions: &[Extraction]) { if std::env::var_os("COMPASS_PROFILE_INTERNAL").is_none() { return; @@ -8469,6 +8588,8 @@ mod tests { "def main():\n return 1\n\n# metadata-only edit\n", )?; let changed = build_local_graph(&options)?; + assert_eq!(changed.files_extracted, 1); + assert_eq!(changed.timings.graph_assembly, Duration::ZERO); let after_store = SqliteStore::open(&store_path)?; let after_reader = GraphSnapshotReader::open_active(&after_store)? .ok_or("changed snapshot is not active")?; @@ -8623,6 +8744,68 @@ mod tests { Ok(()) } + #[test] + fn fact_neutral_kotlin_update_refreshes_file_envelope_nodes() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let root = directory.path(); + let source = root.join("Main.kt"); + fs::write( + root.join("recovery.py"), + "def broken(\n\ndef healthy():\n return True\n", + )?; + let initial = "package example\n\nclass Main\n\n// metadata-only tail\n"; + let changed_source = "package example\n\nclass Main\n"; + fs::write(&source, initial)?; + let mut options = BuildOptions::new(root); + options.no_cluster = true; + options.no_viz = true; + + let cold = build_local_graph(&options)?; + let cold_graph = V1GraphDocument::load(&cold.output_dir.join("graph.json"))?; + assert!( + cold_graph + .graph + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "parser_recovery") + ); + fs::write(&source, changed_source)?; + let changed = build_local_graph(&options)?; + assert_eq!(changed.files_extracted, 1); + assert_eq!(changed.files_cached, 1); + assert_eq!(changed.timings.graph_assembly, Duration::ZERO); + assert!(changed.timings.store_new_objects <= 8); + + let changed_graph = V1GraphDocument::load(&changed.output_dir.join("graph.json"))?; + assert_eq!( + changed_graph.graph.diagnostics, + cold_graph.graph.diagnostics + ); + let semantic_identity = |graph: &V1GraphDocument| { + graph + .nodes + .iter() + .filter(|node| node.kind != NodeKind::File) + .map(|node| (node.id.clone(), node.kind, node.qualified_name.clone())) + .collect::>() + }; + assert_eq!( + semantic_identity(&changed_graph), + semantic_identity(&cold_graph) + ); + let package = changed_graph + .nodes + .iter() + .find(|node| node.kind == NodeKind::Package) + .ok_or("Kotlin package node missing")?; + assert_eq!( + package.source.as_ref().map(|anchor| anchor.end_byte), + Some(changed_source.len() as u64) + ); + compass_model::validate_code_graph(&changed_graph)?; + Ok(()) + } + #[test] fn clustered_fact_neutral_incremental_reuses_community_artifacts() -> Result<(), Box> { @@ -8704,6 +8887,33 @@ mod tests { Ok(()) } + #[test] + fn fact_digest_ignores_kotlin_file_envelope_edits() -> Result<(), Box> { + let directory = tempfile::tempdir()?; + let source = directory.path().join("Main.kt"); + let initial_source = "package example\n\nimport org.springframework.context.annotation.Configuration\nimport org.springframework.context.annotation.EnableLoadTimeWeaving\n\n@Configuration\n@EnableLoadTimeWeaving\nclass Main\n"; + let changed_source = "package example\n\nimport org.springframework.context.annotation.Configuration\nimport org.springframework.context.annotation.EnableLoadTimeWeaving\n\n@Configuration\n@EnableLoadTimeWeaving\nclass Main\n\n// metadata-only edit\n"; + fs::write(&source, initial_source)?; + let evidence = Arc::new(ProjectEvidenceIndex::build( + directory.path(), + std::slice::from_ref(&source), + )); + let mut engine = Engine::with_project_evidence(evidence); + let initial = + engine.extract_source_graph_only(&source, "Main.kt", initial_source.as_bytes())?; + fs::write(&source, changed_source)?; + let changed = + engine.extract_source_graph_only(&source, "Main.kt", changed_source.as_bytes())?; + let initial_digest = serde_json::to_value(FactDigestExtraction { + extraction: &initial, + })?; + let changed_digest = serde_json::to_value(FactDigestExtraction { + extraction: &changed, + })?; + assert_eq!(initial_digest, changed_digest); + Ok(()) + } + #[test] fn cached_cpp_declarations_are_not_project_merged_twice() -> Result<(), Box> { let directory = tempfile::tempdir()?; diff --git a/crates/compass-graph/src/snapshot.rs b/crates/compass-graph/src/snapshot.rs index 4e4850af..3f81d590 100644 --- a/crates/compass-graph/src/snapshot.rs +++ b/crates/compass-graph/src/snapshot.rs @@ -791,6 +791,82 @@ impl GraphSnapshotBuilder { }) } + /// Prepare a point-update snapshot when graph metadata and node payloads + /// change without changing any secondary-index projection. Callers must + /// supply the exact changed-node set; the preflight proves that node IDs, + /// relationships, file-path keys, names, terms, and communities remain + /// unchanged before reusing their immutable roots. + pub fn prepare_node_value_delta( + &self, + store: &S, + previous: &GraphDocument, + current: &GraphDocument, + changed_node_ids: &BTreeSet, + ) -> Result { + validate_code_graph(current) + .map_err(|error| SnapshotError::Corrupt(format!("graph validation failed: {error}")))?; + validate_node_value_delta(previous, current, changed_node_ids)?; + let reader = GraphSnapshotReader::open_active(store)?.ok_or_else(|| { + SnapshotError::Unsupported("node-value delta requires an active snapshot".to_owned()) + })?; + let previous_snapshot_id = snapshot_identity(previous)?; + if reader.selector().snapshot_id != previous_snapshot_id + || reader.manifest().node_count != previous.nodes.len() as u64 + || reader.manifest().edge_count != previous.links.len() as u64 + { + return Err(SnapshotError::Corrupt( + "active snapshot does not match the previous graph".to_owned(), + )); + } + + let current_nodes = current + .nodes + .iter() + .map(|node| (node.id.as_str(), node)) + .collect::>(); + let node_updates = changed_node_ids + .iter() + .map(|id| { + let node = current_nodes.get(id.as_str()).ok_or_else(|| { + SnapshotError::Corrupt(format!("node-value delta is missing changed node {id}")) + })?; + Ok(( + encode_graph_index_key(IndexKind::Nodes, &[id.as_bytes()])?, + Some(encode_json(node)?), + )) + }) + .collect::, SnapshotError>>()?; + + let mut writer = ObjectWriter::new(store)?; + let metadata_entries = build_index(current, IndexKind::Metadata, None)?; + let metadata_entry_count = metadata_entries.len() as u64; + let metadata_digest = build_index_tree(&mut writer, IndexKind::Metadata, metadata_entries)?; + let mut roots = reader.manifest().roots.clone(); + for root in &mut roots { + if root.index == IndexKind::Metadata { + root.entry_count = metadata_entry_count; + root.digest = metadata_digest.clone(); + } else if root.index == IndexKind::Nodes { + root.digest = update_index_tree( + store, + &mut writer, + root.index, + &root.digest, + &node_updates, + 0, + )?; + } + } + let stats = writer.finish()?; + Ok(PreparedGraphSnapshotContent { + snapshot_id: snapshot_identity(current)?, + node_count: current.nodes.len() as u64, + edge_count: current.links.len() as u64, + roots, + stats, + }) + } + /// Prepare a bounded graph delta when an incremental edit changes graph /// records or relationships. Unchanged immutable index trees are reused; /// only indexes whose logical projection depends on changed records are @@ -830,11 +906,30 @@ impl GraphSnapshotBuilder { if !changed_indexes.contains(&root.index) { continue; } - let term_postings = + let previous_term_postings = + (root.index == IndexKind::Terms).then(|| build_term_postings(previous)); + let current_term_postings = (root.index == IndexKind::Terms).then(|| build_term_postings(current)); - let entries = build_index(current, root.index, term_postings.as_ref())?; - root.entry_count = entries.len() as u64; - root.digest = build_index_tree(&mut writer, root.index, entries)?; + let previous_entries = + build_index(previous, root.index, previous_term_postings.as_ref())?; + let current_entries = build_index(current, root.index, current_term_postings.as_ref())?; + if previous_entries == current_entries { + continue; + } + root.entry_count = current_entries.len() as u64; + root.digest = if previous_entries.keys().eq(current_entries.keys()) { + let updates = current_entries + .iter() + .filter(|(key, value)| previous_entries.get(*key) != Some(*value)) + .map(|(key, value)| (key.clone(), Some(value.clone()))) + .collect::>(); + update_index_tree(store, &mut writer, root.index, &root.digest, &updates, 0)? + } else { + // Insertions and deletions can move persistent-tree separators. + // Rebuild this index conservatively; point updates are safe only + // when the complete ordered key set is unchanged. + build_index_tree(&mut writer, root.index, current_entries)? + }; } let stats = writer.finish()?; Ok(PreparedGraphSnapshotContent { @@ -992,21 +1087,15 @@ pub fn write_canonical_graph_json( /// The function performs a complete structural preflight before writing any /// bytes. It returns `Ok(false)` when the previous artifact is not the /// canonical node-link shape or when the supplied changed-node set is not -/// compatible with a file-only delta, allowing the caller to use the normal +/// compatible with a node-value-only delta, allowing the caller to use the normal /// full serializer without risking a partial duplicate document. pub fn write_fact_neutral_graph_json_delta( previous_bytes: &[u8], graph: &GraphDocument, - changed_file_node_ids: &BTreeSet, + changed_node_ids: &BTreeSet, writer: &mut W, ) -> io::Result { - write_fact_neutral_graph_json_delta_inner( - previous_bytes, - graph, - changed_file_node_ids, - true, - writer, - ) + write_fact_neutral_graph_json_delta_inner(previous_bytes, graph, changed_node_ids, true, writer) } /// Publish a fact-neutral edit after the caller has already validated the @@ -1017,13 +1106,13 @@ pub fn write_fact_neutral_graph_json_delta( pub fn write_fact_neutral_graph_json_delta_prevalidated( previous_bytes: &[u8], graph: &GraphDocument, - changed_file_node_ids: &BTreeSet, + changed_node_ids: &BTreeSet, writer: &mut W, ) -> io::Result { write_fact_neutral_graph_json_delta_inner( previous_bytes, graph, - changed_file_node_ids, + changed_node_ids, false, writer, ) @@ -1032,7 +1121,7 @@ pub fn write_fact_neutral_graph_json_delta_prevalidated( fn write_fact_neutral_graph_json_delta_inner( previous_bytes: &[u8], graph: &GraphDocument, - changed_file_node_ids: &BTreeSet, + changed_node_ids: &BTreeSet, validate_records: bool, writer: &mut W, ) -> io::Result { @@ -1072,14 +1161,11 @@ fn write_fact_neutral_graph_json_delta_inner( let mut changed_seen = BTreeSet::new(); for (index, _) in node_ranges.iter().enumerate() { let current = &graph.nodes[index]; - if changed_file_node_ids.contains(¤t.id) { - if current.kind != NodeKind::File { - return Ok(false); - } + if changed_node_ids.contains(¤t.id) { changed_seen.insert(current.id.clone()); } } - if changed_seen.len() != changed_file_node_ids.len() { + if changed_seen.len() != changed_node_ids.len() { return Ok(false); } @@ -1101,7 +1187,7 @@ fn write_fact_neutral_graph_json_delta_inner( writer.write_all(b",")?; } let node = &graph.nodes[index]; - if changed_file_node_ids.contains(&node.id) { + if changed_node_ids.contains(&node.id) { serde_json::to_writer(&mut *writer, node).map_err(io::Error::other)?; } else { writer.write_all(&previous_bytes[range.clone()])?; @@ -3092,47 +3178,7 @@ fn build_term_postings(graph: &GraphDocument) -> BTreeMap> { let mut term_postings = BTreeMap::>::new(); for node in &graph.nodes { - let mut terms = BTreeSet::new(); - terms.extend(search_terms(&node.name)); - terms.extend(search_terms(&node.qualified_name)); - terms.extend(compass_model::search::identifier_search_terms(&node.name)); - terms.extend(compass_model::search::identifier_search_terms( - &node.qualified_name, - )); - terms.extend(search_terms(node.kind.as_str())); - for role in &node.roles { - let role = format!("{role:?}"); - terms.extend(search_terms(&role)); - } - if let Some(language) = &node.language { - terms.extend(search_terms(language)); - } - if let Some(framework) = &node.framework { - terms.extend(search_terms(framework)); - } - if let Some(source) = &node.source { - terms.extend(search_terms(&source.file)); - } - if let Some(community) = &node.community { - terms.extend(search_terms(&community.id.to_string())); - if let Some(label) = &community.label { - terms.extend(search_terms(label)); - } - } - if let Some(path) = node - .details - .as_ref() - .and_then(|details| serde_json::to_value(details).ok()) - .and_then(|value| { - value - .get("data") - .and_then(|data| data.get("path")) - .and_then(serde_json::Value::as_str) - .map(str::to_owned) - }) - { - terms.extend(search_terms(&path)); - } + let mut terms = searchable_node_terms(node); for alias in aliases_by_target .get(node.id.as_str()) .into_iter() @@ -3152,6 +3198,51 @@ fn build_term_postings(graph: &GraphDocument) -> BTreeMap> { term_postings } +fn searchable_node_terms(node: &NodeRecord) -> BTreeSet { + let mut terms = BTreeSet::new(); + terms.extend(search_terms(&node.name)); + terms.extend(search_terms(&node.qualified_name)); + terms.extend(compass_model::search::identifier_search_terms(&node.name)); + terms.extend(compass_model::search::identifier_search_terms( + &node.qualified_name, + )); + terms.extend(search_terms(node.kind.as_str())); + for role in &node.roles { + let role = format!("{role:?}"); + terms.extend(search_terms(&role)); + } + if let Some(language) = &node.language { + terms.extend(search_terms(language)); + } + if let Some(framework) = &node.framework { + terms.extend(search_terms(framework)); + } + if let Some(source) = &node.source { + terms.extend(search_terms(&source.file)); + } + if let Some(community) = &node.community { + terms.extend(search_terms(&community.id.to_string())); + if let Some(label) = &community.label { + terms.extend(search_terms(label)); + } + } + if let Some(path) = node + .details + .as_ref() + .and_then(|details| serde_json::to_value(details).ok()) + .and_then(|value| { + value + .get("data") + .and_then(|data| data.get("path")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + }) + { + terms.extend(search_terms(&path)); + } + terms +} + fn build_index( graph: &GraphDocument, index: IndexKind, @@ -3695,6 +3786,72 @@ fn validate_graph_delta( Ok(()) } +fn validate_node_value_delta( + previous: &GraphDocument, + current: &GraphDocument, + changed_node_ids: &BTreeSet, +) -> Result<(), SnapshotError> { + if previous.directed != current.directed + || previous.multigraph != current.multigraph + || previous.links != current.links + { + return Err(SnapshotError::Unsupported( + "node-value delta changed graph topology".to_owned(), + )); + } + let file_keys = |graph: &GraphDocument| { + graph + .graph + .files + .iter() + .map(|file| (file.path.clone(), file.id.clone())) + .collect::>() + }; + if file_keys(previous) != file_keys(current) { + return Err(SnapshotError::Unsupported( + "node-value delta changed the file path index".to_owned(), + )); + } + if previous.nodes.len() != current.nodes.len() { + return Err(SnapshotError::Unsupported( + "node-value delta changed the node set".to_owned(), + )); + } + let mut changed_seen = BTreeSet::new(); + for (before, after) in previous.nodes.iter().zip(¤t.nodes) { + if before.id != after.id { + return Err(SnapshotError::Unsupported( + "node-value delta changed node identity or ordering".to_owned(), + )); + } + if before == after { + continue; + } + if !changed_node_ids.contains(&after.id) + || before.name != after.name + || before.qualified_name != after.qualified_name + || before.community != after.community + || searchable_node_terms(before) != searchable_node_terms(after) + { + return Err(SnapshotError::Unsupported( + "node-value delta changed a secondary index projection".to_owned(), + )); + } + changed_seen.insert(after.id.clone()); + } + if changed_seen != *changed_node_ids { + return Err(SnapshotError::Corrupt( + "node-value delta changed-node set is not exact".to_owned(), + )); + } + if changed_node_ids.is_empty() && previous.graph == current.graph { + return Err(SnapshotError::Corrupt( + "node-value delta contains no changed graph values".to_owned(), + )); + } + Ok(()) +} + fn graph_delta_indexes(previous: &GraphDocument, current: &GraphDocument) -> BTreeSet { let mut changed = BTreeSet::new(); if previous.graph != current.graph { @@ -5088,7 +5245,35 @@ mod tests { ); assert_eq!(prevalidated, expected); - let invalid = BTreeSet::from(["symbol".to_owned()]); + let mut node_value_changed = current.clone(); + node_value_changed.nodes[1].source = Some(SourceAnchor { + file: "src/main.rs".to_owned(), + start_byte: 2, + end_byte: 6, + start_line: 2, + start_column: 0, + end_line: 2, + end_column: 4, + }); + let node_value_expected = { + let mut bytes = Vec::new(); + write_canonical_graph_json(&node_value_changed, &mut bytes) + .map_err(|error| SnapshotError::Encode(error.to_string()))?; + bytes + }; + let mut node_value_actual = Vec::new(); + assert!( + write_fact_neutral_graph_json_delta( + &previous_bytes, + &node_value_changed, + &BTreeSet::from(["file".to_owned(), "symbol".to_owned()]), + &mut node_value_actual, + ) + .map_err(|error| SnapshotError::Encode(error.to_string()))? + ); + assert_eq!(node_value_actual, node_value_expected); + + let invalid = BTreeSet::from(["missing".to_owned()]); let mut no_output = Vec::new(); assert!( !write_fact_neutral_graph_json_delta( diff --git a/crates/compass-graph/src/v1.rs b/crates/compass-graph/src/v1.rs index 18c40967..cce70712 100644 --- a/crates/compass-graph/src/v1.rs +++ b/crates/compass-graph/src/v1.rs @@ -91,6 +91,7 @@ struct PreparedNodeFailure { struct EdgeNodeFacts { kind: NodeKind, + kotlin_interface: bool, rust_type_parameter: bool, rust_enum_member: bool, unresolved_wiring_site: Option, @@ -882,7 +883,9 @@ fn finalize_prepared_edge( .unwrap_or(NodeKind::Variable); let target_is_constructible = edge_node_facts .get(edge.target.as_str()) - .is_some_and(|facts| facts.kind.is_constructible() || facts.rust_enum_member); + .is_some_and(|facts| { + facts.kind.is_constructible() || facts.rust_enum_member || facts.kotlin_interface + }); let source_is_rust_type_parameter = edge_node_facts .get(edge.source.as_str()) .is_some_and(|facts| facts.rust_type_parameter); @@ -1241,6 +1244,8 @@ fn normalize_v1_with_mode( id.as_str(), EdgeNodeFacts { kind: node.kind, + kotlin_interface: node.kind == NodeKind::Interface + && node.language.as_deref() == Some("kotlin"), rust_type_parameter: node.kind == NodeKind::Parameter && node.language.as_deref() == Some("rust"), rust_enum_member: node.kind == NodeKind::EnumMember diff --git a/crates/compass-graph/tests/store_snapshot.rs b/crates/compass-graph/tests/store_snapshot.rs index 224eeb37..7981ad5c 100644 --- a/crates/compass-graph/tests/store_snapshot.rs +++ b/crates/compass-graph/tests/store_snapshot.rs @@ -805,6 +805,66 @@ fn graph_delta_rebuilds_relationship_indexes_without_rewriting_nodes() -> Result assert_eq!(reader.get_edge(&replacement.id)?, Some(replacement)); assert_eq!(reader.outgoing("b", limits(4))?.len(), 1); assert_eq!(reader.export_graph()?, graph_sorted_with(¤t)); + + Ok(()) +} + +#[test] +fn graph_delta_point_updates_value_only_node_changes() -> Result<(), Box> { + let store = MemoryStore::default(); + let builder = GraphSnapshotBuilder::new(); + let mut previous = graph(); + previous + .nodes + .extend((0..300).map(|index| node(&format!("node-{index:03}")))); + previous.nodes.sort_by(|left, right| left.id.cmp(&right.id)); + let first = builder.prepare(&store, &previous)?; + builder.activate(&store, &first)?; + + let mut current = previous.clone(); + current.graph.build.generation_id = "next-generation".to_owned(); + let changed = current + .nodes + .iter_mut() + .find(|node| node.id == "node-150") + .ok_or("node-150 missing")?; + let provenance = changed + .evidence + .first_mut() + .ok_or("node evidence missing")?; + provenance.score = Some(0.75); + + let changed_ids = BTreeSet::from(["node-150".to_owned()]); + let content = builder.prepare_node_value_delta(&store, &previous, ¤t, &changed_ids)?; + let graph_bytes = canonical_graph_json(¤t)?; + let graph_digest = format!("{:x}", sha2::Sha256::digest(&graph_bytes)); + let delta = builder.finish_content(&store, content, graph_digest, graph_bytes.len() as u64)?; + assert!( + delta.new_objects <= 6, + "point update wrote {} immutable objects", + delta.new_objects + ); + + builder.activate(&store, &delta)?; + let reader = GraphSnapshotReader::open_active(&store)?.ok_or("active snapshot missing")?; + assert_eq!(reader.export_graph()?, graph_sorted_with(¤t)); + + let mut invalid = current.clone(); + invalid + .nodes + .iter_mut() + .find(|node| node.id == "node-150") + .ok_or("node-150 missing")? + .name = "renamed".to_owned(); + assert!( + builder + .prepare_node_value_delta(&store, ¤t, &invalid, &changed_ids) + .is_err() + ); + + let mut metadata_only = current.clone(); + metadata_only.graph.build.generation_id = "metadata-only".to_owned(); + builder.prepare_node_value_delta(&store, ¤t, &metadata_only, &BTreeSet::new())?; Ok(()) } diff --git a/crates/compass-languages/src/adapters.rs b/crates/compass-languages/src/adapters.rs index dc4ea136..98eefc19 100644 --- a/crates/compass-languages/src/adapters.rs +++ b/crates/compass-languages/src/adapters.rs @@ -249,6 +249,24 @@ const PHP_CAPABILITIES: &[LanguageCapability] = &[ LanguageCapability::ExternalReferences, ]; +const KOTLIN_CAPABILITIES: &[LanguageCapability] = &[ + LanguageCapability::Declarations, + LanguageCapability::LexicalScopes, + LanguageCapability::Namespaces, + LanguageCapability::Imports, + LanguageCapability::Aliases, + LanguageCapability::Calls, + LanguageCapability::Construction, + LanguageCapability::Decorators, + LanguageCapability::TypeReferences, + LanguageCapability::BaseTypes, + LanguageCapability::HierarchyDispatch, + LanguageCapability::Members, + LanguageCapability::Ownership, + LanguageCapability::Receivers, + LanguageCapability::ExternalReferences, +]; + const UNIVERSAL_ADAPTERS: &[AdapterProfile] = &[ AdapterProfile { id: "compass.csharp.candidate", @@ -282,6 +300,14 @@ const UNIVERSAL_ADAPTERS: &[AdapterProfile] = &[ profile: UniversalAdapterProfile::UniversalCandidate, capabilities: JAVASCRIPT_CAPABILITIES, }, + AdapterProfile { + id: "compass.kotlin.candidate", + language: "kotlin", + version: 1, + evidence_schema: crate::UNIVERSAL_EVIDENCE_SCHEMA, + profile: UniversalAdapterProfile::UniversalCandidate, + capabilities: KOTLIN_CAPABILITIES, + }, AdapterProfile { id: "compass.php", language: "php", diff --git a/crates/compass-languages/src/engine.rs b/crates/compass-languages/src/engine.rs index b3b9fb8f..4f1a74a9 100644 --- a/crates/compass-languages/src/engine.rs +++ b/crates/compass-languages/src/engine.rs @@ -130,7 +130,7 @@ impl Engine { Ok(extraction) } - /// Extract TypeScript/JavaScript universal evidence directly from source. + /// Extract universal-candidate evidence directly from source. /// /// This hidden API remains useful for qualification fixtures, but it now /// calls the same registered candidate emitter used by normal Compass @@ -145,18 +145,30 @@ impl Engine { ) -> Result { let spec = Registry::resolve(path).ok_or_else(|| ExtractError::Unsupported(path.to_path_buf()))?; - if !matches!(spec.name, "typescript" | "tsx" | "javascript") { + if !matches!(spec.name, "typescript" | "tsx" | "javascript" | "kotlin") { return Err(ExtractError::Unsupported(path.to_path_buf())); } let tree = self.parse(path, spec, source)?; - crate::evidence::extract_candidate_tree_evidence( - path, - source_file, - source, - tree.root_node(), - spec.name, - ) - .map_err(|error| ExtractError::InvalidProgramEvidence { + let evidence = if spec.name == "kotlin" { + let profile = Registry::universal_profile_for_spec(spec) + .ok_or_else(|| ExtractError::Unsupported(path.to_path_buf()))?; + crate::evidence::extract_tree_evidence( + path, + source_file, + source, + tree.root_node(), + profile, + ) + } else { + crate::evidence::extract_candidate_tree_evidence( + path, + source_file, + source, + tree.root_node(), + spec.name, + ) + }; + evidence.map_err(|error| ExtractError::InvalidProgramEvidence { path: path.to_path_buf(), detail: error.to_string(), }) @@ -1603,7 +1615,7 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { parent_declaration: Option<(&str, &str, bool, bool)>, ) { let kind = node.kind(); - if self.config.import_types.contains(&kind) && !matches!(self.language, "kotlin" | "lua") { + if self.config.import_types.contains(&kind) && self.language != "lua" { self.add_import(node); } @@ -1655,8 +1667,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { self.add_python_decorators(node, &id); } else if self.language == "ruby" { self.add_ruby_parent_edge(node, &id); - } else if self.language == "kotlin" { - self.add_kotlin_parent_edges(node, &id); } else if self.language == "scala" { self.add_scala_class_references(node, &id); } @@ -1730,8 +1740,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { self.add_python_decorators(node, &id); } else if self.language == "c" { self.add_c_function_references(node, &id); - } else if self.language == "kotlin" { - self.add_kotlin_function_references(node, &id); } else if self.language == "scala" { self.add_scala_function_references(node, &id); } @@ -1770,26 +1778,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { self.add_js_commonjs_export(node); } - if self.language == "kotlin" && kind == "enum_entry" { - if let Some((class_id, _, _, _)) = parent_declaration - && let Some(name_node) = first_descendant(node, "simple_identifier") - .or_else(|| first_descendant(node, "identifier")) - && let Some(name) = self.node_text(name_node).map(clean_name) - { - let id = make_id(&[class_id, &name]); - self.add_node(&id, &name, line(node), false, None); - self.add_edge(class_id, &id, "case_of", line(node), None); - } - return; - } - - if self.language == "kotlin" && kind == "property_declaration" { - if let Some((class_id, _, _, _)) = parent_declaration { - self.add_kotlin_property_reference(node, class_id); - } - return; - } - if self.language == "scala" && matches!(kind, "val_definition" | "var_definition") && let Some((class_id, _, _, _)) = parent_declaration @@ -2315,22 +2303,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { } fn declaration_name(&self, node: Node<'tree>) -> Option { - if self.language == "kotlin" - && self.config.class_types.contains(&node.kind()) - && let Some(text) = self.node_text(node) - { - for marker in ["class ", "interface ", "object "] { - if let Some(offset) = text.rfind(marker) - && let Some(name) = text[offset + marker.len()..] - .split_whitespace() - .next() - .map(|name| clean_name(name.to_owned())) - .filter(|name| !name.is_empty()) - { - return Some(name); - } - } - } node.child_by_field_name("name") .and_then(|name| self.node_text(name)) .or_else(|| { @@ -3255,124 +3227,6 @@ impl<'source, 'tree> ExtractState<'source, 'tree> { self.add_edge(class_id, &target, "inherits", line(node), None); } - fn add_kotlin_parent_edges(&mut self, node: Node<'tree>, class_id: &str) { - let mut specifiers = Vec::new(); - collect_nodes_of_kind(node, "delegation_specifier", &mut specifiers); - for specifier in specifiers { - let relation = if first_descendant(specifier, "constructor_invocation").is_some() { - "inherits" - } else { - "implements" - }; - let Some(user_type) = first_descendant(specifier, "user_type") else { - continue; - }; - let Some(name_node) = first_descendant(user_type, "type_identifier") - .or_else(|| first_descendant(user_type, "simple_identifier")) - .or_else(|| first_descendant(user_type, "identifier")) - else { - continue; - }; - let Some(name) = self.node_text(name_node).map(clean_name) else { - continue; - }; - let target = self.ensure_type_node(&name, true); - self.add_edge(class_id, &target, relation, line(node), None); - - let mut arguments = Vec::new(); - collect_nodes_of_kind(user_type, "type_projection", &mut arguments); - for argument in arguments { - let mut refs = Vec::new(); - collect_kotlin_type_refs(argument, self.source, true, &mut refs); - self.add_kotlin_type_references(class_id, &refs, "generic_arg", line(node)); - } - } - } - - fn add_kotlin_property_reference(&mut self, node: Node<'tree>, class_id: &str) { - let Some(type_node) = first_descendant(node, "user_type") - .or_else(|| first_descendant(node, "nullable_type")) - .or_else(|| first_descendant(node, "type_reference")) - else { - return; - }; - let mut refs = Vec::new(); - collect_kotlin_type_refs(type_node, self.source, false, &mut refs); - for (name, generic) in refs { - let target = self.ensure_type_node(&name, true); - if target != class_id { - self.add_edge( - class_id, - &target, - "references", - line(node), - Some(if generic { "generic_arg" } else { "field" }), - ); - } - } - } - - fn add_kotlin_function_references(&mut self, node: Node<'tree>, function_id: &str) { - if let Some(parameters) = first_descendant(node, "function_value_parameters") { - let mut cursor = parameters.walk(); - for parameter in parameters - .children(&mut cursor) - .filter(|child| child.kind() == "parameter") - { - let Some(type_node) = first_descendant(parameter, "user_type") - .or_else(|| first_descendant(parameter, "nullable_type")) - .or_else(|| first_descendant(parameter, "type_reference")) - else { - continue; - }; - let mut refs = Vec::new(); - collect_kotlin_type_refs(type_node, self.source, false, &mut refs); - self.add_kotlin_type_references(function_id, &refs, "parameter_type", line(node)); - } - } - - let mut saw_parameters = false; - let mut saw_colon = false; - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - if child.kind() == "function_value_parameters" { - saw_parameters = true; - continue; - } - if saw_parameters && child.kind() == ":" { - saw_colon = true; - continue; - } - if saw_colon && child.is_named() { - let mut refs = Vec::new(); - collect_kotlin_type_refs(child, self.source, false, &mut refs); - self.add_kotlin_type_references(function_id, &refs, "return_type", line(node)); - break; - } - } - } - - fn add_kotlin_type_references( - &mut self, - source: &str, - refs: &[(String, bool)], - context: &str, - at: usize, - ) { - for (name, generic) in refs { - let target = self.ensure_type_node(name, true); - if target != source { - self.add_edge( - source, - &target, - "references", - at, - Some(if *generic { "generic_arg" } else { context }), - ); - } - } - } - fn add_scala_class_references(&mut self, node: Node<'tree>, class_id: &str) { let extends = node .child_by_field_name("extend") @@ -4401,69 +4255,6 @@ fn collect_c_type_names(node: Node<'_>, source: &[u8], output: &mut Vec) } } -fn collect_kotlin_type_refs( - node: Node<'_>, - source: &[u8], - generic: bool, - output: &mut Vec<(String, bool)>, -) { - if matches!(node.kind(), "integral_literal" | "boolean_literal") { - return; - } - if node.kind() == "user_type" { - if let Some(name_node) = first_descendant(node, "type_identifier") - .or_else(|| first_descendant(node, "simple_identifier")) - .or_else(|| first_descendant(node, "identifier")) - && let Ok(name) = name_node.utf8_text(source) - && !kotlin_builtin_type(name) - { - output.push((name.to_owned(), generic)); - } - let mut arguments = Vec::new(); - collect_nodes_of_kind(node, "type_projection", &mut arguments); - for argument in arguments { - let mut cursor = argument.walk(); - for child in argument - .children(&mut cursor) - .filter(|child| child.is_named()) - { - collect_kotlin_type_refs(child, source, true, output); - } - } - return; - } - if matches!(node.kind(), "identifier" | "type_identifier") { - if let Ok(name) = node.utf8_text(source) - && !kotlin_builtin_type(name) - { - output.push((name.to_owned(), generic)); - } - return; - } - let mut cursor = node.walk(); - for child in node.children(&mut cursor).filter(|child| child.is_named()) { - collect_kotlin_type_refs(child, source, generic, output); - } -} - -fn kotlin_builtin_type(name: &str) -> bool { - matches!( - name, - "String" - | "Int" - | "Long" - | "Short" - | "Byte" - | "Boolean" - | "Char" - | "Float" - | "Double" - | "Unit" - | "Any" - | "Nothing" - ) -} - fn collect_scala_type_refs( node: Node<'_>, source: &[u8], diff --git a/crates/compass-languages/src/evidence/build.rs b/crates/compass-languages/src/evidence/build.rs index 1aaf974e..84e9f345 100644 --- a/crates/compass-languages/src/evidence/build.rs +++ b/crates/compass-languages/src/evidence/build.rs @@ -831,6 +831,9 @@ pub(crate) fn extract_tree_evidence( if profile.language == "php" { return super::php::extract_candidate_tree_evidence(path, source_file, source, root); } + if profile.language == "kotlin" { + return super::kotlin::extract_candidate_tree_evidence(path, source_file, source, root); + } if matches!(profile.language, "javascript" | "typescript") { return super::typescript::extract_candidate_tree_evidence( path, diff --git a/crates/compass-languages/src/evidence/kotlin.rs b/crates/compass-languages/src/evidence/kotlin.rs new file mode 100644 index 00000000..39e506f8 --- /dev/null +++ b/crates/compass-languages/src/evidence/kotlin.rs @@ -0,0 +1,1813 @@ +//! Direct universal evidence for Kotlin source. +//! +//! Kotlin syntax is intentionally resolved only inside the Kotlin language +//! partition. Java/Kotlin interoperability requires an exact compiler or SCIP +//! endpoint and is never inferred from a JVM-family terminal name. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::path::Path; + +use tree_sitter::Node; + +use super::build::{EvidenceBuilder, range_for_file, range_for_node}; +use super::model::{ + BindingKind, CandidateRelation, HierarchyConstraint, ReceiverDispatchStrategy, + ResolutionConstraint, SemanticEvidenceBatch, SemanticRole, SymbolNamespace, +}; +use super::validate::{EvidenceError, EvidenceErrorCode, EvidenceLimits}; +use crate::{AdapterRegistry, file_stem, make_id}; + +const PRODUCER: &str = "compass.languages.kotlin.universal"; +const MAX_TRAVERSAL_DEPTH: usize = 128; +const MAX_SCOPE_DEPTH: usize = 64; + +#[derive(Clone, Debug)] +struct Decl { + id: String, + qualified: String, + kind: String, + scope_id: String, + enclosing_type: Option, +} + +#[derive(Clone, Debug)] +struct Import { + spelling: String, + target: String, + binding_id: String, +} + +struct State<'source> { + source: &'source [u8], + source_file: &'source str, + package: String, + builder: EvidenceBuilder, + file: Decl, + declarations: Vec, + by_node: HashMap, + by_terminal: BTreeMap>, + imports: Vec, + value_types: HashMap<(String, String), String>, + scope_parents: HashMap, + parser_errors: Vec<(usize, usize)>, +} + +pub(super) fn extract_candidate_tree_evidence( + path: &Path, + source_file: &str, + source: &[u8], + root: Node<'_>, +) -> Result { + let profile = AdapterRegistry::universal_profile("kotlin").ok_or_else(|| { + EvidenceError::new( + EvidenceErrorCode::InvalidAdapter, + "Kotlin universal adapter is not registered", + ) + })?; + let mut builder = + EvidenceBuilder::new(profile, PRODUCER, source_file, EvidenceLimits::default()); + let file_range = range_for_file(source_file, source); + let file_graph_id = make_id(&[source_file]); + let file_id = builder.declare( + "file", + &file_graph_id, + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(source_file), + source_file, + Some(&file_stem(Path::new(source_file))), + None, + file_range.clone(), + )?; + let file_scope = builder.open_scope("module", Some(&file_id), None, file_range)?; + if root.end_byte() == root.start_byte() { + return builder.finish(); + } + let file = Decl { + id: file_id, + qualified: source_file.to_owned(), + kind: "file".to_owned(), + scope_id: file_scope, + enclosing_type: None, + }; + let package = package_name(root, source).unwrap_or_else(|| "".to_owned()); + let mut state = State { + source, + source_file, + package, + builder, + file, + declarations: Vec::new(), + by_node: HashMap::new(), + by_terminal: BTreeMap::new(), + imports: Vec::new(), + value_types: HashMap::new(), + scope_parents: HashMap::new(), + parser_errors: Vec::new(), + }; + state.capture_parser_errors(root, 0)?; + let package_owner = state.add_package(root)?; + state.collect_imports(root, &package_owner, 0)?; + state.collect_declarations(root, Some(&package_owner), None, 0)?; + state.collect_value_types(root, Some(&package_owner), 0)?; + state.collect_semantics(root, Some(&package_owner), None, 0)?; + if root.has_error() { + state.builder.diagnose( + "partial_parser_recovery", + None, + Some(range_for_node(source_file, root)), + "parser recovered from malformed Kotlin source; emitted evidence remains source-bounded", + )?; + } + state.builder.finish() +} + +impl<'source> State<'source> { + fn add_package(&mut self, root: Node<'_>) -> Result { + let package_node = direct_named_child(root, "package_header"); + let range = package_node + .and_then(|node| direct_named_child(node, "identifier")) + .map_or_else( + || range_for_node(self.source_file, root), + |node| range_for_node(self.source_file, node), + ); + let name = self.package.clone(); + let graph_id = make_id(&["kotlin", "package", &name]); + let id = self.builder.declare_with_namespace( + "package", + &graph_id, + &name, + &name, + Some(&name), + Some(&self.file.scope_id), + Some(SymbolNamespace::Namespace), + range, + )?; + let scope_id = self.builder.open_scope( + "package", + Some(&id), + Some(&self.file.scope_id), + range_for_node(self.source_file, root), + )?; + self.scope_parents + .insert(scope_id.clone(), self.file.scope_id.clone()); + self.own(&self.file.id.clone(), &id)?; + Ok(Decl { + id, + qualified: name, + kind: "package".to_owned(), + scope_id, + enclosing_type: None, + }) + } + + fn collect_imports( + &mut self, + node: Node<'_>, + owner: &Decl, + depth: usize, + ) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(node); + } + if node.kind() == "import_header" { + if !self.trusted(node) { + return Ok(()); + } + let Some(target_node) = direct_named_child(node, "identifier") else { + return Ok(()); + }; + let target = self.text(target_node).trim().to_owned(); + if target.is_empty() || target.ends_with(".*") { + return Ok(()); + } + let alias_node = + direct_named_child(node, "import_alias").and_then(|alias| first_identifier(alias)); + let spelling = alias_node + .map(|alias| self.text(alias).trim().to_owned()) + .unwrap_or_else(|| terminal(&target).to_owned()); + if spelling.is_empty() { + return Ok(()); + } + let binding_id = self.builder.bind_with_identity( + if alias_node.is_some() { + BindingKind::ImportAlias + } else { + BindingKind::Import + }, + &spelling, + &target, + None, + Some(&owner.scope_id), + Some(SymbolNamespace::ValueAndType), + false, + alias_node.map_or_else( + || range_for_node(self.source_file, target_node), + |alias| range_for_node(self.source_file, alias), + ), + )?; + let occurrence_id = self.builder.occur( + SemanticRole::Import, + &owner.id, + &spelling, + qualified_parent(&target), + Some(&owner.scope_id), + range_for_node(self.source_file, target_node), + )?; + self.builder.relate( + CandidateRelation::Imports, + &owner.id, + Some(&occurrence_id), + Some(&binding_id), + &spelling, + ResolutionConstraint { + exact_target_declaration_id: None, + exact_language: Some("kotlin".to_owned()), + module_or_package: qualified_parent(&target).map(str::to_owned), + scope_id: Some(owner.scope_id.clone()), + qualified_name: Some(target.clone()), + argument_count: None, + argument_types: Vec::new(), + allowed_target_kinds: kotlin_import_target_kinds(), + hierarchy: None, + allow_external: true, + }, + )?; + self.imports.push(Import { + spelling, + target, + binding_id, + }); + return Ok(()); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + self.collect_imports(child, owner, depth + 1)?; + } + Ok(()) + } + + fn collect_declarations( + &mut self, + node: Node<'_>, + owner: Option<&Decl>, + enclosing_type: Option<&str>, + depth: usize, + ) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(node); + } + if matches!(node.kind(), "import_header" | "package_header") { + return Ok(()); + } + if is_type_node(node.kind()) { + if !self.trusted(node) { + return Ok(()); + } + let kind = kotlin_type_kind(node, self.source); + let name_node = direct_named_child(node, "type_identifier"); + let name = name_node + .map(|name| self.text(name).trim().to_owned()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Companion".to_owned()); + let qualified = enclosing_type.map_or_else( + || join_qualified(&self.package, &name, "."), + |parent| join_qualified(parent, &name, "."), + ); + let signature = type_parameter_signature(node, self.source); + let graph_id = make_id(&["kotlin", kind, &qualified]); + let parent_scope = owner.map_or(&self.file.scope_id, |owner| &owner.scope_id); + let id = self.builder.declare_type( + kind, + &graph_id, + &name, + &qualified, + Some(&self.package), + Some(parent_scope), + Some(SymbolNamespace::ValueAndType), + signature.as_deref(), + direct_bases_complete(node), + name_node.map_or_else( + || range_for_node(self.source_file, node), + |name| range_for_node(self.source_file, name), + ), + )?; + let scope_id = self.builder.open_scope( + kind, + Some(&id), + Some(parent_scope), + range_for_node(self.source_file, node), + )?; + self.scope_parents + .insert(scope_id.clone(), parent_scope.clone()); + let decl = Decl { + id: id.clone(), + qualified: qualified.clone(), + kind: kind.to_owned(), + scope_id, + enclosing_type: Some(qualified.clone()), + }; + let index = self.declarations.len(); + self.declarations.push(decl.clone()); + self.by_node.insert(node.id(), index); + self.by_terminal.entry(name).or_default().push(index); + let owner_id = owner.map_or_else(|| self.file.id.clone(), |owner| owner.id.clone()); + self.own(&owner_id, &id)?; + self.add_primary_constructor(node, &decl)?; + self.add_promoted_properties(node, &decl)?; + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + if child.kind() != "primary_constructor" { + self.collect_declarations(child, Some(&decl), Some(&qualified), depth + 1)?; + } + } + return Ok(()); + } + if node.kind() == "function_declaration" { + self.add_function(node, owner, enclosing_type)?; + return Ok(()); + } + if node.kind() == "secondary_constructor" { + self.add_secondary_constructor(node, owner)?; + return Ok(()); + } + if node.kind() == "property_declaration" { + self.add_property(node, owner, enclosing_type)?; + return Ok(()); + } + if node.kind() == "type_alias" { + self.add_type_alias(node, owner)?; + return Ok(()); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + self.collect_declarations(child, owner, enclosing_type, depth + 1)?; + } + Ok(()) + } + + fn add_primary_constructor( + &mut self, + node: Node<'_>, + owner: &Decl, + ) -> Result<(), EvidenceError> { + let Some(constructor) = direct_named_child(node, "primary_constructor") else { + return Ok(()); + }; + let parameters = kotlin_parameters(constructor, self.source); + let signature = kotlin_callable_signature("", None, ¶meters); + let graph_id = make_id(&["kotlin", "constructor", &owner.qualified, &signature]); + let id = self.builder.declare_callable( + "constructor", + &graph_id, + "", + &format!("{}::", owner.qualified), + Some(&self.package), + Some(&owner.scope_id), + Some(SymbolNamespace::Value), + Some(&signature), + parameters + .iter() + .map(|parameter| parameter.kind.clone()) + .collect(), + parameters.iter().any(|parameter| parameter.variadic), + range_for_node(self.source_file, constructor), + )?; + self.own(&owner.id, &id) + } + + fn add_promoted_properties( + &mut self, + node: Node<'_>, + owner: &Decl, + ) -> Result<(), EvidenceError> { + let Some(constructor) = direct_named_child(node, "primary_constructor") else { + return Ok(()); + }; + let mut cursor = constructor.walk(); + for parameter in constructor + .children(&mut cursor) + .filter(|child| child.kind() == "class_parameter") + { + if direct_named_child(parameter, "binding_pattern_kind").is_none() { + continue; + } + let Some(name_node) = direct_named_child(parameter, "simple_identifier") else { + continue; + }; + let name = self.text(name_node).trim().to_owned(); + if name.is_empty() { + continue; + } + let qualified = format!("{}::{name}", owner.qualified); + let graph_id = make_id(&["kotlin", "property", &qualified]); + let type_text = + parameter_type_node(parameter).map(|kind| self.text(kind).trim().to_owned()); + let id = self.builder.declare_with_signature( + "property", + &graph_id, + &name, + &qualified, + Some(&self.package), + Some(&owner.scope_id), + Some(SymbolNamespace::Value), + type_text.as_deref(), + range_for_node(self.source_file, name_node), + )?; + self.own(&owner.id, &id)?; + } + Ok(()) + } + + fn add_function( + &mut self, + node: Node<'_>, + owner: Option<&Decl>, + enclosing_type: Option<&str>, + ) -> Result<(), EvidenceError> { + if !self.trusted(node) { + return Ok(()); + } + let Some(name_node) = function_name_node(node) else { + return Ok(()); + }; + let name = self.text(name_node).trim().to_owned(); + if name.is_empty() { + return Ok(()); + } + let receiver = node + .child_by_field_name("receiver") + .or_else(|| direct_named_child(node, "receiver_type")) + .map(|receiver| normalize_type(self.text(receiver))); + let parameters = kotlin_parameters(node, self.source); + let signature = kotlin_callable_signature(&name, receiver.as_deref(), ¶meters); + let qualified = enclosing_type.map_or_else( + || format!("{}::{name}", self.package), + |owner| format!("{owner}::{name}"), + ); + let graph_id = make_id(&["kotlin", "function", &qualified, &signature]); + let parent_scope = owner.map_or(&self.file.scope_id, |owner| &owner.scope_id); + let id = self.builder.declare_callable( + if enclosing_type.is_some() { + "method" + } else { + "function" + }, + &graph_id, + &name, + &qualified, + Some(&self.package), + Some(parent_scope), + Some(SymbolNamespace::Value), + Some(&signature), + parameters + .iter() + .map(|parameter| parameter.kind.clone()) + .collect(), + parameters.iter().any(|parameter| parameter.variadic), + range_for_node(self.source_file, name_node), + )?; + let scope_id = self.builder.open_scope( + "function", + Some(&id), + Some(parent_scope), + range_for_node(self.source_file, node), + )?; + self.scope_parents + .insert(scope_id.clone(), parent_scope.clone()); + let decl = Decl { + id: id.clone(), + qualified, + kind: if enclosing_type.is_some() { + "method".to_owned() + } else { + "function".to_owned() + }, + scope_id, + enclosing_type: enclosing_type.map(str::to_owned), + }; + let index = self.declarations.len(); + self.declarations.push(decl.clone()); + self.by_node.insert(node.id(), index); + self.by_terminal.entry(name).or_default().push(index); + let owner_id = owner.map_or_else(|| self.file.id.clone(), |owner| owner.id.clone()); + self.own(&owner_id, &id)?; + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + if child.kind() == "function_body" { + self.collect_declarations(child, Some(&decl), enclosing_type, 1)?; + } + } + Ok(()) + } + + fn add_secondary_constructor( + &mut self, + node: Node<'_>, + owner: Option<&Decl>, + ) -> Result<(), EvidenceError> { + let Some(owner) = owner.filter(|owner| owner.enclosing_type.is_some()) else { + return Ok(()); + }; + let parameters = kotlin_parameters(node, self.source); + let signature = kotlin_callable_signature("", None, ¶meters); + let qualified = format!("{}::", owner.qualified); + let graph_id = make_id(&["kotlin", "constructor", &qualified, &signature]); + let id = self.builder.declare_callable( + "constructor", + &graph_id, + "", + &qualified, + Some(&self.package), + Some(&owner.scope_id), + Some(SymbolNamespace::Value), + Some(&signature), + parameters + .iter() + .map(|parameter| parameter.kind.clone()) + .collect(), + parameters.iter().any(|parameter| parameter.variadic), + range_for_node(self.source_file, node), + )?; + let scope_id = self.builder.open_scope( + "constructor", + Some(&id), + Some(&owner.scope_id), + range_for_node(self.source_file, node), + )?; + self.scope_parents + .insert(scope_id.clone(), owner.scope_id.clone()); + let decl = Decl { + id: id.clone(), + qualified, + kind: "constructor".to_owned(), + scope_id, + enclosing_type: owner.enclosing_type.clone(), + }; + let index = self.declarations.len(); + self.declarations.push(decl.clone()); + self.by_node.insert(node.id(), index); + self.own(&owner.id, &id)?; + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + self.collect_declarations(child, Some(&decl), owner.enclosing_type.as_deref(), 1)?; + } + Ok(()) + } + + fn add_property( + &mut self, + node: Node<'_>, + owner: Option<&Decl>, + enclosing_type: Option<&str>, + ) -> Result<(), EvidenceError> { + let Some(variable) = direct_named_child(node, "variable_declaration") else { + return Ok(()); + }; + let Some(name_node) = direct_named_child(variable, "simple_identifier") else { + return Ok(()); + }; + let name = self.text(name_node).trim().to_owned(); + if name.is_empty() { + return Ok(()); + } + let qualified = enclosing_type.map_or_else( + || format!("{}::{name}", self.package), + |owner| format!("{owner}::{name}"), + ); + let kind = if modifier_contains(node, self.source, "const") { + "constant" + } else { + "property" + }; + let graph_id = make_id(&["kotlin", kind, &qualified]); + let parent_scope = owner.map_or(&self.file.scope_id, |owner| &owner.scope_id); + let type_text = parameter_type_node(variable).map(|kind| self.text(kind).trim().to_owned()); + let id = self.builder.declare_with_signature( + kind, + &graph_id, + &name, + &qualified, + Some(&self.package), + Some(parent_scope), + Some(SymbolNamespace::Value), + type_text.as_deref(), + range_for_node(self.source_file, name_node), + )?; + let decl = Decl { + id: id.clone(), + qualified, + kind: kind.to_owned(), + scope_id: parent_scope.clone(), + enclosing_type: enclosing_type.map(str::to_owned), + }; + let index = self.declarations.len(); + self.declarations.push(decl); + self.by_node.insert(node.id(), index); + self.by_terminal.entry(name).or_default().push(index); + let owner_id = owner.map_or_else(|| self.file.id.clone(), |owner| owner.id.clone()); + self.own(&owner_id, &id) + } + + fn add_type_alias( + &mut self, + node: Node<'_>, + owner: Option<&Decl>, + ) -> Result<(), EvidenceError> { + let Some(name_node) = direct_named_child(node, "type_identifier") else { + return Ok(()); + }; + let name = self.text(name_node).trim().to_owned(); + if name.is_empty() { + return Ok(()); + } + let qualified = join_qualified(&self.package, &name, "."); + let target = u32::try_from(node.named_child_count().saturating_sub(1)) + .ok() + .and_then(|index| node.named_child(index)) + .map(|target| normalize_type(self.text(target))) + .unwrap_or_default(); + let graph_id = make_id(&["kotlin", "type_alias", &qualified]); + let parent_scope = owner.map_or(&self.file.scope_id, |owner| &owner.scope_id); + let id = self.builder.declare_with_signature( + "type_alias", + &graph_id, + &name, + &qualified, + Some(&self.package), + Some(parent_scope), + Some(SymbolNamespace::Type), + (!target.is_empty()).then_some(target.as_str()), + range_for_node(self.source_file, name_node), + )?; + self.by_terminal + .entry(name.clone()) + .or_default() + .push(self.declarations.len()); + self.declarations.push(Decl { + id: id.clone(), + qualified, + kind: "type_alias".to_owned(), + scope_id: parent_scope.clone(), + enclosing_type: None, + }); + self.by_node.insert(node.id(), self.declarations.len() - 1); + let owner_id = owner.map_or_else(|| self.file.id.clone(), |owner| owner.id.clone()); + self.own(&owner_id, &id) + } + + fn collect_value_types( + &mut self, + node: Node<'_>, + active: Option<&Decl>, + depth: usize, + ) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(node); + } + let owned = self + .by_node + .get(&node.id()) + .map(|index| self.declarations[*index].clone()); + let current = owned.as_ref().or(active); + if matches!(node.kind(), "parameter" | "class_parameter") + && let (Some(owner), Some(name), Some(kind)) = ( + current, + direct_named_child(node, "simple_identifier"), + parameter_type_node(node), + ) + { + let name = self.text(name).trim().to_owned(); + let kind = self.resolve_type(&normalize_type(self.text(kind))); + if !name.is_empty() + && let Some(kind) = kind + { + self.value_types + .insert((owner.scope_id.clone(), name), kind); + } + } + if node.kind() == "property_declaration" + && let (Some(owner), Some(variable)) = + (current, direct_named_child(node, "variable_declaration")) + && let Some(name_node) = direct_named_child(variable, "simple_identifier") + { + let name = self.text(name_node).trim().to_owned(); + let explicit = parameter_type_node(variable) + .and_then(|kind| self.resolve_type(&normalize_type(self.text(kind)))); + let inferred = explicit.or_else(|| { + inferred_initializer_type(node, self.source) + .and_then(|kind| self.resolve_type(&kind)) + }); + if !name.is_empty() + && let Some(kind) = inferred + { + self.value_types + .insert((owner.scope_id.clone(), name), kind); + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + self.collect_value_types(child, current, depth + 1)?; + } + Ok(()) + } + + fn collect_semantics( + &mut self, + node: Node<'_>, + active: Option<&Decl>, + behavioral: Option<&Decl>, + depth: usize, + ) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(node); + } + let owned = self + .by_node + .get(&node.id()) + .map(|index| self.declarations[*index].clone()); + let current = owned.as_ref().or(active); + let current_behavioral = owned + .as_ref() + .filter(|owner| is_behavioral_owner(owner)) + .or(behavioral); + if let Some(owner) = current { + if owned.is_some() { + self.add_annotations(node, owner)?; + self.add_declaration_type_references(node, owner)?; + if is_type_node(node.kind()) { + self.add_base_types(node, owner)?; + } + } + match (node.kind(), current_behavioral) { + ("call_expression", Some(callable)) => self.add_call(node, callable)?, + ("navigation_expression", Some(callable)) + if node + .parent() + .is_none_or(|parent| parent.kind() != "call_expression") => + { + self.add_member_access(node, callable)?; + } + _ => {} + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + if child.kind() != "import_header" { + self.collect_semantics(child, current, current_behavioral, depth + 1)?; + } + } + Ok(()) + } + + fn add_annotations(&mut self, node: Node<'_>, owner: &Decl) -> Result<(), EvidenceError> { + let Some(modifiers) = direct_named_child(node, "modifiers") else { + return Ok(()); + }; + let mut annotations = Vec::new(); + collect_nodes(modifiers, "annotation", &mut annotations, 0); + for annotation in annotations { + let Some(type_node) = + first_descendant(annotation, &["user_type", "type_identifier"], 0) + else { + continue; + }; + let raw = normalize_type(self.text(type_node)); + let spelling = terminal(&raw); + if spelling.is_empty() { + continue; + } + let binding = self.import_for(spelling).cloned(); + let qualified = self.resolve_type(&raw); + let occurrence_id = self.builder.occur( + SemanticRole::Annotation, + &owner.id, + spelling, + qualified_parent(&raw), + Some(&owner.scope_id), + range_for_node(self.source_file, type_node), + )?; + self.builder.relate( + CandidateRelation::Annotates, + &owner.id, + Some(&occurrence_id), + binding.as_ref().map(|binding| binding.binding_id.as_str()), + spelling, + ResolutionConstraint { + exact_target_declaration_id: None, + exact_language: Some("kotlin".to_owned()), + module_or_package: Some(self.package.clone()), + scope_id: Some(owner.scope_id.clone()), + qualified_name: qualified, + argument_count: None, + argument_types: Vec::new(), + allowed_target_kinds: vec!["annotation_type".to_owned(), "class".to_owned()], + hierarchy: None, + allow_external: true, + }, + )?; + } + Ok(()) + } + + fn add_declaration_type_references( + &mut self, + node: Node<'_>, + owner: &Decl, + ) -> Result<(), EvidenceError> { + let mut roots = Vec::new(); + match node.kind() { + "function_declaration" => { + if let Some(receiver) = node + .child_by_field_name("receiver") + .or_else(|| direct_named_child(node, "receiver_type")) + { + roots.push((receiver, "extension_receiver")); + } + if let Some(parameters) = direct_named_child(node, "function_value_parameters") { + let mut parameter_nodes = Vec::new(); + collect_nodes(parameters, "parameter", &mut parameter_nodes, 0); + for parameter in parameter_nodes { + if let Some(kind) = parameter_type_node(parameter) { + roots.push((kind, "parameter_type")); + } + } + } + if let Some(return_type) = function_return_type(node) { + roots.push((return_type, "return_type")); + } + } + "property_declaration" => { + if let Some(variable) = direct_named_child(node, "variable_declaration") + && let Some(kind) = parameter_type_node(variable) + { + roots.push((kind, "property_type")); + } + } + "type_alias" => { + if let Some(target) = u32::try_from(node.named_child_count().saturating_sub(1)) + .ok() + .and_then(|index| node.named_child(index)) + { + roots.push((target, "alias_target")); + } + } + _ if is_type_node(node.kind()) => { + if let Some(parameters) = direct_named_child(node, "type_parameters") { + roots.push((parameters, "generic_bound")); + } + if let Some(constructor) = direct_named_child(node, "primary_constructor") { + roots.push((constructor, "constructor_parameter_type")); + } + } + _ => {} + } + for (root, context) in roots { + let mut types = Vec::new(); + collect_type_nodes(root, &mut types, 0); + types.sort_by_key(Node::start_byte); + types.dedup_by_key(|kind| (kind.start_byte(), kind.end_byte())); + for kind in types { + self.add_type_reference(owner, kind, context)?; + } + } + Ok(()) + } + + fn add_type_reference( + &mut self, + owner: &Decl, + node: Node<'_>, + context: &str, + ) -> Result<(), EvidenceError> { + let raw = normalize_type(self.text(node)); + let spelling = terminal(&raw); + if spelling.is_empty() || kotlin_builtin_type(spelling) { + return Ok(()); + } + let binding = self.import_for(spelling).cloned(); + let occurrence_id = self.builder.occur_with_context( + SemanticRole::TypeReference, + &owner.id, + spelling, + qualified_parent(&raw), + Some(&owner.scope_id), + Some(context), + range_for_node(self.source_file, node), + )?; + self.builder.relate( + CandidateRelation::References, + &owner.id, + Some(&occurrence_id), + binding.as_ref().map(|binding| binding.binding_id.as_str()), + spelling, + ResolutionConstraint { + exact_target_declaration_id: None, + exact_language: Some("kotlin".to_owned()), + module_or_package: Some(self.package.clone()), + scope_id: Some(owner.scope_id.clone()), + qualified_name: self.resolve_type(&raw), + argument_count: None, + argument_types: Vec::new(), + allowed_target_kinds: kotlin_type_target_kinds(), + hierarchy: None, + allow_external: true, + }, + )?; + Ok(()) + } + + fn add_base_types(&mut self, node: Node<'_>, owner: &Decl) -> Result<(), EvidenceError> { + let mut specifications = Vec::new(); + let mut cursor = node.walk(); + specifications.extend( + node.children(&mut cursor) + .filter(|child| child.kind() == "delegation_specifier"), + ); + let complete = direct_bases_complete(node); + for specification in specifications { + let Some(type_node) = + first_descendant(specification, &["user_type", "type_identifier"], 0) + else { + continue; + }; + let raw = normalize_type(self.text(type_node)); + let spelling = terminal(&raw); + if spelling.is_empty() { + continue; + } + let extends = owner.kind != "interface" + && first_descendant(specification, &["constructor_invocation"], 0).is_some(); + let relation = if extends || owner.kind == "interface" { + CandidateRelation::Extends + } else { + CandidateRelation::Implements + }; + let binding = self.import_for(spelling).cloned(); + let occurrence_id = self.builder.occur( + SemanticRole::BaseType, + &owner.id, + spelling, + qualified_parent(&raw), + Some(&owner.scope_id), + range_for_node(self.source_file, type_node), + )?; + self.builder.relate( + relation, + &owner.id, + Some(&occurrence_id), + binding.as_ref().map(|binding| binding.binding_id.as_str()), + spelling, + ResolutionConstraint { + exact_target_declaration_id: None, + exact_language: Some("kotlin".to_owned()), + module_or_package: Some(self.package.clone()), + scope_id: Some(owner.scope_id.clone()), + qualified_name: self.resolve_type(&raw), + argument_count: None, + argument_types: Vec::new(), + allowed_target_kinds: kotlin_type_target_kinds(), + hierarchy: Some(HierarchyConstraint::DirectBase { + base_set_complete: complete, + }), + allow_external: true, + }, + )?; + } + Ok(()) + } + + fn add_call(&mut self, node: Node<'_>, owner: &Decl) -> Result<(), EvidenceError> { + if !self.trusted(node) { + return Ok(()); + } + let Some(callee) = node.named_child(0) else { + return Ok(()); + }; + let (qualifier, name_node) = match callee.kind() { + "simple_identifier" | "type_identifier" => (None, callee), + "navigation_expression" => { + let Some(name) = navigation_member(callee) else { + return Ok(()); + }; + let qualifier = + navigation_receiver(callee).map(|receiver| self.text(receiver).to_owned()); + (qualifier, name) + } + _ => return Ok(()), + }; + let spelling = self.text(name_node).trim().to_owned(); + if spelling.is_empty() { + return Ok(()); + } + let arguments = call_arguments(node, self.source); + let argument_types = arguments + .iter() + .map(|argument| self.expression_type(owner, argument.node, 0)) + .collect::>(); + let argument_context = kotlin_argument_context(&arguments); + let construction = spelling.starts_with(char::is_uppercase); + let receiver_type = qualifier + .as_deref() + .and_then(|receiver| self.receiver_type(owner, receiver)); + // An imported extension remains a candidate at a qualified call site, + // but the Kotlin resolver checks a real member first. + let binding = self.import_for(&spelling).cloned(); + let qualified_name = if construction { + self.resolve_type(&spelling) + } else if let Some(receiver) = receiver_type.as_ref() { + Some(format!("{receiver}::{spelling}")) + } else if let Some(binding) = binding.as_ref() { + Some(imported_callable_name(&binding.target)) + } else if qualifier.is_none() { + owner + .enclosing_type + .as_ref() + .map(|container| format!("{container}::{spelling}")) + .or_else(|| Some(format!("{}::{spelling}", self.package))) + } else { + None + }; + let constrained_qualified_name = + receiver_type.is_none().then_some(qualified_name).flatten(); + let role = if construction { + SemanticRole::Construction + } else { + SemanticRole::Call + }; + let occurrence_id = self.builder.occur_with_context( + role, + &owner.id, + &spelling, + qualifier.as_deref(), + Some(&owner.scope_id), + Some(&argument_context), + range_for_node(self.source_file, name_node), + )?; + self.builder.relate( + if construction { + CandidateRelation::Constructs + } else { + CandidateRelation::Calls + }, + &owner.id, + Some(&occurrence_id), + binding.as_ref().map(|binding| binding.binding_id.as_str()), + &spelling, + ResolutionConstraint { + exact_target_declaration_id: None, + exact_language: Some("kotlin".to_owned()), + module_or_package: Some(self.package.clone()), + scope_id: Some(owner.scope_id.clone()), + qualified_name: constrained_qualified_name, + argument_count: Some(u32::try_from(arguments.len()).unwrap_or(u32::MAX)), + argument_types, + allowed_target_kinds: if construction { + vec![ + "class".to_owned(), + "enum".to_owned(), + "object".to_owned(), + "annotation_type".to_owned(), + ] + } else { + vec!["function".to_owned(), "method".to_owned()] + }, + hierarchy: receiver_type.as_ref().map(|receiver_qualified_name| { + HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name: receiver_qualified_name.clone(), + strategy: ReceiverDispatchStrategy::C3FromReceiver, + } + }), + allow_external: construction || binding.is_some() || receiver_type.is_some(), + }, + )?; + Ok(()) + } + + fn add_member_access(&mut self, node: Node<'_>, owner: &Decl) -> Result<(), EvidenceError> { + let Some(name_node) = navigation_member(node) else { + return Ok(()); + }; + let Some(receiver_node) = navigation_receiver(node) else { + return Ok(()); + }; + let spelling = self.text(name_node).trim().to_owned(); + let qualifier = self.text(receiver_node).trim().to_owned(); + let Some(receiver_type) = self.receiver_type(owner, &qualifier) else { + return Ok(()); + }; + let occurrence_id = self.builder.occur( + SemanticRole::MemberAccess, + &owner.id, + &spelling, + Some(&qualifier), + Some(&owner.scope_id), + range_for_node(self.source_file, name_node), + )?; + self.builder.relate( + CandidateRelation::AccessesMember, + &owner.id, + Some(&occurrence_id), + None, + &spelling, + ResolutionConstraint { + exact_target_declaration_id: None, + exact_language: Some("kotlin".to_owned()), + module_or_package: Some(self.package.clone()), + scope_id: Some(owner.scope_id.clone()), + qualified_name: None, + argument_count: None, + argument_types: Vec::new(), + allowed_target_kinds: vec!["property".to_owned(), "constant".to_owned()], + hierarchy: Some(HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name: receiver_type, + strategy: ReceiverDispatchStrategy::C3FromReceiver, + }), + allow_external: true, + }, + )?; + Ok(()) + } + + fn expression_type(&self, owner: &Decl, node: Node<'_>, depth: usize) -> Option { + if depth >= 8 { + return None; + } + match node.kind() { + "simple_identifier" => self.local_value_type(owner, self.text(node).trim()), + "string_literal" | "line_string_literal" | "multi_line_string_literal" => { + Some("kotlin.String".to_owned()) + } + "character_literal" => Some("kotlin.Char".to_owned()), + "boolean_literal" => Some("kotlin.Boolean".to_owned()), + "null_literal" => Some("null".to_owned()), + "integer_literal" => Some( + if self.text(node).ends_with(['l', 'L']) { + "kotlin.Long" + } else { + "kotlin.Int" + } + .to_owned(), + ), + "real_literal" => Some( + if self.text(node).ends_with(['f', 'F']) { + "kotlin.Float" + } else { + "kotlin.Double" + } + .to_owned(), + ), + "call_expression" => node.named_child(0).and_then(|callee| { + let spelling = terminal(self.text(callee).trim()); + spelling + .starts_with(char::is_uppercase) + .then(|| self.resolve_type(spelling)) + .flatten() + }), + "parenthesized_expression" => node + .named_child(0) + .and_then(|inner| self.expression_type(owner, inner, depth + 1)), + _ => None, + } + } + + fn receiver_type(&self, owner: &Decl, receiver: &str) -> Option { + let receiver = receiver.trim(); + if receiver == "this" || receiver.starts_with("this@") || receiver == "super" { + return owner.enclosing_type.clone(); + } + if let Some(kind) = self.local_value_type(owner, receiver) { + return Some(kind); + } + if receiver.starts_with(char::is_uppercase) { + return self.resolve_type(receiver); + } + None + } + + fn local_value_type(&self, owner: &Decl, name: &str) -> Option { + let mut scope = Some(owner.scope_id.as_str()); + for _ in 0..MAX_SCOPE_DEPTH { + let current = scope?; + if let Some(kind) = self.value_types.get(&(current.to_owned(), name.to_owned())) { + return Some(kind.clone()); + } + scope = self.scope_parents.get(current).map(String::as_str); + } + None + } + + fn resolve_type(&self, raw: &str) -> Option { + let normalized = normalize_type(raw); + let base = erase_type_arguments(&normalized); + if base.is_empty() { + return None; + } + if let Some(builtin) = kotlin_builtin_qualified(&base) { + return Some(builtin); + } + if base.contains('.') && base.starts_with(char::is_lowercase) { + return Some(base); + } + let spelling = terminal(&base); + let imported = self + .imports + .iter() + .filter(|import| import.spelling == spelling) + .map(|import| import.target.as_str()) + .collect::>(); + if let [target] = imported.into_iter().collect::>().as_slice() { + return Some((*target).to_owned()); + } + let local = self + .by_terminal + .get(spelling) + .into_iter() + .flatten() + .filter_map(|index| { + self.declarations + .get(*index) + .map(|decl| decl.qualified.as_str()) + }) + .collect::>(); + if let [target] = local.into_iter().collect::>().as_slice() { + return Some((*target).to_owned()); + } + Some(join_qualified(&self.package, &base, ".")) + } + + fn import_for(&self, spelling: &str) -> Option<&Import> { + let mut imports = self + .imports + .iter() + .filter(|import| import.spelling == spelling); + let only = imports.next()?; + imports.next().is_none().then_some(only) + } + + fn capture_parser_errors(&mut self, node: Node<'_>, depth: usize) -> Result<(), EvidenceError> { + if depth > MAX_TRAVERSAL_DEPTH { + return self.depth_diagnostic(node); + } + if node.is_error() || node.is_missing() { + let start = node.start_byte().min(self.source.len()); + let mut end = node.end_byte().min(self.source.len()); + if end <= start { + end = self.source[start..] + .iter() + .position(|byte| *byte == b'\n') + .map_or(self.source.len(), |offset| { + start.saturating_add(offset).max(start + 1) + }); + } + self.parser_errors.push((start, end)); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + self.capture_parser_errors(child, depth + 1)?; + } + self.parser_errors.sort_unstable(); + self.parser_errors.dedup(); + Ok(()) + } + + fn trusted(&self, node: Node<'_>) -> bool { + !self.parser_errors.iter().any(|(start, end)| { + node.start_byte() < *end && node.end_byte().max(node.start_byte() + 1) > *start + }) + } + + fn depth_diagnostic(&mut self, node: Node<'_>) -> Result<(), EvidenceError> { + self.builder.diagnose( + "kotlin_traversal_limit", + None, + Some(range_for_node(self.source_file, node)), + "Kotlin syntax traversal exceeded its bounded depth", + ) + } + + fn own(&mut self, owner_id: &str, member_id: &str) -> Result<(), EvidenceError> { + self.builder.relate( + CandidateRelation::Owns, + owner_id, + None, + None, + member_id, + ResolutionConstraint { + exact_target_declaration_id: Some(member_id.to_owned()), + exact_language: Some("kotlin".to_owned()), + module_or_package: Some(self.package.clone()), + scope_id: None, + qualified_name: None, + argument_count: None, + argument_types: Vec::new(), + allowed_target_kinds: Vec::new(), + hierarchy: None, + allow_external: false, + }, + )?; + Ok(()) + } + + fn text(&self, node: Node<'_>) -> &str { + self.source + .get(node.start_byte()..node.end_byte()) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .unwrap_or_default() + } +} + +fn is_behavioral_owner(owner: &Decl) -> bool { + matches!(owner.kind.as_str(), "constructor" | "function" | "method") +} + +#[derive(Clone, Debug)] +struct KotlinParameter { + name: String, + kind: String, + defaulted: bool, + variadic: bool, +} + +#[derive(Clone, Copy)] +struct KotlinArgument<'tree> { + node: Node<'tree>, + name: Option<&'tree str>, +} + +fn package_name(root: Node<'_>, source: &[u8]) -> Option { + let header = direct_named_child(root, "package_header")?; + let identifier = direct_named_child(header, "identifier")?; + node_text(source, identifier) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_owned) +} + +fn is_type_node(kind: &str) -> bool { + matches!( + kind, + "class_declaration" | "object_declaration" | "companion_object" + ) +} + +fn kotlin_type_kind(node: Node<'_>, source: &[u8]) -> &'static str { + if node.kind() == "object_declaration" { + return "object"; + } + if node.kind() == "companion_object" { + return "companion_object"; + } + let declaration = node_text(source, node).unwrap_or_default().trim_start(); + let modifiers = direct_named_child(node, "modifiers") + .and_then(|modifiers| node_text(source, modifiers)) + .unwrap_or_default(); + if declaration.starts_with("interface ") || has_direct_token(node, "interface") { + "interface" + } else if declaration.starts_with("enum class ") || has_direct_token(node, "enum") { + "enum" + } else if declaration.starts_with("annotation class ") + || has_direct_token(node, "annotation") + || modifiers + .split_whitespace() + .any(|part| part == "annotation") + { + "annotation_type" + } else { + "class" + } +} + +fn has_direct_token(node: Node<'_>, expected: &str) -> bool { + let mut cursor = node.walk(); + node.children(&mut cursor) + .any(|child| child.kind() == expected) +} + +fn direct_bases_complete(node: Node<'_>) -> bool { + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.kind() == "delegation_specifier") + .all(|child| !child.has_error()) +} + +fn modifier_contains(node: Node<'_>, source: &[u8], expected: &str) -> bool { + direct_named_child(node, "modifiers") + .and_then(|modifiers| node_text(source, modifiers)) + .is_some_and(|modifiers| modifiers.split_whitespace().any(|part| part == expected)) +} + +fn function_name_node(node: Node<'_>) -> Option> { + node.child_by_field_name("name").or_else(|| { + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named()) + .find(|child| child.kind() == "simple_identifier") + }) +} + +fn function_return_type(node: Node<'_>) -> Option> { + let parameters = direct_named_child(node, "function_value_parameters")?; + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named() && child.start_byte() >= parameters.end_byte()) + .find(|child| is_type_syntax(child.kind())) +} + +fn parameter_type_node(node: Node<'_>) -> Option> { + node.child_by_field_name("type").or_else(|| { + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named()) + .find(|child| is_type_syntax(child.kind())) + }) +} + +fn is_type_syntax(kind: &str) -> bool { + matches!( + kind, + "user_type" + | "nullable_type" + | "function_type" + | "parenthesized_type" + | "dynamic_type" + | "type_identifier" + ) +} + +fn kotlin_parameters(node: Node<'_>, source: &[u8]) -> Vec { + let root = direct_named_child(node, "function_value_parameters") + .or_else(|| direct_named_child(node, "primary_constructor")) + .unwrap_or(node); + let expected = if root.kind() == "primary_constructor" { + "class_parameter" + } else { + "parameter" + }; + let mut cursor = root.walk(); + let mut parameters = root + .children(&mut cursor) + .filter(|child| child.kind() == expected) + .collect::>(); + parameters.sort_by_key(Node::start_byte); + parameters.dedup_by_key(|parameter| parameter.id()); + let segments = node_text(source, root) + .map(split_top_level_parameters) + .unwrap_or_default(); + parameters + .into_iter() + .enumerate() + .filter_map(|(index, parameter)| { + let name = direct_named_child(parameter, "simple_identifier") + .and_then(|name| node_text(source, name))? + .trim() + .to_owned(); + let kind = parameter_type_node(parameter) + .and_then(|kind| node_text(source, kind)) + .map(normalize_type) + .unwrap_or_else(|| "_".to_owned()); + let text = segments + .get(index) + .map(String::as_str) + .unwrap_or_else(|| node_text(source, parameter).unwrap_or_default()); + Some(KotlinParameter { + name, + kind, + defaulted: top_level_contains(text, '='), + variadic: text.split_whitespace().any(|part| part == "vararg"), + }) + }) + .collect() +} + +fn split_top_level_parameters(value: &str) -> Vec { + let value = value.trim(); + let value = value + .strip_prefix('(') + .and_then(|value| value.strip_suffix(')')) + .unwrap_or(value); + let mut output = Vec::new(); + let mut start = 0_usize; + let mut round = 0_u32; + let mut square = 0_u32; + let mut angle = 0_u32; + let mut quoted = None; + let mut escaped = false; + for (offset, character) in value.char_indices() { + if let Some(quote) = quoted { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == quote { + quoted = None; + } + continue; + } + match character { + '\'' | '"' => quoted = Some(character), + '(' => round = round.saturating_add(1), + ')' => round = round.saturating_sub(1), + '[' => square = square.saturating_add(1), + ']' => square = square.saturating_sub(1), + '<' => angle = angle.saturating_add(1), + '>' => angle = angle.saturating_sub(1), + ',' if round == 0 && square == 0 && angle == 0 => { + output.push(value[start..offset].trim().to_owned()); + start = offset.saturating_add(character.len_utf8()); + } + _ => {} + } + } + if start < value.len() { + output.push(value[start..].trim().to_owned()); + } + output +} + +fn kotlin_callable_signature( + name: &str, + receiver: Option<&str>, + parameters: &[KotlinParameter], +) -> String { + let receiver = receiver.map_or(String::new(), |receiver| format!("receiver={receiver};")); + let parameters = parameters + .iter() + .map(|parameter| { + format!( + "{}:{}{}{}", + parameter.name, + parameter.kind, + if parameter.defaulted { "=" } else { "" }, + if parameter.variadic { "..." } else { "" } + ) + }) + .collect::>() + .join(","); + format!("{name}({receiver}{parameters})") +} + +fn type_parameter_signature(node: Node<'_>, source: &[u8]) -> Option { + direct_named_child(node, "type_parameters") + .and_then(|parameters| node_text(source, parameters)) + .map(str::trim) + .filter(|parameters| !parameters.is_empty()) + .map(str::to_owned) +} + +fn call_arguments<'tree>(node: Node<'tree>, source: &'tree [u8]) -> Vec> { + let Some(arguments) = first_descendant(node, &["value_arguments"], 0) else { + return Vec::new(); + }; + let mut cursor = arguments.walk(); + arguments + .children(&mut cursor) + .filter(|child| child.kind() == "value_argument") + .filter_map(|argument| { + let mut children_cursor = argument.walk(); + let children = argument + .children(&mut children_cursor) + .filter(|child| child.is_named()) + .collect::>(); + let text = node_text(source, argument)?; + let named = top_level_contains(text, '=') && children.len() >= 2; + let name = named + .then(|| node_text(source, children[0]).map(str::trim)) + .flatten(); + let node = children.last().copied().unwrap_or(argument); + Some(KotlinArgument { node, name }) + }) + .collect() +} + +fn kotlin_argument_context(arguments: &[KotlinArgument<'_>]) -> String { + let names = arguments + .iter() + .map(|argument| argument.name.unwrap_or("_")) + .collect::>() + .join(","); + format!("kotlin_args:{names}") +} + +fn navigation_receiver(node: Node<'_>) -> Option> { + node.named_child(0) +} + +fn navigation_member(node: Node<'_>) -> Option> { + let suffix = direct_named_child(node, "navigation_suffix")?; + first_identifier(suffix) +} + +fn inferred_initializer_type(node: Node<'_>, source: &[u8]) -> Option { + let mut cursor = node.walk(); + let initializer = node + .children(&mut cursor) + .filter(|child| child.is_named()) + .find(|child| child.kind() == "call_expression")?; + let callee = initializer.named_child(0)?; + let spelling = terminal(node_text(source, callee)?.trim()); + spelling + .starts_with(char::is_uppercase) + .then(|| spelling.to_owned()) +} + +fn collect_type_nodes<'tree>(node: Node<'tree>, output: &mut Vec>, depth: usize) { + if depth > MAX_TRAVERSAL_DEPTH { + return; + } + if node.kind() == "user_type" { + output.push(node); + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + collect_type_nodes(child, output, depth + 1); + } +} + +fn collect_nodes<'tree>( + node: Node<'tree>, + expected: &str, + output: &mut Vec>, + depth: usize, +) { + if depth > MAX_TRAVERSAL_DEPTH { + return; + } + if node.kind() == expected { + output.push(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(Node::is_named) { + collect_nodes(child, expected, output, depth + 1); + } +} + +fn first_descendant<'tree>( + node: Node<'tree>, + expected: &[&str], + depth: usize, +) -> Option> { + if depth > MAX_TRAVERSAL_DEPTH { + return None; + } + if expected.contains(&node.kind()) { + return Some(node); + } + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(Node::is_named) + .find_map(|child| first_descendant(child, expected, depth + 1)) +} + +fn direct_named_child<'tree>(node: Node<'tree>, expected: &str) -> Option> { + let mut cursor = node.walk(); + node.children(&mut cursor) + .find(|child| child.is_named() && child.kind() == expected) +} + +fn first_identifier(node: Node<'_>) -> Option> { + first_descendant( + node, + &["simple_identifier", "type_identifier", "identifier"], + 0, + ) +} + +fn node_text<'source>(source: &'source [u8], node: Node<'_>) -> Option<&'source str> { + source + .get(node.start_byte()..node.end_byte()) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) +} + +fn normalize_type(raw: &str) -> String { + raw.chars() + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn erase_type_arguments(raw: &str) -> String { + let mut depth = 0_u32; + raw.chars() + .filter(|character| match character { + '<' => { + depth = depth.saturating_add(1); + false + } + '>' => { + depth = depth.saturating_sub(1); + false + } + '?' if depth == 0 => false, + _ => depth == 0, + }) + .collect() +} + +fn top_level_contains(value: &str, needle: char) -> bool { + let mut round = 0_u32; + let mut square = 0_u32; + let mut angle = 0_u32; + let mut quoted = None; + let mut escaped = false; + for character in value.chars() { + if let Some(quote) = quoted { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == quote { + quoted = None; + } + continue; + } + match character { + '\'' | '"' => quoted = Some(character), + '(' => round = round.saturating_add(1), + ')' => round = round.saturating_sub(1), + '[' => square = square.saturating_add(1), + ']' => square = square.saturating_sub(1), + '<' => angle = angle.saturating_add(1), + '>' => angle = angle.saturating_sub(1), + _ if character == needle && round == 0 && square == 0 && angle == 0 => return true, + _ => {} + } + } + false +} + +fn imported_callable_name(target: &str) -> String { + target.rsplit_once('.').map_or_else( + || target.to_owned(), + |(owner, name)| format!("{owner}::{name}"), + ) +} + +fn join_qualified(owner: &str, name: &str, separator: &str) -> String { + if owner.is_empty() || owner == "" { + name.to_owned() + } else { + format!("{owner}{separator}{name}") + } +} + +fn terminal(value: &str) -> &str { + value + .rsplit(['.', ':']) + .find(|part| !part.is_empty()) + .unwrap_or(value) +} + +fn qualified_parent(value: &str) -> Option<&str> { + value.rsplit_once('.').map(|(parent, _)| parent) +} + +fn kotlin_builtin_type(name: &str) -> bool { + kotlin_builtin_qualified(name).is_some() +} + +fn kotlin_builtin_qualified(name: &str) -> Option { + let base = name.strip_prefix("kotlin.").unwrap_or(name); + matches!( + base, + "Any" + | "Boolean" + | "Byte" + | "Char" + | "Double" + | "Float" + | "Int" + | "Long" + | "Nothing" + | "Short" + | "String" + | "Unit" + | "Array" + ) + .then(|| format!("kotlin.{base}")) +} + +fn kotlin_type_target_kinds() -> Vec { + [ + "annotation_type", + "class", + "companion_object", + "enum", + "interface", + "object", + "type_alias", + ] + .into_iter() + .map(str::to_owned) + .collect() +} + +fn kotlin_import_target_kinds() -> Vec { + let mut kinds = kotlin_type_target_kinds(); + kinds.extend( + ["function", "method", "property", "constant"] + .into_iter() + .map(str::to_owned), + ); + kinds +} diff --git a/crates/compass-languages/src/evidence/mod.rs b/crates/compass-languages/src/evidence/mod.rs index cc2460cf..4ff88dca 100644 --- a/crates/compass-languages/src/evidence/mod.rs +++ b/crates/compass-languages/src/evidence/mod.rs @@ -1,5 +1,6 @@ mod build; mod csharp; +mod kotlin; mod model; mod php; mod typescript; diff --git a/crates/compass-languages/src/evidence/validate.rs b/crates/compass-languages/src/evidence/validate.rs index f5b90337..72cf9287 100644 --- a/crates/compass-languages/src/evidence/validate.rs +++ b/crates/compass-languages/src/evidence/validate.rs @@ -325,6 +325,15 @@ fn validate_fact( "java", "class" | "interface" | "enum" | "record" | "annotation_type" ) | ("csharp", "class" | "interface" | "record" | "struct") + | ( + "kotlin", + "annotation_type" + | "class" + | "companion_object" + | "enum" + | "interface" + | "object" + ) | ("php", "class" | "interface" | "trait" | "enum") | ("rust", "trait") ) diff --git a/crates/compass-languages/src/frameworks/evidence.rs b/crates/compass-languages/src/frameworks/evidence.rs index ac12b522..4df8c7fe 100644 --- a/crates/compass-languages/src/frameworks/evidence.rs +++ b/crates/compass-languages/src/frameworks/evidence.rs @@ -2,6 +2,7 @@ pub(super) enum EvidenceKind { Import, Receiver, + #[allow(dead_code)] DecoratorOrAttribute, Macro, ConfigurationContract, diff --git a/crates/compass-languages/src/frameworks/java.rs b/crates/compass-languages/src/frameworks/java.rs index f870f155..9108664a 100644 --- a/crates/compass-languages/src/frameworks/java.rs +++ b/crates/compass-languages/src/frameworks/java.rs @@ -1,199 +1,8 @@ -use std::collections::BTreeMap; -use std::path::Path; +//! Shared Java source-identity helpers retained by enterprise config packs. +//! +//! Spring source detection lives exclusively in the universal evidence pack. use regex::Regex; -use serde_json::{Map, Value}; -use tree_sitter::Node; - -use super::evidence::{EvidenceKind, EvidenceSet}; -use super::text::{join_route_path, line_anchor, normalize_route_path, text}; -use super::{RawFrameworkFact, RawFrameworkOrigin, RawRouteFact}; - -#[derive(Clone)] -struct Mapping { - name: String, - arguments: String, - offset: usize, - line: String, -} - -pub(super) fn detect(path: &Path, source: &[u8], _root: Node<'_>) -> Vec { - let body = text(source); - let evidence = EvidenceSet::new() - .direct_if( - body.contains("org.springframework.web.bind.annotation"), - "spring", - EvidenceKind::Import, - "org.springframework.web.bind.annotation", - ) - .supporting_if( - body.contains("@RestController"), - "spring", - EvidenceKind::DecoratorOrAttribute, - "@RestController", - ); - if !evidence.activates("spring") { - return Vec::new(); - } - // Method mappings are only HTTP endpoints when owned by a Spring MVC - // controller. Importing the annotation package alone is not sufficient; - // helpers, DTOs, and custom annotation declarations commonly reference - // the same types without registering routes. - if !body.contains("@RestController") && !body.contains("@Controller") { - return Vec::new(); - } - let Ok(annotation) = Regex::new( - r"@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*(?:\((.*)\))?", - ) else { - return Vec::new(); - }; - let Ok(class) = Regex::new(r"\b(?:class|interface)\s+([A-Za-z_][A-Za-z0-9_]*)") else { - return Vec::new(); - }; - let Ok(java_method) = Regex::new( - r"\b(?:public|protected|private|static|final|synchronized|abstract|native|\s)+[A-Za-z0-9_<>,.?\[\]\s]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(([^)]*)\)", - ) else { - return Vec::new(); - }; - let Ok(kotlin_method) = Regex::new(r"\bfun\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(") else { - return Vec::new(); - }; - - let mut facts = Vec::new(); - let package = java_package_name(body); - let mut pending = Vec::::new(); - let mut multiline = BTreeMap::>::new(); - if let Ok(multiline_annotation) = Regex::new( - r"(?s)@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\((.*?)\)", - ) { - for capture in multiline_annotation.captures_iter(body) { - let Some(whole) = capture.get(0) else { - continue; - }; - if !whole.as_str().contains('\n') { - continue; - } - let line_start = body[..whole.start()] - .rfind('\n') - .map_or(0, |index| index.saturating_add(1)); - let line = body[line_start..] - .split_inclusive('\n') - .next() - .unwrap_or_default() - .to_owned(); - let Some(name) = capture.get(1) else { - continue; - }; - multiline.entry(line_start).or_default().push(Mapping { - name: name.as_str().to_owned(), - arguments: capture - .get(2) - .map(|value| value.as_str().to_owned()) - .unwrap_or_default(), - offset: line_start, - line, - }); - } - } - let mut class_name = None::; - let mut class_prefix = String::new(); - let mut offset = 0_usize; - for line in body.split_inclusive('\n') { - if let Some(values) = multiline.remove(&offset) { - pending.extend(values); - } - for capture in annotation.captures_iter(line) { - let Some(name) = capture.get(1) else { - continue; - }; - pending.push(Mapping { - name: name.as_str().to_owned(), - arguments: capture - .get(2) - .map(|value| value.as_str().to_owned()) - .unwrap_or_default(), - offset, - line: line.to_owned(), - }); - } - if let Some(capture) = class.captures(line) { - class_name = capture.get(1).map(|value| value.as_str().to_owned()); - class_prefix = pending - .iter() - .rev() - .find(|mapping| mapping.name == "RequestMapping") - .and_then(|mapping| mapping_paths(&mapping.arguments).into_iter().next()) - .unwrap_or_default(); - pending.clear(); - offset = offset.saturating_add(line.len()); - continue; - } - let Some(class_name) = class_name.as_deref() else { - offset = offset.saturating_add(line.len()); - continue; - }; - let java_capture = java_method.captures(line); - let kotlin_capture = java_capture - .is_none() - .then(|| kotlin_method.captures(line)) - .flatten(); - let Some(method_name) = java_capture - .as_ref() - .or(kotlin_capture.as_ref()) - .and_then(|capture| capture.get(1)) - .map(|value| value.as_str()) - else { - offset = offset.saturating_add(line.len()); - continue; - }; - for mapping in pending.drain(..) { - let paths = { - let values = mapping_paths(&mapping.arguments); - if values.is_empty() { - vec![String::new()] - } else { - values - } - }; - let operations = mapping_operations(&mapping.name, &mapping.arguments); - for method_path in &paths { - let normalized_path = if class_prefix.is_empty() { - normalize_route_path(method_path) - } else { - join_route_path(&class_prefix, method_path) - }; - for operation in &operations { - let mut detail = Map::new(); - if let Some(capture) = java_capture.as_ref() { - let parameters = capture.get(2).map_or("", |value| value.as_str()); - let (qualified, signature) = - java_callable_target(&package, class_name, method_name, parameters); - detail.insert("target_qualified_name".to_owned(), Value::String(qualified)); - detail.insert( - "target_signature_qualified".to_owned(), - Value::String(signature), - ); - } - facts.push(RawFrameworkFact::Route(RawRouteFact { - framework: "spring".to_owned(), - operation: operation.clone(), - raw_path: method_path.clone(), - normalized_path: normalized_path.clone(), - declaring_scope: class_name.to_owned(), - anchor: line_anchor(path, source, mapping.offset, &mapping.line), - handler_reference: format!("{class_name}.{method_name}"), - middleware_references: Vec::new(), - origin: RawFrameworkOrigin::Ast, - rule: Some("spring-request-mapping".to_owned()), - detail, - })); - } - } - } - offset = offset.saturating_add(line.len()); - } - facts -} pub(super) fn java_package_name(body: &str) -> String { Regex::new(r"(?m)^\s*package\s+([A-Za-z_$][A-Za-z0-9_$.]*)\s*;") @@ -274,9 +83,9 @@ fn java_parameter_type(parameter: &str) -> Option { _ => {} } } - parameter - .contains("...") - .then(|| normalized.push_str("...")); + if parameter.contains("...") { + normalized.push_str("..."); + } (!normalized.is_empty()).then_some(normalized) } @@ -312,44 +121,3 @@ fn strip_java_parameter_annotations(parameter: &str) -> String { } output } - -fn mapping_paths(arguments: &str) -> Vec { - let Ok(literal) = Regex::new(r#""([^"]*)"|'([^']*)'"#) else { - return Vec::new(); - }; - literal - .captures_iter(arguments) - .filter_map(|capture| { - capture - .get(1) - .or_else(|| capture.get(2)) - .map(|value| value.as_str().to_owned()) - }) - .collect() -} - -fn mapping_operations(name: &str, arguments: &str) -> Vec { - let composed = match name { - "GetMapping" => Some("GET"), - "PostMapping" => Some("POST"), - "PutMapping" => Some("PUT"), - "PatchMapping" => Some("PATCH"), - "DeleteMapping" => Some("DELETE"), - _ => None, - }; - if let Some(operation) = composed { - return vec![operation.to_owned()]; - } - let Ok(method) = Regex::new(r"RequestMethod\.([A-Z]+)") else { - return vec!["ANY".to_owned()]; - }; - let methods = method - .captures_iter(arguments) - .filter_map(|capture| capture.get(1).map(|value| value.as_str().to_owned())) - .collect::>(); - if methods.is_empty() { - vec!["ANY".to_owned()] - } else { - methods - } -} diff --git a/crates/compass-languages/src/frameworks/mod.rs b/crates/compass-languages/src/frameworks/mod.rs index fe2bfa97..ed4d7b54 100644 --- a/crates/compass-languages/src/frameworks/mod.rs +++ b/crates/compass-languages/src/frameworks/mod.rs @@ -18,7 +18,6 @@ mod remix; mod ruby; mod rust; mod spring; -mod spring_kotlin; mod swift; mod text; mod typescript; @@ -287,18 +286,10 @@ const FRAMEWORK_PACKS: &[FrameworkPack] = &[ csharp::detect_minimal, ), FrameworkPack::universal(&pack::SPRING_JAVA_DESCRIPTOR, spring::detect), + FrameworkPack::universal(&pack::SPRING_KOTLIN_DESCRIPTOR, spring::detect_kotlin), FrameworkPack::source("python-web", &["python"], &[], detect_python), FrameworkPack::universal(&pack::PHP_FRAMEWORKS_DESCRIPTOR, php::detect), FrameworkPack::source("rails-routes", &["ruby"], &["rails"], detect_ruby), - FrameworkPack::source( - "spring-web-kotlin", - &["kotlin"], - &[ - "org.springframework:spring-web", - "org.springframework.boot:spring-boot", - ], - detect_kotlin, - ), FrameworkPack::source("go-web", &["go"], &[], detect_go), FrameworkPack::source("axum-web", &["rust"], &["axum"], detect_axum), FrameworkPack::source("rust-web", &["rust"], &[], detect_rust), @@ -542,13 +533,6 @@ fn detect_ruby( ruby::detect(context.path, context.source, context.root) } -fn detect_kotlin( - context: &DetectionContext<'_, '_>, - _extraction: &mut Extraction, -) -> Vec { - spring_kotlin::detect(context.path, context.source, context.root) -} - fn detect_go( context: &DetectionContext<'_, '_>, _extraction: &mut Extraction, @@ -684,7 +668,7 @@ mod tests { "python-web", "php-frameworks", "rails-routes", - "spring-web-kotlin", + "spring-kotlin", "go-web", "axum-web", "rust-web", diff --git a/crates/compass-languages/src/frameworks/pack.rs b/crates/compass-languages/src/frameworks/pack.rs index c00febf2..a59bf2d5 100644 --- a/crates/compass-languages/src/frameworks/pack.rs +++ b/crates/compass-languages/src/frameworks/pack.rs @@ -435,6 +435,75 @@ pub(super) const SPRING_JAVA_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPack }, }; +pub(super) const SPRING_KOTLIN_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { + id: "spring-kotlin", + kind: FrameworkPackKind::Source, + languages: &["kotlin"], + required_capabilities: &[ + LanguageCapability::Declarations, + LanguageCapability::LexicalScopes, + LanguageCapability::Namespaces, + LanguageCapability::Imports, + LanguageCapability::Aliases, + LanguageCapability::Calls, + LanguageCapability::Construction, + LanguageCapability::TypeReferences, + LanguageCapability::BaseTypes, + LanguageCapability::Members, + LanguageCapability::Ownership, + ], + framework_capabilities: &[ + FrameworkCapability::HttpRoutes, + FrameworkCapability::Beans, + FrameworkCapability::DependencyInjection, + FrameworkCapability::Messaging, + FrameworkCapability::Scheduling, + FrameworkCapability::Persistence, + FrameworkCapability::Transactions, + FrameworkCapability::Security, + ], + dependency_markers: &[ + "org.springframework.boot:spring-boot", + "org.springframework:spring-web", + ], + manifest_policy: FrameworkManifestPolicy::Advisory, + activation_rules: &[ + "spring-annotation-import", + "spring-direct-annotation", + "spring-project-dependency", + ], + accepted_roles: &[ + SemanticRole::Import, + SemanticRole::Call, + SemanticRole::Construction, + SemanticRole::Annotation, + SemanticRole::BaseType, + SemanticRole::TypeReference, + SemanticRole::Ownership, + ], + emitted_relation_families: &[ + FrameworkRelation::Decorates, + FrameworkRelation::RoutesTo, + FrameworkRelation::Registers, + FrameworkRelation::Handles, + FrameworkRelation::Publishes, + FrameworkRelation::Subscribes, + FrameworkRelation::Produces, + FrameworkRelation::Consumes, + FrameworkRelation::Schedules, + FrameworkRelation::Triggers, + FrameworkRelation::DependsOn, + FrameworkRelation::MapsTo, + ], + occurrence_policy: FrameworkOccurrencePolicy::ExactEvidence, + limits: FrameworkLimits { + max_candidates: 20, + max_include_depth: 32, + max_alias_expansions: 1_000, + max_facts_per_file: 100_000, + }, +}; + pub(super) const ASPNET_CSHARP_DESCRIPTOR: FrameworkPackDescriptor = FrameworkPackDescriptor { id: "aspnet-csharp", kind: FrameworkPackKind::Source, @@ -508,4 +577,5 @@ const UNIVERSAL_FRAMEWORK_PACKS: &[FrameworkPackDescriptor] = &[ ASPNET_CSHARP_DESCRIPTOR, PHP_FRAMEWORKS_DESCRIPTOR, SPRING_JAVA_DESCRIPTOR, + SPRING_KOTLIN_DESCRIPTOR, ]; diff --git a/crates/compass-languages/src/frameworks/spring.rs b/crates/compass-languages/src/frameworks/spring.rs index 7ba1a57f..34bd310c 100644 --- a/crates/compass-languages/src/frameworks/spring.rs +++ b/crates/compass-languages/src/frameworks/spring.rs @@ -14,7 +14,31 @@ const FRAMEWORK: &str = "spring"; const SPRING_PREFIX: &str = "org.springframework."; pub(super) fn detect(context: &UniversalDetectionContext<'_, '_>) -> Vec { - if context.evidence.adapter.language != "java" { + detect_language( + context, + "java", + super::pack::SPRING_JAVA_DESCRIPTOR.dependency_markers, + ) +} + +pub(super) fn detect_kotlin(context: &UniversalDetectionContext<'_, '_>) -> Vec { + let mut facts = detect_language( + context, + "kotlin", + super::pack::SPRING_KOTLIN_DESCRIPTOR.dependency_markers, + ); + for fact in &mut facts { + set_fact_pack(fact, "spring-kotlin"); + } + facts +} + +fn detect_language( + context: &UniversalDetectionContext<'_, '_>, + language: &str, + dependency_markers: &[&str], +) -> Vec { + if context.evidence.adapter.language != language { return Vec::new(); } let declarations = context @@ -34,13 +58,14 @@ pub(super) fn detect(context: &UniversalDetectionContext<'_, '_>) -> Vec>(); - let activated = context.project.is_some_and(|project| { - project.has_any_dependency(super::pack::SPRING_JAVA_DESCRIPTOR.dependency_markers) - }) || context - .evidence - .bindings - .iter() - .any(|binding| is_framework_qualified_name(&binding.qualified_target)) + let activated = context + .project + .is_some_and(|project| project.has_any_dependency(dependency_markers)) + || context + .evidence + .bindings + .iter() + .any(|binding| is_framework_qualified_name(&binding.qualified_target)) || candidates.values().any(|candidate| { candidate .constraints @@ -115,6 +140,24 @@ pub(super) fn detect(context: &UniversalDetectionContext<'_, '_>) -> Vec annotation.pack_id = pack_id.to_owned(), + RawFrameworkFact::Route(route) => { + route.detail.insert( + "frameworkPack".to_owned(), + Value::String(pack_id.to_owned()), + ); + } + RawFrameworkFact::Domain(domain) => { + domain.detail.insert( + "frameworkPack".to_owned(), + Value::String(pack_id.to_owned()), + ); + } + } +} + fn repository_facts( context: &UniversalDetectionContext<'_, '_>, declarations: &HashMap<&str, &DeclarationFact>, @@ -215,6 +258,39 @@ fn unique_binding_map(context: &UniversalDetectionContext<'_, '_>) -> Map) -> Vec { + if context.evidence.adapter.language == "kotlin" { + let mut values = BTreeMap::new(); + collect_kotlin_constant_values(context.root, context.source, &mut values); + return context + .evidence + .declarations + .iter() + .filter(|declaration| declaration.kind == "constant") + .filter_map(|declaration| { + let expression = values.get(&declaration.range.start_byte)?; + Some(RawFrameworkFact::Domain(crate::RawDomainFact { + framework: FRAMEWORK.to_owned(), + kind: "_spring_constant".to_owned(), + name: declaration.qualified_name.clone(), + declaring_scope: declaration + .qualified_name + .rsplit_once("::") + .map(|(owner, _)| owner) + .unwrap_or(declaration.qualified_name.as_str()) + .to_owned(), + anchor: anchor(&declaration.range), + origin: crate::RawFrameworkOrigin::Ast, + detail: Map::from_iter([ + ("expression".to_owned(), Value::String(expression.clone())), + ( + "handler_reference".to_owned(), + Value::String(declaration.graph_node_id.clone()), + ), + ]), + })) + }) + .collect(); + } let mut declarations = BTreeMap::new(); for declaration in &context.evidence.declarations { if matches!(declaration.kind.as_str(), "field" | "constant") { @@ -263,6 +339,55 @@ fn constant_facts(context: &UniversalDetectionContext<'_, '_>) -> Vec, + source: &[u8], + output: &mut BTreeMap, +) { + if node.kind() == "property_declaration" { + let text = source + .get(node.start_byte()..node.end_byte()) + .and_then(|value| std::str::from_utf8(value).ok()) + .unwrap_or_default(); + if text.split_whitespace().any(|part| part == "const") { + let mut variables = Vec::new(); + collect_named_kind(node, "variable_declaration", &mut variables); + if let Some(variable) = variables.first().copied() + && let Some(name) = first_kotlin_identifier(variable) + && let Some(expression) = split_assignment(text).map(|(_, value)| value.trim()) + && let Ok(start) = u64::try_from(name.start_byte()) + { + output.insert(start, expression.to_owned()); + } + } + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_kotlin_constant_values(child, source, output); + } +} + +fn collect_named_kind<'tree>(node: Node<'tree>, kind: &str, output: &mut Vec>) { + if node.kind() == kind { + output.push(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_named_kind(child, kind, output); + } +} + +fn first_kotlin_identifier(node: Node<'_>) -> Option> { + if matches!(node.kind(), "simple_identifier" | "type_identifier") { + return Some(node); + } + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named()) + .find_map(first_kotlin_identifier) +} + fn collect_constant_nodes<'tree>(node: Node<'tree>, source: &[u8], output: &mut Vec>) { if matches!(node.kind(), "field_declaration" | "constant_declaration") { let text = node @@ -322,7 +447,11 @@ fn producer_facts( candidates: &HashMap<&str, &crate::RelationshipCandidate>, ) -> Vec { let mut call_nodes = BTreeMap::new(); - collect_named_nodes(context.root, "method_invocation", "name", &mut call_nodes); + if context.evidence.adapter.language == "kotlin" { + collect_kotlin_call_nodes(context.root, &mut call_nodes); + } else { + collect_named_nodes(context.root, "method_invocation", "name", &mut call_nodes); + } context .evidence .occurrences @@ -333,9 +462,22 @@ fn producer_facts( if candidate.relation != CandidateRelation::Calls { return None; } - let qualified = candidate.constraints.qualified_name.as_deref()?; + let qualified = candidate.constraints.qualified_name.clone().or_else(|| { + if let crate::HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name, + .. + } = candidate.constraints.hierarchy.as_ref()? + { + Some(format!( + "{receiver_qualified_name}::{}", + candidate.target_spelling + )) + } else { + None + } + })?; let (kind, transport, relationship, mut argument_index) = - producer_signature(qualified)?; + producer_signature(&qualified)?; if qualified.ends_with("RabbitTemplate::convertAndSend") { argument_index = usize::from(candidate.constraints.argument_count.unwrap_or(0) >= 3); @@ -344,7 +486,12 @@ fn producer_facts( let call = call_nodes .get(&usize::try_from(occurrence.range.start_byte).ok()?) .copied()?; - let subject = call_argument(call, argument_index, context.source)?; + let subject = call_argument( + call, + argument_index, + context.source, + context.evidence.adapter.language.as_str(), + )?; let mut detail = Map::new(); detail.insert( "handler_reference".to_owned(), @@ -386,8 +533,12 @@ fn producer_signature( } } -fn call_argument(node: Node<'_>, index: usize, source: &[u8]) -> Option { - let arguments = node.child_by_field_name("arguments")?; +fn call_argument(node: Node<'_>, index: usize, source: &[u8], language: &str) -> Option { + let arguments = if language == "kotlin" { + first_named_descendant(node, "value_arguments")? + } else { + node.child_by_field_name("arguments")? + }; let mut cursor = arguments.walk(); let argument = arguments .named_children(&mut cursor) @@ -401,6 +552,15 @@ fn call_argument(node: Node<'_>, index: usize, source: &[u8]) -> Option None } })?; + let argument = if language == "kotlin" && argument.kind() == "value_argument" { + let mut cursor = argument.walk(); + argument + .named_children(&mut cursor) + .last() + .unwrap_or(argument) + } else { + argument + }; source .get(argument.start_byte()..argument.end_byte()) .and_then(|value| std::str::from_utf8(value).ok()) @@ -408,6 +568,33 @@ fn call_argument(node: Node<'_>, index: usize, source: &[u8]) -> Option .filter(|value| !value.is_empty()) } +fn collect_kotlin_call_nodes<'tree>(node: Node<'tree>, output: &mut BTreeMap>) { + if node.kind() == "call_expression" + && let Some(callee) = node.named_child(0) + && let Some(name) = if callee.kind() == "navigation_expression" { + first_named_descendant(callee, "navigation_suffix").and_then(first_kotlin_identifier) + } else { + first_kotlin_identifier(callee) + } + { + output.insert(name.start_byte(), node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor).filter(|child| child.is_named()) { + collect_kotlin_call_nodes(child, output); + } +} + +fn first_named_descendant<'tree>(node: Node<'tree>, kind: &str) -> Option> { + if node.kind() == kind { + return Some(node); + } + let mut cursor = node.walk(); + node.children(&mut cursor) + .filter(|child| child.is_named()) + .find_map(|child| first_named_descendant(child, kind)) +} + fn collect_named_nodes<'tree>( node: Node<'tree>, expected_kind: &str, @@ -439,7 +626,9 @@ fn injection_targets( && occurrence.role == SemanticRole::TypeReference && matches!( occurrence.context.as_deref(), - Some("type" | "parameter_type") + Some( + "type" | "parameter_type" | "property_type" | "constructor_parameter_type" + ) ) }) .filter_map(|occurrence| { @@ -464,7 +653,20 @@ fn constructor_facts( .iter() .filter(|declaration| declaration.kind == "constructor") .filter_map(|declaration| { - let targets = injection_targets(context, declaration.id.as_str(), candidates); + let mut targets = injection_targets(context, declaration.id.as_str(), candidates); + if targets.is_empty() + && context.evidence.adapter.language == "kotlin" + && let Some(owner) = declaration + .qualified_name + .rsplit_once("::") + .map(|(owner, _)| owner) + && let Some(owner) = context.evidence.declarations.iter().find(|candidate| { + candidate.qualified_name == owner + && matches!(candidate.kind.as_str(), "class" | "annotation_type") + }) + { + targets = injection_targets(context, owner.id.as_str(), candidates); + } (!targets.is_empty()).then(|| { let mut detail = Map::new(); detail.insert( diff --git a/crates/compass-languages/src/frameworks/spring_kotlin.rs b/crates/compass-languages/src/frameworks/spring_kotlin.rs deleted file mode 100644 index 4190cc24..00000000 --- a/crates/compass-languages/src/frameworks/spring_kotlin.rs +++ /dev/null @@ -1,12 +0,0 @@ -use std::path::Path; - -use tree_sitter::Node; - -use super::RawFrameworkFact; - -/// Kotlin keeps the established JVM syntax extractor, but owns a dedicated -/// adapter entry point so Spring can evolve without coupling its pack to other -/// Kotlin framework conventions. -pub(super) fn detect(path: &Path, source: &[u8], root: Node<'_>) -> Vec { - super::java::detect(path, source, root) -} diff --git a/crates/compass-languages/tests/engine_edge_coverage.rs b/crates/compass-languages/tests/engine_edge_coverage.rs index 76ec6abd..c506ed50 100644 --- a/crates/compass-languages/tests/engine_edge_coverage.rs +++ b/crates/compass-languages/tests/engine_edge_coverage.rs @@ -33,10 +33,11 @@ fn universal_framework_pack_registry_accepts_only_cut_over_language_evidence() { FrameworkPackRegistry::validate_descriptors(&[descriptor]), Ok(()) ); - assert_eq!(FrameworkPackRegistry::descriptors().len(), 3); + assert_eq!(FrameworkPackRegistry::descriptors().len(), 4); assert_eq!(FrameworkPackRegistry::descriptors()[0].id, "aspnet-csharp"); assert_eq!(FrameworkPackRegistry::descriptors()[1].id, "php-frameworks"); assert_eq!(FrameworkPackRegistry::descriptors()[2].id, "spring-java"); + assert_eq!(FrameworkPackRegistry::descriptors()[3].id, "spring-kotlin"); assert_eq!(FrameworkPackRegistry::validate(), Ok(())); let rust = FrameworkPackDescriptor { diff --git a/crates/compass-languages/tests/kotlin_universal_conformance.rs b/crates/compass-languages/tests/kotlin_universal_conformance.rs new file mode 100644 index 00000000..52003c0b --- /dev/null +++ b/crates/compass-languages/tests/kotlin_universal_conformance.rs @@ -0,0 +1,205 @@ +use std::path::Path; + +use compass_languages::{CandidateRelation, Engine, SemanticRole, UniversalAdapterProfile}; + +const SOURCE: &[u8] = br#" +package demo.api + +import other.Service as OtherService +import other.helpers.runTask +import org.springframework.web.bind.annotation.GetMapping + +annotation class Audit(val value: String) +private interface Marker +open class Base + +@Audit("controller") +class Controller(private val service: OtherService, count: Int = 1) : Base(), Marker { + companion object Factory { + const val NAME: String = "demo" + fun create(): Controller = Controller(OtherService()) + } + + @GetMapping(path = ["/x"]) + fun String.render(prefix: String = "x", vararg ids: Long?): String? { + service.run(prefix = prefix, count = ids.size) + runTask(this) + return this + } +} + +object Singleton +typealias Alias = Controller +"#; + +#[test] +fn kotlin_emits_modern_universal_evidence_with_exact_anchors() +-> Result<(), Box> { + let extraction = Engine::default().extract_source_graph_only( + Path::new("src/main/kotlin/demo/api/Controller.kt"), + "src/main/kotlin/demo/api/Controller.kt", + SOURCE, + )?; + assert!(extraction.nodes.is_empty()); + assert!(extraction.edges.is_empty()); + assert!(extraction.raw_calls.is_none()); + let evidence = extraction + .semantic_evidence + .as_ref() + .ok_or_else(|| format!("missing Kotlin evidence: {:?}", extraction.error))?; + let qualification = Engine::default().extract_source_universal_candidate_evidence( + Path::new("src/main/kotlin/demo/api/Controller.kt"), + "src/main/kotlin/demo/api/Controller.kt", + SOURCE, + )?; + assert_eq!(evidence, &qualification); + assert_eq!(evidence.adapter.language, "kotlin"); + assert_eq!(evidence.adapter.version, 1); + assert_eq!( + evidence.adapter.profile, + UniversalAdapterProfile::UniversalCandidate + ); + for (kind, qualified) in [ + ("annotation_type", "demo.api.Audit"), + ("interface", "demo.api.Marker"), + ("class", "demo.api.Base"), + ("class", "demo.api.Controller"), + ("companion_object", "demo.api.Controller.Factory"), + ("constant", "demo.api.Controller.Factory::NAME"), + ("method", "demo.api.Controller::render"), + ("object", "demo.api.Singleton"), + ("type_alias", "demo.api.Alias"), + ] { + assert!( + evidence + .declarations + .iter() + .any(|declaration| declaration.kind == kind + && declaration.qualified_name == qualified), + "missing {kind} {qualified}; declarations={:#?}", + evidence.declarations + ); + } + assert!(evidence.bindings.iter().any(|binding| { + binding.spelling == "OtherService" && binding.qualified_target == "other.Service" + })); + assert!(evidence.occurrences.iter().any(|occurrence| { + occurrence.role == SemanticRole::Annotation + && occurrence.spelling == "GetMapping" + && slice(&occurrence.range, SOURCE) == "GetMapping" + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Extends + && candidate.constraints.qualified_name.as_deref() == Some("demo.api.Base") + })); + assert!(evidence.candidates.iter().any(|candidate| { + candidate.relation == CandidateRelation::Implements + && candidate.constraints.qualified_name.as_deref() == Some("demo.api.Marker") + })); + let named_call = evidence + .occurrences + .iter() + .find(|occurrence| occurrence.role == SemanticRole::Call && occurrence.spelling == "run") + .ok_or("missing named member call")?; + assert_eq!( + named_call.context.as_deref(), + Some("kotlin_args:prefix,count") + ); + assert_eq!(slice(&named_call.range, SOURCE), "run"); + assert!( + evidence + .candidates + .iter() + .all(|candidate| { candidate.constraints.exact_language.as_deref() == Some("kotlin") }) + ); + assert!( + evidence + .declarations + .iter() + .all(|declaration| declaration.language == "kotlin") + ); + assert!( + evidence + .candidates + .iter() + .filter(|candidate| { + matches!( + candidate.relation, + CandidateRelation::Calls | CandidateRelation::Constructs + ) + }) + .all(|candidate| { + evidence.declarations.iter().any(|declaration| { + declaration.id == candidate.source_declaration_id + && matches!( + declaration.kind.as_str(), + "constructor" | "function" | "method" + ) + }) + }) + ); + Ok(()) +} + +#[test] +fn kotlin_evidence_is_deterministic_and_malformed_input_is_bounded() +-> Result<(), Box> { + let path = Path::new("src/Unicode.kt"); + let source = + "package δοκιμή\nclass Café { fun привет(value: String?) = value?.length }\n".as_bytes(); + let first = Engine::default().extract_source_graph_only(path, "src/Unicode.kt", source)?; + let second = Engine::default().extract_source_graph_only(path, "src/Unicode.kt", source)?; + assert_eq!(first.semantic_evidence, second.semantic_evidence); + + let malformed = b"package demo\nclass Broken( { fun call( = target(\n"; + let extraction = Engine::default().extract_source_graph_only( + Path::new("src/Broken.kt"), + "src/Broken.kt", + malformed, + )?; + let evidence = extraction + .semantic_evidence + .ok_or("missing malformed evidence")?; + assert!( + evidence + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "partial_parser_recovery") + ); + assert!(evidence.occurrences.iter().all(|occurrence| { + occurrence.range.end_byte >= occurrence.range.start_byte + && usize::try_from(occurrence.range.end_byte).is_ok_and(|end| end <= malformed.len()) + })); + Ok(()) +} + +#[test] +fn kotlin_traversal_limit_is_reported_without_unbounded_walk() +-> Result<(), Box> { + let source = format!( + "package demo\nfun nested() = {}1{}\n", + "target(".repeat(600), + ")".repeat(600) + ); + let extraction = Engine::default().extract_source_graph_only( + Path::new("src/Deep.kt"), + "src/Deep.kt", + source.as_bytes(), + )?; + let evidence = extraction + .semantic_evidence + .ok_or("missing bounded evidence")?; + assert!( + evidence + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "kotlin_traversal_limit") + ); + Ok(()) +} + +fn slice<'a>(range: &compass_languages::EvidenceRange, source: &'a [u8]) -> &'a str { + let start = usize::try_from(range.start_byte).unwrap_or_default(); + let end = usize::try_from(range.end_byte).unwrap_or_default(); + std::str::from_utf8(source.get(start..end).unwrap_or_default()).unwrap_or_default() +} diff --git a/crates/compass-languages/tests/registry.rs b/crates/compass-languages/tests/registry.rs index c7ec42ca..bf000f37 100644 --- a/crates/compass-languages/tests/registry.rs +++ b/crates/compass-languages/tests/registry.rs @@ -207,6 +207,7 @@ fn only_hard_cut_languages_expose_universal_profiles() { let python = Registry::resolve(Path::new("src/example.py")).expect("python spec"); let go = Registry::resolve(Path::new("src/example.go")).expect("go spec"); let java = Registry::resolve(Path::new("src/Example.java")).expect("java spec"); + let kotlin = Registry::resolve(Path::new("src/Example.kt")).expect("kotlin spec"); let rust = Registry::resolve(Path::new("src/example.rs")).expect("rust spec"); let typescript = Registry::resolve(Path::new("src/example.ts")).expect("typescript spec"); let tsx = Registry::resolve(Path::new("src/example.tsx")).expect("tsx spec"); @@ -224,6 +225,10 @@ fn only_hard_cut_languages_expose_universal_profiles() { Registry::universal_profile_for_spec(java).map(|profile| profile.language), Some("java") ); + assert_eq!( + Registry::universal_profile_for_spec(kotlin).map(|profile| profile.language), + Some("kotlin") + ); assert_eq!( Registry::universal_profile_for_spec(rust).map(|profile| profile.language), Some("rust") diff --git a/crates/compass-languages/tests/universal_evidence.rs b/crates/compass-languages/tests/universal_evidence.rs index ec3c3dd3..30a7ff5e 100644 --- a/crates/compass-languages/tests/universal_evidence.rs +++ b/crates/compass-languages/tests/universal_evidence.rs @@ -476,6 +476,7 @@ fn universal_adapter_profiles_are_unique_sorted_and_truthful() { "go", "java", "javascript", + "kotlin", "php", "python", "rust", @@ -537,6 +538,7 @@ fn empty_hard_cut_sources_emit_zero_width_file_inventory_evidence() { ("/repo/pkg/__init__.py", "pkg/__init__.py", "python"), ("/repo/pkg/empty.go", "pkg/empty.go", "go"), ("/repo/pkg/empty.java", "pkg/empty.java", "java"), + ("/repo/pkg/empty.kt", "pkg/empty.kt", "kotlin"), ("/repo/pkg/empty.rs", "pkg/empty.rs", "rust"), ("/repo/pkg/empty.js", "pkg/empty.js", "javascript"), ("/repo/pkg/empty.php", "pkg/empty.php", "php"), diff --git a/crates/compass-model/src/code_graph.rs b/crates/compass-model/src/code_graph.rs index 8e942f4e..1103b152 100644 --- a/crates/compass-model/src/code_graph.rs +++ b/crates/compass-model/src/code_graph.rs @@ -129,7 +129,12 @@ impl NodeKind { pub const fn is_constructible(self) -> bool { matches!( self, - Self::Class | Self::Struct | Self::Enum | Self::Component | Self::DatabaseProcedure + Self::Class + | Self::Struct + | Self::Enum + | Self::Annotation + | Self::Component + | Self::DatabaseProcedure ) } diff --git a/crates/compass-model/src/validation.rs b/crates/compass-model/src/validation.rs index 7421966a..cea6e0d3 100644 --- a/crates/compass-model/src/validation.rs +++ b/crates/compass-model/src/validation.rs @@ -631,6 +631,8 @@ fn endpoint_kinds_are_valid( EdgeKind::Instantiates => { is_call_source(source.kind) && (target.kind.is_constructible() + || (target.kind == NodeKind::Interface + && target.language.as_deref() == Some("kotlin")) || (target.kind == NodeKind::EnumMember && target.language.as_deref() == Some("rust"))) } @@ -833,6 +835,7 @@ const fn contains_endpoint_pair(source: NodeKind, target: NodeKind) -> bool { | NodeKind::Trait | NodeKind::Protocol | NodeKind::Enum + | NodeKind::Annotation | NodeKind::Component | NodeKind::Schema, NodeKind::Class @@ -1000,6 +1003,7 @@ const fn is_import_target(kind: NodeKind) -> bool { NodeKind::Import | NodeKind::Export | NodeKind::TypeAlias + | NodeKind::Property | NodeKind::Variable | NodeKind::Field | NodeKind::Constant @@ -1304,6 +1308,20 @@ mod tests { use super::*; + #[test] + fn kotlin_annotation_classes_and_top_level_properties_have_valid_endpoints() { + assert!(NodeKind::Annotation.is_constructible()); + assert!(contains_endpoint_pair( + NodeKind::Annotation, + NodeKind::Constructor + )); + assert!(contains_endpoint_pair( + NodeKind::Annotation, + NodeKind::Property + )); + assert!(is_import_target(NodeKind::Property)); + } + #[test] fn accepts_links_and_python_numeric_id_equality() { let extraction = json!({ diff --git a/crates/compass-resolve/src/evidence/api.rs b/crates/compass-resolve/src/evidence/api.rs index 9d333ab4..366b8e4b 100644 --- a/crates/compass-resolve/src/evidence/api.rs +++ b/crates/compass-resolve/src/evidence/api.rs @@ -83,6 +83,7 @@ pub enum ResolutionRule { DeferredReceiver, WildcardBinding, PhpGlobalFunctionFallback, + KotlinNamedDefaultArguments, UniqueModuleOrPackage, ExactHierarchyBase, DirectReceiverSuccessorDispatch, diff --git a/crates/compass-resolve/src/evidence/languages/kotlin.rs b/crates/compass-resolve/src/evidence/languages/kotlin.rs new file mode 100644 index 00000000..e72418f1 --- /dev/null +++ b/crates/compass-resolve/src/evidence/languages/kotlin.rs @@ -0,0 +1,302 @@ +//! Kotlin overload, named/default argument, and extension-call policy. +//! +//! This module never searches Java declarations. Cross-language JVM edges are +//! admitted only through exact compiler/SCIP evidence outside structural +//! `SemanticEvidenceBatch` resolution. + +use super::super::*; + +#[derive(Clone, Debug)] +struct ParameterShape { + name: String, + kind: String, + defaulted: bool, + variadic: bool, +} + +#[derive(Clone, Debug)] +struct CallableShape { + receiver: Option, + parameters: Vec, +} + +impl ResolutionDb<'_> { + pub(in crate::evidence) fn resolve_kotlin_candidate( + &self, + candidate: &RelationshipCandidate, + ) -> Option { + if candidate.language != "kotlin" || !matches!(candidate.relation, CandidateRelation::Calls) + { + return None; + } + let argument_names = self + .occurrence(candidate) + .and_then(OccurrenceRef::context) + .and_then(parse_argument_names)?; + let receiver = candidate + .constraints + .hierarchy + .as_ref() + .and_then(|hierarchy| { + if let HierarchyConstraint::ReceiverDispatch { + receiver_qualified_name, + .. + } = hierarchy + { + Some(receiver_qualified_name.as_str()) + } else { + None + } + }); + + // Kotlin member declarations always shadow extension functions. + let member_qualified = candidate.constraints.qualified_name.clone().or_else(|| { + receiver.map(|receiver| format!("{receiver}::{}", candidate.target_spelling)) + }); + if let Some(qualified) = member_qualified.as_deref() + && let Some(decision) = self.kotlin_unique_applicable( + qualified, + receiver, + &argument_names, + candidate, + false, + ) + { + return Some(decision); + } + + let binding = candidate + .binding_id + .as_deref() + .and_then(|id| self.facts.bindings.get(id))?; + let qualified = imported_callable_name(&binding.qualified_target); + self.kotlin_unique_applicable( + &qualified, + receiver, + &argument_names, + candidate, + receiver.is_some(), + ) + } + + fn kotlin_unique_applicable( + &self, + qualified: &str, + receiver: Option<&str>, + argument_names: &[Option], + candidate: &RelationshipCandidate, + require_extension: bool, + ) -> Option { + let slots = self + .indexes + .names + .by_qualified + .get(&("kotlin".to_owned(), qualified.to_owned()))?; + let mut eligible = slots + .iter() + .filter_map(|slot| self.declaration(*slot)) + .filter(|declaration| { + declaration.language == "kotlin" + && matches!(declaration.kind.as_str(), "function" | "method") + && (candidate.constraints.allowed_target_kinds.is_empty() + || candidate + .constraints + .allowed_target_kinds + .contains(&declaration.kind)) + }) + .filter_map(|declaration| { + let shape = parse_callable_shape(declaration.signature.as_deref()?)?; + if require_extension != shape.receiver.is_some() { + return None; + } + if let (Some(expected), Some(actual)) = (shape.receiver.as_deref(), receiver) + && !kotlin_types_compatible(expected, actual) + { + return None; + } + kotlin_arguments_apply( + &shape.parameters, + argument_names, + &candidate.constraints.argument_types, + ) + .then_some(declaration) + }) + .take(self.budget.candidates_per_lookup().saturating_add(1)); + let only = eligible.next()?; + if eligible.next().is_some() { + return Some(ResolutionDecision::Ambiguous { candidate_count: 2 }); + } + Some(ResolutionDecision::Resolved { + declaration_id: only.id.clone(), + evidence: ResolutionEvidence { + rule: ResolutionRule::KotlinNamedDefaultArguments, + candidate_count: 1, + }, + }) + } +} + +fn parse_argument_names(context: &str) -> Option>> { + let names = context.strip_prefix("kotlin_args:")?; + if names.is_empty() { + return Some(Vec::new()); + } + Some( + names + .split(',') + .map(|name| (name != "_").then(|| name.to_owned())) + .collect(), + ) +} + +fn parse_callable_shape(signature: &str) -> Option { + let (_, body) = signature.split_once('(')?; + let body = body.strip_suffix(')')?; + let (receiver, parameters) = body + .strip_prefix("receiver=") + .and_then(|body| body.split_once(';')) + .map_or((None, body), |(receiver, rest)| { + (Some(receiver.to_owned()), rest) + }); + let parameters = if parameters.is_empty() { + Vec::new() + } else { + parameters + .split(',') + .map(|parameter| { + let (name, kind) = parameter.split_once(':')?; + let variadic = kind.ends_with("..."); + let kind = kind.strip_suffix("...").unwrap_or(kind); + let defaulted = kind.ends_with('='); + let kind = kind.strip_suffix('=').unwrap_or(kind); + Some(ParameterShape { + name: name.to_owned(), + kind: kind.to_owned(), + defaulted, + variadic, + }) + }) + .collect::>>()? + }; + Some(CallableShape { + receiver, + parameters, + }) +} + +fn kotlin_arguments_apply( + parameters: &[ParameterShape], + argument_names: &[Option], + argument_types: &[Option], +) -> bool { + if argument_names.len() != argument_types.len() { + return false; + } + let mut assigned = vec![false; parameters.len()]; + let mut positional = 0_usize; + for (index, name) in argument_names.iter().enumerate() { + let parameter = if let Some(name) = name { + let Some(parameter) = parameters + .iter() + .position(|parameter| ¶meter.name == name) + else { + return false; + }; + parameter + } else { + while assigned.get(positional).copied().unwrap_or(false) { + positional = positional.saturating_add(1); + } + if positional >= parameters.len() { + let Some(last) = parameters.last() else { + return false; + }; + if !last.variadic { + return false; + } + parameters.len().saturating_sub(1) + } else { + let selected = positional; + if !parameters[selected].variadic { + positional = positional.saturating_add(1); + } + selected + } + }; + if assigned[parameter] && !parameters[parameter].variadic { + return false; + } + if let Some(argument) = argument_types.get(index).and_then(Option::as_deref) + && !kotlin_types_compatible(¶meters[parameter].kind, argument) + { + return false; + } + assigned[parameter] = true; + } + parameters + .iter() + .zip(assigned) + .all(|(parameter, assigned)| assigned || parameter.defaulted || parameter.variadic) +} + +fn kotlin_types_compatible(expected: &str, actual: &str) -> bool { + let expected = canonical_type(expected); + let actual = canonical_type(actual); + expected == actual + || expected + .strip_prefix("kotlin.") + .is_some_and(|expected| actual.rsplit('.').next() == Some(expected)) + || actual + .strip_prefix("kotlin.") + .is_some_and(|actual| expected.rsplit('.').next() == Some(actual)) +} + +fn canonical_type(value: &str) -> String { + let mut depth = 0_u32; + value + .chars() + .filter(|character| match character { + '<' => { + depth = depth.saturating_add(1); + false + } + '>' => { + depth = depth.saturating_sub(1); + false + } + '?' if depth == 0 => false, + _ => depth == 0 && !character.is_whitespace(), + }) + .collect() +} + +fn imported_callable_name(target: &str) -> String { + target.rsplit_once('.').map_or_else( + || target.to_owned(), + |(owner, name)| format!("{owner}::{name}"), + ) +} + +#[cfg(test)] +mod tests { + use super::{kotlin_arguments_apply, parse_argument_names, parse_callable_shape}; + + #[test] + fn named_default_and_variadic_arguments_fail_closed() { + let shape = parse_callable_shape("render(receiver=String;prefix:String=,ids:Long?...)") + .expect("valid test signature"); + assert_eq!(shape.receiver.as_deref(), Some("String")); + let names = parse_argument_names("kotlin_args:ids").expect("valid context"); + assert!(kotlin_arguments_apply( + &shape.parameters, + &names, + &[Some("Long".to_owned())] + )); + let unknown = parse_argument_names("kotlin_args:missing").expect("valid context"); + assert!(!kotlin_arguments_apply( + &shape.parameters, + &unknown, + &[Some("Long".to_owned())] + )); + } +} diff --git a/crates/compass-resolve/src/evidence/languages/mod.rs b/crates/compass-resolve/src/evidence/languages/mod.rs index 04409551..8e481b04 100644 --- a/crates/compass-resolve/src/evidence/languages/mod.rs +++ b/crates/compass-resolve/src/evidence/languages/mod.rs @@ -2,6 +2,7 @@ pub(in crate::evidence) mod csharp; pub(in crate::evidence) mod java; +pub(in crate::evidence) mod kotlin; pub(in crate::evidence) mod php; pub(in crate::evidence) mod policy; pub(in crate::evidence) mod rust; diff --git a/crates/compass-resolve/src/evidence/languages/policy.rs b/crates/compass-resolve/src/evidence/languages/policy.rs index 463cbf73..428ae9bf 100644 --- a/crates/compass-resolve/src/evidence/languages/policy.rs +++ b/crates/compass-resolve/src/evidence/languages/policy.rs @@ -10,6 +10,7 @@ pub(in crate::evidence) enum LanguagePolicyKind { TypeScript, CSharp, Java, + Kotlin, Php, Rust, Generic, @@ -21,6 +22,7 @@ impl LanguagePolicyKind { "javascript" | "javascriptreact" | "typescript" | "typescriptreact" => Self::TypeScript, "csharp" => Self::CSharp, "java" => Self::Java, + "kotlin" => Self::Kotlin, "php" => Self::Php, "rust" => Self::Rust, _ => Self::Generic, @@ -55,6 +57,7 @@ impl LanguagePolicyKind { )) } Self::Java => db.resolve_java_same_package_builtin_collision(candidate), + Self::Kotlin => db.resolve_kotlin_candidate(candidate), Self::Generic => None, } } @@ -67,7 +70,12 @@ impl LanguagePolicyKind { ) -> Option<&'a str> { match self { Self::Java => db.unique_java_applicable_overload(overloads, argument_types), - Self::CSharp | Self::Php | Self::TypeScript | Self::Rust | Self::Generic => None, + Self::CSharp + | Self::Kotlin + | Self::Php + | Self::TypeScript + | Self::Rust + | Self::Generic => None, } } } @@ -90,6 +98,10 @@ mod tests { LanguagePolicyKind::for_language("java"), LanguagePolicyKind::Java ); + assert_eq!( + LanguagePolicyKind::for_language("kotlin"), + LanguagePolicyKind::Kotlin + ); assert_eq!( LanguagePolicyKind::for_language("rust"), LanguagePolicyKind::Rust diff --git a/crates/compass-resolve/src/evidence/projection/edges.rs b/crates/compass-resolve/src/evidence/projection/edges.rs index 14e6b68e..1fcb5a47 100644 --- a/crates/compass-resolve/src/evidence/projection/edges.rs +++ b/crates/compass-resolve/src/evidence/projection/edges.rs @@ -301,6 +301,7 @@ pub(super) const fn resolution_rule_name(rule: ResolutionRule) -> &'static str { ResolutionRule::DeferredReceiver => "deferred-receiver", ResolutionRule::WildcardBinding => "wildcard-binding", ResolutionRule::PhpGlobalFunctionFallback => "php-global-function-fallback", + ResolutionRule::KotlinNamedDefaultArguments => "kotlin-named-default-arguments", ResolutionRule::UniqueModuleOrPackage => "unique-module-or-package", ResolutionRule::ExactHierarchyBase => "exact-hierarchy-base", ResolutionRule::DirectReceiverSuccessorDispatch => "direct-receiver-successor-dispatch", diff --git a/crates/compass-resolve/src/evidence/projection/nodes.rs b/crates/compass-resolve/src/evidence/projection/nodes.rs index 0bc2b90f..2c35af73 100644 --- a/crates/compass-resolve/src/evidence/projection/nodes.rs +++ b/crates/compass-resolve/src/evidence/projection/nodes.rs @@ -18,6 +18,7 @@ pub(super) fn declaration_node( // all generic parameters as `parameter` nodes. let graph_kind = match declaration.kind.as_str() { "type_parameter" | "lifetime_parameter" | "const_parameter" => "parameter", + "object" | "companion_object" if declaration.language == "kotlin" => "class", kind => kind, }; let mut attributes = Map::from_iter([ diff --git a/crates/compass-resolve/src/frameworks/domain.rs b/crates/compass-resolve/src/frameworks/domain.rs index 96720c06..5278aa25 100644 --- a/crates/compass-resolve/src/frameworks/domain.rs +++ b/crates/compass-resolve/src/frameworks/domain.rs @@ -667,6 +667,15 @@ fn domain_attributes( } return attributes; } + if symbol_kind == "component" { + attributes.insert( + "component_type".into(), + fact.detail + .get("bean_kind") + .cloned() + .unwrap_or_else(|| Value::String(fact.kind.clone())), + ); + } if symbol_kind == "job" { for key in ["schedule", "queue"] { if let Some(value) = fact.detail.get(key).cloned() { diff --git a/crates/compass-resolve/src/frameworks/mod.rs b/crates/compass-resolve/src/frameworks/mod.rs index b48e4447..1c838c1b 100644 --- a/crates/compass-resolve/src/frameworks/mod.rs +++ b/crates/compass-resolve/src/frameworks/mod.rs @@ -86,6 +86,10 @@ const UNIVERSAL_FRAMEWORK_PACKS: &[UniversalFrameworkPack] = &[ id: "spring-java", expand: spring::expand, }, + UniversalFrameworkPack { + id: "spring-kotlin", + expand: spring::expand_kotlin, + }, ]; pub use domain::{ diff --git a/crates/compass-resolve/src/frameworks/spring.rs b/crates/compass-resolve/src/frameworks/spring.rs index 6af49ef5..cf5b0d44 100644 --- a/crates/compass-resolve/src/frameworks/spring.rs +++ b/crates/compass-resolve/src/frameworks/spring.rs @@ -19,17 +19,30 @@ struct MappingSpec { } pub(super) fn expand(extraction: &mut Extraction) -> Result<(), FrameworkResolutionError> { + expand_pack(extraction, "spring-java", "java") +} + +pub(super) fn expand_kotlin(extraction: &mut Extraction) -> Result<(), FrameworkResolutionError> { + expand_pack(extraction, "spring-kotlin", "kotlin") +} + +fn expand_pack( + extraction: &mut Extraction, + pack_id: &str, + language: &str, +) -> Result<(), FrameworkResolutionError> { let mut annotations = extraction .framework_facts .iter() .filter_map(|fact| match fact { - RawFrameworkFact::Annotation(annotation) if annotation.pack_id == PACK_ID => { + RawFrameworkFact::Annotation(annotation) if annotation.pack_id == pack_id => { Some(annotation.clone()) } _ => None, }) .collect::>(); - let repository_beans = derive_repository_beans(extraction); + let mut repository_beans = derive_repository_beans(extraction, language); + rewrite_fact_packs(&mut repository_beans, pack_id); if annotations.is_empty() { extraction.framework_facts.extend(repository_beans); return Ok(()); @@ -38,13 +51,15 @@ pub(super) fn expand(extraction: &mut Extraction) -> Result<(), FrameworkResolut .framework_facts .iter() .filter_map(|fact| match fact { - RawFrameworkFact::Domain(fact) if fact.kind == "_spring_constructor" => { + RawFrameworkFact::Domain(fact) + if fact.kind == "_spring_constructor" && fact_pack(fact) == Some(pack_id) => + { Some(fact.clone()) } _ => None, }) .collect::>(); - let constants = spring_constants(extraction); + let constants = spring_constants(extraction, pack_id); resolve_annotation_arguments(&mut annotations, &constants); let by_owner = annotations.iter().fold( @@ -180,16 +195,41 @@ pub(super) fn expand(extraction: &mut Extraction) -> Result<(), FrameworkResolut ); derive_beans_and_domains(&annotations, &by_owner, &constructors, &mut derived); + rewrite_fact_packs(&mut derived, pack_id); extraction.framework_facts.retain(|fact| { - !matches!(fact, RawFrameworkFact::Annotation(annotation) if annotation.pack_id == PACK_ID) - && !matches!(fact, RawFrameworkFact::Domain(domain) if domain.kind == "_spring_constructor") - && !matches!(fact, RawFrameworkFact::Domain(domain) if domain.kind == "_spring_constant") + !matches!(fact, RawFrameworkFact::Annotation(annotation) if annotation.pack_id == pack_id) + && !matches!(fact, RawFrameworkFact::Domain(domain) if domain.kind == "_spring_constructor" && fact_pack(domain) == Some(pack_id)) + && !matches!(fact, RawFrameworkFact::Domain(domain) if domain.kind == "_spring_constant" && fact_pack(domain) == Some(pack_id)) }); extraction.framework_facts.extend(derived); Ok(()) } -fn derive_repository_beans(extraction: &Extraction) -> Vec { +fn rewrite_fact_packs(facts: &mut [RawFrameworkFact], pack_id: &str) { + for fact in facts { + match fact { + RawFrameworkFact::Annotation(annotation) => annotation.pack_id = pack_id.to_owned(), + RawFrameworkFact::Route(route) => { + route.detail.insert( + "frameworkPack".to_owned(), + Value::String(pack_id.to_owned()), + ); + } + RawFrameworkFact::Domain(domain) => { + domain.detail.insert( + "frameworkPack".to_owned(), + Value::String(pack_id.to_owned()), + ); + } + } + } +} + +fn fact_pack(fact: &RawDomainFact) -> Option<&str> { + fact.detail.get("frameworkPack").and_then(Value::as_str) +} + +fn derive_repository_beans(extraction: &Extraction, language: &str) -> Vec { let nodes = extraction .nodes .iter() @@ -208,6 +248,9 @@ fn derive_repository_beans(extraction: &Extraction) -> Vec { .filter_map(|edge| { let source = nodes.get(edge.source.as_str())?; let target = nodes.get(edge.target.as_str())?; + if source.string("language") != language { + return None; + } let target_qualified = target.string("qualified_name"); if !target_qualified.starts_with("org.springframework.data.") || !target_qualified.ends_with("Repository") @@ -294,13 +337,13 @@ struct SpringConstants { by_terminal: BTreeMap>, } -fn spring_constants(extraction: &Extraction) -> SpringConstants { +fn spring_constants(extraction: &Extraction, pack_id: &str) -> SpringConstants { let mut constants = SpringConstants::default(); for fact in &extraction.framework_facts { let RawFrameworkFact::Domain(fact) = fact else { continue; }; - if fact.kind != "_spring_constant" { + if fact.kind != "_spring_constant" || fact_pack(fact) != Some(pack_id) { continue; } let Some(expression) = fact.detail.get("expression").and_then(Value::as_str) else { diff --git a/crates/compass-resolve/tests/php_ruby_jvm_routes.rs b/crates/compass-resolve/tests/php_ruby_jvm_routes.rs index 2a548071..81fd9a56 100644 --- a/crates/compass-resolve/tests/php_ruby_jvm_routes.rs +++ b/crates/compass-resolve/tests/php_ruby_jvm_routes.rs @@ -398,7 +398,17 @@ class KotlinController { } "#; let mut engine = Engine::default(); - let mut extraction = engine.extract_source(Path::new("src/KotlinController.kt"), source)?; + let extraction = engine.extract_source(Path::new("src/KotlinController.kt"), source)?; + assert!( + !extraction.framework_facts.is_empty(), + "missing Kotlin universal Spring facts: evidence={:#?}", + extraction.semantic_evidence + ); + let sources = HashMap::from([( + "src/KotlinController.kt".to_owned(), + String::from_utf8(source.to_vec())?, + )]); + let mut extraction = resolve(&[extraction], &sources); let resolved = resolve_and_publish_framework_routes(&mut extraction, FrameworkLimits::default())?; @@ -406,7 +416,18 @@ class KotlinController { resolved.iter().any(|route| { route.route.operation == "GET" && route.route.normalized_path == "/api/users/{id}" - && route.route.handler_reference == "KotlinController.show" + && route + .route + .detail + .get("target_qualified_name") + .and_then(serde_json::Value::as_str) + == Some("example.KotlinController::show") + && route + .route + .detail + .get("frameworkPack") + .and_then(serde_json::Value::as_str) + == Some("spring-kotlin") && route.state == ResolutionState::Exact }), "routes={resolved:#?}" diff --git a/crates/compass-resolve/tests/universal_resolution.rs b/crates/compass-resolve/tests/universal_resolution.rs index 56a74c46..3f8df8ea 100644 --- a/crates/compass-resolve/tests/universal_resolution.rs +++ b/crates/compass-resolve/tests/universal_resolution.rs @@ -4,6 +4,7 @@ include!("universal_resolution/core.rs"); include!("universal_resolution/csharp.rs"); +include!("universal_resolution/kotlin.rs"); include!("universal_resolution/rust.rs"); include!("universal_resolution/python.rs"); include!("universal_resolution/go.rs"); diff --git a/crates/compass-resolve/tests/universal_resolution/kotlin.rs b/crates/compass-resolve/tests/universal_resolution/kotlin.rs new file mode 100644 index 00000000..e2abd849 --- /dev/null +++ b/crates/compass-resolve/tests/universal_resolution/kotlin.rs @@ -0,0 +1,123 @@ +#[test] +fn kotlin_named_default_member_and_imported_extension_calls_resolve_stably() { + let service_source = br#" +package demo +class Service { + fun render(prefix: String = "x", count: Int = 1): String = prefix +} +"#; + let extension_source = br#" +package ext +fun String.decorate(prefix: String = "x"): String = this +"#; + let caller_source = br#" +package demo +import ext.decorate +class Caller { + fun run(service: Service, text: String) { + service.render(count = 2) + text.decorate() + } +} +"#; + let service = extract("src/Service.kt", service_source); + let extension = extract("src/Extensions.kt", extension_source); + let caller = extract("src/Caller.kt", caller_source); + let sources = HashMap::from([ + ( + "src/Service.kt".to_owned(), + String::from_utf8_lossy(service_source).into_owned(), + ), + ( + "src/Extensions.kt".to_owned(), + String::from_utf8_lossy(extension_source).into_owned(), + ), + ( + "src/Caller.kt".to_owned(), + String::from_utf8_lossy(caller_source).into_owned(), + ), + ]); + let first = compass_resolve::resolve( + &[service.clone(), extension.clone(), caller.clone()], + &sources, + ); + let reversed = compass_resolve::resolve(&[caller, extension, service], &sources); + assert_eq!(universal_edges(&first), universal_edges(&reversed)); + + for target in ["demo.Service::render", "ext::decorate"] { + let declaration = first + .nodes + .iter() + .find(|node| node.string("qualified_name") == target) + .unwrap_or_else(|| panic!("missing {target}: {:#?}", first.nodes)); + assert!(first.edges.iter().any(|edge| { + edge.target == declaration.id + && edge.string("relation") == "calls" + && edge.string("resolution_rule") == "kotlin-named-default-arguments" + }), "missing resolved call to {target}: {:#?}", first.edges); + } +} + +#[test] +fn kotlin_never_terminal_matches_a_java_declaration_without_compiler_evidence() { + let java = extract( + "src/Service.java", + b"package demo; public class Service { public void render() {} }", + ); + let kotlin_source = br#" +package demo +class Caller { + fun run(service: Service) { service.render() } +} +"#; + let kotlin = extract("src/Caller.kt", kotlin_source); + let sources = HashMap::from([ + ( + "src/Service.java".to_owned(), + "package demo; public class Service { public void render() {} }".to_owned(), + ), + ( + "src/Caller.kt".to_owned(), + String::from_utf8_lossy(kotlin_source).into_owned(), + ), + ]); + let resolved = compass_resolve::resolve(&[java, kotlin], &sources); + let java_render = resolved + .nodes + .iter() + .find(|node| { + node.string("language") == "java" + && node.string("qualified_name") == "demo.Service::render" + }) + .expect("Java render declaration"); + assert!(resolved.edges.iter().all(|edge| { + !(edge.target == java_render.id + && edge.string("language") == "kotlin" + && edge.string("relation") == "calls") + })); +} + +#[test] +fn kotlin_objects_project_to_supported_class_nodes() { + let source = br#" +package demo +class Owner { + companion object Factory +} +object Singleton +"#; + let extraction = extract("src/Objects.kt", source); + let sources = HashMap::from([( + "src/Objects.kt".to_owned(), + String::from_utf8_lossy(source).into_owned(), + )]); + let resolved = compass_resolve::resolve(&[extraction], &sources); + for qualified in ["demo.Owner.Factory", "demo.Singleton"] { + let node = resolved + .nodes + .iter() + .find(|node| node.string("qualified_name") == qualified) + .unwrap_or_else(|| panic!("missing {qualified}: {:#?}", resolved.nodes)); + assert_eq!(node.string("symbol_kind"), "class"); + } +} diff --git a/docs/design/language-architecture.md b/docs/design/language-architecture.md index 153eef9e..e64474b1 100644 --- a/docs/design/language-architecture.md +++ b/docs/design/language-architecture.md @@ -34,6 +34,7 @@ This architecture is transitioning one language at a time. The status labels bel | Available now | Java is a hard-cut version-3 `UniversalCandidate`; its replaced publisher and Java member resolver are removed, and post-cutover pinned-corpus qualification is complete | | Available now | TypeScript and JavaScript are hard-cut `UniversalCandidate` adapters; TSX uses the TypeScript identity, both share the bounded ECMAScript evidence emitter, and their replaced generic publisher is removed | | Available now | PHP is a hard-cut version-1 `UniversalCandidate` with explicit case-insensitive type/function/method identity, bounded Composer PSR-4 evidence, conservative trait/inheritance dispatch, and universal Laravel/Drupal source packs; Drupal configuration and Blade template extraction remain available | +| Available now | Kotlin is a hard-cut version-1 `UniversalCandidate` with packages, imports, nominal and companion declarations, constructors, functions and extensions, properties, annotations, generic and nullable types, and named/default argument evidence; its complete quality audit remains open | | Available now | The remaining production languages keep their established extraction and resolution paths | | Planned | Later languages transition independently after language-specific qualification | @@ -375,13 +376,12 @@ Framework detection is downstream of language parsing but upstream of final Code Graph v1 publication. Packs emit anchored route or domain facts; the framework resolver validates targets and materializes typed relationships. -The Java Spring and C# ASP.NET source packs are production universal framework -packs. -The Spring pack consumes exact Java annotation, call, import, type, ownership, and hierarchy +The Java and Kotlin Spring and C# ASP.NET source packs are production universal +framework packs. +The Spring packs consume exact language-keyed annotation, call, import, type, ownership, and hierarchy evidence and derives HTTP, bean, injection, messaging, scheduling, persistence, transaction, and security meaning before framework resolution. Its Java legacy -detectors are removed atomically; Kotlin Spring routing remains on its explicit -established pack until Kotlin has a universal language adapter. Established +detector and Kotlin established detector are removed atomically. Established source, config, and template adapters execute through the same static runtime, which owns selection, activation, limits, and publication without requiring a runtime plugin ABI. The ASP.NET pack consumes exact C# attribute, alias, import, @@ -389,6 +389,19 @@ ownership, and overload evidence, then composes controller/action templates in the project resolver; its former regex/line scanner is removed. Other packs retain their established semantics until their own qualification and hard cut. +## Java/Kotlin interoperability boundary + +Kotlin syntax evidence is always keyed to the exact `kotlin` language. Java and +Kotlin declarations may share JVM package names and terminal spellings, but +neither is evidence that one declaration is the other's callable target. +Cross-language calls are therefore published only when fresh project/compiler +evidence, such as an exact SCIP definition endpoint, identifies both anchored +ends. Package proximity, JVM-family membership, imports, and terminal-name +matching cannot create a Java/Kotlin call edge. Missing or conflicting compiler +evidence remains unresolved. This boundary does not prevent a framework pack +from recognizing an external Spring annotation; it prevents that annotation or +call from being rebound to an unrelated local JVM declaration. + ## Quality and failure boundaries The universal framework prefers defensible evidence over speculative graph size. diff --git a/docs/implementation/kotlin-universal-qualification.md b/docs/implementation/kotlin-universal-qualification.md new file mode 100644 index 00000000..f8bcd70e --- /dev/null +++ b/docs/implementation/kotlin-universal-qualification.md @@ -0,0 +1,90 @@ +--- +meta: + contentType: Reference + title: Kotlin universal candidate qualification + navLabel: Kotlin Qualification + category: Implementation + overview: Reproducible baseline and candidate evidence for the Kotlin hard cut. + goal: Record what the Kotlin candidate has proved and which completion gates remain open. + audience: + - Compass language contributors + - release reviewers + openQuestions: [] +--- + +# Kotlin universal candidate qualification + +This record compares the established Kotlin publisher from Compass commit +`2db60035` with the version-1 universal candidate. It does not promote Kotlin +to `UniversalComplete`. + +## Pinned corpus + +- Repository: `spring-projects/spring-framework` +- Commit: `da4b31c82b567a0531c6980b5172cba1fc7e6ed5` +- Inventory: 390 `.kt` and `.kts` files; Compass discovers 388 under its normal + scope policy +- Relative-path/content inventory SHA-256: + `402a15c4318573cfe87f3e8b0d023c216d8a43295ecadf06f2281550f83453be` + +The source checkout is a read-only qualification input. Cold and warm graphs +were built with `--no-cluster --no-viz --inference-level max` from debug +binaries on the same machine. + +## Graph comparison + +| Relation family | Established | Universal v1 | +| --- | ---: | ---: | +| `calls` | 2,174 | 2,838 | +| `contains` | 2,935 | 5,847 | +| `extends` | 97 | 61 | +| `implements` | 118 | 98 | +| `imports` | 0 | 2,170 | +| `instantiates` | 943 | 1,306 | +| `references` | 1,648 | 4,067 | +| `registers` | 0 | 215 | +| `routes_to` | 11 | 28 | + +The universal graph contains 10,649 nodes and 16,632 relationships with graph +SHA-256 +`471b99daef7fd69386482637c120ed2ddab667a2a3c56e0a496a388ef409add4`. +Cold and cache-reused publication are byte-identical. Publication reports zero +omitted nodes, zero omitted relationships, and zero identity collisions. Three +source files exercise Tree-sitter recovery and remain explicitly partial; +4,170 external symbols remain unresolved rather than being rebound by terminal +name. + +## Performance comparison + +| Workload | Established wall / peak RSS | Universal v1 wall / peak RSS | +| --- | ---: | ---: | +| Cold | 12.82 s / 158.3 MB | 28.52 s / 248.1 MB | +| Warm | 0.46 s / 27.5 MB | 0.70 s / 27.8 MB | +| One-file whitespace change | 12.24 s / 144.4 MB | 30.13 s / 229.4 MB | + +The candidate expands relation coverage but currently regresses cold, warm, +and incremental latency. These measurements are evidence for optimization +work, not a performance claim. + +An incremental-publication follow-up on the same 388-file Spring Kotlin corpus +reduced a one-file trailing-comment restoration to 1.01 seconds with the +optimized binary. The run extracted one file, reused 387, spent zero reported +time in graph assembly, wrote zero new immutable objects, and reused 55 store +objects. The earlier 30.13-second observation exposed two defects now covered +by regression tests: `update` did not enter the fact-neutral path, and Kotlin +file/package envelope anchors made harmless EOF edits look semantic. Peak RSS +was not recaptured in the follow-up, so the original memory evidence remains +the only recorded comparison. + +## Completion status + +Fixture conformance covers exact UTF-8 anchors, deterministic ordering, +malformed syntax, traversal limits, named/default arguments, extensions, +object and companion projection, and Java-terminal collision rejection. The +Spring Kotlin route fixture qualifies the universal framework pack. + +The independent quality audit is still required. In particular, no claim is +made that the minimum 2,000 accepted-relationship pool, precision/recall +thresholds, or zero-tolerance critical judgments have passed. Kotlin must +remain `UniversalCandidate` until that audit and the remaining performance work +complete. diff --git a/docs/implementation/universal-evidence.md b/docs/implementation/universal-evidence.md index a0e0cf57..8e6651ed 100644 --- a/docs/implementation/universal-evidence.md +++ b/docs/implementation/universal-evidence.md @@ -30,10 +30,10 @@ future work. | Status | Implementation | | --- | --- | | Available now | `compass-languages` owns the source registry, parsers, established adapters, and semantic evidence version 1 | -| Available now | C#, Python, Go, Rust, Java, TypeScript, and JavaScript are entries in the hard-cut `AdapterRegistry`; C# is at adapter version 1, Go and Java are at version 3, Python is at version 11, Rust is at version 15, and the ECMAScript candidates are at version 5 | +| Available now | C#, Python, Go, Rust, Java, Kotlin, TypeScript, and JavaScript are entries in the hard-cut `AdapterRegistry`; C# and Kotlin are at adapter version 1, Go and Java are at version 3, Python is at version 11, Rust is at version 15, and the ECMAScript candidates are at version 5 | | Available now | `EvidenceBuilder` emits bounded `SemanticEvidenceBatch` values for all registered universal languages; C# and the ECMAScript family use dedicated source-grounded emitters, while TypeScript and JavaScript retain distinct adapter identities | | Available now | `UniversalResolutionIndex` resolves and projects hard-cut evidence without a language-name branch | -| Available now | Rust has passed Phase 2 qualification; C#, Java, TypeScript, and JavaScript remain `UniversalCandidate` while their respective completion gates run | +| Available now | Rust has passed Phase 2 qualification; C#, Java, Kotlin, TypeScript, and JavaScript remain `UniversalCandidate` while their respective completion gates run | | Planned | `GrammarProvider`, grammar provenance, and producer-registry validation | | Planned | Independently qualified hard cuts for the remaining registered languages | @@ -389,25 +389,29 @@ This table describes the current branch. | Go | Hard-cut universal | `SemanticEvidenceBatch` plus shared resolution and projection; no replaced Go collection resolver | | Rust | Hard-cut `UniversalCandidate` | Version-15 adapter evidence plus shared resolution and projection; bounded method-result chains, impl-scoped associated types, exact `Self::Type` returns, scoped generic parameters, and nested lexical calls are preserved, Phase 2 is qualified, and replaced Rust paths are removed | | Java | Hard-cut `UniversalCandidate` | Version-3 evidence plus shared resolution and projection; exact callable ownership, proven conversions, replaced Java paths removed, and post-cutover corpus qualification complete | +| Kotlin | Hard-cut `UniversalCandidate` | Version-1 evidence plus shared resolution and projection; exact Kotlin-only source resolution, named/default arguments and extensions, replaced Kotlin paths removed, and complete quality-audit gates still pending | | TypeScript | Hard-cut `UniversalCandidate` | Version-5 evidence plus shared resolution and projection; TSX aliases this identity and the replaced generic publisher is removed | | JavaScript | Hard-cut `UniversalCandidate` | Version-5 evidence plus shared resolution and projection; CJS/ESM and package decisions retain source and provenance bounds | | Remaining registered languages | Established direct adapters | Current language-specific or generic extraction paths | -Python, Go, Rust, Java, TypeScript, and JavaScript are hard-cut on this branch. +Python, Go, Rust, Java, Kotlin, TypeScript, and JavaScript are hard-cut on this branch. Each later language reuses the same hard-cut registry, evidence model, resolver, and projector without adding language cases to the central publisher. A language's transition does not alter the publication route of any other language. +The pinned Kotlin baseline, coverage deltas, performance results, and open +audit gates are recorded in +[Kotlin universal candidate qualification](kotlin-universal-qualification.md). ## Framework-pack status `FrameworkPackDescriptor` and `FrameworkPackRegistry` define the universal pack contract and validate language capabilities, framework capabilities, activation evidence, accepted roles, typed relationship families, occurrence policy, and -limits. The production registry contains `spring-java`. It derives framework -meaning only from universal Java evidence and publishes through the shared -framework resolver. Established source, config, and template packs remain -active until their individual hard cutovers. +limits. The production registry contains `spring-java` and `spring-kotlin`. +They derive framework meaning only from exact language-keyed universal +evidence and publish through the shared framework resolver. Established source, +config, and template packs remain active until their individual hard cutovers. All established and universal framework adapters now execute through one static framework-pack runtime in `compass-languages`. The runtime owns pack diff --git a/docs/reference/universal-semantic-evidence.md b/docs/reference/universal-semantic-evidence.md index 281fdc23..17bb1707 100644 --- a/docs/reference/universal-semantic-evidence.md +++ b/docs/reference/universal-semantic-evidence.md @@ -699,20 +699,26 @@ Python or Go has met the production qualification gates. ## Current qualification boundary Python and Go are hard-cut universal language adapters. C#, PHP, Rust, Java, -TypeScript, and JavaScript remain `UniversalCandidate`; the latter two share a +Kotlin, TypeScript, and JavaScript remain `UniversalCandidate`; the latter two share a bounded ECMAScript emitter but retain distinct adapter identities. TSX uses the TypeScript candidate profile. C# and PHP use dedicated bounded AST emitters and no longer publish or resolve through their replaced raw extraction paths. Candidate status means the universal route is active while complete capability -and corpus qualification remain in progress. `spring-java` and +and corpus qualification remain in progress. `spring-java`, `spring-kotlin`, and `aspnet-csharp` are production universal framework packs. The `php-frameworks` pack consumes exact PHP call/import/ownership evidence for Laravel routes and Drupal hooks while configuration and template extraction remain separate. Spring advertises typed HTTP, bean, injection, messaging, scheduling, persistence, transaction, and security capabilities; ASP.NET consumes exact C# imports, attributes, -ownership, callable signatures, and source ranges to derive MVC routes. Kotlin -Spring remains on its established detector. +ownership, callable signatures, and source ranges to derive MVC routes. The +Kotlin pack consumes the version-1 Kotlin universal evidence batch and never +re-enters the removed established detector. + +Kotlin source resolution is exact-language only. Java/Kotlin call edges require +fresh project/compiler evidence with exact anchored endpoints; imports, shared +packages, JVM-family membership, or equal terminal names are insufficient. An +absent or conflicting endpoint remains unresolved. C# project resolution is language-keyed and namespace/import aware. Direct bases and receiver types resolve only to exact C# declarations; ambiguous diff --git a/fixtures/code-graph/routes/jvm/SpringController.kt b/fixtures/code-graph/routes/jvm/SpringController.kt new file mode 100644 index 00000000..de1ce500 --- /dev/null +++ b/fixtures/code-graph/routes/jvm/SpringController.kt @@ -0,0 +1,12 @@ +package example.kotlin + +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/kotlin") +class SpringKotlinController { + @GetMapping("/users/{id}") + fun show(id: Long): String = id.toString() +} diff --git a/tests/qualification/code-graph-v1-semantic.json b/tests/qualification/code-graph-v1-semantic.json index 9b2016df..90e6567a 100644 --- a/tests/qualification/code-graph-v1-semantic.json +++ b/tests/qualification/code-graph-v1-semantic.json @@ -298,6 +298,33 @@ "allowHeuristic": false, "candidates": [] }, + { + "id": "flow-spring-kotlin", + "framework": "spring", + "routeFramework": "spring", + "operation": "GET", + "path": "/kotlin/users/{id}", + "routeSource": "fixtures/code-graph/routes/jvm/SpringController.kt", + "handler": { + "qualifiedName": "example.kotlin.SpringKotlinController::show" + }, + "handlerSource": "fixtures/code-graph/routes/jvm/SpringController.kt", + "relationship": "routes_to", + "stage": "handler", + "position": 0, + "handlerKind": "method", + "handlerLanguage": "kotlin", + "resolution": "exact", + "origins": [ + "ast" + ], + "producer": "compass.frameworks.spring", + "rules": [ + "spring-request-mapping|stage:handler:0" + ], + "allowHeuristic": false, + "candidates": [] + }, { "id": "flow-gin", "framework": "gin",