hecks 3.6.0 → 3.9.0
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/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
- data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
- data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
- data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
- data/lib/hecks/bluebook/meta_validator.rb +4 -2
- data/lib/hecks/cache_dir.rb +10 -0
- data/lib/hecks/corpus/routes.rb +1 -1
- data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
- data/lib/hecks/deploy/oidc.json +10 -0
- data/lib/hecks/doors/usage_cache.rb +19 -9
- data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
- data/lib/hecks/fuzzing/failure_triage.rb +80 -0
- data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
- data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
- data/lib/hecks/fuzzing/form_census.rb +28 -52
- data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
- data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
- data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
- data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
- data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
- data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
- data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
- data/lib/hecks/fuzzing/mutation/report.rb +78 -0
- data/lib/hecks/fuzzing/mutation/run.rb +99 -0
- data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
- data/lib/hecks/fuzzing/mutation/site.rb +21 -0
- data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
- data/lib/hecks/fuzzing/mutation.rb +12 -0
- data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
- data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
- data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
- data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
- data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
- data/lib/hecks/fuzzing/properties.rb +7 -0
- data/lib/hecks/fuzzing/replay/observation.rb +1 -1
- data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
- data/lib/hecks/fuzzing/replay/session.rb +22 -6
- data/lib/hecks/fuzzing/replay.rb +1 -0
- data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
- data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
- data/lib/hecks/fuzzing/shrinker.rb +3 -0
- data/lib/hecks/fuzzing.rb +3 -0
- data/lib/hecks/gate/stages.yml +12 -2
- data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
- data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
- data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
- data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
- data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +37 -0
- data/lib/hecks/hecks/hecks.hecksagon +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
- data/lib/hecks/projections/deploy/box/settings.rb +18 -0
- data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
- data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
- data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
- data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
- data/lib/hecks/projections/deploy/box.rb +14 -2
- data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
- data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
- data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
- data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
- data/lib/hecks/projections/deploy/vercel.rb +134 -0
- data/lib/hecks/projections/site/probe.rb +131 -0
- data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
- data/lib/hecks/projections/site/site_host/templates.rb +12 -4
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
- data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
- data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
- data/lib/hecks/runtime/value/validation.rb +34 -3
- data/lib/hecks/rust_build/native_build.rb +29 -1
- data/lib/hecks/rust_build/wasm.rb +16 -3
- data/lib/hecks/site/bluebook/site.bluebook +31 -0
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
- data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
- data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
- data/lib/hecks/tools/deploy_recipe.rb +20 -10
- data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
- data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
- data/lib/hecks/tools/fuzz_sweep.rb +26 -9
- data/lib/hecks/tools/lanes/files.rb +2 -4
- data/lib/hecks/tools/lanes/live.rb +3 -4
- data/lib/hecks/tools/lanes/ruleset.rb +23 -1
- data/lib/hecks/tools/lanes.rb +12 -1
- data/lib/hecks/tools/mutation_run.rb +73 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +3 -1
- data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
- data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
- data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
- data/qa/settings.yml +1 -0
- data/rust/codegen/src/fielded.rs +3 -3
- data/rust/codegen/src/json_codec.rs +2 -2
- data/rust/codegen/src/naming.rs +20 -0
- data/rust/host/Cargo.toml +6 -2
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +28 -5
- data/rust/host/src/auth.rs +11 -1
- data/rust/host/src/boundary_fuzz/api.rs +260 -0
- data/rust/host/src/boundary_fuzz/auth.rs +119 -0
- data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
- data/rust/host/src/boundary_fuzz/ir.rs +79 -0
- data/rust/host/src/boundary_fuzz/server.rs +227 -0
- data/rust/host/src/boundary_fuzz/web.rs +125 -0
- data/rust/host/src/commerce_ir.rs +459 -0
- data/rust/host/src/extension.rs +100 -0
- data/rust/host/src/fuzz_support.rs +165 -0
- data/rust/host/src/ir.rs +4 -448
- data/rust/host/src/journal.rs +1 -1
- data/rust/host/src/lib.rs +39 -0
- data/rust/host/src/main.rs +10 -44
- data/rust/host/src/mint.rs +1 -1
- data/rust/host/src/payments/tests.rs +8 -8
- data/rust/host/src/payments.rs +10 -10
- data/rust/host/src/rate_limit.rs +27 -41
- data/rust/host/src/secrets.rs +44 -0
- data/rust/host/src/server.rs +6 -1
- data/rust/host/src/web/commerce.rs +113 -0
- data/rust/host/src/web/newsletter.rs +2 -1
- data/rust/host/src/web/newsletter_send.rs +1 -1
- data/rust/host/src/web/registration_receipt.rs +2 -2
- data/rust/host/src/web/registrations.rs +49 -49
- data/rust/host/src/web/resend_webhook.rs +299 -0
- data/rust/host/src/web.rs +55 -49
- data/rust/parser/fuzz/.gitignore +4 -0
- data/rust/parser/fuzz/Cargo.lock +89 -0
- data/rust/parser/fuzz/Cargo.toml +27 -0
- data/rust/parser/fuzz/README.md +19 -0
- data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
- data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
- data/rust/parser/src/parse/command.rs +14 -2
- data/rust/parser/src/parse/mod.rs +25 -0
- data/rust/parser/src/ruby_value.rs +95 -6
- data/rust/src/kernel/json.rs +34 -3
- metadata +58 -2
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module Properties
|
|
4
|
+
# Property: what a read-model ask composes from its references.
|
|
5
|
+
#
|
|
6
|
+
# Leans on `AggregationRecompute`'s own field-reading and foreign-key helpers, which
|
|
7
|
+
# `InvariantsAndAggregation` already extends into `Properties`.
|
|
8
|
+
module ReadModels
|
|
9
|
+
# Every composed read-model ask answered the heads its references name.
|
|
10
|
+
#
|
|
11
|
+
# Each head's rows are recomputed from the instances as the ask saw them: the root is the
|
|
12
|
+
# record the reference names, a many-side head holds the records whose foreign key points at
|
|
13
|
+
# it, and a rootless model reads every record. A model's filtering options apply only to the
|
|
14
|
+
# heads they target. A head with no direct foreign key to the root is inconclusive, and a
|
|
15
|
+
# model that groups, reduces or scopes by tenant is checked by its own properties.
|
|
16
|
+
#
|
|
17
|
+
# @param history [Hash] a replayed history, as returned by `Fuzzing::Replay.call`
|
|
18
|
+
# @return [true, String] true, or a message naming each head that composed otherwise
|
|
19
|
+
def read_model_heads_compose_from_references(history)
|
|
20
|
+
bluebooks = history.fetch(:bluebooks, {})
|
|
21
|
+
offenders = Array(history[:queries]).flat_map { |ask| composition_offenders(ask, bluebooks) }
|
|
22
|
+
offenders.empty? || offenders.uniq.join("; ")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def composition_offenders(ask, bluebooks)
|
|
26
|
+
bluebook, model = composed_read_model(ask, bluebooks)
|
|
27
|
+
return [] unless model
|
|
28
|
+
|
|
29
|
+
answer = ask[:rows].first || {}
|
|
30
|
+
offenders = model.aggregate_heads.filter_map { |head| head_offender(bluebook, model, head, ask) }
|
|
31
|
+
offenders + unexpected_heads(ask[:query], model, answer)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The `[bluebook, read_model]` a successful bare-domain ask named; nil when out of scope.
|
|
35
|
+
def composed_read_model(ask, bluebooks)
|
|
36
|
+
return unless answered_read_model_ask?(ask)
|
|
37
|
+
|
|
38
|
+
domain, name = ask[:query].split(".", 2)
|
|
39
|
+
bluebook = bluebooks[domain]
|
|
40
|
+
model = bluebook&.read_model(name)
|
|
41
|
+
[bluebook, model] if model && composable?(model)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# A bare-domain ask (no `::`, so a read model, not an aggregate query) that was answered.
|
|
45
|
+
def answered_read_model_ask?(ask)
|
|
46
|
+
query = ask[:query]
|
|
47
|
+
query.is_a?(String) && !query.include?("::") && !ask[:error] && ask[:rows].is_a?(Array)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def composable?(model)
|
|
51
|
+
!model.reducing? && model.group_by.empty? && !model.authorization&.tenant
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def unexpected_heads(query, model, answer)
|
|
55
|
+
declared = model.aggregate_heads.map { |head| head[:as] }.sort
|
|
56
|
+
return [] if answer.keys.sort == declared
|
|
57
|
+
|
|
58
|
+
["#{query} answered heads #{answer.keys.sort.inspect}, but declares #{declared.inspect}"]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def head_offender(bluebook, model, head, ask)
|
|
62
|
+
expected = expected_head_states(bluebook, model, head, ask)
|
|
63
|
+
return unless expected
|
|
64
|
+
|
|
65
|
+
answered = answered_ids(head, (ask[:rows].first || {})[head[:as]])
|
|
66
|
+
problem = head_problem(model, head, expected, [answered, ask[:args]])
|
|
67
|
+
"#{ask[:query]} head #{head[:as]}: #{problem}" if problem
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def answered_ids(head, answer)
|
|
71
|
+
rows = head[:many] ? answer.to_a : [answer].compact
|
|
72
|
+
rows.map { |row| row[:id].to_s }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The states a head should hold before any option applies, sorted by id; nil when the head
|
|
76
|
+
# has no direct foreign key to the root, which this property cannot read.
|
|
77
|
+
def expected_head_states(bluebook, model, head, ask)
|
|
78
|
+
prefix = "#{bluebook.name}::#{head[:aggregate]}#"
|
|
79
|
+
states = ask[:instances_at].filter_map { |key, state| state.merge(id: key.split("#").last) if key.start_with?(prefix) }
|
|
80
|
+
held = candidate_states(bluebook, model, head, states, ask[:args])
|
|
81
|
+
held&.sort_by { |state| state[:id].to_s }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def candidate_states(bluebook, model, head, states, args)
|
|
85
|
+
return states if model.reference_target.nil?
|
|
86
|
+
|
|
87
|
+
reference_id = args[model.reference_name].to_s
|
|
88
|
+
return states.select { |state| state[:id].to_s == reference_id } if head[:aggregate] == model.reference_target
|
|
89
|
+
|
|
90
|
+
referencing_states(bluebook, model, head, states, reference_id)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The states whose foreign key holds the reference; nil when the head has no such key.
|
|
94
|
+
def referencing_states(bluebook, model, head, states, reference_id)
|
|
95
|
+
aggregate = bluebook.aggregate(head[:aggregate])
|
|
96
|
+
fields = aggregate ? foreign_key_fields(aggregate, model) : []
|
|
97
|
+
states.select { |state| fields.any? { |field| state[field].to_s == reference_id } } unless fields.empty?
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# `asked` is the answered ids and the ask's arguments, which a `where` value may read.
|
|
101
|
+
def head_problem(model, head, expected, asked)
|
|
102
|
+
answered, args = asked
|
|
103
|
+
return exact_problem(expected, answered) unless model.filtered_head_names.include?(head[:as])
|
|
104
|
+
|
|
105
|
+
scoped_problem(model.options_for(head[:as]), expected, answered, args)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# An untargeted head holds exactly the states the references name, in any order.
|
|
109
|
+
def exact_problem(expected, answered)
|
|
110
|
+
ids = expected.map { |state| state[:id].to_s }
|
|
111
|
+
return if answered.sort == ids.sort
|
|
112
|
+
|
|
113
|
+
"answered #{answered.inspect}, but the references compose #{ids.inspect}"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# A targeted head holds the states its `where` clauses admit; `order_by` only reorders them
|
|
117
|
+
# and a `limit` or `offset` can only narrow them.
|
|
118
|
+
def scoped_problem(options, expected, answered, args)
|
|
119
|
+
kept = expected.select { |state| options.wheres.all? { |clause| head_clause_holds?(clause, state, args) } }
|
|
120
|
+
return exact_problem(kept, answered) unless options.limit || options.offset
|
|
121
|
+
|
|
122
|
+
narrowed_problem(kept.map { |state| state[:id].to_s }, answered)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def narrowed_problem(admitted, answered)
|
|
126
|
+
return if (answered - admitted).empty? && answered.size <= admitted.size
|
|
127
|
+
|
|
128
|
+
"answered #{answered.inspect}, beyond what its where clauses admit #{admitted.inspect}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Named for heads: `query_rows.rb` already owns `clause_holds?` in this namespace.
|
|
132
|
+
def head_clause_holds?(clause, state, args)
|
|
133
|
+
Ports::Query::InMemory.holds?(clause, comparable_field(state, clause.field), args)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module Properties
|
|
4
|
+
# Property: the role check at dispatch agrees with the grants the replay itself made.
|
|
5
|
+
#
|
|
6
|
+
# `CommandRules::Authorization` asks the authorization provider whether an identified caller
|
|
7
|
+
# holds a command's role, through the `assignments` verb the provider's chapter declares with
|
|
8
|
+
# `provides "authorization"`. Each `history[:role_checks]` entry carries the grants read back
|
|
9
|
+
# through that same verb before the dispatch, so the answer is recomputed from them.
|
|
10
|
+
module RoleChecks
|
|
11
|
+
# How `Replay.refusal_kind` names the refusal the role gate raises.
|
|
12
|
+
UNAUTHORIZED = "Hecks::Runtime::Unauthorized".freeze
|
|
13
|
+
|
|
14
|
+
# An identified caller holding a live grant of the role is never refused as unauthorized,
|
|
15
|
+
# and one holding none is never accepted.
|
|
16
|
+
#
|
|
17
|
+
# A refusal other than Unauthorized proves nothing about the grant: argument gates run
|
|
18
|
+
# before the role gate, so an ungranted caller can be turned away for another reason first.
|
|
19
|
+
#
|
|
20
|
+
# @param history [Hash] a replayed history, as returned by `Fuzzing::Replay.call`
|
|
21
|
+
# @return [true, String] true, or a message naming each dispatch the grants contradict
|
|
22
|
+
def role_checks_agree_with_grants(history)
|
|
23
|
+
offenders = Array(history[:role_checks]).filter_map { |check| role_check_offender(check) }
|
|
24
|
+
offenders.empty? || offenders.uniq.join("; ")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# A message when `check`'s outcome contradicts its grants; nil when consistent or unclear.
|
|
28
|
+
def role_check_offender(check)
|
|
29
|
+
held = role_held?(check)
|
|
30
|
+
return refused_message(check) if held && check[:outcome] == UNAUTHORIZED
|
|
31
|
+
|
|
32
|
+
accepted_message(check) if !held && check[:outcome].nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def role_held?(check)
|
|
36
|
+
check[:grants].any? { |grant| grant[:role] == check[:role] && !grant[:ended] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def refused_message(check)
|
|
40
|
+
"#{check[:verb]} was refused as unauthorized for #{check[:actor_id]}, who holds a live grant of " \
|
|
41
|
+
"#{check[:role].inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def accepted_message(check)
|
|
45
|
+
"#{check[:verb]} was accepted for #{check[:actor_id]}, who holds no live grant of #{check[:role].inspect}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -13,7 +13,11 @@ require_relative "properties/corrections"
|
|
|
13
13
|
require_relative "properties/outbox"
|
|
14
14
|
require_relative "properties/policy_wiring"
|
|
15
15
|
require_relative "properties/references"
|
|
16
|
+
require_relative "properties/role_checks"
|
|
17
|
+
require_relative "properties/read_model_names"
|
|
18
|
+
require_relative "properties/read_models"
|
|
16
19
|
require_relative "properties/catalog"
|
|
20
|
+
require_relative "properties/guaranteed_by_construction"
|
|
17
21
|
|
|
18
22
|
module Hecks
|
|
19
23
|
module Fuzzing
|
|
@@ -32,6 +36,9 @@ module Hecks
|
|
|
32
36
|
extend Outbox
|
|
33
37
|
extend PolicyWiring
|
|
34
38
|
extend References
|
|
39
|
+
extend RoleChecks
|
|
40
|
+
extend ReadModelNames
|
|
41
|
+
extend ReadModels
|
|
35
42
|
|
|
36
43
|
module_function
|
|
37
44
|
|
|
@@ -10,7 +10,7 @@ module Hecks
|
|
|
10
10
|
{ instances: Replay.snapshot_instances(@runtime), events: events, refusals: @refusals,
|
|
11
11
|
reactions: @runtime.reactions, sagas: @runtime.sagas, saga_instances: saga_instances,
|
|
12
12
|
queries: @queries, dry_runs: @dry_runs, dry_run_traces: @dry_run_traces,
|
|
13
|
-
fan_outs: @fan_outs, guard_checks: @guard_checks,
|
|
13
|
+
fan_outs: @fan_outs, guard_checks: @guard_checks, role_checks: @role_checks,
|
|
14
14
|
mutation_traces: @mutation_traces, outbox_traces: @outbox_traces,
|
|
15
15
|
saga_dispatches: @runtime.saga_dispatches, policy_dispatches: @runtime.policy_dispatches,
|
|
16
16
|
bluebook: @runtime.registry.bluebooks.values.first,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module Replay
|
|
4
|
+
# Reads, before a role-gated dispatch by an identified caller, the grants that actor holds
|
|
5
|
+
# through the authorization provider's own declared `assignments` verb, so the outcome can be
|
|
6
|
+
# compared with what the dispatch actually did.
|
|
7
|
+
module RoleCheck
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# The caller and live grants for one step; nil for any step with no identified caller, no
|
|
11
|
+
# declared role, or no authorization provider (those compare role strings, not grants).
|
|
12
|
+
#
|
|
13
|
+
# @param runtime [Hecks::Runtime] the booted runtime the step will dispatch against
|
|
14
|
+
# @param step [Hash] a replay step, string-keyed
|
|
15
|
+
# @return [Hash, nil] `verb:`, `role:`, `actor_id:` and `grants:` (each `role:`, `ended:`)
|
|
16
|
+
def build(runtime, step)
|
|
17
|
+
return nil unless step["role"] && step["actor_id"]
|
|
18
|
+
|
|
19
|
+
domain, command = command_of(runtime, step["verb"])
|
|
20
|
+
return nil unless command && governed?(runtime, domain, command)
|
|
21
|
+
|
|
22
|
+
{ verb: step["verb"], role: command.role.to_s, actor_id: step["actor_id"].to_s,
|
|
23
|
+
grants: grants_for(runtime.registry, step["actor_id"]) }
|
|
24
|
+
rescue StandardError
|
|
25
|
+
nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The `[domain, command]` an aggregate-level verb names; the command is nil when none does.
|
|
29
|
+
def command_of(runtime, verb)
|
|
30
|
+
domain, aggregate_name, command_name = Naming.split_verb(verb)
|
|
31
|
+
[domain, runtime.registry.bluebook(domain)&.aggregate(aggregate_name)&.command(command_name)]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Whether the command declares a role and its domain has an authorization provider.
|
|
35
|
+
def governed?(runtime, domain, command)
|
|
36
|
+
!command.role.to_s.empty? && !runtime.registry.authorization_provider_for(domain).nil?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The actor's assignments, read through the verb the provider declares for `assignments`.
|
|
40
|
+
def grants_for(registry, actor_id)
|
|
41
|
+
provider = registry.authorization_providers.first
|
|
42
|
+
verb = provider.provided_verb(Bluebook::Capabilities::AUTHORIZATION, :assignments)
|
|
43
|
+
rows = Runtime::Dispatcher.new(registry).query(verb, actor_id: { value: actor_id.to_s })
|
|
44
|
+
rows.map { |row| { role: row[:role_name][:value], ended: !row[:ends_at].nil? } }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -18,7 +18,7 @@ module Hecks
|
|
|
18
18
|
# `outbox_ids` is a set of delivery_ids, not a size, because `runtime.outbox.rows`
|
|
19
19
|
# concatenates several stores' own arrays, so a plain "grew from N to M" tail slice
|
|
20
20
|
# could miss or misattribute rows once more than one repository has an outbox.
|
|
21
|
-
Marks = Struct.new(:reaction, :saga, :outbox_ids, :fan_out_snapshot, :guard_check, :mutation_trace)
|
|
21
|
+
Marks = Struct.new(:reaction, :saga, :outbox_ids, :fan_out_snapshot, :guard_check, :mutation_trace, :role_check)
|
|
22
22
|
|
|
23
23
|
# @param runtime [Hecks::Runtime] a freshly booted runtime
|
|
24
24
|
def initialize(runtime)
|
|
@@ -28,7 +28,7 @@ module Hecks
|
|
|
28
28
|
@dry_runs = []
|
|
29
29
|
@dry_run_traces = []
|
|
30
30
|
@fan_outs = []
|
|
31
|
-
|
|
31
|
+
start_checks
|
|
32
32
|
@mutation_traces = []
|
|
33
33
|
@outbox_traces = []
|
|
34
34
|
@fan_out_targets = fan_out_targets
|
|
@@ -61,6 +61,12 @@ module Hecks
|
|
|
61
61
|
|
|
62
62
|
private
|
|
63
63
|
|
|
64
|
+
# The two checks recorded before a dispatch and compared with its outcome afterwards.
|
|
65
|
+
def start_checks
|
|
66
|
+
@guard_checks = []
|
|
67
|
+
@role_checks = []
|
|
68
|
+
end
|
|
69
|
+
|
|
64
70
|
def play_step(step)
|
|
65
71
|
step = step.transform_keys(&:to_s)
|
|
66
72
|
args = (step["args"] || {}).transform_keys(&:to_sym)
|
|
@@ -84,7 +90,7 @@ module Hecks
|
|
|
84
90
|
result = Replay.as_step_caller(step) { @runtime.dispatch_flat(step["verb"], args) }
|
|
85
91
|
record_effects(step, result, marks)
|
|
86
92
|
rescue *Runtime::DOMAIN_REFUSALS, Bluebook::Expression::EvaluationError => e
|
|
87
|
-
record_refusal(step, e, marks&.guard_check)
|
|
93
|
+
record_refusal(step, e, marks&.guard_check, marks&.role_check)
|
|
88
94
|
end
|
|
89
95
|
|
|
90
96
|
# Taken before dispatch, so this step's own reactions, sagas and outbox rows can be
|
|
@@ -98,17 +104,23 @@ module Hecks
|
|
|
98
104
|
def take_marks(step, args)
|
|
99
105
|
Marks.new(@runtime.reactions.size, @runtime.sagas.size, @runtime.outbox.rows.map(&:delivery_id),
|
|
100
106
|
fan_out_snapshot, GuardCheck.build(@runtime, step["verb"], args),
|
|
101
|
-
MutationTrace.build(@runtime, step["verb"], args))
|
|
107
|
+
MutationTrace.build(@runtime, step["verb"], args), RoleCheck.build(@runtime, step))
|
|
102
108
|
end
|
|
103
109
|
|
|
104
110
|
def record_effects(step, result, marks)
|
|
105
111
|
@fan_outs.concat(Replay.fan_out_findings(@runtime, marks.fan_out_snapshot, result.events,
|
|
106
112
|
@runtime.reactions[marks.reaction..]))
|
|
107
113
|
record_outbox(step, marks)
|
|
108
|
-
|
|
114
|
+
record_accepted_checks(marks)
|
|
109
115
|
record_mutation(marks.mutation_trace) if marks.mutation_trace
|
|
110
116
|
end
|
|
111
117
|
|
|
118
|
+
# An accepted dispatch: the guard let it through and the role gate did not refuse it.
|
|
119
|
+
def record_accepted_checks(marks)
|
|
120
|
+
@guard_checks << marks.guard_check.merge(actual_refused: false, actual_kind: nil) if marks.guard_check
|
|
121
|
+
@role_checks << marks.role_check.merge(outcome: nil) if marks.role_check
|
|
122
|
+
end
|
|
123
|
+
|
|
112
124
|
# Every outbox row this step's own dispatch newly wrote, across every
|
|
113
125
|
# bound repository including any a reaction cascade touched, paired with
|
|
114
126
|
# the reaction/saga rows that same dispatch produced. Skipped when empty.
|
|
@@ -139,8 +151,12 @@ module Hecks
|
|
|
139
151
|
# Only a refusal raised by the guard itself counts for the guard check. A refusal from a
|
|
140
152
|
# stage before or after enforce_givens can share TypeMismatch's class, so
|
|
141
153
|
# anything outside the two guard classes is left out — inconclusive, not a pass.
|
|
142
|
-
|
|
154
|
+
#
|
|
155
|
+
# A role check records the refusal's class whatever it is, since the property decides which
|
|
156
|
+
# outcomes are conclusive: a refusal before the role gate says nothing about the grant.
|
|
157
|
+
def record_refusal(step, error, guard_check, role_check)
|
|
143
158
|
@refusals << { verb: step["verb"], error: error.message, kind: Replay.refusal_kind(error) }
|
|
159
|
+
@role_checks << role_check.merge(outcome: Replay.refusal_kind(error)) if role_check
|
|
144
160
|
return unless guard_check && GUARD_REFUSAL_CLASSES.include?(error.class)
|
|
145
161
|
|
|
146
162
|
@guard_checks << guard_check.merge(actual_refused: true, actual_kind: error.class.name)
|
data/lib/hecks/fuzzing/replay.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative "self_consistency"
|
|
|
5
5
|
require_relative "replay/filters"
|
|
6
6
|
require_relative "replay/fan_out"
|
|
7
7
|
require_relative "replay/guard_check"
|
|
8
|
+
require_relative "replay/role_check"
|
|
8
9
|
require_relative "replay/mutation_trace"
|
|
9
10
|
require_relative "replay/session"
|
|
10
11
|
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
# Measures which lines (and, when stdlib Coverage is running, branches) of the Ruby runtime a
|
|
4
|
+
# block reaches, as a set of stable "path:line" and "path:line:branch" strings.
|
|
5
|
+
#
|
|
6
|
+
# Lines come from a TracePoint, so code that was loaded before the measurement still counts.
|
|
7
|
+
# Branches need stdlib Coverage started before the code was loaded; when it is not running, the
|
|
8
|
+
# measurement is lines only.
|
|
9
|
+
#
|
|
10
|
+
# Pure observation: nothing here draws a random number, so a seed's keys are as reproducible
|
|
11
|
+
# as the seed.
|
|
12
|
+
module RuntimeCoverage
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# @param roots [Array<String>] directories whose files count; anything else is ignored
|
|
16
|
+
# @yield the work to measure
|
|
17
|
+
# @return [Array(Object, Set<String>)] the block's value and the keys it reached
|
|
18
|
+
def measure(roots: default_roots, &block)
|
|
19
|
+
raise ArgumentError, "RuntimeCoverage.measure needs a block" unless block
|
|
20
|
+
|
|
21
|
+
prefixes = roots.map { |root| File.join(File.expand_path(root), "") }
|
|
22
|
+
reached = Set.new
|
|
23
|
+
before = branch_snapshot
|
|
24
|
+
value = trace_lines(prefixes, reached, &block)
|
|
25
|
+
reached.merge(branch_keys(before, branch_snapshot, prefixes))
|
|
26
|
+
[value, reached]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The runtime this gem ships, the default measurement scope.
|
|
30
|
+
def default_roots = [File.expand_path("..", __dir__)]
|
|
31
|
+
|
|
32
|
+
def trace_lines(prefixes, reached)
|
|
33
|
+
value = nil
|
|
34
|
+
tracer = TracePoint.new(:line) do |point|
|
|
35
|
+
path = point.path
|
|
36
|
+
reached << "#{path}:#{point.lineno}" if in_roots?(path, prefixes)
|
|
37
|
+
end
|
|
38
|
+
tracer.enable { value = yield }
|
|
39
|
+
value
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [Hash, nil] Coverage's branch table so far (files that report branches), or nil
|
|
43
|
+
# when Coverage is not running
|
|
44
|
+
def branch_snapshot
|
|
45
|
+
return unless defined?(::Coverage) && ::Coverage.respond_to?(:running?) && ::Coverage.running?
|
|
46
|
+
|
|
47
|
+
::Coverage.peek_result.filter_map { |path, entry| [path, entry[:branches]] if entry.is_a?(Hash) && entry[:branches] }.to_h
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Branch targets whose hit count grew between two snapshots.
|
|
51
|
+
def branch_keys(before, after, prefixes)
|
|
52
|
+
return [] unless before && after
|
|
53
|
+
|
|
54
|
+
after.select { |path, _branches| in_roots?(path, prefixes) }.flat_map do |path, branches|
|
|
55
|
+
grown(before[path] || {}, branches).map { |key| "#{path}:#{key}" }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def in_roots?(path, prefixes) = prefixes.any? { |prefix| path.start_with?(prefix) }
|
|
60
|
+
|
|
61
|
+
def grown(old, new)
|
|
62
|
+
new.flat_map do |origin, targets|
|
|
63
|
+
targets.filter_map do |target, count|
|
|
64
|
+
"#{origin.drop(1).join(":")}:#{target.drop(1).join(":")}" if count > (old.dig(origin, target) || 0)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module Shrinker
|
|
4
|
+
# Pass 3 of the shrinker: offers each surviving argument value a simpler stand-in and keeps
|
|
5
|
+
# the ones the finding survives. Split out of `Shrinker` so each file holds one idea.
|
|
6
|
+
module ValueSimplification
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Offers each surviving argument a simpler value, keeping one the finding survives. Nested
|
|
10
|
+
# hashes are simplified key by key; a candidate that stops reproducing is rejected.
|
|
11
|
+
def call(steps, meter, &)
|
|
12
|
+
steps.each_index do |position|
|
|
13
|
+
original = Shrinker.args_of(steps[position])
|
|
14
|
+
next unless original.is_a?(Hash)
|
|
15
|
+
|
|
16
|
+
original.each_key do |key|
|
|
17
|
+
steps = simplify_path(steps, position, [key], meter, &)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
steps
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Simplifies the value at `path` inside step `position`'s arguments, recursing into hashes.
|
|
24
|
+
def simplify_path(steps, position, path, meter, &)
|
|
25
|
+
value = Shrinker.args_of(steps[position]).dig(*path)
|
|
26
|
+
if value.is_a?(Hash)
|
|
27
|
+
return value.each_key.reduce(steps) { |acc, key| simplify_path(acc, position, path + [key], meter, &) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
simpler_values(value).each do |replacement|
|
|
31
|
+
return steps if meter.exhausted?
|
|
32
|
+
|
|
33
|
+
candidate = with_value(steps, position, path, replacement)
|
|
34
|
+
return candidate if meter.try { yield(candidate) }
|
|
35
|
+
end
|
|
36
|
+
steps
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Simpler stand-ins for `value`, simplest first; none for what is already minimal.
|
|
40
|
+
def simpler_values(value)
|
|
41
|
+
candidates =
|
|
42
|
+
case value
|
|
43
|
+
when String then ["", value[0].to_s]
|
|
44
|
+
when Integer then [0, 1]
|
|
45
|
+
when Float then [0.0, 1.0]
|
|
46
|
+
when Array then [[], value.first(1)]
|
|
47
|
+
else []
|
|
48
|
+
end
|
|
49
|
+
candidates.uniq.reject { |candidate| candidate == value }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def with_value(steps, position, path, replacement)
|
|
53
|
+
step = steps[position]
|
|
54
|
+
args = Marshal.load(Marshal.dump(Shrinker.args_of(step)))
|
|
55
|
+
parent = path.length > 1 ? args.dig(*path[0...-1]) : args
|
|
56
|
+
parent[path.last] = replacement
|
|
57
|
+
candidate = steps.map(&:dup)
|
|
58
|
+
candidate[position] = step.merge("args" => args)
|
|
59
|
+
candidate
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -5,6 +5,8 @@ module Hecks
|
|
|
5
5
|
#
|
|
6
6
|
# Pass 1 removes step chunks (delta debugging) until the list is 1-minimal.
|
|
7
7
|
# Pass 2 drops argument keys one at a time inside each surviving step.
|
|
8
|
+
# Pass 3 replaces each surviving argument value with a simpler one (empty, zero, first
|
|
9
|
+
# element) wherever the finding still reproduces, so a repro reads as small as it is.
|
|
8
10
|
# `budget:` caps candidate checks; when spent, the best candidate so far is returned.
|
|
9
11
|
module Shrinker
|
|
10
12
|
Result = Struct.new(:steps, :attempts, :exhausted, keyword_init: true)
|
|
@@ -26,6 +28,7 @@ module Hecks
|
|
|
26
28
|
meter = Meter.new(budget)
|
|
27
29
|
current = drop_steps(steps.dup, meter, &reproduces)
|
|
28
30
|
current = drop_arguments(current, meter, &reproduces)
|
|
31
|
+
current = ValueSimplification.call(current, meter, &reproduces)
|
|
29
32
|
Result.new(steps: current, attempts: meter.used, exhausted: meter.exhausted?)
|
|
30
33
|
end
|
|
31
34
|
|
data/lib/hecks/fuzzing.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "fuzzing/sequence_generator"
|
|
|
11
11
|
require_relative "fuzzing/nondeterministic"
|
|
12
12
|
require_relative "fuzzing/replay"
|
|
13
13
|
require_relative "fuzzing/shrinker"
|
|
14
|
+
require_relative "fuzzing/shrinker/value_simplification"
|
|
14
15
|
require_relative "fuzzing/properties"
|
|
15
16
|
require_relative "fuzzing/qa_settings"
|
|
16
17
|
require_relative "fuzzing/rotation_priority"
|
|
@@ -20,4 +21,6 @@ require_relative "fuzzing/form_census"
|
|
|
20
21
|
require_relative "fuzzing/target_capabilities"
|
|
21
22
|
require_relative "fuzzing/rust_gap_manifest"
|
|
22
23
|
require_relative "fuzzing/structural_skips"
|
|
24
|
+
require_relative "fuzzing/runtime_coverage"
|
|
23
25
|
require_relative "fuzzing/coverage_campaign"
|
|
26
|
+
require_relative "fuzzing/failure_triage"
|
data/lib/hecks/gate/stages.yml
CHANGED
|
@@ -25,7 +25,17 @@ pre_push:
|
|
|
25
25
|
checks:
|
|
26
26
|
- id: rspec
|
|
27
27
|
title: rspec (parallel)
|
|
28
|
-
run:
|
|
28
|
+
run:
|
|
29
|
+
- sh
|
|
30
|
+
- -ec
|
|
31
|
+
- |
|
|
32
|
+
# Workers are grouped by this machine's own last timings, kept under the git directory
|
|
33
|
+
# (shared by every worktree, never committed). CI's committed baseline leaves out the io
|
|
34
|
+
# specs this run includes, so it only seeds the first run; each run then rewrites the log.
|
|
35
|
+
log="$(git rev-parse --git-common-dir)/hecks_rspec_runtime.log"
|
|
36
|
+
[ -f "$log" ] || cp .github/rspec_runtime_baseline.log "$log"
|
|
37
|
+
bundle exec parallel_rspec spec -n {workers} --group-by runtime --runtime-log "$log" --allowed-missing 100 \
|
|
38
|
+
-o "--format ParallelTests::RSpec::RuntimeLogger --out $log"
|
|
29
39
|
blocked: the suite is red.
|
|
30
40
|
- &check_fuzzing
|
|
31
41
|
id: fuzzing
|
|
@@ -114,7 +124,7 @@ ci:
|
|
|
114
124
|
run: [bundle, exec, exe/hecks, fuzz_run.fuzz, adapter=sqlite, seeds=6, --wait]
|
|
115
125
|
blocked: the SQLite adapter broke a property the Memory adapter held; seeds are 1..N, so the run repeats.
|
|
116
126
|
- id: fuzz_postgres
|
|
117
|
-
title: fuzz on real Postgres,
|
|
127
|
+
title: fuzz on real Postgres, up to four workers (each in its own scratch schema)
|
|
118
128
|
run: [bundle, exec, exe/hecks, fuzz_run.fuzz, adapter=postgres, seeds=3, steps=20, --wait]
|
|
119
129
|
blocked: the Postgres adapter broke a property the Memory adapter held; it needs PGHOST, PGUSER and PGPASSWORD.
|
|
120
130
|
- id: vocabulary_drift
|
|
@@ -89,9 +89,10 @@ module Hecks
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
# What the script would overwrite, read from the script itself (its schemas and databases).
|
|
92
|
+
# Its schemas are the default of the `SCHEMAS=${SCHEMAS:-"..."}` line (overridable).
|
|
92
93
|
def copy_plan(script, held)
|
|
93
94
|
text = File.read(script)
|
|
94
|
-
schemas = text[/^SCHEMAS="([^"]*)"/, 1].to_s.split.join(", ")
|
|
95
|
+
schemas = text[/^SCHEMAS=(?:\$\{SCHEMAS:-)?"([^"]*)"/, 1].to_s.split.join(", ")
|
|
95
96
|
source_db, target_db = copy_databases(text, held)
|
|
96
97
|
drop = plain(held[:force]) == true ? "; force=true drops each target schema first" : ""
|
|
97
98
|
"copy schemas #{schemas} from database #{source_db} on #{plain(held[:source])} into database " \
|
|
@@ -89,17 +89,19 @@ module Hecks
|
|
|
89
89
|
end
|
|
90
90
|
|
|
91
91
|
def compare_checks(projected, live)
|
|
92
|
-
mine =
|
|
93
|
-
theirs =
|
|
92
|
+
mine = required(projected)
|
|
93
|
+
theirs = required(live)
|
|
94
94
|
return [] if mine == theirs
|
|
95
95
|
|
|
96
96
|
["#{projected["name"]}: required checks are #{theirs.inspect} on GitHub, #{mine.inspect} in the model"]
|
|
97
97
|
end
|
|
98
98
|
|
|
99
|
-
#
|
|
100
|
-
|
|
99
|
+
# Each check a ruleset's `required_status_checks` rule demands, as its name and the app id it
|
|
100
|
+
# must come from (nil when any app may report it), sorted.
|
|
101
|
+
def required(ruleset)
|
|
101
102
|
rule = Array(ruleset["rules"]).find { |candidate| candidate["type"] == "required_status_checks" }
|
|
102
|
-
Array(rule&.dig("parameters", "required_status_checks"))
|
|
103
|
+
Array(rule&.dig("parameters", "required_status_checks"))
|
|
104
|
+
.map { |check| [check["context"], check["integration_id"]] }.sort_by(&:to_s)
|
|
103
105
|
end
|
|
104
106
|
|
|
105
107
|
def actors(ruleset)
|
|
@@ -32,6 +32,12 @@ module Hecks
|
|
|
32
32
|
# What the sweep child runs: the `fuzz` tool, which takes the flags that follow.
|
|
33
33
|
ENTRY = 'require "hecks/tools"; Hecks::Tools.script("fuzz", ARGV)'
|
|
34
34
|
|
|
35
|
+
# What the mutation child runs: the `mutate` tool, which takes the flags that follow.
|
|
36
|
+
MUTATE_ENTRY = 'require "hecks/tools"; Hecks::Tools.script("mutate", ARGV)'
|
|
37
|
+
|
|
38
|
+
# The flags of a mutation run, and the fields of the record that fill them.
|
|
39
|
+
MUTATE_FLAGS = { "--seeds" => :seeds, "--steps" => :steps, "--budget" => :budget }.freeze
|
|
40
|
+
|
|
35
41
|
# Signals passed on to a running child's process group.
|
|
36
42
|
FORWARDED = %w[INT TERM HUP QUIT].freeze
|
|
37
43
|
|
|
@@ -63,6 +69,18 @@ module Hecks
|
|
|
63
69
|
answer(run([RbConfig.ruby, "-I", LIB, "-e", ENTRY, "--", *sweep_words(held)]))
|
|
64
70
|
end
|
|
65
71
|
|
|
72
|
+
# Mutation-tests one domain: changes its rules in small ways and reports each change its
|
|
73
|
+
# checks let through.
|
|
74
|
+
#
|
|
75
|
+
# @param held [Hash] the `Mutating` record: `domain`, `seeds`, `steps`, `budget`
|
|
76
|
+
# @return [Hash{Symbol => Hash}] `report:` what the run printed
|
|
77
|
+
# @raise [ConsoleCapture::Failure] when the run could not start
|
|
78
|
+
def probe(**held)
|
|
79
|
+
words = [plain(held[:domain]), *flag_words(held, MUTATE_FLAGS)]
|
|
80
|
+
|
|
81
|
+
answer(run([RbConfig.ruby, "-I", LIB, "-e", MUTATE_ENTRY, "--", *words]))
|
|
82
|
+
end
|
|
83
|
+
|
|
66
84
|
# Starts a child, forwards interrupts to it, and waits for it to end.
|
|
67
85
|
#
|
|
68
86
|
# @param command [Array<String>] the program, then its arguments
|
|
@@ -94,12 +112,16 @@ module Hecks
|
|
|
94
112
|
|
|
95
113
|
# The words after `--`: the domain when named, then each flag the record names.
|
|
96
114
|
def sweep_words(held)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
[*plain(held[:domain]), *flag_words(held, SWEEP_FLAGS), *persist_words(held)]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Each flag whose field the record holds, followed by that field's value.
|
|
119
|
+
def flag_words(held, flags)
|
|
120
|
+
flags.flat_map { |flag, key| plain(held[key]).nil? ? [] : [flag, plain(held[key]).to_s] }
|
|
101
121
|
end
|
|
102
122
|
|
|
123
|
+
def persist_words(held) = plain(held[:persist_regressions]) == true ? ["--persist-regressions"] : []
|
|
124
|
+
|
|
103
125
|
# The options a child is spawned with: its own process group, its output in `output`.
|
|
104
126
|
def spawn_options(output, chdir)
|
|
105
127
|
options = { pgroup: true, out: output, err: output }
|