hecks 0.3.0 → 1.0.1

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 (202) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sql_query_builder.rb +1 -1
  14. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  15. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  16. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  17. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  18. data/lib/hecks/adapters/driven.rb +19 -1
  19. data/lib/hecks/behaviors/dsl.rb +29 -0
  20. data/lib/hecks/behaviors/expectations.rb +62 -2
  21. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  22. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  23. data/lib/hecks/bluebook/assembly.rb +14 -1
  24. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  25. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  26. data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
  27. data/lib/hecks/bluebook/chapter.rb +21 -11
  28. data/lib/hecks/bluebook/command.rb +1 -1
  29. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  30. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  31. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  32. data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
  33. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
  34. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  35. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  36. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  37. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  39. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  40. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  41. data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
  42. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  43. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  44. data/lib/hecks/bluebook/expression/projection.json +48 -0
  45. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  46. data/lib/hecks/bluebook/expression.rb +1 -0
  47. data/lib/hecks/bluebook/hexagon.rb +1 -1
  48. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  49. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  50. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  51. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  52. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  53. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  54. data/lib/hecks/bluebook/model_check.rb +147 -6
  55. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  56. data/lib/hecks/bluebook/process_manager.rb +53 -11
  57. data/lib/hecks/bluebook/project_register.rb +25 -0
  58. data/lib/hecks/bluebook/value_object.rb +19 -2
  59. data/lib/hecks/doc/reference.rb +22 -1
  60. data/lib/hecks/facade/cli_door.rb +6 -3
  61. data/lib/hecks/facade/json_door.rb +16 -4
  62. data/lib/hecks/forms/app.rb +47 -6
  63. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  64. data/lib/hecks/forms/field_renderer.rb +11 -4
  65. data/lib/hecks/forms/html.rb +31 -0
  66. data/lib/hecks/forms/params.rb +30 -1
  67. data/lib/hecks/forms/port_argument.rb +46 -0
  68. data/lib/hecks/forms/record_renderer.rb +6 -2
  69. data/lib/hecks/forms/record_table.rb +6 -1
  70. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  71. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  72. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  73. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  74. data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
  75. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  76. data/lib/hecks/fuzzing/properties.rb +52 -6
  77. data/lib/hecks/fuzzing/replay.rb +51 -18
  78. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  79. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  80. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  81. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  82. data/lib/hecks/grammar/evolve.rb +33 -0
  83. data/lib/hecks/grammar/expression.bluebook +8 -5
  84. data/lib/hecks/grammar/expression_operators.json +497 -1
  85. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  86. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  87. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  88. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  89. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  90. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  91. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  92. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  93. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  94. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  95. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  96. data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
  97. data/lib/hecks/language/oidc.json +20 -0
  98. data/lib/hecks/language/port.bluebook +30 -2
  99. data/lib/hecks/naming.rb +54 -1
  100. data/lib/hecks/ports/access_control.port +7 -2
  101. data/lib/hecks/ports/access_control.rb +1 -1
  102. data/lib/hecks/ports/agent.port +6 -2
  103. data/lib/hecks/ports/agent.rb +1 -1
  104. data/lib/hecks/ports/authentication.port +4 -2
  105. data/lib/hecks/ports/authentication.rb +1 -1
  106. data/lib/hecks/ports/authorization.port +5 -2
  107. data/lib/hecks/ports/authorization.rb +14 -11
  108. data/lib/hecks/ports/clock.port +3 -2
  109. data/lib/hecks/ports/clock.rb +1 -1
  110. data/lib/hecks/ports/extraction.port +3 -2
  111. data/lib/hecks/ports/extraction.rb +1 -1
  112. data/lib/hecks/ports/identity_assignment.port +3 -2
  113. data/lib/hecks/ports/identity_assignment.rb +1 -1
  114. data/lib/hecks/ports/identity_generation.port +3 -2
  115. data/lib/hecks/ports/identity_generation.rb +1 -1
  116. data/lib/hecks/ports/identity_resolution.port +3 -2
  117. data/lib/hecks/ports/identity_resolution.rb +1 -1
  118. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  119. data/lib/hecks/ports/persistence/execution.rb +6 -1
  120. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  121. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  122. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  123. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  129. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  130. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  131. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  132. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  133. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  134. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  135. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  136. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  137. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  138. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  139. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  140. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  141. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  142. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  143. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  144. data/lib/hecks/ports/persistence.rb +1 -1
  145. data/lib/hecks/ports/projection.rb +20 -0
  146. data/lib/hecks/projections/diagrams.rb +230 -1
  147. data/lib/hecks/projections/shape.rb +15 -2
  148. data/lib/hecks/projector/cli_projector.rb +10 -1
  149. data/lib/hecks/projector/exporter.rb +19 -2
  150. data/lib/hecks/query_ir.rb +19 -0
  151. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  152. data/lib/hecks/query_specification/field_path.rb +20 -2
  153. data/lib/hecks/query_specification/hop_path.rb +7 -5
  154. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  155. data/lib/hecks/runtime/boot_gates.rb +41 -0
  156. data/lib/hecks/runtime/caller.rb +19 -3
  157. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  158. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  159. data/lib/hecks/runtime/command_interpreter.rb +165 -34
  160. data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
  161. data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
  162. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  163. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  164. data/lib/hecks/runtime/dependency_planning.rb +66 -5
  165. data/lib/hecks/runtime/dispatcher.rb +19 -4
  166. data/lib/hecks/runtime/entity_interpreter.rb +92 -14
  167. data/lib/hecks/runtime/errors.rb +22 -0
  168. data/lib/hecks/runtime/identity.rb +30 -2
  169. data/lib/hecks/runtime/instance.rb +59 -4
  170. data/lib/hecks/runtime/interpreting.rb +21 -0
  171. data/lib/hecks/runtime/loader.rb +59 -18
  172. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  173. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  174. data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
  175. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  176. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  177. data/lib/hecks/runtime/registry/verification.rb +88 -0
  178. data/lib/hecks/runtime/registry.rb +69 -8
  179. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  180. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  181. data/lib/hecks/runtime/value/admission.rb +19 -1
  182. data/lib/hecks/runtime/value/coercion.rb +75 -10
  183. data/lib/hecks/runtime.rb +17 -5
  184. data/lib/hecks/storehouse.rb +632 -0
  185. data/lib/hecks/version.rb +1 -1
  186. data/lib/hecks/vocabulary.rb +6 -1
  187. data/lib/hecks.rb +28 -3
  188. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  189. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  190. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  191. metadata +49 -37
  192. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  193. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  194. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  195. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  196. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  197. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  198. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  199. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  200. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  201. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  202. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -53,6 +53,14 @@ module Hecks
