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
@@ -73,7 +73,7 @@ Hecks.bluebook "Bluebook" do
73
73
 
74
74
  # Expression::CanonicalForm::STRATEGIES
75
75
  value_object "NormalisationStrategy" do
76
- attribute :name, String, one_of: ["collapse_whitespace", "replace"]
76
+ attribute :name, String, one_of: ["collapse_whitespace", "replace", "scale_call"]
77
77
  end
78
78
 
79
79
  # Ops Runtime::CommandInterpreter applies. `sign` is the arithmetic delta
@@ -602,6 +602,34 @@ Hecks.bluebook "Bluebook" do
602
602
  member name: "textarea", pattern: '\b(text|body|note|notes|description|message|comment)\b', resolves_to: "kind"
603
603
  end
604
604
 
605
+ # The path gates the workflows run before an expensive job: one detector job
606
+ # per row, answering `touched` from the diff of the change against its base.
607
+ # hecks project_ci_gates writes each row into the marked region of its
608
+ # `workflow` (.github/workflows/<workflow>), and the job it writes runs
609
+ # `hecks decide_ci_gate gate=<name>`, which makes the decision from the row.
610
+ #
611
+ # `mode` is how `pattern` (an extended regex over changed paths) decides:
612
+ # `touches` runs the job when any changed path matches, `skips_unless` runs it
613
+ # when any changed path does NOT match (the pattern is the safe-to-skip list).
614
+ # `push` is what a push event does: `skip` leaves the gate out of push runs,
615
+ # `before_sha` diffs against the push's `before` commit.
616
+ value_object "CiGate" do
617
+ attribute :name, String
618
+ attribute :workflow, String
619
+ attribute :mode, String
620
+ attribute :pattern, String
621
+ attribute :push, String
622
+ attribute :label, String
623
+
624
+ member name: "runtime_changed", workflow: "ci.yml", mode: "touches",
625
+ pattern: '^lib/hecks/runtime/', push: "skip",
626
+ label: "does this change touch lib/hecks/runtime/**?"
627
+ member name: "postgres_io_relevant_changed", workflow: "ci-postgres-io-parallel.yml", mode: "skips_unless",
628
+ 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$)',
629
+ push: "before_sha",
630
+ label: "does this change touch anything rspec_postgres_io_parallel covers?"
631
+ end
632
+
605
633
  # Rust keywords (strict + reserved) a generated identifier must not
606
634
  # collide with. RustProjection::Projector::RUST_KEYWORDS reads this;
607
635
  # hecks project_reserved_names projects it into
@@ -111,6 +111,11 @@
111
111
  "verb": "Bluebook::Command.Ensure",
112
112
  "role": "Language"
113
113
  },
114
+ {
115
+ "scope": "bluebook:command.need",
116
+ "verb": "Bluebook::Command.Need",
117
+ "role": "Language"
118
+ },
114
119
  {
115
120
  "scope": "bluebook:command.reference",
116
121
  "verb": "Bluebook::Command.Reference",
@@ -3,7 +3,9 @@ require_relative "../runtime/registry"
3
3
  module Hecks
4
4
  module Ports
5
5
  # What time it is, as one registry-wide adapter answers it.
6
- # Predicates cannot read the clock (replays must be deterministic), so the door fills `now`.
6
+ # Predicates cannot read the clock (replays must be deterministic), so a command that declares
7
+ # `needs :now` has the runtime read it once, before any given runs, into the command's own
8
+ # `now` argument (ADR 0081). The recorded argument is what a replay sees.
7
9
  module Clock
8
10
  NAME = "clock".freeze
9
11
 
@@ -85,7 +85,9 @@ module Hecks
85
85
  memory: { value: deploy_settings.fetch(:memory, 512) },
86
86
  timeout: { value: deploy_settings.fetch(:timeout, 10) },
87
87
  database: { value: deploy_settings.fetch(:database, "Postgres") },
88
- web: { value: deploy_settings.fetch(:web, "None") }
88
+ web: { value: deploy_settings.fetch(:web, "None") },
89
+ **%i[dispatch handler_module secret_env].select { |key| deploy_settings.key?(key) }
90
+ .to_h { |key| [key, { value: deploy_settings[key].to_s }] }
89
91
  }
90
92
  ).instance
91
93
  rescue *Hecks::Runtime::DOMAIN_REFUSALS => e
@@ -105,9 +107,6 @@ module Hecks
105
107
  # The module name `lambda_handler.rb` actually defines; named here
106
108
  # since `dispatch "None"` domains may each pick their own.
107
109
  webhook_handler_module = deploy_settings.fetch(:handler_module, "WebLambdaHandler")
108
- if dispatch_none && !deploy_settings.key?(:handler_module)
109
- raise ArgumentError, "#{world_file}'s deployed_to(\"AwsLambda\") declares dispatch \"None\" but no handler_module — add handler_module \"YourModuleName\" naming the module #{domain}/lambda_handler.rb defines."
110
- end
111
110
 
112
111
  # Scans every loaded chapter, not just this domain's own — a
113
112
  # `uses_framework`-attached chapter can itself declare a cross-domain
@@ -238,6 +238,7 @@ Hecks.bluebook "QualityControl" do
238
238
  reference_to Target
239
239
  attribute :held_by, Engineer
240
240
  attribute :now, Instant
241
+ needs :now
241
242
 
242
243
  given("a live claim is not taken from the agent holding it") do
243
244
  status != "held" || claimed_at.value + window.value <= now.value
@@ -265,6 +266,7 @@ Hecks.bluebook "QualityControl" do
265
266
  # carry it. Required, so an omitted streak can neither blank the counter nor guess at it.
266
267
  reference_to Target
267
268
  attribute :now, Instant
269
+ needs :now
268
270
  attribute :next_streak, CleanStreak
269
271
  # Required: an in-process dispatch does not fill defaults for absent arguments, so an omitted
270
272
  # list refuses rather than blanks. `capabilities.value=""` is an honest value.
@@ -1115,6 +1117,7 @@ Hecks.bluebook "QualityControl" do
1115
1117
  reference_to Bug
1116
1118
  attribute :held_by, Engineer
1117
1119
  attribute :now, Instant
1120
+ needs :now
1118
1121
 
1119
1122
  given("a live claim is not taken from the agent holding it") do
1120
1123
  held_by.value == "nobody" || claimed_at.value + window.value <= now.value
@@ -1388,6 +1391,7 @@ Hecks.bluebook "QualityControl" do
1388
1391
  attribute :proposer, Proposer
1389
1392
  # `now:` is filled from the clock port, as in `Target.Claim`; an explicit value wins.
1390
1393
  attribute :now, Instant
1394
+ needs :now
1391
1395
 
1392
1396
  sets :proposed_at, to: :now
1393
1397
 
@@ -1784,6 +1788,7 @@ Hecks.bluebook "QualityControl" do
1784
1788
  attribute :commit, CommitRef
1785
1789
  attribute :title, PatchTitle
1786
1790
  attribute :now, Instant
1791
+ needs :now
1787
1792
 
1788
1793
  given("the branch is one this practice recognises as its own") { branch.value.start_with?("qa/") }
1789
1794
  given("the bug is fixed") { bug.status == "fixed" }
@@ -1921,6 +1926,7 @@ Hecks.bluebook "QualityControl" do
1921
1926
  attribute :branch, ImprovementBranch
1922
1927
  attribute :title, ImprovementTitle
1923
1928
  attribute :now, Instant
1929
+ needs :now
1924
1930
 
1925
1931
  given("the branch is one this practice recognises as its own") { branch.value.start_with?("qa/") }
1926
1932
  given("the angle is under investigation") { angle.unset? || angle.status == "investigating" }
@@ -83,10 +83,12 @@ module Hecks
83
83
 
84
84
  private
85
85
 
86
- # No-op: `Routing` has already handed `call` a decoded argument hash,
87
- # so there is nothing left to decode here yet. Kept as a step so the
88
- # generated step enum has a slot to move the real decoder into later.
89
- def step_decode_arguments(_ctx); end
86
+ # `Routing` has already handed `call` a decoded argument hash, so the one thing left to do
87
+ # here is answer the outside facts the command `needs`, before any refusal or given reads
88
+ # its arguments. Not traced: the step has always been invisible to a trace observer.
89
+ def step_decode_arguments(ctx)
90
+ ctx.args = enrich_arguments(ctx.command, ctx.args)
91
+ end
90
92
 
91
93
  def step_refuse_unknown_arguments(ctx)
92
94
  step(:refuse_unknown_arguments) { refuse_unknown_arguments(ctx.domain, ctx.aggregate, ctx.command, ctx.args) }
@@ -133,8 +133,11 @@ module Hecks
133
133
 
134
134
  private
135
135
 
136
- # No-op — entities have no `decode_arguments` step of their own.
137
- def step_decode_arguments(_ctx); end
136
+ # Answers the outside facts the entity command `needs`, as the aggregate interpreter does;
137
+ # the arguments are otherwise already decoded.
138
+ def step_decode_arguments(ctx)
139
+ ctx.args = enrich_arguments(ctx.command, ctx.args)
140
+ end
138
141
 
139
142
  # `extra_identity_heads:` covers every entity in `ctx.chain`, not just
140
143
  # the root — each hop is addressed by its own identity fields, which
@@ -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,36 @@ 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
+ missing = command.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
33
+ return args if missing.empty?
34
+
35
+ args.merge(missing.to_h { |fact| [fact, need_value(command, fact)] })
36
+ end
37
+
38
+ # The answer to one fact, in the shape the command's argument of that name takes: a bare
39
+ # Integer for an Integer attribute, else the one-field value object the language declares
40
+ # for an instant.
41
+ def need_value(command, fact)
42
+ answer = NEED_ANSWERS.fetch(fact).call(@registry)
43
+ attribute = command.attributes.find { |held| held.name.to_s == fact.to_s }
44
+ attribute&.type.to_s == "Integer" ? answer : { value: answer }
45
+ end
46
+
18
47
  # Logged after the step's work, so trace order is completion order.
19
48
  def step(name)
20
49
  result = yield
@@ -207,8 +207,12 @@ module Hecks
207
207
  declared: declared)
208
208
  end
209
209
 
210
+ # A fact the command `needs` is not absent: the interpreter answers it before any refusal
211
+ # reads the arguments.
210
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
+ absent = declaring.attributes.reject(&:optional?).map { |attribute| attribute.name.to_sym } -
215
+ offered.keys - needed
212
216
  return if absent.empty?
213
217
 
214
218
  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