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.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/claude_code.rb +3 -3
- data/lib/hecks/adapters/driven/d1.rb +48 -28
- data/lib/hecks/adapters/driven/folder.rb +10 -3
- data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
- data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
- data/lib/hecks/adapters/driven/heki.rb +3 -5
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
- data/lib/hecks/adapters/driven/lambda.rb +2 -2
- data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
- data/lib/hecks/adapters/driven/local_storage.rb +120 -0
- data/lib/hecks/adapters/driven/memory.rb +48 -3
- data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
- data/lib/hecks/adapters/driven/postgres/outbox.rb +100 -0
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +57 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +22 -2
- data/lib/hecks/adapters/driven/postgres.rb +45 -31
- data/lib/hecks/adapters/driven/prism.rb +11 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
- data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +22 -3
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
- data/lib/hecks/adapters/driven/sqlite.rb +87 -8
- data/lib/hecks/adapters/driven.rb +4 -0
- data/lib/hecks/adapters.rb +4 -0
- data/lib/hecks/behaviors/dsl.rb +7 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/behaviors/rspec.rb +2 -0
- data/lib/hecks/behaviors/runner.rb +4 -0
- data/lib/hecks/bluebook/aggregate.rb +6 -5
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
- data/lib/hecks/bluebook/assembly/build.rb +6 -3
- data/lib/hecks/bluebook/assembly/contract.rb +5 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
- data/lib/hecks/bluebook/assembly/marks.rb +15 -4
- data/lib/hecks/bluebook/assembly.rb +1 -3
- data/lib/hecks/bluebook/attribute.rb +6 -0
- data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
- data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
- data/lib/hecks/bluebook/behaviour/read_model.rb +53 -8
- data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
- data/lib/hecks/bluebook/command.rb +6 -5
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +29 -477
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
- data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
- data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
- data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
- data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
- data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
- data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +142 -19
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
- data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
- data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
- data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
- data/lib/hecks/bluebook/entity.rb +5 -3
- data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
- data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
- data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
- data/lib/hecks/bluebook/expression/projection.json +12 -0
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
- data/lib/hecks/bluebook/expression/resolver.rb +83 -11
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/hexagon.rb +10 -0
- data/lib/hecks/bluebook/lifecycle.rb +9 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
- data/lib/hecks/bluebook/meta_validator.rb +5 -5
- data/lib/hecks/bluebook/model_check.rb +119 -82
- data/lib/hecks/bluebook/pattern_subset.rb +9 -0
- data/lib/hecks/bluebook/policy.rb +2 -1
- data/lib/hecks/bluebook/process_manager.rb +6 -0
- data/lib/hecks/bluebook/project_register.rb +45 -25
- data/lib/hecks/bluebook/query.rb +4 -0
- data/lib/hecks/bluebook/read_model.rb +2 -2
- data/lib/hecks/bluebook/smoke_test.rb +5 -0
- data/lib/hecks/bluebook/synthesizer.rb +2 -1
- data/lib/hecks/bluebook/translation.rb +3 -0
- data/lib/hecks/bluebook/value_object.rb +2 -2
- data/lib/hecks/codemod.rb +13 -0
- data/lib/hecks/doc/reference.rb +17 -11
- data/lib/hecks/facade/cli_runner.rb +2 -2
- data/lib/hecks/facade/command_request.rb +11 -11
- data/lib/hecks/facade/handle.rb +3 -3
- data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
- data/lib/hecks/facade.rb +4 -0
- data/lib/hecks/forms/app.rb +10 -6
- data/lib/hecks/forms/command_form_renderer.rb +3 -3
- data/lib/hecks/forms/field_renderer.rb +14 -9
- data/lib/hecks/forms/field_shape.rb +7 -2
- data/lib/hecks/forms/html.rb +5 -2
- data/lib/hecks/forms/index_renderer.rb +1 -1
- data/lib/hecks/forms/page.rb +7 -3
- data/lib/hecks/forms/query_form_renderer.rb +3 -2
- data/lib/hecks/forms/record_renderer.rb +22 -16
- data/lib/hecks/forms.rb +4 -0
- data/lib/hecks/fqn.rb +21 -9
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
- data/lib/hecks/fuzzing/properties/guards.rb +251 -0
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
- data/lib/hecks/fuzzing/properties/querying.rb +206 -0
- data/lib/hecks/fuzzing/properties.rb +41 -1065
- data/lib/hecks/fuzzing/replay.rb +60 -16
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
- data/lib/hecks/fuzzing/value_generator.rb +2 -2
- data/lib/hecks/grammar/evolve.rb +34 -10
- data/lib/hecks/grammar/expression_operators.json +124 -0
- data/lib/hecks/grammar.rb +15 -5
- data/lib/hecks/ir.rb +9 -0
- data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
- data/lib/hecks/language/bluebook/command.bluebook +2 -1
- data/lib/hecks/language/bluebook/entity.bluebook +2 -1
- data/lib/hecks/language/bluebook/projection.bluebook +2 -1
- data/lib/hecks/language/bluebook/query.bluebook +4 -2
- data/lib/hecks/language/bluebook/shape.bluebook +2 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
- data/lib/hecks/literal.rb +18 -8
- data/lib/hecks/naming.rb +6 -3
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.rb +5 -3
- data/lib/hecks/ports/authentication.rb +3 -2
- data/lib/hecks/ports/authorization.rb +1 -1
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.rb +5 -1
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/loading.rb +4 -1
- data/lib/hecks/ports/persistence/append_only.rb +51 -4
- data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
- data/lib/hecks/ports/persistence/plugin.rb +3 -0
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +77 -18
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
- data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
- data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
- data/lib/hecks/ports/persistence.rb +7 -3
- data/lib/hecks/ports/projection.rb +13 -5
- data/lib/hecks/ports/query/in_memory.rb +8 -2
- data/lib/hecks/ports/query.rb +1 -3
- data/lib/hecks/ports.rb +2 -0
- data/lib/hecks/projections/diagrams.rb +11 -3
- data/lib/hecks/projections/glossary.rb +374 -0
- data/lib/hecks/projections/ir.rb +2 -0
- data/lib/hecks/projections/model/deviations.rb +2 -1
- data/lib/hecks/projections/model.rb +15 -10
- data/lib/hecks/projections/vocabulary.rb +1 -3
- data/lib/hecks/projections.rb +1 -0
- data/lib/hecks/projector/cli_projector.rb +78 -42
- data/lib/hecks/projector/docs_projector.rb +21 -12
- data/lib/hecks/projector/exporter.rb +34 -1
- data/lib/hecks/projector/narrate_projector.rb +83 -25
- data/lib/hecks/projector.rb +2 -2
- data/lib/hecks/query_ir.rb +60 -36
- data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
- data/lib/hecks/query_specification/common/comparators.rb +4 -0
- data/lib/hecks/query_specification/common/comparison.rb +8 -1
- data/lib/hecks/query_specification/common/dsl.rb +5 -0
- data/lib/hecks/query_specification/common/limit_spec.rb +9 -2
- data/lib/hecks/query_specification/common/null_policy.rb +6 -2
- data/lib/hecks/query_specification/common/offset_spec.rb +9 -2
- data/lib/hecks/query_specification/common/options.rb +17 -5
- data/lib/hecks/query_specification/common/order_by.rb +9 -2
- data/lib/hecks/query_specification/common/where_clause.rb +15 -2
- data/lib/hecks/query_specification/field_path.rb +4 -4
- data/lib/hecks/query_specification/read_model/specification.rb +5 -2
- data/lib/hecks/router/namespace_installer.rb +22 -10
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
- data/lib/hecks/runtime/command_interpreter.rb +104 -65
- data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
- data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
- data/lib/hecks/runtime/dependency_planning.rb +33 -21
- data/lib/hecks/runtime/dispatcher.rb +41 -7
- data/lib/hecks/runtime/entity_element.rb +25 -6
- data/lib/hecks/runtime/entity_interpreter.rb +28 -15
- data/lib/hecks/runtime/errors.rb +28 -2
- data/lib/hecks/runtime/instance.rb +9 -3
- data/lib/hecks/runtime/interpreting.rb +45 -9
- data/lib/hecks/runtime/loader.rb +21 -0
- data/lib/hecks/runtime/outbox.rb +365 -0
- data/lib/hecks/runtime/policy_interpreter.rb +32 -17
- data/lib/hecks/runtime/query_interpreter.rb +36 -16
- data/lib/hecks/runtime/reaction_invocation.rb +35 -22
- data/lib/hecks/runtime/read_model_interpreter.rb +31 -7
- data/lib/hecks/runtime/reference_hop.rb +1 -1
- data/lib/hecks/runtime/refusal_wording.rb +55 -49
- data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
- data/lib/hecks/runtime/registry/verification.rb +39 -2
- data/lib/hecks/runtime/registry.rb +21 -11
- data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
- data/lib/hecks/runtime/routing.rb +52 -39
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
- data/lib/hecks/runtime/saga_interpreter.rb +119 -90
- data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
- data/lib/hecks/runtime/tenant_scope.rb +7 -0
- data/lib/hecks/runtime/value/coercion.rb +271 -49
- data/lib/hecks/runtime/value.rb +1 -1
- data/lib/hecks/runtime.rb +7 -2
- data/lib/hecks/storehouse.rb +15 -8
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +1 -0
- data/lib/hecks.rb +17 -13
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
- 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
|