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,87 @@
1
+ require_relative "commands"
2
+ require_relative "git"
3
+
4
+ module Hecks
5
+ module Release
6
+ class Runner
7
+ # Makes sure the release tag `vX.Y.Z` is an annotated tag on the release
8
+ # commit and is on origin, creating and pushing it only when it is missing.
9
+ class Tagger
10
+ # @param git [Git] the repository the release is cut from
11
+ # @param console [Console] asks before the tag is created or pushed
12
+ # @param dry_run [Boolean] report what would happen and change nothing
13
+ def initialize(git:, console:, dry_run:)
14
+ @git = git
15
+ @console = console
16
+ @dry_run = dry_run
17
+ end
18
+
19
+ # Brings the tag to the state described above, saying what it did.
20
+ #
21
+ # @param facts [Preflight::Facts] the version and commit being released
22
+ # @return [Boolean] false when the person declined the question, true otherwise
23
+ # @raise [Refusal] if the tag already points at another commit, locally or on origin
24
+ # @raise [CommandFailed] if creating or pushing the tag fails
25
+ def ensure!(facts)
26
+ tag = "v#{facts.version}"
27
+ local = local_commit(tag)
28
+ remote = remote_commit(tag)
29
+ refuse_elsewhere(tag, facts.sha, local: local, remote: remote)
30
+ return true.tap { already_there(tag) } if remote
31
+ return true.tap { preview(tag, facts.sha, local) } if @dry_run
32
+ return false unless @console.confirm?(question(tag, facts.sha, local))
33
+
34
+ create(tag, facts.sha) unless local
35
+ push(tag)
36
+ true
37
+ end
38
+
39
+ private
40
+
41
+ def already_there(tag)
42
+ @console.say("Tag #{tag} is already on origin at the release commit; skipping.")
43
+ end
44
+
45
+ def preview(tag, sha, local)
46
+ @console.say("Would create annotated tag #{tag} at #{sha[0, 7]}.") unless local
47
+ @console.say("Would push #{tag} to origin.")
48
+ end
49
+
50
+ def question(tag, sha, local)
51
+ verb = local ? "Push" : "Create and push"
52
+ "#{verb} annotated tag #{tag} at #{sha[0, 7]} to origin?"
53
+ end
54
+
55
+ def create(tag, sha)
56
+ @git.run!("tag", "-a", tag, "-m", "Release #{tag.delete_prefix('v')}", sha)
57
+ end
58
+
59
+ def push(tag)
60
+ @git.run!("push", "origin", tag)
61
+ end
62
+
63
+ def refuse_elsewhere(tag, sha, local:, remote:)
64
+ { "locally" => local, "on origin" => remote }.each do |where, commit|
65
+ next if commit.nil? || commit == sha
66
+
67
+ raise Refusal, "tag #{tag} already points at #{commit[0, 7]} #{where}, not at the release commit " \
68
+ "#{sha[0, 7]}; if that tag was never released, delete it (git tag -d #{tag}; " \
69
+ "git push origin :refs/tags/#{tag}) and re-run, otherwise main has moved since the release " \
70
+ "and the missing package must be published from a checkout of #{tag}"
71
+ end
72
+ end
73
+
74
+ def local_commit(tag)
75
+ result = @git.capture("rev-parse", "-q", "--verify", "refs/tags/#{tag}^{commit}")
76
+ result.success? ? result.stdout.strip : nil
77
+ end
78
+
79
+ def remote_commit(tag)
80
+ listing = @git.read("ls-remote", "--tags", "origin", "refs/tags/#{tag}", "refs/tags/#{tag}^{}")
81
+ shas = listing.lines.to_h { |line| line.split.then { |sha, ref| [ref, sha] } }
82
+ shas.fetch("refs/tags/#{tag}^{}") { shas["refs/tags/#{tag}"] }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,244 @@
1
+ require_relative "runner/commands"
2
+ require_relative "runner/console"
3
+ require_relative "runner/git"
4
+ require_relative "runner/preflight"
5
+ require_relative "runner/published"
6
+ require_relative "runner/tagger"
7
+ require_relative "runner/gem_publisher"
8
+ require_relative "runner/npm_publisher"
9
+ require_relative "runner/ci_publisher"
10
+
11
+ module Hecks
12
+ module Release
13
+ # Performs the release once the release PR has merged: tags the merge
14
+ # commit, publishes the gem to rubygems.org and gets the JavaScript client
15
+ # published to npm.
16
+ #
17
+ # ## Why it exists
18
+ #
19
+ # A release is four steps that have to happen in one order, and only
20
+ # the contributing guide remembered it. This runs them in that order, refuses to
21
+ # start from a checkout that is not the merged `main`, and can be run again:
22
+ # it asks the registries what is already published and skips it, so a run
23
+ # that died between the gem and the package resumes at the package.
24
+ #
25
+ # ## Steps
26
+ #
27
+ # 1. Preflight: tools installed, on a clean `main` equal to `origin/main`,
28
+ # the gem and the client at one version, the changelog naming it.
29
+ # 2. Published state: which of the gem and the package are already out.
30
+ # 3. Tag: an annotated `vX.Y.Z` on the release commit, pushed to origin.
31
+ # 4. Gem: `bin/release_gem`, unchanged.
32
+ # 5. npm, one of two ways:
33
+ # - by default CI publishes: the tag triggers `publish-client.yml`, which
34
+ # uses npm trusted publishing, and this waits for the version to appear
35
+ # (`--no-wait` skips the wait);
36
+ # - with `--npm-local` this publishes from the machine, with a
37
+ # bypass-two-factor token 1Password holds: the way to make the first
38
+ # publish, before trusted publishing can be configured, and the fallback
39
+ # when CI is down.
40
+ #
41
+ # ## Injectable commands and time
42
+ #
43
+ # Every process is started through `commands`, any object answering
44
+ # `capture(*argv, env:, chdir:)` and `run!(*argv, env:, chdir:)` like
45
+ # {Commands}, so a spec drives the whole release with a recorder. The wait for
46
+ # CI takes its clock and its pause as callables for the same reason.
47
+ class Runner
48
+ # Which steps to run and how.
49
+ class Options
50
+ # @return [Boolean] run every check and build, but tag, push and publish nothing
51
+ attr_reader :dry_run
52
+
53
+ # @param dry_run [Boolean] run every check and build, but tag, push and publish nothing
54
+ # @param gem_only [Boolean] publish the gem, not the package
55
+ # @param npm_only [Boolean] do the npm step only, not the gem: wait for CI's publish, or
56
+ # with `npm_local` publish from here
57
+ # @param npm_local [Boolean] publish the package from this machine, not by CI
58
+ # @param no_wait [Boolean] do not wait for CI's publish to reach npm
59
+ # @param yes [Boolean] answer every confirmation yes
60
+ # @raise [ArgumentError] if the flags contradict each other
61
+ def initialize(dry_run: false, gem_only: false, npm_only: false, npm_local: false, no_wait: false, yes: false)
62
+ check_flags(gem_only, npm_only, npm_local, no_wait)
63
+
64
+ @dry_run = dry_run
65
+ @gem_only = gem_only
66
+ @npm_only = npm_only
67
+ @npm_local = npm_local
68
+ @no_wait = no_wait
69
+ @yes = yes
70
+ end
71
+
72
+ # Says whether confirmations are answered automatically.
73
+ #
74
+ # @return [Boolean] true under `--yes`
75
+ def yes?
76
+ @yes
77
+ end
78
+
79
+ # Says whether the gem step is in scope.
80
+ #
81
+ # @return [Boolean] true unless `--npm-only`
82
+ def gem?
83
+ !@npm_only
84
+ end
85
+
86
+ # Says whether the npm step is in scope.
87
+ #
88
+ # @return [Boolean] true unless `--gem-only`
89
+ def npm?
90
+ !@gem_only
91
+ end
92
+
93
+ # Says whether the package is published from this machine.
94
+ #
95
+ # @return [Boolean] true under `--npm-local`
96
+ def npm_local?
97
+ @npm_local
98
+ end
99
+
100
+ # Says whether to wait for CI's publish to reach npm.
101
+ #
102
+ # @return [Boolean] true unless `--no-wait`
103
+ def wait?
104
+ !@no_wait
105
+ end
106
+
107
+ private
108
+
109
+ def check_flags(gem_only, npm_only, npm_local, no_wait)
110
+ raise ArgumentError, "--gem-only and --npm-only cannot be combined" if gem_only && npm_only
111
+ raise ArgumentError, "--gem-only and --npm-local cannot be combined" if gem_only && npm_local
112
+ return unless no_wait && npm_local
113
+
114
+ raise ArgumentError, "--no-wait applies to CI's publish, so it cannot be combined with --npm-local"
115
+ end
116
+ end
117
+
118
+ # @param root [String] the repository root
119
+ # @param options [Options] which steps to run and how
120
+ # @param commands [#capture, #run!] starts every process the release runs
121
+ # @param input [IO] where confirmations are read from
122
+ # @param out [IO] where progress is written
123
+ # @param err [IO] where refusals and failures are written
124
+ # @param pause [#call] sleeps for a number of seconds while waiting for CI
125
+ # @param now [#call] answers the current time in seconds, monotonically, while waiting for CI
126
+ def initialize(root:, options: Options.new, commands: Commands.new, input: $stdin, out: $stdout, err: $stderr,
127
+ pause: ->(seconds) { sleep(seconds) }, now: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
128
+ @root = root
129
+ @options = options
130
+ @commands = commands
131
+ @console = Console.new(input: input, out: out, err: err, assume_yes: options.yes?)
132
+ @git = Git.new(root: root, commands: commands)
133
+ @published = Published.new(root: root, commands: commands)
134
+ @ci = CiPublisher.new(root: root, published: @published, console: @console, pause: pause, now: now)
135
+ end
136
+
137
+ # Runs the release.
138
+ #
139
+ # @return [Integer] 0 when the release finished or had nothing to do, 1 when a check refused,
140
+ # a step failed, CI's publish did not arrive in time or a confirmation was declined
141
+ def call
142
+ facts = Preflight.new(root: @root, commands: @commands, git: @git, tools: tools).check!
143
+ pending = pending_steps(facts.version)
144
+ require_workflow(pending)
145
+ return declined unless Tagger.new(git: @git, console: @console, dry_run: @options.dry_run).ensure!(facts)
146
+
147
+ publish(facts, pending)
148
+ rescue Refusal, CommandFailed => e
149
+ @console.warn(e.message)
150
+ 1
151
+ end
152
+
153
+ private
154
+
155
+ def tools
156
+ list = %w[git curl]
157
+ list << "gem" if @options.gem?
158
+ list << "npm" if @options.npm?
159
+ list << "op" if !@options.dry_run && (@options.gem? || @options.npm_local?)
160
+ list
161
+ end
162
+
163
+ def pending_steps(version)
164
+ [
165
+ (:gem if @options.gem? && !done?("hecks #{version} is already on rubygems.org", @published.gem?(version))),
166
+ (:npm if @options.npm? && !done?("@hecks/client #{version} is already on npm", @published.npm?(version)))
167
+ ].compact
168
+ end
169
+
170
+ def done?(message, published)
171
+ @console.say("#{message}; skipping.") if published
172
+ published
173
+ end
174
+
175
+ def ci_publishes?(pending)
176
+ pending.include?(:npm) && !@options.npm_local?
177
+ end
178
+
179
+ def require_workflow(pending)
180
+ return unless ci_publishes?(pending) && !@ci.workflow?
181
+
182
+ raise Refusal, "#{CiPublisher::WORKFLOW} is not in this checkout, so CI cannot publish @hecks/client; " \
183
+ "publish it from here with --npm-local (the first publish, before trusted publishing is set up)"
184
+ end
185
+
186
+ def publish(facts, pending)
187
+ if pending.empty?
188
+ @console.say("Nothing to publish for #{facts.version}.")
189
+ return 0
190
+ end
191
+ return declined unless @options.dry_run || publish_confirmed?(facts.version, pending)
192
+
193
+ run_steps(facts.version, pending)
194
+ end
195
+
196
+ def publish_confirmed?(version, pending)
197
+ targets = []
198
+ targets << "hecks #{version} to rubygems.org" if pending.include?(:gem)
199
+ targets << "@hecks/client #{version} to npm" if pending.include?(:npm) && @options.npm_local?
200
+ return true if targets.empty?
201
+
202
+ suffix = ci_publishes?(pending) ? " (CI then publishes @hecks/client from the tag)" : ""
203
+ @console.confirm?("Publish #{targets.join(' and ')}#{suffix}? This cannot be undone.")
204
+ end
205
+
206
+ def run_steps(version, pending)
207
+ dry_run = @options.dry_run
208
+ publish_gem(version, dry_run) if pending.include?(:gem)
209
+ return 1 if pending.include?(:npm) && !publish_npm!(version, dry_run, ci_publishes?(pending))
210
+
211
+ @console.say(dry_run ? "Dry run complete; nothing was tagged, pushed or published." : "Released hecks #{version}.")
212
+ 0
213
+ end
214
+
215
+ def publish_gem(version, dry_run)
216
+ GemPublisher.new(root: @root, commands: @commands, console: @console).publish!(version, dry_run: dry_run)
217
+ end
218
+
219
+ def publish_npm!(version, dry_run, via_ci)
220
+ return wait_for_ci!(version, dry_run) if via_ci
221
+
222
+ NpmPublisher.new(root: @root, commands: @commands, console: @console).publish!(version, dry_run: dry_run)
223
+ true
224
+ rescue CommandFailed => e
225
+ @console.warn("npm publish failed: #{e.message}")
226
+ unless dry_run
227
+ @console.warn("Finish the release with: bin/release --npm-only --npm-local (published steps are not repeated)")
228
+ end
229
+ raise
230
+ end
231
+
232
+ def wait_for_ci!(version, dry_run)
233
+ arrived = @ci.wait!(version, dry_run: dry_run, wait: @options.wait?)
234
+ @console.warn(CiPublisher.timeout_hint(version)) unless arrived
235
+ arrived
236
+ end
237
+
238
+ def declined
239
+ @console.warn("Aborted; nothing was published.")
240
+ 1
241
+ end
242
+ end
243
+ end
244
+ end
@@ -140,7 +140,7 @@ module Hecks
140
140
  .each do |(aggregate, verb), candidates|
141
141
  # Bounded chapters wrap in their own module (`Domain::Aggregate`)
142
142
  # so two BCs can both declare `Person` without colliding on
143
- # Object::Person. Folder-spread files of the SAME chapter still
143
+ # Object::Person. Folder-spread files of the same chapter still
144
144
  # get the shortcut — they are not BCs.
145
145
  next if bounded_chapter?(candidates)
146
146
 
@@ -106,7 +106,7 @@ module Hecks
106
106
  ctx.plan = DependencyPlanning::Analyzer.call(aggregate: aggregate, command: command)
107
107
  # **Resolved here, once, before hydration** — `Registry#repository`
108
108
  # memoizes, so this and `step_hydrate`'s own read of `ctx.repository`
109
- # (no second fetch there any more) always name the same instance;
109
+ # (no second fetch there) always name the same instance;
110
110
  # the isolation decision below (lock vs. CAS+retry) needs the
111
111
  # repository's capabilities before a single step runs.
112
112
  ctx.repository = @registry.repository(domain, aggregate)
@@ -154,7 +154,7 @@ module Hecks
154
154
 
155
155
  def step_hydrate(ctx)
156
156
  # `ctx.repository` is resolved once, in `#call`, before the
157
- # isolation decision (lock vs. CAS+retry) — not here any more.
157
+ # isolation decision (lock vs. CAS+retry) — not here.
158
158
  ctx.strategy = ctx.plan.strategy_for(capabilities: ctx.repository.capabilities)
159
159
  ctx.instance = step(:hydrate) do
160
160
  if ctx.plan.complete_state? && ctx.plan.state_independent?
@@ -543,7 +543,7 @@ module Hecks
543
543
  # Transitional compatibility for live source that has not yet
544
544
  # acquired explicit effects, same as it always was — deliberately
545
545
  # isolated from the normal routing and planning path so
546
- # `reference_to` no longer chooses how a migrated command hydrates
546
+ # `reference_to` does not choose how a migrated command hydrates
547
547
  # or persists. A real, future Wave 8 removes this once that wider
548
548
  # inventory is empty, not before.
549
549
  #
@@ -163,7 +163,7 @@ module Hecks
163
163
  def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil, correction: {})
