hecks 0.3.0 → 1.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 (194) 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/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
data/lib/hecks.rb CHANGED
@@ -20,7 +20,6 @@ require_relative "hecks/bluebook"
20
20
  require_relative "hecks/router"
21
21
 
22
22
  require_relative "hecks/runtime"
23
- require_relative "hecks/translation"
24
23
  require_relative "hecks/adapters"
25
24
  require_relative "hecks/projector"
26
25
  # AFTER the projector registry and its `Target` mixin are both real —
@@ -31,6 +30,7 @@ require_relative "hecks/projections"
31
30
  # `Ports::Clock` (fills a staleness rule's `now` at the door) both exist.
32
31
  require_relative "hecks/facade/cli_door"
33
32
  require_relative "hecks/facade/cli_runner"
33
+ require_relative "hecks/storehouse"
34
34
  require_relative "hecks/framework"
35
35
  require_relative "hecks/embryonaut_bluebook"
36
36
 
@@ -88,7 +88,12 @@ module Hecks
88
88
  # against a real Governance `RoleAssignment` instead, once the
89
89
  # command's domain has Governance attached — see
90
90
  # `CommandRules::Authorization`'s own header.
91
- def as_caller(role:, actor_id: nil, &block) = Runtime.as_caller(role: role, actor_id: actor_id, &block)
91
+ #
92
+ # `as_of` and `scope` are OPTIONAL too, same shape — see
93
+ # `Runtime.as_caller`'s own header for what each does.
94
+ def as_caller(role:, actor_id: nil, as_of: nil, scope: nil, &block)
95
+ Runtime.as_caller(role: role, actor_id: actor_id, as_of: as_of, scope: scope, &block)
96
+ end
92
97
 
93
98
  def bluebook(name, version: nil, &block)
94
99
  collect(:add_bluebook, Bluebook::DSL::BluebookBuilder.build(name, version: version, &block))
