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,49 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module FormCensus
4
+ # The reference-hop forms: a path crossing references, and a reference re-declared under
5
+ # another type. Each reads one aggregate's exported hash.
6
+ module ReferenceForms
7
+ # A given path crossing two references has at least this many segments.
8
+ TWO_HOP_GIVEN_PATH_LENGTH = 3
9
+
10
+ module_function
11
+
12
+ def two_hop_given?(aggregate)
13
+ FormCensus.commands(aggregate).any? do |verb|
14
+ (verb["givens"] || []).any? { |given| deep_lookup?(given["ast"]) }
15
+ end
16
+ end
17
+
18
+ # A `where` whose field crosses two `/` hops, as in `member/sponsor/standing`.
19
+ def multi_hop_where?(aggregate)
20
+ FormCensus.queries(aggregate).any? do |query|
21
+ (query["wheres"] || []).any? { |where| where["field"].to_s.count("/") >= 2 }
22
+ end
23
+ end
24
+
25
+ # A command attribute reusing the name of a reference attribute under a non-reference type.
26
+ def revalued_reference?(aggregate)
27
+ references = FormCensus.attributes(aggregate).select { |held| FormCensus.reference?(held) }
28
+ .to_set { |held| held["name"].to_s }
29
+ FormCensus.commands(aggregate).any? do |verb|
30
+ (verb["attributes"] || []).any? { |held| references.include?(held["name"].to_s) && !FormCensus.reference?(held) }
31
+ end
32
+ end
33
+
34
+ def deep_lookup?(node)
35
+ case node
36
+ when Hash
37
+ return true if node["op"] == "lookup" && Array(node["path"]).size >= TWO_HOP_GIVEN_PATH_LENGTH
38
+
39
+ node.each_value.any? { |child| deep_lookup?(child) }
40
+ when Array
41
+ node.any? { |child| deep_lookup?(child) }
42
+ else
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -1,5 +1,7 @@
1
1
  require "json"
2
2
  require_relative "../corpus"
3
+ require_relative "form_census/chapter_forms"
4
+ require_relative "form_census/reference_forms"
3
5
 
4
6
  module Hecks
5
7
  module Fuzzing
@@ -9,9 +11,6 @@ module Hecks
9
11
  # they cannot drift. The unit is one aggregate: forms on one head meet at dispatch, forms in one
10
12
  # chapter do not.
11
13
  module FormCensus
12
- # A given path crossing two references has at least this many segments.
13
- TWO_HOP_GIVEN_PATH_LENGTH = 3
14
-
15
14
  # Ports and adapters a census boots with, relative to the repository root.
16
15
  CENSUS_SUPPORT = [
17
16
  "lib/hecks/ports/persistence.port", "lib/hecks/ports/extraction.port",
@@ -19,25 +18,28 @@ module Hecks
19
18
  ].freeze
20
19
 
