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.
- checksums.yaml +4 -4
- data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
- data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
- data/lib/hecks/bluebook/query.rb +8 -5
- data/lib/hecks/cli/interview_draft.rb +86 -31
- data/lib/hecks/cli/interview_session.rb +77 -26
- data/lib/hecks/cli/interview_shape.rb +179 -0
- data/lib/hecks/doors/cli_runner.rb +14 -1
- data/lib/hecks/doors/launcher_options.rb +13 -3
- data/lib/hecks/doors/mcp_door_scope.rb +2 -1
- data/lib/hecks/doors.rb +1 -1
- data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
- data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
- data/lib/hecks/embryonaut_bluebook.rb +2 -0
- data/lib/hecks/fuzzing/qa_settings.rb +15 -34
- data/lib/hecks/gate/stages.yml +113 -8
- data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
- data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
- data/lib/hecks/hecks/adapters/git.rb +59 -0
- data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
- data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
- data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
- data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
- data/lib/hecks/hecks/codebase.behaviors +6 -0
- data/lib/hecks/hecks/codebase.bluebook +21 -0
- data/lib/hecks/hecks/custodian.behaviors +6 -0
- data/lib/hecks/hecks/custodian.bluebook +217 -0
- data/lib/hecks/hecks/hecks.hecksagon +24 -0
- data/lib/hecks/hecks/hecks.world +8 -3
- data/lib/hecks/hecks/site.world +5 -2
- data/lib/hecks/language/bluebook/query.bluebook +25 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
- data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
- data/lib/hecks/projections/deploy/box/settings.rb +17 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
- data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
- data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
- data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
- data/lib/hecks/projections/deploy/box.rb +15 -2
- data/lib/hecks/projections/deploy/fargate.rb +3 -3
- data/lib/hecks/projections/deploy/lambda.rb +8 -8
- data/lib/hecks/projections/model/deviations.rb +1 -1
- data/lib/hecks/projections/site/live_edge.rb +162 -0
- data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
- data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
- data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
- data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
- data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
- data/lib/hecks/quality_control/quality_control.bluebook +76 -124
- data/lib/hecks/runtime/interpreting.rb +3 -22
- data/lib/hecks/runtime/needs.rb +48 -0
- data/lib/hecks/runtime/query_interpreter.rb +3 -0
- data/lib/hecks/runtime/registry/verification.rb +3 -3
- data/lib/hecks/rust_build/host.rb +147 -0
- data/lib/hecks/rust_build/kernel_input.rb +40 -18
- data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
- data/lib/hecks/rust_build/project_rust.rb +3 -10
- data/lib/hecks/rust_build.rb +1 -0
- data/lib/hecks/site/bluebook/site.bluebook +53 -1
- data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
- data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
- data/lib/hecks/tools/gate.rb +1 -1
- data/lib/hecks/tools/rust_comment_style.rb +1 -3
- data/lib/hecks/tools/tools_doc.rb +204 -0
- data/lib/hecks/tools.rb +1 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +151 -0
- data/qa/settings.yml +1 -2
- data/rust/build/src/lineage_pass.rs +30 -15
- data/rust/build/src/main.rs +4 -2
- data/rust/build/src/pipeline.rs +8 -19
- data/rust/codegen/src/bridging.rs +7 -7
- data/rust/codegen/src/json.rs +214 -23
- data/rust/codegen/src/literal.rs +6 -3
- data/rust/codegen/src/main.rs +20 -9
- data/rust/codegen/src/naming.rs +4 -2
- data/rust/codegen/src/optional_pass.rs +226 -0
- data/rust/{build → codegen}/src/sidecars.rs +9 -5
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/api.rs +1 -1
- data/rust/host/src/dispatch.rs +5 -0
- data/rust/host/src/needs.rs +99 -2
- data/rust/host/src/resend.rs +4 -4
- data/rust/host/src/ui_schema.rs +1 -1
- data/rust/parser/src/emit.rs +12 -0
- data/rust/parser/src/ir.rs +2 -0
- data/rust/parser/src/keywords.rs +2 -0
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +2 -44
- data/rust/parser/src/parse/mod.rs +1 -0
- data/rust/parser/src/parse/needs.rs +60 -0
- data/rust/parser/src/parse/query.rs +3 -0
- data/rust/src/kernel/cli.rs +16 -6
- data/rust/src/kernel/needs.rs +104 -9
- metadata +22 -8
- data/lib/hecks/rust_build/append_optionals.rb +0 -79
- data/lib/hecks/rust_build/rust_literal.rb +0 -33
- data/lib/hecks/three_zero/forms.yml +0 -127
- data/lib/hecks/three_zero.rb +0 -12
- data/rust/build/src/json.rs +0 -421
- 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 += [
|
|
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
|
|
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
|
@@ -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")
|