53
53
  # argument became indistinguishable from one carrying a literal string.
54
54
  def with_spec_rows(node) = pair_rows(node.to_h[:with_spec])
55
55
 
56
+ # THE SAME READ, ONE LEVEL IN — `compensates` folds `DispatchSpec`
57
+ # into the language's own `compensates_command_name`/
58
+ # `compensates_with_spec` (`Assembly::Contracts`' own comment on
59
+ # "Dispatch"), so its OWN with_spec pairs live nested one hash
60
+ # down from where `with_spec_rows` looks. `&.dig(...)` — no
61
+ # compensation at all is not an error, it is `pair_rows(nil)`, empty.
62
+ def compensates_with_spec_rows(node) = pair_rows(node.to_h[:compensates]&.dig(:with_spec))
63
+
56
64
  # A read model carries the same options an ask does, plus its filters — see
57
65
  # option_rows.
58
66
  def read_model_option_rows(node) = option_rows(node, filters: true)
@@ -155,10 +163,12 @@ module Hecks
155
163
  # was being handed a blank and could never refuse.
156
164
  def mutation_rows(node)
157
165
  Array(node.mutations).flat_map do |mutation|
158
- # `:delegate` (CommandBuilder#delegates_to's own comment) rides
159
- # the SAME multi-binding shape `:append` does — `with: {...}`
160
- # is a field map, same as append's own `fields:`.
161
- next set_row(mutation) unless [:append, :delegate].include?(mutation.op)
166
+ # `:delegate`/`:corrects` (CommandBuilder#delegates_to's and
167
+ # #corrects_impl's own comments) ride the SAME multi-binding
168
+ # shape `:append` does `with: {...}`/the assembled
169
+ # `as:`/`reason:`/`reverses:` hash is a field map, same as
170
+ # append's own `fields:`.
171
+ next set_row(mutation) unless [:append, :delegate, :corrects].include?(mutation.op)
162
172
 
163
173
  mutation.source.map do |field, argument|
164
174
  # Spelled the way Mutation#appended_fields spells it, because
@@ -207,9 +207,22 @@ module Hecks
207
207
  # at a time.
208
208
  def members_row(row) = members_of(row)
209
209
 
210
+ # THE KEY IS STRINGIFIED, NEVER THE VALUE — the same split
211
+ # `Bluebook::ValueObject#to_h`'s own `members:` emission makes
212
+ # (lib/hecks/bluebook/value_object.rb, L7 docs/audits/
213
+ # 2026-08-11-bug-triage.md). `Pair.value` is declared `String`
214
+ # (shape.bluebook), but a real pair can hold whatever native Ruby
215
+ # type the source member line actually wrote (`StatementFrequency`'s
216
+ # own `retention_months: 84`, examples/banking/bluebook/
217
+ # statements.bluebook) — `text` already unwraps the stored Value to
218
+ # that real type, so calling `.to_s` here erased it a second time,
219
+ # on the side spec/round_trip_spec.rb compares straight against a
220
+ # fresh raw load (`Reconstruction.of` directly, no Assembly in
221
+ # between): `declared 84, read back "84"` the moment `to_h` stopped
222
+ # erasing it on the OTHER side and this one kept erasing it alone.
210
223
  def members_of(value_object_row)
211
224
  Array(value_object_row[:members]).map do |member|
212
- Array(member[:pairs]).map { |pair| [text(pair[:key]).to_s, text(pair[:value]).to_s] }
225
+ Array(member[:pairs]).map { |pair| [text(pair[:key]).to_s, text(pair[:value])] }
213
226
  end
214
227
  end
215
228
 