21
20
  FORMS = {
22
- "composite_id" => ->(a) { (a["identified_by"] || []).size >= 2 },
23
- "has_entity" => ->(a) { entities(a).any? },
24
- "two_entities" => ->(a) { entities(a).size >= 2 },
25
- "composite_piece" => ->(a) { entities(a).any? { |piece| (piece["identified_by"] || []).size >= 2 } },
26
- "multi_emit" => ->(a) { commands(a).any? { |verb| (verb["emits"] || []).size >= 2 } },
27
- "lifecycle" => ->(a) { !a["lifecycle"].nil? },
28
- "piece_lifecycle" => ->(a) { entities(a).any? { |piece| !piece["lifecycle"].nil? } },
29
- "has_query" => ->(a) { queries(a).any? },
30
- "list_attr" => ->(a) { attributes(a).any? { |held| held["list"] } },
31
- "reference_attr" => ->(a) { attributes(a).any? { |held| reference?(held) } },
32
- "closed_set" => ->(a) { (a["value_objects"] || []).any? { |shape| shape["closed_set"] } },
33
- "has_default" => ->(a) { attributes(a).any? { |held| !held["default"].nil? } },
34
- "has_optional" => ->(a) { commands(a).any? { |verb| (verb["attributes"] || []).any? { |held| held["optional"] } } },
21
+ "composite_id" => ->(a) { (a["identified_by"] || []).size >= 2 },
22
+ "has_entity" => ->(a) { entities(a).any? },
23
+ "two_entities" => ->(a) { entities(a).size >= 2 },
24
+ "composite_piece" => ->(a) { entities(a).any? { |piece| (piece["identified_by"] || []).size >= 2 } },
25
+ "multi_emit" => ->(a) { commands(a).any? { |verb| (verb["emits"] || []).size >= 2 } },
26
+ "lifecycle" => ->(a) { !a["lifecycle"].nil? },
27
+ "piece_lifecycle" => ->(a) { entities(a).any? { |piece| !piece["lifecycle"].nil? } },
28
+ "has_query" => ->(a) { queries(a).any? },
29
+ "list_attr" => ->(a) { attributes(a).any? { |held| held["list"] } },
30
+ "reference_attr" => ->(a) { attributes(a).any? { |held| reference?(held) } },
31
+ "closed_set" => ->(a) { (a["value_objects"] || []).any? { |shape| shape["closed_set"] } },
32
+ "has_default" => ->(a) { attributes(a).any? { |held| !held["default"].nil? } },
33
+ "has_optional" => ->(a) { optional_command_attribute?(a) },
35
34
  # Reference-hop forms, then `corrects` and `role`; entity commands count too.
36
- "corrects" => ->(a) { every_command(a).any? { |verb| corrects?(verb) } },
37
- "role_gated" => ->(a) { every_command(a).any? { |verb| !verb["role"].to_s.empty? } },
38
- "two_hop_given" => ->(a) { two_hop_given?(a) },
39
- "multi_hop_where" => ->(a) { multi_hop_where?(a) },
40
- "revalued_reference" => ->(a) { revalued_reference?(a) }
35
+ "corrects" => ->(a) { every_command(a).any? { |verb| corrects?(verb) } },
36
+ "role_gated" => ->(a) { every_command(a).any? { |verb| !verb["role"].to_s.empty? } },
37
+ "two_hop_given" => ->(a) { ReferenceForms.two_hop_given?(a) },
38
+ "multi_hop_where" => ->(a) { ReferenceForms.multi_hop_where?(a) },
39
+ "revalued_reference" => ->(a) { ReferenceForms.revalued_reference?(a) },
40
+ # Chapter-level constructs, attributed to the aggregate they name (see `ChapterForms`).
41
+ "cross_domain_policy" => ->(a) { (a["across_policies"] || []).any? },
42
+ "multi_head_read_model" => ->(a) { (a["multi_head_read_models"] || []).any? }
41
43
  }.freeze
42
44
 
43
45
  module_function
@@ -51,42 +53,16 @@ module Hecks
51
53
 
52
54
  def corrects?(verb) = (verb["mutations"] || []).any? { |change| change["op"].to_s == "corrects" }
53
55
 
56
+ def optional_command_attribute?(aggregate)
57
+ commands(aggregate).any? { |verb| (verb["attributes"] || []).any? { |held| held["optional"] } }
58
+ end
59
+
54
60
  def attributes(aggregate) = aggregate["attributes"] || []
55
61
 
56
62
  def queries(aggregate) = aggregate["queries"] || []
57
63
 
58
64
  def reference?(attribute) = attribute["type"].to_s.start_with?("Reference<")
59
65
 
60
- def two_hop_given?(aggregate)
61
- commands(aggregate).any? { |verb| (verb["givens"] || []).any? { |given| deep_lookup?(given["ast"]) } }
62
- end
63
-
64
- # A `where` whose field crosses two `/` hops, as in `member/sponsor/standing`.
65
- def multi_hop_where?(aggregate)
66
- queries(aggregate).any? { |query| (query["wheres"] || []).any? { |where| where["field"].to_s.count("/") >= 2 } }
67
- end
68
-
69
- # A command attribute reusing the name of a reference attribute under a non-reference type.
70
- def revalued_reference?(aggregate)
71
- references = attributes(aggregate).select { |held| reference?(held) }.to_set { |held| held["name"].to_s }
72
- commands(aggregate).any? do |verb|
73
- (verb["attributes"] || []).any? { |held| references.include?(held["name"].to_s) && !reference?(held) }
74
- end
75
- end
76
-
77
- def deep_lookup?(node)
78
- case node
79
- when Hash
80
- return true if node["op"] == "lookup" && Array(node["path"]).size >= TWO_HOP_GIVEN_PATH_LENGTH
81
-
82
- node.each_value.any? { |child| deep_lookup?(child) }
83
- when Array
84
- node.any? { |child| deep_lookup?(child) }
85
- else
86
- false
87
- end
88
- end
89
-
90
66
  def properties(aggregate)
