flexor 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 823be7501c328a913f46121e7aa40b37fde22d73505f92928087f0d68b9355e2
4
+ data.tar.gz: 717311911cdb6e059859390ef9b238c1f524ed96d52071563cbc9f56568d119a
5
+ SHA512:
6
+ metadata.gz: 9abb50d29119d0cb6deb40e8a14dc1bed3cf860fc65eb599adb1fc8f740374fc664440dc55cc5b3868b1cbe93e44d262ea338ab544f0fc590d587d1d738ab047
7
+ data.tar.gz: 531b16d783d2f49bf68edd73a27b47a7565f17f6a465b12b64f9d9542e4d781b9b4dfdacf345b941bad847e36afdee523b8666f42d09cb6ef0dc9f4de393a9c4
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,394 @@
1
+ # ===========================================================================
2
+ # RuboCop Configuration
3
+ #
4
+ # Base: Stock RuboCop defaults
5
+ # AI guardrails: rubocop-claude plugin (all Claude/ cops + stricter metrics)
6
+ # Performance: rubocop-performance (with chain-hostile cops disabled)
7
+ #
8
+ # Philosophy: idiomatic Ruby, pipeline-style chaining, strict for AI agents,
9
+ # readable for humans.
10
+ # ===========================================================================
11
+
12
+ plugins:
13
+ - rubocop-claude
14
+ - rubocop-performance
15
+ - rubocop-rspec
16
+ - rubocop-md
17
+ - rubocop-rake
18
+
19
+ AllCops:
20
+ TargetRubyVersion: 3.4
21
+
22
+ # ===========================================================================
23
+ # Overrides from stock — personal style preferences
24
+ # ===========================================================================
25
+
26
+ # Double quotes everywhere. One less decision to make.
27
+ #
28
+ # # bad
29
+ # name = 'Alice'
30
+ #
31
+ # # good
32
+ # name = "Alice"
33
+ # greeting = "Hello, #{name}"
34
+ Style/StringLiterals:
35
+ EnforcedStyle: double_quotes
36
+
37
+ Style/StringLiteralsInInterpolation:
38
+ EnforcedStyle: double_quotes
39
+
40
+ # Frozen string literal is transitional cruft. Ruby 3.4 has chilled strings,
41
+ # full default freeze is coming in a future Ruby.
42
+ # EnforcedStyle: never actively removes existing magic comments via autocorrect.
43
+ #
44
+ # # bad — autocorrect will strip this
45
+ # # frozen_string_literal: true
46
+ #
47
+ # class Foo
48
+ # end
49
+ #
50
+ # # good
51
+ # class Foo
52
+ # end
53
+ Style/FrozenStringLiteralComment:
54
+ EnforcedStyle: never
55
+
56
+ # Pipeline style. Chaining multi-line blocks is the whole point.
57
+ #
58
+ # # good — this is how we write Ruby
59
+ # users
60
+ # .select { it.active? }
61
+ # .map(&:name)
62
+ # .sort
63
+ #
64
+ # # bad — stock rubocop wants you to break this into temp variables
65
+ # active = users.select { it.active? }
66
+ # names = active.map(&:name)
67
+ # names.sort
68
+ Style/MultilineBlockChain:
69
+ Enabled: false
70
+
71
+ # Block delimiters are a taste call. Pipeline code uses braces for chaining,
72
+ # do/end for side effects. No cop captures this nuance.
73
+ #
74
+ # # good — braces for functional transforms
75
+ # users.map { it.name.downcase }
76
+ #
77
+ # # good — do/end for side effects
78
+ # users.each do |user|
79
+ # send_notification(user)
80
+ # log_activity(user)
81
+ # end
82
+ Style/BlockDelimiters:
83
+ Enabled: false
84
+
85
+ # Write arrays like arrays.
86
+ #
87
+ # # bad
88
+ # colors = %w[red green blue]
89
+ # statuses = %i[active inactive pending]
90
+ #
91
+ # # good
92
+ # colors = ["red", "green", "blue"]
93
+ # statuses = [:active, :inactive, :pending]
94
+ Style/WordArray:
95
+ Enabled: false
96
+
97
+ Style/SymbolArray:
98
+ Enabled: false
99
+
100
+ # Argument indentation: consistent 2-space indent, not aligned to first arg.
101
+ #
102
+ # # bad — renaming the method cascades whitespace changes
103
+ # some_method(arg1,
104
+ # arg2,
105
+ # arg3)
106
+ #
107
+ # # good
108
+ # some_method(
109
+ # arg1,
110
+ # arg2,
111
+ # arg3
112
+ # )
113
+ Layout/FirstArgumentIndentation:
114
+ EnforcedStyle: consistent
115
+
116
+ # Dot-aligned chaining. Dots form a visual column.
117
+ # rubocop-claude sets indented — we override back to aligned.
118
+ #
119
+ # # bad (indented)
120
+ # users.where(active: true)
121
+ # .order(:name)
122
+ # .limit(10)
123
+ #
124
+ # # good (aligned)
125
+ # users.where(active: true)
126
+ # .order(:name)
127
+ # .limit(10)
128
+ Layout/MultilineMethodCallIndentation:
129
+ EnforcedStyle: aligned
130
+
131
+ # ===========================================================================
132
+ # Overrides from rubocop-claude — loosen where pipeline style conflicts
133
+ # ===========================================================================
134
+
135
+ # rubocop-claude sets MaxSafeNavigationChain: 1. That's too tight for
136
+ # chaining code at boundaries (controllers, API responses).
137
+ #
138
+ # # bad — 3+ deep, hiding a nil propagation problem
139
+ # user&.profile&.settings&.theme
140
+ #
141
+ # # good — two-step is normal for optional associations
142
+ # user&.profile&.avatar_url
143
+ #
144
+ # # good — trust internal code, no &. needed
145
+ # user.profile.settings.theme
146
+ Claude/NoOverlyDefensiveCode:
147
+ MaxSafeNavigationChain: 2
148
+
149
+ Style/SafeNavigation:
150
+ MaxChainLength: 2
151
+
152
+ # Allow `return a, b` for tuple-style returns.
153
+ # rubocop-claude sets this; declared here to survive load-order surprises.
154
+ #
155
+ # # bad (stock rubocop flags this)
156
+ # def swap(a, b)
157
+ # return b, a
158
+ # end
159
+ #
160
+ # # good (we allow it)
161
+ # def swap(a, b)
162
+ # return b, a
163
+ # end
164
+ #
165
+ # # still flagged — redundant single return
166
+ # def name
167
+ # return @name
168
+ # end
169
+ Style/RedundantReturn:
170
+ AllowMultipleReturnValues: true
171
+
172
+ # ===========================================================================
173
+ # Overrides from rubocop-performance — disable chain-hostile cops
174
+ # ===========================================================================
175
+ Performance:
176
+ Exclude:
177
+ - "spec/**/*"
178
+
179
+ # ChainArrayAllocation flags idiomatic pipelines for microsecond gains.
180
+ # If we need real throughput we parallelize, not uglify.
181
+ #
182
+ # # "bad" according to this cop — but we write this deliberately
183
+ # users.select(&:active?).map(&:name).sort
184
+ #
185
+ # # "good" according to this cop — mutating, unreadable, not our style
186
+ # users.select!(&:active?)
187
+ # users.map!(&:name)
188
+ # users.sort!
189
+ Performance/ChainArrayAllocation:
190
+ Enabled: false
191
+
192
+ # MapMethodChain wants to collapse chained maps into one block.
193
+ # That's the opposite of pipeline decomposition.
194
+ #
195
+ # # "bad" according to this cop — but each step is atomic and named
196
+ # users
197
+ # .map(&:name)
198
+ # .map(&:downcase)
199
+ #
200
+ # # "good" according to this cop — combines concerns into one block
201
+ # users.map { it.name.downcase }
202
+ Performance/MapMethodChain:
203
+ Enabled: false
204
+
205
+ # ===========================================================================
206
+ # Additional tightening — not set by stock or rubocop-claude
207
+ # ===========================================================================
208
+
209
+ # Short blocks push toward small chained steps instead of fat lambdas.
210
+ # Stock default is 25. rubocop-claude doesn't touch it. We want 8.
211
+ #
212
+ # # bad — too much in one block, decompose into a pipeline
213
+ # users.map { |user|
214
+ # name = user.full_name
215
+ # parts = name.split(" ")
216
+ # first = parts.first
217
+ # last = parts.last
218
+ # domain = user.email.split("@").last
219
+ # "#{first}.#{last}@#{domain}".downcase
220
+ # }
221
+ #
222
+ # # good — each step is clear
223
+ # users
224
+ # .map(&:full_name)
225
+ # .map { it.split(" ") }
226
+ # .map { "#{it.first}.#{it.last}" }
227
+ # .map(&:downcase)
228
+ Metrics/BlockLength:
229
+ Max: 8
230
+ CountAsOne:
231
+ - array
232
+ - hash
233
+ - heredoc
234
+ - method_call
235
+ AllowedMethods:
236
+ - describe
237
+ - context
238
+ - shared_examples
239
+ - shared_examples_for
240
+ - shared_context
241
+
242
+ # Anonymous forwarding (*, **, &) breaks TruffleRuby, JRuby, and
243
+ # Ruby < 3.2. Named args are explicit and portable.
244
+ #
245
+ # # bad — anonymous forwarding, not portable
246
+ # def process(*, **, &)
247
+ # other_method(*, **, &)
248
+ # end
249
+ #
250
+ # # good — named, works everywhere, readable
251
+ # def process(*args, **kwargs, &block)
252
+ # other_method(*args, **kwargs, &block)
253
+ # end
254
+ Style/ArgumentsForwarding:
255
+ Enabled: false
256
+
257
+ # Explicit begin/rescue/end is clearer than implicit method-body rescue.
258
+ # The begin block scopes what's being rescued. Without it, the rescue
259
+ # looks like it belongs to the method signature.
260
+ #
261
+ # # bad — what exactly is being rescued here?
262
+ # def process
263
+ # logger.info("starting")
264
+ # result = dangerous_call
265
+ # logger.info("done")
266
+ # rescue NetworkError => e
267
+ # retry_later(e)
268
+ # end
269
+ #
270
+ # # good — begin scopes the danger
271
+ # def process
272
+ # logger.info("starting")
273
+ # begin
274
+ # result = dangerous_call
275
+ # rescue NetworkError => e
276
+ # retry_later(e)
277
+ # end
278
+ # logger.info("done")
279
+ # end
280
+ Style/RedundantBegin:
281
+ Enabled: false
282
+
283
+ # Classes get rdoc. Run `rake rdoc` and keep it honest.
284
+ #
285
+ # # bad
286
+ # class UserService
287
+ # def call(user) = process(user)
288
+ # end
289
+ #
290
+ # # good
291
+ # # Handles user lifecycle operations including activation,
292
+ # # deactivation, and profile updates.
293
+ # class UserService
294
+ # def call(user) = process(user)
295
+ # end
296
+ Style/Documentation:
297
+ Enabled: true
298
+ Exclude:
299
+ - "spec/**/*"
300
+ - "test/**/*"
301
+
302
+ # Trailing commas in multiline literals and arguments.
303
+ # Cleaner diffs — adding an element doesn't touch the previous line.
304
+ # Also saves keystrokes when appending.
305
+ #
306
+ # # bad
307
+ # method_call(
308
+ # arg1,
309
+ # arg2
310
+ # )
311
+ #
312
+ # # good
313
+ # method_call(
314
+ # arg1,
315
+ # arg2,
316
+ # )
317
+ #
318
+ # # bad
319
+ # hash = {
320
+ # name: "Alice",
321
+ # age: 30
322
+ # }
323
+ #
324
+ # # good
325
+ # hash = {
326
+ # name: "Alice",
327
+ # age: 30,
328
+ # }
329
+ Style/TrailingCommaInArrayLiteral:
330
+ EnforcedStyleForMultiline: comma
331
+
332
+ Style/TrailingCommaInHashLiteral:
333
+ EnforcedStyleForMultiline: comma
334
+
335
+ Style/TrailingCommaInArguments:
336
+ EnforcedStyleForMultiline: comma
337
+
338
+ # ===========================================================================
339
+ # RSpec — rubocop-rspec overrides
340
+ # ===========================================================================
341
+
342
+ # Not every describe block wraps a class. Feature specs, request specs,
343
+ # and integration tests describe behavior, not objects.
344
+ #
345
+ # # "bad" according to this cop — but perfectly valid
346
+ # RSpec.describe "User registration flow" do
347
+ # it "sends a welcome email" do
348
+ # ...
349
+ # end
350
+ # end
351
+ #
352
+ # # this cop only wants
353
+ # RSpec.describe User do
354
+ # ...
355
+ # end
356
+ RSpec/DescribeClass:
357
+ Enabled: false
358
+
359
+ # Subject placement is a readability call, not a rule. Sometimes
360
+ # let blocks need to come first to make subject comprehensible.
361
+ #
362
+ # # "bad" according to this cop
363
+ # let(:user) { create(:user) }
364
+ # subject { described_class.new(user) }
365
+ #
366
+ # # cop wants subject first — but then you're reading
367
+ # # `described_class.new(user)` before knowing what `user` is
368
+ RSpec/LeadingSubject:
369
+ Enabled: false
370
+
371
+ # Block style for expect { }.to change { } reads like a sentence
372
+ # and makes the mutation scope explicit.
373
+ #
374
+ # # bad (method style)
375
+ # expect { user.activate! }.to change(user, :active?).from(false).to(true)
376
+ #
377
+ # # good (block style)
378
+ # expect { user.activate! }.to change { user.active? }.from(false).to(true)
379
+ RSpec/ExpectChange:
380
+ EnforcedStyle: block
381
+
382
+ RSpec/NamedSubject:
383
+ Enabled: false
384
+
385
+ # PROJECT SPECIFIC:
386
+ #
387
+ # Behavior is very intricate here and warrants separation
388
+ RSpec/SpecFilePathFormat:
389
+ Enabled: false
390
+
391
+ RSpec/NestedGroups:
392
+ Enabled: false
393
+ RSpec/MultipleExpectations:
394
+ Max: 3
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 3.4.8
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 David Gillis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,218 @@
1
+ # Flexor
2
+
3
+ A Hash-like data store that does what you tell it to do.
4
+
5
+ Flexor gives you autovivifying nested access, nil-safe chaining, and seamless conversion between hashes and method-style access. Built for spikes, prototyping, and anywhere you need a flexible data container without upfront schema design.
6
+
7
+ ## Installation
8
+
9
+ Install the gem and add to the application's Gemfile by executing:
10
+
11
+ $ bundle add flexor
12
+
13
+ If bundler is not being used to manage dependencies, install the gem by executing:
14
+
15
+ $ gem install flexor
16
+
17
+ ## Usage
18
+
19
+ ### Construction
20
+
21
+ From a hash:
22
+
23
+ ```ruby
24
+ store = Flexor.new({ user: { name: "Alice", address: { city: "NYC" } } })
25
+ store.user.name # => "Alice"
26
+ store.user.address.city # => "NYC"
27
+ ```
28
+
29
+ From JSON:
30
+
31
+ ```ruby
32
+ store = Flexor.from_json('{"api": {"version": 2}}')
33
+ store.api.version # => 2
34
+ ```
35
+
36
+ ### Accessing Properties
37
+
38
+ Method access and bracket access are interchangeable:
39
+
40
+ ```ruby
41
+ store = Flexor.new({ name: "Alice" })
42
+ store.name # => "Alice"
43
+ store[:name] # => "Alice"
44
+ ```
45
+
46
+ Nested chaining works to any depth:
47
+
48
+ ```ruby
49
+ store = Flexor.new
50
+ store.config.database.host = "localhost"
51
+ store.config.database.port = 5432
52
+ store.config.database.host # => "localhost"
53
+ ```
54
+
55
+ ### Safe Chaining
56
+
57
+ Accessing an unset property returns a nil-like Flexor instead of raising. You can chain as deep as you want without guard clauses:
58
+
59
+ ```ruby
60
+ store = Flexor.new
61
+ store.anything.deeply.nested.nil? # => true
62
+ store.missing.nil? # => true
63
+ store.missing.to_s # => ""
64
+ "Hello #{store.ghost}" # => "Hello "
65
+ ```
66
+
67
+ ### Assignment Vivifies
68
+
69
+ When you assign a hash or array of hashes, Flexor auto-converts them so chaining continues to work:
70
+
71
+ ```ruby
72
+ store = Flexor.new
73
+ store.config = { db: { host: "localhost" } }
74
+ store.config.db.host # => "localhost"
75
+ store[:config].class # => Flexor
76
+
77
+ store.items = [{ id: 1 }, { id: 2 }]
78
+ store.items.first.id # => 1
79
+ ```
80
+
81
+ ### Deep Merge
82
+
83
+ `merge` returns a new Flexor; `merge!` mutates in place. Both deep merge nested structures:
84
+
85
+ ```ruby
86
+ defaults = Flexor.new({ db: { host: "localhost", port: 5432 }, log: "info" })
87
+ overrides = { db: { port: 3306, name: "mydb" }, log: "debug" }
88
+
89
+ config = defaults.merge(overrides)
90
+ config.db.host # => "localhost" (preserved from defaults)
91
+ config.db.port # => 3306 (overridden)
92
+ config.db.name # => "mydb" (added)
93
+ config.log # => "debug" (overridden)
94
+ ```
95
+
96
+ ### Serialization
97
+
98
+ `to_json` converts to JSON via `to_h`:
99
+
100
+ ```ruby
101
+ store = Flexor.new({ user: { name: "Alice" }, tags: ["admin"] })
102
+ store.to_json # => '{"user":{"name":"Alice"},"tags":["admin"]}'
103
+
104
+ # Round-trips with from_json
105
+ Flexor.from_json(store.to_json).user.name # => "Alice"
106
+ ```
107
+
108
+ ### Deleting Keys
109
+
110
+ ```ruby
111
+ store = Flexor.new({ a: 1, b: 2, c: 3 })
112
+ store.delete(:b) # => 2
113
+ store.to_h # => { a: 1, c: 3 }
114
+ ```
115
+
116
+ ### Raw Storage
117
+
118
+ If you need to store a raw Hash without conversion, use `set_raw`:
119
+
120
+ ```ruby
121
+ store = Flexor.new
122
+ store.set_raw(:headers, { "Content-Type" => "application/json" })
123
+ store[:headers].class # => Hash
124
+ ```
125
+
126
+ ### Converting Back
127
+
128
+ `to_h` recursively converts back to plain hashes. Round-trips are lossless:
129
+
130
+ ```ruby
131
+ original = { users: [{ name: "Bob" }], meta: { version: 1 } }
132
+ Flexor.new(original).to_h == original # => true
133
+ ```
134
+
135
+ Autovivified-but-never-written paths don't appear in `to_h`:
136
+
137
+ ```ruby
138
+ store = Flexor.new({ real: "data" })
139
+ store.phantom.deep.chain # read-only access
140
+ store.to_h # => { real: "data" }
141
+ ```
142
+
143
+ ### Pattern Matching
144
+
145
+ Hash patterns via `deconstruct_keys`:
146
+
147
+ ```ruby
148
+ config = Flexor.new({ db: { host: "pg", port: 5432 }, cache: "redis" })
149
+
150
+ case config
151
+ in { db: { host: String => host }, cache: "redis" }
152
+ puts "db host=#{host}, cache=redis"
153
+ end
154
+ ```
155
+
156
+ Array patterns via `deconstruct`:
157
+
158
+ ```ruby
159
+ point = Flexor.new({ x: 3, y: 4 })
160
+
161
+ case point
162
+ in [Integer => x, Integer => y]
163
+ puts "Point(#{x}, #{y})"
164
+ end
165
+ ```
166
+
167
+ ### Hash-like Methods
168
+
169
+ ```ruby
170
+ store = Flexor.new({ a: 1, b: 2, c: 3 })
171
+ store.keys # => [:a, :b, :c]
172
+ store.values # => [1, 2, 3]
173
+ store.size # => 3
174
+ store.empty? # => false
175
+ store.key?(:a) # => true
176
+
177
+ store.each { |k, v| puts "#{k}: #{v}" }
178
+ store.map { |k, v| [k, v * 10] }
179
+ store.select { |_k, v| v > 1 }
180
+ ```
181
+
182
+ ### Equality
183
+
184
+ ```ruby
185
+ a = Flexor.new({ x: 1 })
186
+ b = Flexor.new({ x: 1 })
187
+ a == b # => true
188
+ a == { x: 1 } # => true
189
+ Flexor.new.nil? # => true
190
+ ```
191
+
192
+ ### Freezing
193
+
194
+ ```ruby
195
+ store = Flexor.new({ locked: true })
196
+ store.freeze
197
+ store.locked # => true
198
+ store.new_key = 1 # => FrozenError
199
+ ```
200
+
201
+ ### Copying
202
+
203
+ `dup` and `clone` create shallow copies:
204
+
205
+ ```ruby
206
+ original = Flexor.new({ a: 1 })
207
+ copy = original.dup
208
+ copy.b = 2
209
+ original.key?(:b) # => false
210
+ ```
211
+
212
+ ## Development
213
+
214
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. Run `rake` to run both tests and rubocop.
215
+
216
+ ## License
217
+
218
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+ require "rubocop/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+ RuboCop::RakeTask.new
7
+
8
+ task default: [:spec, :rubocop]