@@ -238,7 +251,12 @@ module Hecks
238
251
  # Symbol there — the IR was not uniform about it, and only a round trip
239
252
  # ever said so.
240
253
  identified_by: identity_paths(row),
241
- attributes: Array(row[:attributes]).map { |field| shape_field(field) },
254
+ # `text(row[:aggregate])` the OWNING aggregate, the same one
255
+ # `Judge#owning_aggregate_ref` resolved this piece's own
256
+ # attribute types against on the way in, so reconstruction reads
257
+ # a value-object-typed attribute back the identical way an
258
+ # aggregate's own is (`Shapes#shape_field`'s own comment).
259
+ attributes: Array(row[:attributes]).map { |field| shape_field(field, text(row[:aggregate])) },
242
260
  # ADR 0028 — the SAME shape `aggregate(row)`'s own
243
261
  # `preconditions:` reads two hand-typed methods up, read by
244
262
  # hand for the identical reason: `entity(row)` is hand-typed
@@ -301,7 +319,28 @@ module Hecks
301
319
  dispatches: Array(row[:dispatches]).map { |leg| dispatch(leg) })
302
320
  end
303
321
 
304
- def dispatch(row) = declaration("Dispatch", row)
322
+ # `compensates` TWO FLAT FIELDS on this same row
323
+ # (`process_manager.bluebook`'s own comment on `Dispatch` for
324
+ # why), assembled BY HAND into the shape its own field actually
325
+ # is — `declaration()`'s generic per-field hash-build has no
326
+ # way to turn two cells into a second object, so this reads
327
+ # them directly and passes the result through `extra:`, the
328
+ # same seam `handler`/`process_manager` already use for a
329
+ # `:children` shape a flat Contract cannot describe.
330
+ # `compensates_command_name` absent means no compensation at
331
+ # all — a plain dispatch with nothing to undo. A PLAIN HASH, the
332
+ # SAME declaration shape `to_h` spells for everything else in
333
+ # this file (this file's own top comment) — never a real
334
+ # `DispatchSpec` here; `Assembly#dispatch` is the one place a
335
+ # declaration hash becomes the real object, and building it
336
+ # twice, in two different shapes, is exactly the kind of drift
337
+ # this whole arc exists to remove.
338
+ def dispatch(row)
339
+ name = text(row[:compensates_command_name])
340
+ compensates = name && { command_name: name, with_spec: pairs(row[:compensates_with_spec]) }
341
+
342
+ declaration("Dispatch", row, compensates: compensates)
343
+ end
305
344
 
306
345
  def read_model(row)
307
346
  declaration("ReadModel", row).merge(query_name: text(row[:query_name])).merge(options_of(row))
@@ -52,14 +52,33 @@ module Hecks
52
52
  def identity_path(part) = text(part[:value]).to_s
53
53
 
54
54
  # An argument, a parameter, a piece's attribute — the three places an
55
- # attribute is written that carry no owner id to strip.
55
+ # attribute is written that carry no owner id to strip... except a
56
+ # piece's own attribute now does : an entity is its own root (repeating
57
+ # the aggregate's whole shape one level down), so ITS attributes name a
58
+ # value object exactly the way an aggregate's own do (`Judge#cell`'s own
59
+ # comment), and reconstruction has to undo the same owned-vs-reference
60
+ # split `attribute`, below, already undoes for an aggregate — not the
61
+ # cruder "any qualified name is a Reference" guess this used before,
62
+ # which mistook a piece's own value-object type for a cross-aggregate
63
+ # head every time.
56
64
  #
57
- # A REFERENCE among them came in as the head's ID, because that is what
58
- # Command/Entity/Query.Reference offer, so it goes back out as the
59
- # encoding the IR spells. A qualified name is the tell : an ordinary
60
- # type names something declared beside it (`Money`, `AccountNumber`) and
61
- # never carries a chapter, while a head's id always does.
62
- def shape_field(field)
65
+ # `aggregate_id`, passed only for a piece's own attribute (an
66
+ # argument or a parameter carries no value-object type at all — see
67
+ # entity.bluebook's/command.bluebook's own comments on why so the
68
+ # generic reader path calls this with one argument and gets the
69
+ # OLD reading unchanged), is the piece's OWNING aggregate — the same
70
+ # one its own attribute types were resolved against on the way in
71
+ # (`Judge#owning_aggregate_ref`).
72
+ #
73
+ # A REFERENCE among the other two came in as the head's ID, because
74
+ # that is what Command/Query.Reference offer, so it goes back out as
75
+ # the encoding the IR spells. A qualified name is the tell : an
76
+ # ordinary type names something declared beside it (`Money`,
77
+ # `AccountNumber`) and never carries a chapter, while a head's id
78
+ # always does.
79
+ def shape_field(field, aggregate_id = nil)
80
+ return attribute(field, aggregate_id) if aggregate_id
81
+
63
82
  type = text(field[:type]).to_s
64
83
 