91
67
  FORMS.transform_values { |form| form.call(aggregate) }
92
68
  end
@@ -107,7 +83,7 @@ module Hecks
107
83
 
108
84
  def aggregates_in(chapter_ir)
109
85
  (chapter_ir["aggregates"] || []).map do |aggregate|
110
- ["#{chapter_ir["name"]}::#{aggregate["name"]}", properties(aggregate)]
86
+ ["#{chapter_ir["name"]}::#{aggregate["name"]}", properties(ChapterForms.onto(aggregate, chapter_ir))]
111
87
  end
112
88
  end
113
89
 
@@ -3,12 +3,12 @@ module Hecks
3
3
  module IsolatedBoot
4
4
  # Rebinds a copied domain to a scratch Postgres schema and prepares that schema.
5
5
  module PostgresScratch
6
- # Rewrites every `.hecksagon` in the copy to bind through Postgres, against the
7
- # shared scratch schema unless the caller names its own via `scratch:`.
6
+ # Rewrites every `.hecksagon` in the copy to bind through Postgres, against this process's
7
+ # scratch schema unless the caller names its own via `scratch:`.
8
8
  def rebind_to_postgres!(copy, database: nil, schema: nil)
9
9
  require "pg"
10
10
  database ||= FUZZ_POSTGRES_DATABASE
11
- schema ||= FUZZ_POSTGRES_SCHEMA
11
+ schema ||= scratch_schema(database)
12
12
  rewrite_bindings!(copy, "Postgres")
13
13
  strip_translations!(copy)
14
14
  ensure_fuzz_schema!(database, schema)
@@ -107,15 +107,30 @@ module Hecks
107
107
  reset_schema!(database, schema, create: false)
108
108
  end
109
109
 
110
+ # Several pool children may reach this at once: one wins the CREATE, and the rest see the
111
+ # database exist, or wait out the moment the template database is busy and look again.
110
112
  def create_database_if_missing(database)
111
113
  admin = PG.connect(dbname: "postgres")
112
- exists = admin.exec_params(
113
- "SELECT 1 FROM pg_database WHERE datname = $1", [database]
114
- ).ntuples.positive?
115
- admin.exec(%(CREATE DATABASE "#{database}")) unless exists
114
+ 3.times do
115
+ break if database_exists?(admin, database)
116
+
117
+ create_database(admin, database)
118
+ end
116
119
  admin.close
117
120
  end
118
121
 
122
+ def database_exists?(admin, database)
123
+ admin.exec_params("SELECT 1 FROM pg_database WHERE datname = $1", [database]).ntuples.positive?
124
+ end
125
+
126
+ def create_database(admin, database)
127
+ admin.exec(%(CREATE DATABASE "#{database}"))
128
+ rescue PG::DuplicateDatabase, PG::UniqueViolation
129
+ nil
130
+ rescue PG::ObjectInUse
131
+ sleep(0.2)
132
+ end
133
+
119
134
  # Drops `schema` from `database`, and creates it again when `create` is true.
120
135
  def reset_schema!(database, schema, create:)
121
136
  db = PG.connect(dbname: database)
