minting 2.1.1 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,382 @@
1
+ # AGENTS.md
2
+
3
+ Guidance for AI coding agents working in the `minting` Ruby gem.
4
+
5
+ ## Project
6
+
7
+ `minting` is a money-handling gem for Ruby (>= 3.3). Amounts are stored as
8
+ `Rational` and rounded to the currency subunit — no floating-point anywhere.
9
+ The gem is namespaced under `Mint`. `require 'minting'` auto-binds top-level
10
+ `Money`; top-level `Currency` remains opt-in (see below).
11
+
12
+ ## Release readiness & publicization
13
+
14
+ - The project reached 2.0 stable release. Prioritize compatibility,
15
+ documentation accuracy, and clean release notes over speculative API changes.
16
+ - Keep `README.md`, `CHANGELOG.md`, and `ROADMAP.md` in sync. The core README
17
+ examples are covered by `test/minting_test.rb#test_readme_usage`; verify any
18
+ changed README example directly as well.
19
+ - Add every public-facing change to `CHANGELOG.md` under `## [Unreleased]`.
20
+ - Preserve key public API contracts: `Mint::Money`, `Money::Currency`, `Mint`
21
+ helpers, `Money#format`, and the `Money#to_s` no-args compatibility rule.
22
+ - Respect zero-equality semantics (`Money.from(0,'USD') == Money.from(0,'EUR')`
23
+ and `== 0`) and the `Currency` opt-in alias behavior.
24
+ - When modifying numeric/rounding behavior, run `bundle exec rake bench:check`
25
+ and update baselines with `bundle exec rake bench:baseline` for performance
26
+ improvements.
27
+ - Clean `pkg/` artifacts before commit when preparing gem packaging.
28
+
29
+ ## Essential commands
30
+
31
+ ```bash
32
+ bundle install # install deps (dev deps include benchmark, reek, rubocop, rubycritic, yard)
33
+
34
+ bundle exec rake # default task = :test (full unit suite + SimpleCov)
35
+ bundle exec rake test # unit tests only
36
+ bundle exec rake cop # RuboCop on lib/ (plugins: minitest, packaging, performance, rake, thread_safety)
37
+ bundle exec rake critic # RubyCritic (min score 70, output tmp/rubycritic)
38
+ bundle exec rake yard # YARD docs for lib/**/*.rb
39
+ bundle exec rake bundle:audit # bundler-audit (CI runs this)
40
+
41
+ gem build minting.gemspec # build .gem package
42
+ bin/console # IRB with bundler/setup and minting loaded
43
+
44
+ # Single test file (fast iteration — recommended over `rake` for one change):
45
+ ruby -Ilib:test -r ./test/test_helper.rb test/money/money_test.rb
46
+
47
+ # Single test method (Minitest -n is a regexp):
48
+ ruby -Ilib:test -r ./test/test_helper.rb test/money/money_test.rb -n /test_amount/
49
+ ```
50
+
51
+ The test_helper requires SimpleCov (writes to `tmp/simplecov`) and minitest.
52
+ Always pass `-r ./test/test_helper.rb` when running files directly, otherwise
53
+ coverage and minitest/autorun won't be loaded.
54
+
55
+ ### Performance / benchmarks
56
+
57
+ Benchmarks are Minitest-based (require `benchmark/ips`) and live under
58
+ `bench/`. The CI gate is `rake bench:check`, which compares core
59
+ ops against `bench/check/results/baseline-<platform>.json`.
60
+
61
+ ```bash
62
+ bundle exec rake bench:parse # parser benchmark
63
+ bundle exec rake bench:memory # memory benchmark
64
+ bundle exec rake bench:regression # regression benchmark
65
+ bundle exec rake bench:check # compare core ops with the platform baseline
66
+ bundle exec rake bench:baseline # regenerate the platform baseline
67
+ bundle exec rake bench:against:money # compare with the `money` gem
68
+ bundle exec rake bench:against:shopify # compare with `shopify-money`
69
+ ```
70
+
71
+ Notes:
72
+ - `bench:check` runs `bin/bench_check`, which shells out to
73
+ `bench/check/runner.rb`. The runner **exits early on Ruby < 4.x**
74
+ with a no-op result — the gate only meaningfully runs on Ruby 4.0+.
75
+ - Competitive Shopify benches set `BUNDLE_WITHOUT=money_bench` to avoid
76
+ loading both `money` and `shopify-money` together.
77
+ - The `money` and `shopify-money` gems are in optional Bundler groups
78
+ (`money_bench`, `shopify_bench`) and are **not** installed by a plain
79
+ `bundle install`. Use `bundle install --with money_bench` if you need the
80
+ Money-gem comparison.
81
+
82
+ ### CI
83
+
84
+ `.github/workflows/ci.yml` runs on Ruby 3.3 and 4.0:
85
+ `rake cop`, `rake test`, `rake bench:check`, `rake bundle:audit`.
86
+
87
+ ## Architecture
88
+
89
+ ### Load graph
90
+
91
+ `lib/minting.rb` requires `minting/mint` and `minting/version`, then auto-binds
92
+ `::Money = Mint::Money` (warn-and-skip if already defined).
93
+ `lib/minting/mint.rb` wires the rest: `Currency`, the DSL refinements
94
+ (`mint/dsl/numeric`, `range`, `string`), `i18n`, `Mint` module, registry, and
95
+ finally `money/money` (which itself requires all `money/*` mixins, including
96
+ the parser).
97
+
98
+ ### Top-level constants: `Money` auto-bound, `Currency` opt-in
99
+
100
+ `require 'minting'` auto-binds the top-level `Money` constant to `Mint::Money`
101
+ for convenience. If `::Money` is already defined (e.g. the `money` gem loaded
102
+ first), it warns and skips — use `Mint::Money` in that case. This is a
103
+ **breaking change from < v2.0**, where both constants were opt-in via
104
+ `Mint.use_top_level_constants!` (now removed).
105
+
106
+ `Currency` is **not** auto-bound, because application domain models are
107
+ commonly named `Currency` (e.g. a Rails model). Opt in via
108
+ `require 'minting/aliases'`, which binds `Currency = Mint::Currency`
109
+ with the same warn-and-skip guard.
110
+
111
+ There is **no `lib/minting/dsl.rb`** and **no `Mint.use_top_level_constants!`**
112
+ (removed in v2.0). The only opt-in path for `Currency` is
113
+ `require 'minting/aliases'`.
114
+
115
+ ### Two namespaces, one registry
116
+
117
+ - `Money::Currency` — an immutable value object (`code`, `subunit`,
118
+ `symbol`, `priority`, `country`, `name`, `fractional_multiplier`).
119
+ Identity is by `code` only. Constructed via `Currency.new(...)` or
120
+ `Currency.register(...)`. Rounding logic lives on Currency directly
121
+ (`currency/rounding.rb`).
122
+ - `Mint::Money` — an immutable value object (frozen on `initialize`) holding
123
+ a `Rational` amount and a `Currency`. All behavior is split into mixins
124
+ required by `money/money.rb`: `arithmetics/`, `format/`, `allocation/`,
125
+ `clamp`, `coercion`, `comparable`, `constructors`, `conversion`.
126
+ - `Mint::Registry` — the only place with mutable shared state. Holds
127
+ `@currencies` (frozen hash), `@world_currencies` (frozen, from
128
+ `data/world-currencies.yaml`), `@currency_symbols` /
129
+ `@currency_symbol_map`, and `@zeros` (cached frozen zero-Money per
130
+ currency). All access is guarded by `Mint::Registry::MUTEX` (a `Monitor`).
131
+ Currencies hash is replaced (not mutated) on `register` — never do
132
+ `Registry.currencies.delete(...)`; you'll get a frozen-hash error.
133
+
134
+ ### Currency resolution
135
+
136
+ `Currency.resolve(obj)` accepts `nil`, `Currency`, `Money`, or `String` and
137
+ returns `nil` on miss; `Currency.resolve!(obj)` raises `Mint::UnknownCurrency`.
138
+ `Mint::UnknownCurrency < ArgumentError`, so existing `rescue ArgumentError`
139
+ handlers still work — new code can `rescue Mint::UnknownCurrency` for the
140
+ specific case. `Money.from` always goes through `resolve!`, so
141
+ unknown codes raise rather than returning nil. `Money.from` also short-circuits
142
+ zero amounts to the cached `currency.zero` singleton, so
143
+ `Money.from(0, 'USD')` is the same frozen object across calls — don't assume
144
+ `Money.new` is the only path. `Mint.money` is a deprecated wrapper around
145
+ `Money.from`.
146
+
147
+ ### Amount normalization
148
+
149
+ `Currency#normalize_amount(amount)` = `amount.to_r.round(subunit)`. This is
150
+ the single funnel for construction, parsing, `copy_with`, `allocate`, and
151
+ `split`. The default fast path is `Rational#round` (half-up).
152
+
153
+ `Money.with_rounding(mode)` sets a thread-local mode and activates
154
+ `Currency.custom_rounding_active?` (a class-level flag, irreversible once
155
+ set). When the flag is true, `normalize_amount` checks the thread-local
156
+ before each round call; when false, it skips the check entirely. The block
157
+ restores the thread-local mode on exit. Mode is thread-local; the flag is
158
+ global. Supported modes: `:up`, `:down`, `:even`.
159
+
160
+ ### Parser
161
+
162
+ `Money.parse` / `Money.parse!` live in `money/parse.rb`. `parse` returns
163
+ `nil` for invalid input; `parse!` raises `ArgumentError`. The numeric portion
164
+ is validated strictly, so malformed strings do not leak `Rational` errors.
165
+
166
+ Currency detection order in `parse_currency`:
167
+ 1. Scan all uppercase `\b[A-Z_]+\b` words, return the first registered code.
168
+ This intentionally skips non-currency uppercase words (`"MAX 10.00 USD"`
169
+ → USD).
170
+ 2. Fall back to `Registry.detect_currency(input)` — scans for registered
171
+ symbols, longest symbol first, then by `currency.priority` desc.
172
+ 3. Fall back to the explicit `currency` argument (resolved via
173
+ `Currency.resolve`).
174
+
175
+ So an explicit currency arg is a **fallback**, not an override — if the
176
+ string contains a code/symbol, that wins. This changed in v1.9.1; the test
177
+ `test_parse_with_explicit_currency` in `money/money_parse_test.rb` pins the
178
+ behavior (e.g. `parse('19.99 BRL', 'USD')` → BRL, not USD).
179
+
180
+ Separator classification (`classify_separators`) is positional, not
181
+ locale-aware: `1,234` → thousands comma (because comma is at position -4),
182
+ `19,99` → decimal comma, `1.234,56` → mixed (rightmost separator is
183
+ decimal). Accounting negatives (`($1.23)`) are detected by `(` prefix and `)`
184
+ suffix and negate the amount.
185
+
186
+ ### Formatting
187
+
188
+ `Money#format(template = nil, decimal:, thousand:, width:, locale:)` is the
189
+ core; `to_fs` is its alias. `to_s` takes **no arguments** and uses the default
190
+ formatting fast path; call `format` or `to_fs` for custom output.
191
+
192
+ Format strings use `Kernel.format` named-reference syntax:
193
+ `%<symbol>s`, `%<amount>f`, `%<amount>d`, `%<currency>s`, `%<integral>d`,
194
+ `%<fractional>d`. `%<amount>` is signed, `%<magnitude>` and `%<fractional>` are
195
+ non-negative, and `%<sign>` exposes `+`, `-`, or an empty string for zero. Use
196
+ `%<sign>` for explicit sign placement, especially for amounts between -1 and 1.
197
+ The `%<amount>f` and `%<magnitude>f` specifiers have the currency's subunit
198
+ precision **injected at runtime** (e.g. `%<amount>f` → `%<amount>.2f` for
199
+ USD) by a gsub in `format/formatter.rb`. For zero-subunit currencies (JPY),
200
+ `%<fractional>d` receives zero.
201
+
202
+ `format` can also be a Hash with `:positive`, `:negative`, `:zero` keys for
203
+ per-sign templates (used by the `:accounting` preset). Missing keys fall back
204
+ to `%<symbol>s%<amount>f`; unknown keys raise `ArgumentError`.
205
+
206
+ Compiled formatters are retained in a thread-safe, copy-on-write cache capped
207
+ at 256 configurations. Once full, new configurations are compiled for the
208
+ call but not retained. Do not assume `Formatter.cache` is mutable.
209
+
210
+ Minting's core serialization API is `to_hash`/`from_hash`; JSON and Rails
211
+ `as_json` integration are provided by the `money_attribute` companion gem.
212
+
213
+ `Mint.locale_backend=` (a callable or Hash returning
214
+ `{ decimal:, thousand:, format: }`) supplies defaults when the corresponding
215
+ kwarg is nil. This is how `attribute-money` wires I18n. See
216
+ `test/locale_backend_test.rb` — tests save/restore the backend in
217
+ setup/teardown; do the same if you touch it.
218
+
219
+ ### Equality semantics — read this before touching `comparable.rb`
220
+
221
+ Two distinct notions of equality:
222
+ - `==` (loose): `0 == money` iff `money.zero?` (any currency). Two Moneys
223
+ are `==` iff same amount AND same currency. Non-zero numerics are never
224
+ `==` to Money.
225
+ - `eql?` / `hash` (strict, for Hash lookup): `eql?` requires both amount and
226
+ currency to match exactly — **zero is NOT cross-currency equal under
227
+ `eql?`**. `hash = [amount, currency_code].hash`.
228
+
229
+ So `Money.from(0,'USD') == Money.from(0,'EUR')` is true, but `.eql?` is
230
+ false and their hashes differ. The `<=>` operator raises `TypeError` when
231
+ comparing non-zero Moneys of different currencies, and when comparing a
232
+ non-zero Money to a non-zero Numeric. Only `0` is comparable to Money across
233
+ the numeric boundary.
234
+
235
+ `CoercedNumber` (private, in `coercion.rb`) makes `5 * money` work but
236
+ raises on `5 + money` unless `5` is zero, and raises on `numeric / money`
237
+ entirely (no meaningful currency for the result).
238
+
239
+ ### Allocation and split
240
+
241
+ `split(n)` and `allocate(ratios)` both round each part to the subunit, then
242
+ distribute the residual (`amount - parts.sum`) by adding/subtracting
243
+ `currency.minimum_amount` to the **first** N slots (N = leftover / minimum).
244
+ This means the first slots carry the rounding error — documented behavior,
245
+ pinned by tests. `allocate_left_over` mutates the `amounts` array in place.
246
+
247
+ ### DSL / core extensions
248
+
249
+ Loading `minting` adds helpers to `Numeric` (`10.dollars`, `10.reais`, `10.euros`,
250
+ `n.to_money(currency)`) and `String` (`'19.99'.to_money('USD')`).
251
+ `String#to_money` delegates to `Money.parse`, so symbols and codes in the
252
+ string are recognized; its currency argument is only a fallback.
253
+
254
+ `Range#step` with a `Money` step is patched via `Range.prepend(
255
+ Mint::RangeStepPatch)` **only on Ruby < 4.0** (`mint/dsl/range.rb`). Ruby 4.0+
256
+ handles non-numeric steps natively, so the patch is gated by
257
+ `RUBY_VERSION < '4.0'`.
258
+
259
+ ## Conventions
260
+
261
+ - `# frozen_string_literal: true` magic comment in every file.
262
+ - Ruby 3.3+ syntax is used freely: endless methods (`def foo = ...`), pattern
263
+ matching (`in`/`case in`), anonymous splat/block forwarding (`&`).
264
+ - YARD docstrings on public API; `@api private` for internal methods;
265
+ `Currency.world_currencies` is public despite delegating to the internal registry;
266
+ `# :nodoc:` on internal class/module containers.
267
+ - Currency codes must match `/^[A-Z_]+$/` (enforced in `Registry.register`).
268
+ Custom codes with underscores are allowed (the test suite registers
269
+ `BRL_FUEL`).
270
+ - RuboCop line length max 120. `Metrics/AbcSize` max 30, `MethodLength` max
271
+ 30, `ParameterLists` max 7, `CyclomaticComplexity` max 11. Several cops
272
+ are disabled in test files (see `.rubocop.yml`).
273
+ - `ThreadSafety/ClassInstanceVariable` and
274
+ `ThreadSafety/ClassAndModuleAttributes` are **disabled** — the registry
275
+ legitimately uses class instance vars guarded by a `Monitor`. Don't
276
+ "fix" those warnings by removing the mutex.
277
+ - `Naming/BinaryOperatorParameterName` and `Style/NumericPredicate` are
278
+ disabled.
279
+
280
+ ## Tests
281
+
282
+ - Minitest, no RSpec. Test classes subclass `Minitest::Test`; benchmarks
283
+ subclass `Minitest::Benchmark`.
284
+ - `test/minting_test.rb#test_readme_usage` covers core README examples. Keep
285
+ it in sync with changed public behavior, and run changed examples directly.
286
+ - `test/financial_invariants_test.rb` uses a fixed seed to generate money
287
+ values and validates split/allocation conservation, subunit round trips,
288
+ supported format/parse round trips, and malformed parsing.
289
+ - `using Mint` at the top of a test file enables the refinements for all
290
+ tests in that file.
291
+ - Tests register custom currencies (e.g. `BRL_FUEL` in `money_format_test.rb`)
292
+ at class-load time. `Registry.register` raises on duplicate codes, so if a
293
+ prior test file already registered the same code you'll get a failure —
294
+ reuse existing custom codes rather than registering new ones in multiple
295
+ files.
296
+ - Locale tests (`locale_backend_test.rb`) save and restore
297
+ `Mint.locale_backend` in setup/teardown. Always restore global state.
298
+
299
+ ## Gotchas
300
+
301
+ - **`to_s` takes no args.** Use `format` (or `to_fs`) for
302
+ template/decimal/thousand/width/locale options. Calling `to_s(format: ...)` raises
303
+ `ArgumentError: wrong number of arguments`.
304
+ - **`Money` is auto-bound at require time.** `require 'minting'` sets
305
+ `::Money = Mint::Money`. If `::Money` is already defined (e.g. the `money`
306
+ gem loaded first), it warns and skips. `Currency` is **not** auto-bound —
307
+ use `require 'minting/aliases'` to opt in. There is no
308
+ `Mint.use_top_level_constants!` (removed in v2.0) and no `lib/minting/dsl.rb`.
309
+ - **Money-gem co-loading requires order.** If both minting and the `money`
310
+ gem are loaded in the same process (e.g. competitive benchmarks),
311
+ `require 'money'` must run **before** `require 'minting'` — otherwise the
312
+ money gem's `class Money` reopens and corrupts `Mint::Money`'s methods.
313
+ The competitive benchmark helpers (`competitive/money/benchmark_helper.rb`,
314
+ `competitive/shopify/benchmark_helper.rb`) require `money_setup`/
315
+ `shopify_setup` before `benchmark_helper` for this reason.
316
+ - **Zero singleton.** `Money.from(0, 'USD')` returns the cached frozen
317
+ `currency.zero`, not a fresh object. `Money.from` and `Money.from_subunits`
318
+ both do this. Equality and `assert_same` tests rely on it.
319
+ - **`String#to_money` delegates to the parser.** It recognizes currency
320
+ symbols and codes, and its optional currency argument is only a fallback.
321
+ - **`Registry.currencies` is frozen.** Mutating it raises. `register`
322
+ rebuilds the hash via `merge` and freezes the new one.
323
+ - **Competitive benchmark groups.** `money` and `shopify-money` are in
324
+ optional Bundler groups; plain `bundle install` won't pull them. Shopify
325
+ benches run with `BUNDLE_WITHOUT=money_bench` to avoid both loading at
326
+ once.
327
+ - **`bench:check` is Ruby-4-only.** The runner no-ops on Ruby < 4.x. CI runs
328
+ it on both 3.3 and 4.0, but it only meaningfully gates on 4.0.
329
+ - **CI Ruby versions:** 3.3 and 4.0. `.tool-versions` pins 4.0.6 for local
330
+ dev. The `Range#step` Money patch is only active on < 4.0, so behavior
331
+ differs across the matrix for that one feature.
332
+
333
+ ## Key files
334
+
335
+ | Path | What |
336
+ |------|------|
337
+ | `lib/minting.rb` | entry point (requires `mint/mint`, `version`) |
338
+ | `lib/minting/mint.rb` | load graph for Mint, Currency, registry, parser, DSL |
339
+ | `lib/minting/mint/mint.rb` | deprecated `Mint.money`, `Mint::UnknownCurrency` (`< ArgumentError`, raised by `Currency.resolve!`) |
340
+ | `lib/minting/mint/registry/` | `registry.rb`, `registration.rb`, `symbols.rb`, `zeros.rb` — all shared state + `MUTEX` |
341
+ | `lib/minting/currency/registry.rb` | Currency class methods delegating to Registry (resolve, register, for_code, etc.) |
342
+ | `lib/minting/currency/currency.rb` | `Currency` (immutable value object), `resolve`/`resolve!`/`register`/`for_code`/`for_symbol`/`zero` |
343
+ | `lib/minting/currency/rounding.rb` | `VALID_ROUNDING_MODES`, flag, `current_rounding_mode`, `rounding_mode` |
344
+ | `lib/minting/money/parse.rb` | `Money.parse` / `Money.parse!` |
345
+ | `lib/minting/mint/i18n.rb` | `Mint.locale_backend` + `resolve_locale_for` |
346
+ | `lib/minting/mint/dsl/` | `numeric`, `string`, `range` refinements (`top_level.rb` removed in v2.0) |
347
+ | `lib/minting/aliases.rb` | opt-in `Currency = Mint::Currency` (warn-and-skip if already defined) |
348
+ | `lib/minting/money/money.rb` | `Money` core; requires all `money/*` mixins |
349
+ | `lib/minting/money/constructors.rb` | `from`, `from_subunits`, `no_currency`, `parse`, `copy_with`, `zero`, deprecated `create`/`mint` |
350
+ | `lib/minting/money/arithmetics/` | `methods.rb` (`abs`, `negative?`, `positive?`, `succ`), `operators.rb` (`+`, `-`, `-@`, `*`, `/`, `**`) |
351
+ | `lib/minting/money/comparable.rb` | `==`, `eql?`, `<=>`, `same_currency?`, `zero?` — see Equality section |
352
+ | `lib/minting/money/coercion.rb` | `coerce` + private `CoercedNumber` |
353
+ | `lib/minting/money/format/` | `format.rb` (`format`/`to_fs`), `formatter.rb` (compiled formatter cache), `to_s.rb` |
354
+ | `lib/minting/money/allocation/` | `allocation.rb` (`allocate`), `split.rb` (`split`, `allocate_left_over`) |
355
+ | `lib/minting/money/clamp.rb`, `conversion.rb` | `clamp`, conversions (`to_d`/`to_f`/`to_i`/`to_r`/`to_hash`/`to_html`) |
356
+ | `lib/minting/data/world-currencies.yaml` | Built-in ISO-4217 currencies, preloaded at gem initialization |
357
+ | `test/test_helper.rb` | SimpleCov + minitest/autorun + requires `minting` |
358
+ | `test/minting_test.rb#test_readme_usage` | README contract test — keep in sync with README |
359
+ | `bench/check/runner.rb` | core bench runner (Ruby 4.x only) |
360
+ | `bin/bench_check` | `bench:check` gate script (threshold 0.80x baseline by default) |
361
+
362
+ ## Removed APIs (do not restore for compatibility)
363
+
364
+ - `Mint.parse` / `Mint.parse!` → use `Money.parse` / `Money.parse!`.
365
+ - `Mint.with_rounding` → use `Money.with_rounding`.
366
+ - `Mint.world_currencies` → use `Currency.world_currencies`.
367
+ - `Money#mint` → use `#copy_with(amount:)`.
368
+ - `Money.from_fraction` / `#fractional=` → use `from_subunits` / `#subunits`.
369
+
370
+ ## When making changes
371
+
372
+ - Run the specific test file first (`ruby -Ilib:test -r ./test/test_helper.rb
373
+ test/...`), then `bundle exec rake` for the full suite.
374
+ - After touching numeric/rounding/constructor behavior, run the relevant
375
+ benchmark too (`rake bench:parse`, or `rake bench:check` against the
376
+ baseline). If you improve perf, regenerate the baseline with
377
+ `rake bench:baseline` on the target platform.
378
+ - Keep `test_readme_usage` and the README in sync.
379
+ - Preserve zero-equality and `eql?` semantics exactly — they're load-bearing
380
+ for Hash-based usage and pinned by many tests.
381
+ - Don't remove `Mint::Registry::MUTEX` or the frozen-hash pattern to satisfy
382
+ `ThreadSafety` cops; they're disabled for this reason.
@@ -0,0 +1,144 @@
1
+ # API Review — 2026-08-13
2
+
3
+ ## Scope
4
+
5
+ This review covers the public money, currency, parsing, formatting, allocation,
6
+ and serialization APIs in Minting 2.1.1. The full test suite, RuboCop, the
7
+ Ruby 4 benchmark regression gate, and `bundle-audit` were run during review.
8
+
9
+ ## Validation snapshot
10
+
11
+ - Tests: 224 runs, 751 assertions, no failures or errors.
12
+ - Coverage: 94.56% line coverage.
13
+ - RuboCop: 33 files inspected, no offenses.
14
+ - Benchmark regression gate: passed on Ruby 4.0.6.
15
+ - Dependency audit: no known vulnerabilities found.
16
+
17
+ ## Strengths
18
+
19
+ - `Mint::Money` and `Mint::Currency` are immutable value objects. Amounts are
20
+ represented as `Rational`, avoiding floating-point drift.
21
+ - Currency registration uses a mutex and copy-on-write frozen hashes, which is
22
+ a clear and appropriate concurrency model for shared registry state.
23
+ - The public contracts around zero-money caching, strict `eql?`/`hash`, and
24
+ cross-currency comparison are well considered and covered by tests.
25
+ - Allocation and split preserve the total amount after subunit rounding.
26
+ - Formatting has a flexible template API, sign-specific formats, locale hooks,
27
+ and measured performance coverage.
28
+
29
+ ## Findings and recommendations
30
+
31
+ ## Follow-up status — 2026-08-13
32
+
33
+ The first three findings below have been implemented after this review:
34
+
35
+ - **P1 parser hardening:** `Money.parse` now rejects malformed numeric input
36
+ by returning `nil`, and `Money.parse!` raises `ArgumentError`. Regression
37
+ tests cover malformed forms and the numeric validation grammar.
38
+ - **P2 formatter cache:** compiled formatters are stored in a thread-safe,
39
+ copy-on-write cache capped at 256 configurations. Once full, new
40
+ configurations are compiled without being retained.
41
+ - **P2 generated invariants:** deterministic generated tests cover split and
42
+ allocation conservation, subunit round trips, supported format/parse round
43
+ trips, and malformed parsing.
44
+
45
+ The original findings are retained below as historical context.
46
+
47
+ ### Resolved P1 — Make `Money.parse` reject malformed input reliably
48
+
49
+ `Money.parse` documents a nil-returning contract for invalid input, but the
50
+ current parser can both accept malformed text and leak `ArgumentError`.
51
+
52
+ Examples with an explicit USD currency:
53
+
54
+ ```ruby
55
+ Money.parse('abc1def2', 'USD') # => [USD 12.00]
56
+ Money.parse('USD12oops', 'USD') # => [USD 12.00]
57
+ Money.parse('1.2.3', 'USD') # => [USD 123.00]
58
+ Money.parse('1--2', 'USD') # raises ArgumentError
59
+ Money.parse('--1', 'USD') # raises ArgumentError
60
+ ```
61
+
62
+ The behaviour originates in `parse_amount`, which removes every character
63
+ other than digits, decimal/thousands separators, and minus signs before
64
+ calling `Rational`.
65
+
66
+ Recommended change:
67
+
68
+ 1. Define an explicit grammar for the numeric portion, including an optional
69
+ leading sign, valid separator placement, and accounting parentheses.
70
+ 2. Have `Money.parse` convert invalid numeric syntax to `nil` consistently.
71
+ 3. Keep `Money.parse!` as the raising counterpart, but raise a controlled
72
+ `ArgumentError` with the original input rather than allowing `Rational`'s
73
+ implementation exception through.
74
+ 4. Add regression tests for every example above and property-based malformed
75
+ input tests.
76
+
77
+ Relevant implementation: `lib/minting/money/parse.rb`.
78
+
79
+ ### Resolved P2 — Bound the formatter cache
80
+
81
+ `Money::Formatter.cache` is an unbounded class-level hash keyed by template
82
+ and separator values. Since `Money#format` accepts arbitrary caller-provided
83
+ templates, a long-running process can retain a new compiled formatter for each
84
+ unique input.
85
+
86
+ Recommended change:
87
+
88
+ - Cache only fixed presets, or use a bounded LRU cache for dynamic templates.
89
+ - Document that dynamic formatting templates must be application-controlled.
90
+ - Establish an explicit synchronization policy if formatters may be compiled
91
+ concurrently.
92
+
93
+ Relevant implementation: `lib/minting/money/format/formatter.rb`.
94
+
95
+ ### Resolved P2 — Add invariant and generative tests
96
+
97
+ The existing example-driven test suite is strong. Financial logic would benefit
98
+ from generated inputs to protect its key invariants across signs, subunit
99
+ precisions, and large values.
100
+
101
+ Suggested invariants:
102
+
103
+ - `money.split(n).sum == money` for every positive integer `n`.
104
+ - `money.allocate(ratios).sum == money` for valid ratio arrays.
105
+ - `Money.from_subunits(money.subunits, currency) == money`.
106
+ - Parsing invalid input never raises from `Money.parse`.
107
+ - Formatting and parsing round-trip for explicitly supported formats.
108
+
109
+ ### P3 — Publish RBS signatures
110
+
111
+ RBS definitions would make the public API easier to use safely in Ruby
112
+ applications. They are especially valuable for methods that accept multiple
113
+ input forms, such as currency resolution, constructors, comparisons,
114
+ formatting, and parsing.
115
+
116
+ Start with `Mint::Money`, `Mint::Currency`, `Mint`, and the numeric/string
117
+ refinements; then add Steep or TypeProf validation in CI.
118
+
119
+ ### P4 — Keep maintenance documentation current
120
+
121
+ `doc/agents/AGENTS.md` still describes removed `Mint.parse` and
122
+ `Mint.with_rounding` APIs and parser paths that no longer exist. The public
123
+ API now uses `Money.parse` and the current implementation is under
124
+ `lib/minting/money/parse.rb`.
125
+
126
+ Update this document alongside public API changes so future maintenance work
127
+ is based on the implementation that is actually shipped.
128
+
129
+ ### P5 — Add public project-health files
130
+
131
+ For a public gem, add a short `SECURITY.md` with a vulnerability reporting
132
+ channel and supported-version policy. A `CODE_OF_CONDUCT.md` can follow if
133
+ community contributions become a focus.
134
+
135
+ ## Suggested sequence
136
+
137
+ 1. Introduce RBS gradually, starting with core public classes.
138
+ 2. Keep README and active maintenance guidance synchronized with API changes.
139
+ 3. Add public project-health files.
140
+
141
+ ## No source changes in this review
142
+
143
+ This document records review findings only. It does not change public API
144
+ behaviour.
@@ -0,0 +1,137 @@
1
+ # Security and Memory Leak Review Report
2
+
3
+ **Date**: 2026-07-29
4
+ **Project**: minting Ruby gem
5
+ **Version reviewed**: 2.0 release
6
+ **Author**: devin, model SWE 1.6
7
+
8
+ ## Executive Summary
9
+
10
+ This is a historical review of the 2.0 release. The codebase demonstrated good
11
+ practices with thread-safe registry operations, proper object freezing, and
12
+ input validation. The formatter-cache finding below was remediated on
13
+ 2026-08-13; the YAML-loading recommendation remains open.
14
+
15
+ ## Security Vulnerabilities
16
+
17
+ ### 1. Unsafe YAML Loading (Medium Risk)
18
+
19
+ **Location**:
20
+ - `lib/minting/mint/registry/registry.rb:18-21`
21
+ - `lib/minting/mint/registry/crypto.rb:20-21`
22
+
23
+ **Issue**: The code uses `YAML.load_file` which can execute arbitrary Ruby code if the YAML files are malicious:
24
+
25
+ ```ruby
26
+ @world_currencies = YAML.load_file(path).to_h do |entry|
27
+ [entry['code'], Currency.new(**entry.transform_keys(&:to_sym))]
28
+ end
29
+ ```
30
+
31
+ **Risk**: While the bundled YAML files are trusted, if an attacker can replace these files (e.g., through supply chain attack or file system compromise), they could execute arbitrary code.
32
+
33
+ **Recommendation**: Use `YAML.safe_load` with permitted classes:
34
+ ```ruby
35
+ YAML.safe_load(File.read(path), permitted_classes: [Symbol])
36
+ ```
37
+
38
+ ### 2. Potential ReDoS in Regex (Low Risk)
39
+
40
+ **Location**:
41
+ - `lib/minting/money/format/to_s.rb:11`
42
+ - `lib/minting/money/format/formatter.rb:43`
43
+
44
+ **Issue**: The thousand separator regex `THOUSAND_RE = /(\d)(?=(\d{3})+\z)/` could potentially cause ReDoS with very long strings.
45
+
46
+ **Risk**: This is mitigated by:
47
+ - Money amounts are typically bounded
48
+ - The regex is anchored with `\z`
49
+
50
+ **Recommendation**: Consider adding input validation or length limits for formatting operations.
51
+
52
+ ## Memory Leaks
53
+
54
+ ### 1. Formatter Cache Growth (High Risk at time of review) — Resolved 2026-08-13
55
+
56
+ **Location**: `lib/minting/money/format/formatter.rb:15`
57
+
58
+ **Original issue**: The formatter cache grew unbounded with each unique
59
+ `[format, decimal, thousand]` combination.
60
+
61
+ ```ruby
62
+ def self.cache = @cache ||= {}
63
+ ```
64
+
65
+ **Risk**: In long-running processes (e.g., web servers) with user-supplied format strings, this could lead to significant memory growth over time.
66
+
67
+ **Resolution**: The cache is now thread-safe, copy-on-write, and capped at 256
68
+ retained configurations. When full, new configurations are compiled for the
69
+ current call but are not retained.
70
+
71
+ ### 2. Thread-Local Storage Not Cleaned (Medium Risk)
72
+
73
+ **Location**: `lib/minting/currency/rounding.rb:40-44`
74
+
75
+ **Issue**: Thread-local state persists between requests in thread pool environments:
76
+
77
+ ```ruby
78
+ prev = Thread.current[ROUNDING_THREAD_KEY]
79
+ Thread.current[ROUNDING_THREAD_KEY] = mode
80
+ yield
81
+ ensure
82
+ Thread.current[ROUNDING_THREAD_KEY] = prev
83
+ ```
84
+
85
+ **Risk**: While the `ensure` block restores the previous value, if threads are pooled and reused, the thread-local state persists between requests. This could cause unexpected behavior in web server environments.
86
+
87
+ **Recommendation**: Document this behavior or add cleanup hooks for thread pool environments.
88
+
89
+ ### 3. Per-Subunit Template Cache Growth (Low-Medium Risk)
90
+
91
+ **Location**: `lib/minting/money/format/formatter.rb:104-106`
92
+
93
+ **Issue**: This hash grows with each unique subunit value:
94
+
95
+ ```ruby
96
+ @templates_by_subunit = Hash.new do |h, subunit|
97
+ h[subunit] = @templates.transform_values { |f| f.gsub(SUBUNIT_PLACEHOLDER, subunit.to_s) }
98
+ end
99
+ ```
100
+
101
+ **Risk**: While bounded in practice (typical subunits are 0-3), it could grow if many custom currencies with different subunits are registered.
102
+
103
+ **Recommendation**: This is likely acceptable given the bounded nature of subunits, but consider adding a size limit.
104
+
105
+ ## Positive Security Findings
106
+
107
+ 1. **Thread-safe registry**: The registry uses proper `Monitor` synchronization (`lib/minting/mint/registry/registration.rb:22-29`)
108
+ 2. **Frozen objects**: Money and Currency objects are properly frozen after initialization (`lib/minting/money/constructors.rb:97`)
109
+ 3. **Input validation**: Good validation in parsing and formatting methods
110
+ 4. **No known vulnerabilities**: `bundle exec rake bundle:audit` found no vulnerabilities in dependencies
111
+
112
+ ## Other Concerns
113
+
114
+ 1. **String mutation**: Use of `gsub!` and `sub!` (`lib/minting/money/format/formatter.rb:72-76`) could be problematic if strings are shared, though this appears safe in the current implementation.
115
+
116
+ 2. **Zero singleton cache**: The `@zeros` cache (`lib/minting/mint/registry/zeros.rb`) grows with each unique currency but is properly synchronized and bounded by registered currencies.
117
+
118
+ ## Recommendations Priority
119
+
120
+ ### High Priority
121
+ 1. Replace `YAML.load_file` with `YAML.safe_load` in registry files
122
+ 2. ~~Implement formatter cache size limits or LRU eviction~~ (resolved)
123
+
124
+ ### Medium Priority
125
+ 3. Document thread-local storage behavior for thread pool environments
126
+ 4. Consider adding cleanup hooks for thread pool environments
127
+
128
+ ### Low Priority
129
+ 5. Add input length validation for formatting operations
130
+ 6. Consider adding size limits to per-subunit template cache
131
+
132
+ ## Conclusion
133
+
134
+ The minting gem demonstrates good security practices with proper thread safety,
135
+ immutability, and input validation. Formatter-cache growth has been addressed;
136
+ the remaining primary recommendation is replacing `YAML.load_file` with a
137
+ safe-loading approach.