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
@@ -1,5 +1,6 @@
1
1
  require_relative "value"
2
2
  require_relative "aggregate_lock"
3
+ require_relative "../ports/clock"
3
4
 
4
5
  module Hecks
5
6
  module Runtime
@@ -13,8 +14,57 @@ module Hecks
13
14
  interpreter.singleton_class.attr_accessor :trace
14
15
  end
15
16
 
17
+ # What answers each outside fact a command may `needs` (ADR 0081). A fact is answered once,
18
+ # before any given runs, and the answer rides in the command's own arguments, so the event
19
+ # records it and a replay re-dispatches the recorded value rather than asking again.
20
+ NEED_ANSWERS = { now: ->(registry) { Ports::Clock.now(registry) } }.freeze
21
+
16
22
  private
17
23
 
24
+ # Fills each outside fact the command `needs` and the caller left out. A value the caller
25
+ # supplied is kept, so a test or a back-fill can name its own time.
26
+ #
27
+ # @param command [Class] the command being dispatched
28
+ # @param args [Hash{Symbol => Object}] the arguments the caller passed
29
+ # @return [Hash{Symbol => Object}] `args`, with each missing needed fact answered
30
+ # @raise [Runtime::WiringError] when the port that answers a fact is not bound exactly once
31
+ def enrich_arguments(command, args)
32
+ fill_defaults(command, fill_needs(command, args))
33
+ end
34
+
35
+ # Answers each fact the command `needs` that the caller left out.
36
+ def fill_needs(command, args)
37
+ missing = command.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
38
+ return args if missing.empty?
39
+
40
+ args.merge(missing.to_h { |fact| [fact, need_value(command, fact)] })
41
+ end
42
+
43
+ # Gives each argument the caller left out the default its attribute declares
44
+ # (`attribute :runs, Count, default: 30`), so a declared default holds on every way in and
45
+ # not only where a door spells it out. An argument the caller passed is kept, whatever it is.
46
+ #
47
+ # @param command [Class] the command being dispatched
48
+ # @param args [Hash{Symbol => Object}] the arguments the caller passed
49
+ # @return [Hash{Symbol => Object}] `args`, with each absent defaulted argument filled
50
+ def fill_defaults(command, args)
51
+ absent = command.attributes.reject do |attribute|
52
+ attribute.default.nil? || args.key?(attribute.name.to_sym) || args.key?(attribute.name.to_s)
53
+ end
54
+ return args if absent.empty?
55
+
56
+ args.merge(absent.to_h { |attribute| [attribute.name.to_sym, attribute.default] })
57
+ end
58
+
59
+ # The answer to one fact, in the shape the command's argument of that name takes: a bare
60
+ # Integer for an Integer attribute, else the one-field value object the language declares
61
+ # for an instant.
62
+ def need_value(command, fact)
63
+ answer = NEED_ANSWERS.fetch(fact).call(@registry)
64
+ attribute = command.attributes.find { |held| held.name.to_s == fact.to_s }
65
+ attribute&.type.to_s == "Integer" ? answer : { value: answer }
66
+ end
67
+
18
68
  # Logged after the step's work, so trace order is completion order.
19
69
  def step(name)
20
70
  result = yield
@@ -207,8 +207,14 @@ module Hecks
207
207
  declared: declared)
208
208
  end
209
209
 
210
+ # A fact the command `needs`, and an argument its attribute gives a default, are not absent:
211
+ # the interpreter fills them before any refusal reads the arguments.
210
212
  def refuse_absent_facts!(declaring, offered, declared)
211
- absent = declaring.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } - offered.keys
213
+ needed = declaring.respond_to?(:needs) ? declaring.needs.map(&:to_sym) : []
214
+ defaulted = declaring.attributes.select { |attribute| attribute.respond_to?(:default) && !attribute.default.nil? }
215
+ .map { |attribute| attribute.name.to_sym }
216
+ absent = declaring.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } -
217
+ offered.keys - needed - defaulted
212
218
  return if absent.empty?
213
219
 
214
220
  raise AbsentArgument,
@@ -28,18 +28,21 @@ module Hecks
28
28
  # @raise [Errno::ENOENT] if `path` names no domain directory
29
29
  # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
30
30
  def self.boot(path, shared: nil, install_doors: true, install_facade: nil, environment: FROM_ENV)
31
- loading = Ports::Loading.bootstrap
32
- directory = loading.bluebook_directory(path)
33
- root = loading.shared_root(shared, directory)
34
- registry = Registry.new(root: File.dirname(directory))
35
-
36
- Hecks.with_registry(registry) do
37
- loading.load_library
38
- loading.load_project(root)
39
- loading.load_domain(directory, environment: selected_environment(environment))
40
- end
31
+ described = describe(path, shared: shared, environment: environment)
32
+ boot_described(described, install_doors: install_doors, install_facade: install_facade)
33
+ end
41
34
 
42
- run_boot_gates!(registry, directory)
35
+ # Finishes a boot from declarations `describe` already loaded: runs every boot gate and binds
36
+ # the dispatcher, without reading the domain's files again.
37
+ #
38
+ # @param described [Described] what `describe` answered for the domain to boot
39
+ # @param install_doors [Boolean] install the `Widget::Item.Add(...)` global facade sugar
40
+ # @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`
41
+ # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted dispatcher
42
+ # @raise [Runtime::WiringError] if a boot gate finds a wiring problem
43
+ def self.boot_described(described, install_doors: true, install_facade: nil)
44
+ registry = described.registry
45
+ run_boot_gates!(registry, described.directory)
43
46
  dispatcher = dispatcher_for(registry)
44
47
  redrive_outbox!(dispatcher)
45
48
  seed_privacy_markings!(dispatcher, registry)
@@ -47,7 +50,7 @@ module Hecks
47
50
  end
48
51
 
49
52
  # What `describe` answers: the loaded declarations and nothing bound to run them.
50
- Described = Struct.new(:registry)
53
+ Described = Struct.new(:registry, :directory)
51
54
 
52
55
  # Loads `path`'s declarations into a fresh Registry and stops: no boot gate runs, no
53
56
  # persistence adapter is resolved or bound, nothing connects to a database.
@@ -71,7 +74,7 @@ module Hecks
71
74
  loading.load_project(root)
72
75
  loading.load_domain(directory, environment: selected_environment(environment))
73
76
  end
74
- Described.new(registry)
77
+ Described.new(registry, directory)
75
78
  end
76
79
 
77
80
  # The overlay a boot loads: the caller's own choice (nil meaning none), else the
data/lib/hecks/runtime.rb CHANGED
@@ -62,6 +62,17 @@ module Hecks
62
62
  Loader.describe(path, shared: shared, environment: environment)
63
63
  end
64
64
 
65
+ # Finishes a boot from declarations `describe` already loaded. See Loader.boot_described.
66
+ #
67
+ # @param described [Runtime::Loader::Described] what `describe` answered
68
+ # @param install_doors [Boolean] whether to install the Ruby facade constants
69
+ # @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`; warns
70
+ # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] the dispatcher bound
71
+ # to the booted domain
72
+ def boot_described(described, install_doors: true, install_facade: nil)
73
+ Loader.boot_described(described, install_doors: install_doors, install_facade: install_facade)
74
+ end
75
+
65
76
  # Loads only the given files of a domain; otherwise like `boot`. See Loader.boot_files.
66
77
  #
67
78
  # @param paths [String, Array<String>] one or more file paths within the domain
@@ -23,7 +23,7 @@ evolve: hecks word_status; hecks propose <word> context=X [body=none] [inner=] [
23
23
  [named=] [fills=] [pairs_shape=]; hecks admit_argument|deprecate_argument|retire_argument
24
24
  <word> context=X [at=N] [named=]
25
25
  expression_projection: hecks project_expression_tables [--stdout]
26
- follow: hecks follow <domain> [aggregate=Name] [since=N] [interval=0.5] [wait=N] [--from-now]
26
+ follow: hecks follow <domain> [aggregate=Name] [since=N] [interval=0.5] [wait=N] [--from-now] [--stream]
27
27
  fuzz: hecks fuzz [domain] [seeds=20] [steps=30] [workers=] [adapter=memory]
28
28
  generate: hecks generate_sequence <domain> [seed=1] [steps=30] [adversarial=0.0]
29
29
  hecks_mcp_door: hecks mcp [--stdio]
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "hecks/vocabulary"
6
+ require_relative "../tools"
7
+ require_relative "ci_gates"
8
+
9
+ module Hecks
10
+ module Tools
11
+ # Answers whether a change touches what a gated CI job covers: the decision a `CiGate` row of
12
+ # the Vocabulary chapter names, made by `hecks decide_ci_gate gate=<name>` in the detector job
13
+ # `hecks project_ci_gates` writes into each workflow.
14
+ #
15
+ # The change is read from the run's own event (`GITHUB_EVENT_PATH`) and head (`GITHUB_SHA`),
16
+ # and the answer goes to stdout and, when the runner names one, to `GITHUB_OUTPUT` as
17
+ # `touched=true` or `touched=false`. Every case that cannot confirm a safe diff answers
18
+ # `true`, so a failure here runs the gated job instead of skipping it.
19
+ module CiGateDecision
20
+ NO_COMMIT = "0" * 40
21
+
22
+ module_function
23
+
24
+ # @param argv [Array<String>] `gate=<name>`, a `CiGate` row's name
25
+ # @param root [String] the checkout whose history is diffed
26
+ # @param env [Hash{String => String}] the runner's environment
27
+ # @return [Integer] 0 once an answer is given
28
+ # @raise [SystemExit] when no row has that name
29
+ def main(argv, root: Tools::ROOT, env: ENV)
30
+ name = argv.filter_map { |arg| arg[/\Agate=(.+)\z/, 1] }.first
31
+ abort "decide_ci_gate: name a gate, gate=<name>" unless name
32
+ gate = CiGates.gates.find { |row| row["name"] == name }
33
+ abort "decide_ci_gate: no CiGate row named #{name}" unless gate
34
+
35
+ touched = touched?(gate, root: root, env: env)
36
+ puts "touched=#{touched}"
37
+ File.open(env["GITHUB_OUTPUT"], "a") { |file| file.puts "touched=#{touched}" } if env["GITHUB_OUTPUT"]
38
+ 0
39
+ end
40
+
41
+ # @param gate [Hash{String => String}] a `CiGate` row
42
+ # @param root [String] the checkout
43
+ # @param env [Hash{String => String}] the runner's environment
44
+ # @return [Boolean] whether the gated job should run
45
+ def touched?(gate, root:, env:)
46
+ base = base_of(gate, root: root, env: env)
47
+ if base.nil? || base.empty? || base == NO_COMMIT
48
+ explain(gate, "no usable base to diff against")
49
+ return true
50
+ end
51
+
52
+ changed = changed_files(base, env.fetch("GITHUB_SHA", "HEAD"), root: root)
53
+ if changed.nil?
54
+ explain(gate, "git diff itself failed")
55
+ return true
56
+ end
57
+ return false if changed.empty?
58
+
59
+ matches = changed.map { |path| path.match?(Regexp.new(gate.fetch("pattern"))) }
60
+ gate.fetch("mode") == "touches" ? matches.any? : matches.any?(false)
61
+ end
62
+
63
+ # The commit the change is measured against: a pull request's base, or a merge group's
64
+ # merge-base with its target branch, never the group's `base_sha`, which is the previous
65
+ # queue entry and would let an entry queued behind a red one diff as its own files only.
66
+ #
67
+ # @return [String, nil] the commit, or nil when there is none
68
+ def base_of(gate, root:, env:)
69
+ event = event_of(env)
70
+ base = event.dig("pull_request", "base", "sha").to_s
71
+ if env["GITHUB_EVENT_NAME"] == "merge_group"
72
+ target = event.dig("merge_group", "base_ref").to_s.delete_prefix("refs/heads/")
73
+ base = git(root, "merge-base", "origin/#{target}", env.fetch("GITHUB_SHA", "HEAD"))&.strip.to_s
74
+ end
75
+ base = event["before"].to_s if base.empty? && env["GITHUB_EVENT_NAME"] == "push" && gate["push"] == "before_sha"
76
+ base
77
+ end
78
+
79
+ # @return [Hash] the run's event payload, empty when it cannot be read
80
+ def event_of(env)
81
+ path = env["GITHUB_EVENT_PATH"].to_s
82
+ path.empty? || !File.file?(path) ? {} : JSON.parse(File.read(path))
83
+ rescue JSON::ParserError
84
+ {}
85
+ end
86
+
87
+ # @return [Array<String>, nil] the changed paths, or nil when git could not say
88
+ def changed_files(base, head, root:)
89
+ out = git(root, "diff", "--name-only", base, head)
90
+ return nil if out.nil?
91
+
92
+ out.lines.map(&:chomp).reject(&:empty?)
93
+ end
94
+
95
+ # @return [String, nil] a git command's stdout, or nil when it failed
96
+ def git(root, *)
97
+ out, _err, status = Open3.capture3("git", "-C", root, *)
98
+ status.success? ? out : nil
99
+ end
100
+
101
+ # Says why a gate could not be answered; the caller runs the gated job.
102
+ #
103
+ # @return [nil]
104
+ def explain(gate, reason)
105
+ warn "decide_ci_gate: #{reason} (#{gate['label']}): running the gated job rather than guessing"
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hecks/vocabulary"
4
+ require_relative "../tools"
5
+
6
+ module Hecks
7
+ module Tools
8
+ # Projects the `CiGate` rows of the Vocabulary chapter into the workflows: each row becomes a
9
+ # detector job between a pair of marker comments in its `workflow`. The job runs
10
+ # `hecks decide_ci_gate gate=<name>` (`CiGateDecision`), so the decision is made by the binary
11
+ # from the same row, never by shell the workflow carries.
12
+ #
13
+ # Everything outside the markers is hand-written and left alone. With `--check` nothing is
14
+ # written: the tool answers 1 and names each workflow whose region differs from the rows.
15
+ module CiGates
16
+ # Where the workflows live, relative to the checkout.
17
+ WORKFLOWS = ".github/workflows"
18
+
19
+ module_function
20
+
21
+ # @param argv [Array<String>] `--check` to compare without writing
22
+ # @param root [String] the checkout
23
+ # @return [Integer] 0, or 1 when `--check` finds a region out of date
24
+ # @raise [SystemExit] when a workflow has no marked region for a gate
25
+ def main(argv, root: Tools::ROOT)
26
+ stale = projection(root).reject { |path, text| File.read(path) == text }.keys
27
+ return report(stale, root) if argv.include?("--check")
28
+
29
+ stale.each { |path| File.write(path, projection(root).fetch(path)) }
30
+ stale.each { |path| puts "wrote #{path.delete_prefix("#{root}/")}" }
31
+ puts "ci_gates: #{gates.size} gates, every region current" if stale.empty?
32
+ 0
33
+ end
34
+
35
+ # @param root [String] the checkout
36
+ # @return [Hash{String => String}] each workflow's absolute path to the text it should hold
37
+ def projection(root)
38
+ gates.group_by { |gate| gate.fetch("workflow") }.to_h do |workflow, rows|
39
+ path = File.join(root, WORKFLOWS, workflow)
40
+ [path, rows.reduce(File.read(path)) { |text, gate| replace_region(text, gate, workflow) }]
41
+ end
42
+ end
43
+
44
+ # The values a row's `mode` and `push` may take: the language holds one closed set to a value
45
+ # object, so the rest are held here.
46
+ MODES = %w[touches skips_unless].freeze
47
+ PUSHES = %w[skip before_sha].freeze
48
+
49
+ # @return [Array<Hash{String => String}>] the `CiGate` rows
50
+ # @raise [SystemExit] when a row's `mode` or `push` is not one the action understands
51
+ def gates
52
+ Hecks::Vocabulary.rows("CiGate").each do |gate|
53
+ abort "ci_gates: #{gate['name']} has mode #{gate['mode'].inspect}" unless MODES.include?(gate["mode"])
54
+ abort "ci_gates: #{gate['name']} has push #{gate['push'].inspect}" unless PUSHES.include?(gate["push"])
55
+ end
56
+ end
57
+
58
+ # @param stale [Array<String>] absolute paths whose text differs from the rows
59
+ # @param root [String] the checkout
60
+ # @return [Integer] 0 when current, else 1 with the stale workflows on stderr
61
+ def report(stale, root)
62
+ if stale.empty?
63
+ puts "ci_gates: #{gates.size} gates, every region current"
64
+ return 0
65
+ end
66
+
67
+ warn "ci_gates: out of date: #{stale.map { |path| path.delete_prefix("#{root}/") }.join(', ')} " \
68
+ "(run hecks project_ci_gates)"
69
+ 1
70
+ end
71
+
72
+ # @param text [String] a workflow
73
+ # @param gate [Hash{String => String}] a `CiGate` row
74
+ # @param workflow [String] the workflow's file name, for the refusal
75
+ # @return [String] the workflow with the gate's marked region replaced by its job
76
+ def replace_region(text, gate, workflow)
77
+ name = gate.fetch("name")
78
+ region = /^ # BEGIN GENERATED ci_gate #{Regexp.escape(name)}\b.*?^ # END GENERATED ci_gate #{Regexp.escape(name)}$/m
79
+ abort "ci_gates: #{workflow} has no BEGIN/END GENERATED ci_gate #{name} region" unless text.match?(region)
80
+
81
+ text.sub(region) { job(gate) }
82
+ end
83
+
84
+ # @param gate [Hash{String => String}] a `CiGate` row
85
+ # @return [String] the marked region: the detector job that answers `touched`
86
+ def job(gate)
87
+ name = gate.fetch("name")
88
+ [
89
+ " # BEGIN GENERATED ci_gate #{name} (CiGate vocabulary; hecks project_ci_gates). Do not hand-edit.",
90
+ " #{name}:",
91
+ " runs-on: ubuntu-latest",
92
+ " timeout-minutes: 10",
93
+ *job_condition(gate),
94
+ " outputs:",
95
+ " touched: ${{ steps.diff.outputs.touched }}",
96
+ " steps:",
97
+ " - uses: actions/checkout@v4",
98
+ " with:",
99
+ " # Full history: the diff needs both endpoints present as real objects.",
100
+ " fetch-depth: 0",
101
+ " - uses: ./.github/actions/setup-ruby",
102
+ " - uses: ./.github/actions/hecks-environment",
103
+ " - id: diff",
104
+ " name: #{quoted(gate.fetch('label'))}",
105
+ " run: bundle exec exe/hecks decide_ci_gate gate=#{name} --wait",
106
+ " # END GENERATED ci_gate #{name}"
107
+ ].join("\n")
108
+ end
109
+
110
+ # @param gate [Hash{String => String}] a `CiGate` row
111
+ # @return [Array<String>] the job-level `if:` lines for a gate that skips push runs
112
+ def job_condition(gate)
113
+ return [] unless gate.fetch("push") == "skip"
114
+
115
+ [" # A push is a cache-warming run; the jobs that need this one skip through their `needs:`.",
116
+ " if: github.event_name != 'push'"]
117
+ end
118
+
119
+ # @param text [String]
120
+ # @return [String] the text as a single-quoted YAML scalar
121
+ def quoted(text) = "'#{text.gsub("'", "''")}'"
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "yaml"
5
+ require_relative "../tools"
6
+
7
+ module Hecks
8
+ module Tools
9
+ # Runs a stage's checks, which `lib/hecks/gate/stages.yml` holds as data, all at once and
10
+ # reports every one, so a red check does not hide the others.
11
+ #
12
+ # hecks gate pre_push # every check of the stage
13
+ # hecks gate pre_push only=rubocop # the named checks
14
+ # hecks gate --list # the stages and their checks
15
+ #
16
+ # A green stage prints which checks passed; a red one prints each failing check's whole output
17
+ # and what its failure means, and answers status 1.
18
+ module Gate
19
+ STAGES_FILE = File.expand_path("../gate/stages.yml", __dir__)
20
+ USAGE = "usage: hecks gate <stage> [only=a,b] | hecks gate --list"
21
+
22
+ module_function
23
+
24
+ # @param argv [Array<String>] a stage name and optionally `only=a,b`, or `--list`
25
+ # @param root [String] the checkout the checks run in
26
+ # @return [Integer] 0 when every check passed, 1 when one failed, 2 on bad usage
27
+ def main(argv, root: Tools::ROOT, **)
28
+ stages = YAML.load_file(STAGES_FILE)
29
+ return list(stages) if argv == ["--list"]
30
+
31
+ name = argv.find { |arg| !arg.include?("=") && !arg.start_with?("--") }
32
+ only = argv.filter_map { |arg| arg.delete_prefix("only=").split(",") if arg.start_with?("only=") }.flatten
33
+ stage = stages[name]
34
+ return usage(stages, name) unless stage
35
+
36
+ checks = select(stage.fetch("checks"), only)
37
+ return unknown(stage, only) if checks.nil?
38
+
39
+ report(name, checks, run_all(stage, checks, root))
40
+ end
41
+
42
+ # @param checks [Array<Hash>] a stage's checks
43
+ # @param only [Array<String>] the check ids asked for; every check when empty
44
+ # @return [Array<Hash>, nil] the checks to run, nil when an id names no check
45
+ def select(checks, only)
46
+ return checks if only.empty?
47
+ return nil unless (only - checks.map { |check| check["id"] }).empty?
48
+
49
+ checks.select { |check| only.include?(check["id"]) }
50
+ end
51
+
52
+ # Starts every check at once and waits for all of them.
53
+ #
54
+ # @param stage [Hash] the stage: its `env` and `checks`
55
+ # @param checks [Array<Hash>] the checks to run
56
+ # @param root [String] the directory they run in
57
+ # @return [Hash{String => Array}] each check's id, and its output and whether it passed
58
+ def run_all(stage, checks, root)
59
+ env = stage.fetch("env", {}).reject { |key, _| ENV.key?(key) }
60
+ threads = checks.map do |check|
61
+ Thread.new { [check["id"], run_one(check, env, root)] }
62
+ end
63
+ threads.to_h(&:value)
64
+ end
65
+
66
+ # @return [Array(String, Boolean)] what the check printed, and whether it exited 0
67
+ def run_one(check, env, root)
68
+ argv = check.fetch("run").map { |word| word.to_s.sub("{workers}", workers.to_s) }
69
+ output, status = Open3.capture2e(env, *argv, chdir: root)
70
+ [output, status.success?]
71
+ rescue SystemCallError => e
72
+ ["could not start #{argv.first}: #{e.message}", false]
73
+ end
74
+
75
+ # @return [Integer] half the machine's cores, at least one; the parallel suite at full width
76
+ # starves the fuzzing run beside it
77
+ def workers
78
+ require "etc"
79
+ [Etc.nprocessors / 2, 1].max
80
+ end
81
+
82
+ def report(name, checks, results)
83
+ failed = checks.reject { |check| results.fetch(check["id"]).last }
84
+ failed.each do |check|
85
+ output, = results.fetch(check["id"])
86
+ puts "\n[gate #{name}] #{check['title']}\n\n#{output}"
87
+ puts "\n[gate #{name}] BLOCKED: #{check['blocked']}\n"
88
+ end
89
+ if failed.empty?
90
+ puts "[gate #{name}] green: #{checks.map { |check| check['id'] }.join(', ')}"
91
+ return 0
92
+ end
93
+
94
+ puts "[gate #{name}] red: #{failed.map { |check| check['id'] }.join(', ')}"
95
+ 1
96
+ end
97
+
98
+ def list(stages)
99
+ stages.each do |name, stage|
100
+ puts "#{name}: #{stage.fetch('checks').map { |check| check['id'] }.join(', ')}"
101
+ end
102
+ 0
103
+ end
104
+
105
+ def unknown(stage, only)
106
+ known = stage.fetch("checks").map { |check| check["id"] }
107
+ warn "no such check: #{(only - known).join(', ')} (checks: #{known.join(', ')})"
108
+ 2
109
+ end
110
+
111
+ def usage(stages, name)
112
+ warn(name ? "no such stage: #{name} (stages: #{stages.keys.join(', ')})" : USAGE)
113
+ 2
114
+ end
115
+ end
116
+ end
117
+ end
data/lib/hecks/tools.rb CHANGED
@@ -19,7 +19,10 @@ module Hecks
19
19
  "standardize_comments" => ["tools/comment_style", "CommentStyle"],
20
20
  "standardize_comments_rust" => ["tools/rust_comment_style", "RustCommentStyle"],
21
21
  "regen_codegen_domains" => ["tools/regeneration_run", "RegenerationRun"],
22
+ "gate" => ["tools/gate", "Gate"],
22
23
  "argument_gate_matrix" => ["tools/argument_gate_matrix", "ArgumentGateMatrix"],
24
+ "project_ci_gates" => ["tools/ci_gates", "CiGates"],
25
+ "decide_ci_gate" => ["tools/ci_gate_decision", "CiGateDecision"],
23
26
  "project_deploy" => ["tools/deploy_recipe", "DeployRecipe"],
24
27
  "lint_deploy_recipes" => ["tools/deploy_recipe_lint", "DeployRecipeLint"],
25
28
  "project_oidc" => ["tools/oidc_manifests", "OidcManifests"],
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.0.3".freeze
4
+ VERSION = "3.0.5".freeze
5
5
  end
@@ -49,6 +49,10 @@ module Hecks
49
49
  {"name"=>"build-dependencies"}.freeze,
50
50
  {"name"=>"workspace"}.freeze
51
51
  ].freeze,
52
+ "CiGate" => [
53
+ {"name"=>"runtime_changed", "workflow"=>"ci.yml", "mode"=>"touches", "pattern"=>"^lib/hecks/runtime/", "push"=>"skip", "label"=>"does this change touch lib/hecks/runtime/**?"}.freeze,
54
+ {"name"=>"postgres_io_relevant_changed", "workflow"=>"ci-postgres-io-parallel.yml", "mode"=>"skips_unless", "pattern"=>"^(docs/|editors/|release/|deploy/|\\.claude/|\\.githooks/|rust/(parser|codegen|host|build|lsp|web|tests|project)/|rust/project\\.rb$|rust/project_rust_pipeline\\.rb$|\\.rubocop\\.yml$|\\.rubocop_todo\\.yml$|\\.mcp\\.json$|\\.rspec-local\\.example$|README\\.md$|CHANGELOG\\.md$|CONTRIBUTING\\.md$|SECURITY\\.md$|LICENSE$|\\.gitignore$)", "push"=>"before_sha", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
55
+ ].freeze,
52
56
  "Comparison" => [
53
57
  {"symbol"=>">=", "compares_less_than"=>"true", "compares_equal"=>"false", "negated"=>"true"}.freeze,
54
58
  {"symbol"=>"<=", "compares_less_than"=>"true", "compares_equal"=>"true", "negated"=>"false"}.freeze,
@@ -123,7 +127,8 @@ module Hecks
123
127
  ].freeze,
124
128
  "NormalisationStrategy" => [
125
129
  {"name"=>"collapse_whitespace"}.freeze,
126
- {"name"=>"replace"}.freeze
130
+ {"name"=>"replace"}.freeze,
131
+ {"name"=>"scale_call"}.freeze
127
132
  ].freeze,
128
133
  "Primitive" => [
129
134
  {"name"=>"String"}.freeze,
data/lib/hecks.rb CHANGED
@@ -27,8 +27,7 @@ require_relative "hecks/projector"
27
27
  # every target registers itself as it loads, so this require is the
28
28
  # installation of them.
29
29
  require_relative "hecks/projections"
30
- # After `Projector` (dispatches against the `:cli` projection) and
31
- # `Ports::Clock` (fills a staleness rule's `now` at the door) both exist.
30
+ # After `Projector`, which dispatches against the `:cli` projection.
32
31
  require_relative "hecks/doors/cli_door"
33
32
  require_relative "hecks/doors/cli_runner"
34
33
  require_relative "hecks/storehouse"
@@ -76,6 +75,17 @@ module Hecks
76
75
  Runtime.describe(path, shared: shared, environment: environment)
77
76
  end
78
77
 
78
+ # Finishes a boot from declarations `describe` already loaded, so the domain's files are read
79
+ # once. See `Runtime::Loader.boot_described`.
80
+ #
81
+ # @param described [Runtime::Loader::Described] what `describe` answered
82
+ # @param install_doors [Boolean] install the `Widget::Item.Add`-style facade
83
+ # @param install_facade [Boolean, nil] the deprecated spelling of `install_doors`; warns
84
+ # @return [Runtime::Dispatcher, Runtime::RemoteDispatcher] dispatcher bound to the domain
85
+ def boot_described(described, install_doors: true, install_facade: nil)
86
+ Runtime.boot_described(described, install_doors: install_doors, install_facade: install_facade)
87
+ end
88
+
79
89
  # Boots a domain from an explicit list of files (a `.bluebook`, its
80
90
  # `.hecksagon`, optionally a `.world`) instead of a whole directory —
81
91
  # see `Runtime::Loader.boot_files` for why this exists beside `boot`.
@@ -125,13 +125,15 @@ fn plain_identifier(name: &str) -> bool {
125
125
  }
126
126
 
127
127
  /// Every declared `name` in the IR, in document order. The `fields` maps under `mutations` are
128
- /// skipped: their keys are attribute names and their values are source text, not declarations.
128
+ /// skipped: their keys are attribute names and their values are source text, not declarations. So is
129
+ /// `translations`: an era edge names data paths (`attendee.first_name`, a backfill into a nested
130
+ /// value object), which the host applies to stored rows and which are never written into Rust.
129
131
  fn declared_names<'a>(ir: &'a Json, out: &mut Vec<&'a str>) {
130
132
  match ir {
131
133
  Json::Object(pairs) => {
132
134
  for (key, value) in pairs {
133
135
  match (key.as_str(), value) {
134
- ("fields", _) => {}
136
+ ("fields" | "translations", _) => {}
135
137
  ("name", Json::String(name)) => out.push(name),
136
138
  _ => declared_names(value, out),
137
139
  }
@@ -335,6 +337,15 @@ mod tests {
335
337
  assert_eq!(unsafe_name_refusal("shop", &ir), None);
336
338
  }
337
339
 
340
+ #[test]
341
+ fn ignores_data_paths_named_by_a_translation_edge() {
342
+ let ir = Json::parse(
343
+ r#"{"name":"Shop","aggregates":[{"name":"Pizza"}],"translations":[{"aggregates":[{"name":"Pizza","backfills":[{"name":"attendee.first_name"}]}]}]}"#,
344
+ )
345
+ .unwrap();
346
+ assert_eq!(unsafe_name_refusal("shop", &ir), None);
347
+ }
348
+
338
349
  #[test]
339
350
  fn refuses_an_aggregate_name_that_is_not_a_plain_identifier() {
340
351
  assert!(reserved_name_refusal("shop", "shop", &["My-App"]).is_some());
@@ -1 +1 @@
1
- 3.0.3
1
+ 3.0.5