@@ -0,0 +1,27 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module IsolatedBoot
4
+ # Which Postgres scratch schema a fuzz boot uses: one per pool child, so children can fuzz
5
+ # one database at once, or the shared default for any other process.
6
+ module ScratchSchema
7
+ # The schema this process boots into. A pool child names its own in `FUZZ_SCHEMA_ENV`, so
8
+ # several children can fuzz one database at once; it is dropped when the child exits.
9
+ # Any other process shares `FUZZ_POSTGRES_SCHEMA`, which is safe only one boot at a time.
10
+ def scratch_schema(database)
11
+ named = ENV.fetch(FUZZ_SCHEMA_ENV, nil)
12
+ return FUZZ_POSTGRES_SCHEMA unless named
13
+
14
+ @fuzz_schema_cleanups ||= {}
15
+ @fuzz_schema_cleanups[named] ||= at_exit { drop_schema_quietly(database, named) }
16
+ named
17
+ end
18
+
19
+ def drop_schema_quietly(database, schema)
20
+ reset_schema!(database, schema, create: false)
21
+ rescue PG::Error
22
+ nil
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -6,6 +6,7 @@ require_relative "../runtime/adapter_lookup"
6
6
  require_relative "isolated_boot/copying"
7
7
  require_relative "isolated_boot/worlds"
8
8
  require_relative "isolated_boot/postgres_scratch"
9
+ require_relative "isolated_boot/scratch_schema"
9
10
 
10
11
  module Hecks
11
12
  module Fuzzing
@@ -14,14 +15,17 @@ module Hecks
14
15
  module IsolatedBoot
15
16
  # Postgres has no zero-config default (Postgres.connect_for refuses without a
16
17
  # `database` setting), so every domain name gets a fresh `.world` written for it.
17
- # One shared schema, dropped and recreated per boot, not a fresh name per call —
18
- # safe only because callers run one ephemeral boot at a time.
18
+ # One schema per process, dropped and recreated per boot, not a fresh name per call —
19
+ # a process runs one ephemeral boot at a time. The default is shared by every process that
20
+ # does not name its own schema in `FUZZ_SCHEMA_ENV`, as a sweep's pool children do.
19
21
  FUZZ_POSTGRES_DATABASE = "hecks_fuzz".freeze
20
22
  FUZZ_POSTGRES_SCHEMA = "hecks_fuzz".freeze
23
+ FUZZ_SCHEMA_ENV = "HECKS_FUZZ_SCHEMA".freeze
21
24
 
22
25
  extend Copying
23
26
  extend Worlds
24
27
  extend PostgresScratch
28
+ extend ScratchSchema
25
29
 
26
30
  module_function
27
31
 
