hecks 2.7.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/hecks +13 -0
- data/lib/hecks/adapters/driven/memory.rb +1 -1
- data/lib/hecks/adapters/driven/postgres.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
- data/lib/hecks/behaviors/expectations.rb +2 -2
- data/lib/hecks/bluebook/assembly/contract.rb +1 -1
- data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
- data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
- data/lib/hecks/bluebook/assembly.rb +1 -1
- data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
- data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
- data/lib/hecks/bluebook/capabilities.rb +2 -2
- data/lib/hecks/bluebook/chapter.rb +1 -1
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
- data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
- data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
- data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
- data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
- data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
- data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
- data/lib/hecks/bluebook/meta_validator.rb +1 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
- data/lib/hecks/bluebook/model_check.rb +12 -13
- data/lib/hecks/cache_dir.rb +103 -0
- data/lib/hecks/cli/document.rb +104 -0
- data/lib/hecks/cli/ir.rb +47 -0
- data/lib/hecks/cli/mcp.rb +32 -0
- data/lib/hecks/cli/mcp_door.rb +461 -0
- data/lib/hecks/cli/model_check.rb +270 -0
- data/lib/hecks/cli/project_cli.rb +123 -0
- data/lib/hecks/cli/project_diagrams.rb +43 -0
- data/lib/hecks/cli/run.rb +219 -0
- data/lib/hecks/cli/smoke_test.rb +59 -0
- data/lib/hecks/cli/stores.rb +54 -0
- data/lib/hecks/cli.rb +166 -0
- data/lib/hecks/facade/handle.rb +2 -2
- data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
- data/lib/hecks/mcp_door_scope.rb +176 -0
- data/lib/hecks/ports/persistence/append_only.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
- data/lib/hecks/ports/persistence/state_codec.rb +1 -1
- data/lib/hecks/projections/deploy/fargate.rb +1 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -3
- data/lib/hecks/projections/reference.rb +7 -1
- data/lib/hecks/projector/exporter.rb +4 -4
- data/lib/hecks/projector/target.rb +1 -1
- data/lib/hecks/release/runner/ci_publisher.rb +96 -0
- data/lib/hecks/release/runner/commands.rb +68 -0
- data/lib/hecks/release/runner/console.rb +52 -0
- data/lib/hecks/release/runner/gem_publisher.rb +44 -0
- data/lib/hecks/release/runner/git.rb +48 -0
- data/lib/hecks/release/runner/npm_publisher.rb +89 -0
- data/lib/hecks/release/runner/preflight.rb +119 -0
- data/lib/hecks/release/runner/published.rb +55 -0
- data/lib/hecks/release/runner/tagger.rb +87 -0
- data/lib/hecks/release/runner.rb +244 -0
- data/lib/hecks/router/namespace_installer.rb +1 -1
- data/lib/hecks/runtime/command_interpreter.rb +3 -3
- data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
- data/lib/hecks/runtime/entity_element.rb +1 -1
- data/lib/hecks/runtime/entity_interpreter.rb +1 -1
- data/lib/hecks/runtime/invocation.rb +1 -1
- data/lib/hecks/runtime/loader.rb +1 -1
- data/lib/hecks/runtime/outbox.rb +1 -1
- data/lib/hecks/runtime/reaction_invocation.rb +3 -3
- data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
- data/lib/hecks/runtime/reference_hop.rb +4 -4
- data/lib/hecks/runtime/registry/verification.rb +11 -11
- data/lib/hecks/runtime/registry.rb +8 -4
- data/lib/hecks/runtime/saga_interpreter.rb +1 -1
- data/lib/hecks/runtime/value/coercion.rb +3 -3
- data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
- data/lib/hecks/storehouse.rb +18 -12
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +5 -0
- data/lib/hecks.rb +6 -1
- metadata +28 -58
- data/lib/hecks/bench/cli.rb +0 -74
- data/lib/hecks/bench/environment.rb +0 -100
- data/lib/hecks/bench/postgres_probe.rb +0 -41
- data/lib/hecks/bench/report.rb +0 -116
- data/lib/hecks/bench/ruby_runner.rb +0 -151
- data/lib/hecks/bench/run.rb +0 -55
- data/lib/hecks/bench/rust_runner.rb +0 -165
- data/lib/hecks/bench/stats.rb +0 -75
- data/lib/hecks/bench/suite.rb +0 -172
- data/lib/hecks/bench/workload.rb +0 -137
- data/lib/hecks/bench.rb +0 -28
- data/lib/hecks/codemod.rb +0 -443
- data/lib/hecks/corpus.rb +0 -479
- data/lib/hecks/doc/reference.rb +0 -585
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
- data/lib/hecks/fuzzing/combination_miner.rb +0 -178
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
- data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
- data/lib/hecks/fuzzing/differential.rb +0 -192
- data/lib/hecks/fuzzing/domain_generator.rb +0 -871
- data/lib/hecks/fuzzing/era_boundary.rb +0 -154
- data/lib/hecks/fuzzing/form_census.rb +0 -285
- data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
- data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
- data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
- data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
- data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
- data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
- data/lib/hecks/fuzzing/properties/guards.rb +0 -398
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
- data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
- data/lib/hecks/fuzzing/properties/querying.rb +0 -260
- data/lib/hecks/fuzzing/properties.rb +0 -267
- data/lib/hecks/fuzzing/qa_settings.rb +0 -164
- data/lib/hecks/fuzzing/replay.rb +0 -930
- data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
- data/lib/hecks/fuzzing/self_consistency.rb +0 -825
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
- data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
- data/lib/hecks/fuzzing/shrinker.rb +0 -309
- data/lib/hecks/fuzzing/structural_skips.rb +0 -53
- data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
- data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
- data/lib/hecks/fuzzing/value_generator.rb +0 -340
- data/lib/hecks/fuzzing.rb +0 -23
- data/lib/hecks/grammar/evolve.rb +0 -524
- data/lib/hecks/query_ir.rb +0 -482
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
require_relative "../../hecks"
|
|
2
|
+
require_relative "../bluebook/model_check"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module CLI
|
|
6
|
+
# The command behind `bin/model_check` and `hecks model_check`: static analysis
|
|
7
|
+
# over the IR, printed per domain, exiting non-zero when any error-severity
|
|
8
|
+
# finding is left.
|
|
9
|
+
#
|
|
10
|
+
# It finds unreachable lifecycle states, transitions nothing can fire, saga
|
|
11
|
+
# states no handler chain reaches, a compensation whose `from_state` is
|
|
12
|
+
# unreachable, dispatches to a command that does not exist, and handlers
|
|
13
|
+
# listening for an event nothing emits. The runtime checks none of these: a dead
|
|
14
|
+
# transition never fires, in silence.
|
|
15
|
+
#
|
|
16
|
+
# ## Named domains, or the whole corpus
|
|
17
|
+
#
|
|
18
|
+
# Given domain paths, it boots each and runs one cross-domain check across all
|
|
19
|
+
# of them. Given none, it sweeps the repository's corpus (`Corpus.model_check_members`)
|
|
20
|
+
# and the language's own chapters; that needs `root:`, the repository, so an
|
|
21
|
+
# installed gem without one asks for a domain path instead.
|
|
22
|
+
#
|
|
23
|
+
# ## Options
|
|
24
|
+
#
|
|
25
|
+
# `--strict` makes a `rust_reserved_name` finding an error for every target, not
|
|
26
|
+
# only for those with a Cargo feature under `root`'s `rust/Cargo.toml`.
|
|
27
|
+
# `--profile client` adds `Bluebook::ModelCheck::ClientProfile`'s error findings.
|
|
28
|
+
module ModelCheck
|
|
29
|
+
# The directory the framework's own `.port` and `.adapter` files live under.
|
|
30
|
+
HECKS_DIR = File.expand_path("..", __dir__)
|
|
31
|
+
|
|
32
|
+
# How one run is configured: `strict` and `profile` from the command line,
|
|
33
|
+
# `rust_dir` from the root (nil without one).
|
|
34
|
+
Options = Struct.new(:strict, :profile, :rust_dir)
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Runs the model check `argv` describes and exits with its verdict.
|
|
39
|
+
#
|
|
40
|
+
# @param argv [Array<String>] `--strict`, `--profile NAME` and domain paths
|
|
41
|
+
# @param program [String] the name the usage message calls this command by
|
|
42
|
+
# @param root [String, nil] the repository root, for the corpus sweep and the
|
|
43
|
+
# Rust-target check; nil when there is no checkout
|
|
44
|
+
# @return [void]
|
|
45
|
+
# @raise [SystemExit] always: 0 when clean, 1 with findings or a usage error,
|
|
46
|
+
# 2 for an unknown profile
|
|
47
|
+
def call(argv, program:, root: nil)
|
|
48
|
+
argv = argv.dup
|
|
49
|
+
strict = !argv.delete("--strict").nil?
|
|
50
|
+
profile = take_profile(argv)
|
|
51
|
+
unless profile.nil? || Bluebook::ModelCheck::PROFILES.include?(profile)
|
|
52
|
+
warn "unknown profile #{profile.inspect} (known: #{Bluebook::ModelCheck::PROFILES.join(', ')})"
|
|
53
|
+
exit 2
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
options = Options.new(strict, profile, root && File.join(root, "rust"))
|
|
57
|
+
ok = if argv.any? then check_domains(argv, options)
|
|
58
|
+
elsif root then check_corpus(root, options)
|
|
59
|
+
else abort "usage: #{program} [--strict] [--profile client] <domain> [<domain> …]"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
puts
|
|
63
|
+
puts ok ? "No dead states, no unreachable protocol steps." : "THE MODEL HAS FINDINGS."
|
|
64
|
+
exit(ok ? 0 : 1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Reads `--profile client` or `--profile=client` and removes it from `argv`.
|
|
68
|
+
#
|
|
69
|
+
# @param argv [Array<String>] the arguments, changed in place
|
|
70
|
+
# @return [Symbol, nil] the requested profile, or nil when none was asked for
|
|
71
|
+
def take_profile(argv)
|
|
72
|
+
inline = argv.find { |arg| arg.start_with?("--profile=") }
|
|
73
|
+
index = argv.index("--profile")
|
|
74
|
+
return unless inline || index
|
|
75
|
+
|
|
76
|
+
name = inline ? inline.delete_prefix("--profile=") : argv[index + 1].to_s
|
|
77
|
+
argv.delete(inline) if inline
|
|
78
|
+
argv.slice!(index, 2) if index
|
|
79
|
+
name.to_sym
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Boots each named domain and examines them against one shared set of known
|
|
83
|
+
# domains and emitted events, so a cross-domain reaction between two of them
|
|
84
|
+
# is checked.
|
|
85
|
+
#
|
|
86
|
+
# @param domain_args [Array<String>] domain directory paths
|
|
87
|
+
# @param options [Options] the run's configuration
|
|
88
|
+
# @return [Boolean] true when every domain passes
|
|
89
|
+
def check_domains(domain_args, options)
|
|
90
|
+
booted = domain_args.map do |domain_arg|
|
|
91
|
+
[File.basename(domain_arg.chomp("/")), boot(bluebook_dir(domain_arg.chomp("/")) || domain_arg)]
|
|
92
|
+
end
|
|
93
|
+
examine_all(booted, options)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Boots every corpus member once, then examines each against the set of names
|
|
97
|
+
# every member declares, then examines the language itself.
|
|
98
|
+
#
|
|
99
|
+
# Two passes over the same boots, because a single boot's own registry cannot
|
|
100
|
+
# answer whether a target domain exists anywhere in the corpus.
|
|
101
|
+
#
|
|
102
|
+
# @param root [String] the repository root
|
|
103
|
+
# @param options [Options] the run's configuration
|
|
104
|
+
# @return [Boolean] true when every member and the language pass
|
|
105
|
+
def check_corpus(root, options)
|
|
106
|
+
require_relative "../corpus"
|
|
107
|
+
targets = Corpus.model_check_members(root: root).map { |member| [member.stem, Corpus.source_of(member)] }
|
|
108
|
+
booted = targets.map { |name, source| [name, boot(source)] }
|
|
109
|
+
ok = examine_all(booted, options)
|
|
110
|
+
examine_language(options) && ok
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Boots one source into a fresh registry, with its sibling hecksagons so a
|
|
114
|
+
# policy bound from there is visible too.
|
|
115
|
+
#
|
|
116
|
+
# `root:` is the source's parent: a directory source is already the domain's
|
|
117
|
+
# own `bluebook/` folder, and without a root a member declaring
|
|
118
|
+
# `uses_embryonaut_bluebook` refuses with "needs a registry with a root to
|
|
119
|
+
# vendor from".
|
|
120
|
+
#
|
|
121
|
+
# @param source [String] a directory to boot (every bluebook within), or a
|
|
122
|
+
# single `.bluebook` file path
|
|
123
|
+
# @return [Runtime::Registry] the registry populated with the booted bluebooks
|
|
124
|
+
def boot(source)
|
|
125
|
+
root = File.directory?(source) ? File.dirname(source) : nil
|
|
126
|
+
registry = Runtime::Registry.new(root: root)
|
|
127
|
+
Hecks.with_registry(registry) { load_source(source) }
|
|
128
|
+
registry
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @api private
|
|
132
|
+
def load_source(source)
|
|
133
|
+
Kernel.load(File.join(HECKS_DIR, "ports/persistence.port"))
|
|
134
|
+
Kernel.load(File.join(HECKS_DIR, "ports/extraction.port"))
|
|
135
|
+
Kernel.load(File.join(HECKS_DIR, "adapters/driven/memory.adapter"))
|
|
136
|
+
Kernel.load(File.join(HECKS_DIR, "adapters/driven/prism.adapter"))
|
|
137
|
+
folder = Adapters::Folder.new
|
|
138
|
+
if File.directory?(source)
|
|
139
|
+
folder.load_bluebooks(source)
|
|
140
|
+
else
|
|
141
|
+
folder.load_bluebooks(File.dirname(source), [File.basename(source)])
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A port attaches to its aggregate from the hecksagon, not the bluebook, so a
|
|
145
|
+
# deaf-policy check that never loads it would see a policy reacting to an
|
|
146
|
+
# event nothing appears to emit. A directory loads every `*.hecksagon` in it,
|
|
147
|
+
# as a real boot does. Recording a bind builds IR only, so no adapter needs to
|
|
148
|
+
# resolve here.
|
|
149
|
+
hecksagons = if File.directory?(source)
|
|
150
|
+
Dir.glob(File.join(source, "*.hecksagon"))
|
|
151
|
+
else
|
|
152
|
+
[source.sub(/\.bluebook\z/, ".hecksagon")]
|
|
153
|
+
end
|
|
154
|
+
hecksagons.each { |hecksagon| Kernel.load(hecksagon) if File.exist?(hecksagon) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @api private
|
|
158
|
+
def examine_all(booted, options)
|
|
159
|
+
known_domains = booted.flat_map { |_, registry| registry.bluebooks.keys + registry.hecksagons.keys }.to_set
|
|
160
|
+
global_emitted_events = booted.flat_map do |_, registry|
|
|
161
|
+
registry.bluebooks.values.flat_map { |chapter| Bluebook::ModelCheck.emitted_events(chapter) }
|
|
162
|
+
end.to_set
|
|
163
|
+
|
|
164
|
+
booted.reduce(true) do |all_ok, (name, registry)|
|
|
165
|
+
domain_passes?(name, registry, options,
|
|
166
|
+
known_domains: known_domains, global_emitted_events: global_emitted_events) && all_ok
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Runs `Bluebook::ModelCheck` over one booted domain's chapters, prints its
|
|
171
|
+
# findings against the allowlist, and reports whether it passes.
|
|
172
|
+
#
|
|
173
|
+
# @param name [String] the domain's display name, for the header and the
|
|
174
|
+
# `ALLOWED_FINDINGS` lookup
|
|
175
|
+
# @param registry [Runtime::Registry] a booted registry holding the domain's chapters
|
|
176
|
+
# @param options [Options] the run's configuration
|
|
177
|
+
# @param known_domains [Set<String>] every bluebook and hecksagon name booted this run
|
|
178
|
+
# @param global_emitted_events [Set<String>] every event any booted chapter emits
|
|
179
|
+
# @return [Boolean] false if an allowlist entry is stale; otherwise true when no
|
|
180
|
+
# error-severity finding is left
|
|
181
|
+
def domain_passes?(name, registry, options, known_domains:, global_emitted_events:)
|
|
182
|
+
puts "── #{name}"
|
|
183
|
+
|
|
184
|
+
rust_target = rust_target?(name, options.rust_dir)
|
|
185
|
+
chapters = registry.bluebooks.values
|
|
186
|
+
findings = chapters.flat_map do |chapter|
|
|
187
|
+
Bluebook::ModelCheck.call(chapter, hecksagon: registry.hecksagon(chapter.name), known_domains: known_domains,
|
|
188
|
+
global_emitted_events: global_emitted_events,
|
|
189
|
+
rust_target: rust_target,
|
|
190
|
+
strict: options.strict, profile: options.profile)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
allowed = Bluebook::ModelCheck::ALLOWED_FINDINGS.fetch(name, [])
|
|
194
|
+
matched, findings = findings.partition { |f| allowed.include?([f.kind, f.subject]) }
|
|
195
|
+
matched.each { |f| puts " ALLOWLISTED #{f}" }
|
|
196
|
+
|
|
197
|
+
stale = allowed - matched.map { |f| [f.kind, f.subject] }
|
|
198
|
+
unless stale.empty?
|
|
199
|
+
puts " STALE ALLOWLIST ENTRIES (no longer found — remove them): #{stale.inspect}"
|
|
200
|
+
return false
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
passes?(findings, " clean — #{chapters.size} chapter(s), no dead states, no unreachable protocol steps")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Examines the language's own `Bluebook` and `World` chapters through
|
|
207
|
+
# `MetaValidator.grammar_registry`, the judged graph its conformance specs use,
|
|
208
|
+
# because any one grammar file booted alone is a fraction of the language.
|
|
209
|
+
#
|
|
210
|
+
# @param options [Options] the run's configuration
|
|
211
|
+
# @return [Boolean] true when neither chapter has an error-severity finding
|
|
212
|
+
def examine_language(options)
|
|
213
|
+
ok = true
|
|
214
|
+
["Bluebook", "World"].each do |name|
|
|
215
|
+
chapter = Bluebook::MetaValidator.grammar_registry.bluebook(name)
|
|
216
|
+
next unless chapter
|
|
217
|
+
|
|
218
|
+
puts "── #{name} (the language itself)"
|
|
219
|
+
findings = Bluebook::ModelCheck.call(chapter, strict: options.strict, profile: options.profile)
|
|
220
|
+
ok = passes?(findings, " clean — no dead states, no unreachable protocol steps") && ok
|
|
221
|
+
end
|
|
222
|
+
ok
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @api private
|
|
226
|
+
def passes?(findings, clean_line)
|
|
227
|
+
if findings.empty?
|
|
228
|
+
puts clean_line
|
|
229
|
+
return true
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
errors, warnings = findings.partition { |f| f.severity == :error }
|
|
233
|
+
puts " #{errors.size} error(s), #{warnings.size} warning(s)"
|
|
234
|
+
findings.each { |f| puts " #{f}" }
|
|
235
|
+
errors.empty?
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Says whether the domain has a Cargo feature, which makes a
|
|
239
|
+
# `rust_reserved_name` finding an error for it.
|
|
240
|
+
#
|
|
241
|
+
# The check reads `rust/Cargo.toml` through the repository-only fuzzing
|
|
242
|
+
# tooling, so it is loaded only when there is a `rust/` to read.
|
|
243
|
+
#
|
|
244
|
+
# @param name [String] the domain's display name
|
|
245
|
+
# @param rust_dir [String, nil] the repository's `rust/` directory; nil without a
|
|
246
|
+
# checkout
|
|
247
|
+
# @return [Boolean] true when `rust_dir` declares a feature named after the domain
|
|
248
|
+
def rust_target?(name, rust_dir)
|
|
249
|
+
return false unless rust_dir
|
|
250
|
+
|
|
251
|
+
require_relative "../fuzzing/target_capabilities"
|
|
252
|
+
Fuzzing::TargetCapabilities.rust_feature?(name, rust_dir)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The directory holding a domain's bluebooks: `<domain>/bluebook/` or the
|
|
256
|
+
# domain directory itself, the same rule `Corpus.bluebook_dir` applies.
|
|
257
|
+
#
|
|
258
|
+
# @param domain_path [String] a domain directory path
|
|
259
|
+
# @return [String, nil] the directory of the first `.bluebook` found, or nil when
|
|
260
|
+
# neither place holds one
|
|
261
|
+
def bluebook_dir(domain_path)
|
|
262
|
+
[File.join(domain_path, "bluebook"), domain_path].each do |dir|
|
|
263
|
+
files = Dir[File.join(dir, "*.bluebook")]
|
|
264
|
+
return File.dirname(files.first) unless files.empty?
|
|
265
|
+
end
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "../../hecks"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module CLI
|
|
6
|
+
# The command behind `bin/project_cli` and `hecks project_cli`: writes a
|
|
7
|
+
# command-line launcher beside each domain, named after its bluebook, so
|
|
8
|
+
# `Hecks.bluebook "QualityControl"` in `qa/` becomes `qa/quality_control`.
|
|
9
|
+
#
|
|
10
|
+
# ## A launcher, not a CLI
|
|
11
|
+
#
|
|
12
|
+
# Each file is a dozen lines pinning one directory and handing over to
|
|
13
|
+
# `Facade::CliRunner`. The surface still comes from `Projector.call(:cli, …)` at
|
|
14
|
+
# the moment it runs, so editing a chapter changes the launcher's behaviour
|
|
15
|
+
# without regenerating anything. Re-run this when a domain is added or renamed.
|
|
16
|
+
#
|
|
17
|
+
# ## Beside the domain
|
|
18
|
+
#
|
|
19
|
+
# A domain's command line lives with the domain, so a folder is the whole unit.
|
|
20
|
+
# The launcher boots `__dir__`, and reaches `lib` by counting how deep the domain
|
|
21
|
+
# sits below the root, so no line knows where the checkout was cloned.
|
|
22
|
+
module ProjectCli
|
|
23
|
+
# Build output and other trees under a root that hold no domain a caller means.
|
|
24
|
+
IGNORED = %r{\A(rust|deploy|tmp|coverage)/}
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Writes a launcher for each domain `argv` names, or for every domain under
|
|
29
|
+
# `root`.
|
|
30
|
+
#
|
|
31
|
+
# @param argv [Array<String>] domain paths relative to `root`; empty for all of them
|
|
32
|
+
# @param program [String] the name the generated header credits
|
|
33
|
+
# @param root [String] the directory domains are found under and paths resolve against
|
|
34
|
+
# @param remove_stale_bin [Boolean] whether to delete `root/bin/<name>`, where a
|
|
35
|
+
# launcher for the same chapter would otherwise linger as a second front door
|
|
36
|
+
# @return [void]
|
|
37
|
+
def call(argv, program:, root:, remove_stale_bin: true)
|
|
38
|
+
wanted = argv.empty? ? domains(root) : argv.map { |path| path.delete_prefix("#{root}/").chomp("/") }
|
|
39
|
+
|
|
40
|
+
wanted.each do |path|
|
|
41
|
+
name = bluebook_name(root, path) or next
|
|
42
|
+
snake = Naming.snake(name)
|
|
43
|
+
file = File.join(root, path, snake)
|
|
44
|
+
|
|
45
|
+
File.write(file, launcher(path, name, program))
|
|
46
|
+
FileUtils.chmod("+x", file)
|
|
47
|
+
FileUtils.rm_f(File.join(root, "bin", snake)) if remove_stale_bin
|
|
48
|
+
|
|
49
|
+
puts " #{path}/#{snake} -> #{name}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Finds every domain under `root` a launcher can be written for.
|
|
54
|
+
#
|
|
55
|
+
# @param root [String] the directory to search under
|
|
56
|
+
# @return [Array<String>] domain directory paths relative to `root`,
|
|
57
|
+
# deduplicated and sorted
|
|
58
|
+
def domains(root)
|
|
59
|
+
folder = Adapters::Folder.new
|
|
60
|
+
Dir.glob(File.join(root, "**/*.hecksagon"))
|
|
61
|
+
.map { |path| folder.domain_root(File.dirname(path)) }
|
|
62
|
+
.compact
|
|
63
|
+
.map { |path| path.delete_prefix("#{root}/") }
|
|
64
|
+
.grep_v(IGNORED)
|
|
65
|
+
.uniq.sort
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Boots the domain to read its declared name, which comes from
|
|
69
|
+
# `Hecks.bluebook "…"` rather than the directory, so a domain renamed with
|
|
70
|
+
# `formerly_known_as` gets a launcher under its new name.
|
|
71
|
+
#
|
|
72
|
+
# @param root [String] the directory `path` is relative to
|
|
73
|
+
# @param path [String] a domain directory path
|
|
74
|
+
# @return [String, nil] the domain's declared bluebook name, or nil after warning
|
|
75
|
+
# when it fails to boot
|
|
76
|
+
def bluebook_name(root, path)
|
|
77
|
+
Hecks.boot(File.join(root, path), install_facade: false).registry.bluebooks.keys.first
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
warn " #{path}: cannot boot — #{e.message.lines.first.strip}"
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Renders one domain's launcher.
|
|
84
|
+
#
|
|
85
|
+
# @param path [String] the domain directory path, relative to the root
|
|
86
|
+
# @param name [String] the domain's declared bluebook name
|
|
87
|
+
# @param program [String] the name the generated header credits
|
|
88
|
+
# @return [String] the launcher script's full source
|
|
89
|
+
def launcher(path, name, program)
|
|
90
|
+
snake = Naming.snake(name)
|
|
91
|
+
up = "../" * path.count("/").succ
|
|
92
|
+
|
|
93
|
+
<<~RUBY
|
|
94
|
+
#!/usr/bin/env ruby
|
|
95
|
+
|
|
96
|
+
# #{name}'s command line — GENERATED by #{program}. Do not edit.
|
|
97
|
+
#
|
|
98
|
+
# A launcher, not a CLI: the verbs, their arguments and their refusals are
|
|
99
|
+
# projected from the bluebook beside it every time this runs, so a change
|
|
100
|
+
# to the chapter shows up here without re-minting anything.
|
|
101
|
+
#
|
|
102
|
+
# #{path}/#{snake} every verb, and every question
|
|
103
|
+
# #{path}/#{snake} <verb> --help what it wants, and how it refuses
|
|
104
|
+
|
|
105
|
+
$LOAD_PATH.unshift File.expand_path("#{up}lib", __dir__)
|
|
106
|
+
|
|
107
|
+
require "hecks"
|
|
108
|
+
|
|
109
|
+
runtime = begin
|
|
110
|
+
Hecks.boot(__dir__, install_facade: false)
|
|
111
|
+
rescue StandardError => e
|
|
112
|
+
abort "cannot open #{name}: \#{e.message.lines.first.strip}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
text, status = Hecks::Facade::CliRunner.call(
|
|
116
|
+
runtime: runtime, argv: ARGV, program: "#{path}/#{snake}"
|
|
117
|
+
)
|
|
118
|
+
status.zero? ? puts(text) : abort(text)
|
|
119
|
+
RUBY
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require_relative "../../hecks"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module CLI
|
|
5
|
+
# The command behind `bin/project_diagrams` and `hecks project_diagrams`: boots a
|
|
6
|
+
# domain, finds the chapter it names, and writes its Mermaid diagrams
|
|
7
|
+
# (`Projections::Diagrams`) under `docs/generated/diagrams/<chapter>/` below the
|
|
8
|
+
# root.
|
|
9
|
+
module ProjectDiagrams
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Projects and writes one chapter's diagrams, then lists what was written.
|
|
13
|
+
#
|
|
14
|
+
# @param argv [Array<String>] the domain path, then the chapter name
|
|
15
|
+
# @param program [String] the name the usage message calls this command by
|
|
16
|
+
# @param root [String] the directory `docs/generated/diagrams/` is written under
|
|
17
|
+
# @return [void]
|
|
18
|
+
# @raise [SystemExit] when either argument is missing
|
|
19
|
+
def call(argv, program:, root:)
|
|
20
|
+
usage = "usage: #{program} <domain-path> <ChapterName>"
|
|
21
|
+
domain_path = argv[0] or abort usage
|
|
22
|
+
chapter_name = argv[1] or abort usage
|
|
23
|
+
|
|
24
|
+
runtime = Hecks.boot(File.expand_path(domain_path, Dir.pwd))
|
|
25
|
+
chapter = runtime.registry.bluebook(chapter_name)
|
|
26
|
+
hecksagon = runtime.registry.hecksagon(chapter_name)
|
|
27
|
+
directory = File.expand_path("docs/generated/diagrams/#{Naming.snake(chapter_name)}", root)
|
|
28
|
+
|
|
29
|
+
written = Projector.write(
|
|
30
|
+
Projector.call(:diagrams, bluebook: chapter, options: { hecksagon: hecksagon }),
|
|
31
|
+
directory, as: :files
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if written.empty?
|
|
35
|
+
puts "#{chapter_name} declares no lifecycle, relationship, or dispatch to project yet"
|
|
36
|
+
else
|
|
37
|
+
puts "wrote #{written.size} diagram(s) to #{directory}:"
|
|
38
|
+
written.each { |path| puts " #{path}" }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "../../hecks"
|
|
3
|
+
# ADR 0033 — a domain wired to PostgresEra needs this plugin loaded
|
|
4
|
+
# explicitly; the era/lineage subsystem does not load with core.
|
|
5
|
+
require_relative "../ports/persistence/plugins/era"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module CLI
|
|
9
|
+
# The command behind `bin/run` and `hecks run`: dispatches one verb from the
|
|
10
|
+
# command line, or executes a JSON step list and reports instances, events,
|
|
11
|
+
# refusals, reactions, sagas and query rows.
|
|
12
|
+
#
|
|
13
|
+
# ## Two forms, one argument
|
|
14
|
+
#
|
|
15
|
+
# `run bug.discover reference=BUG#1 severity=high …` needs no JSON. The verb
|
|
16
|
+
# tree, every argument, its declared type and every way the verb can refuse are
|
|
17
|
+
# projected out of the bluebook (`Projector::CliProjector`) and run by
|
|
18
|
+
# `Facade::CliRunner`, so this knows nothing about any domain and its help
|
|
19
|
+
# cannot go stale. The step-list form takes a script path, `-` for stdin, or the
|
|
20
|
+
# JSON itself, which is how the corpus scripts under `spec/corpus/` are run.
|
|
21
|
+
#
|
|
22
|
+
# ## Standing in a domain is enough
|
|
23
|
+
#
|
|
24
|
+
# `Adapters::Folder#domain_root` walks up for the nearest `.hecksagon`, so inside
|
|
25
|
+
# a domain the path can be left off. The first argument is a domain only when it
|
|
26
|
+
# is a directory.
|
|
27
|
+
module Run
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Runs `argv` as a verb invocation or a step list against a domain.
|
|
31
|
+
#
|
|
32
|
+
# @param argv [Array<String>] the arguments: an optional domain directory, then
|
|
33
|
+
# a verb with `name=value` pairs, a script path, `-`, or inline JSON
|
|
34
|
+
# @param program [String] the name usage and help messages call this command by
|
|
35
|
+
# @return [void]
|
|
36
|
+
# @raise [SystemExit] on any refusal, usage error or unmet expectation, and
|
|
37
|
+
# after the command-line form answers
|
|
38
|
+
def call(argv, program:)
|
|
39
|
+
usage = "usage: #{program} [domain] <verb [name=value …] | script.json | - | '{\"steps\":[…]}'>"
|
|
40
|
+
argv = argv.dup
|
|
41
|
+
here = Adapters::Folder.new.domain_root
|
|
42
|
+
domain = argv.first && File.directory?(argv.first) ? argv.shift : here
|
|
43
|
+
abort "no bluebook here — #{Dir.pwd} is not inside a domain. #{usage}" unless domain
|
|
44
|
+
|
|
45
|
+
# The command-line form answers unless the first argument is script-shaped,
|
|
46
|
+
# in which case the step-list form takes over.
|
|
47
|
+
cli_form(domain, argv, program) unless argv.first && script_shaped?(argv.first)
|
|
48
|
+
|
|
49
|
+
script = argv.first or abort usage
|
|
50
|
+
document = parse(read_source(script))
|
|
51
|
+
steps = document["steps"] or abort %(the script has no "steps" — #{usage})
|
|
52
|
+
report = execute(Hecks.boot(domain), steps)
|
|
53
|
+
|
|
54
|
+
# The run is reported before it is judged: an expectation that fails says
|
|
55
|
+
# what was wanted and never what happened, so the evidence goes to stdout
|
|
56
|
+
# first and every unmet expectation is collected rather than aborted on.
|
|
57
|
+
puts JSON.pretty_generate(report)
|
|
58
|
+
unmet = unmet_expectations(document["expectations"] || {}, report)
|
|
59
|
+
abort unmet.join("\n") unless unmet.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Boots `domain` and dispatches `argv` as a projected verb invocation, printing
|
|
63
|
+
# the result and exiting the process.
|
|
64
|
+
#
|
|
65
|
+
# **No fall-through**. This is reached only when the argument is not
|
|
66
|
+
# script-shaped, so a near miss deserves the runner's "did you mean" rather
|
|
67
|
+
# than the step-list form's "no such script".
|
|
68
|
+
#
|
|
69
|
+
# @param domain [String] the domain's directory path
|
|
70
|
+
# @param argv [Array<String>] the arguments after the domain, e.g.
|
|
71
|
+
# `["bug.discover", "reference=BUG#1"]`
|
|
72
|
+
# @param program [String] the name the runner's help calls this command by
|
|
73
|
+
# @return [void]
|
|
74
|
+
# @raise [SystemExit] always
|
|
75
|
+
def cli_form(domain, argv, program)
|
|
76
|
+
runtime = Hecks.boot(domain, install_facade: false)
|
|
77
|
+
text, status = Facade::CliRunner.call(runtime: runtime, argv: argv, program: program)
|
|
78
|
+
status.zero? ? puts(text) : abort(text)
|
|
79
|
+
exit 0
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Says whether `arg` is a step list rather than a verb: stdin, inline JSON, or
|
|
83
|
+
# a file that exists.
|
|
84
|
+
#
|
|
85
|
+
# @param arg [String] the first argument after the domain
|
|
86
|
+
# @return [Boolean] true when `arg` is script-shaped
|
|
87
|
+
def script_shaped?(arg) = arg == "-" || arg.match?(/\A\s*\{/) || File.file?(arg)
|
|
88
|
+
|
|
89
|
+
# Reads the step list from stdin, from the argument itself, or from a file,
|
|
90
|
+
# decided by looking, because a script beginning with `{` is not a filename
|
|
91
|
+
# anybody meant.
|
|
92
|
+
#
|
|
93
|
+
# @param script [String] `-`, inline JSON, or a file path
|
|
94
|
+
# @return [String] the script's JSON text
|
|
95
|
+
# @raise [SystemExit] when `script` names no file
|
|
96
|
+
def read_source(script)
|
|
97
|
+
case script
|
|
98
|
+
when "-" then $stdin.read
|
|
99
|
+
when /\A\s*\{/ then script
|
|
100
|
+
else
|
|
101
|
+
File.exist?(script) ? File.read(script) : abort("no such script: #{script}")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Parses the step list before anything boots, because booting opens a real
|
|
106
|
+
# store and a typo in the JSON should cost a sentence, not a connection.
|
|
107
|
+
#
|
|
108
|
+
# @param source [String] the script's JSON text
|
|
109
|
+
# @return [Hash{String => Object}] the parsed script
|
|
110
|
+
# @raise [SystemExit] when `source` is not JSON
|
|
111
|
+
def parse(source)
|
|
112
|
+
JSON.parse(source)
|
|
113
|
+
rescue JSON::ParserError => e
|
|
114
|
+
abort "that is not JSON: #{e.message.lines.first.strip}"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Runs every step and gathers what the runtime holds afterwards.
|
|
118
|
+
#
|
|
119
|
+
# @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted domain
|
|
120
|
+
# @param steps [Array<Hash{String => Object}>] the script's steps, each a
|
|
121
|
+
# `"verb"` or a `"query"` with optional `"args"`
|
|
122
|
+
# @return [Hash{Symbol => Object}] `:instances`, `:events`, `:refusals`,
|
|
123
|
+
# `:reactions`, `:sagas` and `:queries`, in the order they are printed
|
|
124
|
+
def execute(runtime, steps)
|
|
125
|
+
refusals = []
|
|
126
|
+
queries = []
|
|
127
|
+
steps.each { |step| run_step(runtime, step, refusals, queries) }
|
|
128
|
+
|
|
129
|
+
{ instances: instances(runtime), events: events(runtime), refusals: refusals,
|
|
130
|
+
reactions: runtime.reactions, sagas: runtime.sagas, queries: queries }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @api private
|
|
134
|
+
def run_step(runtime, step, refusals, queries)
|
|
135
|
+
args = (step["args"] || {}).transform_keys(&:to_sym)
|
|
136
|
+
if (question = step["query"])
|
|
137
|
+
queries << ask(runtime, question, args)
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
verb = step["verb"]
|
|
142
|
+
begin
|
|
143
|
+
runtime.dispatch_flat(verb, args)
|
|
144
|
+
rescue StandardError => e
|
|
145
|
+
refusals << { verb: verb, error: e.message.to_s }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# @api private
|
|
150
|
+
def ask(runtime, question, args)
|
|
151
|
+
{ query: question, args: args, rows: runtime.query(question, **args) }
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
{ query: question, args: args, error: e.message }
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# @api private
|
|
157
|
+
def instances(runtime)
|
|
158
|
+
runtime.registry.bluebooks.each_with_object({}) do |(domain_name, bluebook), all|
|
|
159
|
+
bluebook.aggregates.each do |aggregate|
|
|
160
|
+
runtime.registry.repository(domain_name, aggregate).all.each do |record|
|
|
161
|
+
all["#{domain_name}::#{aggregate.name}##{record.id}"] = record.state
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# @api private
|
|
168
|
+
def events(runtime)
|
|
169
|
+
runtime.events.map do |event|
|
|
170
|
+
{ name: event.name, aggregate: event.aggregate, id: event.id, payload: event.payload }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Checks the script's `"expectations"` against what the run produced.
|
|
175
|
+
#
|
|
176
|
+
# @param expectations [Hash{String => Object}] the script's `"event_names"`,
|
|
177
|
+
# `"refusals"` and `"instances"` expectations; `{}` when it declares none
|
|
178
|
+
# @param report [Hash{Symbol => Object}] what `execute` answered
|
|
179
|
+
# @return [Array<String>] one message per unmet expectation; empty when every
|
|
180
|
+
# one held
|
|
181
|
+
def unmet_expectations(expectations, report)
|
|
182
|
+
missing_events = Array(expectations["event_names"]) - report[:events].map { |event| event[:name] }
|
|
183
|
+
unmet = []
|
|
184
|
+
unmet << "matrix expected events missing: #{missing_events.join(', ')}" unless missing_events.empty?
|
|
185
|
+
unmet.concat(unmet_refusals(Array(expectations["refusals"]), report[:refusals]))
|
|
186
|
+
unmet.concat(unmet_instances(expectations["instances"] || {}, report[:instances]))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @api private
|
|
190
|
+
def unmet_refusals(expected_refusals, refusals)
|
|
191
|
+
expected_refusals.filter_map do |expected|
|
|
192
|
+
verb = expected.fetch("verb")
|
|
193
|
+
matched = refusals.any? do |refusal|
|
|
194
|
+
refusal[:verb] == verb && refusal[:error].include?(expected.fetch("includes"))
|
|
195
|
+
end
|
|
196
|
+
next if matched
|
|
197
|
+
|
|
198
|
+
# A refusal that changed its words is a different story from one that never
|
|
199
|
+
# happened, so the actual errors are printed beside the wanted one.
|
|
200
|
+
said = refusals.select { |refusal| refusal[:verb] == verb }.map { |refusal| refusal[:error] }.uniq
|
|
201
|
+
"matrix expected refusal missing: #{expected}\n " \
|
|
202
|
+
"#{verb} actually refused with: #{said.empty? ? '(nothing — every attempt was accepted)' : said.inspect}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @api private
|
|
207
|
+
def unmet_instances(expected_instances, instances)
|
|
208
|
+
expected_instances.flat_map do |key, fields|
|
|
209
|
+
actual = instances[key] || {}
|
|
210
|
+
fields.filter_map do |field, value|
|
|
211
|
+
next if actual[field.to_sym] == value
|
|
212
|
+
|
|
213
|
+
"matrix expected #{key}.#{field}=#{value.inspect}, got #{actual[field.to_sym].inspect}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|