164
164
  state = GuardState.new(subject)
165
165
  # A rule may only read within its own aggregate boundary (S12,
166
- # ADR 0025) — `subject`'s own stored references are no longer
166
+ # ADR 0025) — `subject`'s own stored references are not
167
167
  # dereferenced here at all: a `projects :customer_status, from:
168
168
  # :"customer.status"` field is just `subject`'s own
169
169
  # field, a regular stored attribute, already present in
@@ -375,7 +375,7 @@ module Hecks
375
375
  def enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {})
376
376
  state = GuardState.new(subject)
377
377
  # S12, ADR 0025 — same boundary reasoning as enforce_givens
378
- # above: `subject`'s own stored references are no longer
378
+ # above: `subject`'s own stored references are not
379
379
  # dereferenced here; a `projects`-maintained field is already
380
380
  # part of `state`. `command`/`args` still dereferences — a
381
381
  # fresh reference-typed argument stays in bounds.
@@ -123,7 +123,7 @@ module Hecks
123
123
  amount = unwrap_single_numeric_field(amount) if amount.is_a?(Value)
124
124
 
125
125
  # Widened from Integer to Numeric (migration plan task 4, i106):
126
- # miette's organ math increments a Float (`increment: 0.02`) --
126
+ # an external project's organ math increments a Float (`increment: 0.02`) --
127
127
  # the raw, non-value-object path only ever mattered for Integer