@@ -0,0 +1,90 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Hecks
4
+ # FLAGS THE EXACT SHAPE behind 8+ real bugs already fixed one at a
5
+ # time across this codebase (Tiers 1-5): `holder[a] || holder[b]` —
6
+ # the SAME receiver looked up by two different keys, falling back to
7
+ # the second lookup whenever the first is falsy. `||` cannot tell a
8
+ # genuinely STORED `false` from a MISSING key — both are falsy in
9
+ # Ruby — so a real `false` sitting at `holder[a]` is silently
10
+ # discarded and `holder[b]` (usually absent, so `nil`) is returned
11
+ # instead. Every one of those 8+ instances was the identical
12
+ # AST shape with a different pair of keys (most commonly
13
+ # `h[k.to_sym] || h[k]`, reading a value that could arrive keyed
14
+ # either by symbol or by string off the wire).
15
+ #
16
+ # THE FIX THIS CODEBASE ALREADY CONVERGED ON — see
17
+ # `lib/hecks/query_specification/field_path.rb#read`, the shared
18
+ # digger this whole bug class got consolidated behind: check
19
+ # `key?` FIRST, never fall back through `||`.
20
+ #
21
+ # sym = segment.to_sym
22
+ # return current.key?(sym) ? current[sym] : current[segment]
23
+ #
24
+ # That method's own comment says it plainly: "`key?` first, never
25
+ # `||`, because `||` falls through a genuinely-stored `false` to
26
+ # the OTHER spelling (usually absent) and returns `nil` instead."
27
+ # This cop exists so the NEXT `holder[a] || holder[b]` gets caught
28
+ # mechanically, before it becomes bug #9, rather than found by
29
+ # hand again in a future audit.
30
+ #
31
+ # SCOPED TO THE `[]`/`[]` SHAPE ONLY, deliberately — a receiver
32
+ # method-call is compared by AST structure (`==`, which ignores
33
+ # source location), so `hash[a] || hash[b]` is flagged whether
34
+ # `hash` is a local variable, a method call, or a constant, but
35
+ # `a[k] || b[k]` (DIFFERENT receivers) and `value || default`
36
+ # (an ordinary default, not a second lookup at all) are both left
37
+ # alone — neither one can silently drop a stored `false` the way
38
+ # a same-receiver double-lookup can.
39
+ #
40
+ # @example
41
+ # # bad — a stored `false` at hash[:active] is discarded
42
+ # hash[:active] || hash["active"]
43
+ #
44
+ # # good
45
+ # hash.key?(:active) ? hash[:active] : hash["active"]
46
+ #
47
+ # # good — or the shared digger this codebase already has
48
+ # Hecks::QuerySpecification::FieldPath.dig(hash, "active")
49
+ class FallbackHashLookup < Base
50
+ MSG = "`%<receiver>s[...] || %<receiver>s[...]` falls back to the second lookup " \
51
+ "whenever the first is falsy — but `||` cannot tell a genuinely stored `false` " \
52
+ "apart from a missing key, so a real `false` at `%<receiver>s[%<lhs_key>s]` is " \
53
+ "silently discarded in favor of `%<receiver>s[%<rhs_key>s]` instead of being " \
54
+ "returned. Use `%<receiver>s.key?(%<lhs_key>s) ? %<receiver>s[%<lhs_key>s] : " \
55
+ "%<receiver>s[%<rhs_key>s]`, or a shared digger (see `key?` in " \
56
+ "`Hecks::QuerySpecification::FieldPath#read`), instead."
57
+
58
+ # @!method bracket_lookup(node)
59
+ def_node_matcher :bracket_lookup, "(send $_receiver :[] $_key)"
60
+
61
+ def on_or(node)
62
+ lhs_receiver, lhs_key = bracket_lookup(node.lhs)
63
+ return unless lhs_receiver
64
+
65
+ rhs_receiver, rhs_key = bracket_lookup(node.rhs)
66
+ return unless rhs_receiver
67
+
68
+ # THE STRUCTURAL EQUALITY CHECK — `==` on an AST node (from the
69
+ # `ast` gem `Node` this compiles down to) compares `type` and
70
+ # `children` recursively and ignores source location, so
71
+ # `hash[a] || hash[b]` matches even though the two `hash`
72
+ # sub-nodes are two distinct node OBJECTS parsed from two
73
+ # different source ranges. A different receiver on each side
74
+ # (`a[k] || b[k]`) fails this check and is correctly left alone.
75
+ return unless lhs_receiver == rhs_receiver
76
+
77
+ add_offense(
78
+ node,
79
+ message: format(
80
+ MSG,
81
+ receiver: lhs_receiver.source,
82
+ lhs_key: lhs_key.source,
83
+ rhs_key: rhs_key.source
84
+ )
85
+ )
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,128 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Hecks
4
+ # FLAGS `hash[new] = hash.delete(old)` INSIDE A LOOP — a method that
5
+ # both READS and WRITES the same hash across a loop's iterations,
6
+ # in the specific rename/permutation shape this codebase has
7
+ # already lost real data to once.
8
+ #
9
+ # THE BUG THIS IS THE MECHANICAL FOLLOW-UP TO (M27,
10
+ # docs/audits/2026-08-10-main-bug-audit.md,
11
+ # docs/audits/2026-08-11-bug-triage.md; fixed in
12
+ # `Hecks::Ports::Persistence::Lineage#apply_renames`,
13
+ # lib/hecks/ports/persistence/plugins/era/lineage.rb): a rename
14
+ # rule set applied one rule at a time, `state[new_name] =
15
+ # state.delete(old_name)` per rule, against the SAME hash it was
16
+ # reading from — so a swap (`rename :a, to: :b` alongside `rename
17
+ # :b, to: :a`) on `{a: 1, b: 2}` collapsed to `{a: 1}`. The first
18
+ # rule wrote `b: 1` (clobbering the real `b: 2`) before the second
19
+ # rule ever got a chance to read the value that was supposed to
20
+ # move to `:a` — sequential edits stepping on each other, not the
21
+ # simultaneous permutation a rename set actually means. The same
22
+ # shape loses data identically for ANY chain longer than one link
23
+ # (`a->b->c->a`), not just a two-element swap.
24
+ #
25
+ # THE FIX THIS COP POINTS AT — snapshot every old key's value
26
+ # FIRST, delete all old keys, THEN write all new keys, so no
27
+ # rename in the pass ever reads a key this same pass has already
28
+ # written:
29
+ #
30
+ # snapshot = renames.filter_map { |old_name, new_name| [old_name, new_name, state[old_name]] if state.key?(old_name) }
31
+ # snapshot.each { |old_name, _new_name, _value| state.delete(old_name) }
32
+ # snapshot.each { |_old_name, new_name, value| state[new_name] = value }
33
+ #
34
+ # WHAT THIS COP DOES NOT ATTEMPT — the general checklist item is
35
+ # broader than any AST pattern can safely automate ("any method
36
+ # that both reads and writes the same collection across a loop"
37
+ # covers plenty of correct code too — accumulating into a result
38
+ # hash, memoizing into a cache). This cop stays narrow and
39
+ # precise: only the EXACT `recv[x] = recv.delete(y)` shape, same
40
+ # receiver both sides, inside something that iterates. A single
41
+ # rename outside any loop is not this bug (nothing else can have
42
+ # run first); `hash.store(...)` instead of `hash[...] =`, or
43
+ # `hash.delete(...)` whose result is not immediately written back
44
+ # into the SAME hash, are not this shape either and are left to
45
+ # the broader code-review checklist item, not this cop.
46
+ #
47
+ # @example
48
+ # # bad — one rule at a time against the hash it reads from
49
+ # renames.each do |old_name, new_name|
50
+ # state[new_name] = state.delete(old_name)
51
+ # end
52
+ #
53
+ # # good — snapshot first, then delete, then write: one permutation
54
+ # snapshot = renames.filter_map { |old_name, new_name| [old_name, new_name, state[old_name]] if state.key?(old_name) }
55
+ # snapshot.each { |old_name, _new_name, _value| state.delete(old_name) }
56
+ # snapshot.each { |_old_name, new_name, value| state[new_name] = value }
57
+ class SequentialHashRenameInLoop < Base
58
+ MSG = "`%<recv>s[new] = %<recv>s.delete(old)` inside a loop applies one rename at a time against the " \
59
+ "SAME hash it reads from — a swap (`{a: :b, b: :a}`) on `{a: 1, b: 2}` collapses to `{a: 1}` " \
60
+ "because the first rule's write clobbers the second rule's read target before it runs (the exact " \
61
+ "bug fixed for Lineage#apply_renames). Snapshot every old key's value FIRST, delete all old keys, " \
62
+ "then write all new keys, so the pass applies as one simultaneous permutation instead of a " \
63
+ "sequence of edits each stepping on the last."
64
+
65
+ RESTRICT_ON_SEND = [:[]=].freeze
66
+
67
+ LOOP_METHODS = %i[
68
+ each each_pair each_with_index each_with_object with_index
69
+ map collect flat_map each_entry each_key each_value
70
+ inject reduce each_slice each_cons
71
+ ].freeze
72
+
73
+ # `hash[new_name] = hash.delete(old_name)` parses as a `send`
74
+ # node (indexed assignment has no dedicated node type in Ruby's
75
+ # own grammar): `(send recv :[]= new_key (send recv2 :delete
76
+ # old_key))`. `$_recv`/`$_recv2` are captured (not merely
77
+ # matched) so `same_receiver?` below can compare them
78
+ # structurally — node-pattern has no built-in "same subtree
79
+ # twice" backreference, so the equality check is real Ruby, not
80
+ # the pattern itself.
81
+ def_node_matcher :rename_write?, <<~PATTERN
82
+ (send $_recv :[]= _new_key (send $_recv2 :delete _old_key))
83
+ PATTERN
84
+
85
+ def on_send(node)
86
+ rename_write?(node) do |recv, recv2|
87
+ next unless same_receiver?(recv, recv2)
88
+ next unless in_loop?(node)
89
+
90
+ add_offense(node, message: format(MSG, recv: recv.source))
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ # Structural equality (type + children, recursively) — exactly
97
+ # what `Parser::AST::Node#==` already gives, ignoring source
98
+ # location, so `state` vs `state` (two separate `send(nil,
99
+ # :state)` reader calls) and `@state` vs `@state` both count as
100
+ # "the same collection", not just an identical local variable.
101
+ def same_receiver?(recv, recv2)
102
+ recv == recv2
103
+ end
104
+
105
+ # Walks every ancestor looking for something that iterates —
106
+ # not just the immediate parent, since the offending assignment
107
+ # may sit one or more non-loop nodes deep inside a loop's block
108
+ # (an `if` guarding it, say) and still carries the exact same
109
+ # hazard.
110
+ def in_loop?(node)
111
+ node.each_ancestor(:block, :numblock, :for, :while, :until, :while_post, :until_post).any? do |ancestor|
112
+ loop_ancestor?(ancestor)
113
+ end
114
+ end
115
+
116
+ def loop_ancestor?(ancestor)
117
+ case ancestor.type
118
+ when :for, :while, :until, :while_post, :until_post
119
+ true
120
+ when :block, :numblock
121
+ send_node = ancestor.send_node
122
+ send_node.send_type? && LOOP_METHODS.include?(send_node.method_name)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,160 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Hecks
4
+ # FLAGS PLAIN `@ivar` MUTATION inside a class this codebase already
5
+ # knows is a thread-shared singleton — one `Dispatcher`/`Registry`
6
+ # instance lives for the life of a boot and is dispatched through
7
+ # from every thread of a Puma worker pool (or any other multi-
8
+ # threaded host), so a plain instance variable on either class is
9
+ # shared, mutable state with no per-thread or mutex-guarded
10
+ # isolation at all.
11
+ #
12
+ # THIS IS THE MECHANICAL FOLLOW-UP to a real bug already fixed here
13
+ # (see `dispatcher.rb`'s own `#reenter` comment, and
14
+ # `spec/runtime/dispatcher_spec.rb`): `@reaction_depth` used to be a
15
+ # plain ivar on `Dispatcher`, so two threads' concurrent top-level
16
+ # dispatches corrupted each other's view of "how deep into a
17
+ # reaction cascade am I". The fix moved that one ivar to
18
+ # `Thread.current[:hecks_reaction_depth]`. This cop exists so the
19
+ # NEXT plain ivar someone adds to either class gets flagged before
20
+ # it becomes the next instance of the same bug, rather than after.
21
+ #
22
+ # SCOPED NARROWLY ON PURPOSE — by class name
23
+ # (`Hecks::Runtime::Dispatcher`/`Hecks::Runtime::Registry`), not by
24
+ # blanket-flagging every ivar mutation in the codebase. Most classes
25
+ # in this codebase are NOT shared across threads (a fresh value
26
+ # object per call, a builder used once at boot) and ivar mutation
27
+ # there is completely ordinary Ruby with no hazard behind it at
28
+ # all — flagging it everywhere would be noise nobody trusts, the
29
+ # same reasoning `.rubocop.yml`'s own header gives for every other
30
+ # cop in this repo.
31
+ #
32
+ # WHAT COUNTS AS "PLAIN": `@ivar = ...`, `@ivar ||= ...`, `@ivar +=
33
+ # ...`, `@ivar << ...`, `@ivar[k] = v`. `initialize` is exempt — an
34
+ # ivar being SET UP for the first time, before any other thread can
35
+ # possibly hold a reference to this object, is not the hazard (see
36
+ # `Registry#initialize`'s own `@saga_mutex = Mutex.new`, which this
37
+ # cop must not flag). `Thread.current[...]`-backed state and a
38
+ # `Mutex`-guarded critical section are exactly the two idioms this
39
+ # codebase has already established for this — see `Dispatcher
40
+ # #reenter` and `Registry#saga_mutex` respectively — so the message
41
+ # points at both rather than inventing a third.
42
+ #
43
+ # @example
44
+ # # bad
45
+ # class Dispatcher
46
+ # def reenter(verb)
47
+ # @reaction_depth = @reaction_depth.to_i + 1
48
+ # end
49
+ # end
50
+ #
51
+ # # good
52
+ # class Dispatcher
53
+ # def reenter(verb)
54
+ # Thread.current[:hecks_reaction_depth] = Thread.current[:hecks_reaction_depth].to_i + 1
55
+ # end
56
+ # end
57
+ class ThreadSharedIvarMutation < Base
58
+ MSG = "`%<ivar>s` is a plain instance variable mutated outside `initialize` on " \
59
+ "%<klass>s, which is shared across every thread dispatching through it " \
60
+ "(a Puma worker pool, say) — two concurrent threads would corrupt each " \
61
+ "other's view of it, the exact bug already fixed for `Dispatcher#reaction_depth` " \
62
+ "(see dispatcher.rb's `#reenter`). Use `Thread.current[:...]` for per-thread " \
63
+ "state, or a `Mutex`-guarded critical section (`Registry#saga_mutex`) if the " \
64
+ "state genuinely must be shared."
65
+
66
+ THREAD_SHARED_CLASSES = ["Dispatcher", "Registry"].freeze
67
+
68
+ RESTRICT_ON_SEND = [:<<, :[]=].freeze
69
+
70
+ def on_ivasgn(node)
71
+ # A PLAIN `@x = 1` parses as `(ivasgn :@x (int 1))` — two
72
+ # children. The commissioner also visits the BARE `(ivasgn :@x)`
73
+ # node nested one level inside an `op_asgn`/`or_asgn` (`@x += 1`,
74
+ # `@x ||= 1`) as its own `ivasgn` node with only one child — that
75
+ # one is handled by `on_op_asgn`/`on_or_asgn` below instead, so
76
+ # it's skipped here to avoid double-reporting the same mutation.
77
+ return unless node.children.size == 2
78
+
79
+ check(node, node.children.first)
80
+ end
81
+
82
+ def on_op_asgn(node)
83
+ # `@x += 1` parses as `(op_asgn (ivasgn :@x) :+ (int 1))` — the
84
+ # target is an `ivasgn` node carrying JUST the name (no value
85
+ # child, unlike a plain `@x = 1`), not an `ivar` node.
86
+ ivar_node = node.children.first
87
+ return unless ivar_node.is_a?(RuboCop::AST::Node) && ivar_node.ivasgn_type?
88
+
89
+ check(node, ivar_node.children.first)
90
+ end
91
+
92
+ def on_or_asgn(node)
93
+ on_op_asgn(node)
94
+ end
95
+
96
+ def on_send(node)
97
+ return unless RESTRICT_ON_SEND.include?(node.method_name)
98
+
99
+ receiver = node.receiver
100
+ return unless receiver
101
+
102
+ # `@ivar << x` — the receiver IS the ivar.
103
+ # `@ivar[k] = v` — the receiver is a `send(@ivar, :[], k)`, whose
104
+ # own receiver is the ivar (`node.receiver.receiver`).
105
+ ivar_node = if receiver.ivar_type?
106
+ receiver
107
+ elsif receiver.send_type? && receiver.receiver&.ivar_type?
108
+ receiver.receiver
109
+ end
110
+ return unless ivar_node
111
+
112
+ check(node, ivar_node.children.first)
113
+ end
114
+
115
+ private
116
+
117
+ def check(node, ivar_name)
118
+ return unless inside_thread_shared_class?(node)
119
+ return if inside_initialize?(node)
120
+
121
+ add_offense(node, message: format(MSG, ivar: ivar_name, klass: enclosing_class_name(node)))
122
+ end
123
+
124
+ def inside_thread_shared_class?(node)
125
+ !!enclosing_class_name(node)&.then { |name| THREAD_SHARED_CLASSES.include?(name) }
126
+ end
127
+
128
+ # Walks outward to the nearest enclosing `class` node and returns
129
+ # its own short name (`Dispatcher`, not the fully-qualified
130
+ # `Hecks::Runtime::Dispatcher`) — good enough to match this repo's
131
+ # own one-class-per-file layout without needing full namespace
132
+ # resolution, and avoids a false negative on `class Dispatcher`
133
+ # bodies that reopen the class from inside the `Hecks::Runtime`
134
+ # module (this repo's actual style) as much as one written as
135
+ # `class Hecks::Runtime::Dispatcher`.
136
+ def enclosing_class_name(node)
137
+ klass = node.each_ancestor(:class).first
138
+ return nil unless klass
139
+
140
+ const_node = klass.identifier
141
+ const_node.const_name.to_s.split("::").last
142
+ end
143
+
144
+ # `initialize` is where an ivar is set up for the first time —
145
+ # before this object has been handed to a caller at all, so no
146
+ # other thread can hold a reference to mutate concurrently with
147
+ # it. Also exempts `initialize` methods defined on an object
148
+ # reopened via `class << self` or nested module — `each_ancestor`
149
+ # naturally stops at the nearest enclosing `def`, matching Ruby's
150
+ # own method-scoping.
151
+ def inside_initialize?(node)
152
+ def_node = node.each_ancestor(:def, :defs).first
153
+ return false unless def_node
154
+
155
+ def_node.method?(:initialize)
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-08-25 00:00:00.000000000 Z
11
+ date: 2026-08-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -67,21 +67,6 @@ files:
67
67
  - lib/hecks/adapters/driven/postgres/codec.rb
