functional-light-service 0.5.4 → 6.0.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.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/project-build.yml +35 -11
  3. data/.rubocop.yml +101 -160
  4. data/AUDIT-functional-light-service.md +352 -0
  5. data/CHANGELOG.md +38 -0
  6. data/README.md +54 -2
  7. data/audit/bench.rb +99 -0
  8. data/audit/verify_findings.rb +172 -0
  9. data/functional-light-service.gemspec +15 -21
  10. data/lib/functional-light-service/action.rb +97 -101
  11. data/lib/functional-light-service/configuration.rb +26 -24
  12. data/lib/functional-light-service/context/key_verifier.rb +124 -118
  13. data/lib/functional-light-service/context.rb +63 -20
  14. data/lib/functional-light-service/deprecations.rb +26 -0
  15. data/lib/functional-light-service/errors.rb +8 -6
  16. data/lib/functional-light-service/functional/enum.rb +286 -250
  17. data/lib/functional-light-service/functional/maybe.rb +21 -15
  18. data/lib/functional-light-service/functional/monad.rb +77 -66
  19. data/lib/functional-light-service/functional/null.rb +88 -74
  20. data/lib/functional-light-service/functional/option.rb +100 -97
  21. data/lib/functional-light-service/functional/result.rb +129 -116
  22. data/lib/functional-light-service/localization_adapter.rb +48 -47
  23. data/lib/functional-light-service/organizer/execute.rb +16 -14
  24. data/lib/functional-light-service/organizer/iterate.rb +30 -25
  25. data/lib/functional-light-service/organizer/reduce_if.rb +19 -17
  26. data/lib/functional-light-service/organizer/reduce_until.rb +22 -20
  27. data/lib/functional-light-service/organizer/scoped_reducable.rb +15 -13
  28. data/lib/functional-light-service/organizer/with_callback.rb +28 -26
  29. data/lib/functional-light-service/organizer/with_reducer.rb +81 -77
  30. data/lib/functional-light-service/organizer/with_reducer_factory.rb +20 -18
  31. data/lib/functional-light-service/organizer/with_reducer_log_decorator.rb +110 -108
  32. data/lib/functional-light-service/organizer.rb +114 -114
  33. data/lib/functional-light-service/testing/context_factory.rb +48 -42
  34. data/lib/functional-light-service/testing.rb +3 -1
  35. data/lib/functional-light-service/version.rb +5 -3
  36. data/lib/functional-light-service.rb +30 -28
  37. data/spec/acceptance/after_actions_spec.rb +87 -71
  38. data/spec/acceptance/before_actions_spec.rb +115 -98
  39. data/spec/acceptance/custom_log_from_organizer_spec.rb +61 -60
  40. data/spec/acceptance/deprecation_warnings_spec.rb +82 -0
  41. data/spec/acceptance/fail_spec.rb +52 -50
  42. data/spec/acceptance/message_localization_spec.rb +119 -118
  43. data/spec/acceptance/organizer/context_failure_and_skipping_spec.rb +68 -65
  44. data/spec/acceptance/organizer/reduce_if_spec.rb +89 -89
  45. data/spec/acceptance/organizer/with_callback_spec.rb +113 -110
  46. data/spec/acceptance/{not_having_call_method_warning_spec.rb → organizer_entry_point_spec.rb} +10 -7
  47. data/spec/acceptance/rollback_spec.rb +183 -132
  48. data/spec/action_expects_and_promises_spec.rb +97 -93
  49. data/spec/action_promised_keys_spec.rb +126 -122
  50. data/spec/context_spec.rb +289 -197
  51. data/spec/examples/controller_spec.rb +63 -63
  52. data/spec/examples/validate_address_spec.rb +38 -37
  53. data/spec/lib/deterministic/currify_spec.rb +90 -88
  54. data/spec/lib/deterministic/null_spec.rb +6 -1
  55. data/spec/lib/deterministic/option_spec.rb +140 -137
  56. data/spec/lib/deterministic/result/result_map_spec.rb +155 -154
  57. data/spec/lib/deterministic/result/result_shared.rb +3 -2
  58. data/spec/lib/deterministic/result_spec.rb +2 -2
  59. data/spec/lib/edge_cases_spec.rb +156 -0
  60. data/spec/lib/enum_spec.rb +1 -1
  61. data/spec/lib/native_pattern_matching_spec.rb +74 -0
  62. data/spec/organizer_spec.rb +115 -114
  63. data/spec/readme_spec.rb +45 -47
  64. data/spec/sample/calculates_order_tax_action_spec.rb +16 -16
  65. data/spec/sample/calculates_tax_spec.rb +1 -1
  66. data/spec/sample/looks_up_tax_percentage_action_spec.rb +55 -55
  67. data/spec/sample/tax/calculates_order_tax_action.rb +10 -9
  68. data/spec/sample/tax/looks_up_tax_percentage_action.rb +28 -27
  69. data/spec/sample/tax/provides_free_shipping_action.rb +11 -10
  70. data/spec/spec_helper.rb +6 -0
  71. data/spec/test_doubles.rb +628 -599
  72. data/spec/testing/context_factory_spec.rb +21 -0
  73. metadata +45 -161
  74. data/lib/functional-light-service/organizer/verify_call_method_exists.rb +0 -29
  75. data/spec/acceptance/include_warning_spec.rb +0 -29
