hecks 1.0.2 → 1.2.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 (247) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +100 -0
  15. data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
  16. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
  17. data/lib/hecks/adapters/driven/postgres.rb +45 -31
  18. data/lib/hecks/adapters/driven/prism.rb +11 -0
  19. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  20. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  21. data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
  22. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  23. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  24. data/lib/hecks/adapters/driven.rb +4 -0
  25. data/lib/hecks/adapters.rb +4 -0
  26. data/lib/hecks/behaviors/dsl.rb +7 -0
  27. data/lib/hecks/behaviors/expectations.rb +32 -4
  28. data/lib/hecks/behaviors/rspec.rb +2 -0
  29. data/lib/hecks/behaviors/runner.rb +4 -0
  30. data/lib/hecks/bluebook/aggregate.rb +6 -5
  31. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  32. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  33. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  34. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  35. data/lib/hecks/bluebook/assembly/marks.rb +15 -4
  36. data/lib/hecks/bluebook/assembly.rb +1 -3
  37. data/lib/hecks/bluebook/attribute.rb +6 -0
  38. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  39. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  40. data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
  41. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  42. data/lib/hecks/bluebook/command.rb +6 -5
  43. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  44. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  45. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
  46. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  47. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  48. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  49. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  50. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  51. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  52. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  53. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  54. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  55. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  56. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  57. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  58. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  59. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  60. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  61. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  62. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  63. data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
  64. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  65. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  66. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  68. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  69. data/lib/hecks/bluebook/entity.rb +5 -3
  70. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  71. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  72. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  73. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  74. data/lib/hecks/bluebook/expression/projection.json +12 -0
  75. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  76. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  77. data/lib/hecks/bluebook/expression.rb +1 -0
  78. data/lib/hecks/bluebook/hexagon.rb +10 -0
  79. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  80. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  81. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  82. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  83. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  84. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  85. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  86. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  87. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  88. data/lib/hecks/bluebook/model_check.rb +119 -82
  89. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  90. data/lib/hecks/bluebook/policy.rb +2 -1
  91. data/lib/hecks/bluebook/process_manager.rb +6 -0
  92. data/lib/hecks/bluebook/project_register.rb +45 -25
  93. data/lib/hecks/bluebook/query.rb +4 -0
  94. data/lib/hecks/bluebook/read_model.rb +2 -2
  95. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  96. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  97. data/lib/hecks/bluebook/translation.rb +3 -0
  98. data/lib/hecks/bluebook/value_object.rb +2 -2
  99. data/lib/hecks/codemod.rb +13 -0
  100. data/lib/hecks/doc/reference.rb +17 -11
  101. data/lib/hecks/facade/cli_runner.rb +2 -2
  102. data/lib/hecks/facade/command_request.rb +11 -11
  103. data/lib/hecks/facade/handle.rb +3 -3
  104. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  105. data/lib/hecks/facade.rb +4 -0
  106. data/lib/hecks/forms/app.rb +10 -6
  107. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  108. data/lib/hecks/forms/field_renderer.rb +14 -9
  109. data/lib/hecks/forms/field_shape.rb +7 -2
  110. data/lib/hecks/forms/html.rb +5 -2
  111. data/lib/hecks/forms/index_renderer.rb +1 -1
  112. data/lib/hecks/forms/page.rb +7 -3
  113. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  114. data/lib/hecks/forms/record_renderer.rb +22 -16
  115. data/lib/hecks/forms.rb +4 -0
  116. data/lib/hecks/fqn.rb +21 -9
  117. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  118. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  119. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  120. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  121. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  122. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  123. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  124. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  125. data/lib/hecks/fuzzing/properties.rb +41 -1065
  126. data/lib/hecks/fuzzing/replay.rb +60 -16
  127. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  128. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  129. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  130. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  131. data/lib/hecks/grammar/evolve.rb +34 -10
  132. data/lib/hecks/grammar/expression_operators.json +124 -0
  133. data/lib/hecks/grammar.rb +15 -5
  134. data/lib/hecks/ir.rb +9 -0
  135. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  136. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  138. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  140. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  141. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  142. data/lib/hecks/literal.rb +18 -8
  143. data/lib/hecks/naming.rb +6 -3
  144. data/lib/hecks/ports/access_control.rb +1 -1
  145. data/lib/hecks/ports/agent.rb +5 -3
  146. data/lib/hecks/ports/authentication.rb +3 -2
  147. data/lib/hecks/ports/authorization.rb +1 -1
  148. data/lib/hecks/ports/clock.rb +1 -1
  149. data/lib/hecks/ports/extraction.rb +5 -1
  150. data/lib/hecks/ports/identity_assignment.rb +1 -1
  151. data/lib/hecks/ports/identity_generation.rb +1 -1
  152. data/lib/hecks/ports/identity_resolution.rb +1 -1
  153. data/lib/hecks/ports/loading.rb +4 -1
  154. data/lib/hecks/ports/persistence/append_only.rb +51 -4
  155. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  156. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  157. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  158. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  159. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  169. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  170. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  172. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  173. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  174. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  175. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  176. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  177. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  178. data/lib/hecks/ports/persistence.rb +7 -3
  179. data/lib/hecks/ports/projection.rb +13 -5
  180. data/lib/hecks/ports/query/in_memory.rb +8 -2
  181. data/lib/hecks/ports/query.rb +1 -3
  182. data/lib/hecks/ports.rb +2 -0
  183. data/lib/hecks/projections/diagrams.rb +11 -3
  184. data/lib/hecks/projections/glossary.rb +374 -0
  185. data/lib/hecks/projections/ir.rb +2 -0
  186. data/lib/hecks/projections/model/deviations.rb +2 -1
  187. data/lib/hecks/projections/model.rb +15 -10
  188. data/lib/hecks/projections/vocabulary.rb +1 -3
  189. data/lib/hecks/projections.rb +1 -0
  190. data/lib/hecks/projector/cli_projector.rb +78 -42
  191. data/lib/hecks/projector/docs_projector.rb +21 -12
  192. data/lib/hecks/projector/exporter.rb +34 -1
  193. data/lib/hecks/projector/narrate_projector.rb +83 -25
  194. data/lib/hecks/projector.rb +2 -2
  195. data/lib/hecks/query_ir.rb +60 -36
  196. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  197. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  198. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  199. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  200. data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
  201. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  202. data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
  203. data/lib/hecks/query_specification/common/options.rb +17 -5
  204. data/lib/hecks/query_specification/common/order_by.rb +9 -2
  205. data/lib/hecks/query_specification/common/where_clause.rb +15 -2
  206. data/lib/hecks/query_specification/field_path.rb +4 -4
  207. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  208. data/lib/hecks/router/namespace_installer.rb +22 -10
  209. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  210. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  211. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  212. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  213. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  214. data/lib/hecks/runtime/dispatcher.rb +41 -7
  215. data/lib/hecks/runtime/entity_element.rb +25 -6
  216. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  217. data/lib/hecks/runtime/errors.rb +28 -2
  218. data/lib/hecks/runtime/instance.rb +9 -3
  219. data/lib/hecks/runtime/interpreting.rb +45 -9
  220. data/lib/hecks/runtime/loader.rb +21 -0
  221. data/lib/hecks/runtime/outbox.rb +365 -0
  222. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  223. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  224. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  225. data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
  226. data/lib/hecks/runtime/reference_hop.rb +1 -1
  227. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  228. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  229. data/lib/hecks/runtime/registry/verification.rb +39 -2
  230. data/lib/hecks/runtime/registry.rb +21 -11
  231. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  232. data/lib/hecks/runtime/routing.rb +52 -39
  233. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  234. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  235. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  236. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  237. data/lib/hecks/runtime/value/coercion.rb +271 -49
  238. data/lib/hecks/runtime/value.rb +1 -1
  239. data/lib/hecks/runtime.rb +7 -2
  240. data/lib/hecks/storehouse.rb +15 -8
  241. data/lib/hecks/version.rb +1 -1
  242. data/lib/hecks/vocabulary.rb +1 -0
  243. data/lib/hecks.rb +17 -13
  244. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  245. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  246. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  247. metadata +16 -2
