hecks 2.6.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.
Files changed (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. 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