128
128
  # counters before this corpus existed. Integer stays the common
129
129
  # case; Float is now accepted the same way.
@@ -192,7 +192,7 @@ module Hecks
192
192
  amount_fields = amount.to_h
193
193
  # Widened from Integer to Numeric -- see #arithmetic's own
194
194
  # comment. A synthesised value-object wrapper around a bare
195
- # Float attribute (miette's Synapse#strength, auto-wrapped per
195
+ # Float attribute (an external project's Synapse#strength, auto-wrapped per
196
196
  # Part 3a's "bare primitives forbidden" finding) lands here as
197
197
  # a one-Float-field Value exactly the way a one-Integer-field
198
198
  # Value already did.
@@ -89,7 +89,7 @@ module Hecks
89
89
  # matches by the element's own minted identity string directly instead
90
90
  # (`element_identity`, below) — the routing envelope already resolved
91
91
  # which element it means, so re-deriving `wants` from `args` would be
92
- # redundant at best and wrong if `args` no longer carries that identity
92
+ # redundant at best and wrong if `args` does not carry that identity
93
93
  # at all.
94
94
  # Locate, then copy-before-mutate, in that order — see the "one
95
95
  # level deeper" comment below on why the copy has to happen exactly
@@ -251,7 +251,7 @@ module Hecks
251
251
 
