hecks 3.0.4 → 3.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 90a718a197fae0287e4fe16033540bf625166bc31bfb3482e0752d1039a2a949
4
- data.tar.gz: 15fe27abd9a488dfe4f579118f8e8ffbea1302b956ee36fbd2b9475eb3b88429
3
+ metadata.gz: 16dca2d33433ad6c6cd98b8dee40f652df3d84c7bb93df5cd1f7734b99542c08
4
+ data.tar.gz: '084b92e2ad954b89a488d44997d754cd15388136f502f918f24274fd26cf9e87'
5
5
  SHA512:
6
- metadata.gz: d74d07807e22e8899c041adfa96d799b5eb47713a0dec6c05bbb9e61a579a32a35da890b67aaf876b1ba5182e0961a112f370e0e04adcf2719f8ae57a50cfd45
7
- data.tar.gz: 11e85c77dc6a4f5d34ec3acd54e41873be8d03b980e4ef97a8d0d7c9da4b63ecfc2b04e2a1322f8c1536ad1d58f8f85f50df2ca58f3d6fea44126cb871c05b0d
6
+ metadata.gz: 0f2b6dda10ee042c6cb1bbe3ae9100a66c35dad25b17a031ab7ca6fd0eaf7ea5960b1a3e52024d5a40c3090ca999f1730db9275f7a5ec8b803e8ae0a465102df
7
+ data.tar.gz: 8aebabbb0a023e9c2ff989df18f665924ec9acf4043a922bcb995588fb424683d8d44bea2e02f2076932beae2b27d63da097153c054131aa4232c132123d33eb
data/exe/hecks CHANGED
@@ -21,6 +21,11 @@ if LEGACY.include?(ARGV.first)
21
21
  exit Hecks::CLI.start(ARGV) unless Hecks::CLI.launcher_form?(ARGV)
22
22
  end
23
23
 
24
+ # These verbs keep nothing worth a database, so they run on Memory unless told otherwise, and
25
+ # print nothing when they end well.
26
+ MEMORY_VERBS = %w[console].freeze
27
+ ENV["HECKS_ENVIRONMENT"] ||= "memory" if MEMORY_VERBS.include?(ARGV.first)
28
+
24
29
  require "hecks"
25
30
 
26
31
  # Usage is answered from the projected chapter alone: no adapter is bound and no
@@ -39,7 +44,7 @@ text, status, reason = Hecks::Doors::CliRunner.usage(
39
44
  )
40
45
  unless text
41
46
  runtime = begin
42
- Hecks.boot_described(described, install_doors: false)
47
+ Hecks::Doors::LauncherOptions.quietly(hold: MEMORY_VERBS.include?(ARGV.first)) { Hecks.boot_described(described, install_doors: false) }
43
48
  rescue StandardError => e
44
49
  abort "cannot open Hecks: #{e.message.lines.first.strip}"
45
50
  end
@@ -57,4 +62,5 @@ if reason
57
62
  warn reason
58
63
  exit status
59
64
  end
65
+ exit 0 if status.zero? && MEMORY_VERBS.include?(ARGV.first)
60
66
  status.zero? ? puts(text) : abort(text)
@@ -9,7 +9,7 @@ module Hecks
9
9
  # The command behind `hecks console` and Custodian's `Operation.OpenConsole`: boots a domain
10
10
  # (pizzas on Memory by default) and drops into IRB with its door installed, for dispatching a
11
11
  # real command by hand. No argument needs no database (ADR 0073); a domain directory boots as
12
- # it is wired, which may bind PostgresEra.
12
+ # it is wired, which may bind PostgresEra (examples/directory does).
13
13
  module Console
14
14
  PIZZAS = File.expand_path("../../../examples/pizzas", __dir__)
15
15
 
@@ -25,14 +25,27 @@ module Hecks
25
25
  # @param domain [String, nil] a domain directory; the bundled pizzas domain when nil
26
26
  # @param launcher [#call] starts the interactive session; IRB unless a caller stands in
27
27
  # @return [Runtime] the runtime the session ran against
28
- def call(domain = nil, launcher: -> { IRB.start(__FILE__) })
28
+ def call(domain = nil, launcher: -> { irb_session })
29
29
  runtime = domain ? Hecks.boot(domain) : Hecks.boot_files(DEFAULT_FILES)
30
30
  puts overview(runtime)
31
- puts banner
31
+ puts banner(domain)
32
32
  launcher.call
33
33
  runtime
34
34
  end
35
35
 
36
+ # Starts IRB with an empty command line. IRB reads `ARGV` for a script to run, and a
37
+ # launcher's `ARGV` holds its own words (`console`, `subject=<domain>`), which IRB would
38
+ # open as a file and then exit without a prompt.
39
+ #
40
+ # @return [void] when the session ends; `ARGV` is restored
41
+ def irb_session
42
+ words = ARGV.dup
43
+ ARGV.clear
44
+ IRB.start(__FILE__)
45
+ ensure
46
+ ARGV.replace(words)
47
+ end
48
+
36
49
  # @param runtime [Runtime] a booted domain
