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,179 @@
1
+ require_relative "../naming"
2
+
3
+ module Hecks
4
+ module CLI
5
+ # What an interview's fields, command inputs and state changes become in a drafted aggregate
6
+ # (ADR 0088): its attributes and their value objects, the fields a command is told, a note of
7
+ # who does it, and the lifecycle.
8
+ #
9
+ # Pure, like `InterviewDraft`, which calls it: each method answers lines of bluebook text and
10
+ # writes none. A name is written as `InterviewDraft.word` writes it.
11
+ module InterviewShape
12
+ module_function
13
+
14
+ # The accepted fields of one thing, each with the type it will be written under. The
15
+ # identifier is already a field, and a lifecycle owns `status`, so neither is repeated.
16
+ #
17
+ # @param found [Array<Hash>] the accepted field findings that name the thing
18
+ # @param name [String] the thing's name, which no type may repeat
19
+ # @param identifier [String] the identifying field, snake-cased
20
+ # @param identifier_type [String] the type the identifier is written under
21
+ # @param lifecycle [Boolean] whether the thing has a lifecycle
22
+ # @return [Array<Hash>] each field's `name`, `type` and `values`
23
+ def fields(found, name, identifier, identifier_type, lifecycle:)
24
+ taken = [name, identifier_type]
25
+ found.filter_map { |finding| field_row(finding, taken, identifier, lifecycle) }.uniq { |row| row[:name] }
26
+ end
27
+
28
+ # The attribute lines of the fields: required when the creating action takes one, else
29
+ # optional.
30
+ #
31
+ # @param fields [Array<Hash>] the thing's fields, from `fields`
32
+ # @param required [Array<String>] the field names the creating actions take
33
+ # @return [Array<String>] one line per field
34
+ def attributes(fields, required)
35
+ fields.map do |field|
36
+ suffix = required.include?(field[:name]) ? "" : ", optional: true"
37
+ " attribute :#{field[:name]}, #{field[:type]}#{suffix}"
38
+ end
39
+ end
40
+
41
+ # The value object of each field, a blank line before each: a closed set when the expert
42
+ # listed two or more values, and free text otherwise.
43
+ #
44
+ # @param fields [Array<Hash>] the thing's fields, from `fields`
45
+ # @return [Array<String>] the lines
46
+ def value_objects(fields)
47
+ fields.flat_map do |field|
48
+ rule = field[:values].size > 1 ? ", one_of: #{field[:values].inspect}" : ""
49
+ ["", " value_object #{field[:type].inspect} do", " attribute :value, String#{rule}", " end"]
50
+ end
51
+ end
52
+
53
+ # The field names an action says it is told.
54
+ #
55
+ # @param action [Hash] an accepted action
56
+ # @return [Array<String>] snake-cased field names
57
+ def takes(action)
58
+ action[:takes].to_s.split(/\s*,\s*/).map { |text| Naming.snake(InterviewDraft.word(text)) }.reject(&:empty?)
59
+ end
60
+
61
+ # What an action is told that the thing has as a field, and what it names that the thing
62
+ # does not. The identifier is never counted: a command already carries it.
63
+ #
64
+ # @param action [Hash] an accepted action
65
+ # @param fields [Array<Hash>] the thing's fields, from `fields`
66
+ # @param identifier [String] the identifying field, snake-cased
67
+ # @return [Array(Array<Hash>, Array<String>)] the fields it is told, and the names that have
68
+ # no field
69
+ def inputs(action, fields, identifier)
70
+ known, unknown = (takes(action) - [identifier]).partition { |text| fields.any? { |field| field[:name] == text } }
71
+ [known.map { |text| fields.find { |field| field[:name] == text } }, unknown]
72
+ end
73
+
74
+ # @param inputs [Array<Hash>] the fields a command is told
75
+ # @return [Array<String>] a blank line and an attribute line for each, or none
76
+ def input_lines(inputs)
77
+ inputs.empty? ? [] : ["", *inputs.map { |f| " attribute :#{f[:name]}, #{f[:type]}" }]
78
+ end
79
+
80
+ # @param inputs [Array<Hash>] the fields a command is told
81
+ # @return [Array<String>] a blank line and a `sets` line for each, or none
82
+ def assignment_lines(inputs)
83
+ inputs.empty? ? [] : ["", *inputs.map { |f| " sets :#{f[:name]}" }]
84
+ end
85
+
86
+ # @param unknown [Array<String>] names an action is told that no accepted field has
87
+ # @return [Array<String>] a note for each
88
+ def unknown_lines(unknown)
89
+ unknown.map { |text| " # TODO: takes #{text}, but no accepted field of that name." }
90
+ end
91
+
92
+ # Who may do an action, as the expert said it. It is a note, not a role: a role is only
93
+ # checked once the domain attaches Governance.
94
+ #
95
+ # @param action [Hash] an accepted action
96
+ # @return [Array<String>] the note, or none
97
+ def who_lines(action)
98
+ by = action[:by].to_s.strip
99
+ by.empty? ? [] : [" # Who: #{by}. Declare it as a role once Governance is attached."]
100
+ end
101
+
102
+ # The lifecycle the accepted transitions describe. The state a creating action's step
103
+ # leads to is where a thing starts; a step for an action the thing does not have is kept
104
+ # as a comment.
105
+ #
106
+ # @param steps [Array<Hash>] the thing's accepted transitions
107
+ # @param creating [Array<Hash>] the accepted actions that create it
108
+ # @param rest [Array<Hash>] its other accepted actions
109
+ # @return [Array<String>] the lines, or none when no step and start state can be placed
110
+ def lifecycle(steps, creating, rest)
111
+ births = creating.map { |action| InterviewDraft.word(action[:name]) }
112
+ has = ->(names, step) { names.include?(InterviewDraft.word(step[:action])) }
113
+ starts, moves = steps.partition { |step| has.call(births, step) }
114
+ heres = rest.map { |action| InterviewDraft.word(action[:name]) }
115
+ placed, loose = moves.partition { |step| has.call(heres, step) }
116
+ placed = merged(placed)
117
+ start = beginning(starts, placed)
118
+ return [] if start.empty? || placed.empty?
119
+
120
+ ["", " lifecycle :status, default: #{start.inspect} do", *placed.map { |step| transition_line(step) },
121
+ " end", *loose.map { |step| loose_line(step) }]
122
+ end
123
+
124
+ # @api private
125
+ def field_row(finding, taken, identifier, lifecycle)
126
+ field = Naming.snake(InterviewDraft.word(finding[:name]))
127
+ return if field == identifier || (lifecycle && field == "status")
128
+
129
+ type = Naming.pascal(field)
130
+ type = "#{type}Value" while taken.include?(type)
131
+ taken << type
132
+ { name: field, type: type, values: finding[:values].to_s.split(/\s*,\s*|\s+or\s+/).map(&:strip).reject(&:empty?).uniq }
133
+ end
134
+
135
+ # One step for each action and state it leads to: the states it starts from are joined, and a
136
+ # step with no state to start from means it may be taken from any.
137
+ # @api private
138
+ def merged(steps)
139
+ steps.group_by { |step| [InterviewDraft.word(step[:action]), state_word(step[:to])] }.values.map do |group|
140
+ froms = group.map { |step| step[:from].to_s.strip }
141
+ group.first.merge(from: froms.any?(&:empty?) ? "" : froms.join(","))
142
+ end
143
+ end
144
+
145
+ # The state a thing starts in: where its creating action's step leads, else the first state a
146
+ # change is said to start from.
147
+ # @api private
148
+ def beginning(starts, placed)
149
+ from = placed.map { |step| step[:from].to_s.split(",").first }.find { |text| !text.to_s.strip.empty? }
150
+ state_word(starts.first&.fetch(:to) || from)
151
+ end
152
+
153
+ # @api private
154
+ def transition_line(step)
155
+ " transition #{InterviewDraft.word(step[:action]).inspect} => #{state_word(step[:to]).inspect}" \
156
+ "#{from_clause(step)}"
157
+ end
158
+
159
+ # @api private
160
+ def loose_line(step)
161
+ " # UNPLACED transition: #{InterviewDraft.word(step[:action])} to #{state_word(step[:to])}; " \
162
+ "no accepted action has that name."
163
+ end
164
+
165
+ # The states a step may start from: none, one, or several.
166
+ # @api private
167
+ def from_clause(step)
168
+ states = step[:from].to_s.split(/\s*,\s*/).map { |text| state_word(text) }.reject(&:empty?)
169
+ return "" if states.empty?
170
+
171
+ states.one? ? ", from: #{states.first.inspect}" : ", from: %w[#{states.join(' ')}]"
172
+ end
173
+
174
+ # A state as a lowercase word: `Not Yet Triaged` is `not_yet_triaged`.
175
+ # @api private
176
+ def state_word(text) = text.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
177
+ end
178
+ end
179
+ end
@@ -188,6 +188,7 @@ module Hecks
188
188
  # @param launcher [Hash, nil] the chapter's `launcher` world setting; nil when not opted in
