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.
- checksums.yaml +4 -4
- data/exe/hecks +12 -2
- data/lib/hecks/bluebook/assembly/contracts.rb +3 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -0
- data/lib/hecks/bluebook/command.rb +7 -5
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +42 -0
- data/lib/hecks/bluebook/expression/canonical_form.rb +12 -0
- data/lib/hecks/bluebook/expression/projection.json +21 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -0
- data/lib/hecks/cli/console.rb +24 -8
- data/lib/hecks/cli/project_cli.rb +42 -9
- data/lib/hecks/cli/seed_semantics_corpus.rb +35 -6
- data/lib/hecks/deploy/bluebook/deploy.bluebook +34 -0
- data/lib/hecks/doors/cli_runner.rb +57 -12
- data/lib/hecks/doors/launcher_options.rb +41 -0
- data/lib/hecks/fuzzing/properties.rb +1 -1
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +11 -3
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +9 -7
- data/lib/hecks/gate/stages.yml +55 -0
- data/lib/hecks/grammar/expression.bluebook +6 -1
- data/lib/hecks/grammar/expression_operators.json +135 -0
- data/lib/hecks/hecks/adapters/codebase/gate.rb +37 -0
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +24 -4
- data/lib/hecks/hecks/adapters/codebase/source_tree.rb +3 -1
- data/lib/hecks/hecks/codebase.behaviors +12 -0
- data/lib/hecks/hecks/codebase.bluebook +194 -0
- data/lib/hecks/hecks/custodian.bluebook +2 -0
- data/lib/hecks/hecks/hecks.hecksagon +14 -0
- data/lib/hecks/hecks/hecks.world +5 -1
- data/lib/hecks/language/bluebook/command.bluebook +26 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +29 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/ports/clock.rb +3 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
- data/lib/hecks/projections/deploy/lambda.rb +3 -4
- data/lib/hecks/projector/cli_projector.rb +63 -4
- data/lib/hecks/quality_control/quality_control.bluebook +6 -0
- data/lib/hecks/runtime/command_interpreter.rb +8 -4
- data/lib/hecks/runtime/entity_interpreter.rb +5 -2
- data/lib/hecks/runtime/interpreting.rb +50 -0
- data/lib/hecks/runtime/invocation.rb +7 -1
- data/lib/hecks/runtime/loader.rb +16 -13
- data/lib/hecks/runtime.rb +11 -0
- data/lib/hecks/three_zero/forms.yml +1 -1
- data/lib/hecks/tools/ci_gate_decision.rb +109 -0
- data/lib/hecks/tools/ci_gates.rb +124 -0
- data/lib/hecks/tools/gate.rb +117 -0
- data/lib/hecks/tools.rb +3 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +6 -1
- data/lib/hecks.rb +12 -2
- data/rust/codegen/src/naming.rs +13 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/dispatch.rs +207 -1
- data/rust/host/src/main.rs +1 -0
- data/rust/host/src/needs.rs +243 -0
- data/rust/host/src/resend.rs +52 -10
- data/rust/host/src/server.rs +7 -3
- data/rust/host/src/web/newsletter/template.rs +69 -0
- data/rust/host/src/web/newsletter/tests.rs +43 -0
- data/rust/host/src/web/newsletter.rs +6 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/parser/src/canonical.rs +137 -2
- data/rust/parser/src/emit.rs +25 -19
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +44 -0
- data/rust/project/naming.rb +4 -2
- data/rust/src/kernel/cli.rs +3 -0
- data/rust/src/kernel/mod.rs +1 -0
- data/rust/src/kernel/needs.rs +194 -0
- data/rust/src/kernel/orchestrate.rs +84 -0
- data/rust/src/kernel/repository.rs +43 -2
- 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
|
-
|
|
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,
|
data/lib/hecks/runtime/loader.rb
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
data/lib/hecks/vocabulary.rb
CHANGED
|
@@ -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
|
|
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`.
|
data/rust/codegen/src/naming.rs
CHANGED
|
@@ -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());
|
data/rust/host/HECKS_RELEASE
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.0.
|
|
1
|
+
3.0.5
|