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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3e303214451e949201a80bb0aa29d33e0e8a240e78eda156a4ff6d4913913f20
4
- data.tar.gz: de457dd9cd8212c61114a4f6c939479807de32716f0a40cd24c13c51ccaf5a7b
3
+ metadata.gz: 1f5088f868a4fb798d8295bdf93ad836ac63abeeac372d19ea486ed445587944
4
+ data.tar.gz: 526e4623eeb9947b2ccf74cf8599ee8f0820b677335c36f33905f20695fdb0bc
5
5
  SHA512:
6
- metadata.gz: a35c1b98ca37e475d237288e1c7670ed7ecc32ad8b91a2c3fc6c3a010973673a5441755e3fda5bc2f2b84b2dfeaee2d873276ea14043ea787e959aebc5706130
7
- data.tar.gz: 54222db25bfab9416ef092b467a243fac8df095997274f69be0f3105b1edf6cd39fb2b86b87360bf5a270ddbb62e53bc2c3b6db2dd840f93743e49c12085ef67
6
+ metadata.gz: e6a604f67014c7c7136749ad1d2d661652083a5ab03274faa5531a221f0e6a27e81b4be0e0a464ec5d7253eac7ebc33aae342308fd58a724cc65d90f77d5a68c
7
+ data.tar.gz: c087ba04b615b664d1895fc040c75c4a906b330d080e24e360f9086b2893250f71cf7970d77253064e4ec91d1be12962497fce4097bcb8df66f45cbc1e31effe
@@ -118,6 +118,20 @@ module Hecks
118
118
  @shadow_parsing = previous
119
119
  end
120
120
 
121
+ # Wraps `block` with `shadow_parsing?` false, restoring it afterward. The language's own
122
+ # grammar is built once per process and memoized, so it cannot take the mode of whichever
123
+ # caller happens to ask for it first.
124
+ #
125
+ # @yield the one-time grammar build, with `shadow_parsing?` false throughout
126
+ # @return [Object] the block's own return value
127
+ def while_not_shadow_parsing
128
+ previous = @shadow_parsing
129
+ @shadow_parsing = false
130
+ yield
131
+ ensure
132
+ @shadow_parsing = previous
133
+ end
134
+
121
135
  # Wraps `block` with `disabled?` forced false, restoring it afterward.
122
136
  # Only `grammar_registry`'s one-time fixpoint build uses this.
123
137
  #
@@ -4,6 +4,10 @@ module Hecks
4
4
  module SyntaxBoot
5
5
  # The grammar table's memo keys and its cross-process copy on disk.
6
6
  module DiskCache
7
+ # How long an entry nobody has read stays before the next write sweeps it away. A read
8
+ # marks an entry as used, so only the tables of other code age out.
9
+ UNREAD_KEEP_SECONDS = 24 * 60 * 60
10
+
7
11
  # `equal?`, not `==` — chapter identity, not value equality, is the
8
12
  # fact this cache key tracks.
9
13
  def same_chapters?(cached, current)
@@ -23,18 +27,29 @@ module Hecks
23
27
  def disk_cache_enabled? = ENV["HECKS_SYNTAX_BOOT_CACHE"] != "off"
24
28
 
25
29
  # Fails toward a real boot, never toward a wrong table: a missing
26
- # file, a corrupt blob, or a permission error just misses the cache.
30
+ # file, a corrupt blob, or a permission error just misses the cache. A table
31
+ # the user has not cached is looked for among the ones the gem ships.
27
32
  def read_disk_cache(chapters)
28
33
  return nil unless disk_cache_enabled?
29
34
 
30
35
  path = disk_cache_path(chapters)
31
- return nil unless File.exist?(path)
36
+ return load_table(path, touch: true) if File.exist?(path)
32
37
 
33
- Marshal.load(File.binread(path)) # rubocop:disable Security/MarshalLoad -- own process-local cache, never external input
38
+ shipped = Hecks::CacheDir.prebuilt(File.basename(path))
39
+ File.exist?(shipped) ? load_table(shipped) : nil
34
40
  rescue StandardError
35
41
  nil
36
42
  end
