hecks 3.2.1 → 3.4.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 (120) 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 +21 -23
  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 +88 -33
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +198 -0
  12. data/lib/hecks/corpus.rb +3 -5
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  16. data/lib/hecks/doors.rb +0 -17
  17. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  18. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  19. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  20. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  21. data/lib/hecks/fuzzing/isolated_boot.rb +1 -4
  22. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  23. data/lib/hecks/fuzzing/target_capabilities.rb +1 -1
  24. data/lib/hecks/gate/stages.yml +113 -8
  25. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  26. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  27. data/lib/hecks/hecks/adapters/git.rb +59 -0
  28. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  29. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  30. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  31. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  32. data/lib/hecks/hecks/codebase.behaviors +6 -0
  33. data/lib/hecks/hecks/codebase.bluebook +21 -0
  34. data/lib/hecks/hecks/custodian.behaviors +6 -0
  35. data/lib/hecks/hecks/custodian.bluebook +217 -0
  36. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  37. data/lib/hecks/hecks/hecks.world +8 -3
  38. data/lib/hecks/hecks/site.world +5 -2
  39. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  40. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  41. data/lib/hecks/language/hecksagon/hecksagon.bluebook +0 -5
  42. data/lib/hecks/language/oidc.json +5 -0
  43. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  44. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  45. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  47. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  48. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  49. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  50. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  51. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  52. data/lib/hecks/projections/deploy/box.rb +15 -2
  53. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  54. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  55. data/lib/hecks/projections/model/deviations.rb +1 -1
  56. data/lib/hecks/projections/site/live_edge.rb +162 -0
  57. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  58. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  59. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  60. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  61. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  62. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  63. data/lib/hecks/runtime/interpreting.rb +3 -22
  64. data/lib/hecks/runtime/loader.rb +6 -7
  65. data/lib/hecks/runtime/needs.rb +48 -0
  66. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  67. data/lib/hecks/runtime/registry/verification.rb +3 -3
  68. data/lib/hecks/runtime.rb +6 -11
  69. data/lib/hecks/rust_build/host.rb +147 -0
  70. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  71. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  72. data/lib/hecks/rust_build/project_rust.rb +3 -10
  73. data/lib/hecks/rust_build.rb +1 -0
  74. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  75. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  76. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  77. data/lib/hecks/tools/gate.rb +1 -1
  78. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  79. data/lib/hecks/tools/tools_doc.rb +204 -0
  80. data/lib/hecks/tools.rb +1 -0
  81. data/lib/hecks/version.rb +1 -1
  82. data/lib/hecks/vocabulary.rb +151 -0
  83. data/lib/hecks.rb +6 -12
  84. data/qa/settings.yml +1 -2
  85. data/rust/build/src/lineage_pass.rs +30 -15
  86. data/rust/build/src/main.rs +4 -2
  87. data/rust/build/src/pipeline.rs +8 -19
  88. data/rust/codegen/src/bridging.rs +7 -7
  89. data/rust/codegen/src/json.rs +214 -23
  90. data/rust/codegen/src/literal.rs +6 -3
  91. data/rust/codegen/src/main.rs +20 -9
  92. data/rust/codegen/src/naming.rs +4 -2
  93. data/rust/codegen/src/optional_pass.rs +226 -0
  94. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  95. data/rust/host/HECKS_RELEASE +1 -1
  96. data/rust/host/src/api.rs +1 -1
  97. data/rust/host/src/dispatch.rs +5 -0
  98. data/rust/host/src/needs.rs +99 -2
  99. data/rust/host/src/resend.rs +4 -4
  100. data/rust/host/src/ui_schema.rs +1 -1
  101. data/rust/parser/src/emit.rs +12 -0
  102. data/rust/parser/src/ir.rs +2 -0
  103. data/rust/parser/src/keywords.rs +2 -4
  104. data/rust/parser/src/lex.rs +1 -1
  105. data/rust/parser/src/main.rs +1 -0
  106. data/rust/parser/src/parse/command.rs +2 -44
  107. data/rust/parser/src/parse/hecksagon.rs +15 -24
  108. data/rust/parser/src/parse/mod.rs +1 -0
  109. data/rust/parser/src/parse/needs.rs +60 -0
  110. data/rust/parser/src/parse/query.rs +3 -0
  111. data/rust/src/kernel/cli.rs +16 -6
  112. data/rust/src/kernel/needs.rs +104 -9
  113. metadata +22 -9
  114. data/lib/hecks/facade.rb +0 -31
  115. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  116. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  117. data/lib/hecks/three_zero/forms.yml +0 -127
  118. data/lib/hecks/three_zero.rb +0 -12
  119. data/rust/build/src/json.rs +0 -421
  120. data/rust/build/src/optional_pass.rs +0 -150
