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.
- checksums.yaml +4 -4
- data/exe/hecks +6 -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/project_cli.rb +6 -2
- 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 +23 -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 +51 -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 +3 -2
- 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/projections/deploy/lambda.rb +3 -4
- data/lib/hecks/quality_control/quality_control.bluebook +6 -0
- data/lib/hecks/runtime/command_interpreter.rb +6 -4
- data/lib/hecks/runtime/entity_interpreter.rb +5 -2
- data/lib/hecks/runtime/interpreting.rb +29 -0
- data/lib/hecks/runtime/invocation.rb +5 -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/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 +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",
|
data/lib/hecks/ports/clock.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
def step_decode_arguments(
|
|
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
|
-
#
|
|
137
|
-
|
|
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
|
-
|
|
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,
|
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
|