65
84
  {
@@ -221,9 +240,10 @@ module Hecks
221
240
  # language does not hold is a named gap, not a byte-for-byte one).
222
241
  def mutation(target, op, bindings)
223
242
  base = { target: target.to_sym, op: op.to_sym, sign: Hecks::Bluebook::Mutation.sign_for(op) }
224
- # `:delegate` (CommandBuilder#delegates_to's own comment) rides
225
- # the SAME multi-binding shape `:append` does.
226
- return base.merge(fields: appended(bindings)) if ["append", "delegate"].include?(op)
243
+ # `:delegate`/`:corrects` (CommandBuilder#delegates_to's and
244
+ # #corrects_impl's own comments) ride the SAME multi-binding
245
+ # shape `:append` does.
246
+ return base.merge(fields: appended(bindings)) if ["append", "delegate", "corrects"].include?(op)
227
247
 
228
248
  base.merge(source: classified(bindings.first))
229
249
  end
@@ -8,16 +8,39 @@ module Hecks
8
8
  # `lib/hecks/language/bluebook/` declares what a bluebook IS —
9
9
  # Chapter, Root, Verb, Shape, Ask, Piece, and the rest, split across files
10
10
  # by domain concept and merged into one chapter at load time (see
11
- # GRAMMAR_FILES below) and carries the language's rules as `given` and
12
- # `invariant` rather than as `raise Malformed` scattered across seven
13
- # builder files. This replays a built IR into that domain and turns any
14
- # refusal into a Malformed, so the meta-domain is what actually judges
15
- # rather than a description sitting beside the code.
11
+ # GRAMMAR_FILES below). This replays a built IR into that domain and turns
12
+ # any refusal into a Malformed, so the meta-domain is what actually judges
13
+ # rather than a description sitting beside the code for whatever rules
14
+ # it carries. `spec/meta_rules_spec.rb`'s own header names the plan: port
15
+ # the language's rules OUT of builder `raise Malformed` calls and INTO
16
+ # `given`/`invariant` here, where they are declarations any reader of the
17
+ # meta-domain can consume instead of behavior buried in a builder.
16
18
  #
17
- # Delete language/bluebook/ and validation stops. That is the whole
18
- # point : a self-description that only describes is indistinguishable from
19
- # enforcement, and the first version of this file was deleted for exactly
20
- # that reason.
19
+ # THIS MIGRATION IS PARTIAL, NOT DONE. As of this writing the meta-domain
20
+ # declares 62 given/invariant/ensures rules (`Hecks::QueryIR.collect_rules`
21
+ # against `grammar_registry.bluebook("Bluebook")` enumerates them)
22
+ # `spec/meta_rule_reachability_spec.rb` is what proves, per declaration,
23
+ # not per verb, that most of them still lack a spec exercising the
24
+ # refusal at all (see that file's own KNOWN_GAPS for the current count).
25
+ # Meanwhile `lib/hecks/bluebook/dsl/` still carries well over a hundred
26
+ # `raise Malformed` calls of its own — some are genuinely pre-IR
27
+ # construction errors (arity, argument shape) that cannot become
28
+ # meta-domain rules, and some are exactly the semantic kind this file
29
+ # claims to have moved (see `aggregate_builder.rb`'s `seal_*` passes:
30
+ # a mutation into a field the aggregate never declares, a lifecycle guard
31
+ # on an aggregate with no lifecycle). No doc currently inventories which
32
+ # is which, or tracks migrating the latter — that inventory is the actual
33
+ # next step, not a "delete the folder" thought experiment.
34
+ #
35
+ # So: "delete language/bluebook/ and validation stops" is true for the 62
36
+ # rules actually declared here, and false for whatever a builder's own
37
+ # `raise Malformed` still checks — the language does not yet own its own
38
+ # enforcement end to end, and this comment used to claim it already did.
39
+ # The self-hosting mechanism itself is real and is the point worth
40
+ # keeping : a self-description that only describes is indistinguishable
41
+ # from enforcement, and the first version of this file was deleted for
42
+ # exactly that reason. What is not yet real is that self-hosting being
43
+ # the WHOLE of validation.
21
44
  #
22
45
  # The meta-domain is loaded ONCE and its registry reused ; each bluebook is
23
46
  # judged in a fresh in-memory store so no domain can see another's records.
@@ -131,6 +154,12 @@ module Hecks
131
154
  # dead code, never actually invoked.
132
155
  builder = registry.bluebook_builder(name) { raise "internal: no open builder for #{name}" }
133
156
  builder.resolve_pending_chapter_givens!
157
+ # THE ENTITY-SCOPED ANALOGUE, one level down — same reason,
158
+ # same timing: a bare entity-level given left PENDING by any
159
+ # file of this chapter (`EntityBuilder#pending_chapter_entity_
160
+ # given`) must resolve before anything below reads a piece's
161
+ # own `Given` fields too.
162
+ builder.resolve_pending_chapter_entity_givens!
134
163
 
135
164
  # `BluebookBuilder#build` skipped its own whole-chapter battery
136
165
  # (hops, projected fields, correlation keys, event shapes,
@@ -147,7 +176,48 @@ module Hecks
147
176
  end
148
177
  end
149
178
 
150
- def self.disabled? = ENV["HECKS_META_VALIDATION"] == "off"
179
+ # `&& !@forcing_fixpoint` see `while_forcing_fixpoint` below, whose own
180
+ # window must win even while a growth spec's `while_disabled` is open,
181
+ # for the reason recorded there. Otherwise the SAME stack-restore shape
182
+ # `while_shadow_parsing`/`while_forcing_fixpoint` use, not a bare env
183
+ # toggle any more — it used to be exactly that (`ENV["HECKS_META_
184
+ # VALIDATION"] == "off"`, read directly, with no `previous`/`ensure` of
185
+ # its own), and the gap between "bare toggle" and "stack-restore" was
186
+ # not cosmetic: a test's temporary window could reach code it was never
187
+ # meant to touch. If `grammar_registry`'s ONE-TIME lazy build (below)
188
+ # happened to land inside that window, EVERY language chapter got
189
+ # cached in its raw, never-judged form for the rest of the process —
190
+ # `unmark_scalar`'s String->Integer/Boolean fix (assembly/marks.rb)
191
+ # never ran, so a `Command`'s own `required: true` stayed
192
+ # `required: "true"` forever after, permanently memoized. Found live:
193
+ # an intermittent, parallel_rspec-only ir_golden_spec.rb failure,
194
+ # order-dependent on whether identifier_numeric_coercion_growth_spec.rb's
195
+ # disabled-validation window raced the ONE lazy build in its own worker
196
+ # process — reproduced in isolation by disabling validation before the
197
+ # first `grammar_registry` call. `&& !@forcing_fixpoint` was the first
198
+ # fix and is kept ; converting `@disabled` itself to this shape closes
199
+ # the gap for every OTHER caller of `while_disabled`, not just the one
200
+ # race that was actually observed — nothing outside this file reads
201
+ # `ENV["HECKS_META_VALIDATION"]` any more (confirmed: every one of the
202
+ # dozen growth specs that used to hand-roll `previous = ENV[...] ;
203
+ # ENV[...] = "off" ; ... ; ensure ENV[...] = previous` now calls
204
+ # `while_disabled` instead), so there is no bare global left to race.
205
+ def self.disabled? = @disabled && !@forcing_fixpoint
206
+
207
+ # THE SAME STACK-RESTORE SHAPE `while_shadow_parsing`/`while_forcing_
208
+ # fixpoint` USE. This toggle's real, intended use is a growth spec
209
+ # that boots a scratch bluebook from a tempfile and wants the runtime
210
+ # behaviour without the validation overhead ; that is always a single
211
+ # bounded window around one boot, never a flag meant to survive past
212
+ # it, so the flag itself is scoped in the same `previous`/`ensure`
213
+ # shape rather than a plain assignment a caller could forget to undo.
214
+ def self.while_disabled
215
+ previous = @disabled
216
+ @disabled = true
217
+ yield
218
+ ensure
219
+ @disabled = previous
220
+ end
151
221
 
152
222
  # ADR 0025's own prerequisite (docs/dsl-work-slices.md, S0a): a word
153
223
  # a later slice removes from the LIVE grammar must still parse
@@ -175,6 +245,21 @@ module Hecks
175
245
  @shadow_parsing = previous
176
246
  end
177
247
 
248
+ # THE SAME STACK-RESTORE SHAPE `while_shadow_parsing` USES, for the
249
+ # same reason: whatever this wraps must never see `disabled?` answer
250
+ # true, however a test elsewhere has the env toggle set at that
251
+ # exact moment. Only `grammar_registry`'s own one-time build (below)
252
+ # wraps itself in this — nothing else needs it, and nothing else
253
+ # should reach for it just to dodge `disabled?` for a domain
254
+ # bluebook, which is precisely the toggle's real, intended use.
255
+ def self.while_forcing_fixpoint
256
+ previous = @forcing_fixpoint
257
+ @forcing_fixpoint = true
258
+ yield
259
+ ensure
260
+ @forcing_fixpoint = previous
261
+ end
262
+
178
263
  # The same bluebook judged twice gets the same verdict, and a suite reloads
179
264
  # its fixtures constantly — banking alone is ~200 dispatches per build.
180
265
  # Keyed on the IR itself, so a CHANGED bluebook is always re-judged.
@@ -348,9 +433,14 @@ module Hecks
348
433
  # judged from here on is judged by the language the language itself
349
434
  # produced. Outside load_grammar_into on purpose : its ensure clears
350
435
  # @bootstrapping, and call() must see bootstrapping? == false to do
351
- # anything at all.
352
- LANGUAGE_CHAPTERS.each { |name| registry.add_bluebook(call(registry.bluebook(name))) }
353
- load_attached_grammar_into(registry)
436
+ # anything at all. `while_forcing_fixpoint`-wrapped so a growth
437
+ # spec's own `while_disabled` window can never leave this
438
+ # ONE-TIME build cached in its raw, never-judged form — see
439
+ # `disabled?`'s own comment.
440
+ while_forcing_fixpoint do
441
+ LANGUAGE_CHAPTERS.each { |name| registry.add_bluebook(call(registry.bluebook(name))) }
442
+ load_attached_grammar_into(registry)
443
+ end
354
444
  # Stamped LAST, keyed by this registry's own identity rather than
355
445
  # a bare boolean — a manual reset (fixpoint_spec.rb's own
356
446
  # `@grammar_registry = nil`) makes @grammar_registry not equal
@@ -46,18 +46,51 @@ module Hecks
46
46
  # states), so a state nothing ever transitions into or out of no
47
47
  # longer exists to be unreachable — the finding this allowlisted
48
48
  # cannot occur any more, by construction.
49
- ALLOWED_FINDINGS = {}.freeze
49
+ #
50
+ # "banking"/NotifyOnClosure, FlagKeyReturn — real, confirmed
51
+ # findings, not bugs to fix. `across "Notifications"` names a
52
+ # domain that does not exist anywhere in this repo — no
53
+ # Notifications bluebook, no hecksagon, nothing to `uses_framework`
54
+ # or `subscribe` to. This is deliberate: `spec/runtime/policy_spec.
55
+ # rb` (a test literally named "records a reaction it cannot
56
+ # deliver rather than swallowing it") and `lib/hecks/runtime/
57
+ # errors.rb`'s own `UnknownVerb` comment both treat "target domain
58
+ # not loaded" as the EXPECTED outcome for it — Notifications is
59
+ # used on purpose to exercise the undelivered-reaction runtime
60
+ # path, not left half-built. There is no real `subscribe` line to
61
+ # add (no event of Notifications' own to name) and no real domain
62
+ # to point `uses_framework` at.
63
+ ALLOWED_FINDINGS = {
64
+ "banking" => [
65
+ [:unacknowledged_relationship, "NotifyOnClosure"],
66
+ [:unknown_target_domain, "NotifyOnClosure"],
67
+ [:unacknowledged_relationship, "FlagKeyReturn"],
68
+ [:unknown_target_domain, "FlagKeyReturn"]
69
+ ]
70
+ }.freeze
50
71
 
51
72
  module_function
52
73
 
53
- def call(bluebook)
74
+ # `hecksagon:`/`known_domains:` — both optional, both `nil`-safe
75
+ # (every existing caller with no sibling hecksagon, or checking one
76
+ # domain in isolation, behaves exactly as before). `hecksagon` is
77
+ # THIS bluebook's own sibling wiring file, if the caller loaded one
78
+ # (see `emitted_events`'s own comment on why a caller that didn't
79
+ # simply finds none, correctly). `known_domains` is the caller's
80
+ # OWN corpus-wide view — every bluebook/hecksagon name it has
81
+ # booted anywhere, across every domain it has looked at, not just
82
+ # this one — used only to catch a typo'd `across`/`uses_framework`
83
+ # target; see `cross_domain_policy_findings`'s own comment for why
84
+ # this can only ever be a corpus-scoped heuristic, never a general
85
+ # correctness guarantee.
86
+ def call(bluebook, hecksagon: nil, known_domains: nil)
54
87
  findings = []
55
88
  bluebook.aggregates.each do |aggregate|
56
89
  findings.concat(lifecycle_findings(aggregate, aggregate))
57
90
  aggregate.entities.each { |entity| findings.concat(lifecycle_findings(aggregate, entity)) }
58
91
  end
59
92
  bluebook.process_managers.each { |pm| findings.concat(saga_findings(bluebook, pm)) }
60
- bluebook.policies.each { |policy| findings.concat(policy_findings(bluebook, policy)) }
93
+ bluebook.policies.each { |policy| findings.concat(policy_findings(bluebook, policy, hecksagon, known_domains)) }
61
94
  findings
62
95
  end
63
96
 
@@ -151,7 +184,21 @@ module Hecks
151
184
  # forever, since nothing about *entering* it via default implies
152
185
  # anything must eventually move it, unlike a state a transition
153
186
  # explicitly delivered somewhere.
154
- def terminal_exempt(_lifecycle) = []
187
+ #
188
+ # Scoped to `default` alone, and only when the lifecycle actually
189
+ # declares real transitions elsewhere: an EMPTY lifecycle (no
190
+ # transitions at all) doesn't get this exemption — that's not "a
191
+ # machine whose entry point deliberately awaits external action,"
192
+ # it's much more likely a lifecycle nobody finished wiring, and
193
+ # should still warn (see spec/fixtures/model_check/lifecycle_
194
+ # findings.bluebook's own Widget::Part, which stays warned on
195
+ # purpose).
196
+ def terminal_exempt(lifecycle)
197
+ return [] if lifecycle.transitions.empty?
198
+
199
+ outgoing_sources = lifecycle.transitions.flat_map { |_, t| Array(t.from) }.to_set
200
+ outgoing_sources.include?(lifecycle.default) ? [] : [lifecycle.default]
201
+ end
155
202
 
156
203
  # ── process managers / sagas ──────────────────────────────────────
157
204
 
@@ -200,6 +247,23 @@ module Hecks
200
247
  "declares no command at — cross-domain dispatch is out of this " \
201
248
  "checker's scope, same as CommandRules#resolve_references")