252
252
  def step_hydrate_parent(ctx)
253
253
  # `ctx.repository` is resolved once, in `#call`, before the
254
- # isolation decision — not here any more.
254
+ # isolation decision — not here.
255
255
  ctx.instance = step(:hydrate_parent) do
256
256
  parent(ctx.repository, ctx.aggregate, ctx.entity_name, ctx.command_name, ctx.args, ctx.route)
257
257
  end
@@ -12,7 +12,7 @@ module Hecks
12
12
  # vs the flat facts hash `dispatch_flat` carries, a port operation's
13
13
  # reference attribute
14
14
  # lifted into `to:`). `Runtime::Routing.envelope`/`.payload` delegate here
15
- # and no longer hold that logic themselves.
15
+ # and hold none of that logic themselves.
16
16
  #
17
17
  # `facts` maps a fact name to exactly one of:
18
18
  #
@@ -177,10 +177,11 @@ module Hecks
177
177
  # (conservative — see `SagaPersistence#rehydrate_sagas!`'s own
178
178
  # comment).
179
179
  #
180
- # ADR 0033 — this loader no longer names `EraCheck`, or any other
180
+ # ADR 0033 — this loader does not name `EraCheck`, or any other
181
181
  # era-specific class, at all. Every loaded persistence plugin