189
189
  def dispatch(runtime, spec, name, rest, program, asking, bluebook: nil, launcher: nil)
190
190
  rest, wait = LauncherOptions.take_wait(spec, rest) if launcher
191
+ wait ||= LauncherOptions.settled?(launcher, spec)
191
192
  args = CliDoor.arguments(spec, rest)
192
193
 
193
194
  return answer_query(runtime, spec, args, wait) if spec[:kind] == :query
@@ -252,11 +253,23 @@ module Hecks
252
253
  events: (events.empty? ? handle.events : events).map(&:name) }.merge(extra)
253
254
  if LauncherOptions.failed?(aggregate, state, launcher)
254
255
  field = aggregate.lifecycle.field
255
- why += ["#{aggregate.hecks_name} ended in the failure state #{state[field.to_sym].to_s.inspect}"]
256
+ why += [failure_sentence(aggregate, state, field)]
256
257
  end
257
258
  finish(JSON.pretty_generate(answer), why)
258
259
  end
259
260
 
261
+ # The sentence for a record that ended in a failure state: the state, then the record's own
262
+ # `refusal` when it keeps one, without the class name an adapter's failure carries.
263
+ def failure_sentence(aggregate, state, field)
264
+ sentence = "#{aggregate.hecks_name} ended in the failure state #{state[field.to_sym].to_s.inspect}"
265
+ refusal = state[:refusal]
266
+ refusal = refusal.value if refusal.respond_to?(:value)
267
+ refusal = refusal[:value] if refusal.is_a?(Hash)
268
+ return sentence if refusal.to_s.strip.empty?
269
+
270
+ "#{sentence}: #{refusal.to_s.strip.sub(/\A(\w+::)+\w+: /, '')}"
271
+ end
272
+
260
273
  # The answer and its status: 0 when nothing failed, else 1 with the reasons joined.