202
249
  end
250
+
251
+ # A `compensates` DECLARED WITH NOWHERE TO EVER FIRE — the exact
252
+ # shape of the real bug this whole feature closes ("the
253
+ # reversal was written and never armed"), caught at build/
254
+ # model-check time instead of discovered in production. No
255
+ # handler anywhere answers REFUSED (`pm.saga?` false) means
256
+ # `SagaInterpreter#unwind` never runs for this process
257
+ # manager at all, so a declared `compensates` is structurally
258
+ # unreachable — not a warning about style, a dead declaration.
259
+ if !pm.saga? && handler.dispatches.any?(&:compensates)
260
+ handler.dispatches.select(&:compensates).each do |dispatch|
261
+ findings << Finding.new(kind: :unarmed_compensation, severity: :error, subject: pm.name,
262
+ message: "#{dispatch.command_name} compensates #{dispatch.compensates.command_name}, " \
263
+ "but no handler anywhere in this saga answers a refusal — the " \
264
+ "compensation is declared and can never fire")
265
+ end
266
+ end
203
267
  end
204
268
 
205
269
  if pm.saga? && !reached.include?(pm.saga.from_state)
@@ -240,8 +304,8 @@ module Hecks
240
304
 
241
305
  # ── policies ───────────────────────────────────────────────────────
