hecks 3.0.3 → 3.0.4

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +6 -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/project_cli.rb +6 -2
  12. data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
  13. data/lib/hecks/doors/cli_runner.rb +57 -12
  14. data/lib/hecks/doors/launcher_options.rb +23 -0
  15. data/lib/hecks/fuzzing/properties.rb +1 -1
  16. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
  17. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
  18. data/lib/hecks/gate/stages.yml +51 -0
  19. data/lib/hecks/grammar/expression.bluebook +6 -1
  20. data/lib/hecks/grammar/expression_operators.json +135 -0
  21. data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
  22. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
  23. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
  24. data/lib/hecks/hecks/codebase.behaviors +12 -0
  25. data/lib/hecks/hecks/codebase.bluebook +194 -0
  26. data/lib/hecks/hecks/custodian.bluebook +2 -0
  27. data/lib/hecks/hecks/hecks.hecksagon +14 -0
  28. data/lib/hecks/hecks/hecks.world +3 -2
  29. data/lib/hecks/language/bluebook/command.bluebook +26 -0
  30. data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
  31. data/lib/hecks/language/oidc.json +5 -0
  32. data/lib/hecks/ports/clock.rb +3 -1
  33. data/lib/hecks/projections/deploy/lambda.rb +3 -4
  34. data/lib/hecks/quality_control/quality_control.bluebook +6 -0
  35. data/lib/hecks/runtime/command_interpreter.rb +6 -4
  36. data/lib/hecks/runtime/entity_interpreter.rb +5 -2
  37. data/lib/hecks/runtime/interpreting.rb +29 -0
  38. data/lib/hecks/runtime/invocation.rb +5 -1
  39. data/lib/hecks/runtime/loader.rb +16 -13
  40. data/lib/hecks/runtime.rb +11 -0
  41. data/lib/hecks/three_zero/forms.yml +1 -1
  42. data/lib/hecks/tools/ci_gate_decision.rb +109 -0
  43. data/lib/hecks/tools/ci_gates.rb +124 -0
  44. data/lib/hecks/tools/gate.rb +117 -0
  45. data/lib/hecks/tools.rb +3 -0
  46. data/lib/hecks/version.rb +1 -1
  47. data/lib/hecks/vocabulary.rb +6 -1
  48. data/lib/hecks.rb +12 -2
  49. data/rust/codegen/src/naming.rs +13 -2
  50. data/rust/host/HECKS_RELEASE +1 -1
  51. data/rust/host/src/dispatch.rs +207 -1
  52. data/rust/host/src/main.rs +1 -0
  53. data/rust/host/src/needs.rs +243 -0
  54. data/rust/host/src/resend.rs +52 -10
  55. data/rust/host/src/server.rs +7 -3
  56. data/rust/parser/src/canonical.rs +137 -2
  57. data/rust/parser/src/emit.rs +25 -19
  58. data/rust/parser/src/ir.rs +2 -0
  59. data/rust/parser/src/keywords.rs +2 -0
  60. data/rust/parser/src/main.rs +1 -0
  61. data/rust/parser/src/parse/command.rs +44 -0
  62. data/rust/project/naming.rb +4 -2
  63. data/rust/src/kernel/cli.rs +3 -0
  64. data/rust/src/kernel/mod.rs +1 -0
  65. data/rust/src/kernel/needs.rs +194 -0
  66. data/rust/src/kernel/orchestrate.rs +84 -0
  67. data/rust/src/kernel/repository.rs +43 -2
  68. metadata +9 -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: 90a718a197fae0287e4fe16033540bf625166bc31bfb3482e0752d1039a2a949
4
+ data.tar.gz: 15fe27abd9a488dfe4f579118f8e8ffbea1302b956ee36fbd2b9475eb3b88429
5
5
  SHA512:
6
- metadata.gz: 9d7a89486e004a7e786177ee9debfb4fcdc1e331611da9293a29cc2e16e0b4f2b388d718d3062e7b236e27b2655f16282e6e1b1065bb151fcdd8bb67c839a34f
7
- data.tar.gz: 9713d8af8eb4eebdf7885e0ec9821e304f0d6be393e22968c32b971e83d920e6f4ecfa52ce3ee46a19336de8a4e50d5c70d20c443784042aeac5607046ceb9ce
6
+ metadata.gz: d74d07807e22e8899c041adfa96d799b5eb47713a0dec6c05bbb9e61a579a32a35da890b67aaf876b1ba5182e0961a112f370e0e04adcf2719f8ae57a50cfd45
7
+ data.tar.gz: 11e85c77dc6a4f5d34ec3acd54e41873be8d03b980e4ef97a8d0d7c9da4b63ecfc2b04e2a1322f8c1536ad1d58f8f85f50df2ca58f3d6fea44126cb871c05b0d
data/exe/hecks CHANGED
@@ -24,7 +24,8 @@ end
24
24
  require "hecks"