68
68
  - lib/hecks/adapters/driven/postgres/schema_builder.rb
69
69
  - lib/hecks/adapters/driven/postgres_era.adapter
70
- - lib/hecks/adapters/driven/postgres_era.rb
71
- - lib/hecks/adapters/driven/postgres_era/lineage.rb
72
- - lib/hecks/adapters/driven/postgres_era/lineage/era_store.rb
73
- - lib/hecks/adapters/driven/postgres_era/lineage/field_cache.rb
74
- - lib/hecks/adapters/driven/postgres_era/lineage/head_compiler.rb
75
- - lib/hecks/adapters/driven/postgres_era/lineage/mint_transaction.rb
76
- - lib/hecks/adapters/driven/postgres_era/lineage/provisioning.rb
77
- - lib/hecks/adapters/driven/postgres_era/lineage/resumable_backfill.rb
78
- - lib/hecks/adapters/driven/postgres_era/lineage/tail_merge.rb
79
- - lib/hecks/adapters/driven/postgres_era/lineage/transform_installer.rb
80
- - lib/hecks/adapters/driven/postgres_era/lineage_manager.rb
81
- - lib/hecks/adapters/driven/postgres_era/lineage_manager/coverage_check.rb
82
- - lib/hecks/adapters/driven/postgres_era/lineage_manager/era_resolver.rb
83
- - lib/hecks/adapters/driven/postgres_era/lineage_manager/merge_coordinator.rb
84
- - lib/hecks/adapters/driven/postgres_era/lineage_manager/minter.rb
85
70
  - lib/hecks/adapters/driven/prism.adapter