242
306
 
243
- def policy_findings(bluebook, policy)
244
- return [] if policy.target_domain # cross-domain: CommandRules skips it too; so does this checker.
307
+ def policy_findings(bluebook, policy, hecksagon, known_domains)
308
+ return cross_domain_policy_findings(policy, hecksagon, known_domains) if policy.target_domain
245
309
 
246
310
  emitted = emitted_events(bluebook)
247
311
  findings = []
@@ -276,6 +340,83 @@ module Hecks
276
340
  findings
277
341
  end
278
342
 
343
+ # ── cross-domain policies (Context Mapping) ───────────────────────
344
+ #
345
+ # `uses_framework "X"` already IS a Shared Kernel relationship — it
346
+ # merges X's own bluebook into THIS registry, no boundary. A cross-
347
+ # domain `policy ... across: "X"` already IS a Customer/Supplier
348
+ # relationship — it dispatches into X over real cross-Lambda RPC in
349
+ # the Rust host (`rust/host/src/lambda_client.rs`). Neither is a new
350
+ # word; this makes the CHOICE between them checked instead of a
351
+ # prose comment nobody enforces (`examples/banking/bluebook/
352
+ # banking.hecksagon`'s own hand-written note explaining why
353
+ # Compliance is reached via `across`, never `uses_framework`).
354
+ #
355
+ # NO NEW KEYWORD ANYWHERE — ADR 0025 principle 1 ("one idea, one
356
+ # spelling") refuses a `relationship:`/`as:` argument that would
357
+ # just restate, as a string, the fact the chosen keyword (
358
+ # `uses_framework` vs `across`) already states completely. The
359
+ # DDD vocabulary (Shared Kernel, Customer/Supplier) lives here, in
360
+ # the finding's own name and this comment, and in prose docs — not
361
+ # in the grammar.
362
+ def cross_domain_policy_findings(policy, hecksagon, known_domains)
363
+ return [] unless hecksagon # no sibling hecksagon loaded — nothing to check a relationship against.
364
+
365
+ target = policy.target_domain
366
+ findings = []
367
+
368
+ if hecksagon.framework_members.include?(target)
369
+ # SHARED KERNEL AND CUSTOMER/SUPPLIER ARE MUTUALLY EXCLUSIVE
370
+ # CLAIMS about the SAME target — `uses_framework` means "X is
371
+ # loaded in-process, right here"; `across` means "X is a
372
+ # separate deployment, reached only by RPC." Declaring both is
373
+ # either a pointless RPC to a domain already local, or a
374
+ # `uses_framework` that isn't really doing what its name says.
375
+ findings << Finding.new(kind: :contradictory_relationship, severity: :error, subject: policy.name,
376
+ message: "across #{target.inspect} dispatches over RPC (Customer/Supplier), " \
377
+ "but this hecksagon also uses_framework #{target.inspect} (Shared " \
378
+ "Kernel) — #{target} is already loaded in-process here, so the two " \
379
+ "relationship declarations contradict each other for the same " \
380
+ "target domain")
381
+ elsif hecksagon.subscriptions.none? { |subscribed| Naming.qualifier(subscribed) == target }
382
+ # THIS IS WHAT FINALLY GIVES `subscribe` REAL TEETH — checked
383
+ # here, at model-check time, still never routed at runtime
384
+ # (nothing dispatches off a `subscribe` line; see hecksagon.md's
385
+ # own "checked, not routed" section). ADR 0025 names `subscribe`
386
+ # by number as failing the corpus-use bar; this is the real use.
387
+ findings << Finding.new(kind: :unacknowledged_relationship, severity: :error, subject: policy.name,
388
+ message: "across #{target.inspect} declares a Customer/Supplier " \
389
+ "relationship, but nothing in this hecksagon records the " \
390
+ "expectation — add subscribe \"#{target}.SomeEvent\" for what " \
391
+ "you expect back from it, or uses_framework #{target.inspect} to " \
392
+ "attach it in-process instead")
393
+ end
394
+
395
+ # TYPO DETECTION, DELIBERATELY WEAKER — `known_domains` can only
396
+ # ever be a MONOREPO-SCOPED heuristic: a real external hecks
397
+ # consumer's own domain (this repo's own embryonaut/lifeadelics-
398
+ # shaped case) lives in a genuinely separate repository this
399
+ # corpus scan can never see, so a target this check cannot find
400
+ # is "unknown to THIS corpus," never proof of a typo. Two real,
401
+ # legitimate reasons a target is unresolvable — genuinely
402
+ # undefined by design (the corpus's own "Notifications," used
403
+ # deliberately to exercise the undelivered-reaction runtime path)
404
+ # and real-but-external (a separate repository) — both go in
405
+ # `ALLOWED_FINDINGS`, the same judged-exception mechanism this
406
+ # file already uses for ExternalSettlement, rather than a new
407
+ # keyword invented to declare "this one's fine."
408
+ if known_domains && !known_domains.include?(target)
409
+ findings << Finding.new(kind: :unknown_target_domain, severity: :error, subject: policy.name,
410
+ message: "across #{target.inspect} names a domain nowhere in the corpus " \
411
+ "this check has booted — a typo, or a real domain intentionally " \
412
+ "outside this corpus (undefined by design, or living in a " \
413
+ "separate repository) belongs in ALLOWED_FINDINGS, named and " \
414
+ "explained, not silently assumed correct")
415
+ end
416
+
417
+ findings
418
+ end
419
+
279
420
  # ── shared enumeration ────────────────────────────────────────────