37
43
 
44
+ # @param path [String] a table file
45
+ # @param touch [Boolean] whether to mark it as used (a shipped file is read-only)
46
+ # @return [Hash, nil] the table
47
+ def load_table(path, touch: false)
48
+ table = Marshal.load(File.binread(path)) # rubocop:disable Security/MarshalLoad -- own cache or the gem's own file, never external input
49
+ FileUtils.touch(path) if touch
50
+ table
51
+ end
52
+
38
53
  # Writes to a PID-suffixed temp file and renames it into place, so a
39
54
  # concurrent writer's rename can only overwrite with identical
40
55
  # content, never leave a torn file for a concurrent reader.
@@ -46,10 +61,20 @@ module Hecks
46
61
  tmp_path = "#{path}.#{Process.pid}.tmp"
47
62
  File.binwrite(tmp_path, Marshal.dump(result))
48
63
  File.rename(tmp_path, path)
64
+ prune_disk_cache
49
65
  rescue StandardError
50
66
  nil
51
67
  end
52
68
 
69
+ # Removes the tables no process has read for `UNREAD_KEEP_SECONDS`.
70
+ def prune_disk_cache
71
+ Dir.glob(File.join(cache_dir, "*.marshal")).each do |file|
72
+ File.delete(file) if Time.now - File.mtime(file) > UNREAD_KEEP_SECONDS
73
+ rescue SystemCallError
74
+ next
75
+ end
76
+ end
77
+
53
78
  def disk_cache_path(chapters)
54
79
  File.join(cache_dir, "#{disk_cache_key(chapters)}.marshal")
55
80
  end
@@ -0,0 +1,44 @@
1
+ module Hecks
2
+ module Bluebook
3
+ module MetaValidator
4
+ module VerdictCache
5
+ # The verdict file the process reads (its own, else the one a packaged gem ships) and the
6
+ # Ruby series that file is valid for.
7
+ module Prebuilt
8
+ # The Ruby the judging code ran on, to the minor version: a patch release changes neither
9
+ # a verdict nor the encoding of one, so a cache built on one patch serves the others.
10
+ #
11
+ # @return [String] such as `ruby-3.3`
12
+ def ruby_series = "#{RUBY_ENGINE}-#{RUBY_VERSION[/\A\d+\.\d+/]}"
13
+
14
+ # The file the gem ships for the current code's verdicts, when it ships one.
15
+ #
16
+ # @return [String] an absolute path
17
+ def prebuilt_path = Hecks::CacheDir.prebuilt(File.basename(path))
18
+
19
+ # Reads and decodes the current code's file: the user's own, else the gem's prebuilt one.
20
+ #
21
+ # @return [Hash{String => Hash}] entries, or `{}` on any problem
22
+ def read
23
+ own = path
24
+ return read_file(own) if File.file?(own) && trusted?(own)
25
+
26
+ File.file?(prebuilt_path) ? read_file(prebuilt_path) : {}
27
+ end
28
+
29
+ # @param file [String] a verdict file already trusted
30
+ # @return [Hash{String => Hash}] its entries, or `{}` on any problem
31
+ def read_file(file)
32
+ doc = JSON.parse(File.binread(file), max_nesting: false)
33
+ return {} unless doc.is_a?(Hash) && doc["format"] == FORMAT
34
+
35
+ decoded = decode(doc["entries"])
36
+ well_formed?(decoded) ? decoded : {}
37
+ rescue StandardError
38
+ {}
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -4,6 +4,7 @@ require "json"
4
4
  require_relative "../../cache_dir"
5
5
  require_relative "../../version"
6
6
  require_relative "verdict_cache/tagging"
7
+ require_relative "verdict_cache/prebuilt"
7
8
 
8
9
  module Hecks
9
10
  module Bluebook
@@ -22,6 +23,7 @@ module Hecks
22
23
  # VerdictCache.flush # also runs at process exit
23
24
  module VerdictCache
24
25
  extend Tagging
26
+ extend Prebuilt
25
27
 
26
28
  module_function
27
29
 
@@ -70,7 +72,7 @@ module Hecks
70
72
  # file's relative path and bytes, in sorted order
