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.
Files changed (150) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/memory.rb +1 -1
  4. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  5. data/lib/hecks/adapters/driven/prism.rb +1 -1
  6. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  7. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  8. data/lib/hecks/behaviors/expectations.rb +2 -2
  9. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  10. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  11. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  12. data/lib/hecks/bluebook/assembly.rb +1 -1
  13. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  14. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  15. data/lib/hecks/bluebook/capabilities.rb +2 -2
  16. data/lib/hecks/bluebook/chapter.rb +1 -1
  17. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  18. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  19. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  21. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  22. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  23. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/world_builder.rb +5 -0
  26. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  27. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  28. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  29. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  30. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  31. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  32. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  33. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  34. data/lib/hecks/bluebook/model_check.rb +12 -13
  35. data/lib/hecks/cache_dir.rb +103 -0
  36. data/lib/hecks/cli/document.rb +104 -0
  37. data/lib/hecks/cli/ir.rb +47 -0
  38. data/lib/hecks/cli/mcp.rb +32 -0
  39. data/lib/hecks/cli/mcp_door.rb +461 -0
  40. data/lib/hecks/cli/model_check.rb +270 -0
  41. data/lib/hecks/cli/project_cli.rb +123 -0
  42. data/lib/hecks/cli/project_diagrams.rb +43 -0
  43. data/lib/hecks/cli/run.rb +219 -0
  44. data/lib/hecks/cli/smoke_test.rb +59 -0
  45. data/lib/hecks/cli/stores.rb +54 -0
  46. data/lib/hecks/cli.rb +166 -0
  47. data/lib/hecks/facade/handle.rb +2 -2
  48. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  50. data/lib/hecks/mcp_door_scope.rb +176 -0
  51. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  52. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +1 -1
  53. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  54. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  55. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  56. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  57. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  58. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +12 -6
  59. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  60. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  61. data/lib/hecks/projections/deploy/lambda.rb +3 -3
  62. data/lib/hecks/projections/reference.rb +7 -1
  63. data/lib/hecks/projector/exporter.rb +4 -4
  64. data/lib/hecks/projector/target.rb +1 -1
  65. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  66. data/lib/hecks/release/runner/commands.rb +68 -0
  67. data/lib/hecks/release/runner/console.rb +52 -0
  68. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  69. data/lib/hecks/release/runner/git.rb +48 -0
  70. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  71. data/lib/hecks/release/runner/preflight.rb +119 -0
  72. data/lib/hecks/release/runner/published.rb +55 -0
  73. data/lib/hecks/release/runner/tagger.rb +87 -0
  74. data/lib/hecks/release/runner.rb +244 -0
  75. data/lib/hecks/router/namespace_installer.rb +1 -1
  76. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  77. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  78. data/lib/hecks/runtime/entity_element.rb +1 -1
  79. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  80. data/lib/hecks/runtime/invocation.rb +1 -1
  81. data/lib/hecks/runtime/loader.rb +1 -1
  82. data/lib/hecks/runtime/outbox.rb +1 -1
  83. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  84. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  85. data/lib/hecks/runtime/reference_hop.rb +4 -4
  86. data/lib/hecks/runtime/registry/verification.rb +11 -11
  87. data/lib/hecks/runtime/registry.rb +8 -4
  88. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  89. data/lib/hecks/runtime/value/coercion.rb +3 -3
  90. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  91. data/lib/hecks/storehouse.rb +18 -12
  92. data/lib/hecks/version.rb +1 -1
  93. data/lib/hecks/vocabulary.rb +5 -0
  94. data/lib/hecks.rb +6 -1
  95. metadata +28 -58
  96. data/lib/hecks/bench/cli.rb +0 -74
  97. data/lib/hecks/bench/environment.rb +0 -100
  98. data/lib/hecks/bench/postgres_probe.rb +0 -41
  99. data/lib/hecks/bench/report.rb +0 -116
  100. data/lib/hecks/bench/ruby_runner.rb +0 -151
  101. data/lib/hecks/bench/run.rb +0 -55
  102. data/lib/hecks/bench/rust_runner.rb +0 -165
  103. data/lib/hecks/bench/stats.rb +0 -75
  104. data/lib/hecks/bench/suite.rb +0 -172
  105. data/lib/hecks/bench/workload.rb +0 -137
  106. data/lib/hecks/bench.rb +0 -28
  107. data/lib/hecks/codemod.rb +0 -443
  108. data/lib/hecks/corpus.rb +0 -479
  109. data/lib/hecks/doc/reference.rb +0 -585
  110. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  111. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  112. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  113. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  114. data/lib/hecks/fuzzing/differential.rb +0 -192
  115. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  116. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  117. data/lib/hecks/fuzzing/form_census.rb +0 -285
  118. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  119. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  120. data/lib/hecks/fuzzing/isolated_boot.rb +0 -658
  121. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  122. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  123. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  124. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  125. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  126. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  127. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  128. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  129. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  130. data/lib/hecks/fuzzing/properties.rb +0 -267
  131. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  132. data/lib/hecks/fuzzing/replay.rb +0 -930
  133. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  134. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  135. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  136. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  137. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  138. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  139. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  140. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  141. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  142. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  143. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  144. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  145. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  146. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  147. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  148. data/lib/hecks/fuzzing.rb +0 -23
  149. data/lib/hecks/grammar/evolve.rb +0 -524
  150. 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.