280
421
 
281
422
  # A PORT OPERATION EMITS TOO — the primary/driving port an adapter
@@ -66,6 +66,14 @@ module Hecks
66
66
  def validate(pattern)
67
67
  chars = pattern.to_s.chars
68
68
  index = 0
69
+ # A CHARACTER-CLASS INTERIOR IS A DIFFERENT ALPHABET : inside `[...]`,
70
+ # `*`, `+`, `?`, `(`, `?` are literal characters, not quantifiers or
71
+ # group syntax — `[*+]` means "a literal asterisk or plus". `]` is
72
+ # only the class's close when it isn't the first character after `[`
73
+ # or `[^` (where it is itself a literal, per POSIX bracket-expression
74
+ # rules).
75
+ in_class = false
76
+ class_start = nil
69
77
 
70
78
  while index < chars.length
71
79
  if chars[index] == "\\"
@@ -78,14 +86,36 @@ module Hecks
78
86
  next
79
87
  end
80
88
 
89
+ if in_class
90
+ if chars[index] == "]" && index != class_start
91
+ in_class = false
92
+ index += 1
93
+ next
94
+ end
95
+
96
+ return refuse(:posix_class) if posix_class_at?(chars, index)
97
+
98
+ index += 1
99
+ next
100
+ end
101
+
102
+ if chars[index] == "["
103
+ return refuse(:posix_class) if posix_class_at?(chars, index)
104
+
105
+ in_class = true
106
+ class_start = index + 1
107
+ class_start += 1 if chars[class_start] == "^"
108
+ index += 1
109
+ next
110
+ end
111
+
81
112
  if chars[index] == "(" && chars[index + 1] == "?"