261
274
  def finish(text, reasons)
262
275
  reasons.empty? ? [text, 0] : [text, 1, reasons.join("\n")]
@@ -10,11 +10,10 @@ module Hecks
10
10
  # A `.world` that declares a `launcher` setting switches these on for that chapter; a
11
11
  # domain without one keeps the launcher's plain forms exactly:
12
12
  #
13
- # launcher "Launcher", run_keys: true, failure_states: %w[flagged failed],
14
- # names: { "mcp" => "serve_mcp" }
15
- #
16
13
  # - **run_keys** mints the `run` key of a creating command that was given none.
17
14
  # - **failure_states** are the lifecycle states `--wait` reports as a failure (exit 1).
15
+ # - **settled** lists the commands (`aggregate.command`) that always behave as if `--wait` was
16
+ # given, so a script gets their outcome in its exit status.
18
17
  # - **names** maps a launcher name to the command it stands for (see `CliProjector`).
19
18
  # - **streams** lists the questions `--stream` may tail, one JSON line per new entry.
20
19
  module LauncherOptions
@@ -87,6 +86,17 @@ module Hecks
87
86
  [rest, wait]
88
87
  end
89
88
 
89
+ # Whether a command always waits for its reactions, as if `--wait` had been given.
90
+ #
91
+ # @param launcher [Hash, nil] the chapter's `launcher` setting
92
+ # @param spec [Hash] the command's projected spec
93
+ # @return [Boolean] true for a command the setting lists under `settled`
94
+ def settled?(launcher, spec)
95
+ return false unless launcher && spec[:kind] == :command
96
+
97
+ Array(launcher[:settled]).map(&:to_s).include?(spec[:short].to_s)
98
+ end
99
+
90
100
  # Whether a question may be tailed with `--stream`: the chapter's `launcher` setting lists it
91
101
  # under `streams`, by the question's own name.
92
102
  #
@@ -28,7 +28,8 @@ module Hecks
28
28
  # switch to, or flips a command from a preview to a change of state.
29
29
  DENIED_ARGUMENTS = %w[
30
30
  adapter artifact confirm expected from gem_only header health_path host no_wait npm_local
31
- npm_only output path payload payload_file port rust_binary scheme state_path stdio url write
31
+ npm_only output path payload payload_file port rust_binary scheme stage_dir state_path stdio url
32
+ write
32
33
  ].freeze
33
34
 
34
35
  # Argument names whose values are paths (a comma-separated list for some). A restricted door
data/lib/hecks/doors.rb CHANGED
@@ -2,7 +2,7 @@ module Hecks
2
2
  # The class-free public surface, installed per boot by `Runtime::Loader.bind_runtime`.
3
3
  module Doors
4
4
  # The removal release of the `Facade` and `install_facade:` spellings.
5
- REMOVAL = "3.2.0".freeze
5
+ REMOVAL = "3.4.0".freeze
6
6
 
7
7
  module_function
8
8
 