25
25
 
26
26
  # 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.
27
+ # database is opened. Only a line that runs a verb boots the domain,
28
+ # and it boots from what the usage check already loaded.
28
29
  domain = File.expand_path("../lib/hecks/hecks", __dir__)
29
30
  program = "hecks"
30
31
  described = begin
@@ -38,10 +39,13 @@ text, status, reason = Hecks::Doors::CliRunner.usage(
38
39
  )
39
40
  unless text
40
41
  runtime = begin
41
- Hecks.boot(domain, install_doors: false)
42
+ Hecks.boot_described(described, install_doors: false)
42
43
  rescue StandardError => e
43
44
  abort "cannot open Hecks: #{e.message.lines.first.strip}"
44
45
  end
46
+ # A question the world lists under `streams`, given `--stream`, tails until interrupted.
47
+ streamed = Hecks::Doors::CliRunner.stream(runtime: runtime, argv: ARGV, program: program)
48
+ exit streamed unless streamed.nil?
45
49
  text, status, reason = Hecks::Doors::CliRunner.call(
46
50
  runtime: runtime, argv: ARGV, program: program
47
51
  )
@@ -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)
@@ -212,7 +212,8 @@ module Hecks
212
212
  def described_entry(name, boot, shown)
213
213
  <<~RUBY.chomp
214
214
  # 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.
215
+ # database is opened. Only a line that runs a verb boots the domain,
216
+ # and it boots from what the usage check already loaded.
216
217
  domain = #{boot}
217
218
  program = "#{shown}"
218
219
  described = begin
@@ -226,10 +227,13 @@ module Hecks
226
227
  )
227
228
  unless text
228
229
  runtime = begin
229
- Hecks.boot(domain, install_doors: false)
230
+ Hecks.boot_described(described, install_doors: false)
230
231
  rescue StandardError => e
231
232
  abort "cannot open #{name}: \#{e.message.lines.first.strip}"
232
233
  end
234
+ # A question the world lists under `streams`, given `--stream`, tails until interrupted.
235
+ streamed = Hecks::Doors::CliRunner.stream(runtime: runtime, argv: ARGV, program: program)
236
+ exit streamed unless streamed.nil?
233
237
  text, status, reason = Hecks::Doors::CliRunner.call(
234
238
  runtime: runtime, argv: ARGV, program: program
235
239
  )
@@ -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
@@ -34,6 +34,62 @@ module Hecks
34
34
  bluebook: plan[:bluebook], launcher: plan[:launcher])
35
35
  end
36
36
 
37
+ # Tails a question: asks, prints each new entry as one JSON line, and asks again from the
38
+ # cursor the answer gave, until the reader interrupts. Only for a question the `launcher`
39
+ # setting lists under `streams`, given `--stream`; `from_now` applies to the first ask only.
40
+ #
41
+ # @param runtime [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted domain
42
+ # @param argv [Array<String>] as for `call`, with `--stream`
43
+ # @param program [String] how the caller was invoked
44
+ # @param out [IO] where each entry's line goes
45
+ # @param err [IO] where a refusal goes
46
+ # @param max_polls [Integer, nil] stop after this many asks; unbounded when nil
47
+ # @return [Integer, nil] 0 or 1 for a stream, nil when `call` should run the line
48
+ def stream(runtime:, argv:, program: "hecks run", out: $stdout, err: $stderr, max_polls: nil)
49
+ plan = resolve(runtime, argv, program)
50
+ return if plan[:answer] || !LauncherOptions.streams?(plan[:launcher], plan[:spec])
51
+
52
+ words, streaming = LauncherOptions.take_stream(plan[:rest])
53
+ return unless streaming
54
+
55
+ tail(runtime, plan[:spec], CliDoor.arguments(plan[:spec], words), out, max_polls)
56
+ 0
57
+ rescue Interrupt, Errno::EPIPE
58
+ 0
59
+ rescue Runtime::NotFound, Runtime::TypeMismatch => e
60
+ err.puts("#{e.message}\n\n #{program} #{plan[:name]} --help")
61
+ 1
62
+ rescue *Runtime::DOMAIN_REFUSALS => e
63
+ err.puts(e.message)
64
+ 1
65
+ end
66
+
67
+ # One ask after another, each from the cursor the last gave.
68
+ def tail(runtime, spec, args, out, max_polls)
69
+ args = args.merge(wait: { value: 30 }) unless args.key?(:wait)
70
+ polls = 0
71
+ cursor = nil
72
+ loop do
73
+ ask = cursor ? args.except(:from_now).merge(since: { value: cursor }) : args
74
+ rows = runtime.query(spec[:verb], **ask)
75
+ row = rows.first || {}
76
+ Array(row[:events]).each { |event| out.puts(JSON.generate(entry_line(JsonDoor.materialize(event)))) }
77
+ out.flush
78
+ cursor = row[:cursor]
79
+ polls += 1
80
+ break if cursor.nil? || (max_polls && polls >= max_polls)
81
+ end
82
+ end
83
+
84
+ # An entry as a line: its payload, which the answer holds as JSON text, back as an object.
85
+ def entry_line(event)
86
+ line = JSON.parse(JSON.generate(event))
87
+ line["payload"] = JSON.parse(line["payload"]) if line["payload"].is_a?(String)
88
+ line
89
+ rescue JSON::ParserError
90
+ line
91
+ end
92
+
37
93
  # Answers a command line that asks only for usage, from the projection alone.