182
182
  # (`Ports::Persistence.each_plugin` — nothing here if nothing was
183
- # ever `require`d) is asked to contribute its own `:pre_verify`/
183
+ # ever `require`d, or brought in by a bound adapter, see
184
+ # `load_bound_adapters!`) is asked to contribute its own `:pre_verify`/
184
185
  # `:post_verify` gates generically; `:saga_rehydration` is the one
185
186
  # gate core still registers directly, because ADR 0031 already
186
187
  # proved it's not era-specific.
@@ -193,6 +194,7 @@ module Hecks
193
194
  # a wiring problem
194
195
  def self.run_boot_gates!(registry, directory)
195
196
  gates = BootGates.new
197
+ load_bound_adapters!(registry)
196
198
  Ports::Persistence.each_plugin { |plugin| plugin.contribute_boot_gates(registry, gates) }
197
199
  check_compute_rules_backstop!(registry)
198
200
 
@@ -200,11 +202,33 @@ module Hecks
200
202
  registry.verify!
201
203
 
202
204
  gates.register(:saga_rehydration, ->(reg, _dir) { reg.rehydrate_sagas! }, phase: :post_verify) if
203
- registry.hecksagons.each_key.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
205
+ registry.saga_domains.any? { |domain| registry.saga_persistence(domain) != Ports::Persistence::NULL_SAGA_STORE }
204
206
  gates.run!(:post_verify, registry, directory)