82
113
  return refuse(:lookahead) if %w[= !].include?(chars[index + 2])
83
114
  return refuse(:lookbehind) if chars[index + 2] == "<" && %w[= !].include?(chars[index + 3])
84
115
  return refuse(:atomic_group) if chars[index + 2] == ">"
85
116
  end
86
117
 
87
- return refuse(:posix_class) if posix_class_at?(chars, index)
88
- return refuse(:possessive) if %w[* + ?].include?(chars[index]) && chars[index + 1] == "+"
118
+ return refuse(:possessive) if possessive_at?(chars, index)
89
119
 
90
120
  index += 1
91
121
  end
@@ -115,6 +145,40 @@ module Hecks
115
145
  cursor += 1 while chars[cursor]&.match?(/[a-zA-Z]/)
116
146
  chars[cursor] == ":" && chars[cursor + 1] == "]"
117
147
  end
148
+
149
+ # A possessive quantifier is `*+`, `++`, `?+`, or a bounded `{n}`/{n,m}`
150
+ # immediately followed by `+` — only checked OUTSIDE a character class,
151
+ # where `*`, `+`, `?`, `{`, `}` are quantifier syntax rather than
152
+ # literal characters.
153
+ def possessive_at?(chars, index)
154
+ return true if %w[* + ?].include?(chars[index]) && chars[index + 1] == "+"
155
+ return false unless chars[index] == "{"
156
+
157
+ len = bounded_quantifier_length(chars, index)
158
+ !len.nil? && chars[index + len] == "+"
159
+ end
160
+
161
+ # Length of a `{n}` / `{n,}` / `{n,m}` bound starting at `index`, or nil
162
+ # if what's there isn't one.
163
+ def bounded_quantifier_length(chars, index)
164
+ cursor = index + 1
165
+ digit_seen = false
166
+
167
+ while chars[cursor]&.match?(/[0-9]/)
168
+ digit_seen = true
169
+ cursor += 1
170
+ end
171
+ return nil unless digit_seen
172
+
173
+ if chars[cursor] == ","
174
+ cursor += 1
175
+ cursor += 1 while chars[cursor]&.match?(/[0-9]/)
176
+ end
177
+
178
+ return nil unless chars[cursor] == "}"
179
+
180
+ cursor - index + 1
181
+ end
118
182
  end
119
183
  end
120
184
  end