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.
Files changed (146) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  5. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  6. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  7. data/lib/hecks/cache_dir.rb +10 -0
  8. data/lib/hecks/corpus/routes.rb +1 -1
  9. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  10. data/lib/hecks/deploy/oidc.json +10 -0
  11. data/lib/hecks/doors/usage_cache.rb +19 -9
  12. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  13. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  14. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  15. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  16. data/lib/hecks/fuzzing/form_census.rb +28 -52
  17. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  18. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  19. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  20. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  21. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  22. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  23. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  24. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  25. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  26. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  27. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  28. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  29. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  30. data/lib/hecks/fuzzing/mutation.rb +12 -0
  31. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  32. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  33. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  34. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  35. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  36. data/lib/hecks/fuzzing/properties.rb +7 -0
  37. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  38. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  39. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  40. data/lib/hecks/fuzzing/replay.rb +1 -0
  41. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  42. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  43. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  44. data/lib/hecks/fuzzing.rb +3 -0
  45. data/lib/hecks/gate/stages.yml +12 -2
  46. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  47. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  48. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  49. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  50. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  51. data/lib/hecks/hecks/custodian.behaviors +6 -0
  52. data/lib/hecks/hecks/custodian.bluebook +37 -0
  53. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  54. data/lib/hecks/language/bluebook/vocabulary.bluebook +4 -1
  55. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  56. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  57. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  58. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  59. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  60. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  61. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  62. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  63. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  64. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  65. data/lib/hecks/projections/deploy/box.rb +14 -2
  66. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  67. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  68. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  69. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  70. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  71. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  72. data/lib/hecks/projections/site/probe.rb +131 -0
  73. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  74. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  75. data/lib/hecks/projections.rb +2 -0
  76. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  77. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  78. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  79. data/lib/hecks/runtime/value/validation.rb +34 -3
  80. data/lib/hecks/rust_build/native_build.rb +29 -1
  81. data/lib/hecks/rust_build/wasm.rb +16 -3
  82. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  83. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  84. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  85. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  86. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  87. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  88. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  89. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  90. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  91. data/lib/hecks/tools/lanes/files.rb +2 -4
  92. data/lib/hecks/tools/lanes/live.rb +3 -4
  93. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  94. data/lib/hecks/tools/lanes.rb +12 -1
  95. data/lib/hecks/tools/mutation_run.rb +73 -0
  96. data/lib/hecks/tools.rb +1 -0
  97. data/lib/hecks/version.rb +1 -1
  98. data/lib/hecks/vocabulary.rb +3 -1
  99. data/prebuilt/4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal +0 -0
  100. data/prebuilt/911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal +0 -0
  101. data/prebuilt/verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json +1 -0
  102. data/qa/settings.yml +1 -0
  103. data/rust/codegen/src/fielded.rs +3 -3
  104. data/rust/codegen/src/json_codec.rs +2 -2
  105. data/rust/codegen/src/naming.rs +20 -0
  106. data/rust/host/Cargo.toml +6 -2
  107. data/rust/host/HECKS_RELEASE +1 -1
  108. data/rust/host/src/api.rs +28 -5
  109. data/rust/host/src/auth.rs +11 -1
  110. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  111. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  112. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  113. data/rust/host/src/boundary_fuzz/ir.rs +79 -0
  114. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  115. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  116. data/rust/host/src/commerce_ir.rs +459 -0
  117. data/rust/host/src/extension.rs +100 -0
  118. data/rust/host/src/fuzz_support.rs +165 -0
  119. data/rust/host/src/ir.rs +4 -448
  120. data/rust/host/src/journal.rs +1 -1
  121. data/rust/host/src/lib.rs +39 -0
  122. data/rust/host/src/main.rs +10 -44
  123. data/rust/host/src/mint.rs +1 -1
  124. data/rust/host/src/payments/tests.rs +8 -8
  125. data/rust/host/src/payments.rs +10 -10
  126. data/rust/host/src/rate_limit.rs +27 -41
  127. data/rust/host/src/secrets.rs +44 -0
  128. data/rust/host/src/server.rs +6 -1
  129. data/rust/host/src/web/commerce.rs +113 -0
  130. data/rust/host/src/web/newsletter.rs +2 -1
  131. data/rust/host/src/web/newsletter_send.rs +1 -1
  132. data/rust/host/src/web/registration_receipt.rs +2 -2
  133. data/rust/host/src/web/registrations.rs +49 -49
  134. data/rust/host/src/web/resend_webhook.rs +299 -0
  135. data/rust/host/src/web.rs +55 -49
  136. data/rust/parser/fuzz/.gitignore +4 -0
  137. data/rust/parser/fuzz/Cargo.lock +89 -0
  138. data/rust/parser/fuzz/Cargo.toml +27 -0
  139. data/rust/parser/fuzz/README.md +19 -0
  140. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  141. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  142. data/rust/parser/src/parse/command.rs +14 -2
  143. data/rust/parser/src/parse/mod.rs +25 -0
  144. data/rust/parser/src/ruby_value.rs +95 -6
  145. data/rust/src/kernel/json.rs +34 -3
  146. metadata +58 -2
@@ -8,11 +8,17 @@ module Hecks
8
8
  module FuzzSweep
9
9
  # The command line of a sweep: its options, and what the chosen adapter needs.
10
10
  module Options
11
+ # The options that take a whole number, and the key each fills.
12
+ INTEGER_OPTIONS = { "--seeds" => :seeds, "--steps" => :steps, "--workers" => :workers }.freeze
13
+
14
+ # The most children a Postgres sweep runs at once unless `--workers` says otherwise.
15
+ POSTGRES_WORKERS = 4
16
+
11
17
  # @param args [Array<String>] the command line, consumed
12
- # @return [Hash, nil] `seeds`, `steps`, `workers`, `adapter` and `domain`; nil after a
13
- # refusal
18
+ # @return [Hash, nil] `seeds`, `steps`, `workers`, `adapter`, `persist` and `domain`; nil
19
+ # after a refusal
14
20
  def parse(args)
15
- options = { seeds: 20, steps: 30, workers: nil, adapter: :memory, domain: nil }
21
+ options = { seeds: 20, steps: 30, workers: nil, adapter: :memory, domain: nil, persist: false }
16
22
  until args.empty?
17
23
  apply_option(options, args.shift, args)
18
24
  return nil unless adapter_known?(options[:adapter])
@@ -22,11 +28,9 @@ module Hecks
22
28
 
23
29
  # Records one command-line word in `options`, taking its value from `rest` when it has one.
24
30
  def apply_option(options, arg, rest)
25
- case arg
26
- when "--seeds" then options[:seeds] = Integer(rest.shift)
27
- when "--steps" then options[:steps] = Integer(rest.shift)
28
- when "--workers" then options[:workers] = Integer(rest.shift)
29
- when "--adapter" then options[:adapter] = rest.shift.to_s.downcase.to_sym
31
+ if (key = INTEGER_OPTIONS[arg]) then options[key] = Integer(rest.shift)
32
+ elsif arg == "--persist-regressions" then options[:persist] = true
33
+ elsif arg == "--adapter" then options[:adapter] = rest.shift.to_s.downcase.to_sym
30
34
  else options[:domain] = arg
31
35
  end
32
36
  end
@@ -44,11 +48,18 @@ module Hecks
44
48
  options[:adapter] != :postgres || postgres_reachable?
45
49
  end
46
50
 
47
- # One child per core; a real Postgres runs one at a time.
51
+ # One child per core. A real Postgres is capped, because each child holds its own
52
+ # connections and its own scratch schema, and runs one at a time on macOS, where libpq
53
+ # segfaults when a forked child connects.
48
54
  #
49
55
  # @return [Integer] how many children may run at once
50
56
  def worker_count(options)
51
- options[:workers] || (options[:adapter] == :postgres ? 1 : Etc.nprocessors)
57
+ options[:workers] || (options[:adapter] == :postgres ? postgres_workers : Etc.nprocessors)
58
+ end
59
+
60
+ # @return [Integer] the children a Postgres sweep runs at once by default
61
+ def postgres_workers
62
+ RUBY_PLATFORM.include?("darwin") ? 1 : [Etc.nprocessors, POSTGRES_WORKERS].min
52
63
  end
53
64
 
54
65
  # `postgres` needs a reachable local server and is slower per dispatch, so pass smaller
@@ -51,9 +51,17 @@ module Hecks
51
51
  def report_findings(result)
52
52
  grouped = result.failures.group_by { |failure| failure[:signature] }
53
53
  puts " #{grouped.size} distinct finding(s):"
54
+ shrunk = shrink_groups(result, grouped.values)
55
+ persist_regressions(result, shrunk) if result.persist
56
+ end
57
+
58
+ # Prints each finding and saves its shrunk repro.
59
+ #
60
+ # @return [Array<Hash>] the findings, with their shrunk `:steps`
61
+ def shrink_groups(result, groups)
54
62
  save_dir = save_directory(result)
55
- grouped.each_value.with_index(1) do |group, number|
56
- print_finding(group, number, result.seeds)
63
+ groups.each_with_index.map do |group, index|
64
+ print_finding(group, index + 1, result.seeds)
57
65
  save_finding(result, group.min_by { |failure| failure[:steps].length }, save_dir)
58
66
  end
59
67
  end
@@ -71,11 +79,25 @@ module Hecks
71
79
  end
72
80
 
73
81
  # Shrinks the smallest failing sequence of a finding and saves it as a repro script.
82
+ #
83
+ # @return [Hash] the finding, with its shrunk `:steps`
84
+ def repro_path(result, finding, save_dir) = File.join(save_dir, "#{result.name}-seed#{finding[:seed]}.json")
85
+
74
86
  def save_finding(result, smallest, save_dir)
75
87
  shrunk = shrink(result.domain, smallest[:steps], smallest[:signature], result.adapter)
76
- save_path = File.join(save_dir, "#{result.name}-seed#{smallest[:seed]}.json")
88
+ save_path = repro_path(result, smallest, save_dir)
77
89
  write_script(shrunk, save_path, smallest[:seed], result.name)
78
90
  print_saved(smallest[:steps].length, shrunk.length, "hecks run #{result.domain} #{save_path}")
91
+ smallest.merge(steps: shrunk)
92
+ end
93
+
94
+ # Keeps one minimized repro per distinct finding. Findings that shrank to the same thing
95
+ # collapse here, and one already kept from an earlier run is left alone.
96
+ def persist_regressions(result, shrunk)
97
+ Hecks::Fuzzing::FailureTriage.dedupe(shrunk).each do |finding|
98
+ path = Hecks::Fuzzing::FailureTriage.persist(result.root, result.name, finding)
99
+ puts(path ? " kept regression: #{path}" : " regression #{finding[:triage]} already kept")
100
+ end
79
101
  end
80
102
 
81
103
  def print_saved(from, to, command)
@@ -15,6 +15,7 @@ module Hecks
15
15
  # properties, then shrinks each distinct finding and saves it under `tmp/fuzz-failures/`.
16
16
  #
17
17
  # hecks fuzz [domain] [--seeds N] [--steps N] [--workers N] [--adapter memory|sqlite|postgres]
18
+ # [--persist-regressions]
18
19
  #
19
20
  # The sweep is single-runtime on purpose; the cross-runtime differential is
20
21
  # `spec/rust_conformance_fuzz_spec.rb`. Without a domain it sweeps every domain
@@ -40,7 +41,7 @@ module Hecks
40
41
  Draw = Struct.new(:clean, :failures, :drawn, :skipped)
41
42
 
42
43
  # What the sweep of one domain drew and found, for the final report.
43
- Result = Struct.new(:domain, :name, :seeds, :drawn, :clean, :failures, :adapter, :root)
44
+ Result = Struct.new(:domain, :name, :seeds, :drawn, :clean, :failures, :adapter, :root, :persist)
44
45
 
45
46
  extend Options
46
47
  extend Findings
@@ -83,7 +84,16 @@ module Hecks
83
84
  require "hecks/bluebook/meta_validator"
84
85
  Hecks::Bluebook::MetaValidator.grammar_registry
85
86
  $stdout.flush
86
- fuzz_in_pool(domains, workers) { |domain| fuzz_with(options, domain, root) }
87
+ fuzz_in_pool(domains, workers) { |domain| fuzz_in_child(options, domain, root) }
88
+ end
89
+
90
+ # Runs in a forked child. A Postgres child gets its own scratch schema, so children can fuzz
91
+ # one database at once.
92
+ #
93
+ # @return [Boolean] whether the domain was clean
94
+ def fuzz_in_child(options, domain, root)
95
+ ENV[Hecks::Fuzzing::IsolatedBoot::FUZZ_SCHEMA_ENV] = "hecks_fuzz_#{Process.pid}" if options[:adapter] == :postgres
96
+ fuzz_with(options, domain, root)
87
97
  end
88
98
 
89
99
  # @return [Boolean] whether every domain was clean
@@ -92,29 +102,36 @@ module Hecks
92
102
  end
93
103
 
94
104
  def fuzz_with(options, domain, root)
95
- fuzz_domain(domain, options[:seeds], options[:steps], options[:adapter], root)
105
+ fuzz_domain(domain, options, root)
96
106
  end
97
107
 
98
108
  # Draws seeds until `seeds` results execute or the draw cap is hit.
99
109
  #
100
110
  # @param domain [String] the domain directory
101
- # @param seeds [Integer] how many executing seeds to ask for
102
- # @param steps [Integer] how many steps each sequence asks for
103
- # @param adapter [Symbol] the persistence to boot on
111
+ # @param options [Hash] the parsed command line: `seeds` (how many executing seeds to ask
112
+ # for), `steps` (how many steps each sequence asks for), `adapter` (the persistence to boot
113
+ # on) and `persist` (whether each distinct finding's minimized repro is also kept under
114
+ # `spec/corpus/regressions/`)
104
115
  # @param root [String] the checkout
105
116
  # @return [Boolean] true if clean or skipped
106
- def fuzz_domain(domain, seeds, steps, adapter = :memory, root = Tools::ROOT)
117
+ def fuzz_domain(domain, options, root = Tools::ROOT)
107
118
  domain = domain.chomp("/")
108
119
  name = File.basename(domain)
120
+ adapter = options[:adapter]
109
121
  puts "── #{name}#{" (#{adapter})" unless adapter == :memory}"
110
122
 
111
- draw = draw_seeds(domain, seeds, steps, adapter)
123
+ draw = draw_seeds(domain, options[:seeds], options[:steps], adapter)
112
124
  if draw.skipped
113
125
  puts " skipped — #{draw.skipped} (not fuzzable under :#{adapter})"
114
126
  return true
115
127
  end
116
128
 
117
- report(settle_known(Result.new(domain, name, seeds, draw.drawn, draw.clean, draw.failures, adapter, root)))
129
+ report(settle_known(sweep_result(domain, name, draw, options, root)))
130
+ end
131
+
132
+ def sweep_result(domain, name, draw, options, root)
133
+ Result.new(domain, name, options[:seeds], draw.drawn, draw.clean, draw.failures,
134
+ options[:adapter], root, options[:persist] || false)
118
135
  end
119
136
 
120
137
  # Known findings print but count as clean, so the sweep does not read as short.
@@ -41,7 +41,7 @@ module Hecks
41
41
  private
42
42
 
43
43
  def rulesets
44
- Lanes.lanes.select { |lane| lane["guarded"] == "yes" }.to_h { |lane| [ruleset_path(lane), ruleset(lane)] }
44
+ Lanes.rulesets.to_h { |name, ruleset| [ruleset_path(name), "#{JSON.pretty_generate(ruleset)}\n"] }
45
45
  end
46
46
 
47
47
  # The workflow at `path` for the lanes whose `column` is set, or no file if none is.
@@ -50,9 +50,7 @@ module Hecks
50
50
  lanes.empty? ? {} : { File.join(@root, path) => yield(lanes) }
51
51
  end
52
52
 
53
- def ruleset_path(lane) = File.join(@root, Lanes::RULESETS, "#{lane["name"]}.json")
54
-
55
- def ruleset(lane) = "#{JSON.pretty_generate(Lanes.ruleset(lane))}\n"
53
+ def ruleset_path(name) = File.join(@root, Lanes::RULESETS, "#{name}.json")
56
54
 
57
55
  def write(files, stale, extra)
58
56
  stale.each do |path|
@@ -14,7 +14,7 @@ module Hecks
14
14
  # @param confirm [Boolean] whether to create or update the rulesets
15
15
  # @return [Integer] 0 when GitHub agrees (or was made to), else 1
16
16
  def run(confirm)
17
- found = Lanes.lanes.select { |lane| lane["guarded"] == "yes" }.flat_map { |lane| sync(lane, confirm) }
17
+ found = Lanes.rulesets.values.flat_map { |projected| sync(projected, confirm) }
18
18
  puts "lanes: GitHub holds every guarded lane's ruleset as projected" if found.empty? && !confirm
19
19
  return 0 if found.empty?
20
20
 
@@ -24,9 +24,8 @@ module Hecks
24
24
 
25
25
  private
26
26
 
27
- # @return [Array<String>] the differences left after the lane was looked at
28
- def sync(lane, confirm)
29
- projected = Lanes.ruleset(lane)
27
+ # @return [Array<String>] the differences left after the ruleset was looked at
28
+ def sync(projected, confirm)
30
29
  differences = @github.differences(projected, @github.named(projected["name"]))
31
30
  differences.each { |line| puts line }
32
31
  return [] if differences.empty?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "hecks/vocabulary"
4
+ require_relative "../../quality_control/adapters/github_checks"
4
5
 
5
6
  module Hecks
6
7
  module Tools
@@ -12,6 +13,10 @@ module Hecks
12
13
  # past it: a push of a commit that passed is how a promotion moves the lane, and the GitHub
13
14
  # Actions app cannot be named as a bypass actor.
14
15
  module Ruleset
16
+ # The GitHub Actions app. A required check counts only when this app reported it, so no
17
+ # other app, token or person can post a passing check of the same name against a commit.
18
+ GITHUB_ACTIONS_APP_ID = Hecks::Adapters::GithubChecks::GITHUB_ACTIONS_APP_ID
19
+
15
20
  # What every guarded lane forbids: deleting it and rewinding it.
16
21
  FIXED = [{ "type" => "deletion" }, { "type" => "non_fast_forward" }].freeze
17
22
 
@@ -25,6 +30,21 @@ module Hecks
25
30
  "bypass_actors" => [], "rules" => rules(lane) }
26
31
  end
27
32
 
33
+ # The ruleset for the tag a guarded lane feeds. The tag follows the lane, so it may move
34
+ # forward, which GitHub allows under `non_fast_forward` (a descendant is not a force-push);
35
+ # it cannot be deleted or pointed at an older commit, and no actor is let past it.
36
+ #
37
+ # @param lane [Hash{String => String}] a guarded `Lane` row
38
+ # @return [Hash, nil] the ruleset GitHub is given for the tag, nil when the lane feeds none
39
+ def tag(lane)
40
+ feed = lane["feeds"].to_s
41
+ return if feed.empty?
42
+
43
+ { "name" => "tag-#{feed}", "target" => "tag", "enforcement" => "active",
44
+ "conditions" => { "ref_name" => { "include" => ["refs/tags/#{feed}"], "exclude" => [] } },
45
+ "bypass_actors" => [], "rules" => FIXED }
46
+ end
47
+
28
48
  # @param lane [Hash{String => String}] a `Lane` row
29
49
  # @return [Array<Hash>] the rules: the fixed ones, and the required checks of a `green` lane
30
50
  def rules(lane)
@@ -33,7 +53,9 @@ module Hecks
33
53
 
34
54
  # @return [Hash] the rule that a pushed commit has already passed every `RequiredCheck`
35
55
  def required_checks
36
- contexts = Hecks::Vocabulary.rows("RequiredCheck").map { |check| { "context" => check["name"] } }
56
+ contexts = Hecks::Vocabulary.rows("RequiredCheck").map do |check|
57
+ { "context" => check["name"], "integration_id" => GITHUB_ACTIONS_APP_ID }
58
+ end
37
59
  { "type" => "required_status_checks",
38
60
  "parameters" => { "strict_required_status_checks_policy" => false, "do_not_enforce_on_create" => false,
39
61
  "required_status_checks" => contexts } }
@@ -7,7 +7,8 @@ require_relative "../hecks/adapters/github_rulesets"
7
7
  module Hecks
8
8
  module Tools
9
9
  # Projects the `Lane` rows of the Vocabulary chapter into what GitHub is told about them: a
10
- # ruleset for each guarded lane (`.github/rulesets/<lane>.json`) and the workflow that promotes
10
+ # ruleset for each guarded lane (`.github/rulesets/<lane>.json`), one for the tag such a lane
11
+ # feeds (`.github/rulesets/tag-<tag>.json`), and the workflow that promotes
11
12
  # each lane that follows another (`.github/workflows/promote.yml`). The workflow only runs
12
13
  # `hecks promotion_run.promote`; the rule that decides a promotion is `PromotionRun.Accept`.
13
14
  #
@@ -56,6 +57,16 @@ module Hecks
56
57
  # @return [Hash] the ruleset GitHub is given for it
57
58
  def ruleset(lane) = Ruleset.build(lane)
58
59
 
60
+ # @return [Hash{String => Hash}] every ruleset the rows project, by the name of its file
61
+ # under `.github/rulesets`: for each guarded lane its own, and one for the tag it feeds
62
+ def rulesets
63
+ lanes.select { |lane| lane["guarded"] == "yes" }.each_with_object({}) do |lane, found|
64
+ found[lane["name"]] = Ruleset.build(lane)
65
+ tag = Ruleset.tag(lane)
66
+ found[tag["name"]] = tag if tag
67
+ end
68
+ end
69
+
59
70
  # @param promoted [Array<Hash{String => String}>] the lanes that follow another
60
71
  # @return [String] the promotion workflow
61
72
  def workflow(promoted) = Workflow.text(promoted)
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../tools"
4
+
5
+ module Hecks
6
+ module Tools
7
+ # Mutation-tests a domain's own checks: changes its bluebooks in small ways and reports every
8
+ # change that the fuzz properties, the corpus script and the behaviors tests let through.
9
+ #
10
+ # hecks mutate <domain> [--seed N] [--budget N] [--seeds N] [--steps N] [--corpus path]
11
+ # [--operators a,b] [--min-score fraction]
12
+ #
13
+ # Exit 0 unless `--min-score` is given and the score is below it. See
14
+ # `Hecks::Fuzzing::Mutation`.
15
+ module MutationRun
16
+ USAGE = "usage: hecks mutate <domain> [--seed N] [--budget N] [--seeds N] [--steps N] [--corpus PATH] " \
17
+ "[--operators a,b] [--min-score FRACTION]"
18
+
19
+ # Each flag: the option it sets and how its value is read.
20
+ FLAGS = {
21
+ "--seed" => [:seed, :integer],
22
+ "--budget" => [:budget, :integer],
23
+ "--seeds" => [:seeds, :integer],
24
+ "--steps" => [:steps, :integer],
25
+ "--corpus" => [:corpus, :text],
26
+ "--operators" => [:operators, :names],
27
+ "--min-score" => [:min_score, :fraction]
28
+ }.freeze
29
+
30
+ READERS = {
31
+ integer: ->(text) { Integer(text) },
32
+ fraction: ->(text) { Float(text) },
33
+ text: ->(text) { text },
34
+ names: ->(text) { text.to_s.split(",").map(&:to_sym) }
35
+ }.freeze
36
+
37
+ module_function
38
+
39
+ # @param argv [Array<String>] the domain directory, then the flags
40
+ # @param root [String] the checkout (unused: the domain is named by path)
41
+ # @return [Integer] 0 when the score is high enough or no minimum was asked, 1 when it is not
42
+ # @raise [SystemExit] with the usage line when an argument is missing or unknown
43
+ def main(argv, root: Tools::ROOT)
44
+ _ = root
45
+ require "hecks"
46
+ require "hecks/fuzzing/mutation"
47
+ args = argv.dup
48
+ domain = args.shift or abort USAGE
49
+ options = parse(args)
50
+
51
+ report = Hecks::Fuzzing::Mutation::Run.call(domain, **options.except(:min_score))
52
+ puts report
53
+ report.passes?(options.fetch(:min_score, 0.0)) ? 0 : 1
54
+ end
55
+
56
+ # @param args [Array<String>] the flags, consumed
57
+ # @return [Hash] the options the flags set
58
+ def parse(args)
59
+ options = {}
60
+ apply_flag(options, args.shift, args) until args.empty?
61
+ options
62
+ end
63
+
64
+ # Records one flag in `options`, taking its value from `rest`.
65
+ def apply_flag(options, flag, rest)
66
+ key, kind = FLAGS.fetch(flag) { abort "unknown argument #{flag.inspect}\n#{USAGE}" }
67
+ options[key] = READERS.fetch(kind).call(rest.shift)
68
+ rescue ArgumentError, TypeError
69
+ abort "#{flag} needs a #{kind == :fraction ? "number" : "value"}\n#{USAGE}"
70
+ end
71
+ end
72
+ end
73
+ end
data/lib/hecks/tools.rb CHANGED
@@ -39,6 +39,7 @@ module Hecks
39
39
  "heki_compact" => ["tools/heki_compaction", "HekiCompaction"],
40
40
  "fuzz" => ["tools/fuzz_sweep", "FuzzSweep"],
41
41
  "generate" => ["tools/sequence_script", "SequenceScript"],
42
+ "mutate" => ["tools/mutation_run", "MutationRun"],
42
43
  "corpus" => ["tools/corpus_report", "CorpusReport"],
43
44
  "evolve" => ["tools/evolve_run", "EvolveRun"],
44
45
  "query_ir" => ["tools/query_ir_run", "QueryIrRun"],
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.6.0".freeze
4
+ VERSION = "3.9.0".freeze
5
5
  end
@@ -51,7 +51,7 @@ module Hecks
51
51
  ].freeze,
52
52
  "CiGate" => [
53
53
  {"name"=>"runtime_changed", "workflow"=>"ci.yml", "mode"=>"touches", "pattern"=>"^lib/hecks/runtime/", "push"=>"before_sha", "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)/|\\.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
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)/|\\.rubocop\\.yml$|\\.rubocop_todo\\.yml$|\\.mcp\\.json$|\\.rspec-local\\.example$|README\\.md$|CHANGELOG\\.md$|CONTRIBUTING\\.md$|SECURITY\\.md$|LICENSE$|\\.gitignore$)", "push"=>"skip", "label"=>"does this change touch anything rspec_postgres_io_parallel covers?"}.freeze
55
55
  ].freeze,
56
56
  "Comparison" => [
57
57
  {"symbol"=>">=", "compares_less_than"=>"true", "compares_equal"=>"false", "negated"=>"true"}.freeze,
@@ -153,6 +153,7 @@ module Hecks
153
153
  {"name"=>"guided_generation", "type"=>"Boolean", "meaning"=>"Coverage-guided generation across one sweep's seeds (`Hecks::Fuzzing::CoverageCampaign`)."}.freeze,
154
154
  {"name"=>"corpus_splice_probability", "type"=>"Numeric", "meaning"=>"Fraction (0.0..1.0) of seeds that start from a prefix of an earlier seed's new coverage. Off by default: measured, it trades breadth for depth (banking reached 1.41x the deep-state tuples, chess lost 0.12x of all tuples), while favoring rare verbs alone never lost."}.freeze,
155
155
  {"name"=>"favor_rare_verbs", "type"=>"Integer", "meaning"=>"How many of the least-hit verbs the next seed weights up; 0 turns the lever off."}.freeze,
156
+ {"name"=>"runtime_coverage_feedback", "type"=>"Boolean", "meaning"=>"Whether guided generation also admits a seed to the corpus for reaching a new line or branch of the Ruby runtime (`Hecks::Fuzzing::RuntimeCoverage`), not only a new (verb, kind, state, mutation, outcome) tuple. Off by default: tracing every line slows each seed."}.freeze,
156
157
  {"name"=>"self_consistency_checks", "type"=>"Boolean", "meaning"=>"Whether `hecks quality_control query sweep.run` also runs the self-consistency pass (cold rehydration, replay idempotency, value-object JSON round trip) on the same generated sequence."}.freeze,
157
158
  {"name"=>"shrink_budget", "type"=>"Integer", "meaning"=>"Candidate replays `hecks quality_control query sweep.run` may spend shrinking one surprised check; 0 turns shrinking off. `concurrency` is never shrunk: a race does not reproduce on demand."}.freeze,
158
159
  {"name"=>"yield_weight_seconds", "type"=>"Integer", "meaning"=>"Seconds of staleness that one point of yield score is worth in `RotationPriority.pick`. Zero would make yield invisible, so pick a number that lets it matter."}.freeze,
@@ -414,6 +415,7 @@ module Hecks
414
415
  {"script"=>"fuzz", "section"=>"Custodian", "aggregate"=>"FuzzRun", "verb"=>"fuzz"}.freeze,
415
416
  {"script"=>"generate", "section"=>"Custodian", "aggregate"=>"FuzzRun", "verb"=>"generate_sequence"}.freeze,
416
417
  {"script"=>"bench", "section"=>"Custodian", "aggregate"=>"FuzzRun", "verb"=>"bench"}.freeze,
418
+ {"script"=>"(new)", "section"=>"Custodian", "aggregate"=>"FuzzRun", "verb"=>"mutate"}.freeze,
417
419
  {"script"=>"(new)", "section"=>"Custodian", "aggregate"=>"Era", "verb"=>"hold_first"}.freeze,
418
420
  {"script"=>"project_deploy", "section"=>"Deploy", "aggregate"=>"Recipe", "verb"=>"project"}.freeze,
419
421
  {"script"=>"lint_deploy_recipes", "section"=>"Deploy", "aggregate"=>"MakefileCheck", "verb"=>"lint"}.freeze,