71
73
  def digest_of(lib)
72
74
  state = Digest::SHA256.new
73
- state << "#{FORMAT}\0#{RUBY_VERSION}\0#{Hecks::VERSION}\0"
75
+ state << "#{FORMAT}\0#{ruby_series}\0#{Hecks::VERSION}\0"
74
76
  judging_files(lib).each do |file|
75
77
  state << file.delete_prefix(lib) << "\0" << File.binread(file) << "\0"
76
78
  end
@@ -146,22 +148,6 @@ module Hecks
146
148
  # @return [Hash{String => Hash}] every verdict this process knows
147
149
  def entries = @entries ||= {}
148
150
 
149
- # Reads and decodes the current code's file.
150
- #
151
- # @return [Hash{String => Hash}] entries, or `{}` on any problem
152
- def read
153
- file = path
154
- return {} unless File.file?(file) && trusted?(file)
155
-
156
- doc = JSON.parse(File.binread(file), max_nesting: false)
157
- return {} unless doc.is_a?(Hash) && doc["format"] == FORMAT
158
-
159
- decoded = decode(doc["entries"])
160
- well_formed?(decoded) ? decoded : {}
161
- rescue StandardError
162
- {}
163
- end
164
-
165
151
  # Whether `file` and its directory belong to this user and nobody else
166
152
  # can write them.
167
153
  #
@@ -168,12 +168,14 @@ module Hecks
168
168
  end
169
169
 
170
170
  # The booted, self-judged language registry every ordinary bluebook is
171
- # judged against — built once per process and memoized.
171
+ # judged against — built once per process and memoized, with shadow parsing off: a caller
172
+ # inside `while_shadow_parsing` that is first to ask would otherwise memoize a grammar
173
+ # parsed in that mode, or fail to load it.
172
174
  #
173
175
  # @return [Runtime::Registry] the booted, fixpoint-judged language
174
176
  # registry
175
177
  def self.grammar_registry
176
- @grammar_registry ||= begin
178
+ @grammar_registry ||= while_not_shadow_parsing do
177
179
  registry = load_grammar_into(Runtime::Registry.new)
178
180
  # Assigned before the fixpoint judge below, since judging re-enters
179
181
  # grammar_registry (via fresh_runtime/Plan.for) and a bare `||=`
@@ -20,6 +20,16 @@ module Hecks
20
20
  File.join(root, name)
21
21
  end
22
22
 
23
+ # The directory the gem ships prebuilt cache files in, beside `lib/` so the digest of `lib/`
24
+ # that names a cache file never covers the files built from it. It exists only in a packaged
25
+ # gem, and it is part of the gem's own code, so it needs no ownership check.
26
+ #
27
+ # @param name [String] the file name inside it
28
+ # @return [String] the absolute path of `name` under the gem's `prebuilt/`
29
+ def prebuilt(name)
30
+ File.join(File.expand_path("../..", __dir__), "prebuilt", name)
31
+ end
32
+
23
33
  # The cache root, resolved once per process.
24
34
  #
25
35
  # @return [String] the absolute path of the directory every `path` lives under
@@ -27,7 +27,7 @@ module Hecks
27
27
  # hecks fuzz sweeps it too, once Fuzzing::Replay coerces value-object args
28
28
  # before recomputing givens — today it doesn't, so this given reads as
29
29
  # wrongly admitted.
30
- Route.new(%r{\Aspec/fixtures/rust_host/}, :named_in, "rust/host/src/web.rs", "checkout_fixture",
30
+ Route.new(%r{\Aspec/fixtures/rust_host/}, :named_in, "rust/host/src/web/commerce.rs", "checkout_fixture",
31
31
  "the Rust host's checkout fixture, pinned by its web and /api tests")
32
32
  ].freeze
33
33
  end
@@ -274,6 +274,115 @@ Hecks.bluebook "Deploy" do
274
274
  end
275
275
  end
276
276
 