@@ -0,0 +1,117 @@
1
+ require "json"
2
+ require_relative "lock"
3
+
4
+ module Hecks
5
+ module EmbryonautBluebook
6
+ # Checks a project's vendored packages against their `bluebook.lock` files and describes
7
+ # them as data: the manifest a client image carries to say which bluebooks it holds.
8
+ #
9
+ # { "built_from" => { "commit" => "<project commit>", "dirty" => false },
10
+ # "bluebooks" => { "payments" => { "version" => "1.0.0", "tag" => "payments-v1.0.0",
11
+ # "commit" => "<sha>", "digest" => "<sha256>",
12
+ # "shape" => ["Payments d33c23"] } } }
13
+ #
14
+ # There is no timestamp: the same inputs give the same manifest. The digest is `Lock.digest_of`,
15
+ # the one implementation `Vendor` also writes locks with.
16
+ class Manifest
17
+ # The lock fields every package must carry besides its shape.
18
+ FIELDS = %w[package version tag commit digest].freeze
19
+
20
+ # The fields a package's manifest entry repeats from its lock.
21
+ ENTRY = %w[version tag commit digest].freeze
22
+
23
+ # A refusal: every way the vendored packages disagree with their locks, one per line.
24
+ class Mismatch < StandardError
25
+ # @return [Array<String>] one sentence per disagreement
26
+ attr_reader :problems
27
+
28
+ # @param problems [Array<String>] the disagreements found
29
+ def initialize(problems)
30
+ @problems = problems
31
+ super(problems.map { |problem| "FAIL #{problem}" }.join("\n"))
32
+ end
33
+ end
34
+
35
+ # @param root [String] the project directory holding `vendor/embryonaut_bluebooks/`
36
+ # @param built_from [Hash{String => Object}, nil] the project's own commit and whether the
37
+ # tree is dirty; left out of the manifest when nil
38
+ def initialize(root, built_from: nil)
39
+ @root = root
40
+ @built_from = built_from
41
+ end
42
+
43
+ # Checks every vendored package and describes them.
44
+ #
45
+ # @return [Hash{String => Object}] the manifest
46
+ # @raise [Mismatch] when any package disagrees with its lock
47
+ def call
48
+ problems = []
49
+ packages = names.to_h { |name| [name, entry(name, problems)] }.compact
50
+ raise Mismatch, problems unless problems.empty?
51
+
52
+ { "built_from" => @built_from, "bluebooks" => packages }.compact
53
+ end
54
+
55
+ # The manifest as the text a label or file holds: keys sorted, two-space indent.
56
+ #
57
+ # @return [String] JSON ending in a newline
58
+ def to_json_text
59
+ "#{JSON.pretty_generate(sort(call))}\n"
60
+ end
61
+
62
+ private
63
+
64
+ def vendor_dir = File.join(@root, "vendor", "embryonaut_bluebooks")
65
+
66
+ def names
67
+ return [] unless File.directory?(vendor_dir)
68
+
69
+ Dir.children(vendor_dir).sort.select { |name| File.directory?(File.join(vendor_dir, name)) }
70
+ end
71
+
72
+ # One package's manifest entry, or nil (its reasons added to `problems`) when it has no lock.
73
+ def entry(name, problems)
74
+ dir = File.join(vendor_dir, name)
75
+ path = File.join(dir, "bluebook.lock")
76
+ unless File.exist?(path)
77
+ problems << "#{name}: no bluebook.lock (hecks package.vendor #{name}@<version>)"
78
+ return nil
79
+ end
80
+
81
+ lock = Lock.read(path)
82
+ judge(name, dir, lock, problems)
83
+ lock.to_h.transform_keys(&:to_s).slice(*ENTRY).merge("shape" => Array(lock.shape))
84
+ end
85
+
86
+ def judge(name, dir, lock, problems)
87
+ FIELDS.each { |field| problems << "#{name}: bluebook.lock has no #{field}" if blank?(lock[field.to_sym]) }
88
+ problems << "#{name}: bluebook.lock names package #{lock.package}" if lock.package && lock.package != name
89
+ problems << "#{name}: bluebook.lock has no shape" if Array(lock.shape).empty?
90
+ judge_release(name, lock, problems)
91
+ actual = Lock.digest_of(File.join(dir, "bluebook"))
92
+ return if actual == lock.digest
93
+
94
+ problems << "#{name}: vendored files hash to #{(actual || 'nothing')[0, 12]}, but bluebook.lock says " \
95
+ "#{(lock.digest || '?')[0, 12]} (edited by hand, or re-vendored without its lock?)"
96
+ end
97
+
98
+ # The tag of a release is `<package>-v<version>`; a lock saying otherwise was edited.
99
+ def judge_release(name, lock, problems)
100
+ return if blank?(lock.tag) || blank?(lock.version)
101
+ return if lock.tag == "#{name}-v#{lock.version}"
102
+
103
+ problems << "#{name}: bluebook.lock tag #{lock.tag} is not #{name}-v#{lock.version}"
104
+ end
105
+
106
+ def blank?(value) = value.nil? || value.to_s.empty?
107
+
108
+ def sort(value)
109
+ case value
110
+ when Hash then value.sort.to_h { |key, inner| [key, sort(inner)] }
111
+ when Array then value.map { |inner| sort(inner) }
112
+ else value
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -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")