rutie 0.12.1

The tie between Ruby and Rust.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
# Plan: complete Ruby 2 support in Rutie

**Status:** proposed, 2026-09-30. Written for the agent/maintainer who picks this up.
**Scope:** make everything Ruby 2 exposes through its C API that a Rust
integration reasonably needs available through Rutie's FFI layer, on Ruby
**2.5, 2.6 and 2.7**, dynamically linked, on Linux and macOS. Ruby 3 is
explicitly out of scope until this plan is complete; the design must not
make Ruby 3 harder later, but nothing here is allowed to block on it.

Rutie 0.10.0 (the revert of the `rb-sys` integration, PR #172) is the baseline.

---

## 0. Ground rules (learned the hard way; keep them)

1. **Hand-maintained FFI, no `rb-sys`.** `src/rubysys/` is the one place
   `extern "C"` declarations live. Every declaration carries the C prototype
   as a comment above it, exactly as the existing files do. `rubysys` is a
   private API (see CHANGELOG header) and may change in teeny releases.
2. **Layering is fixed:** `rubysys` (raw extern) → `binding` (thin unsafe
   wrappers taking/returning `Value`) → `class` (safe, typed public API) →
   `dsl` (macros). New surface must go through all three layers; nothing
   public calls `rubysys` directly.
3. **Every public item ships with a doctest** that runs (not `ignore`,
   `no_run` only when the example must not execute), and the doctest starts
   with the hidden `# VM::init();` line the existing ones use. Unit tests run
   their body through `crate::on_ruby_thread(|| { ... })` (see `src/lib.rs`):
   Ruby 2 is bound to the native thread that called `ruby_init` (the GC scans
   that thread's stack), while the test harness gives every test its own
   thread, so all unit tests share one long-lived Ruby thread, serialized by
   `LOCK_FOR_TEST`. Never call `VM::init` from a unit test directly.
4. **Verify on all three Rubies before pushing.** Locally: one Ruby per
   prefix (e.g. `/opt/rb/2.5.9`, `/opt/rb/2.6.10`, `/opt/rb/2.7.8`), put its
   `bin` first on `PATH`, `cargo clean`, `cargo test`. `build.rs` links
   against whatever `ruby` is on `PATH` (or `$RUBY`); a stale `target/` from
   another Ruby segfaults at test time, so always `cargo clean` when switching.
   Use release tarballs (or RVM) to build Rubies: git-tag checkouts of 2.5/2.6
   need patching of bison-generated `parse.c` under modern bison.
   Where `cache.ruby-lang.org` is unreachable (sandboxed agents): RVM's
   prebuilt, relocatable binaries at `https://rvm.io/binaries/` cover 2.5.9
   (`ubuntu/20.04/x86_64`) and 2.6.10 (`debian/11/x86_64`); 2.7.8 builds from
   the `v2_7_8` git tag after copying `config.guess`/`config.sub` into `tool/`,
   with `--enable-shared --with-baseruby=<any ruby> --without-openssl`
   (`make install` then stops at the default gems, after libruby, headers and
   stdlib are installed; that is enough). Linking needs `libgmp-dev`.
   Instead of `cargo clean`, a separate `CARGO_TARGET_DIR` per Ruby works too,
   as long as its path ends in `/target` (`build.rs` splits `OUT_DIR` on it),
   e.g. `CARGO_TARGET_DIR=$HOME/rt-2.7.8/target`.
5. **CI is the arbiter** (`.github/workflows/ci.yml`): 3 OS × {stable, beta}
   × {2.5.9, 2.6.10, 2.7.8} × {dynamic, static}. Dynamic Linux and macOS must
   be green. Static and Windows rows are best-effort (`continue-on-error`).
   macOS builds Ruby against a source-built OpenSSL 1.1.1 with
   `PKG_CONFIG_PATH` pointed at it; do not remove that or Ruby 2.5/2.6 stop
   building (their `ext/openssl` lets pkg-config's `openssl@3` outrank
   `--with-openssl-dir`).
6. **Version-specific ABI is real.** Struct layouts and flag bits differ
   between 2.5, 2.6 and 2.7 (see §3). Anything that reads a Ruby struct field
   directly must be gated per version or go through a C function instead.
   Prefer the function.
7. **CHANGELOG.md is updated in the same commit as the change**, Keep a
   Changelog format, with `thanks to @user` credit. Bump `Cargo.toml` per
   SemVer for the public API.
8. **Add safe methods beside existing ones; don't change existing ones.**
   Following the README's "Safety" section, the fast, raising methods (`send`,
   `unsafe_methods!`, …) keep their behaviour and cost. When a safe variant is
   needed, write a new method (usually `protect`-based and returning
   `Result<_, AnyException>`, like `protect_send` and `Enumerator`) instead
   of adding checks to the existing one. Fixing genuine memory-safety bugs in
   an existing method is the exception, and only when the method breaks no
   matter how carefully it is called (e.g. `GC::register` registering a dead
   stack address, `RString::encode` with options aborting Ruby). If a caller
   can protect themselves (checking bounds or arity, escaping `%`, passing
   only valid input), the existing method keeps its behaviour and cost, and
   the checked behaviour goes in a second, safer method (`VM::raise` /
   `VM::raise_message`, `unsafe_methods!` / `methods!`).
9. **SemVer as the README defines it.** Rutie won't reach 1.0, so MINOR
   versions may break the public API (`src/class/*`, `src/helpers/*`), and
   PATCH versions may only break the private API (`src/rubysys/*`,
   `src/binding/*`, `src/util.rs`). A change that breaks public callers
   (a changed signature or behaviour, a removed item) therefore waits for
   the next MINOR release; adding items and fixing bugs that break however
   a method is called (rule 8) are fine in a PATCH. Say in the CHANGELOG
   which kind each change is.

---

## 1. Where we are (inventory as of 0.10.0)

### Bound C functions (`src/rubysys/`)

| module | functions |
|---|---|
| array | `rb_ary_new`, `_new_capa`, `_new_from_values`, `_dup`, `_entry`, `_store`, `_push`, `_pop`, `_shift`, `_unshift`, `_concat`, `_join`, `_reverse`, `_sort`, `_sort_bang`, `_to_s` |
| class | `rb_define_class(_under)`, `_module(_under)`, `_method`, `_private_method`, `_singleton_method`, `_module_function`, `_attr`, `_const`, `rb_const_get`, `rb_class_new_instance`, `rb_class_superclass`, `rb_obj_class`, `rb_singleton_class`, `rb_mod_ancestors`, `rb_include_module`, `rb_prepend_module`, `rb_extend_object`, `rb_ivar_get/set`, `rb_respond_to`, `rb_equal`, `rb_eql`, `rb_obj_freeze`, `rb_obj_frozen_p`, `rb_scan_args` |
| encoding | 24 functions: enc index/lookup/associate, default external/internal, `rb_str_encode`, `rb_str_export_to_enc`, `rb_econv_prepare_opts`, `rb_enc_codepoint_len` |
| fixnum / float | `rb_int2inum`…`rb_num2ull` family (12), `rb_float_new`, `rb_num2dbl`, `rb_to_float` |
| gc | 13: enable/disable/start/count/stat, mark, mark_maybe, register/unregister address, register_mark_object, force_recycle, adjust_memory_usage |
| hash | `rb_hash_new`, `_aref`, `_aset`, `_delete`, `_clear`, `_dup`, `_size`, `_foreach` |
| rproc | `rb_proc_call_with_block`, `rb_binding_new`, `rb_obj_is_proc`, `rb_obj_is_method`, `check_arity` |
| string | 17: new/new_cstr/utf8 variants, value_cstr/ptr, strlen, cat, force_encoding, valid_encoding_p, asciionly_p, export_locale, check_string_type, locktmp/unlocktmp, new_frozen |
| symbol | `rb_intern`, `rb_intern2`, `rb_id2sym`, `rb_sym2id`, `rb_id2name` |
| thread | `rb_thread_call_with(out)_gvl(2)`, `rb_thread_create`, `rb_thread_wait_fd`, `rb_thread_interrupted` |
| typed_data | `rb_data_typed_object_wrap`, `rb_check_typeddata`, `rb_typeddata_inherited_p`, `rb_typeddata_is_kind_of` |
| vm | `ruby_init`, `ruby_init_loadpath`, `ruby_vm_at_exit`, `rb_eval_string(_protect)`, `rb_f_eval`, `rb_require`, `rb_protect`, `rb_funcallv(_public)`, `rb_block_call`, `rb_block_proc`, `rb_block_given_p`, `rb_yield`, `rb_yield_splat`, `rb_call_super`, `rb_raise`, `rb_exc_raise`, `rb_errinfo`, `rb_set_errinfo`, `rb_exit`, `rb_f_abort` |

### Public Rust surface (`src/class/`, `src/dsl.rs`)

`AnyObject`, `AnyException`, `Array`, `Binding`, `Boolean`, `Class`,
`Encoding`, `Enumerator`, `Fixnum`, `Float`, `GC`, `Hash`, `Integer`,
`Module`, `NilClass`, `Proc`, `RString`, `Symbol`, `Thread`, `VM`; traits
`Object`, `Exception`, `EncodingSupport`, `TryConvert`, `VerifiedObject`;
macros `class!`, `module!`, `methods!`, `unsafe_methods!`,
`wrappable_struct!`, `eval!`.

### Known debts carried into 0.10.0

- ~~`VM::at_exit` runs the closure **immediately**~~ — fixed by P0-2.
- README "Ruby 2 Notes" said Ruby 2 was supported "up through 0.8" — fixed in
  0.10.0 (now a support table plus the OpenSSL 1.1 recipe).
- Three doctests in `src/dsl.rs` (`wrappable_struct!`) are `ignore`d doc
  fragments, not tests. Make them runnable.
- ~~No `cfg` flags for the Ruby minor version exist~~ — added by P0-1.
- `examples/` are not built in CI.

---

## 2. Target: what "Ruby fully available on Rust" means here

The acceptance test for this plan is: **a Rust program embedding Ruby 2, or a
Ruby 2 extension written in Rust, can do everything the equivalent C
extension could do without dropping to raw `rubysys`**, for the API areas in
§4. Each area is "done" when:

1. the C functions in its table are declared in `rubysys` (prototype comment,
   correct types for 2.5–2.7),
2. a `binding` wrapper and a safe `class`-level API exist,
3. every public item has a running doctest and at least one unit test that
   exercises the Ruby side (round-trips a value through Ruby, not just Rust),
4. `cargo test` is green on 2.5.9, 2.6.10 and 2.7.8, stable and beta, on
   Linux and macOS in CI,
5. CHANGELOG has the entry.

---

## 3. Version-specific ABI notes (2.5 → 2.6 → 2.7)

Check these before touching anything that reads Ruby structs directly
(`src/rubysys/value.rs`, `string.rs`, `array.rs` helpers, `typed_data.rs`):

- `RBasic` flag bits (`RUBY_FL_USHIFT`, embed flags for `RString`/`RArray`,
  `RSTRING_EMBED_LEN_MAX`, `RARRAY_EMBED_LEN_MASK`) — the reason
  `wrappable_struct!` has `reserved_bytes` and why array/string lengths were
  once hand-computed. Prefer `rb_str_strlen`/`RSTRING_LEN` via function,
  `rb_ary_len` via `rb_ary_entry` loops or `RARRAY_LEN` via function.
- `rb_data_type_struct.function`: 2.5/2.6 have `dmark, dfree, dsize,
  reserved[2]`; 2.7 has `dmark, dfree, dsize, dcompact, reserved[1]`. The
  current `[null; 2]` layout is compatible with all three (2.7's `dcompact`
  lands on a null). Keep it that way; do **not** add a `dcompact` field
  without a 2.7-only cfg.
- `T_*` value types (`ValueType` enum): `T_IMEMO`/`T_ICLASS` positions are
  stable across 2.5–2.7 but confirm against `include/ruby/ruby.h` for each.
- `rb_scan_args` keyword handling: 2.7 introduced `rb_keyword_given_p` and
  the `:` spec; 2.5/2.6 treat a trailing hash as kwargs. Gate kwargs work.
- Taint/trust (`rb_obj_taint`, `rb_obj_untrust`) are no-ops in 2.7 and
  removed in 3.0 — **do not bind them**.
- `rb_gc_force_recycle` is deprecated from 2.7; keep it but mark deprecated.
- Plumbing (P0-1, done): `build.rs` emits `ruby_2_5` / `ruby_2_6` / `ruby_2_7`
  for the exact version and `ruby_gte_2_5` / `ruby_gte_2_6` / `ruby_gte_2_7`
  cumulatively, so bindings are gated with `#[cfg(ruby_gte_2_7)]` instead of
  runtime version sniffing. The cfgs also reach doctests.
- Found while doing P0 (verified with `nm -D` on each libruby):
  `rb_exec_end_proc` is exported by 2.5 and 2.6 but **not 2.7** — use
  `ruby_cleanup` to run end procs. `rb_frozen_error_raise`,
  `rb_keyword_given_p`, `rb_funcallv_kw` and `rb_scan_args_kw` are 2.7-only.
  `rb_get_kwargs` error messages differ (`missing keyword: z` on 2.5/2.6,
  `missing keyword: :z` on 2.7); don't assert on the exact text.
- `rb_scan_args` calls `rb_fatal` (process abort) on a malformed format and
  `rb_sys_fail` calls `rb_bug` when `errno` is 0; the safe wrappers validate
  the format and use `rb_syserr_fail` instead.

---

## 4. Work packages (in priority order)

Each package lists the C API to bind and the Rust surface to add. Tick items
as they land; keep this file current.

### P0 — plumbing and correctness (do first; everything else builds on it)

- [x] **P0-1 Version cfg flags** from `build.rs` (§3). The check that the
      flags match the linked Ruby is the unit test
      `current_ruby::cfg_flags_match_linked_ruby`, which runs in every CI row.
      Also exported downstream as `DEP_RUBY_VERSION_MAJOR`/`_MINOR`, and a
      `cargo:warning` is printed when the Ruby found is not 2.5–2.7.
- [x] **P0-2 Real `VM::at_exit`.** Bind `rb_set_end_proc(void (*)(VALUE),
      VALUE)`; box the closure (`Box<Box<dyn FnMut(VmPointer) + 'static>>`),
      leak it intentionally (it must outlive `main`), and call it from an
      `extern "C"` trampoline. Requires `F: 'static`; document the breaking
      change and keep the old immediate-call behaviour available under a
      clearly named method if anyone relied on it.
      Done: `F: FnOnce(VmPointer) + 'static`; `rb_set_end_proc` passes its
      data to `rb_gc_mark`, so the box pointer is tagged as a Fixnum. Old
      behaviour is `VM::call_protected`. `VM::cleanup` (`ruby_cleanup`, from
      P5) was added so embedders can run end procs and so it can be tested.
- [x] **P0-3 Exception control flow.** `rb_ensure`, `rb_rescue`, `rb_rescue2`,
      `rb_catch`, `rb_throw`, `rb_iter_break`, `rb_iter_break_value`,
      `rb_jump_tag`. Rust surface: `VM::ensure(body, ensure)`,
      `VM::rescue(body, handler)`, `VM::catch_throw`. Panics must never cross
      into Ruby: wrap every trampoline in `catch_unwind` and convert to a Ruby
      exception (`rb_raise` with a `RuntimeError`).
      Done: `VM::ensure`, `VM::rescue`, `VM::rescue_from(&[Class], ..)`,
      `VM::catch`/`VM::throw`, `VM::iter_break(_value)`, `VM::jump_tag`, plus
      `Object::send_with_block`/`protect_send_with_block` (`rb_block_call`)
      so Rust closures can be blocks. `rb_catch`/`rb_throw`/`rb_rescue` are
      bound in `rubysys` only (the `_obj`/`rescue2` forms cover them).
- [x] **P0-4 Argument checking helpers.** `rb_check_type`, `rb_check_frozen`,
      `rb_error_arity`, `rb_check_arity`, `rb_num_zerodiv`, `rb_notimplement`,
      `rb_sys_fail`, `rb_warn`, `rb_warning`. Surface: `VM::warn`,
      `Object::check_frozen`, arity errors from `methods!`.
      Done: `VM::warn`/`warning`, `VM::check_arity` (returns the error),
      `VM::raise_arity_error`, `VM::raise_zero_division`,
      `VM::not_implemented`, `VM::sys_fail`, `Object::check_frozen`,
      `Object::check_type`. Existing macros keep their behaviour: `methods!`
      passes `Err` for a missing argument (its documented contract) and
      `unsafe_methods!` stays check-free for speed (README "Safety"); callers
      that want arity errors use `VM::check_arity`/`VM::raise_arity_error`.
      `VM::raise` keeps passing its message to `rb_raise` as a printf format
      (callers can escape `%`, so per rule 8 it is unchanged and documented);
      `VM::raise_message` is the plain-text variant, and Rutie's own code
      uses it.
      **Rule followed here and for the rest of the plan:** never change the
      behaviour or cost of an existing public method to make it safe; add a
      new (usually `protect`-based, `Result`-returning) method beside it, as
      `Enumerator` and `protect_send` do.
- [x] **P0-5 `methods!`/`unsafe_methods!` completeness.** Variable arity
      (`argc = -1` with `rb_scan_args` specs including optional, splat, block
      and — gated — keyword args via `rb_get_kwargs`), `rb_define_method_id`,
      `rb_define_alias`, `rb_undef_method`, `rb_define_alloc_func` /
      `rb_undef_alloc_func`, `rb_define_attr` exposure, `rb_obj_call_init`.
      Done: `VM::scan_args(&args, "21*1:&") -> ScannedArgs` (real
      `rb_scan_args`, so keyword rules are the running Ruby's),
      `VM::get_kwargs -> KeywordArgs`, `VM::is_keyword_given` (2.7 only),
      `Class`/`Module::define_alias`/`undef_method`,
      `Class::define_alloc_func`/`undef_alloc_func`, `Object::call_init`.
      Variable-arity methods are plain `extern "C" fn(Argc, *const AnyObject,
      T)` functions using `VM::scan_args` (documented); the `methods!` macro
      grammar is unchanged. `rb_define_method_id` is bound in `rubysys` only;
      `rb_define_attr` was already exposed as `attr_reader`/`writer`/`accessor`.
- [x] **P0-6 Frozen semantics.** `rb_obj_freeze` exists; add `rb_str_freeze`,
      `rb_ary_freeze`, `rb_hash_freeze`, `rb_frozen_error_raise` (2.5+), and
      make mutating wrappers (`Array::push`, `RString::concat`, …) return
      `Result` or document that Ruby raises.
      Done: freeze functions bound in `rubysys`/`binding` (`Object::freeze`
      stays the public route); `rb_frozen_error_raise` is 2.7-only, so
      `rb_error_frozen_object` backs the raising path. The mutating `Array`,
      `Hash` and `RString` wrappers document that they raise `FrozenError`.

### P1 — core object model

- [x] **Object:** `rb_obj_dup`, `rb_obj_clone`, `rb_obj_id`, `rb_inspect`,
      `rb_obj_as_string`, `rb_obj_is_kind_of`, `rb_obj_is_instance_of`,
      `rb_obj_method`, `rb_method_boundp`, `rb_check_funcall`,
      `rb_funcall_with_block`, `rb_funcallv_kw` (2.7, gated), `rb_obj_instance_variables`,
      `rb_ivar_defined`, `rb_obj_remove_instance_variable`, `rb_hash` (object hash).
      Done as `Object` trait methods. Names avoid clashing with other traits
      the types implement: `clone_object` (not `Clone::clone`),
      `inspect_object`/`as_string` (not `Exception::inspect`/`to_s`),
      `hash_value` (not `std::hash::Hash::hash`). `method`,
      `instance_eval` return `Result`; `remove_instance_variable` returns
      `Option`; `send_with_keywords` is 2.7-only.
- [x] **Class/Module:** `rb_class_name`, `rb_class_path`, `rb_mod_name`,
      `rb_class_inherited_p`, `rb_mod_include_p`, `rb_mod_module_eval`,
      `rb_obj_instance_eval`, `rb_class_instance_methods`, `rb_cvar_get/set/defined`,
      `rb_const_defined(_at)`, `rb_const_set`, `rb_const_remove`, `rb_path2class`,
      `rb_define_global_const`, `rb_class_of` (immediates too), `rb_define_alias`.
      Done on both `Class` and `Module` (`name`, `path`, `from_path`,
      `is_method_defined`, `inherits`, `includes_module`, `module_eval`,
      `instance_methods`, class variables, constants); `VM::define_global_const`.
      `rb_class_of` is a `static inline` in `ruby.h`, not an exported
      function; `Object::class`/`singleton_class` cover it. `rb_const_set` is
      bound; `const_set` keeps using `rb_define_const`.
- [x] **Global variables:** `rb_gv_get`, `rb_gv_set`, `rb_define_variable`,
      `rb_define_readonly_variable`, `rb_define_virtual_variable`,
      `rb_define_hooked_variable`. Surface: `VM::global("$x")` get/set.
      Done: `VM::global_get`/`global_set`/`protect_global_set`,
      `VM::define_variable`/`define_readonly_variable` -> `GlobalVariable`,
      `VM::define_virtual_variable` (closures, via a hooked variable whose
      data pointer starts with a `VALUE` because Ruby `rb_gc_mark_maybe`s it).
      Getter/setter ABI differs (2.7 drops the trailing `gvar` argument);
      callbacks take only the shared leading arguments.
- [x] **Symbols/IDs:** `rb_sym2str`, `rb_check_id`, `rb_to_id`, `rb_to_symbol`,
      `rb_is_const_id`, `rb_is_instance_id`, `rb_is_class_id`, `rb_intern_str`.
      Done: `Symbol::find` (never creates a symbol), `from_rstring`,
      `to_rstring`, `is_const_name`, `is_instance_variable_name`,
      `is_class_variable_name`. `rb_to_id`/`rb_intern_str` bound in `rubysys`.
- [x] **Kernel formatting:** `rb_sprintf`/`rb_str_format`, `rb_p`, `rb_String`,
      `rb_Array`, `rb_Integer`, `rb_Float`, `rb_Hash`.
      Done: `VM::format` (`rb_str_format`; `rb_sprintf` is C printf and is
      not exposed), `VM::p`, and `RString`/`Array`/`Integer`/`Float`/`Hash::convert`
      returning `Result`. Ruby 2's `Kernel#Hash` only uses `to_hash`.

### P2 — core types to parity with the C API

- [x] **String:** `rb_str_dup`, `rb_str_substr`, `rb_str_split`, `rb_str_cmp`,
      `rb_str_equal`, `rb_str_hash`, `rb_str_inspect`, `rb_str_replace`,
      `rb_str_resize`, `rb_str_buf_new`, `rb_str_buf_append`, `rb_str_plus`,
      `rb_str_times`, `rb_str_to_inum`, `rb_str_to_dbl`, `rb_str_intern`,
      `rb_str_length`, `rb_str_capacity`, `rb_str_set_len`, `rb_str_modify`,
      `rb_str_conv_enc`, `rb_enc_str_coderange`, `rb_enc_mbclen`, `rb_enc_nth`,
      `rb_str_scrub`. Byte-slice (`&[u8]`) views must respect `rb_str_locktmp`.
      Done: all bound in `rubysys`/`binding`; `RString` gained `with_capacity`,
      `capacity`, `compare`/`PartialOrd`, `ellipsize`, `plus`, `replace`,
      `truncate`, `scrub`, `split`, `byte_slice`, `substr`, `times`,
      `to_i`/`parse_integer`, `to_f`/`parse_float`, `coderange` (`CodeRange`)
      and `with_locked_bytes` (locktmp held via `rb_ensure`, panic-safe).
      Safety notes: `rb_str_subseq` is not bounds-checked (wrapped as
      `byte_slice` with a check); growing with `rb_str_resize` exposes
      uninitialized bytes (only `truncate` is public). `dup`, `inspect`,
      `hash`, `length`, `intern`, `equal`, `buf_append` are covered by the
      `Object` trait, `count_chars`, `Symbol::from_rstring` and existing
      methods. `set_len`, `modify`, `conv_enc`, `mbclen`, `nth` stay
      binding-level (raw pointers/encodings).
- [x] **Array:** `rb_ary_delete`, `rb_ary_delete_at`, `rb_ary_includes`,
      `rb_ary_clear`, `rb_ary_subseq`, `rb_ary_plus`, `rb_ary_cmp`, `rb_ary_replace`,
      `rb_ary_resize`, `rb_ary_rotate`, `rb_ary_assoc`, `rb_ary_rassoc`,
      `rb_ary_to_ary`, `rb_check_array_type`, `rb_ary_each` (via `rb_block_call`),
      `rb_ary_freeze`, `rb_ary_aref`. `Array` should implement `IntoIterator`
      (by `Value` copy) and `FromIterator<AnyObject>`.
      Done: `delete`, `delete_at`, `includes`, `clear`, `slice`, `plus`,
      `compare`, `replace`, `resize`, `rotate_bang`, `assoc`, `rassoc`,
      `TryConvert` (`rb_check_array_type`). `IntoIterator`/`FromIterator`
      already existed; freezing is `Object::freeze`. `rb_ary_aref`,
      `rb_ary_to_ary` bound in `rubysys`; `rb_ary_each` needs a Ruby block, so
      iteration uses the iterator or `send_with_block`.
- [x] **Hash:** `rb_hash_lookup`, `rb_hash_lookup2`, `rb_hash_fetch`,
      `rb_hash_has_key`? (use `rb_hash_lookup2` with undef), `rb_hash_keys`,
      `rb_hash_values`, `rb_hash_update_by`, `rb_hash_set_ifnone`,
      `rb_hash_freeze`, `rb_check_hash_type`, `rb_hash_delete_if`?, `rb_hash_tbl`
      (avoid), `rb_env_clear`? (no). `Hash` gets `iter()` over `(AnyObject, AnyObject)`.
      Done: `lookup`, `has_key` (`rb_hash_lookup2` with `Qundef`), `fetch`,
      `keys`, `values` (through `rb_hash_foreach`: `rb_hash_keys` is exported
      by 2.6/2.7 but in no public header, `rb_hash_values` by none),
      `update` (`rb_hash_update_by`), `set_default` (through `default=`, since
      `rb_hash_set_ifnone` skips the frozen check and keeps a default-proc
      flag), `iter` -> `HashIterator`, `TryConvert` (`rb_check_hash_type`).
- [x] **Numeric:** Bignum — `rb_big2str`, `rb_cstr_to_inum`, `rb_str2inum`,
      `rb_big_cmp`, `rb_big_plus/minus/mul/div/modulo/pow`, `rb_big2ll/ull/dbl`,
      `rb_dbl2big`, `rb_int_positive_pow`; `rb_num_coerce_bin/cmp/relop`,
      `rb_num2fix`, `rb_fix2str`, `rb_Integer`, `rb_Float`; **Rational/Complex** —
      `rb_rational_new`, `rb_rational_raw`, `rb_Rational`, `rb_rational_num/den`,
      `rb_complex_new`, `rb_complex_raw`, `rb_Complex`, `rb_complex_real/imag`
      (2.7 exposes `rb_complex_real`/`_imag`; gate older versions to
      `rb_funcall`). New types: `Bignum`? (fold into `Integer`), `Rational`,
      `Complex`. Implement `TryFrom<i128>/u128`, `From<f64>`.
      Done: `Integer::from_str_radix`, `to_s_radix`, `is_bignum`, exact
      `i128`/`u128` both ways (`rb_integer_pack`/`unpack`), `TryFrom<f64>`,
      `to_f64`, arithmetic (`div`/`modulo` return `Result`), `compare`.
      New `Rational` and `Complex` types; `Float::rationalize`. Notes: the
      `rb_big_*` functions take a Bignum receiver only (a Fixnum is UB), so
      arithmetic goes through the Integer methods; `rb_int_positive_pow` is
      in `internal.h` only (not bound); `rb_complex_real/imag/abs/arg` are
      2.6+ (2.5 falls back to method calls); `rb_cstr_to_inum` passes no
      length, which disables base-0 prefix detection, so parsing uses
      `rb_str_to_inum`. `rb_num_coerce_*` are bound (`binding::numeric`).
- [x] **Range:** `rb_range_new`, `rb_range_values`, `rb_range_beg_len`,
      `rb_arithmetic_sequence_extract` (2.6+). New type `Range`.
      Done. Ruby 2 stores ranges as `T_STRUCT`, so `Range`/`Struct` are
      verified with `kind_of` against `rb_cRange`/`rb_cStruct`, not `ty()`.
      `rb_range_beg_len` raises for non-integer bounds (wrapped in `Result`).
- [x] **Regexp / MatchData:** `rb_reg_new_str`, `rb_reg_new`, `rb_reg_regcomp`,
      `rb_reg_match`, `rb_reg_match2`, `rb_reg_nth_match`, `rb_reg_last_match`,
      `rb_reg_backref_number`, `rb_backref_get/set`, `rb_reg_options`,
      `rb_reg_source`. New types `Regexp`, `MatchData`.
      Done. `rb_reg_source` is not exported by any 2.x (uses `source`).
      Matching returns `Result` because strings with invalid bytes raise.
      With named groups, Onigmo does not capture unnamed groups.
- [x] **Time:** `rb_time_new`, `rb_time_nano_new`, `rb_time_timespec_new`,
      `rb_time_num_new`, `rb_time_interval`, `rb_time_timeval`, `rb_time_timespec`,
      `rb_time_utc_offset`. New type `Time` with `From<SystemTime>`/`Duration`.
      Done. `rb_time_num_new` does not validate its argument (must be an
      exact Integer/Rational), so `Time::at` calls `Time.at` instead.
- [x] **Struct:** `rb_struct_define`, `rb_struct_define_under`, `rb_struct_new`,
      `rb_struct_alloc`, `rb_struct_aref`, `rb_struct_aset`, `rb_struct_getmember`,
      `rb_struct_members`, `rb_struct_size`. New type `Struct`.
      Done. `rb_struct_define(_under)`/`rb_struct_new` are variadic with an
      unbounded member list, so definitions go through `Struct.new`; they are
      bound in `rubysys` for fixed-arity C-style use.
- [x] **Enumerator / Enumerable:** `rb_enumeratorize`, `rb_enumeratorize_with_size`
      (`RETURN_ENUMERATOR` equivalent for `methods!`), `rb_enum_values_pack`,
      `rb_cmpint`, `rb_cmperr`, `rb_obj_is_kind_of(Enumerable)`. Make
      `Enumerator` iterable from Rust (`next` via `rb_funcall` with
      `StopIteration` mapped to `None`).
      Done: `Enumerator::new`, `iter`/`IntoIterator` (`StopIteration` ends;
      other exceptions yielded once as `Err`), `Object::try_compare`
      (`rb_cmpint`). Found and fixed: a fiber may only be resumed under the
      `rb_protect` it was created under, so `protect_send`-based `next` broke
      when called from different stack depths; the enumerator methods now use
      `rb_rescue2`. `rb_enumeratorize_with_size`/`RETURN_ENUMERATOR` need
      the calling C frame (`rb_frame_this_func`), so `methods!` users call
      `Enumerator::new(&rtself, "method", &args)` instead.
- [x] **Proc / Method / Binding:** `rb_proc_new`, `rb_proc_arity`,
      `rb_proc_lambda_p`, `rb_proc_call`, `rb_block_lambda`, `rb_method_call`,
      `rb_obj_method`, `rb_mod_method_arity`, `rb_obj_method_arity`,
      `rb_yield_values`, `rb_yield_values2`, `rb_need_block`, `rb_binding_new`
      (exists) + `Binding::local_variable_get/set` via `rb_funcall`.
      Done: `Proc::new` (closure owned by a hidden typed-data object the
      proc's block marks, so it is freed with the proc), `arity`,
      `protect_call`; `Method` type; `method_arity`/`instance_method_arity`;
      `VM::yield_values`/`need_block`; `Binding` locals/receiver/eval.
      `rb_block_lambda`, `rb_method_call_with_block` bound in `rubysys`.
      Note: for non-lambda procs Ruby does not count optional arguments in
      `arity`.
- [x] **Encoding:** `rb_enc_get`, `rb_enc_name`, `rb_enc_find`, `rb_ascii8bit_encoding`,
      `rb_utf8_encoding`, `rb_usascii_encoding`, `rb_locale_encoding`,
      `rb_filesystem_encoding`, `rb_enc_str_buf_cat`, `rb_enc_uint_chr`,
      `rb_enc_precise_mbclen`, `rb_enc_ascget`. Round out `Encoding`.
      Done: `ascii_8bit`, `locale`, `filesystem`, `of`, `index`, `chr`,
      `is_ascii_compatible`, `is_dummy`, `RString::concat_bytes`; fixed
      `Encoding`'s `VerifiedObject` (instances are `T_DATA`). `rb_enc_name` is
      a macro (uses `name`); `precise_mbclen`/`ascget`/`codelen` stay in
      `rubysys`. An embedded VM knows only the built-in encodings until
      `VM::init_loadpath()` + `require "enc/encdb"`.

### P3 — exceptions, IO, and the standard objects an embedder hits

- [x] **Exceptions:** `rb_exc_new_str`, `rb_exc_new_cstr`, `rb_exc_new`,
      `rb_class_new_instance` for exception classes, `rb_ensure`/`rb_rescue`
      (P0-3), `rb_exc_fatal`, `rb_interrupt`, `rb_bug` (bind but never call in
      library code), `rb_syserr_new`, `rb_mod_syserr_fail`. Expose the builtin
      exception class hierarchy as constants (`rb_eStandardError`,
      `rb_eArgError`, `rb_eTypeError`, `rb_eRuntimeError`, `rb_eNoMethodError`,
      `rb_eIOError`, `rb_eStopIteration`, `rb_eFrozenError` (2.5+),
      `rb_eZeroDivError`, `rb_eKeyError`, `rb_eRangeError`, `rb_eNotImpError`,
      `rb_eSystemExit`, `rb_eInterrupt`, `rb_eSignal`, `rb_eEncodingError`,
      `rb_eEncCompatError`, `rb_eLoadError`, `rb_eSecurityError`) as
      `extern static` VALUEs with a typed `Class` accessor each.
      Done: all `rb_e*` statics (`rubysys::exception`) with `Class::*()`
      accessors generated with doctests (`src/class/builtins.rs`);
      `AnyException::from_class` (no name lookup), `from_errno`,
      `from_io_error`; `VM::raise_interrupt`. `rb_exc_fatal`, `rb_bug`,
      `rb_mod_syserr_fail`, `rb_exc_new(_cstr)` bound in `rubysys` only.
- [x] **Builtin class/module globals:** `rb_cObject`, `rb_cBasicObject`,
      `rb_mKernel`, `rb_mComparable`, `rb_mEnumerable`, `rb_cString`, `rb_cArray`,
      `rb_cHash`, `rb_cInteger`, `rb_cFloat`, `rb_cRational`, `rb_cComplex`,
      `rb_cRange`, `rb_cRegexp`, `rb_cTime`, `rb_cSymbol`, `rb_cProc`, `rb_cMethod`,
      `rb_cThread`, `rb_cIO`, `rb_cFile`, `rb_cNilClass`, `rb_cTrueClass`,
      `rb_cFalseClass`, `rb_cEncoding`, `rb_cStruct`, `rb_cEnumerator`,
      `rb_cModule`, `rb_cClass`. Today many wrappers do `Class::from_existing("X")`
      string lookups; replace with the statics.
      Done: `rubysys::builtins` + `Class::*()`/`Module::*()` accessors
      (`class_class`, `module_class`, `method_class`, `struct_class` avoid
      keyword/trait clashes). `rb_cFixnum`/`rb_cBignum`/`rb_cCont` are not
      exported by 2.5-2.7. Internal `VerifiedObject` checks (`Proc`,
      `Binding`, `Encoding`, `Enumerator`, `Exception`, `Thread`) and
      `VM::exit_bang` now use the statics; semantics are unchanged.
- [x] **IO / File / Dir:** `rb_io_write`, `rb_io_puts`, `rb_io_print`, `rb_io_gets`,
      `rb_io_getbyte`, `rb_io_close`, `rb_io_flush`, `rb_io_eof`, `rb_io_binmode`,
      `rb_io_check_readable/writable/closed`, `rb_io_stdio_file`, `rb_stdin`,
      `rb_stdout`, `rb_stderr`, `rb_file_open`, `rb_file_open_str`,
      `rb_file_expand_path`, `rb_file_absolute_path`, `rb_file_dirname`,
      `rb_dir_getwd`, `rb_io_taint_check`? (no — taint). New types `IO`, `File`.
      Done: `IO` (standard streams, write/puts/print/gets/getbyte/flush/close/
      eof/binmode, all `Result`), `File` (`open`, path helpers,
      `current_directory`; `Deref<Target = IO>`). `rb_io_check_*` and
      `rb_io_stdio_file` take the internal `rb_io_t *`, so they are not
      bound; `is_closed` uses `closed?`.
- [x] **Marshal / ObjectSpace / GC extras:** `rb_marshal_dump`, `rb_marshal_load`,
      `rb_define_finalizer`, `rb_undefine_finalizer`, `rb_objspace_each_objects`
      (careful), `rb_memory_id`, `rb_gc_writebarrier`, `rb_gc_writebarrier_unprotect`,
      `rb_gc_latest_gc_info`, `rb_gc_register_mark_object` (exists). `GC::WeakMap`
      via `rb_funcall`.
      Done: `Marshal::dump`/`load` (documented as unsafe for untrusted
      data), `GC::define_finalizer`/`undefine_finalizer`/`latest_info`/
      `write_barrier`/`write_barrier_unprotect`. `rb_memory_id` is 2.7-only
      (bound, gated). `rb_objspace_each_objects` hands out raw heap pages and
      is not bound; use `ObjectSpace.each_object` with `send_with_block`.
- [x] **Load / require / $LOAD_PATH:** `rb_load`, `rb_load_protect`, `rb_f_require`,
      `rb_provide`, `rb_provided`, `rb_feature_provided`, `ruby_incpush`,
      `rb_require_string` (2.7). Surface: `VM::load(path, wrap)`, `VM::provide`.
      Done: `VM::load` (`rb_load_protect`), `protect_require` (`rb_f_require`
      on every version; `rb_require_string` bound for 2.7), `provide`,
      `is_provided`, `add_load_path` (`ruby_incpush`), `find_file`. Features
      are recorded with their extension (`"x.so"`); `rb_provided("x")`
      without one only matches `.rb` features.

### P4 — concurrency

- [x] **Threads:** `rb_thread_current`, `rb_thread_main`, `rb_thread_alone`,
      `rb_thread_schedule`, `rb_thread_sleep`, `rb_thread_sleep_forever`,
      `rb_thread_wait_for`, `rb_thread_wakeup`, `rb_thread_run`, `rb_thread_kill`,
      `rb_thread_local_aref/aset`, `rb_thread_check_ints`, `rb_thread_atfork`,
      `rb_thread_fd_writable`, `rb_thread_fd_select`? (avoid), `rb_thread_wait_fd`
      (exists). `Thread` gets `join`, `value`, `alive`, `kill`, `current`.
      Done: `Thread::current`, `main`, `is_alone`, `pass`, `sleep`
      (`rb_thread_wait_for`), `check_interrupts`, `wait_fd_writable`, and
      instance `join`, `join_value` (not `value`, which would shadow
      `Object::value`), `is_alive`, `kill`, `wakeup` (`Result`: waking a dead
      thread raises), `local_get`/`local_set`. `rb_thread_sleep_forever`,
      `rb_thread_run`, `rb_thread_atfork` are bound in `rubysys` only;
      `rb_thread_fd_select` is not bound.
- [x] **Mutex / Queue:** `rb_mutex_new`, `rb_mutex_lock`, `rb_mutex_unlock`,
      `rb_mutex_trylock`, `rb_mutex_locked_p`, `rb_mutex_synchronize`,
      `rb_mutex_sleep`. New type `Mutex` with an RAII guard that unlocks on drop
      (and on Ruby exceptions via `rb_ensure`).
      Done: `Mutex` (`new`, `lock` → `MutexGuard`, `try_lock`, `is_locked`,
      `synchronize`) and `MutexGuard::sleep`; the guard is `!Send` and
      unlocks on drop. Ruby exceptions skip Rust destructors, so
      `synchronize` (`rb_mutex_synchronize`, which unlocks with `rb_ensure`,
      under `rb_protect`) is the documented choice when the locked code may
      raise. `Queue` has no C API in 2.x; use `Class::from_existing("Queue")`
      and `send`.
- [x] **Fiber:** `rb_fiber_new`, `rb_fiber_resume`, `rb_fiber_yield`,
      `rb_fiber_current`, `rb_fiber_alive_p`. New type `Fiber`.
      Done: `Fiber::new` (Rust closure, owned by the fiber's proc like
      `Proc::new`), `resume`, `yield_values`, `current`, `is_alive`. Ruby
      creates and switches fibers only while the thread has an active tag,
      and resumes a fiber only under the `rb_protect` tag it was created
      under, so these use `rb_rescue2` (like the enumerator fix in P2d); a
      fiber made inside `VM::eval`/`VM::protect` cannot be resumed from Rust,
      and `eval` refuses to run directly on top of a fiber.
- [x] **GVL helpers:** `Thread::call_without_gvl` exists; add an unblocking
      function argument (`rb_thread_call_without_gvl` UBF, `RUBY_UBF_IO` /
      `RUBY_UBF_PROCESS` constants) and document Send/Sync expectations.
      Done: `call_without_gvl` already takes a Rust unblocking closure;
      `Thread::call_without_gvl_io` passes `RUBY_UBF_IO` and documents that
      the closure must not touch Ruby and must share only `Send`/`Sync`
      data. `RUBY_UBF_PROCESS` is the same function in 2.x.

### P5 — VM lifecycle and embedding

- [x] `ruby_setup`, `ruby_cleanup`, `ruby_finalize`, `ruby_options`,
      `ruby_run_node`, `ruby_exec_node`, `ruby_script`, `ruby_set_argv`,
      `ruby_prog_init`, `ruby_init_stack` (only where the platform needs it),
      `ruby_sysinit`, `ruby_native_thread_p`, `ruby_stack_check`,
      `ruby_stack_length`. Surface: `VM::init_with_args(&[&str])`,
      `VM::cleanup() -> i32`, `VM::run_file`, and make `VM::init` idempotent
      (it is not today: calling twice is UB).
      Done: all of these are bound in `rubysys` (with `rb_argv0`). Surface:
      `VM::try_init` (`ruby_setup`, error instead of `exit`),
      `VM::init_with_args`, `VM::set_argv`, `VM::set_script_name`,
      `VM::run_file`, `VM::is_initialized` (`rb_cObject` is set),
      `VM::is_ruby_thread`, `VM::is_stack_near_limit`, `VM::stack_length`;
      `VM::cleanup` since P0-2. `VM::init` was already idempotent
      (`ruby_setup` returns early when the VM exists; re-init after
      `ruby_cleanup` is still unsupported), now documented and tested.
      `ruby_options` works once per process (2.7 reads past its builtin
      table when the prelude loads again, and the `ruby` command has already
      called it in an extension), so `run_file` checks `rb_argv0` and
      returns an error instead; it leaks its argv on purpose, as Ruby keeps
      it for `$0=`. `ruby_finalize`, `ruby_run_node`, `ruby_sysinit`,
      `ruby_init_stack`, `ruby_prog_init` stay `rubysys`-only.
- [x] `rb_set_end_proc` proper `at_exit` (P0-2) and `ruby_vm_at_exit` semantics
      documented side by side. (`VM::cleanup` exists since P0-2.)
      Done: `VM::at_vm_exit` (`ruby_vm_at_exit`, plain `extern "C" fn`,
      runs when the VM is freed) cross-referenced from `VM::at_exit`.
- [x] Signals: `rb_f_trap`-equivalents are not public C API; document that
      `VM::trap` (exists via `Signal.trap`) is the supported route.
      Done: documented on `VM::trap`, with a running doctest.

### P6 — DSL, docs, examples, release

- [x] `wrappable_struct!`: make the three `ignore`d doctests runnable; add
      `dsize` support (`rb_data_typed_object_zalloc` + size fn) and a
      `#[derive]`-free way to declare the wrapped type `Send`-safe; document
      the 2.7 `dcompact` slot (§3).
      Done: the three examples run and assert (0 ignored doctests). New
      optional `size(data) { .. }` clause (`dsize`, reported by
      `ObjectSpace.memsize_of`), accepted before or after `mark`. The wrapper
      stays `Sync` for any `T`, since it only holds the type descriptor (no
      derive needed). `dcompact` (`reserved[0]` on 2.7) is left empty, so
      objects marked with `GC::mark` are pinned by `GC.compact`; documented.
      Internal macro calls go through `$crate::` so `rutie::wrappable_struct!`
      works without importing the macro.
- [x] `methods!`: keyword args (gated), splat, optional args (P0-5); emit
      `rb_error_arity` on mismatch instead of panicking.
      Done: splat, the README's "Variadic Functions / Splat Operator" goal
      (done here instead of a separate P9): a trailing `*name` parameter
      takes the remaining arguments as an `Array`, with no unsafe code, and
      the README now shows it. Each method's parameter list goes to an
      internal `@method` rule, so large `methods!` blocks don't recurse
      deeply. Optional, keyword and block parameters use
      `VM::scan_args` in a plain `extern fn` (P0-5, documented from
      `methods!`); keeping the rest of the grammar unchanged keeps existing
      code compiling. `methods!` never panicked on missing arguments (each
      one is a `Result`). `unsafe_methods!` stays unchecked: its contract is
      that the caller guarantees the arguments, so per rule 8 the checked
      version is `methods!`, not a change to `unsafe_methods!`.
- [x] Build all `examples/` in CI (they exercise `rutie_ruby_example`,
      `rutie_ruby_gvl_example`, `rutie_rust_example` end to end), on the same
      matrix as the crate.
      Done: a CI step on every dynamic Linux/macOS row runs `examples/eval.rs`,
      `rutie_rust_example`'s tests, and both Ruby extension examples'
      minitest suites (`rutie` gem plus minitest `~> 5.15.0`; the reporter gem
      is now optional in their `test_helper.rb`). Verified locally on 2.5.9,
      2.6.10 and 2.7.8. The failure step now prints only the end of the RVM
      make log, which used to push the test output out of reach.
- [x] README: "Ruby 2 Notes" rewritten for 0.10 (support table, OpenSSL 1.1
      recipe). Still to do: add the local multi-Ruby testing recipe from §0.4.
      Done: "Testing against several Rubies" under Contributing (ground rule
      4's recipe).
- [x] `build.rs`: honour `$RUBY` consistently (it does for `rbconfig`; check
      `is_linked_ruby` test uses the same), emit the version cfgs (P0-1, done),
      and print a clear error when the linked Ruby's major version ≠ 2 (a
      `cargo:warning` is printed since P0-1; decide whether it should fail).
      Done: `is_linked_ruby` now runs `$RUBY` like `build.rs`. Decision: keep
      the warning rather than failing, so the later Ruby 3 work can build
      the crate while it's being ported; the warning says Ruby 2 is the
      supported target.
- [ ] Release cadence: superseded. Each Rutie minor now maps to three Rubies:
      0.10 = 2.5/2.6/2.7 (this plan, P0–P8; 0.10.0 is not released yet, so
      all of it, including the breaking `VM::at_exit` change, ships in
      0.10.0), 0.11 = 3.0/3.1/3.2, 0.12 = 3.1/3.2/3.3, 0.13 = 3.2/3.3/3.4
      (P9).

### P7 — unit tests for every public API

Every package above ships with tests for what it adds, written as a
`#[cfg(test)] mod tests` at the bottom of the file that defines the API and run
through `crate::on_ruby_thread` (§0.3). P7 backfills the API that existed
before this plan, so that **every public item has at least one unit test that
round-trips through Ruby**, not only a doctest.

- [x] Inventory: list every public item (`src/class/**`, `src/helpers/**`,
      `src/dsl.rs`, `src/util.rs` public fns, `typed_data`) and the unit tests
      covering it; keep the table in this section current.
      Done: public functions per file that a unit test calls (counted by a
      script over the `mod tests` blocks; trait impls such as `From` and
      `VerifiedObject` are exercised by the same tests):

      | File | Covered |
      |---|---|
      | `class/any_exception.rs` | 3/3 |
      | `class/array.rs` | 31/31 |
      | `class/binding.rs` | 7/7 |
      | `class/boolean.rs` | 2/2 |
      | `class/class.rs` | 37/37 |
      | `class/complex.rs` | 8/8 |
      | `class/encoding.rs` | 15/15 |
      | `class/enumerator.rs` | 8/8 |
      | `class/fiber.rs` | 5/5 |
      | `class/fixnum.rs` | 5/5 |
      | `class/float.rs` | 5/5 |
      | `class/gc.rs` | 19/19 |
      | `class/global_variable.rs` | 2/2 |
      | `class/hash.rs` | 16/16 |
      | `class/integer.rs` | 19/19 |
      | `class/io.rs` | 18/18 |
      | `class/marshal.rs` | 2/2 |
      | `class/method.rs` | 4/4 |
      | `class/module.rs` | 34/34 |
      | `class/mutex.rs` | 6/6 |
      | `class/nil_class.rs` | 1/1 |
      | `class/range.rs` | 6/6 |
      | `class/rational.rs` | 6/6 |
      | `class/regexp.rs` | 12/12 |
      | `class/rproc.rs` | 5/5 |
      | `class/rstruct.rs` | 9/9 |
      | `class/string.rs` | 34/34 |
      | `class/symbol.rs` | 10/10 |
      | `class/thread.rs` | 20/20 |
      | `class/time.rs` | 7/7 |
      | `class/traits/encoding_support.rs` | 6/6 |
      | `class/traits/exception.rs` | 9/9 |
      | `class/traits/object.rs` | 52/52 |
      | `class/traits/try_convert.rs` | 1/1 |
      | `class/traits/verified_object.rs` | 2/2 |
      | `class/vm.rs` | 66/70 |
      | `helpers/codepoint_iterator.rs` | 1/1 |
      | `helpers/scan_args.rs` | 2/2 |
      | `util.rs` | 17/18 |

      The five not counted are doctest-only on purpose: `VM::cleanup`,
      `VM::run_file`, `VM::at_vm_exit` and `VM::exit_bang` end the process or
      its VM, and `util::ptr_to_data` is called through a turbofish the
      script does not match. The `dsl` macros are tested in `dsl.rs`.
- [x] Backfill a bottom-of-file test module for each file that lacks one
      (today: `any_exception`, `any_object`, `binding`, `boolean`, `encoding`,
      `enumerator`, `fixnum`, `float`, `gc`, `module`, `nil_class`, `rproc`,
      `thread`, `traits/*`, `helpers/codepoint_iterator`, `typed_data`,
      `dsl` macros), covering success paths, error paths (`Err`/raised
      exceptions via `VM::protect`), frozen receivers and GC survival
      (`GC::start` between creating and using objects) where relevant.
      Done: every file with public items has one. Two bugs found and fixed
      under rule 8 (they broke however they were called): `GC::register`
      registered a dead stack address, and `RString::encode` with options
      aborted Ruby (`rb_econv_prepare_opts`'s output was ignored).
      `on_ruby_thread` now repeats a failing test's panic message on the
      test's own thread (the Ruby thread's output went to whichever test
      started it).
- [x] Version-specific behaviour gets version-specific tests under
      `#[cfg(ruby_2_5)]`/`#[cfg(ruby_gte_2_6)]`/`#[cfg(ruby_gte_2_7)]`.
      Done: the gated APIs (`Range::arithmetic_sequence`,
      `Object::send_with_keywords`, `VM::is_keyword_given`) have gated tests,
      and `cfg_flags_match_linked_ruby` checks the flags against the running
      Ruby.
- [x] `cargo test --lib` green on 2.5.9, 2.6.10 and 2.7.8, stable and beta.

### P8 — doctest audit

- [x] Every public item has a doctest that **runs**: remove `ignore`
      (the three `wrappable_struct!` fragments, P6), and keep `no_run`/`text`
      only where running is impossible (process exit, signals), each with a
      comment saying why.
      Done: no `ignore` or `no_run` blocks are left. The `text` blocks that
      remain are Ruby call sequences or show how `protect_send` is built,
      and each sits next to a running example. `VM::exit` runs under
      `VM::protect` (it raises `SystemExit` there); `VM::exit_bang` really
      exits, with a comment saying why nothing follows it.
- [x] Doctests assert results (`assert!`/`assert_eq!`), not just call the API;
      examples that only print are given assertions.
      Done: an audit script (every public fn, trait method and macro in
      `src/class`, `src/helpers`, `src/dsl.rs`) finds no example without an
      assertion. `VM::p` captures `$stdout`; the `GC::mark*`/`is_marked`
      examples mark from a `wrappable_struct!` mark function (their only
      correct use) and check the objects survive. Trait method declarations
      (`EncodingSupport`, `TryConvert`, `VerifiedObject`) got their own
      examples.
- [x] Doctests must not depend on version-specific messages (see §3); gate
      version-specific examples with `# #[cfg(ruby_gte_2_7)]`.
- [ ] `cargo test --doc` green on 2.5.9, 2.6.10 and 2.7.8, stable and beta, and
      in CI on Linux and macOS.
      Local: green on all three, stable and beta (627/628/630 doctests).
      CI: Linux green; macOS 2.5/2.6 unit tests crash before the doctests run
      (under investigation, see the PR).

### P9 — Ruby 3 upgrade plan

- [x] Plan the path from 0.10 (Ruby 2) to 0.11 (Ruby 3.0–3.2), 0.12
      (3.1–3.3) and 0.13 (3.2–3.4): `docs/ruby3-upgrade-plan.md`. It lists the
      verified ABI differences (special constants, `RString`/`RArray`
      layouts), removed and deprecated C APIs Rutie binds, behaviour changes
      that affect tests, and the work packages for each release. The README
      has the version roadmap table.

---

## 5. How to work a package (checklist for each item)

1. Read the C prototype in the oldest supported Ruby's headers
   (`include/ruby/intern.h`, `ruby.h`) **and** in 2.7's; note any signature
   or semantic drift and gate it with the version cfgs.
2. Add the `extern "C"` declaration to the right `src/rubysys/*.rs` with the
   prototype comment. Types come from `src/rubysys/types.rs`; never `u64` for
   `VALUE`.
3. Add the `binding` function (unsafe inside, `Value` in/out, no
   allocation policy decisions).
4. Add the `class`-level safe API. Anything that can raise in Ruby is either
   wrapped with `protect` (returns `Result<_, AnyException>`) or documented as
   raising.
5. Doctest + unit test, written as you go: the unit test lives in the
   `#[cfg(test)] mod tests` at the bottom of the same file and runs through
   `crate::on_ruby_thread`. Tests that mutate global VM state (globals,
   constants, `$LOAD_PATH`) must clean up or use names unique to the test.
6. `cargo test` on 2.5.9, 2.6.10, 2.7.8 (stable; beta at least once per
   package). `cargo clippy` clean.
7. CHANGELOG entry under `[Unreleased]` with credit. Tick the box in this
   file. Open the PR against `master`; CI must be green on dynamic
   Linux/macOS rows.

---

## 6. Explicit non-goals for this plan

- Ruby 3.x (Ractors, `rb_ext_ractor_safe`, `RB_GC_GUARD` changes, taint
  removal fallout, keyword-argument separation) — after this plan.
- `rb-sys`/bindgen-based generation — rejected; see PR #172 revert rationale.
- Windows CI — best-effort only; no Ruby install step exists there.
- Static libruby — best-effort rows stay `continue-on-error`.
- Binding internal/`RUBY_INTERNAL` headers, `st_table` directly, or anything
  under `include/ruby/internal` — not public API even in Ruby 2.