277
+ aggregate "VercelTarget" do
278
+ description "One domain's validated Vercel deploy target — what deployed_to(\"Vercel\") must resolve to before hecks deploy project generates a Vercel function's configuration from it"
279
+
280
+ identified_by :domain
281
+
282
+ attribute :domain, DomainName
283
+ attribute :region, Region
284
+ attribute :memory, Memory
285
+ attribute :max_duration, MaxDuration
286
+
287
+ value_object "DomainName" do
288
+ attribute :value, String
289
+ invariant("a domain is named") { !value.to_s.empty? }
290
+ end
291
+
292
+ # A Vercel region id such as iad1 or fra1: three letters and a digit. Vercel refuses an
293
+ # unknown id at deploy; this checks the shape only.
294
+ value_object "Region" do
295
+ attribute :value, String
296
+ invariant("a region is a Vercel id such as iad1") { value.to_s.match?(/\A[a-z]{3}[0-9]\z/) }
297
+ end
298
+
299
+ # Function memory in MB. Vercel sells 128 MB up to a plan's ceiling; the ceiling is the
300
+ # plan's to enforce.
301
+ value_object "Memory" do
302
+ attribute :value, Integer
303
+ invariant("memory is at least 128 MB") { value >= 128 }
304
+ invariant("memory is at most 10240 MB") { value <= 10240 }
305
+ end
306
+
307
+ # Seconds a single invocation may run. 800 is the longest any plan allows.
308
+ value_object "MaxDuration" do
309
+ attribute :value, Integer
310
+ invariant("a duration is at least 1 second") { value >= 1 }
311
+ invariant("a duration is at most 800 seconds") { value <= 800 }
312
+ end
313
+
314
+ # All four required for the same reason as `BoxTarget.Declare`; this validates a target
315
+ # already resolved by `hecks deploy project`'s defaults.
316
+ command "Declare" do
317
+ goal "Validate one domain's Vercel deploy target before anything is generated from it"
318
+
319
+ attribute :domain, DomainName
320
+ attribute :region, Region
321
+ attribute :memory, Memory
322
+ attribute :max_duration, MaxDuration
323
+
324
+ sets :domain
325
+ sets :region
326
+ sets :memory
327
+ sets :max_duration
328
+
329
+ emits "VercelTargetDeclared"
330
+ end
331
+ end
332
+
333
+ aggregate "SharedDatabaseTarget" do
334
+ description "One platform's validated shared database instance — what deployed_to(\"AwsSharedDatabase\") must resolve to before hecks deploy project generates the RDS stack that several client sites each keep a database on."
335
+
336
+ identified_by :domain
337
+
338
+ attribute :domain, DomainName
339
+ attribute :region, Region
340
+ attribute :database_class, DatabaseClass
341
+ attribute :storage_gb, StorageGb
342
+
343
+ value_object "DomainName" do
344
+ attribute :value, String
345
+ invariant("a domain is named") { !value.to_s.empty? }
346
+ end
347
+
348
+ value_object "Region" do
349
+ attribute :value, String
350
+ invariant("a region is named") { !value.to_s.empty? }
351
+ end
352
+
353
+ # An RDS instance class such as db.t4g.small.
354
+ value_object "DatabaseClass" do
355
+ attribute :value, String
356
+ invariant("a database class is db.family.size, such as db.t4g.small") { value.to_s.match?(/\Adb\.[a-z][a-z0-9-]*\.[a-z0-9]+\z/) }
357
+ end
358
+
359
+ # Every client's database lives on this disk, so it is sized for all of them; 20 GB is
360
+ # RDS's gp3 floor.
361
+ value_object "StorageGb" do
362
+ attribute :value, Integer
363
+ invariant("storage is at least 20 GB") { value >= 20 }
364
+ invariant("storage is at most 65536 GB") { value <= 65536 }
365
+ end
366
+
367
+ # All four required for the same reason as `BoxTarget.Declare`; this validates a target
368
+ # already resolved by `hecks deploy project`'s defaults.
369
+ command "Declare" do
370
+ goal "Validate one platform's shared database target before anything is generated from it"
371
+
372
+ attribute :domain, DomainName
373
+ attribute :region, Region
374
+ attribute :database_class, DatabaseClass
375
+ attribute :storage_gb, StorageGb
376
+
377
+ sets :domain
378
+ sets :region
379
+ sets :database_class
380
+ sets :storage_gb
381
+
382
+ emits "SharedDatabaseTargetDeclared"
383
+ end
384
+ end
385
+
277
386
  # The validated shape of `hecks deploy tenant.provision`, which does the generating and provisioning.