86
71
  - lib/hecks/adapters/driven/prism.rb
87
72
  - lib/hecks/adapters/driven/secure_random_identity.adapter
@@ -213,6 +198,7 @@ files:
213
198
  - lib/hecks/forms/index_renderer.rb
214
199
  - lib/hecks/forms/page.rb
215
200
  - lib/hecks/forms/params.rb
201
+ - lib/hecks/forms/port_argument.rb
216
202
  - lib/hecks/forms/query_form_renderer.rb
217
203
  - lib/hecks/forms/record_renderer.rb
218
204
  - lib/hecks/forms/record_table.rb
@@ -228,6 +214,7 @@ files:
228
214
  - lib/hecks/framework/oidc.json
229
215
  - lib/hecks/freezer.rb
230
216
  - lib/hecks/fuzzing.rb
217
+ - lib/hecks/fuzzing/bounded_exhaustive_expressions.rb
231
218
  - lib/hecks/fuzzing/invalid_value_generator.rb
232
219
  - lib/hecks/fuzzing/isolated_boot.rb
233
220
  - lib/hecks/fuzzing/properties.rb
@@ -299,8 +286,42 @@ files:
299
286
  - lib/hecks/ports/persistence/append_only.rb
300
287
  - lib/hecks/ports/persistence/binding_policy.rb
