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
@@ -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
@@ -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"
@@ -15,9 +16,11 @@ module Hecks
15
16
  # - **run_keys** mints the `run` key of a creating command that was given none.
16
17
  # - **failure_states** are the lifecycle states `--wait` reports as a failure (exit 1).
17
18
  # - **names** maps a launcher name to the command it stands for (see `CliProjector`).
19
+ # - **streams** lists the questions `--stream` may tail, one JSON line per new entry.
18
20
  module LauncherOptions
19
21
  SETTING = "launcher".freeze
20
22
  WAIT = "--wait".freeze
23
+ STREAM = "--stream".freeze
21
24
  RUN_KEY = "run.value".freeze
22
25
 
23
26
  module_function
@@ -44,6 +47,23 @@ module Hecks
44
47
  { program: program, names: settings && settings[:names], mint_run_keys: settings && settings[:run_keys] }
45
48
  end
46
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
+
47
67
  # Takes `--wait` out of a verb's words, unless the verb declares a `wait` argument of its own.
48
68
  #
49
69
  # `--wait=false` (or `no`, `0`, `off`) is a `--wait` that was switched off; a bare `--wait`
@@ -67,6 +87,27 @@ module Hecks
67
87
  [rest, wait]
68
88
  end
69
89
 
90
+ # Whether a question may be tailed with `--stream`: the chapter's `launcher` setting lists it
91
+ # under `streams`, by the question's own name.
92
+ #
93
+ # @param launcher [Hash, nil] the chapter's `launcher` setting
94
+ # @param spec [Hash] the question's projected spec
95
+ # @return [Boolean] true for a question the setting names
96
+ def streams?(launcher, spec)
97
+ return false unless launcher && spec[:kind] == :query
98
+
99
+ Array(launcher[:streams]).map(&:to_s).include?(spec[:verb].to_s.split(/[.:]+/).last)
100
+ end
101
+
102
+ # Takes `--stream` out of a question's words.
103
+ #
104
+ # @param words [Array<String>] the words after the question
105
+ # @return [Array(Array<String>, Boolean)] the remaining words, and whether `--stream` was
106
+ # given
107
+ def take_stream(words)
108
+ [words.reject { |word| word == STREAM }, words.include?(STREAM)]
109
+ end
110
+
70
111
  # @api private
71
112
  def wait_word?(word) = word == WAIT || word.start_with?("#{WAIT}=")
72
113
 
@@ -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,55 @@
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_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.
52
+ - id: comment_blocks
53
+ title: comment blocks, no run of comment lines past ADR 0075's cap
54
+ run: [bundle, exec, exe/hecks, check_comments, "paths=lib,spec,examples", only=long_block, --wait]
55
+ blocked: a comment block ran past the cap; shorten it.
@@ -211,6 +211,11 @@ Hecks.bluebook "Expression" do
211
211
 
212
212
  member strategy: "collapse_whitespace", source_token: "", replacement: "", boundary: "none", position: 1
213
213
  member strategy: "replace", source_token: ".length", replacement: ".size", boundary: "word", position: 2
214
+ # A duration written as a call on a whole number is that many seconds: `days(730)` is
215
+ # `63072000`. `source_token` names the call and `replacement` the seconds one unit is worth.
216
+ member strategy: "scale_call", source_token: "days", replacement: "86400", boundary: "word", position: 3
217
+ member strategy: "scale_call", source_token: "hours", replacement: "3600", boundary: "word", position: 4
218
+ member strategy: "scale_call", source_token: "minutes", replacement: "60", boundary: "word", position: 5
214
219
  end
215
220
 
216
221
  value_object "Reading" do
@@ -233,7 +238,7 @@ Hecks.bluebook "Expression" do
233
238
  attribute :position, Position, optional: true
234
239
 
235
240
  given("a rule must name a linked strategy") do
236
- strategy.value == "collapse_whitespace" || strategy.value == "replace"
241
+ strategy.value == "collapse_whitespace" || strategy.value == "replace" || strategy.value == "scale_call"
237
242
  end
238
243
 
239
244
  given("a rule must state its boundary") { boundary.value == "none" || boundary.value == "word" }
@@ -1644,6 +1644,141 @@
1644
1644
  }
1645
1645
  }
1646
1646
  },
1647
+ {
1648
+ "verb": "Expression::Normalisation.Propose",
1649
+ "args": {
1650
+ "name": {
1651
+ "value": "days_to_seconds"
1652
+ },
1653
+ "strategy": {
1654
+ "value": "scale_call"
1655
+ },
1656
+ "source_token": {
1657
+ "value": "days"
1658
+ },
1659
+ "replacement": {
1660
+ "value": "86400"
1661
+ },
1662
+ "boundary": {
1663
+ "value": "word"
1664
+ },
1665
+ "position": {
1666
+ "value": 3
1667
+ }
1668
+ }
1669
+ },
1670
+ {
1671
+ "verb": "Expression::Normalisation.Read",
1672
+ "args": {
1673
+ "name": {
1674
+ "value": "days_to_seconds"
1675
+ },
1676
+ "target": {
1677
+ "value": "ruby"
1678
+ },
1679
+ "form": {
1680
+ "value": "text.gsub(/(?<![[:alnum:]_.])days\\(\\s*(\\d+)\\s*\\)/) { $1.to_i * 86400 }"
1681
+ }
1682
+ }
1683
+ },
1684
+ {
1685
+ "verb": "Expression::Normalisation.Admit",
1686
+ "args": {
1687
+ "name": {
1688
+ "value": "days_to_seconds"
1689
+ }
1690
+ }
1691
+ },
1692
+ {
1693
+ "verb": "Expression::Normalisation.Propose",
1694
+ "args": {
1695
+ "name": {
1696
+ "value": "hours_to_seconds"
1697
+ },
1698
+ "strategy": {
1699
+ "value": "scale_call"
1700
+ },
1701
+ "source_token": {
1702
+ "value": "hours"
1703
+ },
1704
+ "replacement": {
1705
+ "value": "3600"
1706
+ },
1707
+ "boundary": {
1708
+ "value": "word"
1709
+ },
1710
+ "position": {
1711
+ "value": 4
1712
+ }
1713
+ }
1714
+ },
1715
+ {
1716
+ "verb": "Expression::Normalisation.Read",
1717
+ "args": {
1718
+ "name": {
1719
+ "value": "hours_to_seconds"
1720
+ },
1721
+ "target": {
1722
+ "value": "ruby"
1723
+ },
1724
+ "form": {
1725
+ "value": "text.gsub(/(?<![[:alnum:]_.])hours\\(\\s*(\\d+)\\s*\\)/) { $1.to_i * 3600 }"
1726
+ }
1727
+ }
1728
+ },
1729
+ {
1730
+ "verb": "Expression::Normalisation.Admit",
1731
+ "args": {
1732
+ "name": {
1733
+ "value": "hours_to_seconds"
1734
+ }
1735
+ }
1736
+ },
1737
+ {
1738
+ "verb": "Expression::Normalisation.Propose",
1739
+ "args": {
1740
+ "name": {
1741
+ "value": "minutes_to_seconds"
1742
+ },
1743
+ "strategy": {
1744
+ "value": "scale_call"
1745
+ },
1746
+ "source_token": {
1747
+ "value": "minutes"
1748
+ },
1749
+ "replacement": {
1750
+ "value": "60"
1751
+ },
1752
+ "boundary": {
1753
+ "value": "word"
1754
+ },
1755
+ "position": {
1756
+ "value": 5
1757
+ }
1758
+ }
1759
+ },
1760
+ {
1761
+ "verb": "Expression::Normalisation.Read",
1762
+ "args": {
1763
+ "name": {
1764
+ "value": "minutes_to_seconds"
1765
+ },
1766
+ "target": {
1767
+ "value": "ruby"
1768
+ },
1769
+ "form": {
1770
+ "value": "text.gsub(/(?<![[:alnum:]_.])minutes\\(\\s*(\\d+)\\s*\\)/) { $1.to_i * 60 }"
1771
+ }
1772
+ }
1773
+ },
1774
+ {
1775
+ "verb": "Expression::Normalisation.Admit",
1776
+ "args": {
1777
+ "name": {
1778
+ "value": "minutes_to_seconds"
1779
+ }
1780
+ }
1781
+ },
1647
1782
  {
1648
1783
  "verb": "Expression::Operator.Propose",
1649
1784
  "args": {
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "tree"
4
+ require_relative "ruby_child"
5
+
6
+ module Hecks
7
+ module Adapters
8
+ module Codebase
9
+ # What Codebase's `GateRun` asks of the working tree: running a stage's checks.
10
+ #
11
+ # It runs `Hecks::Tools::Gate` in this process. The tool starts the stage's checks together,
12
+ # each as its own process, and answers 1 when any is red; the refusal is what the red checks
13
+ # printed, so `--wait` and the journal both say which check failed.
14
+ module Gate
15
+ # Every operation this family carries out.
16
+ OPERATIONS = %w[gate].freeze
17
+
18
+ module_function
19
+
20
+ # Runs the stage.
21
+ #
22
+ # @param _operation [String] `gate`
23
+ # @param held [Hash] the `GateRun` record's fields: `stage`, and `only` when given
24
+ # @param tree [Tree] the working tree, already known to be a hecks checkout
25
+ # @param shell [#capture, nil] unused: the tool runs in this process
26
+ # @return [String] which checks passed
27
+ # @raise [ConsoleCapture::Failure] with what the red checks printed
28
+ def call(_operation, held, tree, shell: nil)
29
+ plain = ->(name) { held[name].is_a?(Hash) ? held[name][:value] : held[name] }
30
+ only = plain.call(:only).to_s
31
+ argv = [plain.call(:stage).to_s, *("only=#{only}" unless only.empty?)]
32
+ RubyChild.new(tree).answer("gate", *argv).strip
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -7,7 +7,7 @@ module Hecks
7
7
  module Adapters
8
8
  module Codebase
9
9
  # What Codebase's `RegenerationRun` asks of the working tree: regenerating every corpus
10
- # domain's committed Rust output.
10
+ # domain's committed Rust output, and projecting the CI path gates into the workflows.
11
11
  #
12
12
  # It runs `Hecks::Tools::RegenerationRun` in this process: the run forks once for each
13
13
  # domain, in a fixed order, because the domains share the files they stamp. With `check` it
@@ -16,7 +16,7 @@ module Hecks
16
16
  # files reports drift and writes only when it is confirmed.
17
17
  module Regeneration
18
18
  # Every operation this family carries out.
19
- OPERATIONS = %w[regenerate_corpus].freeze
19
+ OPERATIONS = %w[regenerate_corpus project_ci_gates decide_ci_gate].freeze
20
20
 
21
21
  # How many lines of a script's output a refusal keeps.
22
22
  KEPT_LINES = 60
@@ -25,23 +25,43 @@ module Hecks
25
25
 
26
26
  # Carries out the regeneration.
27
27
  #
28
- # @param _operation [String] `regenerate_corpus`
28
+ # @param operation [String] `regenerate_corpus` or `project_ci_gates`
29
29
  # @param held [Hash] the `RegenerationRun` record's fields: `check`, `confirm`
30
30
  # @param tree [Tree] the working tree, already known to be a hecks checkout
31
31
  # @param shell [#capture, nil] unused: the tool runs in this process
32
32
  # @return [String] how many domains were checked or regenerated
33
33
  # @raise [ConsoleCapture::Failure] with the difference, when a check finds drift, or with
34
34
  # what the script printed when it ends badly
35
- def call(_operation, held, tree, shell: nil)
35
+ def call(operation, held, tree, shell: nil)
36
36
  flag = ->(name) { (held[name].is_a?(Hash) ? held[name][:value] : held[name]) == true }
37
37
  check = flag.call(:check) || !flag.call(:confirm)
38
38
  child = RubyChild.new(tree)
39
+ return project_ci_gates(child, check) if operation == "project_ci_gates"
40
+ return decide_ci_gate(child, held) if operation == "decide_ci_gate"
41
+
39
42
  result = child.capture("regen_codegen_domains", *("--check" if check))
40
43
  raise ConsoleCapture::Failure, refusal(result) unless result.ok?
41
44
 
42
45
  summary(result.out, check)
43
46
  end
44
47
 
48
+ # @param child [RubyChild] the checkout's tool runner
49
+ # @param check [Boolean] whether to only compare
50
+ # @return [String] what the tool printed
51
+ # @raise [ConsoleCapture::Failure] with the stale workflows, when a check finds drift
52
+ def project_ci_gates(child, check)
53
+ child.answer("project_ci_gates", *("--check" if check))
54
+ end
55
+
56
+ # @param child [RubyChild] the checkout's tool runner
57
+ # @param held [Hash] the record's fields: `gate`, a `CiGate` row's name
58
+ # @return [String] `touched=true` or `touched=false`
59
+ # @raise [ConsoleCapture::Failure] when no row has that name
60
+ def decide_ci_gate(child, held)
61
+ gate = held[:gate].is_a?(Hash) ? held[:gate][:value] : held[:gate]
62
+ child.answer("decide_ci_gate", "gate=#{gate}")
63
+ end
64
+
45
65
  # @param output [String] what the script printed
46
66
  # @param check [Boolean] whether it only compared
47
67
  # @return [String] the verdict, with how many domains it covered
@@ -5,6 +5,7 @@ require_relative "language"
5
5
  require_relative "kernel_tables"
6
6
  require_relative "conformance"
7
7
  require_relative "regeneration"
8
+ require_relative "gate"
8
9
  require_relative "style"
9
10
  require_relative "codemods"
10
11
  require_relative "test_suite"
@@ -178,7 +179,8 @@ module Hecks
178
179
 
179
180
  # The task families, each carrying out the operations it lists.
180
181
  FAMILIES = [Codebase::Language, Codebase::KernelTables, Codebase::Conformance, Codebase::Regeneration,
181
- Codebase::Style, Codebase::Codemods, Codebase::TestSuite, Codebase::CorpusTasks, Codebase::Publishing].freeze
182
+ Codebase::Gate, Codebase::Style, Codebase::Codemods, Codebase::TestSuite,
183
+ Codebase::CorpusTasks, Codebase::Publishing].freeze
182
184
  private_constant :FAMILIES
183
185
 
184
186
  def tree = Codebase::Tree.new
@@ -79,6 +79,18 @@ Hecks.behaviors "Codebase" do
79
79
  expect refused: "must match"
80
80
  end
81
81
 
82
+ test "ProjectCiGates refuses a run that is not named" do
83
+ tests "ProjectCiGates", on: "RegenerationRun"
84
+ input run: { value: "" }
85
+ expect refused: "must match"
86
+ end
87
+
88
+ test "DecideCiGate refuses a run that is not named" do
89
+ tests "DecideCiGate", on: "RegenerationRun"
90
+ input run: { value: "" }, gate: { value: "runtime_changed" }
91
+ expect refused: "must match"
92
+ end
93
+
82
94
  test "CheckComments keeps a file with no violations as a completed check" do
83
95
  tests "CheckComments", on: "StyleRun"
84
96
  input paths: { value: "lib/hecks/canonical_json.rb" }, run: { value: "clean" }