37
50
  # @return [String] each chapter's vision and its aggregates' commands
38
51
  def overview(runtime)
@@ -46,16 +59,19 @@ module Hecks
46
59
  end.join
47
60
  end
48
61
 
49
- # @return [String] the version and a first session to try against the pizzas domain
50
- def banner
62
+ # @param domain [String, nil] a domain directory, or nil for the bundled pizzas domain
63
+ # @return [String] the version, and for the pizzas domain a first session to try
64
+ def banner(domain = nil)
65
+ return "\nhecks #{Hecks::VERSION}\n\n" if domain
66
+
51
67
  <<~BANNER
52
68
 
53
69
  hecks #{Hecks::VERSION}
54
70
 
55
71
  try:
56
- order = Order.create_pizza!(name: { value: "Margherita" }, pizza: { price_cents: { cents: 1200 }, size: { value: "large" } })
57
- order.add_topping!(topping: { value: "Basil" }, amount: { value: 3 })
58
- order.purchase!(customer_name: { value: "Chris" }, amount: { cents: 1200 })
72
+ order = Order.create_pizza!(name: "Margherita", pizza: { price_cents: { cents: 1200 }, size: "large" })
73
+ order.add_topping!(topping: "Basil", amount: 3)
74
+ order.purchase!(customer_name: "Chris", amount: { cents: 1200 })
59
75
  order.status
60
76
  order.events.last
61
77
 
@@ -103,7 +103,7 @@ module Hecks
103
103
  label = executable || "#{path}/#{snake}"
104
104
  file = File.join(root, label)
105
105
  text = launcher(path, name, program, executable: executable, legacy: setting[:legacy],
106
- opted: !setting.empty?)
106
+ memory_verbs: setting[:memory_verbs], opted: !setting.empty?)
107
107
  return skip(path, "#{label} is a directory") if File.directory?(file)
108
108
 
109
109
  return :drifted if check && !(File.file?(file) && File.read(file) == text)
@@ -150,10 +150,11 @@ module Hecks
150
150
  # @param executable [String, nil] the file's path under the root when it is not beside the
151
151
  # domain; its program name is then that file's basename, and its header names the generator
152
152
  # @param legacy [Array<String>, nil] verbs an executable hands to `Hecks::CLI` first
153
+ # @param memory_verbs [Array<String>, nil] verbs run on Memory unless HECKS_ENVIRONMENT is set
153
154
  # @param opted [Boolean] whether the chapter's world declares a `launcher` setting
154
155
  # @return [String] the Ruby source
155
156
  # @raise [ArgumentError] if a name, path, executable or legacy verb is not plain
156
- def launcher(path, name, program, executable: nil, legacy: nil, opted: !executable.nil?)
157
+ def launcher(path, name, program, executable: nil, legacy: nil, memory_verbs: nil, opted: !executable.nil?)
157
158
  plain!("chapter name", name, NAME)
158
159
  plain!("domain path", path, PATH)
159
160
  snake = Naming.snake(name)
@@ -174,9 +175,10 @@ module Hecks
174
175
  where = shown = "#{path}/#{snake}"
175
176
  end
176
177
 
177
- handoff = legacy_handoff(Array(legacy)) if executable
178
+ handoff = legacy_handoff(Array(legacy)) + memory_default(Array(memory_verbs)) if executable
178
179
  encoding = opted ? ENCODING : ""
179
180
  ending = opted ? OPTED_ENDING : PLAIN_ENDING
181
+ ending = quiet_ending(ending) if executable && !Array(memory_verbs).empty?
180
182
 
181
183
  <<~RUBY
182
184
  #!/usr/bin/env ruby
@@ -194,7 +196,7 @@ module Hecks
194
196
  #{handoff}
195
197
  require "hecks"
196
198
 
197
- #{entry(name, boot, shown, opted)}
199
+ #{entry(name, boot, shown, opted, quiet: !Array(memory_verbs).empty?)}
198
200
  #{ending}
199
201
  RUBY
200
202
  end
@@ -203,13 +205,15 @@ module Hecks
203
205
  # @return [String] the launcher's middle: for an opted-in launcher, usage answered from the
204
206
  # projection and a boot only for a line that runs a verb; for any other, the boot and
205
207
  # dispatch every launcher has always had, byte for byte
206
- def entry(name, boot, shown, opted)
207
- opted ? described_entry(name, boot, shown) : plain_entry(name, boot, shown)
208
+ def entry(name, boot, shown, opted, quiet: false)
209
+ opted ? described_entry(name, boot, shown, quiet: quiet) : plain_entry(name, boot, shown)
208
210
  end
209
211
 
210
212
  # @api private
211
213
  # @return [String] the opted-in launcher's middle
212
- def described_entry(name, boot, shown)
214
+ def described_entry(name, boot, shown, quiet: false)
215
+ started = "Hecks.boot_described(described, install_doors: false)"
216
+ started = "Hecks::Doors::LauncherOptions.quietly(hold: MEMORY_VERBS.include?(ARGV.first)) { #{started} }" if quiet
213
217
  <<~RUBY.chomp