301
288
  - lib/hecks/ports/persistence/execution.rb
302
- - lib/hecks/ports/persistence/lineage.rb
303
289
  - lib/hecks/ports/persistence/null_saga_store.rb
290
+ - lib/hecks/ports/persistence/plugin.rb
291
+ - lib/hecks/ports/persistence/plugins/era.rb
292
+ - lib/hecks/ports/persistence/plugins/era/era_check.rb
293
+ - lib/hecks/ports/persistence/plugins/era/era_guard.rb
294
+ - lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb
295
+ - lib/hecks/ports/persistence/plugins/era/era_tamper.rb
296
+ - lib/hecks/ports/persistence/plugins/era/lineage.rb
297
+ - lib/hecks/ports/persistence/plugins/era/postgres_era.rb
298
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb
299
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb
300
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb
301
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb
302
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb
303
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb
304
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb
305
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb
306
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb
307
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb
308
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb
309
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb
310
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb
311
+ - lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb
312
+ - lib/hecks/ports/persistence/plugins/era/storage_shape.rb
313
+ - lib/hecks/ports/persistence/plugins/era/translation.rb
314
+ - lib/hecks/ports/persistence/plugins/era/translation/audit.rb
315
+ - lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb
316
+ - lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb
317
+ - lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb
318
+ - lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb
319
+ - lib/hecks/ports/persistence/plugins/era/translation/reattest.rb
320
+ - lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb
321
+ - lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb
322
+ - lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb
323
+ - lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb
324
+ - lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb
304
325
  - lib/hecks/ports/persistence/remote_runtime.rb