205
207
  gates
206
208
  end
207
209
 
210
+ # Resolves the Ruby implementation of every adapter a hecksagon binds.
211
+ #
212
+ # An adapter whose implementation loads on first mention (`PostgresEra`,
213
+ # through the era plugin) registers its persistence plugin as a side effect
214
+ # of that load. Resolving here, before the gates are collected, is what makes
215
+ # a domain that declares `persisted_by "PostgresEra"` boot with the plugin
216
+ # loaded and its era gates registered, without the app requiring the plugin
217
+ # itself. An adapter with no Ruby implementation is left for `verify!`, which
218
+ # refuses it with the bind it came from.
219
+ #
220
+ # @param registry [Runtime::Registry] the registry mid-boot, whose hecksagons name the binds
221
+ # @return [void]
222
+ def self.load_bound_adapters!(registry)
223
+ registry.hecksagons.each_value do |hexagon|
224
+ hexagon.binds.each do |bind|
225
+ registry.adapter_class(bind.adapter)
226
+ rescue WiringError
227
+ next
228
+ end
229
+ end
230
+ end
231
+
208
232
  # The one piece of the old, era-owned `check_compute_rules!` core
209
233
  # still carries — deliberately thinner. `registry.translations` is
210
234
  # plain `Bluebook::Translation`/`TranslationAggregate`/
@@ -398,7 +398,7 @@ module Hecks
398
398
  # A DOMAIN_REFUSAL never reaches here — PolicyInterpreter and
399
399
  # SagaInterpreter both rescue it as a recorded, undelivered
400
400
  # reaction. Anything that does reach here is a defect in the
401
- # relay's own path (a consumer that no longer exists, an
401
+ # relay's own path (a consumer that does not exist, an
402
402
  # adapter that raised outside the interpreters' own rescue).
403
403
  repository.outbox_settle(row.id, status: "failed", error: "#{e.class}: #{e.message}")
404
404
  row.status = "failed"
@@ -271,9 +271,9 @@ module Hecks
271
271
  # aggregate, never one of its entities). An entity target's own identity