214
218
  # Usage is answered from the projected chapter alone: no adapter is bound and no
215
219
  # database is opened. Only a line that runs a verb boots the domain,
@@ -227,7 +231,7 @@ module Hecks
227
231
  )
228
232
  unless text
229
233
  runtime = begin
230
- Hecks.boot_described(described, install_doors: false)
234
+ #{started}
231
235
  rescue StandardError => e
232
236
  abort "cannot open #{name}: \#{e.message.lines.first.strip}"
233
237
  end
@@ -264,6 +268,31 @@ module Hecks
264
268
  raise ArgumentError, "#{what} #{value.to_s.inspect} is not a plain word or path"
265
269
  end
266
270
 
271
+ # @api private
272
+ # @param ending [String] the launcher's closing lines
273
+ # @return [String] the same lines, except that a memory verb that ended well prints nothing: a
274
+ # person is at it, so the settled record is noise after the session
275
+ def quiet_ending(ending)
276
+ ending.sub("status.zero? ? puts(text)",
277
+ "exit 0 if status.zero? && MEMORY_VERBS.include?(ARGV.first)\nstatus.zero? ? puts(text)")
278
+ end
279
+
280
+ # @api private
281
+ # @return [String] the lines that default the listed verbs to Memory, or nothing
282
+ def memory_default(verbs)
283
+ return "" if verbs.empty?
284
+
285
+ verbs.each { |verb| plain!("memory verb", verb, VERB) }
286
+
287
+ <<~RUBY
288
+
289
+ # These verbs keep nothing worth a database, so they run on Memory unless told otherwise, and
290
+ # print nothing when they end well.
291
+ MEMORY_VERBS = %w[#{verbs.join(' ')}].freeze
292
+ ENV["HECKS_ENVIRONMENT"] ||= "memory" if MEMORY_VERBS.include?(ARGV.first)
293
+ RUBY
294
+ end
295
+
267
296
  # @api private
268
297
  # @return [String] the lines that hand the legacy verbs to `Hecks::CLI`, or nothing
269
298
  def legacy_handoff(legacy)
@@ -7,9 +7,12 @@ require_relative "../fuzzing"
7
7
  module Hecks
8
8
  module CLI
9
9
  # The command behind `hecks seed_semantics_corpus`: seeds (or deliberately re-seeds) the
10
- # `expect` block of semantics-corpus fixtures from the Ruby runtime. Review the result
10
+ # `expect` block of corpus fixtures from the Ruby runtime. Review the result
11
11
  # against `docs/semantics/bluebook-semantics.md` before committing.
12
12
  #
13
+ # Seeds `spec/corpus/semantics/` and the conformance corpus ({FULL_SCRIPTS} plus
14
+ # `spec/corpus/rust_conformance/`, whose `expect` also freezes more).
15
+ #
13
16
  # hecks seed_semantics_corpus # fill fixtures missing an expect
14
17
  # SEED=refusal_kind_lifecycle hecks seed_semantics_corpus # re-seed one, deliberately
15
18
  #
@@ -18,6 +21,15 @@ module Hecks
18
21
  module SeedSemanticsCorpus
19
22
  module_function
20
23
 
24
+ # Full corpus scripts held to the conformance bar, keyed by file, valued by domain.
25
+ FULL_SCRIPTS = {
26
+ "spec/corpus/banking.json" => "examples/banking",
27
+ "spec/corpus/chess.json" => "examples/chess"
28
+ }.freeze
29
+
30
+ # The `expect` keys a conformance fixture freezes beyond the semantics three.
31
+ CONFORMANCE_KEYS = %w[queries sagas dry_runs reactions].freeze
32
+
21
33
  # Seeds the fixtures that need it and reports which.
22
34
  #
23
35
  # @param root [String] the checkout whose `spec/corpus/semantics/` is seeded
@@ -26,12 +38,13 @@ module Hecks
26
38
  # @return [Integer] the exit status, always 0
27
39
  def call(root:, env: ENV, out: $stdout)
28
40
  only = env.fetch("SEED", nil)
29
- seeded = Dir.glob(File.join(root, "spec/corpus/semantics", "*.json")).filter_map do |path|
41
+ seeded = corpus_paths(root).filter_map do |path, domain, full|
30
42
  fixture = JSON.parse(File.read(path))
31
43
  name = File.basename(path, ".json")
32
44
  next if only ? only != name : fixture.key?("expect")
33
45
 
34
- fixture["expect"] = expectation_for(root, fixture)
46
+ fixture["domain"] ||= domain if domain
47
+ fixture["expect"] = expectation_for(root, fixture, full: full)
35
48
  File.write(path, "#{JSON.pretty_generate(fixture)}\n")
36
49
  name
37
50
  end
@@ -39,19 +52,35 @@ module Hecks
39
52
  0
40
53
  end
41
54
 
55
+ # @param root [String] the checkout
56
+ # @return [Array<Array>] `[path, domain_to_add_or_nil, full_expect?]` for every seedable file
57
+ def corpus_paths(root)
58
+ semantics = Dir.glob(File.join(root, "spec/corpus/semantics", "*.json")).map { |p| [p, nil, false] }
59
+ conformance = Dir.glob(File.join(root, "spec/corpus/rust_conformance", "*.json")).map { |p| [p, nil, true] }
60
+ scripts = FULL_SCRIPTS.map { |file, domain| [File.join(root, file), domain, true] }
61
+ semantics + conformance + scripts
62
+ end
63
+
42
64
  # Replays the fixture's script against the Ruby runtime. Each refusal's `"kind"` is reduced
43
65
  # to its class name; `occurred_at` is stripped from events.
44
66
  #
45
67
  # @param root [String] the checkout the fixture's domain path is relative to
46
68
  # @param fixture [Hash] the parsed fixture: `domain` and `steps`
47
- # @return [Hash{String => Array}] its `"expect"` block: refusals, instances, events
48
- def expectation_for(root, fixture)
69
+ # @param full [Boolean] also freeze queries, sagas, dry runs, reactions (conformance)
70
+ # @return [Hash{String => Array}] its `"expect"` block: refusals, instances, events, and
71
+ # with `full` the {CONFORMANCE_KEYS}
72
+ def expectation_for(root, fixture, full: false)
49
73
  result = Hecks::Fuzzing::Replay.call(File.join(root, fixture["domain"]), fixture["steps"])
50
- {
74
+ expect = {
51
75
  "refusals" => JSON.parse(JSON.generate(result[:refusals])).each { |r| r["kind"] = r["kind"]&.split("::")&.last },
52
76
  "instances" => JSON.parse(JSON.generate(result[:instances])),
53
77
  "events" => JSON.parse(JSON.generate(result[:events])).each { |e| e.delete("occurred_at") }
54
78
  }
79
+ return expect unless full
80
+
81
+ # instances_at is Replay's per-query snapshot for the property harness, not an outcome.
82
+ expect["queries"] = JSON.parse(JSON.generate(result[:queries].map { |q| q.except(:instances_at) }))
83
+ expect.merge!(CONFORMANCE_KEYS.drop(1).to_h { |k| [k, JSON.parse(JSON.generate(result[k.to_sym]))] })
55
84
  end
56
85
 
57
86
  # @param out [IO] where the report goes
@@ -1,3 +1,4 @@
1
+ require "stringio"
1
2
  require_relative "../ports/identity_generation"
2
3
  require_relative "../runtime/errors"
3
4
  require_relative "cli_door"
@@ -46,6 +47,23 @@ module Hecks
46
47
  { program: program, names: settings && settings[:names], mint_run_keys: settings && settings[:run_keys] }
47
48
  end
48
49
 
50
+ # Runs the block with standard error held back, so the wiring notes hecks prints about its own
51
+ # chapters (a Memory journal loses sagas on restart) do not open a console session. They are
52
+ # for an operator running a stored journal. A raised error still surfaces, unchanged.
53
+ #
54
+ # @param hold [Boolean] whether to hold standard error back; false runs the block as it is
55
+ # @yield the boot
56
+ # @return [Object] the block's value
57
+ def quietly(hold: true)
58
+ return yield unless hold
59
+
60
+ shown = $stderr
61
+ $stderr = StringIO.new
62
+ yield
63
+ ensure
64
+ $stderr = shown if hold
65
+ end
66
+
49
67
  # Takes `--wait` out of a verb's words, unless the verb declares a `wait` argument of its own.
50
68
  #
51
69
  # `--wait=false` (or `no`, `0`, `off`) is a `--wait` that was switched off; a bare `--wait`
@@ -45,6 +45,10 @@ pre_push:
45
45
  title: codegen drift, the committed rust/src/generated/ must match a fresh regeneration
46
46
  run: [bundle, exec, exe/hecks, regenerate_corpus, --check, --wait]
47
47
  blocked: regenerating hits a diff, so the committed Rust is stale; run `hecks regenerate_corpus --confirm` and commit the result.
48
+ - id: comment_style
49
+ title: comment style, every comment under lib/hecks against docs/COMMENT_STYLE_GUIDE.md
50
+ run: [bundle, exec, exe/hecks, check_comments, paths=lib/hecks, --wait]
51
+ blocked: a comment breaks the style guide, such as a line past 100 characters; `hecks fix_comments` rewrites the mechanical ones, and the rest are shortened by hand. CI runs this same check.
48
52
  - id: comment_blocks
49
53
  title: comment blocks, no run of comment lines past ADR 0075's cap
50
54
  run: [bundle, exec, exe/hecks, check_comments, "paths=lib,spec,examples", only=long_block, --wait]
@@ -26,8 +26,10 @@
26
26
  # `failure_states` are the lifecycle states `--wait` reports as exit 1 (every failure state the
27
27
  # chapter's lifecycles declare), `names` gives a command the launcher name clients type,
28
28
  # `executable` is where `hecks project_cli` writes this chapter's launcher (the gem's
29
- # `exe/hecks`), `streams` lists the questions `--stream` may tail (`Follow`), and `legacy` names
30
- # the verbs that launcher hands to `Hecks::CLI` in the positional forms ADR 0066 shipped.
29
+ # `exe/hecks`), `streams` lists the questions `--stream` may tail (`Follow`), `memory_verbs` names
30
+ # the verbs that launcher runs on the Memory environment unless `HECKS_ENVIRONMENT` is set (so
31
+ # `hecks console` needs no database), and `legacy` names the verbs it hands to `Hecks::CLI` in the
32
+ # positional forms ADR 0066 shipped.
31
33
  Hecks.world "Hecks" do
32
34
  realm "Hecks"
33
35
  default_adapter "PostgresEra"
@@ -38,6 +40,7 @@ Hecks.world "Hecks" do
38
40
  names: { "mcp" => "serve_mcp", "console" => "open_console" },
39
41
  streams: %w[Follow],
40
42
  executable: "exe/hecks",
43
+ memory_verbs: %w[console],
41
44
  legacy: %w[run docs narrate ir stores model_check smoke_test
42
45
  project_diagrams project_cli mcp]
43
46
  end
@@ -123,7 +123,8 @@ module Hecks
123
123
  raise unless defined?(PG::Error) && e.is_a?(PG::Error)
124
124
 
125
125
  raise Runtime::WiringError,
126
- "cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip}"
126
+ "cannot bind PostgresEra at #{declared} for #{name}: #{e.message.strip} " \
127
+ "-- to run without a database, set HECKS_ENVIRONMENT=memory (a domain needs an environments/memory overlay)"
127
128
  end
128
129
 
129
130
  # Joins the process's shared connection for the declared database and schema, so every
@@ -206,6 +206,7 @@ module Hecks
206
206
 
207
207
  { verb: fqn(bluebook, aggregate, command, entity), kind: :command,
208
208
  summary: command.goal, role: command.role, role_gated: !command.role.to_s.empty?,
209
+ group: aggregate.hecks_name, internal: command.role.to_s == "System",
209
210
  creates: command.creates?,
210
211
  receiver: receiver, legacy_receiver: (receiver == :aggregate ? :id : nil),
211
212
  legacy_arguments: legacy_arguments,
@@ -230,7 +231,7 @@ module Hecks
230
231
  else
231
232
  "#{port.name} telling #{aggregate.hecks_name}"
232
233
  end,
233
- role_gated: false,
234
+ role_gated: false, group: aggregate.hecks_name, internal: true,
234
235
  summary: port_summary(port, operation), arguments: arguments }
235
236
  end
236
237
 
@@ -257,13 +258,30 @@ module Hecks
257
258
  summary: model.description, arguments: arguments }
258
259
  end
259
260
 
261
+ # A question that only reads the aggregate's own records back: it returns no document and
262
+ # filters on nothing but the record's identity ("how one request ended") or its lifecycle
263
+ # status ("every request that was refused"). Each journaled run has such a pair, which a
264
+ # person reads through `hecks <verb> --wait` rather than asking for by name, so the help
265
+ # sets them apart with the bookkeeping verbs. A query that returns a document, or filters
266
+ # on anything else, is a real question. Only an aggregate that journals its own runs (it has
267
+ # system-role commands, the ones `internal` verbs are made of) has such a pair: a release's
268
+ # "every version that was shipped" is a question worth asking by name.
269
+ def bookkeeping_query?(aggregate, query)
270
+ return false if query.returns || query.wheres.empty?
271
+ return false unless aggregate.commands.any? { |command| command.role.to_s == "System" }
272
+
273
+ own = Array(aggregate.identified_by).map(&:to_s) + [aggregate.lifecycle&.field.to_s]
274
+ query.wheres.all? { |clause| own.include?(clause.field.to_s) }
275
+ end
276
+
260
277
  def query_spec(bluebook, aggregate, entity, query)
261
278
  arguments = Array(query.to_h[:attributes]).flat_map do |declared|
262
279
  attribute = query.attributes.find { |a| a.name.to_s == declared[:name].to_s }
263
280
  attribute ? options_for(attribute, entity || aggregate, aggregate) : []
264
281
  end
265
282
 
266
- { verb: fqn(bluebook, aggregate, query, entity), kind: :query,
283
+ { verb: fqn(bluebook, aggregate, query, entity), kind: :query, group: aggregate.hecks_name,
284
+ internal: entity.nil? && bookkeeping_query?(aggregate, query),
267
285
  summary: query.description, arguments: arguments, returns: query.returns }
268
286
  end
269
287
 
@@ -373,16 +391,57 @@ module Hecks
373
391
  " #{program} ask <question> [name=value …] read something", ""]
374
392
 
375
393
  out << "verbs:"
376
- verbs.each_value { |spec| out << " #{spec[:short].ljust(width)} #{spec[:summary]}" }
394
+ out.concat(listing(verbs, width) { |spec| spec[:summary] })
377
395
  out << ""
378
396
  out << "questions (nothing here changes anything):"
379
- questions.each_value { |spec| out << " #{spec[:short].ljust(width)} #{first_sentence(spec[:summary])}" }
397
+ out.concat(listing(questions, width) { |spec| first_sentence(spec[:summary]) })
380
398
  out << ""
381
399
  out << " #{program} <verb> --help what one verb wants, and every way it refuses"
382
400
  out << " a verb can always be spelled in full — #{example_qualified(verbs)}"
383
401
  out.join("\n")
384
402
  end
385
403
 
404
+ # The verb or question lines of the help. A domain with more than one aggregate is listed
405
+ # under a heading per aggregate, so related verbs sit together; the bookkeeping a run records
406
+ # about itself (`internal`: system-role commands and port operations) is set apart as names
407
+ # only, since a person never types them. A single-aggregate domain keeps the plain list.
408
+ def listing(specs, width)
409
+ shown, internal = specs.values.partition { |spec| !spec[:internal] }
410
+ groups = shown.group_by { |spec| spec[:group] }
411
+ lines = []
412
+ if groups.length > 1
413
+ groups.each do |group, members|
414
+ lines << " #{heading(group)}" if group
415
+ members.each { |spec| lines << " #{spec[:short].ljust(width)} #{yield(spec)}" }
416
+ end
417
+ else
418
+ shown.each { |spec| lines << " #{spec[:short].ljust(width)} #{yield(spec)}" }
419
+ end
420
+ lines.concat(internal_lines(internal)) unless internal.empty?
421
+ lines
422
+ end
423
+
424
+ # "LanguageRun" reads "Language": the Run suffix names the journaled-command shape all of
425
+ # these aggregates share, so it adds nothing under a heading ("Test suite", "Model check").
426
+ def heading(group)
427
+ words = Naming.snake(group.sub(/Run\z/, "")).tr("_", " ")
428
+ "#{words.capitalize}:"
429
+ end
430
+
431
+ # The internal verbs as bare names, wrapped, under one line saying what they are.
432
+ def internal_lines(specs)
433
+ lines = [" internal — what a run records about itself, named here only (`--help` still works):"]
434
+ line = " "
435
+ specs.map { |spec| spec[:short] }.each do |name|
436
+ if line.length + name.length + 1 > 98
437
+ lines << line
438
+ line = " "
439
+ end
440
+ line += " #{name}"
441
+ end
442
+ lines << line
443
+ end
444
+
386
445
  # One verb whose full spelling differs from its short one, or `""` when none does.
387
446
  def example_qualified(verbs)
388
447
  name, spec = verbs.find { |key, value| key != value[:short] } || verbs.first
@@ -243,6 +243,8 @@ module Hecks
243
243
  target_args = ctx.args.merge(
244
244
  delegation.source.to_h { |target_key, source_key| [target_key.to_sym, ctx.args[source_key]] }
245
245
  )
246
+ # The target is dispatched as if called directly, so its needs and defaults are filled too.
247
+ target_args = enrich_arguments(target_command, target_args)
246
248
 
247
249
  refuse_unknown_arguments(ctx.domain, ctx.aggregate, target_command, target_args,
248
250
  extra_identity_heads: entity.identity_heads)
@@ -29,12 +29,33 @@ module Hecks
29
29
  # @return [Hash{Symbol => Object}] `args`, with each missing needed fact answered
30
30
  # @raise [Runtime::WiringError] when the port that answers a fact is not bound exactly once
31
31
  def enrich_arguments(command, args)
32
+ fill_defaults(command, fill_needs(command, args))
33
+ end
34
+
35
+ # Answers each fact the command `needs` that the caller left out.
36
+ def fill_needs(command, args)
32
37
  missing = command.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
33
38
  return args if missing.empty?
34
39
 
35
40
  args.merge(missing.to_h { |fact| [fact, need_value(command, fact)] })
36
41
  end
37
42
 
43
+ # Gives each argument the caller left out the default its attribute declares
44
+ # (`attribute :runs, Count, default: 30`), so a declared default holds on every way in and
45
+ # not only where a door spells it out. An argument the caller passed is kept, whatever it is.
46
+ #
47
+ # @param command [Class] the command being dispatched
48
+ # @param args [Hash{Symbol => Object}] the arguments the caller passed
49
+ # @return [Hash{Symbol => Object}] `args`, with each absent defaulted argument filled
50
+ def fill_defaults(command, args)
51
+ absent = command.attributes.reject do |attribute|
52
+ attribute.default.nil? || args.key?(attribute.name.to_sym) || args.key?(attribute.name.to_s)
53
+ end
54
+ return args if absent.empty?
55
+
56
+ args.merge(absent.to_h { |attribute| [attribute.name.to_sym, attribute.default] })
57
+ end
58
+
38
59
  # The answer to one fact, in the shape the command's argument of that name takes: a bare
39
60
  # Integer for an Integer attribute, else the one-field value object the language declares
40
61
  # for an instant.
@@ -207,12 +207,14 @@ module Hecks
207
207
  declared: declared)
208
208
  end
209
209
 
210
- # A fact the command `needs` is not absent: the interpreter answers it before any refusal
211
- # reads the arguments.
210
+ # A fact the command `needs`, and an argument its attribute gives a default, are not absent:
211
+ # the interpreter fills them before any refusal reads the arguments.
212
212
  def refuse_absent_facts!(declaring, offered, declared)
213
213
  needed = declaring.respond_to?(:needs) ? declaring.needs.map(&:to_sym) : []
214
+ defaulted = declaring.attributes.select { |attribute| attribute.respond_to?(:default) && !attribute.default.nil? }
215
+ .map { |attribute| attribute.name.to_sym }
214
216
  absent = declaring.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } -
215
- offered.keys - needed
217
+ offered.keys - needed - defaulted
216
218
  return if absent.empty?
217
219
 
218
220
  raise AbsentArgument,
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.0.4".freeze
4
+ VERSION = "3.0.5".freeze
5
5
  end
@@ -1 +1 @@
1
- 3.0.4
1
+ 3.0.5
@@ -0,0 +1,69 @@
1
+ // The optional branded confirmation email: an HTML template an operator
2
+ // hosts, fetched at send time. Hecks ships no brand; with no template (or
3
+ // any trouble with one) the caller keeps the plain-text body.
4
+ use super::super::newsletter_send::personalize;
5
+ use std::time::Duration;
6
+
7
+ pub(super) const CONFIRM_TOKEN: &str = "{{CONFIRM_URL}}";
8
+ const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
9
+ pub(super) const MAX_TEMPLATE_BYTES: usize = 256 * 1024;
10
+
11
+ // Escapes a URL for an HTML attribute or text position.
12
+ fn escape_html(value: &str) -> String {
13
+ value.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
14
+ }
15
+
16
+ // Fills the placeholders with HTML-escaped URLs. `Err` names why the
17
+ // template is unusable: without {{CONFIRM_URL}} the email would carry no
18
+ // link. {{UNSUBSCRIBE_URL}} is optional. The result starts at its first
19
+ // non-blank character so the mailer sends it as HTML.
20
+ pub(super) fn fill_template(template: &str, confirm_url: &str, unsubscribe_url: &str) -> Result<String, &'static str> {
21
+ if !template.contains(CONFIRM_TOKEN) {
22
+ return Err("the template has no {{CONFIRM_URL}} placeholder");
23
+ }
24
+ let filled = template.replace(CONFIRM_TOKEN, &escape_html(confirm_url));
25
+ Ok(personalize(&filled, &escape_html(unsubscribe_url)).trim_start().to_string())
26
+ }
27
+
28
+ // Reads a response body, stopping as soon as it passes the cap.
29
+ async fn read_capped(mut response: reqwest::Response) -> Result<String, String> {
30
+ let mut bytes: Vec<u8> = Vec::new();
31
+ while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
32
+ bytes.extend_from_slice(&chunk);
33
+ if bytes.len() > MAX_TEMPLATE_BYTES {
34
+ return Err(format!("the template is larger than {MAX_TEMPLATE_BYTES} bytes"));
35
+ }
36
+ }
37
+ String::from_utf8(bytes).map_err(|_| "the template is not UTF-8".to_string())
38
+ }
39
+
40
+ // GETs the template (5 second timeout, size capped); a non-2xx answer or
41
+ // any transport trouble is an `Err` with the reason.
42
+ async fn fetch_template(url: &str) -> Result<String, String> {
43
+ let http = reqwest::Client::builder().timeout(FETCH_TIMEOUT).build().map_err(|e| e.to_string())?;
44
+ let response = http.get(url).send().await.map_err(|e| e.to_string())?;
45
+ if !response.status().is_success() {
46
+ return Err(format!("the template answered {}", response.status()));
47
+ }
48
+ read_capped(response).await
49
+ }
50
+
51
+ // The HTML body when NEWSLETTER_CONFIRMATION_TEMPLATE_URL names a usable
52
+ // template; None (after logging why) when the caller should send plain text.
53
+ pub(super) async fn branded_body(confirm_url: &str, unsubscribe_url: &str) -> Option<String> {
54
+ let url = std::env::var("NEWSLETTER_CONFIRMATION_TEMPLATE_URL").ok().filter(|u| !u.trim().is_empty())?;
55
+ let template = match fetch_template(url.trim()).await {
56
+ Ok(t) => t,
57
+ Err(e) => {
58
+ eprintln!("newsletter: the confirmation template could not be fetched ({e}), so the plain-text confirmation was sent");
59
+ return None;
60
+ }
61
+ };
62
+ match fill_template(&template, confirm_url, unsubscribe_url) {
63
+ Ok(body) => Some(body),
64
+ Err(e) => {
65
+ eprintln!("newsletter: the confirmation template was rejected ({e}), so the plain-text confirmation was sent");
66
+ None
67
+ }
68
+ }
69
+ }
@@ -102,3 +102,46 @@ fn a_confirm_token_is_not_an_unsubscribe_token_and_the_reverse() {
102
102
  assert!(!unsubscribe_token_matches("s3cret-value", &confirm, "a@example.com"));
103
103
  assert!(!confirm_token_matches("s3cret-value", &unsubscribe, "a@example.com"));
104
104
  }
105
+
106
+ use template::fill_template;
107
+
108
+ const CONFIRM: &str = "https://example.com/newsletter-confirmed.html?email=a%40b.c&token=t";
109
+ const UNSUBSCRIBE: &str = "https://example.com/newsletter-unsubscribed.html?email=a%40b.c&token=u";
110
+
111
+ #[test]
112
+ fn a_template_gets_the_confirm_link_in_place_of_its_placeholder() {
113
+ let body = fill_template("<p><a href=\"{{CONFIRM_URL}}\">Confirm</a></p>", CONFIRM, UNSUBSCRIBE).unwrap();
114
+ assert_eq!(body, "<p><a href=\"https://example.com/newsletter-confirmed.html?email=a%40b.c&amp;token=t\">Confirm</a></p>");
115
+ }
116
+
117
+ #[test]
118
+ fn a_template_without_the_confirm_placeholder_is_rejected() {
119
+ assert!(fill_template("<p>{{UNSUBSCRIBE_URL}}</p>", CONFIRM, UNSUBSCRIBE).is_err());
120
+ assert!(fill_template("", CONFIRM, UNSUBSCRIBE).is_err());
121
+ }
122
+
123
+ #[test]
124
+ fn the_unsubscribe_placeholder_is_optional_and_replaced_wherever_present() {
125
+ assert!(fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", CONFIRM, UNSUBSCRIBE).is_ok());
126
+ let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>{{UNSUBSCRIBE_URL}}|{{UNSUBSCRIBE_URL}}", CONFIRM, UNSUBSCRIBE).unwrap();
127
+ assert!(!body.contains("{{"));
128
+ assert_eq!(body.matches("newsletter-unsubscribed.html").count(), 2);
129
+ }
130
+
131
+ #[test]
132
+ fn ampersands_and_quotes_in_a_url_are_escaped_inside_the_html() {
133
+ let body = fill_template("<a href=\"{{CONFIRM_URL}}\">x</a>", "https://e.com/?a=1&b=\"2\"<>'", UNSUBSCRIBE).unwrap();
134
+ assert_eq!(body, "<a href=\"https://e.com/?a=1&amp;b=&quot;2&quot;&lt;&gt;&#39;\">x</a>");
135
+ }
136
+
137
+ #[test]
138
+ fn a_filled_template_starts_at_its_first_tag_so_the_mailer_sends_html() {
139
+ let body = fill_template("\n <p>{{CONFIRM_URL}}</p>", CONFIRM, UNSUBSCRIBE).unwrap();
140
+ assert!(body.starts_with('<'));
141
+ }
142
+
143
+ #[test]
144
+ fn without_a_template_the_plain_text_confirmation_is_unchanged() {
145
+ let body = confirmation_body(CONFIRM);
146
+ assert_eq!(body, format!("Please confirm your newsletter subscription by opening this link:\n\n{CONFIRM}\n\nIf you didn't ask for this, you can ignore this email and nothing will be sent to you.\n"));
147
+ }
@@ -14,6 +14,7 @@ use tokio::sync::Mutex;
14
14
  use tokio_postgres::Client;
15
15
 
16
16
  mod cooldown;
17
+ mod template;
17
18
  #[cfg(test)]
18
19
  mod tests;
19
20
 
@@ -194,8 +195,12 @@ pub(super) async fn send_confirmation(email: &str) {
194
195
  let site = site_url();
195
196
  let link = confirm_url(&site, email, &confirm_token(&secret, email));
196
197
  let unsubscribe = unsubscribe_url(&site, email, &unsubscribe_token(&secret, email));
198
+ let body = match template::branded_body(&link, &unsubscribe).await {
199
+ Some(html) => html,
200
+ None => confirmation_body(&link),
201
+ };
197
202
  let delivery = mailer
198
- .deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &confirmation_body(&link), unsubscribe_url: Some(&unsubscribe) })
203
+ .deliver(&Email { to: email, subject: "Confirm your newsletter subscription", body: &body, unsubscribe_url: Some(&unsubscribe) })
199
204
  .await;
200
205
  if !delivery.ok {
201
206
  eprintln!("newsletter: the confirmation email was not delivered: {}", delivery.reason.unwrap_or_default());
@@ -132,7 +132,7 @@ pub(super) fn unsubscribe_url(site_url: &str, email: &str, token: &str) -> Strin
132
132
  url.to_string()
133
133
  }
134
134
 
135
- fn personalize(body: &str, unsubscribe_url: &str) -> String {
135
+ pub(super) fn personalize(body: &str, unsubscribe_url: &str) -> String {
136
136
  body.replace(UNSUBSCRIBE_TOKEN, unsubscribe_url)
137
137
  }
138
138
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.4
4
+ version: 3.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-02 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -858,6 +858,7 @@ files:
858
858
  - rust/host/src/web.rs
859
859
  - rust/host/src/web/newsletter.rs
860
860
  - rust/host/src/web/newsletter/cooldown.rs
861
+ - rust/host/src/web/newsletter/template.rs
861
862
  - rust/host/src/web/newsletter/tests.rs
862
863
  - rust/host/src/web/newsletter_send.rs
863
864
  - rust/host/src/web/registrations.rs