38
94
  #
39
95
  # Needs a registry and no bound adapter, so a `Runtime::Loader::Described` serves: the
@@ -110,7 +166,7 @@ module Hecks
110
166
  # @param launcher [Hash, nil] the chapter's `launcher` world setting; nil when not opted in
111
167
  def dispatch(runtime, spec, name, rest, program, asking, bluebook: nil, launcher: nil)
112
168
  rest, wait = LauncherOptions.take_wait(spec, rest) if launcher
113
- args = stamp_time(runtime, spec, CliDoor.arguments(spec, rest))
169
+ args = CliDoor.arguments(spec, rest)
114
170
 
115
171
  return answer_query(runtime, spec, args, wait) if spec[:kind] == :query
116
172
 
@@ -231,17 +287,6 @@ module Hecks
231
287
  rows.first[:text]
232
288
  end
233
289
 
234
- # Fills in a `now` argument the caller left out, from the clock port; an explicit one wins.
235
- #
236
- # Done at the door, not in the runtime: a clock read inside the interpreter would give a
237
- # replayed corpus step the replay day's time. Matched by the argument's name alone.
238
- def stamp_time(runtime, spec, args)
239
- return args unless spec[:arguments].any? { |argument| argument[:path].start_with?("now.") }
240
- return args if args.key?(:now)
241
-
242
- args.merge(now: { value: Ports::Clock.now(runtime.registry) })
243
- end
244
-
245
290
  # The reactions one dispatch caused that the domain refused, as `refused_reactions:`.
246
291
  #
247
292
  # A policy's trigger that a `given` refuses is not the command's own refusal: the command
@@ -15,9 +15,11 @@ module Hecks
15
15
  # - **run_keys** mints the `run` key of a creating command that was given none.
16
16
  # - **failure_states** are the lifecycle states `--wait` reports as a failure (exit 1).
17
17
  # - **names** maps a launcher name to the command it stands for (see `CliProjector`).
18
+ # - **streams** lists the questions `--stream` may tail, one JSON line per new entry.
18
19
  module LauncherOptions
19
20
  SETTING = "launcher".freeze
20
21
  WAIT = "--wait".freeze
22
+ STREAM = "--stream".freeze
21
23
  RUN_KEY = "run.value".freeze
22
24
 
23
25
  module_function
@@ -67,6 +69,27 @@ module Hecks
67
69
  [rest, wait]
68
70
  end
69
71
 
72
+ # Whether a question may be tailed with `--stream`: the chapter's `launcher` setting lists it
73
+ # under `streams`, by the question's own name.
74
+ #
75
+ # @param launcher [Hash, nil] the chapter's `launcher` setting
76
+ # @param spec [Hash] the question's projected spec
77
+ # @return [Boolean] true for a question the setting names
78
+ def streams?(launcher, spec)
79
+ return false unless launcher && spec[:kind] == :query
80
+
81
+ Array(launcher[:streams]).map(&:to_s).include?(spec[:verb].to_s.split(/[.:]+/).last)
82
+ end
83
+
84
+ # Takes `--stream` out of a question's words.
85
+ #
86
+ # @param words [Array<String>] the words after the question
87
+ # @return [Array(Array<String>, Boolean)] the remaining words, and whether `--stream` was
88
+ # given
89
+ def take_stream(words)
90
+ [words.reject { |word| word == STREAM }, words.include?(STREAM)]
91
+ end
92
+
70
93
  # @api private