272
272
  # is resolved entirely separately, from `args` (`build`'s own `entity_
273
273
  # identities` loop) — this method is never consulted for it, so
274
- # answering for an entity target does not "invent an entity identity"
275
- # any more than answering for a plain one invents that identity; it was
276
- # already the one thing this method has ever supplied. `build`'s own
274
+ # answering for an entity target does not "invent an entity identity",
275
+ # just as answering for a plain one invents none; it is
276
+ # the one thing this method supplies. `build`'s own
277
277
  # explicit branch (below) already applies this correctly either way
278
278
  # (`aggregate_identity ||= inherited_receiver`, entity identities read
279
279
  # from `args` regardless) — the caller that actually needed a guard
@@ -33,6 +33,8 @@ module Hecks
33
33
  # @raise [KeyError] if a rooted read model is asked without its reference argument
34
34
  # @raise [ArgumentError] if `group_by` or `median` names a field its target
35
35
  # aggregate does not declare
36
+ # @raise [Runtime::InvariantViolation] if two rows reach the same full `group_by`
37
+ # key path and that path does not cover the grouped aggregate's whole identity
36
38
  # @raise [Runtime::WiringError] if the aggregate's repository cannot be resolved
37
39
  def call(domain, model, args)
38
40
  project(domain, model, args)
@@ -148,7 +150,8 @@ module Hecks
148
150
  reduced_head = aggregation_target(model, bluebook)
