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.
- checksums.yaml +4 -4
- data/README.md +142 -40
- data/Rakefile +2 -7
- data/doc/agents/AGENTS.md +382 -0
- data/doc/api_review-2026-08-13.md +144 -0
- data/doc/security-report.md +137 -0
- data/lib/minting/aliases.rb +8 -8
- data/lib/minting/currency/currency.rb +1 -7
- data/lib/minting/currency/registry.rb +6 -5
- data/lib/minting/currency/rounding.rb +14 -1
- data/lib/minting/mint/dsl/numeric.rb +7 -2
- data/lib/minting/mint/dsl/string.rb +2 -2
- data/lib/minting/mint/mint.rb +6 -1
- data/lib/minting/mint/registry/crypto.rb +7 -3
- data/lib/minting/mint/registry/registration.rb +6 -2
- data/lib/minting/mint/registry/registry.rb +1 -1
- data/lib/minting/money/allocation/allocation.rb +6 -1
- data/lib/minting/money/clamp.rb +6 -16
- data/lib/minting/money/constructors.rb +1 -1
- data/lib/minting/money/conversion.rb +3 -0
- data/lib/minting/money/format/format.rb +10 -1
- data/lib/minting/money/format/formatter.rb +50 -22
- data/lib/minting/money/format/to_s.rb +4 -3
- data/lib/minting/money/money.rb +2 -1
- data/lib/minting/money/parse/separator_parser.rb +60 -0
- data/lib/minting/money/parse.rb +74 -14
- data/lib/minting/money/rounding.rb +4 -5
- data/lib/minting/version.rb +1 -1
- metadata +6 -2
|
@@ -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.
|