305
326
  - lib/hecks/ports/persistence/repository_factory.rb
306
327
  - lib/hecks/ports/projection.port
@@ -349,6 +370,8 @@ files:
349
370
  - lib/hecks/router.rb
350
371
  - lib/hecks/router/namespace_installer.rb
351
372
  - lib/hecks/runtime.rb
373
+ - lib/hecks/runtime/aggregate_lock.rb
374
+ - lib/hecks/runtime/boot_gates.rb
352
375
  - lib/hecks/runtime/caller.rb
353
376
  - lib/hecks/runtime/capability_graph.rb
354
377
  - lib/hecks/runtime/command_interpreter.rb
@@ -364,10 +387,6 @@ files:
364
387
  - lib/hecks/runtime/dispatcher.rb
365
388
  - lib/hecks/runtime/entity_element.rb
366
389
  - lib/hecks/runtime/entity_interpreter.rb
367
- - lib/hecks/runtime/era_check.rb
368
- - lib/hecks/runtime/era_guard.rb
369
- - lib/hecks/runtime/era_guard/shape_diff.rb
370
- - lib/hecks/runtime/era_tamper.rb
371
390
  - lib/hecks/runtime/errors.rb
372
391
  - lib/hecks/runtime/event.rb
373
392
  - lib/hecks/runtime/identity.rb