@@ -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,7 +177,7 @@ 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
183
  # ever `require`d, or brought in by a bound adapter, see
@@ -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)
@@ -272,7 +272,7 @@ module Hecks
272
272
  # types the consumer inlines. `uses_embryonaut_bluebook` only loads
273
273
  # the package's `.bluebook` files — persistence, Governance, and the
274
274
  # anti-corruption layer (`translates` field mapping) live on a named
275
- # sibling hecksagon the CONSUMER must declare. Without that hexagon
275
+ # sibling hecksagon the consumer must declare. Without that hexagon
276
276
  # the package has no wiring of its own, and cross-context field
277
277
  # mapping has nowhere to be written. Breaking in 2.0: attaching a
278
278
  # vendored chapter without `Hecks.hecksagon "PackageName"` refuses
@@ -311,7 +311,7 @@ module Hecks
311
311
  # An explicit `bounded` mark on a consumer chapter always needs an
312
312
  # ACL — at least one `translates` block. Any field can be mapped;
313
313
  # the BC does not list which. `uses_framework` / `uses_embryonaut_bluebook`
314
- # mark the ATTACHED chapter bounded (module wrap, no Object shortcut)
314
+ # mark the attached chapter bounded (module wrap, no Object shortcut)
315
315
  # and require the sibling hecksagon above; they do not require a
316
316
  # `translates` on that sibling unless the consumer also wrote `bounded`.
317
317
  # rust/host Google sign-in reads `ir.json`'s `membership` and
@@ -393,22 +393,22 @@ module Hecks
393
393
  bluebook_ir.aggregates.flat_map { |aggregate| aggregate.commands + aggregate.entities.flat_map(&:commands) }
394
394
  end
395
395
 
396
- # TWO UNRELATED PACKAGES, ONE CHAPTER NAME BY COINCIDENCE — the
396
+ # Two unrelated packages, one chapter name by coincidence — the
397
397
  # real risk `Registry#bluebook_sources` exists to catch (found
398
398
  # live: a stale `vendor/hecksagain` fork's own copy of Governance/
399
399
  # Identity/Deploy, still reachable on 4 consuming apps' own load
400
400
  # paths alongside the real gem). `BluebookBuilder.build`'s own
401
- # accumulation (several files declaring the SAME chapter name ON
402
- # PURPOSE — `lib/hecks/language/bluebook/*.bluebook` all open
401
+ # accumulation (several files declaring the same chapter name on
402
+ # purpose — `lib/hecks/language/bluebook/*.bluebook` all open
403
403
  # `Hecks.bluebook "Bluebook"`) is never touched here — that merge
404
- # stays unconditional, checked only AFTER every file has loaded,
404
+ # stays unconditional, checked only after every file has loaded,
405
405
  # the same "check the merged final result once" timing
406
406
  # `refuse_ungoverned_roles!` already uses and for the same reason
407
407
  # (a check against an incomplete load can never see the real
408
408
  # shape). What distinguishes intentional accumulation from
409
- # coincidence is PACKAGE ROOT, not file identity: files a real
409
+ # coincidence is package root, not file identity: files a real
410
410
  # gemspec or a `vendor/` boundary already treats as one unit are
411
- # expected to share a name; files from two DIFFERENT roots never
411
+ # expected to share a name; files from two different roots never
412
412
  # legitimately do.
413
413
  def refuse_cross_package_bluebook_merge!
414
414
  @bluebook_sources.each do |name, paths|
@@ -424,9 +424,9 @@ module Hecks
424
424
  end
425
425
  end
426
426
 
427
- # THE NEAREST BOUNDARY A PATH ALREADY BELONGS TO — a real
428
- # gemspec (this IS a package, whatever depends on it or vendors
429
- # it), or a bare `vendor/` path component, treated as its OWN
427
+ # The nearest boundary a path already belongs to — a real
428
+ # gemspec (this is a package, whatever depends on it or vendors
429
+ # it), or a bare `vendor/` path component, treated as its own
430
430
  # root regardless of what gemspec might sit above it: vendored
431
431
  # code should never be considered "the same package" as whatever
432
432
  # it's vendored into, even when nothing else marks the boundary.