278
387
  # Not a saga: each of its steps is idempotent and local, so there is nothing to resume.
279
388
  aggregate "Tenant" do
@@ -301,6 +301,11 @@
301
301
  "verb": "Deploy::ServiceRoll.Verify",
302
302
  "role": null
303
303
  },
304
+ {
305
+ "scope": "deploy:shared_database_target.declare",
306
+ "verb": "Deploy::SharedDatabaseTarget.Declare",
307
+ "role": null
308
+ },
304
309
  {
305
310
  "scope": "deploy:smoke_run.flag",
306
311
  "verb": "Deploy::SmokeRun.Flag",
@@ -355,6 +360,11 @@
355
360
  "scope": "deploy:tenant.reprovision",
356
361
  "verb": "Deploy::Tenant.Reprovision",
357
362
  "role": null
363
+ },
364
+ {
365
+ "scope": "deploy:vercel_target.declare",
366
+ "verb": "Deploy::VercelTarget.Declare",
367
+ "role": null
358
368
  }
359
369
  ],
360
370
  "roles": [
@@ -8,8 +8,9 @@ module Hecks
8
8
  #
9
9
  # The help is a pure function of the domain's declarations and of the gem that projects them,
10
10
  # and reading those is most of what a launcher costs. The answer is kept in a file named for a
11
- # digest of everything it depends on (the domain's declaration files, the gem's own library,
12
- # the hecks and Ruby versions, the environment overlay, the command line and the program name),
11
+ # digest of everything it depends on (the domain's declaration files, the gem's judging code and
12
+ # doors, the hecks and Ruby versions, the environment overlay, the command line and the
13
+ # program name),
13
14
  # so any edit to one of them is simply a different entry: nothing is ever stale, only unused.
14
15
  # Only an answer that ended well is kept, and any trouble with the cache itself is ignored
15
16
  # and the help is worked out as if it did not exist.
@@ -71,15 +72,20 @@ module Hecks
71
72
  # @return [String, nil] nil when the digest cannot be taken
72
73
  def entry_path(domain, argv, program, audience = "")
73
74
  digest = Digest::SHA256.new
74
- [Hecks::VERSION, RUBY_VERSION, ENV["HECKS_ENVIRONMENT"].to_s, program, audience, argv.join("\0")].each do |part|
75
- digest << part.to_s << "\0"
76
- end
77
- [domain, library].each { |root| fingerprint(root, digest) }
75
+ key_parts(program, audience, argv).each { |part| digest << part.to_s << "\0" }
76
+ fingerprint_code(domain, digest)
78
77
  File.join(directory, "usage-#{digest.hexdigest}.json")
79
78
  rescue SystemCallError
80
79
  nil
81
80
  end
82
81
 
82
+ # @return [Array<String>] what the answer depends on besides the files: the gem and Ruby
83
+ # series, the environment overlay, the program and audience and the command line
84
+ def key_parts(program, audience, argv)
85
+ [Hecks::VERSION, Hecks::Bluebook::MetaValidator::VerdictCache.ruby_series,
86
+ ENV["HECKS_ENVIRONMENT"].to_s, program, audience, argv.join("\0")]
87
+ end
88
+
83
89
  # Feeds the digest every declaration file under `root`: its path below `root` and its bytes,
84
90
  # so a touch or a checkout elsewhere keeps the entry and an edit does not.
85
91
  def fingerprint(root, digest)
@@ -88,9 +94,13 @@ module Hecks
88
94
  end
89
95
  end
90
96
 
91
- # @return [String] the gem's own library directory, which the frameworks load from
92
- def library
93
- File.expand_path("..", __dir__)
97
+ # Feeds the digest what shapes a help text: the domain's declarations, the judging trees of
98
+ # the gem (the same set the verdict cache keys on) and the doors that render the text. An
99
+ # edit to the gem's projections, deploy or release code cannot change a help line.
100
+ def fingerprint_code(domain, digest)
101
+ fingerprint(domain, digest)
102
+ digest << Hecks::Bluebook::MetaValidator::VerdictCache.code_digest << "\0"
103
+ fingerprint(__dir__, digest)
94
104
  end
95
105
 
96
106
  # Reads the remembered answer and marks the entry as used, so a sweep keeps what is read.
@@ -26,6 +26,7 @@ module Hecks
26
26
  @max_prefix_depth = max_prefix_depth
27
27
  @corpus = []
28
28
  @seen = Set.new
29
+ @runtime_seen = Set.new
29
30
  @verb_hits = Hash.new(0)
30
31
  @declared_verbs = Set.new
31
32
  @seeds = @spliced = @seeds_with_new = 0
@@ -43,34 +44,39 @@ module Hecks
43
44
  end
44
45
 
45
46
  # Folds one seed's trace into the running coverage; admits a corpus entry if it reached
46
- # a new tuple.
47
+ # a new tuple, or, when `runtime` is given, a new runtime line or branch.
47
48
  #
48
49
  # @param seed [Integer] the seed the trace was generated from
49
50
  # @param plan [Fuzzing::CoverageCampaign::Plan] the plan that seed ran with
50
51
  # @param trace [Fuzzing::SequenceGenerator::Trace] from `SequenceGenerator.trace`
52
+ # @param runtime [Enumerable<String>, nil] keys from `RuntimeCoverage.measure`; nil when
53
+ # runtime feedback is off, which leaves admission to tuples alone
51
54
  # @return [void]
52
- def record(seed, plan, trace)
53
- @seeds += 1
54
- @spliced += 1 if plan.spliced?
55
- @declared_verbs.merge(trace.verbs)
56
-
57
- new_at = trace.coverage.filter_map do |index, tuple|
58
- @verb_hits[tuple.split(" | ").first] += 1
59
- index if @seen.add?(tuple)
60
- end
61
- return if new_at.empty?
55
+ def record(seed, plan, trace, runtime: nil)
56
+ tally(plan, trace)
57
+ new_at = new_tuple_steps(trace)
58
+ fresh_runtime = new_runtime?(runtime)
59
+ return if new_at.empty? && !fresh_runtime
62
60
 
63
61
  @seeds_with_new += 1
64
- admit(seed, plan, new_at.max + 1)
62
+ # A tuple cuts the entry at the step that reached it; a runtime key is not tied to a step,
63
+ # so a seed that reached only new runtime keeps its whole sequence.
64
+ admit(seed, plan, new_at.empty? ? [trace.steps.size, 1].max : new_at.max + 1)
65
65
  end
66
66
 
67
+ # The number of distinct runtime lines and branches reached so far; zero while runtime
68
+ # feedback is off.
69
+ def runtime_seen = @runtime_seen.size
70
+
67
71
  # The number of distinct coverage tuples reached so far.
68
72
  def tuples_seen = @seen.size
69
73
 
70
74
  # One line of running totals: tuples seen, seeds run, seeds with new coverage, splices.
71
75
  def summary
72
- "coverage: #{@seen.size} distinct (verb, kind, state, mutation, outcome) tuple(s) over #{@seeds} seed(s); " \
73
- "#{@seeds_with_new} seed(s) reached something new; #{@spliced} spliced from a corpus of #{@corpus.size}"
76
+ runtime = @runtime_seen.empty? ? "" : " and #{@runtime_seen.size} runtime line/branch key(s)"
77
+ "coverage: #{@seen.size} distinct (verb, kind, state, mutation, outcome) tuple(s)#{runtime} over " \
78
+ "#{@seeds} seed(s); #{@seeds_with_new} seed(s) reached something new; " \
79
+ "#{@spliced} spliced from a corpus of #{@corpus.size}"
74
80
  end
75
81
 
76
82
  # Serializes the accumulated corpus and coverage knowledge, JSON-safe, so a later process can
@@ -78,10 +84,12 @@ module Hecks
78
84
  # favor count, corpus and prefix-depth bounds — are configuration a caller supplies fresh each
79
85
  # time, not state, so they are not part of this.
80
86
  #
81
- # @return [Hash] with string keys "corpus", "seen", "verb_hits", "declared_verbs"
87
+ # @return [Hash] with string keys "corpus", "seen", "runtime_seen", "verb_hits" and
88
+ # "declared_verbs"
82
89
  def to_h
83
90
  { "corpus" => @corpus.map { |entry| { "spec" => entry[:spec], "attempts" => entry[:attempts] } },
84
91
  "seen" => @seen.to_a,
92
+ "runtime_seen" => @runtime_seen.to_a.sort,
85
93
  "verb_hits" => @verb_hits.dup,
86
94
  "declared_verbs" => @declared_verbs.to_a }
87
95
  end
@@ -94,9 +102,8 @@ module Hecks
94
102
  def restore!(state)
95
103
  state = state.transform_keys(&:to_s)
96
104
  @corpus = Array(state["corpus"]).map { |entry| { spec: entry["spec"], attempts: entry["attempts"] } }
97
- @seen = Set.new(Array(state["seen"]))
105
+ @seen, @runtime_seen, @declared_verbs = %w[seen runtime_seen declared_verbs].map { |key| Set.new(Array(state[key])) }
98
106
  @verb_hits = Hash.new(0).merge(state["verb_hits"] || {})
99
- @declared_verbs = Set.new(Array(state["declared_verbs"]))
100
107
  end
101
108
 
102
109
  # Builds a campaign that already knows what a prior campaign's `#to_h` reached, configured
@@ -116,6 +123,24 @@ module Hecks
116
123
 
117
124
  private
118
125
 
126
+ def tally(plan, trace)
127
+ @seeds += 1
128
+ @spliced += 1 if plan.spliced?
129
+ @declared_verbs.merge(trace.verbs)
130
+ end
131
+
132
+ # The step indexes whose tuples nothing had reached before; each tuple's verb counts as a hit.
133
+ def new_tuple_steps(trace)
134
+ trace.coverage.filter_map do |index, tuple|
135
+ @verb_hits[tuple.split(" | ").first] += 1
136
+ index if @seen.add?(tuple)
137
+ end
138
+ end
139
+
140
+ def new_runtime?(runtime)
141
+ runtime.to_a.map { |key| @runtime_seen.add?(key) }.any?
142
+ end
143
+
119
144
  def splice_prefix(random)
120
145
  entry = @corpus[random.rand(@corpus.size)]
121
146
  entry[:spec].merge("steps" => random.rand(1..entry[:attempts]))
@@ -0,0 +1,80 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "json"
4
+
5
+ module Hecks
6
+ module Fuzzing
7
+ # Tells one fuzz finding from another and keeps the smallest repro of each.
8
+ #
9
+ # A finding's identity is its property (or exception class), its message with the minted
10
+ # parts normalized away, and the shape of its shrunk steps (the verbs, in order). Two seeds
11
+ # that break the same property the same way share an identity however their ids differ.
12
+ module FailureTriage
13
+ # Directory, under the checkout, where minimized repros are kept.
14
+ REGRESSION_DIR = "spec/corpus/regressions".freeze
15
+
16
+ module_function
17
+
18
+ # @param property [String] the property name or exception class the finding opens with
19
+ # @param message [String, nil] the finding's message
20
+ # @param steps [Array<Hash>] the shrunk steps that reproduce it
21
+ # @return [String] a stable twelve-character identity
22
+ def signature(property, message, steps)
23
+ shape = steps.map { |step| step.key?("verb") ? step["verb"] : step[:verb] }
24
+ Digest::SHA256.hexdigest([property, normalize(message), shape].to_json)[0, 12]
25
+ end
26
+
27
+ # Strips the parts a run mints (ids, hex, numbers, quoted literals) so two runs of one
28
+ # defect read the same.
29
+ #
30
+ # @param message [String, nil]
31
+ # @return [String]
32
+ def normalize(message)
33
+ message.to_s
34
+ .gsub(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i, "<uuid>")
35
+ .gsub(/\b0x[0-9a-f]+\b|\b[0-9a-f]{16,}\b/i, "<hex>")
36
+ .gsub(/(["']).*?\1/, "<str>")
37
+ .gsub(/-?\d+(?:\.\d+)?/, "<n>")
38
+ .squeeze(" ").strip
39
+ end
40
+
41
+ # Collapses findings to one per identity, keeping the smallest sequence of each.
42
+ #
43
+ # @param failures [Array<Hash>] findings carrying `:signature`, `:message` and `:steps`
44
+ # @return [Array<Hash>] one finding per distinct identity, each with `:triage` (the
45
+ # identity) and `:duplicates` (how many findings it stands for)
46
+ def dedupe(failures)
47
+ failures.group_by { |failure| signature(failure[:signature], failure[:message], failure[:steps]) }
48
+ .map do |identity, group|
49
+ group.min_by { |failure| failure[:steps].length }.merge(triage: identity, duplicates: group.length)
50
+ end
51
+ end
52
+
53
+ # Writes a minimized repro under `<root>/spec/corpus/regressions/<domain>/`, in the
54
+ # `{name, note, steps}` shape `hecks run` replays. An existing file is never touched: the
55
+ # identity is in the file name, so a finding already kept is left as it was.
56
+ #
57
+ # @param root [String] the checkout
58
+ # @param domain_name [String] the domain's basename
59
+ # @param finding [Hash] a `dedupe` result carrying `:triage`, `:steps`, `:seed`, `:message`
60
+ # @return [String, nil] the path written, or nil when that finding was already kept
61
+ def persist(root, domain_name, finding)
62
+ dir = File.join(root, REGRESSION_DIR, domain_name)
63
+ path = File.join(dir, "#{finding.fetch(:triage)}.json")
64
+ return nil if File.exist?(path)
65
+
66
+ FileUtils.mkdir_p(dir)
67
+ File.write(path, JSON.pretty_generate(document(domain_name, finding)), mode: "wx")
68
+ path
69
+ rescue Errno::EEXIST
70
+ nil
71
+ end
72
+
73
+ def document(domain_name, finding)
74
+ { name: "#{domain_name}-regression-#{finding.fetch(:triage)}",
75
+ note: "hecks fuzz found #{finding[:signature]} at seed #{finding[:seed]}: #{finding[:message]}",
76
+ steps: finding.fetch(:steps) }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,38 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module FormCensus
4
+ # The chapter-level constructs an aggregate takes part in, folded onto its own hash so the
5
+ # per-aggregate forms can read them.
6
+ #
7
+ # Policies and read models live on the chapter, not the aggregate: an `across` policy is
8
+ # attributed to the aggregate whose event it answers, and a read model to each aggregate it
9
+ # takes a head from.
10
+ module ChapterForms
11
+ module_function
12
+
13
+ # @param aggregate [Hash] one aggregate of an exported chapter
14
+ # @param chapter_ir [Hash] the exported chapter it belongs to
15
+ # @return [Hash] the aggregate with `across_policies` and `multi_head_read_models` added
16
+ def onto(aggregate, chapter_ir)
17
+ aggregate.merge("across_policies" => across_policies(aggregate, chapter_ir),
18
+ "multi_head_read_models" => multi_head_read_models(aggregate, chapter_ir))
19
+ end
20
+
21
+ # Policies with a target domain whose triggering event is qualified by this aggregate.
22
+ def across_policies(aggregate, chapter_ir)
23
+ (chapter_ir["policies"] || []).select do |policy|
24
+ !policy["target_domain"].to_s.empty? && policy["on_event"].to_s.split(".", 2).first == aggregate["name"]
25
+ end
26
+ end
27
+
28
+ # Read models composing two or more aggregate heads, one of them this aggregate.
29
+ def multi_head_read_models(aggregate, chapter_ir)
30
+ (chapter_ir["read_models"] || []).select do |read_model|
31
+ heads = read_model["aggregate_heads"] || []
32
+ heads.size >= 2 && heads.any? { |head| head["aggregate"] == aggregate["name"] }
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end