@@ -0,0 +1,352 @@
1
+ # Audit tecnico completo — functional-light-service v0.5.4
2
+
3
+ > Audit indipendente condotto sul codice reale di `lib/` (1.601 righe) e `spec/` (54 file di spec).
4
+ > **Ogni finding contrassegnato [VERIFICATO] è stato riprodotto eseguendo codice contro la libreria**
5
+ > (Ruby 3.4.9 mingw-ucrt, dry-inflector 1.3.1, i18n 1.15.2). I numeri di performance sono misurati
6
+ > con `benchmark-ips`, non stimati. Data audit: 2026-07-03.
7
+
8
+ ---
9
+
10
+ ## 1. Executive summary
11
+
12
+ 1. **[Critico]** Gli hook dichiarativi `before_actions`/`after_actions` funzionano **solo alla prima chiamata** dell'Organizer: `with` li consuma e li azzera sulla classe (`organizer.rb:22-30`). Riprodotto: prima call = −9, seconda call = 1. In multi-thread (Puma/Sidekiq) è anche una race condition.
13
+ 2. **[Alto]** `Context#fetch` viola il contratto di `Hash#fetch`: non solleva mai `KeyError` e **scrive nel context durante una lettura** (`context.rb:130-136`). Riprodotto.
14
+ 3. **[Alto]** Gli alias sono asimmetrici: la lettura traduce alias→chiave originale, la scrittura no. Scrivere su un alias produce un valore invisibile in lettura e due chiavi divergenti nell'hash (`context.rb:113-128`). Riprodotto.
15
+ 4. **[Alto]** Il motore di pattern matching custom (`enum.rb`) costa **~250-300x** rispetto al `case/in` nativo di Ruby (misurato: `Option#value_or` 28-31k i/s vs 6,7-7M i/s). È costo CPU (`binding.eval`, `Matcher` + `instance_eval`, `Struct.new` per chiamata nei guard), non GC.
16
+ 5. **[Alto]** Il rollback è incompleto se un'action compare più volte nella pipeline: `actions.index(current_action)` trova la prima occorrenza (`with_reducer.rb:70`). Riprodotto: rollback eseguito solo su 1 action su 3.
17
+
18
+ Nota di metodo: il profilo dei costi è **CPU-bound** (reflection, eval, dispatch), non GC-bound. Un'estensione nativa C/Rust **non è giustificata** (vedi §2.6-F4); i fix in Ruby puro eliminano la quasi totalità dell'overhead.
19
+
20
+ ---
21
+
22
+ ## 2. Findings dettagliati
23
+
24
+ ### Area 1 — Concorrenza e thread-safety
25
+
26
+ #### 1.1 — Gli hook di classe vengono consumati e azzerati da `with`: persi dalla seconda chiamata, race in multi-thread
27
+ - **Severità**: Critico
28
+ - **Posizione**: `lib/functional-light-service/organizer.rb:22-30`
29
+ - **Descrizione**: `with` copia `@before_actions`/`@after_actions` nel context e poi **azzera le variabili d'istanza di classe** (`@before_actions = nil`). Un organizer che dichiara gli hook con il macro `before_actions` (a load-time, una volta) li perde definitivamente dopo la prima `call`. L'azzeramento esiste per servire `Testing::ContextFactory` (che appende un hook temporaneo via `append_before_actions`, `testing/context_factory.rb:11-22`), ma il costo è la rottura del caso d'uso di produzione.
30
+ - **Scenario di fallimento** [VERIFICATO]:
31
+ ```ruby
32
+ class Org1
33
+ extend FunctionalLightService::Organizer
34
+ before_actions ->(ctx) { ctx.number -= 10 if ctx.current_action == AddOne }
35
+ def self.call(n) = with(:number => n).reduce([AddOne])
36
+ end
37
+ Org1.call(0).fetch(:number) # => -9 (hook applicato)
38
+ Org1.call(0).fetch(:number) # => 1 (hook PERSO)
39
+ ```
40
+ Interleaving multi-thread: il thread A esegue `with` e azzera `@before_actions`; il thread B, entrato dopo il `dup` di A ma prima del proprio `if @before_actions`, legge `nil` e perde gli hook anche alla "prima" chiamata. Nessuna sincronizzazione presente. La spec `spec/acceptance/before_actions_spec.rb:54-58` chiama l'organizer **una sola volta**, quindi il bug non è mai stato osservato dalla suite.
41
+ - **Fix proposto**: `with` deve solo *leggere* lo stato di classe: `data[:_before_actions] = @before_actions.dup if @before_actions` senza azzerare. Per il ContextFactory, sostituire il meccanismo "appendi sulla classe + consuma" con un hook passato per-chiamata (es. `organizer.call(ctx, _before_actions: [...])` interno al factory) oppure rimuovere l'hook nel proprio `ensure`. Trade-off: nessuna rottura per gli utenti; da adattare `ContextFactory` e la sua spec. Rischio di regressione basso e circoscritto al testing helper.
42
+
43
+ #### 1.2 — La classe Action trattiene l'ultimo context in `@ctx`: race tra thread e retention di memoria
44
+ - **Severità**: Alto
45
+ - **Posizione**: `lib/functional-light-service/action.rb:42` (scrittura), `action.rb:33-35` (macro `ctx`, dead code)
46
+ - **Descrizione**: ad ogni `execute`, l'Action scrive `@ctx = action_context` **sulla classe** (le classi sono globali e condivise tra thread). Due job Sidekiq che eseguono la stessa Action si sovrascrivono a vicenda `@ctx`; inoltre la classe trattiene per sempre un riferimento all'ultimo context (e a tutto ciò che contiene: record, connessioni, payload), impedendone il GC. Il macro `ctx(*args)` che dovrebbe leggerlo non è usato da nessuna parte (grep su spec/ e README: zero usi).
47
+ - **Scenario di fallimento** [VERIFICATO]:
48
+ ```ruby
49
+ AddOne.execute(:number => 5)
50
+ AddOne.instance_variable_get(:@ctx) # => FunctionalLightService::Context({number: 6}, ...)
51
+ # resta vivo finché vive la classe = per sempre
52
+ ```
53
+ - **Fix proposto**: eliminare la riga `@ctx = action_context` e il macro `ctx` (dead code). Zero impatto sull'API usata; −6 righe.
54
+
55
+ #### 1.3 — Memoizzazioni `||=` non sincronizzate in Configuration e Null
56
+ - **Severità**: Basso
57
+ - **Posizione**: `lib/functional-light-service/configuration.rb:7-13`, `lib/functional-light-service/functional/null.rb:12-14`
58
+ - **Descrizione**: `@localization_adapter ||= ...` e `@instance ||= new([])` possono produrre due istanze sotto race. Entrambi gli oggetti sono stateless/equivalenti (`Null#==` confronta per `null?`), quindi l'effetto è benigno.
59
+ - **Scenario di fallimento**: due thread al primo accesso concorrente ottengono istanze diverse; nessun comportamento errato osservabile. "Da verificare" solo come igiene.
60
+ - **Fix proposto**: assegnare le default a load-time (costante) o proteggere con `Mutex`. Priorità bassa.
61
+
62
+ #### 1.4 — Il Context mutabile condiviso è sicuro *solo* finché resta per-chiamata
63
+ - **Severità**: Medio (rischio architetturale, non bug attivo)
64
+ - **Posizione**: `lib/functional-light-service/context.rb` (tutta la classe)
65
+ - **Descrizione**: il design muta il Context in-place lungo la pipeline. Non è una race di per sé (ogni `call` crea il suo context), ma qualunque riuso — context passato a più organizer in thread diversi, context memorizzato in una costante, o il caso 1.2 — diventa immediatamente stato condiviso non protetto. Il contratto "il context non si condivide tra thread" non è documentato.
66
+ - **Fix proposto**: documentare esplicitamente il contratto nel README; in prospettiva vedi Area 5 (F1).
67
+
68
+ ---
69
+
70
+ ### Area 2 — Correttezza / bug
71
+
72
+ #### 2.1 — `Context#fetch` non solleva mai `KeyError` e scrive durante la lettura
73
+ - **Severità**: Alto
74
+ - **Posizione**: `lib/functional-light-service/context.rb:130-136`
75
+ - **Descrizione**: l'override è `self[key] ||= block_given? ? super(key, &blk) : super`. Due violazioni del contratto di `Hash#fetch`: (a) `fetch(:mancante)` senza default dovrebbe sollevare `KeyError`, invece `super` riceve `default = nil` e ritorna `nil`; (b) l'operatore `||=` **scrive la chiave nel context** — un metodo di lettura con side-effect permanente. Gli accessor generati (`context.rb:108`) usano `fetch`, quindi ogni lettura di chiave con valore `nil`/`false` esegue una scrittura.
76
+ - **Scenario di fallimento** [VERIFICATO]:
77
+ ```ruby
78
+ ctx = FunctionalLightService::Context.make({})
79
+ ctx.fetch(:missing) # => nil (atteso: KeyError)
80
+ ctx.keys # => [:missing] ← la lettura ha creato la chiave
81
+ ```
82
+ (Nota: `fetch(:flag, true)` con `:flag => false` esistente NON sovrascrive col default — `Hash#fetch` ritorna il valore esistente anche se falsy. Verificato e non riproducibile come bug.)
83
+ - **Fix proposto**: rimuovere l'override o ridurlo alla sola traduzione alias (`super(aliases.key(key) || key, ...)`) preservando la semantica nativa. **Breaking change dichiarato**: chi si affida a `fetch(:x)` ⇒ `nil` su chiave mancante (il README stesso usa `result.fetch(:number)` su chiavi esistenti, che resta valido). Rischio medio: da accompagnare con spec di contratto.
84
+
85
+ #### 2.2 — Alias asimmetrici: la scrittura su un alias è invisibile in lettura
86
+ - **Severità**: Alto
87
+ - **Posizione**: `lib/functional-light-service/context.rb:113-128` (`assign_aliases`, `[]`), `[]=` non overridato
88
+ - **Descrizione**: `[]` traduce l'alias verso la chiave originale (`aliases.key(key) || key`), ma `[]=` scrive la chiave letterale. Dopo `assign_aliases(:codice_fiscale => :cf)`, scrivere `ctx[:cf] = "NUOVO"` crea una chiave `:cf` che la lettura `ctx[:cf]` non vedrà mai (risolve su `:codice_fiscale`). L'hash contiene due verità divergenti. Inoltre `assign_aliases` copia fisicamente i valori sulle chiavi alias (`context.rb:116-118`), ridondante rispetto alla traduzione in lettura e ulteriore fonte di divergenza.
89
+ - **Scenario di fallimento** [VERIFICATO]:
90
+ ```ruby
91
+ ctx = FunctionalLightService::Context.make(:codice_fiscale => "ABC")
92
+ ctx.assign_aliases(:codice_fiscale => :cf)
93
+ ctx[:cf] = "NUOVO"
94
+ ctx[:cf] # => "ABC" (la scrittura è persa)
95
+ ctx.to_h # => {codice_fiscale: "ABC", cf: "NUOVO"}
96
+ ```
97
+ - **Fix proposto**: overridare anche `[]=` con la stessa traduzione, ed eliminare la copia fisica in `assign_aliases` (con inverse-hash precomputato, vedi F3-Area 3). Trade-off: chi (impropriamente) leggeva la chiave alias via `to_h` vede il cambiamento; documentare gli alias come nomi alternativi, non copie.
98
+
99
+ #### 2.3 — Rollback parziale quando un'action compare più volte nella pipeline
100
+ - **Severità**: Alto
101
+ - **Posizione**: `lib/functional-light-service/organizer/with_reducer.rb:69-74`
102
+ - **Descrizione**: per decidere quali action ri-percorrere, `reversable_actions` usa `actions.index(@context.current_action)`. `current_action` è la *classe* dell'action: se la stessa classe compare due volte e il fallimento avviene alla seconda occorrenza, `index` ritorna la prima ⇒ il rollback copre solo il prefisso sbagliato. Inoltre, se il fallimento avviene dentro una lambda-step (`execute(...)`), `current_action` è l'ultima *Action* eseguita, non lo step corrente.
103
+ - **Scenario di fallimento** [VERIFICATO]:
104
+ ```ruby
105
+ with(ctx).reduce([RollB, RollA, RollB]) # fail_with_rollback! nella SECONDA RollB
106
+ # eseguite: B, A, B — rollback attesi: B, A, B — rollback effettivi: [:b] (solo il primo prefisso)
107
+ ```
108
+ - **Fix proposto**: tracciare l'indice dell'azione corrente durante il `reduce` (variabile locale del loop passata al rescue) invece di ricostruirlo a posteriori con `index`. Nessun breaking change; rischio basso.
109
+
110
+ #### 2.4 — Accessor silenziosamente non definiti quando la chiave collide con un metodo di Hash/Context
111
+ - **Severità**: Medio
112
+ - **Posizione**: `lib/functional-light-service/context.rb:106` (`next if respond_to?(key.to_sym)`)
113
+ - **Descrizione**: se un'action dichiara `expects :size` (o `:count`, `:key`, `:merge`, `:message`…), l'accessor non viene definito perché `Context` (che è un `Hash`) risponde già a quel nome. Dentro l'action, `ctx.size` ritorna il numero di chiavi dell'hash, non il valore — **senza alcun errore o warning**. `ReservedKeysVerifier` protegge solo `:message`, `:error_code`, `:current_action` (`key_verifier.rb:113-115`).
114
+ - **Scenario di fallimento** [VERIFICATO]:
115
+ ```ruby
116
+ class UsesSize
117
+ extend FunctionalLightService::Action
118
+ expects :size
119
+ executed { |ctx| ctx[:observed] = ctx.size }
120
+ end
121
+ UsesSize.execute(:size => 999)[:observed] # => 1 (Hash#size), non 999
122
+ ```
123
+ - **Fix proposto**: in `define_accessor_methods_for_keys`, sollevare (o loggare a livello warn) quando la chiave collide con un metodo esistente invece di saltare in silenzio. Trade-off: chi oggi ha collisioni latenti vedrà l'errore — che è esattamente lo scopo.
124
+
125
+ #### 2.5 — `skip_remaining!` nei costrutti annidati viene resettato e cancella l'outcome
126
+ - **Severità**: Medio
127
+ - **Posizione**: `lib/functional-light-service/organizer/scoped_reducable.rb:5-7`, `lib/functional-light-service/context.rb:45-48`
128
+ - **Descrizione**: `scoped_reduce` chiama `reset_skip_remaining!` prima e dopo ogni sotto-riduzione. Due effetti non documentati: (a) uno `skip_remaining!` dentro gli step di `iterate` salta solo il resto degli step *di quell'item* — l'iterazione continua con l'item successivo; (b) `reset_skip_remaining!` non resetta solo il flag: **sovrascrive l'intero `@outcome`** con un `Success` vuoto, cancellando qualunque messaggio impostato con `succeed!`/`skip_remaining!`.
129
+ - **Scenario di fallimento** [VERIFICATO]:
130
+ ```ruby
131
+ # iterate su [1,2,3]; skip_remaining! quando counter==2:
132
+ result[:seen] # => [1, 3] — il 3 è stato processato: lo skip non ha fermato l'iterazione
133
+ # e separatamente:
134
+ ctx.succeed!("fatto bene"); ctx.reset_skip_remaining!; ctx.message # => ""
135
+ ```
136
+ - **Fix proposto**: separare il reset del flag dal reset dell'outcome (`@skip_remaining = false` senza toccare `@outcome`), e documentare la semantica per-scope dello skip (o offrire `skip_all!`). Trade-off: chi dipende dal reset del messaggio (improbabile) cambia comportamento.
137
+
138
+ #### 2.6 — `fail!` muta l'hash di opzioni del chiamante
139
+ - **Severità**: Medio
140
+ - **Posizione**: `lib/functional-light-service/context.rb:69` (`delete`), `lib/functional-light-service/localization_adapter.rb:26` (`merge!`)
141
+ - **Descrizione**: `fail!` fa `options_or_error_code.delete(:error_code)` sull'hash passato dall'utente, e l'adapter i18n fa `i18n_options.merge!(type)`. Input del chiamante modificato in-place.
142
+ - **Scenario di fallimento** [VERIFICATO]:
143
+ ```ruby
144
+ opts = { :error_code => 500 }
145
+ ctx.fail!("boom", opts)
146
+ opts # => {} — un secondo fail!("x", opts) perde l'error_code
147
+ ```
148
+ - **Fix proposto**: `options = options_or_error_code.dup` in testa a `fail!`; `i18n_options.merge(type)` senza bang. Rischio nullo.
149
+
150
+ #### 2.7 — Quattro modi di fallire e control flow non-locale (`throw`/`raise` come flusso)
151
+ - **Severità**: Medio
152
+ - **Posizione**: `lib/functional-light-service/action.rb:50` (`catch(:jump_when_failed)`), `lib/functional-light-service/context.rb:83-91`
153
+ - **Descrizione**: esistono `fail!` (continua l'esecuzione dell'action corrente), `fail_and_return!` (`throw` catturato in `execute`), `fail_with_rollback!` (eccezione catturata nel reducer), più il ritorno di `Failure(...)` monadico per il codice utente. Il `throw` è un salto non-locale invisibile nello stack; chiamare `fail_and_return!` su un context fuori da un `execute` produce `UncaughtThrowError`; `fail_with_rollback!` fuori da un organizer propaga `FailWithRollbackError` (workaround documentato nel README con `organized_by.nil?`, riga ~805-824). Nessuna guida su quale usare quando.
154
+ - **Scenario di fallimento**: `FunctionalLightService::Context.make({}).fail_and_return!("x")` ⇒ `UncaughtThrowError` (deducibile dal codice; non è un percorso d'uso normale).
155
+ - **Fix proposto**: documentare una gerarchia chiara (`fail_and_return!` come default nelle action; `fail!` solo quando serve continuare; rollback per compensazioni) e in una futura major valutare la deprecazione di uno dei due. Il `catch/throw` in sé può restare: è il male minore rispetto alle eccezioni per il flusso ordinario, ma va nascosto dietro un'unica API.
156
+
157
+ #### 2.8 — `Null`: firma di `respond_to?` errata, `method_missing` che nasconde i typo, monkey-patch globale di `Object`
158
+ - **Severità**: Basso (ma con effetto sistemico)
159
+ - **Posizione**: `lib/functional-light-service/functional/null.rb:61-65`, `null.rb:47-51`, `lib/functional-light-service/functional/maybe.rb:1-9`
160
+ - **Descrizione**: (a) `def respond_to?(m)` omette il parametro `include_all`: qualunque chiamata a due argomenti esplode; (b) manca `respond_to_missing?` (la convenzione Ruby corretta); (c) con `@methods` vuoto, `Null.instance` risponde a *tutto*: un typo su un metodo si propaga come `Null` silenzioso invece di un `NoMethodError`; (d) `maybe.rb` monkey-patcha `Object` con `null?`/`some?` per tutti gli oggetti del processo — invasivo per una gem.
161
+ - **Scenario di fallimento** [VERIFICATO]:
162
+ ```ruby
163
+ Null.instance.respond_to?(:foo, true) # => ArgumentError (given 2, expected 1)
164
+ ```
165
+ - **Fix proposto**: correggere la firma (`def respond_to_missing?(m, include_all = false)`); valutare la deprecazione dell'intero duo `Maybe()`/`Null` a favore di `Option` (vedi Area 4-F3).
166
+
167
+ #### 2.9 — `Context` eredita da `Hash`: le operazioni Hash degradano silenziosamente il tipo
168
+ - **Severità**: Basso
169
+ - **Posizione**: `lib/functional-light-service/context.rb:3`
170
+ - **Descrizione**: `select`, `reject`, `merge`, `slice` ecc. ritornano `Hash` puro: outcome, alias e stato di skip spariscono senza errore.
171
+ - **Scenario di fallimento** [VERIFICATO]: `ctx.select { true }.class # => Hash` (né `success?` né `message` disponibili).
172
+ - **Fix proposto**: nel breve, documentare; nel lungo, composizione invece di ereditarietà (Area 5-F2, breaking).
173
+
174
+ #### 2.10 — Finding minori verificati o da verificare
175
+ - **`Some(nil)` è costruibile** [VERIFICATO] — `option.rb`: nessuna validazione in `Some.new(nil)`; `Option.some?(nil)` correttamente dà `None`, ma il costruttore diretto no. Severità Basso. Fix: raise o normalizzazione in `Some#initialize`.
176
+ - **Reserved keys incomplete** — `key_verifier.rb:113-115` non include `:callback`, `:_before_actions`, `:_after_actions`, `:_aliases`, tutte chiavi che l'infrastruttura scrive nel context (`with_callback.rb:13`, `organizer.rb:20-29`). Un'action con `expects :callback` o dati utente con quelle chiavi collidono. Severità Basso, *da verificare* lo scenario completo. Fix: estendere la lista.
177
+ - **`EnumBuilder#method_missing` senza `respond_to_missing?`** e ridefinizione di una variante ⇒ `NoMethodError` criptico (`enum.rb:116-122`). Severità Basso.
178
+ - **`attr_accessor :outcome`** (`context.rb:6`): chiunque può assegnare `ctx.outcome = "banana"` e far esplodere `success?` a distanza. Severità Basso. Fix: `attr_reader` + writer privato.
179
+
180
+ ---
181
+
182
+ ### Area 3 — Performance
183
+
184
+ > Metodo: micro-benchmark `benchmark-ips` eseguiti su Ruby 3.4.9 (build mingw-ucrt **senza YJIT**).
185
+ > Tutti i costi sotto sono **CPU** (reflection, eval, creazione classi), non GC: la distinzione è
186
+ > verificata dal fatto che i rapporti restano identici tra run e che le operazioni dominanti
187
+ > (eval/caller/define_method) non allocano in modo significativo rispetto al lavoro utile.
188
+
189
+ #### 3.1 — Il motore `match` custom costa ~250-300x rispetto al `case/in` nativo
190
+ - **Severità**: Alto (per chi usa Option/Result in hot path; irrilevante per uso sporadico)
191
+ - **Posizione**: `lib/functional-light-service/functional/enum.rb:135-170` (match), `enum.rb:176-182` (guard), `option.rb:31-73` (tutte le operazioni Option passano da `match`)
192
+ - **Descrizione**: ogni `match` paga: `block.binding.eval('self')` (`enum.rb:136`), creazione `Matcher` + `instance_eval` del blocco, exhaustiveness-check con `collect/uniq/sort` per chiamata, e nei guard **`Struct.new(*args)` crea una classe per chiamata** (`enum.rb:178-180`). `Option#map`, `#fmap`, `#value_or`, `#+`, `Result#or/and/+` usano tutti `match`. `Result#map/bind` invece no — ed è infatti 100x più veloce.
193
+ - **Misure** (`benchmark-ips`):
194
+ | Operazione | i/s | vs baseline |
195
+ |---|---|---|
196
+ | `Option#value_or` (match engine) | ~29k | **~264-300x più lento** |
197
+ | `case/in` nativo equivalente | ~6,7-7,0M | baseline |
198
+ | `Result#+` (match con guard ⇒ `Struct.new`/call) | ~24-25k | **~75-76x più lento** della somma diretta |
199
+ | `Result#map` (via `bind`, senza match) | ~1,02M | solo 1,8x più lento della lambda diretta |
200
+ - **Scenario**: pipeline ETL che chiama `value_or` su 1M di Option: ~35 secondi di solo overhead di match contro ~0,15s col `case/in`.
201
+ - **Fix proposto**: reimplementare le operazioni di `Option`/`Result` con dispatch diretto (`is_a?`/polimorfismo), mantenendo `match` come API pubblica ma riscritta sopra `case/in` con exhaustiveness garantita da `else raise MatchError`. I benchmark sopra sono il criterio di accettazione. Trade-off: il DSL `match` con guard `where {}` va mappato su pattern guard nativi; le spec `spec/lib/enum_spec.rb` proteggono il comportamento.
202
+
203
+ #### 3.2 — `Organizer.with` paga `caller(1..1)` + `methods.include?(:call)` a ogni chiamata (e a ogni item di `iterate`)
204
+ - **Severità**: Alto
205
+ - **Posizione**: `lib/functional-light-service/organizer.rb:19`, `lib/functional-light-service/organizer/verify_call_method_exists.rb:7-11`
206
+ - **Descrizione**: `VerifyCallMethodExists.run(self, caller(1..1).first)` è un deprecation-check (il commento dice "should be removed eventually") che a ogni `with` cattura e formatta uno stack frame (`caller` è notoriamente costoso) e alloca l'array completo dei metodi della classe (`klass.methods.include?(:call)` invece di `respond_to?(:call)`). `ScopedReducable#scoped_reduce` richiama `organizer.with(ctx)` **per ogni item** di `iterate`/`reduce_if`/`reduce_until` (`scoped_reducable.rb:6`).
207
+ - **Misure**: `caller(1..1)` + regex + `methods.include?` ≈ 58k i/s (~17 µs/call) contro `respond_to?(:call)` ≈ 10,6M i/s: **~180-184x**. Su un `iterate` da 100k item: ~1,7s di puro overhead di deprecation-check.
208
+ - **Fix proposto**: rimuovere il check (è uno shim transitorio) o eseguirlo una sola volta per classe (flag memoizzato). Rischio zero.
209
+
210
+ #### 3.3 — Accessor singleton definiti su ogni Context a ogni `execute`
211
+ - **Severità**: Medio
212
+ - **Posizione**: `lib/functional-light-service/context.rb:102-111`, chiamato da `action.rb:48`
213
+ - **Descrizione**: ogni `execute` definisce reader/writer come **singleton method sul singolo context** (`define_singleton_method`). Costo CPU per definizione + materializzazione della singleton class per ogni context.
214
+ - **Misure**: creazione context + accessor per 3 chiavi ≈ 58k i/s contro 335k i/s senza accessor: **~5,5-5,8x** sul costo di setup per action.
215
+ - **Fix proposto**: sostituire con `method_missing` + `respond_to_missing?` sul Context (dispatch dinamico ma senza definizione per-istanza), oppure generare i metodi **una volta per classe Action** su un modulo cache-ato (chiavi note a load-time da `expects`/`promises`). La seconda opzione preserva la velocità di chiamata. Attenzione a combinare col fix 2.4.
216
+
217
+ #### 3.4 — `Context#[]` fa un reverse-scan O(n) degli alias a ogni lettura
218
+ - **Severità**: Medio
219
+ - **Posizione**: `lib/functional-light-service/context.rb:125-128`
220
+ - **Descrizione**: `aliases.key(key)` scandisce l'hash degli alias per valore a **ogni** lettura di **ogni** chiave, anche quando gli alias non c'entrano.
221
+ - **Misure**: `ctx[:b]` con 1 alias attivo ≈ 3,9M i/s contro 13,2M i/s di `Hash#[]` puro: **~3,2-3,4x** su ogni singolo accesso.
222
+ - **Fix proposto**: precomputare l'hash inverso in `assign_aliases` (`@inverse_aliases = aliases.invert`) e fare `@inverse_aliases[key] || key`; bypass totale quando `@aliases` è vuoto (`return super if @aliases.nil?`). Rischio nullo.
223
+
224
+ #### 3.5 — Overhead per-item di `iterate` e allocazioni evitabili
225
+ - **Severità**: Medio
226
+ - **Posizione**: `lib/functional-light-service/organizer/iterate.rb:12-19`, `lib/functional-light-service/organizer/with_reducer.rb:22-28`
227
+ - **Descrizione**: (a) `Dry::Inflector.new` + `singularize` a ogni invocazione della lambda (basta farlo una volta in `run`); (b) ogni item paga l'intero stack `with` → `VerifyCallMethodExists` → `WithReducerFactory.make` → `WithReducer.new` → `Context.make`; (c) quando non c'è `around_each`, `around_each_handler` crea una **classe anonima** per ogni WithReducer (`Class.new` con `def self.call`).
228
+ - **Misure** (end-to-end): `Organizer.call` con 1 action ≈ 18k i/s, cioè **~55 µs a chiamata**, ~200x il lavoro utile equivalente. Per servizi con I/O è rumore; dentro `iterate` su collezioni grandi domina il tempo totale.
229
+ - **Fix proposto**: inflettere fuori dalla lambda; sostituire la classe anonima con un modulo costante (`NoopHandler = ->(_ctx, &blk) { blk.call }` o modulo con `.call`); costruire un reducer leggero per gli step annidati che non ripassi da `with`. Beneficio atteso: la maggior parte dei 55 µs (misurare dopo, stesso benchmark).
230
+
231
+ #### 3.6 — YJIT e `frozen_string_literal`: benefici reali ma da ridimensionare
232
+ - **Severità**: Basso (informativo)
233
+ - **Posizione**: build locale; tutti i file di `lib/` (nessuno ha il magic comment — verificato con grep)
234
+ - **Descrizione**: la roadmap precedente indicava "+30-100% con YJIT". Verificato sul campo: **la build Ruby di sviluppo (3.4.9 mingw-ucrt) è compilata senza supporto YJIT** (`ruby --yjit` ⇒ "Ruby was built without YJIT support"). Inoltre YJIT accelera dispatch ripetitivo e codice monomorfico, ma **non salva** `binding.eval`, `caller`, `define_singleton_method` per-call: i fix 3.1-3.5 vengono prima. `frozen_string_literal: true` è assente ovunque: beneficio modesto (le stringhe in hot path sono soprattutto messaggi di log) ma gratuito.
235
+ - **Fix proposto**: aggiungere il magic comment ovunque (rubocop lo automatizza); abilitare YJIT solo dove la piattaforma lo supporta (produzione Linux), **dopo** i fix CPU, e rimisurare con lo stesso `bench.rb`.
236
+
237
+ ---
238
+
239
+ ### Area 4 — Codice superfluo / semplificazione
240
+
241
+ #### F1 — Dead code certo: macro `ctx` e scrittura `@ctx`
242
+ - **Severità**: Basso — **Posizione**: `action.rb:33-35, 42`
243
+ - Nessun uso in lib/, spec/ o README (verificato con grep). Eliminazione: −6 righe, chiude anche il finding 1.2.
244
+
245
+ #### F2 — `VerifyCallMethodExists`: shim transitorio dichiarato, mai rimosso
246
+ - **Severità**: Medio — **Posizione**: `organizer/verify_call_method_exists.rb` (30 righe) + `organizer.rb:19`
247
+ - Il commento nel file stesso dice "This should be removed eventually". Rimozione: −30 righe, −17 µs per call (finding 3.2), meno una spec (`not_having_call_method_warning_spec.rb`).
248
+
249
+ #### F3 — Doppio sistema per l'assenza di valore: `Option` monadico E `Maybe()`/`Null`
250
+ - **Severità**: Medio — **Posizione**: `functional/option.rb` vs `functional/maybe.rb` + `functional/null.rb` (~90 righe)
251
+ - Due paradigmi per lo stesso problema, di cui uno (`Null`) monkey-patcha `Object` e nasconde i typo (finding 2.8). Deprecare `Maybe`/`Null` a favore di `Option`: −90 righe, API più coerente. **Breaking** per chi usa `Maybe()`: deprecation warning per una minor, rimozione in major.
252
+
253
+ #### F4 — Il motore enum (250 righe) è sostituibile con `case/in` + `Data.define` di Ruby 3.2
254
+ - **Severità**: Alto (per manutenibilità) — **Posizione**: `functional/enum.rb` (250 righe; la nota della sessione precedente diceva "~6000 righe": errato, sono 250)
255
+ - `Success(:s)`/`Failure(:f)`/`Some(:s)`/`None()` sono definibili come classi concrete (o `Data.define`), con `match` reimplementato sopra `case/in`. Elimina `method_missing` builder, `binding.eval`, `Kernel.eval` in `impl` (`enum.rb:246`), `Struct.new` nei guard. Stima: −200 righe nette, chiude i finding 3.1 e 2.10-c. Rischio: il DSL pubblico `match do Some() {...} end` va preservato come facciata; le spec `enum_spec.rb`, `option_spec.rb`, `result_spec.rb` sono la rete di sicurezza.
256
+
257
+ #### F5 — Minori
258
+ - Deprecation warning per `include` (organizer.rb:8-14, action.rb:8-14): rimuovibili in una major.
259
+ - gemspec: `i18n` e `dry-inflector` dichiarate sia runtime che development (ridondante); `test_files` è deprecato in RubyGems recenti; il magic comment `# -*- encoding: utf-8 -*-` è inutile da Ruby 2.0.
260
+ - Operatori esotici su Result/Option: `>=` come alias di `try`, `<<` di `pipe`, `>>` di `map` (`result.rb:23-37, 91`) — un operatore di confronto che esegue una lambda è una trappola di leggibilità; candidati a deprecazione.
261
+
262
+ **Impatto complessivo stimato dell'area**: da ~1.600 a ~1.100-1.200 righe, con superficie API più piccola e nessuna perdita funzionale per gli usi documentati.
263
+
264
+ ---
265
+
266
+ ### Area 5 — Design e paradigma
267
+
268
+ #### F1 — Il conflitto FP/mutabilità è reale, ma la soluzione giusta è dichiararlo, non forzare l'immutabilità
269
+ - **Severità**: Medio (concettuale)
270
+ - **Posizione**: trasversale (`context.rb`, `functional/*`)
271
+ - **Descrizione**: il Context è un Hash mutabile con stato interno (`@outcome`, `@skip_remaining`); le monadi promettono composizionalità che il flusso `ctx.try! { ... }.map_err { ctx.fail!(...) }` nega subito (side-effect dentro la catena). In pratica **il Result dentro al Context non è usato come monade ma come "esito ricco"** (message + error_code). La referential transparency non c'è e non ci sarà.
272
+ - **Direzione proposta** (coerente col vincolo di preservare il metodo): assumere esplicitamente il modello **"Functional Core, Imperative Shell"**: il Context è la shell imperativa (mutabile, per-chiamata, non condivisa — da documentare, finding 1.4); le monadi Option/Result restano per i **valori di ritorno del dominio dentro le singole Action**, dove la composizione locale (`map`/`bind`) ha senso. Rinunciare alle API che fingono composizione sul Context. Un Context immutabile (ogni action ritorna un nuovo context) sarebbe FP "vera" ma è una riscrittura breaking dell'intero ecosistema di action esistenti: sconsigliata.
273
+
274
+ #### F2 — `Context < Hash` è la radice di più bug: preferire la composizione (in una major)
275
+ - **Severità**: Medio — **Posizione**: `context.rb:3`
276
+ - Ereditare da Hash espone ~120 metodi non progettati (finding 2.9), rende necessari gli override fragili di `[]`/`fetch` (2.1, 2.2) e la collisione degli accessor (2.4). Una classe che *contiene* un hash e delega solo `[]`, `[]=`, `key?`, `keys`, `each`, `to_h` chiuderebbe strutturalmente quella famiglia di bug. **Breaking change** (chi usa `ctx.merge`, `ctx.slice`… oggi): da fare solo in una major, con changelog esplicito.
277
+
278
+ #### F3 — Superficie API: quattro modi di fallire, due modi di leggere l'esito
279
+ - **Severità**: Medio — vedi finding 2.7. In più: l'esito si legge sia da `ctx.success?/failure?/message/error_code` sia da `ctx.outcome` (Result esposto e perfino scrivibile, finding 2.10-d). Consolidare su una via primaria documentata.
280
+
281
+ #### F4 — Incapsulamento
282
+ - **Severità**: Basso — `Monad#==` legge `other.instance_variable_get(:@value)` (`monad.rb:57`) perché `value` è privato nelle varianti Nullary. Con la migrazione a classi concrete (F4-Area 4) diventa un `protected attr_reader`. `WithCallback` usa la chiave "pubblica" `ctx[:callback]` come canale interno (`with_callback.rb:13`) con nesting max 2 dichiarato nel commento: da spostare su chiave riservata `:_callback` e da aggiungere alle reserved keys (finding 2.10-b).
283
+
284
+ ---
285
+
286
+ ### Area 6 — Modernizzazione e manutenibilità
287
+
288
+ #### F1 — Versione minima Ruby: 2.6 (EOL da marzo 2022)
289
+ - **Severità**: Medio — **Posizione**: `functional-light-service.gemspec:18`
290
+ - (Nota: la review precedente diceva ">= 2.5"; il valore reale è `>= 2.6.0`.) Il floor blocca `case/in` stabile (3.1), `Data.define` (3.2), e mantiene vivo codice di compatibilità. Proposta: **>= 3.1** (minimo per il refactor del match), meglio **>= 3.2** per `Data.define`. Breaking: major bump.
291
+
292
+ #### F2 — Copertura test: buona in superficie, cieca sui punti che contano
293
+ - **Severità**: Alto (è ciò che ha lasciato vivere il finding 1.1)
294
+ - **Posizione**: `spec/` (54 file)
295
+ - Comportamenti critici **non testati**, tutti dimostrati in questo audit:
296
+ 1. Seconda chiamata di un organizer con hook dichiarativi (`before_actions_spec.rb:54-58` chiama una sola volta) → finding 1.1.
297
+ 2. Qualunque scenario multi-thread (zero occorrenze di `Thread` in spec/).
298
+ 3. Contratto di `Context#fetch` (KeyError, no-write-on-read) → finding 2.1.
299
+ 4. Scrittura su chiave alias → finding 2.2.
300
+ 5. Rollback con action duplicate nella pipeline → finding 2.3.
301
+ 6. `skip_remaining!` dentro `iterate`/`reduce_if` e preservazione del messaggio → finding 2.5.
302
+ 7. Collisione `expects` con metodi Hash → finding 2.4.
303
+ - **Fix proposto**: aggiungere queste spec *prima* dei fix (red → green); sono la specifica del comportamento corretto.
304
+
305
+ #### F3 — README e documentazione
306
+ - **Severità**: Basso
307
+ - Residuo di fork non adattato: l'esempio di `fail_with_rollback!` usa `extend LightService::Action` (`README.md:814`) — copiandolo si ottiene `NameError`. Le occorrenze alle righe 134-202 sono narrativa storica sulla gem originale (accettabili, ma vale la pena etichettarle come tali). Mancano: la semantica per-scope di `skip_remaining!`, il contratto di non-condivisione del Context tra thread, la guida "quale fail usare quando".
308
+
309
+ #### F4 — Estensione nativa (Rust/C): non giustificata, con evidenza
310
+ - **Severità**: — (raccomandazione)
311
+ - I benchmark di quest'audit dimostrano che l'overhead è **CPU su reflection/eval Ruby evitabile in Ruby stesso**: il match engine perde 264x contro il `case/in` nativo *già disponibile nella VM*, e 17 dei 55 µs per call sono un deprecation-check rimuovibile. Dopo i fix 3.1-3.5 il profilo residuo è method dispatch che la VM esegue già in C. Un'estensione nativa per l'orchestrazione dovrebbe richiamare callback Ruby (le Action) attraverso il boundary nativo a ogni passo: si paga il crossing senza eliminare il costo dominante. **Quando avrebbe senso**: solo se dentro un'Action comparisse un algoritmo puro CPU-bound (parsing, hashing, calcolo numerico su grandi array) — e in quel caso **Rust + Magnus** (memory safety, build più gestibile per un singolo manutentore, precedenti reali: polars-rb, wasmtime-rb) e mai C (un errore di memoria = segfault del processo host). Percorso obbligato prima di qualunque nativo: profilare con `vernier`/`stackprof` un workload reale dopo i fix Ruby.
312
+
313
+ ---
314
+
315
+ ## 3. Piano di refactor prioritizzato
316
+
317
+ Ordine per (impatto × urgenza), con stima di impatto e rischio di regressione:
318
+
319
+ - [ ] **1. Scrivere le spec mancanti dei comportamenti rotti** (Area 6-F2: doppia chiamata con hook, fetch contract, alias write, rollback duplicati, skip in iterate). Impatto: alto (specifica del corretto). Rischio: nullo. *Da fare prima di ogni fix.*
320
+ - [ ] **2. Fix hook consumati da `with`** (finding 1.1) + redesign del meccanismo `ContextFactory`. Impatto: critico. Rischio: basso, circoscritto al testing helper.
321
+ - [ ] **3. Eliminare `@ctx` di classe e macro `ctx`** (1.2 / Area 4-F1). Impatto: chiude race + retention. Rischio: nullo (dead code).
322
+ - [ ] **4. Rimuovere `VerifyCallMethodExists`** (3.2 / Area 4-F2). Impatto: −17 µs/call, −30 righe. Rischio: quasi nullo (sparisce un warning deprecato).
323
+ - [ ] **5. Fix `Context#fetch`** (2.1): semantica Hash nativa. Impatto: contratto corretto. Rischio: medio — **breaking dichiarato** per chi si affida a `fetch(:x) ⇒ nil`.
324
+ - [ ] **6. Simmetria alias in `[]=` + inverse hash precomputato + bypass senza alias** (2.2, 3.4). Impatto: correttezza + 3x su ogni lettura. Rischio: basso.
325
+ - [ ] **7. Fix rollback con indice tracciato nel reduce** (2.3). Impatto: correttezza delle compensazioni. Rischio: basso.
326
+ - [ ] **8. `fail!` senza mutazione dell'input; reset dello skip separato dall'outcome; raise su collisione accessor** (2.6, 2.5, 2.4). Impatto: medio. Rischio: basso.
327
+ - [ ] **9. Riscrivere le operazioni Option/Result senza match engine; `match` come facciata su `case/in`** (3.1 / Area 4-F4). Impatto: fino a ~260x sugli hot path FP, −200 righe. Rischio: medio — coperto da enum/option/result spec. Richiede bump Ruby ≥ 3.1/3.2 (major).
328
+ - [ ] **10. Alleggerire `iterate`/`scoped_reduce` + handler no-op costante + inflector fuori dalla lambda** (3.5, 3.6-parte). Impatto: taglia gran parte dei ~55 µs/call nei loop. Rischio: basso. *Rimisurare con `bench.rb` dopo.*
329
+ - [ ] **11. Accessor per-classe-Action invece che per-istanza-context** (3.3). Impatto: ~5x sul setup per action. Rischio: medio (interazione con 2.4).
330
+ - [ ] **12. `frozen_string_literal` ovunque + gemspec pulito (dipendenze duplicate, `test_files`) + README (riga 814, doc semantiche mancanti)** (3.6, Area 4-F5, Area 6-F3). Impatto: igiene. Rischio: nullo.
331
+ - [ ] **13. Major release**: bump Ruby ≥ 3.1/3.2, deprecare `Maybe`/`Null` e gli operatori esotici, unificare l'API di fallimento, valutare Context per composizione (Area 5-F2). Impatto: manutenibilità a lungo termine. Rischio: alto ma dichiarato — è il punto dove i breaking change si concentrano deliberatamente.
332
+ - [ ] **14. Solo dopo tutto ciò**: profilare un workload reale con `vernier`; YJIT in produzione Linux; nativo (Rust+Magnus) solo se emerge un algoritmo puro CPU-bound — improbabile (Area 6-F4).
333
+
334
+ ---
335
+
336
+ ## 4. Cosa NON toccare
337
+
338
+ - **Il metodo Organizer / Action / Context con `expects`/`promises`**: la scomposizione in action piccole a singola responsabilità, la lista `actions` come documentazione vivente del flusso e la verifica dichiarativa delle chiavi sono il valore della libreria. Nessun finding li mette in discussione.
339
+ - **La verifica delle chiavi (`KeyVerifier`)**: design pulito (template method, tre verifier), costo proporzionato. Da estendere (reserved keys), non da riscrivere.
340
+ - **Il pattern decorator per il logging** (`WithReducerFactory` + `WithReducerLogDecorator`): separazione corretta, stato per-istanza (quindi per-chiamata), zero costo quando il logger è nullo.
341
+ - **`Result#map`/`bind` (il nucleo monadico senza match)**: misurato a solo 1,8x dal codice diretto — è la parte *sana* del layer funzionale. La riscrittura del match engine deve preservarne la semantica, non sostituirla.
342
+ - **La semantica di corto-circuito su failure** (`stop_processing?` controllato da ogni step): semplice, uniforme in tutti i costrutti (`execute`, `iterate`, `reduce_if`, `reduce_until`, `with_callback`), facile da ragionare.
343
+ - **`catch(:jump_when_failed)` come meccanismo interno** di `fail_and_return!`: è il male minore (le eccezioni per il flusso ordinario costerebbero di più); va incapsulato e documentato, non eliminato.
344
+ - **Il testing helper `ContextFactory` come concetto**: preparare un context reale eseguendo la pipeline fino all'action da testare è un'ottima idea; è solo il suo *meccanismo* (mutazione della classe organizer) a dover cambiare.
345
+
346
+ ---
347
+
348
+ ## Appendice — Riproducibilità
349
+
350
+ - Scenari di fallimento: script `audit/verify_findings.rb` (13 check, 12 confermati; l'unico non riprodotto — "fetch con default sovrascrive valori falsy" — è documentato come tale nel finding 2.1). Esecuzione: `ruby audit/verify_findings.rb` dalla root del progetto (richiede dry-inflector e i18n).
351
+ - Benchmark: script `audit/bench.rb` con `benchmark-ips`, 7 confronti; numeri riportati nei finding 3.1-3.5.
352
+ - Ambiente: Ruby 3.4.9 (x64-mingw-ucrt, senza YJIT), Windows 11; dipendenze runtime reali della gem (dry-inflector, i18n).
data/CHANGELOG.md CHANGED
@@ -1,3 +1,41 @@
1
+ ## 6.0.0 (2026-07-03)
2
+
3
+ Release maggiore guidata da un audit tecnico completo (vedi `AUDIT-functional-light-service.md`
4
+ e la sezione "Upgrading to 6.0" del README). Richiede **Ruby >= 3.1** (testato fino a Ruby 4.0).
5
+
6
+ ### Fixed
7
+ - Gli hook before_actions/after_actions dichiarativi non vengono piu consumati dalla prima chiamata dell'organizer (bug critico + race condition in multi-thread) ( 2026-07-03 ) [ sphynx79]
8
+ - La classe Action non trattiene piu l'ultimo context in una variabile di classe (race + memory retention) ( 2026-07-03 ) [ sphynx79]
9
+ - Context#fetch rispetta il contratto di Hash#fetch: KeyError su chiave mancante, nessuna scrittura durante la lettura (BREAKING) ( 2026-07-03 ) [ sphynx79]
10
+ - Alias simmetrici: lettura e scrittura risolvono entrambe verso la chiave originale; niente piu copie fisiche divergenti (BREAKING) ( 2026-07-03 ) [ sphynx79]
11
+ - Rollback completo anche con la stessa action presente piu volte nella pipeline ( 2026-07-03 ) [ sphynx79]
12
+ - fail! non muta piu l'hash di opzioni del chiamante ( 2026-07-03 ) [ sphynx79]
13
+ - reset_skip_remaining! preserva l'outcome e il suo messaggio ( 2026-07-03 ) [ sphynx79]
14
+ - Errore esplicito (ReservedKeysInContextError) quando una chiave expects/promises collide con un metodo esistente del Context (BREAKING) ( 2026-07-03 ) [ sphynx79]
15
+ - Null: respond_to_missing? con la firma corretta; Some(nil) vietato (BREAKING); Context#outcome in sola lettura (BREAKING) ( 2026-07-03 ) [ sphynx79]
16
+ - Spec compatibili con Ruby 3.1-3.4+ (formato Hash#inspect, messaggi NoMethodError, kwargs rspec-mocks) ( 2026-07-03 ) [ sphynx79]
17
+
18
+ ### Added
19
+ - Supporto al pattern matching nativo di Ruby (case/in) per Result/Option e tutti gli enum (deconstruct/deconstruct_keys) ( 2026-07-03 ) [ sphynx79]
20
+ - Modulo Deprecations: warning non fatali, una volta per processo, silenziabili ( 2026-07-03 ) [ sphynx79]
21
+ - Deprecati (funzionanti con warning): Maybe()/Null, Result#>=, Result#<<, Result#+, Option#+ ( 2026-07-03 ) [ sphynx79]
22
+ - Audit tecnico completo con script di verifica e benchmark riproducibili in audit/ ( 2026-07-03 ) [ sphynx79]
23
+
24
+ ### Performance
25
+ - Operazioni Option/Result con dispatch diretto: value_or da ~29k a ~7,1M i/s (~245x) ( 2026-07-03 ) [ sphynx79]
26
+ - Motore match 3x piu veloce (Binding#receiver, exhaustiveness memoizzata, cache degli Struct dei guard) ( 2026-07-03 ) [ sphynx79]
27
+ - Rimosso il deprecation shim VerifyCallMethodExists (~17us per ogni with, pagato anche per item in iterate) ( 2026-07-03 ) [ sphynx79]
28
+ - Accessor del context via method_missing con whitelist (niente singleton class per istanza); iterate senza inflection per chiamata; handler around_each di default costante. Overhead end-to-end per call: da ~55us a ~21us ( 2026-07-03 ) [ sphynx79]
29
+ - frozen_string_literal: true in tutta la lib ( 2026-07-03 ) [ sphynx79]
30
+
31
+ ### Changed
32
+ - required_ruby_version >= 3.1; dev dependencies modernizzate (rspec 3.13, rubocop 1.75+, simplecov 0.22, solargraph 0.60); CI matrix Ruby 3.1/3.2/3.3/3.4/4.0 ( 2026-07-03 ) [ sphynx79]
33
+ - README: sezione "Upgrading to 6.0", contratto di threading documentato, fix esempio fail_with_rollback! (residuo LightService::) ( 2026-07-03 ) [ sphynx79]
34
+
35
+ ### Removed
36
+ - Kernel.eval in impl (sostituito da const_get); dead code (macro ctx di Action) ( 2026-07-03 ) [ sphynx79]
37
+
38
+
1
39
  ## 0.5.4 (2026-07-03)
2
40
  ### Fixed
3
41
  - Blocca rexml < 3.3 per compatibilita con simplecov-cobertura 2.1.0 (fix CI Codecov: Malformed XML No root element) ( 2026-07-03 ) [ sphynx79]
data/README.md CHANGED
@@ -38,7 +38,7 @@
38
38
 
39
39
  ## Requirements
40
40
 
41
- This gem requires ruby >= 2.5.0
41
+ This gem requires ruby >= 3.1 (tested up to ruby 4.0)
42
42
 
43
43
  ## Installation
44
44
 
@@ -811,7 +811,7 @@ action is running inside an organizer:
811
811
 
812
812
  ```ruby
813
813
  class FooAction
814
- extend LightService::Action
814
+ extend FunctionalLightService::Action
815
815
 
816
816
  executed do |context|
817
817
  # context.organized_by will be nil if run from an action,
@@ -1465,6 +1465,58 @@ actions in order and write code for the actions. That's it.
1465
1465
 
1466
1466
  For further examples, please visit the project's [Wiki](https://github.com/sphynx79/functional-light-service/wiki).
1467
1467
 
1468
+ ## Upgrading to 6.0
1469
+
1470
+ Version 6.0 requires **Ruby >= 3.1** and ships a few breaking changes plus new guarantees.
1471
+ They come from a full technical audit (see `AUDIT-functional-light-service.md`).
1472
+
1473
+ ### Breaking changes
1474
+
1475
+ - **`Context#fetch` now honours the `Hash#fetch` contract**: `fetch(:missing)` without a
1476
+ default raises `KeyError` (it used to return `nil`) and fetch never writes to the
1477
+ context anymore.
1478
+ - **Aliases are pure alternative names**: reads *and* writes on an alias resolve to the
1479
+ original key. `assign_aliases` no longer copies values, so `to_h` contains only the
1480
+ original keys.
1481
+ - **Key collisions raise**: declaring `expects :size` (or any key that clashes with an
1482
+ existing `Hash`/`Context` method) raises `ReservedKeysInContextError` instead of
1483
+ silently returning the wrong value. Access such data via `ctx[:size]` instead.
1484
+ - **`Some(nil)` raises `ArgumentError`**: absence is expressed with `None`.
1485
+ - **`Context#outcome` is read-only**: use `succeed!`/`fail!` to change the outcome.
1486
+ - The infrastructure keys `:_aliases`, `:_before_actions` and `:_after_actions` are
1487
+ reserved and cannot be used in `expects`/`promises`.
1488
+
1489
+ ### New guarantees and features
1490
+
1491
+ - **Declarative hooks are stable**: `before_actions`/`after_actions` declared on an
1492
+ organizer now apply to *every* call (they used to disappear after the first one).
1493
+ - **Rollback is complete** even when the same action class appears more than once in
1494
+ the pipeline.
1495
+ - **Native pattern matching**: every enum variant supports `case/in`:
1496
+
1497
+ ```ruby
1498
+ case result
1499
+ in FunctionalLightService::Result::Success[value] then value
1500
+ in FunctionalLightService::Result::Failure[error] then handle(error)
1501
+ end
1502
+ ```
1503
+
1504
+ For hot paths prefer `case/in` (or `success?`/`value`) over the `match` DSL: it is
1505
+ roughly two orders of magnitude faster.
1506
+ - **`skip_remaining!` is scoped**: inside `iterate`/`reduce_if`/`reduce_until` it skips
1507
+ the remaining *steps of the current sub-pipeline* (for `iterate`: of the current item),
1508
+ then the outer flow continues. The outcome message set by `skip_remaining!` is preserved.
1509
+ - **Deprecations** (still working, warn once on stderr): `Maybe()`/`Null` (use
1510
+ `Option`), `Result#>=` (use `try`), `Result#<<` (use `pipe`), `Result#+`/`Option#+`.
1511
+ Silence them with `FunctionalLightService::Deprecations.silenced = true`.
1512
+
1513
+ ### Threading contract
1514
+
1515
+ A `Context` is a per-call object: create it inside each organizer call (which is what
1516
+ `with` does) and do not share a live context between threads. Class-level state
1517
+ (hooks, aliases, logger) is read-only at call time, so calling the same organizer from
1518
+ multiple threads (Puma, Sidekiq) is safe.
1519
+
1468
1520
  ## Contributing
1469
1521
 
1470
1522
  1. Fork it
data/audit/bench.rb ADDED
@@ -0,0 +1,99 @@
1
+ # Micro-benchmark dei costi nascosti individuati nell'audit
2
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
3
+ require 'functional-light-service'
4
+ require 'benchmark/ips'
5
+
6
+ include FunctionalLightService::Prelude::Result
7
+ include FunctionalLightService::Prelude::Option
8
+
9
+ puts "Ruby #{RUBY_VERSION}, YJIT: #{defined?(RubyVM::YJIT) && RubyVM::YJIT.enabled? ? 'on' : 'off'}"
10
+ puts
11
+
12
+ some = Some(42)
13
+ success = Success(42)
14
+
15
+ # 1) Costo del motore match (Option#value_or usa match) vs equivalente diretto
16
+ Benchmark.ips do |x|
17
+ x.report("Option#value_or (match engine)") { some.value_or(0) }
18
+ x.report("equivalente is_a? diretto") { some.is_a?(FunctionalLightService::Option::Some) ? some.value : 0 }
19
+ x.report("case/in nativo Ruby") do
20
+ case some
21
+ in FunctionalLightService::Option::Some then some.value
22
+ else 0
23
+ end
24
+ end
25
+ x.compare!
26
+ end
27
+
28
+ # 2) match esplicito con guard (Struct.new per chiamata) su Result#+
29
+ Benchmark.ips do |x|
30
+ other = Success(1)
31
+ x.report("Result#+ (match con guard -> Struct.new/call)") { success + other }
32
+ x.report("somma diretta is_a?") do
33
+ if success.is_a?(FunctionalLightService::Result::Success) && other.is_a?(FunctionalLightService::Result::Success)
34
+ FunctionalLightService::Result::Success.new(success.value + other.value)
35
+ end
36
+ end
37
+ x.compare!
38
+ end
39
+
40
+ # 3) Result#map (bind, senza match) — per confronto: la parte "sana"
41
+ Benchmark.ips do |x|
42
+ f = ->(v) { Success(v + 1) }
43
+ x.report("Result#map via bind") { success.map(f) }
44
+ x.report("lambda diretta") { success.is_a?(FunctionalLightService::Result::Success) ? f.call(success.value) : success }
45
+ x.compare!
46
+ end
47
+
48
+ # 4) Overhead di Organizer.with: caller(1..1) + methods.include?(:call)
49
+ Benchmark.ips do |x|
50
+ klass = Class.new { def self.call; end }
51
+ x.report("caller(1..1) + methods.include?") do
52
+ c = caller(1..1).first
53
+ c =~ /`(.*)'/
54
+ klass.methods.include?(:call)
55
+ end
56
+ x.report("respond_to?(:call) soltanto") { klass.respond_to?(:call) }
57
+ x.compare!
58
+ end
59
+
60
+ # 5) define_accessor_methods_for_keys: singleton methods per ogni context
61
+ Benchmark.ips do |x|
62
+ keys = %i[number total counter]
63
+ x.report("nuovo Context + define accessor per keys") do
64
+ ctx = FunctionalLightService::Context.make(:number => 1, :total => 2, :counter => 3)
65
+ ctx.define_accessor_methods_for_keys(keys)
66
+ end
67
+ x.report("nuovo Context senza accessor") do
68
+ FunctionalLightService::Context.make(:number => 1, :total => 2, :counter => 3)
69
+ end
70
+ x.compare!
71
+ end
72
+
73
+ # 6) Context#[] con alias attivi (Hash#key reverse scan) vs Hash puro
74
+ Benchmark.ips do |x|
75
+ ctx = FunctionalLightService::Context.make(:a => 1, :b => 2, :c => 3)
76
+ ctx.assign_aliases(:a => :alfa)
77
+ h = { :a => 1, :b => 2, :c => 3 }
78
+ x.report("Context#[] (con lookup alias)") { ctx[:b] }
79
+ x.report("Hash#[] puro") { h[:b] }
80
+ x.compare!
81
+ end
82
+
83
+ # 7) Organizer end-to-end: quota di overhead per call minimale
84
+ class BenchAdd
85
+ extend FunctionalLightService::Action
86
+ expects :number
87
+ executed { |ctx| ctx[:number] = ctx[:number] + 1 }
88
+ end
89
+ class BenchOrg
90
+ extend FunctionalLightService::Organizer
91
+ def self.call(n)
92
+ with(:number => n).reduce([BenchAdd])
93
+ end
94
+ end
95
+ Benchmark.ips do |x|
96
+ x.report("Organizer.call 1 action") { BenchOrg.call(1) }
97
+ x.report("lavoro utile equivalente") { { :number => 1 }.tap { |h| h[:number] += 1 } }
98
+ x.compare!
99
+ end