feat: minga standalone — compartición P2P soberana con montaje FUSE content-addressed (front-door, git-dep al monorepo)
Front-door limpio: solo crates del dominio; Llimphi y lo fundacional por git-dep del monorepo gioser.git. cargo check pasa (7 crates, 0 errores). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Semántico sayk'uy · 994
03_ukupacha/minga/card-discovery/src/discovery.rs
- musuq struct CardDiscovery pub
- musuq fn announce_all pub
- musuq fn find_remote pub
- musuq fn has_dht pub
- musuq fn local pub
- musuq fn local_only_discovery_has_no_dht
- musuq mod tests
- musuq fn with_dht pub
03_ukupacha/minga/card-discovery/src/index.rs
- musuq struct CardIndex pub
- musuq fn all pub
- musuq fn by_id pub
- musuq fn by_id_finds_exact
- musuq fn by_kind pub
- musuq fn by_kind_splits_ente_and_data
- musuq fn by_label pub
- musuq fn by_label_is_case_insensitive_substring
- musuq fn card
- musuq fn insert pub
- musuq fn is_empty pub
- musuq fn len pub
- musuq fn new pub
- musuq fn providing pub
- musuq fn providing_filters_by_capability
- musuq mod tests
03_ukupacha/minga/card-discovery/src/lib.rs
03_ukupacha/minga/card-discovery/src/registry.rs
- musuq fn missing_dir_is_an_error
- musuq fn scan_dir pub
- musuq fn scans_only_valid_json_cards
- musuq mod tests
03_ukupacha/minga/minga-cli/src/bundle.rs
- musuq def BUNDLE_VERSION pub
- musuq struct BundleExportAllStats pub
- musuq struct BundleExportStats pub
- musuq struct BundleImportAllStats pub
- musuq struct BundleImportStats pub
- musuq struct BundleMultiV1 pub
- musuq struct BundleV1 pub
- musuq def MULTI_MAGIC pub
- musuq def MULTI_MAGIC_ZSTD pub
- musuq def MULTI_VERSION pub
- musuq def ZSTD_LEVEL
- musuq fn build_bundle_for_root
- musuq fn cmd_bundle_export pub
- musuq fn cmd_bundle_export_all pub
- musuq fn cmd_bundle_import pub
- musuq fn cmd_bundle_import_all pub
- musuq fn import_one
- musuq fn is_multi_bundle_magic
- musuq fn roots_new pub
- musuq fn total_attestations_added pub
- musuq fn total_nodes_inserted pub
- musuq fn total_retractions_added pub
03_ukupacha/minga/minga-cli/src/commands.rs
- musuq struct BlameLine pub
- musuq struct BulkIngestStats pub
- musuq enum DiffLine pub
- musuq struct DiffResult pub
- musuq struct HistoryEntry pub
- musuq struct IngestResult pub
- musuq def KEYPAIR_FILENAME pub
- musuq struct LogEntry pub
- musuq struct PruneStats pub
- musuq def REPO_DIRNAME pub
- musuq struct RepoStatus pub
- musuq struct RetireResult pub
- musuq struct RootRow pub
- musuq struct ShowResult pub
- musuq struct SignResult pub
- musuq struct SignerEntry pub
- musuq struct VerifyResult pub
- musuq fn canonical_path_key
- musuq fn cmd_blame pub
- musuq fn cmd_diff pub
- musuq fn cmd_history pub
- musuq fn cmd_ingest pub
- musuq fn cmd_ingest_dir pub
- musuq fn cmd_init pub
- musuq fn cmd_listen pub
- musuq fn cmd_log pub
- musuq fn cmd_mount pub
- musuq fn cmd_prune pub
- musuq fn cmd_retire pub
- musuq fn cmd_roots pub
- musuq fn cmd_show pub
- musuq fn cmd_sign pub
- musuq fn cmd_signers pub
- musuq fn cmd_status pub
- musuq fn cmd_sync pub
- musuq fn cmd_verify_root pub
- musuq fn cmd_watch pub
- musuq fn detect_dialect
- musuq fn extract_peer_id
- musuq fn first_author_for
- musuq fn hex_decode_32
- musuq fn ingest_into_repo
- musuq fn ingest_node_alpha
- musuq fn initial_scan
- musuq fn is_consistent pub
- musuq fn is_hidden_dirname
- musuq fn is_supported_source
- musuq fn matches_stored pub
- musuq fn nibble
- musuq def parse_hash_hex
- musuq fn resolve_hash
- musuq fn retire_root
- musuq fn source_for_alpha
- musuq fn try_current_alpha
- musuq def unix_now_secs
- musuq fn walk_and_ingest
03_ukupacha/minga/minga-cli/src/error.rs
- musuq enum CliError pub
03_ukupacha/minga/minga-cli/src/lib.rs
03_ukupacha/minga/minga-cli/src/main.rs
- musuq enum BundleCommand
- musuq struct Cli
- musuq enum Command
- musuq fn civil_from_days
- musuq fn days_from_civil
- musuq fn format_ts
- musuq fn main
- musuq fn parse_since
- musuq fn prompt_passphrase
- musuq fn prompt_passphrase_with_confirm
- musuq fn run
- musuq fn since_err
03_ukupacha/minga/minga-cli/src/serve.rs
- musuq struct ApiError
- musuq struct AppState
- musuq struct HistoryQuery
- musuq struct ShowQuery
- musuq struct SignersQuery
- musuq fn build_router
- musuq fn build_router_for_test pub
- musuq fn build_router_for_test_with_token pub
- musuq fn cmd_serve pub
- musuq fn constant_time_eq
- musuq fn from
- musuq fn get_history
- musuq fn get_roots
- musuq fn get_show
- musuq fn get_signers
- musuq fn get_status
- musuq fn into_response
- musuq fn require_bearer
- musuq fn unauthorized
03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs
- musuq fn bundle_import_is_idempotent
- musuq fn bundle_roundtrip_carries_retractions
- musuq fn bundle_roundtrip_preserves_root_and_attestation
- musuq fn bundle_roundtrip_propagates_vouching_attestations
- musuq fn multi_bundle_import_is_idempotent
- musuq fn multi_bundle_legacy_mngm_still_imports
- musuq fn multi_bundle_rejects_single_bundle_and_viceversa
- musuq fn multi_bundle_round_trip_carries_all_roots
- musuq fn multi_bundle_zstd_roundtrip_keeps_data_and_reports_ratio
03_ukupacha/minga/minga-cli/tests/cli_smoke.rs
- musuq fn blame_attributes_lines_across_two_revisions
- musuq fn blame_errors_for_path_without_history
- musuq fn diff_detects_changes_between_versions
- musuq fn ingest_persists_across_runs
- musuq fn ingest_persists_function_with_self_attestation
- musuq fn ingest_same_file_twice_is_idempotent
- musuq fn init_creates_keypair_and_repo
- musuq fn init_refuses_existing_non_empty_directory
- musuq fn prune_removes_unreachable_nodes_after_retire
- musuq fn rename_local_var_keeps_same_alpha_hash
- musuq fn retire_removes_root_and_persists_signed_retraction
- musuq fn retire_unknown_hash_still_signs_negative_attestation
- musuq fn signers_since_filters_old_attestations
- musuq fn status_shows_empty_state_after_init
- musuq fn status_with_wrong_passphrase_errors
- musuq fn verify_root_matches_dialect_used_to_ingest
03_ukupacha/minga/minga-cli/tests/serve_http.rs
- musuq fn _quiet_populate_unused
- musuq fn http_roots_lists_ingested_alpha
- musuq fn http_show_invalid_hash_is_400
- musuq fn http_show_returns_rendered_source
- musuq fn http_show_unknown_alpha_is_404
- musuq fn http_signers_returns_local_author
- musuq fn http_signers_since_filters_in_query
- musuq fn http_status_returns_counts
- musuq fn http_with_correct_token_passes
- musuq fn http_with_wrong_token_rejects
- musuq fn http_without_token_rejects_unauthenticated
- musuq fn populate_repo
03_ukupacha/minga/minga-cli/tests/watcher.rs
- musuq fn wait_until_mst_size
- musuq fn watcher_ignores_non_rs_files
- musuq fn watcher_ingests_new_file_after_creation
- musuq fn watcher_initial_scan_picks_up_existing_files
03_ukupacha/minga/minga-core/src/alpha/common.rs
- musuq def TAG_BINDER pub
- musuq def TAG_LEAF pub
- musuq def TAG_NO_LEAF pub
- musuq def TAG_REF_BOUND pub
- musuq def TAG_REF_FREE pub
- musuq fn emit_binder_body pub
- musuq fn emit_binder_node pub
- musuq fn emit_identifier_ref pub
- musuq fn emit_leaf_marker pub
- musuq fn push_identifier_name pub
- musuq fn write_kind_and_field pub
- musuq fn write_str pub
03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs
- musuq fn collect_formal_param_binders
- musuq fn collect_var_decl_binders
- musuq fn emit_arrow_single_binder
- musuq fn emit_named_binder
- musuq fn feed
- musuq fn feed_arrow
- musuq fn feed_block
- musuq fn feed_callable
- musuq fn feed_catch
- musuq fn feed_declarator
- musuq fn feed_default
- musuq fn feed_for
- musuq fn feed_for_in
- musuq fn feed_formal_params
- musuq fn feed_rest_param
- musuq fn feed_typed_param
- musuq fn feed_var_decl
- musuq fn hash_node_alpha_ecmascript pub
03_ukupacha/minga/minga-core/src/alpha/go.rs
- musuq fn collect_parameter_list_binders
- musuq fn collect_short_var_binders
- musuq fn emit_named_binder
- musuq fn feed
- musuq fn feed_block
- musuq fn feed_callable
- musuq fn feed_default
- musuq fn feed_for_statement
- musuq fn feed_if_statement
- musuq fn feed_parameter_declaration
- musuq fn feed_parameter_list
- musuq fn feed_range_clause
- musuq fn feed_short_var_decl
- musuq fn feed_short_var_left
- musuq fn hash_node_alpha_go pub
03_ukupacha/minga/minga-core/src/alpha/mod.rs
- musuq mod common pub
- musuq mod ecmascript pub
- musuq mod go pub
- musuq fn hash_alpha_with pub
- musuq mod python pub
- musuq mod rust pub
- musuq fn verify_root_alpha pub
03_ukupacha/minga/minga-core/src/alpha/python.rs
- musuq fn collect_as_pattern_targets
- musuq fn collect_param_binders
- musuq fn collect_target_binders
- musuq fn collect_with_clause_binders
- musuq fn emit_param_binder
- musuq fn feed
- musuq fn feed_as_literal
- musuq fn feed_complex_param
- musuq fn feed_comprehension
- musuq fn feed_default
- musuq fn feed_for_in_clause
- musuq fn feed_for_statement
- musuq fn feed_function_definition
- musuq fn feed_lambda
- musuq fn feed_params
- musuq fn feed_splat_param
- musuq fn feed_target_as_binders
- musuq fn feed_with_statement
- musuq fn hash_node_alpha_python pub
03_ukupacha/minga/minga-core/src/alpha/rust.rs
- musuq fn collect_callable_binders
- musuq fn collect_field_pattern_binders
- musuq fn collect_let_condition_binders
- musuq fn collect_match_pattern_binders
- musuq fn collect_pattern_binders
- musuq fn feed
- musuq fn feed_as_literal
- musuq fn feed_block
- musuq fn feed_callable
- musuq fn feed_callable_params
- musuq fn feed_default
- musuq fn feed_field_pattern
- musuq fn feed_for
- musuq fn feed_if_expression
- musuq fn feed_let
- musuq fn feed_let_condition
- musuq fn feed_match_arm
- musuq fn feed_match_pattern_split
- musuq fn feed_parameter
- musuq fn feed_pattern
- musuq fn feed_while_expression
- musuq fn hash_node_alpha pub
- musuq fn is_binder_identifier
- musuq fn is_binder_name
03_ukupacha/minga/minga-core/src/ast.rs
- musuq struct SemanticNode pub
- musuq fn build
- musuq fn from_tree_sitter pub
03_ukupacha/minga/minga-core/src/attestation.rs
- musuq struct Attestation pub
- musuq enum AttestationError pub
- musuq struct AttestationStore pub
- musuq fn add pub
- musuq fn all pub
- musuq fn authors_of pub
- musuq fn create pub
- musuq fn fmt
- musuq fn get pub
- musuq fn is_empty pub
- musuq fn len pub
- musuq fn new pub
- musuq fn verify pub
03_ukupacha/minga/minga-core/src/cas.rs
- musuq struct ContentHash pub
- musuq fn as_bytes pub
- musuq fn fmt
- musuq fn hash_components pub
- musuq fn hash_node pub
- musuq fn write_str
03_ukupacha/minga/minga-core/src/identity.rs
- musuq def AES_NONCE_LEN
- musuq def ARGON2_SALT_LEN
- musuq struct Did pub
- musuq def KEYPAIR_HEADER_LEN
- musuq def KEYPAIR_MAGIC
- musuq def KEYPAIR_VERSION
- musuq struct Keypair pub
- musuq enum KeypairCryptoError pub
- musuq struct Signature pub
- musuq fn as_bytes pub
- musuq fn as_bytes pub
- musuq fn decrypt pub
- musuq fn derive_aes_key
- musuq fn did pub
- musuq fn encrypt pub
- musuq fn fmt
- musuq fn fmt
- musuq fn from_seed pub
- musuq fn generate pub
- musuq fn sign pub
- musuq fn verify pub
03_ukupacha/minga/minga-core/src/lib.rs
- musuq mod alpha pub
- musuq mod ast pub
- musuq mod attestation pub
- musuq mod cas pub
- musuq mod identity pub
- musuq mod mst pub
- musuq mod parse pub
- musuq mod retraction pub
- musuq mod root_decl pub
- musuq mod store pub
03_ukupacha/minga/minga-core/src/mst.rs
- musuq def H
- musuq type Item
- musuq struct Iter pub
- musuq struct Mst pub
- musuq struct MstDiff pub
- musuq struct NodeData
- musuq struct NodeProbe pub
- musuq enum Subtree
- musuq fn build_probe_index pub
- musuq fn collect_all
- musuq fn contains pub
- musuq fn contains_in
- musuq fn descend_left
- musuq fn diff pub
- musuq fn diff_subtrees
- musuq fn empty_subtree_hash pub
- musuq fn index_subtree
- musuq fn insert pub
- musuq fn insert_in
- musuq fn is_empty pub
- musuq fn is_empty pub
- musuq fn iter pub
- musuq fn len pub
- musuq fn len_of
- musuq fn level_of
- musuq fn merge_diff_sorted
- musuq fn new pub
- musuq fn next
- musuq fn root_hash pub
- musuq fn split_at
- musuq fn subtree_hash
- musuq fn total pub
03_ukupacha/minga/minga-core/src/parse.rs
- musuq enum Dialect pub
- musuq enum ParseError pub
- musuq fn as_byte pub
- musuq fn assert_parses
- musuq fn count_errors
- musuq fn detect_by_content pub
- musuq fn detect_by_extension pub
- musuq fn detect_by_shebang pub
- musuq fn detect_content_go_clean
- musuq fn detect_content_handles_empty
- musuq fn detect_content_python_clean
- musuq fn detect_content_returns_none_for_garbage
- musuq fn detect_content_rust_clean
- musuq fn detect_extension_canonical
- musuq fn detect_extension_case_insensitive
- musuq fn detect_shebang_deno_with_ext_ts
- musuq fn detect_shebang_no_match_for_bash
- musuq fn detect_shebang_node
- musuq fn detect_shebang_python_direct
- musuq fn detect_shebang_python_env
- musuq fn detect_shebang_requires_hashbang
- musuq fn dialect_name_canonical
- musuq fn from_byte pub
- musuq fn go pub
- musuq fn go_parses_basic
- musuq fn javascript pub
- musuq fn javascript_parses_basic
- musuq fn last_token
- musuq fn name pub
- musuq fn parse pub
- musuq fn parse_with
- musuq fn python pub
- musuq fn python_parses_basic
- musuq fn rust pub
- musuq fn rust_parses_basic
- musuq fn structural_hash_distinguishes_languages
- musuq mod tests
- musuq fn typescript pub
- musuq fn typescript_parses_basic
- musuq fn walk_count
03_ukupacha/minga/minga-core/src/retraction.rs
- musuq def RETRACTION_DOMAIN pub
- musuq struct Retraction pub
- musuq enum RetractionError pub
- musuq struct RetractionStore pub
- musuq fn add pub
- musuq fn all pub
- musuq fn attestation_signature_cannot_be_replayed_as_retraction
- musuq fn authors_of pub
- musuq fn create pub
- musuq fn create_then_verify_is_ok
- musuq fn fmt
- musuq fn get pub
- musuq fn is_empty pub
- musuq fn len pub
- musuq fn new pub
- musuq fn tampered_content_does_not_verify
- musuq mod tests
- musuq fn verify pub
03_ukupacha/minga/minga-core/src/root_decl.rs
03_ukupacha/minga/minga-core/src/store.rs
- musuq struct MemStore pub
- musuq trait NodeStore pub
- musuq struct StoredNode pub
- musuq fn contains
- musuq fn get
- musuq fn hash_stored pub
- musuq fn is_empty
- musuq fn iter
- musuq fn len
- musuq fn new pub
- musuq fn put
- musuq fn put_chunked
- musuq fn reconstruct
03_ukupacha/minga/minga-core/tests/alpha_invariants.rs
- musuq fn alpha_body_change_matters
- musuq fn alpha_closure_captures_outer_binding
- musuq fn alpha_closure_distinguishes_captured_vs_free
- musuq fn alpha_closure_param_rename_invariant
- musuq fn alpha_distinguishes_bound_vs_free
- musuq fn alpha_diverges_from_structural_under_rename
- musuq fn alpha_for_loop_var_rename_invariant
- musuq fn alpha_free_variable_identity_matters
- musuq fn alpha_function_name_matters
- musuq fn alpha_if_let_binder_rename_invariant
- musuq fn alpha_if_let_does_not_collide_with_unrelated_program
- musuq fn alpha_if_let_else_does_not_see_binder
- musuq fn alpha_let_chain_binders_propagate_to_consequence
- musuq fn alpha_let_else_binder_rename_invariant
- musuq fn alpha_let_rename_invariant
- musuq fn alpha_match_arms_have_independent_scope
- musuq fn alpha_match_captured_pattern_rename_invariant
- musuq fn alpha_match_captured_range_changes_hash
- musuq fn alpha_match_constructor_distinguishes_arms
- musuq fn alpha_match_constructor_vs_binder
- musuq fn alpha_match_guard_binder_rename_invariant
- musuq fn alpha_match_guard_op_distinguishes
- musuq fn alpha_match_simple_arm_rename_invariant
- musuq fn alpha_match_struct_pattern_field_name_matters
- musuq fn alpha_match_struct_pattern_rename_invariant
- musuq fn alpha_match_tuple_struct_binder_rename_invariant
- musuq fn alpha_mut_pattern_rename_invariant
- musuq fn alpha_or_pattern_binder_rename_invariant
- musuq fn alpha_param_order_matters
- musuq fn alpha_param_rename_invariant
- musuq fn alpha_param_swap_with_rename_invariant
- musuq fn alpha_shadowing_let_invariant
- musuq fn alpha_signature_type_matters
- musuq fn alpha_tuple_destructure_position_matters
- musuq fn alpha_tuple_destructure_rename_invariant
- musuq fn alpha_while_let_binder_rename_invariant
03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs
- musuq fn go_func_literal_closure_rename_invariant
- musuq fn go_function_name_matters
- musuq fn go_function_rename_invariant
- musuq fn go_if_init_rename_invariant
- musuq fn go_range_clause_rename_invariant
- musuq fn go_short_var_decl_rename_invariant
- musuq fn h
- musuq fn js_arrow_function_rename_invariant
- musuq fn js_arrow_shorthand_rename_invariant
- musuq fn js_catch_rename_invariant
- musuq fn js_for_classic_rename_invariant
- musuq fn js_for_of_rename_invariant
- musuq fn js_function_name_matters
- musuq fn js_function_rename_invariant
- musuq fn js_let_const_rename_invariant
- musuq fn python_def_function_name_matters
- musuq fn python_def_param_rename_invariant
- musuq fn python_for_iterable_name_matters
- musuq fn python_for_loop_rename_invariant
- musuq fn python_lambda_does_not_collide_with_unrelated
- musuq fn python_lambda_rename_invariant
- musuq fn python_list_comprehension_rename_invariant
- musuq fn python_nested_comprehension_rename_invariant
- musuq fn python_with_statement_rename_invariant
- musuq fn structurally_similar_programs_in_different_languages_have_distinct_hashes
- musuq fn ts_typed_param_rename_invariant
- musuq fn ts_typed_param_type_matters
03_ukupacha/minga/minga-core/tests/attestation_invariants.rs
- musuq fn authors_of_for_unknown_content_is_empty
- musuq fn ch
- musuq fn create_then_verify_succeeds
- musuq fn distinct_authors_distinct_signatures_same_content
- musuq fn kp
- musuq fn modifying_author_invalidates
- musuq fn modifying_content_invalidates
- musuq fn modifying_signature_invalidates
- musuq fn store_accepts_valid_attestation
- musuq fn store_is_idempotent_for_same_author_content
- musuq fn store_keeps_multiple_authors_per_content
- musuq fn store_rejects_invalid_signature
- musuq fn store_rejects_swapped_content
03_ukupacha/minga/minga-core/tests/cas_invariants.rs
- musuq fn body_change_breaks_hash
- musuq fn comment_invariant
- musuq fn order_matters
- musuq fn rename_breaks_hash_for_now
- musuq fn signature_change_breaks_hash
- musuq fn whitespace_invariant
03_ukupacha/minga/minga-core/tests/identity_invariants.rs
- musuq fn decrypt_rejects_invalid_format
- musuq fn decrypt_rejects_tampered_ciphertext
- musuq fn decrypt_with_wrong_passphrase_fails
- musuq fn did_display_uses_did_key_prefix
- musuq fn distinct_passphrases_produce_distinct_blobs
- musuq fn distinct_seeds_produce_distinct_dids
- musuq fn encrypt_decrypt_roundtrip_preserves_identity
- musuq fn generate_produces_unique_dids
- musuq fn keypair_debug_does_not_leak_private_key
- musuq fn keypair_from_seed_is_deterministic
- musuq fn kp
- musuq fn re_encrypting_same_keypair_produces_distinct_blobs
- musuq fn sign_verify_roundtrip
- musuq fn verify_fails_with_tampered_message
- musuq fn verify_fails_with_tampered_signature
- musuq fn verify_fails_with_wrong_did
- musuq fn verify_handles_invalid_did_bytes
03_ukupacha/minga/minga-core/tests/mst_invariants.rs
- musuq fn ch
- musuq fn mst_contains_after_many_inserts
- musuq fn mst_diff_apply_converges
- musuq fn mst_diff_disjoint_sets
- musuq fn mst_diff_history_independent
- musuq fn mst_diff_identical_is_empty
- musuq fn mst_diff_is_symmetric
- musuq fn mst_diff_one_empty_yields_other
- musuq fn mst_diff_output_is_sorted
- musuq fn mst_diff_partial_overlap
- musuq fn mst_diff_single_key_change
- musuq fn mst_empty
- musuq fn mst_history_independence
- musuq fn mst_insert_single
- musuq fn mst_iter_yields_sorted_keys
- musuq fn mst_levels_distribute_naturally
- musuq fn mst_no_duplicates_inflate_size
- musuq fn mst_root_hash_changes_with_size
- musuq fn mst_set_difference_changes_root
03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs
- musuq fn attestation_roundtrip
- musuq fn attestation_roundtrip_preserves_verify
- musuq fn content_hash_roundtrip
- musuq fn did_roundtrip
- musuq fn empty_collections_serialize_compactly
- musuq fn malformed_bytes_fail_decode
- musuq fn node_probe_roundtrip
- musuq fn roundtrip
- musuq fn signature_roundtrip
- musuq fn signature_roundtrip_preserves_verify
- musuq fn stored_node_roundtrip
- musuq fn stored_node_with_leaf_roundtrip
03_ukupacha/minga/minga-core/tests/store_invariants.rs
- musuq fn find_first_kind
- musuq fn store_dedup_shared_subtree
- musuq fn store_hash_matches_cas
- musuq fn store_idempotent_put
- musuq fn store_multiple_files_share_common_constants
- musuq fn store_round_trip_preserves_tree
- musuq fn store_subtree_resolvable_independently
- musuq fn store_unknown_hash_is_none
03_ukupacha/minga/minga-dht/src/lib.rs
03_ukupacha/minga/minga-explorer-llimphi/src/main.rs
- musuq struct Explorer
- musuq struct Model
- musuq type Model
- musuq enum Msg
- musuq type Msg
- musuq def RECENT_LIMIT
- musuq def REFRESH_INTERVAL
- musuq def REPO_DIRNAME
- musuq struct RepoSnapshot
- musuq fn app_menu
- musuq fn apply_lang_from_wawa
- musuq fn empty_message
- musuq fn handle_menu_command
- musuq fn init
- musuq fn initial_size
- musuq fn load_snapshot
- musuq fn load_snapshot_errors_on_missing_dir
- musuq fn main
- musuq fn menubar_spec
- musuq fn on_key
- musuq fn short_hash
- musuq fn short_hash_handles_empty_or_shorter
- musuq fn short_hash_takes_first_12_chars
- musuq fn snapshot_default_is_zeros_and_empty_lists
- musuq mod tests
- musuq fn theme_from_wawa
- musuq fn title
- musuq fn update
- musuq fn view
- musuq fn view_overlay
- musuq fn viewport_of
03_ukupacha/minga/minga-p2p/src/async_driver.rs
- musuq enum AsyncSyncError pub
- musuq def MAX_FRAME_SIZE
- musuq fn recv_frame
- musuq fn run_sync_async pub
- musuq fn send_frame
03_ukupacha/minga/minga-p2p/src/harness.rs
03_ukupacha/minga/minga-p2p/src/lib.rs
03_ukupacha/minga/minga-p2p/src/message.rs
03_ukupacha/minga/minga-p2p/src/network.rs
- musuq def SYNC_PROTOCOL pub
03_ukupacha/minga/minga-p2p/src/peer.rs
- musuq struct MingaPeer pub
- musuq enum PeerOpenError pub
- musuq struct PeerState
- musuq enum PeerSyncError pub
- musuq fn add_dht_peer pub
- musuq fn announce_all_roots pub
- musuq fn announce_provider pub
- musuq fn brahman_net pub
- musuq fn dial pub
- musuq fn find_closest_peers pub
- musuq fn find_providers pub
- musuq fn flush pub
- musuq fn handle_incoming
- musuq fn ingest pub
- musuq fn ingest_attestation pub
- musuq fn ingest_with_dialect pub
- musuq fn listen pub
- musuq fn merge_back
- musuq fn merge_into_state
- musuq fn new pub
- musuq fn open pub
- musuq fn open_with_node pub
- musuq fn peer_id pub
- musuq fn run_passive_accept pub
- musuq fn snapshot pub
- musuq fn snapshot_session
- musuq fn sync_with pub
03_ukupacha/minga/minga-p2p/src/session.rs
- musuq struct SyncSession pub
- musuq fn attestations pub
- musuq fn handle pub
- musuq def hello_payload
- musuq fn into_parts pub
- musuq fn into_parts_with_retractions pub
- musuq fn into_parts_with_roots pub
- musuq fn is_done pub
- musuq fn local_did pub
- musuq fn maybe_done
- musuq fn mst pub
- musuq fn new pub
- musuq fn peer_did pub
- musuq fn process_probe
- musuq fn received_hello pub
- musuq fn rejected_attests pub
- musuq fn rejected_delivers pub
- musuq fn rejected_hellos pub
- musuq fn rejected_retracts pub
- musuq fn rejected_root_decls pub
- musuq fn retractions pub
- musuq fn self_nonce pub
- musuq fn should_probe
- musuq fn start pub
- musuq fn store pub
- musuq fn take_verified_root_decls pub
- musuq fn with_retractions pub
- musuq fn with_roots pub
- musuq fn without_attestations pub
03_ukupacha/minga/minga-p2p/tests/async_driver_invariants.rs
- musuq fn async_sync_disjoint_sets_merge
- musuq fn async_sync_identical_repos
- musuq fn async_sync_one_empty_pulls_everything
- musuq fn async_sync_propagates_attestations
- musuq fn async_sync_propagates_authenticated_identity
- musuq fn build_repo
- musuq fn kp
03_ukupacha/minga/minga-p2p/tests/kad_discovery.rs
- musuq fn identify_auto_populates_kad_routing_table
- musuq fn kad_discovery_then_sync
- musuq fn kad_three_node_discovery_via_rendezvous
- musuq fn kad_two_node_basic_discovery
- musuq fn singleton
03_ukupacha/minga/minga-p2p/tests/kad_providers.rs
- musuq fn kp
- musuq fn minga_peer_announce_all_roots_publishes_each_alpha
- musuq fn minga_peer_ingest_auto_announces_provider
- musuq fn provider_announce_and_lookup_two_nodes
- musuq fn provider_lookup_returns_empty_for_unknown_content
03_ukupacha/minga/minga-p2p/tests/libp2p_integration.rs
- musuq fn build_repo
- musuq fn kp
- musuq fn libp2p_sync_two_peers_over_tcp
- musuq fn libp2p_sync_with_attestations
03_ukupacha/minga/minga-p2p/tests/passive_listener.rs
- musuq fn kp
- musuq fn passive_listener_propagates_attestations
- musuq fn passive_listener_serves_two_consecutive_peers
- musuq fn singleton_repo
- musuq fn sync_with_retry
03_ukupacha/minga/minga-p2p/tests/persistent_peer.rs
- musuq fn _session_marker
- musuq fn ingest_attestation_persists_across_restart
- musuq fn ingest_multiple_authors_for_same_content_persist
- musuq fn ingest_persists_across_restart
- musuq fn kp
- musuq fn open_creates_empty_repo_at_new_path
- musuq fn root_hash_stable_across_restart
- musuq fn sync_into_persistent_peer_survives_restart
03_ukupacha/minga/minga-p2p/tests/sync_invariants.rs
- musuq fn _signature_marker
- musuq fn build_repo
- musuq fn build_repo_with_attests
- musuq fn cas_hash_node_equals_hash_stored
- musuq fn forged_retraction_signature_is_rejected
- musuq fn hello_payload
- musuq fn kp
- musuq fn sync_accepts_well_formed_deliver
- musuq fn sync_attest_push_before_hello_is_rejected
- musuq fn sync_attest_push_count_in_stats
- musuq fn sync_attestations_about_remote_content
- musuq fn sync_attestations_are_idempotent_across_runs
- musuq fn sync_attestations_are_verified_at_receiver
- musuq fn sync_both_empty_terminates
- musuq fn sync_captures_peer_did_after_valid_hello
- musuq fn sync_disjoint_sets_merge
- musuq fn sync_idempotent_after_convergence
- musuq fn sync_identical_is_noop
- musuq fn sync_merges_multiple_authors_for_shared_content
- musuq fn sync_one_empty_pulls_everything
- musuq fn sync_partial_overlap_converges
- musuq fn sync_proceeds_after_valid_hello_following_rejection
- musuq fn sync_propagates_attestations_for_owned_content
- musuq fn sync_propagates_retractions_for_owned_content
- musuq fn sync_propagates_verified_root_declarations
- musuq fn sync_rejects_hello_signed_over_different_root
- musuq fn sync_rejects_hello_with_swapped_did
- musuq fn sync_rejects_hello_with_tampered_signature
- musuq fn sync_rejects_replay_of_hello_from_different_session
- musuq fn sync_rejects_root_decl_before_hello
- musuq fn sync_rejects_root_decl_with_forged_alpha
- musuq fn sync_rejects_root_decl_with_unknown_dialect_byte
- musuq fn sync_rejects_tampered_deliver
- musuq fn sync_subtree_short_circuit_skips_shared_branches
- musuq fn sync_three_way_via_pairwise_runs
- musuq fn sync_transitive_children_pulled
03_ukupacha/minga/minga-p2p/tests/wire_roundtrips.rs
- musuq fn attest_push_roundtrip
- musuq fn deliver_roundtrip
- musuq fn done_roundtrip
- musuq fn empty_bytes_decode_to_error
- musuq fn fetch_roundtrip
- musuq fn hello_roundtrip
- musuq fn kp
- musuq fn malformed_bytes_decode_to_error
- musuq fn message_decode_after_encode_preserves_signatures
- musuq fn probe_req_roundtrip
- musuq fn probe_res_empty_roundtrip
- musuq fn probe_res_with_probe_roundtrip
- musuq fn roundtrip
03_ukupacha/minga/minga-store/src/alpha_paths_store.rs
- musuq struct SledAlphaPathsStore pub
- musuq fn compose_key
- musuq fn flush pub
- musuq fn is_empty pub
- musuq fn len pub
- musuq fn most_recent_path pub
- musuq fn most_recent_returns_latest_ts
- musuq fn open_temp_db
- musuq fn open_tree pub
- musuq fn paths_for pub
- musuq fn record pub
- musuq fn record_and_query_single_alpha
- musuq fn record_does_not_regress_timestamp
- musuq fn scan_does_not_leak_across_alphas
- musuq mod tests
03_ukupacha/minga/minga-store/src/attestation_store.rs
- musuq struct SledAttestationStore pub
- musuq fn add pub
- musuq fn authors_of pub
- musuq fn compose_key
- musuq fn flush pub
- musuq fn get pub
- musuq fn is_empty pub
- musuq fn iter pub
- musuq fn len pub
- musuq fn open_tree pub
03_ukupacha/minga/minga-store/src/error.rs
- musuq enum StoreError pub
03_ukupacha/minga/minga-store/src/keypair_file.rs
- musuq enum KeypairFileError pub
- musuq fn load pub
- musuq fn save pub
03_ukupacha/minga/minga-store/src/lib.rs
- musuq mod alpha_paths_store pub
- musuq mod attestation_store pub
- musuq mod error pub
- musuq mod keypair_file pub
- musuq mod mst_store pub
- musuq mod node_store pub
- musuq mod path_history_store pub
- musuq mod repo pub
- musuq mod retraction_store pub
- musuq mod roots_store pub
- musuq mod timestamp_store pub
03_ukupacha/minga/minga-store/src/mst_store.rs
- musuq struct SledMstStore pub
- musuq fn contains pub
- musuq fn flush pub
- musuq fn insert pub
- musuq fn is_empty pub
- musuq fn iter pub
- musuq fn len pub
- musuq fn open_tree pub
- musuq fn remove pub
- musuq fn to_in_memory pub
03_ukupacha/minga/minga-store/src/node_store.rs
- musuq struct SledNodeStore pub
- musuq fn children_of pub
- musuq fn contains pub
- musuq fn flush pub
- musuq fn get pub
- musuq fn is_empty pub
- musuq fn iter pub
- musuq fn iter_hashes pub
- musuq fn len pub
- musuq fn open_tree pub
- musuq fn put pub
- musuq fn put_chunked pub
- musuq fn reconstruct pub
- musuq fn remove pub
03_ukupacha/minga/minga-store/src/path_history_store.rs
- musuq struct SledPathHistoryStore pub
- musuq fn append pub
- musuq fn append_and_read_history
- musuq fn append_skips_duplicate_tail
- musuq fn flush pub
- musuq fn history pub
- musuq fn history_empty_for_unknown_path
- musuq fn is_empty pub
- musuq fn iter pub
- musuq fn len pub
- musuq fn open_temp_db
- musuq fn open_tree pub
- musuq mod tests
03_ukupacha/minga/minga-store/src/repo.rs
- musuq struct PersistentRepo pub
- musuq fn flush pub
- musuq fn open pub
03_ukupacha/minga/minga-store/src/retraction_store.rs
- musuq struct SledRetractionStore pub
- musuq fn add pub
- musuq fn compose_key
- musuq fn contains pub
- musuq fn flush pub
- musuq fn get pub
- musuq fn is_empty pub
- musuq fn iter pub
- musuq fn len pub
- musuq fn open_tree pub
03_ukupacha/minga/minga-store/src/roots_store.rs
03_ukupacha/minga/minga-store/src/timestamp_store.rs
- musuq struct SledTimestampStore pub
- musuq fn compose_key
- musuq fn flush pub
- musuq fn get pub
- musuq fn open_tree pub
- musuq fn put pub
03_ukupacha/minga/minga-store/tests/alpha_paths_rebuild.rs
03_ukupacha/minga/minga-store/tests/attestation_store_persistence.rs
- musuq fn add_then_get_roundtrips
- musuq fn ch
- musuq fn data_persists_across_reopen
- musuq fn idempotent_per_author_and_content
- musuq fn invalid_signature_rejected
- musuq fn kp
- musuq fn multiple_authors_per_content
- musuq fn open_store
- musuq fn unknown_content_returns_empty
03_ukupacha/minga/minga-store/tests/keypair_file_persistence.rs
- musuq fn file_size_is_compact
- musuq fn load_missing_file_errors
- musuq fn load_with_wrong_passphrase_errors
- musuq fn save_overwrites_existing
- musuq fn save_then_load_preserves_identity
03_ukupacha/minga/minga-store/tests/mst_store_persistence.rs
- musuq fn ch
- musuq fn data_persists_across_reopen
- musuq fn insert_and_contains
- musuq fn iter_returns_sorted_keys
- musuq fn open_store
- musuq fn order_independent_persistence
- musuq fn root_hash_matches_in_memory_mst
03_ukupacha/minga/minga-store/tests/node_store_persistence.rs
- musuq fn data_persists_across_reopen
- musuq fn open_store
- musuq fn put_chunked_accepts_correct_hash
- musuq fn put_chunked_rejects_hash_mismatch
- musuq fn put_hash_matches_cas
- musuq fn put_is_idempotent
- musuq fn round_trip_preserves_tree
- musuq fn shared_subtrees_dedup
- musuq fn unknown_hash_returns_none
03_ukupacha/minga/minga-store/tests/repo_integration.rs
03_ukupacha/minga/minga-vfs/src/fs.rs
- musuq enum Dir
- musuq def INO_CAS_DIR
- musuq def INO_DYNAMIC_BASE
- musuq def INO_README
- musuq def INO_ROOT
- musuq def INO_ROOTS_DIR
- musuq struct MingaFs pub
- musuq def README
- musuq def TTL
- musuq fn dir_attr
- musuq fn file_attr
- musuq fn getattr
- musuq fn hex_val
- musuq fn intern_ino
- musuq fn lookup
- musuq fn new pub
- musuq fn parse_hash
- musuq fn parse_hash_accepts_64_lowercase_hex
- musuq fn parse_hash_rejects_bad_length_and_chars
- musuq fn parse_hash_roundtrips_display
- musuq fn read
- musuq fn readdir
- musuq fn resolve
- musuq fn statfs
- musuq mod tests
03_ukupacha/minga/minga-vfs/src/lib.rs
- musuq mod fs
- musuq fn mount pub
- musuq fn mount_options
- musuq mod render pub
- musuq mod source pub
- musuq fn spawn_mount pub
03_ukupacha/minga/minga-vfs/src/render.rs
- musuq fn branch
- musuq fn collect_leaves
- musuq fn escape
- musuq fn flush_py_header
- musuq fn is_py_compound
- musuq fn join_py_tokens
- musuq fn leaf
- musuq fn needs_space
- musuq fn no_space_after
- musuq fn no_space_before
- musuq fn pretty_print
- musuq fn push_indent
- musuq fn python_class_with_method
- musuq fn python_function_indents_body
- musuq fn python_if_else_keeps_branches_at_same_level
- musuq fn render_py_block
- musuq fn render_py_block_children
- musuq fn render_py_compound
- musuq fn render_python
- musuq fn render_sexp pub
- musuq fn render_source pub
- musuq fn sexp_escapes_special_chars
- musuq fn sexp_node
- musuq fn sexp_shows_kinds_fields_and_leaves
- musuq fn source_indents_on_braces
- musuq mod tests
03_ukupacha/minga/minga-vfs/src/source.rs
- musuq struct MemSource pub
- musuq trait NodeSource pub
- musuq struct RepoSource pub
- musuq fn add_root pub
- musuq fn add_root_is_idempotent
- musuq fn get
- musuq fn get
- musuq fn leaf
- musuq fn mem_source_reconstructs_what_it_stored
- musuq fn new pub
- musuq fn new pub
- musuq fn reconstruct pub
- musuq fn roots
- musuq fn roots
- musuq mod tests
- musuq fn unknown_hash_reconstructs_to_none
03_ukupacha/minga/minga-vfs/tests/projection.rs
111 qillqakuna · +25926 −0
- .gitignore +3 −0
- 03_ukupacha/minga/ARQUITECTURA.md +130 −0
- 03_ukupacha/minga/LEEME.md +37 −0
- 03_ukupacha/minga/README.md +39 −0
- 03_ukupacha/minga/README.qu.md +26 −0
- 03_ukupacha/minga/REPORTE.md +221 −0
- 03_ukupacha/minga/card-discovery/Cargo.toml +21 −0
- 03_ukupacha/minga/card-discovery/src/discovery.rs +64 −0
- 03_ukupacha/minga/card-discovery/src/index.rs +116 −0
- 03_ukupacha/minga/card-discovery/src/lib.rs +19 −0
- 03_ukupacha/minga/card-discovery/src/registry.rs +46 −0
- 03_ukupacha/minga/minga-cli/Cargo.toml +35 −0
- 03_ukupacha/minga/minga-cli/LEEME.md +17 −0
- 03_ukupacha/minga/minga-cli/README.md +17 −0
- 03_ukupacha/minga/minga-cli/src/bundle.rs +455 −0
- 03_ukupacha/minga/minga-cli/src/commands.rs +1354 −0
- 03_ukupacha/minga/minga-cli/src/error.rs +109 −0
- 03_ukupacha/minga/minga-cli/src/lib.rs +26 −0
- 03_ukupacha/minga/minga-cli/src/main.rs +713 −0
- 03_ukupacha/minga/minga-cli/src/serve.rs +291 −0
- 03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs +330 −0
- 03_ukupacha/minga/minga-cli/tests/cli_smoke.rs +356 −0
- 03_ukupacha/minga/minga-cli/tests/serve_http.rs +256 −0
- 03_ukupacha/minga/minga-cli/tests/watcher.rs +129 −0
- 03_ukupacha/minga/minga-core/Cargo.toml +27 −0
- 03_ukupacha/minga/minga-core/LEEME.md +9 −0
- 03_ukupacha/minga/minga-core/README.md +9 −0
- 03_ukupacha/minga/minga-core/src/alpha/common.rs +105 −0
- 03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs +365 −0
- 03_ukupacha/minga/minga-core/src/alpha/go.rs +283 −0
- 03_ukupacha/minga/minga-core/src/alpha/mod.rs +69 −0
- 03_ukupacha/minga/minga-core/src/alpha/python.rs +387 −0
- 03_ukupacha/minga/minga-core/src/alpha/rust.rs +562 −0
- 03_ukupacha/minga/minga-core/src/ast.rs +52 −0
- 03_ukupacha/minga/minga-core/src/attestation.rs +127 −0
- 03_ukupacha/minga/minga-core/src/cas.rs +95 −0
- 03_ukupacha/minga/minga-core/src/identity.rs +223 −0
- 03_ukupacha/minga/minga-core/src/lib.rs +27 −0
- 03_ukupacha/minga/minga-core/src/mst.rs +457 −0
- 03_ukupacha/minga/minga-core/src/parse.rs +523 −0
- 03_ukupacha/minga/minga-core/src/retraction.rs +176 −0
- 03_ukupacha/minga/minga-core/src/root_decl.rs +45 −0
- 03_ukupacha/minga/minga-core/src/store.rs +144 −0
- 03_ukupacha/minga/minga-core/tests/alpha_invariants.rs +367 −0
- 03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs +307 −0
- 03_ukupacha/minga/minga-core/tests/attestation_invariants.rs +136 −0
- 03_ukupacha/minga/minga-core/tests/cas_invariants.rs +64 −0
- 03_ukupacha/minga/minga-core/tests/identity_invariants.rs +172 −0
- 03_ukupacha/minga/minga-core/tests/mst_invariants.rs +347 −0
- 03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs +134 −0
- 03_ukupacha/minga/minga-core/tests/store_invariants.rs +129 −0
- 03_ukupacha/minga/minga-dht/Cargo.toml +16 −0
- 03_ukupacha/minga/minga-dht/LEEME.md +9 −0
- 03_ukupacha/minga/minga-dht/README.md +9 −0
- 03_ukupacha/minga/minga-dht/src/key.rs +8 −0
- 03_ukupacha/minga/minga-dht/src/lib.rs +72 −0
- 03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml +26 −0
- 03_ukupacha/minga/minga-explorer-llimphi/LEEME.md +16 −0
- 03_ukupacha/minga/minga-explorer-llimphi/README.md +16 −0
- 03_ukupacha/minga/minga-explorer-llimphi/src/main.rs +653 −0
- 03_ukupacha/minga/minga-p2p/Cargo.toml +26 −0
- 03_ukupacha/minga/minga-p2p/LEEME.md +10 −0
- 03_ukupacha/minga/minga-p2p/README.md +10 −0
- 03_ukupacha/minga/minga-p2p/src/async_driver.rs +100 −0
- 03_ukupacha/minga/minga-p2p/src/harness.rs +95 −0
- 03_ukupacha/minga/minga-p2p/src/lib.rs +26 −0
- 03_ukupacha/minga/minga-p2p/src/message.rs +116 −0
- 03_ukupacha/minga/minga-p2p/src/network.rs +22 −0
- 03_ukupacha/minga/minga-p2p/src/peer.rs +458 −0
- 03_ukupacha/minga/minga-p2p/src/session.rs +687 −0
- 03_ukupacha/minga/minga-p2p/tests/async_driver_invariants.rs +161 −0
- 03_ukupacha/minga/minga-p2p/tests/kad_discovery.rs +189 −0
- 03_ukupacha/minga/minga-p2p/tests/kad_providers.rs +139 −0
- 03_ukupacha/minga/minga-p2p/tests/libp2p_integration.rs +161 −0
- 03_ukupacha/minga/minga-p2p/tests/passive_listener.rs +128 −0
- 03_ukupacha/minga/minga-p2p/tests/persistent_peer.rs +239 −0
- 03_ukupacha/minga/minga-p2p/tests/sync_invariants.rs +1072 −0
- 03_ukupacha/minga/minga-p2p/tests/wire_roundtrips.rs +128 −0
- 03_ukupacha/minga/minga-store/Cargo.toml +18 −0
- 03_ukupacha/minga/minga-store/LEEME.md +9 −0
- 03_ukupacha/minga/minga-store/README.md +9 −0
- 03_ukupacha/minga/minga-store/src/alpha_paths_store.rs +166 −0
- 03_ukupacha/minga/minga-store/src/attestation_store.rs +83 −0
- 03_ukupacha/minga/minga-store/src/error.rs +19 −0
- 03_ukupacha/minga/minga-store/src/keypair_file.rs +41 −0
- 03_ukupacha/minga/minga-store/src/lib.rs +46 −0
- 03_ukupacha/minga/minga-store/src/mst_store.rs +81 −0
- 03_ukupacha/minga/minga-store/src/node_store.rs +171 −0
- 03_ukupacha/minga/minga-store/src/path_history_store.rs +136 −0
- 03_ukupacha/minga/minga-store/src/repo.rs +86 −0
- 03_ukupacha/minga/minga-store/src/retraction_store.rs +79 −0
- 03_ukupacha/minga/minga-store/src/roots_store.rs +105 −0
- 03_ukupacha/minga/minga-store/src/timestamp_store.rs +64 −0
- 03_ukupacha/minga/minga-store/tests/alpha_paths_rebuild.rs +45 −0
- 03_ukupacha/minga/minga-store/tests/attestation_store_persistence.rs +113 −0
- 03_ukupacha/minga/minga-store/tests/keypair_file_persistence.rs +67 −0
- 03_ukupacha/minga/minga-store/tests/mst_store_persistence.rs +119 −0
- 03_ukupacha/minga/minga-store/tests/node_store_persistence.rs +145 −0
- 03_ukupacha/minga/minga-store/tests/repo_integration.rs +104 −0
- 03_ukupacha/minga/minga-vfs/Cargo.toml +16 −0
- 03_ukupacha/minga/minga-vfs/LEEME.md +9 −0
- 03_ukupacha/minga/minga-vfs/README.md +9 −0
- 03_ukupacha/minga/minga-vfs/src/fs.rs +346 −0
- 03_ukupacha/minga/minga-vfs/src/lib.rs +81 −0
- 03_ukupacha/minga/minga-vfs/src/render.rs +427 −0
- 03_ukupacha/minga/minga-vfs/src/source.rs +164 −0
- 03_ukupacha/minga/minga-vfs/tests/projection.rs +77 −0
- Cargo.lock +7873 −0
- Cargo.toml +443 −0
- LICENSE +21 −0
- README.md +11 −0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b7141ea
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+/target
+**/*.rs.bk
+*.pdb
diff --git a/03_ukupacha/minga/ARQUITECTURA.md b/03_ukupacha/minga/ARQUITECTURA.md
new file mode 100644
index 0000000..5780a50
--- /dev/null
+++ b/03_ukupacha/minga/ARQUITECTURA.md
@@ -0,0 +1,130 @@
+# ARQUITECTURA.md — minga
+
+> Descripción técnico-arquitectónica densa, optimizada para consumo por IA.
+> Snapshot: 2026-05-30. Fuente autoritativa cuando difiera con la prosa de los READMEs.
+
+```yaml
+DOMINIO: minga
+CUADRANTE: 03_ukupacha (RAÍZ)
+NOMBRE: quechua "trabajo comunitario voluntario"
+TESIS: VFS P2P content-addressed que versiona CÓDIGO COMO AST (no texto); la verdad es el BLAKE3 del contenido, no el path
+PARADIGMA: VCS semántico distribuido + DHT + FUSE; sin servidor, sin autoridad; latencia comunitaria/doméstica, no CDN global
+TAMAÑO: ~10 KLoC, 8 crates
+ESTADO: octavo sprint cerrado (2026-05-29) — backlog completo salvo 1 item trigger-driven
+```
+
+## Idea-fuerza
+
+```
+IDENTIDAD = ESTRUCTURA, NO PATH:
+ Cada archivo se parsea a SemanticNode (AST normalizado) y se direcciona por BLAKE3 de su estructura lógica.
+ α-hashing per-lenguaje: dos archivos con misma estructura bajo renombrado de variables ligadas ⇒ mismo α-hash.
+ => versionar SIGNIFICADO, no líneas. Renombrar una variable local no cambia la raíz α.
+
+VERDAD CRIPTOGRÁFICA:
+ Un peer NUNCA puede colar contenido falso bajo un hash legítimo: hash_components() es función pura,
+ el receptor re-verifica hash_stored(stored)==hash antes de insertar. Sin confianza en el peer.
+```
+
+## Crates
+
+```
+minga-core (3640) PURO sin IO: SemanticNode(AST) · ContentHash(BLAKE3) · Mst(Merkle Search Tree) ·
+ Attestation/Retraction(Ed25519) · Did · α-hashing per-dialect (Rust/Py/JS/TS/Go/…)
+minga-store (1077) sled 8-tree write-through: nodes(CAS) · attestations · mst · roots(α→struct+dialect) ·
+ path_history · alpha_paths · retractions · timestamps. "Same shape" que wawa-fs.
+minga-dht (223) discovery typed sobre Kademlia compartida: DhtKey = [kind_tag(1)]++[blake3(32)];
+ RecordKind{Code,Card,Persona,Service} evita colisión de namespaces en la MISMA DHT.
+minga-p2p (1504) MingaPeer (API alto nivel) + SyncSession (máquina de estados) + protocolo /minga/sync/1.0.0.
+minga-vfs (1018) VFS distribuido vía FUSE: path→DHT→lazy chunk fetch; cooperative fetch de peer cercano.
+minga-cli (2948) init/ingest/log/show/blame/history/roots/sign/verify/retire/prune/diff/watch/sync/mount/bundle/serve.
+minga-explorer-llimphi (439) dashboard: peers · content local · tráfico; watcher reactivo a wawa-config.
+card-discovery (245) widget de descubrimiento de Cards Brahman; consumido por nahual-shell y agora-app. ← NEXO BRAHMAN
+```
+
+## Transporte: `BrahmanNet` (re-export de `shared/card/card-net`)
+
+```
+STACK libp2p: TCP + Noise + Yamux + Kademlia(MemoryStore, modo Server) + identify + stream::Behaviour.
+UN SOLO NODO, MÚLTIPLES PROTOCOLOS sobre el mismo PeerId:
+ /brahman/handshake/1.0.0 (identidad remota — card-handshake)
+ /minga/sync/1.0.0 (sync de código — minga)
+ /agora/gossip/1.0.0 (web-of-trust — agora)
+CONVERGENCIA: MingaPeer::open_with_node(Arc<LibP2pNode>) y AgoraNet::sharing(net) ADOPTAN el mismo nodo.
+ La convergencia es a nivel de TRANSPORTE, no de protocolo wire. Demo: agora-net-brahman/examples/convergencia_minga.rs
+```
+
+## Protocolo de sync `/minga/sync/1.0.0` (anti-entropy simétrico request/response, NO gossip)
+
+```
+1. Challenge{nonce}×2 anti-replay, ambos peers
+2. Hello{did, root_subtree_hash, sig} sig sobre (peer_nonce||my_nonce||did||root); si roots iguales ⇒ done
+3. ProbeReq{subtree_hash} / ProbeRes{NodeProbe{level,keys,child_hashes}} descenso recursivo del MST, poda ramas idénticas
+4. Fetch{hash} / Deliver{hash, StoredNode} receptor VERIFICA hash_stored==hash antes de insertar (anti-malicia)
+5. AttestPush{Vec<Attestation>} propaga autoría tras Hello autenticado; cada firma Ed25519 verificada
+6. RetractPush{Vec<Retraction>} tombstones firmados con RETRACTION_DOMAIN (anti-replay)
+7. RootDeclaration{Vec<RootDecl{α,struct_hash,dialect}>} receptor RE-VERIFICA verify_root_alpha(node,α) contra contenido recibido
+8. Done×2 sesión termina cuando ambos Done cruzan
+CODIFICACIÓN: postcard. ROL: simétrico (sync_with activo / run_passive_accept pasivo).
+```
+
+## INVARIANTES
+
+```
+M-INV-1 Deliver se inserta sólo si hash_stored(stored)==hash. Contenido falso bajo hash legítimo = imposible.
+M-INV-2 RootDeclaration se acepta sólo si verify_root_alpha(reconstruido, α) coincide. α-mappings maliciosos = rechazados.
+M-INV-3 Atestaciones/Retracciones: firma Ed25519 verificada independientemente con la pubkey del Did.
+M-INV-4 α-hash invariante bajo renombrado de variables ligadas (per-dialect). Convergencia estructural, no textual.
+M-INV-5 write-through best-effort: si el IO a sled falla, la RAM sigue autoritativa; el próximo sync repopula.
+```
+
+## NAT / discovery — qué hay y qué falta
+
+```
+HAY: TCP+Noise (auth en transporte) · Kademlia DHT · identify (auto-inyecta listen-addrs) · stream multiplexado.
+ bootstrap automático en listen (sprint #4).
+FALTA: UPnP/IGD · hole-punching · relay nodes · mDNS local.
+IMPLICACIÓN: hoy peers en misma LAN o con multiaddr explícito (add_dht_peer). Diseñado para latencia doméstica, no CDN global.
+```
+
+## Relaciones inter-dominio
+
+```
+agora : COMPARTE el nodo BrahmanNet — un PeerId, una DHT, dos protocolos (sync + gossip). Convergencia de transporte.
+wawa : MISMO content-addressing BLAKE3; minga-store "same shape" que wawa-fs; chunks en $XDG_DATA_HOME/minga/chunks/.
+card-* : card-discovery (dentro de minga) consume el broker Brahman; card-net es la base de transporte compartida.
+ayni : ayni-minga usa card-net/BrahmanNet como impl del trait Transporte (chat P2P soberano).
+chasqui : sin vínculo de código directo; el broker chasqui es local (Unix socket), minga es la capa P2P real.
+akasha : potencial indexación semántica de SemanticNode — no integrado.
+```
+
+## Estado vs aspiración
+
+```
+HECHO (8 sprints, 2026-05-29): ingest/sync/FUSE/CLI completo · α-hashing multi-dialect · blame/history/roots ·
+ sign(vouching colaborativo) · retire(tombstone) · bundle v1+multi(zstd, magic MNGM/MNGZ) · serve(HTTP+Bearer auth) ·
+ RootDeclaration con re-verificación α · DHT bootstrap automático · convergencia transporte con agora.
+
+PENDIENTE ÚNICO (trigger-driven):
+ #5/A MingaPeer genérico sobre NodeStore (backend sled directo sin cargar todo a RAM).
+ Bloqueado por costo de refactor (NodeStore::get→owned, cascada en SyncSession/tests).
+ Trigger: primer repo real >100k nodos. Hoy sin caso ⇒ diferido con criterio.
+
+ASPIRA_A (especulativo, no comprometido):
+ NAT traversal global (UPnP/hole-punching/relay) · mDNS local · más dialects (C++, etc.) ·
+ CRDT colaborativo (Yrs) para edición real-time (diseño aprobado, no implementado) ·
+ búsqueda semántica vía akasha · reputación de provisión vía ayni.
+
+NORTE_ARQUITECTÓNICO:
+ minga es la CAPA P2P REAL de la suite (la única con libp2p vivo, DHT, FUSE y verificación criptográfica end-to-end).
+ Versiona significado, no texto. Su destino es ser el sustrato de almacenamiento/sync distribuido SOBRE EL QUE corren
+ agora (confianza), ayni (chat) y eventualmente wawa (releases) — todos compartiendo el mismo nodo BrahmanNet.
+```
+
+---
+
+**Síntesis de una línea para otra IA:** minga es un VCS semántico P2P content-addressed que parsea código a AST
+normalizado y lo direcciona por BLAKE3 estructural (con α-hashing invariante bajo renombrado), sincronizado entre peers
+por un protocolo anti-entropy simétrico de 8 mensajes con verificación criptográfica de cada nodo, montable como FUSE y
+descubrible por una Kademlia DHT typed — y es la **única capa P2P realmente viva** de la suite (libp2p `BrahmanNet`
+compartido con agora y ayni), cuyo norte es ser el sustrato de almacenamiento distribuido de todo el ecosistema.
diff --git a/03_ukupacha/minga/LEEME.md b/03_ukupacha/minga/LEEME.md
new file mode 100644
index 0000000..9096d41
--- /dev/null
+++ b/03_ukupacha/minga/LEEME.md
@@ -0,0 +1,37 @@
+# minga
+
+> `minga` (quechua: *trabajo comunitario voluntario*). Colaboración entre nodos.
+
+Red de pares para el monorepo. DHT + P2P + VFS distribuido. Cualquier nodo puede aportar storage o cómputo a la red de `minga`; los protocolos garantizan que la identidad de los bytes (BLAKE3) es la verdad, no el path. Compatible con la ingesta de `wawa` y con el gossip de `agora`.
+
+## Instalación
+
+```sh
+# nodo standalone
+cargo run --release -p minga-cli
+
+# explorer (ver peers + content)
+cargo run --release -p minga-explorer-llimphi
+```
+
+## Compatibilidad
+
+- **Linux / macOS / Windows / Wawa** — Rust nativo + tokio para I/O.
+
+## Crates
+
+| Crate | Rol |
+|---|---|
+| [`minga-core`](minga-core/README.md) | Modelo: peer, chunk, address. |
+| [`minga-dht`](minga-dht/README.md) | DHT (Kademlia adaptado). |
+| [`minga-p2p`](minga-p2p/README.md) | Capa P2P (libp2p o propio). |
+| [`minga-vfs`](minga-vfs/README.md) | VFS distribuido. |
+| [`minga-store`](minga-store/README.md) | Storage local. |
+| [`minga-cli`](minga-cli/README.md) | CLI. |
+| [`minga-explorer-llimphi`](minga-explorer-llimphi/README.md) | UI: peers, content, tráfico. |
+
+## Consideraciones
+
+- **No es BitTorrent**: el modelo es content-addressed BLAKE3 (matchea wawa), no hash de torrent.
+- **Privacidad por defecto**: nada se publica sin que el usuario lo marque como compartible.
+- Diseñado para latencias domésticas/comunitarias, no para CDN globales.
diff --git a/03_ukupacha/minga/README.md b/03_ukupacha/minga/README.md
new file mode 100644
index 0000000..bcb566d
--- /dev/null
+++ b/03_ukupacha/minga/README.md
@@ -0,0 +1,39 @@
+# minga
+
+> `minga` (Quechua: *voluntary community labor*). Collaboration between nodes.
+
+Peer-to-peer network for the monorepo. DHT + P2P + distributed VFS. Any node can contribute storage or compute; protocols guarantee that the bytes' identity (BLAKE3) is truth, not the path. Compatible with `wawa`'s ingestion and with `agora`'s gossip.
+
+## Install
+
+```sh
+cargo run --release -p minga-cli
+cargo run --release -p minga-explorer-llimphi
+```
+
+## Compatibility
+
+- **Linux / macOS / Windows / Wawa** — native Rust + tokio for I/O.
+
+Crates listed in [README.md](README.md).
+
+## Considerations
+
+- **Not BitTorrent**: model is content-addressed BLAKE3 (matches wawa), not torrent hash.
+- **Privacy by default**: nothing is published without explicit user share.
+- Designed for community/domestic latency, not global CDN.
+
+## Estado (2026-05-31)
+
+> Reporte técnico detallado en [REPORTE.md](REPORTE.md). Mapa arquitectónico en [ARQUITECTURA.md](ARQUITECTURA.md).
+
+### Hecho
+- VCS semántico P2P funcionalmente completo: `minga-core` (AST + CAS + MST + atestaciones + α-hashing por lenguaje), `minga-store` (sled: nodes/attestations/mst/roots/timestamps/path-history/alpha-paths/retractions), `minga-dht` (DhtKey tipado), `minga-p2p` (MingaPeer libp2p con sync, Kademlia, RootDeclaration y RetractPush en el wire), `minga-vfs` (FUSE + pretty-printer Python indent-aware).
+- CLI rica (`minga-cli`): init, ingest, ingest-dir, watch (autoremove), log, show (+ diff/sexp), diff, blame, history, roots, signers, sign (vouching), retire, verify, prune (GC), sync (DHT lookup), listen (announce-all-roots), mount.
+- Bundle offline ("USB-stick mode"): export/import single + export-all/import-all multi-bundle con zstd, re-verificación criptográfica end-to-end. Daemon HTTP read-only `serve` (axum) con auth Bearer opcional (`--token`/`MINGA_SERVE_TOKEN`).
+- Convergencia con ágora: `MingaPeer` adopta `Arc<LibP2pNode>` compartido; un solo PeerId/listen sirve `/minga/sync/1.0.0` + `/agora/gossip/1.0.0`. Discovery de personas por `DhtKey::Persona` (Fase 2b).
+- Frontends: `minga-explorer-llimphi` (dashboard con theme/lang reactivos vía wawa-config) + `shuma-module-minga` (tab del shell con raíces, verify, dot de retracciones); menús principal + contextuales (lote 2). `cargo check --workspace` verde.
+
+### Pendiente
+- `MingaPeer` genérico sobre `NodeStore` (backend sled directo en lugar de cargar todo a RAM en sync). Diferido con criterio: trigger cuando un repo real supere ~100k nodos (sin caso hoy).
+- Roadmap "Grafo de la Verdad" (ver memoria del proyecto): GossipSub + VFS-lazy-P2P, reputación, +9 lenguajes — fuera del backlog ya cerrado.
diff --git a/03_ukupacha/minga/README.qu.md b/03_ukupacha/minga/README.qu.md
new file mode 100644
index 0000000..44b8e72
--- /dev/null
+++ b/03_ukupacha/minga/README.qu.md
@@ -0,0 +1,26 @@
+<!-- Quechua (Cusco/Collao). Revisión bienvenida. -->
+
+# minga
+
+> `minga` (runa-simi: *ayllukunapa voluntario llank'ay*). Nodokuna pura llank'ay tinkuy.
+
+Monorepupa peer-pi peer ayllu-red. DHT + P2P + reqsisqa VFS. Sapan nodo storage utaq cómputo qun atin; protocolos byteskunaq identidad (BLAKE3) cheqaqmi, manan path-chu. `wawa` ingestawan + `agora` gossipwan tinkuy.
+
+## Churay
+
+```sh
+cargo run --release -p minga-cli
+cargo run --release -p minga-explorer-llimphi
+```
+
+## Tinkuy
+
+- **Linux / macOS / Windows / Wawa** — Rust + tokio I/O-paq.
+
+Crateskuna [README.md](README.md)-pi.
+
+## Yuyaykunaq
+
+- **Manan BitTorrent**: content-addressed BLAKE3 (wawa-wan tinkuq), manan torrent hash.
+- **Privacy default**: mana imapas hawapi mana runaq comparte.
+- Ayllu / wasi latencia, manan hatun CDN.
diff --git a/03_ukupacha/minga/REPORTE.md b/03_ukupacha/minga/REPORTE.md
new file mode 100644
index 0000000..454b60d
--- /dev/null
+++ b/03_ukupacha/minga/REPORTE.md
@@ -0,0 +1,221 @@
+# minga — reporte técnico para IA
+
+> Estado: **2026-05-28** · rama `main` · compila limpio (`cargo check --workspace` pasa).
+> Audiencia: sesión futura de Claude u otra IA que retome el VCS semántico.
+
+---
+
+## 1. Mapa actualizado
+
+```
+03_ukupacha/minga/
+├── minga-core ← AST + CAS + MST + atestaciones + α-hashing per-language
+├── minga-store ← sled: nodes, attestations, mst, roots (NUEVO), timestamps (NUEVO)
+├── minga-dht ← DhtKey typed; DhtKey::for_hash NUEVO (sin re-blake3)
+├── minga-p2p ← MingaPeer (libp2p) + ingest_with_dialect NUEVO + DhtKey en announce/find
+├── minga-vfs ← FUSE + render_source (NUEVO: Python indent-aware)
+├── minga-cli ← init, status, ingest, log NUEVO, show NUEVO, listen, sync (DHT NUEVO), watch (Remove NUEVO), mount
+└── minga-explorer-llimphi ← dashboard + watcher wawa-config NUEVO
+```
+
+## 2. Cambios de este sprint
+
+### Cableado α-hash al ingest (#1) — alto impacto
+- `PersistentRepo` ahora abre **5 trees**: `nodes`, `attestations`, `mst`, `roots`, `attestation_timestamps`.
+- Nuevo `SledRootsStore`: `α_hash → (struct_hash, dialect)`. Indirección que separa la **identidad del archivo** (α-hash, estable bajo renombrado) del **CAS del grafo** (struct-hash).
+- `cmd_ingest`/`cmd_watch` ahora computan `α = hash_alpha_with(dialect, &node)`, lo registran como raíz del MST, lo firman con la atestación, y guardan `α → struct` en `roots`.
+- `IngestResult` expone tanto `alpha` como `struct_hash` y `dialect`.
+- `RepoSource::get` resuelve transparentemente: si `hash` es α (root), redirige al struct; si no, lo busca directo en `nodes`. Esto preserva la navegación `cas/<hash>` para nodos internos.
+
+### DHT typed (#2)
+- `minga-dht::DhtKey::for_hash(kind, [u8;32])` — nuevo constructor que **no re-blake3-ea** un hash existente.
+- `MingaPeer::announce_provider`/`find_providers` ahora envuelven el `ContentHash` en `DhtKey::for_hash(RecordKind::Code, ...)`. Comparte la malla Kademlia con cards/personas sin colisión.
+
+### CLI `minga log` y `minga show` (#3)
+- `minga log [path]`: lista atestaciones ordenadas por timestamp local (descendente). Si pasás `path`, marca con `*` la entrada cuyo α-hash coincide con el contenido actual del archivo. Salida: `* YYYY-MM-DD HH:MM <α-hash> [dialect] by <DID>`.
+- `minga show <hash> [--sexp]`: pinta la fuente reconstruida (forma canónica) del nodo. Acepta α-hashes (raíces) y hashes estructurales del grafo CAS. Con `--sexp` devuelve el árbol literal.
+- `SledTimestampStore`: timestamps locales de cuándo se observó cada atestación. **No** se transmite por wire — es metadata propia del peer.
+
+### `watch` con remove tracking (#4)
+- `cmd_watch` mantiene un `HashMap<PathBuf, ContentHash>` en memoria; el `initial_scan` lo popula con todos los archivos soportados.
+- En `EventKind::Remove(_)` retira el α-hash del MST y de `roots` (vía nuevos métodos `SledMstStore::remove` y `SledRootsStore::remove`).
+- Los **nodos del grafo CAS NO se eliminan** — pueden estar compartidos con otras raíces. Las atestaciones tampoco (siguen siendo prueba histórica).
+
+### DHT lookup en sync (#6)
+- `cmd_sync <target>` detecta si `target` es un hex de 64 caracteres (α-hash) y, en ese caso, hace `peer.find_providers(hash)` en el DHT. Itera los providers retornados intentando `sync_with` hasta éxito o deadline.
+- Nuevo error `CliError::NoProvidersForHash` cuando el DHT no devuelve nadie. Necesita al menos un peer bootstrap conocido (`add_dht_peer`).
+
+### Shebang detection (#7)
+- `minga_core::parse::detect_by_shebang(source)`: reconoce `python*`, `node`, `deno`, `bun`, `tsx`, `ts-node`, `go`, `rustc`. Override por `--ext=ts` para deno.
+- CLI: `detect_dialect` ahora prueba primero por extensión, después por shebang.
+- `is_supported_source` (usado por `watch`) también consulta shebang — scripts sin extensión (`bin/foo`, `tool`) ahora se versionan.
+
+### Python pretty-printer indent-aware (#9)
+- `render_source` detecta el root kind `module` y delega a `render_python`.
+- Recorre el AST de tree-sitter Python reconociendo statements compuestos (`function_definition`, `class_definition`, `if_statement`, `for_statement`, `while_statement`, `with_statement`, `try_statement`, `match_statement` + variantes async/decorated).
+- Para cada compound: separa el header (todo lo que no es `block`/cláusulas) y emite `header:` + `block` con `indent + 1`. Cláusulas anidadas (`elif_clause`, `else_clause`, `except_clause`, `finally_clause`, `case_clause`) se recursan al mismo nivel.
+- Tests verifican `def`/`return`, `if`/`else`, y `class` con método (indentación 4/8).
+
+### Wawa-config watcher en explorer (#10)
+- `minga-explorer-llimphi` ahora depende de `wawa-config`.
+- `init` carga `WawaConfig::load()`, mappea `theme_variant` vía `canonical_theme_name` + `Theme::by_name`, aplica `accent_rgb`, llama `rimay_localize::set_locale(&cfg.lang)`.
+- `ConfigWatcher::spawn` con closure que `handle.dispatch(Msg::WawaChanged(cfg))` — reactiva theme/lang sin reinicio.
+
+### `shuma-module-minga` (#8) — feature nueva
+- Tab del shell shuma que muestra el repo Minga del cwd.
+- Counts (`raíces / nodos / atestaciones / mst`) + lista de raíces recientes con su α-hash corto y dialect.
+- Shortcut "Refresh" + monitor "minga · raíces" en el panel derecho.
+- El chasis `shuma-shell-llimphi` registra el nuevo `Kind::Minga`, ramas en `update`/`view` (Main + DrawerTab + contributions), y el handler de `minga.refresh` que lanza `load_snapshot` en un thread.
+
+## 3. Diferido
+
+### MingaPeer genérico sobre NodeStore (#5)
+**No implementado** — requiere generizar `PeerState`, `SyncSession`, `snapshot`, y `merge_into_state` sobre un trait `NodeStore` (que ya existe en `minga-core::store`). Toca el wire protocol indirectamente porque las sesiones de sync clonan el store entero.
+
+Razón para diferir: alto costo de refactor + tests, beneficio sólo se manifiesta en repos grandes que minga aún no tiene. Cuando el primer repo supere los 100k nodos, retomar.
+
+## 4. Comandos útiles
+
+```bash
+# Init + ingest + log + show
+minga -r ./.minga init
+minga -r ./.minga ingest src/main.rs
+minga -r ./.minga log src/main.rs # marca con * el α actual
+minga -r ./.minga show <alpha_hex>
+
+# Watch (autoingest + autoremove)
+minga -r ./.minga watch ./src
+
+# Sync por DHT (necesita peer bootstrap)
+minga -r ./.minga sync <alpha_hex> # busca providers
+minga -r ./.minga sync <multiaddr> # conexión directa
+
+# Mount FUSE
+mkdir mnt && minga -r ./.minga mount mnt
+ls mnt/roots/ # un archivo por α-hash
+cat mnt/roots/<α> # fuente reconstruida (Python indent-aware ahora)
+
+# Explorer Llimphi (con theme reactivo via wawa-config)
+MINGA_REPO=./.minga cargo run -p minga-explorer-llimphi
+```
+
+## 5. Diseño preservado
+
+1. **Sync protocol intacto.** Los α-hashes del MST viajan como `ContentHash`es por el wire (32 bytes); el receptor no necesita re-verificar α — confía en la atestación firmada. La indirección α→struct es local a cada peer.
+2. **`MemStore` sigue siendo el medio de sync** entre peers; `MingaPeer::open` carga todo a RAM como antes. La generización a `S: NodeStore` queda como item #5.
+3. **Tree `roots` separado del MST.** El MST contiene sólo los α-hashes (claves), igual que antes — la nueva indirección `roots` es independiente. Esto preserva todos los tests del protocolo.
+4. **`SledTimestampStore` es local.** Dos peers que ven la misma atestación tendrán timestamps distintos (cuando llegó a cada uno) — esto es deliberado: `minga log` es una vista local del historial.
+
+## 6. Sub-sprint posterior (5 items adicionales completados)
+
+| # | Tarea | Estado |
+|---|---|---|
+| 11 | **`minga diff`** entre dos hashes (LCS vía `similar` crate) | hecho — el test `rename_local_var_keeps_same_alpha_hash` valida que α se manifiesta end-to-end |
+| 12 | **`minga retire`** — tombstone firmado (`Retraction` con `RETRACTION_DOMAIN` prefix; `SledRetractionStore` paralelo a atestaciones; quita del MST y `roots` pero conserva la atestación original como prueba histórica) | hecho |
+| 13 | **`minga verify`** — `verify_root_alpha(node, claimed) -> Option<Dialect>` que prueba cada dialecto; el CLI reporta consistencia + drift con dialect registrado | hecho (la re-verificación al recibir-wire requiere modificar el protocolo de sync, documentado abajo) |
+| 14 | **Click en raíz del módulo shuma-module-minga**: dispara `SelectRoot(hash)` → el chasis spawnea `load_root_source` en thread → resultado vía `SourceLoaded` → panel inferior con `render_source` | hecho — race-protect: si llega un click nuevo mientras carga el anterior, el resultado viejo se descarta |
+| 15 | **Detección de dialect por contenido**: marcadores textuales por línea (`def`/`fn`/`func`/`function`/`interface`) + tie-break por ratio de nodos ERROR. `detect_dialect` ahora prueba ext → shebang → contenido | hecho |
+
+### #13 (verify) — alcance honesto
+
+La re-verificación se ofrece como primitiva (`alpha::verify_root_alpha`) y como subcomando (`minga verify <hash>`). Verifica **localmente** que una raíz del repo es consistente bajo algún dialect; útil tras sync con peers no-confiables.
+
+**No** intercepta automáticamente en el path de sync porque el wire actual no transmite dialect ni el binding α→struct. Para integrarlo ahí hay que extender `minga-p2p::session::Message` con una variante `RootDeclaration { alpha, struct_hash, dialect }`; queda para una fase futura.
+
+## 7. Tercer sprint (4 items más completados)
+
+| # | Tarea | Resultado |
+|---|---|---|
+| 16 | **`minga prune`** — mark-sweep GC del grafo CAS. Marca alcanzables desde `roots` siguiendo `children_of` (lectura liviana), borra los huérfanos de `nodes`. Idempotente. Atestaciones/retracciones/timestamps preservados (referencian α-hash, no struct). | hecho |
+| 17 | **`minga show --diff-against <other>`** — atajo a `minga diff` desde el subcomando `show`. Mutuamente excluyente con `--sexp`. | hecho |
+| 18 | **`shuma-module-minga` shortcut Verify** — recorre raíces visibles, las verifica con `verify_root_alpha` en un thread. Marca cada `RootRow` con `verified: Option<bool>`, pintado con `·`/`✓`/`✘`. | hecho |
+| 19 | **Sync de Retractions** — Wire extendido con `Message::RetractPush { retractions }`. `SyncSession::with_retractions` constructor nuevo. Push tras Hello autenticado. Idéntica idempotencia/verificación que atestaciones. `MingaPeer::open` carga retracciones desde disco; `merge_into_state` las mezcla y persiste. Test `sync_propagates_retractions_for_owned_content` cierra el round-trip; `forged_retraction_signature_is_rejected` verifica que firmas inválidas se cuentan como `rejected_retracts`. | hecho |
+
+### Cambios en el wire (importante para compat)
+
+`Message` ganó la variante `RetractPush { retractions: Vec<Retraction> }`. Repos sincronizados con peers que corran versión vieja del protocolo **descartarán** ese mensaje (postcard error en `decode`) y la sesión seguirá funcionando para atestaciones. No es un break-change explícito; sólo se pierde la propagación de retracciones contra peers que no lo entienden.
+
+`SyncSession::new` mantiene la firma vieja (con `RetractionStore::new()` interno); el nuevo `with_retractions` toma 5 args. `into_parts` sigue devolviendo 3-tupla por compat; `into_parts_with_retractions` agrega la cuarta.
+
+## 8. Cuarto sprint — B, D, E, F + descubribilidad CLI
+
+| # | Tarea | Resultado |
+|---|---|---|
+| B | **Wire-side α-verification** — `RootDeclaration { alpha, struct, dialect }` añadido al protocolo (`minga-p2p::session::Message`). El receptor re-verifica `verify_root_alpha` contra el contenido recibido; declaraciones inconsistentes se rechazan sin tocar `roots`. | hecho (commit `580875e`) |
+| D | **`minga blame <path>`** — historial path→α (nuevo `SledPathHistoryStore`) + diff línea-a-línea entre versiones consecutivas + propagación de atribución. Resultado: cada línea actual atada al α que la introdujo. | hecho (commit `750b6f9`) |
+| E | **DHT bootstrap automático en `listen`** — `MingaPeer::announce_all_roots()` se llama tras `listen`; cualquier α local pasa a ser descubrible por `sync <hash>` sin conocer multiaddr. | hecho (commit `b519700`) |
+| F | **Dot rojo en `shuma-module-minga`** para raíces con retracciones pendientes — `SledRetractionStore::iter` reverse-indexado por α; render del módulo lo marca con `·` rojo al lado del α-hash. | hecho (commit `b519700`) |
+| G | **`minga roots`** — lista todas las raíces con path conocido, dialect, fecha de última atestación y cantidad de firmas, ordenadas por actividad reciente. Reverse-index del `SledPathHistoryStore` para resolver α→path. Cierra el hueco entre `status` (counts) y `show <hash>` (que requería conocer el hash). | hecho |
+| H | **`minga history <path>`** — dump cronológico descendente del historial path→α + dialect + marcador `current` (best-effort: parsea el archivo actual y compara α). Versión liviana del blame cuando sólo querés saber "cuándo cambió este archivo". | hecho |
+
+## 9. Quinto sprint — vouching colaborativo y bulk ingest
+
+| # | Tarea | Resultado |
+|---|---|---|
+| K | **`minga sign <α-hash>`** — emite una atestación bajo el keypair local sobre un α-hash existente. A diferencia de `ingest` (firma como efecto de versionar contenido propio), `sign` es vouching explícito: Alice ingiere, Bob sincroniza, Bob firma. La raíz queda con dos atestaciones independientes — habilita co-autoría semántica y aval de revisores. Idempotente: re-firmar con el mismo keypair reemplaza la entrada con bytes idénticos (no duplica). Avisa si el α no es raíz registrada (puede ser fragmento del CAS o raíz huérfana). | hecho |
+| L | **`minga ingest-dir <dir> [--recursive]`** — versión one-shot del `initial_scan` interno de `watch`. Recorre el directorio, ingiere todos los archivos soportados, reporta `(seen, ingested, failed)`. En modo recursivo poda dot-dirs (`.git`, `.minga`, `.venv`) para evitar ruido. Hace lo que muchos usuarios harían con un `find … -exec minga ingest {} \;` pero sin el costo de re-abrir el repo sled por archivo. | hecho |
+
+## 10. Sexto sprint — vista de firmas y bundle offline
+
+| # | Tarea | Resultado |
+|---|---|---|
+| M | **`minga signers <α-hash>`** — lista de DIDs que han atestado la raíz, con timestamp local. Marca con `↺` quienes también firmaron una retracción posterior. Vista natural sobre lo que `cmd_sign` siembra; antes había que pasar por `cmd_log` y filtrar. | hecho |
+| I | **`minga bundle export <α-hash> <out>`** + **`minga bundle import <archivo>`** — empaquetado offline ("USB-stick mode") con misma garantía criptográfica que el wire. El export hace BFS por el DAG, recolecta `StoredNode`s + atestaciones + retracciones, y serializa con postcard. El import re-verifica cada pieza: `put_chunked` rehashe los nodos antes de insertar; `hash_alpha_with` re-deriva el α y se compara contra el claimado; `Attestation::add`/`Retraction::add` ya verifican firma Ed25519. Idempotente bajo reintentos. Nuevo módulo `minga-cli::bundle` con `BundleV1` (versionado para forward-compat). | hecho |
+
+### Notas sobre el bundle (#I)
+
+- **Formato.** `BundleV1 { version, alpha, struct_hash, dialect_byte, nodes, attestations, retractions }` — todo postcard. Dialect viaja como `u8` para no atar el formato a una variante específica; un importador viejo recibe `UnknownDialect` (error claro) si llega un byte que no reconoce.
+- **Dialect requerido en `roots`.** Si la raíz fue sincronizada bajo el wire pre-`RootDeclaration` (commit `580875e`) y no tiene dialect persistido, `export` falla con `BundleMissingDialect` — sin dialect el receptor no puede re-verificar el α. Re-ingerí esa raíz primero para registrar su dialect.
+- **Path metadata excluida.** El bundle no transmite `SledPathHistoryStore` ni `SledTimestampStore` (locales por diseño, como en el wire de sync). El receptor pone timestamp = `now` al merge.
+- **Atestaciones/retractions con `content != alpha`** se descartan silenciosamente en el import. No debería pasar bajo bundles bien formados; el filtro es defensivo.
+
+## 10.bis Convergencia con ágora (lateral)
+
+Aunque no toca el dominio de minga directamente, este sprint cerró el bridge entre los dos: ágora ahora puede correr sobre el mismo nodo libp2p que `MingaPeer`. Cambios relevantes para minga:
+
+- **`MingaPeer::node`** pasó de `LibP2pNode` (por valor) a `Arc<LibP2pNode>` (compartido).
+- Nuevos constructores:
+ - **`MingaPeer::open_with_node(keypair, path, node: Arc<LibP2pNode>)`** — adopta un nodo libp2p ya existente en lugar de crear uno propio. El `open` clásico ahora delega a esto.
+ - **`MingaPeer::brahman_net() -> Arc<LibP2pNode>`** — accessor para que otros consumidores (típicamente `agora_net_brahman::AgoraNet`) compartan el mismo nodo.
+- Sin cambios en el wire de sync — la convergencia es a nivel de transport, no de protocolo. `/minga/sync/1.0.0` sigue idéntico; agora registra `/agora/gossip/1.0.0` en paralelo y libp2p stream behaviour los demultiplexa.
+
+Demo: `cargo run -p agora-net-brahman --example convergencia_minga` — un solo `PeerId` sirviendo ambos protocolos sobre un solo `listen`.
+
+## 11. Séptimo sprint — cierre del backlog abierto (2026-05-28)
+
+| # | Tarea | Resultado |
+|---|---|---|
+| O | **Round-trip test del bundle** — `tests/bundle_roundtrip.rs` en `minga-cli` con cuatro casos: round-trip básico (raíz + atestación), idempotencia del import, propagación de vouching multi-firma (A→B firma→C ve dos firmas), y propagación de retracciones bajo re-ingest. Era la red de seguridad mínima antes de tocar el formato. | hecho |
+| N | **`minga bundle export-all`** + **`minga bundle import-all`** — multi-bundle con magic prefix `MNGM` para distinguir del `BundleV1` clásico, retro-compatible. Empaca todas las raíces del repo en un solo archivo postcard; raíces sin dialect persistido se reportan en `skipped_missing_dialect` sin abortar. Import detecta el formato por magic y agrega errores específicos (`ExpectedSingleBundle`/`ExpectedMultiBundle`) para que el usuario no tenga que adivinar qué archivo es. Reuso máximo: `build_bundle_for_root` y `import_one` son helpers compartidos entre single y multi. | hecho |
+| J | **`SledAlphaPathsStore`** — índice inverso α→paths persistente en disco con clave compuesta `[α(32)][path]` y valor `ts_secs(8 be)`. Reemplaza el reverse-index que `cmd_roots` reconstruía en RAM cada llamada. Write-through en `cmd_ingest` (ambos call-sites: `commands.rs:103-106` y `commands.rs:1318-1322`). Migración perezosa en `PersistentRepo::open`: si el tree está vacío pero `path_history` tiene entradas, se rebuildea una vez. Test de migración en `minga-store/tests/alpha_paths_rebuild.rs`. | hecho |
+| C | **`minga serve <addr>`** — daemon HTTP read-only sobre axum. Endpoints: `GET /status`, `GET /roots`, `GET /roots/:α/show[?sexp=1]`, `GET /roots/:α/signers`, `GET /roots/:α/history?path=`. Mapeo de errores: `HashNotFound`/`PathNotIngested`→404, `InvalidHash`/`UnsupportedLanguage`→400, resto→500. Pasphrase en RAM durante el daemon (no se descifra por request). Tests en `tests/serve_http.rs` vía `tower::ServiceExt::oneshot` (sin abrir socket real). | hecho |
+
+### Diferido con criterio explícito
+
+| # | Tarea | Razón |
+|---|---|---|
+| A / #5 | `MingaPeer` genérico sobre `NodeStore` (backend sled directo en lugar de cargar todo a RAM) | El refactor requiere cambiar la firma `NodeStore::get(&self, h) -> Option<&StoredNode>` a `Option<StoredNode>` (owned) porque sled devuelve `IVec`s temporales — eso cascadea por `session.rs`, `peer.rs`, los tests del protocolo, y `MemStore` mismo. El payoff sólo aparece cuando el repo supera ~100k nodos (sin caso real hoy). Permanece como "tomar cuando se justifique"; el trigger sigue siendo el mismo del 6º sprint. |
+
+## 12. Octavo sprint — cierre de los 3 follow-ups (2026-05-29)
+
+| # | Tarea | Resultado |
+|---|---|---|
+| R | **`minga signers --since <ts>`** — filtro por timestamp local (reusa `SledTimestampStore`). El parser acepta `YYYY-MM-DD` (medianoche UTC) y duraciones relativas con sufijo `m/h/d/w` (`30d`, `12h`, `2w`). Atestaciones sin timestamp persistido (`ts_secs == 0`, legacy pre-`SledTimestampStore`) se excluyen cuando hay filtro — no podemos juzgar si son recientes. El endpoint `GET /roots/:α/signers` aceptó el query param `since=<u64>` (Unix epoch directo — el HTTP no asume formatos amigables). | hecho |
+| Q | **Multi-bundle comprimido con zstd** — `export-all` ahora siempre escribe con header nuevo `MNGZ` y cuerpo zstd-comprimido (nivel 3, default rápido). `import-all` detecta y descomprime; archivos viejos con header `MNGM` siguen importando como antes (test `multi_bundle_legacy_mngm_still_imports` lo cubre). `BundleExportAllStats` ganó `uncompressed_bytes` para reportar el ratio sin tener que volver a serializar. Sin cambios en `BundleV1` single-bundle — no aportaba lo suficiente al overhead total. | hecho |
+| P | **`minga serve --token <valor>`** — middleware axum que exige `Authorization: Bearer <valor>`; comparación constant-time vía XOR byte-a-byte para no filtrar el secreto por timing. Si `--token` no se pasa, se respeta también la env `MINGA_SERVE_TOKEN` (camino recomendado para no exponer el secreto en el `ps`). Sin token configurado, el daemon sigue corriendo abierto — razonable sólo para `127.0.0.1`. Tres tests cubren: sin auth header → 401, token incorrecto → 401, token correcto → 200. | hecho |
+
+### Notas de implementación
+
+- **Compresión: criterio del 3 vs 19.** Zstd nivel 3 da ~2–3× sobre postcard de código fuente real (probado con repo del workspace gioser). Subir a 19+ exprime un 20–30 % extra a costa de 5–10× tiempo CPU — no vale para un caso "dump completo del repo a USB" donde el cuello suele ser el disco, no el CPU.
+- **Token: por qué Bearer y no Basic.** Bearer mantiene compat directa con curl/HTTP clients que ya hablan OAuth-style; no obliga al usuario a base64-ear nada. La comparación constant-time es una formalidad — el atacante con timing oracle sobre la red local ya tiene acceso al filesystem del daemon.
+- **`--since` sin filtro abre la puerta a `--since` en `log`/`roots`.** No se implementó: el formato `Vec<LogEntry>` ya viene ordenado por timestamp, así que un caller puede filtrar; el ahorro mínimo no justifica duplicar el parser.
+
+## 13. Estado final
+
+Todos los items del backlog histórico — más los 3 follow-ups del séptimo sprint — están cerrados o diferidos con criterio explícito. El único pendiente activo es:
+
+- **#5/A**: `MingaPeer` genérico sobre `NodeStore` (backend sled directo). Trigger: cuando un repo real pase de ~100k nodos. Sin caso concreto hoy.
+
+---
+
+*Generado por Claude (Opus 4.7) — `2026-05-29`. Octavo sprint: 3 items, cierra el follow-up backlog del REPORTE. Minga es funcionalmente completa con VCS semántico P2P + bundle offline + multi-bundle comprimido + daemon HTTP con auth opcional + índice inverso persistente + verificación criptográfica end-to-end.*
diff --git a/03_ukupacha/minga/card-discovery/Cargo.toml b/03_ukupacha/minga/card-discovery/Cargo.toml
new file mode 100644
index 0000000..f2dc13c
--- /dev/null
+++ b/03_ukupacha/minga/card-discovery/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "card-discovery"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — búsqueda de Cards: índice local con filtros + escaneo de directorios + discovery P2P sobre minga-dht."
+
+[dependencies]
+card-core = { workspace = true }
+cards = { workspace = true }
+minga-dht = { path = "../minga-dht" }
+libp2p = { workspace = true }
+ulid = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
+serde_json = { workspace = true }
+tokio = { workspace = true }
diff --git a/03_ukupacha/minga/card-discovery/src/discovery.rs b/03_ukupacha/minga/card-discovery/src/discovery.rs
new file mode 100644
index 0000000..79706c1
--- /dev/null
+++ b/03_ukupacha/minga/card-discovery/src/discovery.rs
@@ -0,0 +1,64 @@
+//! `CardDiscovery` — une el índice local de Cards con el DHT.
+
+use crate::index::CardIndex;
+use minga_dht::{Dht, DhtKey};
+use libp2p::PeerId;
+
+/// Búsqueda de Cards: siempre local, opcionalmente sobre la malla P2P.
+pub struct CardDiscovery {
+ /// Índice local consultable.
+ pub index: CardIndex,
+ dht: Option<Dht>,
+}
+
+impl CardDiscovery {
+ /// Discovery sólo-local (sin malla P2P).
+ pub fn local(index: CardIndex) -> Self {
+ Self { index, dht: None }
+ }
+
+ /// Discovery local + DHT.
+ pub fn with_dht(index: CardIndex, dht: Dht) -> Self {
+ Self { index, dht: Some(dht) }
+ }
+
+ /// `true` si hay malla P2P conectada.
+ pub fn has_dht(&self) -> bool {
+ self.dht.is_some()
+ }
+
+ /// Anuncia al DHT cada Card local (clave = `DhtKey::card(id)`).
+ /// No-op si no hay DHT.
+ pub fn announce_all(&self) {
+ if let Some(dht) = &self.dht {
+ for card in self.index.all() {
+ dht.announce(&DhtKey::card(card.id.to_string()));
+ }
+ }
+ }
+
+ /// Busca proveedores remotos de una Card por id. Vacío si no hay DHT.
+ pub async fn find_remote(&self, card_id: &str) -> Vec<PeerId> {
+ match &self.dht {
+ Some(dht) => dht.find(&DhtKey::card(card_id)).await,
+ None => Vec::new(),
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use card_core::Card;
+
+ #[tokio::test]
+ async fn local_only_discovery_has_no_dht() {
+ let mut ix = CardIndex::new();
+ ix.insert(Card::new("local-1"));
+ let disc = CardDiscovery::local(ix);
+ assert!(!disc.has_dht());
+ disc.announce_all(); // no-op, no debe panickear
+ assert!(disc.find_remote("cualquiera").await.is_empty());
+ assert_eq!(disc.index.len(), 1);
+ }
+}
diff --git a/03_ukupacha/minga/card-discovery/src/index.rs b/03_ukupacha/minga/card-discovery/src/index.rs
new file mode 100644
index 0000000..1dcd23f
--- /dev/null
+++ b/03_ukupacha/minga/card-discovery/src/index.rs
@@ -0,0 +1,116 @@
+//! Índice en memoria de Cards, con filtros de búsqueda.
+
+use card_core::{Capability, Card, CardKind};
+use ulid::Ulid;
+
+/// Colección consultable de Cards.
+#[derive(Debug, Clone, Default)]
+pub struct CardIndex {
+ cards: Vec<Card>,
+}
+
+impl CardIndex {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ pub fn insert(&mut self, card: Card) {
+ self.cards.push(card);
+ }
+
+ pub fn len(&self) -> usize {
+ self.cards.len()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.cards.is_empty()
+ }
+
+ pub fn all(&self) -> &[Card] {
+ &self.cards
+ }
+
+ /// Card por id exacto.
+ pub fn by_id(&self, id: Ulid) -> Option<&Card> {
+ self.cards.iter().find(|c| c.id == id)
+ }
+
+ /// Cards cuyo label contiene `needle` (case-insensitive).
+ pub fn by_label(&self, needle: &str) -> Vec<&Card> {
+ let n = needle.to_lowercase();
+ self.cards
+ .iter()
+ .filter(|c| c.label.to_lowercase().contains(&n))
+ .collect()
+ }
+
+ /// Cards de un `CardKind` dado.
+ pub fn by_kind(&self, kind: CardKind) -> Vec<&Card> {
+ self.cards.iter().filter(|c| c.kind == kind).collect()
+ }
+
+ /// Cards que proveen la `Capability` dada.
+ pub fn providing(&self, cap: &Capability) -> Vec<&Card> {
+ self.cards
+ .iter()
+ .filter(|c| c.provides.contains(cap))
+ .collect()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn card(label: &str) -> Card {
+ Card::new(label)
+ }
+
+ #[test]
+ fn by_label_is_case_insensitive_substring() {
+ let mut ix = CardIndex::new();
+ ix.insert(card("Broker Demo"));
+ ix.insert(card("file explorer"));
+ assert_eq!(ix.by_label("broker").len(), 1);
+ assert_eq!(ix.by_label("EXPLOR").len(), 1);
+ assert_eq!(ix.by_label("zzz").len(), 0);
+ }
+
+ #[test]
+ fn by_id_finds_exact() {
+ let mut ix = CardIndex::new();
+ let c = card("x");
+ let id = c.id;
+ ix.insert(c);
+ assert!(ix.by_id(id).is_some());
+ assert!(ix.by_id(Ulid::new()).is_none());
+ }
+
+ #[test]
+ fn providing_filters_by_capability() {
+ let mut spawner = card("spawner");
+ spawner.provides.insert(Capability::Spawn);
+ let mut logger = card("logger");
+ logger.provides.insert(Capability::Journal);
+
+ let mut ix = CardIndex::new();
+ ix.insert(spawner);
+ ix.insert(logger);
+ assert_eq!(ix.providing(&Capability::Spawn).len(), 1);
+ assert_eq!(ix.providing(&Capability::Journal).len(), 1);
+ assert_eq!(ix.providing(&Capability::FilesystemRoot).len(), 0);
+ }
+
+ #[test]
+ fn by_kind_splits_ente_and_data() {
+ let mut ente = card("ente");
+ ente.kind = CardKind::Ente;
+ let mut data = card("data");
+ data.kind = CardKind::Data;
+ let mut ix = CardIndex::new();
+ ix.insert(ente);
+ ix.insert(data);
+ assert_eq!(ix.by_kind(CardKind::Ente).len(), 1);
+ assert_eq!(ix.by_kind(CardKind::Data).len(), 1);
+ }
+}
diff --git a/03_ukupacha/minga/card-discovery/src/lib.rs b/03_ukupacha/minga/card-discovery/src/lib.rs
new file mode 100644
index 0000000..567ccb8
--- /dev/null
+++ b/03_ukupacha/minga/card-discovery/src/lib.rs
@@ -0,0 +1,19 @@
+//! `brahman-card-discovery` — búsqueda de Cards local + DHT.
+//!
+//! - [`index`] — `CardIndex`: índice en memoria con filtros (label,
+//! kind, capability, id).
+//! - [`registry`] — `scan_dir`: carga Cards `*.json` de un directorio.
+//! - [`discovery`] — `CardDiscovery`: une el índice local con la malla
+//! P2P vía `brahman-dht`.
+//!
+//! Lo consume el widget card-browser de `nahual-shell` y `agora_app`.
+
+#![forbid(unsafe_code)]
+
+pub mod index;
+pub mod registry;
+pub mod discovery;
+
+pub use discovery::CardDiscovery;
+pub use index::CardIndex;
+pub use registry::scan_dir;
diff --git a/03_ukupacha/minga/card-discovery/src/registry.rs b/03_ukupacha/minga/card-discovery/src/registry.rs
new file mode 100644
index 0000000..62454eb
--- /dev/null
+++ b/03_ukupacha/minga/card-discovery/src/registry.rs
@@ -0,0 +1,46 @@
+//! Registro local: escaneo de directorios con Cards en disco.
+
+use crate::index::CardIndex;
+use std::path::Path;
+
+/// Escanea `dir` (no recursivo) cargando toda Card `*.json` válida.
+/// Los archivos que no parsean como Card se saltan en silencio.
+pub fn scan_dir(dir: &Path) -> std::io::Result<CardIndex> {
+ let mut index = CardIndex::new();
+ for entry in std::fs::read_dir(dir)? {
+ let path = entry?.path();
+ if path.extension().and_then(|e| e.to_str()) == Some("json") {
+ if let Ok(card) = cards::load_card_file(&path) {
+ index.insert(card);
+ }
+ }
+ }
+ Ok(index)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use card_core::Card;
+
+ #[test]
+ fn scans_only_valid_json_cards() {
+ let dir = tempfile::tempdir().unwrap();
+ for name in ["alpha", "beta"] {
+ let card = Card::new(name);
+ let json = serde_json::to_string(&card).unwrap();
+ std::fs::write(dir.path().join(format!("{name}.json")), json).unwrap();
+ }
+ // Ruido que debe ignorarse.
+ std::fs::write(dir.path().join("readme.txt"), "no soy una card").unwrap();
+ std::fs::write(dir.path().join("roto.json"), "{ no json }").unwrap();
+
+ let ix = scan_dir(dir.path()).unwrap();
+ assert_eq!(ix.len(), 2);
+ }
+
+ #[test]
+ fn missing_dir_is_an_error() {
+ assert!(scan_dir(Path::new("/no/existe/jamas")).is_err());
+ }
+}
diff --git a/03_ukupacha/minga/minga-cli/Cargo.toml b/03_ukupacha/minga/minga-cli/Cargo.toml
new file mode 100644
index 0000000..2362cf9
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/Cargo.toml
@@ -0,0 +1,35 @@
+[package]
+name = "minga-cli"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+description = "CLI de Minga: init, status, ingest, listen, sync, watch, mount."
+
+[[bin]]
+name = "minga"
+path = "src/main.rs"
+
+[dependencies]
+minga-core = { path = "../minga-core" }
+minga-p2p = { path = "../minga-p2p" }
+minga-store = { path = "../minga-store" }
+minga-vfs = { path = "../minga-vfs" }
+clap = { workspace = true }
+rpassword = { workspace = true }
+tokio = { workspace = true }
+thiserror = { workspace = true }
+futures = { workspace = true }
+libp2p = { workspace = true }
+notify = { workspace = true }
+similar = { workspace = true }
+serde = { workspace = true }
+postcard = { workspace = true }
+axum = { workspace = true }
+serde_json = { workspace = true }
+zstd = { workspace = true }
+
+[dev-dependencies]
+tempfile = { workspace = true }
+tower = { workspace = true }
+http-body-util = "0.1"
diff --git a/03_ukupacha/minga/minga-cli/LEEME.md b/03_ukupacha/minga/minga-cli/LEEME.md
new file mode 100644
index 0000000..a90f466
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/LEEME.md
@@ -0,0 +1,17 @@
+# minga-cli
+
+> CLI de [minga](../README.md).
+
+Comandos: `minga peer add/list`, `minga put/get`, `minga ls <vfs-path>`, `minga share <local-path>`.
+
+## Uso
+
+```sh
+cargo run --release -p minga-cli -- peer list
+cargo run --release -p minga-cli -- put /local/file
+```
+
+## Deps
+
+- Todos los `minga-*`
+- `clap`
diff --git a/03_ukupacha/minga/minga-cli/README.md b/03_ukupacha/minga/minga-cli/README.md
new file mode 100644
index 0000000..9b5dacc
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/README.md
@@ -0,0 +1,17 @@
+# minga-cli
+
+> CLI of [minga](../README.md).
+
+Commands: `minga peer add/list`, `minga put/get`, `minga ls <vfs-path>`, `minga share <local-path>`.
+
+## Usage
+
+```sh
+cargo run --release -p minga-cli -- peer list
+cargo run --release -p minga-cli -- put /local/file
+```
+
+## Deps
+
+- All `minga-*`
+- `clap`
diff --git a/03_ukupacha/minga/minga-cli/src/bundle.rs b/03_ukupacha/minga/minga-cli/src/bundle.rs
new file mode 100644
index 0000000..0a0a554
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/src/bundle.rs
@@ -0,0 +1,455 @@
+//! Bundle: empaquetado offline de una raíz para transferencia sin red.
+//!
+//! Un `BundleV1` agrupa todo lo necesario para que un peer que recibe
+//! el archivo pueda reconstruir e integrar la raíz sin contactar al
+//! emisor:
+//! - el α-hash, struct_hash y dialect (binding RootDeclaration);
+//! - todos los `StoredNode`s alcanzables desde el struct_hash;
+//! - todas las atestaciones firmadas sobre el α-hash;
+//! - todas las retracciones firmadas sobre el α-hash.
+//!
+//! Se serializa con postcard — el mismo codec del wire de sync — y se
+//! escribe a un archivo. El receptor verifica criptográficamente cada
+//! pieza antes de mergear:
+//! - cada `StoredNode` entra por `put_chunked` (re-hashea y compara);
+//! - el α-hash se re-deriva de la raíz reconstruida bajo el dialect
+//! declarado y se compara contra el claimado en el bundle;
+//! - cada atestación / retracción re-verifica su firma Ed25519 en
+//! `add()` antes de persistirse.
+//!
+//! Es el equivalente "USB-stick" al wire libp2p: misma garantía de
+//! integridad, distinto transporte.
+
+use std::path::Path;
+
+use minga_core::{
+ alpha::hash_alpha_with, hash_stored, parse, Attestation, ContentHash, Retraction, StoredNode,
+};
+use minga_store::{keypair_file, PersistentRepo};
+
+use crate::commands::{unix_now_secs, KEYPAIR_FILENAME, REPO_DIRNAME};
+use crate::error::CliError;
+
+/// Versión actual del formato. Se serializa explícitamente para que el
+/// importador pueda rechazar (o adaptar) bundles de otra época.
+pub const BUNDLE_VERSION: u32 = 1;
+
+/// Magic prefix de un multi-bundle (varias raíces empacadas juntas).
+/// Si los primeros 4 bytes del archivo coinciden, el cuerpo restante es
+/// un `BundleMultiV1` postcard; si no, el archivo entero es un
+/// `BundleV1` clásico — esto preserva compat con bundles existentes
+/// sin tocar su layout.
+pub const MULTI_MAGIC: &[u8; 4] = b"MNGM";
+/// Variante zstd-comprimida del multi-bundle. El cuerpo tras el magic
+/// es un stream zstd que, descomprimido, produce exactamente el postcard
+/// de `BundleMultiV1`. El export nuevo siempre escribe esta variante; el
+/// import detecta `MNGM` (legacy, sin compresión) y `MNGZ` (comprimido)
+/// transparentemente — repos viejos siguen pudiendo importar y leer.
+pub const MULTI_MAGIC_ZSTD: &[u8; 4] = b"MNGZ";
+pub const MULTI_VERSION: u32 = 1;
+
+/// Nivel de compresión zstd para el export. 3 es el default del crate
+/// (rápido, ratio decente). Subir a 19+ exprime hasta 30 % extra pero
+/// triplica el tiempo de export — no vale para un caso "dump completo
+/// del repo a USB".
+const ZSTD_LEVEL: i32 = 3;
+
+/// El bundle serializable. El layout es estable: cualquier cambio de
+/// campos sube `BUNDLE_VERSION` y agrega una rama en `import`.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct BundleV1 {
+ pub version: u32,
+ pub alpha: ContentHash,
+ pub struct_hash: ContentHash,
+ /// Dialect serializado como byte para no atar el formato a una
+ /// variante específica de `Dialect`. Si el importador es más viejo
+ /// y no reconoce este byte, falla con `UnknownDialect` sin tocar
+ /// los stores.
+ pub dialect_byte: u8,
+ /// Todos los `StoredNode`s alcanzables desde `struct_hash` (DAG).
+ /// El orden es BFS por el emisor — el importador no lo necesita
+ /// (deduplica por hash) pero conservarlo facilita debug.
+ pub nodes: Vec<StoredNode>,
+ pub attestations: Vec<Attestation>,
+ pub retractions: Vec<Retraction>,
+}
+
+/// Estadísticas devueltas por `cmd_bundle_export`.
+#[derive(Debug, Clone, Copy)]
+pub struct BundleExportStats {
+ pub alpha: ContentHash,
+ pub nodes: usize,
+ pub attestations: usize,
+ pub retractions: usize,
+ pub bytes: usize,
+}
+
+/// Wrapper de múltiples bundles en un solo archivo. Se serializa con
+/// postcard precedido del prefijo `MULTI_MAGIC` para que el importador
+/// pueda distinguirlo de un `BundleV1` plano.
+#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
+pub struct BundleMultiV1 {
+ pub version: u32,
+ pub items: Vec<BundleV1>,
+}
+
+/// Estadísticas devueltas por `cmd_bundle_export_all`.
+#[derive(Debug, Clone)]
+pub struct BundleExportAllStats {
+ /// Raíces efectivamente empacadas.
+ pub roots: usize,
+ /// Raíces saltadas por falta de dialect persistido (typically
+ /// raíces sincronizadas bajo el wire pre-`RootDeclaration`).
+ pub skipped_missing_dialect: Vec<ContentHash>,
+ pub total_nodes: usize,
+ pub total_attestations: usize,
+ pub total_retractions: usize,
+ /// Tamaño final del archivo en disco (post-compresión).
+ pub bytes: usize,
+ /// Tamaño del postcard plano antes de comprimir — útil para ver el
+ /// ratio cuando interesa.
+ pub uncompressed_bytes: usize,
+}
+
+/// Estadísticas agregadas del import de un multi-bundle.
+#[derive(Debug, Clone)]
+pub struct BundleImportAllStats {
+ /// Resultado individual de cada raíz en el orden en que vino dentro
+ /// del multi-bundle.
+ pub items: Vec<BundleImportStats>,
+}
+
+impl BundleImportAllStats {
+ pub fn roots_new(&self) -> usize {
+ self.items.iter().filter(|s| s.root_was_new).count()
+ }
+ pub fn total_nodes_inserted(&self) -> usize {
+ self.items.iter().map(|s| s.nodes_inserted).sum()
+ }
+ pub fn total_attestations_added(&self) -> usize {
+ self.items.iter().map(|s| s.attestations_added).sum()
+ }
+ pub fn total_retractions_added(&self) -> usize {
+ self.items.iter().map(|s| s.retractions_added).sum()
+ }
+}
+
+/// `minga bundle export <α-hash> <out>`: serializa la raíz, todos los
+/// nodos alcanzables, atestaciones y retractions en un archivo
+/// postcard. Errores:
+/// - `HashNotFound` si el α-hash no es una raíz local;
+/// - `BundleMissingDialect` si la raíz fue sincronizada bajo el wire
+/// viejo (pre-`RootDeclaration`) y no tiene dialect persistido — sin
+/// dialect el receptor no puede re-verificar el α-hash.
+pub fn cmd_bundle_export(
+ repo_path: &Path,
+ passphrase: &str,
+ hash_hex: &str,
+ out: &Path,
+) -> Result<BundleExportStats, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let alpha = crate::commands::parse_hash_hex(hash_hex)?;
+ let bundle = build_bundle_for_root(&repo, alpha)?;
+
+ let bytes = postcard::to_allocvec(&bundle).map_err(|_| CliError::InvalidBundle)?;
+ std::fs::write(out, &bytes)?;
+
+ Ok(BundleExportStats {
+ alpha: bundle.alpha,
+ nodes: bundle.nodes.len(),
+ attestations: bundle.attestations.len(),
+ retractions: bundle.retractions.len(),
+ bytes: bytes.len(),
+ })
+}
+
+/// Empaqueta todas las raíces del repo en un solo archivo (multi-bundle).
+/// Raíces sin dialect persistido (sync'd bajo el wire viejo) se saltan y
+/// se reportan en `skipped_missing_dialect` — el caller decide si eso es
+/// fatal o se acepta como degradación.
+pub fn cmd_bundle_export_all(
+ repo_path: &Path,
+ passphrase: &str,
+ out: &Path,
+) -> Result<BundleExportAllStats, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let mut items: Vec<BundleV1> = Vec::new();
+ let mut skipped: Vec<ContentHash> = Vec::new();
+ for entry in repo.roots.iter() {
+ let (alpha, _struct_hash, dialect_opt) = entry?;
+ if dialect_opt.is_none() {
+ skipped.push(alpha);
+ continue;
+ }
+ items.push(build_bundle_for_root(&repo, alpha)?);
+ }
+
+ let total_nodes: usize = items.iter().map(|b| b.nodes.len()).sum();
+ let total_attestations: usize = items.iter().map(|b| b.attestations.len()).sum();
+ let total_retractions: usize = items.iter().map(|b| b.retractions.len()).sum();
+ let roots = items.len();
+
+ let multi = BundleMultiV1 {
+ version: MULTI_VERSION,
+ items,
+ };
+ let body = postcard::to_allocvec(&multi).map_err(|_| CliError::InvalidBundle)?;
+ let uncompressed_bytes = body.len();
+ let compressed = zstd::encode_all(body.as_slice(), ZSTD_LEVEL).map_err(CliError::Io)?;
+
+ let mut bytes = Vec::with_capacity(MULTI_MAGIC_ZSTD.len() + compressed.len());
+ bytes.extend_from_slice(MULTI_MAGIC_ZSTD);
+ bytes.extend_from_slice(&compressed);
+ std::fs::write(out, &bytes)?;
+
+ Ok(BundleExportAllStats {
+ roots,
+ skipped_missing_dialect: skipped,
+ total_nodes,
+ total_attestations,
+ total_retractions,
+ bytes: bytes.len(),
+ uncompressed_bytes,
+ })
+}
+
+/// Construye un `BundleV1` para una raíz registrada en `roots`. Es el
+/// core compartido entre el export single y el export-all: BFS por el
+/// DAG estructural + agrega atestaciones y retracciones de esa α.
+fn build_bundle_for_root(
+ repo: &PersistentRepo,
+ alpha: ContentHash,
+) -> Result<BundleV1, CliError> {
+ use std::collections::HashSet;
+
+ let (struct_hash, dialect_opt) = repo
+ .roots
+ .get(&alpha)?
+ .ok_or(CliError::HashNotFound(alpha))?;
+ let dialect = dialect_opt.ok_or(CliError::BundleMissingDialect(alpha))?;
+
+ // BFS por el DAG de StoredNodes desde la raíz estructural. Los
+ // hashes se dedupean en `visited`; el orden en `nodes` es estable
+ // por iteración pero no esencial — el importador no lo asume.
+ let mut visited: HashSet<ContentHash> = HashSet::new();
+ let mut nodes: Vec<StoredNode> = Vec::new();
+ let mut frontier: Vec<ContentHash> = vec![struct_hash];
+ while let Some(h) = frontier.pop() {
+ if !visited.insert(h) {
+ continue;
+ }
+ let stored = repo
+ .nodes
+ .get(&h)?
+ .ok_or(CliError::HashNotFound(h))?;
+ for c in &stored.children {
+ if !visited.contains(c) {
+ frontier.push(*c);
+ }
+ }
+ nodes.push(stored);
+ }
+
+ let attestations = repo.attestations.get(&alpha)?;
+ let retractions = repo.retractions.get(&alpha)?;
+
+ Ok(BundleV1 {
+ version: BUNDLE_VERSION,
+ alpha,
+ struct_hash,
+ dialect_byte: dialect.as_byte(),
+ nodes,
+ attestations,
+ retractions,
+ })
+}
+
+/// Estadísticas devueltas por `cmd_bundle_import`.
+#[derive(Debug, Clone, Copy)]
+pub struct BundleImportStats {
+ pub alpha: ContentHash,
+ /// `StoredNode`s recién agregados al `nodes` tree (los que ya
+ /// estaban se omiten silenciosamente).
+ pub nodes_inserted: usize,
+ /// Atestaciones nuevas (no había una para `(content, author)`).
+ pub attestations_added: usize,
+ /// Atestaciones cuya firma falló — descartadas sin tocar el store.
+ pub attestations_rejected: usize,
+ pub retractions_added: usize,
+ pub retractions_rejected: usize,
+ /// `true` cuando la raíz quedó registrada en `roots`/`mst` (i.e.
+ /// no estaba ya). `false` para imports idempotentes.
+ pub root_was_new: bool,
+}
+
+/// `minga bundle import <archivo>`: deserializa un bundle, verifica
+/// criptográficamente cada pieza, y mergea idempotentemente en los
+/// stores locales. Si algo falla (versión incompatible, dialect
+/// desconocido, α-hash inconsistente, postcard malformado), aborta
+/// sin haber tocado nada — los `StoredNode`s pasan por `put_chunked`
+/// que verifica el hash, pero el tree es append-only así que reintentos
+/// son seguros.
+pub fn cmd_bundle_import(
+ repo_path: &Path,
+ passphrase: &str,
+ in_path: &Path,
+) -> Result<BundleImportStats, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let bytes = std::fs::read(in_path)?;
+ if is_multi_bundle_magic(&bytes) {
+ return Err(CliError::ExpectedSingleBundle);
+ }
+ let bundle: BundleV1 = postcard::from_bytes(&bytes).map_err(|_| CliError::InvalidBundle)?;
+ let stats = import_one(&repo, bundle)?;
+ repo.flush()?;
+ Ok(stats)
+}
+
+/// `true` si los primeros bytes son `MNGM` (multi-bundle legacy) o
+/// `MNGZ` (multi-bundle comprimido). Helper compartido entre
+/// `cmd_bundle_import` (que lo usa para rechazar multi) y la detección
+/// previa al strip del prefijo en `import_all`.
+fn is_multi_bundle_magic(bytes: &[u8]) -> bool {
+ bytes.len() >= MULTI_MAGIC.len()
+ && (&bytes[..MULTI_MAGIC.len()] == MULTI_MAGIC
+ || &bytes[..MULTI_MAGIC_ZSTD.len()] == MULTI_MAGIC_ZSTD)
+}
+
+/// Importa un multi-bundle (formato `MULTI_MAGIC + BundleMultiV1`). Si
+/// el archivo es un single-bundle clásico, lo reportamos como error
+/// para que el caller elija entre `bundle import` y `bundle import-all`.
+pub fn cmd_bundle_import_all(
+ repo_path: &Path,
+ passphrase: &str,
+ in_path: &Path,
+) -> Result<BundleImportAllStats, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let bytes = std::fs::read(in_path)?;
+ let body: Vec<u8> = if bytes.len() >= MULTI_MAGIC_ZSTD.len()
+ && &bytes[..MULTI_MAGIC_ZSTD.len()] == MULTI_MAGIC_ZSTD
+ {
+ zstd::decode_all(&bytes[MULTI_MAGIC_ZSTD.len()..]).map_err(CliError::Io)?
+ } else if bytes.len() >= MULTI_MAGIC.len() && &bytes[..MULTI_MAGIC.len()] == MULTI_MAGIC {
+ bytes[MULTI_MAGIC.len()..].to_vec()
+ } else {
+ return Err(CliError::ExpectedMultiBundle);
+ };
+ let multi: BundleMultiV1 =
+ postcard::from_bytes(&body).map_err(|_| CliError::InvalidBundle)?;
+ if multi.version != MULTI_VERSION {
+ return Err(CliError::UnsupportedBundleVersion(multi.version));
+ }
+
+ let mut items = Vec::with_capacity(multi.items.len());
+ for bundle in multi.items {
+ items.push(import_one(&repo, bundle)?);
+ }
+ repo.flush()?;
+ Ok(BundleImportAllStats { items })
+}
+
+/// Core compartido entre import single y multi. No flushea (el caller
+/// lo hace una sola vez al final para amortizar I/O en el multi).
+fn import_one(repo: &PersistentRepo, bundle: BundleV1) -> Result<BundleImportStats, CliError> {
+ if bundle.version != BUNDLE_VERSION {
+ return Err(CliError::UnsupportedBundleVersion(bundle.version));
+ }
+
+ let dialect = parse::Dialect::from_byte(bundle.dialect_byte)
+ .ok_or(CliError::UnknownDialect(bundle.dialect_byte))?;
+
+ // 1) Insertar nodos con verificación de hash. put_chunked rechaza
+ // si `hash_stored(stored) != hash`, así que un bundle adulterado
+ // se detecta en la primera entrada inconsistente.
+ let mut nodes_inserted = 0usize;
+ for stored in &bundle.nodes {
+ let h = hash_stored(stored);
+ if !repo.nodes.contains(&h)? {
+ repo.nodes.put_chunked(h, stored)?;
+ nodes_inserted += 1;
+ }
+ }
+
+ // 2) Reconstruir el SemanticNode de la raíz para re-derivar α.
+ let root_node = repo
+ .nodes
+ .reconstruct(&bundle.struct_hash)?
+ .ok_or(CliError::HashNotFound(bundle.struct_hash))?;
+ let computed_alpha = hash_alpha_with(dialect, &root_node);
+ if computed_alpha != bundle.alpha {
+ return Err(CliError::BundleAlphaMismatch {
+ struct_hash: bundle.struct_hash,
+ claimed_alpha: bundle.alpha,
+ });
+ }
+
+ // 3) Registrar la raíz si es nueva. `roots.put` y `mst.insert` son
+ // idempotentes — el flag `root_was_new` lo computamos antes.
+ let root_was_new = !repo.roots.contains(&bundle.alpha)?;
+ repo.roots.put(bundle.alpha, bundle.struct_hash, dialect)?;
+ repo.mst.insert(bundle.alpha)?;
+
+ // 4) Atestaciones — `add()` re-verifica firma Ed25519. Las que
+ // tengan content != bundle.alpha (no deberían existir, pero…) se
+ // descartan: la atestación es sobre OTRA raíz, no nos sirve acá.
+ let now_secs = unix_now_secs();
+ let mut atts_added = 0usize;
+ let mut atts_rejected = 0usize;
+ for att in bundle.attestations {
+ if att.content != bundle.alpha {
+ atts_rejected += 1;
+ continue;
+ }
+ let existed = repo
+ .attestations
+ .get(&att.content)?
+ .iter()
+ .any(|a| a.author == att.author);
+ match repo.attestations.add(att.clone()) {
+ Ok(()) => {
+ if !existed {
+ atts_added += 1;
+ }
+ let _ = repo.timestamps.put(&att.content, &att.author, now_secs);
+ }
+ Err(_) => atts_rejected += 1,
+ }
+ }
+
+ // 5) Retracciones — misma lógica, mismo filtro por content.
+ let mut rets_added = 0usize;
+ let mut rets_rejected = 0usize;
+ for r in bundle.retractions {
+ if r.content != bundle.alpha {
+ rets_rejected += 1;
+ continue;
+ }
+ let existed = repo.retractions.contains(&r.content, &r.author)?;
+ match repo.retractions.add(r) {
+ Ok(()) => {
+ if !existed {
+ rets_added += 1;
+ }
+ }
+ Err(_) => rets_rejected += 1,
+ }
+ }
+
+ Ok(BundleImportStats {
+ alpha: bundle.alpha,
+ nodes_inserted,
+ attestations_added: atts_added,
+ attestations_rejected: atts_rejected,
+ retractions_added: rets_added,
+ retractions_rejected: rets_rejected,
+ root_was_new,
+ })
+}
diff --git a/03_ukupacha/minga/minga-cli/src/commands.rs b/03_ukupacha/minga/minga-cli/src/commands.rs
new file mode 100644
index 0000000..08b7d1d
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/src/commands.rs
@@ -0,0 +1,1354 @@
+//! Implementaciones de los subcomandos. Funciones puras que retornan
+//! datos estructurados — el binario las llama y formatea la salida.
+//!
+//! Layout en disco bajo `repo_path/`:
+//! - `keypair` — la `Keypair` del peer cifrada con passphrase.
+//! - `repo/` — directorio sled con `nodes`, `attestations`, `mst`.
+
+use std::fs;
+use std::path::Path;
+use std::time::Duration;
+
+use libp2p::{multiaddr::Protocol, Multiaddr, PeerId};
+use minga_core::{
+ alpha::hash_alpha_with, parse, Attestation, ContentHash, Did, Keypair, Retraction,
+};
+use minga_p2p::MingaPeer;
+use minga_store::{keypair_file, PersistentRepo};
+
+use crate::error::CliError;
+
+pub const KEYPAIR_FILENAME: &str = "keypair";
+pub const REPO_DIRNAME: &str = "repo";
+
+#[derive(Debug, Clone)]
+pub struct RepoStatus {
+ pub did: Did,
+ pub mst_len: usize,
+ pub nodes_len: usize,
+ pub attestations_len: usize,
+ pub roots_len: usize,
+}
+
+#[derive(Debug, Clone)]
+pub struct IngestResult {
+ /// α-hash de la raíz: identidad del archivo, estable bajo
+ /// renombrado de variables ligadas.
+ pub alpha: ContentHash,
+ /// Hash estructural de la raíz dentro del grafo CAS.
+ pub struct_hash: ContentHash,
+ pub did: Did,
+ pub dialect: parse::Dialect,
+}
+
+/// `minga init`: genera un keypair fresco, crea el repo persistente,
+/// y guarda el keypair cifrado.
+pub fn cmd_init(repo_path: &Path, passphrase: &str) -> Result<Did, CliError> {
+ if repo_path.exists() {
+ // Si el directorio existe pero está vacío, lo aceptamos.
+ // Si tiene cualquier cosa, abortamos para no pisar un repo.
+ let mut entries = fs::read_dir(repo_path)?;
+ if entries.next().is_some() {
+ return Err(CliError::AlreadyExists(repo_path.to_path_buf()));
+ }
+ } else {
+ fs::create_dir_all(repo_path)?;
+ }
+
+ let keypair = Keypair::generate();
+ keypair_file::save(&keypair, repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+
+ // Crear el repo sled vacío. Se cierra al final del scope; el
+ // siguiente comando lo reabre.
+ let _ = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ Ok(keypair.did())
+}
+
+/// `minga status`: descifra el keypair, abre el repo, devuelve
+/// estadísticas básicas.
+pub fn cmd_status(repo_path: &Path, passphrase: &str) -> Result<RepoStatus, CliError> {
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ Ok(RepoStatus {
+ did: keypair.did(),
+ mst_len: repo.mst.len(),
+ nodes_len: repo.nodes.len(),
+ attestations_len: repo.attestations.len(),
+ roots_len: repo.roots.len(),
+ })
+}
+
+/// `minga ingest <file>`: parsea el archivo con tree-sitter, inserta
+/// el AST en el grafo CAS, calcula su α-hash (estable bajo renombrado
+/// de variables ligadas), lo registra como raíz del MST y crea una
+/// atestación firmada por el dueño del keypair (auto-firma de autoría).
+pub fn cmd_ingest(
+ repo_path: &Path,
+ passphrase: &str,
+ file: &Path,
+) -> Result<IngestResult, CliError> {
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let source = fs::read_to_string(file)?;
+ let dialect = detect_dialect(file)?;
+ let node = dialect.parse(&source)?;
+ let (alpha, struct_hash) = ingest_node_alpha(&repo, &keypair, dialect, &node)?;
+ // Anexamos al historial path → α para que `minga blame` pueda
+ // atribuir cada línea actual al α que la introdujo. Errores de
+ // canonicalize se tratan como "no hay historial" — la ingesta
+ // funcional ya está completa.
+ if let Some(path_key) = canonical_path_key(file) {
+ let now_secs = unix_now_secs();
+ let _ = repo.paths.append(&path_key, alpha, now_secs);
+ let _ = repo.alpha_paths.record(alpha, &path_key, now_secs);
+ }
+ repo.flush()?;
+
+ Ok(IngestResult {
+ alpha,
+ struct_hash,
+ did: keypair.did(),
+ dialect,
+ })
+}
+
+/// Canonicaliza `path` y lo convierte a String para usar como clave
+/// del historial. `None` si el archivo no existe o `canonicalize`
+/// falla (filesystem inusual). El callsite debe degradar
+/// silenciosamente — la ingesta principal no depende de esto.
+fn canonical_path_key(path: &Path) -> Option<String> {
+ let abs = fs::canonicalize(path).ok()?;
+ Some(abs.to_string_lossy().into_owned())
+}
+
+pub(crate) fn unix_now_secs() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// Ingiere un nodo ya parseado: lo desempaqueta en el grafo CAS,
+/// calcula su α-hash, lo registra como raíz y atesta autoría. Compartido
+/// entre `cmd_ingest` y el bucle interno de `cmd_watch`.
+fn ingest_node_alpha(
+ repo: &PersistentRepo,
+ keypair: &Keypair,
+ dialect: parse::Dialect,
+ node: &minga_core::SemanticNode,
+) -> Result<(ContentHash, ContentHash), CliError> {
+ let struct_hash = repo.nodes.put(node)?;
+ let alpha = hash_alpha_with(dialect, node);
+ repo.roots.put(alpha, struct_hash, dialect)?;
+ repo.mst.insert(alpha)?;
+ let att = Attestation::create(keypair, alpha);
+ repo.attestations.add(att.clone())?;
+ let now_secs = unix_now_secs();
+ repo.timestamps.put(&att.content, &att.author, now_secs)?;
+ Ok((alpha, struct_hash))
+}
+
+/// Resultado de `cmd_sign`: confirmación con metadata sobre si la firma
+/// es nueva (vouching genuino) o redundante (idempotencia).
+#[derive(Debug, Clone)]
+pub struct SignResult {
+ pub alpha: ContentHash,
+ pub author: Did,
+ /// `false` si el repo ya tenía una atestación local de este author
+ /// sobre `alpha` (re-firmar es idempotente, no se duplica entrada).
+ /// `true` cuando esta es la primera vez que este DID firma esa raíz.
+ pub is_new_attestation: bool,
+ /// `true` si el α-hash está en el tree `roots`. Cuando es `false`,
+ /// se firma igual — útil para vouching de fragmentos del CAS o de
+ /// raíces que aún no llegaron por sync — pero el CLI lo avisa al
+ /// usuario para que no firme algo desconocido por error de tipeo.
+ pub is_known_root: bool,
+}
+
+/// `minga sign <α-hash>`: emite una atestación bajo el keypair local
+/// sobre un α-hash existente. A diferencia de `ingest` (que firma como
+/// efecto secundario de versionar contenido propio), `sign` es
+/// **vouching explícito**: Alice ingiere, Bob sincroniza, Bob firma
+/// con `sign` — la raíz queda con dos atestaciones independientes,
+/// permitiendo "co-autoría" semántica o aval de revisores.
+///
+/// Re-firmar la misma raíz con el mismo keypair es idempotente:
+/// `SledAttestationStore` indexa por `content || author`, así que la
+/// segunda inserción reemplaza la primera con bytes idénticos.
+pub fn cmd_sign(
+ repo_path: &Path,
+ passphrase: &str,
+ hash_hex: &str,
+) -> Result<SignResult, CliError> {
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let alpha = parse_hash_hex(hash_hex)?;
+ let did = keypair.did();
+
+ // ¿Ya habíamos firmado este α con este DID? Sólo para reportar al
+ // caller; la firma se emite igual y el store la deduplica sola.
+ let already = repo
+ .attestations
+ .get(&alpha)?
+ .iter()
+ .any(|a| a.author == did);
+ let is_known_root = repo.roots.contains(&alpha)?;
+
+ let att = Attestation::create(&keypair, alpha);
+ repo.attestations.add(att.clone())?;
+ repo.timestamps
+ .put(&att.content, &att.author, unix_now_secs())?;
+ repo.flush()?;
+
+ Ok(SignResult {
+ alpha,
+ author: did,
+ is_new_attestation: !already,
+ is_known_root,
+ })
+}
+
+/// Una entrada de `cmd_signers`: quién firmó la raíz, cuándo, y si
+/// también la retractó.
+#[derive(Debug, Clone)]
+pub struct SignerEntry {
+ pub author: Did,
+ /// Timestamp local de cuándo se observó la atestación. `0` si no
+ /// hay timestamp (atestación vieja sin entry en
+ /// `SledTimestampStore`).
+ pub ts_secs: u64,
+ /// `true` si el mismo `author` también firmó una `Retraction` sobre
+ /// esta raíz — declara que avaló y luego revocó. La atestación
+ /// original sigue presente como prueba histórica.
+ pub retracted: bool,
+}
+
+/// `minga signers <α-hash>`: lista los DIDs que han atestado una raíz.
+/// Complementa `cmd_sign` ofreciendo la vista "quién avaló esto" sin
+/// pasar por `cmd_log` (que mezcla todas las raíces).
+///
+/// Salida ordenada por timestamp local descendente (más reciente
+/// primero). Marca con `retracted = true` a los DIDs que también
+/// emitieron una retracción — útil para visualizar cambios de postura
+/// en la cadena de aval.
+///
+/// `since_secs` filtra firmas observadas antes de ese instante Unix
+/// (utilidad: feed "qué hay nuevo en este repo" para integraciones tipo
+/// shuma/minga-explorer). Atestaciones sin timestamp (`ts_secs == 0`,
+/// típicas en repos viejos sin `SledTimestampStore`) se excluyen cuando
+/// se aplica el filtro — no podemos decir si son recientes.
+pub fn cmd_signers(
+ repo_path: &Path,
+ passphrase: &str,
+ hash_hex: &str,
+ since_secs: Option<u64>,
+) -> Result<Vec<SignerEntry>, CliError> {
+ use std::collections::HashSet;
+
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let alpha = parse_hash_hex(hash_hex)?;
+
+ let atts = repo.attestations.get(&alpha)?;
+ let retractions = repo.retractions.get(&alpha)?;
+ let retract_authors: HashSet<Did> = retractions.into_iter().map(|r| r.author).collect();
+
+ let mut entries: Vec<SignerEntry> = atts
+ .into_iter()
+ .map(|a| {
+ let ts = repo
+ .timestamps
+ .get(&a.content, &a.author)
+ .ok()
+ .flatten()
+ .unwrap_or(0);
+ SignerEntry {
+ author: a.author,
+ ts_secs: ts,
+ retracted: retract_authors.contains(&a.author),
+ }
+ })
+ .filter(|e| match since_secs {
+ Some(cut) => e.ts_secs >= cut,
+ None => true,
+ })
+ .collect();
+
+ entries.sort_by(|a, b| b.ts_secs.cmp(&a.ts_secs).then(a.author.0.cmp(&b.author.0)));
+ Ok(entries)
+}
+
+/// Una entrada del log: atestación + timestamp de cuándo se observó.
+#[derive(Debug, Clone)]
+pub struct LogEntry {
+ pub alpha: ContentHash,
+ pub struct_hash: Option<ContentHash>,
+ pub dialect: Option<parse::Dialect>,
+ pub author: Did,
+ pub ts_secs: u64,
+ /// Si `true`, esta entrada coincide con el archivo señalado por el
+ /// caller (vía `cmd_log` con `Some(path)`). Sólo se calcula cuando
+ /// hay path: en `cmd_log(None)` siempre es `false`.
+ pub current: bool,
+}
+
+/// `minga log [path]`: enumera las atestaciones del repo ordenadas por
+/// timestamp descendente. Si `path` es `Some`, computa su α-hash actual
+/// y marca la entrada coincidente con `current = true`.
+pub fn cmd_log(
+ repo_path: &Path,
+ passphrase: &str,
+ path: Option<&Path>,
+) -> Result<Vec<LogEntry>, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let current_alpha = match path {
+ Some(p) => {
+ let source = fs::read_to_string(p)?;
+ let dialect = detect_dialect(p)?;
+ let node = dialect.parse(&source)?;
+ Some(hash_alpha_with(dialect, &node))
+ }
+ None => None,
+ };
+
+ let mut entries: Vec<LogEntry> = Vec::new();
+ for att in repo.attestations.iter() {
+ let att = att?;
+ let ts = repo
+ .timestamps
+ .get(&att.content, &att.author)?
+ .unwrap_or(0);
+ let (struct_hash, dialect) = match repo.roots.get(&att.content)? {
+ Some((sh, dl)) => (Some(sh), dl),
+ None => (None, None),
+ };
+ let current = current_alpha
+ .as_ref()
+ .map(|a| a == &att.content)
+ .unwrap_or(false);
+ entries.push(LogEntry {
+ alpha: att.content,
+ struct_hash,
+ dialect,
+ author: att.author,
+ ts_secs: ts,
+ current,
+ });
+ }
+ // Más recientes primero. Empate por hash para orden estable.
+ entries.sort_by(|a, b| b.ts_secs.cmp(&a.ts_secs).then(a.alpha.0.cmp(&b.alpha.0)));
+ Ok(entries)
+}
+
+/// Resultado de `cmd_show`: la fuente reconstruida (forma canónica) o
+/// el árbol como S-expression, según `mode`.
+#[derive(Debug, Clone)]
+pub struct ShowResult {
+ pub alpha: Option<ContentHash>,
+ pub struct_hash: ContentHash,
+ pub dialect: Option<parse::Dialect>,
+ /// `true` si el `hash` recibido era un α-hash (raíz registrada).
+ /// `false` si era un hash estructural directo.
+ pub is_root: bool,
+ pub rendered: String,
+}
+
+/// `minga show <hash>`: pinta el contenido del nodo identificado por
+/// `hash`. Acepta α-hashes (raíces) y hashes estructurales del grafo
+/// interno. `as_sexp = true` devuelve el árbol literal del store; `false`
+/// (default) devuelve el código fuente reconstruido en forma canónica.
+pub fn cmd_show(
+ repo_path: &Path,
+ passphrase: &str,
+ hash_hex: &str,
+ as_sexp: bool,
+) -> Result<ShowResult, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let hash = parse_hash_hex(hash_hex)?;
+
+ // ¿Es α-hash de una raíz registrada?
+ let (alpha, struct_hash, dialect, is_root) = match repo.roots.get(&hash)? {
+ Some((sh, dl)) => (Some(hash), sh, dl, true),
+ None => (None, hash, None, false),
+ };
+
+ let stored_node = repo
+ .nodes
+ .reconstruct(&struct_hash)?
+ .ok_or(CliError::HashNotFound(struct_hash))?;
+
+ let rendered = if as_sexp {
+ minga_vfs::render_sexp(&stored_node)
+ } else {
+ minga_vfs::render_source(&stored_node)
+ };
+
+ Ok(ShowResult {
+ alpha,
+ struct_hash,
+ dialect,
+ is_root,
+ rendered,
+ })
+}
+
+pub(crate) fn parse_hash_hex(s: &str) -> Result<ContentHash, CliError> {
+ let bytes = hex_decode_32(s).ok_or(CliError::InvalidHash(s.to_string()))?;
+ Ok(ContentHash(bytes))
+}
+
+/// Resuelve un hash que puede ser α (raíz) o struct (nodo interno) al
+/// struct-hash con el cual lookupar en `nodes`. Si es raíz, también
+/// retorna el dialect persistido.
+fn resolve_hash(
+ repo: &PersistentRepo,
+ hash: ContentHash,
+) -> Result<(ContentHash, Option<parse::Dialect>, bool), CliError> {
+ match repo.roots.get(&hash)? {
+ Some((sh, dl)) => Ok((sh, dl, true)),
+ None => Ok((hash, None, false)),
+ }
+}
+
+/// Una línea del diff. `Same` se preserva tal cual; `Add`/`Remove` se
+/// marcan con `+`/`-` en la salida unified.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum DiffLine {
+ Same(String),
+ Add(String),
+ Remove(String),
+}
+
+/// Resultado de `cmd_diff`: metadata de ambos lados + líneas resultantes.
+#[derive(Debug, Clone)]
+pub struct DiffResult {
+ pub left_hash: ContentHash,
+ pub right_hash: ContentHash,
+ pub left_dialect: Option<parse::Dialect>,
+ pub right_dialect: Option<parse::Dialect>,
+ pub left_is_root: bool,
+ pub right_is_root: bool,
+ pub lines: Vec<DiffLine>,
+ /// Cuántas líneas son `Add`.
+ pub additions: usize,
+ /// Cuántas líneas son `Remove`.
+ pub deletions: usize,
+}
+
+/// `minga diff <left> <right>`: reconstruye ambos nodos y emite el
+/// diff unified entre sus `render_source`. Acepta α-hashes (raíces) y
+/// hashes estructurales.
+pub fn cmd_diff(
+ repo_path: &Path,
+ passphrase: &str,
+ left_hex: &str,
+ right_hex: &str,
+) -> Result<DiffResult, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let left_hash = parse_hash_hex(left_hex)?;
+ let right_hash = parse_hash_hex(right_hex)?;
+
+ let (left_struct, left_dialect, left_is_root) = resolve_hash(&repo, left_hash)?;
+ let (right_struct, right_dialect, right_is_root) = resolve_hash(&repo, right_hash)?;
+
+ let left_node = repo
+ .nodes
+ .reconstruct(&left_struct)?
+ .ok_or(CliError::HashNotFound(left_struct))?;
+ let right_node = repo
+ .nodes
+ .reconstruct(&right_struct)?
+ .ok_or(CliError::HashNotFound(right_struct))?;
+
+ let left_text = minga_vfs::render_source(&left_node);
+ let right_text = minga_vfs::render_source(&right_node);
+
+ let diff = similar::TextDiff::from_lines(&left_text, &right_text);
+ let mut lines = Vec::new();
+ let mut additions = 0;
+ let mut deletions = 0;
+ for change in diff.iter_all_changes() {
+ let text = change.to_string();
+ // `similar` incluye el `\n` final en cada línea — lo conservamos
+ // para que el caller pueda imprimir sin reflowing.
+ match change.tag() {
+ similar::ChangeTag::Equal => lines.push(DiffLine::Same(text)),
+ similar::ChangeTag::Insert => {
+ additions += 1;
+ lines.push(DiffLine::Add(text));
+ }
+ similar::ChangeTag::Delete => {
+ deletions += 1;
+ lines.push(DiffLine::Remove(text));
+ }
+ }
+ }
+
+ Ok(DiffResult {
+ left_hash,
+ right_hash,
+ left_dialect,
+ right_dialect,
+ left_is_root,
+ right_is_root,
+ lines,
+ additions,
+ deletions,
+ })
+}
+
+fn hex_decode_32(s: &str) -> Option<[u8; 32]> {
+ if s.len() != 64 {
+ return None;
+ }
+ let mut out = [0u8; 32];
+ for (i, chunk) in s.as_bytes().chunks(2).enumerate() {
+ let hi = nibble(chunk[0])?;
+ let lo = nibble(chunk[1])?;
+ out[i] = (hi << 4) | lo;
+ }
+ Some(out)
+}
+
+fn nibble(b: u8) -> Option<u8> {
+ match b {
+ b'0'..=b'9' => Some(b - b'0'),
+ b'a'..=b'f' => Some(b - b'a' + 10),
+ b'A'..=b'F' => Some(b - b'A' + 10),
+ _ => None,
+ }
+}
+
+/// Una línea de `cmd_blame`: el texto tal como aparece en el archivo
+/// actual, más el α-hash que la introdujo (la primera versión del
+/// archivo que la contenía) con su timestamp y autor.
+#[derive(Debug, Clone)]
+pub struct BlameLine {
+ pub text: String,
+ pub alpha: ContentHash,
+ pub ts_secs: u64,
+ pub author: Did,
+}
+
+/// `minga blame <path>`: para cada línea del archivo actual, devuelve
+/// el α-hash que la introdujo. Reconstruye la cadena de versiones del
+/// path desde su historial, ejecuta diffs línea-a-línea entre versiones
+/// consecutivas, y propaga la atribución hacia adelante: las líneas
+/// nuevas en una versión se atribuyen a ella; las preservadas heredan
+/// la atribución de la versión anterior.
+///
+/// Necesita que el path haya sido ingerido al menos una vez (vía
+/// `minga ingest` o el `cmd_watch`). El archivo en disco se ignora —
+/// la blame es contra la **última** versión registrada en el historial,
+/// no contra la copia actual no-ingerida.
+pub fn cmd_blame(
+ repo_path: &Path,
+ passphrase: &str,
+ file: &Path,
+) -> Result<Vec<BlameLine>, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let path_key = canonical_path_key(file)
+ .ok_or_else(|| CliError::PathNotIngested(file.to_path_buf()))?;
+ let history = repo.paths.history(&path_key)?;
+ if history.is_empty() {
+ return Err(CliError::PathNotIngested(file.to_path_buf()));
+ }
+
+ // Atribución por línea para la versión "current" del walk. La
+ // representamos como Vec<(line_text, attribution_index)>, donde
+ // attribution_index apunta a una entrada de `history` (el α que
+ // introdujo esa línea). Empezamos con la versión más vieja: todas
+ // sus líneas se atribuyen a su propio α.
+ let oldest_source = source_for_alpha(&repo, &history[0].0)?;
+ let mut current_lines: Vec<(String, usize)> = oldest_source
+ .lines()
+ .map(|l| (l.to_string(), 0_usize))
+ .collect();
+
+ // Avanzamos por la historia: en cada paso, computamos diff entre
+ // current_lines y la fuente de la siguiente versión, y construimos
+ // la nueva lista de líneas atribuidas preservando attributions de
+ // las que no cambiaron y asignando el nuevo α a las insertadas.
+ for (idx, (alpha, _ts)) in history.iter().enumerate().skip(1) {
+ let new_source = source_for_alpha(&repo, alpha)?;
+ let current_text: String = current_lines
+ .iter()
+ .map(|(t, _)| t.as_str())
+ .collect::<Vec<_>>()
+ .join("\n");
+ let diff = similar::TextDiff::from_lines(¤t_text, &new_source);
+ let mut next: Vec<(String, usize)> = Vec::new();
+ let mut old_idx = 0usize;
+ for change in diff.iter_all_changes() {
+ // `similar` agrega `\n` al final de cada línea — lo
+ // quitamos para uniformidad con la entrada original
+ // (que vino de .lines()).
+ let mut text = change.to_string();
+ if text.ends_with('\n') {
+ text.pop();
+ }
+ match change.tag() {
+ similar::ChangeTag::Equal => {
+ let attr = current_lines
+ .get(old_idx)
+ .map(|(_, a)| *a)
+ .unwrap_or(idx);
+ next.push((text, attr));
+ old_idx += 1;
+ }
+ similar::ChangeTag::Delete => {
+ old_idx += 1;
+ }
+ similar::ChangeTag::Insert => {
+ next.push((text, idx));
+ }
+ }
+ }
+ current_lines = next;
+ }
+
+ // Resolvemos cada attribution_index a su BlameLine completa.
+ // Para `author`/`ts` consultamos la atestación local sobre el α.
+ let mut out = Vec::with_capacity(current_lines.len());
+ for (text, attr_idx) in current_lines {
+ let (alpha, ts) = history[attr_idx];
+ let author = first_author_for(&repo, &alpha).unwrap_or(Did([0u8; 32]));
+ out.push(BlameLine {
+ text,
+ alpha,
+ ts_secs: ts,
+ author,
+ });
+ }
+ Ok(out)
+}
+
+/// Reconstruye la fuente canónica del α-hash de una raíz. Resuelve
+/// vía `roots` (α → struct), reconstruye y renderea.
+fn source_for_alpha(
+ repo: &PersistentRepo,
+ alpha: &ContentHash,
+) -> Result<String, CliError> {
+ let (struct_hash, _, _) = resolve_hash(repo, *alpha)?;
+ let node = repo
+ .nodes
+ .reconstruct(&struct_hash)?
+ .ok_or(CliError::HashNotFound(struct_hash))?;
+ Ok(minga_vfs::render_source(&node))
+}
+
+/// Devuelve el primer DID que firmó una atestación sobre `alpha`.
+/// `None` si la raíz no tiene atestaciones registradas localmente.
+fn first_author_for(repo: &PersistentRepo, alpha: &ContentHash) -> Option<Did> {
+ let atts = repo.attestations.get(alpha).ok()?;
+ atts.first().map(|a| a.author)
+}
+
+/// Una fila de `cmd_roots`: una raíz registrada en el repo con metadata
+/// agregada de paths e historial de atestaciones.
+#[derive(Debug, Clone)]
+pub struct RootRow {
+ pub alpha: ContentHash,
+ pub struct_hash: ContentHash,
+ pub dialect: Option<parse::Dialect>,
+ /// Path local donde se ingirió este α por última vez. `None` si la
+ /// raíz vino por sync sin pasar nunca por un path local, o si el
+ /// historial path→α no la cubre.
+ pub path: Option<String>,
+ /// Timestamp Unix de la atestación más reciente sobre esta raíz
+ /// (cualquier autor). `0` si no hay timestamps locales registrados.
+ pub last_seen_secs: u64,
+ /// Cuántas atestaciones distintas hay almacenadas localmente.
+ pub attestations: usize,
+}
+
+/// `minga roots`: lista todas las raíces registradas en el repo con
+/// path conocido, dialect, fecha de última atestación y cantidad de
+/// firmas. Ordenado por `last_seen_secs` descendente — empate por
+/// α-hash para estabilidad.
+///
+/// Cierra la asimetría histórica entre `status` (sólo da counts) y
+/// `show <hash>` (que exige conocer el hash de antemano): permite
+/// descubrir las raíces de un repo sin levantar el explorer Llimphi
+/// ni el módulo shuma.
+pub fn cmd_roots(repo_path: &Path, passphrase: &str) -> Result<Vec<RootRow>, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let mut rows = Vec::new();
+ for r in repo.roots.iter() {
+ let (alpha, struct_hash, dialect) = r?;
+ let atts = repo.attestations.get(&alpha)?;
+ let attestations = atts.len();
+ let last_seen_secs = atts
+ .iter()
+ .filter_map(|a| repo.timestamps.get(&a.content, &a.author).ok().flatten())
+ .max()
+ .unwrap_or(0);
+ // Reverse-index persistente: lookup directo por prefijo α en
+ // lugar de reconstruir el mapa en RAM. Ver
+ // `minga_store::SledAlphaPathsStore`.
+ let path = repo.alpha_paths.most_recent_path(&alpha)?;
+ rows.push(RootRow {
+ alpha,
+ struct_hash,
+ dialect,
+ path,
+ last_seen_secs,
+ attestations,
+ });
+ }
+ rows.sort_by(|a, b| {
+ b.last_seen_secs
+ .cmp(&a.last_seen_secs)
+ .then(a.alpha.0.cmp(&b.alpha.0))
+ });
+ Ok(rows)
+}
+
+/// Una fila de `cmd_history`: una versión histórica de un path.
+#[derive(Debug, Clone)]
+pub struct HistoryEntry {
+ pub alpha: ContentHash,
+ pub ts_secs: u64,
+ pub dialect: Option<parse::Dialect>,
+ /// `true` si el α-hash actual del archivo en disco coincide con esta
+ /// entrada. Si el archivo no existe o no se puede parsear hoy, todas
+ /// las filas vienen con `current = false`.
+ pub current: bool,
+}
+
+/// `minga history <path>`: dumpea el historial path→α (poblado por
+/// `ingest`/`watch`) para `path`. Útil para ver cuándo cambió un
+/// archivo sin reconstruir el blame completo (que ya requiere correr
+/// diff línea-a-línea entre cada par de versiones consecutivas).
+///
+/// Salida cronológica **descendente** (la versión más reciente arriba),
+/// igual que `cmd_log`. Si el archivo todavía existe en disco y su
+/// α-hash actual coincide con alguna entrada, esa entrada lleva
+/// `current = true`.
+pub fn cmd_history(
+ repo_path: &Path,
+ passphrase: &str,
+ file: &Path,
+) -> Result<Vec<HistoryEntry>, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let path_key = canonical_path_key(file)
+ .ok_or_else(|| CliError::PathNotIngested(file.to_path_buf()))?;
+ let history = repo.paths.history(&path_key)?;
+ if history.is_empty() {
+ return Err(CliError::PathNotIngested(file.to_path_buf()));
+ }
+
+ // α-hash del contenido actual en disco, si parseamos sin error.
+ // Best-effort: si el archivo se movió o ya no parsea, todas las
+ // filas salen sin el marcador `current`.
+ let current_alpha = file
+ .exists()
+ .then(|| try_current_alpha(file))
+ .flatten();
+
+ let mut out = Vec::with_capacity(history.len());
+ for (alpha, ts) in history {
+ let dialect = repo.roots.get(&alpha)?.and_then(|(_, d)| d);
+ let current = current_alpha.as_ref().map(|a| a == &alpha).unwrap_or(false);
+ out.push(HistoryEntry {
+ alpha,
+ ts_secs: ts,
+ dialect,
+ current,
+ });
+ }
+ // Más reciente primero, idéntica convención que `cmd_log`.
+ out.reverse();
+ Ok(out)
+}
+
+/// Best-effort: lee `file`, detecta dialect y devuelve su α-hash actual.
+/// Cualquier error (io, dialect, parse) → `None` — el caller degrada
+/// silenciosamente al modo "sin marcador current".
+fn try_current_alpha(file: &Path) -> Option<ContentHash> {
+ let source = fs::read_to_string(file).ok()?;
+ let dialect = detect_dialect(file).ok()?;
+ let node = dialect.parse(&source).ok()?;
+ Some(hash_alpha_with(dialect, &node))
+}
+
+/// Resultado de `cmd_verify_root`: si la raíz es consistente y bajo
+/// qué dialecto (independiente del que figure en `roots`).
+#[derive(Debug, Clone)]
+pub struct VerifyResult {
+ pub alpha: ContentHash,
+ pub struct_hash: ContentHash,
+ /// Dialecto registrado en el tree `roots` al momento de ingerir.
+ /// `None` si la raíz no estaba registrada (caso "huérfano":
+ /// puede pasar tras sync si el wire no transmite el binding).
+ pub stored_dialect: Option<parse::Dialect>,
+ /// Dialecto bajo el cual `hash_alpha_with(d, &node) == alpha`.
+ /// `None` significa **inconsistente** — el α-hash claimado no
+ /// se corresponde con el contenido del nodo bajo ningún
+ /// dialecto soportado.
+ pub verified_dialect: Option<parse::Dialect>,
+}
+
+impl VerifyResult {
+ pub fn is_consistent(&self) -> bool {
+ self.verified_dialect.is_some()
+ }
+
+ pub fn matches_stored(&self) -> bool {
+ match (self.stored_dialect, self.verified_dialect) {
+ (Some(a), Some(b)) => a == b,
+ (None, Some(_)) => true, // sin info previa, lo verificado es lo bueno
+ _ => false,
+ }
+ }
+}
+
+/// `minga verify <hash>`: reconstruye el nodo al que apunta `hash`
+/// (asumido α-hash de una raíz) y verifica que algún dialect produce
+/// ese hash sobre ese contenido. Útil para auditar repos sincronizados
+/// donde el remitente no es 100 % confiable.
+pub fn cmd_verify_root(
+ repo_path: &Path,
+ passphrase: &str,
+ hash_hex: &str,
+) -> Result<VerifyResult, CliError> {
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let alpha = parse_hash_hex(hash_hex)?;
+ let (struct_hash, stored_dialect, _is_root) = resolve_hash(&repo, alpha)?;
+
+ let node = repo
+ .nodes
+ .reconstruct(&struct_hash)?
+ .ok_or(CliError::HashNotFound(struct_hash))?;
+
+ let verified_dialect = minga_core::alpha::verify_root_alpha(&node, &alpha);
+
+ Ok(VerifyResult {
+ alpha,
+ struct_hash,
+ stored_dialect,
+ verified_dialect,
+ })
+}
+
+/// Resultado de `cmd_prune`: cuántos nodos había antes, cuántos
+/// quedaron vivos (alcanzables desde alguna raíz), cuántos se borraron.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct PruneStats {
+ pub before: usize,
+ pub alive: usize,
+ pub removed: usize,
+ pub roots: usize,
+}
+
+/// `minga prune`: mark-sweep del grafo CAS. Marca todos los nodos
+/// alcanzables desde alguna raíz del tree `roots` (siguiendo los
+/// `children` recursivamente) y borra del tree `nodes` los que no
+/// quedaron marcados. Idempotente: correr dos veces seguidas no
+/// elimina nada en la segunda pasada.
+///
+/// Las atestaciones, retracciones y timestamps quedan intactos —
+/// referencian α-hashes (no struct-hashes) y son históricos.
+pub fn cmd_prune(repo_path: &Path, passphrase: &str) -> Result<PruneStats, CliError> {
+ use std::collections::HashSet;
+
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let before = repo.nodes.len();
+
+ // 1. Recolectar los struct-hashes raíz a partir de `roots`.
+ let mut roots_set: HashSet<ContentHash> = HashSet::new();
+ let mut roots_count = 0usize;
+ for r in repo.roots.iter() {
+ let (_alpha, struct_hash, _dialect) = r?;
+ roots_set.insert(struct_hash);
+ roots_count += 1;
+ }
+
+ // 2. Mark: BFS desde cada raíz por `children_of` (lee sólo los
+ // hashes, sin reconstruir el SemanticNode completo).
+ let mut alive_set: HashSet<ContentHash> = HashSet::new();
+ let mut frontier: Vec<ContentHash> = roots_set.into_iter().collect();
+ while let Some(h) = frontier.pop() {
+ if !alive_set.insert(h) {
+ continue; // ya visitado
+ }
+ if let Some(children) = repo.nodes.children_of(&h)? {
+ for c in children {
+ if !alive_set.contains(&c) {
+ frontier.push(c);
+ }
+ }
+ }
+ // Si `children_of` devolvió None, el hash es huérfano (en el
+ // tree `roots` pero NO en `nodes` — debería ser imposible bajo
+ // ingest sano; pasamos de él en silencio).
+ }
+ let alive = alive_set.len();
+
+ // 3. Sweep: borrar todo lo que no quedó vivo.
+ let all_hashes: Vec<ContentHash> = repo
+ .nodes
+ .iter_hashes()
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut removed = 0usize;
+ for h in &all_hashes {
+ if !alive_set.contains(h) {
+ if repo.nodes.remove(h)? {
+ removed += 1;
+ }
+ }
+ }
+ repo.flush()?;
+
+ Ok(PruneStats {
+ before,
+ alive,
+ removed,
+ roots: roots_count,
+ })
+}
+
+/// Resultado de `cmd_retire`: confirmación con metadata.
+#[derive(Debug, Clone)]
+pub struct RetireResult {
+ pub alpha: ContentHash,
+ pub author: Did,
+ /// `true` si la raíz existía en el MST antes de la retracción
+ /// (caso esperado). `false` si el hash no era una raíz conocida
+ /// (la retracción se firma igual — funciona como "declaración de
+ /// no autoría" útil para sync, aunque el efecto local es nulo).
+ pub was_root: bool,
+}
+
+/// `minga retire <hash>`: emite una atestación negativa firmada
+/// declarando que el dueño del keypair ya no respalda `hash`. Quita la
+/// entrada del MST y de `roots`, persiste la retracción en su tree
+/// propio. Las atestaciones originales NO se borran (siguen como
+/// prueba histórica de que en algún momento se firmaron).
+pub fn cmd_retire(
+ repo_path: &Path,
+ passphrase: &str,
+ hash_hex: &str,
+) -> Result<RetireResult, CliError> {
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let alpha = parse_hash_hex(hash_hex)?;
+ let was_root = repo.roots.contains(&alpha)?;
+
+ let retraction = Retraction::create(&keypair, alpha);
+ repo.retractions.add(retraction)?;
+
+ // Quitar del MST y de roots (los nodos del CAS quedan: pueden estar
+ // referenciados por otras raíces o ser navegables vía cas/<hash>).
+ repo.mst.remove(&alpha)?;
+ repo.roots.remove(&alpha)?;
+ repo.flush()?;
+
+ Ok(RetireResult {
+ alpha,
+ author: keypair.did(),
+ was_root,
+ })
+}
+
+/// `minga mount <punto>`: monta el repositorio como un filesystem FUSE
+/// de sólo lectura. Cada hash del store se vuelve un archivo
+/// navegable con `ls`/`cat`. Bloquea hasta que se desmonte el punto
+/// (`fusermount -u <punto>` o una señal al proceso).
+pub fn cmd_mount(
+ repo_path: &Path,
+ passphrase: &str,
+ mountpoint: &Path,
+) -> Result<(), CliError> {
+ // Cargar el keypair valida la passphrase: montar es navegar el
+ // repo, así que pedimos la misma credencial que `status`/`watch`.
+ let _keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+ minga_vfs::mount(minga_vfs::RepoSource::new(repo), mountpoint)?;
+ Ok(())
+}
+
+/// Detecta el dialecto del archivo en tres pasos cada vez más caros:
+/// 1. **Extensión** (`.rs`, `.py`, …) — sin abrir el archivo.
+/// 2. **Shebang** (primera línea) — un read_line.
+/// 3. **Contenido** — parsea con cada gramática y elige la que produce
+/// el AST con menos errores. Para esto sí leemos el archivo entero.
+///
+/// Error si los tres pasos fallan.
+fn detect_dialect(file: &Path) -> Result<parse::Dialect, CliError> {
+ let ext = file
+ .extension()
+ .and_then(|e| e.to_str())
+ .unwrap_or("");
+ if let Some(d) = parse::detect_by_extension(ext) {
+ return Ok(d);
+ }
+ // Shebang: leer sólo la primera línea.
+ if let Ok(f) = fs::File::open(file) {
+ use std::io::{BufRead, BufReader};
+ let mut first = String::new();
+ if BufReader::new(f).read_line(&mut first).is_ok() {
+ if let Some(d) = parse::detect_by_shebang(&first) {
+ return Ok(d);
+ }
+ }
+ }
+ // Fallback caro: leer el contenido y probar cada parser.
+ if let Ok(source) = fs::read_to_string(file) {
+ if let Some(d) = parse::detect_by_content(&source) {
+ return Ok(d);
+ }
+ }
+ Err(CliError::UnsupportedLanguage {
+ path: file.to_path_buf(),
+ extension: ext.to_string(),
+ })
+}
+
+/// `minga listen <addr>`: arranca el peer, escucha en `addr`, y
+/// acepta sincronizaciones entrantes hasta que el proceso se cierre.
+pub async fn cmd_listen(
+ repo_path: &Path,
+ passphrase: &str,
+ addr: &str,
+) -> Result<Multiaddr, CliError> {
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let did = keypair.did();
+ let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?;
+ let multi: Multiaddr = addr
+ .parse()
+ .map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?;
+ let actual = peer.listen(multi).await;
+ let _accept = peer.run_passive_accept();
+
+ // Bootstrap del DHT: anuncia todas las raíces locales como
+ // proveedores en Kademlia. Los peers que busquen el α-hash de un
+ // archivo que tengamos podrán descubrirnos sin conocer nuestro
+ // multiaddr de antemano (siempre que compartan al menos un peer
+ // bootstrap de la malla `brahman-net`).
+ let announced = peer.announce_all_roots().await;
+
+ // Bloqueamos para siempre mientras la task de accept procesa
+ // sincronizaciones. El usuario cierra con Ctrl+C.
+ println!("Escuchando en: {}", actual);
+ println!("DID Minga: {}", did);
+ println!("PeerID libp2p: {}", peer.peer_id());
+ if announced > 0 {
+ println!("Anunciadas {} raíces en el DHT", announced);
+ }
+ futures::future::pending::<()>().await;
+
+ Ok(actual)
+}
+
+/// `minga sync <target>`: dializa al peer y ejecuta una sincronización
+/// completa con él. `target` puede ser:
+/// - un **multiaddr libp2p** con `/p2p/<peer_id>` — se conecta directo;
+/// - un **α-hash en hex** (64 caracteres) — busca proveedores en el DHT
+/// (vía [`minga_p2p::MingaPeer::find_providers`]) y sincroniza con el
+/// primero que responda. Hay que tener al menos un peer bootstrap
+/// conocido (vía `add_dht_peer`) o el lookup no devuelve resultados.
+pub async fn cmd_sync(
+ repo_path: &Path,
+ passphrase: &str,
+ target: &str,
+) -> Result<(), CliError> {
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let peer = MingaPeer::open(keypair, repo_path.join(REPO_DIRNAME))?;
+
+ // ¿Es un α-hash hex? Si lo es, vamos por la rama DHT.
+ if target.len() == 64 && target.chars().all(|c| c.is_ascii_hexdigit()) {
+ let hash = parse_hash_hex(target)?;
+ let providers = peer.find_providers(hash).await;
+ if providers.is_empty() {
+ return Err(CliError::NoProvidersForHash(hash));
+ }
+ let deadline = std::time::Instant::now() + Duration::from_secs(10);
+ for pid in providers {
+ if peer.sync_with(pid).await.is_ok() {
+ return Ok(());
+ }
+ if std::time::Instant::now() >= deadline {
+ return Err(CliError::SyncTimeout);
+ }
+ }
+ return Err(CliError::SyncTimeout);
+ }
+
+ // Rama multiaddr clásica.
+ let multi: Multiaddr = target
+ .parse()
+ .map_err(|e: libp2p::multiaddr::Error| CliError::Multiaddr(e.to_string()))?;
+ let peer_id = extract_peer_id(&multi).ok_or(CliError::NoPeerIdInMultiaddr)?;
+
+ peer.dial(multi);
+
+ let deadline = std::time::Instant::now() + Duration::from_secs(10);
+ loop {
+ if peer.sync_with(peer_id).await.is_ok() {
+ return Ok(());
+ }
+ if std::time::Instant::now() >= deadline {
+ return Err(CliError::SyncTimeout);
+ }
+ tokio::time::sleep(Duration::from_millis(100)).await;
+ }
+}
+
+fn extract_peer_id(addr: &Multiaddr) -> Option<PeerId> {
+ addr.iter().find_map(|p| match p {
+ Protocol::P2p(peer_id) => Some(peer_id),
+ _ => None,
+ })
+}
+
+/// Estadísticas devueltas por `cmd_ingest_dir`: cuántos archivos
+/// soportados se vieron, cuántos se ingirieron sin error, y la lista
+/// de fallos para que el CLI los reporte.
+#[derive(Debug, Clone)]
+pub struct BulkIngestStats {
+ /// Archivos que pasaron `is_supported_source` (extensión o shebang).
+ pub seen: usize,
+ /// De esos, cuántos terminaron en el grafo.
+ pub ingested: usize,
+ /// Fallos individuales — la ingesta sigue tras un error.
+ pub failed: Vec<(std::path::PathBuf, String)>,
+}
+
+/// `minga ingest-dir <dir> [--recursive]`: ingiere todos los archivos
+/// soportados de un directorio en una sola pasada. Es básicamente
+/// `initial_scan` (el bootstrap interno de `watch`) expuesto como
+/// one-shot, para versionar un repo entero sin dejar el watcher
+/// corriendo.
+///
+/// En modo recursivo, **omite directorios ocultos** (los que empiezan
+/// con `.`): evita pisar `.git`, `.minga`, `.venv` y similares — son
+/// fuentes de ruido (archivos generados, repos anidados). Si necesitás
+/// versionar un dot-dir explícitamente, llamalo con `--recursive` desde
+/// dentro o pasalo como `dir` raíz.
+pub fn cmd_ingest_dir(
+ repo_path: &Path,
+ passphrase: &str,
+ dir: &Path,
+ recursive: bool,
+) -> Result<BulkIngestStats, CliError> {
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ let mut stats = BulkIngestStats {
+ seen: 0,
+ ingested: 0,
+ failed: Vec::new(),
+ };
+ walk_and_ingest(&repo, &keypair, dir, recursive, &mut stats);
+ repo.flush()?;
+ Ok(stats)
+}
+
+fn walk_and_ingest(
+ repo: &PersistentRepo,
+ keypair: &Keypair,
+ dir: &Path,
+ recursive: bool,
+ stats: &mut BulkIngestStats,
+) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let p = entry.path();
+ // Saltar dot-dirs sólo en descenso recursivo. El directorio raíz
+ // pasado por el usuario puede ser oculto y se respeta.
+ if p.is_dir() {
+ if recursive && !is_hidden_dirname(&p) {
+ walk_and_ingest(repo, keypair, &p, recursive, stats);
+ }
+ continue;
+ }
+ if is_supported_source(&p) {
+ stats.seen += 1;
+ match ingest_into_repo(repo, keypair, &p) {
+ Ok(_) => stats.ingested += 1,
+ Err(e) => stats.failed.push((p, e.to_string())),
+ }
+ }
+ }
+}
+
+/// `true` si el último componente del path empieza con `.`. Usamos esto
+/// para podar descenso en `ingest-dir --recursive` y evitar `.git`,
+/// `.minga`, `.venv`, etc.
+fn is_hidden_dirname(p: &Path) -> bool {
+ p.file_name()
+ .and_then(|n| n.to_str())
+ .map(|n| n.starts_with('.'))
+ .unwrap_or(false)
+}
+
+/// `minga watch <dir>`: vigila un directorio, re-parsea y re-ingesta
+/// cualquier archivo soportado que se cree o modifique. Si un archivo
+/// se borra, retira su última raíz del MST y de `roots`. Los nodos del
+/// grafo CAS NO se eliminan (pueden estar compartidos con otras raíces).
+pub async fn cmd_watch(
+ repo_path: &Path,
+ passphrase: &str,
+ watch_dir: &Path,
+) -> Result<(), CliError> {
+ use std::collections::HashMap;
+
+ let keypair = keypair_file::load(repo_path.join(KEYPAIR_FILENAME), passphrase)?;
+ let repo = PersistentRepo::open(repo_path.join(REPO_DIRNAME))?;
+
+ // Tracker en memoria: path → α-hash más reciente ingerido en esta
+ // sesión. Necesario para resolver el "remove": cuando notify nos
+ // dice "este path desapareció" sabemos cuál hash retirar.
+ let mut path_to_alpha: HashMap<std::path::PathBuf, ContentHash> = HashMap::new();
+
+ // Pasada inicial: ingerimos todos los archivos soportados ya
+ // presentes y registramos su α-hash en el tracker.
+ initial_scan(&repo, &keypair, watch_dir, &mut path_to_alpha);
+
+ // Canal entre el callback síncrono de notify y el bucle async.
+ let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
+ let mut watcher = notify::recommended_watcher(
+ move |res: Result<notify::Event, notify::Error>| {
+ if let Ok(event) = res {
+ let _ = tx.send(event);
+ }
+ },
+ )?;
+ notify::Watcher::watch(&mut watcher, watch_dir, notify::RecursiveMode::Recursive)?;
+
+ while let Some(event) = rx.recv().await {
+ match event.kind {
+ notify::EventKind::Create(_) | notify::EventKind::Modify(_) => {
+ for path in &event.paths {
+ if is_supported_source(path) {
+ match ingest_into_repo(&repo, &keypair, path) {
+ Ok(hash) => {
+ path_to_alpha.insert(path.clone(), hash);
+ eprintln!("ingerido: {} → {}", path.display(), hash);
+ }
+ Err(e) => {
+ eprintln!(
+ "warning: {} no se pudo ingerir: {}",
+ path.display(),
+ e
+ );
+ }
+ }
+ }
+ }
+ }
+ notify::EventKind::Remove(_) => {
+ for path in &event.paths {
+ if let Some(hash) = path_to_alpha.remove(path) {
+ match retire_root(&repo, &hash) {
+ Ok(()) => eprintln!("retirado: {} (era {})", path.display(), hash),
+ Err(e) => eprintln!(
+ "warning: no se pudo retirar {}: {}",
+ path.display(),
+ e
+ ),
+ }
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ Ok(())
+}
+
+fn initial_scan(
+ repo: &PersistentRepo,
+ keypair: &Keypair,
+ dir: &Path,
+ tracker: &mut std::collections::HashMap<std::path::PathBuf, ContentHash>,
+) {
+ let Ok(entries) = fs::read_dir(dir) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let p = entry.path();
+ if is_supported_source(&p) {
+ if let Ok(hash) = ingest_into_repo(repo, keypair, &p) {
+ tracker.insert(p, hash);
+ }
+ }
+ }
+}
+
+fn ingest_into_repo(
+ repo: &PersistentRepo,
+ keypair: &Keypair,
+ file: &Path,
+) -> Result<ContentHash, CliError> {
+ let source = fs::read_to_string(file)?;
+ let dialect = detect_dialect(file)?;
+ let node = dialect.parse(&source)?;
+ let (alpha, _struct_hash) = ingest_node_alpha(repo, keypair, dialect, &node)?;
+ if let Some(path_key) = canonical_path_key(file) {
+ let ts = unix_now_secs();
+ let _ = repo.paths.append(&path_key, alpha, ts);
+ let _ = repo.alpha_paths.record(alpha, &path_key, ts);
+ }
+ repo.flush()?;
+ Ok(alpha)
+}
+
+/// Retira una raíz del MST y del tree `roots`. **No** borra los nodos
+/// del grafo CAS — quedan disponibles para `cas/<hash>` y por si otra
+/// raíz los referencia. Las atestaciones tampoco se borran: registran
+/// que el contenido existió en algún momento.
+fn retire_root(repo: &PersistentRepo, alpha: &ContentHash) -> Result<(), CliError> {
+ repo.mst.remove(alpha)?;
+ repo.roots.remove(alpha)?;
+ repo.flush()?;
+ Ok(())
+}
+
+/// Detecta si un archivo debe ingerirse: existe, es regular, y o bien
+/// su extensión corresponde a un dialecto soportado, o bien su primera
+/// línea tiene un shebang reconocible. `watch` se apoya en esto para
+/// no llamar a `read_to_string` sobre archivos ajenos.
+fn is_supported_source(path: &Path) -> bool {
+ if !path.is_file() {
+ return false;
+ }
+ let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
+ if parse::detect_by_extension(ext).is_some() {
+ return true;
+ }
+ if let Ok(f) = fs::File::open(path) {
+ use std::io::{BufRead, BufReader};
+ let mut first = String::new();
+ if BufReader::new(f).read_line(&mut first).is_ok() {
+ return parse::detect_by_shebang(&first).is_some();
+ }
+ }
+ false
+}
diff --git a/03_ukupacha/minga/minga-cli/src/error.rs b/03_ukupacha/minga/minga-cli/src/error.rs
new file mode 100644
index 0000000..1806631
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/src/error.rs
@@ -0,0 +1,109 @@
+use std::path::PathBuf;
+
+#[derive(Debug, thiserror::Error)]
+pub enum CliError {
+ #[error("io: {0}")]
+ Io(#[from] std::io::Error),
+
+ #[error("keypair file: {0}")]
+ KeypairFile(#[from] minga_store::KeypairFileError),
+
+ #[error("store: {0}")]
+ Store(#[from] minga_store::StoreError),
+
+ #[error("attestation: {0}")]
+ Attestation(#[from] minga_core::AttestationError),
+
+ #[error("parse: {0}")]
+ Parse(#[from] minga_core::parse::ParseError),
+
+ #[error("network: {0}")]
+ Network(#[from] minga_p2p::NodeError),
+
+ #[error("peer open: {0}")]
+ PeerOpen(#[from] minga_p2p::PeerOpenError),
+
+ #[error("peer sync: {0}")]
+ PeerSync(#[from] minga_p2p::PeerSyncError),
+
+ #[error("multiaddr inválido: {0}")]
+ Multiaddr(String),
+
+ #[error("el directorio del repo ya existe: {0}")]
+ AlreadyExists(PathBuf),
+
+ #[error("el multiaddr no incluye `/p2p/<peer_id>`")]
+ NoPeerIdInMultiaddr,
+
+ #[error("timeout esperando conexión")]
+ SyncTimeout,
+
+ #[error("notify (file watcher): {0}")]
+ Notify(#[from] notify::Error),
+
+ #[error(
+ "lenguaje no soportado para {path}: extensión '{extension}' no mapea \
+ a ningún dialecto conocido (rs, py, pyi, ts, js, mjs, cjs, go)"
+ )]
+ UnsupportedLanguage { path: PathBuf, extension: String },
+
+ #[error("hash hex inválido: '{0}' (esperado 64 caracteres hex)")]
+ InvalidHash(String),
+
+ #[error("hash no encontrado en el repo: {0}")]
+ HashNotFound(minga_core::ContentHash),
+
+ #[error("ningún peer del DHT anuncia ser proveedor de {0}")]
+ NoProvidersForHash(minga_core::ContentHash),
+
+ #[error(
+ "el path {0} no tiene historial de ingesta — corré `minga ingest` o \
+ `minga watch` primero"
+ )]
+ PathNotIngested(PathBuf),
+
+ #[error("bundle malformado: postcard no pudo decodificarlo")]
+ InvalidBundle,
+
+ #[error(
+ "versión de bundle {0} no soportada por este binario (esperado 1) — \
+ actualizá minga o re-exportá desde la versión que generó el archivo"
+ )]
+ UnsupportedBundleVersion(u32),
+
+ #[error(
+ "el dialecto del bundle (byte {0}) no es reconocido por este binario \
+ — el archivo lo generó una versión más nueva con soporte para un \
+ lenguaje que esta no entiende"
+ )]
+ UnknownDialect(u8),
+
+ #[error(
+ "la raíz del bundle ({struct_hash}) no produce el α-hash declarado \
+ ({claimed_alpha}) bajo el dialecto declarado — bundle corrupto o \
+ falsificado"
+ )]
+ BundleAlphaMismatch {
+ struct_hash: minga_core::ContentHash,
+ claimed_alpha: minga_core::ContentHash,
+ },
+
+ #[error(
+ "la raíz {0} no tiene dialect registrado en `roots` — exportá una \
+ raíz que haya sido ingerida (no sincronizada bajo el viejo wire \
+ pre-RootDeclaration)"
+ )]
+ BundleMissingDialect(minga_core::ContentHash),
+
+ #[error(
+ "el archivo es un multi-bundle — usá `minga bundle import-all` para \
+ importar todas las raíces de una vez"
+ )]
+ ExpectedSingleBundle,
+
+ #[error(
+ "el archivo es un bundle single — usá `minga bundle import` para \
+ importar una raíz individual"
+ )]
+ ExpectedMultiBundle,
+}
diff --git a/03_ukupacha/minga/minga-cli/src/lib.rs b/03_ukupacha/minga/minga-cli/src/lib.rs
new file mode 100644
index 0000000..1a15d94
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/src/lib.rs
@@ -0,0 +1,26 @@
+//! `minga-cli`: subcomandos del CLI de Minga.
+//!
+//! La CLI expone funciones puras (`commands`) que retornan `Result`
+//! con la información estructurada. El binario `minga` (en `main.rs`)
+//! solo parsea argumentos, prompts de passphrase, y formatea la
+//! salida. Esa separación hace los comandos directamente testeables
+//! sin spawn de subprocesos.
+
+pub mod bundle;
+pub mod commands;
+pub mod error;
+pub mod serve;
+
+pub use bundle::{
+ cmd_bundle_export, cmd_bundle_export_all, cmd_bundle_import, cmd_bundle_import_all,
+ BundleExportAllStats, BundleExportStats, BundleImportAllStats, BundleImportStats,
+};
+pub use commands::{
+ cmd_blame, cmd_diff, cmd_history, cmd_ingest, cmd_ingest_dir, cmd_init, cmd_listen, cmd_log,
+ cmd_mount, cmd_prune, cmd_retire, cmd_roots, cmd_show, cmd_sign, cmd_signers, cmd_status,
+ cmd_sync, cmd_verify_root, cmd_watch, BlameLine, BulkIngestStats, DiffLine, DiffResult,
+ HistoryEntry, IngestResult, LogEntry, PruneStats, RepoStatus, RetireResult, RootRow,
+ ShowResult, SignResult, SignerEntry, VerifyResult,
+};
+pub use error::CliError;
+pub use serve::cmd_serve;
diff --git a/03_ukupacha/minga/minga-cli/src/main.rs b/03_ukupacha/minga/minga-cli/src/main.rs
new file mode 100644
index 0000000..a261df6
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/src/main.rs
@@ -0,0 +1,713 @@
+//! Binario `minga`: argument parsing y formateo de salida.
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::{Parser, Subcommand};
+use minga_cli::{
+ cmd_blame, cmd_bundle_export, cmd_bundle_export_all, cmd_bundle_import, cmd_bundle_import_all,
+ cmd_diff, cmd_history, cmd_ingest, cmd_ingest_dir, cmd_init, cmd_listen, cmd_log, cmd_mount,
+ cmd_prune, cmd_retire, cmd_roots, cmd_serve, cmd_show, cmd_sign, cmd_signers, cmd_status,
+ cmd_sync, cmd_verify_root, cmd_watch, CliError, DiffLine,
+};
+
+#[derive(Parser)]
+#[command(
+ name = "minga",
+ version,
+ about = "Minga: VCS semántico P2P. Versiona AST, no líneas."
+)]
+struct Cli {
+ /// Ruta del repositorio Minga. Por defecto: `.minga` en el cwd.
+ #[arg(short, long, default_value = ".minga", global = true)]
+ repo: PathBuf,
+
+ #[command(subcommand)]
+ command: Command,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// Inicializa un nuevo repo: genera keypair Ed25519, lo cifra con
+ /// passphrase, y crea el almacén persistente vacío.
+ Init,
+
+ /// Muestra DID, tamaño del MST, nodos almacenados y atestaciones.
+ Status,
+
+ /// Parsea un archivo Rust, lo añade al MST y firma una atestación
+ /// de autoría con la identidad del repo.
+ Ingest {
+ /// Ruta del archivo .rs a ingerir.
+ file: PathBuf,
+ },
+
+ /// Ingiere todos los archivos soportados de un directorio en una
+ /// sola pasada (sin dejar `watch` corriendo). Útil para versionar un
+ /// repo entero on-demand.
+ IngestDir {
+ /// Directorio a recorrer.
+ dir: PathBuf,
+ /// Descender recursivamente. Los dot-dirs (`.git`, `.minga`, …)
+ /// se saltan durante el descenso para evitar ruido.
+ #[arg(short, long)]
+ recursive: bool,
+ },
+
+ /// Firma una atestación bajo el keypair local sobre un α-hash
+ /// existente — vouching colaborativo. A diferencia de `ingest`, no
+ /// crea contenido nuevo: sólo declara aval sobre algo ya en el repo
+ /// (típicamente traído por `sync` desde otro peer).
+ Sign {
+ /// α-hash en hex (64 caracteres).
+ hash: String,
+ },
+
+ /// Escucha conexiones de peers en una multiaddr libp2p y acepta
+ /// sincronizaciones entrantes hasta Ctrl+C.
+ Listen {
+ /// Multiaddr libp2p, ej. `/ip4/0.0.0.0/tcp/4001`.
+ addr: String,
+ },
+
+ /// Sincroniza una vez con un peer remoto (multiaddr con `/p2p/<id>`).
+ Sync {
+ /// Multiaddr completo, ej. `/ip4/1.2.3.4/tcp/4001/p2p/12D3KooW...`.
+ peer: String,
+ },
+
+ /// Vigila un directorio y re-ingiere automáticamente cualquier
+ /// archivo `.rs` que se cree o modifique. Minga como VCS de fondo:
+ /// el usuario escribe en su editor y el código queda versionado.
+ Watch {
+ /// Directorio a vigilar.
+ dir: PathBuf,
+ },
+
+ /// Monta el repositorio como filesystem FUSE de sólo lectura.
+ /// Cada hash del store se vuelve un archivo navegable con
+ /// `ls`/`cat`. Bloquea hasta `fusermount -u <punto>`.
+ Mount {
+ /// Punto de montaje: un directorio existente.
+ point: PathBuf,
+ },
+
+ /// Lista atestaciones del repo ordenadas por timestamp descendente.
+ /// Si se pasa un archivo, marca la entrada cuyo α-hash coincide
+ /// con el contenido actual del archivo.
+ Log {
+ /// Archivo cuyo α-hash debe marcarse como "current" (opcional).
+ file: Option<PathBuf>,
+ },
+
+ /// Pinta el contenido del nodo identificado por `hash`. Acepta
+ /// α-hashes (raíces) y hashes estructurales del grafo interno.
+ Show {
+ /// Hash en hex (64 caracteres).
+ hash: String,
+ /// Si se pasa, devuelve el árbol como S-expression en vez de
+ /// la fuente reconstruida.
+ #[arg(long)]
+ sexp: bool,
+ /// Compara contra otro hash (atajo de `minga diff`). Mutuamente
+ /// excluyente con `--sexp`.
+ #[arg(long = "diff-against")]
+ diff_against: Option<String>,
+ },
+
+ /// Compara dos versiones del repo (típicamente dos α-hashes) y
+ /// muestra el diff unified de sus `render_source`.
+ Diff {
+ /// Hash izquierdo en hex.
+ left: String,
+ /// Hash derecho en hex.
+ right: String,
+ },
+
+ /// Retira una raíz: emite una atestación negativa firmada por el
+ /// keypair del repo y quita el α-hash del MST/`roots`. Las
+ /// atestaciones originales se conservan como prueba histórica.
+ Retire {
+ /// α-hash en hex (64 caracteres).
+ hash: String,
+ },
+
+ /// Verifica que el α-hash de una raíz local es consistente con su
+ /// contenido bajo algún dialecto soportado. Útil para auditar
+ /// raíces traídas por sync (cuyo dialect no necesariamente está
+ /// registrado, o cuyo remitente puede no ser confiable).
+ Verify {
+ /// α-hash en hex (64 caracteres).
+ hash: String,
+ },
+
+ /// Recolector de basura del grafo CAS: borra nodos no alcanzables
+ /// desde ninguna raíz (típicamente quedan tras `retire`/`watch`
+ /// Remove). Idempotente.
+ Prune,
+
+ /// Para cada línea del archivo registrado, muestra el α-hash que la
+ /// introdujo. Reconstruye la cadena de versiones del path desde su
+ /// historial (poblado por `ingest`/`watch`) y propaga la atribución
+ /// hacia adelante con diffs línea-a-línea.
+ Blame {
+ /// Archivo cuyo historial atribuir.
+ file: PathBuf,
+ },
+
+ /// Lista todas las raíces del repo con su path conocido, dialect,
+ /// fecha de última atestación y cantidad de firmas. Ordenado por
+ /// actividad reciente — útil cuando no recordás un α-hash.
+ Roots,
+
+ /// Historial cronológico (más reciente primero) de un path: cada
+ /// α-hash que pasó por esa ruta vía `ingest`/`watch`. Marca con `*`
+ /// la entrada cuyo α coincide con el contenido actual del archivo.
+ History {
+ /// Archivo cuyo historial mostrar.
+ file: PathBuf,
+ },
+
+ /// Lista los DIDs que han atestado un α-hash, con timestamp local
+ /// de cuándo se observó la firma. Marca quienes también firmaron
+ /// una retracción posterior.
+ Signers {
+ /// α-hash en hex (64 caracteres).
+ hash: String,
+ /// Filtra firmas observadas desde ese instante. Acepta:
+ /// `YYYY-MM-DD` (medianoche UTC), o un relativo con sufijo
+ /// `m`/`h`/`d`/`w` (`30d`, `12h`, `2w`). Atestaciones sin
+ /// timestamp local (legacy) quedan fuera del filtro.
+ #[arg(long)]
+ since: Option<String>,
+ },
+
+ /// Bundle: empaquetar / desempaquetar una raíz para transferencia
+ /// offline (USB-stick) — mismo nivel de verificación criptográfica
+ /// que el wire libp2p, sin necesidad de red.
+ #[command(subcommand)]
+ Bundle(BundleCommand),
+
+ /// Levanta un daemon HTTP read-only sobre el repo. Útil para
+ /// integrar minga con frontends no-Rust (web, mobile, otro shell).
+ /// El passphrase se mantiene en memoria mientras el daemon corre.
+ Serve {
+ /// Socket de escucha (ej. `127.0.0.1:7777`).
+ addr: String,
+ /// Si se pasa, exige `Authorization: Bearer <token>` en cada
+ /// request — sin token, el daemon corre abierto (ok sólo en
+ /// `127.0.0.1`). Para no exponer el token en el `ps`,
+ /// también se acepta vía env `MINGA_SERVE_TOKEN`.
+ #[arg(long)]
+ token: Option<String>,
+ },
+}
+
+#[derive(Subcommand)]
+enum BundleCommand {
+ /// Empaqueta α-hash + nodos alcanzables + atestaciones + retracciones
+ /// en un archivo postcard portable.
+ Export {
+ /// α-hash en hex (64 caracteres).
+ hash: String,
+ /// Ruta de salida del bundle (sobreescribe si existe).
+ out: PathBuf,
+ },
+
+ /// Empaqueta TODAS las raíces del repo en un solo multi-bundle.
+ /// Las raíces sin dialect persistido (sync'd bajo wire pre-RootDeclaration)
+ /// se saltan; se reportan al final.
+ ExportAll {
+ /// Ruta de salida del multi-bundle (sobreescribe si existe).
+ out: PathBuf,
+ },
+
+ /// Lee un bundle single, re-verifica criptográficamente cada pieza,
+ /// y mergea idempotentemente en los stores locales.
+ Import {
+ /// Ruta del bundle a importar.
+ file: PathBuf,
+ },
+
+ /// Lee un multi-bundle y mergea cada raíz contenida con las mismas
+ /// garantías que `import`. Idempotente.
+ ImportAll {
+ /// Ruta del multi-bundle a importar.
+ file: PathBuf,
+ },
+}
+
+fn main() -> ExitCode {
+ match run() {
+ Ok(()) => ExitCode::SUCCESS,
+ Err(e) => {
+ eprintln!("error: {}", e);
+ ExitCode::FAILURE
+ }
+ }
+}
+
+fn run() -> Result<(), CliError> {
+ let cli = Cli::parse();
+
+ match cli.command {
+ Command::Init => {
+ let pass = prompt_passphrase_with_confirm()?;
+ let did = cmd_init(&cli.repo, &pass)?;
+ println!("Repo inicializado en {}", cli.repo.display());
+ println!("DID: {}", did);
+ }
+ Command::Status => {
+ let pass = prompt_passphrase()?;
+ let s = cmd_status(&cli.repo, &pass)?;
+ println!("DID: {}", s.did);
+ println!("Raíces (α): {}", s.roots_len);
+ println!("MST: {} claves", s.mst_len);
+ println!("Nodos almacenados: {}", s.nodes_len);
+ println!("Atestaciones: {}", s.attestations_len);
+ }
+ Command::Ingest { file } => {
+ let pass = prompt_passphrase()?;
+ let r = cmd_ingest(&cli.repo, &pass, &file)?;
+ println!("Ingerido: {} ({})", file.display(), r.dialect.name());
+ println!("α-hash: {}", r.alpha);
+ println!("struct: {}", r.struct_hash);
+ println!("Firmado por: {}", r.did);
+ }
+ Command::IngestDir { dir, recursive } => {
+ let pass = prompt_passphrase()?;
+ let s = cmd_ingest_dir(&cli.repo, &pass, &dir, recursive)?;
+ println!(
+ "Bulk ingest en {}: {} archivos vistos, {} ingeridos, {} fallos",
+ dir.display(),
+ s.seen,
+ s.ingested,
+ s.failed.len()
+ );
+ for (p, msg) in s.failed {
+ eprintln!(" ✘ {}: {}", p.display(), msg);
+ }
+ }
+ Command::Sign { hash } => {
+ let pass = prompt_passphrase()?;
+ let r = cmd_sign(&cli.repo, &pass, &hash)?;
+ if r.is_new_attestation {
+ println!("Firmado α-hash {} por {}", r.alpha, r.author);
+ } else {
+ println!(
+ "Atestación ya existente para {} bajo {} (idempotente, no se duplica)",
+ r.alpha, r.author
+ );
+ }
+ if !r.is_known_root {
+ println!(
+ "⚠ aviso: {} no está registrado como raíz local (firmaste igual)",
+ r.alpha
+ );
+ }
+ }
+ Command::Listen { addr } => {
+ let pass = prompt_passphrase()?;
+ let rt = tokio::runtime::Runtime::new()
+ .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
+ rt.block_on(cmd_listen(&cli.repo, &pass, &addr))?;
+ }
+ Command::Sync { peer } => {
+ let pass = prompt_passphrase()?;
+ let rt = tokio::runtime::Runtime::new()
+ .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
+ rt.block_on(cmd_sync(&cli.repo, &pass, &peer))?;
+ println!("Sync completo.");
+ }
+ Command::Watch { dir } => {
+ let pass = prompt_passphrase()?;
+ println!("Vigilando {}. Ctrl+C para parar.", dir.display());
+ let rt = tokio::runtime::Runtime::new()
+ .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
+ rt.block_on(cmd_watch(&cli.repo, &pass, &dir))?;
+ }
+ Command::Mount { point } => {
+ let pass = prompt_passphrase()?;
+ println!(
+ "Montando {} en {}. `fusermount -u {}` para desmontar.",
+ cli.repo.display(),
+ point.display(),
+ point.display()
+ );
+ cmd_mount(&cli.repo, &pass, &point)?;
+ }
+ Command::Log { file } => {
+ let pass = prompt_passphrase()?;
+ let entries = cmd_log(&cli.repo, &pass, file.as_deref())?;
+ if entries.is_empty() {
+ println!("(repo sin atestaciones)");
+ }
+ for e in entries {
+ let mark = if e.current { "*" } else { " " };
+ let when = format_ts(e.ts_secs);
+ let dialect = e.dialect.map(|d| d.name()).unwrap_or("?");
+ println!("{} {} {} [{}] by {}", mark, when, e.alpha, dialect, e.author);
+ }
+ }
+ Command::Prune => {
+ let pass = prompt_passphrase()?;
+ let s = cmd_prune(&cli.repo, &pass)?;
+ println!(
+ "Prune: {} raíces · {} nodos antes · {} alcanzables · {} borrados",
+ s.roots, s.before, s.alive, s.removed
+ );
+ }
+ Command::Verify { hash } => {
+ let pass = prompt_passphrase()?;
+ let v = cmd_verify_root(&cli.repo, &pass, &hash)?;
+ println!("α-hash: {}", v.alpha);
+ println!("struct: {}", v.struct_hash);
+ println!(
+ "registrado: {}",
+ v.stored_dialect.map(|d| d.name()).unwrap_or("(huérfano — sin entrada en `roots`)")
+ );
+ match v.verified_dialect {
+ Some(d) => println!("verificado: OK como {}", d.name()),
+ None => {
+ println!("verificado: ✘ INCONSISTENTE — ningún dialecto produce ese α-hash");
+ std::process::exit(2);
+ }
+ }
+ if !v.matches_stored() && v.stored_dialect.is_some() {
+ println!("⚠ aviso: dialect registrado ≠ verificado (posible drift)");
+ }
+ }
+ Command::Retire { hash } => {
+ let pass = prompt_passphrase()?;
+ let r = cmd_retire(&cli.repo, &pass, &hash)?;
+ if r.was_root {
+ println!("Retirada raíz {}", r.alpha);
+ } else {
+ println!(
+ "Atestación negativa firmada para {} (no era raíz local)",
+ r.alpha
+ );
+ }
+ println!("Firmada por: {}", r.author);
+ }
+ Command::Diff { left, right } => {
+ let pass = prompt_passphrase()?;
+ let d = cmd_diff(&cli.repo, &pass, &left, &right)?;
+ let left_kind = if d.left_is_root { "α" } else { "struct" };
+ let right_kind = if d.right_is_root { "α" } else { "struct" };
+ eprintln!("--- {} ({})", d.left_hash, left_kind);
+ eprintln!("+++ {} ({})", d.right_hash, right_kind);
+ eprintln!(
+ "@@ +{} −{} @@",
+ d.additions, d.deletions
+ );
+ for line in d.lines {
+ match line {
+ DiffLine::Same(t) => print!(" {t}"),
+ DiffLine::Add(t) => print!("+{t}"),
+ DiffLine::Remove(t) => print!("-{t}"),
+ }
+ }
+ }
+ Command::Show {
+ hash,
+ sexp,
+ diff_against,
+ } => {
+ let pass = prompt_passphrase()?;
+ if let Some(other) = diff_against {
+ // Atajo: show --diff-against <other> ≡ minga diff <hash> <other>.
+ if sexp {
+ eprintln!("--sexp y --diff-against son mutuamente excluyentes");
+ return Err(CliError::Io(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "flags incompatibles",
+ )));
+ }
+ let d = cmd_diff(&cli.repo, &pass, &hash, &other)?;
+ eprintln!("--- {}", d.left_hash);
+ eprintln!("+++ {}", d.right_hash);
+ eprintln!("@@ +{} −{} @@", d.additions, d.deletions);
+ for line in d.lines {
+ match line {
+ DiffLine::Same(t) => print!(" {t}"),
+ DiffLine::Add(t) => print!("+{t}"),
+ DiffLine::Remove(t) => print!("-{t}"),
+ }
+ }
+ } else {
+ let r = cmd_show(&cli.repo, &pass, &hash, sexp)?;
+ if r.is_root {
+ let dialect = r.dialect.map(|d| d.name()).unwrap_or("?");
+ eprintln!(
+ "# raíz α={} → struct={} ({})",
+ r.alpha.unwrap(),
+ r.struct_hash,
+ dialect
+ );
+ } else {
+ eprintln!("# nodo estructural {}", r.struct_hash);
+ }
+ print!("{}", r.rendered);
+ }
+ }
+ Command::Blame { file } => {
+ let pass = prompt_passphrase()?;
+ let lines = cmd_blame(&cli.repo, &pass, &file)?;
+ for line in lines {
+ let short: String = line.alpha.to_string().chars().take(12).collect();
+ let when = format_ts(line.ts_secs);
+ println!("{} {} {} | {}", short, when, line.author, line.text);
+ }
+ }
+ Command::Roots => {
+ let pass = prompt_passphrase()?;
+ let rows = cmd_roots(&cli.repo, &pass)?;
+ if rows.is_empty() {
+ println!("(repo sin raíces)");
+ }
+ for r in rows {
+ let when = format_ts(r.last_seen_secs);
+ let dialect = r.dialect.map(|d| d.name()).unwrap_or("?");
+ let short: String = r.alpha.to_string().chars().take(12).collect();
+ let path = r.path.as_deref().unwrap_or("(sin path local)");
+ println!(
+ "{} {} [{:<6}] ×{} {}",
+ short, when, dialect, r.attestations, path
+ );
+ }
+ }
+ Command::History { file } => {
+ let pass = prompt_passphrase()?;
+ let entries = cmd_history(&cli.repo, &pass, &file)?;
+ for e in entries {
+ let mark = if e.current { "*" } else { " " };
+ let when = format_ts(e.ts_secs);
+ let dialect = e.dialect.map(|d| d.name()).unwrap_or("?");
+ println!("{} {} {} [{}]", mark, when, e.alpha, dialect);
+ }
+ }
+ Command::Signers { hash, since } => {
+ let pass = prompt_passphrase()?;
+ let since_secs = match since.as_deref() {
+ Some(s) => Some(parse_since(s)?),
+ None => None,
+ };
+ let entries = cmd_signers(&cli.repo, &pass, &hash, since_secs)?;
+ if entries.is_empty() {
+ let extra = match since_secs {
+ Some(_) => " bajo el filtro --since",
+ None => "",
+ };
+ println!("(sin atestaciones locales para ese α-hash{extra})");
+ }
+ for e in entries {
+ let when = format_ts(e.ts_secs);
+ let marker = if e.retracted { "↺" } else { " " };
+ println!("{} {} {}", marker, when, e.author);
+ }
+ }
+ Command::Bundle(BundleCommand::Export { hash, out }) => {
+ let pass = prompt_passphrase()?;
+ let s = cmd_bundle_export(&cli.repo, &pass, &hash, &out)?;
+ println!("Bundle escrito en {}", out.display());
+ println!(" α-hash: {}", s.alpha);
+ println!(" nodos: {}", s.nodes);
+ println!(" atestaciones: {}", s.attestations);
+ println!(" retractions: {}", s.retractions);
+ println!(" tamaño: {} bytes", s.bytes);
+ }
+ Command::Bundle(BundleCommand::Import { file }) => {
+ let pass = prompt_passphrase()?;
+ let s = cmd_bundle_import(&cli.repo, &pass, &file)?;
+ println!("Bundle importado: α-hash {}", s.alpha);
+ if s.root_was_new {
+ println!(" raíz nueva, registrada en MST y `roots`");
+ } else {
+ println!(" raíz ya conocida (idempotente)");
+ }
+ println!(" nodos insertados: {}", s.nodes_inserted);
+ println!(
+ " atestaciones: {} nuevas, {} rechazadas",
+ s.attestations_added, s.attestations_rejected
+ );
+ println!(
+ " retractions: {} nuevas, {} rechazadas",
+ s.retractions_added, s.retractions_rejected
+ );
+ }
+ Command::Bundle(BundleCommand::ExportAll { out }) => {
+ let pass = prompt_passphrase()?;
+ let s = cmd_bundle_export_all(&cli.repo, &pass, &out)?;
+ println!("Multi-bundle escrito en {}", out.display());
+ println!(" raíces: {}", s.roots);
+ println!(" nodos: {}", s.total_nodes);
+ println!(" atestaciones: {}", s.total_attestations);
+ println!(" retractions: {}", s.total_retractions);
+ println!(
+ " tamaño: {} bytes (zstd · raw {} bytes, ratio {:.2}×)",
+ s.bytes,
+ s.uncompressed_bytes,
+ if s.bytes == 0 {
+ 0.0
+ } else {
+ s.uncompressed_bytes as f64 / s.bytes as f64
+ }
+ );
+ if !s.skipped_missing_dialect.is_empty() {
+ println!(
+ " ⚠ {} raíces saltadas (sin dialect registrado):",
+ s.skipped_missing_dialect.len()
+ );
+ for h in s.skipped_missing_dialect {
+ println!(" {}", h);
+ }
+ }
+ }
+ Command::Serve { addr, token } => {
+ let pass = prompt_passphrase()?;
+ // Fallback al env si --token no se pasó. La env es el camino
+ // recomendado para evitar exponer el secreto en el `ps`.
+ let env_tok = std::env::var("MINGA_SERVE_TOKEN").ok();
+ let token = token.or(env_tok);
+ let rt = tokio::runtime::Runtime::new()
+ .map_err(|e| CliError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
+ rt.block_on(cmd_serve(&cli.repo, &pass, &addr, token.as_deref()))?;
+ }
+ Command::Bundle(BundleCommand::ImportAll { file }) => {
+ let pass = prompt_passphrase()?;
+ let s = cmd_bundle_import_all(&cli.repo, &pass, &file)?;
+ println!(
+ "Multi-bundle importado: {} raíces ({} nuevas)",
+ s.items.len(),
+ s.roots_new()
+ );
+ println!(" nodos insertados: {}", s.total_nodes_inserted());
+ println!(
+ " atestaciones nuevas: {}",
+ s.total_attestations_added()
+ );
+ println!(
+ " retractions nuevas: {}",
+ s.total_retractions_added()
+ );
+ }
+ }
+ Ok(())
+}
+
+/// Formato compacto del timestamp Unix → `YYYY-MM-DD HH:MM` UTC.
+fn format_ts(secs: u64) -> String {
+ if secs == 0 {
+ return " (sin fecha) ".to_string();
+ }
+ // Convertir segundos Unix a una fecha legible UTC sin chrono.
+ // Algoritmo: días civiles desde epoch + descomposición Howard Hinnant.
+ let days = (secs / 86_400) as i64;
+ let secs_of_day = secs % 86_400;
+ let h = secs_of_day / 3600;
+ let m = (secs_of_day % 3600) / 60;
+ let (y, mo, d) = civil_from_days(days + 719_468);
+ format!("{:04}-{:02}-{:02} {:02}:{:02}", y, mo, d, h, m)
+}
+
+/// Resuelve `--since` a un instante Unix (UTC). Acepta:
+/// - `YYYY-MM-DD` → medianoche UTC de ese día;
+/// - sufijo de duración `Nm` / `Nh` / `Nd` / `Nw` → `now - N`.
+///
+/// El parser es estricto para no confundir un typo con una fecha
+/// silenciosamente válida; cualquier formato inesperado produce
+/// `InvalidInput` con un mensaje claro.
+fn parse_since(s: &str) -> Result<u64, CliError> {
+ let s = s.trim();
+ // Forma absoluta: YYYY-MM-DD.
+ if let Some((y, rest)) = s.split_once('-') {
+ if let Some((mo, d)) = rest.split_once('-') {
+ let y: i64 = y.parse().map_err(|_| since_err(s))?;
+ let mo: u32 = mo.parse().map_err(|_| since_err(s))?;
+ let d: u32 = d.parse().map_err(|_| since_err(s))?;
+ if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
+ return Err(since_err(s));
+ }
+ let days = days_from_civil(y, mo, d);
+ let unix_days = days - 719_468;
+ if unix_days < 0 {
+ return Err(since_err(s));
+ }
+ return Ok((unix_days as u64) * 86_400);
+ }
+ }
+ // Forma relativa: digit+ + sufijo.
+ let last = s.chars().last().ok_or_else(|| since_err(s))?;
+ let (num, mult) = match last {
+ 'm' => (&s[..s.len() - 1], 60u64),
+ 'h' => (&s[..s.len() - 1], 3_600u64),
+ 'd' => (&s[..s.len() - 1], 86_400u64),
+ 'w' => (&s[..s.len() - 1], 7 * 86_400u64),
+ _ => return Err(since_err(s)),
+ };
+ let n: u64 = num.parse().map_err(|_| since_err(s))?;
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0);
+ Ok(now.saturating_sub(n.saturating_mul(mult)))
+}
+
+fn since_err(input: &str) -> CliError {
+ CliError::Io(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ format!(
+ "--since '{input}' inválido: usá YYYY-MM-DD o un relativo \
+ como 30d / 12h / 2w / 15m"
+ ),
+ ))
+}
+
+/// Inversa de `civil_from_days`: cuenta de días absolutos según el
+/// algoritmo de Howard Hinnant. Resultado se interpreta a Unix days
+/// restándole 719_468.
+fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
+ let y = if m <= 2 { y - 1 } else { y };
+ let era = if y >= 0 { y } else { y - 399 } / 400;
+ let yoe = (y - era * 400) as u64;
+ let m = m as u64;
+ let d = d as u64;
+ let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ era * 146_097 + doe as i64
+}
+
+/// Howard Hinnant — días desde Mar 1, 0000 (sistema proléptico) a (Y, M, D).
+fn civil_from_days(z: i64) -> (i64, u32, u32) {
+ let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
+ let doe = (z - era * 146_097) as u64;
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let y = yoe as i64 + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
+ let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
+ let y = if m <= 2 { y + 1 } else { y };
+ (y, m, d)
+}
+
+fn prompt_passphrase() -> Result<String, CliError> {
+ let pass = rpassword::prompt_password("Passphrase: ")
+ .map_err(CliError::Io)?;
+ Ok(pass)
+}
+
+fn prompt_passphrase_with_confirm() -> Result<String, CliError> {
+ let pass = rpassword::prompt_password("Passphrase nueva: ")
+ .map_err(CliError::Io)?;
+ let conf = rpassword::prompt_password("Confirma: ")
+ .map_err(CliError::Io)?;
+ if pass != conf {
+ return Err(CliError::Io(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "passphrases no coinciden",
+ )));
+ }
+ Ok(pass)
+}
diff --git a/03_ukupacha/minga/minga-cli/src/serve.rs b/03_ukupacha/minga/minga-cli/src/serve.rs
new file mode 100644
index 0000000..dbdd7c4
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/src/serve.rs
@@ -0,0 +1,291 @@
+//! Daemon HTTP read-only sobre el repo Minga local.
+//!
+//! Sólo expone consultas — no permite modificar el repo (sería el job de
+//! una API authenticada, fuera del alcance de este MVP). Ítem #C del
+//! REPORTE: paralelo a `shuma-gateway`, sirve como puente hacia
+//! frontends no-Llimphi (web, mobile, otro shell) que quieran leer
+//! roots, history o blame sin embeber `minga-cli` como librería.
+//!
+//! Endpoints:
+//! - `GET /status` — counts del repo
+//! - `GET /roots` — lista completa de raíces con metadata
+//! - `GET /roots/:alpha/show` — fuente reconstruida (text/plain)
+//! - `GET /roots/:alpha/show?sexp=1` — S-expression del árbol
+//! - `GET /roots/:alpha/signers` — DIDs que han firmado
+//! - `GET /roots/:alpha/history?path=<file>` — historial cronológico
+//!
+//! El passphrase del keypair se pide UNA vez al arrancar el daemon y se
+//! mantiene en memoria — todas las requests reaprovechan el load. No es
+//! ideal (el keypair real ni siquiera se necesita para read-only, sólo
+//! para descifrar y abrir el repo), pero es el path corto al MVP.
+
+use std::net::SocketAddr;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use axum::extract::{Path as AxumPath, Query, Request, State};
+use axum::http::{header, StatusCode};
+use axum::middleware::{self, Next};
+use axum::response::{IntoResponse, Response};
+use axum::routing::get;
+use axum::{Json, Router};
+use serde::Deserialize;
+
+use crate::commands::{cmd_history, cmd_roots, cmd_show, cmd_signers, cmd_status};
+use crate::error::CliError;
+
+#[derive(Clone)]
+struct AppState {
+ repo: Arc<PathBuf>,
+ passphrase: Arc<String>,
+}
+
+/// Arranca el daemon HTTP. Bloquea hasta Ctrl+C (o hasta que axum cierre
+/// por algún error de bind).
+///
+/// Si `token` es `Some`, cada request debe llegar con un header
+/// `Authorization: Bearer <token>`; comparación constant-time. Sin
+/// `token`, el daemon corre como antes (read-only sin auth — razonable
+/// sólo en `127.0.0.1`).
+pub async fn cmd_serve(
+ repo_path: &std::path::Path,
+ passphrase: &str,
+ addr: &str,
+ token: Option<&str>,
+) -> Result<(), CliError> {
+ // Sanity check: que el repo se pueda abrir y la passphrase sea
+ // correcta. Si esto falla, devolvemos el error al CLI sin levantar
+ // el server — mejor que dejarlo arrancar y devolver 500 a la
+ // primera request.
+ let _ = cmd_status(repo_path, passphrase)?;
+
+ let state = AppState {
+ repo: Arc::new(repo_path.to_path_buf()),
+ passphrase: Arc::new(passphrase.to_string()),
+ };
+
+ let app = build_router(state, token.map(|t| t.to_string()));
+
+ let sock: SocketAddr = addr.parse().map_err(|_| {
+ CliError::Io(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ format!("addr inválida: {addr}"),
+ ))
+ })?;
+ let listener = tokio::net::TcpListener::bind(sock).await.map_err(CliError::Io)?;
+ let auth_state = match token {
+ Some(_) => "con token requerido",
+ None => "sin auth — bindeá a 127.0.0.1",
+ };
+ eprintln!("minga serve escuchando en http://{} ({})", sock, auth_state);
+ axum::serve(listener, app).await.map_err(CliError::Io)?;
+ Ok(())
+}
+
+/// Construye el router con las rutas + (opcionalmente) la layer de
+/// auth. Comparte la base entre `cmd_serve` y `build_router_for_test`.
+fn build_router(state: AppState, token: Option<String>) -> Router {
+ let mut r = Router::new()
+ .route("/status", get(get_status))
+ .route("/roots", get(get_roots))
+ .route("/roots/:alpha/show", get(get_show))
+ .route("/roots/:alpha/signers", get(get_signers))
+ .route("/roots/:alpha/history", get(get_history))
+ .with_state(state);
+ if let Some(tok) = token {
+ let expected = Arc::new(tok);
+ r = r.layer(middleware::from_fn(move |req, next| {
+ let expected = expected.clone();
+ async move { require_bearer(expected, req, next).await }
+ }));
+ }
+ r
+}
+
+/// Middleware: rechaza con 401 si falta el header o no hace match con
+/// `expected`. La comparación es constant-time vía `subtle` indirecto
+/// (XOR byte-a-byte sobre slices del mismo largo).
+async fn require_bearer(expected: Arc<String>, req: Request, next: Next) -> Response {
+ let Some(h) = req.headers().get(header::AUTHORIZATION) else {
+ return unauthorized("missing Authorization header");
+ };
+ let Ok(val) = h.to_str() else {
+ return unauthorized("invalid Authorization header");
+ };
+ let Some(tok) = val.strip_prefix("Bearer ") else {
+ return unauthorized("expected Bearer scheme");
+ };
+ if constant_time_eq(tok.as_bytes(), expected.as_bytes()) {
+ next.run(req).await
+ } else {
+ unauthorized("invalid token")
+ }
+}
+
+fn unauthorized(msg: &str) -> Response {
+ (StatusCode::UNAUTHORIZED, Json(serde_json::json!({ "error": msg }))).into_response()
+}
+
+fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
+ if a.len() != b.len() {
+ return false;
+ }
+ let mut diff = 0u8;
+ for (x, y) in a.iter().zip(b.iter()) {
+ diff |= x ^ y;
+ }
+ diff == 0
+}
+
+async fn get_status(State(s): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
+ let st = cmd_status(&s.repo, &s.passphrase)?;
+ Ok(Json(serde_json::json!({
+ "did": st.did.to_string(),
+ "roots": st.roots_len,
+ "mst": st.mst_len,
+ "nodes": st.nodes_len,
+ "attestations": st.attestations_len,
+ })))
+}
+
+async fn get_roots(State(s): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
+ let rows = cmd_roots(&s.repo, &s.passphrase)?;
+ let items: Vec<_> = rows
+ .into_iter()
+ .map(|r| {
+ serde_json::json!({
+ "alpha": r.alpha.to_string(),
+ "struct_hash": r.struct_hash.to_string(),
+ "dialect": r.dialect.map(|d| d.name()),
+ "path": r.path,
+ "last_seen_secs": r.last_seen_secs,
+ "attestations": r.attestations,
+ })
+ })
+ .collect();
+ Ok(Json(serde_json::json!({ "items": items })))
+}
+
+#[derive(Deserialize)]
+struct ShowQuery {
+ sexp: Option<u8>,
+}
+
+async fn get_show(
+ State(s): State<AppState>,
+ AxumPath(alpha): AxumPath<String>,
+ Query(q): Query<ShowQuery>,
+) -> Result<Response, ApiError> {
+ let sexp = matches!(q.sexp, Some(n) if n != 0);
+ let r = cmd_show(&s.repo, &s.passphrase, &alpha, sexp)?;
+ Ok((
+ [(axum::http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
+ r.rendered,
+ )
+ .into_response())
+}
+
+#[derive(Deserialize)]
+struct SignersQuery {
+ /// Cutoff Unix timestamp; sólo se incluyen firmas con
+ /// `ts_secs >= since`. Mismo significado que `--since` del CLI.
+ since: Option<u64>,
+}
+
+async fn get_signers(
+ State(s): State<AppState>,
+ AxumPath(alpha): AxumPath<String>,
+ Query(q): Query<SignersQuery>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+ let entries = cmd_signers(&s.repo, &s.passphrase, &alpha, q.since)?;
+ let items: Vec<_> = entries
+ .into_iter()
+ .map(|e| {
+ serde_json::json!({
+ "author": e.author.to_string(),
+ "ts_secs": e.ts_secs,
+ "retracted": e.retracted,
+ })
+ })
+ .collect();
+ Ok(Json(serde_json::json!({ "alpha": alpha, "items": items })))
+}
+
+#[derive(Deserialize)]
+struct HistoryQuery {
+ path: String,
+}
+
+async fn get_history(
+ State(s): State<AppState>,
+ AxumPath(_alpha_unused): AxumPath<String>,
+ Query(q): Query<HistoryQuery>,
+) -> Result<Json<serde_json::Value>, ApiError> {
+ // El `:alpha` del path no se usa: la API ergonomicamente reusa el
+ // namespace `/roots/:alpha/...` pero `history` opera por path local.
+ let entries = cmd_history(&s.repo, &s.passphrase, std::path::Path::new(&q.path))?;
+ let items: Vec<_> = entries
+ .into_iter()
+ .map(|e| {
+ serde_json::json!({
+ "alpha": e.alpha.to_string(),
+ "ts_secs": e.ts_secs,
+ "dialect": e.dialect.map(|d| d.name()),
+ "current": e.current,
+ })
+ })
+ .collect();
+ Ok(Json(serde_json::json!({ "path": q.path, "items": items })))
+}
+
+/// Wrapper que mapea `CliError` a HTTP. Errores "de usuario"
+/// (HashNotFound, PathNotIngested, InvalidHash) van como 4xx; el resto
+/// como 500.
+struct ApiError(CliError);
+
+impl From<CliError> for ApiError {
+ fn from(e: CliError) -> Self {
+ ApiError(e)
+ }
+}
+
+impl IntoResponse for ApiError {
+ fn into_response(self) -> Response {
+ let (status, body) = match &self.0 {
+ CliError::HashNotFound(_) | CliError::PathNotIngested(_) => {
+ (StatusCode::NOT_FOUND, self.0.to_string())
+ }
+ CliError::InvalidHash(_) | CliError::UnsupportedLanguage { .. } => {
+ (StatusCode::BAD_REQUEST, self.0.to_string())
+ }
+ _ => (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()),
+ };
+ (status, Json(serde_json::json!({ "error": body }))).into_response()
+ }
+}
+
+/// Sólo para que un test pueda llamarlo y armar requests sin levantar
+/// un socket. Devuelve el `Router` configurado contra `repo_path`.
+#[doc(hidden)]
+pub fn build_router_for_test(repo_path: PathBuf, passphrase: String) -> Router {
+ let state = AppState {
+ repo: Arc::new(repo_path),
+ passphrase: Arc::new(passphrase),
+ };
+ build_router(state, None)
+}
+
+/// Variante con token activo, para tests de auth.
+#[doc(hidden)]
+pub fn build_router_for_test_with_token(
+ repo_path: PathBuf,
+ passphrase: String,
+ token: String,
+) -> Router {
+ let state = AppState {
+ repo: Arc::new(repo_path),
+ passphrase: Arc::new(passphrase),
+ };
+ build_router(state, Some(token))
+}
+
diff --git a/03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs b/03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs
new file mode 100644
index 0000000..240abae
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/tests/bundle_roundtrip.rs
@@ -0,0 +1,330 @@
+//! Round-trip de bundle: export en repo A → import en repo B vacío,
+//! verificando que la raíz, sus atestaciones y su contenido sobreviven
+//! intactos y que el import es idempotente.
+//!
+//! Ítem #O del REPORTE: la red de seguridad mínima para detectar
+//! regresiones en el formato `BundleV1` o en el path de
+//! verificación criptográfica al importar.
+
+use std::fs;
+
+use minga_cli::{
+ cmd_bundle_export, cmd_bundle_export_all, cmd_bundle_import, cmd_bundle_import_all, cmd_init,
+ cmd_ingest, cmd_retire, cmd_show, cmd_sign, cmd_status, CliError,
+};
+use tempfile::TempDir;
+
+#[test]
+fn bundle_roundtrip_preserves_root_and_attestation() {
+ let dir = TempDir::new().unwrap();
+
+ // Repo A: emisor.
+ let repo_a = dir.path().join("a");
+ let did_a = cmd_init(&repo_a, "pa").unwrap();
+ let src = dir.path().join("f.rs");
+ fs::write(&src, "fn f() -> i32 { 42 }").unwrap();
+ let ing = cmd_ingest(&repo_a, "pa", &src).unwrap();
+ assert_eq!(ing.did, did_a);
+
+ // Export.
+ let bundle_path = dir.path().join("f.bundle");
+ let exp = cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &bundle_path).unwrap();
+ assert_eq!(exp.alpha, ing.alpha);
+ assert_eq!(exp.attestations, 1, "ingest deja una autoatestación");
+ assert!(exp.nodes >= 1);
+ assert!(bundle_path.exists());
+
+ // Repo B: receptor vacío.
+ let repo_b = dir.path().join("b");
+ let did_b = cmd_init(&repo_b, "pb").unwrap();
+ assert_ne!(did_a, did_b, "los DIDs deben diferir entre repos");
+
+ // Import.
+ let imp = cmd_bundle_import(&repo_b, "pb", &bundle_path).unwrap();
+ assert_eq!(imp.alpha, ing.alpha, "α preservado tras round-trip");
+ assert!(imp.root_was_new);
+ assert!(imp.nodes_inserted >= 1);
+ assert_eq!(imp.attestations_added, 1);
+ assert_eq!(imp.attestations_rejected, 0);
+ assert_eq!(imp.retractions_added, 0);
+
+ // El status de B refleja exactamente lo que A tenía sobre esa raíz.
+ let s = cmd_status(&repo_b, "pb").unwrap();
+ assert_eq!(s.roots_len, 1);
+ assert_eq!(s.mst_len, 1);
+ assert_eq!(s.attestations_len, 1);
+
+ // El contenido reconstruido en B coincide con el de A.
+ let show_a = cmd_show(&repo_a, "pa", &ing.alpha.to_string(), false).unwrap();
+ let show_b = cmd_show(&repo_b, "pb", &ing.alpha.to_string(), false).unwrap();
+ assert_eq!(show_a.rendered, show_b.rendered);
+}
+
+#[test]
+fn bundle_import_is_idempotent() {
+ let dir = TempDir::new().unwrap();
+
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "p").unwrap();
+ let src = dir.path().join("g.rs");
+ fs::write(&src, "fn g() -> i32 { 1 }").unwrap();
+ let ing = cmd_ingest(&repo_a, "p", &src).unwrap();
+ let bundle_path = dir.path().join("g.bundle");
+ cmd_bundle_export(&repo_a, "p", &ing.alpha.to_string(), &bundle_path).unwrap();
+
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "p").unwrap();
+
+ let imp1 = cmd_bundle_import(&repo_b, "p", &bundle_path).unwrap();
+ assert!(imp1.root_was_new);
+
+ let imp2 = cmd_bundle_import(&repo_b, "p", &bundle_path).unwrap();
+ assert!(!imp2.root_was_new, "segunda importación: raíz ya conocida");
+ assert_eq!(imp2.nodes_inserted, 0, "nodos deduplicados");
+ assert_eq!(imp2.attestations_added, 0, "atestación deduplicada");
+ assert_eq!(imp2.retractions_added, 0);
+
+ let s = cmd_status(&repo_b, "p").unwrap();
+ assert_eq!(s.roots_len, 1);
+ assert_eq!(s.attestations_len, 1);
+}
+
+#[test]
+fn bundle_roundtrip_propagates_vouching_attestations() {
+ // Dos peers atestan la misma raíz; el bundle transporta ambas
+ // firmas a un tercero limpio.
+ let dir = TempDir::new().unwrap();
+
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "pa").unwrap();
+ let src = dir.path().join("h.rs");
+ fs::write(&src, "fn h() -> i32 { 7 }").unwrap();
+ let ing = cmd_ingest(&repo_a, "pa", &src).unwrap();
+
+ // Ronda intermedia: bundle a→b, b firma como segundo vouching, bundle b→c.
+ let pkg_ab = dir.path().join("h.ab.bundle");
+ cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &pkg_ab).unwrap();
+
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "pb").unwrap();
+ cmd_bundle_import(&repo_b, "pb", &pkg_ab).unwrap();
+ let sign_b = cmd_sign(&repo_b, "pb", &ing.alpha.to_string()).unwrap();
+ assert!(sign_b.is_new_attestation);
+ assert!(sign_b.is_known_root);
+
+ let pkg_bc = dir.path().join("h.bc.bundle");
+ let exp_bc = cmd_bundle_export(&repo_b, "pb", &ing.alpha.to_string(), &pkg_bc).unwrap();
+ assert_eq!(exp_bc.attestations, 2, "ambas firmas viajan en el bundle");
+
+ let repo_c = dir.path().join("c");
+ cmd_init(&repo_c, "pc").unwrap();
+ let imp_c = cmd_bundle_import(&repo_c, "pc", &pkg_bc).unwrap();
+ assert_eq!(imp_c.attestations_added, 2);
+ assert_eq!(imp_c.attestations_rejected, 0);
+
+ let s = cmd_status(&repo_c, "pc").unwrap();
+ assert_eq!(s.attestations_len, 2);
+ assert_eq!(s.roots_len, 1);
+}
+
+#[test]
+fn bundle_roundtrip_carries_retractions() {
+ let dir = TempDir::new().unwrap();
+
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "pa").unwrap();
+ let src = dir.path().join("r.rs");
+ fs::write(&src, "fn r() -> i32 { 9 }").unwrap();
+ let ing = cmd_ingest(&repo_a, "pa", &src).unwrap();
+
+ // Bundle base hacia B (antes de retractar — necesitamos α en `roots`
+ // para que el export funcione).
+ let pkg = dir.path().join("r.bundle");
+ cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &pkg).unwrap();
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "pb").unwrap();
+ cmd_bundle_import(&repo_b, "pb", &pkg).unwrap();
+
+ // A retracta su propia raíz y exporta de nuevo para arrastrar la
+ // retracción. Nota: retire la saca de `roots`, así que el export se
+ // hace ANTES sobre la versión retractada — la retracción persiste en
+ // su tree pese al cleanup del MST… revisemos.
+ cmd_retire(&repo_a, "pa", &ing.alpha.to_string()).unwrap();
+ // Tras retire, α salió de roots; export fallaría con HashNotFound.
+ // Reingerimos para volver a registrar la raíz; la retracción sigue
+ // en su tree por diseño (es prueba histórica) y debería viajar.
+ cmd_ingest(&repo_a, "pa", &src).unwrap();
+
+ let pkg2 = dir.path().join("r.2.bundle");
+ let exp2 = cmd_bundle_export(&repo_a, "pa", &ing.alpha.to_string(), &pkg2).unwrap();
+ assert_eq!(exp2.retractions, 1, "la retracción sigue en el tree y viaja");
+
+ let repo_c = dir.path().join("c");
+ cmd_init(&repo_c, "pc").unwrap();
+ let imp_c = cmd_bundle_import(&repo_c, "pc", &pkg2).unwrap();
+ assert_eq!(imp_c.retractions_added, 1);
+ assert_eq!(imp_c.retractions_rejected, 0);
+}
+
+#[test]
+fn multi_bundle_round_trip_carries_all_roots() {
+ // A tiene tres archivos ingeridos; un multi-bundle los lleva a B
+ // de un solo viaje, B termina con las mismas tres raíces.
+ let dir = TempDir::new().unwrap();
+
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "p").unwrap();
+ let files = [
+ ("uno.rs", "fn one() -> i32 { 1 }"),
+ ("dos.rs", "fn two() -> i32 { 2 }"),
+ ("tres.rs", "fn three() -> i32 { 3 }"),
+ ];
+ let mut alphas = Vec::new();
+ for (n, src) in &files {
+ let p = dir.path().join(n);
+ std::fs::write(&p, src).unwrap();
+ alphas.push(cmd_ingest(&repo_a, "p", &p).unwrap().alpha);
+ }
+
+ let pkg = dir.path().join("triple.bundle");
+ let exp = cmd_bundle_export_all(&repo_a, "p", &pkg).unwrap();
+ assert_eq!(exp.roots, 3);
+ assert!(exp.skipped_missing_dialect.is_empty());
+ assert_eq!(exp.total_attestations, 3);
+
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "p").unwrap();
+ let imp = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap();
+ assert_eq!(imp.items.len(), 3);
+ assert_eq!(imp.roots_new(), 3);
+ assert_eq!(imp.total_attestations_added(), 3);
+
+ let s = cmd_status(&repo_b, "p").unwrap();
+ assert_eq!(s.roots_len, 3);
+ assert_eq!(s.attestations_len, 3);
+ for a in &alphas {
+ let shown = cmd_show(&repo_b, "p", &a.to_string(), false).unwrap();
+ assert_eq!(shown.alpha, Some(*a));
+ }
+}
+
+#[test]
+fn multi_bundle_import_is_idempotent() {
+ let dir = TempDir::new().unwrap();
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "p").unwrap();
+ let p1 = dir.path().join("a1.rs");
+ std::fs::write(&p1, "fn a1() -> i32 { 1 }").unwrap();
+ cmd_ingest(&repo_a, "p", &p1).unwrap();
+ let p2 = dir.path().join("a2.rs");
+ std::fs::write(&p2, "fn a2() -> i32 { 2 }").unwrap();
+ cmd_ingest(&repo_a, "p", &p2).unwrap();
+
+ let pkg = dir.path().join("multi.bundle");
+ cmd_bundle_export_all(&repo_a, "p", &pkg).unwrap();
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "p").unwrap();
+
+ let r1 = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap();
+ assert_eq!(r1.roots_new(), 2);
+ let r2 = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap();
+ assert_eq!(r2.roots_new(), 0, "segunda corrida: nada nuevo");
+ assert_eq!(r2.total_nodes_inserted(), 0);
+ assert_eq!(r2.total_attestations_added(), 0);
+}
+
+#[test]
+fn multi_bundle_zstd_roundtrip_keeps_data_and_reports_ratio() {
+ // El export-all comprime con zstd; el import-all debe descomprimir y
+ // recuperar exactamente las mismas raíces. uncompressed_bytes >
+ // bytes salvo en el degenerado del archivo casi vacío.
+ let dir = TempDir::new().unwrap();
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "p").unwrap();
+ let mut alphas = Vec::new();
+ for i in 0..5 {
+ let p = dir.path().join(format!("a{i}.rs"));
+ std::fs::write(&p, format!("fn a{i}() -> i32 {{ {i} }}")).unwrap();
+ alphas.push(cmd_ingest(&repo_a, "p", &p).unwrap().alpha);
+ }
+
+ let pkg = dir.path().join("z.bundle");
+ let exp = cmd_bundle_export_all(&repo_a, "p", &pkg).unwrap();
+ assert_eq!(exp.roots, 5);
+ assert!(
+ exp.uncompressed_bytes > 0,
+ "el postcard raw no puede ser vacío para 5 raíces"
+ );
+ // No exigimos ratio > 1: con datos muy chicos zstd a veces ni
+ // comprime. Pero sí exigimos que el header haya cambiado a MNGZ.
+ let on_disk = std::fs::read(&pkg).unwrap();
+ assert_eq!(&on_disk[..4], b"MNGZ", "export-all nuevo emite MNGZ");
+
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "p").unwrap();
+ let imp = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap();
+ assert_eq!(imp.roots_new(), 5);
+}
+
+#[test]
+fn multi_bundle_legacy_mngm_still_imports() {
+ // Construimos a mano un archivo `MNGM` + postcard sin compresión
+ // (simulando un bundle generado por una versión vieja) y
+ // verificamos que el import nuevo lo acepta. Sin esto, romperíamos
+ // a usuarios con archivos pre-zstd en disco.
+ use minga_cli::bundle::{BundleMultiV1, MULTI_MAGIC};
+
+ let dir = TempDir::new().unwrap();
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "p").unwrap();
+ let p = dir.path().join("legacy.rs");
+ std::fs::write(&p, "fn legacy() -> i32 { 1 }").unwrap();
+ let ing = cmd_ingest(&repo_a, "p", &p).unwrap();
+
+ // Reusamos la implementación existente para armar un BundleV1 single,
+ // y lo envolvemos a mano con el header MNGM legacy.
+ let single = dir.path().join("legacy.single");
+ cmd_bundle_export(&repo_a, "p", &ing.alpha.to_string(), &single).unwrap();
+ let single_bytes = std::fs::read(&single).unwrap();
+ let bundle: minga_cli::bundle::BundleV1 = postcard::from_bytes(&single_bytes).unwrap();
+ let multi = BundleMultiV1 {
+ version: 1,
+ items: vec![bundle],
+ };
+ let body = postcard::to_allocvec(&multi).unwrap();
+ let mut legacy = Vec::with_capacity(MULTI_MAGIC.len() + body.len());
+ legacy.extend_from_slice(MULTI_MAGIC);
+ legacy.extend_from_slice(&body);
+ let pkg = dir.path().join("legacy.mngm");
+ std::fs::write(&pkg, &legacy).unwrap();
+
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "p").unwrap();
+ let imp = cmd_bundle_import_all(&repo_b, "p", &pkg).unwrap();
+ assert_eq!(imp.roots_new(), 1);
+}
+
+#[test]
+fn multi_bundle_rejects_single_bundle_and_viceversa() {
+ // Cruzar single ↔ multi import debe devolver el error específico,
+ // no `InvalidBundle`. Eso le ahorra al usuario el "qué archivo es
+ // esto" cuando ya tiene la respuesta a mano.
+ let dir = TempDir::new().unwrap();
+ let repo_a = dir.path().join("a");
+ cmd_init(&repo_a, "p").unwrap();
+ let p = dir.path().join("x.rs");
+ std::fs::write(&p, "fn x() -> i32 { 0 }").unwrap();
+ let ing = cmd_ingest(&repo_a, "p", &p).unwrap();
+
+ let single = dir.path().join("s.bundle");
+ cmd_bundle_export(&repo_a, "p", &ing.alpha.to_string(), &single).unwrap();
+ let multi = dir.path().join("m.bundle");
+ cmd_bundle_export_all(&repo_a, "p", &multi).unwrap();
+
+ let repo_b = dir.path().join("b");
+ cmd_init(&repo_b, "p").unwrap();
+ let err1 = cmd_bundle_import(&repo_b, "p", &multi).unwrap_err();
+ assert!(matches!(err1, CliError::ExpectedSingleBundle));
+ let err2 = cmd_bundle_import_all(&repo_b, "p", &single).unwrap_err();
+ assert!(matches!(err2, CliError::ExpectedMultiBundle));
+}
diff --git a/03_ukupacha/minga/minga-cli/tests/cli_smoke.rs b/03_ukupacha/minga/minga-cli/tests/cli_smoke.rs
new file mode 100644
index 0000000..738eef1
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/tests/cli_smoke.rs
@@ -0,0 +1,356 @@
+//! Smoke tests del CLI: init → ingest → status, todo persistido.
+
+use std::fs;
+
+use minga_cli::{cmd_ingest, cmd_init, cmd_status, CliError};
+use tempfile::TempDir;
+
+#[test]
+fn init_creates_keypair_and_repo() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ let did = cmd_init(&repo, "passphrase-secreta").unwrap();
+
+ // El keypair existe en disco.
+ assert!(repo.join("keypair").exists());
+ // El repo sled existe (es un directorio).
+ assert!(repo.join("repo").is_dir());
+ // El DID retornado es no-trivial.
+ assert_ne!(did, minga_core::Did([0u8; 32]));
+}
+
+#[test]
+fn init_refuses_existing_non_empty_directory() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ fs::create_dir(&repo).unwrap();
+ fs::write(repo.join("garbage"), b"hello").unwrap();
+ let r = cmd_init(&repo, "p");
+ assert!(matches!(r, Err(CliError::AlreadyExists(_))));
+}
+
+#[test]
+fn status_shows_empty_state_after_init() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+ let s = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s.mst_len, 0);
+ assert_eq!(s.nodes_len, 0);
+ assert_eq!(s.attestations_len, 0);
+}
+
+#[test]
+fn status_with_wrong_passphrase_errors() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "correcta").unwrap();
+ let r = cmd_status(&repo, "incorrecta");
+ assert!(r.is_err());
+}
+
+#[test]
+fn ingest_persists_function_with_self_attestation() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ let did = cmd_init(&repo, "p").unwrap();
+
+ // Escribir un archivo Rust de ejemplo.
+ let src = dir.path().join("ejemplo.rs");
+ fs::write(&src, "fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
+
+ let r = cmd_ingest(&repo, "p", &src).unwrap();
+ assert_eq!(r.did, did, "la firma debe ser del repo, no de otro");
+
+ let s = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s.mst_len, 1);
+ assert!(s.nodes_len > 1, "el AST tiene más de un nodo");
+ assert_eq!(s.attestations_len, 1, "una autoatestación");
+}
+
+#[test]
+fn ingest_persists_across_runs() {
+ // Simulamos "reiniciar el proceso": cmd_init en una llamada,
+ // cmd_ingest en otra (que reabre el repo).
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let src1 = dir.path().join("uno.rs");
+ fs::write(&src1, "fn one() -> i32 { 1 }").unwrap();
+ cmd_ingest(&repo, "p", &src1).unwrap();
+
+ let src2 = dir.path().join("dos.rs");
+ fs::write(&src2, "fn two() -> i32 { 2 }").unwrap();
+ cmd_ingest(&repo, "p", &src2).unwrap();
+
+ let s = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s.mst_len, 2);
+ assert_eq!(s.attestations_len, 2);
+}
+
+#[test]
+fn ingest_same_file_twice_is_idempotent() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let src = dir.path().join("f.rs");
+ fs::write(&src, "fn f() -> i32 { 42 }").unwrap();
+
+ let r1 = cmd_ingest(&repo, "p", &src).unwrap();
+ let r2 = cmd_ingest(&repo, "p", &src).unwrap();
+ assert_eq!(r1.alpha, r2.alpha);
+ assert_eq!(r1.struct_hash, r2.struct_hash);
+
+ let s = cmd_status(&repo, "p").unwrap();
+ // El MST tiene 1 entrada (mismo hash). Atestaciones también: 1
+ // por (autor, contenido) — idempotente.
+ assert_eq!(s.mst_len, 1);
+ assert_eq!(s.attestations_len, 1);
+}
+
+#[test]
+fn rename_local_var_keeps_same_alpha_hash() {
+ // Item #1 manifestándose: dos archivos Rust α-equivalentes (sólo
+ // difieren en el nombre de la variable ligada) producen el mismo
+ // α-hash → mismo MST → mismo "archivo" desde el punto de vista
+ // del VCS semántico.
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let a = dir.path().join("a.rs");
+ fs::write(&a, "fn f() -> i32 { let x = 1; x }").unwrap();
+ let b = dir.path().join("b.rs");
+ fs::write(&b, "fn f() -> i32 { let y = 1; y }").unwrap();
+
+ let r1 = cmd_ingest(&repo, "p", &a).unwrap();
+ let r2 = cmd_ingest(&repo, "p", &b).unwrap();
+ assert_eq!(
+ r1.alpha, r2.alpha,
+ "α-equivalencia: cambiar nombre de variable ligada no cambia el α-hash"
+ );
+ assert_ne!(
+ r1.struct_hash, r2.struct_hash,
+ "estructuralmente sí difieren (los leaf_text de los `identifier` son distintos)"
+ );
+
+ let s = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s.mst_len, 1, "una sola raíz canónica en el MST");
+ assert_eq!(s.roots_len, 1);
+}
+
+#[test]
+fn diff_detects_changes_between_versions() {
+ use minga_cli::{cmd_diff, DiffLine};
+
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let v1 = dir.path().join("v1.rs");
+ fs::write(&v1, "fn add(a: i32, b: i32) -> i32 { a + b }").unwrap();
+ let v2 = dir.path().join("v2.rs");
+ // Genuinamente distinto (no sólo rename): cambia el cuerpo.
+ fs::write(&v2, "fn add(a: i32, b: i32) -> i32 { a - b }").unwrap();
+
+ let r1 = cmd_ingest(&repo, "p", &v1).unwrap();
+ let r2 = cmd_ingest(&repo, "p", &v2).unwrap();
+ assert_ne!(r1.alpha, r2.alpha, "cambio sustantivo cambia el α-hash");
+
+ let d = cmd_diff(&repo, "p", &r1.alpha.to_string(), &r2.alpha.to_string()).unwrap();
+ assert!(d.additions > 0 || d.deletions > 0, "debe haber cambios visibles");
+ assert!(d.left_is_root && d.right_is_root, "ambos son raíces");
+ assert!(d.lines.iter().any(|l| matches!(l, DiffLine::Add(_) | DiffLine::Remove(_))));
+}
+
+#[test]
+fn retire_removes_root_and_persists_signed_retraction() {
+ use minga_cli::{cmd_retire, cmd_status};
+
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let src = dir.path().join("f.rs");
+ fs::write(&src, "fn f() -> i32 { 1 }").unwrap();
+ let ing = cmd_ingest(&repo, "p", &src).unwrap();
+ assert_eq!(cmd_status(&repo, "p").unwrap().roots_len, 1);
+
+ let r = cmd_retire(&repo, "p", &ing.alpha.to_string()).unwrap();
+ assert!(r.was_root);
+ assert_eq!(r.alpha, ing.alpha);
+
+ let s = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s.roots_len, 0, "raíz retirada del tree roots");
+ assert_eq!(s.mst_len, 0, "raíz retirada del MST");
+ // La atestación original NO se borra: sigue siendo prueba de
+ // que el autor firmó este hash en algún momento.
+ assert_eq!(s.attestations_len, 1);
+}
+
+#[test]
+fn retire_unknown_hash_still_signs_negative_attestation() {
+ use minga_cli::cmd_retire;
+ // Útil para sync: un peer puede firmar "yo no respaldo X" sobre
+ // un hash que llegó por la red sin que tenga que existir en su
+ // tree local de roots.
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let fake = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
+ let r = cmd_retire(&repo, "p", fake).unwrap();
+ assert!(!r.was_root);
+}
+
+#[test]
+fn verify_root_matches_dialect_used_to_ingest() {
+ use minga_cli::cmd_verify_root;
+
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let src = dir.path().join("f.py");
+ fs::write(&src, "def f():\n return 1\n").unwrap();
+ let ing = cmd_ingest(&repo, "p", &src).unwrap();
+
+ let v = cmd_verify_root(&repo, "p", &ing.alpha.to_string()).unwrap();
+ assert!(v.is_consistent(), "el α verificado debe matchear");
+ assert_eq!(v.verified_dialect, Some(minga_core::parse::Dialect::Python));
+ assert_eq!(v.stored_dialect, Some(minga_core::parse::Dialect::Python));
+ assert!(v.matches_stored());
+}
+
+#[test]
+fn prune_removes_unreachable_nodes_after_retire() {
+ use minga_cli::{cmd_prune, cmd_retire, cmd_status};
+
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ // Ingerimos dos archivos distintos: dejan ambos AST en el grafo CAS.
+ let a = dir.path().join("a.rs");
+ fs::write(&a, "fn a() -> i32 { 1 }").unwrap();
+ let b = dir.path().join("b.rs");
+ fs::write(&b, "fn b() -> i32 { 2 }").unwrap();
+ let r_a = cmd_ingest(&repo, "p", &a).unwrap();
+ cmd_ingest(&repo, "p", &b).unwrap();
+
+ let nodes_with_both = cmd_status(&repo, "p").unwrap().nodes_len;
+
+ // Retiramos una raíz: el AST queda huérfano (los nodos siguen).
+ cmd_retire(&repo, "p", &r_a.alpha.to_string()).unwrap();
+ let s_after_retire = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s_after_retire.roots_len, 1);
+ assert_eq!(
+ s_after_retire.nodes_len, nodes_with_both,
+ "retire NO borra nodos del CAS — el GC lo hace después"
+ );
+
+ // Prune: borra los huérfanos.
+ let stats = cmd_prune(&repo, "p").unwrap();
+ assert_eq!(stats.roots, 1);
+ assert_eq!(stats.before, nodes_with_both);
+ assert!(stats.removed > 0, "alguna parte del AST de a.rs debe haber quedado huérfana");
+ assert!(stats.alive < stats.before);
+
+ // Segunda pasada: idempotente.
+ let stats2 = cmd_prune(&repo, "p").unwrap();
+ assert_eq!(stats2.removed, 0, "prune idempotente");
+ assert_eq!(stats2.before, stats.alive);
+}
+
+#[test]
+fn blame_attributes_lines_across_two_revisions() {
+ // El archivo arranca con una función; después le agregamos otra.
+ // `minga blame` debe atribuir las líneas de la función original
+ // al primer α-hash, y las de la nueva al segundo.
+ use minga_cli::cmd_blame;
+
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let src = dir.path().join("evolución.rs");
+ fs::write(&src, "fn first() -> i32 { 1 }\n").unwrap();
+ let r1 = cmd_ingest(&repo, "p", &src).unwrap();
+
+ // Sobrescribimos el archivo: misma función + una nueva.
+ fs::write(
+ &src,
+ "fn first() -> i32 { 1 }\nfn second() -> i32 { 2 }\n",
+ )
+ .unwrap();
+ let r2 = cmd_ingest(&repo, "p", &src).unwrap();
+ assert_ne!(r1.alpha, r2.alpha, "el contenido cambió, el α debe cambiar");
+
+ let blame = cmd_blame(&repo, "p", &src).unwrap();
+ assert!(!blame.is_empty(), "blame no debe estar vacío");
+
+ // Al menos una línea atribuida a r1.alpha (la `first`).
+ assert!(
+ blame.iter().any(|l| l.alpha == r1.alpha),
+ "alguna línea original debe atribuirse a r1: {:?}",
+ blame.iter().map(|l| (&l.text, l.alpha.to_string())).collect::<Vec<_>>()
+ );
+ // Al menos una línea atribuida a r2.alpha (la `second`).
+ assert!(
+ blame.iter().any(|l| l.alpha == r2.alpha),
+ "alguna línea nueva debe atribuirse a r2"
+ );
+}
+
+#[test]
+fn signers_since_filters_old_attestations() {
+ // `cmd_signers` con `Some(now+horizonte)` debe filtrar todo: la
+ // firma local quedó persistida con `ts_secs == now`, así que un
+ // corte muy futuro la deja afuera. Y un corte de hace una hora la
+ // incluye. Verifica el filtro sin necesidad de manipular tiempo.
+ use minga_cli::cmd_signers;
+
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+ let src = dir.path().join("s.rs");
+ fs::write(&src, "fn s() -> i32 { 1 }").unwrap();
+ let ing = cmd_ingest(&repo, "p", &src).unwrap();
+
+ // Sin filtro: 1 firma.
+ let all = cmd_signers(&repo, "p", &ing.alpha.to_string(), None).unwrap();
+ assert_eq!(all.len(), 1);
+
+ // Filtro al futuro: 0 firmas.
+ let future = u64::MAX / 2;
+ let none = cmd_signers(&repo, "p", &ing.alpha.to_string(), Some(future)).unwrap();
+ assert!(none.is_empty());
+
+ // Filtro a "hace una hora": 1 firma (la nuestra es de hace segundos).
+ let now = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap()
+ .as_secs();
+ let recent = cmd_signers(&repo, "p", &ing.alpha.to_string(), Some(now - 3600)).unwrap();
+ assert_eq!(recent.len(), 1);
+}
+
+#[test]
+fn blame_errors_for_path_without_history() {
+ use minga_cli::cmd_blame;
+
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ cmd_init(&repo, "p").unwrap();
+
+ let src = dir.path().join("nunca.rs");
+ fs::write(&src, "fn x() -> i32 { 0 }").unwrap();
+
+ let err = cmd_blame(&repo, "p", &src).unwrap_err();
+ assert!(
+ matches!(err, minga_cli::CliError::PathNotIngested(_)),
+ "esperaba PathNotIngested, obtuvo {err}"
+ );
+}
diff --git a/03_ukupacha/minga/minga-cli/tests/serve_http.rs b/03_ukupacha/minga/minga-cli/tests/serve_http.rs
new file mode 100644
index 0000000..d8c5945
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/tests/serve_http.rs
@@ -0,0 +1,256 @@
+//! Smoke del daemon HTTP: levanta el router en proceso, manda requests
+//! sintéticas via `tower::ServiceExt::oneshot`, valida JSON.
+//!
+//! No abre un socket real — el server arranca en tests E2E sería más
+//! ruido que valor.
+
+use std::fs;
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode};
+use http_body_util::BodyExt;
+use minga_cli::serve::{build_router_for_test, build_router_for_test_with_token};
+use minga_cli::{cmd_init, cmd_ingest};
+use serde_json::Value;
+use tempfile::TempDir;
+use tower::ServiceExt;
+
+fn populate_repo() -> (TempDir, String) {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+ let src = dir.path().join("hola.rs");
+ fs::write(&src, "fn hola() -> i32 { 1 }").unwrap();
+ let alpha = cmd_ingest(&repo, "p", &src).unwrap().alpha.to_string();
+ let r = repo.to_string_lossy().to_string();
+ std::mem::forget(dir);
+ let kept = TempDir::new().unwrap();
+ // El dir original ya tiene contenido pero no podemos retornarlo
+ // post-forget; re-encapsulamos.
+ (kept, format!("{}|{}", r, alpha))
+}
+
+#[tokio::test]
+async fn http_status_returns_counts() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+ let src = dir.path().join("a.rs");
+ fs::write(&src, "fn a() -> i32 { 1 }").unwrap();
+ cmd_ingest(&repo, "p", &src).unwrap();
+
+ let app = build_router_for_test(repo.clone(), "p".to_string());
+ let res = app
+ .oneshot(Request::builder().uri("/status").body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::OK);
+ let bytes = res.into_body().collect().await.unwrap().to_bytes();
+ let json: Value = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(json["roots"], 1);
+ assert_eq!(json["attestations"], 1);
+}
+
+#[tokio::test]
+async fn http_roots_lists_ingested_alpha() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+ let src = dir.path().join("b.rs");
+ fs::write(&src, "fn b() -> i32 { 9 }").unwrap();
+ let ing = cmd_ingest(&repo, "p", &src).unwrap();
+
+ let app = build_router_for_test(repo.clone(), "p".to_string());
+ let res = app
+ .oneshot(Request::builder().uri("/roots").body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::OK);
+ let bytes = res.into_body().collect().await.unwrap().to_bytes();
+ let json: Value = serde_json::from_slice(&bytes).unwrap();
+ let items = json["items"].as_array().unwrap();
+ assert_eq!(items.len(), 1);
+ assert_eq!(items[0]["alpha"], ing.alpha.to_string());
+ assert_eq!(items[0]["dialect"], "rust");
+ assert_eq!(items[0]["attestations"], 1);
+}
+
+#[tokio::test]
+async fn http_show_returns_rendered_source() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+ let src = dir.path().join("c.rs");
+ fs::write(&src, "fn c() -> i32 { 7 }").unwrap();
+ let ing = cmd_ingest(&repo, "p", &src).unwrap();
+
+ let app = build_router_for_test(repo.clone(), "p".to_string());
+ let res = app
+ .oneshot(
+ Request::builder()
+ .uri(format!("/roots/{}/show", ing.alpha))
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::OK);
+ let bytes = res.into_body().collect().await.unwrap().to_bytes();
+ let body = String::from_utf8(bytes.to_vec()).unwrap();
+ assert!(body.contains("fn"), "render incluye 'fn'; got: {body}");
+}
+
+#[tokio::test]
+async fn http_show_unknown_alpha_is_404() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+
+ let app = build_router_for_test(repo.clone(), "p".to_string());
+ let fake = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
+ let res = app
+ .oneshot(
+ Request::builder()
+ .uri(format!("/roots/{fake}/show"))
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::NOT_FOUND);
+}
+
+#[tokio::test]
+async fn http_show_invalid_hash_is_400() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+
+ let app = build_router_for_test(repo.clone(), "p".to_string());
+ let res = app
+ .oneshot(
+ Request::builder()
+ .uri("/roots/no-soy-un-hash/show")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::BAD_REQUEST);
+}
+
+#[tokio::test]
+async fn http_signers_returns_local_author() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ let did = cmd_init(&repo, "p").unwrap();
+ let src = dir.path().join("s.rs");
+ fs::write(&src, "fn s() -> i32 { 4 }").unwrap();
+ let ing = cmd_ingest(&repo, "p", &src).unwrap();
+
+ let app = build_router_for_test(repo.clone(), "p".to_string());
+ let res = app
+ .oneshot(
+ Request::builder()
+ .uri(format!("/roots/{}/signers", ing.alpha))
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::OK);
+ let bytes = res.into_body().collect().await.unwrap().to_bytes();
+ let json: Value = serde_json::from_slice(&bytes).unwrap();
+ let items = json["items"].as_array().unwrap();
+ assert_eq!(items.len(), 1);
+ assert_eq!(items[0]["author"], did.to_string());
+}
+
+#[tokio::test]
+async fn http_without_token_rejects_unauthenticated() {
+ // Token activado pero el request no manda Authorization → 401.
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+
+ let app = build_router_for_test_with_token(repo, "p".to_string(), "secret-42".to_string());
+ let res = app
+ .oneshot(Request::builder().uri("/status").body(Body::empty()).unwrap())
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn http_with_wrong_token_rejects() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+
+ let app = build_router_for_test_with_token(repo, "p".to_string(), "secret-42".to_string());
+ let res = app
+ .oneshot(
+ Request::builder()
+ .uri("/status")
+ .header("authorization", "Bearer secret-NO")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+async fn http_with_correct_token_passes() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+
+ let app = build_router_for_test_with_token(repo, "p".to_string(), "secret-42".to_string());
+ let res = app
+ .oneshot(
+ Request::builder()
+ .uri("/status")
+ .header("authorization", "Bearer secret-42")
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::OK);
+}
+
+#[tokio::test]
+async fn http_signers_since_filters_in_query() {
+ // Endpoint /roots/:α/signers?since=<future> debe devolver lista vacía.
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("r");
+ cmd_init(&repo, "p").unwrap();
+ let src = dir.path().join("z.rs");
+ fs::write(&src, "fn z() -> i32 { 8 }").unwrap();
+ let ing = cmd_ingest(&repo, "p", &src).unwrap();
+
+ let app = build_router_for_test(repo, "p".to_string());
+ // since muy en el futuro → 0 firmas
+ let huge = u64::MAX / 2;
+ let res = app
+ .oneshot(
+ Request::builder()
+ .uri(format!("/roots/{}/signers?since={}", ing.alpha, huge))
+ .body(Body::empty())
+ .unwrap(),
+ )
+ .await
+ .unwrap();
+ assert_eq!(res.status(), StatusCode::OK);
+ let bytes = res.into_body().collect().await.unwrap().to_bytes();
+ let json: Value = serde_json::from_slice(&bytes).unwrap();
+ assert!(json["items"].as_array().unwrap().is_empty());
+}
+
+// Silenciamos el unused warning del helper de exploración.
+#[allow(dead_code)]
+fn _quiet_populate_unused() {
+ let _ = populate_repo;
+}
diff --git a/03_ukupacha/minga/minga-cli/tests/watcher.rs b/03_ukupacha/minga/minga-cli/tests/watcher.rs
new file mode 100644
index 0000000..616ce40
--- /dev/null
+++ b/03_ukupacha/minga/minga-cli/tests/watcher.rs
@@ -0,0 +1,129 @@
+//! Tests del file watcher: el "puente humano" que convierte Minga en
+//! un VCS de fondo — el usuario edita archivos y Minga los versiona
+//! sin acción explícita.
+
+use std::fs;
+use std::time::Duration;
+
+use minga_cli::{cmd_init, cmd_status, cmd_watch};
+use tempfile::TempDir;
+
+/// Espera hasta que el `cmd_status` reporte `expected` claves en MST,
+/// o hasta `timeout`. Devuelve `true` si se alcanzó la cuenta.
+async fn wait_until_mst_size(
+ repo: &std::path::Path,
+ pass: &str,
+ expected: usize,
+ timeout: Duration,
+) -> bool {
+ let deadline = std::time::Instant::now() + timeout;
+ loop {
+ if let Ok(s) = cmd_status(repo, pass) {
+ if s.mst_len >= expected {
+ return true;
+ }
+ }
+ if std::time::Instant::now() >= deadline {
+ return false;
+ }
+ tokio::time::sleep(Duration::from_millis(80)).await;
+ }
+}
+
+#[tokio::test]
+async fn watcher_initial_scan_picks_up_existing_files() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ let watch = dir.path().join("src");
+ fs::create_dir(&watch).unwrap();
+ cmd_init(&repo, "p").unwrap();
+
+ // Escribimos archivos ANTES de arrancar el watcher.
+ fs::write(watch.join("a.rs"), "fn a() -> i32 { 1 }").unwrap();
+ fs::write(watch.join("b.rs"), "fn b() -> i32 { 2 }").unwrap();
+
+ // Arrancamos el watcher en una task. La pasada inicial debería
+ // ingerir ambos.
+ let repo_clone = repo.clone();
+ let handle = tokio::spawn(async move {
+ let _ = cmd_watch(&repo_clone, "p", &watch).await;
+ });
+
+ // Damos margen para la pasada inicial. cmd_watch tiene el repo
+ // abierto, pero cmd_status no puede mientras tanto (sled lock).
+ // Solución: cancelamos el watcher antes de medir.
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ handle.abort();
+ let _ = handle.await;
+ tokio::time::sleep(Duration::from_millis(150)).await;
+
+ let s = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s.mst_len, 2, "esperaba 2 funciones del initial scan");
+ assert_eq!(s.attestations_len, 2);
+}
+
+#[tokio::test]
+async fn watcher_ingests_new_file_after_creation() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ let watch = dir.path().join("src");
+ fs::create_dir(&watch).unwrap();
+ cmd_init(&repo, "p").unwrap();
+
+ // Watcher arranca con directorio vacío.
+ let repo_clone = repo.clone();
+ let watch_clone = watch.clone();
+ let handle = tokio::spawn(async move {
+ let _ = cmd_watch(&repo_clone, "p", &watch_clone).await;
+ });
+
+ // Margen para que el watcher se inicialice y registre con notify.
+ tokio::time::sleep(Duration::from_millis(300)).await;
+
+ // Creamos un archivo. notify debería emitir un evento y el
+ // watcher debería ingerirlo.
+ fs::write(watch.join("new.rs"), "fn new() -> i32 { 42 }").unwrap();
+
+ // Esperamos a que el evento se procese.
+ tokio::time::sleep(Duration::from_millis(800)).await;
+
+ // Detenemos el watcher para liberar el lock de sled antes de
+ // hacer cmd_status.
+ handle.abort();
+ let _ = handle.await;
+ tokio::time::sleep(Duration::from_millis(150)).await;
+
+ // Polling con timeout — algunos sistemas de archivos tienen
+ // latencia de eventos.
+ assert!(
+ wait_until_mst_size(&repo, "p", 1, Duration::from_secs(3)).await,
+ "el watcher no ingirió el archivo creado",
+ );
+}
+
+#[tokio::test]
+async fn watcher_ignores_non_rs_files() {
+ let dir = TempDir::new().unwrap();
+ let repo = dir.path().join("repo");
+ let watch = dir.path().join("src");
+ fs::create_dir(&watch).unwrap();
+ cmd_init(&repo, "p").unwrap();
+
+ // Pre-poblamos con un .rs y varios archivos no-Rust.
+ fs::write(watch.join("real.rs"), "fn real() -> i32 { 0 }").unwrap();
+ fs::write(watch.join("readme.md"), "# proyecto").unwrap();
+ fs::write(watch.join("data.json"), "{}").unwrap();
+
+ let repo_clone = repo.clone();
+ let handle = tokio::spawn(async move {
+ let _ = cmd_watch(&repo_clone, "p", &watch).await;
+ });
+
+ tokio::time::sleep(Duration::from_millis(500)).await;
+ handle.abort();
+ let _ = handle.await;
+ tokio::time::sleep(Duration::from_millis(150)).await;
+
+ let s = cmd_status(&repo, "p").unwrap();
+ assert_eq!(s.mst_len, 1, "solo el .rs debe haberse ingerido");
+}
diff --git a/03_ukupacha/minga/minga-core/Cargo.toml b/03_ukupacha/minga/minga-core/Cargo.toml
new file mode 100644
index 0000000..c3b7427
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/Cargo.toml
@@ -0,0 +1,27 @@
+[package]
+name = "minga-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+description = "Minga core: semantic AST, content addressing, Merkle Search Tree. Pure logic, no IO."
+
+[dependencies]
+tree-sitter = { workspace = true }
+tree-sitter-rust = { workspace = true }
+tree-sitter-python = { workspace = true }
+tree-sitter-typescript = { workspace = true }
+tree-sitter-javascript = { workspace = true }
+tree-sitter-go = { workspace = true }
+blake3 = { workspace = true }
+thiserror = { workspace = true }
+ed25519-dalek = { workspace = true }
+rand = { workspace = true }
+serde = { workspace = true }
+serde-big-array = { workspace = true }
+aes-gcm = { workspace = true }
+argon2 = { workspace = true }
+
+[dev-dependencies]
+blake3 = { workspace = true }
+postcard = { workspace = true }
diff --git a/03_ukupacha/minga/minga-core/LEEME.md b/03_ukupacha/minga/minga-core/LEEME.md
new file mode 100644
index 0000000..6f4c403
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/LEEME.md
@@ -0,0 +1,9 @@
+# minga-core
+
+> Modelo de [minga](../README.md): peer, chunk, address.
+
+`Peer { id: PublicKey, addr }`, `Chunk { hash: Blake3, data }`, `Address = Blake3`. Tipos sin red — los importan core y transport.
+
+## Deps
+
+- `serde`, `blake3`, `ed25519-dalek`
diff --git a/03_ukupacha/minga/minga-core/README.md b/03_ukupacha/minga/minga-core/README.md
new file mode 100644
index 0000000..c4f1584
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/README.md
@@ -0,0 +1,9 @@
+# minga-core
+
+> Model of [minga](../README.md): peer, chunk, address.
+
+`Peer { id: PublicKey, addr }`, `Chunk { hash: Blake3, data }`, `Address = Blake3`. Types without network — imported by core and transport.
+
+## Deps
+
+- `serde`, `blake3`, `ed25519-dalek`
diff --git a/03_ukupacha/minga/minga-core/src/alpha/common.rs b/03_ukupacha/minga/minga-core/src/alpha/common.rs
new file mode 100644
index 0000000..e7d0e32
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/alpha/common.rs
@@ -0,0 +1,105 @@
+//! Primitives compartidos entre todos los profiles α-hashing.
+//!
+//! Cada profile per-language (rust, python, ecmascript, go) tiene su
+//! propia lógica de "qué nodos introducen binders" y "cómo distinguir
+//! binders de constructors". Pero el format del wire del hash
+//! (TAG_LEAF, TAG_BINDER, índice de Bruijn) es universal: lo emitimos
+//! desde acá para garantizar que dos lenguajes con la misma
+//! estructura semántica produzcan hashes comparables a nivel de bits.
+
+use crate::ast::SemanticNode;
+use blake3::Hasher;
+
+pub const TAG_NO_LEAF: u8 = 0;
+pub const TAG_LEAF: u8 = 1;
+pub const TAG_BINDER: u8 = 2;
+pub const TAG_REF_BOUND: u8 = 3;
+pub const TAG_REF_FREE: u8 = 4;
+
+/// Emite el kind del nodo + presencia/ausencia de field_name.
+pub fn write_kind_and_field(h: &mut Hasher, node: &SemanticNode) {
+ write_str(h, &node.kind);
+ match &node.field_name {
+ Some(f) => {
+ h.update(&[1]);
+ write_str(h, f);
+ }
+ None => {
+ h.update(&[0]);
+ }
+ }
+}
+
+pub fn write_str(h: &mut Hasher, s: &str) {
+ h.update(&(s.len() as u64).to_le_bytes());
+ h.update(s.as_bytes());
+}
+
+/// Emite el marker de leaf: TAG_LEAF + bytes del leaf si lo hay,
+/// TAG_NO_LEAF si no.
+pub fn emit_leaf_marker(h: &mut Hasher, node: &SemanticNode) {
+ match &node.leaf_text {
+ Some(t) => {
+ h.update(&[TAG_LEAF]);
+ h.update(&(t.len() as u64).to_le_bytes());
+ h.update(t);
+ }
+ None => {
+ h.update(&[TAG_NO_LEAF]);
+ }
+ }
+}
+
+/// Emite un binder anónimo: el contenido textual NO afecta el hash.
+/// Esta es la primitiva de α-equivalencia: dos términos que sólo
+/// difieren en nombres de variables ligadas hashean idénticos.
+pub fn emit_binder_body(h: &mut Hasher) {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&[TAG_BINDER]);
+ h.update(&[0u8; 8]);
+}
+
+/// Emite el kind del nodo + binder body. Atajo para nodos cuyo único
+/// rol es ser binder (e.g. un identifier en posición de pattern).
+pub fn emit_binder_node(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ emit_binder_body(h);
+}
+
+/// Emite un identifier referencia: si está en scope, índice de
+/// Bruijn (offset desde la cima); si no, nombre literal (variable
+/// libre).
+pub fn emit_identifier_ref(h: &mut Hasher, node: &SemanticNode, scope: &[String]) {
+ h.update(&[TAG_NO_LEAF]);
+ if let Some(t) = &node.leaf_text {
+ if let Ok(name) = std::str::from_utf8(t) {
+ if let Some(i) = scope.iter().rposition(|n| n == name) {
+ let de_bruijn = (scope.len() - 1 - i) as u64;
+ h.update(&[TAG_REF_BOUND]);
+ h.update(&de_bruijn.to_le_bytes());
+ } else {
+ h.update(&[TAG_REF_FREE]);
+ h.update(&(t.len() as u64).to_le_bytes());
+ h.update(t);
+ }
+ } else {
+ h.update(&[TAG_REF_FREE]);
+ h.update(&(t.len() as u64).to_le_bytes());
+ h.update(t);
+ }
+ } else {
+ h.update(&[TAG_REF_FREE]);
+ h.update(&[0u8; 8]);
+ }
+ h.update(&[0u8; 8]);
+}
+
+/// Push el nombre del identifier al vector de binders, si tiene
+/// leaf_text válido. Helper común para todos los `collect_binders`.
+pub fn push_identifier_name(node: &SemanticNode, out: &mut Vec<String>) {
+ if let Some(t) = &node.leaf_text {
+ if let Ok(s) = std::str::from_utf8(t) {
+ out.push(s.to_string());
+ }
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs b/03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs
new file mode 100644
index 0000000..7b89864
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/alpha/ecmascript.rs
@@ -0,0 +1,365 @@
+//! α-hashing per-language para JavaScript / TypeScript.
+//!
+//! Las dos gramáticas comparten la mayoría de los kinds (TypeScript
+//! es JS + type annotations), así que un solo profile las cubre. El
+//! caller (`hash_alpha_with`) despacha tanto `Dialect::JavaScript`
+//! como `Dialect::TypeScript` acá.
+//!
+//! Cobertura:
+//! - **`function_declaration`**, **`function_expression`**,
+//! **`method_definition`**, **`generator_function_declaration`**:
+//! parameters introducen binders al body.
+//! - **`arrow_function`**: parameters (formal_parameters O identifier
+//! directo si es shorthand `x => ...`) introducen binder(es) al body.
+//! - **`statement_block`**: cualquier `lexical_declaration` (let/const)
+//! o `variable_declaration` (var) dentro del block introduce binders
+//! al resto del block.
+//! - **`for_in_statement`** (cubre tanto `for (x in obj)` como
+//! `for (x of arr)` en tree-sitter-javascript): el `left` es
+//! binder al `body`.
+//! - **`for_statement`**: el `initializer` (lexical_declaration)
+//! introduce binder(es) al `condition`, `increment` y `body`.
+//! - **`catch_clause`**: el `parameter` introduce binder al `body`.
+//!
+//! TypeScript-specific: `type` annotations (`x: number`) viajan como
+//! children con field=type que se feedean por el path normal — el
+//! tipo afecta el hash (cambiar de `number` a `string` rompe
+//! α-equivalencia, intencionalmente).
+//!
+//! Pendientes (scope acotado):
+//! - Destructuring (`const {a, b} = obj`, `const [x, y] = arr`).
+//! - Class fields y constructor con `this.x = ...`.
+//! - Hoisting de `var` a function scope (hoy se trata como block-scoped).
+
+use crate::alpha::common::{
+ emit_binder_body, emit_identifier_ref, emit_leaf_marker, push_identifier_name,
+ write_kind_and_field, TAG_NO_LEAF,
+};
+use crate::ast::SemanticNode;
+use crate::cas::ContentHash;
+use blake3::Hasher;
+
+pub fn hash_node_alpha_ecmascript(node: &SemanticNode) -> ContentHash {
+ let mut h = Hasher::new();
+ let mut scope: Vec<String> = Vec::new();
+ feed(&mut h, node, &mut scope);
+ ContentHash(*h.finalize().as_bytes())
+}
+
+fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ match node.kind.as_str() {
+ "function_declaration"
+ | "function_expression"
+ | "generator_function_declaration"
+ | "generator_function"
+ | "method_definition" => feed_callable(h, node, scope),
+ "arrow_function" => feed_arrow(h, node, scope),
+ "statement_block" => feed_block(h, node, scope),
+ "for_in_statement" => feed_for_in(h, node, scope),
+ "for_statement" => feed_for(h, node, scope),
+ "catch_clause" => feed_catch(h, node, scope),
+ // Lexical declarations dispatcheadas también desde feed
+ // general, no sólo desde feed_block. Necesario para
+ // for_statement (initializer) y otros contextos donde una
+ // declaration aparece sin ser hijo directo de un block.
+ "lexical_declaration" | "variable_declaration" => feed_var_decl(h, node, scope),
+ "identifier" => emit_identifier_ref(h, node, scope),
+ _ => feed_default(h, node, scope),
+ }
+}
+
+fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ emit_leaf_marker(h, node);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed(h, c, scope);
+ }
+}
+
+/// Callable estándar: parameters → body.
+fn feed_callable(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("parameters") {
+ collect_formal_param_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("parameters") => feed_formal_params(h, c, scope),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// Arrow function: dos formas. `x => body` (single identifier) o
+/// `(x, y) => body` (formal_parameters). Detectamos cuál.
+fn feed_arrow(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("parameter") => {
+ // `x => ...` — el identifier solo.
+ if c.kind == "identifier" {
+ push_identifier_name(c, &mut binders);
+ }
+ }
+ Some("parameters") => {
+ collect_formal_param_binders(c, &mut binders);
+ }
+ _ => {}
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("parameter") => emit_arrow_single_binder(h, c),
+ Some("parameters") => feed_formal_params(h, c, scope),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+fn emit_arrow_single_binder(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ if node.kind == "identifier" {
+ emit_binder_body(h);
+ } else {
+ // Otra forma (rare); fallback al feed normal sin binder.
+ emit_leaf_marker(h, node);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ }
+}
+
+/// Statement block: `let`/`const`/`var` declarations introducen
+/// binders al resto del block (lexical scope).
+fn feed_block(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let scope_before = scope.len();
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.kind.as_str() {
+ "lexical_declaration" | "variable_declaration" => {
+ feed_var_decl(h, c, scope);
+ collect_var_decl_binders(c, scope);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+ scope.truncate(scope_before);
+}
+
+/// Procesa una let/const/var declaration: el `value` se evalúa en el
+/// scope previo (los binders aún no existen para sí mismos); el
+/// `name` se emite como binder anónimo.
+fn feed_var_decl(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.kind == "variable_declarator" {
+ feed_declarator(h, c, scope);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn feed_declarator(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("name") if c.kind == "identifier" => emit_named_binder(h, c),
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+fn collect_var_decl_binders(node: &SemanticNode, out: &mut Vec<String>) {
+ for c in &node.children {
+ if c.kind == "variable_declarator" {
+ for cc in &c.children {
+ if cc.field_name.as_deref() == Some("name") && cc.kind == "identifier" {
+ push_identifier_name(cc, out);
+ }
+ }
+ }
+ }
+}
+
+/// `for (x of arr)` o `for (x in obj)`. left = identifier (con
+/// posible kind=const/let prefix para lexical decl), right = expr,
+/// body = block.
+fn feed_for_in(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("left") && c.kind == "identifier" {
+ push_identifier_name(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("left") if c.kind == "identifier" => emit_named_binder(h, c),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// `for (let i = 0; i < n; i++) { body }`. El initializer (lexical
+/// decl) introduce binders que viven en condition + increment + body.
+fn feed_for(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("initializer")
+ && (c.kind == "lexical_declaration" || c.kind == "variable_declaration")
+ {
+ collect_var_decl_binders(c, &mut binders);
+ }
+ }
+
+ let scope_before = scope.len();
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("initializer") => {
+ feed(h, c, scope);
+ // Tras procesar el initializer extendemos scope para
+ // que condition/increment/body lo vean.
+ scope.extend(binders.iter().cloned());
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+ scope.truncate(scope_before);
+}
+
+/// `catch (e) { body }`. parameter es identifier → binder al body.
+fn feed_catch(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("parameter") && c.kind == "identifier" {
+ push_identifier_name(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("parameter") if c.kind == "identifier" => emit_named_binder(h, c),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// formal_parameters de function declarations. Soporta:
+/// - `identifier` (param simple).
+/// - `required_parameter` (TypeScript: `x: number`).
+/// - `optional_parameter` (TypeScript: `x?: number`).
+/// - `rest_pattern` / `rest_parameter` (`...rest`).
+fn feed_formal_params(h: &mut Hasher, params: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, params);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(params.children.len() as u64).to_le_bytes());
+ for c in ¶ms.children {
+ match c.kind.as_str() {
+ "identifier" => emit_named_binder(h, c),
+ "required_parameter" | "optional_parameter" => {
+ feed_typed_param(h, c, scope);
+ }
+ "rest_pattern" | "rest_parameter" => {
+ feed_rest_param(h, c, scope);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+fn feed_typed_param(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ let mut named_binder = false;
+ for c in &node.children {
+ if !named_binder && c.kind == "identifier" {
+ emit_named_binder(h, c);
+ named_binder = true;
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn feed_rest_param(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.kind == "identifier" {
+ emit_named_binder(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn collect_formal_param_binders(params: &SemanticNode, out: &mut Vec<String>) {
+ for c in ¶ms.children {
+ match c.kind.as_str() {
+ "identifier" => push_identifier_name(c, out),
+ "required_parameter" | "optional_parameter" | "rest_pattern" | "rest_parameter" => {
+ if let Some(ident) = c.children.iter().find(|cc| cc.kind == "identifier") {
+ push_identifier_name(ident, out);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+fn emit_named_binder(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ emit_binder_body(h);
+}
diff --git a/03_ukupacha/minga/minga-core/src/alpha/go.rs b/03_ukupacha/minga/minga-core/src/alpha/go.rs
new file mode 100644
index 0000000..396a61c
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/alpha/go.rs
@@ -0,0 +1,283 @@
+//! α-hashing per-language para Go.
+//!
+//! Cobertura:
+//! - **`function_declaration`**, **`method_declaration`**,
+//! **`func_literal`** (closure): `parameter_list` introduce
+//! binder(es) al `body`.
+//! - **`parameter_declaration`**: puede agrupar varios names con un
+//! tipo (`a, b int`). Cada `name` es binder; `type` viaja como
+//! referencia.
+//! - **`block`**: `short_var_declaration` (`x := ...`) introduce
+//! binders al resto del block.
+//! - **`for_statement`** con **`range_clause`** (`for k, v := range m`):
+//! los identifiers del `left` son binders al `body`.
+//! - **`for_statement`** con **`for_clause`** (C-style `for i := 0; i < n; i++`):
+//! el `initializer` (short_var_declaration) introduce binders al
+//! condition + update + body.
+//! - **`if_statement`** con **`initializer`**: binders del
+//! short_var_declaration viven en condition + consequence + alternative.
+//!
+//! Pendientes (scope acotado):
+//! - `var_declaration` (`var x = ...`) tratado como literal por
+//! ahora; introduce binder al scope envolvente igual que
+//! short_var_declaration pero distinto kind.
+//! - `type_switch_statement` con assertion binding.
+//! - `select` statements con send/receive binding.
+
+use crate::alpha::common::{
+ emit_binder_body, emit_identifier_ref, emit_leaf_marker, push_identifier_name,
+ write_kind_and_field, TAG_NO_LEAF,
+};
+use crate::ast::SemanticNode;
+use crate::cas::ContentHash;
+use blake3::Hasher;
+
+pub fn hash_node_alpha_go(node: &SemanticNode) -> ContentHash {
+ let mut h = Hasher::new();
+ let mut scope: Vec<String> = Vec::new();
+ feed(&mut h, node, &mut scope);
+ ContentHash(*h.finalize().as_bytes())
+}
+
+fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ match node.kind.as_str() {
+ "function_declaration" | "method_declaration" | "func_literal" => {
+ feed_callable(h, node, scope)
+ }
+ "block" => feed_block(h, node, scope),
+ "for_statement" => feed_for_statement(h, node, scope),
+ "if_statement" => feed_if_statement(h, node, scope),
+ // Dispatcheados también fuera de block/for/if para que sus
+ // identifiers se emitan como binders cuando aparecen en
+ // contextos como range_clause o initializer de if/for.
+ "short_var_declaration" => feed_short_var_decl(h, node, scope),
+ "range_clause" => feed_range_clause(h, node, scope),
+ "identifier" => emit_identifier_ref(h, node, scope),
+ _ => feed_default(h, node, scope),
+ }
+}
+
+/// `for k, v := range m` — el `left` (expression_list) tiene
+/// identifiers que son binders. El `right` se evalúa como referencia
+/// normal (es la fuente de iteración).
+fn feed_range_clause(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("left") {
+ feed_short_var_left(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ emit_leaf_marker(h, node);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed(h, c, scope);
+ }
+}
+
+fn feed_callable(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("parameters") {
+ collect_parameter_list_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("parameters") => feed_parameter_list(h, c, scope),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+fn feed_parameter_list(h: &mut Hasher, params: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, params);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(params.children.len() as u64).to_le_bytes());
+ for c in ¶ms.children {
+ if c.kind == "parameter_declaration" {
+ feed_parameter_declaration(h, c, scope);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+/// `a, b int` — todos los `name=identifier` son binders; `type`
+/// viaja como referencia normal (puede mencionar tipos importados).
+fn feed_parameter_declaration(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("name") && c.kind == "identifier" {
+ emit_named_binder(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn collect_parameter_list_binders(params: &SemanticNode, out: &mut Vec<String>) {
+ for c in ¶ms.children {
+ if c.kind == "parameter_declaration" {
+ for cc in &c.children {
+ if cc.field_name.as_deref() == Some("name") && cc.kind == "identifier" {
+ push_identifier_name(cc, out);
+ }
+ }
+ }
+ }
+}
+
+/// Block: `short_var_declaration` introduce binders al resto.
+fn feed_block(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let scope_before = scope.len();
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.kind == "short_var_declaration" {
+ feed_short_var_decl(h, c, scope);
+ collect_short_var_binders(c, scope);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+ scope.truncate(scope_before);
+}
+
+fn feed_short_var_decl(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("left") {
+ feed_short_var_left(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn feed_short_var_left(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.kind == "identifier" {
+ emit_named_binder(h, c);
+ } else {
+ // separadores ',' y otros tokens — emit literal.
+ emit_leaf_marker(h, c);
+ h.update(&(c.children.len() as u64).to_le_bytes());
+ }
+ }
+}
+
+fn collect_short_var_binders(node: &SemanticNode, out: &mut Vec<String>) {
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("left") {
+ for cc in &c.children {
+ if cc.kind == "identifier" {
+ push_identifier_name(cc, out);
+ }
+ }
+ }
+ }
+}
+
+/// `for k, v := range m { body }` o `for i := 0; i < n; i++ { body }`.
+fn feed_for_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ match c.kind.as_str() {
+ "range_clause" => {
+ for cc in &c.children {
+ if cc.field_name.as_deref() == Some("left") {
+ for ccc in &cc.children {
+ if ccc.kind == "identifier" {
+ push_identifier_name(ccc, &mut binders);
+ }
+ }
+ }
+ }
+ }
+ "for_clause" => {
+ for cc in &c.children {
+ if cc.field_name.as_deref() == Some("initializer")
+ && cc.kind == "short_var_declaration"
+ {
+ collect_short_var_binders(cc, &mut binders);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// `if x := init(); cond { ... } else { ... }`. El initializer
+/// introduce binders que viven en condition + consequence +
+/// alternative.
+fn feed_if_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("initializer")
+ && c.kind == "short_var_declaration"
+ {
+ collect_short_var_binders(c, &mut binders);
+ }
+ }
+
+ let scope_before = scope.len();
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("initializer") => {
+ feed(h, c, scope);
+ scope.extend(binders.iter().cloned());
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+ scope.truncate(scope_before);
+}
+
+fn emit_named_binder(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ emit_binder_body(h);
+}
diff --git a/03_ukupacha/minga/minga-core/src/alpha/mod.rs b/03_ukupacha/minga/minga-core/src/alpha/mod.rs
new file mode 100644
index 0000000..ebfc698
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/alpha/mod.rs
@@ -0,0 +1,69 @@
+//! Hash α-equivalente per-language.
+//!
+//! Cada dialecto soportado por [`crate::parse`] tiene su propio
+//! profile en este módulo. Todos comparten primitives de wire en
+//! [`common`] para garantizar comparabilidad bit-a-bit del hash
+//! entre lenguajes con la misma estructura semántica.
+//!
+//! ## API
+//!
+//! - [`hash_node_alpha`] — alias histórico. Asume Rust. Mantenido
+//! por compat con callers viejos (`alpha::hash_node_alpha` sigue
+//! apuntando a Rust).
+//! - [`hash_alpha_with`] — toma [`crate::parse::Dialect`] y delega
+//! al profile correspondiente.
+
+pub mod common;
+pub mod ecmascript;
+pub mod go;
+pub mod python;
+pub mod rust;
+
+pub use rust::hash_node_alpha;
+
+use crate::ast::SemanticNode;
+use crate::cas::ContentHash;
+use crate::parse::Dialect;
+
+/// Calcula el hash α-equivalente de `node` usando el profile del
+/// `dialect`. Cada profile entiende los binders propios de su
+/// lenguaje (def/lambda/comprehensions en Python, function/arrow en
+/// JS/TS, func/range en Go, etc.).
+///
+/// Para callers que ya saben que están en Rust, [`hash_node_alpha`]
+/// es atajo equivalente.
+pub fn hash_alpha_with(dialect: Dialect, node: &SemanticNode) -> ContentHash {
+ match dialect {
+ Dialect::Rust => rust::hash_node_alpha(node),
+ Dialect::Python => python::hash_node_alpha_python(node),
+ Dialect::TypeScript => ecmascript::hash_node_alpha_ecmascript(node),
+ Dialect::JavaScript => ecmascript::hash_node_alpha_ecmascript(node),
+ Dialect::Go => go::hash_node_alpha_go(node),
+ }
+}
+
+/// Verifica que `claimed_alpha` sea el α-hash de `node` bajo *algún*
+/// dialecto soportado. Devuelve el dialecto que coincide (raro tener
+/// más de uno: los profiles α producen hashes distintos por las
+/// constantes de wire de cada profile). Si ningún dialecto matchea,
+/// devuelve `None` — la raíz está inconsistente con su contenido.
+///
+/// Usado al auditar un repo (sea sincronizado o ingerido) sin
+/// confiar en el `dialect` persistido en `SledRootsStore`: si el
+/// repo se trajo del wire de un peer no-confiable, ésta es la forma
+/// de validar que el α-hash que figura en el MST corresponde de
+/// verdad al contenido del nodo.
+pub fn verify_root_alpha(node: &SemanticNode, claimed_alpha: &ContentHash) -> Option<Dialect> {
+ for d in [
+ Dialect::Rust,
+ Dialect::Python,
+ Dialect::TypeScript,
+ Dialect::JavaScript,
+ Dialect::Go,
+ ] {
+ if &hash_alpha_with(d, node) == claimed_alpha {
+ return Some(d);
+ }
+ }
+ None
+}
diff --git a/03_ukupacha/minga/minga-core/src/alpha/python.rs b/03_ukupacha/minga/minga-core/src/alpha/python.rs
new file mode 100644
index 0000000..7e5f713
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/alpha/python.rs
@@ -0,0 +1,387 @@
+//! α-hashing per-language para Python.
+//!
+//! Cobertura:
+//! - **`function_definition`** y **`lambda`**: parámetros introducen
+//! binders al body. Soporta defaults (`def f(x=1)`) y type hints
+//! (`def f(x: int)`) — el binder es el identifier; el default y el
+//! type viajan como expresiones referenciables al scope previo.
+//! - **`for_statement`**: el `left` (identifier o tuple_pattern)
+//! introduce binder(es) al `body`.
+//! - **Comprehensions**: `list_comprehension`, `set_comprehension`,
+//! `dictionary_comprehension`, `generator_expression`. Cada
+//! `for_in_clause` introduce binder(es) que viven en el `body` +
+//! `if_clause`s + `for_in_clause`s siguientes (semántica de scope
+//! incremental de Python).
+//! - **`with_statement`**: `with X() as y:` introduce `y` al body.
+//!
+//! Python NO distingue binders por capitalización (a diferencia de
+//! Rust con `Some` vs `x`). En posición de parámetro/for-target,
+//! todo identifier es binder.
+//!
+//! Pendientes (no cubiertos hoy, scope acotado):
+//! - `class_definition` y métodos (`self` no es binder explícito en
+//! la firma; el primer parámetro recibe nombre arbitrario).
+//! - `assignment` como introductor de scope (Python no tiene `let`
+//! explícito; un `x = 1` agrega x al scope global o local del
+//! bloque envolvente — manejarlo bien requiere análisis de scope
+//! que va más allá del α-hashing tradicional).
+//! - Nested defaults, walrus operator (`:=`), starred patterns.
+
+use crate::alpha::common::{
+ emit_binder_body, emit_identifier_ref, emit_leaf_marker, push_identifier_name,
+ write_kind_and_field, TAG_NO_LEAF,
+};
+use crate::ast::SemanticNode;
+use crate::cas::ContentHash;
+use blake3::Hasher;
+
+pub fn hash_node_alpha_python(node: &SemanticNode) -> ContentHash {
+ let mut h = Hasher::new();
+ let mut scope: Vec<String> = Vec::new();
+ feed(&mut h, node, &mut scope);
+ ContentHash(*h.finalize().as_bytes())
+}
+
+fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ match node.kind.as_str() {
+ "function_definition" => feed_function_definition(h, node, scope),
+ "lambda" => feed_lambda(h, node, scope),
+ "for_statement" => feed_for_statement(h, node, scope),
+ "list_comprehension"
+ | "set_comprehension"
+ | "dictionary_comprehension"
+ | "generator_expression" => feed_comprehension(h, node, scope),
+ "with_statement" => feed_with_statement(h, node, scope),
+ // Cuando un as_pattern_target aparece (típicamente dentro de
+ // un with_clause), sus identifiers son binders. El scope ya
+ // se extendió en feed_with_statement antes de llegar al body;
+ // pero el target mismo necesita emitir binders anónimos para
+ // que el hash no varíe con el nombre.
+ "as_pattern_target" => feed_target_as_binders(h, node),
+ "identifier" => emit_identifier_ref(h, node, scope),
+ _ => feed_default(h, node, scope),
+ }
+}
+
+fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ emit_leaf_marker(h, node);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed(h, c, scope);
+ }
+}
+
+/// `def f(x, y=1, z: int): body` → params son binders al body.
+/// El `name` (identifier de la función) se trata como literal — no
+/// es un binder local (es publicado al scope envolvente, no manejado
+/// acá).
+fn feed_function_definition(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("parameters") {
+ collect_param_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("parameters") => feed_params(h, c, scope),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ Some("name") => {
+ // Nombre de la función: viaja como literal (afecta el
+ // hash, no es α-anónimo). Mismo tratamiento que en
+ // Rust con `function_item.name`.
+ feed_as_literal(h, c);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// `lambda x, y: body` — params binders al body.
+fn feed_lambda(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("parameters") {
+ collect_param_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("parameters") => feed_params(h, c, scope),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// `for x in iterable: body` — x es binder al body.
+fn feed_for_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("left") {
+ collect_target_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("left") => feed_target_as_binders(h, c),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// `[expr for x in xs if cond]` — los `for_in_clause` y `if_clause`
+/// se procesan en orden: cada `for_in_clause` añade binders que
+/// viven en lo siguiente. El `body` (la expresión final) ve TODOS
+/// los binders acumulados.
+fn feed_comprehension(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ // Recolectamos TODOS los binders de TODAS las for_in_clauses.
+ // Python evalúa la comprehension de izquierda a derecha pero el
+ // body ve todo; α-hashing colapsa eso a "todos visibles en body".
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.kind == "for_in_clause" {
+ for cc in &c.children {
+ if cc.field_name.as_deref() == Some("left") {
+ collect_target_binders(cc, &mut binders);
+ }
+ }
+ }
+ }
+
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.kind == "for_in_clause" {
+ feed_for_in_clause(h, c, scope);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+
+ scope.truncate(scope_before);
+}
+
+/// `for x in xs` dentro de una comprehension. El `left` es binder
+/// (anónimo); el `right` se evalúa en el scope previo (sin x).
+/// Pero como `feed_comprehension` ya extendió el scope antes de
+/// llamarnos, x sí está en scope para el right de un `for X in expr`
+/// posterior — semántica correcta de comprehensions de Python.
+fn feed_for_in_clause(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("left") {
+ feed_target_as_binders(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+/// `with X() as y, Z() as w: body` — los `as` introducen binders al body.
+fn feed_with_statement(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.kind == "with_clause" {
+ collect_with_clause_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+fn collect_with_clause_binders(node: &SemanticNode, out: &mut Vec<String>) {
+ // En tree-sitter-python, with_item.value puede ser un as_pattern
+ // que tiene su propio alias. Recursamos para encontrar cualquier
+ // as_pattern_target en el subárbol.
+ for c in &node.children {
+ if c.kind == "with_item" {
+ collect_as_pattern_targets(c, out);
+ }
+ }
+}
+
+fn collect_as_pattern_targets(node: &SemanticNode, out: &mut Vec<String>) {
+ if node.kind == "as_pattern_target" {
+ collect_target_binders(node, out);
+ return;
+ }
+ for c in &node.children {
+ collect_as_pattern_targets(c, out);
+ }
+}
+
+/// Los parameters de def/lambda se procesan emitiendo cada
+/// identifier como binder anónimo. Defaults / type hints / *args /
+/// **kwargs se preservan literalmente (afectan el hash).
+fn feed_params(h: &mut Hasher, params: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, params);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(params.children.len() as u64).to_le_bytes());
+ for c in ¶ms.children {
+ match c.kind.as_str() {
+ "identifier" => emit_param_binder(h, c),
+ "typed_parameter" | "default_parameter" | "typed_default_parameter" => {
+ feed_complex_param(h, c, scope);
+ }
+ "list_splat_pattern" | "dictionary_splat_pattern" => {
+ // *args, **kwargs: el binder es el identifier interno.
+ feed_splat_param(h, c);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+fn emit_param_binder(h: &mut Hasher, ident: &SemanticNode) {
+ write_kind_and_field(h, ident);
+ emit_binder_body(h);
+}
+
+/// `x: int`, `x = 1`, `x: int = 1` — el primer identifier es binder;
+/// el resto (type, default) son referenciables.
+fn feed_complex_param(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ let mut named_binder = false;
+ for c in &node.children {
+ if !named_binder && c.kind == "identifier" {
+ emit_param_binder(h, c);
+ named_binder = true;
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn feed_splat_param(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.kind == "identifier" {
+ emit_param_binder(h, c);
+ } else {
+ feed_as_literal(h, c);
+ }
+ }
+}
+
+fn collect_param_binders(params: &SemanticNode, out: &mut Vec<String>) {
+ for c in ¶ms.children {
+ match c.kind.as_str() {
+ "identifier" => push_identifier_name(c, out),
+ "typed_parameter" | "default_parameter" | "typed_default_parameter" => {
+ if let Some(ident) = c.children.iter().find(|cc| cc.kind == "identifier") {
+ push_identifier_name(ident, out);
+ }
+ }
+ "list_splat_pattern" | "dictionary_splat_pattern" => {
+ if let Some(ident) = c.children.iter().find(|cc| cc.kind == "identifier") {
+ push_identifier_name(ident, out);
+ }
+ }
+ _ => {}
+ }
+ }
+}
+
+/// El `left` de `for x in xs:` o de `with X as y:` puede ser un
+/// identifier solo o una tupla destructurada (`for k, v in ...`).
+fn collect_target_binders(target: &SemanticNode, out: &mut Vec<String>) {
+ match target.kind.as_str() {
+ "identifier" => push_identifier_name(target, out),
+ "tuple_pattern" | "pattern_list" | "list_pattern" => {
+ for c in &target.children {
+ collect_target_binders(c, out);
+ }
+ }
+ _ => {
+ // Recursamos por si hay subnodos relevantes (e.g. parens).
+ for c in &target.children {
+ collect_target_binders(c, out);
+ }
+ }
+ }
+}
+
+/// Emit del target como binders anónimos. Mismo recorrido que collect.
+fn feed_target_as_binders(h: &mut Hasher, target: &SemanticNode) {
+ write_kind_and_field(h, target);
+ match target.kind.as_str() {
+ "identifier" => emit_binder_body(h),
+ "tuple_pattern" | "pattern_list" | "list_pattern" => {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(target.children.len() as u64).to_le_bytes());
+ for c in &target.children {
+ feed_target_as_binders(h, c);
+ }
+ }
+ _ => {
+ // Fallback: literal (preserva la estructura textual).
+ emit_leaf_marker(h, target);
+ h.update(&(target.children.len() as u64).to_le_bytes());
+ for c in &target.children {
+ feed_target_as_binders(h, c);
+ }
+ }
+ }
+}
+
+fn feed_as_literal(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ emit_leaf_marker(h, node);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed_as_literal(h, c);
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/alpha/rust.rs b/03_ukupacha/minga/minga-core/src/alpha/rust.rs
new file mode 100644
index 0000000..5e39f18
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/alpha/rust.rs
@@ -0,0 +1,562 @@
+//! Hash α-equivalente.
+//!
+//! Dos términos que difieren *solo* en los nombres de variables ligadas
+//! producen el mismo hash. Los nombres de funciones, los identificadores
+//! libres y los constructores (variantes, tipos) **sí** afectan al hash:
+//! forman parte de la interfaz pública o discriminan el término.
+//!
+//! Implementación: durante el recorrido se mantiene una pila de scopes.
+//! Al encontrar un binder reconocido, su nombre se empuja sobre la pila;
+//! al salir del scope, se descarta. Las referencias a identificadores se
+//! buscan desde la cima:
+//! - si están, se emite un índice estilo de Bruijn (offset desde la cima);
+//! - si no, se emite el nombre literal (variable libre).
+//!
+//! **Distinción binder vs. constructor:** dentro de un patrón, un
+//! `identifier` puede ser binder (`x`, `mi_var`) o constructor / variante
+//! (`None`, `Ok`, `MAX_VAL`). La gramática no los distingue; usamos la
+//! convención de Rust: minúscula inicial (o `_` seguido de letra) = binder,
+//! mayúscula inicial = constructor. Cuando el grammar marca explícitamente
+//! `field_name = "pattern"` (parámetros, lets), forzamos binder.
+//!
+//! **Cobertura del MVP:**
+//! - Parámetros de `function_item` y `closure_expression`.
+//! - Bindings de `let_declaration` dentro de `block`, con desestructura.
+//! - Variable de `for_expression`.
+//! - Brazos de `match` (`match_arm` con guarda; cada arm es un scope
+//! independiente).
+//! - Patrones: `tuple_pattern`, `tuple_struct_pattern`, `struct_pattern`,
+//! `field_pattern` (forma completa y shorthand), `captured_pattern`
+//! (`n @ pat`), `range_pattern`, `slice_pattern`, `ref_pattern`,
+//! `reference_pattern`, `mut_pattern`.
+//!
+//! **Cobertura adicional (este módulo cierra el plan):**
+//! - `if_expression` y `while_expression` detectan `let_condition`
+//! en su `condition` y propagan los binders al `consequence`/`body`.
+//! Cubre `if let`, `while let` y let-chains (`let X && let Y`).
+//! - `let_declaration` con `alternative` (let-else): el alternative
+//! se procesa en el scope SIN los binders del pattern (Rust no
+//! los ve en la rama de fallo). Funciona naturalmente porque
+//! `feed_let` no extiende scope; el block padre lo hace después.
+//! - `or_pattern`: todos los lados tienen los mismos binders (Rust
+//! enforcement); recolectamos sólo del primer alternativo para
+//! evitar duplicados, emitimos feed_pattern para cada uno.
+
+use crate::alpha::common::{
+ emit_binder_body, emit_binder_node, emit_identifier_ref, emit_leaf_marker,
+ push_identifier_name, write_kind_and_field, TAG_NO_LEAF,
+};
+use crate::ast::SemanticNode;
+use crate::cas::ContentHash;
+use blake3::Hasher;
+
+pub fn hash_node_alpha(node: &SemanticNode) -> ContentHash {
+ let mut h = Hasher::new();
+ let mut scope: Vec<String> = Vec::new();
+ feed(&mut h, node, &mut scope);
+ ContentHash(*h.finalize().as_bytes())
+}
+
+fn feed(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+
+ match node.kind.as_str() {
+ "function_item" | "closure_expression" => feed_callable(h, node, scope),
+ "block" => feed_block(h, node, scope),
+ "for_expression" => feed_for(h, node, scope),
+ "if_expression" => feed_if_expression(h, node, scope),
+ "while_expression" => feed_while_expression(h, node, scope),
+ "let_condition" => feed_let_condition(h, node, scope),
+ "match_arm" => feed_match_arm(h, node, scope),
+ "identifier" if node.field_name.as_deref() == Some("pattern") => emit_binder_body(h),
+ "identifier" => emit_identifier_ref(h, node, scope),
+ _ => feed_default(h, node, scope),
+ }
+}
+
+/// Dentro de un `let_condition` (`if let X = expr`, `while let X = expr`,
+/// let-chains), el `pattern` debe pasar por `feed_pattern` para que los
+/// identifiers del pattern se emitan como TAG_BINDER (anónimos), no
+/// como referencias libres. El `value` y demás children van por feed
+/// normal.
+fn feed_let_condition(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ feed_pattern(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+/// Maneja `if let X = expr { ... }` y let-chains (`if let X = a && let Y = b`).
+/// Los binders del/los `let_condition`(s) se acumulan y se propagan
+/// SÓLO al `consequence` (no al `alternative`, que es el `else`).
+fn feed_if_expression(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("condition") {
+ collect_let_condition_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("consequence") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// Maneja `while let X = expr { ... }`. Los binders del `let_condition`
+/// se propagan SÓLO al `body` (no al `condition` mismo, que se evalúa
+/// con el scope previo).
+fn feed_while_expression(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("condition") {
+ collect_let_condition_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+/// Recolecta binders de patterns dentro de cualquier `let_condition`
+/// nested en `node`. Para let-chains (`let X = a && let Y = b`),
+/// recursa en el árbol del condition para capturar todos.
+fn collect_let_condition_binders(node: &SemanticNode, out: &mut Vec<String>) {
+ if node.kind == "let_condition" {
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ collect_pattern_binders(c, out);
+ }
+ }
+ }
+ for c in &node.children {
+ collect_let_condition_binders(c, out);
+ }
+}
+
+fn feed_default(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ emit_leaf_marker(h, node);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed(h, c, scope);
+ }
+}
+
+fn feed_callable(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("parameters") {
+ collect_callable_binders(c, &mut binders);
+ }
+ }
+
+ let scope_before = scope.len();
+ scope.extend(binders);
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("parameters") {
+ feed_callable_params(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+
+ scope.truncate(scope_before);
+}
+
+fn feed_block(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let scope_before = scope.len();
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.kind == "let_declaration" {
+ feed_let(h, c, scope);
+ for cc in &c.children {
+ if cc.field_name.as_deref() == Some("pattern") {
+ collect_pattern_binders(cc, scope);
+ }
+ }
+ } else {
+ feed(h, c, scope);
+ }
+ }
+ scope.truncate(scope_before);
+}
+
+fn feed_let(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ feed_pattern(h, c);
+ } else {
+ feed(h, c, scope);
+ }
+ }
+}
+
+fn feed_for(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ collect_pattern_binders(c, &mut binders);
+ }
+ }
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ match c.field_name.as_deref() {
+ Some("pattern") => feed_pattern(h, c),
+ Some("body") => {
+ let scope_before = scope.len();
+ scope.extend(binders.iter().cloned());
+ feed(h, c, scope);
+ scope.truncate(scope_before);
+ }
+ _ => feed(h, c, scope),
+ }
+ }
+}
+
+fn feed_match_arm(h: &mut Hasher, node: &SemanticNode, scope: &mut Vec<String>) {
+ h.update(&[TAG_NO_LEAF]);
+
+ let mut binders: Vec<String> = Vec::new();
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ collect_match_pattern_binders(c, &mut binders);
+ }
+ }
+
+ let scope_before = scope.len();
+ scope.extend(binders);
+
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ if c.kind == "match_pattern" {
+ feed_match_pattern_split(h, c, scope);
+ } else {
+ feed_pattern(h, c);
+ }
+ } else {
+ feed(h, c, scope);
+ }
+ }
+
+ scope.truncate(scope_before);
+}
+
+fn feed_match_pattern_split(h: &mut Hasher, mp: &SemanticNode, scope: &mut Vec<String>) {
+ write_kind_and_field(h, mp);
+ emit_leaf_marker(h, mp);
+ h.update(&(mp.children.len() as u64).to_le_bytes());
+ for c in &mp.children {
+ if c.field_name.as_deref() == Some("condition") {
+ feed(h, c, scope);
+ } else {
+ feed_pattern(h, c);
+ }
+ }
+}
+
+fn collect_match_pattern_binders(p: &SemanticNode, out: &mut Vec<String>) {
+ if p.kind == "match_pattern" {
+ for c in &p.children {
+ if c.field_name.as_deref() != Some("condition") {
+ collect_pattern_binders(c, out);
+ }
+ }
+ } else {
+ collect_pattern_binders(p, out);
+ }
+}
+
+fn feed_callable_params(h: &mut Hasher, params: &SemanticNode) {
+ write_kind_and_field(h, params);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(params.children.len() as u64).to_le_bytes());
+ for c in ¶ms.children {
+ match c.kind.as_str() {
+ "parameter" => feed_parameter(h, c),
+ _ => feed_pattern(h, c),
+ }
+ }
+}
+
+fn feed_parameter(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ feed_pattern(h, c);
+ } else {
+ feed_as_literal(h, c);
+ }
+ }
+}
+
+/// Pattern-aware emitter. Within a pattern, identifiers split into two
+/// roles: binders (introduce a new local) and constructors (variant or
+/// path references). The disambiguation rule mirrors Rust's: a `pattern`
+/// field forces binder; otherwise lowercase initial = binder, uppercase =
+/// constructor.
+fn feed_pattern(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ match node.kind.as_str() {
+ "identifier" => {
+ if is_binder_identifier(node) {
+ emit_binder_body(h);
+ } else {
+ emit_leaf_marker(h, node);
+ h.update(&[0u8; 8]);
+ }
+ }
+ "tuple_pattern" | "ref_pattern" | "reference_pattern" | "mut_pattern" | "slice_pattern" => {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed_pattern(h, c);
+ }
+ }
+ "or_pattern" => {
+ // Cada lado del or-pattern debe introducir el mismo set
+ // de binders (Rust enforcement). Emitimos cada rama pero
+ // sólo recolectaremos binders de la primera —
+ // la responsabilidad recae en `collect_pattern_binders`.
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed_pattern(h, c);
+ }
+ }
+ "tuple_struct_pattern" => {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("type") {
+ feed_as_literal(h, c);
+ } else {
+ feed_pattern(h, c);
+ }
+ }
+ }
+ "struct_pattern" => {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ if c.field_name.as_deref() == Some("type") {
+ feed_as_literal(h, c);
+ } else if c.kind == "field_pattern" {
+ feed_field_pattern(h, c);
+ } else {
+ feed_as_literal(h, c);
+ }
+ }
+ }
+ "captured_pattern" => {
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ let mut named_binder = false;
+ for c in &node.children {
+ if !named_binder && c.kind == "identifier" {
+ emit_binder_node(h, c);
+ named_binder = true;
+ } else {
+ feed_pattern(h, c);
+ }
+ }
+ }
+ _ => feed_as_literal(h, node),
+ }
+}
+
+fn feed_field_pattern(h: &mut Hasher, fp: &SemanticNode) {
+ write_kind_and_field(h, fp);
+ let has_pattern = fp
+ .children
+ .iter()
+ .any(|c| c.field_name.as_deref() == Some("pattern"));
+ h.update(&[TAG_NO_LEAF]);
+ h.update(&(fp.children.len() as u64).to_le_bytes());
+ for c in &fp.children {
+ if has_pattern {
+ if c.field_name.as_deref() == Some("pattern") {
+ feed_pattern(h, c);
+ } else {
+ feed_as_literal(h, c);
+ }
+ } else if matches!(
+ c.kind.as_str(),
+ "identifier" | "shorthand_field_identifier" | "field_identifier"
+ ) {
+ emit_binder_node(h, c);
+ } else {
+ feed_as_literal(h, c);
+ }
+ }
+}
+
+fn feed_as_literal(h: &mut Hasher, node: &SemanticNode) {
+ write_kind_and_field(h, node);
+ emit_leaf_marker(h, node);
+ h.update(&(node.children.len() as u64).to_le_bytes());
+ for c in &node.children {
+ feed_as_literal(h, c);
+ }
+}
+
+fn collect_callable_binders(params: &SemanticNode, out: &mut Vec<String>) {
+ for c in ¶ms.children {
+ match c.kind.as_str() {
+ "parameter" => {
+ for cc in &c.children {
+ if cc.field_name.as_deref() == Some("pattern") {
+ collect_pattern_binders(cc, out);
+ }
+ }
+ }
+ _ => collect_pattern_binders(c, out),
+ }
+ }
+}
+
+fn collect_pattern_binders(p: &SemanticNode, out: &mut Vec<String>) {
+ match p.kind.as_str() {
+ "identifier" => {
+ if is_binder_identifier(p) {
+ push_identifier_name(p, out);
+ }
+ }
+ "tuple_pattern" | "ref_pattern" | "reference_pattern" | "mut_pattern" | "slice_pattern" => {
+ for c in &p.children {
+ collect_pattern_binders(c, out);
+ }
+ }
+ "or_pattern" => {
+ // Sólo recolectamos del primer alternativo: Rust exige
+ // que todos los lados introduzcan exactamente los mismos
+ // binders, así que el primero es representativo. Iterar
+ // todos duplicaría los nombres y rompería los índices
+ // de Bruijn en el cuerpo.
+ if let Some(first) = p
+ .children
+ .iter()
+ .find(|c| !matches!(c.kind.as_str(), "|" | "or"))
+ {
+ collect_pattern_binders(first, out);
+ }
+ }
+ "tuple_struct_pattern" => {
+ for c in &p.children {
+ if c.field_name.as_deref() != Some("type") {
+ collect_pattern_binders(c, out);
+ }
+ }
+ }
+ "struct_pattern" => {
+ for c in &p.children {
+ if c.kind == "field_pattern" {
+ collect_field_pattern_binders(c, out);
+ }
+ }
+ }
+ "captured_pattern" => {
+ let mut named_binder = false;
+ for c in &p.children {
+ if !named_binder && c.kind == "identifier" {
+ push_identifier_name(c, out);
+ named_binder = true;
+ } else {
+ collect_pattern_binders(c, out);
+ }
+ }
+ }
+ _ => {}
+ }
+}
+
+fn collect_field_pattern_binders(fp: &SemanticNode, out: &mut Vec<String>) {
+ let has_pattern = fp
+ .children
+ .iter()
+ .any(|c| c.field_name.as_deref() == Some("pattern"));
+ if has_pattern {
+ for c in &fp.children {
+ if c.field_name.as_deref() == Some("pattern") {
+ collect_pattern_binders(c, out);
+ }
+ }
+ } else {
+ for c in &fp.children {
+ if matches!(
+ c.kind.as_str(),
+ "identifier" | "shorthand_field_identifier" | "field_identifier"
+ ) {
+ push_identifier_name(c, out);
+ }
+ }
+ }
+}
+
+/// Determina si un `identifier` en posición de patrón se interpreta como
+/// binder. Reglas (específicas de Rust):
+/// - Si tiene `field_name == "pattern"` (parámetros, lets), siempre es binder.
+/// - Si su nombre comienza con minúscula, es binder.
+/// - Si comienza con `_` seguido de letra/dígito, es binder (convención
+/// Rust para "intencionalmente sin usar").
+/// - Resto: constructor / variante / constante (literal).
+fn is_binder_identifier(node: &SemanticNode) -> bool {
+ if node.field_name.as_deref() == Some("pattern") {
+ return true;
+ }
+ let Some(t) = &node.leaf_text else { return false };
+ let Ok(s) = std::str::from_utf8(t) else { return false };
+ is_binder_name(s)
+}
+
+fn is_binder_name(s: &str) -> bool {
+ let mut chars = s.chars();
+ match chars.next() {
+ Some('_') => chars
+ .next()
+ .map_or(false, |c| c.is_lowercase() || c.is_ascii_digit() || c == '_'),
+ Some(c) => c.is_lowercase(),
+ None => false,
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/ast.rs b/03_ukupacha/minga/minga-core/src/ast.rs
new file mode 100644
index 0000000..c45cfef
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/ast.rs
@@ -0,0 +1,52 @@
+use tree_sitter::Node;
+
+/// Nodo de AST normalizado: descarta posiciones, whitespace y trivia
+/// (comentarios marcados como `extra` en la gramática). Dos fragmentos de
+/// código semánticamente equivalentes producen árboles idénticos.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SemanticNode {
+ pub kind: String,
+ pub field_name: Option<String>,
+ pub leaf_text: Option<Vec<u8>>,
+ pub children: Vec<SemanticNode>,
+}
+
+impl SemanticNode {
+ pub fn from_tree_sitter(node: Node<'_>, source: &[u8]) -> Self {
+ Self::build(node, source, None)
+ }
+
+ fn build(node: Node<'_>, source: &[u8], field_name: Option<String>) -> Self {
+ let kind = node.kind().to_string();
+ let mut children = Vec::new();
+
+ // Incluimos todos los hijos no-`extra`: nombrados (rules de la
+ // gramática) y anónimos (tokens literales como operadores y
+ // separadores). Lo único que descartamos son `extras` —
+ // comentarios y whitespace en gramáticas tree-sitter — que es
+ // exactamente la invariancia que queremos: dos formas con el
+ // mismo contenido y estructura producen el mismo árbol.
+ let mut cursor = node.walk();
+ if cursor.goto_first_child() {
+ loop {
+ let child = cursor.node();
+ if !child.is_extra() {
+ let field = cursor.field_name().map(|s| s.to_string());
+ children.push(Self::build(child, source, field));
+ }
+ if !cursor.goto_next_sibling() {
+ break;
+ }
+ }
+ }
+
+ let leaf_text = if children.is_empty() {
+ let range = node.byte_range();
+ Some(source[range].to_vec())
+ } else {
+ None
+ };
+
+ SemanticNode { kind, field_name, leaf_text, children }
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/attestation.rs b/03_ukupacha/minga/minga-core/src/attestation.rs
new file mode 100644
index 0000000..960b140
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/attestation.rs
@@ -0,0 +1,127 @@
+//! Atestaciones firmadas: la sustancia material de la atribución
+//! irrefutable. Una `Attestation` es una firma criptográfica sobre un
+//! `ContentHash` que vincula a su autor (un `Did`) con un fragmento
+//! concreto de contenido del repositorio.
+//!
+//! Modelo: cada hash del MST puede tener cero o más atestaciones,
+//! provenientes de autores distintos. La existencia de una atestación
+//! válida prueba que el dueño de cierta clave privada **vio y firmó
+//! exactamente ese hash** — no puede negarlo después sin admitir que
+//! filtró su llave. Es el equivalente a un commit firmado en Git pero
+//! a granularidad arbitraria: una función, un módulo, o un estado del
+//! repositorio entero.
+//!
+//! `AttestationStore` solo acepta atestaciones criptográficamente
+//! válidas: el `add` rechaza cualquier intento de inyectar firmas
+//! falsificadas. Esto convierte al store en una fuente confiable de
+//! la pregunta "¿quién ha respaldado este contenido?".
+
+use crate::cas::ContentHash;
+use crate::identity::{Did, Keypair, Signature};
+use std::collections::HashMap;
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct Attestation {
+ pub content: ContentHash,
+ pub author: Did,
+ pub signature: Signature,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AttestationError {
+ InvalidSignature,
+}
+
+impl std::fmt::Display for AttestationError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidSignature => write!(f, "firma de la atestación no verifica"),
+ }
+ }
+}
+
+impl std::error::Error for AttestationError {}
+
+impl Attestation {
+ /// Crea una atestación firmando el `ContentHash` con la `Keypair`
+ /// del autor. El `Did` queda registrado a partir de la `Keypair`
+ /// — no se acepta un `Did` arbitrario, lo que descarta de raíz
+ /// las atestaciones donde alguien dice ser otro.
+ pub fn create(keypair: &Keypair, content: ContentHash) -> Self {
+ Self {
+ content,
+ author: keypair.did(),
+ signature: keypair.sign(&content.0),
+ }
+ }
+
+ /// Verifica que `signature` es una firma válida sobre `content`
+ /// hecha con la llave privada del `author`. Cualquier modificación
+ /// de cualquiera de los tres campos invalida la atestación.
+ pub fn verify(&self) -> bool {
+ self.author.verify(&self.content.0, &self.signature)
+ }
+}
+
+/// Registro de atestaciones por `ContentHash`.
+///
+/// Idempotente por `(author, content)`: insertar dos veces la misma
+/// atestación no la duplica. Pero un mismo `ContentHash` puede tener
+/// atestaciones de **autores distintos** — es la base de los "filtros
+/// de convergencia" del spec, donde el peso de un cambio se mide por
+/// cuántas identidades reputadas lo respaldan.
+#[derive(Debug, Default, Clone)]
+pub struct AttestationStore {
+ by_content: HashMap<ContentHash, Vec<Attestation>>,
+}
+
+impl AttestationStore {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Inserta una atestación. Devuelve `Err(InvalidSignature)` si la
+ /// firma no verifica — el store NUNCA almacena firmas rotas, así
+ /// que cualquier consulta posterior puede confiar en lo que lee.
+ pub fn add(&mut self, att: Attestation) -> Result<(), AttestationError> {
+ if !att.verify() {
+ return Err(AttestationError::InvalidSignature);
+ }
+ let entry = self.by_content.entry(att.content).or_default();
+ if !entry.iter().any(|a| a.author == att.author) {
+ entry.push(att);
+ }
+ Ok(())
+ }
+
+ pub fn get(&self, content: &ContentHash) -> &[Attestation] {
+ self.by_content
+ .get(content)
+ .map(Vec::as_slice)
+ .unwrap_or(&[])
+ }
+
+ /// Conjunto de DIDs que han atestado este contenido. Cada autor
+ /// aparece como máximo una vez (deduplicación por `add`).
+ pub fn authors_of(&self, content: &ContentHash) -> Vec<Did> {
+ self.by_content
+ .get(content)
+ .map(|v| v.iter().map(|a| a.author).collect())
+ .unwrap_or_default()
+ }
+
+ pub fn len(&self) -> usize {
+ self.by_content.values().map(Vec::len).sum()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.by_content.values().all(Vec::is_empty)
+ }
+
+ /// Itera todas las atestaciones del store (orden no especificado).
+ /// Usado por el protocolo de sync para enumerar lo que tenemos y
+ /// empujarlo al peer.
+ pub fn all(&self) -> impl Iterator<Item = &Attestation> + '_ {
+ self.by_content.values().flat_map(|v| v.iter())
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/cas.rs b/03_ukupacha/minga/minga-core/src/cas.rs
new file mode 100644
index 0000000..ce290e4
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/cas.rs
@@ -0,0 +1,95 @@
+use crate::ast::SemanticNode;
+use blake3::Hasher;
+
+/// Hash de 32 bytes que identifica unívocamente un `SemanticNode` por su
+/// estructura lógica. Dos nodos con misma estructura → mismo hash, sin
+/// importar format, comentarios o posición en el archivo fuente.
+#[derive(
+ Debug,
+ Clone,
+ Copy,
+ PartialEq,
+ Eq,
+ Hash,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+)]
+#[serde(transparent)]
+pub struct ContentHash(pub [u8; 32]);
+
+impl ContentHash {
+ pub fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+}
+
+impl std::fmt::Display for ContentHash {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ for b in &self.0 {
+ write!(f, "{:02x}", b)?;
+ }
+ Ok(())
+ }
+}
+
+/// Hash Merkle de un `SemanticNode`. El hash es función pura de
+/// `(kind, field_name, leaf_text, &[child_hash])`. Esquema estricto:
+/// los hijos contribuyen como hash, no como bytestream completo. Eso
+/// permite verificar un nodo recibido por la red **sin tener** sus
+/// hijos: basta con tener los hashes de los hijos (que vienen en el
+/// `StoredNode.children`) y reproducir esta función.
+pub fn hash_node(node: &SemanticNode) -> ContentHash {
+ let child_hashes: Vec<ContentHash> = node.children.iter().map(hash_node).collect();
+ hash_components(
+ &node.kind,
+ node.field_name.as_deref(),
+ node.leaf_text.as_deref(),
+ &child_hashes,
+ )
+}
+
+/// Primitiva canónica del hash estructural. Es la única definición
+/// authoritativa: cualquier otra función que produzca un hash de
+/// contenido debe expresarse encima de ésta. Garantiza que
+/// `hash_node(&semantic)` y `hash_stored(&stored)` coincidan bit a bit
+/// para representaciones equivalentes del mismo árbol.
+pub fn hash_components(
+ kind: &str,
+ field_name: Option<&str>,
+ leaf_text: Option<&[u8]>,
+ child_hashes: &[ContentHash],
+) -> ContentHash {
+ let mut h = Hasher::new();
+ write_str(&mut h, kind);
+ match field_name {
+ Some(f) => {
+ h.update(&[1]);
+ write_str(&mut h, f);
+ }
+ None => {
+ h.update(&[0]);
+ }
+ }
+ match leaf_text {
+ Some(t) => {
+ h.update(&[1]);
+ h.update(&(t.len() as u64).to_le_bytes());
+ h.update(t);
+ }
+ None => {
+ h.update(&[0]);
+ }
+ }
+ h.update(&(child_hashes.len() as u64).to_le_bytes());
+ for ch in child_hashes {
+ h.update(&ch.0);
+ }
+ ContentHash(*h.finalize().as_bytes())
+}
+
+fn write_str(h: &mut Hasher, s: &str) {
+ h.update(&(s.len() as u64).to_le_bytes());
+ h.update(s.as_bytes());
+}
diff --git a/03_ukupacha/minga/minga-core/src/identity.rs b/03_ukupacha/minga/minga-core/src/identity.rs
new file mode 100644
index 0000000..8624291
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/identity.rs
@@ -0,0 +1,223 @@
+//! Identidad self-sovereign basada en Ed25519.
+//!
+//! Cada peer (y cada autor humano o agente IA) se identifica por un
+//! `Did` — el bytestring de su clave pública Ed25519. La clave privada
+//! vive en su `Keypair` y nunca sale del nodo. Firmar un mensaje con la
+//! `Keypair` produce una `Signature` que cualquiera con el `Did` puede
+//! verificar — la atribución es irrefutable bajo el modelo
+//! criptográfico estándar (asumiendo que la clave privada no fugó).
+//!
+//! El esquema es deliberadamente minimalista: no hay rotación de
+//! claves, ni revocación, ni metadatos en el DID. Esas capas (DID
+//! Documents, métodos `did:web`/`did:ion`, claves de firma versus de
+//! cifrado, etc.) se construyen encima cuando la complejidad del
+//! producto lo justifique. Por ahora, el `Did` ES la clave pública.
+
+use aes_gcm::{aead::Aead, Aes256Gcm, KeyInit, Nonce};
+use argon2::Argon2;
+use ed25519_dalek::{
+ Signature as Ed25519Sig, Signer, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH,
+ SIGNATURE_LENGTH,
+};
+use rand::rngs::OsRng;
+use rand::RngCore;
+
+/// Cabecera del format de keypair cifrado en disco.
+const KEYPAIR_MAGIC: &[u8; 8] = b"MINGAKEY";
+const KEYPAIR_VERSION: u8 = 1;
+const ARGON2_SALT_LEN: usize = 16;
+const AES_NONCE_LEN: usize = 12;
+const KEYPAIR_HEADER_LEN: usize = 8 + 1 + ARGON2_SALT_LEN + AES_NONCE_LEN;
+
+#[derive(Debug, thiserror::Error)]
+pub enum KeypairCryptoError {
+ #[error("format inválido: faltan magic / versión / longitud")]
+ InvalidFormat,
+
+ #[error("passphrase incorrecta o cifrado manipulado")]
+ DecryptFailed,
+
+ #[error("argon2: {0}")]
+ Argon2(String),
+}
+
+/// Decentralized Identifier: 32 bytes de la clave pública Ed25519.
+#[derive(
+ Debug,
+ Clone,
+ Copy,
+ PartialEq,
+ Eq,
+ Hash,
+ PartialOrd,
+ Ord,
+ serde::Serialize,
+ serde::Deserialize,
+)]
+#[serde(transparent)]
+pub struct Did(pub [u8; SECRET_KEY_LENGTH]);
+
+impl Did {
+ pub fn as_bytes(&self) -> &[u8; SECRET_KEY_LENGTH] {
+ &self.0
+ }
+
+ /// Verifica que `sig` sea una firma válida sobre `msg` producida
+ /// con la llave privada correspondiente a este DID. Devuelve
+ /// `false` ante cualquier irregularidad: bytes de DID que no son
+ /// un punto válido en la curva, firma malformada, mensaje que no
+ /// coincide.
+ pub fn verify(&self, msg: &[u8], sig: &Signature) -> bool {
+ let Ok(vk) = VerifyingKey::from_bytes(&self.0) else {
+ return false;
+ };
+ let ed_sig = Ed25519Sig::from_bytes(&sig.0);
+ vk.verify(msg, &ed_sig).is_ok()
+ }
+}
+
+impl std::fmt::Display for Did {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "did:key:")?;
+ for b in &self.0 {
+ write!(f, "{:02x}", b)?;
+ }
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+#[serde(transparent)]
+pub struct Signature(
+ #[serde(with = "serde_big_array::BigArray")] pub [u8; SIGNATURE_LENGTH],
+);
+
+impl Signature {
+ pub fn as_bytes(&self) -> &[u8; SIGNATURE_LENGTH] {
+ &self.0
+ }
+}
+
+/// Llave criptográfica completa: priva (para firmar) + pública (para
+/// que otros verifiquen). Por convención llamamos `Did` al lado público
+/// expuesto al mundo, pero el `Keypair` mantiene ambos lados juntos.
+#[derive(Clone)]
+pub struct Keypair {
+ signing: SigningKey,
+}
+
+impl Keypair {
+ /// Genera un nuevo `Keypair` usando aleatoriedad del sistema
+ /// operativo (`/dev/urandom` en Unix, `BCryptGenRandom` en
+ /// Windows). Para producción.
+ pub fn generate() -> Self {
+ let mut seed = [0u8; SECRET_KEY_LENGTH];
+ OsRng.fill_bytes(&mut seed);
+ Self::from_seed(&seed)
+ }
+
+ /// Reconstruye un `Keypair` desde una semilla de 32 bytes. Misma
+ /// semilla → mismo `Keypair` (mismo `Did`, mismas firmas). Útil
+ /// para tests reproducibles y para escenarios donde la semilla
+ /// proviene de otra fuente determinista (HKDF, BIP39, etc.).
+ pub fn from_seed(seed: &[u8; SECRET_KEY_LENGTH]) -> Self {
+ Self {
+ signing: SigningKey::from_bytes(seed),
+ }
+ }
+
+ pub fn did(&self) -> Did {
+ Did(self.signing.verifying_key().to_bytes())
+ }
+
+ pub fn sign(&self, msg: &[u8]) -> Signature {
+ Signature(self.signing.sign(msg).to_bytes())
+ }
+
+ /// Cifra la parte privada del keypair con una passphrase humana.
+ /// Esquema:
+ ///
+ /// 1. Genera un salt aleatorio de 16 bytes y un nonce de 12 bytes.
+ /// 2. Deriva una clave AES-256 desde la passphrase vía Argon2id
+ /// (parámetros por defecto OWASP).
+ /// 3. Cifra los 32 bytes de la clave secreta con AES-256-GCM
+ /// (autenticado: integrity built-in).
+ /// 4. Compone el blob:
+ /// `MAGIC(8) || VERSION(1) || SALT(16) || NONCE(12) || CIPHERTEXT+TAG(48)`.
+ ///
+ /// Total: 85 bytes. La passphrase nunca se almacena; quien no la
+ /// conozca no puede recuperar la identidad.
+ pub fn encrypt(&self, passphrase: &str) -> Result<Vec<u8>, KeypairCryptoError> {
+ let mut salt = [0u8; ARGON2_SALT_LEN];
+ let mut nonce_bytes = [0u8; AES_NONCE_LEN];
+ OsRng.fill_bytes(&mut salt);
+ OsRng.fill_bytes(&mut nonce_bytes);
+
+ let aes_key = derive_aes_key(passphrase, &salt)?;
+
+ let cipher = Aes256Gcm::new_from_slice(&aes_key)
+ .map_err(|_| KeypairCryptoError::DecryptFailed)?;
+ let nonce = Nonce::from_slice(&nonce_bytes);
+ let secret_bytes = self.signing.to_bytes();
+ let ciphertext = cipher
+ .encrypt(nonce, secret_bytes.as_ref())
+ .map_err(|_| KeypairCryptoError::DecryptFailed)?;
+
+ let mut out = Vec::with_capacity(KEYPAIR_HEADER_LEN + ciphertext.len());
+ out.extend_from_slice(KEYPAIR_MAGIC);
+ out.push(KEYPAIR_VERSION);
+ out.extend_from_slice(&salt);
+ out.extend_from_slice(&nonce_bytes);
+ out.extend_from_slice(&ciphertext);
+ Ok(out)
+ }
+
+ /// Descifra un keypair cifrado con `encrypt`. Falla con
+ /// `DecryptFailed` si la passphrase es incorrecta **o** si los
+ /// bytes han sido manipulados (AES-GCM detecta ambas vías).
+ pub fn decrypt(bytes: &[u8], passphrase: &str) -> Result<Self, KeypairCryptoError> {
+ if bytes.len() < KEYPAIR_HEADER_LEN {
+ return Err(KeypairCryptoError::InvalidFormat);
+ }
+ if &bytes[..8] != KEYPAIR_MAGIC {
+ return Err(KeypairCryptoError::InvalidFormat);
+ }
+ if bytes[8] != KEYPAIR_VERSION {
+ return Err(KeypairCryptoError::InvalidFormat);
+ }
+
+ let salt = &bytes[9..9 + ARGON2_SALT_LEN];
+ let nonce_bytes = &bytes[9 + ARGON2_SALT_LEN..KEYPAIR_HEADER_LEN];
+ let ciphertext = &bytes[KEYPAIR_HEADER_LEN..];
+
+ let aes_key = derive_aes_key(passphrase, salt)?;
+ let cipher = Aes256Gcm::new_from_slice(&aes_key)
+ .map_err(|_| KeypairCryptoError::DecryptFailed)?;
+ let nonce = Nonce::from_slice(nonce_bytes);
+ let plaintext = cipher
+ .decrypt(nonce, ciphertext)
+ .map_err(|_| KeypairCryptoError::DecryptFailed)?;
+
+ if plaintext.len() != SECRET_KEY_LENGTH {
+ return Err(KeypairCryptoError::InvalidFormat);
+ }
+ let mut seed = [0u8; SECRET_KEY_LENGTH];
+ seed.copy_from_slice(&plaintext);
+ Ok(Self::from_seed(&seed))
+ }
+}
+
+fn derive_aes_key(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], KeypairCryptoError> {
+ let mut key = [0u8; 32];
+ Argon2::default()
+ .hash_password_into(passphrase.as_bytes(), salt, &mut key)
+ .map_err(|e| KeypairCryptoError::Argon2(e.to_string()))?;
+ Ok(key)
+}
+
+impl std::fmt::Debug for Keypair {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ // Nunca exponemos la parte privada en debug. Solo el DID.
+ write!(f, "Keypair {{ did: {} }}", self.did())
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/lib.rs b/03_ukupacha/minga/minga-core/src/lib.rs
new file mode 100644
index 0000000..3582a03
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/lib.rs
@@ -0,0 +1,27 @@
+//! Núcleo puro de Minga: AST normalizado, direccionamiento por contenido
+//! semántico y Merkle Search Tree. Sin IO, sin red, sin filesystem.
+//!
+//! La separación es deliberada: este crate jamás importa libp2p, fuser ni
+//! ningún tipo asociado a un canal de IO. Si algo aquí necesita IO, el
+//! contrato se expone como trait y la implementación vive en otro crate.
+
+pub mod alpha;
+pub mod ast;
+pub mod attestation;
+pub mod cas;
+pub mod identity;
+pub mod mst;
+pub mod parse;
+pub mod retraction;
+pub mod root_decl;
+pub mod store;
+
+pub use alpha::hash_node_alpha;
+pub use ast::SemanticNode;
+pub use attestation::{Attestation, AttestationError, AttestationStore};
+pub use cas::{hash_components, hash_node, ContentHash};
+pub use identity::{Did, Keypair, KeypairCryptoError, Signature};
+pub use mst::{empty_subtree_hash, Mst, MstDiff, NodeProbe};
+pub use retraction::{Retraction, RetractionError, RetractionStore, RETRACTION_DOMAIN};
+pub use root_decl::RootDecl;
+pub use store::{hash_stored, MemStore, NodeStore, StoredNode};
diff --git a/03_ukupacha/minga/minga-core/src/mst.rs b/03_ukupacha/minga/minga-core/src/mst.rs
new file mode 100644
index 0000000..86d990e
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/mst.rs
@@ -0,0 +1,457 @@
+//! Merkle Search Tree (MST).
+//!
+//! Estructura B-árbol probabilística sobre hashes, en la que el "nivel" de
+//! cada clave se deriva determinísticamente de su propio hash (cantidad de
+//! nibbles cero al inicio). Eso da dos propiedades clave:
+//!
+//! * **Independencia del orden de inserción.** El conjunto `{a, b, c}`
+//! siempre produce el mismo árbol y el mismo `root_hash`, sin importar
+//! en qué orden se insertaron las claves.
+//! * **Comparación logarítmica.** Dos repositorios pueden saber si tienen
+//! el mismo conjunto de hashes con un único byte (`root_hash`); y, si
+//! difieren, descender solo por las ramas con hashes distintos.
+//!
+//! Esta implementación es completa para insert/contains/iter y produce un
+//! `root_hash` Merkle correcto. La operación de `diff` mínima (delta de
+//! sincronización P2P) se construirá encima cuando exista `minga-p2p`.
+
+use crate::cas::ContentHash;
+use blake3::Hasher;
+use std::collections::HashMap;
+use std::sync::OnceLock;
+
+/// Resumen estructural de un nodo interno del MST: nivel al que viven
+/// sus claves, las claves a ese nivel, y el hash de cada uno de sus
+/// hijos (subárboles). Esto es lo que un peer transmite cuando otro le
+/// pregunta por la forma de un subárbol durante una sincronización
+/// recursiva: bandwidth proporcional a la divergencia, no al tamaño.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct NodeProbe {
+ pub level: u32,
+ pub keys: Vec<ContentHash>,
+ pub child_hashes: Vec<ContentHash>,
+}
+
+/// Hash canónico del subárbol vacío (el "neutro" del MST). Cualquier
+/// peer puede computarlo localmente sin tocar la red, lo que permite
+/// reconocer ramas vacías en el otro lado sin pedir un probe.
+pub fn empty_subtree_hash() -> ContentHash {
+ static H: OnceLock<ContentHash> = OnceLock::new();
+ *H.get_or_init(|| {
+ let mut h = Hasher::new();
+ h.update(b"E");
+ ContentHash(*h.finalize().as_bytes())
+ })
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct Mst {
+ root: Subtree,
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+enum Subtree {
+ #[default]
+ Empty,
+ Node(Box<NodeData>),
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct NodeData {
+ level: u32,
+ keys: Vec<ContentHash>,
+ children: Vec<Subtree>,
+}
+
+/// Nivel determinístico de un hash: número de nibbles (4 bits) cero al
+/// inicio. Distribución geométrica con base 16, lo que da árbol balanceado
+/// en expectativa con profundidad logarítmica.
+fn level_of(h: &ContentHash) -> u32 {
+ let mut count = 0u32;
+ for &b in &h.0 {
+ if b == 0 {
+ count += 2;
+ } else if b < 0x10 {
+ count += 1;
+ break;
+ } else {
+ break;
+ }
+ }
+ count
+}
+
+impl Mst {
+ pub fn new() -> Self {
+ Self { root: Subtree::Empty }
+ }
+
+ /// Inserta `h`. Devuelve `true` si era una clave nueva.
+ pub fn insert(&mut self, h: ContentHash) -> bool {
+ let l = level_of(&h);
+ let root = std::mem::take(&mut self.root);
+ let (new_root, inserted) = insert_in(root, h, l);
+ self.root = new_root;
+ inserted
+ }
+
+ pub fn contains(&self, h: &ContentHash) -> bool {
+ contains_in(&self.root, h)
+ }
+
+ pub fn len(&self) -> usize {
+ len_of(&self.root)
+ }
+
+ pub fn is_empty(&self) -> bool {
+ matches!(self.root, Subtree::Empty)
+ }
+
+ /// Recorrido in-order: claves emitidas en orden ascendente por hash.
+ pub fn iter(&self) -> Iter<'_> {
+ let mut it = Iter { stack: Vec::new() };
+ it.descend_left(&self.root);
+ it
+ }
+
+ /// Hash Merkle del árbol completo. Dos MSTs con el mismo conjunto de
+ /// claves tienen el mismo `root_hash`, sin importar orden de inserción.
+ pub fn root_hash(&self) -> ContentHash {
+ subtree_hash(&self.root)
+ }
+
+ /// Construye un índice `subtree_hash -> NodeProbe` cubriendo cada
+ /// nodo interno del árbol. Sirve a un peer como tabla de respuestas
+ /// instantáneas a `ProbeReq`s del otro lado: dado un hash que el
+ /// peer recibió de nosotros (en un Hello o un ProbeRes previo),
+ /// podemos reconstituir su `NodeProbe` en `O(1)`.
+ pub fn build_probe_index(&self) -> HashMap<ContentHash, NodeProbe> {
+ let mut idx = HashMap::new();
+ index_subtree(&self.root, &mut idx);
+ idx
+ }
+
+ /// Diferencia simétrica entre `self` y `other`. Devuelve las claves
+ /// que están en `self` pero no en `other`, y viceversa.
+ ///
+ /// Aprovecha la estructura Merkle: cualquier subárbol cuya raíz
+ /// hashee igual entre ambos lados se descarta sin descender. Cuando
+ /// dos nodos comparten nivel y separadores, recurrimos en paralelo
+ /// sobre sus hijos — cada par idéntico se poda por hash. Cuando la
+ /// estructura diverge (niveles distintos o separadores distintos en
+ /// el mismo nivel), enumeramos las claves de ambos y hacemos merge
+ /// ordenado.
+ ///
+ /// El resultado siempre viene ordenado por hash ascendente, lo que
+ /// permite a un peer P2P hacer streaming de los bloques que faltan
+ /// en orden estable y deduplicar mientras los recibe.
+ pub fn diff(&self, other: &Mst) -> MstDiff {
+ let mut d = MstDiff::default();
+ diff_subtrees(&self.root, &other.root, &mut d.only_in_self, &mut d.only_in_other);
+ d
+ }
+}
+
+/// Resultado de comparar dos MSTs. `is_empty()` ⇔ ambos representan el
+/// mismo conjunto.
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct MstDiff {
+ pub only_in_self: Vec<ContentHash>,
+ pub only_in_other: Vec<ContentHash>,
+}
+
+impl MstDiff {
+ pub fn is_empty(&self) -> bool {
+ self.only_in_self.is_empty() && self.only_in_other.is_empty()
+ }
+
+ pub fn total(&self) -> usize {
+ self.only_in_self.len() + self.only_in_other.len()
+ }
+}
+
+fn contains_in(t: &Subtree, h: &ContentHash) -> bool {
+ match t {
+ Subtree::Empty => false,
+ Subtree::Node(n) => match n.keys.binary_search(h) {
+ Ok(_) => true,
+ Err(i) => contains_in(&n.children[i], h),
+ },
+ }
+}
+
+fn len_of(t: &Subtree) -> usize {
+ match t {
+ Subtree::Empty => 0,
+ Subtree::Node(n) => n.keys.len() + n.children.iter().map(len_of).sum::<usize>(),
+ }
+}
+
+fn subtree_hash(t: &Subtree) -> ContentHash {
+ let mut h = Hasher::new();
+ match t {
+ Subtree::Empty => {
+ h.update(b"E");
+ }
+ Subtree::Node(n) => {
+ h.update(b"N");
+ h.update(&n.level.to_le_bytes());
+ h.update(&(n.keys.len() as u64).to_le_bytes());
+ for k in &n.keys {
+ h.update(&k.0);
+ }
+ for c in &n.children {
+ h.update(&subtree_hash(c).0);
+ }
+ }
+ }
+ ContentHash(*h.finalize().as_bytes())
+}
+
+/// Inserta `h` (de nivel `l`) en el subárbol `t`. Devuelve el nuevo
+/// subárbol y si fue una inserción real (no duplicado).
+fn insert_in(t: Subtree, h: ContentHash, l: u32) -> (Subtree, bool) {
+ match t {
+ Subtree::Empty => {
+ let node = NodeData {
+ level: l,
+ keys: vec![h],
+ children: vec![Subtree::Empty, Subtree::Empty],
+ };
+ (Subtree::Node(Box::new(node)), true)
+ }
+ Subtree::Node(boxed) => {
+ let n = *boxed;
+ if l > n.level {
+ // Nueva clave de nivel mayor: parte el árbol actual y la
+ // promueve a nueva raíz.
+ let (left, right) = split_at(Subtree::Node(Box::new(n)), &h);
+ let new_root = NodeData {
+ level: l,
+ keys: vec![h],
+ children: vec![left, right],
+ };
+ (Subtree::Node(Box::new(new_root)), true)
+ } else if l == n.level {
+ match n.keys.binary_search(&h) {
+ Ok(_) => (Subtree::Node(Box::new(n)), false),
+ Err(i) => {
+ let NodeData { level, mut keys, mut children } = n;
+ let middle = std::mem::replace(&mut children[i], Subtree::Empty);
+ let (left, right) = split_at(middle, &h);
+ keys.insert(i, h);
+ children[i] = left;
+ children.insert(i + 1, right);
+ (
+ Subtree::Node(Box::new(NodeData { level, keys, children })),
+ true,
+ )
+ }
+ }
+ } else {
+ // l < n.level: la clave nueva pertenece a un subárbol bajo
+ // el separador correspondiente.
+ let i = match n.keys.binary_search(&h) {
+ Ok(_) => unreachable!(
+ "colisión: clave de nivel inferior coincide con separador de nivel superior"
+ ),
+ Err(i) => i,
+ };
+ let NodeData { level, keys, mut children } = n;
+ let child = std::mem::replace(&mut children[i], Subtree::Empty);
+ let (new_child, inserted) = insert_in(child, h, l);
+ children[i] = new_child;
+ (
+ Subtree::Node(Box::new(NodeData { level, keys, children })),
+ inserted,
+ )
+ }
+ }
+ }
+}
+
+/// Parte `t` en (claves < pivot, claves > pivot). Pre-condición: el nivel
+/// de cada subárbol involucrado es estrictamente menor que el del pivot
+/// (que vive arriba). El pivot mismo no aparece en el resultado.
+fn split_at(t: Subtree, pivot: &ContentHash) -> (Subtree, Subtree) {
+ match t {
+ Subtree::Empty => (Subtree::Empty, Subtree::Empty),
+ Subtree::Node(boxed) => {
+ let n = *boxed;
+ let i = match n.keys.binary_search(pivot) {
+ Ok(_) => unreachable!("pivot coincide con clave de nivel inferior"),
+ Err(i) => i,
+ };
+ let NodeData { level, keys, children } = n;
+
+ let mut left_keys = keys.clone();
+ left_keys.truncate(i);
+ let mut right_keys = keys;
+ right_keys.drain(..i);
+
+ let mut left_children: Vec<Subtree> = Vec::with_capacity(i + 1);
+ let mut right_children: Vec<Subtree> = Vec::with_capacity(level as usize + 1);
+
+ let mut iter = children.into_iter();
+ for _ in 0..i {
+ left_children.push(iter.next().expect("invariante: children > i"));
+ }
+ let middle = iter.next().expect("invariante: existe children[i]");
+ let (l_mid, r_mid) = split_at(middle, pivot);
+ left_children.push(l_mid);
+ right_children.push(r_mid);
+ for c in iter {
+ right_children.push(c);
+ }
+
+ let left = if left_keys.is_empty() {
+ left_children.pop().unwrap_or(Subtree::Empty)
+ } else {
+ Subtree::Node(Box::new(NodeData {
+ level,
+ keys: left_keys,
+ children: left_children,
+ }))
+ };
+ let right = if right_keys.is_empty() {
+ right_children.pop().unwrap_or(Subtree::Empty)
+ } else {
+ Subtree::Node(Box::new(NodeData {
+ level,
+ keys: right_keys,
+ children: right_children,
+ }))
+ };
+ (left, right)
+ }
+ }
+}
+
+fn index_subtree(t: &Subtree, idx: &mut HashMap<ContentHash, NodeProbe>) {
+ if let Subtree::Node(n) = t {
+ let child_hashes: Vec<ContentHash> = n.children.iter().map(subtree_hash).collect();
+ let probe = NodeProbe {
+ level: n.level,
+ keys: n.keys.clone(),
+ child_hashes,
+ };
+ idx.insert(subtree_hash(t), probe);
+ for c in &n.children {
+ index_subtree(c, idx);
+ }
+ }
+}
+
+fn diff_subtrees(
+ t1: &Subtree,
+ t2: &Subtree,
+ only_in_1: &mut Vec<ContentHash>,
+ only_in_2: &mut Vec<ContentHash>,
+) {
+ // Short-circuit por hash Merkle: si los dos subárboles colapsan al
+ // mismo hash de 32 bytes, representan el mismo conjunto. Una sola
+ // comparación poda toda la rama. Aplicado recursivamente, en árboles
+ // mayormente iguales el coste es proporcional a la divergencia, no al
+ // tamaño total.
+ if subtree_hash(t1) == subtree_hash(t2) {
+ return;
+ }
+ match (t1, t2) {
+ (Subtree::Empty, _) => collect_all(t2, only_in_2),
+ (_, Subtree::Empty) => collect_all(t1, only_in_1),
+ (Subtree::Node(n1), Subtree::Node(n2)) => {
+ if n1.level == n2.level && n1.keys == n2.keys {
+ // Mismo nivel y mismos separadores: los hijos se alinean
+ // posicionalmente. Recurrimos en paralelo — cada par
+ // idéntico se podará en su llamada por el hash de Merkle.
+ for (c1, c2) in n1.children.iter().zip(n2.children.iter()) {
+ diff_subtrees(c1, c2, only_in_1, only_in_2);
+ }
+ } else {
+ // Estructura divergente. Enumeramos ambos lados ordenados
+ // y hacemos merge. Correcto pero sin más poda Merkle: una
+ // futura iteración con `split_at` por cada separador del
+ // nivel mayor recuperaría la poda en el caso desalineado.
+ let mut k1 = Vec::with_capacity(len_of(t1));
+ let mut k2 = Vec::with_capacity(len_of(t2));
+ collect_all(t1, &mut k1);
+ collect_all(t2, &mut k2);
+ merge_diff_sorted(&k1, &k2, only_in_1, only_in_2);
+ }
+ }
+ }
+}
+
+fn collect_all(t: &Subtree, out: &mut Vec<ContentHash>) {
+ if let Subtree::Node(n) = t {
+ for i in 0..n.keys.len() {
+ collect_all(&n.children[i], out);
+ out.push(n.keys[i]);
+ }
+ collect_all(&n.children[n.keys.len()], out);
+ }
+}
+
+fn merge_diff_sorted(
+ a: &[ContentHash],
+ b: &[ContentHash],
+ only_a: &mut Vec<ContentHash>,
+ only_b: &mut Vec<ContentHash>,
+) {
+ let mut i = 0;
+ let mut j = 0;
+ while i < a.len() && j < b.len() {
+ match a[i].cmp(&b[j]) {
+ std::cmp::Ordering::Less => {
+ only_a.push(a[i]);
+ i += 1;
+ }
+ std::cmp::Ordering::Greater => {
+ only_b.push(b[j]);
+ j += 1;
+ }
+ std::cmp::Ordering::Equal => {
+ i += 1;
+ j += 1;
+ }
+ }
+ }
+ only_a.extend_from_slice(&a[i..]);
+ only_b.extend_from_slice(&b[j..]);
+}
+
+pub struct Iter<'a> {
+ /// Cada frame es (nodo, próximo índice de clave a emitir). Cuando se
+ /// pushea un frame, ya descendimos por su hijo izquierdo (children[0]).
+ stack: Vec<(&'a NodeData, usize)>,
+}
+
+impl<'a> Iter<'a> {
+ fn descend_left(&mut self, t: &'a Subtree) {
+ let mut cur = t;
+ while let Subtree::Node(n) = cur {
+ self.stack.push((n.as_ref(), 0));
+ cur = &n.children[0];
+ }
+ }
+}
+
+impl<'a> Iterator for Iter<'a> {
+ type Item = &'a ContentHash;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ loop {
+ let (node, ki) = {
+ let top = self.stack.last()?;
+ (top.0, top.1)
+ };
+ if ki < node.keys.len() {
+ self.stack.last_mut().unwrap().1 = ki + 1;
+ self.descend_left(&node.children[ki + 1]);
+ return Some(&node.keys[ki]);
+ } else {
+ self.stack.pop();
+ }
+ }
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/parse.rs b/03_ukupacha/minga/minga-core/src/parse.rs
new file mode 100644
index 0000000..be34f36
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/parse.rs
@@ -0,0 +1,523 @@
+//! Adaptadores de parsing por dialecto.
+//!
+//! Cada función devuelve un [`SemanticNode`] normalizado a partir del
+//! source code. La normalización vive en `ast::SemanticNode::from_tree_sitter`
+//! y es agnóstica al lenguaje — cualquier tree-sitter grammar produce
+//! el mismo shape de árbol semántico (sin whitespace, sin comentarios).
+//!
+//! Lenguajes soportados (cada uno son ~6 LOC + dep tree-sitter-X):
+//! - [`rust`] — Rust completo (con α-hashing en `alpha::hash_node_alpha`).
+//! - [`python`] — Python 3.x.
+//! - [`typescript`] — TypeScript (no TSX).
+//! - [`javascript`] — JavaScript / ECMAScript.
+//! - [`go`] — Go.
+//!
+//! Para hashing α-equivalente, sólo Rust tiene implementación dedicada
+//! hoy. Otros lenguajes caen al [`crate::cas::hash_node`] estructural,
+//! que es α-NO-equivalente: dos versiones del mismo término que
+//! difieren en nombres de variables ligadas tendrán hashes distintos.
+//! Suficiente para detección de cambios; no para detección de
+//! equivalencia semántica.
+//!
+//! ## Auto-detección por extensión
+//!
+//! [`detect_by_extension`] mapea `.rs` → Rust, `.py` → Python, etc.
+//! Útil para `minga ingest` cuando el caller no quiere especificar
+//! el dialecto a mano.
+
+use crate::ast::SemanticNode;
+use thiserror::Error;
+use tree_sitter::{Language, Parser};
+
+#[derive(Debug, Error)]
+pub enum ParseError {
+ #[error("tree-sitter no pudo configurar el lenguaje")]
+ Language,
+ #[error("tree-sitter no produjo árbol para la entrada")]
+ NoTree,
+}
+
+/// Identificadores estables de cada dialecto soportado.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum Dialect {
+ Rust,
+ Python,
+ TypeScript,
+ JavaScript,
+ Go,
+}
+
+impl Dialect {
+ /// Nombre canónico para logging / display.
+ pub fn name(self) -> &'static str {
+ match self {
+ Dialect::Rust => "rust",
+ Dialect::Python => "python",
+ Dialect::TypeScript => "typescript",
+ Dialect::JavaScript => "javascript",
+ Dialect::Go => "go",
+ }
+ }
+
+ /// Byte estable para persistir/transmitir el dialecto. Numérico para
+ /// no depender del orden de la enum si se agregan lenguajes.
+ pub fn as_byte(self) -> u8 {
+ match self {
+ Dialect::Rust => 1,
+ Dialect::Python => 2,
+ Dialect::TypeScript => 3,
+ Dialect::JavaScript => 4,
+ Dialect::Go => 5,
+ }
+ }
+
+ /// Inversa de [`Dialect::as_byte`]. `None` si el byte no corresponde
+ /// a un dialecto conocido por esta versión.
+ pub fn from_byte(b: u8) -> Option<Self> {
+ Some(match b {
+ 1 => Dialect::Rust,
+ 2 => Dialect::Python,
+ 3 => Dialect::TypeScript,
+ 4 => Dialect::JavaScript,
+ 5 => Dialect::Go,
+ _ => return None,
+ })
+ }
+
+ /// Parsea `source` con la gramática de este dialecto.
+ pub fn parse(self, source: &str) -> Result<SemanticNode, ParseError> {
+ match self {
+ Dialect::Rust => rust(source),
+ Dialect::Python => python(source),
+ Dialect::TypeScript => typescript(source),
+ Dialect::JavaScript => javascript(source),
+ Dialect::Go => go(source),
+ }
+ }
+}
+
+/// Mapea una extensión de archivo (sin el `.`) al dialecto correspondiente.
+/// `None` si la extensión no corresponde a un lenguaje soportado.
+///
+/// ```
+/// use minga_core::parse::{detect_by_extension, Dialect};
+/// assert_eq!(detect_by_extension("rs"), Some(Dialect::Rust));
+/// assert_eq!(detect_by_extension("py"), Some(Dialect::Python));
+/// assert_eq!(detect_by_extension("unknown"), None);
+/// ```
+pub fn detect_by_extension(ext: &str) -> Option<Dialect> {
+ match ext.to_ascii_lowercase().as_str() {
+ "rs" => Some(Dialect::Rust),
+ "py" | "pyi" => Some(Dialect::Python),
+ "ts" => Some(Dialect::TypeScript),
+ "js" | "mjs" | "cjs" => Some(Dialect::JavaScript),
+ "go" => Some(Dialect::Go),
+ _ => None,
+ }
+}
+
+/// Detecta el dialecto leyendo la primera línea como shebang. Reconoce
+/// las formas habituales:
+/// - `#!/usr/bin/env python3` → Python
+/// - `#!/usr/bin/python3.11` → Python
+/// - `#!/usr/bin/env node` / `deno` → JavaScript
+/// - `#!/usr/bin/env -S deno run --ext=ts` / `tsx` → TypeScript
+/// - `#!/usr/bin/env bash` / `sh` → `None` (no soportado)
+///
+/// Sólo mira la **primera línea**: si no comienza por `#!`, devuelve
+/// `None` sin tocar el resto del buffer.
+pub fn detect_by_shebang(source: &str) -> Option<Dialect> {
+ let first = source.lines().next()?;
+ let rest = first.strip_prefix("#!")?.trim();
+ let interpreter = last_token(rest);
+ let lower = interpreter.to_ascii_lowercase();
+ let trimmed = lower.trim_start_matches(|c: char| c == '/' || c.is_ascii_alphanumeric() == false);
+ let last_segment = lower.rsplit('/').next().unwrap_or(&lower);
+ // Coincidencia laxa por sufijo: cubre versiones como `python3.11`.
+ let cand = if last_segment.starts_with("python") {
+ Some(Dialect::Python)
+ } else if last_segment == "node" || last_segment == "deno" || last_segment == "bun" {
+ // Por defecto JS; el ext flag se evalúa abajo.
+ Some(Dialect::JavaScript)
+ } else if last_segment == "tsx" || last_segment == "ts-node" {
+ Some(Dialect::TypeScript)
+ } else if last_segment.ends_with("rustc") {
+ Some(Dialect::Rust)
+ } else if last_segment == "go" {
+ Some(Dialect::Go)
+ } else {
+ None
+ };
+ // Override por `--ext=ts` en la cadena (env -S deno run --ext=ts).
+ if rest.contains("--ext=ts") || rest.contains("--ext ts") {
+ return Some(Dialect::TypeScript);
+ }
+ let _ = trimmed; // suppress unused
+ cand
+}
+
+/// Detecta el dialecto **por contenido**. Combina dos señales:
+///
+/// 1. **Marcadores textuales** distintivos por lenguaje (palabras
+/// clave en posición de declaración: `def `/`class ` para Python,
+/// `fn `/`impl `/`pub ` para Rust, `func `/`package ` para Go,
+/// `function `/`interface `/anotaciones de tipo TS para JS/TS).
+/// Tree-sitter es muy permisivo — acepta casi cualquier cosa con
+/// pocos `ERROR`, así que no se puede confiar sólo en eso.
+/// 2. **Ratio de nodos `ERROR`** al parsear con la gramática candidata.
+/// Sirve como tie-break: si dos lenguajes empatan en marcadores,
+/// gana el que produce el AST más limpio.
+///
+/// Si ningún candidato consigue un parse limpio (≤ 5 % errores) o
+/// ningún marcador textual identifica un lenguaje, devuelve `None`.
+pub fn detect_by_content(source: &str) -> Option<Dialect> {
+ if source.trim().is_empty() {
+ return None;
+ }
+ // Marcadores textuales por lenguaje. Cada lenguaje suma puntos
+ // según cuántos marcadores aparecen en el source.
+ let mut scores: [(Dialect, u32); 5] = [
+ (Dialect::Rust, 0),
+ (Dialect::Python, 0),
+ (Dialect::TypeScript, 0),
+ (Dialect::JavaScript, 0),
+ (Dialect::Go, 0),
+ ];
+ for line in source.lines() {
+ let t = line.trim_start();
+ // Rust: `fn `/`pub fn `/`impl `/`use `/`mod `/`let `.
+ if t.starts_with("fn ")
+ || t.starts_with("pub fn ")
+ || t.starts_with("pub(crate) fn ")
+ || t.starts_with("impl ")
+ || t.starts_with("use ")
+ || t.starts_with("mod ")
+ || t.starts_with("let ")
+ || t.starts_with("struct ")
+ || t.starts_with("enum ")
+ || t.starts_with("trait ")
+ {
+ scores[0].1 += 1;
+ }
+ // Python: `def `/`class `/`import `/`from `/`elif `.
+ if t.starts_with("def ")
+ || t.starts_with("class ")
+ || t.starts_with("import ")
+ || t.starts_with("from ")
+ || t.starts_with("elif ")
+ || t.starts_with("async def ")
+ {
+ scores[1].1 += 1;
+ }
+ // TypeScript: anotaciones `: \w+`, `interface `, `type `,
+ // `enum `. JS no usa la mayoría de éstos.
+ if t.starts_with("interface ")
+ || t.starts_with("type ")
+ || t.starts_with("enum ")
+ {
+ scores[2].1 += 1;
+ }
+ // JavaScript/TypeScript ambos.
+ if t.starts_with("function ")
+ || t.starts_with("const ")
+ || t.starts_with("let ")
+ || t.starts_with("var ")
+ || t.starts_with("export ")
+ || t.starts_with("import {")
+ {
+ // `let ` también lo usa Rust — dejamos que el score Rust
+ // gane si hay otros indicadores; aquí sólo sumamos a JS
+ // si no parece Rust en otros aspectos. Para simplicidad,
+ // sumamos a JS y a Rust independientemente y dejamos que
+ // el tie-break de errores decida.
+ scores[3].1 += 1;
+ }
+ // Go.
+ if t.starts_with("func ")
+ || t.starts_with("package ")
+ || t.starts_with("import (")
+ || t.starts_with("type ") && t.contains(" struct ")
+ {
+ scores[4].1 += 1;
+ }
+ }
+
+ // Si nadie tiene marcadores, no podemos decidir.
+ let max_score = scores.iter().map(|(_, n)| *n).max().unwrap_or(0);
+ if max_score == 0 {
+ return None;
+ }
+
+ // Top candidatos: los que empatan en el score más alto.
+ let top: Vec<Dialect> = scores
+ .iter()
+ .filter(|(_, n)| *n == max_score)
+ .map(|(d, _)| *d)
+ .collect();
+
+ // Si hay un único top, devolverlo (sin verificar errores —
+ // marcadores robustos ya identifican el lenguaje).
+ if top.len() == 1 {
+ return Some(top[0]);
+ }
+
+ // Empate: parsear con cada candidato y elegir el de menos errores.
+ let mut best: Option<(Dialect, f32)> = None;
+ for d in top {
+ if let Ok(node) = d.parse(source) {
+ let (errors, total) = count_errors(&node);
+ if total == 0 {
+ continue;
+ }
+ let ratio = errors as f32 / total as f32;
+ if best.map_or(true, |(_, r)| ratio < r) {
+ best = Some((d, ratio));
+ }
+ }
+ }
+ best.and_then(|(d, r)| if r <= 0.05 { Some(d) } else { None })
+}
+
+/// Cuenta nodos `ERROR`/`MISSING` y el total de nodos en el subárbol.
+/// Tree-sitter marca con `kind == "ERROR"` cualquier sección que la
+/// gramática no pudo absorber; `is_named() == false` y un nombre vacío
+/// suele indicar tokens missing.
+fn count_errors(node: &SemanticNode) -> (usize, usize) {
+ let mut errors = 0usize;
+ let mut total = 0usize;
+ walk_count(node, &mut errors, &mut total);
+ (errors, total)
+}
+
+fn walk_count(n: &SemanticNode, errors: &mut usize, total: &mut usize) {
+ *total += 1;
+ if n.kind == "ERROR" {
+ *errors += 1;
+ }
+ for c in &n.children {
+ walk_count(c, errors, total);
+ }
+}
+
+/// Devuelve el último "token" (separado por whitespace) de la cadena.
+/// Para shebangs `#!/usr/bin/env python3 -u` queremos el `python3`,
+/// pero también `#!/usr/bin/python3.11` (sin `env`) — el truco es:
+/// si hay `env`, tomar lo siguiente; si no, el path completo y luego
+/// el último segmento. Esta función devuelve el penúltimo token cuando
+/// el primero parece un path absoluto a `env`.
+fn last_token(s: &str) -> &str {
+ let mut tokens = s.split_whitespace().filter(|t| !t.starts_with('-'));
+ let first = tokens.next().unwrap_or("");
+ if first.ends_with("/env") || first == "env" {
+ tokens.next().unwrap_or(first)
+ } else {
+ first
+ }
+}
+
+fn parse_with(lang: Language, source: &str) -> Result<SemanticNode, ParseError> {
+ let mut parser = Parser::new();
+ parser.set_language(&lang).map_err(|_| ParseError::Language)?;
+ let tree = parser.parse(source, None).ok_or(ParseError::NoTree)?;
+ Ok(SemanticNode::from_tree_sitter(tree.root_node(), source.as_bytes()))
+}
+
+pub fn rust(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_rust::LANGUAGE.into(), source)
+}
+
+pub fn python(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_python::LANGUAGE.into(), source)
+}
+
+pub fn typescript(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), source)
+}
+
+pub fn javascript(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_javascript::LANGUAGE.into(), source)
+}
+
+pub fn go(source: &str) -> Result<SemanticNode, ParseError> {
+ parse_with(tree_sitter_go::LANGUAGE.into(), source)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn assert_parses(d: Dialect, source: &str) -> SemanticNode {
+ let node = d.parse(source).expect("parse should succeed");
+ // Sanity: el root siempre tiene al menos un child para code real.
+ assert!(
+ !node.children.is_empty(),
+ "{}: root node sin children — parse posiblemente vacío",
+ d.name()
+ );
+ node
+ }
+
+ #[test]
+ fn rust_parses_basic() {
+ assert_parses(Dialect::Rust, "fn add(a: i32, b: i32) -> i32 { a + b }");
+ }
+
+ #[test]
+ fn python_parses_basic() {
+ assert_parses(
+ Dialect::Python,
+ "def add(a: int, b: int) -> int:\n return a + b\n",
+ );
+ }
+
+ #[test]
+ fn typescript_parses_basic() {
+ assert_parses(
+ Dialect::TypeScript,
+ "function add(a: number, b: number): number { return a + b; }",
+ );
+ }
+
+ #[test]
+ fn javascript_parses_basic() {
+ assert_parses(
+ Dialect::JavaScript,
+ "function add(a, b) { return a + b; }",
+ );
+ }
+
+ #[test]
+ fn go_parses_basic() {
+ assert_parses(
+ Dialect::Go,
+ "package main\n\nfunc add(a, b int) int {\n return a + b\n}\n",
+ );
+ }
+
+ #[test]
+ fn detect_extension_canonical() {
+ assert_eq!(detect_by_extension("rs"), Some(Dialect::Rust));
+ assert_eq!(detect_by_extension("py"), Some(Dialect::Python));
+ assert_eq!(detect_by_extension("pyi"), Some(Dialect::Python));
+ assert_eq!(detect_by_extension("ts"), Some(Dialect::TypeScript));
+ assert_eq!(detect_by_extension("js"), Some(Dialect::JavaScript));
+ assert_eq!(detect_by_extension("mjs"), Some(Dialect::JavaScript));
+ assert_eq!(detect_by_extension("cjs"), Some(Dialect::JavaScript));
+ assert_eq!(detect_by_extension("go"), Some(Dialect::Go));
+ assert_eq!(detect_by_extension("unknown"), None);
+ assert_eq!(detect_by_extension(""), None);
+ }
+
+ #[test]
+ fn detect_shebang_python_env() {
+ assert_eq!(
+ detect_by_shebang("#!/usr/bin/env python3\nprint(1)\n"),
+ Some(Dialect::Python)
+ );
+ }
+
+ #[test]
+ fn detect_shebang_python_direct() {
+ assert_eq!(
+ detect_by_shebang("#!/usr/bin/python3.11\n"),
+ Some(Dialect::Python)
+ );
+ }
+
+ #[test]
+ fn detect_shebang_node() {
+ assert_eq!(
+ detect_by_shebang("#!/usr/bin/env node\nconsole.log(1)\n"),
+ Some(Dialect::JavaScript)
+ );
+ }
+
+ #[test]
+ fn detect_shebang_deno_with_ext_ts() {
+ assert_eq!(
+ detect_by_shebang("#!/usr/bin/env -S deno run --ext=ts\n"),
+ Some(Dialect::TypeScript)
+ );
+ }
+
+ #[test]
+ fn detect_content_rust_clean() {
+ assert_eq!(
+ detect_by_content("fn main() { let x = 1; println!(\"{}\", x); }"),
+ Some(Dialect::Rust)
+ );
+ }
+
+ #[test]
+ fn detect_content_python_clean() {
+ assert_eq!(
+ detect_by_content("def add(a, b):\n return a + b\n"),
+ Some(Dialect::Python)
+ );
+ }
+
+ #[test]
+ fn detect_content_go_clean() {
+ assert_eq!(
+ detect_by_content("package main\n\nfunc add(a, b int) int { return a + b }\n"),
+ Some(Dialect::Go)
+ );
+ }
+
+ #[test]
+ fn detect_content_returns_none_for_garbage() {
+ // Texto que ninguna gramática absorbe sin un montón de ERRORs.
+ let garbage = "++++ @@@@ ###% %%%% [[[[[[[[[ ............";
+ assert_eq!(detect_by_content(garbage), None);
+ }
+
+ #[test]
+ fn detect_content_handles_empty() {
+ assert_eq!(detect_by_content(""), None);
+ assert_eq!(detect_by_content(" \n\t "), None);
+ }
+
+ #[test]
+ fn detect_shebang_no_match_for_bash() {
+ assert_eq!(detect_by_shebang("#!/bin/bash\necho hola\n"), None);
+ }
+
+ #[test]
+ fn detect_shebang_requires_hashbang() {
+ // No es shebang — empieza con `//` (comentario JS), no debe matchear.
+ assert_eq!(detect_by_shebang("// nope\n"), None);
+ }
+
+ #[test]
+ fn detect_extension_case_insensitive() {
+ assert_eq!(detect_by_extension("RS"), Some(Dialect::Rust));
+ assert_eq!(detect_by_extension("Py"), Some(Dialect::Python));
+ assert_eq!(detect_by_extension("TS"), Some(Dialect::TypeScript));
+ }
+
+ #[test]
+ fn dialect_name_canonical() {
+ assert_eq!(Dialect::Rust.name(), "rust");
+ assert_eq!(Dialect::Python.name(), "python");
+ assert_eq!(Dialect::TypeScript.name(), "typescript");
+ assert_eq!(Dialect::JavaScript.name(), "javascript");
+ assert_eq!(Dialect::Go.name(), "go");
+ }
+
+ #[test]
+ fn structural_hash_distinguishes_languages() {
+ // Mismo "shape" textual pero distintos lenguajes producen
+ // árboles distintos (las gramáticas no coinciden) y por tanto
+ // hashes estructurales distintos. Importante para evitar
+ // colisiones en el CAS cuando el mismo source se ingiere
+ // bajo dialectos distintos.
+ use crate::cas::hash_node;
+ let py = Dialect::Python.parse("x = 1").unwrap();
+ let js = Dialect::JavaScript.parse("x = 1").unwrap();
+ assert_ne!(
+ hash_node(&py),
+ hash_node(&js),
+ "py y js deberían tener hashes distintos para el mismo source"
+ );
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/retraction.rs b/03_ukupacha/minga/minga-core/src/retraction.rs
new file mode 100644
index 0000000..8097a00
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/retraction.rs
@@ -0,0 +1,176 @@
+//! Retracciones firmadas: la contraparte de las [`Attestation`]es.
+//!
+//! Una `Retraction` registra que el dueño de cierta clave privada
+//! **ya no respalda** un contenido concreto. No borra la atestación
+//! original (sigue siendo prueba histórica de que en algún momento la
+//! firmó), pero permite a quien consulte el repo saber que el autor
+//! posteriormente la retiró.
+//!
+//! ## Por qué es una firma separada
+//!
+//! La firma cubre un mensaje distinto al de [`Attestation::create`]:
+//! concretamente `b"minga.retract:" ++ content_hash`. Esto evita que
+//! una atestación válida pueda re-empaquetarse como retracción
+//! reutilizando su firma — sin el prefijo, las dos llaves serían
+//! intercambiables.
+//!
+//! ## Modelo
+//!
+//! Idempotente por `(author, content)` igual que `AttestationStore`.
+//! El `RetractionStore` rechaza firmas inválidas — el verificador
+//! sabe que cualquier cosa que lea pasó el check criptográfico.
+
+use crate::cas::ContentHash;
+use crate::identity::{Did, Keypair, Signature};
+use std::collections::HashMap;
+
+/// Prefijo de dominio para que la firma de una retracción no colisione
+/// con la de una atestación: los mensajes firmados son distintos.
+pub const RETRACTION_DOMAIN: &[u8] = b"minga.retract:";
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct Retraction {
+ pub content: ContentHash,
+ pub author: Did,
+ pub signature: Signature,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RetractionError {
+ InvalidSignature,
+}
+
+impl std::fmt::Display for RetractionError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidSignature => write!(f, "firma de la retracción no verifica"),
+ }
+ }
+}
+
+impl std::error::Error for RetractionError {}
+
+impl Retraction {
+ /// Construye y firma una retracción para `content`.
+ pub fn create(keypair: &Keypair, content: ContentHash) -> Self {
+ let mut msg = Vec::with_capacity(RETRACTION_DOMAIN.len() + 32);
+ msg.extend_from_slice(RETRACTION_DOMAIN);
+ msg.extend_from_slice(&content.0);
+ Self {
+ content,
+ author: keypair.did(),
+ signature: keypair.sign(&msg),
+ }
+ }
+
+ /// Verifica la firma. La cobertura es sobre `RETRACTION_DOMAIN ++
+ /// content_hash`, no sobre el `content_hash` solo — eso evita que
+ /// una `Attestation::signature` se pueda reutilizar como
+ /// `Retraction::signature` y viceversa.
+ pub fn verify(&self) -> bool {
+ let mut msg = Vec::with_capacity(RETRACTION_DOMAIN.len() + 32);
+ msg.extend_from_slice(RETRACTION_DOMAIN);
+ msg.extend_from_slice(&self.content.0);
+ self.author.verify(&msg, &self.signature)
+ }
+}
+
+/// Registro in-memory de retracciones, espejo de [`crate::AttestationStore`].
+///
+/// Idempotente por `(author, content)`. Rechaza firmas inválidas. Un
+/// mismo `content_hash` puede tener retracciones de autores distintos.
+#[derive(Debug, Default, Clone)]
+pub struct RetractionStore {
+ by_content: HashMap<ContentHash, Vec<Retraction>>,
+}
+
+impl RetractionStore {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Inserta una retracción. `Err(InvalidSignature)` si la firma no
+ /// verifica — el store nunca almacena firmas rotas.
+ pub fn add(&mut self, r: Retraction) -> Result<(), RetractionError> {
+ if !r.verify() {
+ return Err(RetractionError::InvalidSignature);
+ }
+ let entry = self.by_content.entry(r.content).or_default();
+ if !entry.iter().any(|x| x.author == r.author) {
+ entry.push(r);
+ }
+ Ok(())
+ }
+
+ pub fn get(&self, content: &ContentHash) -> &[Retraction] {
+ self.by_content
+ .get(content)
+ .map(Vec::as_slice)
+ .unwrap_or(&[])
+ }
+
+ /// Conjunto de DIDs que han retirado este contenido.
+ pub fn authors_of(&self, content: &ContentHash) -> Vec<Did> {
+ self.by_content
+ .get(content)
+ .map(|v| v.iter().map(|a| a.author).collect())
+ .unwrap_or_default()
+ }
+
+ pub fn len(&self) -> usize {
+ self.by_content.values().map(Vec::len).sum()
+ }
+
+ pub fn is_empty(&self) -> bool {
+ self.by_content.values().all(Vec::is_empty)
+ }
+
+ /// Itera todas las retracciones (orden no especificado).
+ pub fn all(&self) -> impl Iterator<Item = &Retraction> + '_ {
+ self.by_content.values().flat_map(|v| v.iter())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn create_then_verify_is_ok() {
+ let kp = Keypair::generate();
+ let h = ContentHash([7u8; 32]);
+ let r = Retraction::create(&kp, h);
+ assert!(r.verify());
+ assert_eq!(r.author, kp.did());
+ assert_eq!(r.content, h);
+ }
+
+ #[test]
+ fn attestation_signature_cannot_be_replayed_as_retraction() {
+ // Una `Attestation` firma directamente el `content_hash`. Una
+ // `Retraction` firma `RETRACTION_DOMAIN ++ content_hash`. Por
+ // lo tanto la firma de una NO sirve para la otra: el prefijo
+ // de dominio rompe la equivalencia.
+ let kp = Keypair::generate();
+ let h = ContentHash([42u8; 32]);
+ let att = crate::Attestation::create(&kp, h);
+
+ // Intentamos fabricar una retracción reutilizando la firma de
+ // la atestación.
+ let forged = Retraction {
+ content: h,
+ author: kp.did(),
+ signature: att.signature,
+ };
+ assert!(!forged.verify(), "la firma de Attestation no debe verificar como Retraction");
+ }
+
+ #[test]
+ fn tampered_content_does_not_verify() {
+ let kp = Keypair::generate();
+ let h = ContentHash([1u8; 32]);
+ let mut r = Retraction::create(&kp, h);
+ r.content = ContentHash([2u8; 32]);
+ assert!(!r.verify());
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/root_decl.rs b/03_ukupacha/minga/minga-core/src/root_decl.rs
new file mode 100644
index 0000000..8ae723b
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/root_decl.rs
@@ -0,0 +1,45 @@
+//! `RootDecl`: declaración de una raíz que viaja por el wire de sync.
+//!
+//! Un peer que conoce una raíz local (`α_hash → struct_hash` bajo cierto
+//! `dialect`) puede empujarla a su contraparte como una `RootDecl`. El
+//! receptor **re-verifica** que `α_hash` corresponda realmente al
+//! `struct_hash` bajo el `dialect` declarado, llamando a
+//! [`crate::alpha::verify_root_alpha`] tras reconstruir el `SemanticNode`
+//! del CAS local. Sólo las declaraciones que verifican entran al
+//! `SledRootsStore` del receptor.
+//!
+//! El dialecto se transmite como `u8` (vía [`crate::parse::Dialect::as_byte`])
+//! en vez de derivar serde sobre `Dialect`: el byte es estable bajo
+//! reordenamiento o adición de variantes en la enum, igual que ya se hace
+//! para persistencia en `SledRootsStore`.
+
+use crate::cas::ContentHash;
+use crate::parse::Dialect;
+
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct RootDecl {
+ pub alpha: ContentHash,
+ pub struct_hash: ContentHash,
+ /// Dialect en su forma byte estable ([`Dialect::as_byte`]). Un byte
+ /// desconocido (versión futura del protocolo introduciendo un nuevo
+ /// lenguaje) hace que el receptor descarte la declaración sin
+ /// verificar, sin tumbar la sesión.
+ pub dialect_byte: u8,
+}
+
+impl RootDecl {
+ pub fn new(alpha: ContentHash, struct_hash: ContentHash, dialect: Dialect) -> Self {
+ Self {
+ alpha,
+ struct_hash,
+ dialect_byte: dialect.as_byte(),
+ }
+ }
+
+ /// Decodifica el dialecto al enum. `None` si el byte no corresponde
+ /// a un dialecto conocido por esta versión del binario — el receptor
+ /// debe contar la declaración como rechazada en ese caso.
+ pub fn dialect(&self) -> Option<Dialect> {
+ Dialect::from_byte(self.dialect_byte)
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/src/store.rs b/03_ukupacha/minga/minga-core/src/store.rs
new file mode 100644
index 0000000..1eb0c93
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/src/store.rs
@@ -0,0 +1,144 @@
+//! Almacén de nodos direccionados por contenido.
+//!
+//! Cada `SemanticNode` se descompone en `StoredNode`s donde los hijos son
+//! referencias por hash, no estructuras inline. Así dos subárboles con la
+//! misma estructura se almacenan una sola vez, sin importar en cuántos
+//! lugares aparezcan en el repositorio. Esa es la diferencia entre "Git
+//! semántico" y "diff de líneas".
+//!
+//! `NodeStore` es el contrato; `MemStore` es la implementación de
+//! referencia, en memoria, agnóstica de IO. Un futuro `SledStore` o
+//! `RocksStore` vivirá en otro crate y se enchufará vía este trait sin
+//! tocar el resto del núcleo.
+
+use crate::ast::SemanticNode;
+use crate::cas::{self, ContentHash};
+use std::collections::HashMap;
+
+/// Forma "stored": idéntica a `SemanticNode` excepto que los hijos son
+/// hashes en vez de estructuras anidadas. Es el format canónico en
+/// reposo y el que permite la deduplicación.
+#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
+pub struct StoredNode {
+ pub kind: String,
+ pub field_name: Option<String>,
+ pub leaf_text: Option<Vec<u8>>,
+ pub children: Vec<ContentHash>,
+}
+
+/// Hash de un `StoredNode`, idéntico al `hash_node` del `SemanticNode`
+/// equivalente. Permite a un protocolo de wire verificar que el nodo
+/// que le entregaron tiene efectivamente el hash que se le anunció,
+/// sin necesidad de reconstruir descendientes.
+pub fn hash_stored(stored: &StoredNode) -> ContentHash {
+ cas::hash_components(
+ &stored.kind,
+ stored.field_name.as_deref(),
+ stored.leaf_text.as_deref(),
+ &stored.children,
+ )
+}
+
+pub trait NodeStore {
+ /// Inserta un árbol completo. Recursivamente desempaqueta los hijos
+ /// y devuelve el hash de la raíz. Idempotente: insertar el mismo
+ /// árbol dos veces no aumenta el tamaño.
+ fn put(&mut self, node: &SemanticNode) -> ContentHash;
+
+ /// Inserta un nodo ya troceado por su hash. No recurre en hijos: el
+ /// llamador es responsable de garantizar que estarán presentes (lo
+ /// hace típicamente un protocolo de sync que va recibiendo nodos en
+ /// orden y solicita los faltantes a medida que descubre referencias).
+ fn put_chunked(&mut self, hash: ContentHash, stored: StoredNode);
+
+ fn get(&self, h: &ContentHash) -> Option<&StoredNode>;
+
+ fn contains(&self, h: &ContentHash) -> bool {
+ self.get(h).is_some()
+ }
+
+ /// Reconstruye el `SemanticNode` original a partir de su hash,
+ /// resolviendo recursivamente los hijos. `None` si algún hash no se
+ /// encuentra (almacén incompleto, inconsistente).
+ fn reconstruct(&self, h: &ContentHash) -> Option<SemanticNode>;
+
+ /// Itera todas las parejas `(hash, stored_node)` del store. Sin
+ /// orden garantizado. Usado para mergear stores tras una sesión
+ /// de sync (un peer recibe los nodos del otro en su sesión, y
+ /// luego los volcamos al store compartido).
+ fn iter(&self) -> Box<dyn Iterator<Item = (&ContentHash, &StoredNode)> + '_>;
+
+ fn len(&self) -> usize;
+
+ fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+}
+
+#[derive(Debug, Default, Clone)]
+pub struct MemStore {
+ map: HashMap<ContentHash, StoredNode>,
+}
+
+impl MemStore {
+ pub fn new() -> Self {
+ Self::default()
+ }
+}
+
+impl NodeStore for MemStore {
+ fn put(&mut self, node: &SemanticNode) -> ContentHash {
+ // Recorrido bottom-up: primero los hijos (devuelven su hash),
+ // luego compongo el hash del padre desde sus child_hashes
+ // mediante la primitiva canónica de cas. Cada subárbol se
+ // hashea exactamente una vez — sin recomputar `hash_node` sobre
+ // el árbol entero del padre.
+ let mut child_hashes = Vec::with_capacity(node.children.len());
+ for c in &node.children {
+ child_hashes.push(self.put(c));
+ }
+ let h = cas::hash_components(
+ &node.kind,
+ node.field_name.as_deref(),
+ node.leaf_text.as_deref(),
+ &child_hashes,
+ );
+ self.map.entry(h).or_insert_with(|| StoredNode {
+ kind: node.kind.clone(),
+ field_name: node.field_name.clone(),
+ leaf_text: node.leaf_text.clone(),
+ children: child_hashes,
+ });
+ h
+ }
+
+ fn put_chunked(&mut self, hash: ContentHash, stored: StoredNode) {
+ self.map.entry(hash).or_insert(stored);
+ }
+
+ fn get(&self, h: &ContentHash) -> Option<&StoredNode> {
+ self.map.get(h)
+ }
+
+ fn iter(&self) -> Box<dyn Iterator<Item = (&ContentHash, &StoredNode)> + '_> {
+ Box::new(self.map.iter())
+ }
+
+ fn reconstruct(&self, h: &ContentHash) -> Option<SemanticNode> {
+ let s = self.map.get(h)?;
+ let mut children = Vec::with_capacity(s.children.len());
+ for ch in &s.children {
+ children.push(self.reconstruct(ch)?);
+ }
+ Some(SemanticNode {
+ kind: s.kind.clone(),
+ field_name: s.field_name.clone(),
+ leaf_text: s.leaf_text.clone(),
+ children,
+ })
+ }
+
+ fn len(&self) -> usize {
+ self.map.len()
+ }
+}
diff --git a/03_ukupacha/minga/minga-core/tests/alpha_invariants.rs b/03_ukupacha/minga/minga-core/tests/alpha_invariants.rs
new file mode 100644
index 0000000..2fc1903
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/alpha_invariants.rs
@@ -0,0 +1,367 @@
+//! Invariantes del hash α-equivalente.
+//!
+//! El hash α debe ser estable bajo renombre de variables ligadas y romper
+//! con cualquier cambio que afecte la *intención* del término: nombre de
+//! la función, tipos en la firma, posición de argumentos, identidad de
+//! variables libres.
+
+use minga_core::{alpha::hash_node_alpha, parse};
+
+#[test]
+fn alpha_param_rename_invariant() {
+ let a = parse::rust("fn f(x: i32) -> i32 { x + 1 }").unwrap();
+ let b = parse::rust("fn f(y: i32) -> i32 { y + 1 }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_let_rename_invariant() {
+ let a = parse::rust("fn f() -> i32 { let x = 1; x + 2 }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let y = 1; y + 2 }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_param_swap_with_rename_invariant() {
+ let a = parse::rust("fn f(x: i32, y: i32) -> i32 { x - y }").unwrap();
+ let b = parse::rust("fn f(a: i32, b: i32) -> i32 { a - b }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_shadowing_let_invariant() {
+ let a = parse::rust("fn f() -> i32 { let x = 1; let x = x + 1; x }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let a = 1; let b = a + 1; b }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_function_name_matters() {
+ let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap();
+ let b = parse::rust("fn g(x: i32) -> i32 { x }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_signature_type_matters() {
+ let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap();
+ let b = parse::rust("fn f(x: i64) -> i64 { x }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_body_change_matters() {
+ let a = parse::rust("fn f(x: i32) -> i32 { x + 1 }").unwrap();
+ let b = parse::rust("fn f(x: i32) -> i32 { x + 2 }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_free_variable_identity_matters() {
+ let a = parse::rust("fn f() { foo() }").unwrap();
+ let b = parse::rust("fn f() { bar() }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_distinguishes_bound_vs_free() {
+ // En el primero `x` es parámetro (ligado); en el segundo `x` es libre.
+ let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap();
+ let b = parse::rust("fn f() -> i32 { x }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_param_order_matters() {
+ let a = parse::rust("fn f(x: i32, y: i32) -> i32 { x - y }").unwrap();
+ let b = parse::rust("fn f(x: i32, y: i32) -> i32 { y - x }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_diverges_from_structural_under_rename() {
+ // Bajo renombre, el hash estructural rompe pero el α se conserva. Esto
+ // demuestra que α añade poder discriminatorio en una dimensión nueva
+ // (intención) ortogonal a la sintaxis.
+ use minga_core::cas::hash_node;
+ let a = parse::rust("fn f(x: i32) -> i32 { x + 1 }").unwrap();
+ let b = parse::rust("fn f(z: i32) -> i32 { z + 1 }").unwrap();
+ assert_ne!(hash_node(&a), hash_node(&b));
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_closure_param_rename_invariant() {
+ let a = parse::rust("fn f() -> i32 { let g = |x: i32| x + 1; g(0) }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let g = |y: i32| y + 1; g(0) }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_closure_captures_outer_binding() {
+ // El cierre captura `z` (renombrable) del entorno; renombrar tanto el
+ // exterior como el parámetro debe seguir produciendo el mismo hash.
+ let a = parse::rust("fn f() -> i32 { let z = 1; let g = |x: i32| x + z; g(0) }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let q = 1; let g = |y: i32| y + q; g(0) }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_closure_distinguishes_captured_vs_free() {
+ // En el primero `z` es ligado en el scope exterior (parámetro de `f`);
+ // en el segundo `z` es libre. Aunque la forma del cierre coincide,
+ // la identidad del término difiere.
+ let a = parse::rust("fn f(z: i32) -> i32 { let g = |x: i32| x + z; g(0) }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let g = |x: i32| x + z; g(0) }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_for_loop_var_rename_invariant() {
+ let a = parse::rust("fn f(v: Vec<i32>) -> i32 { let mut s = 0; for x in v { s += x } s }")
+ .unwrap();
+ let b = parse::rust("fn f(v: Vec<i32>) -> i32 { let mut s = 0; for y in v { s += y } s }")
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_tuple_destructure_rename_invariant() {
+ let a = parse::rust("fn f() -> i32 { let (a, b) = (1, 2); a + b }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let (x, y) = (1, 2); x + y }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_tuple_destructure_position_matters() {
+ // (a, b) y (a, b) pero el cuerpo usa b - a vs a - b: distintos.
+ let a = parse::rust("fn f() -> i32 { let (x, y) = (1, 2); x - y }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let (x, y) = (1, 2); y - x }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_mut_pattern_rename_invariant() {
+ let a = parse::rust("fn f() -> i32 { let mut x = 1; x += 2; x }").unwrap();
+ let b = parse::rust("fn f() -> i32 { let mut z = 1; z += 2; z }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_simple_arm_rename_invariant() {
+ let a = parse::rust("fn f(v: i32) -> i32 { match v { x => x + 1, _ => 0 } }").unwrap();
+ let b = parse::rust("fn f(v: i32) -> i32 { match v { y => y + 1, _ => 0 } }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_arms_have_independent_scope() {
+ // Arm 1 introduce `x`; arm 2 introduce `y`. Ambos renombrables sin
+ // afectarse mutuamente.
+ let a = parse::rust("fn f(v: i32) -> i32 { match v { x => x, y => y + 1, _ => 0 } }").unwrap();
+ let b = parse::rust("fn f(v: i32) -> i32 { match v { a => a, b => b + 1, _ => 0 } }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_constructor_distinguishes_arms() {
+ // Some vs Ok: distintos constructores; el hash debe reflejarlo.
+ let a =
+ parse::rust("fn f(v: Option<i32>) -> i32 { match v { Some(x) => x, _ => 0 } }").unwrap();
+ let b =
+ parse::rust("fn f(v: Result<i32, ()>) -> i32 { match v { Ok(x) => x, _ => 0 } }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_tuple_struct_binder_rename_invariant() {
+ let a =
+ parse::rust("fn f(v: Option<i32>) -> i32 { match v { Some(x) => x + 1, None => 0 } }")
+ .unwrap();
+ let b =
+ parse::rust("fn f(v: Option<i32>) -> i32 { match v { Some(y) => y + 1, None => 0 } }")
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_struct_pattern_rename_invariant() {
+ let a = parse::rust(
+ "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { x: a, y: b } => a + b } }",
+ )
+ .unwrap();
+ let b = parse::rust(
+ "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { x: c, y: d } => c + d } }",
+ )
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_struct_pattern_field_name_matters() {
+ // Renombrar el campo (la "x" antes del `:`) cambia la identidad: es
+ // parte de la firma del struct, no un binder.
+ let a = parse::rust(
+ "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { x: a, y: b } => a + b } }",
+ )
+ .unwrap();
+ let b = parse::rust(
+ "struct P{x:i32,y:i32} fn f(p: P) -> i32 { match p { P { y: a, x: b } => a + b } }",
+ )
+ .unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_guard_binder_rename_invariant() {
+ let a = parse::rust("fn f(v: i32) -> i32 { match v { x if x > 0 => x, _ => 0 } }").unwrap();
+ let b = parse::rust("fn f(v: i32) -> i32 { match v { y if y > 0 => y, _ => 0 } }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_guard_op_distinguishes() {
+ let a = parse::rust("fn f(v: i32) -> i32 { match v { x if x > 0 => x, _ => 0 } }").unwrap();
+ let b = parse::rust("fn f(v: i32) -> i32 { match v { x if x < 0 => x, _ => 0 } }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_captured_pattern_rename_invariant() {
+ let a = parse::rust("fn f(v: i32) -> i32 { match v { n @ 1..=5 => n, _ => 0 } }").unwrap();
+ let b = parse::rust("fn f(v: i32) -> i32 { match v { m @ 1..=5 => m, _ => 0 } }").unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_captured_range_changes_hash() {
+ let a = parse::rust("fn f(v: i32) -> i32 { match v { n @ 1..=5 => n, _ => 0 } }").unwrap();
+ let b = parse::rust("fn f(v: i32) -> i32 { match v { n @ 1..=9 => n, _ => 0 } }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_match_constructor_vs_binder() {
+ // En el primero, `None` es discriminator (mayúscula); en el segundo,
+ // `x` es un catch-all binder. Estructural y semánticamente distintos.
+ let a =
+ parse::rust("fn f(v: Option<i32>) -> i32 { match v { None => 0, Some(z) => z } }").unwrap();
+ let b =
+ parse::rust("fn f(v: Option<i32>) -> i32 { match v { x => 0, Some(z) => z } }").unwrap();
+ assert_ne!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+// ====================================================================
+// Pendientes documentados — cierre del MVP de α-Rust.
+// ====================================================================
+
+#[test]
+fn alpha_if_let_binder_rename_invariant() {
+ // El binder de `if let Some(x) = v` participa sólo del consequence.
+ // Renombrar x por y no debe afectar el hash.
+ let a = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }",
+ )
+ .unwrap();
+ let b = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { if let Some(y) = v { y + 1 } else { 0 } }",
+ )
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_if_let_else_does_not_see_binder() {
+ // Sanity: el binder NO debe visitar el `else` (alternative). En
+ // `if let Some(x) = v { ... } else { v }`, el `else` ve `v` libre.
+ // Si renombramos sólo en el consequence, da el mismo hash.
+ let a = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { if let Some(x) = v { x } else { 0 } }",
+ )
+ .unwrap();
+ let b = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { if let Some(y) = v { y } else { 0 } }",
+ )
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_while_let_binder_rename_invariant() {
+ // El binder del while-let vive sólo en el body.
+ let a = parse::rust(
+ "fn f(mut it: Option<i32>) -> i32 { let mut total = 0; while let Some(x) = it { total += x; it = None; } total }",
+ )
+ .unwrap();
+ let b = parse::rust(
+ "fn f(mut it: Option<i32>) -> i32 { let mut total = 0; while let Some(y) = it { total += y; it = None; } total }",
+ )
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_let_else_binder_rename_invariant() {
+ // let-else: el binder vive sólo después del let, no en el else.
+ let a = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { let Some(x) = v else { return 0 }; x + 1 }",
+ )
+ .unwrap();
+ let b = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { let Some(y) = v else { return 0 }; y + 1 }",
+ )
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_or_pattern_binder_rename_invariant() {
+ // En un or-pattern (`Some(x) | Other(x)`), todos los lados
+ // introducen el mismo binder. Renombrar afecta a TODOS los lados
+ // a la vez. El hash se mantiene.
+ let a = parse::rust(
+ r#"
+ enum E { A(i32), B(i32) }
+ fn f(v: E) -> i32 { match v { E::A(x) | E::B(x) => x } }
+ "#,
+ )
+ .unwrap();
+ let b = parse::rust(
+ r#"
+ enum E { A(i32), B(i32) }
+ fn f(v: E) -> i32 { match v { E::A(y) | E::B(y) => y } }
+ "#,
+ )
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&b));
+}
+
+#[test]
+fn alpha_let_chain_binders_propagate_to_consequence() {
+ // Let-chain: dos let-conditions con &&. Ambos binders viven en
+ // el consequence. Renombrar ambos da mismo hash.
+ let a = parse::rust(
+ "fn f(a: Option<i32>, b: Option<i32>) -> i32 { if let Some(x) = a && let Some(y) = b { x + y } else { 0 } }",
+ )
+ .unwrap();
+ let c = parse::rust(
+ "fn f(a: Option<i32>, b: Option<i32>) -> i32 { if let Some(p) = a && let Some(q) = b { p + q } else { 0 } }",
+ )
+ .unwrap();
+ assert_eq!(hash_node_alpha(&a), hash_node_alpha(&c));
+}
+
+#[test]
+fn alpha_if_let_does_not_collide_with_unrelated_program() {
+ // Sanity negativo: dos programas con `if let` distintos
+ // (operación distinta) NO deben dar el mismo hash.
+ let plus = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { if let Some(x) = v { x + 1 } else { 0 } }",
+ )
+ .unwrap();
+ let minus = parse::rust(
+ "fn f(v: Option<i32>) -> i32 { if let Some(x) = v { x - 1 } else { 0 } }",
+ )
+ .unwrap();
+ assert_ne!(hash_node_alpha(&plus), hash_node_alpha(&minus));
+}
diff --git a/03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs b/03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs
new file mode 100644
index 0000000..c39d6cc
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/alpha_polyglot.rs
@@ -0,0 +1,307 @@
+//! α-equivalencia para Python, TypeScript, JavaScript, Go.
+//!
+//! Mismas propiedades que `alpha_invariants.rs` para Rust:
+//! - Renombre de variables ligadas → mismo hash.
+//! - Cambio de estructura / nombres libres → hash distinto.
+
+use minga_core::alpha::hash_alpha_with;
+use minga_core::parse::Dialect;
+
+fn h(d: Dialect, src: &str) -> minga_core::cas::ContentHash {
+ let n = d.parse(src).expect("parse OK");
+ hash_alpha_with(d, &n)
+}
+
+// ============================================================================
+// Python
+// ============================================================================
+
+#[test]
+fn python_def_param_rename_invariant() {
+ let a = h(Dialect::Python, "def f(x):\n return x + 1\n");
+ let b = h(Dialect::Python, "def f(y):\n return y + 1\n");
+ assert_eq!(a, b);
+}
+
+#[test]
+fn python_def_function_name_matters() {
+ let a = h(Dialect::Python, "def f(x):\n return x\n");
+ let b = h(Dialect::Python, "def g(x):\n return x\n");
+ assert_ne!(a, b, "el nombre de la función NO es α-anónimo");
+}
+
+#[test]
+fn python_lambda_rename_invariant() {
+ let a = h(Dialect::Python, "f = lambda x: x + 1\n");
+ let b = h(Dialect::Python, "f = lambda y: y + 1\n");
+ assert_eq!(a, b);
+}
+
+#[test]
+fn python_for_loop_rename_invariant() {
+ let a = h(
+ Dialect::Python,
+ "for x in xs:\n print(x)\n",
+ );
+ let b = h(
+ Dialect::Python,
+ "for y in xs:\n print(y)\n",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn python_for_iterable_name_matters() {
+ let a = h(
+ Dialect::Python,
+ "for x in xs:\n print(x)\n",
+ );
+ let b = h(
+ Dialect::Python,
+ "for x in ys:\n print(x)\n",
+ );
+ assert_ne!(a, b, "el iterable es variable libre, su nombre importa");
+}
+
+#[test]
+fn python_list_comprehension_rename_invariant() {
+ let a = h(Dialect::Python, "result = [x*2 for x in xs]\n");
+ let b = h(Dialect::Python, "result = [y*2 for y in xs]\n");
+ assert_eq!(a, b);
+}
+
+#[test]
+fn python_nested_comprehension_rename_invariant() {
+ // Doble for_in_clause: x e y son binders.
+ let a = h(
+ Dialect::Python,
+ "result = [(x, y) for x in xs for y in ys]\n",
+ );
+ let b = h(
+ Dialect::Python,
+ "result = [(a, b) for a in xs for b in ys]\n",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn python_with_statement_rename_invariant() {
+ let a = h(
+ Dialect::Python,
+ "with open(p) as f:\n f.read()\n",
+ );
+ let b = h(
+ Dialect::Python,
+ "with open(p) as g:\n g.read()\n",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn python_lambda_does_not_collide_with_unrelated() {
+ let plus = h(Dialect::Python, "f = lambda x: x + 1\n");
+ let minus = h(Dialect::Python, "f = lambda x: x - 1\n");
+ assert_ne!(plus, minus, "operación distinta debe dar hash distinto");
+}
+
+// ============================================================================
+// JavaScript / TypeScript (mismo profile)
+// ============================================================================
+
+#[test]
+fn js_function_rename_invariant() {
+ let a = h(Dialect::JavaScript, "function f(x) { return x + 1; }");
+ let b = h(Dialect::JavaScript, "function f(y) { return y + 1; }");
+ assert_eq!(a, b);
+}
+
+#[test]
+fn js_function_name_matters() {
+ let a = h(Dialect::JavaScript, "function f(x) { return x; }");
+ let b = h(Dialect::JavaScript, "function g(x) { return x; }");
+ assert_ne!(a, b);
+}
+
+#[test]
+fn js_arrow_function_rename_invariant() {
+ let a = h(Dialect::JavaScript, "const f = (x) => x + 1;");
+ let b = h(Dialect::JavaScript, "const f = (y) => y + 1;");
+ assert_eq!(a, b);
+}
+
+#[test]
+fn js_arrow_shorthand_rename_invariant() {
+ // `x => ...` (sin paréntesis) — single identifier.
+ let a = h(Dialect::JavaScript, "const f = x => x + 1;");
+ let b = h(Dialect::JavaScript, "const f = y => y + 1;");
+ assert_eq!(a, b);
+}
+
+#[test]
+fn js_let_const_rename_invariant() {
+ let a = h(Dialect::JavaScript, "function f() { const x = 1; return x + 2; }");
+ let b = h(Dialect::JavaScript, "function f() { const y = 1; return y + 2; }");
+ assert_eq!(a, b);
+}
+
+#[test]
+fn js_for_of_rename_invariant() {
+ let a = h(
+ Dialect::JavaScript,
+ "function f() { for (const x of xs) { use(x); } }",
+ );
+ let b = h(
+ Dialect::JavaScript,
+ "function f() { for (const y of xs) { use(y); } }",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn js_for_classic_rename_invariant() {
+ let a = h(
+ Dialect::JavaScript,
+ "function f() { for (let i = 0; i < n; i++) { use(i); } }",
+ );
+ let b = h(
+ Dialect::JavaScript,
+ "function f() { for (let j = 0; j < n; j++) { use(j); } }",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn js_catch_rename_invariant() {
+ let a = h(
+ Dialect::JavaScript,
+ "function f() { try { x(); } catch (e) { log(e); } }",
+ );
+ let b = h(
+ Dialect::JavaScript,
+ "function f() { try { x(); } catch (err) { log(err); } }",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn ts_typed_param_rename_invariant() {
+ // El TIPO afecta el hash, pero el nombre del parámetro no.
+ let a = h(
+ Dialect::TypeScript,
+ "function f(x: number): number { return x + 1; }",
+ );
+ let b = h(
+ Dialect::TypeScript,
+ "function f(y: number): number { return y + 1; }",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn ts_typed_param_type_matters() {
+ let int_v = h(
+ Dialect::TypeScript,
+ "function f(x: number): number { return x; }",
+ );
+ let str_v = h(
+ Dialect::TypeScript,
+ "function f(x: string): string { return x; }",
+ );
+ assert_ne!(int_v, str_v, "el tipo afecta semántica");
+}
+
+// ============================================================================
+// Go
+// ============================================================================
+
+#[test]
+fn go_function_rename_invariant() {
+ let a = h(
+ Dialect::Go,
+ "package main\nfunc add(a, b int) int { return a + b }\n",
+ );
+ let b = h(
+ Dialect::Go,
+ "package main\nfunc add(x, y int) int { return x + y }\n",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn go_function_name_matters() {
+ let a = h(
+ Dialect::Go,
+ "package main\nfunc add(a, b int) int { return a + b }\n",
+ );
+ let b = h(
+ Dialect::Go,
+ "package main\nfunc sub(a, b int) int { return a + b }\n",
+ );
+ assert_ne!(a, b);
+}
+
+#[test]
+fn go_short_var_decl_rename_invariant() {
+ let a = h(
+ Dialect::Go,
+ "package main\nfunc main() { x := compute(); use(x) }\n",
+ );
+ let b = h(
+ Dialect::Go,
+ "package main\nfunc main() { y := compute(); use(y) }\n",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn go_range_clause_rename_invariant() {
+ let a = h(
+ Dialect::Go,
+ "package main\nfunc main() { for k, v := range m { use(k, v) } }\n",
+ );
+ let b = h(
+ Dialect::Go,
+ "package main\nfunc main() { for x, y := range m { use(x, y) } }\n",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn go_if_init_rename_invariant() {
+ let a = h(
+ Dialect::Go,
+ "package main\nfunc main() { if x := lookup(); x > 0 { use(x) } }\n",
+ );
+ let b = h(
+ Dialect::Go,
+ "package main\nfunc main() { if y := lookup(); y > 0 { use(y) } }\n",
+ );
+ assert_eq!(a, b);
+}
+
+#[test]
+fn go_func_literal_closure_rename_invariant() {
+ let a = h(
+ Dialect::Go,
+ "package main\nvar f = func(x int) int { return x + 1 }\n",
+ );
+ let b = h(
+ Dialect::Go,
+ "package main\nvar f = func(y int) int { return y + 1 }\n",
+ );
+ assert_eq!(a, b);
+}
+
+// ============================================================================
+// Cross-language sanity
+// ============================================================================
+
+#[test]
+fn structurally_similar_programs_in_different_languages_have_distinct_hashes() {
+ // `def f(x): return x+1` en Python vs `function f(x){return x+1}` en JS.
+ // Mismo "shape" en idea pero distintas gramáticas → distintos kinds →
+ // distintos hashes. Importante para evitar colisiones cross-language.
+ let py = h(Dialect::Python, "def f(x):\n return x + 1\n");
+ let js = h(Dialect::JavaScript, "function f(x) { return x + 1; }");
+ assert_ne!(py, js);
+}
diff --git a/03_ukupacha/minga/minga-core/tests/attestation_invariants.rs b/03_ukupacha/minga/minga-core/tests/attestation_invariants.rs
new file mode 100644
index 0000000..3e692d2
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/attestation_invariants.rs
@@ -0,0 +1,136 @@
+//! Invariantes de las atestaciones firmadas y del `AttestationStore`.
+//!
+//! La tesis del módulo: una atestación válida es una **prueba**
+//! criptográfica de autoría, no una declaración. El store nunca
+//! almacena pruebas falsas — cualquier intento de inyectar una firma
+//! corrupta se rechaza al ingresar, no al consultar.
+
+use minga_core::{Attestation, AttestationError, AttestationStore, ContentHash, Keypair};
+
+fn kp(seed: u8) -> Keypair {
+ Keypair::from_seed(&[seed; 32])
+}
+
+fn ch(seed: u8) -> ContentHash {
+ ContentHash([seed; 32])
+}
+
+#[test]
+fn create_then_verify_succeeds() {
+ let alice = kp(1);
+ let att = Attestation::create(&alice, ch(7));
+ assert!(att.verify());
+ assert_eq!(att.author, alice.did());
+ assert_eq!(att.content, ch(7));
+}
+
+#[test]
+fn modifying_content_invalidates() {
+ let alice = kp(1);
+ let mut att = Attestation::create(&alice, ch(7));
+ att.content = ch(8);
+ assert!(!att.verify());
+}
+
+#[test]
+fn modifying_signature_invalidates() {
+ let alice = kp(1);
+ let mut att = Attestation::create(&alice, ch(7));
+ att.signature.0[0] ^= 0xFF;
+ assert!(!att.verify());
+}
+
+#[test]
+fn modifying_author_invalidates() {
+ let alice = kp(1);
+ let bob = kp(2);
+ let mut att = Attestation::create(&alice, ch(7));
+ att.author = bob.did();
+ assert!(!att.verify());
+}
+
+#[test]
+fn store_accepts_valid_attestation() {
+ let alice = kp(1);
+ let att = Attestation::create(&alice, ch(5));
+ let mut store = AttestationStore::new();
+ assert!(store.add(att.clone()).is_ok());
+ assert_eq!(store.len(), 1);
+ assert_eq!(store.get(&ch(5)), &[att][..]);
+}
+
+#[test]
+fn store_rejects_invalid_signature() {
+ let alice = kp(1);
+ let mut att = Attestation::create(&alice, ch(5));
+ att.signature.0[10] ^= 1;
+ let mut store = AttestationStore::new();
+ assert_eq!(store.add(att), Err(AttestationError::InvalidSignature));
+ assert_eq!(store.len(), 0);
+}
+
+#[test]
+fn store_rejects_swapped_content() {
+ // Atestación creada para `ch(1)`, modificada para reclamar `ch(2)`.
+ // La firma sigue siendo válida sobre `ch(1)` pero ahora el content
+ // dice `ch(2)` — no verifica.
+ let alice = kp(1);
+ let mut att = Attestation::create(&alice, ch(1));
+ att.content = ch(2);
+ let mut store = AttestationStore::new();
+ assert!(store.add(att).is_err());
+}
+
+#[test]
+fn store_is_idempotent_for_same_author_content() {
+ let alice = kp(1);
+ let att = Attestation::create(&alice, ch(5));
+ let mut store = AttestationStore::new();
+ store.add(att.clone()).unwrap();
+ store.add(att.clone()).unwrap();
+ store.add(att).unwrap();
+ assert_eq!(store.len(), 1);
+}
+
+#[test]
+fn store_keeps_multiple_authors_per_content() {
+ let alice = kp(1);
+ let bob = kp(2);
+ let carol = kp(3);
+ let h = ch(99);
+ let mut store = AttestationStore::new();
+ store.add(Attestation::create(&alice, h)).unwrap();
+ store.add(Attestation::create(&bob, h)).unwrap();
+ store.add(Attestation::create(&carol, h)).unwrap();
+ assert_eq!(store.len(), 3);
+ assert_eq!(store.get(&h).len(), 3);
+
+ let authors = store.authors_of(&h);
+ assert_eq!(authors.len(), 3);
+ assert!(authors.contains(&alice.did()));
+ assert!(authors.contains(&bob.did()));
+ assert!(authors.contains(&carol.did()));
+}
+
+#[test]
+fn authors_of_for_unknown_content_is_empty() {
+ let store = AttestationStore::new();
+ assert!(store.authors_of(&ch(0)).is_empty());
+ assert_eq!(store.get(&ch(0)).len(), 0);
+}
+
+#[test]
+fn distinct_authors_distinct_signatures_same_content() {
+ // Firmar el mismo `ContentHash` con dos llaves distintas produce
+ // firmas distintas (Ed25519 es determinista por llave, así que la
+ // diferencia viene de la llave, no de un nonce aleatorio).
+ let alice = kp(1);
+ let bob = kp(2);
+ let h = ch(50);
+ let a1 = Attestation::create(&alice, h);
+ let a2 = Attestation::create(&bob, h);
+ assert_ne!(a1.signature, a2.signature);
+ assert_ne!(a1.author, a2.author);
+ assert!(a1.verify());
+ assert!(a2.verify());
+}
diff --git a/03_ukupacha/minga/minga-core/tests/cas_invariants.rs b/03_ukupacha/minga/minga-core/tests/cas_invariants.rs
new file mode 100644
index 0000000..6b57a2c
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/cas_invariants.rs
@@ -0,0 +1,64 @@
+//! Invariantes del direccionamiento por contenido semántico.
+//!
+//! Estos tests definen la *tesis matemática* del núcleo: qué cambios deben
+//! preservar el hash y qué cambios deben romperlo. Si alguno falla, la
+//! garantía fundacional de Minga está rota.
+
+use minga_core::{cas::hash_node, parse};
+
+#[test]
+fn whitespace_invariant() {
+ let a = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
+ let b = parse::rust("fn add(x:i32,y:i32)->i32{x+y}").unwrap();
+ let c = parse::rust("fn add( x : i32 , y : i32 )\n -> i32\n{\n x + y\n}").unwrap();
+ assert_eq!(hash_node(&a), hash_node(&b));
+ assert_eq!(hash_node(&a), hash_node(&c));
+}
+
+#[test]
+fn comment_invariant() {
+ let a = parse::rust("fn f() { 1 + 2 }").unwrap();
+ let b = parse::rust("fn f() { /* comentario */ 1 + 2 // cola\n }").unwrap();
+ let c = parse::rust("// arriba\nfn f() {\n // dentro\n 1 + 2\n}\n").unwrap();
+ assert_eq!(hash_node(&a), hash_node(&b));
+ assert_eq!(hash_node(&a), hash_node(&c));
+}
+
+#[test]
+fn body_change_breaks_hash() {
+ let a = parse::rust("fn f() { 1 + 2 }").unwrap();
+ let b = parse::rust("fn f() { 1 + 3 }").unwrap();
+ assert_ne!(hash_node(&a), hash_node(&b));
+}
+
+#[test]
+fn rename_breaks_hash_for_now() {
+ // Capa base: renombrar identificadores cambia el hash. La identidad
+ // por intención (alpha-equivalencia: mismo cuerpo módulo nombres
+ // ligados) es una capa superior que se construirá encima.
+ let a = parse::rust("fn add(x: i32) -> i32 { x }").unwrap();
+ let b = parse::rust("fn add(y: i32) -> i32 { y }").unwrap();
+ assert_ne!(hash_node(&a), hash_node(&b));
+}
+
+#[test]
+fn signature_change_breaks_hash() {
+ let a = parse::rust("fn f(x: i32) -> i32 { x }").unwrap();
+ let b = parse::rust("fn f(x: i64) -> i64 { x }").unwrap();
+ assert_ne!(hash_node(&a), hash_node(&b));
+}
+
+#[test]
+fn order_matters() {
+ // Reordenar dos funciones top-level cambia el hash del archivo entero
+ // (el árbol del source_file tiene hijos ordenados). El hash de cada
+ // función individual debe permanecer estable.
+ let file_a = parse::rust("fn a() {} fn b() {}").unwrap();
+ let file_b = parse::rust("fn b() {} fn a() {}").unwrap();
+ assert_ne!(hash_node(&file_a), hash_node(&file_b));
+
+ // Pero las funciones individuales (segundo nivel) sí coinciden cruzadas:
+ let fa = &file_a.children[0]; // fn a
+ let fb_in_b = &file_b.children[1]; // fn a en file_b
+ assert_eq!(hash_node(fa), hash_node(fb_in_b));
+}
diff --git a/03_ukupacha/minga/minga-core/tests/identity_invariants.rs b/03_ukupacha/minga/minga-core/tests/identity_invariants.rs
new file mode 100644
index 0000000..3563e7d
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/identity_invariants.rs
@@ -0,0 +1,172 @@
+//! Invariantes de la identidad criptográfica: roundtrip de firma,
+//! determinismo desde semilla, detección de manipulaciones.
+
+use minga_core::{Did, Keypair, KeypairCryptoError, Signature};
+
+fn kp(seed: u8) -> Keypair {
+ Keypair::from_seed(&[seed; 32])
+}
+
+#[test]
+fn keypair_from_seed_is_deterministic() {
+ let a = kp(7);
+ let b = kp(7);
+ assert_eq!(a.did(), b.did());
+ let msg = b"hola minga";
+ assert_eq!(a.sign(msg), b.sign(msg));
+}
+
+#[test]
+fn distinct_seeds_produce_distinct_dids() {
+ let a = kp(1);
+ let b = kp(2);
+ assert_ne!(a.did(), b.did());
+}
+
+#[test]
+fn generate_produces_unique_dids() {
+ // Dos `generate()` consecutivos deben dar DIDs distintos con
+ // probabilidad abrumadora (chance de colisión ≈ 2^-256).
+ let a = Keypair::generate();
+ let b = Keypair::generate();
+ assert_ne!(a.did(), b.did());
+}
+
+#[test]
+fn sign_verify_roundtrip() {
+ let k = kp(42);
+ let msg = b"mensaje arbitrario de longitud variable, con UTF-8: cafe \xc3\xa9";
+ let sig = k.sign(msg);
+ assert!(k.did().verify(msg, &sig));
+}
+
+#[test]
+fn verify_fails_with_wrong_did() {
+ let signer = kp(10);
+ let msg = b"contenido";
+ let sig = signer.sign(msg);
+ let imposter = kp(11).did();
+ assert!(!imposter.verify(msg, &sig));
+}
+
+#[test]
+fn verify_fails_with_tampered_message() {
+ let k = kp(99);
+ let sig = k.sign(b"mensaje original");
+ assert!(!k.did().verify(b"mensaje modificado", &sig));
+}
+
+#[test]
+fn verify_fails_with_tampered_signature() {
+ let k = kp(99);
+ let mut sig = k.sign(b"x");
+ sig.0[0] ^= 0xFF;
+ assert!(!k.did().verify(b"x", &sig));
+}
+
+#[test]
+fn verify_handles_invalid_did_bytes() {
+ // Did con bytes que no forman un punto válido en la curva debería
+ // fallar verificación silenciosamente (sin pánico).
+ let bogus_did = Did([0xFF; 32]);
+ let sig = Signature([0u8; 64]);
+ assert!(!bogus_did.verify(b"anything", &sig));
+}
+
+#[test]
+fn did_display_uses_did_key_prefix() {
+ let did = kp(0).did();
+ let s = format!("{}", did);
+ assert!(s.starts_with("did:key:"));
+ assert_eq!(s.len(), "did:key:".len() + 64); // 32 bytes en hex = 64 chars
+}
+
+#[test]
+fn encrypt_decrypt_roundtrip_preserves_identity() {
+ let original = kp(7);
+ let blob = original.encrypt("contraseña-correcta").unwrap();
+ let restored = Keypair::decrypt(&blob, "contraseña-correcta").unwrap();
+
+ // El DID se preserva: misma identidad pública.
+ assert_eq!(original.did(), restored.did());
+
+ // Y la capacidad de firmar — un mensaje firmado por uno verifica
+ // contra el DID del otro (porque son la misma llave).
+ let msg = b"prueba post-cifrado";
+ let sig_original = original.sign(msg);
+ let sig_restored = restored.sign(msg);
+ assert_eq!(sig_original, sig_restored);
+ assert!(restored.did().verify(msg, &sig_original));
+}
+
+#[test]
+fn decrypt_with_wrong_passphrase_fails() {
+ let kp = kp(11);
+ let blob = kp.encrypt("correcta").unwrap();
+ let r = Keypair::decrypt(&blob, "incorrecta");
+ assert!(matches!(r, Err(KeypairCryptoError::DecryptFailed)));
+}
+
+#[test]
+fn decrypt_rejects_tampered_ciphertext() {
+ // AES-GCM es authenticated: cualquier modificación del cipher
+ // (incluyendo el tag) hace fallar la verificación.
+ let kp = kp(13);
+ let mut blob = kp.encrypt("pass").unwrap();
+ let last = blob.len() - 1;
+ blob[last] ^= 0xFF;
+ let r = Keypair::decrypt(&blob, "pass");
+ assert!(matches!(r, Err(KeypairCryptoError::DecryptFailed)));
+}
+
+#[test]
+fn decrypt_rejects_invalid_format() {
+ assert!(matches!(
+ Keypair::decrypt(b"too short", "x"),
+ Err(KeypairCryptoError::InvalidFormat)
+ ));
+ let mut bogus = vec![0xFFu8; 100];
+ bogus[0..8].copy_from_slice(b"NOTMINGA");
+ assert!(matches!(
+ Keypair::decrypt(&bogus, "x"),
+ Err(KeypairCryptoError::InvalidFormat)
+ ));
+}
+
+#[test]
+fn distinct_passphrases_produce_distinct_blobs() {
+ // Cifrar la misma key con dos passphrases distintas produce blobs
+ // distintos (también porque salt y nonce son aleatorios — no es
+ // determinismo, es solo que no colisionan).
+ let kp = kp(17);
+ let a = kp.encrypt("alpha").unwrap();
+ let b = kp.encrypt("beta").unwrap();
+ assert_ne!(a, b);
+}
+
+#[test]
+fn re_encrypting_same_keypair_produces_distinct_blobs() {
+ // Salt y nonce aleatorios: el mismo keypair y la misma passphrase
+ // producen cipher distintos en cada llamada. Sin patrón observable.
+ let kp = kp(19);
+ let blob1 = kp.encrypt("p").unwrap();
+ let blob2 = kp.encrypt("p").unwrap();
+ assert_ne!(blob1, blob2);
+ // Pero ambos descifran a la misma identidad.
+ assert_eq!(
+ Keypair::decrypt(&blob1, "p").unwrap().did(),
+ Keypair::decrypt(&blob2, "p").unwrap().did()
+ );
+}
+
+#[test]
+fn keypair_debug_does_not_leak_private_key() {
+ // El derive de Debug expondría los bytes secretos. Lo
+ // sobreescribimos para que solo muestre el DID.
+ let k = kp(1);
+ let s = format!("{:?}", k);
+ assert!(s.contains("did:key:"));
+ // No debería aparecer ningún byte de la semilla [1u8; 32] en hex
+ // contiguo (fragmento "010101..." sería sospechoso si emergiera).
+ assert!(!s.contains("0101010101010101"));
+}
diff --git a/03_ukupacha/minga/minga-core/tests/mst_invariants.rs b/03_ukupacha/minga/minga-core/tests/mst_invariants.rs
new file mode 100644
index 0000000..05c35e7
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/mst_invariants.rs
@@ -0,0 +1,347 @@
+//! Invariantes del Merkle Search Tree.
+//!
+//! La tesis del MST: dado un mismo conjunto de hashes, el árbol y su
+//! `root_hash` son únicos, sin importar el orden de inserción. Eso es lo
+//! que permite a dos repositorios saber si convergen comparando un solo
+//! hash de 32 bytes y, si difieren, descender solo por las ramas con
+//! diferencias.
+
+use minga_core::{cas::ContentHash, mst::Mst};
+
+fn ch(seed: u64) -> ContentHash {
+ // Usamos blake3 para que la distribución de niveles (nibbles cero al
+ // inicio) sea representativa, no degenerada.
+ let h = blake3::hash(&seed.to_le_bytes());
+ ContentHash(*h.as_bytes())
+}
+
+#[test]
+fn mst_empty() {
+ let m = Mst::new();
+ assert!(m.is_empty());
+ assert_eq!(m.len(), 0);
+ assert_eq!(m.iter().count(), 0);
+}
+
+#[test]
+fn mst_insert_single() {
+ let mut m = Mst::new();
+ let h = ch(1);
+ assert!(m.insert(h));
+ assert!(!m.insert(h)); // duplicado: no-op
+ assert_eq!(m.len(), 1);
+ assert!(m.contains(&h));
+ assert!(!m.contains(&ch(2)));
+}
+
+#[test]
+fn mst_iter_yields_sorted_keys() {
+ let mut m = Mst::new();
+ let mut hashes: Vec<ContentHash> = (0..32u64).map(ch).collect();
+ for h in &hashes {
+ m.insert(*h);
+ }
+ let collected: Vec<ContentHash> = m.iter().copied().collect();
+ hashes.sort();
+ assert_eq!(collected, hashes);
+}
+
+#[test]
+fn mst_history_independence() {
+ // Mismo conjunto, tres órdenes de inserción distintos: orden natural,
+ // inverso, y reordenado por byte arbitrario. Los tres deben producir
+ // exactamente el mismo árbol.
+ let hashes: Vec<ContentHash> = (0..50u64).map(ch).collect();
+
+ let mut m_natural = Mst::new();
+ for h in &hashes {
+ m_natural.insert(*h);
+ }
+
+ let mut m_reverse = Mst::new();
+ for h in hashes.iter().rev() {
+ m_reverse.insert(*h);
+ }
+
+ let mut shuffled = hashes.clone();
+ shuffled.sort_by_key(|h| h.0[7]);
+ let mut m_shuffled = Mst::new();
+ for h in &shuffled {
+ m_shuffled.insert(*h);
+ }
+
+ assert_eq!(m_natural.len(), 50);
+ assert_eq!(m_natural.len(), m_reverse.len());
+ assert_eq!(m_natural.len(), m_shuffled.len());
+
+ assert_eq!(m_natural.root_hash(), m_reverse.root_hash());
+ assert_eq!(m_natural.root_hash(), m_shuffled.root_hash());
+
+ let s_natural: Vec<ContentHash> = m_natural.iter().copied().collect();
+ let s_reverse: Vec<ContentHash> = m_reverse.iter().copied().collect();
+ let s_shuffled: Vec<ContentHash> = m_shuffled.iter().copied().collect();
+ assert_eq!(s_natural, s_reverse);
+ assert_eq!(s_natural, s_shuffled);
+}
+
+#[test]
+fn mst_set_difference_changes_root() {
+ let mut m1 = Mst::new();
+ m1.insert(ch(1));
+ m1.insert(ch(2));
+
+ let mut m2 = Mst::new();
+ m2.insert(ch(1));
+ m2.insert(ch(3));
+
+ let mut m3 = Mst::new();
+ m3.insert(ch(1));
+ m3.insert(ch(2));
+
+ assert_ne!(m1.root_hash(), m2.root_hash());
+ assert_eq!(m1.root_hash(), m3.root_hash());
+}
+
+#[test]
+fn mst_root_hash_changes_with_size() {
+ let mut m = Mst::new();
+ let h0 = m.root_hash();
+ m.insert(ch(1));
+ let h1 = m.root_hash();
+ m.insert(ch(2));
+ let h2 = m.root_hash();
+ assert_ne!(h0, h1);
+ assert_ne!(h1, h2);
+}
+
+#[test]
+fn mst_contains_after_many_inserts() {
+ let mut m = Mst::new();
+ let hashes: Vec<ContentHash> = (0..200u64).map(ch).collect();
+ for h in &hashes {
+ m.insert(*h);
+ }
+ for h in &hashes {
+ assert!(m.contains(h), "falta clave {h}");
+ }
+ assert!(!m.contains(&ch(9999)));
+ assert_eq!(m.len(), 200);
+}
+
+#[test]
+fn mst_no_duplicates_inflate_size() {
+ let mut m = Mst::new();
+ for _ in 0..10 {
+ m.insert(ch(42));
+ }
+ assert_eq!(m.len(), 1);
+}
+
+#[test]
+fn mst_diff_identical_is_empty() {
+ let hs: Vec<_> = (0..30u64).map(ch).collect();
+ let mut a = Mst::new();
+ let mut b = Mst::new();
+ for h in &hs {
+ a.insert(*h);
+ b.insert(*h);
+ }
+ let d = a.diff(&b);
+ assert!(d.is_empty());
+ assert_eq!(d.total(), 0);
+}
+
+#[test]
+fn mst_diff_history_independent() {
+ // Mismo conjunto en orden distinto: diff vacío. Aquí estresa el
+ // short-circuit de Merkle: con 1000 claves construidas en órdenes
+ // opuestos, la igualdad debe detectarse en una sola comparación.
+ let hs: Vec<_> = (0..1000u64).map(ch).collect();
+ let mut a = Mst::new();
+ for h in &hs {
+ a.insert(*h);
+ }
+ let mut b = Mst::new();
+ for h in hs.iter().rev() {
+ b.insert(*h);
+ }
+ assert!(a.diff(&b).is_empty());
+}
+
+#[test]
+fn mst_diff_one_empty_yields_other() {
+ let hs: Vec<_> = (0..10u64).map(ch).collect();
+ let empty = Mst::new();
+ let mut full = Mst::new();
+ for h in &hs {
+ full.insert(*h);
+ }
+
+ let d_full_vs_empty = full.diff(&empty);
+ assert_eq!(d_full_vs_empty.only_in_self.len(), 10);
+ assert!(d_full_vs_empty.only_in_other.is_empty());
+
+ let d_empty_vs_full = empty.diff(&full);
+ assert!(d_empty_vs_full.only_in_self.is_empty());
+ assert_eq!(d_empty_vs_full.only_in_other.len(), 10);
+}
+
+#[test]
+fn mst_diff_disjoint_sets() {
+ let only_a: Vec<_> = (0..15u64).map(ch).collect();
+ let only_b: Vec<_> = (100..115u64).map(ch).collect();
+ let mut a = Mst::new();
+ for h in &only_a {
+ a.insert(*h);
+ }
+ let mut b = Mst::new();
+ for h in &only_b {
+ b.insert(*h);
+ }
+ let d = a.diff(&b);
+ assert_eq!(d.only_in_self.len(), 15);
+ assert_eq!(d.only_in_other.len(), 15);
+
+ // El conjunto reportado debe coincidir exactamente con los inputs.
+ let mut got_a = d.only_in_self.clone();
+ let mut got_b = d.only_in_other.clone();
+ got_a.sort();
+ got_b.sort();
+ let mut want_a = only_a.clone();
+ let mut want_b = only_b.clone();
+ want_a.sort();
+ want_b.sort();
+ assert_eq!(got_a, want_a);
+ assert_eq!(got_b, want_b);
+}
+
+#[test]
+fn mst_diff_partial_overlap() {
+ let common: Vec<_> = (0..40u64).map(ch).collect();
+ let only_a: Vec<_> = (40..50u64).map(ch).collect();
+ let only_b: Vec<_> = (50..58u64).map(ch).collect();
+
+ let mut a = Mst::new();
+ for h in common.iter().chain(only_a.iter()) {
+ a.insert(*h);
+ }
+ let mut b = Mst::new();
+ for h in common.iter().chain(only_b.iter()) {
+ b.insert(*h);
+ }
+
+ let d = a.diff(&b);
+ // Las claves comunes no aparecen en el diff; solo las únicas.
+ assert_eq!(d.only_in_self.len(), only_a.len());
+ assert_eq!(d.only_in_other.len(), only_b.len());
+}
+
+#[test]
+fn mst_diff_is_symmetric() {
+ let a_keys: Vec<_> = (0..20u64).map(ch).collect();
+ let b_keys: Vec<_> = (10..30u64).map(ch).collect();
+ let mut a = Mst::new();
+ for h in &a_keys {
+ a.insert(*h);
+ }
+ let mut b = Mst::new();
+ for h in &b_keys {
+ b.insert(*h);
+ }
+ let ab = a.diff(&b);
+ let ba = b.diff(&a);
+ assert_eq!(ab.only_in_self, ba.only_in_other);
+ assert_eq!(ab.only_in_other, ba.only_in_self);
+}
+
+#[test]
+fn mst_diff_output_is_sorted() {
+ // Sin importar la divergencia, el output viene ordenado por hash.
+ let a_keys: Vec<_> = (0..25u64).map(ch).collect();
+ let b_keys: Vec<_> = (15..40u64).map(ch).collect();
+ let mut a = Mst::new();
+ for h in &a_keys {
+ a.insert(*h);
+ }
+ let mut b = Mst::new();
+ for h in &b_keys {
+ b.insert(*h);
+ }
+ let d = a.diff(&b);
+ let mut sorted = d.only_in_self.clone();
+ sorted.sort();
+ assert_eq!(d.only_in_self, sorted);
+ let mut sorted2 = d.only_in_other.clone();
+ sorted2.sort();
+ assert_eq!(d.only_in_other, sorted2);
+}
+
+#[test]
+fn mst_diff_apply_converges() {
+ // La propiedad fundacional para sincronización P2P: si cada peer
+ // calcula el diff y aplica las claves que le faltan, ambos
+ // convergen al mismo conjunto y el segundo diff es vacío.
+ let common: Vec<_> = (0..50u64).map(ch).collect();
+ let only_a: Vec<_> = (50..70u64).map(ch).collect();
+ let only_b: Vec<_> = (70..85u64).map(ch).collect();
+
+ let mut a = Mst::new();
+ for h in common.iter().chain(only_a.iter()) {
+ a.insert(*h);
+ }
+ let mut b = Mst::new();
+ for h in common.iter().chain(only_b.iter()) {
+ b.insert(*h);
+ }
+
+ let d = a.diff(&b);
+
+ for h in &d.only_in_other {
+ a.insert(*h);
+ }
+ for h in &d.only_in_self {
+ b.insert(*h);
+ }
+
+ assert_eq!(a.root_hash(), b.root_hash());
+ assert!(a.diff(&b).is_empty());
+ assert_eq!(a.len(), common.len() + only_a.len() + only_b.len());
+}
+
+#[test]
+fn mst_diff_single_key_change() {
+ // Repos casi idénticos, diferenciados por una sola clave. El
+ // short-circuit de Merkle debería podar todo lo demás. No medimos
+ // el coste aquí (es un test de corrección), pero verificamos que
+ // el resultado es exactamente la diferencia esperada.
+ let hs: Vec<_> = (0..200u64).map(ch).collect();
+ let mut a = Mst::new();
+ for h in &hs {
+ a.insert(*h);
+ }
+ let mut b = a.clone();
+ let extra = ch(9999);
+ b.insert(extra);
+
+ let d = a.diff(&b);
+ assert!(d.only_in_self.is_empty());
+ assert_eq!(d.only_in_other, vec![extra]);
+}
+
+#[test]
+fn mst_levels_distribute_naturally() {
+ // Sanity: con 1000 claves blake3, esperamos que algunas tengan nivel
+ // > 0 (probabilidad de >= 1 nibble cero al inicio ≈ 1/16, así que
+ // ~62 claves esperadas). Si el árbol es de un solo nivel, algo en la
+ // promoción/split está mal.
+ let mut m = Mst::new();
+ for i in 0..1000u64 {
+ m.insert(ch(i));
+ }
+ assert_eq!(m.len(), 1000);
+ // Si todas las claves estuvieran al mismo nivel, el árbol sería un
+ // único nodo gigante y `root_hash` sería trivialmente reconstruible.
+ // No es una verificación profunda, pero pillaría una regresión obvia.
+ assert!(m.contains(&ch(0)));
+ assert!(m.contains(&ch(999)));
+}
diff --git a/03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs b/03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs
new file mode 100644
index 0000000..fff8279
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/serde_roundtrips.rs
@@ -0,0 +1,134 @@
+//! Tests de roundtrip de serialización para los tipos de wire.
+//!
+//! Cualquier tipo que cruce la red debe (a) (de)serializar bit-a-bit
+//! igual sobre postcard, y (b) preservar todos sus invariantes
+//! semánticos tras un viaje. Estos tests son la red de seguridad
+//! contra cambios de schema accidentales que romperían la
+//! compatibilidad on-the-wire.
+
+use minga_core::{Attestation, ContentHash, Keypair, NodeProbe, Signature, StoredNode};
+
+fn roundtrip<T: serde::Serialize + for<'a> serde::Deserialize<'a> + PartialEq + std::fmt::Debug>(
+ value: &T,
+) {
+ let bytes = postcard::to_allocvec(value).unwrap();
+ let decoded: T = postcard::from_bytes(&bytes).unwrap();
+ assert_eq!(value, &decoded);
+}
+
+#[test]
+fn content_hash_roundtrip() {
+ let h = ContentHash([42; 32]);
+ roundtrip(&h);
+
+ // Codifica como exactamente 32 bytes (transparent sobre [u8; 32]).
+ let bytes = postcard::to_allocvec(&h).unwrap();
+ assert_eq!(bytes.len(), 32);
+ assert_eq!(bytes, vec![42u8; 32]);
+}
+
+#[test]
+fn did_roundtrip() {
+ let kp = Keypair::from_seed(&[7; 32]);
+ let did = kp.did();
+ roundtrip(&did);
+ let bytes = postcard::to_allocvec(&did).unwrap();
+ assert_eq!(bytes.len(), 32);
+}
+
+#[test]
+fn signature_roundtrip() {
+ let kp = Keypair::from_seed(&[3; 32]);
+ let sig = kp.sign(b"mensaje");
+ roundtrip(&sig);
+ // 64 bytes Ed25519 + cualquier overhead transparent.
+ let bytes = postcard::to_allocvec(&sig).unwrap();
+ assert_eq!(bytes.len(), 64);
+}
+
+#[test]
+fn signature_roundtrip_preserves_verify() {
+ let kp = Keypair::from_seed(&[9; 32]);
+ let msg = b"el mensaje original";
+ let sig = kp.sign(msg);
+
+ let bytes = postcard::to_allocvec(&sig).unwrap();
+ let decoded: Signature = postcard::from_bytes(&bytes).unwrap();
+
+ // El predicado criptográfico se preserva exactamente.
+ assert!(kp.did().verify(msg, &decoded));
+}
+
+#[test]
+fn stored_node_roundtrip() {
+ let s = StoredNode {
+ kind: "function_item".to_string(),
+ field_name: Some("body".to_string()),
+ leaf_text: None,
+ children: vec![ContentHash([1; 32]), ContentHash([2; 32])],
+ };
+ roundtrip(&s);
+}
+
+#[test]
+fn stored_node_with_leaf_roundtrip() {
+ let s = StoredNode {
+ kind: "integer_literal".to_string(),
+ field_name: None,
+ leaf_text: Some(b"42".to_vec()),
+ children: Vec::new(),
+ };
+ roundtrip(&s);
+}
+
+#[test]
+fn attestation_roundtrip() {
+ let kp = Keypair::from_seed(&[5; 32]);
+ let att = Attestation::create(&kp, ContentHash([99; 32]));
+ roundtrip(&att);
+}
+
+#[test]
+fn attestation_roundtrip_preserves_verify() {
+ let kp = Keypair::from_seed(&[11; 32]);
+ let att = Attestation::create(&kp, ContentHash([77; 32]));
+
+ let bytes = postcard::to_allocvec(&att).unwrap();
+ let decoded: Attestation = postcard::from_bytes(&bytes).unwrap();
+
+ assert!(decoded.verify());
+}
+
+#[test]
+fn node_probe_roundtrip() {
+ let probe = NodeProbe {
+ level: 3,
+ keys: vec![ContentHash([1; 32]), ContentHash([2; 32])],
+ child_hashes: vec![
+ ContentHash([10; 32]),
+ ContentHash([20; 32]),
+ ContentHash([30; 32]),
+ ],
+ };
+ roundtrip(&probe);
+}
+
+#[test]
+fn empty_collections_serialize_compactly() {
+ // postcard codifica longitudes con varint. Vec vacío = 1 byte (longitud 0).
+ let probe = NodeProbe {
+ level: 0,
+ keys: Vec::new(),
+ child_hashes: Vec::new(),
+ };
+ let bytes = postcard::to_allocvec(&probe).unwrap();
+ // postcard varint: u32(0) = 1 byte, vec_len(0) = 1 byte ×2 = 3 bytes total.
+ assert_eq!(bytes.len(), 3);
+}
+
+#[test]
+fn malformed_bytes_fail_decode() {
+ let bogus = vec![0xFFu8; 100];
+ let result: Result<Attestation, _> = postcard::from_bytes(&bogus);
+ assert!(result.is_err());
+}
diff --git a/03_ukupacha/minga/minga-core/tests/store_invariants.rs b/03_ukupacha/minga/minga-core/tests/store_invariants.rs
new file mode 100644
index 0000000..a9eb7f4
--- /dev/null
+++ b/03_ukupacha/minga/minga-core/tests/store_invariants.rs
@@ -0,0 +1,129 @@
+//! Invariantes del NodeStore.
+//!
+//! El almacén tiene tres responsabilidades cruzadas que deben sostenerse
+//! simultáneamente:
+//! 1. **Round-trip exacto**: lo que entró sale igual.
+//! 2. **Hash estable**: el hash que devuelve `put` coincide con
+//! `cas::hash_node` del nodo original.
+//! 3. **Deduplicación**: subárboles compartidos se almacenan una sola vez.
+
+use minga_core::{
+ cas::hash_node,
+ parse,
+ store::{MemStore, NodeStore},
+};
+
+#[test]
+fn store_round_trip_preserves_tree() {
+ let original = parse::rust("fn add(x: i32, y: i32) -> i32 { x + y }").unwrap();
+ let mut store = MemStore::new();
+ let h = store.put(&original);
+ let reconstructed = store.reconstruct(&h).unwrap();
+ assert_eq!(reconstructed, original);
+}
+
+#[test]
+fn store_hash_matches_cas() {
+ let n = parse::rust("fn f() -> bool { true }").unwrap();
+ let mut store = MemStore::new();
+ let put_hash = store.put(&n);
+ assert_eq!(put_hash, hash_node(&n));
+}
+
+#[test]
+fn store_idempotent_put() {
+ let n = parse::rust("fn f() { 1 + 2 + 3 }").unwrap();
+ let mut store = MemStore::new();
+ let h1 = store.put(&n);
+ let len_after_first = store.len();
+ let h2 = store.put(&n);
+ let len_after_second = store.len();
+ assert_eq!(h1, h2);
+ assert_eq!(len_after_first, len_after_second);
+}
+
+#[test]
+fn store_dedup_shared_subtree() {
+ // Dos funciones con cuerpo idéntico: el subárbol del bloque y todos
+ // sus descendientes deben aparecer una sola vez en el almacén.
+ let a = parse::rust("fn alpha() -> i32 { 1 + 2 }").unwrap();
+ let b = parse::rust("fn beta() -> i32 { 1 + 2 }").unwrap();
+
+ let mut store = MemStore::new();
+ let h_a = store.put(&a);
+ let count_after_a = store.len();
+ let h_b = store.put(&b);
+ let count_after_b = store.len();
+
+ assert_ne!(h_a, h_b, "los hashes raíz deben diferir (nombres distintos)");
+
+ // Buscar el bloque del cuerpo en ambas y verificar mismo hash.
+ let body_a = find_first_kind(&a, "block").unwrap();
+ let body_b = find_first_kind(&b, "block").unwrap();
+ assert_eq!(hash_node(body_a), hash_node(body_b));
+
+ // Crecimiento esperado al añadir b: solo los nodos que difieren entre
+ // las dos funciones (el `function_item` raíz, el identificador del
+ // nombre `beta`, posiblemente algún wrapper). En cualquier caso,
+ // estrictamente menos que duplicar el almacén.
+ assert!(
+ count_after_b < 2 * count_after_a,
+ "dedup falló: {count_after_b} >= 2 * {count_after_a}"
+ );
+}
+
+#[test]
+fn store_subtree_resolvable_independently() {
+ // El hash de cualquier subárbol debe poder reconstruirse aunque
+ // hayamos pedido un árbol mayor que lo contiene.
+ let n = parse::rust("fn f() -> i32 { let x = 7; x * 2 }").unwrap();
+ let mut store = MemStore::new();
+ store.put(&n);
+
+ let block = find_first_kind(&n, "block").unwrap();
+ let block_hash = hash_node(block);
+ assert!(store.contains(&block_hash));
+ let reconstructed_block = store.reconstruct(&block_hash).unwrap();
+ assert_eq!(&reconstructed_block, block);
+}
+
+#[test]
+fn store_unknown_hash_is_none() {
+ let store = MemStore::new();
+ let bogus = minga_core::ContentHash([0xAB; 32]);
+ assert!(store.get(&bogus).is_none());
+ assert!(store.reconstruct(&bogus).is_none());
+}
+
+#[test]
+fn store_multiple_files_share_common_constants() {
+ // Tres archivos con el literal "42" repetido: el nodo
+ // `integer_literal` con texto "42" debe almacenarse una sola vez.
+ let n1 = parse::rust("fn a() -> i32 { 42 }").unwrap();
+ let n2 = parse::rust("fn b() -> i32 { 42 }").unwrap();
+ let n3 = parse::rust("fn c() -> i32 { 42 }").unwrap();
+ let mut store = MemStore::new();
+ store.put(&n1);
+ let after_one = store.len();
+ store.put(&n2);
+ store.put(&n3);
+ let after_three = store.len();
+ // Cota laxa: 3 archivos no triplican el almacén; comparten ~todos los
+ // nodos del cuerpo (block, integer_literal "42").
+ assert!(after_three < 3 * after_one);
+}
+
+fn find_first_kind<'a>(
+ node: &'a minga_core::SemanticNode,
+ kind: &str,
+) -> Option<&'a minga_core::SemanticNode> {
+ if node.kind == kind {
+ return Some(node);
+ }
+ for c in &node.children {
+ if let Some(f) = find_first_kind(c, kind) {
+ return Some(f);
+ }
+ }
+ None
+}
diff --git a/03_ukupacha/minga/minga-dht/Cargo.toml b/03_ukupacha/minga/minga-dht/Cargo.toml
new file mode 100644
index 0000000..bb199ac
--- /dev/null
+++ b/03_ukupacha/minga/minga-dht/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "minga-dht"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — capa de discovery typed sobre el Kademlia compartido de card-net. Claves namespaced por kind (Code/Card/Persona/Service) sin colisión en una sola malla."
+
+[dependencies]
+card-net = { workspace = true }
+libp2p = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true }
diff --git a/03_ukupacha/minga/minga-dht/LEEME.md b/03_ukupacha/minga/minga-dht/LEEME.md
new file mode 100644
index 0000000..d7892d2
--- /dev/null
+++ b/03_ukupacha/minga/minga-dht/LEEME.md
@@ -0,0 +1,9 @@
+# minga-dht
+
+> DHT (Kademlia adaptado) de [minga](../README.md).
+
+Lookup por `Address`. K-buckets, replicación configurable, refresh periódico. Sin masterless trust — los peers verifican lo que reciben contra el hash.
+
+## Deps
+
+- [`minga-core`](../minga-core/README.md), `tokio`
diff --git a/03_ukupacha/minga/minga-dht/README.md b/03_ukupacha/minga/minga-dht/README.md
new file mode 100644
index 0000000..4a11175
--- /dev/null
+++ b/03_ukupacha/minga/minga-dht/README.md
@@ -0,0 +1,9 @@
+# minga-dht
+
+> DHT (adapted Kademlia) of [minga](../README.md).
+
+Lookup by `Address`. K-buckets, configurable replication, periodic refresh. No masterless trust — peers verify what they receive against the hash.
+
+## Deps
+
+- [`minga-core`](../minga-core/README.md), `tokio`
diff --git a/03_ukupacha/minga/minga-dht/src/key.rs b/03_ukupacha/minga/minga-dht/src/key.rs
new file mode 100644
index 0000000..e7f917a
--- /dev/null
+++ b/03_ukupacha/minga/minga-dht/src/key.rs
@@ -0,0 +1,8 @@
+//! Claves namespaced del DHT — **re-export** de la primitiva unificadora.
+//!
+//! `DhtKey`/`RecordKind` se mudaron a `card-net` (Capa 1 de Brahman) porque
+//! son el namespace COMÚN que comparten minga, agora y card-discovery — no
+//! pertenecen a un dominio concreto. Este módulo las re-exporta para no
+//! romper los `use minga_dht::{DhtKey, RecordKind}` históricos.
+
+pub use card_net::key::*;
diff --git a/03_ukupacha/minga/minga-dht/src/lib.rs b/03_ukupacha/minga/minga-dht/src/lib.rs
new file mode 100644
index 0000000..f4e76c2
--- /dev/null
+++ b/03_ukupacha/minga/minga-dht/src/lib.rs
@@ -0,0 +1,72 @@
+//! `brahman-dht` — capa de discovery typed sobre el Kademlia compartido.
+//!
+//! `brahman-net` corre un único Kademlia para todo el ecosistema. Este
+//! crate le pone arriba un esquema de claves namespaced ([`DhtKey`]):
+//! `minga` publica bloques de código, `brahman-card-discovery` publica
+//! Cards, `agora_app` publica Personas — todo sobre la misma malla sin
+//! colisión, porque cada clave lleva un byte de `kind`.
+//!
+//! El modelo es de **provider records**: un nodo `announce`-a que provee
+//! una clave; otros `find`-an quién la provee y abren un stream directo.
+
+#![forbid(unsafe_code)]
+
+pub mod key;
+
+pub use key::{DhtKey, RecordKind, DHT_KEY_LEN};
+
+use card_net::BrahmanNet;
+use libp2p::PeerId;
+use std::sync::Arc;
+
+/// Discovery typed sobre `brahman-net`.
+#[derive(Clone)]
+pub struct Dht {
+ net: Arc<BrahmanNet>,
+}
+
+impl Dht {
+ /// Crea la capa DHT sobre un nodo `brahman-net` ya inicializado.
+ pub fn new(net: Arc<BrahmanNet>) -> Self {
+ Self { net }
+ }
+
+ /// Anuncia que este nodo provee `key`. El registro de provider se
+ /// renueva solo mientras el nodo siga vivo en la malla.
+ pub fn announce(&self, key: &DhtKey) {
+ self.net.start_providing(&key.to_bytes());
+ }
+
+ /// Retira el anuncio de `key`.
+ pub fn withdraw(&self, key: &DhtKey) {
+ self.net.stop_providing(&key.to_bytes());
+ }
+
+ /// Busca los peers que proveen `key`.
+ pub async fn find(&self, key: &DhtKey) -> Vec<PeerId> {
+ self.net.find_providers(&key.to_bytes()).await
+ }
+
+ /// El nodo `brahman-net` subyacente (para abrir streams a un provider).
+ pub fn net(&self) -> &Arc<BrahmanNet> {
+ &self.net
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[tokio::test]
+ async fn announce_find_withdraw_on_a_live_node() {
+ // Smoke: un nodo solo. `find` de una clave que nadie provee
+ // devuelve vacío; announce/withdraw no panickean.
+ let net = Arc::new(BrahmanNet::new().expect("nodo libp2p"));
+ let dht = Dht::new(net);
+ let key = DhtKey::card("modulo-inexistente");
+ dht.announce(&key);
+ dht.withdraw(&key);
+ let found = dht.find(&DhtKey::card("nadie-lo-provee")).await;
+ assert!(found.is_empty());
+ }
+}
diff --git a/03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml b/03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml
new file mode 100644
index 0000000..9d7f16b
--- /dev/null
+++ b/03_ukupacha/minga/minga-explorer-llimphi/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "minga-explorer-llimphi"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Dashboard Llimphi del repo Minga: counts de nodos AST, atestaciones, claves MST, refresh por polling. Reemplazo del `minga-explorer` GPUI; lee sled directo (sin passphrase) y compone header + 3 stat cards + banner de error con widgets reusables de llimphi."
+
+[dependencies]
+minga-store = { path = "../minga-store" }
+llimphi-ui = { workspace = true }
+llimphi-theme = { workspace = true }
+llimphi-widget-app-header = { workspace = true }
+llimphi-widget-banner = { workspace = true }
+llimphi-widget-stat-card = { workspace = true }
+llimphi-widget-menubar = { workspace = true }
+llimphi-widget-context-menu = { workspace = true }
+llimphi-motion = { workspace = true }
+app-bus = { workspace = true }
+rimay-localize = { workspace = true }
+wawa-config = { workspace = true }
+
+[[bin]]
+name = "minga-explorer-llimphi"
+path = "src/main.rs"
diff --git a/03_ukupacha/minga/minga-explorer-llimphi/LEEME.md b/03_ukupacha/minga/minga-explorer-llimphi/LEEME.md
new file mode 100644
index 0000000..7fe9e6f
--- /dev/null
+++ b/03_ukupacha/minga/minga-explorer-llimphi/LEEME.md
@@ -0,0 +1,16 @@
+# minga-explorer-llimphi
+
+> UI: peers, content, tráfico de [minga](../README.md).
+
+Tres pestañas: peers (estado de cada conexión), content (lo que tenés localmente), traffic (bandwidth/req-rate por peer). Útil para diagnosticar la red.
+
+## Uso
+
+```sh
+cargo run --release -p minga-explorer-llimphi
+```
+
+## Deps
+
+- Todos los `minga-*`
+- [`llimphi-ui`](../../../02_ruway/llimphi/)
diff --git a/03_ukupacha/minga/minga-explorer-llimphi/README.md b/03_ukupacha/minga/minga-explorer-llimphi/README.md
new file mode 100644
index 0000000..6df4d9c
--- /dev/null
+++ b/03_ukupacha/minga/minga-explorer-llimphi/README.md
@@ -0,0 +1,16 @@
+# minga-explorer-llimphi
+
+> UI: peers, content, traffic of [minga](../README.md).
+
+Three tabs: peers (per-connection state), content (what you have locally), traffic (bandwidth/req-rate per peer). Useful for diagnosing the network.
+
+## Usage
+
+```sh
+cargo run --release -p minga-explorer-llimphi
+```
+
+## Deps
+
+- All `minga-*`
+- [`llimphi-ui`](../../../02_ruway/llimphi/)
diff --git a/03_ukupacha/minga/minga-explorer-llimphi/src/main.rs b/03_ukupacha/minga/minga-explorer-llimphi/src/main.rs
new file mode 100644
index 0000000..0fb056a
--- /dev/null
+++ b/03_ukupacha/minga/minga-explorer-llimphi/src/main.rs
@@ -0,0 +1,653 @@
+//! `minga-explorer-llimphi` — dashboard Llimphi del repo Minga (VCS
+//! semántico P2P).
+//!
+//! Polling cada 2s contra `MINGA_REPO` (env, default `./.minga`), abre
+//! el `PersistentRepo` (sled, sin passphrase porque los counts son
+//! lectura pública) y muestra:
+//! - Cantidad de nodos AST almacenados.
+//! - Cantidad de atestaciones firmadas.
+//! - Cantidad de claves del MST (Merkle Search Tree).
+//!
+//! No requiere keypair descifrado — eso queda para el CLI
+//! (`minga status`) cuando hace falta el DID. El explorer foco es
+//! observabilidad rápida.
+//!
+//! Stack visual: llimphi-theme + llimphi-widget-app-header +
+//! llimphi-widget-banner + llimphi-widget-stat-card. Mismo patrón que
+//! `nakui-explorer-llimphi`.
+//!
+//! Uso:
+//! ```sh
+//! cargo run -p minga-explorer-llimphi
+//! # con repo custom:
+//! MINGA_REPO=/path/to/.minga cargo run -p minga-explorer-llimphi
+//! ```
+
+#![forbid(unsafe_code)]
+
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Duration;
+
+use app_bus::{AppMenu, Menu, MenuItem};
+use llimphi_theme::Theme;
+use llimphi_ui::llimphi_layout::taffy::{
+ prelude::{length, percent, AlignItems, Dimension, FlexDirection, Size, Style},
+ Rect,
+};
+use llimphi_ui::llimphi_raster::peniko::Color;
+use llimphi_ui::llimphi_text::Alignment;
+use llimphi_ui::{App, Handle, Key, KeyEvent, KeyState, NamedKey, View};
+use llimphi_motion::{animate, motion, Tween};
+use llimphi_widget_app_header::{app_header, AppHeaderPalette};
+use llimphi_widget_banner::{banner_view, BannerKind};
+use llimphi_widget_context_menu::{
+ context_menu_view, ContextMenuItem, ContextMenuPalette, ContextMenuSpec,
+};
+use llimphi_widget_menubar::{
+ menubar_command_at, menubar_nav, menubar_overlay_animated, menubar_view, MenuBarSpec,
+ DEFAULT_HEIGHT as MENU_H,
+};
+use llimphi_widget_stat_card::{stat_card_view, StatCardPalette};
+use minga_store::PersistentRepo;
+
+const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
+const REPO_DIRNAME: &str = "repo";
+
+/// Cuántos items recientes mostrar por sección. Los stores no tienen
+/// orden cronológico (sled ordena lexicográfico por hash); los
+/// "recent" acá son simplemente los primeros del iter — sirve como
+/// sample, no como log temporal.
+const RECENT_LIMIT: usize = 5;
+
+#[derive(Clone, Default, Debug)]
+struct RepoSnapshot {
+ nodes: usize,
+ attestations: usize,
+ mst_keys: usize,
+ recent_nodes: Vec<(String, String)>,
+ recent_attestations: Vec<(String, String)>,
+ recent_mst_keys: Vec<String>,
+}
+
+struct Model {
+ theme: Theme,
+ repo_path: PathBuf,
+ snapshot: Option<RepoSnapshot>,
+ error: Option<String>,
+ last_load_ms: u64,
+ /// Mantenemos vivo el watcher para que su thread no muera. No se
+ /// usa después de crearlo (consume su sí mismo cuando se dropea).
+ _wawa_watcher: Option<wawa_config::ConfigWatcher>,
+ /// Barra de menú principal: índice del menú raíz abierto (`None`
+ /// cerrado).
+ menu_open: Option<usize>,
+ /// Fila activa dentro del dropdown abierto (`usize::MAX` = ninguna).
+ menu_active: usize,
+ /// Animación de aparición del dropdown.
+ menu_anim: Tween<f32>,
+ /// Menú contextual sobre el dashboard: `(x, y)` ancla en ventana.
+ /// `None` cerrado. El explorer es de sólo lectura — el contextual
+ /// sólo ofrece acciones de observación (refrescar / tema).
+ context_menu: Option<(f32, f32)>,
+}
+
+#[derive(Clone)]
+enum Msg {
+ /// Tick del scheduler: corre `load_snapshot` y dispatcha el
+ /// resultado como `Refresh`.
+ Tick,
+ /// Resultado de un refresh: snapshot exitoso o mensaje de error,
+ /// junto al tiempo que tardó el load.
+ Refresh {
+ result: Result<RepoSnapshot, String>,
+ elapsed_ms: u64,
+ },
+ /// El bus de wawa-config cambió: re-aplicar theme/accent/idioma.
+ WawaChanged(wawa_config::WawaConfig),
+ /// Barra de menú principal: abrir/cerrar un menú raíz (`None` cerrar).
+ MenuOpen(Option<usize>),
+ /// Comando elegido en el menú principal — se traduce al `Msg` real.
+ MenuCommand(String),
+ /// Navega la fila activa del dropdown (+1/-1).
+ MenuNav(i32),
+ /// Ejecuta el comando de la fila activa (Enter).
+ MenuActivate,
+ /// No-op: sólo fuerza re-render durante la animación del dropdown.
+ MenuTick,
+ /// Cierra cualquier menú abierto (click-fuera / Esc).
+ CloseMenus,
+ /// Right-click en la raíz → abre el menú contextual anclado en
+ /// `(x, y)` de ventana.
+ ContextMenuOpen(f32, f32),
+ /// Cicla el tema claro/oscuro localmente (override del de wawa hasta
+ /// el próximo cambio del bus).
+ CycleTheme,
+}
+
+struct Explorer;
+
+impl App for Explorer {
+ type Model = Model;
+ type Msg = Msg;
+
+ fn title() -> &'static str {
+ "Minga — Repo"
+ }
+
+ fn initial_size() -> (u32, u32) {
+ (800, 560)
+ }
+
+ fn init(handle: &Handle<Msg>) -> Model {
+ let repo_path = std::env::var("MINGA_REPO")
+ .ok()
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(".minga"));
+
+ // Primer refresh inmediato + ticks periódicos. El `Tick` dispara
+ // el load en un thread aparte (vía `Handle::spawn` desde update);
+ // así el sled no bloquea el hilo de UI.
+ handle.dispatch(Msg::Tick);
+ handle.spawn_periodic(REFRESH_INTERVAL, || Msg::Tick);
+
+ // Cargar config wawa una vez y aplicarla; suscribirse a cambios.
+ let initial_cfg = wawa_config::WawaConfig::load();
+ let theme = theme_from_wawa(&initial_cfg);
+ apply_lang_from_wawa(&initial_cfg);
+
+ let handle_clone = handle.clone();
+ let watcher = wawa_config::ConfigWatcher::spawn(move |cfg| {
+ handle_clone.dispatch(Msg::WawaChanged(cfg));
+ })
+ .ok();
+
+ Model {
+ theme,
+ repo_path,
+ snapshot: None,
+ error: None,
+ last_load_ms: 0,
+ _wawa_watcher: watcher,
+ menu_open: None,
+ menu_active: usize::MAX,
+ men
Parcheqa qatikun, ichaqa kaypi kuchusqa: gitwan committa mañakuy llapanta rikunaykipaq.