@@ -0,0 +1,76 @@
1
+ require_relative "../../cli/run"
2
+ require_relative "../../behaviors"
3
+ require_relative "observation"
4
+
5
+ module Hecks
6
+ module Fuzzing
7
+ module Mutation
8
+ # What is replayed against a domain and its mutants: the generated sequences, the corpus
9
+ # script and the domain's own `.behaviors` tests.
10
+ #
11
+ # @!attribute [r] sequences
12
+ # @return [Array<Array<Hash>>] generated step lists, valid for the unmutated domain
13
+ # @!attribute [r] corpus
14
+ # @return [Hash, nil] the parsed corpus script
15
+ Plan = Struct.new(:sequences, :corpus)
16
+
17
+ # The checks a mutant is held to, each answering what it saw.
18
+ module Checks
19
+ module_function
20
+
21
+ # What the unmutated domain does under the plan, as values a mutant's run is compared to.
22
+ #
23
+ # @param domain [String] the domain directory
24
+ # @param plan [Plan] what to replay
25
+ # @return [Hash] `:sequences` an observation per sequence, `:corpus` one for the script and
26
+ # `:failing_behaviors` the `.behaviors` tests that already fail
27
+ def baseline(domain, plan)
28
+ { sequences: plan.sequences.map { |steps| Observation.of(Replay.call(domain, steps)) },
29
+ corpus: plan.corpus && Observation.of(corpus_report(domain, plan.corpus)),
30
+ failing_behaviors: failing_behaviors(domain) }
31
+ end
32
+
33
+ # @return [String, nil] why the copy cannot boot, nil when it does
34
+ def boot_failure(copy)
35
+ Replay.call(copy, [])
36
+ nil
37
+ rescue StandardError, ScriptError => e
38
+ "#{e.class}: #{e.message.lines.first.to_s.strip}"
39
+ end
40
+
41
+ # @return [String, nil] the first property a history breaks, as `property: <name>`
42
+ def property_failure(history)
43
+ broken = Properties.check(history).find { |_, result| result != true }
44
+ broken && "property: #{broken.first}"
45
+ end
46
+
47
+ # The report `hecks run` would print for the script.
48
+ def corpus_report(domain, corpus)
49
+ IsolatedBoot.call(domain) do |copy|
50
+ CLI::Run.execute(Hecks.boot(copy, environment: nil), corpus.fetch("steps"))
51
+ end
52
+ end
53
+
54
+ # @return [String, nil] the first expectation of the script the report does not meet
55
+ def unmet_expectation(corpus, report)
56
+ unmet = CLI::RunExpectations.unmet(corpus["expectations"] || {}, report).first
57
+ unmet && unmet.lines.first.strip
58
+ end
59
+
60
+ # The `.behaviors` tests of the domain that do not pass, by description.
61
+ #
62
+ # @param domain [String] the domain directory
63
+ # @return [Array<String>] empty when every test passes or the domain has none
64
+ def failing_behaviors(domain)
65
+ Behaviors.run_all(domain).files.flat_map { |file| failures_in(file) }
66
+ end
67
+
68
+ def failures_in(file)
69
+ return ["#{file.path}: #{file.parse_error}"] if file.parse_error
70
+
71
+ file.runs.reject { |run| run.status == :pass }.map(&:description)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,136 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ module Mutation
4
+ # Each finder looks at one line of a `SourceMap` and answers the changes that apply there, as
5
+ # `{ operator:, removed:, replacement: }` hashes; `Operators` turns them into `Site`s.
6
+ module Finders
7
+ # The shape of a change: a line (or block) taken out, or a line written differently.
8
+ module Change
9
+ module_function
10
+
11
+ def removal(operator, length = 1) = { operator: operator, removed: length, replacement: [] }
12
+
13
+ def rewrite(operator, line) = { operator: operator, removed: 1, replacement: [line] }
14
+ end
15
+
16
+ # Rules a command or value object enforces: `given`, `invariant`, comparisons, query bounds,
17
+ # an attribute's `pattern:` and `one_of:`.
18
+ module Rules
19
+ RULE = /\A\s*(given|invariant)\b.*\{.*\}\s*\z/
20
+ COMPARISONS = { " >= " => " > ", " > " => " >= ", " <= " => " < ", " < " => " <= ",
21
+ " == " => " != ", " != " => " == " }.freeze
22
+ PATTERN = /,\s*pattern:\s*'[^']*'/
23
+ CLOSED_SET = /,\s*one_of:\s*\[[^\]]*\]/
24
+ BOUND = /\A\s*where\(.*\b(lt|gt):/
25
+
26
+ module_function
27
+
28
+ def at(map, index)
29
+ line = map.lines[index]
30
+ rule(line) + attribute(line) + query_bound(line)
31
+ end
32
+
33
+ def rule(line)
34
+ return [] unless line.match?(RULE)
35
+
36
+ flipped = flip_comparison(line)
37
+ [Change.removal(:"drop_#{line[RULE, 1]}"), (Change.rewrite(:flip_comparison, flipped) if flipped)].compact
38
+ end
39
+
40
+ # The first comparison inside the line's braces, moved one step.
41
+ def flip_comparison(line)
42
+ body = line[/\{.*\}/]
43
+ operator = COMPARISONS.keys.find { |candidate| body.include?(candidate) }
44
+ operator && line.sub(body, body.sub(operator, COMPARISONS.fetch(operator)))
45
+ end
46
+
47
+ def attribute(line)
48
+ return [] unless line.match?(/\A\s*attribute\b/)
49
+
50
+ [[:drop_pattern, PATTERN], [:drop_one_of, CLOSED_SET]].filter_map do |operator, shape|
51
+ (found = line[shape]) && Change.rewrite(operator, line.sub(found, ""))
52
+ end
53
+ end
54
+
55
+ def query_bound(line)
56
+ return [] unless line.match?(BOUND)
57
+
58
+ [Change.rewrite(:flip_query_bound, line.sub(/\b(lt|gt):/) { Regexp.last_match(1) == "lt" ? "gt:" : "lt:" })]
59
+ end
60
+ end
61
+
62
+ # The shape of the domain: `from:` guards, `sets`, lifecycle targets, emitted events.
63
+ module Structure
64
+ GUARD = /,\s*from:\s*"[^"]*"/
65
+
66
+ module_function
67
+
68
+ def at(map, index)
69
+ line = map.lines[index]
70
+ scope = map.scope(index)
71
+ sets(line, scope) + command_guard(line) + transition(map, line, scope) + emits(map, line)
72
+ end
73
+
74
+ def sets(line, scope)
75
+ line.match?(/\A\s*sets\s+:\w+\s*\z/) && scope == "command" ? [Change.removal(:drop_sets)] : []
76
+ end
77
+
78
+ def command_guard(line)
79
+ guard = line[GUARD]
80
+ guard && line.match?(/\A\s*command\b.*\bdo\s*\z/) ? [Change.rewrite(:drop_from_guard, line.sub(guard, ""))] : []
81
+ end
82
+
83
+ def transition(map, line, scope)
84
+ match = line.match(SourceMap::TRANSITION)
85
+ return [] unless match && scope == "lifecycle"
86
+
87
+ [retarget(map, line, match[3]), guard(line, match[4])].compact
88
+ end
89
+
90
+ def retarget(map, line, target)
91
+ other = map.states.find { |state| state != target }
92
+ other && Change.rewrite(:retarget_transition, line.sub("=> \"#{target}\"", "=> \"#{other}\""))
93
+ end
94
+
95
+ def guard(line, rest)
96
+ found = rest[GUARD]
97
+ found && Change.rewrite(:drop_from_guard, line.sub(found, ""))
98
+ end
99
+
100
+ def emits(map, line)
101
+ match = line.match(SourceMap::EMITS)
102
+ other = match && map.emitted.find { |name| name != match[2] }
103
+ other ? [Change.rewrite(:swap_emits, "#{match[1]}emits #{other}\n")] : []
104
+ end
105
+ end
106
+
107
+ # Reactions: whole policies, and what a process manager does on each of its transitions.
108
+ module Reactions
109
+ module_function
110
+
111
+ def at(map, index)
112
+ line = map.lines[index]
113
+ scope = map.scope(index)
114
+ policy(map, index, line) + saga_transition(map, index, line, scope) + saga_dispatch(line, scope)
115
+ end
116
+
117
+ def policy(map, index, line)
118
+ length = line.match?(/\A\s*policy\s+"/) && map.block_length(index)
119
+ length ? [Change.removal(:drop_policy, length)] : []
120
+ end
121
+
122
+ def saga_transition(map, index, line, scope)
123
+ length = scope == "process_manager" && line.match?(/\A\s*transition\b.*\bdo\s*\z/) && map.block_length(index)
124
+ length ? [Change.removal(:drop_saga_transition, length)] : []
125
+ end
126
+
127
+ def saga_dispatch(line, scope)
128
+ scope == "transition" && line.match?(/\A\s*dispatch\s+\S/) ? [Change.removal(:drop_saga_dispatch)] : []
129
+ end
130
+ end
131
+
132
+ ALL = [Rules, Structure, Reactions].freeze
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,94 @@
1
+ require_relative "checks"
2
+
3
+ module Hecks
4
+ module Fuzzing
5
+ module Mutation
6
+ # How one mutant fared.
7
+ #
8
+ # @!attribute [r] site
9
+ # @return [Site] what was changed
10
+ # @!attribute [r] status
11
+ # @return [Symbol] `:killed`, `:survived`, `:unreached` or `:invalid`
12
+ # @!attribute [r] by
13
+ # @return [String, nil] what killed it (`property: <name>`, `crash: <class>`, `corpus: ...`,
14
+ # `behavior: ...`), or why it is invalid
15
+ Outcome = Struct.new(:site, :status, :by, keyword_init: true)
16
+
17
+ # Replays one mutant and says which check, if any, noticed.
18
+ #
19
+ # The trials run in order and stop at the first that fails. Each answers whether it killed the
20
+ # mutant and whether the mutant behaved differently from the original under it; a mutant that
21
+ # differed everywhere it was looked at and was never killed survived.
22
+ class Judge
23
+ TRIALS = %i[sequence_trial corpus_trial behavior_trial].freeze
24
+
25
+ # @param site [Site] what was changed
26
+ # @param copy [String] the mutated copy of the domain
27
+ # @param plan [Plan] what to replay
28
+ # @param baseline [Hash] what `Checks.baseline` answered for the unmutated domain
29
+ # @return [Outcome] the verdict
30
+ def self.call(site, copy, plan, baseline) = new(site, copy, plan, baseline).call
31
+
32
+ def initialize(site, copy, plan, baseline)
33
+ @site = site
34
+ @copy = copy
35
+ @plan = plan
36
+ @baseline = baseline
37
+ end
38
+
39
+ # @return [Outcome] invalid when it does not boot, killed by the first failing trial,
40
+ # otherwise survived or unreached by whether anything differed
41
+ def call
42
+ reason = Checks.boot_failure(@copy)
43
+ return outcome(:invalid, reason) if reason
44
+
45
+ run_trials
46
+ rescue StandardError, ScriptError => e
47
+ outcome(:killed, "crash: #{e.class}")
48
+ end
49
+
50
+ private
51
+
52
+ def run_trials
53
+ differed = false
54
+ TRIALS.each do |trial|
55
+ killer, changed = send(trial)
56
+ return outcome(:killed, killer) if killer
57
+
58
+ differed ||= changed
59
+ end
60
+ outcome(differed ? :survived : :unreached)
61
+ end
62
+
63
+ def outcome(status, by = nil) = Outcome.new(site: @site, status: status, by: by)
64
+
65
+ def sequence_trial
66
+ differed = false
67
+ @plan.sequences.each_with_index do |steps, index|
68
+ history = Replay.call(@copy, steps)
69
+ broken = Checks.property_failure(history)
70
+ return [broken, true] if broken
71
+
72
+ differed ||= Observation.of(history) != @baseline[:sequences][index]
73
+ end
74
+ [nil, differed]
75
+ end
76
+
77
+ def corpus_trial
78
+ return [nil, false] unless @plan.corpus
79
+
80
+ report = Checks.corpus_report(@copy, @plan.corpus)
81
+ unmet = Checks.unmet_expectation(@plan.corpus, report)
82
+ return ["corpus: #{unmet}", true] if unmet
83
+
84
+ [nil, Observation.of(report) != @baseline[:corpus]]
85
+ end
86
+
87
+ def behavior_trial
88
+ failing = (Checks.failing_behaviors(@copy) - @baseline[:failing_behaviors]).first
89
+ [failing && "behavior: #{failing}", !failing.nil?]
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,38 @@
1
+ require "json"
2
+
3
+ module Hecks
4
+ module Fuzzing
5
+ module Mutation
6
+ # A history or report reduced to plain values, so two runs compare equal when they did the
7
+ # same thing and differ when they did not.
8
+ module Observation
9
+ PARTS = %i[instances events refusals reactions sagas queries].freeze
10
+ PLAIN = [String, Numeric, true, false, nil].freeze
11
+
12
+ module_function
13
+
14
+ # @param history [Hash] a replay's history, or a `hecks run` report
15
+ # @return [String] canonical JSON of its observable parts
16
+ def of(history)
17
+ JSON.generate(plain(history.slice(*PARTS)))
18
+ end
19
+
20
+ # @return [Object] the value as hashes, arrays, strings, numbers and booleans only
21
+ def plain(value)
22
+ case value
23
+ when Hash then value.sort_by { |key, _| key.to_s }.to_h { |key, item| [key.to_s, plain(item)] }
24
+ when Array then value.map { |item| plain(item) }
25
+ when *PLAIN then value
26
+ else opaque(value)
27
+ end
28
+ end
29
+
30
+ # An object that is not plain data: its own hash form when it has one, else its text with
31
+ # the address that would differ between runs taken out.
32
+ def opaque(value)
33
+ value.respond_to?(:to_h) ? plain(value.to_h) : value.to_s.gsub(/0x\h+/, "0x")
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end