@@ -0,0 +1,99 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module Properties
4
+ # Lifecycle/saga-shape properties, and replay determinism itself: a
5
+ # replayed history's lifecycle fields are among the aggregate's own
6
+ # declared states, a saga's advances follow its declared handlers, and
7
+ # replaying the same steps twice produces the same history.
8
+
9
+ # Holds lifecycle_values_are_declared, saga_advances_follow_declared_handlers,
10
+ # and replay_is_deterministic — the foundational property, since every
11
+ # other property here trusts that a single replay's own history is
12
+ # trustworthy in the first place.
13
+ module LifecycleAndReplay
14
+ # Every lifecycle field a replay leaves an instance holding is one
15
+ # of the aggregate's OWN declared states — the full set, not just
16
+ # `Lifecycle#states`' default+targets (see ModelCheck.full_states'
17
+ # own comment on that hole). The tie to M2 is direct: the model
18
+ # checker proves which states a domain's OWN declarations can ever
19
+ # produce ; this proves a REAL RUN never produced anything else —
20
+ # a coercion bug, a stale string surviving a rename, a default
21
+ # that drifted from the declared set, would all show up here as a
22
+ # value nothing upstream would have predicted.
23
+ def lifecycle_values_are_declared(history)
24
+ bluebook = history.fetch(:bluebook)
25
+ declared = {}
26
+ bluebook.aggregates.each do |aggregate|
27
+ declared[aggregate.hecks_name] = Bluebook::ModelCheck.full_states(aggregate.lifecycle) if aggregate.lifecycle
28
+ end
29
+ return true if declared.empty?
30
+
31
+ offenders = history.fetch(:instances).filter_map do |key, state|
32
+ aggregate_name = key.split("::").last.split("#").first
33
+ states = declared[aggregate_name]
34
+ next unless states
35
+
36
+ lifecycle = bluebook.aggregate(aggregate_name).lifecycle
37
+ value = state[lifecycle.field]
38
+ next if value.nil? || states.include?(value.to_s)
39
+
40
+ "#{key} holds #{lifecycle.field}=#{value.inspect}, which #{aggregate_name} never declares as a state"
41
+ end
42
+
43
+ offenders.empty? || offenders.join("; ")
44
+ end
45
+
46
+ # Every saga advance a replay actually logged moved along an edge
47
+ # the process manager DECLARED — `(from, to)` pairs that appear in
48
+ # `saga_log` with `advanced: true` must be a `(handler.from_state,
49
+ # handler.to_state)` pair some handler on that PM declares
50
+ # (compensation edges included ; a REFUSED-triggered advance is a
51
+ # handler like any other). A saga that advanced along a pair no
52
+ # handler names would mean the runtime moved state the language
53
+ # never authorized — the same trust ModelCheck's static reachability
54
+ # rests on, checked here against what a run actually did.
55
+ def saga_advances_follow_declared_handlers(history)
56
+ bluebook = history.fetch(:bluebook)
57
+ edges = Hash.new { |h, k| h[k] = [] }
58
+ bluebook.process_managers.each do |pm|
59
+ pm.handlers.each { |handler| edges[pm.name] << [handler.from_state, handler.to_state] }
60
+ end
61
+ return true if edges.empty?
62
+
63
+ offenders = history.fetch(:sagas).filter_map do |entry|
64
+ next unless entry[:advanced]
65
+
66
+ pair = [entry[:from], entry[:to]]
67
+ next if edges[entry[:process_manager]].include?(pair)
68
+
69
+ "#{entry[:process_manager]} advanced #{pair.inspect}, which no declared handler names"
70
+ end
71
+
72
+ offenders.empty? || offenders.join("; ")
73
+ end
74
+
75
+ # THE FOUNDATIONAL ONE. `Hecks::Runtime` mints nothing — every
76
+ # identity is declared and derived, never invented (see
77
+ # command_interpreter.rb's own "NOTHING IS MINTED" — a random hex,
78
+ # a counter, anything not reproducible from the payload, was
79
+ # refused out of the runtime specifically because it broke this).
80
+ # So the SAME steps, replayed against a FRESH boot, must produce
81
+ # BYTE-IDENTICAL events, refusals, and instances — any drift here
82
+ # is nondeterminism the runtime promised not to have: a wall-clock
83
+ # read that leaked into compared state, a Hash iteration order a
84
+ # comparison depended on, anything. Two independent replays, not a
85
+ # cached one compared to itself, so a bug that corrupts the FIRST
86
+ # run's own bookkeeping cannot pass by agreeing with itself.
87
+ def replay_is_deterministic(domain_path, steps, adapter: :memory)
88
+ first = Replay.call(domain_path, steps, adapter: adapter)
89
+ second = Replay.call(domain_path, steps, adapter: adapter)
90
+
91
+ comparable = ->(history) { history.except(:bluebook, :bluebooks) }
92
+ return true if comparable.call(first) == comparable.call(second)
93
+
94
+ "two replays of the same #{steps.length} steps produced different histories"
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,206 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module Properties
4
+ # Query-answering properties: a query's own answer matches a from-
5
+ # scratch reference recomputation, paging partitions its rows the same
6
+ # way, and the shared query/paging helpers both lean on.
7
+
8
+ # Holds query_answers_match_reference and paging_offset_partitions_correctly,
9
+ # plus the shared verb-resolution and eligible-rows/hop-chain helpers
10
+ # (#query_for_verb, #query_eligible_rows, #resolve_hop_clause) other
11
+ # property modules in this directory also call.
12
+ module Querying
13
+ # THE QUERY ORACLE — differential testing within the one runtime,
14
+ # the shape the retired cross-runtime harness should always have
15
+ # been. Every generated ask was answered twice at the same instant
16
+ # (Replay records both): once through whatever the aggregate is
17
+ # actually bound to (Memory's native hook is Ports::Query::InMemory;
18
+ # a SQL binding would compile it), once through the reference
19
+ # interpreter's own evaluation. The two are separate, live
20
+ # implementations of the same comparator vocabulary, and they have
21
+ # drifted before — an adapter that ACCEPTS what the reference says
22
+ # matches nothing, or orders what it refuses to order, shows up
23
+ # here as a finding no self-referential adapter spec could see.
24
+ # M23 — `Replay` now runs the native and reference engines
25
+ # INDEPENDENTLY (each in its own begin/rescue — see that file's own
26
+ # comment at the capture site), so this property can tell apart what
27
+ # used to be indistinguishable: "both engines refused" (fine — the
28
+ # ask was genuinely bad, nothing to compare) from "one refused and
29
+ # the other did not" (a real divergence — the two engines disagree
30
+ # about whether the ask was even VALID, never mind what it answers).
31
+ # `native_refused`/`reference_refused` are read by KEY PRESENCE, not
32
+ # truthiness — `Replay` only ever adds `:error`/`:reference_error`
33
+ # to an entry when that side actually raised, so an absent key is an
34
+ # unambiguous "this side answered." A read-model ask (no reference
35
+ # twin attempted at all, `asked[:query]` without "::") is skipped
36
+ # entirely, same as always — there is no second engine to disagree
37
+ # with.
38
+ def query_answers_match_reference(history)
39
+ offenders = history.fetch(:queries).filter_map do |asked|
40
+ next unless asked[:query].is_a?(String) && asked[:query].include?("::")
41
+
42
+ native_refused = asked.key?(:error)
43
+ reference_refused = asked.key?(:reference_error)
44
+
45
+ if native_refused != reference_refused
46
+ next "#{asked[:query]} #{asked[:args].inspect} — native " \
47
+ "#{native_refused ? "refused (#{asked[:error]})" : 'answered'}, " \
48
+ "but the reference interpreter #{reference_refused ? "refused (#{asked[:reference_error]})" : 'answered'} — " \
49
+ "a refusal-shaped divergence, not just a differing row set"
50
+ end
51
+
52
+ next if native_refused
53
+ next if asked[:rows] == asked[:reference_rows]
54
+
55
+ "#{asked[:query]} #{asked[:args].inspect} answered #{asked[:rows].inspect} " \
56
+ "natively but #{asked[:reference_rows].inspect} through the reference interpreter"
57
+ end
58
+
59
+ offenders.empty? || offenders.join("; ")
60
+ end
61
+
62
+ # THE SAME "TWO ENGINES, COMPARED" SHAPE query_answers_match_reference
63
+ # already uses, aimed squarely at Query#options' offset/limit pair —
64
+ # but recomputed from history[:instances] directly, a THIRD,
65
+ # independent computation, rather than comparing QueryInterpreter's
66
+ # own native and reference paths against each other (which could
67
+ # share the identical bug neither implementation happened to hit —
68
+ # see #4's own fix, which touched BOTH #interpret and
69
+ # #reference_interpret at once). `order_by` declared alongside
70
+ # `offset` or `limit` names a genuinely paged query. Ports::Query::
71
+ # Ordering.apply is the SAME engine QueryInterpreter#ordered calls,
72
+ # reused here rather than re-derived, so this oracle cannot drift
73
+ # from what "in order" means without the interpreter drifting the
74
+ # identical way — only the offset-then-limit .drop/.first slice
75
+ # (#4's own fix) is independently reproduced, in plain Ruby.
76
+ #
77
+ # Real target: ATMCard.ByFee (`limit 3; offset 1`).
78
+ #
79
+ # One independent recomputation, order-dependent by construction
80
+ # (declared query -> eligible rows -> ordered -> offset-sliced ->
81
+ # limit-sliced -> compared against the real answer, per the doc
82
+ # comment above): splitting it would scatter `rows`/`ordered`/
83
+ # `skipped`/`expected` across method boundaries as params/returns
84
+ # for a sequence that's only ever computed once, in this order.
85
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
86
+ def paging_offset_partitions_correctly(history)
87
+ bluebooks = history.fetch(:bluebooks)
88
+
89
+ offenders = history.fetch(:queries).filter_map do |asked|
90
+ next if asked[:error] || !asked[:query].is_a?(String) || !asked[:query].include?("::")
91
+
92
+ declared = query_for_verb(bluebooks, asked[:query])
93
+ next unless declared&.order_by && (declared.offset || declared.limit)
94
+
95
+ domain, aggregate_name, = Naming.split_verb(asked[:query])
96
+ args = asked[:args] || {}
97
+ rows = query_eligible_rows(asked.fetch(:instances_at), domain, aggregate_name, declared.wheres, args,
98
+ bluebooks: bluebooks)
99
+ ordered = Ports::Query::Ordering.apply(
100
+ rows, declared.order_by, declared.null_semantics, identity: ->(row) { row[:id].to_s }
101
+ ) { |row| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, declared.order_by.field)) }
102
+
103
+ skipped = declared.offset ? ordered.drop(resolve_paging_value(declared.offset.value, args).to_i) : ordered
104
+ expected = declared.limit ? skipped.first(resolve_paging_value(declared.limit.value, args).to_i) : skipped
105
+ actual = asked[:rows]
106
+ next if actual == expected
107
+
108
+ "#{asked[:query]} #{args.inspect} answered #{actual.inspect}, but independently recomputing " \
109
+ "order/offset/limit from #{rows.length} eligible row(s) gives #{expected.inspect}"
110
+ end
111
+
112
+ offenders.empty? || offenders.join("; ")
113
+ end
114
+
115
+ # THE DECLARED Query ITSELF, resolved from a replayed verb — the
116
+ # same shape #command_for_verb resolves a command by, one
117
+ # construct over. Entity-level queries (a dotted query_path) are
118
+ # out of scope here — paging on an entity's own list has no real
119
+ # corpus site yet, and the "one many-side head, one aggregate,
120
+ # no FK-join" shape #query_eligible_rows assumes doesn't hold for
121
+ # one.
122
+ def query_for_verb(bluebooks, verb)
123
+ domain, aggregate_name, query_path = Naming.split_verb(verb)
124
+ return nil unless query_path && !query_path.include?(".")
125
+
126
+ bluebook = bluebooks[domain]
127
+ aggregate = bluebook&.aggregate(aggregate_name)
128
+ aggregate&.query(query_path)
129
+ end
130
+
131
+ # A QUERY'S OWN ROWS — unlike #eligible_rows (a ReadModel's
132
+ # reduced/grouped many-side head, possibly FK-joined against a
133
+ # root), a Query always asks about its OWN owning aggregate
134
+ # directly ; no join, no reference_target. `id:` merged in the
135
+ # same way #eligible_rows' own rows are, since a stable sort
136
+ # (Ordering.apply's own `identity:`) and the real answer's own
137
+ # `record.state.merge(id: record.id)` both need it.
138
+ # `bluebooks:` — needed ONLY to recognise and resolve a `/` HOP
139
+ # clause (`engagement/client/status`, hop_chain.bluebook's own
140
+ # PricedAboveViaEngagement): a hop's head names one of the OWNING
141
+ # aggregate's declared references, and only the declaration graph
142
+ # can say which attribute that is and which aggregate it targets.
143
+ # A local clause never consults it. Latent gap this closed, found
144
+ # by the fuzzer itself the first time a generated sequence ever
145
+ # built a full hop chain AND had its paged query answer a row
146
+ # (seed 1, the moment scalar_value_objects.bluebook joined the
147
+ # fixtures corpus and shifted every seeded draw): the recompute
148
+ # dug `engagement/client/status` as a LOCAL dotted path, found
149
+ # nil, and declared every genuinely-eligible row ineligible — a
150
+ # false property violation against a correct runtime answer,
151
+ # reproducible on an untouched main with this same 4-step script.
152
+ def query_eligible_rows(instances, domain, aggregate_name, wheres, args, bluebooks: {})
153
+ aggregate = bluebooks[domain]&.aggregate(aggregate_name)
154
+ prefix = "#{domain}::#{aggregate_name}#"
155
+ instances.filter_map do |key, state|
156
+ next unless key.start_with?(prefix)
157
+
158
+ row = state.merge(id: key.split("#").last)
159
+ next unless wheres.all? do |clause|
160
+ resolved = resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
161
+ held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, resolved.field))
162
+ Ports::Query::InMemory.holds?(resolved, held, args)
163
+ end
164
+
165
+ row
166
+ end
167
+ end
168
+
169
+ # `Runtime::ReferenceHop#fold`, independently restated over the
170
+ # replay's own `:instances_at` snapshot instead of live
171
+ # repositories — the same shape every other recompute in this
172
+ # file takes (never the runtime's own code path, or the property
173
+ # would be checking the runtime against itself). One hop peels
174
+ # off the head (`HopPath.next_hop`, the identical one-step
175
+ # primitive the live fold uses), the inner clause recurses
176
+ # through `query_eligible_rows` against the TARGET's own
177
+ # snapshot rows (so a multi-hop tail resolves hop by hop, exactly
178
+ # as the live path's own recursion does), and the ids that
179
+ # answered fold back as the same local `in` membership clause the
180
+ # live fold builds. A clause with no `/`, or one whose head this
181
+ # aggregate's declarations cannot resolve, passes through
182
+ # untouched and evaluates locally as it always did.
183
+ def resolve_hop_clause(instances, domain, aggregate, clause, args, bluebooks)
184
+ return clause unless aggregate && QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes)
185
+
186
+ hop, rest = QuerySpecification::HopPath.next_hop(clause.field, aggregate.attributes)
187
+ target = hop.target
188
+ return clause unless target
189
+
190
+ inner = QuerySpecification::Common::WhereClause.new(field: rest, op: clause.op, value: clause.value)
191
+ ids = query_eligible_rows(instances, domain, target.hecks_name, [inner], args, bluebooks: bluebooks)
192
+ .map { |row| row[:id].to_s }.uniq
193
+
194
+ QuerySpecification::Common::WhereClause.new(field: hop.attribute.name, op: "in", value: ids)
195
+ end
196
+
197
+ # `QueryInterpreter#resolve_query_value`, reproduced: a declared
198
+ # limit/offset is either a literal or a Symbol naming an argument
199
+ # the caller supplied.
200
+ def resolve_paging_value(value, args)
201
+ value.is_a?(Symbol) ? args[value] : value
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end