hecks 3.2.1 → 3.3.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +1 -1
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/interview_draft.rb +86 -31
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +179 -0
  12. data/lib/hecks/doors/cli_runner.rb +14 -1
  13. data/lib/hecks/doors/launcher_options.rb +13 -3
  14. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  15. data/lib/hecks/doors.rb +1 -1
  16. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  17. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  18. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  20. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  21. data/lib/hecks/gate/stages.yml +113 -8
  22. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  23. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  24. data/lib/hecks/hecks/adapters/git.rb +59 -0
  25. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  26. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  27. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  28. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  29. data/lib/hecks/hecks/codebase.behaviors +6 -0
  30. data/lib/hecks/hecks/codebase.bluebook +21 -0
  31. data/lib/hecks/hecks/custodian.behaviors +6 -0
  32. data/lib/hecks/hecks/custodian.bluebook +217 -0
  33. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  34. data/lib/hecks/hecks/hecks.world +8 -3
  35. data/lib/hecks/hecks/site.world +5 -2
  36. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  37. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  38. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  39. data/lib/hecks/language/oidc.json +5 -0
  40. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  41. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  42. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  43. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  44. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  45. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  47. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  48. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  49. data/lib/hecks/projections/deploy/box.rb +15 -2
  50. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  51. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  52. data/lib/hecks/projections/model/deviations.rb +1 -1
  53. data/lib/hecks/projections/site/live_edge.rb +162 -0
  54. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  55. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  56. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  57. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  58. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  59. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  60. data/lib/hecks/runtime/interpreting.rb +3 -22
  61. data/lib/hecks/runtime/needs.rb +48 -0
  62. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  63. data/lib/hecks/runtime/registry/verification.rb +3 -3
  64. data/lib/hecks/rust_build/host.rb +147 -0
  65. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  66. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  67. data/lib/hecks/rust_build/project_rust.rb +3 -10
  68. data/lib/hecks/rust_build.rb +1 -0
  69. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  70. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  71. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  72. data/lib/hecks/tools/gate.rb +1 -1
  73. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  74. data/lib/hecks/tools/tools_doc.rb +204 -0
  75. data/lib/hecks/tools.rb +1 -0
  76. data/lib/hecks/version.rb +1 -1
  77. data/lib/hecks/vocabulary.rb +151 -0
  78. data/qa/settings.yml +1 -2
  79. data/rust/build/src/lineage_pass.rs +30 -15
  80. data/rust/build/src/main.rs +4 -2
  81. data/rust/build/src/pipeline.rs +8 -19
  82. data/rust/codegen/src/bridging.rs +7 -7
  83. data/rust/codegen/src/json.rs +214 -23
  84. data/rust/codegen/src/literal.rs +6 -3
  85. data/rust/codegen/src/main.rs +20 -9
  86. data/rust/codegen/src/naming.rs +4 -2
  87. data/rust/codegen/src/optional_pass.rs +226 -0
  88. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  89. data/rust/host/HECKS_RELEASE +1 -1
  90. data/rust/host/src/api.rs +1 -1
  91. data/rust/host/src/dispatch.rs +5 -0
  92. data/rust/host/src/needs.rs +99 -2
  93. data/rust/host/src/resend.rs +4 -4
  94. data/rust/host/src/ui_schema.rs +1 -1
  95. data/rust/parser/src/emit.rs +12 -0
  96. data/rust/parser/src/ir.rs +2 -0
  97. data/rust/parser/src/keywords.rs +2 -0
  98. data/rust/parser/src/main.rs +1 -0
  99. data/rust/parser/src/parse/command.rs +2 -44
  100. data/rust/parser/src/parse/mod.rs +1 -0
  101. data/rust/parser/src/parse/needs.rs +60 -0
  102. data/rust/parser/src/parse/query.rs +3 -0
  103. data/rust/src/kernel/cli.rs +16 -6
  104. data/rust/src/kernel/needs.rs +104 -9
  105. metadata +22 -8
  106. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  107. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  108. data/lib/hecks/three_zero/forms.yml +0 -127
  109. data/lib/hecks/three_zero.rb +0 -12
  110. data/rust/build/src/json.rs +0 -421
  111. data/rust/build/src/optional_pass.rs +0 -150
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "edge"
5
+
6
+ module Hecks
7
+ module Projections
8
+ module Site
9
+ # Compares the CloudFront behaviours a site's edge generates with the ones a live
10
+ # distribution has, and says every way they differ. It only reads: the live side is a
11
+ # `get-distribution-config` answer, however it was obtained.
12
+ #
13
+ # A behaviour is identified by its path pattern (`(default)` for the default one) and judged
14
+ # on its origin, allowed and cached methods, viewer protocol, compression and three policy
15
+ # ids. The edge may name a policy by a CloudFormation intrinsic (`!Ref PageCachePolicy`) that
16
+ # the live distribution holds as an id, so a reference is compared through the `refs` the
17
+ # caller gives for it, and one with none is reported as unchecked. The behaviours a change is
18
+ # about to add (`expect_new`) are reported as expected additions, not differences.
19
+ class LiveEdge
20
+ # The default behaviour's name, since it has no path pattern.
21
+ DEFAULT = "(default)"
22
+
23
+ # The fields a behaviour is compared on, besides its path.
24
+ FIELDS = %i[origin methods cached protocol compress cache request response].freeze
25
+
26
+ # What a comparison found.
27
+ #
28
+ # @!attribute [r] expected
29
+ # @return [Array<String>] a line per behaviour only the project has, that was expected
30
+ # @!attribute [r] differences
31
+ # @return [Array<String>] a line per difference that was not expected
32
+ # @!attribute [r] unchecked
33
+ # @return [Array<String>] the policy references no `refs` entry resolves
34
+ # @!attribute [r] counts
35
+ # @return [Array<Integer>] the number of generated and of live behaviours, default aside
36
+ Comparison = Struct.new(:expected, :differences, :unchecked, :counts, keyword_init: true) do
37
+ # @return [Boolean] whether live is as generated, expected additions aside
38
+ def clean? = differences.empty? && unchecked.empty?
39
+
40
+ # @return [String] the report, one section per kind of finding
41
+ def to_s
42
+ lines = ["generated: #{counts[0]} behaviours + default; live: #{counts[1]} + default"]
43
+ lines += section("expected additions (only in the project):", expected)
44
+ lines += section("differences:", differences)
45
+ lines += section("policy references with no refs entry (unchecked):", unchecked)
46
+ lines << "the live distribution matches the project" if clean?
47
+ lines.join("\n")
48
+ end
49
+
50
+ private
51
+
52
+ def section(title, entries) = entries.empty? ? [] : [title, *entries.map { |entry| " #{entry}" }]
53
+ end
54
+
55
+ # @param edge [Edge] the project's checked edge
56
+ # @param live [Hash] a CloudFront distribution configuration, or an answer wrapping one in
57
+ # `DistributionConfig`
58
+ # @param expect_new [Array<String>] path patterns the project may have and the live one not
59
+ # @param refs [Hash{String => String}] a policy intrinsic to the id it stands for live
60
+ def initialize(edge, live, expect_new: [], refs: {})
61
+ @edge = edge
62
+ @live = live.fetch("DistributionConfig", live)
63
+ @expect_new = expect_new
64
+ @refs = refs
65
+ end
66
+
67
+ # @return [Comparison] every way the project and the live distribution differ
68
+ def call
69
+ generated = generated_behaviours
70
+ current = live_behaviours
71
+ unchecked = unresolved(generated.values)
72
+ generated = generated.transform_values { |entry| resolve(entry) }
73
+ expected, differences = missing(generated, current)
74
+ differences.concat(changed(generated, current), order(generated, current))
75
+ Comparison.new(expected: expected, differences: differences, unchecked: unchecked,
76
+ counts: [generated.size - 1, current.size - 1])
77
+ end
78
+
79
+ # Reads a policy reference mapping written as `!Ref Name=id` words.
80
+ #
81
+ # @param text [String, nil] the words, separated by commas
82
+ # @return [Hash{String => String}] each intrinsic to its id
83
+ def self.refs_from(text)
84
+ text.to_s.split(",").to_h { |pair| pair.strip.split("=", 2).map(&:strip) }
85
+ end
86
+
87
+ private
88
+
89
+ def generated_behaviours
90
+ list = [[DEFAULT, @edge.default_behaviour], *@edge.behaviours.map { |entry| [entry[:path], entry] }]
91
+ list.to_h do |path, entry|
92
+ [path, { path: path, origin: entry[:origin], methods: Array(entry[:methods]).sort, cached: %w[GET HEAD],
93
+ protocol: entry[:viewer_protocol], compress: entry[:compress] == true,
94
+ cache: policy_id(entry[:cache_policy]), request: policy_id(entry[:origin_request_policy]),
95
+ response: policy_id(entry[:response_headers_policy]) }]
96
+ end
97
+ end
98
+
99
+ def policy_id(policy) = policy&.first
100
+
101
+ def live_behaviours
102
+ list = [[DEFAULT, @live.fetch("DefaultCacheBehavior")],
103
+ *Array(@live.dig("CacheBehaviors", "Items")).map { |entry| [entry.fetch("PathPattern"), entry] }]
104
+ list.to_h do |path, entry|
105
+ [path, { path: path, origin: entry.fetch("TargetOriginId"), methods: methods_of(entry, "Items"),
106
+ cached: cached_of(entry), protocol: entry.fetch("ViewerProtocolPolicy"),
107
+ compress: entry.fetch("Compress", false) == true, cache: entry["CachePolicyId"],
108
+ request: blank(entry["OriginRequestPolicyId"]), response: blank(entry["ResponseHeadersPolicyId"]) }]
109
+ end
110
+ end
111
+
112
+ def methods_of(entry, key) = entry.fetch("AllowedMethods").fetch(key).sort
113
+
114
+ def cached_of(entry) = entry.dig("AllowedMethods", "CachedMethods", "Items").sort
115
+
116
+ def blank(value) = value.to_s.empty? ? nil : value
117
+
118
+ def intrinsic?(value) = value.is_a?(String) && value.start_with?("!")
119
+
120
+ def unresolved(entries)
121
+ entries.flat_map { |entry| entry.values_at(:cache, :request, :response) }
122
+ .select { |value| intrinsic?(value) && !@refs.key?(value) }.uniq.sort
123
+ end
124
+
125
+ def resolve(entry) = entry.transform_values { |value| intrinsic?(value) ? @refs.fetch(value, value) : value }
126
+
127
+ def missing(generated, current)
128
+ expected = []
129
+ differences = []
130
+ generated.each do |path, entry|
131
+ next if current.key?(path)
132
+
133
+ detail = entry.except(:path).map { |key, value| "#{key}=#{value.inspect}" }.join(" ")
134
+ (@expect_new.include?(path) ? expected : differences) << "only in the project: #{path} #{detail}"
135
+ end
136
+ current.each_key { |path| differences << "only live: #{path}" unless generated.key?(path) }
137
+ [expected, differences]
138
+ end
139
+
140
+ def changed(generated, current)
141
+ generated.flat_map do |path, entry|
142
+ next [] unless current.key?(path)
143
+
144
+ FIELDS.filter_map do |field|
145
+ next if entry[field] == current[path][field]
146
+
147
+ "#{path} differs in #{field}: project=#{entry[field].inspect} live=#{current[path][field].inspect}"
148
+ end
149
+ end
150
+ end
151
+
152
+ def order(generated, current)
153
+ mine = generated.keys.reject { |path| path == DEFAULT || !current.key?(path) }
154
+ theirs = current.keys.reject { |path| path == DEFAULT || !generated.key?(path) }
155
+ return [] if mine == theirs
156
+
157
+ ["order differs among the behaviours both have:\n project: #{mine.join(' ')}\n live: #{theirs.join(' ')}"]
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
@@ -64,20 +64,6 @@ module Hecks
64
64
  raise Refusal, "the working tree at #{@repo_dir} is dirty — commit or discard first:\n#{dirty}"
