hecks 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +13 -0
- data/lib/hecks/adapters/driven/memory.rb +1 -1
- data/lib/hecks/adapters/driven/postgres.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
- data/lib/hecks/behaviors/expectations.rb +2 -2
- data/lib/hecks/bluebook/assembly/contract.rb +1 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
- data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
- data/lib/hecks/bluebook/assembly.rb +1 -1
- data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
- data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
- data/lib/hecks/bluebook/capabilities.rb +2 -2
- data/lib/hecks/bluebook/chapter.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
- data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
- data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
- data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
- data/lib/hecks/bluebook/meta_validator.rb +1 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
- data/lib/hecks/bluebook/model_check.rb +12 -13
- data/lib/hecks/cache_dir.rb +103 -0
- data/lib/hecks/cli/document.rb +104 -0
- data/lib/hecks/cli/ir.rb +47 -0
- data/lib/hecks/cli/mcp.rb +32 -0
- data/lib/hecks/cli/mcp_door.rb +461 -0
- data/lib/hecks/cli/model_check.rb +270 -0
- data/lib/hecks/cli/project_cli.rb +123 -0
- data/lib/hecks/cli/project_diagrams.rb +43 -0
- data/lib/hecks/cli/run.rb +219 -0
- data/lib/hecks/cli/smoke_test.rb +59 -0
- data/lib/hecks/cli/stores.rb +54 -0
- data/lib/hecks/cli.rb +166 -0
- data/lib/hecks/facade/handle.rb +2 -2
- data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
- data/lib/hecks/mcp_door_scope.rb +176 -0
- data/lib/hecks/ports/persistence/append_only.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
- data/lib/hecks/ports/persistence/state_codec.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -3
- data/lib/hecks/projections/reference.rb +7 -1
- data/lib/hecks/projector/exporter.rb +4 -4
- data/lib/hecks/projector/target.rb +1 -1
- data/lib/hecks/release/runner/ci_publisher.rb +96 -0
- data/lib/hecks/release/runner/commands.rb +68 -0
- data/lib/hecks/release/runner/console.rb +52 -0
- data/lib/hecks/release/runner/gem_publisher.rb +44 -0
- data/lib/hecks/release/runner/git.rb +48 -0
- data/lib/hecks/release/runner/npm_publisher.rb +89 -0
- data/lib/hecks/release/runner/preflight.rb +119 -0
- data/lib/hecks/release/runner/published.rb +55 -0
- data/lib/hecks/release/runner/tagger.rb +87 -0
- data/lib/hecks/release/runner.rb +244 -0
- data/lib/hecks/router/namespace_installer.rb +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +3 -3
- data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
- data/lib/hecks/runtime/entity_element.rb +1 -1
- data/lib/hecks/runtime/entity_interpreter.rb +1 -1
- data/lib/hecks/runtime/invocation.rb +1 -1
- data/lib/hecks/runtime/loader.rb +1 -1
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/reaction_invocation.rb +3 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
- data/lib/hecks/runtime/reference_hop.rb +4 -4
- data/lib/hecks/runtime/registry/verification.rb +11 -11
- data/lib/hecks/runtime/registry.rb +8 -4
- data/lib/hecks/runtime/saga_interpreter.rb +1 -1
- data/lib/hecks/runtime/value/coercion.rb +3 -3
- data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
- data/lib/hecks/storehouse.rb +18 -12
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +5 -0
- data/lib/hecks.rb +6 -1
- metadata +28 -58
- data/lib/hecks/bench/cli.rb +0 -74
- data/lib/hecks/bench/environment.rb +0 -100
- data/lib/hecks/bench/postgres_probe.rb +0 -41
- data/lib/hecks/bench/report.rb +0 -116
- data/lib/hecks/bench/ruby_runner.rb +0 -151
- data/lib/hecks/bench/run.rb +0 -55
- data/lib/hecks/bench/rust_runner.rb +0 -165
- data/lib/hecks/bench/stats.rb +0 -75
- data/lib/hecks/bench/suite.rb +0 -172
- data/lib/hecks/bench/workload.rb +0 -137
- data/lib/hecks/bench.rb +0 -28
- data/lib/hecks/codemod.rb +0 -443
- data/lib/hecks/corpus.rb +0 -479
- data/lib/hecks/doc/reference.rb +0 -585
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
- data/lib/hecks/fuzzing/combination_miner.rb +0 -178
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
- data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
- data/lib/hecks/fuzzing/differential.rb +0 -192
- data/lib/hecks/fuzzing/domain_generator.rb +0 -871
- data/lib/hecks/fuzzing/era_boundary.rb +0 -154
- data/lib/hecks/fuzzing/form_census.rb +0 -285
- data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
- data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
- data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
- data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
- data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
- data/lib/hecks/fuzzing/properties/guards.rb +0 -398
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
- data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
- data/lib/hecks/fuzzing/properties/querying.rb +0 -260
- data/lib/hecks/fuzzing/properties.rb +0 -267
- data/lib/hecks/fuzzing/qa_settings.rb +0 -164
- data/lib/hecks/fuzzing/replay.rb +0 -930
- data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
- data/lib/hecks/fuzzing/self_consistency.rb +0 -825
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
- data/lib/hecks/fuzzing/shrinker.rb +0 -309
- data/lib/hecks/fuzzing/structural_skips.rb +0 -53
- data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
- data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
- data/lib/hecks/fuzzing/value_generator.rb +0 -340
- data/lib/hecks/fuzzing.rb +0 -23
- data/lib/hecks/grammar/evolve.rb +0 -524
- data/lib/hecks/query_ir.rb +0 -482
|
@@ -1,399 +0,0 @@
|
|
|
1
|
-
module Hecks
|
|
2
|
-
module Fuzzing
|
|
3
|
-
module Properties
|
|
4
|
-
# Stored-record, saga-rehydration, fan-out, and read-model-aggregation
|
|
5
|
-
# properties: every stored record still satisfies its own declared
|
|
6
|
-
# invariants, a saga rehydrates cleanly from a checkpoint, a policy's
|
|
7
|
-
# own for_each/where fans out exactly once per matching row, and a
|
|
8
|
-
# read model's count/median/group_by answers match an independent
|
|
9
|
-
# recomputation.
|
|
10
|
-
|
|
11
|
-
# Holds stored_records_satisfy_declared_invariants, sagas_rehydrate_cleanly,
|
|
12
|
-
# fanout_dispatches_once_per_matching_row, aggregation_matches_recompute,
|
|
13
|
-
# and group_by_matches_recompute, plus the recomputation helpers
|
|
14
|
-
# (#check_piece_invariants, #eligible_rows, #nest_rows,
|
|
15
|
-
# #recompute_median) each leans on.
|
|
16
|
-
module InvariantsAndAggregation
|
|
17
|
-
# Every stored record still satisfies its own aggregate's declared
|
|
18
|
-
# invariants — Admissibility#enforce_invariants (command_rules/
|
|
19
|
-
# admissibility.rb) checks these after every command's mutations,
|
|
20
|
-
# before save, the same point `ensures` is checked. Nothing until
|
|
21
|
-
# now re-checked a record after a whole replay finished, independent
|
|
22
|
-
# of whichever call site was supposed to have refused a violation
|
|
23
|
-
# in the first place — a record failing its own declared invariant
|
|
24
|
-
# here is proof a violating write landed anyway: the call site
|
|
25
|
-
# stopped calling enforce_invariants, or some other path (a
|
|
26
|
-
# translation, a backfill) wrote around it entirely.
|
|
27
|
-
#
|
|
28
|
-
# `history[:instances]` entries are already plain, symbol-keyed
|
|
29
|
-
# state Hashes (Replay.call's own `record.state`) — called against
|
|
30
|
-
# Evaluator.call the same way ValueObject::Builder#build already
|
|
31
|
-
# does for a VO's own invariants (value/coercion.rb), no GuardState
|
|
32
|
-
# wrapper needed the way enforce_invariants' own live call uses one
|
|
33
|
-
# (GuardState exists for `parent.`/projected-field dereferencing
|
|
34
|
-
# mid-dispatch; a stored record's own scalar fields need none of
|
|
35
|
-
# that to re-check a same-aggregate invariant against itself).
|
|
36
|
-
#
|
|
37
|
-
# Real target: Account's own `invariant("the balance never goes
|
|
38
|
-
# negative") { balance.cents >= 0 }`.
|
|
39
|
-
#
|
|
40
|
-
# Entity#invariants (round 7) closes here too, not for free —
|
|
41
|
-
# `stored_records_satisfy_declared_invariants` only ever checked
|
|
42
|
-
# the aggregate's own flat state; a piece's own invariant is
|
|
43
|
-
# checked against every element of a `list_of` field, a genuinely
|
|
44
|
-
# different walk `check_piece_invariants` below makes,
|
|
45
|
-
# independently of `Admissibility#check_entity_invariants` (the
|
|
46
|
-
# live enforcement path this property exists to catch drifting
|
|
47
|
-
# from) — same reasoning `stored_records_satisfy_declared_
|
|
48
|
-
# invariants`' own top-level check already applies one level up.
|
|
49
|
-
#
|
|
50
|
-
# Real target: SafeDepositBox's own Visit — `invariant("a written
|
|
51
|
-
# note is not blank") { !note || !note.text.to_s.empty? }`.
|
|
52
|
-
#
|
|
53
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
54
|
-
# @return [true, String] true if every stored record satisfies its own
|
|
55
|
-
# (and its own entities') declared invariants; otherwise a message
|
|
56
|
-
# naming the record and the invariant it violates
|
|
57
|
-
def stored_records_satisfy_declared_invariants(history)
|
|
58
|
-
bluebooks = history.fetch(:bluebooks)
|
|
59
|
-
|
|
60
|
-
offenders = history.fetch(:instances).filter_map do |key, state|
|
|
61
|
-
domain_name = key.split("::").first
|
|
62
|
-
aggregate_name = key.split("::").last.split("#").first
|
|
63
|
-
bluebook = bluebooks[domain_name]
|
|
64
|
-
aggregate = bluebook&.aggregate(aggregate_name)
|
|
65
|
-
next unless aggregate
|
|
66
|
-
|
|
67
|
-
violated = aggregate.invariants.find do |invariant|
|
|
68
|
-
!Bluebook::Expression::Evaluator.call(invariant.canonical, state)
|
|
69
|
-
end
|
|
70
|
-
next "#{key} violates #{aggregate_name}'s own declared invariant #{violated.description.inspect}" if violated
|
|
71
|
-
|
|
72
|
-
check_piece_invariants(aggregate, state, key)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
offenders.empty? || offenders.join("; ")
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# A piece's own invariant, checked against every element a
|
|
79
|
-
# `list_of` field holds — the same lookup `Admissibility#
|
|
80
|
-
# check_entity_invariants` makes (`owner.attributes.find { |a|
|
|
81
|
-
# a.list? && a.type.to_s == entity.hecks_name }`), independently
|
|
82
|
-
# reapplied here against a stored record's own plain Hash state
|
|
83
|
-
# rather than a live `Instance`.
|
|
84
|
-
# @param owner_construct [Bluebook::Aggregate, Bluebook::Entity] the
|
|
85
|
-
# construct whose own `list_of` entities to check
|
|
86
|
-
# @param owner_state [Hash] `owner_construct`'s own stored state
|
|
87
|
-
# @param key [String] the top-level record key, for the message
|
|
88
|
-
# @return [String, nil] a message naming the first violating piece found,
|
|
89
|
-
# at any nesting depth; `nil` if every piece satisfies its invariants
|
|
90
|
-
def check_piece_invariants(owner_construct, owner_state, key)
|
|
91
|
-
owner_construct.entities.each do |entity|
|
|
92
|
-
next if entity.invariants.empty?
|
|
93
|
-
|
|
94
|
-
list_attr = owner_construct.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
|
|
95
|
-
next unless list_attr
|
|
96
|
-
|
|
97
|
-
Array(owner_state[list_attr.name]).each do |element|
|
|
98
|
-
violated = entity.invariants.find do |invariant|
|
|
99
|
-
!Bluebook::Expression::Evaluator.call(invariant.canonical, element)
|
|
100
|
-
end
|
|
101
|
-
if violated
|
|
102
|
-
return "#{key}'s own #{entity.hecks_name} violates its declared invariant " \
|
|
103
|
-
"#{violated.description.inspect}"
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
nested = check_piece_invariants(entity, element, key)
|
|
107
|
-
return nested if nested
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
nil
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# A saga instance's own checkpoint survives being written and read
|
|
114
|
-
# back — the durability contract `SagaInterpreter#checkpoint` makes
|
|
115
|
-
# (`state:` plus a `deep_copy`d `memory:`, handed to whatever
|
|
116
|
-
# adapter answers `save_saga`) and `Registry#rehydrate_sagas!`
|
|
117
|
-
# promises to restore on the next boot (`each_saga` yielding
|
|
118
|
-
# `[pm, correlation, state, memory]` back into `saga_instances`).
|
|
119
|
-
# `Replay` captures the live store already materialised the same
|
|
120
|
-
# way `checkpoint` itself does (`Value.materialize`, not raw
|
|
121
|
-
# `Runtime::Value`s — see its own comment); this property pushes
|
|
122
|
-
# that captured memory through the same `JSON.generate` then
|
|
123
|
-
# `JSON.parse(symbolize_names: true)` round-trip `checkpoint`'s own
|
|
124
|
-
# `deep_copy` performs (mirrored here rather than called — a
|
|
125
|
-
# private instance method with no registry to hand it) and checks
|
|
126
|
-
# it comes back byte-identical. A memory holding anything that
|
|
127
|
-
# round-trip cannot carry faithfully — a bare Symbol leaf, a
|
|
128
|
-
# non-JSON type a future field introduces — is corruption the
|
|
129
|
-
# durable path would introduce on a real restart, caught here
|
|
130
|
-
# without needing one.
|
|
131
|
-
#
|
|
132
|
-
# `declares_state?` (Behaviour::ProcessManager) is the other half:
|
|
133
|
-
# a live or rehydrated instance sitting in a state the procedure
|
|
134
|
-
# never declares is the saga-durability twin of
|
|
135
|
-
# `lifecycle_values_are_declared` above.
|
|
136
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
137
|
-
# @return [true, String] true if every saga instance holds a declared
|
|
138
|
-
# state and its own memory survives a checkpoint round-trip; otherwise a
|
|
139
|
-
# message naming the process manager, correlation, and problem
|
|
140
|
-
def sagas_rehydrate_cleanly(history)
|
|
141
|
-
bluebook = history.fetch(:bluebook)
|
|
142
|
-
process_managers = bluebook.process_managers.to_h { |pm| [pm.name, pm] }
|
|
143
|
-
|
|
144
|
-
offenders = history.fetch(:saga_instances).flat_map do |pm_name, conversations|
|
|
145
|
-
pm = process_managers[pm_name]
|
|
146
|
-
|
|
147
|
-
conversations.filter_map do |correlation, instance|
|
|
148
|
-
problems = []
|
|
149
|
-
|
|
150
|
-
problems << "holds state #{instance[:state].inspect}, which #{pm_name} never declares" \
|
|
151
|
-
if pm && !pm.declares_state?(instance[:state])
|
|
152
|
-
|
|
153
|
-
rehydrated = JSON.parse(JSON.generate(instance[:memory]), symbolize_names: true)
|
|
154
|
-
if rehydrated != instance[:memory]
|
|
155
|
-
problems << "memory does not survive its own checkpoint round-trip " \
|
|
156
|
-
"(checkpointed #{instance[:memory].inspect}, rehydrated #{rehydrated.inspect})"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
next if problems.empty?
|
|
160
|
-
|
|
161
|
-
"#{pm_name}##{correlation.inspect}: #{problems.join(' and ')}"
|
|
162
|
-
end
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
offenders.empty? || offenders.join("; ")
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
# A `for_each` policy dispatches exactly once per row its declared
|
|
169
|
-
# query answers — never once for the triggering event regardless of
|
|
170
|
-
# row count, never skipping a matched row, never firing on a row a
|
|
171
|
-
# concurrent mutation only made match after the fact. `Replay`
|
|
172
|
-
# computes the expected row-id set independently, at the same
|
|
173
|
-
# instant the real dispatch runs (`Replay.expected_fan_out_rows`,
|
|
174
|
-
# the query oracle's own shape aimed at fan-out: two engines
|
|
175
|
-
# compared, never one graded against itself), and records it
|
|
176
|
-
# beside what the reaction log actually shows. `expected_row_ids`
|
|
177
|
-
# is `nil`, not `[]`, when `policy.where` did not hold — no
|
|
178
|
-
# dispatch is the claim then, not "dispatched to zero rows," and a
|
|
179
|
-
# policy that dispatched anyway despite a failing guard is as real
|
|
180
|
-
# a finding as a row it skipped.
|
|
181
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
182
|
-
# @return [true, String] true if every fan-out finding's actual dispatches
|
|
183
|
-
# match its independently computed expected row set; otherwise a message
|
|
184
|
-
# naming the policy, event, and disagreement
|
|
185
|
-
def fanout_dispatches_once_per_matching_row(history)
|
|
186
|
-
offenders = history.fetch(:fan_outs).filter_map do |finding|
|
|
187
|
-
expected = finding[:expected_row_ids]
|
|
188
|
-
actual = finding[:actual_row_ids].sort
|
|
189
|
-
|
|
190
|
-
if expected.nil?
|
|
191
|
-
next if actual.empty?
|
|
192
|
-
|
|
193
|
-
next "#{finding[:policy]} on #{finding[:on]}: where did not hold, but dispatched to #{actual.inspect}"
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
next if actual == expected
|
|
197
|
-
|
|
198
|
-
"#{finding[:policy]} on #{finding[:on]}: for_each answered #{expected.inspect}, " \
|
|
199
|
-
"but the reaction log shows dispatches to #{actual.inspect}"
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
offenders.empty? || offenders.join("; ")
|
|
203
|
-
end
|
|
204
|
-
|
|
205
|
-
# A `count`/`median` report's reduced scalar matches the same
|
|
206
|
-
# reduction done independently, over the same eligible rows —
|
|
207
|
-
# `ReadModelInterpreter#project`'s own FK-join (root first, then
|
|
208
|
-
# each many-side head matched against it) and `#median` (odd →
|
|
209
|
-
# the true middle, even → the average of the two middles as a
|
|
210
|
-
# Float, empty → `nil`; `count` is the filtered length, empty →
|
|
211
|
-
# `0`), reproduced here in plain Ruby against `history[:instances]`
|
|
212
|
-
# rather than a live registry — `FieldPath.dig` +
|
|
213
|
-
# `Ports::Query::InMemory.comparable`/`.holds?` are the same two
|
|
214
|
-
# calls the interpreter itself makes to read a field and judge a
|
|
215
|
-
# `where`, called here rather than re-derived, so this oracle
|
|
216
|
-
# cannot drift from what "read a field" or "a clause holds" mean
|
|
217
|
-
# without the interpreter drifting the identical way.
|
|
218
|
-
#
|
|
219
|
-
# Only a report whose `:query` is answered by the same bluebook
|
|
220
|
-
# `history[:bluebook]` carries (the bare `Domain.report_name`
|
|
221
|
-
# form, `domain == bluebook.name`) is checked — the same "only
|
|
222
|
-
# what we have the grammar for" scope `lifecycle_values_are_declared`
|
|
223
|
-
# already takes for a multi-domain replay.
|
|
224
|
-
# See the comment above: a closed chain of eligibility guards
|
|
225
|
-
# ("only a report answered by this bluebook, only a count/median
|
|
226
|
-
# report, only one with a reduced many-side head") each gating the
|
|
227
|
-
# next, ending in one independent recomputation compared against
|
|
228
|
-
# the live answer. The guards are what make this oracle scoped
|
|
229
|
-
# correctly, not incidental complexity — narrower than "every
|
|
230
|
-
# branch reads as its own precondition."
|
|
231
|
-
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
232
|
-
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
233
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
234
|
-
# @return [true, String] true if every eligible count/median report answer
|
|
235
|
-
# matches an independent recomputation; otherwise a message naming the
|
|
236
|
-
# query and the disagreement
|
|
237
|
-
def aggregation_matches_recompute(history)
|
|
238
|
-
bluebook = history.fetch(:bluebook)
|
|
239
|
-
|
|
240
|
-
offenders = history.fetch(:queries).filter_map do |asked|
|
|
241
|
-
next if asked[:error]
|
|
242
|
-
|
|
243
|
-
domain, name = asked[:query].to_s.split(".", 2)
|
|
244
|
-
next unless name && domain == bluebook.name
|
|
245
|
-
|
|
246
|
-
model = bluebook.read_model(name)
|
|
247
|
-
next unless model && (model.count? || model.median_field)
|
|
248
|
-
|
|
249
|
-
reduced_head = model.aggregate_heads.find { |head| head[:many] }
|
|
250
|
-
next unless reduced_head
|
|
251
|
-
|
|
252
|
-
rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, reduced_head, asked[:args] || {})
|
|
253
|
-
expected = model.count? ? rows.length : recompute_median(rows, model.median_field)
|
|
254
|
-
actual = asked[:rows]&.first&.dig(reduced_head[:as])
|
|
255
|
-
next if actual == expected
|
|
256
|
-
|
|
257
|
-
"#{asked[:query]} #{asked[:args].inspect} answered #{actual.inspect} for #{reduced_head[:as]}, " \
|
|
258
|
-
"but recomputing independently from #{rows.length} eligible row(s) gives #{expected.inspect}"
|
|
259
|
-
end
|
|
260
|
-
|
|
261
|
-
offenders.empty? || offenders.join("; ")
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
# `aggregation_matches_recompute`'s own shape, extended from
|
|
265
|
-
# reducing a many-side head to a scalar (count/median) to nesting
|
|
266
|
-
# it — `ReadModelInterpreter#group_by_target`/`#nest`, reproduced
|
|
267
|
-
# here in plain Ruby against `history[:instances]` the same way
|
|
268
|
-
# `eligible_rows` already reproduces the FK-join and `where`
|
|
269
|
-
# narrowing count/median share. `Value.materialize_unwrapped` is
|
|
270
|
-
# the same call `#project` makes before nesting (a single-field
|
|
271
|
-
# value object recurses to its bare scalar — a real grouping key
|
|
272
|
-
# has to be one) — called here rather than re-derived, so this
|
|
273
|
-
# oracle cannot drift from what "the group key" means without the
|
|
274
|
-
# interpreter drifting the identical way.
|
|
275
|
-
#
|
|
276
|
-
# Real target: AccountsByKind (`group_by :kind, :number`,
|
|
277
|
-
# rootless — always generator-eligible with `{}` args).
|
|
278
|
-
# Same closed eligibility-guard chain as aggregation_matches_
|
|
279
|
-
# recompute just above (see its own comment) — group_by in place
|
|
280
|
-
# of count/median, nest_rows in place of recompute_median.
|
|
281
|
-
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
282
|
-
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
283
|
-
#
|
|
284
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
285
|
-
# @return [true, String] true if every eligible group_by report answer matches
|
|
286
|
-
# an independent recompute; otherwise a semicolon-joined message naming each
|
|
287
|
-
# offending query
|
|
288
|
-
def group_by_matches_recompute(history)
|
|
289
|
-
bluebook = history.fetch(:bluebook)
|
|
290
|
-
|
|
291
|
-
offenders = history.fetch(:queries).filter_map do |asked|
|
|
292
|
-
next if asked[:error]
|
|
293
|
-
|
|
294
|
-
domain, name = asked[:query].to_s.split(".", 2)
|
|
295
|
-
next unless name && domain == bluebook.name
|
|
296
|
-
|
|
297
|
-
model = bluebook.read_model(name)
|
|
298
|
-
next unless model&.group_by&.any?
|
|
299
|
-
|
|
300
|
-
grouped_head = model.aggregate_heads.find { |head| head[:many] }
|
|
301
|
-
next unless grouped_head
|
|
302
|
-
|
|
303
|
-
rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, grouped_head, asked[:args] || {})
|
|
304
|
-
materialized = rows.map { |state| Runtime::Value.materialize_unwrapped(state) }
|
|
305
|
-
expected = nest_rows(materialized, model.group_by_fields)
|
|
306
|
-
actual = asked[:rows]&.first&.dig(grouped_head[:as])
|
|
307
|
-
next if actual == expected
|
|
308
|
-
|
|
309
|
-
"#{asked[:query]} #{asked[:args].inspect} answered a #{grouped_head[:as]} grouping that disagrees " \
|
|
310
|
-
"with independently nesting group_by #{model.group_by_fields.inspect} over #{rows.length} " \
|
|
311
|
-
"eligible row(s)"
|
|
312
|
-
end
|
|
313
|
-
|
|
314
|
-
offenders.empty? || offenders.join("; ")
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
# `ReadModelInterpreter#nest`, byte for byte: one level of nesting
|
|
318
|
-
# per `group_by` field in declared order, leaf is the row with
|
|
319
|
-
# every grouped field stripped (already spent, as the keys that
|
|
320
|
-
# reached it).
|
|
321
|
-
# @param rows [Array<Hash>] materialized, symbol-keyed rows to nest
|
|
322
|
-
# @param fields [Array<Symbol>] the `group_by` fields, in declared order
|
|
323
|
-
# @return [Hash] one level of nesting per field, in order; the leaf under
|
|
324
|
-
# each key path is the first row in that group with every grouped field
|
|
325
|
-
# stripped
|
|
326
|
-
def nest_rows(rows, fields)
|
|
327
|
-
field, *rest = fields
|
|
328
|
-
rows.group_by { |row| row[field] }.transform_values do |group|
|
|
329
|
-
stripped = group.map { |row| row.reject { |key, _| key == field } }
|
|
330
|
-
rest.empty? ? stripped.first : nest_rows(stripped, rest)
|
|
331
|
-
end
|
|
332
|
-
end
|
|
333
|
-
|
|
334
|
-
# The eligible rows a `count`/`median` head reduces — every
|
|
335
|
-
# instance of the reduced head's own aggregate, FK-matched against
|
|
336
|
-
# the report's root reference (if it has one; a rootless report has
|
|
337
|
-
# none to match) exactly the way `ReadModelInterpreter#reference_fields`
|
|
338
|
-
# finds the matching attribute, then narrowed by the report's own
|
|
339
|
-
# `where` clauses via the same `InMemory.holds?` the interpreter's
|
|
340
|
-
# `execute` calls.
|
|
341
|
-
# @param bluebook [Bluebook::Chapter] the bluebook the report belongs to
|
|
342
|
-
# @param instances [Hash] the snapshot to read rows from (`history[:instances]`
|
|
343
|
-
# shape, or the query's own `instances_at`)
|
|
344
|
-
# @param domain [String] the domain name the reduced aggregate belongs to
|
|
345
|
-
# @param model [Bluebook::ReadModel] the report being recomputed
|
|
346
|
-
# @param reduced_head [Hash] the many-side aggregate head being reduced, from
|
|
347
|
-
# `model.aggregate_heads`
|
|
348
|
-
# @param args [Hash] the query's own arguments, for FK matching and `where`
|
|
349
|
-
# evaluation
|
|
350
|
-
# @return [Array<Hash>] every eligible row's own state, `id:` merged in
|
|
351
|
-
def eligible_rows(bluebook, instances, domain, model, reduced_head, args)
|
|
352
|
-
aggregate = bluebook.aggregate(reduced_head[:aggregate])
|
|
353
|
-
prefix = "#{domain}::#{reduced_head[:aggregate]}#"
|
|
354
|
-
# `id:` merged in, the same `record.to_h` (`@state.merge(id:
|
|
355
|
-
# @id)`) every live head row carries — count/median never read
|
|
356
|
-
# it, but group_by_matches_recompute's own independent nesting
|
|
357
|
-
# does, the same way ReadModelInterpreter#row(record) = record.
|
|
358
|
-
# to_h does for the live path it's checking against.
|
|
359
|
-
rows = instances.filter_map { |key, state| state.merge(id: key.split("#").last) if key.start_with?(prefix) }
|
|
360
|
-
|
|
361
|
-
if model.reference_target
|
|
362
|
-
reference_id = args[model.reference_name].to_s
|
|
363
|
-
fk_fields = aggregate.attributes.select do |attribute|
|
|
364
|
-
attribute.reference? && attribute.type.target_name == model.reference_target.to_s
|
|
365
|
-
end.map(&:name)
|
|
366
|
-
|
|
367
|
-
rows = rows.select { |state| fk_fields.any? { |field| state[field].to_s == reference_id } }
|
|
368
|
-
end
|
|
369
|
-
|
|
370
|
-
rows.select do |state|
|
|
371
|
-
model.wheres.all? do |clause|
|
|
372
|
-
held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, clause.field))
|
|
373
|
-
Ports::Query::InMemory.holds?(clause, held, args)
|
|
374
|
-
end
|
|
375
|
-
end
|
|
376
|
-
end
|
|
377
|
-
|
|
378
|
-
# `ReadModelInterpreter#median`'s own definition, reproduced byte
|
|
379
|
-
# for byte: odd count → the true middle value, sorted; even count
|
|
380
|
-
# → the average of the two middle values, as a Float; empty → nil,
|
|
381
|
-
# never zero, so a caller cannot mistake "nothing to average" for
|
|
382
|
-
# "averaged to zero."
|
|
383
|
-
# @param rows [Array<Hash>] eligible rows, as returned by `#eligible_rows`
|
|
384
|
-
# @param field [Symbol] the field to average
|
|
385
|
-
# @return [Object, Float, nil] the true middle value for an odd count, the
|
|
386
|
-
# Float average of the two middle values for an even count, or `nil` for
|
|
387
|
-
# an empty `rows`
|
|
388
|
-
def recompute_median(rows, field)
|
|
389
|
-
values = rows.map { |state| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, field)) }
|
|
390
|
-
.compact.sort
|
|
391
|
-
return nil if values.empty?
|
|
392
|
-
|
|
393
|
-
middle = values.length / 2
|
|
394
|
-
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
|
|
395
|
-
end
|
|
396
|
-
end
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
end
|
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
require_relative "../nondeterministic"
|
|
2
|
-
|
|
3
|
-
module Hecks
|
|
4
|
-
module Fuzzing
|
|
5
|
-
module Properties
|
|
6
|
-
# Lifecycle/saga-shape properties, and replay determinism itself: a
|
|
7
|
-
# replayed history's lifecycle fields are among the aggregate's own
|
|
8
|
-
# declared states, a saga's advances follow its declared handlers, and
|
|
9
|
-
# replaying the same steps twice produces the same history.
|
|
10
|
-
|
|
11
|
-
# Holds lifecycle_values_are_declared, saga_advances_follow_declared_handlers,
|
|
12
|
-
# and replay_is_deterministic — the foundational property, since every
|
|
13
|
-
# other property here trusts that a single replay's own history is
|
|
14
|
-
# trustworthy in the first place.
|
|
15
|
-
module LifecycleAndReplay
|
|
16
|
-
# Every lifecycle field a replay leaves an instance holding is one
|
|
17
|
-
# of the aggregate's own declared states — the full set, not just
|
|
18
|
-
# `Lifecycle#states`' default+targets (see ModelCheck.full_states'
|
|
19
|
-
# own comment on that hole). The tie to M2 is direct: the model
|
|
20
|
-
# checker proves which states a domain's own declarations can ever
|
|
21
|
-
# produce ; this proves a real run never produced anything else —
|
|
22
|
-
# a coercion bug, a stale string surviving a rename, a default
|
|
23
|
-
# that drifted from the declared set, would all show up here as a
|
|
24
|
-
# value nothing upstream would have predicted.
|
|
25
|
-
#
|
|
26
|
-
# @param history [Hash] a replayed history, as returned by `Fuzzing::Replay.call`
|
|
27
|
-
# @return [true, String] true if every instance's lifecycle field holds a declared
|
|
28
|
-
# state; otherwise a semicolon-joined message naming each offending instance
|
|
29
|
-
def lifecycle_values_are_declared(history)
|
|
30
|
-
bluebook = history.fetch(:bluebook)
|
|
31
|
-
declared = {}
|
|
32
|
-
bluebook.aggregates.each do |aggregate|
|
|
33
|
-
declared[aggregate.hecks_name] = Bluebook::ModelCheck.full_states(aggregate.lifecycle) if aggregate.lifecycle
|
|
34
|
-
end
|
|
35
|
-
return true if declared.empty?
|
|
36
|
-
|
|
37
|
-
offenders = history.fetch(:instances).filter_map do |key, state|
|
|
38
|
-
aggregate_name = key.split("::").last.split("#").first
|
|
39
|
-
states = declared[aggregate_name]
|
|
40
|
-
next unless states
|
|
41
|
-
|
|
42
|
-
lifecycle = bluebook.aggregate(aggregate_name).lifecycle
|
|
43
|
-
value = state[lifecycle.field]
|
|
44
|
-
next if value.nil? || states.include?(value.to_s)
|
|
45
|
-
|
|
46
|
-
"#{key} holds #{lifecycle.field}=#{value.inspect}, which #{aggregate_name} never declares as a state"
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
offenders.empty? || offenders.join("; ")
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# Every saga advance a replay actually logged moved along an edge
|
|
53
|
-
# the process manager declared — `(from, to)` pairs that appear in
|
|
54
|
-
# `saga_log` with `advanced: true` must be a `(handler.from_state,
|
|
55
|
-
# handler.to_state)` pair some handler on that PM declares
|
|
56
|
-
# (compensation edges included ; a refused-triggered advance is a
|
|
57
|
-
# handler like any other). A saga that advanced along a pair no
|
|
58
|
-
# handler names would mean the runtime moved state the language
|
|
59
|
-
# never authorized — the same trust ModelCheck's static reachability
|
|
60
|
-
# rests on, checked here against what a run actually did.
|
|
61
|
-
#
|
|
62
|
-
# @param history [Hash] a replayed history as returned by `Replay.call`
|
|
63
|
-
# @return [true, String] true if every logged advance matches a declared
|
|
64
|
-
# handler edge; otherwise a message naming the process manager and pair
|
|
65
|
-
# that does not
|
|
66
|
-
def saga_advances_follow_declared_handlers(history)
|
|
67
|
-
bluebook = history.fetch(:bluebook)
|
|
68
|
-
edges = Hash.new { |h, k| h[k] = [] }
|
|
69
|
-
bluebook.process_managers.each do |pm|
|
|
70
|
-
pm.handlers.each { |handler| edges[pm.name] << [handler.from_state, handler.to_state] }
|
|
71
|
-
end
|
|
72
|
-
return true if edges.empty?
|
|
73
|
-
|
|
74
|
-
offenders = history.fetch(:sagas).filter_map do |entry|
|
|
75
|
-
next unless entry[:advanced]
|
|
76
|
-
|
|
77
|
-
pair = [entry[:from], entry[:to]]
|
|
78
|
-
next if edges[entry[:process_manager]].include?(pair)
|
|
79
|
-
|
|
80
|
-
"#{entry[:process_manager]} advanced #{pair.inspect}, which no declared handler names"
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
offenders.empty? || offenders.join("; ")
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# The foundational one. `Hecks::Runtime` mints nothing — every
|
|
87
|
-
# identity is declared and derived, never invented (see
|
|
88
|
-
# command_interpreter.rb's own "nothing is minted" — a random hex,
|
|
89
|
-
# a counter, anything not reproducible from the payload, was
|
|
90
|
-
# refused out of the runtime specifically because it broke this).
|
|
91
|
-
# So the same steps, replayed against a fresh boot, must produce
|
|
92
|
-
# byte-identical events, refusals, and instances — any drift here
|
|
93
|
-
# is nondeterminism the runtime promised not to have: a wall-clock
|
|
94
|
-
# read that leaked into compared state, a Hash iteration order a
|
|
95
|
-
# comparison depended on, anything. Two independent replays, not a
|
|
96
|
-
# cached one compared to itself, so a bug that corrupts the first
|
|
97
|
-
# run's own bookkeeping cannot pass by agreeing with itself.
|
|
98
|
-
#
|
|
99
|
-
# @param domain_path [String] path to the domain directory to boot, such as
|
|
100
|
-
# `"examples/pizzas"`
|
|
101
|
-
# @param steps [Array<Hash>] the step list to replay twice
|
|
102
|
-
# @param adapter [Symbol] persistence adapter to boot with (`:memory`,
|
|
103
|
-
# `:postgres`, or `:postgres_era`)
|
|
104
|
-
# @return [true, String] true if both replays produce identical histories
|
|
105
|
-
# (after stripping declared nondeterministic fields); otherwise a message
|
|
106
|
-
# naming the step count that diverged
|
|
107
|
-
def replay_is_deterministic(domain_path, steps, adapter: :memory)
|
|
108
|
-
first = Replay.call(domain_path, steps, adapter: adapter)
|
|
109
|
-
second = Replay.call(domain_path, steps, adapter: adapter)
|
|
110
|
-
|
|
111
|
-
# What leaves this comparison, and why, is declared once in
|
|
112
|
-
# `Nondeterministic::FIELDS`: the `outbox_row` group (per-enqueue
|
|
113
|
-
# uuids), the `event` group on each row's full `Event#to_h`
|
|
114
|
-
# (`occurred_at`, which `history[:events]` never carried), and the
|
|
115
|
-
# `history` group (live IR objects). Stripped per `outbox_traces`
|
|
116
|
-
# row rather than dropping `outbox_traces` wholesale: everything
|
|
117
|
-
# else on a row (status, consumer, kind, the event's own name/
|
|
118
|
-
# aggregate/id/payload) is reproducible from the steps alone and
|
|
119
|
-
# stays checked.
|
|
120
|
-
strip_outbox_nondeterminism = lambda do |history|
|
|
121
|
-
traces = Array(history[:outbox_traces]).map do |trace|
|
|
122
|
-
trace.merge(rows: trace[:rows].map do |row|
|
|
123
|
-
Nondeterministic.strip(row, :outbox_row).merge(event: Nondeterministic.strip(row[:event], :event))
|
|
124
|
-
end)
|
|
125
|
-
end
|
|
126
|
-
history.merge(outbox_traces: traces)
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
comparable = ->(history) { Nondeterministic.strip(strip_outbox_nondeterminism.call(history), :history) }
|
|
130
|
-
return true if comparable.call(first) == comparable.call(second)
|
|
131
|
-
|
|
132
|
-
"two replays of the same #{steps.length} steps produced different histories"
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
end
|