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
@@ -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
- @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"
@@ -25,7 +25,17 @@ pre_push:
25
25
  checks:
26
26
  - id: rspec
27
27
  title: rspec (parallel)
28
- run: [bundle, exec, parallel_rspec, spec, -n, "{workers}"]
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, one worker (every ephemeral boot recreates the same scratch schema)
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 = 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 }