hecks 3.6.0 → 3.7.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 (119) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/syntax_boot/disk_cache.rb +28 -3
  3. data/lib/hecks/bluebook/meta_validator/verdict_cache/prebuilt.rb +44 -0
  4. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +3 -17
  5. data/lib/hecks/cache_dir.rb +10 -0
  6. data/lib/hecks/deploy/bluebook/deploy.bluebook +109 -0
  7. data/lib/hecks/deploy/oidc.json +10 -0
  8. data/lib/hecks/doors/usage_cache.rb +19 -9
  9. data/lib/hecks/fuzzing/coverage_campaign.rb +42 -17
  10. data/lib/hecks/fuzzing/failure_triage.rb +80 -0
  11. data/lib/hecks/fuzzing/form_census/chapter_forms.rb +38 -0
  12. data/lib/hecks/fuzzing/form_census/reference_forms.rb +49 -0
  13. data/lib/hecks/fuzzing/form_census.rb +28 -52
  14. data/lib/hecks/fuzzing/isolated_boot/postgres_scratch.rb +22 -7
  15. data/lib/hecks/fuzzing/isolated_boot/scratch_schema.rb +27 -0
  16. data/lib/hecks/fuzzing/isolated_boot.rb +6 -2
  17. data/lib/hecks/fuzzing/mutation/checks.rb +76 -0
  18. data/lib/hecks/fuzzing/mutation/finders.rb +136 -0
  19. data/lib/hecks/fuzzing/mutation/judge.rb +94 -0
  20. data/lib/hecks/fuzzing/mutation/observation.rb +38 -0
  21. data/lib/hecks/fuzzing/mutation/operators.rb +52 -0
  22. data/lib/hecks/fuzzing/mutation/report.rb +78 -0
  23. data/lib/hecks/fuzzing/mutation/run.rb +99 -0
  24. data/lib/hecks/fuzzing/mutation/selection.rb +32 -0
  25. data/lib/hecks/fuzzing/mutation/site.rb +21 -0
  26. data/lib/hecks/fuzzing/mutation/source_map.rb +59 -0
  27. data/lib/hecks/fuzzing/mutation.rb +12 -0
  28. data/lib/hecks/fuzzing/properties/catalog.rb +10 -59
  29. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +63 -0
  30. data/lib/hecks/fuzzing/properties/read_model_names.rb +50 -0
  31. data/lib/hecks/fuzzing/properties/read_models.rb +138 -0
  32. data/lib/hecks/fuzzing/properties/role_checks.rb +50 -0
  33. data/lib/hecks/fuzzing/properties.rb +7 -0
  34. data/lib/hecks/fuzzing/replay/observation.rb +1 -1
  35. data/lib/hecks/fuzzing/replay/role_check.rb +49 -0
  36. data/lib/hecks/fuzzing/replay/session.rb +22 -6
  37. data/lib/hecks/fuzzing/replay.rb +1 -0
  38. data/lib/hecks/fuzzing/runtime_coverage.rb +70 -0
  39. data/lib/hecks/fuzzing/shrinker/value_simplification.rb +64 -0
  40. data/lib/hecks/fuzzing/shrinker.rb +3 -0
  41. data/lib/hecks/fuzzing.rb +3 -0
  42. data/lib/hecks/gate/stages.yml +1 -1
  43. data/lib/hecks/hecks/adapters/deploy_toolchain/answers.rb +2 -1
  44. data/lib/hecks/hecks/adapters/github_rulesets.rb +7 -5
  45. data/lib/hecks/hecks/adapters/process_pool.rb +26 -4
  46. data/lib/hecks/hecks/custodian.behaviors +6 -0
  47. data/lib/hecks/hecks/custodian.bluebook +37 -0
  48. data/lib/hecks/hecks/hecks.hecksagon +6 -0
  49. data/lib/hecks/language/bluebook/vocabulary.bluebook +3 -0
  50. data/lib/hecks/projections/deploy/box/roll_scripts.rb +6 -4
  51. data/lib/hecks/projections/deploy/box/settings/patterns.rb +4 -0
  52. data/lib/hecks/projections/deploy/box/settings/types.rb +7 -3
  53. data/lib/hecks/projections/deploy/box/settings.rb +18 -0
  54. data/lib/hecks/projections/deploy/box/shared_database.rb +69 -0
  55. data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +6 -7
  56. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +1 -1
  57. data/lib/hecks/projections/deploy/box/templates/provision-database.sh.tmpl +97 -0
  58. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +4 -2
  59. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +2 -1
  60. data/lib/hecks/projections/deploy/box.rb +14 -2
  61. data/lib/hecks/projections/deploy/shared_database_instance/templates/Makefile.tmpl +11 -0
  62. data/lib/hecks/projections/deploy/shared_database_instance/templates/README.md.tmpl +25 -0
  63. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +180 -0
  64. data/lib/hecks/projections/deploy/shared_database_instance.rb +106 -0
  65. data/lib/hecks/projections/deploy/vercel/settings.rb +83 -0
  66. data/lib/hecks/projections/deploy/vercel.rb +134 -0
  67. data/lib/hecks/projections/site/site_host/templates.rb +12 -4
  68. data/lib/hecks/projections.rb +2 -0
  69. data/lib/hecks/quality_control/adapters/github_checks.rb +9 -2
  70. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +1 -0
  71. data/lib/hecks/quality_control/cli/qa_sweep/seed_loop.rb +10 -2
  72. data/lib/hecks/rust_build/native_build.rb +29 -1
  73. data/lib/hecks/rust_build/wasm.rb +16 -3
  74. data/lib/hecks/tools/deploy_recipe/generation.rb +59 -0
  75. data/lib/hecks/tools/deploy_recipe/targets.rb +56 -2
  76. data/lib/hecks/tools/deploy_recipe/worlds.rb +2 -1
  77. data/lib/hecks/tools/deploy_recipe.rb +20 -10
  78. data/lib/hecks/tools/fuzz_sweep/options.rb +21 -10
  79. data/lib/hecks/tools/fuzz_sweep/reporting.rb +25 -3
  80. data/lib/hecks/tools/fuzz_sweep.rb +26 -9
  81. data/lib/hecks/tools/lanes/files.rb +2 -4
  82. data/lib/hecks/tools/lanes/live.rb +3 -4
  83. data/lib/hecks/tools/lanes/ruleset.rb +23 -1
  84. data/lib/hecks/tools/lanes.rb +12 -1
  85. data/lib/hecks/tools/mutation_run.rb +73 -0
  86. data/lib/hecks/tools.rb +1 -0
  87. data/lib/hecks/version.rb +1 -1
  88. data/lib/hecks/vocabulary.rb +2 -0
  89. data/prebuilt/945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal +0 -0
  90. data/prebuilt/a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal +0 -0
  91. data/prebuilt/verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json +1 -0
  92. data/qa/settings.yml +1 -0
  93. data/rust/host/Cargo.toml +2 -2
  94. data/rust/host/HECKS_RELEASE +1 -1
  95. data/rust/host/src/api.rs +28 -5
  96. data/rust/host/src/auth.rs +11 -1
  97. data/rust/host/src/boundary_fuzz/api.rs +260 -0
  98. data/rust/host/src/boundary_fuzz/auth.rs +119 -0
  99. data/rust/host/src/boundary_fuzz/expr_json.rs +158 -0
  100. data/rust/host/src/boundary_fuzz/ir.rs +78 -0
  101. data/rust/host/src/boundary_fuzz/server.rs +227 -0
  102. data/rust/host/src/boundary_fuzz/web.rs +125 -0
  103. data/rust/host/src/fuzz_support.rs +165 -0
  104. data/rust/host/src/ir.rs +4 -0
  105. data/rust/host/src/main.rs +9 -4
  106. data/rust/host/src/secrets.rs +44 -0
  107. data/rust/host/src/server.rs +6 -1
  108. data/rust/host/src/web.rs +12 -2
  109. data/rust/parser/fuzz/.gitignore +4 -0
  110. data/rust/parser/fuzz/Cargo.lock +89 -0
  111. data/rust/parser/fuzz/Cargo.toml +27 -0
  112. data/rust/parser/fuzz/README.md +19 -0
  113. data/rust/parser/fuzz/fuzz_targets/chapter.rs +50 -0
  114. data/rust/parser/fuzz/fuzz_targets/resolve.rs +35 -0
  115. data/rust/parser/src/parse/command.rs +14 -2
  116. data/rust/parser/src/parse/mod.rs +25 -0
  117. data/rust/parser/src/ruby_value.rs +95 -6
  118. data/rust/src/kernel/json.rs +34 -3
  119. metadata +50 -1
@@ -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
- @guard_checks = []
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
- @guard_checks << marks.guard_check.merge(actual_refused: false, actual_kind: nil) if marks.guard_check
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
- def record_refusal(step, error, guard_check)
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)
@@ -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"
@@ -114,7 +114,7 @@ ci:
114
114
  run: [bundle, exec, exe/hecks, fuzz_run.fuzz, adapter=sqlite, seeds=6, --wait]
115
115
  blocked: the SQLite adapter broke a property the Memory adapter held; seeds are 1..N, so the run repeats.
116
116
  - id: fuzz_postgres
117
- title: fuzz on real Postgres, one worker (every ephemeral boot recreates the same scratch schema)
117
+ title: fuzz on real Postgres, up to four workers (each in its own scratch schema)
118
118
  run: [bundle, exec, exe/hecks, fuzz_run.fuzz, adapter=postgres, seeds=3, steps=20, --wait]
119
119
  blocked: the Postgres adapter broke a property the Memory adapter held; it needs PGHOST, PGUSER and PGPASSWORD.
120
120
  - 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 = contexts(projected)
93
- theirs = contexts(live)
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
- # The check names a ruleset's `required_status_checks` rule demands, sorted.
100
- def contexts(ruleset)
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")).map { |check| check["context"] }.sort
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
- words = []
98
- words << plain(held[:domain]) if plain(held[:domain])
99
- SWEEP_FLAGS.each { |flag, key| words.push(flag, plain(held[key]).to_s) unless plain(held[key]).nil? }
100
- words
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 }
@@ -147,6 +147,12 @@ Hecks.behaviors "Custodian" do
147
147
  expect refused: "a count is positive"
148
148
  end
149
149
 
150
+ test "Mutate refuses a budget that is not positive" do
151
+ tests "Mutate", on: "FuzzRun"
152
+ input run: { value: "mutants" }, domain: { value: "examples/pizzas" }, budget: { value: 0 }
153
+ expect refused: "a count is positive"
154
+ end
155
+
150
156
  test "Fuzz refuses a persistence it does not know" do
151
157
  tests "Fuzz", on: "FuzzRun"
152
158
  input run: { value: "sweep" }, adapter: { value: "cassandra" }
@@ -1963,7 +1963,9 @@ policy "RecordTheEra" do
1963
1963
  attribute :seeds, Count, optional: true
1964
1964
  attribute :steps, Count, optional: true
1965
1965
  attribute :workers, Count, optional: true
1966
+ attribute :budget, Count, optional: true
1966
1967
  attribute :adapter, Persistence, optional: true
1968
+ attribute :persist_regressions, RegressionSwitch, optional: true
1967
1969
  attribute :domains, NameList, optional: true
1968
1970
  attribute :targets, NameList, optional: true
1969
1971
  attribute :iterations, Count, optional: true
@@ -2006,6 +2008,11 @@ policy "RecordTheEra" do
2006
2008
  attribute :value, String, pattern: '^(memory|sqlite|postgres)$'
2007
2009
  end
2008
2010
 
2011
+ # Whether each distinct finding's minimized repro is also kept under `spec/corpus/regressions/`.
2012
+ value_object "RegressionSwitch" do
2013
+ attribute :value, Boolean
2014
+ end
2015
+
2009
2016
  # Names, comma separated: benchmark domains, or the targets to measure.
2010
2017
  value_object "NameList" do
2011
2018
  attribute :value, String, pattern: '^[^ \t\n\r,]+(,[^ \t\n\r,]+)*$'
@@ -2047,6 +2054,7 @@ policy "RecordTheEra" do
2047
2054
  attribute :steps, Count, optional: true
2048
2055
  attribute :workers, Count, optional: true
2049
2056
  attribute :adapter, Persistence, optional: true
2057
+ attribute :persist_regressions, RegressionSwitch, optional: true
2050
2058
 
2051
2059
  sets :domain
2052
2060
  sets :run
@@ -2054,10 +2062,34 @@ policy "RecordTheEra" do
2054
2062
  sets :steps
2055
2063
  sets :workers
2056
2064
  sets :adapter
2065
+ sets :persist_regressions
2057
2066
 
2058
2067
  emits FuzzRequested
2059
2068
  end
2060
2069
 
2070
+ # Mutation-tests a domain's own checks: changes its bluebooks in small ways and keeps which
2071
+ # changes the fuzz properties, the corpus script and the domain's behaviors tests let through.
2072
+ # A survivor is a hole in those checks. `budget` caps how many mutants are tried. The run is a
2073
+ # child process started through the ProcessPool adapter, as a sweep is.
2074
+ command "Mutate" do
2075
+ role "Operator"
2076
+ goal "Mutate a domain's rules and keep whether its checks noticed each change"
2077
+
2078
+ attribute :domain, DomainPath
2079
+ attribute :run, FuzzKey
2080
+ attribute :seeds, Count, optional: true
2081
+ attribute :steps, Count, optional: true
2082
+ attribute :budget, Count, optional: true
2083
+
2084
+ sets :domain
2085
+ sets :run
2086
+ sets :seeds
2087
+ sets :steps
2088
+ sets :budget
2089
+
2090
+ emits MutateRequested
2091
+ end
2092
+
2061
2093
  # Measures each persistence adapter, and the Rust binary when there is one to build or use.
2062
2094
  # The report is kept in this record, which the Hecks domain binds to Memory: keeping a
2063
2095
  # domain's benchmark history beside it, in Sqlite under the project's `.hecks/`, is decided
@@ -2148,6 +2180,11 @@ policy "RecordTheEra" do
2148
2180
  trigger FuzzRun::ProcessPool::Sweep, with: { run: :run }
2149
2181
  end
2150
2182
 
2183
+ policy "MutateWhenRequested" do
2184
+ on "FuzzRun.MutateRequested"
2185
+ trigger FuzzRun::ProcessPool::Probe, with: { run: :run }
2186
+ end
2187
+
2151
2188
  policy "MeasureWhenRequested" do
2152
2189
  on "FuzzRun.BenchRequested"
2153
2190
  trigger FuzzRun::RustToolchain::Measure, with: { run: :run }
@@ -188,6 +188,12 @@ Hecks.hecksagon "Hecks" do
188
188
  answers "FuzzAnswered"
189
189
  refuses "FuzzRefused"
190
190
  end
191
+
192
+ asks "Probe", to: FuzzRun do
193
+ attribute :run, FuzzKey
194
+ answers "FuzzAnswered"
195
+ refuses "FuzzRefused"
196
+ end
191
197
  end
192
198
 
193
199
  Hecks::FuzzRun.port "RustToolchain" do
@@ -706,6 +706,8 @@ Hecks.bluebook "Bluebook" do
706
706
  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."
707
707
  member name: "favor_rare_verbs", type: "Integer",
708
708
  meaning: "How many of the least-hit verbs the next seed weights up; 0 turns the lever off."
709
+ member name: "runtime_coverage_feedback", type: "Boolean",
710
+ 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."
709
711
  member name: "self_consistency_checks", type: "Boolean",
710
712
  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."
711
713
  member name: "shrink_budget", type: "Integer",
@@ -802,6 +804,7 @@ Hecks.bluebook "Bluebook" do
802
804
  member script: "fuzz", section: "Custodian", aggregate: "FuzzRun", verb: "fuzz"
803
805
  member script: "generate", section: "Custodian", aggregate: "FuzzRun", verb: "generate_sequence"
804
806
  member script: "bench", section: "Custodian", aggregate: "FuzzRun", verb: "bench"
807
+ member script: "(new)", section: "Custodian", aggregate: "FuzzRun", verb: "mutate"
805
808
  member script: "(new)", section: "Custodian", aggregate: "Era", verb: "hold_first"
806
809
  member script: "project_deploy", section: "Deploy", aggregate: "Recipe", verb: "project"
807
810
  member script: "lint_deploy_recipes", section: "Deploy", aggregate: "MakefileCheck", verb: "lint"
@@ -44,7 +44,8 @@ module Hecks
44
44
  template("deploy-box.sh.tmpl", "STACK" => plan.infra_name, "BOX_STACK" => plan.box_stack,
45
45
  "RDS_STACK" => plan.rds_stack, "REGION" => region,
46
46
  "DIR" => "/opt/#{plan.infra_name}", "HEALTH_CHECKS" => health_checks(plan),
47
- "SMOKE_HEADER" => smoke_header(plan), "USAGE" => deploy_usage(plan))
47
+ "SMOKE_HEADER" => smoke_header(plan), "USAGE" => deploy_usage(plan),
48
+ "DB_SECRET_LINE" => database_secret_line(plan))
48
49
  end
49
50
 
50
51
  # @param plan [Settings::Plan] the resolved settings
@@ -96,9 +97,10 @@ module Hecks
96
97
  # @return [String] a Makefile whose targets create the two stacks and roll the box
97
98
  def makefile(plan)
98
99
  hint = plan.task_definition ? "[TASKDEF=family:revision]" : '[TAGS="web=20260101 worker=20260101"]'
99
- base = template("Makefile.tmpl", "STACK" => plan.infra_name, "DEPLOY_HINT" => hint,
100
- "RDS_STACK" => plan.rds_stack, "BOX_STACK" => plan.box_stack,
101
- "DEPLOY_RECIPE" => RollRecipe.deploy(plan))
100
+ base = template("Makefile.tmpl", { "STACK" => plan.infra_name, "DEPLOY_HINT" => hint,
101
+ "RDS_STACK" => plan.rds_stack, "BOX_STACK" => plan.box_stack,
102
+ "DEPLOY_RECIPE" => RollRecipe.deploy(plan) }
103
+ .merge(database_makefile_values(plan)))
102
104
  plan.hosting ? "#{base}\ninclude hosting.mk\n" : base
103
105
  end
104
106
 
@@ -23,6 +23,10 @@ module Hecks
23
23
  S3_BUCKET = /\A[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]\z/
24
24
  S3_SHAPE = "s3_access: a list of `{ bucket: \"name\", write: true }` hashes (write is optional)".freeze
25
25
  FROM_TASKDEF = %i[env secrets repository].freeze
26
+
27
+ # The settings that size an RDS instance: with `shared_database` they are the shared
28
+ # instance's.
29
+ SHARED_INSTANCE_SETTINGS = %i[database_class storage_gb engine_version backup_days].freeze
26
30
  # Default images, each a version tag plus the digest of its multi-architecture index,
27
31
  # so a rebuilt box pulls the same bytes.
28
32
  TUNNEL_IMAGE = "cloudflare/cloudflared:2026.9.3" \