@@ -0,0 +1,209 @@
1
+ require "open3"
2
+ require "tmpdir"
3
+ require "fileutils"
4
+ require_relative "../vendoring"
5
+ require_relative "lock"
6
+
7
+ module Hecks
8
+ module EmbryonautBluebook
9
+ # A bluebook registry repository, judged and released: packages at `<name>/bluebook.yml`, each
10
+ # with `<name>/bluebook/*.bluebook` and a `<name>/CHANGELOG.md`, released as the annotated tag
11
+ # `<name>-vX.Y.Z`.
12
+ #
13
+ # `check` is the rule that a package's bluebook files may not change without a version bump and
14
+ # a changelog entry; `release` is the sequence of refusals before a tag is made. A package's
15
+ # files are compared through `Lock.digest_of`, the digest a consuming project locks.
16
+ class Registry
17
+ # A version a package may carry, `X.Y.Z`.
18
+ VERSION = /\A\d+\.\d+\.\d+\z/
19
+
20
+ # A package name is a directory name.
21
+ NAME = /\A[a-z][a-z0-9_]*\z/
22
+
23
+ # What `check` found, in package order: a note for a package that has no release yet and a
24
+ # sentence for each fault.
25
+ class Report
26
+ # @return [Array<String>] each fault as `<package>: <why>`
27
+ attr_reader :failures
28
+
29
+ def initialize
30
+ @lines = []
31
+ @failures = []
32
+ end
33
+
34
+ # @param text [String] a fact about a package that is no fault
35
+ # @return [void]
36
+ def note(text) = @lines << "note #{text}"
37
+
38
+ # @param text [String] a fault, as `<package>: <why>`
39
+ # @return [void]
40
+ def fault(text)
41
+ @failures << text
42
+ @lines << "FAIL #{text}"
43
+ end
44
+
45
+ # @return [Boolean] whether no package is at fault
46
+ def ok? = @failures.empty?
47
+
48
+ # @return [String] the notes and faults in package order, then `versions ok` if none
49
+ def to_s = (@lines + (ok? ? ["versions ok"] : [])).join("\n")
50
+ end
51
+
52
+ # What a release made.
53
+ Tagged = Struct.new(:tag, :commit, keyword_init: true) do
54
+ # @return [String] the report, ending in the command that publishes the tag
55
+ def to_s = "Tagged #{tag} at #{commit}. Publish it with:\n git push origin #{tag}"
56
+ end
57
+
58
+ # @param root [String] the registry's working tree
59
+ # @raise [Vendoring::Error] if `root` is not a git repository
60
+ def initialize(root)
61
+ @root = File.expand_path(root.to_s)
62
+ @source = Vendoring::GitSource.new(@root)
63
+ raise Vendoring::Error, "#{@root} is not a git repository" unless git("rev-parse", "--git-dir").last.success?
64
+ end
65
+
66
+ # Judges every package against its latest release.
67
+ #
68
+ # A package fails when its `bluebook.yml` has no `X.Y.Z` version or names another package,
69
+ # when a release tag points at a commit whose `bluebook.yml` says another version, or when
70
+ # its bluebook files differ from the latest release's without a newer version and a
71
+ # `CHANGELOG.md` entry for it.
72
+ #
73
+ # @return [Report] what was found
74
+ def check
75
+ report = Report.new
76
+ packages.each { |package| check_package(package, report) }
77
+ report
78
+ end
79
+
80
+ # Tags a package's current version, after the refusals that stand between a version and a
81
+ # release: the tag exists, the version is not newer, the changelog has no entry, the package
82
+ # has uncommitted changes, or its bluebook files are those of the latest release. Nothing is
83
+ # pushed.
84
+ #
85
+ # @param package [String] the package name
86
+ # @return [Tagged] the tag made and the commit it stands on
87
+ # @raise [Vendoring::Error] with the first refusal
88
+ def release(package)
89
+ fault!("#{package.inspect} is not a package name") unless package.to_s.match?(NAME)
90
+ version = manifest_version(package) or fault!("no #{package}/bluebook.yml")
91
+ fault!("#{package}/bluebook.yml: version '#{version}' is not X.Y.Z") unless version.match?(VERSION)
92
+
93
+ tag = "#{package}-v#{version}"
94
+ refuse_release!(package, version, tag)
95
+ notes = changelog_section(package, version)
96
+ _, _, status = git("tag", "-a", tag, "-m", "#{package} #{version}", "-m", notes)
97
+ raise Vendoring::Error, "git could not tag #{tag}" unless status.success?
98
+
99
+ Tagged.new(tag: tag, commit: git("rev-parse", "--short", "HEAD").first.strip)
100
+ end
101
+
102
+ private
103
+
104
+ def packages
105
+ Dir.glob(File.join(@root, "*", "bluebook.yml")).map { |file| File.basename(File.dirname(file)) }.sort
106
+ end
107
+
108
+ def check_package(package, report)
109
+ version = manifest_version(package).to_s
110
+ unless version.match?(VERSION)
111
+ report.fault "#{package}: version '#{version}' is not X.Y.Z"
112
+ return
113
+ end
114
+ name = File.read(File.join(@root, package, "bluebook.yml"))[/^name: *(.*)$/, 1]
115
+ report.fault "#{package}: bluebook.yml name does not match the directory" unless name == package
116
+ tags(package).each { |tag| check_tag(package, tag, report) }
117
+ latest = latest_version(package)
118
+ return report.note("#{package}: no release tag yet (#{version} unreleased)") unless latest
119
+
120
+ check_changes(package, version, latest, report)
121
+ end
122
+
123
+ def check_tag(package, tag, report)
124
+ tagged = @source.read(tag, "#{package}/bluebook.yml").to_s[/^version: *(.*)$/, 1].to_s
125
+ return if tagged == tag.delete_prefix("#{package}-v")
126
+
127
+ report.fault "#{package}: #{tag} points at a commit whose bluebook.yml says '#{tagged}'"
128
+ end
129
+
130
+ def check_changes(package, version, latest, report)
131
+ return if released_digest(package, latest) == current_digest(package)
132
+
133
+ if version == latest || newest(latest, version) != version
134
+ report.fault "#{package}: bluebook files changed since #{package}-v#{latest} but " \
135
+ "bluebook.yml is still #{version}; bump it"
136
+ end
137
+ return if changelog_entry?(package, version)
138
+
139
+ report.fault "#{package}: CHANGELOG.md has no '## #{version}' entry"
140
+ end
141
+
142
+ def refuse_release!(package, version, tag)
143
+ fault!("#{tag} already exists") if git("rev-parse", "-q", "--verify", "refs/tags/#{tag}").last.success?
144
+
145
+ latest = latest_version(package)
146
+ fault!("#{version} is not newer than the latest release #{latest}") if latest && newest(latest, version) != version
147
+ fault!("#{package}/CHANGELOG.md has no '## #{version}' entry") unless changelog_entry?(package, version)
148
+ fault!("#{package} has uncommitted changes; commit them first") unless clean?(package)
149
+ return unless latest && released_digest(package, latest) == current_digest(package)
150
+
151
+ fault!("the bluebook files are identical to #{package}-v#{latest}; nothing to release")
152
+ end
153
+
154
+ def fault!(why) = raise(Vendoring::Error, why)
155
+
156
+ def clean?(package) = git("status", "--porcelain", "--", package).first.strip.empty?
157
+
158
+ def manifest_version(package)
159
+ file = File.join(@root, package, "bluebook.yml")
160
+ File.file?(file) ? File.read(file)[/^version: *(.*)$/, 1] : nil
161
+ end
162
+
163
+ def tags(package) = @source.tags("#{package}-v*")
164
+
165
+ def latest_version(package)
166
+ tags(package).map { |tag| tag.delete_prefix("#{package}-v") }.max_by { |version| order(version) }
167
+ end
168
+
169
+ # The greater of two versions, compared as `sort -V` does: runs of digits as numbers.
170
+ def newest(left, right) = [left, right].max_by { |version| order(version) }
171
+
172
+ def order(version) = version.scan(/\d+|\D+/).map { |part| part.match?(/\A\d/) ? [0, part.to_i, ""] : [1, 0, part] }
173
+
174
+ def changelog_entry?(package, version)
175
+ file = File.join(@root, package, "CHANGELOG.md")
176
+ File.file?(file) && File.read(file).match?(/^## #{Regexp.escape(version)}( |$)/)
177
+ end
178
+
179
+ # The lines under a version's heading, up to the next heading, without trailing blank lines.
180
+ def changelog_section(package, version)
181
+ on = false
182
+ lines = File.readlines(File.join(@root, package, "CHANGELOG.md"), chomp: true).select do |line|
183
+ on = line.match?(/^## #{Regexp.escape(version)}( |$)/) if line.start_with?("## ")
184
+ on && !line.start_with?("## ")
185
+ end
186
+ lines.join("\n").sub(/\n+\z/, "")
187
+ end
188
+
189
+ def current_digest(package)
190
+ dir = File.join(@root, package, "bluebook")
191
+ File.directory?(dir) ? Lock.digest_of(dir) : nil
192
+ end
193
+
194
+ # The digest of a package's bluebook files as a release tag holds them.
195
+ def released_digest(package, version)
196
+ tag = "#{package}-v#{version}"
197
+ files = @source.files(tag, "#{package}/bluebook", "*.bluebook")
198
+ return nil if files.empty?
199
+
200
+ Dir.mktmpdir("hecks-registry") do |scratch|
201
+ @source.export(tag, files, scratch)
202
+ Lock.digest_of(File.join(scratch, package, "bluebook"))
203
+ end
204
+ end
205
+
206
+ def git(*) = Open3.capture3(Vendoring::GitEnvironment.clean, "git", "-C", @root, *)
207
+ end
208
+ end
209
+ end
@@ -125,7 +125,7 @@ module Hecks
125
125
  return if @allow_downgrade || Gem::Version.new(version) >= Gem::Version.new(previous_version)
126
126
 
127
127
  raise Vendoring::Error, "#{@name} #{previous_version} is vendored; #{version} is older. " \
128
- "Pass allow_downgrade to do it anyway."
128
+ "Pass allow_downgrade (ALLOW_DOWNGRADE=1 on the command line) to do it anyway."
129
129
  end
130
130
 
131
131
  def minor_or_more?(old, new)
@@ -3,6 +3,8 @@ module Hecks
3
3
  # package that ships outside hecks's own lib/ (see .load! and .vendor!).
4
4
  module EmbryonautBluebook
5
5
  autoload :Lock, File.join(__dir__, "embryonaut_bluebook/lock")
6
+ autoload :Manifest, File.join(__dir__, "embryonaut_bluebook/manifest")
7
+ autoload :Registry, File.join(__dir__, "embryonaut_bluebook/registry")
6
8
  autoload :Shape, File.join(__dir__, "embryonaut_bluebook/shape")
7
9
  autoload :Vendor, File.join(__dir__, "embryonaut_bluebook/vendor")
8
10
  autoload :VendorCli, File.join(__dir__, "embryonaut_bluebook/vendor_cli")
@@ -12,9 +12,6 @@ module Hecks
12
12
  # A vendored package named by `attaches "name", from: :vendor`.
13
13
  VENDORED_ATTACHED = /attaches\s*\(?\s*"([\w-]+)"\s*\)?\s*,\s*from:\s*:vendor/
14
14
 
15
- # A vendored package named by the deprecated `uses_embryonaut_bluebook "name"`.
16
- VENDORED_DEPRECATED = /uses_embryonaut_bluebook\s*\(?\s*"([\w-]+)"/
17
-
18
15
  module_function
19
16
 
20
17
  # Copies `domain_path` to a tmpdir, rebinds its persistence to `adapter`, and
@@ -74,7 +71,7 @@ module Hecks
74
71
  def carry_vendored_bluebooks!(source, destination)
75
72
  names = Dir.glob(File.join(destination, "**", "*.hecksagon")).flat_map do |path|
76
73
  text = File.read(path)
77
- (text.scan(VENDORED_DEPRECATED) + text.scan(VENDORED_ATTACHED)).flatten
74
+ text.scan(VENDORED_ATTACHED).flatten
78
75
  end
79
76
  names.uniq.each do |name|
80
77
  package = File.join(File.dirname(source), "vendor", "embryonaut_bluebooks", name)
@@ -1,44 +1,26 @@
1
1
  require "yaml"
2
+ require "hecks/vocabulary"
2
3
 
3
4
  module Hecks
4
5
  module Fuzzing
5
6
  # Loads and validates `qa/settings.yml`, the hecks_qa dials.
6
7
  #
7
8
  # Fails loud: a missing key, unknown key or wrong-typed value raises at load time.
8
- # The meaning of each dial is documented on `QualityControlDials` in the bluebook.
9
+ # What each dial is and takes is declared as a `QaDial` row in the Vocabulary chapter.
9
10
  class QaSettings
10
- # Accessor name to the class (or classes) a valid value must be an instance of.
11
- # Booleans list both TrueClass and FalseClass; Numeric admits `0` as well as `0.0`.
12
- EXPECTED_TYPES = {
13
- cadence_seconds: Integer,
14
- pr_cap_per_day: Integer,
15
- widening_tiers: Array,
16
- sweep_max_parallel: Integer,
17
- liveness_fallback_seconds: Integer,
18
- draft_only: [TrueClass, FalseClass],
19
- auto_merge: [TrueClass, FalseClass],
20
- branch_prefix: String,
21
- adversarial_fraction: Numeric,
22
- guided_generation: [TrueClass, FalseClass],
23
- corpus_splice_probability: Numeric,
24
- favor_rare_verbs: Integer,
25
- self_consistency_checks: [TrueClass, FalseClass],
26
- shrink_budget: Integer,
27
- yield_weight_seconds: Integer,
28
- yield_decay_percent: Integer,
29
- rotation_stale_floor_seconds: Integer,
30
- persistence_parity_seed_cap: Integer,
31
- concurrency_seed_cap: Integer,
32
- adapter_parity_pairs: Hash,
33
- modes: Hash,
34
- role_draw_probability: Numeric,
35
- dry_run_fraction: Numeric,
36
- generated_domains_per_tick: Integer,
37
- generated_domains_rust: [TrueClass, FalseClass],
38
- generated_domain_seeds: Integer,
39
- structural_refusal_boundary: Array
11
+ # The classes a value of each `type` of a `QaDial` row must be an instance of. Booleans list
12
+ # both TrueClass and FalseClass; Numeric admits `0` as well as `0.0`.
13
+ TYPE_CLASSES = {
14
+ "Integer" => [Integer], "Numeric" => [Numeric], "Boolean" => [TrueClass, FalseClass],
15
+ "String" => [String], "Array" => [Array], "Hash" => [Hash]
40
16
  }.freeze
41
17
 
18
+ # Accessor name to the classes a valid value must be an instance of, one entry per `QaDial`
19
+ # row of the Vocabulary chapter, where each dial is declared with its type and its meaning.
20
+ EXPECTED_TYPES = Hecks::Vocabulary.rows("QaDial").to_h do |dial|
21
+ [dial.fetch("name").to_sym, TYPE_CLASSES.fetch(dial.fetch("type"))]
22
+ end.freeze
23
+
42
24
  attr_reader(*EXPECTED_TYPES.keys)
43
25
 
44
26
  # The one real file, resolved from this file's `__dir__` rather than the bluebook's: the
@@ -68,8 +50,7 @@ module Hecks
68
50
  end
69
51
  end
70
52
 
71
- # @param raw [Hash] parsed YAML settings keyed by symbol, one entry per dial in
72
- # `EXPECTED_TYPES`
53
+ # @param raw [Hash] parsed YAML settings keyed by symbol, one entry per `QaDial` row
73
54
  # @param path [String] path to the settings file, used only in error messages
74
55
  # @raise [ArgumentError] if `raw` is missing a required key, declares an unknown
75
56
  # key, or gives a value the wrong type for its dial
@@ -81,7 +62,7 @@ module Hecks
81
62
  if extra.any?
82
63
  raise ArgumentError,
83
64
  "#{path} declares unknown key(s) #{extra.sort.join(', ')} — " \
84
- "Hecks::Fuzzing::QaSettings::EXPECTED_TYPES doesn't recognise them"
65
+ "no QaDial row of the Vocabulary chapter declares them"
85
66
  end
86
67
 
87
68
  EXPECTED_TYPES.each do |key, expected|
@@ -16,7 +16,7 @@ module Hecks
16
16
  POSTGRES_ERA_VARIABLE = /^\s*([a-z_]\w*)\s*=\s*"PostgresEra"\s*(?:#.*)?$/
17
17
 
18
18
  # Captures the member a hecksagon attaches; authorization is read off its declaration.
19
- FRAMEWORK_ATTACHED = /(?:attaches|uses_framework)\s*\(?\s*"([^"]+)"/
19
+ FRAMEWORK_ATTACHED = /attaches\s*\(?\s*"([^"]+)"/
20
20
 
21
21
  # A command-level `role "..."`, the only construct a role check can compare a caller against.
22
22
  ROLE_GATED = /^\s*role\s+"/
@@ -9,6 +9,16 @@
9
9
  #
10
10
  # `env` is set only where the caller has not set it: a push check has no use for the Hecks
11
11
  # domain's database, so it runs on Memory, as a pull-request run does in CI.
12
+ #
13
+ # A check a second stage repeats is written once, under an anchor (`&name`), and aliased
14
+ # (`*name`) where the other stage takes it, so the two stages cannot drift apart.
15
+ #
16
+ # The `ci` stage is the checks the CI workflow `ci-checks.yml` holds. Each of its steps runs one
17
+ # job's checks with `hecks gate_run.gate stage=ci only=<ids> --wait`, so the commands live here and
18
+ # the workflow keeps only which job runs which ids and what each step is called;
19
+ # spec/gate_ci_stage_spec.rb pins that every check is run by exactly one step. Select checks
20
+ # with `only=`: several of them write generated files and compare, so the stage is not meant to
21
+ # run whole in one checkout.
12
22
  pre_push:
13
23
  env:
14
24
  HECKS_ENVIRONMENT: memory
@@ -17,7 +27,8 @@ pre_push:
17
27
  title: rspec (parallel)
18
28
  run: [bundle, exec, parallel_rspec, spec, -n, "{workers}"]
19
29
  blocked: the suite is red.
20
- - id: fuzzing
30
+ - &check_fuzzing
31
+ id: fuzzing
21
32
  title: spec/fuzzing, which the parallel run above excludes
22
33
  run: [bundle, exec, rspec, spec/fuzzing, --tag, fuzzing]
23
34
  blocked: spec/fuzzing is red.
@@ -25,31 +36,125 @@ pre_push:
25
36
  title: adapter agreement, one declared query and every engine giving the same answer
26
37
  run: [bundle, exec, rspec, spec/adapters/query_agreement_spec.rb, --tag, io]
27
38
  blocked: the engines disagree about a declared query; one of them is wrong, and a self-consistent adapter spec cannot tell which.
28
- - id: engine_agreement
39
+ - &check_engine_agreement
40
+ id: engine_agreement
29
41
  title: engine agreement, every declared comparator on both engines
30
42
  run: [bundle, exec, exe/hecks, conformance_run.check_engine_agreement, --wait]
31
43
  blocked: a comparator is missing a shared case or a cross-engine spec.
32
- - id: model_check
44
+ - &check_model_check
45
+ id: model_check
33
46
  title: model check, unreachable states, dead transitions and orphaned handlers
34
47
  run: [bundle, exec, exe/hecks, model_check, --wait]
35
48
  blocked: the model checker found something.
36
- - id: doc_coverage
49
+ - &check_doc_coverage
50
+ id: doc_coverage
37
51
  title: docs coverage, words with no running example
38
52
  run: [bundle, exec, exe/hecks, conformance_run.measure_doc_coverage, --wait]
39
53
  blocked: the language ships something undocumented; write the missing sections and examples.
40
- - id: rubocop
54
+ - &check_rubocop
55
+ id: rubocop
41
56
  title: rubocop, the static style and correctness gate
42
57
  run: [bundle, exec, rubocop, -c, .rubocop.yml]
43
58
  blocked: rubocop found an offense.
44
- - id: codegen_drift
59
+ - &check_codegen_drift
60
+ id: codegen_drift
45
61
  title: codegen drift, the committed rust/src/generated/ must match a fresh regeneration
46
62
  run: [bundle, exec, exe/hecks, regeneration_run.regenerate_corpus, --check, --wait]
47
63
  blocked: regenerating hits a diff, so the committed Rust is stale; run `hecks regenerate_corpus --confirm` and commit the result.
48
- - id: comment_style
64
+ - &check_comment_style
65
+ id: comment_style
49
66
  title: comment style, every comment under lib/hecks against docs/COMMENT_STYLE_GUIDE.md
50
67
  run: [bundle, exec, exe/hecks, style_run.check_comments, paths=lib/hecks, --wait]
51
68
  blocked: a comment breaks the style guide, such as a line past 100 characters; `hecks fix_comments` rewrites the mechanical ones, and the rest are shortened by hand. CI runs this same check.
52
- - id: comment_blocks
69
+ - &check_comment_blocks
70
+ id: comment_blocks
53
71
  title: comment blocks, no run of comment lines past ADR 0075's cap
54
72
  run: [bundle, exec, exe/hecks, style_run.check_comments, "paths=lib,spec,examples", only=long_block, --wait]
55
73
  blocked: a comment block ran past the cap; shorten it.
74
+
75
+ # The checks `.githooks/post-commit` runs after each commit. A red one is reported and never
76
+ # blocks the commit; pre_push runs it again as the backstop.
77
+ post_commit:
78
+ env:
79
+ HECKS_ENVIRONMENT: memory
80
+ checks:
81
+ - *check_fuzzing
82
+
83
+ # The checks `.github/workflows/ci-checks.yml` runs. It sets no `env`: the job's journal choice
84
+ # (the `hecks-environment` action) reaches every check, as it reached each command before.
85
+ ci:
86
+ checks:
87
+ - *check_model_check
88
+ - *check_engine_agreement
89
+ - *check_doc_coverage
90
+ - *check_rubocop
91
+ - *check_comment_style
92
+ - *check_comment_blocks
93
+ - *check_codegen_drift
94
+ - id: lint_deploy_recipes
95
+ title: generated deploy recipes, a command with no checked exit status or no echo
96
+ run: [bundle, exec, exe/hecks, deploy, makefile_check.lint, --wait]
97
+ blocked: a generated deploy Makefile has an unchecked command, or real AWS or database work with no echo telling the person running it what is about to happen.
98
+ - id: fuzz
99
+ title: fuzz, every example domain on the Memory adapter
100
+ run: [bundle, exec, exe/hecks, fuzz_run.fuzz, --wait]
101
+ blocked: a property, an interpreter crash or a determinism check failed on a replayed sequence; the seed is in the output.
102
+ - id: fuzz_sqlite
103
+ title: fuzz on a real on-disk SQLite database
104
+ run: [bundle, exec, exe/hecks, fuzz_run.fuzz, adapter=sqlite, seeds=6, --wait]
105
+ blocked: the SQLite adapter broke a property the Memory adapter held; seeds are 1..N, so the run repeats.
106
+ - id: fuzz_postgres
107
+ title: fuzz on real Postgres, one worker (every ephemeral boot recreates the same scratch schema)
108
+ run: [bundle, exec, exe/hecks, fuzz_run.fuzz, adapter=postgres, seeds=3, steps=20, --wait]
109
+ blocked: the Postgres adapter broke a property the Memory adapter held; it needs PGHOST, PGUSER and PGPASSWORD.
110
+ - id: vocabulary_drift
111
+ title: vocabulary, the reserved-name tables regenerated must match the committed ones
112
+ run:
113
+ - sh
114
+ - -ec
115
+ - |
116
+ bundle exec exe/hecks language_run.project_vocabulary --confirm --wait
117
+ bundle exec exe/hecks language_run.project_reserved_names --confirm --wait
118
+ git diff --exit-code -- lib/hecks/vocabulary.rb rust/codegen/src/reserved_names.rs rust/build/src/reserved_names.rs
119
+ blocked: a generated vocabulary file is stale; the check rewrote it, so commit the result.
120
+ - id: ci_gates
121
+ title: ci gates, the CiGate rows must match the marked regions of the workflows
122
+ run: [bundle, exec, exe/hecks, regeneration_run.project_ci_gates, --wait]
123
+ blocked: a CiGate row changed with no projection, or a region was edited by hand; run `hecks regeneration_run.project_ci_gates --confirm`.
124
+ - id: tools_doc
125
+ title: tools doc, docs/tools.md must match the RetiredScript rows
126
+ run: [bundle, exec, exe/hecks, regeneration_run.project_tools_doc, --wait]
127
+ blocked: a command's arguments changed with no regenerated doc; run `hecks regeneration_run.project_tools_doc --confirm`.
128
+ - id: rust_coverage
129
+ title: rust coverage, every generated module
130
+ run: [bundle, exec, exe/hecks, query, corpus_run.corpus_rust_coverage, --wait]
131
+ blocked: a construct a domain declares has no generated Rust, or an entry on RUST_COVERAGE_PENDING is now fixed and must be deleted.
132
+ - id: rust_coverage_codegen
133
+ title: rust coverage against hecks-codegen's own manifest, pizzas governance identity banking meta
134
+ run:
135
+ - sh
136
+ - -ec
137
+ - |
138
+ for module in pizzas governance identity banking meta; do
139
+ bundle exec exe/hecks query build.rust_coverage module_name=$module codegen=rust --wait
140
+ done
141
+ blocked: hecks-codegen generated nothing for a construct a domain declares.
142
+ - id: kernel_coverage
143
+ title: rust kernel coverage, every capability variant has a kernel file
144
+ run: [bundle, exec, exe/hecks, kernel_run.measure_kernel_coverage, --wait]
145
+ blocked: a capability's enum variant has no rust/src/kernel file behind it.
146
+ - id: rust_vocabulary_drift
147
+ title: rust vocabulary, the kernel vocab tables regenerated must match the committed ones
148
+ run:
149
+ - sh
150
+ - -ec
151
+ - |
152
+ bundle exec exe/hecks language_run.project_rust_vocabulary --confirm --wait
153
+ git diff --exit-code -- rust/src/kernel/vocab/
154
+ untracked="$(git status --porcelain --untracked-files=all -- rust/src/kernel/vocab/)"
155
+ if [ -n "$untracked" ]; then
156
+ echo "rust/src/kernel/vocab has uncommitted generated files:" >&2
157
+ echo "$untracked" >&2
158
+ exit 1
159
+ fi
160
+ blocked: the generated kernel vocab tables are stale or untracked; the check rewrote them, so commit the result.
@@ -16,7 +16,7 @@ module Hecks
16
16
  # files reports drift and writes only when it is confirmed.
17
17
  module Regeneration
18
18
  # Every operation this family carries out.
19
- OPERATIONS = %w[regenerate_corpus project_ci_gates decide_ci_gate].freeze
19
+ OPERATIONS = %w[regenerate_corpus project_ci_gates project_tools_doc decide_ci_gate].freeze
20
20
 
21
21
  # How many lines of a script's output a refusal keeps.
22
22
  KEPT_LINES = 60
@@ -37,6 +37,7 @@ module Hecks
37
37
  check = flag.call(:check) || !flag.call(:confirm)
38
38
  child = RubyChild.new(tree)
39
39
  return project_ci_gates(child, check) if operation == "project_ci_gates"
40
+ return project_tools_doc(child, check) if operation == "project_tools_doc"
40
41
  return decide_ci_gate(child, held) if operation == "decide_ci_gate"
41
42
 
42
43
  result = child.capture("regen_codegen_domains", *("--check" if check))
@@ -53,6 +54,14 @@ module Hecks
53
54
  child.answer("project_ci_gates", *("--check" if check))
54
55
  end
55
56
 
57
+ # @param child [RubyChild] the checkout's tool runner
58
+ # @param check [Boolean] whether to only compare
59
+ # @return [String] what the tool printed
60
+ # @raise [ConsoleCapture::Failure] with the stale sections, when a check finds drift
61
+ def project_tools_doc(child, check)
62
+ child.answer("project_tools_doc", *("--check" if check))
63
+ end
64
+
56
65
  # @param child [RubyChild] the checkout's tool runner
57
66
  # @param held [Hash] the record's fields: `gate`, a `CiGate` row's name
58
67
  # @return [String] `touched=true` or `touched=false`
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../doors/json_door"
4
+
5
+ module Hecks
6
+ module Adapters
7
+ # Gives a booted domain its first administrator through the chapter that provides
8
+ # `"membership"`, the way a project's hand-written bootstrap script does.
9
+ #
10
+ # The chapter names its own verbs (`provides "membership", admit:, grant:, people:`), so no
11
+ # aggregate is recognised by name. Both dispatches run as a caller that asserts the role the
12
+ # command is gated to and binds no `actor_id`: Governance checks an identified caller against
13
+ # its role assignments, which a domain with no administrator cannot have yet, and checks an
14
+ # unidentified one by comparing the role named (ADR 0025). That is the bootstrap step, and it is
15
+ # refused once anyone holds an administrator role, so it cannot add a second.
16
+ class FirstAdmin
17
+ # The roles that count as an administrator besides the one the grant command is gated to.
18
+ OWNER_ROLES = %w[Owner].freeze
19
+
20
+ # What a bootstrap did.
21
+ #
22
+ # @!attribute [r] email
23
+ # @return [String] who was granted access
24
+ # @!attribute [r] role
25
+ # @return [String] the role they now hold
26
+ # @!attribute [r] admitted
27
+ # @return [Boolean] whether the person had to be admitted first
28
+ Result = Struct.new(:email, :role, :admitted, keyword_init: true) do
29
+ # @return [String] the sentence the verb answers with
30
+ def to_s = "Granted #{role} access to #{email}#{' (admitted first)' if admitted}"
31
+ end
32
+
33
+ # @param runtime [Hecks::Runtime] the booted domain
34
+ def initialize(runtime)
35
+ @runtime = runtime
36
+ @registry = runtime.registry
37
+ end
38
+
39
+ # Admits the person if they are not yet, then grants them a role.
40
+ #
41
+ # @param email [String] the person's email, the membership aggregate's identity
42
+ # @param name [String, nil] their name; the part of the email before the `@` when nil
43
+ # @param role [String, nil] the role to grant; the one the grant command is gated to when nil
44
+ # @return [Result] what was done
45
+ # @raise [Runtime::NotFound] when the domain has no membership chapter, the email is not an
46
+ # email, or an administrator already exists
47
+ def call(email:, name: nil, role: nil)
48
+ raise Runtime::NotFound, "#{email.inspect} is not an email address" unless email.to_s.match?(/\A[^\s@]+@[^\s@]+\z/)
49
+
50
+ provider = membership_provider
51
+ verbs = verbs_of(provider)
52
+ gate = gated_role(provider, verbs.fetch(:grant))
53
+ people = rows(verbs.fetch(:people))
54
+ refuse_if_administered!(people, [gate, *OWNER_ROLES].uniq)
55
+
56
+ admitted = people.none? { |person| person.dig(:email, :value) == email }
57
+ admit(provider, verbs.fetch(:admit), email, name || email[/\A[^@]+/]) if admitted
58
+ grant(provider, verbs.fetch(:grant), email, role || gate)
59
+ Result.new(email: email, role: role || gate, admitted: admitted)
60
+ end
61
+
62
+ private
63
+
64
+ def membership_provider
65
+ domain = @registry.bluebooks.values.first&.name
66
+ @registry.membership_provider_for(domain) or
67
+ raise Runtime::NotFound, "this domain attaches no chapter that provides \"membership\""
68
+ end
69
+
70
+ def verbs_of(provider)
71
+ %i[admit grant people].to_h { |key| [key, provider.provided_verb(Bluebook::Capabilities::MEMBERSHIP, key)] }
72
+ end
73
+
74
+ def rows(verb) = @runtime.query(verb).map { |row| Doors::JsonDoor.materialize(row) }
75
+
76
+ # The role a verb's command declares, which the dispatch must assert to be let through.
77
+ def gated_role(provider, verb)
78
+ aggregate, command = verb.split("::").last.split(".")
79
+ found = provider.aggregates.find { |candidate| candidate.hecks_name == aggregate }
80
+ &.commands&.find { |candidate| candidate.hecks_name == command }
81
+ found&.role&.to_s or raise Runtime::NotFound, "#{verb} declares no role to bootstrap as"
82
+ end
83
+
84
+ def refuse_if_administered!(people, roles)
85
+ held = people.select { |person| roles.map(&:downcase).include?(person.dig(:role, :value).to_s.downcase) }
86
+ return if held.empty?
87
+
88
+ who = held.map { |person| person.dig(:email, :value) }.join(", ")
89
+ raise Runtime::NotFound, "an administrator already exists (#{who}); grant roles through the " \
90
+ "domain's own #{roles.first} access instead"
91
+ end
92
+
93
+ def admit(provider, verb, email, name)
94
+ as(provider, verb) do
95
+ @runtime.dispatch(verb, with: { email: { value: email }, name: { value: name } })
96
+ end
97
+ end
98
+
99
+ def grant(provider, verb, email, role)
100
+ as(provider, verb) { @runtime.dispatch(verb, to: email, with: { role: { value: role } }) }
101
+ end
102
+
103
+ def as(provider, verb, &)
104
+ Hecks.as_caller(role: gated_role(provider, verb), &)
105
+ end
106
+ end
107
+ end
108
+ end