@@ -389,33 +408,25 @@ files:
389
408
  - lib/hecks/runtime/routing.rb
390
409
  - lib/hecks/runtime/saga_interpreter.rb
391
410
  - lib/hecks/runtime/saga_interpreter/correlation.rb
392
- - lib/hecks/runtime/storage_shape.rb
411
+ - lib/hecks/runtime/saga_pending_dispatch.rb
393
412
  - lib/hecks/runtime/tenant_check.rb
394
413
  - lib/hecks/runtime/tenant_scope.rb
395
414
  - lib/hecks/runtime/value.rb
396
415
  - lib/hecks/runtime/value/admission.rb
397
416
  - lib/hecks/runtime/value/coercion.rb
398
417
  - lib/hecks/runtime/value/invariant_violation.rb
399
- - lib/hecks/translation.rb
400
- - lib/hecks/translation/audit.rb
401
- - lib/hecks/translation/audit/approval_digest.rb
402
- - lib/hecks/translation/audit/layer_one.rb
403
- - lib/hecks/translation/audit/layer_two.rb
404
- - lib/hecks/translation/audit/unfed_report.rb
405
- - lib/hecks/translation/reattest.rb
406
- - lib/hecks/translation/rule_compiler.rb
407
- - lib/hecks/translation/scaffold.rb
408
- - lib/hecks/translation/scaffold/differ.rb
409
- - lib/hecks/translation/scaffold/renderer.rb
410
- - lib/hecks/translation/scaffold/writer.rb
418
+ - lib/hecks/storehouse.rb
411
419
  - lib/hecks/version.rb
412
420
  - lib/hecks/vocabulary.rb
413
- homepage: https://github.com/chrisyoung/hecks
421
+ - lib/rubocop/cop/hecks/fallback_hash_lookup.rb
422
+ - lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb
423
+ - lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb
424
+ homepage: https://github.com/heckslabs/hecks
414
425
  licenses:
415
426
  - Apache-2.0
416
427
  metadata:
417
428
  allowed_push_host: https://rubygems.org
418
- source_code_uri: https://github.com/chrisyoung/hecks
429
+ source_code_uri: https://github.com/heckslabs/hecks
419
430
  post_install_message:
420
431
  rdoc_options: []
421
432
  require_paths: