hecks 3.0.3 → 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.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +12 -2
  3. data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
  4. data/lib/hecks/bluebook/assembly/marks.rb +3 -0
  5. data/lib/hecks/bluebook/command.rb +7 -5
  6. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  7. data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
  8. data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
  9. data/lib/hecks/bluebook/expression/projection.json +21 -0
  10. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
  11. data/lib/hecks/cli/console.rb +24 -8
  12. data/lib/hecks/cli/project_cli.rb +42 -9
  13. data/lib/hecks/cli/seed_semantics_corpus.rb +35 -6
  14. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
  15. data/lib/hecks/doors/cli_runner.rb +57 -12
  16. data/lib/hecks/doors/launcher_options.rb +41 -0
  17. data/lib/hecks/fuzzing/properties.rb +1 -1
  18. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
  19. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
  20. data/lib/hecks/gate/stages.yml +55 -0
  21. data/lib/hecks/grammar/expression.bluebook +6 -1
  22. data/lib/hecks/grammar/expression_operators.json +135 -0
  23. data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
  24. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
  25. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  26. data/lib/hecks/hecks/codebase.behaviors +12 -0
  27. data/lib/hecks/hecks/codebase.bluebook +194 -0
  28. data/lib/hecks/hecks/custodian.bluebook +2 -0
  29. data/lib/hecks/hecks/hecks.hecksagon +14 -0
  30. data/lib/hecks/hecks/hecks.world +5 -1
  31. data/lib/hecks/language/bluebook/command.bluebook +26 -0
  32. data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
  33. data/lib/hecks/language/oidc.json +5 -0
  34. data/lib/hecks/ports/clock.rb +3 -1
  35. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  36. data/lib/hecks/projections/deploy/lambda.rb +3 -4
  37. data/lib/hecks/projector/cli_projector.rb +63 -4
  38. data/lib/hecks/quality_control/quality_control.bluebook +6 -0
  39. data/lib/hecks/runtime/command_interpreter.rb +8 -4
  40. data/lib/hecks/runtime/entity_interpreter.rb +5 -2
  41. data/lib/hecks/runtime/interpreting.rb +50 -0
  42. data/lib/hecks/runtime/invocation.rb +7 -1
  43. data/lib/hecks/runtime/loader.rb +16 -13
  44. data/lib/hecks/runtime.rb +11 -0
  45. data/lib/hecks/three_zero/forms.yml +1 -1
  46. data/lib/hecks/tools/ci_gate_decision.rb +109 -0
  47. data/lib/hecks/tools/ci_gates.rb +124 -0
  48. data/lib/hecks/tools/gate.rb +117 -0
  49. data/lib/hecks/tools.rb +3 -0
  50. data/lib/hecks/version.rb +1 -1
  51. data/lib/hecks/vocabulary.rb +6 -1
  52. data/lib/hecks.rb +12 -2
  53. data/rust/codegen/src/naming.rs +13 -2
  54. data/rust/host/HECKS_RELEASE +1 -1
  55. data/rust/host/src/dispatch.rs +207 -1
  56. data/rust/host/src/main.rs +1 -0
  57. data/rust/host/src/needs.rs +243 -0
  58. data/rust/host/src/resend.rs +52 -10
  59. data/rust/host/src/server.rs +7 -3
  60. data/rust/host/src/web/newsletter/template.rs +69 -0
  61. data/rust/host/src/web/newsletter/tests.rs +43 -0
  62. data/rust/host/src/web/newsletter.rs +6 -1
  63. data/rust/host/src/web/newsletter_send.rs +1 -1
  64. data/rust/parser/src/canonical.rs +137 -2
  65. data/rust/parser/src/emit.rs +25 -19
  66. data/rust/parser/src/ir.rs +2 -0
  67. data/rust/parser/src/keywords.rs +2 -0
  68. data/rust/parser/src/main.rs +1 -0
  69. data/rust/parser/src/parse/command.rs +44 -0
  70. data/rust/project/naming.rb +4 -2
  71. data/rust/src/kernel/cli.rs +3 -0
  72. data/rust/src/kernel/mod.rs +1 -0
  73. data/rust/src/kernel/needs.rs +194 -0
  74. data/rust/src/kernel/orchestrate.rs +84 -0
  75. data/rust/src/kernel/repository.rs +43 -2
  76. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46f6f36759cf946f2fd26b0b11b0ec64b02b7b84e1213fcf239a08aa314d0168
4
- data.tar.gz: 295310e1f10f391cfdc53900f7eda2ee6b96db34ced1e93fbd73e50978b6fa07
3
+ metadata.gz: 16dca2d33433ad6c6cd98b8dee40f652df3d84c7bb93df5cd1f7734b99542c08
4
+ data.tar.gz: '084b92e2ad954b89a488d44997d754cd15388136f502f918f24274fd26cf9e87'
5
5
  SHA512:
6
- metadata.gz: 9d7a89486e004a7e786177ee9debfb4fcdc1e331611da9293a29cc2e16e0b4f2b388d718d3062e7b236e27b2655f16282e6e1b1065bb151fcdd8bb67c839a34f
7
- data.tar.gz: 9713d8af8eb4eebdf7885e0ec9821e304f0d6be393e22968c32b971e83d920e6f4ecfa52ce3ee46a19336de8a4e50d5c70d20c443784042aeac5607046ceb9ce
6
+ metadata.gz: 0f2b6dda10ee042c6cb1bbe3ae9100a66c35dad25b17a031ab7ca6fd0eaf7ea5960b1a3e52024d5a40c3090ca999f1730db9275f7a5ec8b803e8ae0a465102df
7
+ data.tar.gz: 8aebabbb0a023e9c2ff989df18f665924ec9acf4043a922bcb995588fb424683d8d44bea2e02f2076932beae2b27d63da097153c054131aa4232c132123d33eb
data/exe/hecks CHANGED
@@ -21,10 +21,16 @@ 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
27
- # database is opened. Only a line that runs a verb boots the domain.
32
+ # database is opened. Only a line that runs a verb boots the domain,
33
+ # and it boots from what the usage check already loaded.
28
34
  domain = File.expand_path("../lib/hecks/hecks", __dir__)
29
35
  program = "hecks"
30
36
  described = begin
@@ -38,10 +44,13 @@ text, status, reason = Hecks::Doors::CliRunner.usage(
38
44
  )
39
45
  unless text
40
46
  runtime = begin
41
- Hecks.boot(domain, install_doors: false)
47
+ Hecks::Doors::LauncherOptions.quietly(hold: MEMORY_VERBS.include?(ARGV.first)) { Hecks.boot_described(described, install_doors: false) }
42
48
  rescue StandardError => e
43
49
  abort "cannot open Hecks: #{e.message.lines.first.strip}"
44
50
  end
51
+ # A question the world lists under `streams`, given `--stream`, tails until interrupted.
52
+ streamed = Hecks::Doors::CliRunner.stream(runtime: runtime, argv: ARGV, program: program)
53
+ exit streamed unless streamed.nil?
45
54
  text, status, reason = Hecks::Doors::CliRunner.call(
46
55
  runtime: runtime, argv: ARGV, program: program
47
56
  )
@@ -53,4 +62,5 @@ if reason
53
62
  warn reason
54
63
  exit status
55
64
  end
65
+ exit 0 if status.zero? && MEMORY_VERBS.include?(ARGV.first)
56
66
  status.zero? ? puts(text) : abort(text)
@@ -62,6 +62,7 @@ module Hecks
62
62
  attributes: [:attributes, [:each, :shape_field]],
63
63
  givens: [:givens, [:each, :given]],
64
64
  ensures: [:ensures, [:each, :given]],
65
+ needs: [:needs, [:each, :need]],
65
66
  mutations: [:mutations, [:each, :mutation]],
66
67
  emits: [:emits, :plain],
67
68
  # Lifecycle state as a command guard: one state, an array, or nil (ADR 0025).
@@ -70,7 +71,8 @@ module Hecks
70
71
  },
71
72
  rows: { mutations: :mutation_rows },
72
73
  reads: { attributes: [:each, :shape_field], givens: [:each, :rule], ensures: [:each, :rule],
73
- mutations: [:call, :mutations], emits: :names, provenance: :provenance, from: :from },
74
+ needs: [:each, :need], mutations: [:call, :mutations], emits: :names, provenance: :provenance,
75
+ from: :from },
74
76
  derived: { position: :walk }
75
77
  ),
76
78
 
@@ -74,6 +74,9 @@ module Hecks
74
74
  Given.new(description: rule[:description], canonical: rule[:canonical])
75
75
  end
76
76
 
77
+ # One outside fact a command needs, as the Symbol `Command#needs` holds.
78
+ def need(row) = row[:fact].to_sym
79
+
77
80
  # All three fields are identifiers, unlike Invariant/Given's free text, so they
78
81
  # come back as Symbols.
79
82
  def projected_field(row)
@@ -54,6 +54,7 @@ module Hecks
54
54
  attributes: many(:attributes),
55
55
  givens: -> { givens.map { |rule| Expression::AstJson.rule_row(rule) } },
56
56
  ensures: -> { ensures.map { |rule| Expression::AstJson.rule_row(rule) } },
57
+ needs: -> { needs.map { |fact| { fact: fact.to_s } } },
57
58
  mutations: many(:mutations),
58
59
  emits: :emits,
59
60
  # The lifecycle state this command is admissible from: a guard, not a transition.
@@ -62,7 +63,7 @@ module Hecks
62
63
  )
63
64
 
64
65
  class << self
65
- attr_reader :role, :goal, :attributes, :givens, :ensures, :mutations, :emits, :references,
66
+ attr_reader :role, :goal, :attributes, :givens, :ensures, :needs, :mutations, :emits, :references,
66
67
  :from, :provenance
67
68
 
68
69
  # Mints one command as its own anonymous subclass of the class `declare` is called on.
@@ -71,18 +72,18 @@ module Hecks
71
72
  # @param role [String, nil] the declared role text; `goal` is the goal text
72
73
  # @param attributes [Array<Bluebook::Attribute>] the declared arguments
73
74
  # @param givens [Array<Bluebook::Given>] the declared preconditions
74
- # @param ensures [Array<Bluebook::Given>] the declared postconditions
75
+ # @param ensures [Array<Bluebook::Given>] the postconditions; `needs` the facts it supplies
75
76
  # @param mutations [Array<Bluebook::Mutation>] the state changes it applies
76
77
  # @param emits [Array<String>] the event names it may emit
77
78
  # @param references [String, Symbol, nil] the aggregate its `reference_to` addresses
78
79
  # @param from [String, Array<String>, nil] the lifecycle states it is admissible from
79
80
  # @param provenance [Object, nil] the declared canonical source, as written
80
- def declare(name:, role: nil, goal: nil, attributes: [], givens: [], ensures: [],
81
+ def declare(name:, role: nil, goal: nil, attributes: [], givens: [], ensures: [], needs: [],
81
82
  mutations: [], emits: [], references: nil, from: nil, provenance: nil)
82
83
  verb = Class.new(self)
83
84
  verb.hecks_name = name.to_s
84
85
  verb.absorb(role: role, goal: goal, attributes: attributes, givens: givens,
85
- ensures: ensures, mutations: mutations, emits: emits, references: references&.to_s,
86
+ ensures: ensures, needs: needs, mutations: mutations, emits: emits, references: references&.to_s,
86
87
  from: from, provenance: provenance)
87
88
  verb
88
89
  end
@@ -93,12 +94,13 @@ module Hecks
93
94
  #
94
95
  # @return [Class] self
95
96
  def absorb(role:, goal:, attributes:, givens:, ensures:, mutations:, emits:, references:,
96
- from: nil, provenance: nil)
97
+ needs: [], from: nil, provenance: nil)
97
98
  @role = role
98
99
  @goal = goal
99
100
  @attributes = attributes
100
101
  @givens = givens
101
102
  @ensures = ensures
103
+ @needs = needs
102
104
  @mutations = mutations
103
105
  @emits = emits
104
106
  @references = references
@@ -41,6 +41,7 @@ module Hecks
41
41
  ["Command", "then_set"] => :then_set_impl,
42
42
  ["Command", "delegates_to"] => :delegates_to_impl,
43
43
  ["Command", "attribute"] => :attribute_impl,
44
+ ["Command", "needs"] => :needs_impl,
44
45
  ["Command", "corrects"] => :corrects_impl,
45
46
  ["Entity", "identified_by"] => :identified_by_impl,
46
47
  ["Entity", "given"] => :given_impl,
@@ -22,6 +22,7 @@ module Hecks
22
22
  @owner = owner
23
23
  @givens = []
24
24
  @ensures = []
25
+ @needs = []
25
26
  @mutations = []
26
27
  @emits = []
27
28
  @named_givens = named_givens
@@ -66,6 +67,31 @@ module Hecks
66
67
  # @return [String] the description as stored
67
68
  def goal(value) = @goal = value
68
69
 
70
+ # The outside facts a command may declare it needs, each the name of the attribute the
71
+ # runtime fills when the caller leaves it out. `now` is the clock port's answer, in epoch
72
+ # seconds.
73
+ NEEDABLE_FACTS = %i[now].freeze
74
+
75
+ # Declares an outside fact the runtime supplies before any given runs (ADR 0081):
76
+ # `needs :now` fills the command's own `now` attribute from the clock port when the caller
77
+ # names none.
78
+ #
79
+ # @param fact [Symbol] one of `NEEDABLE_FACTS`
80
+ # @return [Array<Symbol>] the facts declared so far
81
+ # @raise [Bluebook::DSL::Malformed] if the fact is not one the runtime can supply, or is
82
+ # declared twice
83
+ def needs_impl(fact)
84
+ fact = fact.to_sym
85
+ unless NEEDABLE_FACTS.include?(fact)
86
+ raise Malformed,
87
+ "#{@name} needs :#{fact}, which the runtime cannot supply — it supplies " \
88
+ "#{NEEDABLE_FACTS.map { |known| ":#{known}" }.join(', ')}"
89
+ end
90
+ raise Malformed, "#{@name} declares needs :#{fact} twice" if @needs.include?(fact)
91
+
92
+ @needs << fact
93
+ end
94
+
69
95
  # Names where a concept adopted from a canonical source came from.
70
96
  #
71
97
  # @param from [Object] the canonical source, captured exactly as written
@@ -292,6 +318,7 @@ module Hecks
292
318
  def build
293
319
  resolve_implicit_attributes!
294
320
  refuse_duplicate_targets!
321
+ refuse_undeclared_needs!
295
322
 
296
323
  delegation = @mutations.find { |mutation| mutation.op == :delegate }
297
324
  if delegation && (@mutations.size > 1 || @emits.any?)
@@ -308,6 +335,7 @@ module Hecks
308
335
  attributes: attributes,
309
336
  givens: @givens,
310
337
  ensures: @ensures,
338
+ needs: @needs,
311
339
  mutations: @mutations,
312
340
  emits: @emits,
313
341
  references: @references,
@@ -316,6 +344,20 @@ module Hecks
316
344
  )
317
345
  end
318
346
 
347
+ # A fact is filled into the argument of the same name, so the command declares one.
348
+ #
349
+ # @raise [Bluebook::DSL::Malformed] if a needed fact has no attribute to fill
350
+ def refuse_undeclared_needs!
351
+ declared = attributes.map { |attribute| attribute.name.to_s }
352
+ missing = @needs.reject { |fact| declared.include?(fact.to_s) }
353
+ return if missing.empty?
354
+
355
+ raise Malformed,
356
+ "#{@name} needs :#{missing.first} but declares no attribute :#{missing.first} " \
357
+ "for the runtime to fill — add `attribute :#{missing.first}, <type>`"
358
+ end
359
+ private :refuse_undeclared_needs!
360
+
319
361
  # Evaluates a `command` block against a fresh builder and returns what it built.
320
362
  #
321
363
  # @param name [String] the command's name
@@ -40,6 +40,7 @@ module Hecks
40
40
  case rule.strategy
41
41
  when "collapse_whitespace" then map_outside_strings(text) { |segment| segment.gsub(/\s+/, " ") }
42
42
  when "replace" then replace(text, rule)
43
+ when "scale_call" then scale_call(text, rule)
43
44
  else
44
45
  raise ArgumentError, "#{rule.strategy.inspect} is not a linked normalisation strategy"
45
46
  end
@@ -55,6 +56,17 @@ module Hecks
55
56
  end
56
57
  end
57
58
 
59
+ # Folds a duration written as a call on a whole number into seconds: `days(730)` becomes
60
+ # `63072000` when the rule's token is `days` and its replacement `86400`. A call on anything
61
+ # but a literal whole number is left as written, and a method call (`x.days(3)`) is not a
62
+ # duration.
63
+ def scale_call(text, rule)
64
+ call = /(?<![[:alnum:]_.])#{Regexp.escape(rule.source_token)}\(\s*(\d+)\s*\)/
65
+ map_outside_strings(text) do |segment|
66
+ segment.gsub(call) { (Regexp.last_match(1).to_i * rule.replacement.to_i).to_s }
67
+ end
68
+ end
69
+
58
70
  # Yields each run of `text` outside quoted literals, copying quoted runs through verbatim.
59
71
  # Quote-blind rewriting would change what a predicate compares a string against.
60
72
  # Handles `"` and `'`; an unterminated quote is passed through raw.
@@ -225,6 +225,27 @@
225
225
  "replacement": ".size",
226
226
  "boundary": "word",
227
227
  "position": 2
228
+ },
229
+ {
230
+ "strategy": "scale_call",
231
+ "source_token": "days",
232
+ "replacement": "86400",
233
+ "boundary": "word",
234
+ "position": 3
235
+ },
236
+ {
237
+ "strategy": "scale_call",
238
+ "source_token": "hours",
239
+ "replacement": "3600",
240
+ "boundary": "word",
241
+ "position": 4
242
+ },
243
+ {
244
+ "strategy": "scale_call",
245
+ "source_token": "minutes",
246
+ "replacement": "60",
247
+ "boundary": "word",
248
+ "position": 5
228
249
  }
229
250
  ]
230
251
  }
@@ -83,6 +83,9 @@ module Hecks
83
83
  # A rule's plain description/canonical pair, for a `given`/`invariant`/`ensures`.
84
84
  def rule(row) = { description: text(row[:description]), canonical: text(row[:canonical]) }
85
85
 
86
+ # One outside fact a command needs, in the shape `Command#to_h` emits it.
87
+ def need(row) = { fact: text(row[:fact]) }
88
+
86
89
  # `projects :name, from: :"reference.remote_field"` (ADR 0025), read back as
87
90
  # its plain name/reference/remote_field triple.
88
91
  def projected_field(row)
@@ -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,16 +205,19 @@ 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
- # database is opened. Only a line that runs a verb boots the domain.
219
+ # database is opened. Only a line that runs a verb boots the domain,
220
+ # and it boots from what the usage check already loaded.
216
221
  domain = #{boot}
217
222
  program = "#{shown}"
218
223
  described = begin
@@ -226,10 +231,13 @@ module Hecks
226
231
  )
227
232
  unless text
228
233
  runtime = begin
229
- Hecks.boot(domain, install_doors: false)
234
+ #{started}
230
235
  rescue StandardError => e
231
236
  abort "cannot open #{name}: \#{e.message.lines.first.strip}"
232
237
  end
238
+ # A question the world lists under `streams`, given `--stream`, tails until interrupted.
239
+ streamed = Hecks::Doors::CliRunner.stream(runtime: runtime, argv: ARGV, program: program)
240
+ exit streamed unless streamed.nil?
233
241
  text, status, reason = Hecks::Doors::CliRunner.call(
234
242
  runtime: runtime, argv: ARGV, program: program
235
243
  )
@@ -260,6 +268,31 @@ module Hecks
260
268
  raise ArgumentError, "#{what} #{value.to_s.inspect} is not a plain word or path"
261
269
  end
262
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
+
263
296
  # @api private
264
297
  # @return [String] the lines that hand the legacy verbs to `Hecks::CLI`, or nothing
265
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
@@ -20,6 +20,9 @@ Hecks.bluebook "Deploy" do
20
20
  attribute :timeout, Timeout
21
21
  attribute :database, Database
22
22
  attribute :web, Web
23
+ attribute :dispatch, Dispatch, optional: true
24
+ attribute :handler_module, HandlerModule, optional: true
25
+ attribute :secret_env, SecretEnv, optional: true
23
26
 
24
27
  value_object "DomainName" do
25
28
  attribute :value, String
@@ -47,6 +50,27 @@ Hecks.bluebook "Deploy" do
47
50
  attribute :value, String, one_of: ["None", "Rust"]
48
51
  end
49
52
 
53
+ # "Rust" (default) or "None": a domain with no `.bluebook` command surface to dispatch through
54
+ # (a webhook that dispatches straight into Ruby) skips the rust/host dispatch Lambda, and its
55
+ # WebFunction becomes its only Lambda. That WebFunction's `lambda_handler.rb` defines the module
56
+ # `handler_module` names, so `dispatch "None"` must name one.
57
+ value_object "Dispatch" do
58
+ attribute :value, String, one_of: ["Rust", "None"]
59
+ end
60
+
61
+ # The module the domain's `lambda_handler.rb` defines for its WebFunction.
62
+ value_object "HandlerModule" do
63
+ attribute :value, String
64
+ invariant("a handler module is named") { !value.to_s.empty? }
65
+ end
66
+
67
+ # The env var the WebFunction's `lambda_handler.rb` finds the fetched secret's plaintext under,
68
+ # once it resolves `<name>_ARN`.
69
+ value_object "SecretEnv" do
70
+ attribute :value, String
71
+ invariant("a secret env var is named") { !value.to_s.empty? }
72
+ end
73
+
50
74
  # 128-10240 MB, AWS Lambda's own floor and ceiling.
51
75
  value_object "Memory" do
52
76
  attribute :value, Integer
@@ -73,6 +97,13 @@ Hecks.bluebook "Deploy" do
73
97
  attribute :timeout, Timeout
74
98
  attribute :database, Database
75
99
  attribute :web, Web
100
+ attribute :dispatch, Dispatch, optional: true
101
+ attribute :handler_module, HandlerModule, optional: true
102
+ attribute :secret_env, SecretEnv, optional: true
103
+
104
+ given('dispatch "None" names its handler_module: add handler_module "YourModuleName" for the module lambda_handler.rb defines') do
105
+ dispatch.unset? || dispatch.value != "None" || handler_module.set?
106
+ end
76
107
 
77
108
  sets :domain
78
109
  sets :region
@@ -80,6 +111,9 @@ Hecks.bluebook "Deploy" do
80
111
  sets :timeout
81
112
  sets :database
82
113
  sets :web
114
+ sets :dispatch
115
+ sets :handler_module
116
+ sets :secret_env
83
117
 
84
118
  emits "LambdaTargetDeclared"
85
119
  end