# mojibake — Traceability Matrix Software Level: **DAL-C** (DO-178C) Bidirectional trace: **HLR → LLR → source → test**. Source items carry a `// MJB-LLR-nnn` comment; tests are named `mjb_llr_nnn_` so this matrix can be checked mechanically: ```bash # every LLR tagged in source grep -rho 'MJB-LLR-[0-9]\+' src/ | sort -u # every LLR exercised by a test grep -rho 'mjb_llr_[0-9]\+' src/ tests/ | sort -u ``` Paths are relative to the repository root. --- ## HLR → LLR | HLR | Subject | LLRs | |---|---|---| | MJB-HLR-001 | File load from command line | 111, 112 | | MJB-HLR-002 | Encoding and BOM | 110, 111, 113, 115, 118 | | MJB-HLR-003 | Line ending preservation | 114, 115 | | MJB-HLR-004 | Rope text storage | 090–092, 117 | | MJB-HLR-005 | Selection model | 001–011, 020–025 | | MJB-HLR-006 | Character and line motion | 062, 063, 064 | | MJB-HLR-007 | Word motions select | 060, 061, 065–069 | | MJB-HLR-008 | Goto commands | 070–073, 150–154 | | MJB-HLR-009 | Insert-mode entry | 049, 117 | | MJB-HLR-010 | Text modification | 040–050, 117 | | MJB-HLR-011 | Undo and redo | 045, 046, 051–053 | | MJB-HLR-012 | Viewport pagination | 090–092, 200 | | MJB-HLR-013 | Scrolling and paging | 093–102 | | MJB-HLR-014 | TOML configuration | 180, 181 | | MJB-HLR-015 | Config-driven modal keymap | 150–157, 182, 184 | | MJB-HLR-016 | Command mode | 158, 159, 160 | | MJB-HLR-017 | File write | 130–137 | | MJB-HLR-018 | Error handling | 042, 044, 118, 183 | | MJB-HLR-019 | Single-widget presentation | 200–205 | --- ## LLR → source → test ### Selection (`src/buffer/selection.rs`) | LLR | Source item | Test | |---|---|---| | 001 | `Range` | `mjb_llr_001_offsets_are_byte_indices` | | 002 | `Range::from`, `Range::to` | `mjb_llr_002_from_and_to_ignore_direction` | | 003 | `Range::is_empty` | `mjb_llr_003_is_empty` | | 004 | `Range::direction` | `mjb_llr_004_direction` | | 005 | `Range::cursor` | `mjb_llr_005_cursor_steps_back_on_forward_range`, `mjb_llr_005_cursor_respects_grapheme_clusters` | | 006 | `Range::put_cursor` | `mjb_llr_006_put_cursor_without_extend_collapses` | | 007 | `Range::put_cursor` | `mjb_llr_007_put_cursor_with_extend_keeps_anchor`, `mjb_llr_007_put_cursor_extend_flips_direction`, `mjb_llr_007_select_mode_motions_extend` | | 008 | `Range::line_range` | `mjb_llr_008_line_range` | | 009 | `Selection` | `mjb_llr_009_selection_invariant` | | 010 | `Selection::primary` | `mjb_llr_010_primary_round_trips` | | 011 | `Range::clamped` | `mjb_llr_011_clamped_snaps_into_bounds_and_onto_char_boundary`, `mjb_llr_011_motions_at_boundaries_never_leave_the_buffer` | ### Graphemes (`src/buffer/grapheme.rs`) | LLR | Source item | Test | |---|---|---| | 020 | `prev_grapheme_boundary` | `mjb_llr_020_prev_boundary_saturates_at_zero`, `mjb_llr_020_combining_mark_is_one_cluster` | | 021 | `next_grapheme_boundary` | `mjb_llr_021_next_boundary_saturates_at_end`, `mjb_llr_021_multibyte_advances_whole_char` | | 022 | chunk-walking loops in both | `mjb_llr_022_boundaries_resolve_across_chunk_edges` | | 023 | `is_grapheme_boundary` | `mjb_llr_023_boundary_detection` | | 024 | `grapheme_width` | `mjb_llr_024_widths` | | 025 | `display_column` | `mjb_llr_025_display_column_counts_width_not_bytes`, `mjb_llr_025_display_column_tab_expands` | ### Transactions and history (`src/buffer/transaction.rs`, `history.rs`) | LLR | Source item | Test | |---|---|---| | 040 | `Operation` | `mjb_llr_040_operation_counts_are_byte_lengths` | | 041 | `ChangeSet` | `mjb_llr_041_changeset_records_both_lengths`, `mjb_llr_041_empty_changeset_reports_equal_lengths` | | 042 | `ChangeSet::apply` length check | `mjb_llr_042_length_mismatch_is_rejected`, `mjb_llr_042_rejected_change_leaves_rope_untouched` | | 043 | `ChangeSet::apply` | `mjb_llr_043_apply_insert_and_delete` | | 044 | `ChangeSet::apply` boundary check | `mjb_llr_044_non_char_boundary_errors_rather_than_panics` | | 045 | `ChangeSet::invert` | `mjb_llr_045_invert_maps_each_operation` | | 046 | `apply` + `invert` | `mjb_llr_046_apply_then_invert_round_trips` | | 047 | `Transaction` | `mjb_llr_047_transaction_carries_a_selection` | | 048 | `Transaction::change` | `mjb_llr_048_multiple_ordered_changes` | | 049 | `Transaction::insert` | `mjb_llr_049_insert_at_cursor` | | 050 | `Transaction::delete` | `mjb_llr_050_delete_selection_span`, `mjb_llr_050_d_with_an_empty_selection_deletes_one_grapheme`, `mjb_llr_050_d_on_an_empty_buffer_is_a_noop` | | 051 | `History::commit` | `mjb_llr_051_commit_then_undo_then_redo`, `mjb_llr_051_commit_after_undo_discards_the_redo_branch`, `mjb_llr_051_undo_restores_typed_text` | | 052 | `History::undo`, `Document::undo` | `mjb_llr_052_undo_past_start_is_a_noop`, `mjb_llr_052_undo_past_history_start_is_a_noop`, `mjb_llr_052_undo_past_history_start_is_safe`, `mjb_llr_052_u_undoes_a_deletion` | | 053 | `History::redo`, `Document::redo` | `mjb_llr_053_redo_past_end_is_a_noop`, `mjb_llr_053_redo_past_end_is_safe`, `mjb_llr_053_capital_u_redoes` | ### Movement (`src/buffer/movement.rs`) | LLR | Source item | Test | |---|---|---| | 060 | `CharCategory`, `categorize_char` | `mjb_llr_060_categories` | | 061 | `is_word_boundary` | `mjb_llr_061_word_boundary_is_category_change` | | 062 | `move_char_left` | `mjb_llr_062_move_char_left_stops_at_zero`, `mjb_llr_062_h_and_l_move_by_one_grapheme`, `mjb_llr_062_h_at_start_of_buffer_is_a_noop` | | 063 | `move_char_right` | `mjb_llr_063_move_char_right_stops_at_end`, `mjb_llr_063_move_char_right_skips_whole_multibyte_char`, `mjb_llr_063_l_at_end_of_buffer_is_a_noop` | | 064 | `move_vertically` | `mjb_llr_064_vertical_motion_preserves_column`, `mjb_llr_064_vertical_motion_clamps_to_short_line`, `mjb_llr_064_vertical_motion_is_noop_at_edges`, `mjb_llr_064_j_and_k_move_between_lines` | | 065 | `next_word_start` | `mjb_llr_065_next_word_start_produces_a_selection`, `mjb_llr_065_w_leaves_a_selection`, `mjb_llr_065_w_then_d_deletes_the_word`, `mjb_llr_065_counted_next_word_start_advances_once_per_count` | | 066 | `prev_word_start` | `mjb_llr_066_prev_word_start_spans_backward`, `mjb_llr_066_b_selects_backward`, `mjb_llr_066_counted_prev_word_start_advances_once_per_count` | | 067 | `next_word_end` | `mjb_llr_067_next_word_end_spans_the_word`, `mjb_llr_067_e_selects_to_the_word_end`, `mjb_llr_067_counted_next_word_end_advances_once_per_count` | | 068 | `is_separator` and the skip loops in the three word functions | `mjb_llr_068_word_motion_stops_at_punctuation`, `mjb_llr_068_long_word_motion_absorbs_punctuation`, `mjb_llr_068_word_motion_crosses_line_endings`, `mjb_llr_068_capital_w_treats_punctuation_as_word_characters` | | 069 | `word_move` boundary guards | `mjb_llr_069_word_motion_is_noop_at_boundaries`, `mjb_llr_069_w_at_end_of_buffer_is_a_noop`, `mjb_llr_069_counted_motion_saturates_at_the_buffer_end` | | 070 | `goto_file_start` | `mjb_llr_070_goto_file_start`, `mjb_llr_070_gg_goes_to_file_start` | | 071 | `goto_last_line` | `mjb_llr_071_goto_last_line`, `mjb_llr_071_goto_last_line_without_trailing_newline`, `mjb_llr_071_ge_goes_to_the_last_line` | | 072 | `goto_line_start` | `mjb_llr_072_goto_line_start`, `mjb_llr_072_gh_goes_to_the_line_start`, `mjb_llr_072_gs_goes_to_first_non_whitespace` | | 073 | `goto_line_end`, `line_end_byte` | `mjb_llr_073_goto_line_end_excludes_terminator`, `mjb_llr_073_goto_line_end_handles_crlf`, `mjb_llr_073_gl_goes_to_the_line_end` | ### Viewport (`src/buffer/view.rs`) | LLR | Source item | Test | |---|---|---| | 090 | `ViewPosition` | `mjb_llr_090_anchor_is_a_byte_offset_at_a_line_start` | | 091 | `View::top_line` | `mjb_llr_091_top_line_from_anchor` | | 092 | `View::visible_lines`, `visible_line_range` | `mjb_llr_092_visible_lines_are_bounded_by_height`, `mjb_llr_092_visible_lines_clamp_near_end_of_buffer`, `mjb_llr_092_empty_buffer_renders_safely`, `mjb_llr_092_visible_line_count_is_independent_of_file_size`, `mjb_llr_092_scrolling_a_large_file_stays_responsive` | | 093 | scroll-off clamping | `mjb_llr_093_scrolloff_larger_than_viewport_is_clamped` | | 094 | scroll-up branch | `mjb_llr_094_scrolls_up_to_honour_top_margin`, `mjb_llr_094_scroll_near_start_saturates_at_zero` | | 095 | scroll-down branch | `mjb_llr_095_scrolls_down_to_honour_bottom_margin` | | 096 | no-scroll branch | `mjb_llr_096_no_scroll_when_cursor_is_comfortable` | | 097 | `View::set_top_line` | `mjb_llr_097_top_line_clamps_into_buffer` | | 098 | zero-height guard | `mjb_llr_098_zero_height_viewport_is_a_noop`, `mjb_llr_098_zero_height_viewport_does_not_panic` | | 099 | `ensure_horizontal_in_view` | `mjb_llr_099_horizontal_scroll_follows_cursor` | | 100 | `View::page` (half) | `mjb_llr_100_half_page_moves_cursor_and_view`, `mjb_llr_100_ctrl_d_pages_half_a_screen_down`, `mjb_llr_100_ctrl_u_pages_back_up` | | 101 | `View::page` (full) | `mjb_llr_101_full_page_moves_by_height`, `mjb_llr_101_ctrl_f_pages_a_full_screen_down` | | 102 | paging saturation | `mjb_llr_102_paging_saturates_at_both_ends` | ### Document, encoding, line endings | LLR | Source item | Test | |---|---|---| | 110 | `encoding::detect_bom` | `mjb_llr_110_detects_each_bom` | | 111 | `Document::open`, `Buffer::new` | `mjb_llr_111_loads_contents`, `mjb_llr_111_plain_utf8_round_trips`, `mjb_llr_111_no_path_yields_a_scratch_buffer` | | 112 | `Document::open` not-found arm | `mjb_llr_112_missing_file_yields_empty_buffer_that_remembers_the_path`, `mjb_llr_112_empty_file_is_editable`, `mjb_llr_112_file_without_trailing_newline_round_trips`, `mjb_llr_112_writing_a_new_file_creates_it`, `mjb_llr_112_missing_file_opens_as_an_empty_buffer` | | 113 | `encoding::decode` non-UTF-8 branch | `mjb_llr_113_declared_utf16_is_transcoded_not_rejected`, `mjb_llr_113_empty_input_decodes_to_empty`, `mjb_llr_113_declared_utf16_file_opens`, `mjb_llr_113_declared_utf16_file_opens_and_edits` | | 118 | `DecodeError`, `encoding::decode` UTF-8 branch, `Document::open` | `mjb_llr_118_invalid_utf8_is_rejected`, `mjb_llr_118_rejection_names_the_offset`, `mjb_llr_118_truncated_multibyte_char_is_rejected`, `mjb_llr_118_declared_utf8_is_strict_too`, `mjb_llr_118_valid_multibyte_utf8_is_accepted`, `mjb_llr_118_invalid_utf8_file_is_refused`, `mjb_llr_118_binary_file_is_refused_not_opened` | | 114 | `LineEnding::detect` | `mjb_llr_114_detects_lf`, `_detects_crlf`, `_detects_lone_cr`, `_falls_back_to_platform_default`, `_first_terminator_decides`, `_crlf_is_detected_and_normalized_for_storage` | | 115 | `encoding::encode`, `Document::encode` | `mjb_llr_115_bom_round_trips`, `mjb_llr_115_utf16le_round_trips`, `mjb_llr_115_utf16be_round_trips`, `mjb_llr_115_utf16_handles_non_ascii_and_surrogates`, `mjb_llr_115_apply_restores_original_ending`, `mjb_llr_115_crlf_round_trips_through_save` | | 116 | `Document::is_modified`, `History::revision` | `mjb_llr_116_modified_flag_lifecycle`, `mjb_llr_116_revision_is_not_stack_depth`, `mjb_llr_116_revision_is_zero_when_pristine_and_returns_on_undo`, `mjb_llr_116_undo_back_to_saved_state_is_clean`, `mjb_llr_116_divergent_edit_at_the_same_depth_stays_modified` | | 117 | `Document::apply` | `mjb_llr_117_apply_updates_text_and_records_history` | ### Save path (`src/buffer/save.rs`) | LLR | Source item | Test | |---|---|---| | 130 | `resolve_write_path` | `mjb_llr_130_write_follows_symlink_without_replacing_it`, `mjb_llr_130_relative_symlink_resolves_against_its_own_directory`, `mjb_llr_130_write_through_a_symlink_end_to_end` | | 131 | `readonly` | `mjb_llr_131_readonly_target_is_refused`, `mjb_llr_131_missing_file_is_not_readonly`, `mjb_llr_131_readonly_file_reports_and_preserves_contents` | | 132 | missing-parent branch | `mjb_llr_132_missing_parent_is_refused_without_force`, `mjb_llr_132_force_creates_the_parent` | | 133 | `must_copy` | `mjb_llr_133_hardlink_is_detected_and_preserved`, `mjb_llr_133_plain_file_does_not_need_copy_mode` | | 134 | `backup_path`, backup creation | `mjb_llr_134_backup_is_created_beside_the_target`, `mjb_llr_134_backup_path_handles_a_bare_file_name` | | 135 | restore-on-failure | `mjb_llr_135_failed_write_restores_the_original` | | 136 | `copy_permissions`, backup removal | `mjb_llr_136_no_backup_file_is_left_behind` | | 137 | `Document::save` | `mjb_llr_116_modified_flag_lifecycle` | ### Keymap and commands (`src/buffer/keymap.rs`, `mod.rs`) | LLR | Source item | Test | |---|---|---| | 150 | `Keymap::new` prefix set | `mjb_llr_150_proper_prefixes_are_precomputed`, `mjb_llr_150_prefix_set_is_empty_for_a_mode_without_sequences` | | 151 | `Keymap::resolve` match arm | `mjb_llr_151_single_key_binding_matches_immediately`, `mjb_llr_151_two_key_sequence_resolves`, `mjb_llr_151_sequences_sharing_a_prefix_stay_distinct` | | 152 | `Keymap::resolve` prefix arm | `mjb_llr_152_prefix_key_waits_for_more` | | 153 | `Keymap::resolve` cancel arm | `mjb_llr_153_unknown_continuation_cancels`, `mjb_llr_153_unbound_key_cancels_immediately`, `mjb_llr_153_unknown_g_sequence_does_not_corrupt_state` | | 154 | absence of any timer | `mjb_llr_154_pending_is_not_discarded_by_time`, `mjb_llr_154_gg_resolves_regardless_of_intervening_time` | | 155 | count accumulation | `mjb_llr_155_count_accumulates_and_is_delivered`, `_count_is_consumed_once`, `_leading_zero_is_not_a_count`, `_zero_extends_an_existing_count`, `_digits_are_not_counts_in_insert_mode`, `_count_repeats_a_motion` | | 156 | `self_insert_char` | `mjb_llr_156_self_insert_accepts_plain_printables`, `_rejects_control_and_alt`, `_rejects_non_char_and_sequences`, `_i_then_typing_inserts_text`, `_command_keys_type_literally_in_insert_mode`, `_unbound_control_key_is_discarded_in_insert_mode` | | 157 | `Command` enum | `mjb_llr_157_deserializes_from_the_variant_name`, `mjb_llr_157_unknown_command_name_is_an_error_not_a_panic`, `mjb_llr_157_display_round_trips_through_deserialization` | | 158 | `Buffer::handle_command_mode_key` | `mjb_llr_158_escape_cancels_the_command_line`, `_command_line_accepts_backspace`, `_backspacing_an_empty_command_line_leaves_command_mode` | | 159 | `Buffer::run_command_line` | `mjb_llr_159_write_saves_to_disk`, `_quit_returns_the_quit_outcome`, `_wq_writes_then_quits`, `_x_is_an_alias_for_wq`, `_unknown_command_is_reported_not_fatal`, `_force_write_creates_missing_directories`, `_empty_command_line_does_nothing` | | 160 | unsaved-changes guard | `mjb_llr_160_quit_with_unsaved_changes_is_refused`, `mjb_llr_160_force_quit_discards_changes` | ### Configuration (`src/config.rs`) | LLR | Source item | Test | |---|---|---| | 180 | `CONFIG`, `Config::new` | `mjb_llr_180_builtin_defaults_parse` | | 181 | single TOML source | `mjb_llr_181_only_config_toml_is_read` + `cargo tree` check (see below) | | 182 | default-merge loop | `mjb_llr_182_user_bindings_merge_per_binding` | | 183 | `KeyBindings::deserialize`, `parse_color` | `mjb_llr_183_invalid_keybinding_is_recoverable`, `mjb_llr_183_malformed_colours_never_panic`, `mjb_llr_183_colour_cube_bounds`, `mjb_llr_183_grayscale_ramp_bounds`, `mjb_llr_183_bright_colour_is_base_plus_eight` | | 184 | `Mode` | `mjb_llr_184_modes_deserialize_lowercase`, `mjb_llr_184_v_toggles_select_mode` | | 185 | `EditorConfig` | `mjb_llr_185_editor_defaults`, `mjb_llr_185_final_newline_only_added_when_missing`, `mjb_llr_185_final_newline_added_on_encode_when_requested`, `mjb_llr_185_empty_document_encodes_empty` | ### Presentation (`src/components/buffer.rs`, `src/app.rs`) Rendering is tested against a real cell grid via ratatui's `TestBackend` (`tests/rendering.rs`), not asserted by inspection. | LLR | Source item | Test | |---|---|---| | 200 | `BufferComponent::draw` render loop | `mjb_llr_200_renders_the_file_contents`, `mjb_llr_200_renders_only_the_visible_window`, `mjb_llr_200_line_numbers_are_shown_in_the_gutter`, `mjb_llr_200_empty_file_renders_without_panicking`, `mjb_llr_200_wide_characters_render` | | 201 | cursor/selection styling in `draw` | `mjb_llr_201_cursor_is_styled_distinctly` | | 202 | `status_line`, `message_line` | `mjb_llr_202_status_line_shows_mode_and_path`, `mjb_llr_202_status_line_marks_an_unmodified_file`, `mjb_llr_202_scratch_buffer_is_labelled` | | 203 | `App::handle_global_key`, `Keymap::lookup_single` | `mjb_llr_203_lookup_single_does_not_disturb_pending`, `mjb_llr_203_globally_bound_key_is_not_typed_as_text` | | 204 | `App::new` component vec | `mjb_llr_204_no_fps_counter_or_hello_world_is_rendered` | | 205 | absence of `last_tick_key_events` | `mjb_llr_205_pending_chord_survives_redraws` | `mjb_llr_200_renders_only_the_visible_window` is the direct check on MJB-HLR-012: a 1000-line file in an 8-row terminal must not put line 500 on screen. --- ## Completeness check The matrix above is verified mechanically, not by inspection: ```bash grep -oE 'MJB-LLR-[0-9]+' docs/requirements/llr.md | sort -u > defined grep -rhoE 'MJB-LLR-[0-9]+' src/ | sort -u > tagged grep -rhoE 'mjb_llr_[0-9]+' src/ tests/ | sed 's/mjb_llr_/MJB-LLR-/' | sort -u > tested comm -23 defined tagged # LLRs with no source tag comm -23 defined tested # LLRs with no test comm -13 defined tested # tests naming an LLR that does not exist ``` Result at time of writing — all three sets empty: ``` defined: 98 tagged: 98 tested: 98 ``` Every low-level requirement is tagged in source and exercised by at least one test named for it. Test totals: **282 passing** (179 unit, 89 editing integration, 14 rendering integration). --- ## Coverage Statement (line) coverage of the buffer core, the DAL-C structural criterion: ``` src/buffer/** statement coverage: 2665/2770 = 96.21% ``` Measured with `cargo llvm-cov --summary-only test`. Per-module figures: | Module | Lines | Covered | |---|---|---| | `buffer/view.rs` | 233 | 99.57% | | `buffer/transaction.rs` | 265 | 98.87% | | `buffer/encoding.rs` | 140 | 98.57% | | `buffer/movement.rs` | 346 | 97.98% | | `buffer/selection.rs` | 182 | 97.80% | | `buffer/keymap.rs` | 223 | 97.76% | | `buffer/history.rs` | 90 | 96.67% | | `buffer/command.rs` | 28 | 96.43% | | `buffer/document.rs` | 268 | 94.40% | | `buffer/mod.rs` | 408 | 93.87% | | `buffer/save.rs` | 218 | 93.12% | | `buffer/line_ending.rs` | 87 | 89.66% | | `buffer/grapheme.rs` | 195 | 89.23% | **MC/DC and decision coverage are not DAL-C objectives and are not claimed.** Only statement coverage is reported. ### Uncovered code, and why - `buffer/grapheme.rs` (89.23%) — the lowest figure. The uncovered lines are defensive `Err(_) => return` arms for `GraphemeIncomplete` variants that cannot arise from a cursor constructed over the whole slice. They exist so a malformed state degrades instead of panicking (MJB-HLR-018), and are unreachable by construction, so no test can drive them. - `buffer/save.rs` (93.12%) — the restore-on-failure path is reachable only when the process cannot write to a directory it owns. Running the suite as root defeats the permission bits, so `mjb_llr_135_failed_write_restores_the_original` skips its assertion in that case rather than reporting a false pass. The path is covered when the suite runs as an ordinary user. - `buffer/line_ending.rs` (89.66%) — `LineEnding::platform_default` has one arm per platform; only the host's arm executes. - Remaining gaps are `Display`/`From` glue generated by `thiserror` on error variants that no test provokes. ### Verification not expressible as a unit test `cargo tree` confirms MJB-LLR-181 and MJB-HLR-014 — that no parser for a non-TOML configuration format remains in the dependency graph: ```bash cargo tree | grep -iE "json|yaml|ini\b" # returns nothing ``` Confirmed: `config v0.15.25` resolves with only `pathdiff`, `serde_core`, `toml`, and `winnow`.