hecks 1.0.2 → 1.1.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 +97 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
- data/lib/hecks/adapters/driven/postgres.rb +25 -14
- 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 +17 -1
- 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 +2 -1
- 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/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 +19 -472
- 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 +11 -3
- 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 +40 -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 +23 -13
- 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/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/projector/cli_projector.rb +49 -29
- data/lib/hecks/projector/docs_projector.rb +21 -12
- data/lib/hecks/projector/exporter.rb +10 -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/null_policy.rb +6 -2
- data/lib/hecks/query_specification/common/options.rb +17 -5
- 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 +29 -2
- 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 +26 -3
- 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 +207 -48
- 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 +14 -2
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module Properties
|
|
4
|
+
# Guard/authorization properties: every given/ensures refusal a run
|
|
5
|
+
# actually raised names a rule the refusing command actually declares,
|
|
6
|
+
# a scope-authorized answer (or its refusal) is correctly worded, and
|
|
7
|
+
# a lifecycle guard's own violation is refused rather than silently
|
|
8
|
+
# admitted.
|
|
9
|
+
|
|
10
|
+
# Holds authorize_scopes_or_refuses, guard_refusals_are_declared, and
|
|
11
|
+
# lifecycle_guard_and_given_violations_are_refused, plus the shared
|
|
12
|
+
# verb-to-declaration lookups (#command_for_verb,
|
|
13
|
+
# #effective_guard_descriptions) each resolves a refusal against.
|
|
14
|
+
module Guards
|
|
15
|
+
# `Query#options`' OTHER HALF — TenantScope.apply's own contract
|
|
16
|
+
# (tenant_scope.rb), independently restated as a property rather
|
|
17
|
+
# than exercised only through whatever the generator happens to
|
|
18
|
+
# try. NOT closed by the generator here on purpose: SafeDepositBox.
|
|
19
|
+
# Rented — the only real corpus query declaring `authorize` at
|
|
20
|
+
# all — declares ZERO attributes of its own, so StepBuilder#args_for
|
|
21
|
+
# always hands it `{}` and TenantScope.apply refuses every
|
|
22
|
+
# generated attempt, unconditionally (confirmed: no successful ask
|
|
23
|
+
# against an authorize-bearing query reaches this property via the
|
|
24
|
+
# standard battery today). Extending the generator to invent a
|
|
25
|
+
# `tenant:` value ran into a separate, real finding along the way —
|
|
26
|
+
# SafeDepositBox is COMPOSITE-identified (`identified_by` is nil
|
|
27
|
+
# for it — Runtime::Identified#derive_identity), so the generator's
|
|
28
|
+
# existing `known_ids` pool (keyed by `aggregate.identified_by ||
|
|
29
|
+
# "id"`) tracks a stray, never-real scalar for it rather than its
|
|
30
|
+
# true `branch_code`+`box_number` pair — a second, narrower
|
|
31
|
+
# generator gap this property does not attempt to fix, since fixing
|
|
32
|
+
# it well enough to trust a generated `tenant:` value would be the
|
|
33
|
+
# heavier, "benefits every future property" path the plan itself
|
|
34
|
+
# names as the alternative. Hand-built fixtures close the real
|
|
35
|
+
# claim directly instead: faster, narrower, and correct either way,
|
|
36
|
+
# since TenantScope.apply's contract is identical regardless of
|
|
37
|
+
# where a `tenant:` arg came from.
|
|
38
|
+
#
|
|
39
|
+
# Two claims, matching TenantScope.apply's own two branches: every
|
|
40
|
+
# SUCCESSFUL answer's own tenant field agrees with the tenant arg
|
|
41
|
+
# given (the WhereClause TenantScope injects is a Symbol reference
|
|
42
|
+
# into args, resolved dynamically — this checks the OUTCOME, not
|
|
43
|
+
# re-deriving that resolution) ; every ask MISSING a required
|
|
44
|
+
# tenant: refuses with the declared wording, never succeeds. A
|
|
45
|
+
# refusal for an unrelated reason with the tenant arg present is
|
|
46
|
+
# not this property's claim either way — skipped, not graded.
|
|
47
|
+
# The three mutually exclusive outcomes ("refused, was it for the
|
|
48
|
+
# declared reason?" / "succeeded without a tenant that was
|
|
49
|
+
# required?" / "succeeded with a tenant, does every row actually
|
|
50
|
+
# agree with it?") map exactly onto TenantScope.apply's own two
|
|
51
|
+
# branches, per the comment above — splitting them into separate
|
|
52
|
+
# methods would mean threading `asked`, `tenant`, `args`, and
|
|
53
|
+
# `declared` out to each one for no gain, since none of the three
|
|
54
|
+
# branches shares logic with the others beyond that shared setup.
|
|
55
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
56
|
+
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
57
|
+
def authorize_scopes_or_refuses(history)
|
|
58
|
+
bluebooks = history.fetch(:bluebooks)
|
|
59
|
+
|
|
60
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
61
|
+
next unless asked[:query].is_a?(String) && asked[:query].include?("::")
|
|
62
|
+
|
|
63
|
+
declared = query_for_verb(bluebooks, asked[:query])
|
|
64
|
+
authorization = declared&.authorization
|
|
65
|
+
tenant = authorization&.tenant&.to_sym
|
|
66
|
+
next unless tenant
|
|
67
|
+
|
|
68
|
+
args = asked[:args] || {}
|
|
69
|
+
tenant_given = args.key?(tenant)
|
|
70
|
+
|
|
71
|
+
if asked[:error]
|
|
72
|
+
next if tenant_given
|
|
73
|
+
next if asked[:error].to_s.include?("declares authorize with tenant: #{tenant}")
|
|
74
|
+
|
|
75
|
+
"#{asked[:query]} #{args.inspect} refused with no #{tenant}: given, but not with the declared " \
|
|
76
|
+
"tenant_required wording (#{asked[:error]})"
|
|
77
|
+
elsif !tenant_given
|
|
78
|
+
"#{asked[:query]} #{args.inspect} answered successfully with no #{tenant}: given, but #{declared.name} " \
|
|
79
|
+
"declares authorize with tenant: #{tenant}"
|
|
80
|
+
else
|
|
81
|
+
wanted = args[tenant].to_s
|
|
82
|
+
mismatched = asked[:rows].find do |row|
|
|
83
|
+
Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(row, tenant)).to_s != wanted
|
|
84
|
+
end
|
|
85
|
+
next unless mismatched
|
|
86
|
+
|
|
87
|
+
"#{asked[:query]} #{args.inspect} answered a row whose #{tenant} disagrees with the given " \
|
|
88
|
+
"#{wanted.inspect}: #{mismatched.inspect}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
offenders.empty? || offenders.join("; ")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# EVERY GIVEN/ENSURES REFUSAL A RUN ACTUALLY RAISED NAMES A RULE
|
|
96
|
+
# THE COMMAND ACTUALLY DECLARES. `GivenNotMet`/`EnsuresNotMet` both
|
|
97
|
+
# quote their guard's own `description` verbatim
|
|
98
|
+
# (command_rules/admissibility.rb: `"#{command.hecks_name} refused
|
|
99
|
+
# — #{given.description}"`) — the SAME text `behavior.bluebook`'s
|
|
100
|
+
# own `Rule`/Command.Ensure hold as `Rule#description`, so a
|
|
101
|
+
# refusal whose quoted text is not among the refusing command's
|
|
102
|
+
# OWN `guard_descriptions` (Behaviour::Command, both givens and
|
|
103
|
+
# ensures) is either a stale message surviving a renamed rule, a
|
|
104
|
+
# rule firing against the wrong command's own guard set, or the
|
|
105
|
+
# wording drifting out from under the declaration it is supposed
|
|
106
|
+
# to quote — banking's own 128 status givens (customer/account
|
|
107
|
+
# guards, some through a cross-aggregate dereference) are exactly
|
|
108
|
+
# the surface this exists to hold to its word.
|
|
109
|
+
#
|
|
110
|
+
# `kind:` is what tells a guard refusal apart from the FOUR other
|
|
111
|
+
# `RefusalWording` templates sharing the identical "X refused — Y"
|
|
112
|
+
# shape (LifecycleRefused/transition_blocked, both TypeMismatch
|
|
113
|
+
# object-reference templates, Unauthorized/role_mismatch) — see
|
|
114
|
+
# Replay's own comment at the refusal rescue site. Pattern-matching
|
|
115
|
+
# the string alone would confuse a guard's own wording with any of
|
|
116
|
+
# those; the raised class does not.
|
|
117
|
+
GUARD_REFUSAL_KINDS = %w[Hecks::Runtime::GivenNotMet Hecks::Runtime::EnsuresNotMet].freeze
|
|
118
|
+
|
|
119
|
+
def guard_refusals_are_declared(history)
|
|
120
|
+
bluebooks = history.fetch(:bluebooks)
|
|
121
|
+
|
|
122
|
+
offenders = history.fetch(:refusals).filter_map do |refusal|
|
|
123
|
+
next unless GUARD_REFUSAL_KINDS.include?(refusal[:kind])
|
|
124
|
+
|
|
125
|
+
match = refusal[:error].to_s.match(/\A(.+) refused — (.+)\z/)
|
|
126
|
+
next "#{refusal[:verb]} raised #{refusal[:kind]} with unparseable message #{refusal[:error].inspect}" unless match
|
|
127
|
+
|
|
128
|
+
command = command_for_verb(bluebooks, refusal[:verb])
|
|
129
|
+
next "#{refusal[:verb]} raised #{refusal[:kind]}, but no declared command resolves that verb" unless command
|
|
130
|
+
|
|
131
|
+
declared = effective_guard_descriptions(bluebooks, refusal[:verb], command)
|
|
132
|
+
next if declared.include?(match[2])
|
|
133
|
+
|
|
134
|
+
"#{refusal[:verb]} refused — #{match[2].inspect} — but #{command.hecks_name} declares no given " \
|
|
135
|
+
"or ensures with that description (it declares #{declared.inspect})"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
offenders.empty? || offenders.join("; ")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# A DECLARED PROCESS MANAGER'S OWN COMMAND — `command.hecks_name`,
|
|
142
|
+
# or an entity's own if the verb's second component is itself
|
|
143
|
+
# dotted (`Aggregate.Entity.Command`, the same two shapes
|
|
144
|
+
# `Dispatcher#dispatch` itself branches on). Shared by the guard
|
|
145
|
+
# property above and available for anything else that needs to go
|
|
146
|
+
# from a replayed verb back to its declaration.
|
|
147
|
+
#
|
|
148
|
+
# RESOLVED AGAINST `bluebooks` (the FULL map, `history[:bluebooks]`
|
|
149
|
+
# — every loaded domain, keyed by name), never a single assumed
|
|
150
|
+
# bluebook: a verb names its OWN domain (`Naming.split_verb`'s
|
|
151
|
+
# first element), and that domain is not always the one Replay
|
|
152
|
+
# happens to expose as `history[:bluebook]`. A fuzz run against
|
|
153
|
+
# `lib/hecks/grammar` (Expression + Translation, in load
|
|
154
|
+
# order) found this the hard way — every `Translation::Map.Seal`
|
|
155
|
+
# refusal read as "no declared command resolves that verb" purely
|
|
156
|
+
# because `history[:bluebook]` was Expression, not Translation; the
|
|
157
|
+
# refusal was real, this property's own domain resolution was not.
|
|
158
|
+
# A DELEGATING DOOR REFUSES WITH ITS TARGET'S OWN WORDS. `delegates_to`
|
|
159
|
+
# (CommandBuilder#delegates_to_impl) hands the whole dispatch to one
|
|
160
|
+
# entity command, and that command's given is what refuses — raised
|
|
161
|
+
# back through the door, in the door's name (chess: `Game.MoveKnight
|
|
162
|
+
# refused — "it is that color's turn"`, a given Knight.Move declares
|
|
163
|
+
# and MoveKnight, a pure passthrough, never could). Read the door's
|
|
164
|
+
# own guards first, then every delegation target's; an offence is
|
|
165
|
+
# only a description NEITHER declares. Found live mining chess's
|
|
166
|
+
# history: every refused move through a door read as undeclared.
|
|
167
|
+
def effective_guard_descriptions(bluebooks, verb, command)
|
|
168
|
+
own = command.guard_descriptions
|
|
169
|
+
delegated = command.mutations.select { |m| m.op == :delegate }.flat_map do |delegation|
|
|
170
|
+
domain, aggregate_name, = Naming.split_verb(verb)
|
|
171
|
+
target = command_for_verb(bluebooks, "#{domain}::#{aggregate_name}.#{delegation.target}")
|
|
172
|
+
target ? target.guard_descriptions : []
|
|
173
|
+
end
|
|
174
|
+
own + delegated
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def command_for_verb(bluebooks, verb)
|
|
178
|
+
domain, aggregate_name, command_path = Naming.split_verb(verb)
|
|
179
|
+
return nil unless command_path
|
|
180
|
+
|
|
181
|
+
bluebook = bluebooks[domain]
|
|
182
|
+
return nil unless bluebook
|
|
183
|
+
|
|
184
|
+
aggregate = bluebook.aggregate(aggregate_name)
|
|
185
|
+
return nil unless aggregate
|
|
186
|
+
|
|
187
|
+
if command_path.include?(".")
|
|
188
|
+
entity_name, sub = command_path.split(".", 2)
|
|
189
|
+
entity = aggregate.entities.find { |e| e.hecks_name == entity_name }
|
|
190
|
+
entity&.command(sub)
|
|
191
|
+
else
|
|
192
|
+
aggregate.command(command_path)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# `guard_refusals_are_declared`'s OWN OPPOSITE DIRECTION. That
|
|
197
|
+
# property is passive and one-directional — for a refusal that
|
|
198
|
+
# ALREADY HAPPENED, is the quoted text real declared text? It says
|
|
199
|
+
# nothing about a guard that should have refused and silently did
|
|
200
|
+
# not — a call site that stopped calling enforce_givens/enforce_
|
|
201
|
+
# lifecycle_guard would never appear in history[:refusals] at all,
|
|
202
|
+
# invisible to that property by construction.
|
|
203
|
+
#
|
|
204
|
+
# This one calls Admissibility#enforce_givens (which itself folds
|
|
205
|
+
# in #enforce_lifecycle_guard whenever `declaring:` is passed)
|
|
206
|
+
# DIRECTLY, against Replay's own pre-dispatch snapshot
|
|
207
|
+
# (history[:guard_checks], one bounded, additive extension — see
|
|
208
|
+
# that file's own comment at the capture site) — an independent
|
|
209
|
+
# recomputation, not grading production against itself, the same
|
|
210
|
+
# "two engines, compared" shape query_answers_match_reference and
|
|
211
|
+
# the fan-out oracle already establish. `recomputed_refused`
|
|
212
|
+
# (Replay's own call, made live, before this step's real dispatch
|
|
213
|
+
# could mutate anything a cross-aggregate given dereferences) is
|
|
214
|
+
# compared against `actual_refused` (GivenNotMet/LifecycleRefused
|
|
215
|
+
# specifically — Replay's own comment on GUARD_REFUSAL_CLASSES
|
|
216
|
+
# explains why ANY other refusal class, or an outright success,
|
|
217
|
+
# both count as "the guard did not fire," since enforce_givens
|
|
218
|
+
# runs FIRST in DISPATCH_ORDER).
|
|
219
|
+
#
|
|
220
|
+
# Aggregate#preconditions closes for free alongside this — a
|
|
221
|
+
# no-block `given` reference (CommandBuilder#given) pushes the
|
|
222
|
+
# SAME Given struct object `enforce_givens` already iterates
|
|
223
|
+
# command.givens for, so there is no separate runtime path a
|
|
224
|
+
# property could exercise beyond what this already reaches.
|
|
225
|
+
# Entity#preconditions closes the identical way, one level down
|
|
226
|
+
# (ADR 0028) — a piece's own bare `given` reference pushes the
|
|
227
|
+
# SAME Given struct onto ITS OWN referencing command's givens,
|
|
228
|
+
# so LedgerEntry's own Amend/Reverse (banking) already exercise
|
|
229
|
+
# this through the exact mechanism above, no separate path.
|
|
230
|
+
#
|
|
231
|
+
# Real targets: Account.Debit/CloseAccount (`from:` guards),
|
|
232
|
+
# Credit/Debit (the named-once `given("customer is active")`
|
|
233
|
+
# precondition) — FreezeAccount deliberately references the
|
|
234
|
+
# DIFFERENT named precondition `"customer is not closed"` instead
|
|
235
|
+
# (a suspended customer must still be freezable), so it is not a
|
|
236
|
+
# `"customer is active"` example, just the same MECHANISM.
|
|
237
|
+
def lifecycle_guard_and_given_violations_are_refused(history)
|
|
238
|
+
offenders = history.fetch(:guard_checks).filter_map do |check|
|
|
239
|
+
next if check[:recomputed_refused] == check[:actual_refused]
|
|
240
|
+
|
|
241
|
+
"#{check[:verb]} — independently recomputing enforce_givens/enforce_lifecycle_guard against the " \
|
|
242
|
+
"pre-dispatch state says #{check[:recomputed_refused] ? "refused (#{check[:recomputed_kind]})" : 'admitted'}, " \
|
|
243
|
+
"but the real dispatch #{check[:actual_refused] ? "refused (#{check[:actual_kind]})" : 'admitted it'}"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
offenders.empty? || offenders.join("; ")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
def stored_records_satisfy_declared_invariants(history)
|
|
53
|
+
bluebooks = history.fetch(:bluebooks)
|
|
54
|
+
|
|
55
|
+
offenders = history.fetch(:instances).filter_map do |key, state|
|
|
56
|
+
domain_name = key.split("::").first
|
|
57
|
+
aggregate_name = key.split("::").last.split("#").first
|
|
58
|
+
bluebook = bluebooks[domain_name]
|
|
59
|
+
aggregate = bluebook&.aggregate(aggregate_name)
|
|
60
|
+
next unless aggregate
|
|
61
|
+
|
|
62
|
+
violated = aggregate.invariants.find do |invariant|
|
|
63
|
+
!Bluebook::Expression::Evaluator.call(invariant.canonical, state)
|
|
64
|
+
end
|
|
65
|
+
next "#{key} violates #{aggregate_name}'s own declared invariant #{violated.description.inspect}" if violated
|
|
66
|
+
|
|
67
|
+
check_piece_invariants(aggregate, state, key)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
offenders.empty? || offenders.join("; ")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A PIECE'S OWN INVARIANT, checked against every element a
|
|
74
|
+
# `list_of` field holds — the SAME lookup `Admissibility#
|
|
75
|
+
# check_entity_invariants` makes (`owner.attributes.find { |a|
|
|
76
|
+
# a.list? && a.type.to_s == entity.hecks_name }`), independently
|
|
77
|
+
# reapplied here against a STORED record's own plain Hash state
|
|
78
|
+
# rather than a live `Instance`.
|
|
79
|
+
def check_piece_invariants(owner_construct, owner_state, key)
|
|
80
|
+
owner_construct.entities.each do |entity|
|
|
81
|
+
next if entity.invariants.empty?
|
|
82
|
+
|
|
83
|
+
list_attr = owner_construct.attributes.find { |a| a.list? && a.type.to_s == entity.hecks_name }
|
|
84
|
+
next unless list_attr
|
|
85
|
+
|
|
86
|
+
Array(owner_state[list_attr.name]).each do |element|
|
|
87
|
+
violated = entity.invariants.find do |invariant|
|
|
88
|
+
!Bluebook::Expression::Evaluator.call(invariant.canonical, element)
|
|
89
|
+
end
|
|
90
|
+
if violated
|
|
91
|
+
return "#{key}'s own #{entity.hecks_name} violates its declared invariant " \
|
|
92
|
+
"#{violated.description.inspect}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
nested = check_piece_invariants(entity, element, key)
|
|
96
|
+
return nested if nested
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# A SAGA INSTANCE'S OWN CHECKPOINT SURVIVES BEING WRITTEN AND READ
|
|
103
|
+
# BACK — the durability contract `SagaInterpreter#checkpoint` makes
|
|
104
|
+
# (`state:` plus a `deep_copy`d `memory:`, handed to whatever
|
|
105
|
+
# adapter answers `save_saga`) and `Registry#rehydrate_sagas!`
|
|
106
|
+
# promises to restore on the next boot (`each_saga` yielding
|
|
107
|
+
# `[pm, correlation, state, memory]` back into `saga_instances`).
|
|
108
|
+
# `Replay` captures the LIVE store already materialised the same
|
|
109
|
+
# way `checkpoint` itself does (`Value.materialize`, not raw
|
|
110
|
+
# `Runtime::Value`s — see its own comment); this property pushes
|
|
111
|
+
# that captured memory through the SAME `JSON.generate` then
|
|
112
|
+
# `JSON.parse(symbolize_names: true)` round-trip `checkpoint`'s own
|
|
113
|
+
# `deep_copy` performs (mirrored here rather than called — a
|
|
114
|
+
# private instance method with no registry to hand it) and checks
|
|
115
|
+
# it comes back byte-identical. A memory holding anything that
|
|
116
|
+
# round-trip cannot carry faithfully — a bare Symbol leaf, a
|
|
117
|
+
# non-JSON type a future field introduces — is corruption the
|
|
118
|
+
# durable path would introduce on a REAL restart, caught here
|
|
119
|
+
# without needing one.
|
|
120
|
+
#
|
|
121
|
+
# `declares_state?` (Behaviour::ProcessManager) is the other half:
|
|
122
|
+
# a live or rehydrated instance sitting in a state the procedure
|
|
123
|
+
# never declares is the saga-durability twin of
|
|
124
|
+
# `lifecycle_values_are_declared` above.
|
|
125
|
+
def sagas_rehydrate_cleanly(history)
|
|
126
|
+
bluebook = history.fetch(:bluebook)
|
|
127
|
+
process_managers = bluebook.process_managers.to_h { |pm| [pm.name, pm] }
|
|
128
|
+
|
|
129
|
+
offenders = history.fetch(:saga_instances).flat_map do |pm_name, conversations|
|
|
130
|
+
pm = process_managers[pm_name]
|
|
131
|
+
|
|
132
|
+
conversations.filter_map do |correlation, instance|
|
|
133
|
+
problems = []
|
|
134
|
+
|
|
135
|
+
problems << "holds state #{instance[:state].inspect}, which #{pm_name} never declares" \
|
|
136
|
+
if pm && !pm.declares_state?(instance[:state])
|
|
137
|
+
|
|
138
|
+
rehydrated = JSON.parse(JSON.generate(instance[:memory]), symbolize_names: true)
|
|
139
|
+
if rehydrated != instance[:memory]
|
|
140
|
+
problems << "memory does not survive its own checkpoint round-trip " \
|
|
141
|
+
"(checkpointed #{instance[:memory].inspect}, rehydrated #{rehydrated.inspect})"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
next if problems.empty?
|
|
145
|
+
|
|
146
|
+
"#{pm_name}##{correlation.inspect}: #{problems.join(' and ')}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
offenders.empty? || offenders.join("; ")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A `for_each` POLICY DISPATCHES EXACTLY ONCE PER ROW ITS DECLARED
|
|
154
|
+
# QUERY ANSWERS — never once for the triggering event regardless of
|
|
155
|
+
# row count, never skipping a matched row, never firing on a row a
|
|
156
|
+
# concurrent mutation only made match AFTER the fact. `Replay`
|
|
157
|
+
# computes the expected row-id set INDEPENDENTLY, at the same
|
|
158
|
+
# instant the real dispatch runs (`Replay.expected_fan_out_rows`,
|
|
159
|
+
# the query oracle's own shape aimed at fan-out: two engines
|
|
160
|
+
# compared, never one graded against itself), and records it
|
|
161
|
+
# beside what the reaction log actually shows. `expected_row_ids`
|
|
162
|
+
# is `nil`, not `[]`, when `policy.where` did not hold — no
|
|
163
|
+
# dispatch is the claim then, not "dispatched to zero rows," and a
|
|
164
|
+
# policy that dispatched anyway despite a failing guard is as real
|
|
165
|
+
# a finding as a row it skipped.
|
|
166
|
+
def fanout_dispatches_once_per_matching_row(history)
|
|
167
|
+
offenders = history.fetch(:fan_outs).filter_map do |finding|
|
|
168
|
+
expected = finding[:expected_row_ids]
|
|
169
|
+
actual = finding[:actual_row_ids].sort
|
|
170
|
+
|
|
171
|
+
if expected.nil?
|
|
172
|
+
next if actual.empty?
|
|
173
|
+
|
|
174
|
+
next "#{finding[:policy]} on #{finding[:on]}: where did not hold, but dispatched to #{actual.inspect}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
next if actual == expected
|
|
178
|
+
|
|
179
|
+
"#{finding[:policy]} on #{finding[:on]}: for_each answered #{expected.inspect}, " \
|
|
180
|
+
"but the reaction log shows dispatches to #{actual.inspect}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
offenders.empty? || offenders.join("; ")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# A `count`/`median` REPORT'S REDUCED SCALAR MATCHES THE SAME
|
|
187
|
+
# REDUCTION DONE INDEPENDENTLY, over the SAME eligible rows —
|
|
188
|
+
# `ReadModelInterpreter#project`'s own FK-join (root first, then
|
|
189
|
+
# each many-side head matched against it) and `#median` (odd →
|
|
190
|
+
# the true middle, even → the average of the two middles as a
|
|
191
|
+
# Float, empty → `nil`; `count` is the filtered length, empty →
|
|
192
|
+
# `0`), reproduced here in plain Ruby against `history[:instances]`
|
|
193
|
+
# rather than a live registry — `FieldPath.dig` +
|
|
194
|
+
# `Ports::Query::InMemory.comparable`/`.holds?` are the SAME two
|
|
195
|
+
# calls the interpreter itself makes to read a field and judge a
|
|
196
|
+
# `where`, called here rather than re-derived, so this oracle
|
|
197
|
+
# cannot drift from what "read a field" or "a clause holds" mean
|
|
198
|
+
# without the interpreter drifting the identical way.
|
|
199
|
+
#
|
|
200
|
+
# Only a report whose `:query` is answered by the SAME bluebook
|
|
201
|
+
# `history[:bluebook]` carries (the bare `Domain.report_name`
|
|
202
|
+
# form, `domain == bluebook.name`) is checked — the same "only
|
|
203
|
+
# what we have the grammar for" scope `lifecycle_values_are_declared`
|
|
204
|
+
# already takes for a multi-domain replay.
|
|
205
|
+
# See the comment above: a closed chain of eligibility guards
|
|
206
|
+
# ("only a report answered by this bluebook, only a count/median
|
|
207
|
+
# report, only one with a reduced many-side head") each gating the
|
|
208
|
+
# next, ending in one independent recomputation compared against
|
|
209
|
+
# the live answer. The guards are what make this oracle SCOPED
|
|
210
|
+
# correctly, not incidental complexity — narrower than "every
|
|
211
|
+
# branch reads as its own precondition."
|
|
212
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
213
|
+
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
214
|
+
def aggregation_matches_recompute(history)
|
|
215
|
+
bluebook = history.fetch(:bluebook)
|
|
216
|
+
|
|
217
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
218
|
+
next if asked[:error]
|
|
219
|
+
|
|
220
|
+
domain, name = asked[:query].to_s.split(".", 2)
|
|
221
|
+
next unless name && domain == bluebook.name
|
|
222
|
+
|
|
223
|
+
model = bluebook.read_model(name)
|
|
224
|
+
next unless model && (model.count? || model.median_field)
|
|
225
|
+
|
|
226
|
+
reduced_head = model.aggregate_heads.find { |head| head[:many] }
|
|
227
|
+
next unless reduced_head
|
|
228
|
+
|
|
229
|
+
rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, reduced_head, asked[:args] || {})
|
|
230
|
+
expected = model.count? ? rows.length : recompute_median(rows, model.median_field)
|
|
231
|
+
actual = asked[:rows]&.first&.dig(reduced_head[:as])
|
|
232
|
+
next if actual == expected
|
|
233
|
+
|
|
234
|
+
"#{asked[:query]} #{asked[:args].inspect} answered #{actual.inspect} for #{reduced_head[:as]}, " \
|
|
235
|
+
"but recomputing independently from #{rows.length} eligible row(s) gives #{expected.inspect}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
offenders.empty? || offenders.join("; ")
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# `aggregation_matches_recompute`'s own shape, extended from
|
|
242
|
+
# reducing a many-side head to a scalar (count/median) to NESTING
|
|
243
|
+
# it — `ReadModelInterpreter#group_by_target`/`#nest`, reproduced
|
|
244
|
+
# here in plain Ruby against `history[:instances]` the same way
|
|
245
|
+
# `eligible_rows` already reproduces the FK-join and `where`
|
|
246
|
+
# narrowing count/median share. `Value.materialize_unwrapped` is
|
|
247
|
+
# the SAME call `#project` makes before nesting (a single-field
|
|
248
|
+
# value object recurses to its bare scalar — a real grouping key
|
|
249
|
+
# has to BE one) — called here rather than re-derived, so this
|
|
250
|
+
# oracle cannot drift from what "the group key" means without the
|
|
251
|
+
# interpreter drifting the identical way.
|
|
252
|
+
#
|
|
253
|
+
# Real target: AccountsByKind (`group_by :kind, :number`,
|
|
254
|
+
# rootless — always generator-eligible with `{}` args).
|
|
255
|
+
# Same closed eligibility-guard chain as aggregation_matches_
|
|
256
|
+
# recompute just above (see its own comment) — group_by in place
|
|
257
|
+
# of count/median, nest_rows in place of recompute_median.
|
|
258
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity
|
|
259
|
+
# rubocop:disable-next Metrics/PerceivedComplexity
|
|
260
|
+
def group_by_matches_recompute(history)
|
|
261
|
+
bluebook = history.fetch(:bluebook)
|
|
262
|
+
|
|
263
|
+
offenders = history.fetch(:queries).filter_map do |asked|
|
|
264
|
+
next if asked[:error]
|
|
265
|
+
|
|
266
|
+
domain, name = asked[:query].to_s.split(".", 2)
|
|
267
|
+
next unless name && domain == bluebook.name
|
|
268
|
+
|
|
269
|
+
model = bluebook.read_model(name)
|
|
270
|
+
next unless model&.group_by&.any?
|
|
271
|
+
|
|
272
|
+
grouped_head = model.aggregate_heads.find { |head| head[:many] }
|
|
273
|
+
next unless grouped_head
|
|
274
|
+
|
|
275
|
+
rows = eligible_rows(bluebook, asked.fetch(:instances_at), domain, model, grouped_head, asked[:args] || {})
|
|
276
|
+
materialized = rows.map { |state| Runtime::Value.materialize_unwrapped(state) }
|
|
277
|
+
expected = nest_rows(materialized, model.group_by_fields)
|
|
278
|
+
actual = asked[:rows]&.first&.dig(grouped_head[:as])
|
|
279
|
+
next if actual == expected
|
|
280
|
+
|
|
281
|
+
"#{asked[:query]} #{asked[:args].inspect} answered a #{grouped_head[:as]} grouping that disagrees " \
|
|
282
|
+
"with independently nesting group_by #{model.group_by_fields.inspect} over #{rows.length} " \
|
|
283
|
+
"eligible row(s)"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
offenders.empty? || offenders.join("; ")
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# `ReadModelInterpreter#nest`, byte for byte: one level of nesting
|
|
290
|
+
# per `group_by` field in declared order, leaf is the row with
|
|
291
|
+
# every grouped field stripped (already spent, as the keys that
|
|
292
|
+
# reached it).
|
|
293
|
+
def nest_rows(rows, fields)
|
|
294
|
+
field, *rest = fields
|
|
295
|
+
rows.group_by { |row| row[field] }.transform_values do |group|
|
|
296
|
+
stripped = group.map { |row| row.reject { |key, _| key == field } }
|
|
297
|
+
rest.empty? ? stripped.first : nest_rows(stripped, rest)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# THE ELIGIBLE ROWS a `count`/`median` head reduces — every
|
|
302
|
+
# instance of the reduced head's own aggregate, FK-matched against
|
|
303
|
+
# the report's root reference (if it has one; a rootless report has
|
|
304
|
+
# none to match) exactly the way `ReadModelInterpreter#reference_fields`
|
|
305
|
+
# finds the matching attribute, then narrowed by the report's own
|
|
306
|
+
# `where` clauses via the SAME `InMemory.holds?` the interpreter's
|
|
307
|
+
# `execute` calls.
|
|
308
|
+
def eligible_rows(bluebook, instances, domain, model, reduced_head, args)
|
|
309
|
+
aggregate = bluebook.aggregate(reduced_head[:aggregate])
|
|
310
|
+
prefix = "#{domain}::#{reduced_head[:aggregate]}#"
|
|
311
|
+
# `id:` MERGED IN, the same `record.to_h` (`@state.merge(id:
|
|
312
|
+
# @id)`) every live head row carries — count/median never read
|
|
313
|
+
# it, but group_by_matches_recompute's own independent nesting
|
|
314
|
+
# does, the same way ReadModelInterpreter#row(record) = record.
|
|
315
|
+
# to_h does for the live path it's checking against.
|
|
316
|
+
rows = instances.filter_map { |key, state| state.merge(id: key.split("#").last) if key.start_with?(prefix) }
|
|
317
|
+
|
|
318
|
+
if model.reference_target
|
|
319
|
+
reference_id = args[model.reference_name].to_s
|
|
320
|
+
fk_fields = aggregate.attributes.select do |attribute|
|
|
321
|
+
attribute.reference? && attribute.type.target_name == model.reference_target.to_s
|
|
322
|
+
end.map(&:name)
|
|
323
|
+
|
|
324
|
+
rows = rows.select { |state| fk_fields.any? { |field| state[field].to_s == reference_id } }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
rows.select do |state|
|
|
328
|
+
model.wheres.all? do |clause|
|
|
329
|
+
held = Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, clause.field))
|
|
330
|
+
Ports::Query::InMemory.holds?(clause, held, args)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# `ReadModelInterpreter#median`'s own definition, reproduced byte
|
|
336
|
+
# for byte: odd count → the true middle value, sorted; even count
|
|
337
|
+
# → the average of the two middle values, as a Float; empty → nil,
|
|
338
|
+
# never zero, so a caller cannot mistake "nothing to average" for
|
|
339
|
+
# "averaged to zero."
|
|
340
|
+
def recompute_median(rows, field)
|
|
341
|
+
values = rows.map { |state| Ports::Query::InMemory.comparable(QuerySpecification::FieldPath.dig(state, field)) }
|
|
342
|
+
.compact.sort
|
|
343
|
+
return nil if values.empty?
|
|
344
|
+
|
|
345
|
+
middle = values.length / 2
|
|
346
|
+
values.length.odd? ? values[middle] : (values[middle - 1] + values[middle]) / 2.0
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|