65
65
  end
66
66
 
67
- # Refuses one more pull request than the day's cap allows; a cap of zero is no cap.
68
- #
69
- # @param opened_today [Integer] how many PRs the ledger recorded since local midnight
70
- # @param cap [Integer] `PR_CAP_PER_DAY`
71
- # @return [void]
72
- # @raise [Refusal] when `opened_today` has reached a positive `cap`
73
- def assert_under_daily_cap!(opened_today:, cap:)
74
- return unless cap.positive? && opened_today >= cap
75
-
76
- raise Refusal, "#{opened_today} PR(s) already opened since local midnight, and " \
77
- "QualityControlDials::PR_CAP_PER_DAY is #{cap} — leave this as an open Bug/Angle " \
78
- "and open it tomorrow, or raise the dial in qa/settings.yml"
79
- end
80
-
81
67
  # Refuses a fix commit that is not part of the branch being opened.
82
68
  #
83
69
  # @param commit [String] the commit the ledger says fixes the bug
@@ -26,7 +26,7 @@ module Hecks
26
26
  SKIP_DIR_BASENAMES = %w[.git .hg .svn vendor node_modules .bundle tmp log coverage .yardoc .ruby-lsp
27
27
  .parked bower_components .claude dist build].freeze
28
28
 
29
- # Word-boundary matched so "hecksagain" (a real, separate gem) and "hecks_site" never
29
+ # Word-boundary matched so a near-miss gem name (a separate gem) and "hecks_site" never
30
30
  # false-positive as a hecks dependency.
