hecks 0.3.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/d1.rb +38 -14
- data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
- data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
- data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
- data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
- data/lib/hecks/adapters/driven/heki.rb +40 -9
- data/lib/hecks/adapters/driven/lambda.rb +16 -2
- data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
- data/lib/hecks/adapters/driven/postgres.rb +76 -29
- data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
- data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
- data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
- data/lib/hecks/adapters/driven/sqlite.rb +30 -9
- data/lib/hecks/adapters/driven.rb +19 -1
- data/lib/hecks/behaviors/dsl.rb +29 -0
- data/lib/hecks/behaviors/expectations.rb +62 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
- data/lib/hecks/bluebook/assembly/marks.rb +4 -3
- data/lib/hecks/bluebook/assembly.rb +14 -1
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
- data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
- data/lib/hecks/bluebook/chapter.rb +21 -11
- data/lib/hecks/bluebook/command.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
- data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
- data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
- data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
- data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
- data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
- data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
- data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
- data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
- data/lib/hecks/bluebook/expression/projection.json +48 -0
- data/lib/hecks/bluebook/expression/resolver.rb +161 -10
- data/lib/hecks/bluebook/hexagon.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
- data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
- data/lib/hecks/bluebook/meta_validator.rb +103 -13
- data/lib/hecks/bluebook/model_check.rb +132 -5
- data/lib/hecks/bluebook/pattern_subset.rb +66 -2
- data/lib/hecks/bluebook/process_manager.rb +53 -11
- data/lib/hecks/bluebook/value_object.rb +9 -1
- data/lib/hecks/doc/reference.rb +22 -1
- data/lib/hecks/facade/cli_door.rb +6 -3
- data/lib/hecks/facade/json_door.rb +16 -4
- data/lib/hecks/forms/app.rb +47 -6
- data/lib/hecks/forms/command_form_renderer.rb +1 -1
- data/lib/hecks/forms/field_renderer.rb +11 -4
- data/lib/hecks/forms/html.rb +31 -0
- data/lib/hecks/forms/params.rb +30 -1
- data/lib/hecks/forms/port_argument.rb +46 -0
- data/lib/hecks/forms/record_renderer.rb +6 -2
- data/lib/hecks/forms/record_table.rb +6 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
- data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
- data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
- data/lib/hecks/fuzzing/properties.rb +52 -6
- data/lib/hecks/fuzzing/replay.rb +51 -18
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
- data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
- data/lib/hecks/fuzzing/value_generator.rb +25 -2
- data/lib/hecks/grammar/evolve.rb +33 -0
- data/lib/hecks/grammar/expression.bluebook +8 -5
- data/lib/hecks/grammar/expression_operators.json +497 -1
- data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
- data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
- data/lib/hecks/language/bluebook/command.bluebook +42 -8
- data/lib/hecks/language/bluebook/entity.bluebook +86 -10
- data/lib/hecks/language/bluebook/policy.bluebook +21 -4
- data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
- data/lib/hecks/language/bluebook/projection.bluebook +6 -6
- data/lib/hecks/language/bluebook/query.bluebook +4 -4
- data/lib/hecks/language/bluebook/shape.bluebook +6 -6
- data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
- data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
- data/lib/hecks/language/oidc.json +20 -0
- data/lib/hecks/language/port.bluebook +30 -2
- data/lib/hecks/naming.rb +54 -1
- data/lib/hecks/ports/access_control.port +7 -2
- data/lib/hecks/ports/access_control.rb +1 -1
- data/lib/hecks/ports/agent.port +6 -2
- data/lib/hecks/ports/agent.rb +1 -1
- data/lib/hecks/ports/authentication.port +4 -2
- data/lib/hecks/ports/authentication.rb +1 -1
- data/lib/hecks/ports/authorization.port +5 -2
- data/lib/hecks/ports/authorization.rb +14 -11
- data/lib/hecks/ports/clock.port +3 -2
- data/lib/hecks/ports/clock.rb +1 -1
- data/lib/hecks/ports/extraction.port +3 -2
- data/lib/hecks/ports/extraction.rb +1 -1
- data/lib/hecks/ports/identity_assignment.port +3 -2
- data/lib/hecks/ports/identity_assignment.rb +1 -1
- data/lib/hecks/ports/identity_generation.port +3 -2
- data/lib/hecks/ports/identity_generation.rb +1 -1
- data/lib/hecks/ports/identity_resolution.port +3 -2
- data/lib/hecks/ports/identity_resolution.rb +1 -1
- data/lib/hecks/ports/persistence/append_only.rb +40 -4
- data/lib/hecks/ports/persistence/execution.rb +6 -1
- data/lib/hecks/ports/persistence/plugin.rb +54 -0
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
- data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
- data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
- data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
- data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
- data/lib/hecks/ports/persistence.rb +1 -1
- data/lib/hecks/ports/projection.rb +20 -0
- data/lib/hecks/projections/diagrams.rb +230 -1
- data/lib/hecks/projections/shape.rb +15 -2
- data/lib/hecks/projector/cli_projector.rb +10 -1
- data/lib/hecks/projector/exporter.rb +19 -2
- data/lib/hecks/query_ir.rb +19 -0
- data/lib/hecks/query_specification/common/null_policy.rb +13 -1
- data/lib/hecks/query_specification/field_path.rb +20 -2
- data/lib/hecks/query_specification/hop_path.rb +7 -5
- data/lib/hecks/runtime/aggregate_lock.rb +45 -0
- data/lib/hecks/runtime/boot_gates.rb +41 -0
- data/lib/hecks/runtime/caller.rb +19 -3
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
- data/lib/hecks/runtime/command_interpreter.rb +97 -13
- data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
- data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
- data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
- data/lib/hecks/runtime/command_rules/references.rb +27 -19
- data/lib/hecks/runtime/dependency_planning.rb +14 -0
- data/lib/hecks/runtime/dispatcher.rb +19 -4
- data/lib/hecks/runtime/entity_interpreter.rb +85 -14
- data/lib/hecks/runtime/errors.rb +22 -0
- data/lib/hecks/runtime/identity.rb +30 -2
- data/lib/hecks/runtime/instance.rb +59 -4
- data/lib/hecks/runtime/interpreting.rb +21 -0
- data/lib/hecks/runtime/loader.rb +59 -18
- data/lib/hecks/runtime/query_interpreter.rb +36 -4
- data/lib/hecks/runtime/reaction_invocation.rb +9 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
- data/lib/hecks/runtime/refusal_wording.rb +2 -0
- data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
- data/lib/hecks/runtime/registry/verification.rb +88 -0
- data/lib/hecks/runtime/registry.rb +69 -8
- data/lib/hecks/runtime/saga_interpreter.rb +215 -13
- data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
- data/lib/hecks/runtime/value/admission.rb +19 -1
- data/lib/hecks/runtime/value/coercion.rb +75 -10
- data/lib/hecks/runtime.rb +17 -5
- data/lib/hecks/storehouse.rb +632 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +7 -2
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
- metadata +48 -37
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
- /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
- /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
- /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
- /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
require "tmpdir"
|
|
3
|
+
require "securerandom"
|
|
3
4
|
|
|
4
5
|
module Hecks
|
|
5
6
|
module Fuzzing
|
|
@@ -23,15 +24,58 @@ module Hecks
|
|
|
23
24
|
# domain's own rules and shape are untouched ; only WHICH adapter this
|
|
24
25
|
# one ephemeral copy answers through changes. What the domain is
|
|
25
26
|
# bound to for real deployment is never touched — only this tmp copy.
|
|
27
|
+
#
|
|
28
|
+
# `adapter:` (PRD 02) — Memory is the default and the only mode every
|
|
29
|
+
# existing caller still gets with no change. `:sqlite` rebinds to the
|
|
30
|
+
# REAL SQLite adapter instead of the in-memory one, for exactly the
|
|
31
|
+
# same reason PRD 02 exists: 15 declared properties (properties.rb)
|
|
32
|
+
# and every fuzz/replay run has only ever been checked against
|
|
33
|
+
# Memory's own hand-written repository, never against a real,
|
|
34
|
+
# persisted, SQL-backed one — and `spec/adapters/query_agreement_spec
|
|
35
|
+
# .rb` already found 4 shipped query bugs from exactly that
|
|
36
|
+
# comparison, on a fixed corpus far smaller than what the fuzzer
|
|
37
|
+
# generates. `:sqlite` rewrites to `"SqlitePersistence"` — the name
|
|
38
|
+
# `lib/hecks/adapters/driven/sqlite.adapter` actually registers under
|
|
39
|
+
# (`Sqlite` is the class; `SqlitePersistence` is a thin subclass
|
|
40
|
+
# that's the one real port binding names) — and needs no `.world`
|
|
41
|
+
# settings at all, the same reason Memory needs none:
|
|
42
|
+
# `Sqlite#resolve_path` already defaults an
|
|
43
|
+
# unbound `database` setting to `"data/<table>.db"` relative to
|
|
44
|
+
# `root:`, which `Hecks.boot(copy)` passes as this ephemeral copy's
|
|
45
|
+
# own directory — a fresh, empty `data/` per run, exactly like
|
|
46
|
+
# Memory's own zero-history guarantee, just backed by a real SQLite
|
|
47
|
+
# file instead of a Hash. Postgres/PostgresEra are deliberately NOT
|
|
48
|
+
# added here: a real Postgres run needs a live server and shared
|
|
49
|
+
# connection settings this in-process, no-adapter-config path has no
|
|
50
|
+
# place to source safely — that stays `io: true`-gated, direct-adapter
|
|
51
|
+
# coverage (`spec/adapters/driven/postgres_*_spec.rb`), not this.
|
|
26
52
|
module IsolatedBoot
|
|
27
53
|
module_function
|
|
28
54
|
|
|
29
|
-
|
|
55
|
+
# PRD 02 (docs/future-features.md) — `adapter:` picks WHICH real
|
|
56
|
+
# persistence this one ephemeral boot answers through, not just
|
|
57
|
+
# Memory. `:memory` is the original, zero-config behavior (every
|
|
58
|
+
# other caller in this codebase that doesn't pass `adapter:` gets
|
|
59
|
+
# exactly what it always got). `:sqlite` is nearly as cheap —
|
|
60
|
+
# `Adapters::Sqlite#resolve_path` already defaults to
|
|
61
|
+
# `data/<table>.db` under the copy's own root with no settings at
|
|
62
|
+
# all, the same "needs nothing declared" property Memory has, so a
|
|
63
|
+
# real on-disk B-tree gets exercised for free. `:postgres` is the
|
|
64
|
+
# expensive one — a real server, a real round trip per dispatch —
|
|
65
|
+
# so `bin/fuzz --adapter postgres` is meant to run with smaller
|
|
66
|
+
# seed/step counts than the Memory default, not as a like-for-like
|
|
67
|
+
# swap; see that flag's own comment.
|
|
68
|
+
def call(domain_path, adapter: :memory)
|
|
30
69
|
Dir.mktmpdir("hecks-fuzz") do |tmp|
|
|
31
70
|
copy = File.join(tmp, File.basename(domain_path))
|
|
32
71
|
copy_dereferencing(domain_path, copy)
|
|
33
72
|
FileUtils.rm_rf(File.join(copy, "data"))
|
|
34
|
-
|
|
73
|
+
case adapter
|
|
74
|
+
when :memory then rebind_to_memory!(copy)
|
|
75
|
+
when :sqlite then rebind_to_sqlite!(copy)
|
|
76
|
+
when :postgres then rebind_to_postgres!(copy)
|
|
77
|
+
else raise ArgumentError, "unknown fuzz adapter #{adapter.inspect} — :memory, :sqlite, or :postgres"
|
|
78
|
+
end
|
|
35
79
|
yield copy
|
|
36
80
|
end
|
|
37
81
|
end
|
|
@@ -47,8 +91,8 @@ module Hecks
|
|
|
47
91
|
#
|
|
48
92
|
# `FileUtils.cp` follows a symlink and copies its CONTENT, which is
|
|
49
93
|
# what an isolated boot wants: the copy has to stand alone, since
|
|
50
|
-
#
|
|
51
|
-
#
|
|
94
|
+
# rebind! rewrites files in it and must not reach back through a
|
|
95
|
+
# link into the real tree.
|
|
52
96
|
def copy_dereferencing(source, destination)
|
|
53
97
|
FileUtils.mkdir_p(destination)
|
|
54
98
|
Dir.glob(File.join(source, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
@@ -65,28 +109,178 @@ module Hecks
|
|
|
65
109
|
end
|
|
66
110
|
|
|
67
111
|
def rebind_to_memory!(copy)
|
|
68
|
-
|
|
69
|
-
# `persisted_by`/`projected_by` can be spelled two ways now —
|
|
70
|
-
# aggregate-scoped (`Banking::Customer.persisted_by("Heki")`,
|
|
71
|
-
# always parenthesised) and, since §0's domain-level default
|
|
72
|
-
# binds, a bare call at the hecksagon's own root
|
|
73
|
-
# (`persisted_by "Heki"`, no parens, no receiver). Both must
|
|
74
|
-
# be caught here or a domain that only declares the bare form
|
|
75
|
-
# keeps its real adapter under an "isolated" fuzz boot.
|
|
76
|
-
lines = File.readlines(path).reject { |line| line.match?(/\bprojected_by\s*\(?\s*"/) }
|
|
77
|
-
File.write(path, lines.join.gsub(/persisted_by\s*\(?\s*"[^"]+"\s*\)?/, 'persisted_by("Memory")'))
|
|
78
|
-
end
|
|
112
|
+
rewrite_bindings!(copy, "Memory")
|
|
79
113
|
|
|
80
114
|
# THE SETTINGS, NOT JUST THE BIND — `WorldBuilder#method_missing`
|
|
81
115
|
# stores a settings block under BOTH "verb:adapter" and the bare
|
|
82
116
|
# "verb" (world_builder.rb:32-33), so a bind rewritten to Memory
|
|
83
117
|
# still falls back to whatever adapter's settings were declared
|
|
84
118
|
# bare — Postgres's `database:`, which Memory does not take and
|
|
85
|
-
# WiringError refuses on sight. Memory
|
|
86
|
-
#
|
|
87
|
-
#
|
|
119
|
+
# WiringError refuses on sight. Both Memory and Sqlite need no
|
|
120
|
+
# settings at all (`Sqlite#resolve_path` defaults `database` to
|
|
121
|
+
# `data/<table>.db` under `root:` when unset — the same
|
|
122
|
+
# zero-config default Memory gets from having no `.world` at
|
|
123
|
+
# all), so the simplest correct fix for either target is
|
|
124
|
+
# dropping `.world` from the copy entirely.
|
|
125
|
+
Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# SAME DANCE AS MEMORY, ONE ADAPTER OVER — `Adapters::Sqlite#
|
|
129
|
+
# resolve_path` (adapters/driven/sqlite.rb) defaults to
|
|
130
|
+
# `data/<table>.db` under the boot's own root when no `database`
|
|
131
|
+
# setting is declared, which `data/` already being cleared makes a
|
|
132
|
+
# fresh on-disk store with zero configuration, the same "no .world
|
|
133
|
+
# needed" property Memory has. `data/` itself is left for Sqlite to
|
|
134
|
+
# recreate on first write, same as it always was for Memory/Heki —
|
|
135
|
+
# nothing here creates it up front.
|
|
136
|
+
def rebind_to_sqlite!(copy)
|
|
137
|
+
rewrite_bindings!(copy, "SqlitePersistence")
|
|
88
138
|
Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
|
|
89
139
|
end
|
|
140
|
+
|
|
141
|
+
# THE EXPENSIVE ONE — Postgres has no zero-config default the way
|
|
142
|
+
# Sqlite/Memory do (`Adapters::Postgres.connect_for` refuses outright
|
|
143
|
+
# with no `database` setting), so dropping `.world` the way the
|
|
144
|
+
# other two do would just move the WiringError from "wrong adapter"
|
|
145
|
+
# to "no adapter." Every domain name this copy declares gets a
|
|
146
|
+
# FRESH `.world` written for it instead — not a rewrite of whatever
|
|
147
|
+
# was there, a replacement, same reasoning `rewrite_bindings!`
|
|
148
|
+
# already applies to `.hecksagon`: this ephemeral boot owns every
|
|
149
|
+
# binding decision, nothing about a real deployment's own settings
|
|
150
|
+
# is relevant or safe to half-preserve here.
|
|
151
|
+
#
|
|
152
|
+
# ONE SHARED SCHEMA, DROPPED AND RECREATED BEFORE EVERY BOOT — not a
|
|
153
|
+
# fresh randomly-named one per call. `bin/fuzz` drives every
|
|
154
|
+
# ephemeral boot sequentially (one `IsolatedBoot.call` fully exits
|
|
155
|
+
# before the next begins — see that file's own single-threaded
|
|
156
|
+
# `while` loop), so nothing is ever concurrent here ; a fresh name
|
|
157
|
+
# every time would just leak schemas in `FUZZ_POSTGRES_DATABASE`
|
|
158
|
+
# forever with nothing to ever drop them. If a caller ever DOES
|
|
159
|
+
# start running fuzz adapters concurrently, this needs to move to a
|
|
160
|
+
# process-unique schema name (`SecureRandom.hex` is already
|
|
161
|
+
# `require`d here for exactly that day) — flagged, not solved,
|
|
162
|
+
# since nothing today calls this from more than one thread.
|
|
163
|
+
FUZZ_POSTGRES_DATABASE = "hecks_fuzz"
|
|
164
|
+
FUZZ_POSTGRES_SCHEMA = "hecks_fuzz"
|
|
165
|
+
|
|
166
|
+
def rebind_to_postgres!(copy)
|
|
167
|
+
require "pg"
|
|
168
|
+
rewrite_bindings!(copy, "Postgres")
|
|
169
|
+
ensure_fuzz_schema!
|
|
170
|
+
|
|
171
|
+
# ONE `.world` PER DIRECTORY A `.hecksagon` ACTUALLY LIVES IN, not
|
|
172
|
+
# one at `copy`'s own root — `Folder#load_domain` resolves a
|
|
173
|
+
# SINGLE `bluebook_directory` and globs `*.world` there, non-
|
|
174
|
+
# recursively (`Folder#load_each`); a domain can hold several
|
|
175
|
+
# `Hecks.hecksagon "<Name>" do ... end` SIBLING blocks in that one
|
|
176
|
+
# file (banking.hecksagon declares "Banking", "Governance", and
|
|
177
|
+
# "Identity" together), so every name found in one `.hecksagon`
|
|
178
|
+
# file gets bundled into one `.world` written beside it, not
|
|
179
|
+
# scattered by name.
|
|
180
|
+
Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
|
|
181
|
+
names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
|
|
182
|
+
next if names.empty?
|
|
183
|
+
|
|
184
|
+
world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres.world")
|
|
185
|
+
File.write(world_path, names.map { |name|
|
|
186
|
+
<<~WORLD
|
|
187
|
+
Hecks.world "#{name}" do
|
|
188
|
+
persisted_by("Postgres") do
|
|
189
|
+
database "#{FUZZ_POSTGRES_DATABASE}"
|
|
190
|
+
schema "#{FUZZ_POSTGRES_SCHEMA}"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
WORLD
|
|
194
|
+
}.join("\n"))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Any PRE-EXISTING `.world` this copy shipped with (a real
|
|
198
|
+
# deployment's own connection string) is now redundant with — and
|
|
199
|
+
# would conflict with, `Registry#add_world`'s own header on
|
|
200
|
+
# loading the same domain name twice — the fresh one just
|
|
201
|
+
# written above, so it goes the same way `rebind_to_memory!`
|
|
202
|
+
# already sends every `.world` in a Memory-mode boot.
|
|
203
|
+
Dir.glob(File.join(copy, "**", "*.world")).each do |path|
|
|
204
|
+
File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres.world"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# ADMIN CONNECTION LIVES OUTSIDE THE TMP COPY ENTIRELY — same as
|
|
209
|
+
# every other real-Postgres spec in this repo (`support/
|
|
210
|
+
# postgres_probe.rb`'s own header). Database created once per
|
|
211
|
+
# process and remembered (`@fuzz_database_ready` on this module's
|
|
212
|
+
# own singleton, the same memoization shape `PostgresProbe
|
|
213
|
+
# .available?` already uses) ; the schema inside it is dropped and
|
|
214
|
+
# recreated on EVERY call, which is what actually isolates one
|
|
215
|
+
# ephemeral boot's data from the next.
|
|
216
|
+
def ensure_fuzz_schema!
|
|
217
|
+
# `Adapters::Postgres#initialize` opens ONE real `PG::Connection`
|
|
218
|
+
# PER AGGREGATE and never explicitly closes it — fine for a
|
|
219
|
+
# process that boots once and runs, exactly what every other
|
|
220
|
+
# caller of this adapter is. A fuzz run boots dozens to hundreds
|
|
221
|
+
# of EPHEMERAL times in one process (every seed does at least a
|
|
222
|
+
# generate + a replay, `replay_is_deterministic` doubles that,
|
|
223
|
+
# shrinking multiplies it further), and `PG::Connection` only
|
|
224
|
+
# actually closes its socket when Ruby's GC finalizes the
|
|
225
|
+
# (by-then-unreferenced) object — which, under a tight loop like
|
|
226
|
+
# this, does not reliably keep pace with how fast new
|
|
227
|
+
# connections open. Discovered live: `bin/fuzz --adapter
|
|
228
|
+
# postgres` against `examples/banking` hit a real local
|
|
229
|
+
# Postgres's own `max_connections` after roughly a dozen
|
|
230
|
+
# ephemeral boots, `PG::ConnectionBad: ... "too many clients
|
|
231
|
+
# already"`. A `GC.start` here — right before the next ephemeral
|
|
232
|
+
# boot's connections open, not on some timer — reclaims every
|
|
233
|
+
# connection the PREVIOUS boot's now-unreferenced adapters held,
|
|
234
|
+
# keeping the live count bounded regardless of run length. This
|
|
235
|
+
# is a real constraint on running Postgres in a loop, not
|
|
236
|
+
# something to route around by connecting less carefully.
|
|
237
|
+
GC.start
|
|
238
|
+
|
|
239
|
+
unless @fuzz_database_ready
|
|
240
|
+
admin = PG.connect(dbname: "postgres")
|
|
241
|
+
exists = admin.exec_params(
|
|
242
|
+
"SELECT 1 FROM pg_database WHERE datname = $1", [FUZZ_POSTGRES_DATABASE]
|
|
243
|
+
).ntuples.positive?
|
|
244
|
+
admin.exec(%(CREATE DATABASE "#{FUZZ_POSTGRES_DATABASE}")) unless exists
|
|
245
|
+
admin.close
|
|
246
|
+
@fuzz_database_ready = true
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
db = PG.connect(dbname: FUZZ_POSTGRES_DATABASE)
|
|
250
|
+
# QUIET ON PURPOSE — same as `Adapters::Postgres.connect_for`'s
|
|
251
|
+
# own `SET client_min_messages`: a `DROP SCHEMA ... CASCADE` that
|
|
252
|
+
# actually has something to drop (every boot after the first)
|
|
253
|
+
# NOTICEs once per dropped object, which is the ordinary case
|
|
254
|
+
# here, not news, and would otherwise bury `bin/fuzz`'s own
|
|
255
|
+
# output under a wall of "drop cascades to table ..." on every
|
|
256
|
+
# single ephemeral boot.
|
|
257
|
+
db.exec("SET client_min_messages = warning")
|
|
258
|
+
quoted = db.quote_ident(FUZZ_POSTGRES_SCHEMA)
|
|
259
|
+
db.exec("DROP SCHEMA IF EXISTS #{quoted} CASCADE")
|
|
260
|
+
db.exec("CREATE SCHEMA #{quoted}")
|
|
261
|
+
db.close
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# THE SHARED REWRITE — factored out of `rebind_to_memory!` when
|
|
265
|
+
# Sqlite/Postgres modes needed the identical `.hecksagon` surgery
|
|
266
|
+
# with only the target adapter name differing. `persisted_by`/
|
|
267
|
+
# `projected_by` can be spelled two ways: aggregate-scoped
|
|
268
|
+
# (`Banking::Customer.persisted_by("Heki")`, always parenthesised)
|
|
269
|
+
# and, since §0's domain-level default binds, a bare call at the
|
|
270
|
+
# hecksagon's own root (`persisted_by "Heki"`, no parens, no
|
|
271
|
+
# receiver). Both must be caught here or a domain that only
|
|
272
|
+
# declares the bare form keeps its real adapter under an
|
|
273
|
+
# "isolated" fuzz boot. `projected_by` is dropped outright rather
|
|
274
|
+
# than rebound, for every target adapter — `Registry
|
|
275
|
+
# #read_repository` already falls back to the authoritative
|
|
276
|
+
# repository when none exists, so a read model this ephemeral copy
|
|
277
|
+
# never wires is simply unread, not broken.
|
|
278
|
+
def rewrite_bindings!(copy, adapter_name)
|
|
279
|
+
Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |path|
|
|
280
|
+
lines = File.readlines(path).reject { |line| line.match?(/\bprojected_by\s*\(?\s*"/) }
|
|
281
|
+
File.write(path, lines.join.gsub(/persisted_by\s*\(?\s*"[^"]+"\s*\)?/, "persisted_by(\"#{adapter_name}\")"))
|
|
282
|
+
end
|
|
283
|
+
end
|
|
90
284
|
end
|
|
91
285
|
end
|
|
92
286
|
end
|
|
@@ -236,9 +236,9 @@ module Hecks
|
|
|
236
236
|
# comparison depended on, anything. Two independent replays, not a
|
|
237
237
|
# cached one compared to itself, so a bug that corrupts the FIRST
|
|
238
238
|
# run's own bookkeeping cannot pass by agreeing with itself.
|
|
239
|
-
def replay_is_deterministic(domain_path, steps)
|
|
240
|
-
first = Replay.call(domain_path, steps)
|
|
241
|
-
second = Replay.call(domain_path, steps)
|
|
239
|
+
def replay_is_deterministic(domain_path, steps, adapter: :memory)
|
|
240
|
+
first = Replay.call(domain_path, steps, adapter: adapter)
|
|
241
|
+
second = Replay.call(domain_path, steps, adapter: adapter)
|
|
242
242
|
|
|
243
243
|
comparable = ->(history) { history.reject { |key, _| key == :bluebook || key == :bluebooks } }
|
|
244
244
|
return true if comparable.call(first) == comparable.call(second)
|
|
@@ -257,9 +257,34 @@ module Hecks
|
|
|
257
257
|
# drifted before — an adapter that ACCEPTS what the reference says
|
|
258
258
|
# matches nothing, or orders what it refuses to order, shows up
|
|
259
259
|
# here as a finding no self-referential adapter spec could see.
|
|
260
|
+
# M23 — `Replay` now runs the native and reference engines
|
|
261
|
+
# INDEPENDENTLY (each in its own begin/rescue — see that file's own
|
|
262
|
+
# comment at the capture site), so this property can tell apart what
|
|
263
|
+
# used to be indistinguishable: "both engines refused" (fine — the
|
|
264
|
+
# ask was genuinely bad, nothing to compare) from "one refused and
|
|
265
|
+
# the other did not" (a real divergence — the two engines disagree
|
|
266
|
+
# about whether the ask was even VALID, never mind what it answers).
|
|
267
|
+
# `native_refused`/`reference_refused` are read by KEY PRESENCE, not
|
|
268
|
+
# truthiness — `Replay` only ever adds `:error`/`:reference_error`
|
|
269
|
+
# to an entry when that side actually raised, so an absent key is an
|
|
270
|
+
# unambiguous "this side answered." A read-model ask (no reference
|
|
271
|
+
# twin attempted at all, `asked[:query]` without "::") is skipped
|
|
272
|
+
# entirely, same as always — there is no second engine to disagree
|
|
273
|
+
# with.
|
|
260
274
|
def query_answers_match_reference(history)
|
|
261
275
|
offenders = history.fetch(:queries).filter_map do |asked|
|
|
262
|
-
next
|
|
276
|
+
next unless asked[:query].is_a?(String) && asked[:query].include?("::")
|
|
277
|
+
|
|
278
|
+
native_refused = asked.key?(:error)
|
|
279
|
+
reference_refused = asked.key?(:reference_error)
|
|
280
|
+
|
|
281
|
+
if native_refused != reference_refused
|
|
282
|
+
next "#{asked[:query]} #{asked[:args].inspect} — native #{native_refused ? "refused (#{asked[:error]})" : 'answered'}, " \
|
|
283
|
+
"but the reference interpreter #{reference_refused ? "refused (#{asked[:reference_error]})" : 'answered'} — " \
|
|
284
|
+
"a refusal-shaped divergence, not just a differing row set"
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
next if native_refused
|
|
263
288
|
next if asked[:rows] == asked[:reference_rows]
|
|
264
289
|
|
|
265
290
|
"#{asked[:query]} #{asked[:args].inspect} answered #{asked[:rows].inspect} " \
|
|
@@ -462,10 +487,12 @@ module Hecks
|
|
|
462
487
|
|
|
463
488
|
command = command_for_verb(bluebooks, refusal[:verb])
|
|
464
489
|
next "#{refusal[:verb]} raised #{refusal[:kind]}, but no declared command resolves that verb" unless command
|
|
465
|
-
|
|
490
|
+
|
|
491
|
+
declared = effective_guard_descriptions(bluebooks, refusal[:verb], command)
|
|
492
|
+
next if declared.include?(match[2])
|
|
466
493
|
|
|
467
494
|
"#{refusal[:verb]} refused — #{match[2].inspect} — but #{command.hecks_name} declares no given " \
|
|
468
|
-
"or ensures with that description (it declares #{
|
|
495
|
+
"or ensures with that description (it declares #{declared.inspect})"
|
|
469
496
|
end
|
|
470
497
|
|
|
471
498
|
offenders.empty? || offenders.join("; ")
|
|
@@ -488,6 +515,25 @@ module Hecks
|
|
|
488
515
|
# refusal read as "no declared command resolves that verb" purely
|
|
489
516
|
# because `history[:bluebook]` was Expression, not Translation; the
|
|
490
517
|
# refusal was real, this property's own domain resolution was not.
|
|
518
|
+
# A DELEGATING DOOR REFUSES WITH ITS TARGET'S OWN WORDS. `delegates_to`
|
|
519
|
+
# (CommandBuilder#delegates_to_impl) hands the whole dispatch to one
|
|
520
|
+
# entity command, and that command's given is what refuses — raised
|
|
521
|
+
# back through the door, in the door's name (chess: `Game.MoveKnight
|
|
522
|
+
# refused — "it is that color's turn"`, a given Knight.Move declares
|
|
523
|
+
# and MoveKnight, a pure passthrough, never could). Read the door's
|
|
524
|
+
# own guards first, then every delegation target's; an offence is
|
|
525
|
+
# only a description NEITHER declares. Found live mining chess's
|
|
526
|
+
# history: every refused move through a door read as undeclared.
|
|
527
|
+
def effective_guard_descriptions(bluebooks, verb, command)
|
|
528
|
+
own = command.guard_descriptions
|
|
529
|
+
delegated = command.mutations.select { |m| m.op == :delegate }.flat_map do |delegation|
|
|
530
|
+
domain, aggregate_name, = Naming.split_verb(verb)
|
|
531
|
+
target = command_for_verb(bluebooks, "#{domain}::#{aggregate_name}.#{delegation.target}")
|
|
532
|
+
target ? target.guard_descriptions : []
|
|
533
|
+
end
|
|
534
|
+
own + delegated
|
|
535
|
+
end
|
|
536
|
+
|
|
491
537
|
def command_for_verb(bluebooks, verb)
|
|
492
538
|
domain, aggregate_name, command_path = Naming.split_verb(verb)
|
|
493
539
|
return nil unless command_path
|
data/lib/hecks/fuzzing/replay.rb
CHANGED
|
@@ -57,12 +57,12 @@ module Hecks
|
|
|
57
57
|
# DISPATCH_ORDER.
|
|
58
58
|
GUARD_REFUSAL_CLASSES = [Runtime::GivenNotMet, Runtime::LifecycleRefused].freeze
|
|
59
59
|
|
|
60
|
-
def call(domain_path, steps)
|
|
60
|
+
def call(domain_path, steps, adapter: :memory)
|
|
61
61
|
# See isolated_boot.rb's own header: resets data/ AND rebinds
|
|
62
|
-
# persistence to Memory, since a
|
|
63
|
-
# lives outside the copied
|
|
64
|
-
# resetting data/ alone.
|
|
65
|
-
IsolatedBoot.call(domain_path) do |copy|
|
|
62
|
+
# persistence to the chosen adapter (Memory by default), since a
|
|
63
|
+
# Postgres-bound domain's real store lives outside the copied
|
|
64
|
+
# directory and cannot be reached by resetting data/ alone.
|
|
65
|
+
IsolatedBoot.call(domain_path, adapter: adapter) do |copy|
|
|
66
66
|
runtime = Hecks.boot(copy)
|
|
67
67
|
|
|
68
68
|
refusals = []
|
|
@@ -112,22 +112,55 @@ module Hecks
|
|
|
112
112
|
next
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
+
# THE QUERY ORACLE — TWO INDEPENDENT ENGINES, EACH RUN AND
|
|
116
|
+
# CAUGHT ON ITS OWN, never a single shared `begin`/`rescue`
|
|
117
|
+
# wrapping both calls. A shared begin/rescue meant `runtime.
|
|
118
|
+
# query` raising (native refuses) short-circuited BEFORE
|
|
119
|
+
# `runtime.reference_query` ever ran at all — the entry
|
|
120
|
+
# recorded only `error:`, with no `reference_rows` and no
|
|
121
|
+
# record of what the reference interpreter would have
|
|
122
|
+
# answered — and `runtime.reference_query` raising instead
|
|
123
|
+
# (reference refuses, native already succeeded) landed in the
|
|
124
|
+
# SAME rescue, discarding the native `rows` this begin block
|
|
125
|
+
# had already computed and recording the whole ask as an
|
|
126
|
+
# ordinary refusal. Either way, "one engine refused and the
|
|
127
|
+
# other did not" — a real divergence, exactly the shape a
|
|
128
|
+
# differential oracle exists to catch — read as "refused,
|
|
129
|
+
# nothing to compare" and vanished. Each engine's own
|
|
130
|
+
# success/failure is captured here independently instead, so
|
|
131
|
+
# `Properties.query_answers_match_reference` can tell "both
|
|
132
|
+
# refused" (fine) apart from "one refused, the other did not"
|
|
133
|
+
# (a finding) rather than having every refusal-shaped run
|
|
134
|
+
# skipped uniformly.
|
|
135
|
+
native_rows = native_error = nil
|
|
115
136
|
begin
|
|
116
|
-
|
|
117
|
-
# THE QUERY ORACLE'S OTHER HALF — the same ask at the same
|
|
118
|
-
# instant, answered by the reference interpreter instead of
|
|
119
|
-
# the bound adapter's native hook. Recorded side by side so
|
|
120
|
-
# Properties.query_answers_match_reference can treat any
|
|
121
|
-
# difference as a finding. Read-model asks (bare domain
|
|
122
|
-
# form, no "::") have no reference twin and record only the
|
|
123
|
-
# one answer.
|
|
124
|
-
reference = question.include?("::") ? runtime.reference_query(question, **args) : nil
|
|
125
|
-
queries << { query: question, args: args, rows: rows, reference_rows: reference,
|
|
126
|
-
instances_at: snapshot_instances(runtime) }
|
|
137
|
+
native_rows = runtime.query(question, **args)
|
|
127
138
|
rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
|
|
128
|
-
|
|
129
|
-
refusals << { verb: question, error: e.message, kind: e.class.name }
|
|
139
|
+
native_error = e
|
|
130
140
|
end
|
|
141
|
+
|
|
142
|
+
# Read-model asks (bare domain form, no "::") have no
|
|
143
|
+
# reference twin at all — never attempted, not "attempted and
|
|
144
|
+
# agreed."
|
|
145
|
+
has_reference = question.include?("::")
|
|
146
|
+
reference_rows = reference_error = nil
|
|
147
|
+
if has_reference
|
|
148
|
+
begin
|
|
149
|
+
reference_rows = runtime.reference_query(question, **args)
|
|
150
|
+
rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
|
|
151
|
+
reference_error = e
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
entry = { query: question, args: args, rows: native_rows, instances_at: snapshot_instances(runtime) }
|
|
156
|
+
entry[:error] = native_error.message if native_error
|
|
157
|
+
if has_reference
|
|
158
|
+
entry[:reference_rows] = reference_rows
|
|
159
|
+
entry[:reference_error] = reference_error.message if reference_error
|
|
160
|
+
end
|
|
161
|
+
queries << entry
|
|
162
|
+
|
|
163
|
+
refusals << { verb: question, error: native_error.message, kind: native_error.class.name } if native_error
|
|
131
164
|
next
|
|
132
165
|
end
|
|
133
166
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "../value_generator"
|
|
2
|
+
require_relative "../../naming"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Fuzzing
|
|
@@ -11,8 +12,7 @@ module Hecks
|
|
|
11
12
|
|
|
12
13
|
def record_outcome(catalog, entry, args)
|
|
13
14
|
aggregate = entry[:aggregate]
|
|
14
|
-
|
|
15
|
-
parent_scalar = ValueGenerator.scalar_of(args[parent_key])
|
|
15
|
+
parent_scalar = identity_scalar_of(aggregate, args)
|
|
16
16
|
|
|
17
17
|
@known_ids[aggregate.hecks_name] << parent_scalar if entry[:entity].nil? && entry[:command].creates?
|
|
18
18
|
|
|
@@ -35,6 +35,32 @@ module Hecks
|
|
|
35
35
|
@entity_known_ids[key] << new_id
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
# THE SCALAR THIS STEP'S OWN AGGREGATE IDENTITY RESOLVES TO, from the
|
|
39
|
+
# args a creating (or entity-populating) command actually
|
|
40
|
+
# dispatched — a SINGLE declared head (or the untyped default
|
|
41
|
+
# `:id`) reads straight off its own top-level arg the way this
|
|
42
|
+
# always has (`ValueGenerator.scalar_of`, opening the identity value
|
|
43
|
+
# object). A genuinely COMPOSITE identity (`composite_identity?`,
|
|
44
|
+
# shared with StepBuilder#add_identity! — see its own comment) has
|
|
45
|
+
# no such top-level arg at all: `add_identity!` deliberately leaves
|
|
46
|
+
# a composite creating command's own individually-declared fields
|
|
47
|
+
# alone rather than forcing a synthetic `id` neither the bluebook
|
|
48
|
+
# nor the command ever declared, so this joins those fields itself,
|
|
49
|
+
# in the SAME declaration order `Runtime::Identity.of` joins them
|
|
50
|
+
# at dispatch (`Naming.identity`, `Naming::IDENTITY_JOIN`) — which
|
|
51
|
+
# is exactly what makes the result usable later as the bare `id:`
|
|
52
|
+
# a composite Revoke/act-again command expects (`Identity.from`'s
|
|
53
|
+
# own untyped fallback, reached when `identity_of` finds no
|
|
54
|
+
# top-level field args to join, reads a bare `id:` straight
|
|
55
|
+
# through unattributed).
|
|
56
|
+
def identity_scalar_of(aggregate, args)
|
|
57
|
+
parent_key = (aggregate.identified_by || :id).to_s
|
|
58
|
+
return ValueGenerator.scalar_of(args[parent_key]) unless composite_identity?(aggregate)
|
|
59
|
+
|
|
60
|
+
parts = aggregate.identity_paths.map { |path| ValueGenerator.scalar_of(args[path.to_s.split(".").first]) }
|
|
61
|
+
Naming.identity(parts)
|
|
62
|
+
end
|
|
63
|
+
|
|
38
64
|
def pick_known(name)
|
|
39
65
|
pool = @known_ids[name]
|
|
40
66
|
return ValueGenerator.random_id(@random) if pool.empty? || @random.rand < ValueGenerator::INVALID_REFERENCE_PROBABILITY
|
|
@@ -2,6 +2,8 @@ require_relative "../../bluebook/expression/resolver"
|
|
|
2
2
|
require_relative "../invalid_value_generator"
|
|
3
3
|
require_relative "../value_generator"
|
|
4
4
|
require_relative "../../runtime/errors"
|
|
5
|
+
require_relative "../../runtime/value"
|
|
6
|
+
require_relative "../../naming"
|
|
5
7
|
|
|
6
8
|
module Hecks
|
|
7
9
|
module Fuzzing
|
|
@@ -52,10 +54,6 @@ module Hecks
|
|
|
52
54
|
|
|
53
55
|
def args_for(attributes, aggregate)
|
|
54
56
|
args = attributes.each_with_object({}) do |attribute, built|
|
|
55
|
-
# List-typed direct command arguments have no example in this repo's
|
|
56
|
-
# domains today — every list is populated via a per-element append
|
|
57
|
-
# command instead. Skipped rather than guessed at.
|
|
58
|
-
next if attribute.list?
|
|
59
57
|
# AN OPTIONAL ARGUMENT IS SOMETIMES NOT GIVEN, and that is an
|
|
60
58
|
# ordinary payload rather than a damaged one — see
|
|
61
59
|
# OPTIONAL_OMITTED_PROBABILITY for why this cannot live in
|
|
@@ -63,12 +61,52 @@ module Hecks
|
|
|
63
61
|
# exist at all.
|
|
64
62
|
next if attribute.optional? && @random.rand < SequenceGenerator::OPTIONAL_OMITTED_PROBABILITY
|
|
65
63
|
|
|
66
|
-
|
|
64
|
+
if attribute.list?
|
|
65
|
+
value = list_value_for(attribute, aggregate)
|
|
66
|
+
# A list-of-ENTITY command attribute has no real example
|
|
67
|
+
# anywhere in this repo's domains — every entity-owned list is
|
|
68
|
+
# populated via a per-element append command instead, never a
|
|
69
|
+
# whole-list command argument — so `list_value_for` (below)
|
|
70
|
+
# answers `nil` for one rather than guessing at an entity's own
|
|
71
|
+
# shape, and this step still skips it exactly as it always
|
|
72
|
+
# has. A list-of-VALUE-OBJECT attribute (`ConsoleSettings::
|
|
73
|
+
# Collection.ReplaceColumns`' own `columns`, `list_of(Column)`)
|
|
74
|
+
# is the real, previously-unfuzzable case this now covers —
|
|
75
|
+
# `sets :columns` imports the owner aggregate's own declared
|
|
76
|
+
# `list_of` attribute onto the command verbatim (Command
|
|
77
|
+
# Builder#resolve_bare_set!), so it is a required, ordinary
|
|
78
|
+
# top-level argument like any other, not an entity mutation.
|
|
79
|
+
next if value.nil?
|
|
80
|
+
|
|
81
|
+
built[attribute.name.to_s] = value
|
|
82
|
+
else
|
|
83
|
+
built[attribute.name.to_s] = ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids)
|
|
84
|
+
end
|
|
67
85
|
end
|
|
68
86
|
|
|
69
87
|
malform(args, attributes, aggregate)
|
|
70
88
|
end
|
|
71
89
|
|
|
90
|
+
# A `list_of` ATTRIBUTE'S OWN VALUE — an array of independently
|
|
91
|
+
# generated elements, each shaped exactly the way a bare (non-list)
|
|
92
|
+
# attribute of the SAME declared element type already is
|
|
93
|
+
# (`ValueGenerator.value_for`), since `list_of(X)`'s own element
|
|
94
|
+
# coercion is `X`'s ordinary shape repeated, not a different one
|
|
95
|
+
# (`Attribute#type` is already unwrapped from `list_of(...)` at
|
|
96
|
+
# declare time — the same fact `command_builder.rb`'s own comment
|
|
97
|
+
# on `resolve_bare_set!` names). 0-3 elements — enough to exercise a
|
|
98
|
+
# genuinely non-trivial replace without generating pathologically
|
|
99
|
+
# large payloads every time. `nil`, not `[]`, when the element type
|
|
100
|
+
# isn't a declared value object at all (an entity-typed list) — see
|
|
101
|
+
# `args_for`'s own comment on why that case still skips rather than
|
|
102
|
+
# guesses.
|
|
103
|
+
def list_value_for(attribute, aggregate)
|
|
104
|
+
value_object = Runtime::Value.value_object_for(aggregate, attribute.type.to_s)
|
|
105
|
+
return nil unless value_object
|
|
106
|
+
|
|
107
|
+
Array.new(@random.rand(0..3)) { ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids) }
|
|
108
|
+
end
|
|
109
|
+
|
|
72
110
|
# ONE MALFORMATION AT A TIME, and usually none. A step whose payload is
|
|
73
111
|
# wrong in three ways only ever proves which check runs first ; wrong in
|
|
74
112
|
# exactly one way names the check that fired. And the rate stays low on
|
|
@@ -120,13 +158,40 @@ module Hecks
|
|
|
120
158
|
entity_scalar = pick_entity_known(aggregate.hecks_name, entry[:entity].hecks_name, parent_scalar)
|
|
121
159
|
args[entity_key] = identity_shaped(entry[:entity], entry[:entity].identified_by, entity_scalar, aggregate)
|
|
122
160
|
elsif entry[:command].creates?
|
|
123
|
-
|
|
161
|
+
# A COMPOSITE IDENTITY (`identified_by` answering nil with MORE
|
|
162
|
+
# THAN ONE declared path — Behaviour::Identified's own "a
|
|
163
|
+
# composite has no single head" comment) supplies every one of
|
|
164
|
+
# its parts as its own ordinary, individually-declared command
|
|
165
|
+
# attribute already — `RoleAssignment::Assign` takes actor_id/
|
|
166
|
+
# role_name/starts_at directly, `args_for` (above) already
|
|
167
|
+
# generated all three. Forcing a synthetic top-level `id` here
|
|
168
|
+
# too — this codebase's own fallback for the SINGLE-key and the
|
|
169
|
+
# genuinely untyped (`identity_paths.empty?`, no `identified_by`
|
|
170
|
+
# declared at all) cases — hands a composite creating command
|
|
171
|
+
# an argument it never declared at all, refused every time as
|
|
172
|
+
# unknown before this check existed (a creating `Assign`/`Grant`
|
|
173
|
+
# step was never anything BUT refused). `identity_paths.empty?`
|
|
174
|
+
# is the untyped default (falls all the way back to a minted
|
|
175
|
+
# `:id` the runtime itself never declared as an attribute
|
|
176
|
+
# either), which still needs exactly the old minting behavior.
|
|
177
|
+
unless composite_identity?(aggregate)
|
|
178
|
+
args[parent_key] ||= identity_shaped(aggregate, aggregate.identified_by, ValueGenerator.random_id(@random), aggregate)
|
|
179
|
+
end
|
|
124
180
|
else
|
|
125
181
|
scalar = pick_known(aggregate.hecks_name)
|
|
126
182
|
args[parent_key] = identity_shaped(aggregate, aggregate.identified_by, scalar, aggregate)
|
|
127
183
|
end
|
|
128
184
|
end
|
|
129
185
|
|
|
186
|
+
# TRUE ONLY FOR A GENUINE MULTI-FIELD IDENTITY — `identified_by`
|
|
187
|
+
# returns nil both for a real composite (`identity_paths.size > 1`)
|
|
188
|
+
# and for the untyped default with NO identity declared at all
|
|
189
|
+
# (`identity_paths.size == 0`, Behaviour::Identified's own
|
|
190
|
+
# `Array(@identified_by)` fallback) ; only the first of those two
|
|
191
|
+
# has its own parts already sitting in `args` as real, individually-
|
|
192
|
+
# generated command attributes.
|
|
193
|
+
def composite_identity?(aggregate) = aggregate.identified_by.nil? && aggregate.identity_paths.size > 1
|
|
194
|
+
|
|
130
195
|
# A bare scalar id, shaped to match whatever `construct` itself
|
|
131
196
|
# declares that identity field as. `Account::LedgerEntry` is addressed
|
|
132
197
|
# by `sequence`, and `sequence` is declared as a value-object-typed
|
|
@@ -73,8 +73,8 @@ module Hecks
|
|
|
73
73
|
# the generating end rather than the scoring end.
|
|
74
74
|
UNEXERCISED_WEIGHT = 4
|
|
75
75
|
|
|
76
|
-
def self.generate(domain_path, seed:, steps:)
|
|
77
|
-
new(domain_path, seed: seed, steps: steps).call
|
|
76
|
+
def self.generate(domain_path, seed:, steps:, adapter: :memory)
|
|
77
|
+
new(domain_path, seed: seed, steps: steps, adapter: adapter).call
|
|
78
78
|
end
|
|
79
79
|
|
|
80
80
|
# How many EVENTS the generated sequence actually produced — not
|
|
@@ -87,10 +87,11 @@ module Hecks
|
|
|
87
87
|
# replay one.
|
|
88
88
|
attr_reader :event_count
|
|
89
89
|
|
|
90
|
-
def initialize(domain_path, seed:, steps:)
|
|
90
|
+
def initialize(domain_path, seed:, steps:, adapter: :memory)
|
|
91
91
|
@domain_path = domain_path
|
|
92
92
|
@seed = seed
|
|
93
93
|
@step_count = steps
|
|
94
|
+
@adapter = adapter
|
|
94
95
|
@random = Random.new(seed)
|
|
95
96
|
@known_ids = Hash.new { |h, k| h[k] = [] }
|
|
96
97
|
@entity_known_ids = Hash.new { |h, k| h[k] = [] }
|
|
@@ -106,7 +107,7 @@ module Hecks
|
|
|
106
107
|
# a Postgres-bound domain's real store lives outside the copied
|
|
107
108
|
# directory entirely and `rm_rf`ing data/ alone cannot reach it —
|
|
108
109
|
# see isolated_boot.rb's own header.
|
|
109
|
-
IsolatedBoot.call(@domain_path) do |copy|
|
|
110
|
+
IsolatedBoot.call(@domain_path, adapter: @adapter) do |copy|
|
|
110
111
|
runtime = Hecks.boot(copy)
|
|
111
112
|
catalog = build_catalog(runtime)
|
|
112
113
|
Array.new(@step_count) { attempt_step(runtime, catalog) }.compact
|