149
151
  [heads.each_with_object({}) do |(as, value), out|
150
152
  out[as] = if grouped_head && as == grouped_head[:as]
151
- nest(value.map { |record| Value.materialize_unwrapped(row(record)) }, model.group_by_fields)
153
+ nest(value.map { |record| Value.materialize_unwrapped(row(record)) }, model.group_by_fields,
154
+ collision_check(model, bluebook, grouped_head))
152
155
  elsif reduced_head && as == reduced_head[:as] && model.count?
153
156
  value.length
154
157
  elsif reduced_head && as == reduced_head[:as]
@@ -267,28 +270,47 @@ module Hecks
267
270
  target
268
271
  end
269
272
 
273
+ # The read model whose `group_by` leaves must each hold one row, or nil
274
+ # when its key path covers the grouped aggregate's whole identity and so
275
+ # cannot collide (ADR 0061, decision D1). Decided from the declaration,
276
+ # never from the rows.
277
+ def collision_check(model, bluebook, grouped_head)
278
+ model.groups_by_identity?(bluebook.aggregate(grouped_head[:aggregate])) ? nil : model
279
+ end
280
+
270
281
  # One level of nesting per field, in `group_by`'s own declared
271
282
  # order — the leaf is the row with every grouped field removed
272
- # (already spent, as the keys that reached it). Assumes the full
273
- # `group_by` path uniquely identifies one row (true for grouping by
274
- # an aggregate's own full identity, ConsoleSettings' own real use)
275
- # — `leaves.first` silently keeps only the first row when several
276
- # share the same full key path. A real, deliberate scope limit:
277
- # true multi-row-per-leaf grouping would change the leaf shape
278
- # from "one row" to "an array of rows", which no caller needs yet.
279
- def nest(rows, fields)
283
+ # (already spent, as the keys that reached it). A leaf holds one row:
284
+ # when `checked` names a read model and two rows reach the same full
285
+ # key path, this refuses rather than keep one of them (ADR 0061,
286
+ # decision D1). An array-of-rows leaf would change the output shape
287
+ # of every shipped `group_by`, so it is not offered here.
288
+ #
289
+ # Groups are walked in first-occurrence order and depth first, so the
290
+ # collision reported is the first one reached; the Rust kernel's
291
+ # `read_model::nest` walks in the same order.
292
+ def nest(rows, fields, checked, reached = [])
280
293
  field, *rest = fields
281
- rows.group_by { |row| row[field] }.transform_values do |group|
294
+ rows.group_by { |row| row[field] }.to_h do |key, group|
282
295
  # Strip only the field just grouped by, not the whole remaining
283
296
  # list — `rest`'s own fields have to survive into the recursive
284
- # call below, or the next level groups by a key that's already
285
- # gone (found by trying it: a two-field group_by's own second
286
- # level came back keyed `nil` for every group, every time).
287
- stripped = group.map { |row| row.reject { |key, _| key == field } }
288
- rest.empty? ? stripped.first : nest(stripped, rest)
297
+ # call below, or the next level groups by a key that's already gone.
298
+ stripped = group.map { |row| row.reject { |name, _| name == field } }
299
+ path = reached + [[field, key]]
300
+ [key, rest.empty? ? leaf(stripped, checked, path) : nest(stripped, rest, checked, path)]
289
301
  end
290
302
  end
291
303
 
304
+ def leaf(rows, checked, path)
305
+ return rows.first unless checked && rows.size > 1
306
+
307
+ raise InvariantViolation,
308
+ RefusalWording.render_site("InvariantViolation", "group_by_collision",
309
+ read_model: checked.name, fields: checked.group_by_fields,
310
+ ids: rows.map { |row| row[:id] },
311
+ key: path.map { |field, value| "#{field} = #{value}" }.join(", "))
312
+ end
313
+
292
314
  # `count`/`median`'s own declared target — the same single
293
315
  # many-side head `group_by_target` resolves, for the same reason
294
316
  # (`seal_aggregation` already refuses zero or several many-side
@@ -45,7 +45,7 @@ module Hecks
45
45
  # Options, Hecks::Runtime::ReferenceHop::Folded] `declared` unchanged when it has no
46
46
  # hop clauses; otherwise a `Folded` wrapper whose `#wheres` replaces each hop clause
47
47
  # with its folded `in` clause
48
- # @raise [Runtime::WiringError] if a hop's target no longer resolves (see `fold`)
48
+ # @raise [Runtime::WiringError] if a hop's target does not resolve (see `fold`)
49
49
  def apply(declared, args, registry:, domain:, aggregate:)
50
50
  hopped, local = declared.wheres.partition { |clause| QuerySpecification::HopPath.hop_head?(clause.field, aggregate.attributes) }
51
51
  return declared if hopped.empty?
@@ -65,7 +65,7 @@ module Hecks
65
65
  # @param aggregate [Bluebook::Aggregate] the aggregate `clause` is declared against
66
66
  # @return [QuerySpecification::Common::WhereClause] a synthetic `in` clause on the hop
67
67
  # attribute's name, whose value is every id the inner clause admits on the target
68
- # @raise [Runtime::WiringError] if the hop's target aggregate no longer resolves
68
+ # @raise [Runtime::WiringError] if the hop's target aggregate does not resolve
69
69
  def fold(clause, args, registry:, domain:, aggregate:)
70
70
  step = QuerySpecification::HopPath.next_hop(clause.field, aggregate.attributes)
71
71
  hop, rest = step
@@ -74,7 +74,7 @@ module Hecks
74
74
  # this exact clause before the domain ever booted. Held anyway:
75
75
  # an unresolvable target folding silently to "matches
76
76
  # everything" is precisely the failure shape this whole feature
77
- # exists to close, and a hop that can no longer resolve (a
77
+ # exists to close, and a hop that cannot resolve (a
78
78
  # target aggregate unloaded after boot, say) is a real fact
79
79
  # worth a real error, not a query that quietly stops filtering.
80
80
  unless hop&.target
@@ -106,7 +106,7 @@ module Hecks
106
106
  # @param registry [Runtime::Registry] the booted registry to resolve `target`'s
107
107
  # repository from
108
108
  # @return [Array<String>] every distinct id the inner clause(s) admit on `target`
109
- # @raise [Runtime::WiringError] if a hop nested inside `wheres` no longer resolves
109
+ # @raise [Runtime::WiringError] if a hop nested inside `wheres` does not resolve
110
110
  def matching_ids(domain, target, wheres, args, registry:)
111
111
  spec = apply(QuerySpecification::Common::Options.new(wheres: wheres), args,
112
112
  registry: registry, domain: domain, aggregate: target)