31
31
  GEMFILE_PATTERN = /gem\s*\(?\s*["']hecks["']/
32
32
  GEMFILE_LOCK_PATTERN = /^\s+hecks\s+\(/
@@ -15,9 +15,10 @@ module Hecks
15
15
  # take the record and the `GitPr` adapter agrees. The command's `given`s are the rules, asked
16
16
  # as a dry run before anything is opened: the branch starts with the practice's prefix; with
17
17
  # `--bug`, the bug is `fixed`; with `--angle`, it is `investigating`. The adapter answers the
18
- # rest: the tree is clean, `PR_CAP_PER_DAY` (0 = uncapped) is unmet since local midnight, and
19
- # the bug's commit is an ancestor of `HEAD`. It is idempotent. `QA_REPO_DIR` picks the checkout
20
- # `git` runs in, so a spec can use a throwaway repo and a fake `gh`.
18
+ # rest: the tree is clean and the bug's commit is an ancestor of `HEAD`. The per-day cap
19
+ # (`PR_CAP_PER_DAY`, 0 = uncapped) is `DailyQuota.Take`'s own `given`: asked as a dry run with
20
+ # the others, and taken for real once the PR is recorded. It is idempotent. `QA_REPO_DIR`
21
+ # picks the checkout `git` runs in, so a spec can use a throwaway repo and a fake `gh`.
21
22
  class QaOpenPr
22
23
  EXIT_OK = 0
23
24
 
@@ -147,7 +148,10 @@ module Hecks
147
148
  "reads as picked up before something is built from it)"
148
149
  when "the branch is one this practice recognises as its own"
149
150
  " (the branch is #{@branch.inspect}; a PR this practice opens is on a branch it can recognise " \
150
- "as its own, QualityControlDials::BRANCH_PREFIX)"
151
+ "as its own)"
152
+ when "the day's cap is not spent"
153
+ " (QualityControlDials::PR_CAP_PER_DAY is #{dial(:PR_CAP_PER_DAY, 0)} — leave this as an open " \
154
+ "Bug/Angle and open it tomorrow, or raise the dial in qa/settings.yml)"
151
155
  else ""
152
156
  end
153
157
  end
@@ -184,15 +188,23 @@ module Hecks
184
188
  check_ancestry
185
189
  end
186
190
 
191
+ # The record of the UTC day the clock reads: `DailyQuota.Today` fills the day from the clock.
192
+ def todays_quota = query("DailyQuota.Today").first
193
+
194
+ # A day with no record has spent nothing, so only an opened day can refuse.
187
195
  def check_daily_cap
188
196
  cap = dial(:PR_CAP_PER_DAY, 0)
189
- return unless cap.positive?
197
+ quota = todays_quota
198
+ return unless cap.positive? && quota
199
+
200
+ refuse_unless_ledger_takes("QualityControl::DailyQuota.Take", today: quota[:today], cap: { value: cap })
201
+ end
190
202
 
191
- today = Time.now
192
- midnight = Time.new(today.year, today.month, today.day).to_i
193
- opened_today = query("Patch.OpenedSince", since: { value: midnight }).size +
194
- query("Improvement.OpenedSince", since: { value: midnight }).size
195
- refusing { @git_pr.assert_under_daily_cap!(opened_today: opened_today, cap: cap) }
203
+ # Counts the PR just recorded against the day, opening the day's record on its first.
204
+ def take_daily_slot
205
+ row = todays_quota
206
+ quota = row ? ::QualityControl::DailyQuota.find(row[:today][:value]) : ::QualityControl::DailyQuota.open!
207
+ quota.take!(cap: { value: dial(:PR_CAP_PER_DAY, 0) })
196
208
  end
197
209
 
198
210
  def check_ancestry
@@ -271,6 +283,7 @@ module Hecks
271
283
  branch: { value: view[:headRefName] }, commit: { value: sha }, title: { value: view[:title] },
272
284
  now: { value: now }
273
285
  )
286
+ take_daily_slot
274
287
  puts "recorded Patch ##{number} for #{@bug.id} at #{sha[0, 7]}"
275
288
  end
276
289
 
@@ -282,12 +295,15 @@ module Hecks
282
295
  end
283
296
 
284
297
  # A run that stopped between `open!` and `land!` leaves the record `opened`: finish it.
285
- existing || ::QualityControl::Improvement.open!(
286
- **(@angle ? { angle: @angle.id } : {}),
287
- number: { value: number }, url: { value: view[:url] },
288
- branch: { value: view[:headRefName] }, title: { value: view[:title] },
289
- now: { value: now }
290
- )
298
+ unless existing
299
+ ::QualityControl::Improvement.open!(
300
+ **(@angle ? { angle: @angle.id } : {}),
301
+ number: { value: number }, url: { value: view[:url] },
302
+ branch: { value: view[:headRefName] }, title: { value: view[:title] },
303
+ now: { value: now }
304
+ )
305
+ take_daily_slot
306
+ end
291
307
  ::QualityControl::Improvement.find(number).land!(number: { value: number }, commit: { value: sha })
292
308
  puts "recorded Improvement ##{number}#{" for #{@angle.id}" if @angle}, landed at #{sha[0, 7]}"
293
309
  end
@@ -54,13 +54,13 @@ module Hecks
54
54
  "cannot see, because there is no second engine to disagree with. " \
55
55
  "spec/rust_conformance_fuzz_spec.rb's own DOMAINS list wires up only pizzas and " \
56
56
  "banking for CI-gated differential fuzzing. rust/Cargo.toml already declares " \
57
- "compiled-binary features for compliance and roster (plus embryonaut and meta) that " \
57
+ "compiled-binary features for compliance and roster (plus meta) that " \
58
58
  "sit unused for this purpose; examples/chess and examples/directory have no Cargo " \
59
59
  "feature at all — `hecks project_rust` has never regenerated a binary for either. " \
60
60
  "Widening either list would let differential fuzzing catch a whole bug class " \
61
61
  "Ruby-only fuzzing cannot, on domains the practice already owns.",
62
62
  citation: "BUG#1/#3/#4 (engine-divergence bugs); spec/rust_conformance_fuzz_spec.rb DOMAINS " \
63
- "(pizzas/banking only); rust/Cargo.toml [features] (compliance/roster/embryonaut/" \
63
+ "(pizzas/banking only); rust/Cargo.toml [features] (compliance/roster/" \
64
64
  "meta have binaries and are not wired in; chess/directory have no feature at all)",
65
65
  proposer: "Claude QA"
66
66
  },
@@ -12,8 +12,7 @@ module Hecks
12
12
  # rotation `Target`, printing each one's inferred capabilities. It is idempotent: references
13
13
  # already on file are skipped whatever their status (re-`Identify` refuses).
14
14
  #
15
- # It skips `examples/embryonaut` (an external product) and `lib/hecks/framework` (no standalone
16
- # Rust binary) on purpose.
15
+ # It skips `lib/hecks/framework` (no standalone Rust binary) on purpose.
17
16
  class QaSeedTargets
18
17
  # Identifies the targets the ledger lacks.
19
18
  #
@@ -1,7 +1,8 @@
1
1
  # Quality control: every check writes its expectation down before it looks at the answer.
2
2
  # A bug is logged with the failing test that proves it, or it is not logged.
3
3
 
4
- # Tunable dials for the QA loop: values in qa/settings.yml, reasoning here.
4
+ # Tunable dials for the QA loop: values in qa/settings.yml, what each is in the `QaDial` rows of the
5
+ # Vocabulary chapter.
5
6
  # `require`, not `require_relative`: isolated fuzz boots load a copy of this file from a tmpdir.
6
7
  require "hecks/fuzzing/qa_settings"
7
8
 
@@ -11,111 +12,15 @@ unless defined?(QualityControlDials)
11
12
  # boots. HECKS_QA_SETTINGS_PATH lets a subprocess test read a different settings file.
12
13
  settings = Hecks::Fuzzing::QaSettings.load(ENV.fetch("HECKS_QA_SETTINGS_PATH", Hecks::Fuzzing::QaSettings::DEFAULT_PATH))
13
14
 
14
- # Minimum gap in seconds between one tick's report and the next dispatch; 0 is immediately.
15
- # Read by the `/loop hecks_qa` caller, not by any script.
16
- CADENCE_SECONDS = settings.cadence_seconds
17
-
18
- # Most PRs (Patch plus Improvement) `hecks quality_control patch.open` opens per calendar day; 0 is uncapped.
19
- PR_CAP_PER_DAY = settings.pr_cap_per_day
20
-
21
- # Fuzz depth by `Target.clean_streak`, in three discrete tiers, read by
22
- # `SweepDepth.for_streak`. The last row (`upto: Float::INFINITY`) is the ceiling.
23
- WIDENING_TIERS = settings.widening_tiers
24
-
25
- # How many `hecks quality_control query sweep.run <target>` children `--all` keeps running at once.
26
- SWEEP_MAX_PARALLEL = settings.sweep_max_parallel
27
-
28
- # How long the `/loop hecks_qa` caller waits for a tick's report before assuming it stalled.
29
- LIVENESS_FALLBACK_SECONDS = settings.liveness_fallback_seconds
15
+ # Each dial of qa/settings.yml as a constant named for it: `PR_CAP_PER_DAY` is `pr_cap_per_day`.
16
+ # What each is and takes is declared once, as its `QaDial` row in the Vocabulary chapter.
17
+ Hecks::Vocabulary.rows("QaDial").each do |dial|
18
+ const_set(dial.fetch("name").upcase, settings.public_send(dial.fetch("name")))
19
+ end
30
20
 
31
21
  # The identity the loop runs as. `WaivedBy` repeats this literal (invariants read literals,
32
22
  # not constants); `spec/quality_control_spec.rb` pins the two equal.
33
23
  AUTOMATED_ENGINEER = "qa_sweep"
34
-
35
- # How `hecks quality_control patch.open` opens a PR: not a draft, queued for auto-merge, on a branch that starts
36
- # with BRANCH_PREFIX.
37
- DRAFT_ONLY = settings.draft_only
38
- AUTO_MERGE = settings.auto_merge
39
- BRANCH_PREFIX = settings.branch_prefix
40
-
41
- # Fraction (0.0..1.0) of generated command steps mutated adversarially; 0 turns the layer off.
42
- # Higher finds refusal-ordering splits sooner but reaches deep state less often.
43
- ADVERSARIAL_FRACTION = settings.adversarial_fraction
44
-
45
- # Coverage-guided generation across one sweep's seeds (`Hecks::Fuzzing::CoverageCampaign`).
46
- GUIDED_GENERATION = settings.guided_generation
47
-
48
- # Fraction (0.0..1.0) of seeds that start from a prefix of an earlier seed's new coverage.
49
- # Off by default: measured, it trades breadth for depth (banking reached 1.41x the deep-state
50
- # tuples, chess lost 0.12x of all tuples), while favoring rare verbs alone never lost.
51
- CORPUS_SPLICE_PROBABILITY = settings.corpus_splice_probability
52
-
53
- # How many of the least-hit verbs the next seed weights up; 0 turns the lever off.
54
- FAVOR_RARE_VERBS = settings.favor_rare_verbs
55
-
56
- # Whether `hecks quality_control query sweep.run` also runs the self-consistency pass (cold rehydration, replay
57
- # idempotency, value-object JSON round trip) on the same generated sequence.
58
- SELF_CONSISTENCY_CHECKS = settings.self_consistency_checks
59
-
60
- # Candidate replays `hecks quality_control query sweep.run` may spend shrinking one surprised check; 0 turns shrinking
61
- # off. `concurrency` is never shrunk: a race does not reproduce on demand.
62
- SHRINK_BUDGET = settings.shrink_budget
63
-
64
- # Seconds of staleness that one point of yield score is worth in `RotationPriority.pick`.
65
- # Zero would make yield invisible, so pick a number that lets it matter.
66
- YIELD_WEIGHT_SECONDS = settings.yield_weight_seconds
67
-
68
- # Percent of the prior yield score kept at each release, so recent findings dominate.
69
- # Applied by the caller: the mutation language cannot express "decay, then add" in `Release`.
70
- YIELD_DECAY_PERCENT = settings.yield_decay_percent
71
-
72
- # Once a waiting target is this stale, `RotationPriority.pick` takes it whatever its yield
73
- # score, so a quiet target cannot starve.
74
- ROTATION_STALE_FLOOR_SECONDS = settings.rotation_stale_floor_seconds
75
-
76
- # Ceiling on `--seeds` for `--persistence-parity`, which pays for real Postgres per dispatch.
77
- # `hecks quality_control query sweep.run` clamps down, never up, and prints a note.
78
- PERSISTENCE_PARITY_SEED_CAP = settings.persistence_parity_seed_cap
79
-
80
- # Ceiling on `--seeds` for the concurrency mode, which needs two disposable schemas per seed.
81
- CONCURRENCY_SEED_CAP = settings.concurrency_seed_cap
82
-
83
- # The two adapters each adapter-parity mode compares, by mode name; a new pairing is a new
84
- # entry. `adapter_parity_sqlite` needs no `database:`/`schema:` because sqlite has no
85
- # disposable database.
86
- ADAPTER_PARITY_PAIRS = settings.adapter_parity_pairs
87
-
88
- # Which comparison modes `hecks quality_control query sweep.run` may run: the enabled ones intersected with what
89
- # `TargetCapabilities.infer(path)` finds in the target. `--modes a,b,c` overrides for one run.
90
- #
91
- # `wasm_front` and `adapter_parity_postgres` are off because nothing runs them; the others are
92
- # built and off because their I/O cost across the rotation is unmeasured. `hecks quality_control query sweep.run`
93
- # refuses to start when a mode outside `TargetCapabilities::RUNNABLE_MODES` is enabled.
94
- MODES = settings.modes
95
-
96
- # How often a generated role-gated command step carries a drawn caller (`role:`/`actor_id:`);
97
- # 0 turns the draw off.
98
- ROLE_DRAW_PROBABILITY = settings.role_draw_probability
99
-
100
- # Fraction of generated command steps offered as a dry run instead of a real dispatch; 0 is
101
- # off. Kept low: a dry run creates no state for later steps to act on.
102
- DRY_RUN_FRACTION = settings.dry_run_fraction
103
-
104
- # How many generated domains one `hecks quality_control sweep.tick` checks after the rotation sweep; 0 skips the
105
- # step.
106
- GENERATED_DOMAINS_PER_TICK = settings.generated_domains_per_tick
107
-
108
- # Whether generated domains are also compared against a compiled Rust binary built in a scratch
109
- # copy.
110
- GENERATED_DOMAINS_RUST = settings.generated_domains_rust
111
-
112
- # Generated sequences run per generated domain.
113
- GENERATED_DOMAIN_SEEDS = settings.generated_domain_seeds
114
-
115
- # Construct families a skipped query verb may belong to. A skip inside the list is logged Held,
116
- # one outside it Surprised. Shrink-only: spec/fuzzing/rust_gap_manifest_spec.rb fails when a
117
- # family here is declared by no committed manifest.
118
- STRUCTURAL_REFUSAL_BOUNDARY = settings.structural_refusal_boundary
119
24
  end
120
25
  end
121
26
 
@@ -1726,8 +1631,7 @@ Hecks.bluebook "QualityControl" do
1726
1631
  attribute :branch, PatchBranch
1727
1632
  attribute :commit, CommitRef
1728
1633
  attribute :title, PatchTitle
1729
- # Makes `PR_CAP_PER_DAY` countable (`OpenedSince`). `default: 0` hydrates older rows at the
1730
- # epoch, which the cap never counts.
1634
+ # When the PR was opened, in epoch seconds. `default: 0` hydrates older rows at the epoch.
1731
1635
  attribute :opened_at, Instant
1732
1636
 
1733
1637
  value_object "Instant" do
@@ -1775,8 +1679,7 @@ Hecks.bluebook "QualityControl" do
1775
1679
  # the record is written by the process that made it true. `now` is that script's clock read.
1776
1680
  # The script asks `dry_run?` of this command before it opens anything, so the two `given`s are
1777
1681
  # the rules for opening a PR at all: the branch is one this practice recognises as its own, and
1778
- # the bug it fixes is fixed. The prefix repeats `BRANCH_PREFIX` as a literal, as `WaivedBy`
1779
- # repeats `AUTOMATED_ENGINEER`; `spec/quality_control_spec.rb` pins the two equal.
1682
+ # the bug it fixes is fixed. The prefix is declared here, once.
1780
1683
  command "Open" do
1781
1684
  role "QA engineer"
1782
1685
  goal "Record that a fix has been opened as a pull request, the moment its number, branch and commit are already known"
@@ -1828,21 +1731,78 @@ Hecks.bluebook "QualityControl" do
1828
1731
  order_by :number
1829
1732
  end
1830
1733
 
1831
- # `hecks quality_control patch.open` compares this and `Improvement.OpenedSince` counts, from local midnight,
1832
- # against `PR_CAP_PER_DAY`. `gte` on a bound argument, the `Account.AtOrAbove` shape.
1833
- query "OpenedSince" do
1834
- description "Every patch opened at or after one instant, in number order — what today's count against PR_CAP_PER_DAY is read from."
1835
- attribute :since, Instant
1836
- where(:"opened_at.value" => { gte: :since })
1837
- order_by :number
1838
- end
1839
-
1840
1734
  query "All" do
1841
1735
  description "Every patch ever opened — what a runner reads back to reconcile against GitHub's own list."
1842
1736
  order_by :number
1843
1737
  end
1844
1738
  end
1845
1739
 
1740
+ # How many pull requests the practice has opened on one UTC day, and the rule that the day's cap
1741
+ # is not exceeded. A rule across many records belongs to an aggregate that owns it (ADR 0081): the
1742
+ # cap is a `given` on one record, not a count over `Patch` and `Improvement`, and a record saves
1743
+ # atomically, so two runs cannot both take the last slot. Identified by the day, so a new day is
1744
+ # a new record. `hecks quality_control patch.open` asks `Today` for the day's record, opens it
1745
+ # with `Open` when there is none, and takes a slot with `Take` once a PR is open.
1746
+ aggregate "DailyQuota" do
1747
+ description "One UTC day's count of the pull requests the practice has opened, and the rule that the day's cap is not exceeded."
1748
+
1749
+ identified_by :today
1750
+
1751
+ # Whole days since the epoch, the fact the runtime calls `today`.
1752
+ attribute :today, Day
1753
+ attribute :used, SlotsUsed
1754
+
1755
+ value_object "Day" do
1756
+ attribute :value, Integer, default: 0
1757
+ invariant("a day is not before the epoch") { !value.negative? }
1758
+ end
1759
+
1760
+ value_object "SlotsUsed" do
1761
+ attribute :value, Integer, default: 0
1762
+ invariant("a count of pull requests is not negative") { !value.negative? }
1763
+ end
1764
+
1765
+ value_object "SlotCap" do
1766
+ attribute :value, Integer
1767
+ invariant("a cap is not negative") { !value.negative? }
1768
+ end
1769
+
1770
+ command "Open" do
1771
+ role "QA engineer"
1772
+ goal "Open the day the clock reads, with no slot taken yet"
1773
+
1774
+ attribute :today, Day
1775
+ needs :today
1776
+
1777
+ sets :today
1778
+
1779
+ emits "QuotaOpened"
1780
+ end
1781
+
1782
+ # `cap` is the dial's value, `PR_CAP_PER_DAY`; 0 is uncapped. The script asks this as a dry
1783
+ # run before it opens a PR, so a spent day refuses before anything is opened.
1784
+ command "Take" do
1785
+ role "QA engineer"
1786
+ goal "Take one of the day's slots for a pull request, refused once the day's cap is spent"
1787
+
1788
+ reference_to DailyQuota
1789
+ attribute :cap, SlotCap
1790
+
1791
+ given("the day's cap is not spent") { cap.value == 0 || used.value < cap.value }
1792
+
1793
+ sets :used, increment: { value: 1 }
1794
+
1795
+ emits "SlotTaken"
1796
+ end
1797
+
1798
+ query "Today" do
1799
+ description "The quota of the day the clock reads: nothing before the day's first pull request."
1800
+ attribute :today, Day
1801
+ needs :today
1802
+ where(:"today.value" => :today)
1803
+ end
1804
+ end
1805
+
1846
1806
  # A PR for deliberate, non-bugfix work: a sibling of `Patch`, because `Bug` means "proven wrong"
1847
1807
  # and `Patch.Open` keeps its required `reference_to Bug`.
1848
1808
  # Born `opened`, with no investigating state: the work was decided before any code moved.
@@ -1864,7 +1824,7 @@ Hecks.bluebook "QualityControl" do
1864
1824
  attribute :branch, ImprovementBranch
1865
1825
  attribute :commit, CommitRef
1866
1826
  attribute :title, ImprovementTitle
1867
- # See `Patch.opened_at`: the same field, for the same cap.
1827
+ # See `Patch.opened_at`.
1868
1828
  attribute :opened_at, Instant
1869
1829
 
1870
1830
  value_object "Instant" do
@@ -1997,14 +1957,6 @@ Hecks.bluebook "QualityControl" do
1997
1957
  order_by :number
1998
1958
  end
1999
1959
 
2000
- # `Patch.OpenedSince`'s twin: the cap counts both kinds of PR.
2001
- query "OpenedSince" do
2002
- description "Every improvement opened at or after one instant, in number order — the other half of today's count against PR_CAP_PER_DAY."
2003
- attribute :since, Instant
2004
- where(:"opened_at.value" => { gte: :since })
2005
- order_by :number
2006
- end
2007
-
2008
1960
  query "All" do
2009
1961
  description "Every improvement ever landed — what a runner reads back to reconcile against GitHub's own list."
2010
1962
  order_by :number
@@ -1,6 +1,6 @@
1
1
  require_relative "value"
2
2
  require_relative "aggregate_lock"
3
- require_relative "../ports/clock"
3
+ require_relative "needs"
4
4
 
5
5
  module Hecks
6
6
  module Runtime
@@ -14,11 +14,6 @@ module Hecks
14
14
  interpreter.singleton_class.attr_accessor :trace
15
15
  end
16
16
 
17
- # What answers each outside fact a command may `needs` (ADR 0081). A fact is answered once,
18
- # before any given runs, and the answer rides in the command's own arguments, so the event
19
- # records it and a replay re-dispatches the recorded value rather than asking again.
20
- NEED_ANSWERS = { now: ->(registry) { Ports::Clock.now(registry) } }.freeze
21
-
22
17
  private
23
18
 
24
19
  # Fills each outside fact the command `needs` and the caller left out. A value the caller
@@ -32,13 +27,8 @@ module Hecks
32
27
  fill_defaults(command, fill_needs(command, args))
33
28
  end
34
29
 
35
- # Answers each fact the command `needs` that the caller left out.
36
- def fill_needs(command, args)
37
- missing = command.needs.reject { |fact| args.key?(fact) || args.key?(fact.to_s) }
38
- return args if missing.empty?
39
-
40
- args.merge(missing.to_h { |fact| [fact, need_value(command, fact)] })
41
- end
30
+ # Answers each fact the command `needs` that the caller left out (see `Needs`).
31
+ def fill_needs(command, args) = Needs.fill(command, args, registry: @registry)
42
32
 
43
33
  # Gives each argument the caller left out the default its attribute declares
44
34
  # (`attribute :runs, Count, default: 30`), so a declared default holds on every way in and
@@ -56,15 +46,6 @@ module Hecks
56
46
  args.merge(absent.to_h { |attribute| [attribute.name.to_sym, attribute.default] })
57
47
  end
58
48
 
59
- # The answer to one fact, in the shape the command's argument of that name takes: a bare
60
- # Integer for an Integer attribute, else the one-field value object the language declares
61
- # for an instant.
62
- def need_value(command, fact)
63
- answer = NEED_ANSWERS.fetch(fact).call(@registry)
64
- attribute = command.attributes.find { |held| held.name.to_s == fact.to_s }
65
- attribute&.type.to_s == "Integer" ? answer : { value: answer }
66
- end
67
-
68
49
  # Logged after the step's work, so trace order is completion order.
69
50
  def step(name)
70
51
  result = yield