From 8eb83e9ec6e1992dddb95a924d2cf92976ccec0c Mon Sep 17 00:00:00 2001 From: akcorca Date: Thu, 6 Aug 2026 19:35:33 +0900 Subject: [PATCH 1/4] refactor: sharpen responsibility boundaries and dogfood gates --- .../runtime_boundary/async_runtime/rust.rs | 600 ++-------------- .../async_runtime/rust/api_paths.rs | 37 + .../async_runtime/rust/import_identity.rs | 178 +++++ .../async_runtime/rust/runtime_receiver.rs | 301 ++++++++ .../tests/cli/semantic_idioms/guards.rs | 170 ++--- .../library_api/core_builtins.rs | 53 +- .../tests/cli/semantic_idioms/literals.rs | 417 ++++------- crates/nose-cli/tests/cli/support/fixtures.rs | 28 + crates/nose-cli/tests/equivalence.rs | 79 +++ .../equivalence/collection_membership.rs | 656 +++++++----------- .../equivalence/map_default_boundaries.rs | 105 ++- .../tests/equivalence/option_boundaries.rs | 87 +-- crates/nose-detect/src/lib.rs | 2 + crates/nose-detect/src/orchestration.rs | 226 +----- .../src/orchestration/connected_pricing.rs | 12 +- .../nose-detect/src/orchestration/features.rs | 11 +- .../src/orchestration/incremental_session.rs | 16 +- .../nose-detect/src/orchestration/output.rs | 123 ++++ .../nose-detect/src/orchestration/scoring.rs | 39 ++ .../nose-detect/src/orchestration/stages.rs | 3 +- .../nose-detect/src/orchestration/timing.rs | 29 + .../src/strict_exact/tests/receivers.rs | 92 +-- .../src/strict_exact/tests/support.rs | 21 +- crates/nose-detect/src/test_support.rs | 124 ++++ .../src/units/tests/strict_exact_surfaces.rs | 62 +- crates/nose-frontend/src/corpus.rs | 172 +++++ crates/nose-frontend/src/corpus_tests.rs | 2 +- crates/nose-frontend/src/lib.rs | 188 +---- .../src/lower/post_lower_evidence.rs | 595 +--------------- .../lower/post_lower_evidence/api_records.rs | 95 +++ .../lower/post_lower_evidence/bound_order.rs | 163 +++++ .../src/lower/post_lower_evidence/symbols.rs | 313 +++++++++ .../tests/source_evidence/hof_demand.rs | 29 +- .../src/value_graph/tests/support.rs | 5 +- .../src/constructor_contracts.rs | 110 ++- .../src/library_api/contract_keys.rs | 20 +- crates/nose-semantics/src/library_api/rows.rs | 11 +- crates/nose-semantics/src/operators.rs | 493 +------------ .../src/operators/collection_contracts.rs | 91 +++ .../src/operators/comparisons.rs | 100 +++ .../src/operators/value_domains.rs | 286 ++++++++ .../src/packs/compiled/constants.rs | 209 +----- .../compiled/constants/language_metadata.rs | 94 +++ .../constants/protocol_coordinates.rs | 134 ++++ crates/nose-semantics/src/test_support.rs | 72 +- .../admission_resolvers/pack_resolvers_0.rs | 203 +----- .../admission_resolvers/pack_resolvers_2.rs | 83 +-- .../admission_resolvers/span/core.rs | 153 +--- .../admission_resolvers/support_provenance.rs | 5 + .../support_provenance/java_collection.rs | 164 +++++ .../support_provenance/rust_option.rs | 199 ++++++ .../tests/semantic_evidence/import_symbols.rs | 53 ++ .../semantic_evidence/import_symbols/core.rs | 171 ++--- docs/dogfooding-history.md | 41 ++ docs/dogfooding.md | 13 +- docs/refactoring-ratchets.md | 42 ++ scripts/check-ci-local.sh | 5 + scripts/duplication-baseline.json | 18 +- scripts/evidence/artifacts.json | 2 +- 59 files changed, 3881 insertions(+), 3924 deletions(-) create mode 100644 crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/api_paths.rs create mode 100644 crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/import_identity.rs create mode 100644 crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/runtime_receiver.rs create mode 100644 crates/nose-detect/src/orchestration/output.rs create mode 100644 crates/nose-detect/src/orchestration/scoring.rs create mode 100644 crates/nose-detect/src/orchestration/timing.rs create mode 100644 crates/nose-detect/src/test_support.rs create mode 100644 crates/nose-frontend/src/corpus.rs create mode 100644 crates/nose-frontend/src/lower/post_lower_evidence/api_records.rs create mode 100644 crates/nose-frontend/src/lower/post_lower_evidence/bound_order.rs create mode 100644 crates/nose-frontend/src/lower/post_lower_evidence/symbols.rs create mode 100644 crates/nose-semantics/src/operators/collection_contracts.rs create mode 100644 crates/nose-semantics/src/operators/comparisons.rs create mode 100644 crates/nose-semantics/src/operators/value_domains.rs create mode 100644 crates/nose-semantics/src/packs/compiled/constants/language_metadata.rs create mode 100644 crates/nose-semantics/src/packs/compiled/constants/protocol_coordinates.rs create mode 100644 crates/nose-semantics/src/tests/library_api_evidence/admission_resolvers/support_provenance/java_collection.rs create mode 100644 crates/nose-semantics/src/tests/library_api_evidence/admission_resolvers/support_provenance/rust_option.rs diff --git a/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust.rs b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust.rs index 1cad3923f..2ab8495f4 100644 --- a/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust.rs +++ b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust.rs @@ -1,9 +1,16 @@ -use super::{callee_field_method, callee_path, method_receiver, node_defines_name, rust_imports}; use crate::verify_admission::AdmissionContext; -use nose_il::{ - stable_symbol_hash, DomainEvidence, EvidenceAnchor, EvidenceEmitter, EvidenceKind, - EvidenceStatus, Interner, NodeId, NodeKind, SymbolEvidenceKind, +use nose_il::{Interner, NodeId}; + +mod api_paths; +mod import_identity; +mod runtime_receiver; + +use api_paths::{is_join_macro, is_select_macro, is_spawn, runtime_root}; +use import_identity::{ + imported_async_join_macro_member, imported_async_select_macro_member, + imported_async_spawn_member, }; +use runtime_receiver::is_future_drive_call; pub(super) fn push_rust_async_runtime_call_missing_evidence( il: &nose_il::Il, @@ -20,573 +27,70 @@ pub(super) fn push_rust_async_runtime_call_missing_evidence( { return false; } + let is_macro_invocation = nose_semantics::source_call_at_node(il, call) == Some(nose_il::SourceCallKind::MacroInvocation); - if !is_macro_invocation { - if callee_path.is_some_and(rust_async_spawn_path) - || rust_imported_async_spawn_member(il, interner, callee, context) - { - super::push_task_spawn_missing_evidence(labels); - return true; - } - if rust_future_drive_call(il, interner, callee, callee_path, context) { - push_future_drive_missing_evidence(labels); - return true; - } - } - if !is_macro_invocation { - return false; - } - if callee_path.is_some_and(rust_async_join_macro_path) { - super::push_async_aggregate_all_missing_evidence(labels); - return true; - } - if rust_imported_async_join_macro_member(il, interner, callee, context) { - super::push_async_aggregate_all_missing_evidence(labels); - return true; - } - if callee_path.is_some_and(rust_async_select_macro_path) { - super::push_async_aggregate_first_missing_evidence(labels); - return true; - } - if rust_imported_async_select_macro_member(il, interner, callee, context) { - super::push_async_aggregate_first_missing_evidence(labels); - return true; - } - false -} - -fn runtime_root(callee_path: &str) -> Option<&str> { - callee_path.split("::").next() -} - -fn module_root(module: &str) -> &str { - module.split("::").next().unwrap_or(module) -} - -fn rust_async_spawn_path(callee_path: &str) -> bool { - matches!( - callee_path, - "tokio::spawn" - | "tokio::task::spawn" - | "tokio::task::spawn_blocking" - | "async_std::task::spawn" - | "async_std::task::spawn_blocking" - ) -} - -fn rust_async_join_macro_path(callee_path: &str) -> bool { - matches!( - callee_path, - "tokio::join" - | "tokio::try_join" - | "futures::join" - | "futures::try_join" - | "futures_util::join" - | "futures_util::try_join" - ) -} - -fn rust_async_select_macro_path(callee_path: &str) -> bool { - matches!( - callee_path, - "tokio::select" | "futures::select" | "futures_util::select" - ) + let operation = if is_macro_invocation { + macro_operation(il, interner, callee, callee_path, context) + } else { + call_operation(il, interner, callee, callee_path, context) + }; + operation.is_some_and(|operation| { + operation.push_missing_evidence(labels); + true + }) } -fn rust_future_drive_call( +fn call_operation( il: &nose_il::Il, interner: &Interner, callee: NodeId, callee_path: Option<&str>, context: &AdmissionContext, -) -> bool { - callee_path == Some("tokio_test::block_on") - || rust_imported_runtime_member(il, interner, callee, "tokio_test", "block_on", context) - || (callee_field_method(il, interner, callee) == Some("block_on") - && method_receiver(il, callee).is_some_and(|receiver| { - rust_tokio_runtime_block_on_receiver(il, interner, receiver, context) - })) -} - -fn rust_tokio_runtime_block_on_receiver( - il: &nose_il::Il, - interner: &Interner, - receiver: NodeId, - context: &AdmissionContext, -) -> bool { - if context.rust_runtime_root_is_local_for_file("tokio", &il.meta.path) { - return false; - } - rust_tokio_runtime_driver_receiver_expr(il, interner, receiver, context) - || rust_tokio_runtime_local_binding_receiver_expr(il, interner, receiver, context) - || rust_tokio_runtime_parameter_receiver_expr(il, interner, receiver) - || rust_tokio_runtime_field_receiver_expr(il, receiver) -} - -fn rust_tokio_runtime_driver_receiver_expr( - il: &nose_il::Il, - interner: &Interner, - receiver: NodeId, - context: &AdmissionContext, -) -> bool { - if let Some(inner) = rust_try_propagation_operand(il, receiver) { - return rust_tokio_runtime_driver_result_expr(il, interner, inner, context); - } - if il.kind(receiver) != NodeKind::Call { - return false; - } - let Some(callee) = il.children(receiver).first().copied() else { - return false; - }; - if callee_path(il, interner, callee) - .as_deref() - .is_some_and(|path| rust_tokio_runtime_driver_path(il, interner, callee, path, context)) +) -> Option { + if callee_path.is_some_and(is_spawn) + || imported_async_spawn_member(il, interner, callee, context) { - return true; + return Some(RustAsyncOperation::Spawn); } - if !rust_tokio_runtime_unwrap_method(il, interner, callee) { - return false; - } - method_receiver(il, callee) - .is_some_and(|inner| rust_tokio_runtime_driver_result_expr(il, interner, inner, context)) + is_future_drive_call(il, interner, callee, callee_path, context) + .then_some(RustAsyncOperation::FutureDrive) } -fn rust_try_propagation_operand(il: &nose_il::Il, node: NodeId) -> Option { - (nose_semantics::source_protocol_at_node(il, node) - == Some(nose_il::SourceProtocolKind::TryPropagation)) - .then(|| il.children(node).first().copied()) - .flatten() -} - -fn rust_tokio_runtime_driver_result_expr( - il: &nose_il::Il, - interner: &Interner, - receiver: NodeId, - context: &AdmissionContext, -) -> bool { - if il.kind(receiver) != NodeKind::Call { - return false; - } - let Some(callee) = il.children(receiver).first().copied() else { - return false; - }; - if callee_path(il, interner, callee) - .as_deref() - .is_some_and(|path| rust_tokio_runtime_result_path(il, interner, callee, path, context)) - { - return true; - } - if rust_tokio_runtime_result_adapter_method(il, interner, callee) { - return method_receiver(il, callee).is_some_and(|inner| { - rust_tokio_runtime_driver_result_expr(il, interner, inner, context) - }); - } - if callee_field_method(il, interner, callee) != Some("build") { - return false; - } - method_receiver(il, callee) - .is_some_and(|inner| rust_tokio_runtime_builder_expr(il, interner, inner, context)) -} - -fn rust_tokio_runtime_builder_expr( - il: &nose_il::Il, - interner: &Interner, - receiver: NodeId, - context: &AdmissionContext, -) -> bool { - if il.kind(receiver) != NodeKind::Call { - return false; - } - let Some(callee) = il.children(receiver).first().copied() else { - return false; - }; - if callee_path(il, interner, callee) - .as_deref() - .is_some_and(|path| rust_tokio_runtime_builder_path(il, interner, callee, path, context)) - { - return true; - } - if !rust_tokio_runtime_builder_chain_method(il, interner, callee) { - return false; - } - method_receiver(il, callee) - .is_some_and(|inner| rust_tokio_runtime_builder_expr(il, interner, inner, context)) -} - -fn rust_tokio_runtime_local_binding_receiver_expr( - il: &nose_il::Il, - interner: &Interner, - receiver: NodeId, - context: &AdmissionContext, -) -> bool { - if il.kind(receiver) != NodeKind::Var { - return false; - } - let Some(local_name) = super::super::super::node_exact_name(il, interner, receiver) else { - return false; - }; - rust_last_visible_local_assignment_rhs(il, interner, receiver, local_name) - .is_some_and(|rhs| rust_tokio_runtime_driver_receiver_expr(il, interner, rhs, context)) -} - -fn rust_tokio_runtime_parameter_receiver_expr( - il: &nose_il::Il, - interner: &Interner, - receiver: NodeId, -) -> bool { - if il.kind(receiver) != NodeKind::Var { - return false; - } - matches!( - nose_semantics::domain_evidence_for_receiver(il, interner, receiver), - Some(DomainEvidence::Nominal { type_hash }) - if type_hash == stable_symbol_hash("tokio::runtime::Runtime") - || type_hash == stable_symbol_hash("tokio::runtime::Handle") - ) -} - -fn rust_tokio_runtime_field_receiver_expr(il: &nose_il::Il, receiver: NodeId) -> bool { - if il.kind(receiver) != NodeKind::Field { - return false; - } - matches!( - nose_semantics::domain_evidence_for_node(il, receiver), - Some(DomainEvidence::Nominal { type_hash }) - if type_hash == stable_symbol_hash("tokio::runtime::Runtime") - || type_hash == stable_symbol_hash("tokio::runtime::Handle") - ) -} - -fn rust_last_visible_local_assignment_rhs( - il: &nose_il::Il, - interner: &Interner, - receiver: NodeId, - local_name: &str, -) -> Option { - let occurrence_span = il.node(receiver).span; - let mut last_assignment = None; - for (idx, node) in il.nodes.iter().enumerate() { - if node.kind != NodeKind::Assign - || node.span.file != occurrence_span.file - || occurrence_span.start_byte < node.span.end_byte - { - continue; - } - let node_id = NodeId(idx as u32); - let Some((lhs, rhs)) = il.assignment_parts(node_id) else { - continue; - }; - if il.kind(lhs) != NodeKind::Var - || !node_defines_name(il, interner, lhs, local_name) - || !rust_local_assignment_visible_at(il, node_id, receiver) - { - continue; - } - if last_assignment - .map(|(start, _)| start <= node.span.start_byte) - .unwrap_or(true) - { - last_assignment = Some((node.span.start_byte, rhs)); - } - } - last_assignment.map(|(_, rhs)| rhs) -} - -fn rust_local_assignment_visible_at( - il: &nose_il::Il, - assignment: NodeId, - occurrence: NodeId, -) -> bool { - let Some(block) = rust_nearest_block_containing_node(il, assignment) else { - return false; - }; - let block_span = il.node(block).span; - let occurrence_span = il.node(occurrence).span; - block_span.file == occurrence_span.file - && block_span.start_byte <= occurrence_span.start_byte - && occurrence_span.end_byte <= block_span.end_byte -} - -fn rust_nearest_block_containing_node(il: &nose_il::Il, target: NodeId) -> Option { - let target_span = il.node(target).span; - il.nodes - .iter() - .enumerate() - .filter(|(_, node)| { - node.kind == NodeKind::Block - && node.span.file == target_span.file - && node.span.start_byte <= target_span.start_byte - && target_span.end_byte <= node.span.end_byte - }) - .min_by_key(|(_, node)| node.span.end_byte.saturating_sub(node.span.start_byte)) - .map(|(idx, _)| NodeId(idx as u32)) -} - -fn rust_tokio_runtime_unwrap_method(il: &nose_il::Il, interner: &Interner, callee: NodeId) -> bool { - matches!( - callee_field_method(il, interner, callee), - Some("unwrap" | "expect") - ) -} - -fn rust_tokio_runtime_result_adapter_method( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, -) -> bool { - matches!(callee_field_method(il, interner, callee), Some("map_err")) -} - -fn rust_tokio_runtime_builder_chain_method( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, -) -> bool { - matches!( - callee_field_method(il, interner, callee), - Some( - "disable_lifo_slot" - | "enable_all" - | "enable_io" - | "enable_time" - | "event_interval" - | "global_queue_interval" - | "worker_threads" - | "max_blocking_threads" - | "start_paused" - | "thread_keep_alive" - | "thread_name" - | "thread_stack_size" - | "unhandled_panic" - ) - ) -} - -fn rust_tokio_runtime_driver_path( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, - path: &str, - context: &AdmissionContext, -) -> bool { - match path { - "tokio::runtime::Handle::current" => true, - "Handle::current" => { - rust_imported_runtime_type_visible(il, interner, callee, "Handle", context) - } - _ => false, - } -} - -fn rust_tokio_runtime_result_path( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, - path: &str, - context: &AdmissionContext, -) -> bool { - match path { - "tokio::runtime::Runtime::new" | "tokio::runtime::Handle::try_current" => true, - "Runtime::new" => { - rust_imported_runtime_type_visible(il, interner, callee, "Runtime", context) - } - "Handle::try_current" => { - rust_imported_runtime_type_visible(il, interner, callee, "Handle", context) - } - _ => false, - } -} - -fn rust_tokio_runtime_builder_path( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, - path: &str, - context: &AdmissionContext, -) -> bool { - match path { - "tokio::runtime::Builder::new_current_thread" - | "tokio::runtime::Builder::new_multi_thread" => true, - "Builder::new_current_thread" | "Builder::new_multi_thread" => { - rust_imported_runtime_type_visible(il, interner, callee, "Builder", context) - } - _ => false, - } -} - -fn rust_imported_runtime_type_visible( - il: &nose_il::Il, - interner: &Interner, - occurrence: NodeId, - exported: &str, - context: &AdmissionContext, -) -> bool { - let module = "tokio::runtime"; - if context.rust_runtime_root_is_local_for_file(module_root(module), &il.meta.path) { - return false; - } - rust_imports::rust_imported_binding_evidence_only_symbol_for_local( - il, exported, occurrence, module, exported, - ) && !rust_imported_local_shadowed(il, interner, occurrence, exported, module, exported) -} - -fn rust_imported_async_spawn_member( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, - context: &AdmissionContext, -) -> bool { - rust_imported_runtime_member(il, interner, callee, "tokio", "spawn", context) - || rust_imported_runtime_member(il, interner, callee, "tokio::task", "spawn", context) - || rust_imported_runtime_member( - il, - interner, - callee, - "tokio::task", - "spawn_blocking", - context, - ) - || rust_imported_runtime_member(il, interner, callee, "async_std::task", "spawn", context) - || rust_imported_runtime_member( - il, - interner, - callee, - "async_std::task", - "spawn_blocking", - context, - ) -} - -fn rust_imported_async_join_macro_member( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, - context: &AdmissionContext, -) -> bool { - rust_imported_runtime_member(il, interner, callee, "tokio", "join", context) - || rust_imported_runtime_member(il, interner, callee, "tokio", "try_join", context) - || rust_imported_runtime_member(il, interner, callee, "futures", "join", context) - || rust_imported_runtime_member(il, interner, callee, "futures", "try_join", context) - || rust_imported_runtime_member(il, interner, callee, "futures_util", "join", context) - || rust_imported_runtime_member(il, interner, callee, "futures_util", "try_join", context) -} - -fn rust_imported_async_select_macro_member( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, - context: &AdmissionContext, -) -> bool { - rust_imported_runtime_member(il, interner, callee, "tokio", "select", context) - || rust_imported_runtime_member(il, interner, callee, "futures", "select", context) - || rust_imported_runtime_member(il, interner, callee, "futures_util", "select", context) -} - -fn rust_imported_runtime_member( +fn macro_operation( il: &nose_il::Il, interner: &Interner, callee: NodeId, - module: &str, - exported: &str, + callee_path: Option<&str>, context: &AdmissionContext, -) -> bool { - if context.rust_runtime_root_is_local_for_file(module_root(module), &il.meta.path) { - return false; +) -> Option { + if callee_path.is_some_and(is_join_macro) + || imported_async_join_macro_member(il, interner, callee, context) + { + return Some(RustAsyncOperation::JoinAll); } - (nose_semantics::imported_member_symbol(il, interner, callee, module, exported) - || rust_imports::rust_imported_binding_evidence_only_symbol( - il, interner, callee, module, exported, - )) - && !rust_imported_member_shadowed(il, interner, callee, module, exported) + (callee_path.is_some_and(is_select_macro) + || imported_async_select_macro_member(il, interner, callee, context)) + .then_some(RustAsyncOperation::SelectFirst) } -fn rust_imported_member_shadowed( - il: &nose_il::Il, - interner: &Interner, - callee: NodeId, - module: &str, - exported: &str, -) -> bool { - let Some(local_name) = super::super::super::node_exact_name(il, interner, callee) else { - return false; - }; - rust_imported_local_shadowed(il, interner, callee, local_name, module, exported) +enum RustAsyncOperation { + Spawn, + FutureDrive, + JoinAll, + SelectFirst, } -fn rust_imported_local_shadowed( - il: &nose_il::Il, - interner: &Interner, - occurrence: NodeId, - local_name: &str, - module: &str, - exported: &str, -) -> bool { - let occurrence_span = il.node(occurrence).span; - for unit in &il.units { - if il.node(unit.root).span.file == occurrence_span.file - && unit - .name - .is_some_and(|symbol| interner.resolve(symbol) == local_name) - { - return true; - } - } - for (idx, node) in il.nodes.iter().enumerate() { - if node.span.file != occurrence_span.file { - continue; - } - let node_id = NodeId(idx as u32); - match node.kind { - NodeKind::Assign => { - let Some(lhs) = il.children(node_id).first().copied() else { - continue; - }; - if !node_defines_name(il, interner, lhs, local_name) { - continue; - } - if !rust_imported_binding_at_span(il, node.span, local_name, module, exported) - && super::definition_shadows_occurrence(il, node_id, occurrence) - { - return true; - } - } - NodeKind::Block | NodeKind::Module | NodeKind::Param - if node_defines_name(il, interner, node_id, local_name) - && super::definition_shadows_occurrence(il, node_id, occurrence) => - { - return true; +impl RustAsyncOperation { + fn push_missing_evidence(self, labels: &mut Vec<&'static str>) { + match self { + Self::Spawn => super::push_task_spawn_missing_evidence(labels), + Self::FutureDrive => { + super::super::push_unique(labels, "future-drive-scheduling-contract"); + super::super::push_unique(labels, "future-settled-value-channel-contract"); } - _ => {} + Self::JoinAll => super::push_async_aggregate_all_missing_evidence(labels), + Self::SelectFirst => super::push_async_aggregate_first_missing_evidence(labels), } } - false -} - -fn rust_imported_binding_at_span( - il: &nose_il::Il, - span: nose_il::Span, - local: &str, - module: &str, - exported: &str, -) -> bool { - let local_hash = stable_symbol_hash(local); - let module_hash = stable_symbol_hash(module); - let exported_hash = stable_symbol_hash(exported); - il.evidence_anchored_at(span).any(|record| { - record.anchor == EvidenceAnchor::binding(span, local_hash) - && record.kind - == EvidenceKind::Symbol(SymbolEvidenceKind::ImportedBinding { - module_hash, - exported_hash, - }) - && record.provenance.emitter == EvidenceEmitter::Builtin - && record.status == EvidenceStatus::Asserted - && il.evidence_dependencies_asserted(record) - }) -} - -fn push_future_drive_missing_evidence(labels: &mut Vec<&'static str>) { - super::super::push_unique(labels, "future-drive-scheduling-contract"); - super::super::push_unique(labels, "future-settled-value-channel-contract"); } diff --git a/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/api_paths.rs b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/api_paths.rs new file mode 100644 index 000000000..66fae5c5f --- /dev/null +++ b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/api_paths.rs @@ -0,0 +1,37 @@ +pub(super) fn runtime_root(callee_path: &str) -> Option<&str> { + callee_path.split("::").next() +} + +pub(super) fn module_root(module: &str) -> &str { + module.split("::").next().unwrap_or(module) +} + +pub(super) fn is_spawn(callee_path: &str) -> bool { + matches!( + callee_path, + "tokio::spawn" + | "tokio::task::spawn" + | "tokio::task::spawn_blocking" + | "async_std::task::spawn" + | "async_std::task::spawn_blocking" + ) +} + +pub(super) fn is_join_macro(callee_path: &str) -> bool { + matches!( + callee_path, + "tokio::join" + | "tokio::try_join" + | "futures::join" + | "futures::try_join" + | "futures_util::join" + | "futures_util::try_join" + ) +} + +pub(super) fn is_select_macro(callee_path: &str) -> bool { + matches!( + callee_path, + "tokio::select" | "futures::select" | "futures_util::select" + ) +} diff --git a/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/import_identity.rs b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/import_identity.rs new file mode 100644 index 000000000..3a896c5ff --- /dev/null +++ b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/import_identity.rs @@ -0,0 +1,178 @@ +use super::super::{node_defines_name, rust_imports}; +use super::api_paths::module_root; +use crate::verify_admission::AdmissionContext; +use nose_il::{ + stable_symbol_hash, EvidenceAnchor, EvidenceEmitter, EvidenceKind, EvidenceStatus, Interner, + NodeId, NodeKind, SymbolEvidenceKind, +}; + +pub(super) fn imported_runtime_type_visible( + il: &nose_il::Il, + interner: &Interner, + occurrence: NodeId, + exported: &str, + context: &AdmissionContext, +) -> bool { + let module = "tokio::runtime"; + if context.rust_runtime_root_is_local_for_file(module_root(module), &il.meta.path) { + return false; + } + rust_imports::rust_imported_binding_evidence_only_symbol_for_local( + il, exported, occurrence, module, exported, + ) && !imported_local_shadowed(il, interner, occurrence, exported, module, exported) +} + +pub(super) fn imported_async_spawn_member( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + context: &AdmissionContext, +) -> bool { + imported_runtime_member(il, interner, callee, "tokio", "spawn", context) + || imported_runtime_member(il, interner, callee, "tokio::task", "spawn", context) + || imported_runtime_member( + il, + interner, + callee, + "tokio::task", + "spawn_blocking", + context, + ) + || imported_runtime_member(il, interner, callee, "async_std::task", "spawn", context) + || imported_runtime_member( + il, + interner, + callee, + "async_std::task", + "spawn_blocking", + context, + ) +} + +pub(super) fn imported_async_join_macro_member( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + context: &AdmissionContext, +) -> bool { + imported_runtime_member(il, interner, callee, "tokio", "join", context) + || imported_runtime_member(il, interner, callee, "tokio", "try_join", context) + || imported_runtime_member(il, interner, callee, "futures", "join", context) + || imported_runtime_member(il, interner, callee, "futures", "try_join", context) + || imported_runtime_member(il, interner, callee, "futures_util", "join", context) + || imported_runtime_member(il, interner, callee, "futures_util", "try_join", context) +} + +pub(super) fn imported_async_select_macro_member( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + context: &AdmissionContext, +) -> bool { + imported_runtime_member(il, interner, callee, "tokio", "select", context) + || imported_runtime_member(il, interner, callee, "futures", "select", context) + || imported_runtime_member(il, interner, callee, "futures_util", "select", context) +} + +pub(super) fn imported_runtime_member( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + module: &str, + exported: &str, + context: &AdmissionContext, +) -> bool { + if context.rust_runtime_root_is_local_for_file(module_root(module), &il.meta.path) { + return false; + } + (nose_semantics::imported_member_symbol(il, interner, callee, module, exported) + || rust_imports::rust_imported_binding_evidence_only_symbol( + il, interner, callee, module, exported, + )) + && !imported_member_shadowed(il, interner, callee, module, exported) +} + +fn imported_member_shadowed( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + module: &str, + exported: &str, +) -> bool { + let Some(local_name) = super::super::super::super::node_exact_name(il, interner, callee) else { + return false; + }; + imported_local_shadowed(il, interner, callee, local_name, module, exported) +} + +fn imported_local_shadowed( + il: &nose_il::Il, + interner: &Interner, + occurrence: NodeId, + local_name: &str, + module: &str, + exported: &str, +) -> bool { + let occurrence_span = il.node(occurrence).span; + for unit in &il.units { + if il.node(unit.root).span.file == occurrence_span.file + && unit + .name + .is_some_and(|symbol| interner.resolve(symbol) == local_name) + { + return true; + } + } + for (idx, node) in il.nodes.iter().enumerate() { + if node.span.file != occurrence_span.file { + continue; + } + let node_id = NodeId(idx as u32); + match node.kind { + NodeKind::Assign => { + let Some(lhs) = il.children(node_id).first().copied() else { + continue; + }; + if !node_defines_name(il, interner, lhs, local_name) { + continue; + } + if !imported_binding_at_span(il, node.span, local_name, module, exported) + && super::super::definition_shadows_occurrence(il, node_id, occurrence) + { + return true; + } + } + NodeKind::Block | NodeKind::Module | NodeKind::Param + if node_defines_name(il, interner, node_id, local_name) + && super::super::definition_shadows_occurrence(il, node_id, occurrence) => + { + return true; + } + _ => {} + } + } + false +} + +fn imported_binding_at_span( + il: &nose_il::Il, + span: nose_il::Span, + local: &str, + module: &str, + exported: &str, +) -> bool { + let local_hash = stable_symbol_hash(local); + let module_hash = stable_symbol_hash(module); + let exported_hash = stable_symbol_hash(exported); + il.evidence_anchored_at(span).any(|record| { + record.anchor == EvidenceAnchor::binding(span, local_hash) + && record.kind + == EvidenceKind::Symbol(SymbolEvidenceKind::ImportedBinding { + module_hash, + exported_hash, + }) + && record.provenance.emitter == EvidenceEmitter::Builtin + && record.status == EvidenceStatus::Asserted + && il.evidence_dependencies_asserted(record) + }) +} diff --git a/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/runtime_receiver.rs b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/runtime_receiver.rs new file mode 100644 index 000000000..477cb56e1 --- /dev/null +++ b/crates/nose-cli/src/verify_admission/runtime_boundary/async_runtime/rust/runtime_receiver.rs @@ -0,0 +1,301 @@ +use super::super::{callee_field_method, callee_path, method_receiver, node_defines_name}; +use super::import_identity::{imported_runtime_member, imported_runtime_type_visible}; +use crate::verify_admission::AdmissionContext; +use nose_il::{stable_symbol_hash, DomainEvidence, Interner, NodeId, NodeKind}; + +pub(super) fn is_future_drive_call( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + callee_path: Option<&str>, + context: &AdmissionContext, +) -> bool { + callee_path == Some("tokio_test::block_on") + || imported_runtime_member(il, interner, callee, "tokio_test", "block_on", context) + || (callee_field_method(il, interner, callee) == Some("block_on") + && method_receiver(il, callee) + .is_some_and(|receiver| is_tokio_runtime_receiver(il, interner, receiver, context))) +} + +fn is_tokio_runtime_receiver( + il: &nose_il::Il, + interner: &Interner, + receiver: NodeId, + context: &AdmissionContext, +) -> bool { + if context.rust_runtime_root_is_local_for_file("tokio", &il.meta.path) { + return false; + } + is_runtime_driver_expr(il, interner, receiver, context) + || is_local_runtime_binding(il, interner, receiver, context) + || has_runtime_parameter_domain(il, interner, receiver) + || has_runtime_field_domain(il, receiver) +} + +fn is_runtime_driver_expr( + il: &nose_il::Il, + interner: &Interner, + receiver: NodeId, + context: &AdmissionContext, +) -> bool { + if let Some(inner) = try_propagation_operand(il, receiver) { + return is_runtime_result_expr(il, interner, inner, context); + } + if il.kind(receiver) != NodeKind::Call { + return false; + } + let Some(callee) = il.children(receiver).first().copied() else { + return false; + }; + if callee_path(il, interner, callee) + .as_deref() + .is_some_and(|path| is_runtime_driver_path(il, interner, callee, path, context)) + { + return true; + } + if !is_runtime_unwrap_method(il, interner, callee) { + return false; + } + method_receiver(il, callee) + .is_some_and(|inner| is_runtime_result_expr(il, interner, inner, context)) +} + +fn try_propagation_operand(il: &nose_il::Il, node: NodeId) -> Option { + (nose_semantics::source_protocol_at_node(il, node) + == Some(nose_il::SourceProtocolKind::TryPropagation)) + .then(|| il.children(node).first().copied()) + .flatten() +} + +fn is_runtime_result_expr( + il: &nose_il::Il, + interner: &Interner, + receiver: NodeId, + context: &AdmissionContext, +) -> bool { + if il.kind(receiver) != NodeKind::Call { + return false; + } + let Some(callee) = il.children(receiver).first().copied() else { + return false; + }; + if callee_path(il, interner, callee) + .as_deref() + .is_some_and(|path| is_runtime_result_path(il, interner, callee, path, context)) + { + return true; + } + if is_runtime_result_adapter(il, interner, callee) { + return method_receiver(il, callee) + .is_some_and(|inner| is_runtime_result_expr(il, interner, inner, context)); + } + if callee_field_method(il, interner, callee) != Some("build") { + return false; + } + method_receiver(il, callee) + .is_some_and(|inner| is_runtime_builder_expr(il, interner, inner, context)) +} + +fn is_runtime_builder_expr( + il: &nose_il::Il, + interner: &Interner, + receiver: NodeId, + context: &AdmissionContext, +) -> bool { + if il.kind(receiver) != NodeKind::Call { + return false; + } + let Some(callee) = il.children(receiver).first().copied() else { + return false; + }; + if callee_path(il, interner, callee) + .as_deref() + .is_some_and(|path| is_runtime_builder_path(il, interner, callee, path, context)) + { + return true; + } + if !is_runtime_builder_chain_method(il, interner, callee) { + return false; + } + method_receiver(il, callee) + .is_some_and(|inner| is_runtime_builder_expr(il, interner, inner, context)) +} + +fn is_local_runtime_binding( + il: &nose_il::Il, + interner: &Interner, + receiver: NodeId, + context: &AdmissionContext, +) -> bool { + if il.kind(receiver) != NodeKind::Var { + return false; + } + let Some(local_name) = super::super::super::super::node_exact_name(il, interner, receiver) + else { + return false; + }; + last_visible_local_assignment_rhs(il, interner, receiver, local_name) + .is_some_and(|rhs| is_runtime_driver_expr(il, interner, rhs, context)) +} + +fn has_runtime_parameter_domain(il: &nose_il::Il, interner: &Interner, receiver: NodeId) -> bool { + il.kind(receiver) == NodeKind::Var + && is_runtime_domain(nose_semantics::domain_evidence_for_receiver( + il, interner, receiver, + )) +} + +fn has_runtime_field_domain(il: &nose_il::Il, receiver: NodeId) -> bool { + il.kind(receiver) == NodeKind::Field + && is_runtime_domain(nose_semantics::domain_evidence_for_node(il, receiver)) +} + +fn is_runtime_domain(domain: Option) -> bool { + matches!( + domain, + Some(DomainEvidence::Nominal { type_hash }) + if type_hash == stable_symbol_hash("tokio::runtime::Runtime") + || type_hash == stable_symbol_hash("tokio::runtime::Handle") + ) +} + +fn last_visible_local_assignment_rhs( + il: &nose_il::Il, + interner: &Interner, + receiver: NodeId, + local_name: &str, +) -> Option { + let occurrence_span = il.node(receiver).span; + let mut last_assignment = None; + for (idx, node) in il.nodes.iter().enumerate() { + if node.kind != NodeKind::Assign + || node.span.file != occurrence_span.file + || occurrence_span.start_byte < node.span.end_byte + { + continue; + } + let node_id = NodeId(idx as u32); + let Some((lhs, rhs)) = il.assignment_parts(node_id) else { + continue; + }; + if il.kind(lhs) != NodeKind::Var + || !node_defines_name(il, interner, lhs, local_name) + || !local_assignment_visible_at(il, node_id, receiver) + { + continue; + } + if last_assignment + .map(|(start, _)| start <= node.span.start_byte) + .unwrap_or(true) + { + last_assignment = Some((node.span.start_byte, rhs)); + } + } + last_assignment.map(|(_, rhs)| rhs) +} + +fn local_assignment_visible_at(il: &nose_il::Il, assignment: NodeId, occurrence: NodeId) -> bool { + let Some(block) = nearest_block_containing_node(il, assignment) else { + return false; + }; + let block_span = il.node(block).span; + let occurrence_span = il.node(occurrence).span; + block_span.file == occurrence_span.file + && block_span.start_byte <= occurrence_span.start_byte + && occurrence_span.end_byte <= block_span.end_byte +} + +fn nearest_block_containing_node(il: &nose_il::Il, target: NodeId) -> Option { + let target_span = il.node(target).span; + il.nodes + .iter() + .enumerate() + .filter(|(_, node)| { + node.kind == NodeKind::Block + && node.span.file == target_span.file + && node.span.start_byte <= target_span.start_byte + && target_span.end_byte <= node.span.end_byte + }) + .min_by_key(|(_, node)| node.span.end_byte.saturating_sub(node.span.start_byte)) + .map(|(idx, _)| NodeId(idx as u32)) +} + +fn is_runtime_unwrap_method(il: &nose_il::Il, interner: &Interner, callee: NodeId) -> bool { + matches!( + callee_field_method(il, interner, callee), + Some("unwrap" | "expect") + ) +} + +fn is_runtime_result_adapter(il: &nose_il::Il, interner: &Interner, callee: NodeId) -> bool { + callee_field_method(il, interner, callee) == Some("map_err") +} + +fn is_runtime_builder_chain_method(il: &nose_il::Il, interner: &Interner, callee: NodeId) -> bool { + matches!( + callee_field_method(il, interner, callee), + Some( + "disable_lifo_slot" + | "enable_all" + | "enable_io" + | "enable_time" + | "event_interval" + | "global_queue_interval" + | "worker_threads" + | "max_blocking_threads" + | "start_paused" + | "thread_keep_alive" + | "thread_name" + | "thread_stack_size" + | "unhandled_panic" + ) + ) +} + +fn is_runtime_driver_path( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + path: &str, + context: &AdmissionContext, +) -> bool { + match path { + "tokio::runtime::Handle::current" => true, + "Handle::current" => imported_runtime_type_visible(il, interner, callee, "Handle", context), + _ => false, + } +} + +fn is_runtime_result_path( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + path: &str, + context: &AdmissionContext, +) -> bool { + match path { + "tokio::runtime::Runtime::new" | "tokio::runtime::Handle::try_current" => true, + "Runtime::new" => imported_runtime_type_visible(il, interner, callee, "Runtime", context), + "Handle::try_current" => { + imported_runtime_type_visible(il, interner, callee, "Handle", context) + } + _ => false, + } +} + +fn is_runtime_builder_path( + il: &nose_il::Il, + interner: &Interner, + callee: NodeId, + path: &str, + context: &AdmissionContext, +) -> bool { + match path { + "tokio::runtime::Builder::new_current_thread" + | "tokio::runtime::Builder::new_multi_thread" => true, + "Builder::new_current_thread" | "Builder::new_multi_thread" => { + imported_runtime_type_visible(il, interner, callee, "Builder", context) + } + _ => false, + } +} diff --git a/crates/nose-cli/tests/cli/semantic_idioms/guards.rs b/crates/nose-cli/tests/cli/semantic_idioms/guards.rs index 79dbc93bb..4a5cb176a 100644 --- a/crates/nose-cli/tests/cli/semantic_idioms/guards.rs +++ b/crates/nose-cli/tests/cli/semantic_idioms/guards.rs @@ -122,54 +122,33 @@ fn query_mode_semantic_reports_flattened_guard_span_only() { #[test] fn query_mode_semantic_preserves_js_typeof_operator() { - let dir = std::env::temp_dir().join(format!("nose_typeof_semantic_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("typeof_a.ts"), - "export function isString(value: unknown) {\n return typeof value === \"string\";\n}\n", - ) - .unwrap(); - fs::write( - dir.join("typeof_b.ts"), - "export function acceptsString(input: unknown) {\n return typeof input === \"string\";\n}\n", - ) - .unwrap(); - fs::write( - dir.join("plain_equality_negative.ts"), - "export function equalsString(value: unknown) {\n return value === \"string\";\n}\n", - ) - .unwrap(); - fs::write( - dir.join("void_a.ts"), - "export function eraseValue(value: unknown) {\n return void value;\n}\n", - ) - .unwrap(); - fs::write( - dir.join("void_b.ts"), - "export function eraseInput(input: unknown) {\n return void input;\n}\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only the identical typeof guard: {semantic}" - ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("typeof_a.ts") - && semantic_text.contains("typeof_b.ts") - && !semantic_text.contains("plain_equality_negative.ts") - && !semantic_text.contains("void_a.ts") - && !semantic_text.contains("void_b.ts"), - "semantic mode must preserve typeof and reject unproved unary JS operators: {semantic}" + assert_single_semantic_family( + "typeof_semantic", + &[ + ( + "typeof_a.ts", + "export function isString(value: unknown) {\n return typeof value === \"string\";\n}\n", + ), + ( + "typeof_b.ts", + "export function acceptsString(input: unknown) {\n return typeof input === \"string\";\n}\n", + ), + ( + "plain_equality_negative.ts", + "export function equalsString(value: unknown) {\n return value === \"string\";\n}\n", + ), + ( + "void_a.ts", + "export function eraseValue(value: unknown) {\n return void value;\n}\n", + ), + ( + "void_b.ts", + "export function eraseInput(input: unknown) {\n return void input;\n}\n", + ), + ], + &["typeof_a.ts", "typeof_b.ts"], + &["plain_equality_negative.ts", "void_a.ts", "void_b.ts"], ); - - let _ = fs::remove_dir_all(&dir); } #[test] @@ -279,82 +258,39 @@ fn query_mode_semantic_allows_safe_uninterpreted_method_calls() { #[test] fn query_mode_semantic_distinguishes_sequence_kinds() { - let dir = std::env::temp_dir().join(format!("nose_seq_semantic_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("list_a.py"), - "def pair(a, b):\n return [a, b]\n", - ) - .unwrap(); - fs::write( - dir.join("list_b.py"), - "def make_pair(x, y):\n return [x, y]\n", - ) - .unwrap(); - fs::write( - dir.join("tuple.py"), - "def tuple_pair(a, b):\n return (a, b)\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only the same list-construction family: {semantic}" - ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("list_a.py") - && semantic_text.contains("list_b.py") - && !semantic_text.contains("tuple.py"), - "semantic mode must preserve list-vs-tuple sequence kind: {semantic}" + assert_single_semantic_family( + "seq_semantic", + &[ + ("list_a.py", "def pair(a, b):\n return [a, b]\n"), + ("list_b.py", "def make_pair(x, y):\n return [x, y]\n"), + ("tuple.py", "def tuple_pair(a, b):\n return (a, b)\n"), + ], + &["list_a.py", "list_b.py"], + &["tuple.py"], ); - - let _ = fs::remove_dir_all(&dir); } #[test] fn query_mode_semantic_allows_static_import_identity() { - let dir = std::env::temp_dir().join(format!("nose_import_identity_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("import_a.py"), - "from shared_math import unused_helper, helper\n\ndef report(value):\n shifted = value + 1\n return helper(shifted)\n", - ) - .unwrap(); - fs::write( - dir.join("import_b.py"), - "from shared_math import unused_helper, helper as calc\n\ndef build(input):\n shifted = input + 1\n return calc(shifted)\n", - ) - .unwrap(); - fs::write( - dir.join("import_negative.py"), - "from shared_math import unused_helper, other_helper as calc\n\ndef other(input):\n shifted = input + 1\n return calc(shifted)\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only same import coordinate: {semantic}" - ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("import_a.py") - && semantic_text.contains("import_b.py") - && !semantic_text.contains("import_negative.py"), - "semantic mode must preserve static import coordinates: {semantic}" + assert_single_semantic_family( + "import_identity", + &[ + ( + "import_a.py", + "from shared_math import unused_helper, helper\n\ndef report(value):\n shifted = value + 1\n return helper(shifted)\n", + ), + ( + "import_b.py", + "from shared_math import unused_helper, helper as calc\n\ndef build(input):\n shifted = input + 1\n return calc(shifted)\n", + ), + ( + "import_negative.py", + "from shared_math import unused_helper, other_helper as calc\n\ndef other(input):\n shifted = input + 1\n return calc(shifted)\n", + ), + ], + &["import_a.py", "import_b.py"], + &["import_negative.py"], ); - - let _ = fs::remove_dir_all(&dir); } #[test] diff --git a/crates/nose-cli/tests/cli/semantic_idioms/library_api/core_builtins.rs b/crates/nose-cli/tests/cli/semantic_idioms/library_api/core_builtins.rs index 7ea4a20c5..5de8d46a6 100644 --- a/crates/nose-cli/tests/cli/semantic_idioms/library_api/core_builtins.rs +++ b/crates/nose-cli/tests/cli/semantic_idioms/library_api/core_builtins.rs @@ -60,42 +60,25 @@ fn query_mode_semantic_proves_regex_literal_predicate_matches() { #[test] fn query_mode_semantic_allows_proved_js_static_builtins() { - let dir = std::env::temp_dir().join(format!("nose_static_builtin_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("array_a.ts"), - "export function isList(value: unknown) {\n return Array.isArray(value);\n}\n", - ) - .unwrap(); - fs::write( - dir.join("array_b.ts"), - "export function acceptsArray(input: unknown) {\n return Array.isArray(input);\n}\n", - ) - .unwrap(); - fs::write( - dir.join("typeof_negative.ts"), - "export function acceptsObject(value: unknown) {\n return typeof value === \"object\";\n}\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only the identical Array.isArray guard: {semantic}" + assert_single_semantic_family( + "static_builtin", + &[ + ( + "array_a.ts", + "export function isList(value: unknown) {\n return Array.isArray(value);\n}\n", + ), + ( + "array_b.ts", + "export function acceptsArray(input: unknown) {\n return Array.isArray(input);\n}\n", + ), + ( + "typeof_negative.ts", + "export function acceptsObject(value: unknown) {\n return typeof value === \"object\";\n}\n", + ), + ], + &["array_a.ts", "array_b.ts"], + &["typeof_negative.ts"], ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("array_a.ts") - && semantic_text.contains("array_b.ts") - && !semantic_text.contains("typeof_negative.ts"), - "semantic mode must keep static builtin calls exact: {semantic}" - ); - - let _ = fs::remove_dir_all(&dir); } #[test] diff --git a/crates/nose-cli/tests/cli/semantic_idioms/literals.rs b/crates/nose-cli/tests/cli/semantic_idioms/literals.rs index 52e3ea89c..d4491fa33 100644 --- a/crates/nose-cli/tests/cli/semantic_idioms/literals.rs +++ b/crates/nose-cli/tests/cli/semantic_idioms/literals.rs @@ -2,294 +2,183 @@ use super::*; #[test] fn query_mode_semantic_converges_cross_language_list_literals() { - let dir = std::env::temp_dir().join(format!("nose_list_cross_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("pair.js"), - "export function pair(a, b) {\n return [a, b];\n}\n", - ) - .unwrap(); - fs::write( - dir.join("pair.py"), - "def make_pair(x, y):\n return [x, y]\n", - ) - .unwrap(); - fs::write( - dir.join("pair.rb"), - "def build_pair(first, second)\n [first, second]\nend\n", - ) - .unwrap(); - fs::write( - dir.join("tuple_negative.py"), - "def tuple_pair(a, b):\n return (a, b)\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report one cross-language list literal family: {semantic}" - ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("pair.js") - && semantic_text.contains("pair.py") - && semantic_text.contains("pair.rb") - && !semantic_text.contains("tuple_negative.py"), - "semantic mode must converge list-like literals without merging tuples: {semantic}" + assert_single_semantic_family( + "list_cross", + &[ + ( + "pair.js", + "export function pair(a, b) {\n return [a, b];\n}\n", + ), + ("pair.py", "def make_pair(x, y):\n return [x, y]\n"), + ( + "pair.rb", + "def build_pair(first, second)\n [first, second]\nend\n", + ), + ( + "tuple_negative.py", + "def tuple_pair(a, b):\n return (a, b)\n", + ), + ], + &["pair.js", "pair.py", "pair.rb"], + &["tuple_negative.py"], ); - - let _ = fs::remove_dir_all(&dir); } #[test] fn query_mode_semantic_preserves_js_object_keys() { - let dir = std::env::temp_dir().join(format!("nose_object_semantic_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("object_a.ts"), - "export function example(command: string, description: string) {\n return { command, description };\n}\n", - ) - .unwrap(); - fs::write( - dir.join("object_b.ts"), - "export function makeExample(cmd: string, desc: string) {\n return { command: cmd, description: desc };\n}\n", - ) - .unwrap(); - fs::write( - dir.join("object_key_negative.ts"), - "export function makeParam(name: string, description: string) {\n return { name, description };\n}\n", - ) - .unwrap(); - fs::write( - dir.join("object_computed_a.ts"), - "const KEY = \"command\";\nexport function computed(command: string, description: string) {\n return { [KEY]: command, description };\n}\n", - ) - .unwrap(); - fs::write( - dir.join("object_computed_b.ts"), - "const FIELD = \"command\";\nexport function computedOther(cmd: string, desc: string) {\n return { [FIELD]: cmd, description: desc };\n}\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only same-key object construction: {semantic}" - ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("object_a.ts") - && semantic_text.contains("object_b.ts") - && !semantic_text.contains("object_key_negative.ts") - && !semantic_text.contains("object_computed_a.ts") - && !semantic_text.contains("object_computed_b.ts"), - "semantic mode must preserve static object keys and reject computed-key object contracts: {semantic}" + assert_single_semantic_family( + "object_semantic", + &[ + ( + "object_a.ts", + "export function example(command: string, description: string) {\n return { command, description };\n}\n", + ), + ( + "object_b.ts", + "export function makeExample(cmd: string, desc: string) {\n return { command: cmd, description: desc };\n}\n", + ), + ( + "object_key_negative.ts", + "export function makeParam(name: string, description: string) {\n return { name, description };\n}\n", + ), + ( + "object_computed_a.ts", + "const KEY = \"command\";\nexport function computed(command: string, description: string) {\n return { [KEY]: command, description };\n}\n", + ), + ( + "object_computed_b.ts", + "const FIELD = \"command\";\nexport function computedOther(cmd: string, desc: string) {\n return { [FIELD]: cmd, description: desc };\n}\n", + ), + ], + &["object_a.ts", "object_b.ts"], + &[ + "object_key_negative.ts", + "object_computed_a.ts", + "object_computed_b.ts", + ], ); - - let _ = fs::remove_dir_all(&dir); } #[test] fn query_mode_semantic_converges_cross_language_map_literals() { - let dir = std::env::temp_dir().join(format!("nose_map_cross_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("map.ts"), - "export function example(command: string, description: string) {\n return { command, description };\n}\n", - ) - .unwrap(); - fs::write( - dir.join("map.py"), - "def make_example(cmd, desc):\n return {\"command\": cmd, \"description\": desc}\n", - ) - .unwrap(); - fs::write( - dir.join("map.rb"), - "def build_example(command, description)\n { command: command, description: description }\nend\n", - ) - .unwrap(); - fs::write( - dir.join("map_key_negative.ts"), - "export function makeParam(name: string, description: string) {\n return { name, description };\n}\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report one cross-language map literal family: {semantic}" + assert_single_semantic_family( + "map_cross", + &[ + ( + "map.ts", + "export function example(command: string, description: string) {\n return { command, description };\n}\n", + ), + ( + "map.py", + "def make_example(cmd, desc):\n return {\"command\": cmd, \"description\": desc}\n", + ), + ( + "map.rb", + "def build_example(command, description)\n { command: command, description: description }\nend\n", + ), + ( + "map_key_negative.ts", + "export function makeParam(name: string, description: string) {\n return { name, description };\n}\n", + ), + ], + &["map.ts", "map.py", "map.rb"], + &["map_key_negative.ts"], ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("map.ts") - && semantic_text.contains("map.py") - && semantic_text.contains("map.rb") - && !semantic_text.contains("map_key_negative.ts"), - "semantic mode must converge map-like literals without dropping key identity: {semantic}" - ); - - let _ = fs::remove_dir_all(&dir); } #[test] fn query_mode_semantic_captures_module_literal_bindings() { - let dir = std::env::temp_dir().join(format!("nose_module_const_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("locale_a.ts"), - "const labels = { today: \"today\", tomorrow: \"tomorrow\" };\nexport function label(token: string) {\n return labels[token];\n}\n", - ) - .unwrap(); - fs::write( - dir.join("locale_b.ts"), - "const labels = { today: \"heute\", tomorrow: \"morgen\" };\nexport function label(token: string) {\n return labels[token];\n}\n", - ) - .unwrap(); - fs::write( - dir.join("locale_a_copy.ts"), - "const labels = { today: \"today\", tomorrow: \"tomorrow\" };\nexport function relativeLabel(key: string) {\n return labels[key];\n}\n", - ) - .unwrap(); - fs::write( - dir.join("locale_mutated.ts"), - "let labels = { today: \"today\", tomorrow: \"tomorrow\" };\nlabels = { today: \"heute\", tomorrow: \"morgen\" };\nexport function mutatedLabel(key: string) {\n return labels[key];\n}\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only same module-literal binding behavior: {semantic}" - ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("locale_a.ts") - && semantic_text.contains("locale_a_copy.ts") - && !semantic_text.contains("locale_b.ts") - && !semantic_text.contains("locale_mutated.ts"), - "semantic mode must include captured module literal values: {semantic}" + assert_single_semantic_family( + "module_const", + &[ + ( + "locale_a.ts", + "const labels = { today: \"today\", tomorrow: \"tomorrow\" };\nexport function label(token: string) {\n return labels[token];\n}\n", + ), + ( + "locale_b.ts", + "const labels = { today: \"heute\", tomorrow: \"morgen\" };\nexport function label(token: string) {\n return labels[token];\n}\n", + ), + ( + "locale_a_copy.ts", + "const labels = { today: \"today\", tomorrow: \"tomorrow\" };\nexport function relativeLabel(key: string) {\n return labels[key];\n}\n", + ), + ( + "locale_mutated.ts", + "let labels = { today: \"today\", tomorrow: \"tomorrow\" };\nlabels = { today: \"heute\", tomorrow: \"morgen\" };\nexport function mutatedLabel(key: string) {\n return labels[key];\n}\n", + ), + ], + &["locale_a.ts", "locale_a_copy.ts"], + &["locale_b.ts", "locale_mutated.ts"], ); - - let _ = fs::remove_dir_all(&dir); } #[test] fn query_mode_semantic_preserves_python_dict_keys() { - let dir = std::env::temp_dir().join(format!("nose_dict_semantic_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("dict_a.py"), - "def example(command, description):\n return {\"command\": command, \"description\": description}\n", - ) - .unwrap(); - fs::write( - dir.join("dict_b.py"), - "def make_example(cmd, desc):\n return {\"command\": cmd, \"description\": desc}\n", - ) - .unwrap(); - fs::write( - dir.join("dict_key_negative.py"), - "def make_param(name, description):\n return {\"name\": name, \"description\": description}\n", - ) - .unwrap(); - fs::write( - dir.join("dict_spread_a.py"), - "def with_spread(base, command):\n return {**base, \"command\": command}\n", - ) - .unwrap(); - fs::write( - dir.join("dict_spread_b.py"), - "def copy_spread(other, cmd):\n return {**other, \"command\": cmd}\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only same-key dict construction: {semantic}" - ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("dict_a.py") - && semantic_text.contains("dict_b.py") - && !semantic_text.contains("dict_key_negative.py") - && !semantic_text.contains("dict_spread_a.py") - && !semantic_text.contains("dict_spread_b.py"), - "semantic mode must preserve dict keys and reject unproved unpacking: {semantic}" + assert_single_semantic_family( + "dict_semantic", + &[ + ( + "dict_a.py", + "def example(command, description):\n return {\"command\": command, \"description\": description}\n", + ), + ( + "dict_b.py", + "def make_example(cmd, desc):\n return {\"command\": cmd, \"description\": desc}\n", + ), + ( + "dict_key_negative.py", + "def make_param(name, description):\n return {\"name\": name, \"description\": description}\n", + ), + ( + "dict_spread_a.py", + "def with_spread(base, command):\n return {**base, \"command\": command}\n", + ), + ( + "dict_spread_b.py", + "def copy_spread(other, cmd):\n return {**other, \"command\": cmd}\n", + ), + ], + &["dict_a.py", "dict_b.py"], + &[ + "dict_key_negative.py", + "dict_spread_a.py", + "dict_spread_b.py", + ], ); - - let _ = fs::remove_dir_all(&dir); } #[test] fn query_mode_semantic_preserves_ruby_hash_keys() { - let dir = std::env::temp_dir().join(format!("nose_hash_semantic_{}", std::process::id())); - let _ = fs::remove_dir_all(&dir); - fs::create_dir_all(&dir).unwrap(); - fs::write( - dir.join("hash_a.rb"), - "def example(command, description)\n { command: command, description: description }\nend\n", - ) - .unwrap(); - fs::write( - dir.join("hash_b.rb"), - "def make_example(cmd, desc)\n { command: cmd, description: desc }\nend\n", - ) - .unwrap(); - fs::write( - dir.join("hash_key_negative.rb"), - "def make_param(name, description)\n { name: name, description: description }\nend\n", - ) - .unwrap(); - fs::write( - dir.join("hash_splat_a.rb"), - "def with_splat(base, command)\n { **base, command: command }\nend\n", - ) - .unwrap(); - fs::write( - dir.join("hash_splat_b.rb"), - "def copy_splat(other, cmd)\n { **other, command: cmd }\nend\n", - ) - .unwrap(); - - let semantic = query_min_json(&dir, "semantic"); - let semantic_json = query_json(&semantic); - let semantic_families = query_families(&semantic_json); - assert_eq!( - semantic_families.len(), - 1, - "semantic mode should report only same-key hash construction: {semantic}" + assert_single_semantic_family( + "hash_semantic", + &[ + ( + "hash_a.rb", + "def example(command, description)\n { command: command, description: description }\nend\n", + ), + ( + "hash_b.rb", + "def make_example(cmd, desc)\n { command: cmd, description: desc }\nend\n", + ), + ( + "hash_key_negative.rb", + "def make_param(name, description)\n { name: name, description: description }\nend\n", + ), + ( + "hash_splat_a.rb", + "def with_splat(base, command)\n { **base, command: command }\nend\n", + ), + ( + "hash_splat_b.rb", + "def copy_splat(other, cmd)\n { **other, command: cmd }\nend\n", + ), + ], + &["hash_a.rb", "hash_b.rb"], + &[ + "hash_key_negative.rb", + "hash_splat_a.rb", + "hash_splat_b.rb", + ], ); - let semantic_text = semantic_json.to_string(); - assert!( - semantic_text.contains("hash_a.rb") - && semantic_text.contains("hash_b.rb") - && !semantic_text.contains("hash_key_negative.rb") - && !semantic_text.contains("hash_splat_a.rb") - && !semantic_text.contains("hash_splat_b.rb"), - "semantic mode must preserve hash keys and reject unproved splats: {semantic}" - ); - - let _ = fs::remove_dir_all(&dir); } diff --git a/crates/nose-cli/tests/cli/support/fixtures.rs b/crates/nose-cli/tests/cli/support/fixtures.rs index da0af931e..ff49d45a7 100644 --- a/crates/nose-cli/tests/cli/support/fixtures.rs +++ b/crates/nose-cli/tests/cli/support/fixtures.rs @@ -29,6 +29,34 @@ pub(crate) fn write_files(dir: &Path, files: &[(&str, &str)]) { } } +pub(crate) fn assert_single_semantic_family( + tag: &str, + files: &[(&str, &str)], + included: &[&str], + excluded: &[&str], +) { + let project = TempProject::new(tag); + write_files(project.path(), files); + + let report = project.query_semantic_min_json(); + assert_eq!( + super::query::query_families(&report).len(), + 1, + "{tag}: expected exactly one semantic family: {report}" + ); + assert!( + super::query::family_contains_all(&report, included), + "{tag}: semantic family must contain {included:?}: {report}" + ); + let report_text = report.to_string(); + for file in excluded { + assert!( + !report_text.contains(file), + "{tag}: semantic family must exclude {file}: {report}" + ); + } +} + /// Write a small project (a 3-copy clone family + a decoy) into a unique temp dir. pub(crate) fn make_project(tag: &str) -> PathBuf { let dir = make_temp_dir(tag); diff --git a/crates/nose-cli/tests/equivalence.rs b/crates/nose-cli/tests/equivalence.rs index d662a46e6..2a29ededb 100644 --- a/crates/nose-cli/tests/equivalence.rs +++ b/crates/nose-cli/tests/equivalence.rs @@ -36,6 +36,18 @@ fn count_nodes(il: &nose_il::Il, root: NodeId, kind: Option) .sum::() } +macro_rules! fp_case { + ($source:ident, $lang:ident) => { + fingerprint_case(stringify!($source), $source, Lang::$lang) + }; +} + +macro_rules! named_fp_case { + ($source:ident, $lang:ident, $function:literal) => { + named_fingerprint_case(stringify!($source), $source, Lang::$lang, $function) + }; +} + #[path = "equivalence/algebra_laws.rs"] mod algebra_laws; #[path = "equivalence/async_scheduling_hard_negatives.rs"] @@ -251,6 +263,73 @@ fn value_fp_named(interner: &Interner, src: &str, lang: Lang, name: &str) -> Vec nose_normalize::value_fingerprint(&n, root, interner) } +#[derive(Clone, Copy)] +struct FingerprintCase<'a> { + label: &'a str, + source: &'a str, + lang: Lang, + function: Option<&'a str>, +} + +fn fingerprint_case<'a>(label: &'a str, source: &'a str, lang: Lang) -> FingerprintCase<'a> { + FingerprintCase { + label, + source, + lang, + function: None, + } +} + +fn named_fingerprint_case<'a>( + label: &'a str, + source: &'a str, + lang: Lang, + function: &'a str, +) -> FingerprintCase<'a> { + FingerprintCase { + label, + source, + lang, + function: Some(function), + } +} + +fn assert_fingerprint_cases_converge<'a>( + interner: &Interner, + expected: &[u64], + cases: impl IntoIterator>, +) { + for case in cases { + let actual = match case.function { + Some(name) => value_fp_named(interner, case.source, case.lang, name), + None => value_fp(interner, case.source, case.lang), + }; + assert_eq!( + expected, actual, + "{} ({:?}, function {:?}) should converge with the reference", + case.label, case.lang, case.function + ); + } +} + +fn assert_fingerprint_cases_stay_split<'a>( + interner: &Interner, + reference: &[u64], + cases: impl IntoIterator>, +) { + for case in cases { + let actual = match case.function { + Some(name) => value_fp_named(interner, case.source, case.lang, name), + None => value_fp(interner, case.source, case.lang), + }; + assert_ne!( + reference, actual, + "{} ({:?}, function {:?}) must stay split from the reference", + case.label, case.lang, case.function + ); + } +} + fn class_value_fp(interner: &Interner, src: &str, lang: Lang, name: &str) -> Vec { let il = nose_frontend::lower_source(FileId(0), "t", src.as_bytes(), lang, interner).unwrap(); let n = normalize(&il, interner, &NormalizeOptions::default()); diff --git a/crates/nose-cli/tests/equivalence/collection_membership.rs b/crates/nose-cli/tests/equivalence/collection_membership.rs index b668d61b3..1ee17af1c 100644 --- a/crates/nose-cli/tests/equivalence/collection_membership.rs +++ b/crates/nose-cli/tests/equivalence/collection_membership.rs @@ -1,9 +1,9 @@ use super::*; -// A long, flat sequence of independent convergence assertions — its cognitive -// complexity (133) and line count are breadth, not deep branching, so it sits -// above the production-oriented gates. Splitting it would not aid readability. -#[allow(clippy::cognitive_complexity, clippy::too_many_lines)] +// This is one behavior matrix so every case uses the same interner and explicit +// reference fingerprints. The case DSL keeps the breadth visible and gives each +// failure a source identifier and language without hiding the source snippets. +#[allow(clippy::too_many_lines)] #[test] fn collection_membership_set_construction_converges_with_boundaries() { let i = Interner::new(); @@ -183,385 +183,258 @@ fn collection_membership_set_construction_converges_with_boundaries() { let ruby_set_mutated = "require \"set\"\n\ndef f(value, other)\n values = Set.new([\"red\", \"blue\"])\n values.add(\"green\")\n values.include?(value)\nend\n"; let literal_fp = value_fp(&i, py_literal, Lang::Python); - assert_eq!(literal_fp, value_fp(&i, py_set_factory, Lang::Python)); - assert_eq!(literal_fp, value_fp(&i, py_tuple_factory, Lang::Python)); - assert_eq!(literal_fp, value_fp(&i, py_frozenset_factory, Lang::Python)); - assert_eq!(literal_fp, value_fp(&i, py_deque_import, Lang::Python)); - assert_eq!(literal_fp, value_fp(&i, py_deque_alias, Lang::Python)); - assert_eq!(literal_fp, value_fp(&i, py_deque_namespace, Lang::Python)); - assert_ne!( - literal_fp, - value_fp(&i, py_module_tuple, Lang::Python), - "module-bound tuple literals no longer reopen as membership collections without surface/domain evidence" - ); - assert_eq!(literal_fp, value_fp(&i, py_module_set, Lang::Python)); - assert_eq!(literal_fp, value_fp(&i, js_set_inline, Lang::JavaScript)); - assert_eq!(literal_fp, value_fp(&i, js_set_local, Lang::JavaScript)); - assert_eq!(literal_fp, value_fp(&i, js_module_set, Lang::JavaScript)); - assert_eq!(literal_fp, value_fp(&i, ts_module_set, Lang::TypeScript)); - assert_ne!(literal_fp, value_fp(&i, js_set_call, Lang::JavaScript)); - assert_ne!( - literal_fp, - value_fp(&i, js_array_contains, Lang::JavaScript), - "JavaScript .contains is not a standard array membership contract" - ); - assert_eq!(literal_fp, value_fp(&i, js_array_some, Lang::JavaScript)); - assert_eq!(literal_fp, value_fp(&i, ts_array_some, Lang::TypeScript)); - assert_eq!( - literal_fp, - value_fp(&i, js_array_indexof_ne, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_sequence_indexof_ne, Lang::JavaScript), - "JS sequence expressions must not prove static array membership" - ); - assert_eq!( - literal_fp, - value_fp(&i, ts_array_indexof_ge, Lang::TypeScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_indexof_gt, Lang::JavaScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_indexof_reversed, Lang::JavaScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_findindex_ne, Lang::JavaScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, ts_array_findindex_ge, Lang::TypeScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_findindex_gt, Lang::JavaScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_findindex_reversed, Lang::JavaScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_filter_length_ne, Lang::JavaScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, ts_array_filter_length_ge, Lang::TypeScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_filter_length_gt, Lang::JavaScript) - ); - assert_eq!( - literal_fp, - value_fp(&i, js_array_filter_length_reversed, Lang::JavaScript) - ); - assert_eq!(literal_fp, value_fp(&i, java_list_of, Lang::Java)); - assert_eq!(literal_fp, value_fp(&i, java_set_of, Lang::Java)); - assert_eq!(literal_fp, value_fp(&i, java_arrays_aslist, Lang::Java)); - assert_eq!(literal_fp, value_fp(&i, java_module_list, Lang::Java)); - assert_eq!(literal_fp, value_fp(&i, go_slices_package, Lang::Go)); - assert_eq!(literal_fp, value_fp(&i, go_slices_alias, Lang::Go)); - assert_eq!(literal_fp, value_fp(&i, go_slices_const, Lang::Go)); - assert_eq!(literal_fp, value_fp(&i, go_slices_local, Lang::Go)); - assert_eq!(literal_fp, value_fp(&i, java_local_list, Lang::Java)); - assert_eq!(literal_fp, value_fp(&i, rust_local_array, Lang::Rust)); - assert_eq!(literal_fp, value_fp(&i, rust_local_typed_array, Lang::Rust)); - assert_eq!(literal_fp, value_fp(&i, rust_local_slice_ref, Lang::Rust)); - assert_eq!(literal_fp, value_fp(&i, rust_local_vec, Lang::Rust)); - assert_eq!(literal_fp, value_fp(&i, rust_std_hashset, Lang::Rust)); - assert_eq!(literal_fp, value_fp(&i, rust_std_btreeset, Lang::Rust)); - assert_eq!(literal_fp, value_fp(&i, rust_std_vecdeque, Lang::Rust)); - assert_eq!(literal_fp, value_fp(&i, swift_array_literal, Lang::Swift)); - assert_eq!(literal_fp, value_fp(&i, swift_local_array, Lang::Swift)); - assert_ne!( - literal_fp, - value_fp_named(&i, rust_std_shadowed, Lang::Rust, "f"), - "a local Rust std module must not be treated as the standard library" - ); - assert_eq!(literal_fp, value_fp(&i, ruby_member, Lang::Ruby)); - assert_eq!(literal_fp, value_fp(&i, ruby_set_new_include, Lang::Ruby)); - assert_eq!(literal_fp, value_fp(&i, ruby_set_new_member, Lang::Ruby)); - assert_eq!(literal_fp, value_fp(&i, ruby_set_local, Lang::Ruby)); - assert_ne!(literal_fp, value_fp(&i, js_wrong_element, Lang::JavaScript)); - assert_ne!( - literal_fp, - value_fp(&i, js_wrong_collection, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_global_shadowed_set, Lang::JavaScript), - "construct syntax alone must not prove a shadowed JS Set global" - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_some_wrong_element, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_some_wrong_collection, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_indexof_wrong_element, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_indexof_wrong_collection, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_indexof_value, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_findindex_wrong_element, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_findindex_wrong_collection, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_findindex_value, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_filter_length_wrong_element, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp( - &i, - js_array_filter_length_wrong_collection, - Lang::JavaScript - ) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_filter_length_value, Lang::JavaScript) - ); - assert_ne!( - literal_fp, - value_fp(&i, js_array_filter_length_zero, Lang::JavaScript) - ); - assert_ne!( - value_fp(&i, js_nan_includes, Lang::JavaScript), - value_fp(&i, js_nan_some, Lang::JavaScript) - ); - assert_ne!( - value_fp(&i, js_nan_includes, Lang::JavaScript), - value_fp(&i, js_nan_indexof, Lang::JavaScript) - ); - assert_ne!( - value_fp(&i, js_nan_includes, Lang::JavaScript), - value_fp(&i, js_nan_findindex, Lang::JavaScript) - ); - assert_ne!( - value_fp(&i, js_nan_includes, Lang::JavaScript), - value_fp(&i, js_nan_filter_length, Lang::JavaScript) - ); - assert_ne!( - value_fp(&i, js_nan_not_includes, Lang::JavaScript), - value_fp(&i, js_nan_filter_length_absence, Lang::JavaScript) + assert_fingerprint_cases_converge( + &i, + &literal_fp, + [ + fp_case!(py_set_factory, Python), + fp_case!(py_tuple_factory, Python), + fp_case!(py_frozenset_factory, Python), + fp_case!(py_deque_import, Python), + fp_case!(py_deque_alias, Python), + fp_case!(py_deque_namespace, Python), + fp_case!(py_module_set, Python), + fp_case!(js_set_inline, JavaScript), + fp_case!(js_set_local, JavaScript), + fp_case!(js_module_set, JavaScript), + fp_case!(ts_module_set, TypeScript), + fp_case!(js_array_some, JavaScript), + fp_case!(ts_array_some, TypeScript), + fp_case!(js_array_indexof_ne, JavaScript), + fp_case!(ts_array_indexof_ge, TypeScript), + fp_case!(js_array_indexof_gt, JavaScript), + fp_case!(js_array_indexof_reversed, JavaScript), + fp_case!(js_array_findindex_ne, JavaScript), + fp_case!(ts_array_findindex_ge, TypeScript), + fp_case!(js_array_findindex_gt, JavaScript), + fp_case!(js_array_findindex_reversed, JavaScript), + fp_case!(js_array_filter_length_ne, JavaScript), + fp_case!(ts_array_filter_length_ge, TypeScript), + fp_case!(js_array_filter_length_gt, JavaScript), + fp_case!(js_array_filter_length_reversed, JavaScript), + fp_case!(java_list_of, Java), + fp_case!(java_set_of, Java), + fp_case!(java_arrays_aslist, Java), + fp_case!(java_module_list, Java), + fp_case!(go_slices_package, Go), + fp_case!(go_slices_alias, Go), + fp_case!(go_slices_const, Go), + fp_case!(go_slices_local, Go), + fp_case!(java_local_list, Java), + fp_case!(rust_local_array, Rust), + fp_case!(rust_local_typed_array, Rust), + fp_case!(rust_local_slice_ref, Rust), + fp_case!(rust_local_vec, Rust), + fp_case!(rust_std_hashset, Rust), + fp_case!(rust_std_btreeset, Rust), + fp_case!(rust_std_vecdeque, Rust), + fp_case!(swift_array_literal, Swift), + fp_case!(swift_local_array, Swift), + fp_case!(ruby_member, Ruby), + fp_case!(ruby_set_new_include, Ruby), + fp_case!(ruby_set_new_member, Ruby), + fp_case!(ruby_set_local, Ruby), + ], + ); + assert_fingerprint_cases_stay_split( + &i, + &literal_fp, + [ + fingerprint_case( + "module tuple lacks surface/domain evidence", + py_module_tuple, + Lang::Python, + ), + fp_case!(js_set_call, JavaScript), + fingerprint_case( + "Array.contains is not a standard JavaScript contract", + js_array_contains, + Lang::JavaScript, + ), + fingerprint_case( + "sequence expression is not a static JavaScript array", + js_sequence_indexof_ne, + Lang::JavaScript, + ), + named_fingerprint_case( + "shadowed Rust std module", + rust_std_shadowed, + Lang::Rust, + "f", + ), + fp_case!(js_wrong_element, JavaScript), + fp_case!(js_wrong_collection, JavaScript), + fingerprint_case( + "construct syntax does not prove a shadowed JavaScript Set", + js_global_shadowed_set, + Lang::JavaScript, + ), + fp_case!(js_array_some_wrong_element, JavaScript), + fp_case!(js_array_some_wrong_collection, JavaScript), + fp_case!(js_array_indexof_wrong_element, JavaScript), + fp_case!(js_array_indexof_wrong_collection, JavaScript), + fp_case!(js_array_indexof_value, JavaScript), + fp_case!(js_array_findindex_wrong_element, JavaScript), + fp_case!(js_array_findindex_wrong_collection, JavaScript), + fp_case!(js_array_findindex_value, JavaScript), + fp_case!(js_array_filter_length_wrong_element, JavaScript), + fp_case!(js_array_filter_length_wrong_collection, JavaScript), + fp_case!(js_array_filter_length_value, JavaScript), + fp_case!(js_array_filter_length_zero, JavaScript), + ], + ); + let nan_membership_fp = value_fp(&i, js_nan_includes, Lang::JavaScript); + assert_fingerprint_cases_stay_split( + &i, + &nan_membership_fp, + [ + fp_case!(js_nan_some, JavaScript), + fp_case!(js_nan_indexof, JavaScript), + fp_case!(js_nan_findindex, JavaScript), + fp_case!(js_nan_filter_length, JavaScript), + ], + ); + let nan_absence_fp = value_fp(&i, js_nan_not_includes, Lang::JavaScript); + assert_fingerprint_cases_stay_split( + &i, + &nan_absence_fp, + [ + fp_case!(js_nan_filter_length_absence, JavaScript), + fp_case!(js_nan_every, JavaScript), + ], ); let absence_fp = value_fp(&i, py_absence, Lang::Python); - assert_ne!(literal_fp, absence_fp); - assert_eq!(absence_fp, value_fp(&i, js_not_includes, Lang::JavaScript)); - assert_eq!( - absence_fp, - value_fp(&i, js_array_every_absence, Lang::JavaScript) - ); - assert_eq!( - absence_fp, - value_fp(&i, ts_array_every_absence, Lang::TypeScript) - ); - assert_eq!( - absence_fp, - value_fp(&i, js_array_filter_length_absence_eq, Lang::JavaScript) - ); - assert_eq!( - absence_fp, - value_fp(&i, ts_array_filter_length_absence_le, Lang::TypeScript) - ); - assert_eq!( - absence_fp, - value_fp(&i, js_array_filter_length_absence_lt, Lang::JavaScript) - ); - assert_eq!( - absence_fp, - value_fp( - &i, - js_array_filter_length_absence_reversed, - Lang::JavaScript - ) - ); - assert_ne!( - absence_fp, - value_fp(&i, js_array_every_wrong_element, Lang::JavaScript) - ); - assert_ne!( - absence_fp, - value_fp(&i, js_array_every_wrong_collection, Lang::JavaScript) - ); - assert_ne!( - absence_fp, - value_fp( - &i, - js_array_filter_length_absence_wrong_element, - Lang::JavaScript - ) - ); - assert_ne!( - absence_fp, - value_fp( - &i, - js_array_filter_length_absence_wrong_collection, - Lang::JavaScript - ) - ); - assert_ne!( - value_fp(&i, js_nan_not_includes, Lang::JavaScript), - value_fp(&i, js_nan_every, Lang::JavaScript) - ); - assert_ne!(literal_fp, value_fp(&i, js_shadowed_set, Lang::JavaScript)); - assert_ne!( - literal_fp, - value_fp(&i, js_module_set_mutated, Lang::JavaScript) - ); assert_ne!( - literal_fp, - value_fp(&i, js_module_array_fill_mutated, Lang::JavaScript), - "JS Array.fill must invalidate module/local collection proofs" - ); - assert_ne!( - literal_fp, - value_fp(&i, js_local_array_copywithin_mutated, Lang::JavaScript), - "JS Array.copyWithin must invalidate local collection proofs" - ); - assert_ne!( - literal_fp, - value_fp(&i, ts_module_set_shadowed, Lang::TypeScript) - ); - assert_ne!(literal_fp, value_fp(&i, java_wrong_element, Lang::Java)); - assert_ne!(literal_fp, value_fp(&i, java_wrong_collection, Lang::Java)); - assert_ne!(literal_fp, value_fp(&i, java_shadowed_list, Lang::Java)); - assert_ne!(literal_fp, value_fp(&i, java_local_list_class, Lang::Java)); - assert_ne!( - literal_fp, - value_fp(&i, java_module_list_shadowed, Lang::Java) + literal_fp, absence_fp, + "membership and absence must stay split" + ); + assert_fingerprint_cases_converge( + &i, + &absence_fp, + [ + fp_case!(js_not_includes, JavaScript), + fp_case!(js_array_every_absence, JavaScript), + fp_case!(ts_array_every_absence, TypeScript), + fp_case!(js_array_filter_length_absence_eq, JavaScript), + fp_case!(ts_array_filter_length_absence_le, TypeScript), + fp_case!(js_array_filter_length_absence_lt, JavaScript), + fp_case!(js_array_filter_length_absence_reversed, JavaScript), + ], + ); + assert_fingerprint_cases_stay_split( + &i, + &absence_fp, + [ + fp_case!(js_array_every_wrong_element, JavaScript), + fp_case!(js_array_every_wrong_collection, JavaScript), + fp_case!(js_array_filter_length_absence_wrong_element, JavaScript), + fp_case!(js_array_filter_length_absence_wrong_collection, JavaScript), + ], + ); + assert_fingerprint_cases_stay_split( + &i, + &literal_fp, + [ + fp_case!(js_shadowed_set, JavaScript), + fp_case!(js_module_set_mutated, JavaScript), + fingerprint_case( + "Array.fill invalidates collection evidence", + js_module_array_fill_mutated, + Lang::JavaScript, + ), + fingerprint_case( + "Array.copyWithin invalidates collection evidence", + js_local_array_copywithin_mutated, + Lang::JavaScript, + ), + fp_case!(ts_module_set_shadowed, TypeScript), + fp_case!(java_wrong_element, Java), + fp_case!(java_wrong_collection, Java), + fp_case!(java_shadowed_list, Java), + fp_case!(java_local_list_class, Java), + fp_case!(java_module_list_shadowed, Java), + ], ); let singleton_literal = "def f(value, other):\n return value in [\"red\"]\n"; let singleton_fp = value_fp(&i, singleton_literal, Lang::Python); - assert_eq!( - singleton_fp, - value_fp(&i, java_collections_singleton, Lang::Java) - ); - assert_eq!( - singleton_fp, - value_fp(&i, java_collections_singleton_list, Lang::Java) - ); - assert_ne!( - singleton_fp, - value_fp_named(&i, java_collections_missing_import, Lang::Java, "f") - ); - assert_ne!( - singleton_fp, - value_fp(&i, java_collections_shadowed_receiver, Lang::Java) + assert_fingerprint_cases_converge( + &i, + &singleton_fp, + [ + fp_case!(java_collections_singleton, Java), + fp_case!(java_collections_singleton_list, Java), + ], + ); + assert_fingerprint_cases_stay_split( + &i, + &singleton_fp, + [ + named_fingerprint_case( + "unimported Java Collections", + java_collections_missing_import, + Lang::Java, + "f", + ), + fp_case!(java_collections_shadowed_receiver, Java), + ], ); let empty_literal = "def f(value, other):\n return value in []\n"; let empty_fp = value_fp(&i, empty_literal, Lang::Python); - assert_eq!( - empty_fp, - value_fp(&i, java_collections_empty_list, Lang::Java) - ); - assert_eq!( - empty_fp, - value_fp(&i, java_collections_empty_set, Lang::Java) - ); - assert_ne!( - literal_fp, - value_fp(&i, py_factory_wrong_element, Lang::Python) - ); - assert_ne!( - literal_fp, - value_fp(&i, py_factory_wrong_collection, Lang::Python) - ); - assert_ne!(literal_fp, value_fp(&i, py_factory_shadowed, Lang::Python)); - assert_ne!( - literal_fp, - value_fp(&i, py_deque_wrong_element, Lang::Python) - ); - assert_ne!( - literal_fp, - value_fp(&i, py_deque_wrong_collection, Lang::Python) - ); - assert_ne!( - literal_fp, - value_fp(&i, py_deque_missing_import, Lang::Python) - ); - assert_ne!( - literal_fp, - value_fp_named(&i, py_deque_shadowed, Lang::Python, "f") - ); - assert_ne!(literal_fp, value_fp(&i, py_deque_mutated, Lang::Python)); - assert_ne!(literal_fp, value_fp(&i, py_module_mutated, Lang::Python)); - assert_ne!(literal_fp, value_fp(&i, go_slices_wrong_element, Lang::Go)); - assert_ne!( - literal_fp, - value_fp(&i, go_slices_wrong_collection, Lang::Go) - ); - assert_ne!(literal_fp, value_fp(&i, go_slices_mutated, Lang::Go)); - assert_ne!(literal_fp, value_fp(&i, go_slices_local_mutated, Lang::Go)); - assert_ne!(literal_fp, value_fp(&i, go_slices_unimported, Lang::Go)); - assert_ne!( - literal_fp, - value_fp(&i, java_local_list_mutated, Lang::Java) - ); - assert_ne!( - literal_fp, - value_fp(&i, rust_local_wrong_element, Lang::Rust) + assert_fingerprint_cases_converge( + &i, + &empty_fp, + [ + fp_case!(java_collections_empty_list, Java), + fp_case!(java_collections_empty_set, Java), + ], + ); + assert_fingerprint_cases_stay_split( + &i, + &literal_fp, + [ + fp_case!(py_factory_wrong_element, Python), + fp_case!(py_factory_wrong_collection, Python), + fp_case!(py_factory_shadowed, Python), + fp_case!(py_deque_wrong_element, Python), + fp_case!(py_deque_wrong_collection, Python), + fp_case!(py_deque_missing_import, Python), + named_fingerprint_case( + "shadowed Python deque", + py_deque_shadowed, + Lang::Python, + "f", + ), + fp_case!(py_deque_mutated, Python), + fp_case!(py_module_mutated, Python), + fp_case!(go_slices_wrong_element, Go), + fp_case!(go_slices_wrong_collection, Go), + fp_case!(go_slices_mutated, Go), + fp_case!(go_slices_local_mutated, Go), + fp_case!(go_slices_unimported, Go), + fp_case!(java_local_list_mutated, Java), + fp_case!(rust_local_wrong_element, Rust), + fp_case!(rust_local_wrong_collection, Rust), + fp_case!(rust_local_mutated, Rust), + fp_case!(rust_local_custom_receiver, Rust), + fp_case!(rust_std_wrong_element, Rust), + fp_case!(rust_std_wrong_collection, Rust), + fp_case!(rust_std_mutated, Rust), + fp_case!(swift_wrong_element, Swift), + fp_case!(swift_wrong_collection, Swift), + fp_case!(swift_mutated, Swift), + named_fingerprint_case( + "custom Swift receiver", + swift_custom_receiver, + Lang::Swift, + "f", + ), + fp_case!(ruby_set_wrong_element, Ruby), + fp_case!(ruby_set_wrong_collection, Ruby), + fp_case!(ruby_set_missing_require, Ruby), + fp_case!(ruby_set_shadowed, Ruby), + fp_case!(ruby_set_mutated, Ruby), + ], ); - assert_ne!( - literal_fp, - value_fp(&i, rust_local_wrong_collection, Lang::Rust) - ); - assert_ne!(literal_fp, value_fp(&i, rust_local_mutated, Lang::Rust)); - assert_ne!( - literal_fp, - value_fp(&i, rust_local_custom_receiver, Lang::Rust) - ); - assert_ne!(literal_fp, value_fp(&i, rust_std_wrong_element, Lang::Rust)); - assert_ne!( - literal_fp, - value_fp(&i, rust_std_wrong_collection, Lang::Rust) - ); - assert_ne!(literal_fp, value_fp(&i, rust_std_mutated, Lang::Rust)); - assert_ne!(literal_fp, value_fp(&i, swift_wrong_element, Lang::Swift)); - assert_ne!( - literal_fp, - value_fp(&i, swift_wrong_collection, Lang::Swift) - ); - assert_ne!(literal_fp, value_fp(&i, swift_mutated, Lang::Swift)); - assert_ne!( - literal_fp, - value_fp_named(&i, swift_custom_receiver, Lang::Swift, "f") - ); - assert_ne!(literal_fp, value_fp(&i, ruby_set_wrong_element, Lang::Ruby)); - assert_ne!( - literal_fp, - value_fp(&i, ruby_set_wrong_collection, Lang::Ruby) - ); - assert_ne!( - literal_fp, - value_fp(&i, ruby_set_missing_require, Lang::Ruby) - ); - assert_ne!(literal_fp, value_fp(&i, ruby_set_shadowed, Lang::Ruby)); - assert_ne!(literal_fp, value_fp(&i, ruby_set_mutated, Lang::Ruby)); let ts_array = "function f(values: string[], value: string, other: string): boolean { return values.includes(value); }"; let ts_set = "function f(values: Set, value: string, other: string): boolean { return values.has(value); }"; @@ -578,19 +451,28 @@ fn collection_membership_set_construction_converges_with_boundaries() { let py_alias_unresolved = "def f(values: Values[str], value: str, other: str, other_values: Values[str]) -> bool:\n return value in values\n"; let py_alias_shadowed = "from typing import Sequence as Values\nValues = str\n\ndef f(values: Values[str], value: str, other: str, other_values: Values[str]) -> bool:\n return value in values\n"; let typed_fp = value_fp(&i, ts_array, Lang::TypeScript); - assert_eq!(typed_fp, value_fp(&i, ts_set, Lang::TypeScript)); - assert_eq!(typed_fp, value_fp(&i, py_tuple, Lang::Python)); - assert_eq!(typed_fp, value_fp(&i, py_alias_sequence, Lang::Python)); - assert_eq!(typed_fp, value_fp(&i, py_alias_container, Lang::Python)); - assert_eq!(typed_fp, value_fp(&i, py_alias_set, Lang::Python)); - assert_eq!(typed_fp, value_fp(&i, java_queue, Lang::Java)); - assert_eq!(typed_fp, value_fp(&i, rust_vecdeque, Lang::Rust)); - assert_ne!(typed_fp, value_fp(&i, ts_untyped, Lang::TypeScript)); - assert_ne!(typed_fp, value_fp(&i, py_alias_wrong_element, Lang::Python)); - assert_ne!( - typed_fp, - value_fp(&i, py_alias_wrong_receiver, Lang::Python) + assert_fingerprint_cases_converge( + &i, + &typed_fp, + [ + fp_case!(ts_set, TypeScript), + fp_case!(py_tuple, Python), + fp_case!(py_alias_sequence, Python), + fp_case!(py_alias_container, Python), + fp_case!(py_alias_set, Python), + fp_case!(java_queue, Java), + fp_case!(rust_vecdeque, Rust), + ], + ); + assert_fingerprint_cases_stay_split( + &i, + &typed_fp, + [ + fp_case!(ts_untyped, TypeScript), + fp_case!(py_alias_wrong_element, Python), + fp_case!(py_alias_wrong_receiver, Python), + fp_case!(py_alias_unresolved, Python), + fp_case!(py_alias_shadowed, Python), + ], ); - assert_ne!(typed_fp, value_fp(&i, py_alias_unresolved, Lang::Python)); - assert_ne!(typed_fp, value_fp(&i, py_alias_shadowed, Lang::Python)); } diff --git a/crates/nose-cli/tests/equivalence/map_default_boundaries.rs b/crates/nose-cli/tests/equivalence/map_default_boundaries.rs index 37896155a..230d088a5 100644 --- a/crates/nose-cli/tests/equivalence/map_default_boundaries.rs +++ b/crates/nose-cli/tests/equivalence/map_default_boundaries.rs @@ -22,25 +22,45 @@ fn map_default_lookup_converges_cross_language() { let py_alias_dict = "from typing import Dict as MapLike\n\ndef f(lookup: MapLike[str, int], other_lookup: MapLike[str, int], key: str, other_key: str, fallback: int, other_default: int) -> int:\n return lookup.get(key, fallback)\n"; let fp = value_fp(&i, go, Lang::Go); - assert_eq!(fp, value_fp(&i, java_explicit, Lang::Java)); - assert_eq!(fp, value_fp(&i, java_builtin, Lang::Java)); - assert_eq!(fp, value_fp(&i, java_guard_return, Lang::Java)); - assert_eq!(fp, value_fp(&i, rust_explicit, Lang::Rust)); - assert_eq!(fp, value_fp(&i, rust_unwrap, Lang::Rust)); - assert_eq!(fp, value_fp(&i, ts_has_get, Lang::TypeScript)); - assert_eq!(fp, value_fp(&i, ts_guard_return, Lang::TypeScript)); + assert_fingerprint_cases_converge( + &i, + &fp, + [ + fp_case!(java_explicit, Java), + fp_case!(java_builtin, Java), + fp_case!(java_guard_return, Java), + fp_case!(rust_explicit, Rust), + fp_case!(rust_unwrap, Rust), + fp_case!(ts_has_get, TypeScript), + fp_case!(ts_guard_return, TypeScript), + fp_case!(py_dict, Python), + fp_case!(py_guard_return, Python), + fp_case!(py_mapping, Python), + fp_case!(py_mutable_mapping, Python), + fp_case!(py_alias_mapping, Python), + fp_case!(py_alias_mutable_mapping, Python), + fp_case!(py_alias_dict, Python), + ], + ); // `lookup.get(key) ?? fallback` is nullish COALESCE; the strict `selected === undefined ? …` // guard is conflated with `== null` by the null/undefined value model. Neither merges with the // absence-default family — they diverge on a present null-valued key (#410, experiments §CT). - assert_ne!(fp, value_fp(&i, ts_nullish, Lang::TypeScript)); - assert_ne!(fp, value_fp(&i, ts_temp_guard, Lang::TypeScript)); - assert_eq!(fp, value_fp(&i, py_dict, Lang::Python)); - assert_eq!(fp, value_fp(&i, py_guard_return, Lang::Python)); - assert_eq!(fp, value_fp(&i, py_mapping, Lang::Python)); - assert_eq!(fp, value_fp(&i, py_mutable_mapping, Lang::Python)); - assert_eq!(fp, value_fp(&i, py_alias_mapping, Lang::Python)); - assert_eq!(fp, value_fp(&i, py_alias_mutable_mapping, Lang::Python)); - assert_eq!(fp, value_fp(&i, py_alias_dict, Lang::Python)); + assert_fingerprint_cases_stay_split( + &i, + &fp, + [ + fingerprint_case( + "TypeScript nullish default replaces a present null", + ts_nullish, + Lang::TypeScript, + ), + fingerprint_case( + "TypeScript undefined guard lacks absence-only proof", + ts_temp_guard, + Lang::TypeScript, + ), + ], + ); } #[test] @@ -86,21 +106,24 @@ fn map_default_lookup_keeps_wrong_coordinate_boundaries() { let py_untyped = "def f(lookup, other_lookup, key, other_key, fallback, other_default):\n return lookup.get(key, fallback)\n"; let fp = value_fp(&i, go, Lang::Go); - assert_ne!(fp, value_fp(&i, wrong_key, Lang::Java)); - assert_ne!(fp, value_fp(&i, wrong_default, Lang::Rust)); - assert_ne!(fp, value_fp(&i, wrong_map, Lang::Go)); - assert_ne!(fp, value_fp(&i, ts_wrong_key, Lang::TypeScript)); - assert_ne!(fp, value_fp(&i, ts_wrong_default, Lang::TypeScript)); - assert_ne!(fp, value_fp(&i, ts_wrong_map, Lang::TypeScript)); - assert_ne!(fp, value_fp(&i, ts_untyped, Lang::TypeScript)); - assert_ne!( - fp, - value_fp(&i, ts_temp_shadowed_undefined, Lang::TypeScript) + assert_fingerprint_cases_stay_split( + &i, + &fp, + [ + fp_case!(wrong_key, Java), + fp_case!(wrong_default, Rust), + fp_case!(wrong_map, Go), + fp_case!(ts_wrong_key, TypeScript), + fp_case!(ts_wrong_default, TypeScript), + fp_case!(ts_wrong_map, TypeScript), + fp_case!(ts_untyped, TypeScript), + fp_case!(ts_temp_shadowed_undefined, TypeScript), + fp_case!(py_wrong_key, Python), + fp_case!(py_wrong_default, Python), + fp_case!(py_wrong_map, Python), + fp_case!(py_untyped, Python), + ], ); - assert_ne!(fp, value_fp(&i, py_wrong_key, Lang::Python)); - assert_ne!(fp, value_fp(&i, py_wrong_default, Lang::Python)); - assert_ne!(fp, value_fp(&i, py_wrong_map, Lang::Python)); - assert_ne!(fp, value_fp(&i, py_untyped, Lang::Python)); } #[test] @@ -117,14 +140,20 @@ fn map_default_lookup_keeps_alias_and_guard_boundaries() { let guard_wrong_map = "def f(lookup: dict[str, int], other_lookup: dict[str, int], key: str, other_key: str, fallback: int, other_default: int) -> int:\n if key in other_lookup:\n return other_lookup[key]\n return fallback\n"; let fp = value_fp(&i, go, Lang::Go); - assert_ne!(fp, value_fp(&i, py_alias_wrong_key, Lang::Python)); - assert_ne!(fp, value_fp(&i, py_alias_wrong_default, Lang::Python)); - assert_ne!(fp, value_fp(&i, py_alias_wrong_map, Lang::Python)); - assert_ne!(fp, value_fp(&i, py_alias_unresolved, Lang::Python)); - assert_ne!(fp, value_fp(&i, py_alias_shadowed, Lang::Python)); - assert_ne!(fp, value_fp(&i, guard_wrong_key, Lang::TypeScript)); - assert_ne!(fp, value_fp(&i, guard_wrong_default, Lang::Java)); - assert_ne!(fp, value_fp(&i, guard_wrong_map, Lang::Python)); + assert_fingerprint_cases_stay_split( + &i, + &fp, + [ + fp_case!(py_alias_wrong_key, Python), + fp_case!(py_alias_wrong_default, Python), + fp_case!(py_alias_wrong_map, Python), + fp_case!(py_alias_unresolved, Python), + fp_case!(py_alias_shadowed, Python), + fp_case!(guard_wrong_key, TypeScript), + fp_case!(guard_wrong_default, Java), + fp_case!(guard_wrong_map, Python), + ], + ); } #[test] diff --git a/crates/nose-cli/tests/equivalence/option_boundaries.rs b/crates/nose-cli/tests/equivalence/option_boundaries.rs index ee036c336..96819f8df 100644 --- a/crates/nose-cli/tests/equivalence/option_boundaries.rs +++ b/crates/nose-cli/tests/equivalence/option_boundaries.rs @@ -75,35 +75,35 @@ fn option_defaulting_converges_with_nullish_default_boundaries() { let shadowed_undefined = "function f(value, fallback, other, otherDefault, undefined) { return value === undefined ? fallback : value; }"; let fp = value_fp(&i, js, Lang::JavaScript); - assert_eq!(fp, value_fp(&i, js_guard, Lang::JavaScript)); - assert_eq!(fp, value_fp(&i, ts_guard, Lang::TypeScript)); - assert_eq!(fp, value_fp(&i, rust_unwrap, Lang::Rust)); - assert_eq!(fp, value_fp(&i, rust_unwrap_else, Lang::Rust)); - assert_eq!(fp, value_fp(&i, rust_map_or, Lang::Rust)); - assert_eq!(fp, value_fp(&i, rust_guard, Lang::Rust)); - assert_eq!(fp, value_fp(&i, swift_coalesce, Lang::Swift)); - assert_ne!(fp, value_fp(&i, wrong_default, Lang::Rust)); - assert_ne!(fp, value_fp(&i, wrong_value, Lang::Rust)); - assert_ne!(fp, value_fp(&i, swift_wrong_default, Lang::Swift)); - assert_ne!(fp, value_fp(&i, swift_wrong_value, Lang::Swift)); - assert_ne!( - fp, - value_fp_named(&i, swift_effectful_default, Lang::Swift, "f") - ); - assert_ne!( - fp, - value_fp_named(&i, swift_computed_property_default, Lang::Swift, "f") - ); - assert_ne!( - fp, - value_fp_named(&i, swift_custom_coalesce, Lang::Swift, "f") + assert_fingerprint_cases_converge( + &i, + &fp, + [ + fp_case!(js_guard, JavaScript), + fp_case!(ts_guard, TypeScript), + fp_case!(rust_unwrap, Rust), + fp_case!(rust_unwrap_else, Rust), + fp_case!(rust_map_or, Rust), + fp_case!(rust_guard, Rust), + fp_case!(swift_coalesce, Swift), + ], ); - assert_ne!( - fp, - value_fp_named(&i, swift_optional_coalesce_overload, Lang::Swift, "f") + assert_fingerprint_cases_stay_split( + &i, + &fp, + [ + fp_case!(wrong_default, Rust), + fp_case!(wrong_value, Rust), + fp_case!(swift_wrong_default, Swift), + fp_case!(swift_wrong_value, Swift), + named_fp_case!(swift_effectful_default, Swift, "f"), + named_fp_case!(swift_computed_property_default, Swift, "f"), + named_fp_case!(swift_custom_coalesce, Swift, "f"), + named_fp_case!(swift_optional_coalesce_overload, Swift, "f"), + fp_case!(truthy_or, JavaScript), + fp_case!(shadowed_undefined, JavaScript), + ], ); - assert_ne!(fp, value_fp(&i, truthy_or, Lang::JavaScript)); - assert_ne!(fp, value_fp(&i, shadowed_undefined, Lang::JavaScript)); } const RUBY_NIL_PREDICATE_ISEQ_BOUNDARIES: &[(&str, &str)] = &[ @@ -244,22 +244,25 @@ fn swift_optional_nil_presence_requires_optional_coordinate() { let missing_fp = value_fp(&i, py_missing, Lang::Python); let present_fp = value_fp(&i, py_present, Lang::Python); - assert_eq!(missing_fp, value_fp(&i, swift_missing, Lang::Swift)); - assert_eq!( - missing_fp, - value_fp(&i, swift_missing_reversed, Lang::Swift) + assert_fingerprint_cases_converge( + &i, + &missing_fp, + [ + fp_case!(swift_missing, Swift), + fp_case!(swift_missing_reversed, Swift), + ], ); - assert_eq!(present_fp, value_fp(&i, swift_present, Lang::Swift)); - assert_ne!(missing_fp, value_fp(&i, swift_present, Lang::Swift)); - assert_ne!(missing_fp, value_fp(&i, swift_wrong_value, Lang::Swift)); - assert_ne!(missing_fp, value_fp(&i, swift_rebound, Lang::Swift)); - assert_ne!( - missing_fp, - value_fp_named(&i, swift_custom_nil, Lang::Swift, "f") - ); - assert_ne!( - missing_fp, - value_fp_named(&i, swift_optional_equality_overload, Lang::Swift, "f") + assert_fingerprint_cases_converge(&i, &present_fp, [fp_case!(swift_present, Swift)]); + assert_fingerprint_cases_stay_split( + &i, + &missing_fp, + [ + fp_case!(swift_present, Swift), + fp_case!(swift_wrong_value, Swift), + fp_case!(swift_rebound, Swift), + named_fp_case!(swift_custom_nil, Swift, "f"), + named_fp_case!(swift_optional_equality_overload, Swift, "f"), + ], ); } diff --git a/crates/nose-detect/src/lib.rs b/crates/nose-detect/src/lib.rs index 0be359332..9e8ddd909 100644 --- a/crates/nose-detect/src/lib.rs +++ b/crates/nose-detect/src/lib.rs @@ -28,6 +28,8 @@ mod reinvented; mod report; mod strict_exact; mod test_paths; +#[cfg(test)] +mod test_support; mod units; mod witness; diff --git a/crates/nose-detect/src/orchestration.rs b/crates/nose-detect/src/orchestration.rs index 5a805ab6b..d5d11a396 100644 --- a/crates/nose-detect/src/orchestration.rs +++ b/crates/nose-detect/src/orchestration.rs @@ -1,40 +1,34 @@ use crate::{ - candidates::{ - build_connected_groups, build_groups, round3, structural_candidates, ConnectedAccepted, - ConnectedRoute, - }, + candidates::{build_connected_groups, build_groups, structural_candidates}, cluster::UnionFind, - connected, - contiguous::{self, Stream}, - detectors::{connected_witness_score, Detector}, - locations::{ - attach_enclosing_units, connected_loc_of, enclosing_unit_indices, enclosing_units, - is_nested, loc_of, - }, - model::{Dump, DupPair, EnclosingUnit, LineSpan, Metrics, Report, UnitLoc}, + contiguous::Stream, + detectors::Detector, + locations::enclosing_units, + model::{Dump, Metrics, Report}, options::DetectOptions, reinvented::reinvented_helpers, units::UnitFeat, }; use nose_il::Corpus; -use rayon::prelude::*; -use std::collections::{HashMap, HashSet}; - -use crate::incremental::{self, IncrementalDetectionState, IncrementalDetectionStats}; mod features; pub use features::{ corpus_features, corpus_features_with_normalized, file_stream, units_of_file, CorpusFeatures, }; mod incremental_session; +mod output; +mod scoring; mod stages; +mod timing; pub use incremental_session::{ detect_from_units_incremental_session_with_accepted_coverage, detect_from_units_incremental_with_accepted_coverage, }; -use stages::{ - ConnectedStage, ContiguousStage, DetectionStageSource, DetectionStages, ResolvedDetectionStages, -}; +use output::{append_resolved_contiguous, build_pair_output, detection_dump}; +use scoring::score_ordinary_candidates; +pub(crate) use scoring::{AcceptedPair, ScoredCandidate}; +use stages::{ConnectedStage, DetectionStages, ResolvedDetectionStages}; +use timing::StageTimer; pub fn detect(corpus: &Corpus, opts: &DetectOptions, detector: &dyn Detector) -> Report { detect_with_dump_inner(corpus, opts, detector, DetectionOutput::REPORT).0 @@ -68,36 +62,6 @@ pub fn detect_with_direct_accepted_coverage( .0 } -/// Per-stage wall-clock timing, printed to stderr when `NOSE_TIME` is set. A -/// zero-cost no-op otherwise (the `Instant`s are cheap; only the env check gates -/// printing). -struct StageTimer { - on: bool, - start: std::time::Instant, - last: std::time::Instant, -} -impl StageTimer { - fn new() -> Self { - let now = std::time::Instant::now(); - StageTimer { - on: std::env::var_os("NOSE_TIME").is_some(), - start: now, - last: now, - } - } - fn lap(&mut self, stage: &str) { - let now = std::time::Instant::now(); - if self.on { - eprintln!( - " [time] {stage:<12} {:>7.1}ms (total {:>7.1}ms)", - now.duration_since(self.last).as_secs_f64() * 1e3, - now.duration_since(self.start).as_secs_f64() * 1e3, - ); - } - self.last = now; - } -} - #[derive(Clone, Copy)] enum CoverageTrace { None, @@ -423,172 +387,8 @@ fn finish_detection( (report, dump) } -fn detection_dump(units: &[UnitFeat], candidates: &[(usize, usize)]) -> Dump { - Dump { - units: units - .iter() - .map(|u| UnitLoc { - path: u.path.clone(), - start_line: u.start_line, - end_line: u.end_line, - lang: u.lang.name().to_string(), - name: u.name.clone(), - }) - .collect(), - candidates: candidates - .iter() - .map(|&(i, j)| (i as u32, j as u32)) - .collect(), - } -} - -pub(crate) type AcceptedPair = (usize, usize, f64); - -#[derive(Clone, Copy, Debug)] -pub(crate) struct ScoredCandidate { - pub(crate) left: usize, - pub(crate) right: usize, - /// Nested pairs are intentionally not scored by the ordinary detector. - pub(crate) ordinary_score: Option, -} - -fn score_ordinary_candidates( - units: &[UnitFeat], - candidates: &[(usize, usize)], - detector: &dyn Detector, - threshold: f64, -) -> (Vec, Vec) { - let scored = candidates - .par_iter() - .map(|&(left, right)| ScoredCandidate { - left, - right, - ordinary_score: (!is_nested(&units[left], &units[right])) - .then(|| detector.score(&units[left], &units[right])), - }) - .collect::>(); - let accepted = scored - .iter() - .filter_map(|candidate| { - candidate - .ordinary_score - .filter(|&score| score >= threshold) - .map(|score| (candidate.left, candidate.right, score)) - }) - .collect(); - (scored, accepted) -} - -fn build_pair_output( - units: &[UnitFeat], - enclosing: &[Option], - ordinary: &[AcceptedPair], - connected: &[ConnectedAccepted], - emit_pairs: bool, -) -> Vec { - if !emit_pairs { - return Vec::new(); - } - let mut output = ordinary - .iter() - .map(|&(left, right, score)| DupPair { - left: loc_of(&units[left], enclosing[left].clone()), - right: loc_of(&units[right], enclosing[right].clone()), - score: round3(score), - cross_language: units[left].lang != units[right].lang, - }) - .collect::>(); - output.extend(connected.iter().map(|pair| { - let left = connected_loc_of( - &units[pair.left], - enclosing[pair.left].clone(), - pair.witness.left_lines, - pair.witness.mapped_nodes, - ); - let right = connected_loc_of( - &units[pair.right], - enclosing[pair.right].clone(), - pair.witness.right_lines, - pair.witness.mapped_nodes, - ); - DupPair { - left, - right, - score: round3(pair.score), - cross_language: units[pair.left].lang != units[pair.right].lang, - } - })); - output.sort_by(|left, right| right.score.total_cmp(&left.score)); - output -} - pub(crate) mod connected_pricing; use connected_pricing::{ deduplicate_connected, deduplicate_same_unit, score_connected_candidates, score_same_unit_candidates, }; - -fn append_contiguous_groups( - report: &mut Report, - streams: &[Stream], - opts: &DetectOptions, - units: &[UnitFeat], - trace_accepted_coverage: bool, -) { - if !opts.contiguous { - return; - } - let (extra, accepted_edges) = contiguous::detect( - streams, - opts.contiguous_min_tokens, - opts.contiguous_min_lines, - trace_accepted_coverage, - ); - append_contiguous_output( - report, - extra, - accepted_edges, - units, - trace_accepted_coverage, - ); -} - -fn append_resolved_contiguous( - report: &mut Report, - contiguous: Option, - streams: &[Stream], - opts: &DetectOptions, - units: &[UnitFeat], - trace_accepted_coverage: bool, -) { - if let Some(ContiguousStage { - groups, - accepted_edges, - }) = contiguous - { - append_contiguous_output( - report, - groups, - accepted_edges, - units, - trace_accepted_coverage, - ); - } else { - append_contiguous_groups(report, streams, opts, units, trace_accepted_coverage); - } -} - -fn append_contiguous_output( - report: &mut Report, - mut groups: Vec, - accepted_edges: Vec>, - units: &[UnitFeat], - trace_accepted_coverage: bool, -) { - attach_enclosing_units(&mut groups, units); - report.metrics.groups += groups.len(); - report.groups.extend(groups); - if trace_accepted_coverage { - report.accepted_group_edges.extend(accepted_edges); - } -} diff --git a/crates/nose-detect/src/orchestration/connected_pricing.rs b/crates/nose-detect/src/orchestration/connected_pricing.rs index 612e38cbd..26dec0e63 100644 --- a/crates/nose-detect/src/orchestration/connected_pricing.rs +++ b/crates/nose-detect/src/orchestration/connected_pricing.rs @@ -1,4 +1,14 @@ -use super::*; +use super::{AcceptedPair, ScoredCandidate}; +use crate::{ + candidates::{ConnectedAccepted, ConnectedRoute}, + connected, + detectors::connected_witness_score, + locations::{enclosing_unit_indices, is_nested}, + model::LineSpan, + units::UnitFeat, +}; +use rayon::prelude::*; +use std::collections::{HashMap, HashSet}; pub(super) fn score_connected_candidates( units: &[UnitFeat], diff --git a/crates/nose-detect/src/orchestration/features.rs b/crates/nose-detect/src/orchestration/features.rs index 9231f637f..3ba023b2b 100644 --- a/crates/nose-detect/src/orchestration/features.rs +++ b/crates/nose-detect/src/orchestration/features.rs @@ -1,7 +1,12 @@ -use super::*; -use crate::{minhash, units}; -use nose_il::{Il, Interner}; +use crate::{ + contiguous::{self, Stream}, + minhash, + options::DetectOptions, + units::{self, UnitFeat}, +}; +use nose_il::{Corpus, Il, Interner}; use nose_normalize::NormalizeOptions; +use rayon::prelude::*; /// Build one file's syntax-channel token stream from its (raw) IL. Exposed so the /// CLI's `--cache-dir` can cache it per file and pass it to [`super::detect_from_units`] — diff --git a/crates/nose-detect/src/orchestration/incremental_session.rs b/crates/nose-detect/src/orchestration/incremental_session.rs index 4abca580b..999c56629 100644 --- a/crates/nose-detect/src/orchestration/incremental_session.rs +++ b/crates/nose-detect/src/orchestration/incremental_session.rs @@ -1,5 +1,17 @@ -use super::stages::{ConnectedStage, ContiguousStage}; -use super::*; +use super::{ + finish_detection, + stages::{ConnectedStage, ContiguousStage, DetectionStageSource, DetectionStages}, + timing::StageTimer, + DetectionOutput, DetectionRequest, +}; +use crate::{ + contiguous::{self, Stream}, + detectors::Detector, + incremental::{self, IncrementalDetectionState, IncrementalDetectionStats}, + model::Report, + options::DetectOptions, + units::UnitFeat, +}; /// Cached-query entry point with persistent candidate membership and pair-score /// reuse. The state is content-addressed by the CLI; this layer owns its schema. diff --git a/crates/nose-detect/src/orchestration/output.rs b/crates/nose-detect/src/orchestration/output.rs new file mode 100644 index 000000000..93028b45b --- /dev/null +++ b/crates/nose-detect/src/orchestration/output.rs @@ -0,0 +1,123 @@ +use super::{stages::ContiguousStage, AcceptedPair}; +use crate::{ + candidates::{round3, ConnectedAccepted}, + contiguous::{self, Stream}, + locations::{attach_enclosing_units, connected_loc_of, loc_of}, + model::{Dump, DupPair, EnclosingUnit, Report, UnitLoc}, + options::DetectOptions, + units::UnitFeat, +}; + +pub(super) fn detection_dump(units: &[UnitFeat], candidates: &[(usize, usize)]) -> Dump { + Dump { + units: units + .iter() + .map(|unit| UnitLoc { + path: unit.path.clone(), + start_line: unit.start_line, + end_line: unit.end_line, + lang: unit.lang.name().to_string(), + name: unit.name.clone(), + }) + .collect(), + candidates: candidates + .iter() + .map(|&(left, right)| (left as u32, right as u32)) + .collect(), + } +} + +pub(super) fn build_pair_output( + units: &[UnitFeat], + enclosing: &[Option], + ordinary: &[AcceptedPair], + connected: &[ConnectedAccepted], + emit_pairs: bool, +) -> Vec { + if !emit_pairs { + return Vec::new(); + } + let mut output = ordinary + .iter() + .map(|&(left, right, score)| DupPair { + left: loc_of(&units[left], enclosing[left].clone()), + right: loc_of(&units[right], enclosing[right].clone()), + score: round3(score), + cross_language: units[left].lang != units[right].lang, + }) + .collect::>(); + output.extend(connected.iter().map(|pair| { + let left = connected_loc_of( + &units[pair.left], + enclosing[pair.left].clone(), + pair.witness.left_lines, + pair.witness.mapped_nodes, + ); + let right = connected_loc_of( + &units[pair.right], + enclosing[pair.right].clone(), + pair.witness.right_lines, + pair.witness.mapped_nodes, + ); + DupPair { + left, + right, + score: round3(pair.score), + cross_language: units[pair.left].lang != units[pair.right].lang, + } + })); + output.sort_by(|left, right| right.score.total_cmp(&left.score)); + output +} + +pub(super) fn append_resolved_contiguous( + report: &mut Report, + resolved: Option, + streams: &[Stream], + opts: &DetectOptions, + units: &[UnitFeat], + trace_accepted_coverage: bool, +) { + if let Some(ContiguousStage { + groups, + accepted_edges, + }) = resolved + { + append_contiguous_output( + report, + groups, + accepted_edges, + units, + trace_accepted_coverage, + ); + } else if opts.contiguous { + let (groups, accepted_edges) = contiguous::detect( + streams, + opts.contiguous_min_tokens, + opts.contiguous_min_lines, + trace_accepted_coverage, + ); + append_contiguous_output( + report, + groups, + accepted_edges, + units, + trace_accepted_coverage, + ); + } +} + +fn append_contiguous_output( + report: &mut Report, + mut groups: Vec, + accepted_edges: Vec>, + units: &[UnitFeat], + trace_accepted_coverage: bool, +) { + attach_enclosing_units(&mut groups, units); + report.metrics.groups += groups.len(); + report.groups.extend(groups); + if trace_accepted_coverage { + report.accepted_group_edges.extend(accepted_edges); + } +} diff --git a/crates/nose-detect/src/orchestration/scoring.rs b/crates/nose-detect/src/orchestration/scoring.rs new file mode 100644 index 000000000..9691131e6 --- /dev/null +++ b/crates/nose-detect/src/orchestration/scoring.rs @@ -0,0 +1,39 @@ +use crate::{detectors::Detector, locations::is_nested, units::UnitFeat}; +use rayon::prelude::*; + +pub(crate) type AcceptedPair = (usize, usize, f64); + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ScoredCandidate { + pub(crate) left: usize, + pub(crate) right: usize, + /// Nested pairs are intentionally not scored by the ordinary detector. + pub(crate) ordinary_score: Option, +} + +pub(super) fn score_ordinary_candidates( + units: &[UnitFeat], + candidates: &[(usize, usize)], + detector: &dyn Detector, + threshold: f64, +) -> (Vec, Vec) { + let scored = candidates + .par_iter() + .map(|&(left, right)| ScoredCandidate { + left, + right, + ordinary_score: (!is_nested(&units[left], &units[right])) + .then(|| detector.score(&units[left], &units[right])), + }) + .collect::>(); + let accepted = scored + .iter() + .filter_map(|candidate| { + candidate + .ordinary_score + .filter(|&score| score >= threshold) + .map(|score| (candidate.left, candidate.right, score)) + }) + .collect(); + (scored, accepted) +} diff --git a/crates/nose-detect/src/orchestration/stages.rs b/crates/nose-detect/src/orchestration/stages.rs index 26df20737..077ebc669 100644 --- a/crates/nose-detect/src/orchestration/stages.rs +++ b/crates/nose-detect/src/orchestration/stages.rs @@ -1,4 +1,5 @@ -use super::{AcceptedPair, ConnectedAccepted, ScoredCandidate}; +use super::{AcceptedPair, ScoredCandidate}; +use crate::candidates::ConnectedAccepted; #[derive(Default)] pub(super) struct ConnectedStage { diff --git a/crates/nose-detect/src/orchestration/timing.rs b/crates/nose-detect/src/orchestration/timing.rs new file mode 100644 index 000000000..aee05bc9e --- /dev/null +++ b/crates/nose-detect/src/orchestration/timing.rs @@ -0,0 +1,29 @@ +/// Per-stage wall-clock timing, printed to stderr when `NOSE_TIME` is set. +pub(super) struct StageTimer { + enabled: bool, + start: std::time::Instant, + last: std::time::Instant, +} + +impl StageTimer { + pub(super) fn new() -> Self { + let now = std::time::Instant::now(); + Self { + enabled: std::env::var_os("NOSE_TIME").is_some(), + start: now, + last: now, + } + } + + pub(super) fn lap(&mut self, stage: &str) { + let now = std::time::Instant::now(); + if self.enabled { + eprintln!( + " [time] {stage:<12} {:>7.1}ms (total {:>7.1}ms)", + now.duration_since(self.last).as_secs_f64() * 1e3, + now.duration_since(self.start).as_secs_f64() * 1e3, + ); + } + self.last = now; + } +} diff --git a/crates/nose-detect/src/strict_exact/tests/receivers.rs b/crates/nose-detect/src/strict_exact/tests/receivers.rs index 01a304928..13698f1fb 100644 --- a/crates/nose-detect/src/strict_exact/tests/receivers.rs +++ b/crates/nose-detect/src/strict_exact/tests/receivers.rs @@ -4,51 +4,7 @@ use super::support::*; #[test] fn strict_exact_len_rejects_pull_lazy_library_hof_arg() { let interner = Interner::new(); - let mut b = IlBuilder::new(FileId(0)); - let item = b.add(NodeKind::Lit, Payload::LitInt(1), sp(1), &[]); - let coll = b.add(NodeKind::Seq, Payload::None, sp(1), &[item]); - let param = b.add(NodeKind::Param, Payload::Cid(0), sp(2), &[]); - let body_value = b.add(NodeKind::Var, Payload::Cid(0), sp(2), &[]); - let ret = b.add(NodeKind::Return, Payload::None, sp(2), &[body_value]); - let body = b.add(NodeKind::Block, Payload::None, sp(2), &[ret]); - let lambda = b.add(NodeKind::Lambda, Payload::None, sp(2), &[param, body]); - let hof = b.add( - NodeKind::HoF, - Payload::HoF(HoFKind::Map), - sp(3), - &[coll, lambda], - ); - let len = b.add( - NodeKind::Call, - Payload::Builtin(Builtin::Len), - sp(4), - &[hof], - ); - let mut il = b.finish( - len, - FileMeta { - path: "t.rs".into(), - lang: Lang::Rust, - }, - Vec::new(), - Vec::new(), - ); - il.evidence.push(method_call_library_api_evidence( - 0, - Lang::Rust, - "map", - il.node(hof).span, - 1, - Vec::new(), - )); - il.evidence.push(method_call_library_api_evidence( - 1, - Lang::Rust, - "len", - il.node(len).span, - 0, - Vec::new(), - )); + let (il, _hof, len) = nose_semantics::test_support::rust_pull_lazy_map_len_test_il(); let facts = StrictFacts::collect(&il, &interner); assert!( @@ -98,51 +54,9 @@ fn binding_domain_does_not_make_opaque_binding_exact_value() { #[test] fn binding_domain_after_receiver_use_does_not_prove_receiver() { - let interner = Interner::new(); - let xs = interner.intern("xs"); - let mut b = IlBuilder::new(FileId(0)); - let receiver = b.add(NodeKind::Var, Payload::Cid(0), sp(20), &[]); - let callee = b.add( - NodeKind::Field, - Payload::Name(interner.intern("includes")), - sp(21), - &[receiver], - ); - let item = b.add(NodeKind::Lit, Payload::LitInt(7), sp(22), &[]); - let call = b.add(NodeKind::Call, Payload::None, sp(23), &[callee, item]); - let lhs = b.add(NodeKind::Var, Payload::Cid(0), sp(30), &[]); - let seq = b.add(NodeKind::Seq, Payload::None, sp(31), &[]); - let assign = b.add(NodeKind::Assign, Payload::None, sp(30), &[lhs, seq]); - let root = b.add(NodeKind::Block, Payload::None, sp(19), &[call, assign]); - let mut il = b.finish( - root, - FileMeta { - path: "t.ts".into(), - lang: Lang::TypeScript, - }, - Vec::new(), - vec![xs], - ); - il.evidence.push(evidence( - 0, - EvidenceAnchor::binding(sp(30), stable_symbol_hash("xs")), - EvidenceKind::Domain(nose_il::DomainEvidence::Collection), - Vec::new(), - )); - il.evidence.push(method_call_library_api_evidence( - 1, - Lang::TypeScript, - "includes", - sp(23), - 1, - vec![EvidenceId(0)], - )); - - let facts = StrictFacts::collect(&il, &interner); + let fixture = crate::test_support::BindingDomainContainsFixture::after_receiver_use(); assert!( - !strict_exact_collection_contains_call_safe( - &il, &interner, &facts, call, callee, "includes" - ), + !fixture.is_safe(), "binding-domain evidence must be visible at the receiver use site" ); } diff --git a/crates/nose-detect/src/strict_exact/tests/support.rs b/crates/nose-detect/src/strict_exact/tests/support.rs index 34d9be30b..8476d932c 100644 --- a/crates/nose-detect/src/strict_exact/tests/support.rs +++ b/crates/nose-detect/src/strict_exact/tests/support.rs @@ -5,8 +5,7 @@ pub(super) use nose_il::{ }; use nose_normalize::{normalize, NormalizeOptions}; use nose_semantics::test_support::{ - library_api_test_evidence_with_dependencies, - method_call_library_api_test_evidence_with_dependencies, LibraryApiTestContract, + library_api_test_evidence_with_dependencies, LibraryApiTestContract, }; use nose_semantics::{ language_core_evidence_provenance, library_map_get_contract, BUILTIN_COMPAT_PACK_ID, @@ -84,24 +83,6 @@ pub(super) fn language_core_evidence( record } -pub(super) fn method_call_library_api_evidence( - id: u32, - lang: Lang, - method: &str, - call_span: Span, - arity: usize, - dependencies: Vec, -) -> EvidenceRecord { - method_call_library_api_test_evidence_with_dependencies( - id, - lang, - method, - call_span, - arity, - dependencies, - ) -} - pub(super) fn map_get_library_api_evidence( id: u32, lang: Lang, diff --git a/crates/nose-detect/src/test_support.rs b/crates/nose-detect/src/test_support.rs new file mode 100644 index 000000000..0b19eedca --- /dev/null +++ b/crates/nose-detect/src/test_support.rs @@ -0,0 +1,124 @@ +use crate::strict_exact::{strict_exact_collection_contains_call_safe, StrictFacts}; +use nose_il::{ + stable_symbol_hash, DomainEvidence, EvidenceAnchor, EvidenceId, EvidenceKind, FileId, FileMeta, + Il, IlBuilder, Interner, Lang, NodeId, NodeKind, Payload, Span, +}; +use nose_semantics::test_support::{ + compat_test_asserted_evidence, method_call_library_api_test_evidence_with_dependencies, +}; + +pub(crate) struct BindingDomainContainsFixture { + pub(crate) il: Il, + pub(crate) interner: Interner, + call: NodeId, + callee: NodeId, + binding_span: Span, +} + +impl BindingDomainContainsFixture { + pub(crate) fn before_receiver_use() -> Self { + Self::new(true) + } + + pub(crate) fn after_receiver_use() -> Self { + Self::new(false) + } + + fn new(binding_before_use: bool) -> Self { + let interner = Interner::new(); + let xs = interner.intern("xs"); + let mut builder = IlBuilder::new(FileId(0)); + let binding_span = span(30); + let lhs = builder.add(NodeKind::Var, Payload::Cid(0), binding_span, &[]); + let sequence = builder.add(NodeKind::Seq, Payload::None, span(31), &[]); + let assignment = builder.add( + NodeKind::Assign, + Payload::None, + binding_span, + &[lhs, sequence], + ); + let receiver_line = if binding_before_use { 32 } else { 20 }; + let receiver = builder.add(NodeKind::Var, Payload::Cid(0), span(receiver_line), &[]); + let callee = builder.add( + NodeKind::Field, + Payload::Name(interner.intern("includes")), + span(receiver_line + 1), + &[receiver], + ); + let item = builder.add( + NodeKind::Lit, + Payload::LitInt(7), + span(receiver_line + 2), + &[], + ); + let call = builder.add( + NodeKind::Call, + Payload::None, + span(receiver_line + 3), + &[callee, item], + ); + let children = if binding_before_use { + [assignment, call] + } else { + [call, assignment] + }; + let root_line = if binding_before_use { 29 } else { 19 }; + let root = builder.add(NodeKind::Block, Payload::None, span(root_line), &children); + let mut il = builder.finish( + root, + FileMeta { + path: "t.ts".into(), + lang: Lang::TypeScript, + }, + Vec::new(), + vec![xs], + ); + il.evidence.push(compat_test_asserted_evidence( + 0, + EvidenceAnchor::binding(binding_span, stable_symbol_hash("xs")), + EvidenceKind::Domain(DomainEvidence::Collection), + Vec::new(), + )); + il.evidence + .push(method_call_library_api_test_evidence_with_dependencies( + 1, + Lang::TypeScript, + "includes", + il.node(call).span, + 1, + vec![EvidenceId(0)], + )); + Self { + il, + interner, + call, + callee, + binding_span, + } + } + + pub(crate) fn is_safe(&self) -> bool { + let facts = StrictFacts::collect(&self.il, &self.interner); + strict_exact_collection_contains_call_safe( + &self.il, + &self.interner, + &facts, + self.call, + self.callee, + "includes", + ) + } + + pub(crate) fn add_conflicting_map_domain(&mut self) { + self.il.evidence.push(compat_test_asserted_evidence( + 2, + EvidenceAnchor::binding(self.binding_span, stable_symbol_hash("xs")), + EvidenceKind::Domain(DomainEvidence::Map), + Vec::new(), + )); + } +} + +fn span(line: u32) -> Span { + Span::new(FileId(0), line, line, line, line) +} diff --git a/crates/nose-detect/src/units/tests/strict_exact_surfaces.rs b/crates/nose-detect/src/units/tests/strict_exact_surfaces.rs index f16663b7f..0fe276557 100644 --- a/crates/nose-detect/src/units/tests/strict_exact_surfaces.rs +++ b/crates/nose-detect/src/units/tests/strict_exact_surfaces.rs @@ -1,7 +1,7 @@ use super::support::*; use crate::strict_exact::{ - function_binding_safe, strict_exact_collection_contains_call_safe, - strict_exact_membership_collection_safe, strict_exact_safe_tree, StrictFacts, + function_binding_safe, strict_exact_membership_collection_safe, strict_exact_safe_tree, + StrictFacts, }; use crate::units::fragments::call_may_mutate_blocked_cid; use nose_il::{ @@ -294,62 +294,12 @@ fn strict_exact_contains_consumes_receiver_domain_evidence() { #[test] fn strict_exact_contains_consumes_binding_domain_evidence() { - let interner = Interner::new(); - let xs = interner.intern("xs"); - let mut b = IlBuilder::new(FileId(0)); - let lhs = b.add(NodeKind::Var, Payload::Cid(0), sp(30), &[]); - let seq = b.add(NodeKind::Seq, Payload::None, sp(31), &[]); - let assign = b.add(NodeKind::Assign, Payload::None, sp(30), &[lhs, seq]); - let receiver = b.add(NodeKind::Var, Payload::Cid(0), sp(32), &[]); - let callee = b.add( - NodeKind::Field, - Payload::Name(interner.intern("includes")), - sp(33), - &[receiver], - ); - let item = b.add(NodeKind::Lit, Payload::LitInt(7), sp(34), &[]); - let call = b.add(NodeKind::Call, Payload::None, sp(35), &[callee, item]); - let root = b.add(NodeKind::Block, Payload::None, sp(29), &[assign, call]); - let mut il = b.finish( - root, - FileMeta { - path: "t.ts".into(), - lang: Lang::TypeScript, - }, - Vec::new(), - vec![xs], - ); - il.evidence.push(evidence( - 0, - EvidenceAnchor::binding(sp(30), stable_symbol_hash("xs")), - EvidenceKind::Domain(nose_semantics::DomainEvidence::Collection), - Vec::new(), - )); - il.evidence.push(method_call_library_api_evidence( - 1, - Lang::TypeScript, - "includes", - sp(35), - 1, - vec![EvidenceId(0)], - )); - - let facts = StrictFacts::collect(&il, &interner); - assert!(strict_exact_collection_contains_call_safe( - &il, &interner, &facts, call, callee, "includes" - )); + let mut fixture = crate::test_support::BindingDomainContainsFixture::before_receiver_use(); + assert!(fixture.is_safe()); - il.evidence.push(evidence( - 2, - EvidenceAnchor::binding(sp(30), stable_symbol_hash("xs")), - EvidenceKind::Domain(nose_semantics::DomainEvidence::Map), - Vec::new(), - )); - let facts = StrictFacts::collect(&il, &interner); + fixture.add_conflicting_map_domain(); assert!( - !strict_exact_collection_contains_call_safe( - &il, &interner, &facts, call, callee, "includes" - ), + !fixture.is_safe(), "conflicting binding-domain evidence must close strict exact receiver proof" ); } diff --git a/crates/nose-frontend/src/corpus.rs b/crates/nose-frontend/src/corpus.rs new file mode 100644 index 000000000..54a3ec724 --- /dev/null +++ b/crates/nose-frontend/src/corpus.rs @@ -0,0 +1,172 @@ +//! Corpus discovery, lowering, and cross-file resolution. +//! +//! Single-buffer language dispatch stays at the crate facade. This module owns +//! the file-system and parallel-orchestration boundary that turns those lowered +//! buffers into a resolved corpus. + +use crate::{ + discover_unique_paths, embedded, lower_source, module_imports, source_artifacts, + swift_cross_file_shadows, +}; +use nose_il::{Corpus, FileId, Il, Interner, Lang}; +use rayon::prelude::*; +use std::path::Path; + +/// Whether a discovered source buffer should enter the analysis corpus. Cache +/// loaders use the same generated/binary artifact gate as the uncached path so +/// an artifact never becomes analyzable merely because it was cached. +pub fn source_is_analyzable(path: &Path, lang: Lang, source: &[u8]) -> bool { + source_artifacts::skip_reason(path, lang, source).is_none() +} + +/// Lower every analyzable region of a file into separate [`Il`]s. For most languages +/// this is one `Il` (delegating to [`lower_source`]); for `