71
94
  def wait_word?(word) = word == WAIT || word.start_with?("#{WAIT}=")
72
95
 
@@ -39,7 +39,7 @@ module Hecks
39
39
  query_answers_match_reference: %w[Query#wheres Query#order_field Query#order_way Query#limit],
40
40
  paging_offset_partitions_correctly: %w[Query#options],
41
41
  authorize_scopes_or_refuses: %w[Query#options],
42
- guard_refusals_are_declared: %w[Command#givens Command#ensures],
42
+ guard_refusals_are_declared: %w[Command#givens Command#ensures Command#needs],
43
43
  lifecycle_guard_and_given_violations_are_refused: %w[Command#from Aggregate#preconditions Entity#preconditions],
44
44
  # Dispatch#command_name/with_spec aren't claimable feature names — the
45
45
  # coverage walk only reaches one level of entity nesting and Dispatch
@@ -212,9 +212,10 @@ module Hecks
212
212
  if mapped.empty? && !entry.key?(:entity) && command.creates?
213
213
  mapped = command.attributes.map { |attribute| attribute.name.to_s }
214
214
  end
215
+ needed = needed_facts_of(entry)
215
216
  command.attributes.select do |attribute|
216
217
  name = attribute.name.to_s
217
- mapped.include?(name) && args.key?(name) && !heads.include?(name)
218
+ mapped.include?(name) && args.key?(name) && !heads.include?(name) && !needed.include?(name)
218
219
  end
219
220
  end
220
221
 
@@ -352,12 +353,19 @@ module Hecks
352
353
  end
353
354
 
354
355
  def droppable_required_attributes(args, entry)
355
- heads = identity_heads_of(entry)
356
+ heads = identity_heads_of(entry)
357
+ needed = needed_facts_of(entry)
356
358
  entry[:command].attributes.reject(&:optional?).select do |attribute|
357
- args.key?(attribute.name.to_s) && !heads.include?(attribute.name.to_s)
359
+ name = attribute.name.to_s
360
+ args.key?(name) && !heads.include?(name) && !needed.include?(name)
358
361
  end
359
362
  end
360
363
 
364
+ # The facts the command `needs`: the runtime answers them when a step leaves them out
365
+ # (each engine from its own clock), so a recorded step carries them and dropping one is not
366
+ # an absent-argument case.
367
+ def needed_facts_of(entry) = entry[:command].needs.map(&:to_s)
368
+
361
369
  # Aims a grant at a role some command declares; random role text would make `actor_known`
362
370
  # unreachable. Does nothing without the role draw.
363
371
  def steer_grant!(args, entry, catalog)
@@ -39,7 +39,7 @@ module Hecks
39
39
  # The caller draw and then the dry-run coin follow the mutation. The order
40
40
  # is part of the seed contract; both draw nothing when off.
41
41
  def build_command_step(runtime, catalog, entry)
42
- args = args_for(entry[:command].attributes, entry[:aggregate])
42
+ args = args_for(entry[:command].attributes, entry[:aggregate], needed: entry[:command].needs.map(&:to_s))
43
43
  add_identity!(args, entry)
44
44
  steer_grant!(args, entry, catalog)
45
45
  mutations = adversarial_mutations!(args, entry, catalog)
@@ -76,7 +76,9 @@ module Hecks
76
76
  Hecks.as_caller(role: caller["role"], actor_id: caller["actor_id"], &)
77
77
  end
78
78
 
79
- def args_for(attributes, aggregate)
79
+ # `needed` names the facts the runtime answers when a step leaves them out; a malformation
80
+ # never drops one, since each engine would fill it from its own clock.
81
+ def args_for(attributes, aggregate, needed: [])
80
82
  args = attributes.each_with_object({}) do |attribute, built|
81
83
  # Omitting an optional argument is an ordinary payload, not a malformation
82
84
  # (see OPTIONAL_OMITTED_PROBABILITY).
@@ -94,7 +96,7 @@ module Hecks
94
96
  end
95
97
  end
96
98
 
97
- malform(args, attributes, aggregate)
99
+ malform(args, attributes, aggregate, needed)
98
100
  end
99
101
 
100
102
  # An array of 0-3 independently generated elements shaped like the bare
@@ -108,21 +110,21 @@ module Hecks
108
110
 
109
111
  # At most one malformation per step, so the check that fired is identifiable.
110
112
  # The rate stays low because refused steps reach no state.
111
- def malform(args, attributes, aggregate)
113
+ def malform(args, attributes, aggregate, needed)
112
114
  return args if args.empty? || @random.rand >= MALFORMED_ARGUMENT_PROBABILITY
113
115
 
114
116
  case @random.rand(3)
115
117
  when 0 then corrupt_one(args, attributes, aggregate)
116
- when 1 then drop_one(args, aggregate)
118
+ when 1 then drop_one(args, aggregate, needed)
117
119
  else args.merge([InvalidValueGenerator.undeclared_argument(random: @random)].to_h)
118
120
  end
119
121
  end
120
122
 
121
123
  # Never drops the identity: an auto-minted id is unreproducible, so the
122
124
  # step's outcome could not be replayed.
123
- def drop_one(args, aggregate)
125
+ def drop_one(args, aggregate, needed)
124
126
  identity = (aggregate.identified_by || :id).to_s
125
- droppable = args.keys - [identity, "id"]
127
+ droppable = args.keys - [identity, "id"] - needed
126
128
  return args if droppable.empty?
127
129
 
128
130
  args.reject { |name, _| name == droppable.sample(random: @random) }
@@ -0,0 +1,51 @@
1
+ # The checks a stage runs before its change is let through. `hecks gate <stage>` reads this file and
2
+ # the git pre-push hook calls it, so the list of checks lives here and nowhere else.
3
+ #
4
+ # A check is one command run from the repository root, all of a stage's checks at once:
5
+ # id the name it is reported and selected by (`only=a,b`)
6
+ # title the heading its output is printed under
7
+ # run the program and its arguments; `{workers}` is half the machine's cores, at least one
8
+ # blocked what a red check means and what to do about it
9
+ #
10
+ # `env` is set only where the caller has not set it: a push check has no use for the Hecks
11
+ # domain's database, so it runs on Memory, as a pull-request run does in CI.
12
+ pre_push:
13
+ env:
14
+ HECKS_ENVIRONMENT: memory
15
+ checks:
16
+ - id: rspec
17
+ title: rspec (parallel)
18
+ run: [bundle, exec, parallel_rspec, spec, -n, "{workers}"]
19
+ blocked: the suite is red.
20
+ - id: fuzzing
21
+ title: spec/fuzzing, which the parallel run above excludes
22
+ run: [bundle, exec, rspec, spec/fuzzing, --tag, fuzzing]
23
+ blocked: spec/fuzzing is red.
24
+ - id: query_agreement
25
+ title: adapter agreement, one declared query and every engine giving the same answer
26
+ run: [bundle, exec, rspec, spec/adapters/query_agreement_spec.rb, --tag, io]
27
+ blocked: the engines disagree about a declared query; one of them is wrong, and a self-consistent adapter spec cannot tell which.
28
+ - id: engine_agreement
29
+ title: engine agreement, every declared comparator on both engines
30
+ run: [bundle, exec, exe/hecks, check_engine_agreement, --wait]
31
+ blocked: a comparator is missing a shared case or a cross-engine spec.
32
+ - id: model_check
33
+ title: model check, unreachable states, dead transitions and orphaned handlers
34
+ run: [bundle, exec, exe/hecks, model_check, --wait]
35
+ blocked: the model checker found something.
36
+ - id: doc_coverage
37
+ title: docs coverage, words with no running example
38
+ run: [bundle, exec, exe/hecks, measure_doc_coverage, --wait]
39
+ blocked: the language ships something undocumented; write the missing sections and examples.
40
+ - id: rubocop
41
+ title: rubocop, the static style and correctness gate
42
+ run: [bundle, exec, rubocop, -c, .rubocop.yml]
43
+ blocked: rubocop found an offense.
44
+ - id: codegen_drift
45
+ title: codegen drift, the committed rust/src/generated/ must match a fresh regeneration
46
+ run: [bundle, exec, exe/hecks, regenerate_corpus, --check, --wait]
47
+ blocked: regenerating hits a diff, so the committed Rust is stale; run `hecks regenerate_corpus --confirm` and commit the result.
48
+ - id: comment_blocks
49
+ title: comment blocks, no run of comment lines past ADR 0075's cap
50
+ run: [bundle, exec, exe/hecks, check_comments, "paths=lib,spec,examples", only=long_block, --wait]
51
+ blocked: a comment block ran past the cap; shorten it.