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.
- 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 +21 -23
- 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 +88 -33
- data/lib/hecks/cli/interview_session.rb +77 -26
- data/lib/hecks/cli/interview_shape.rb +198 -0
- data/lib/hecks/corpus.rb +3 -5
- 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 +0 -17
- 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/isolated_boot.rb +1 -4
- data/lib/hecks/fuzzing/qa_settings.rb +15 -34
- data/lib/hecks/fuzzing/target_capabilities.rb +1 -1
- 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 +0 -5
- 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/loader.rb +6 -7
- 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/runtime.rb +6 -11
- 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/lib/hecks.rb +6 -12
- 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 -4
- data/rust/parser/src/lex.rs +1 -1
- data/rust/parser/src/main.rs +1 -0
- data/rust/parser/src/parse/command.rs +2 -44
- data/rust/parser/src/parse/hecksagon.rs +15 -24
- 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 -9
- data/lib/hecks/facade.rb +0 -31
- 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
|
@@ -24,36 +24,59 @@ module Hecks
|
|
|
24
24
|
|
|
25
25
|
TASK = "You interview a subject matter expert, through the developer who sits beside them, about the " \
|
|
26
26
|
"business domain called %<subject>s, so the developer can model it. Ask one plain-language " \
|
|
27
|
-
"question at a time about what the business keeps track of, what
|
|
28
|
-
"
|
|
27
|
+
"question at a time about what the business keeps track of, what each thing has, what happens to " \
|
|
28
|
+
"it and who does it, how it moves from one state to the next, and what must never happen. Use the " \
|
|
29
|
+
"expert's own words. Do not assume what kind of business it is, or an " \
|
|
29
30
|
"industry, from the name of the domain: learn it from what the expert says. When you interpret " \
|
|
30
31
|
"an answer, propose findings only with the verbs listed under `verbs`, using the argument " \
|
|
31
32
|
"names given there, and nothing else.".freeze
|
|
32
33
|
|
|
33
34
|
VERBS = {
|
|
34
|
-
"SME::Interview.ProposeThing"
|
|
35
|
+
"SME::Interview.ProposeThing" => {
|
|
35
36
|
"meaning" => "a thing the business keeps track of, and the field that identifies one of it",
|
|
36
37
|
"arguments" => %w[name identifier]
|
|
37
38
|
},
|
|
38
|
-
"SME::Interview.ProposeAction"
|
|
39
|
-
"meaning" => "something that happens to a thing, and the event it announces, in the past tense"
|
|
40
|
-
|
|
39
|
+
"SME::Interview.ProposeAction" => {
|
|
40
|
+
"meaning" => "something that happens to a thing, and the event it announces, in the past tense; " \
|
|
41
|
+
"creates is true for the action that brings the thing into being; takes lists, comma " \
|
|
42
|
+
"separated, the fields it needs to be told; by says who does it",
|
|
43
|
+
"arguments" => %w[name thing event creates takes by]
|
|
41
44
|
},
|
|
42
|
-
"SME::Interview.
|
|
45
|
+
"SME::Interview.ProposeField" => {
|
|
46
|
+
"meaning" => "something a thing has; values lists, comma separated, what it may be when the " \
|
|
47
|
+
"expert gave a closed set, and is left out for free text",
|
|
48
|
+
"arguments" => %w[thing name values]
|
|
49
|
+
},
|
|
50
|
+
"SME::Interview.ProposeTransition" => {
|
|
51
|
+
"meaning" => "the state an action leaves a thing in, and the state it had to be in before; the " \
|
|
52
|
+
"action that creates the thing has no from",
|
|
53
|
+
"arguments" => %w[thing action to from]
|
|
54
|
+
},
|
|
55
|
+
"SME::Interview.ProposeRule" => {
|
|
43
56
|
"meaning" => "a rule the expert stated, in their own words",
|
|
44
57
|
"arguments" => %w[statement]
|
|
45
58
|
}
|
|
46
59
|
}.freeze
|
|
47
60
|
|
|
48
|
-
# What each finding verb needs,
|
|
61
|
+
# What each finding verb needs, the fields it may leave out, and the SME commands that
|
|
62
|
+
# carry it out and decide it.
|
|
49
63
|
KINDS = {
|
|
50
|
-
"SME::Interview.ProposeThing"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
"SME::Interview.ProposeThing" => { kind: "thing", fields: %w[name identifier], optional: [],
|
|
65
|
+
propose: :propose_thing!, entity: "ThingFinding",
|
|
66
|
+
accept: "AcceptThing", reject: "RejectThing" },
|
|
67
|
+
"SME::Interview.ProposeAction" => { kind: "action", fields: %w[name thing event creates takes by],
|
|
68
|
+
optional: %w[creates takes by], propose: :propose_action!,
|
|
69
|
+
entity: "ActionFinding", accept: "AcceptAction", reject: "RejectAction" },
|
|
70
|
+
"SME::Interview.ProposeRule" => { kind: "rule", fields: %w[statement], optional: [],
|
|
71
|
+
propose: :propose_rule!, entity: "RuleFinding",
|
|
72
|
+
accept: "AcceptRule", reject: "RejectRule" },
|
|
73
|
+
"SME::Interview.ProposeField" => { kind: "field", fields: %w[thing name values], optional: %w[values],
|
|
74
|
+
propose: :propose_field!, entity: "FieldFinding",
|
|
75
|
+
accept: "AcceptField", reject: "RejectField" },
|
|
76
|
+
"SME::Interview.ProposeTransition" => { kind: "transition", fields: %w[thing action to from],
|
|
77
|
+
optional: %w[from], propose: :propose_transition!,
|
|
78
|
+
entity: "TransitionFinding", accept: "AcceptTransition",
|
|
79
|
+
reject: "RejectTransition" }
|
|
57
80
|
}.freeze
|
|
58
81
|
|
|
59
82
|
TRUE_WORDS = %w[true yes y 1].freeze
|
|
@@ -153,7 +176,7 @@ module Hecks
|
|
|
153
176
|
return say("Ignored a proposal for #{proposal.verb}: not a finding I know.") unless config
|
|
154
177
|
|
|
155
178
|
fields = fields_of(proposal, config)
|
|
156
|
-
missing = config[:fields].reject { |f| f
|
|
179
|
+
missing = config[:fields].reject { |f| config[:optional].include?(f) || fields[f].to_s.strip != "" }
|
|
157
180
|
return say("Ignored a #{config[:kind]} proposal with no #{missing.join(', ')}.") unless missing.empty?
|
|
158
181
|
|
|
159
182
|
number = propose(config, fields) or return
|
|
@@ -166,7 +189,7 @@ module Hecks
|
|
|
166
189
|
rows = proposal.arguments.to_h { |row| [row[:name].to_s, row[:value]] }
|
|
167
190
|
fields = rows.slice(*config[:fields])
|
|
168
191
|
fields["creates"] = TRUE_WORDS.include?(fields["creates"].to_s.downcase) if fields.key?("creates")
|
|
169
|
-
fields
|
|
192
|
+
fields.reject { |key, value| key != "creates" && config[:optional].include?(key) && value.to_s.strip.empty? }
|
|
170
193
|
end
|
|
171
194
|
|
|
172
195
|
def propose(config, fields)
|
|
@@ -192,19 +215,27 @@ module Hecks
|
|
|
192
215
|
case config[:kind]
|
|
193
216
|
when "thing" then "thing: #{fields['name']}, identified by #{fields['identifier']}"
|
|
194
217
|
when "action" then "action: #{fields['name']} on #{fields['thing']}, announcing #{fields['event']}" \
|
|
195
|
-
"#{', creating it' if fields['creates']}"
|
|
218
|
+
"#{', creating it' if fields['creates']}#{action_extras(fields)}"
|
|
219
|
+
when "field" then "field: #{fields['name']} of #{fields['thing']}#{", one of #{fields['values']}" if fields['values']}"
|
|
220
|
+
when "transition" then "transition: #{fields['action']} leaves #{fields['thing']} #{fields['to']}" \
|
|
221
|
+
"#{", from #{fields['from']}" if fields['from']}"
|
|
196
222
|
else "rule: #{fields['statement']}"
|
|
197
223
|
end
|
|
198
224
|
end
|
|
199
225
|
|
|
226
|
+
# @api private
|
|
227
|
+
def action_extras(fields)
|
|
228
|
+
"#{", taking #{fields['takes']}" if fields['takes']}#{", by #{fields['by']}" if fields['by']}"
|
|
229
|
+
end
|
|
230
|
+
|
|
200
231
|
# The plain prompt: findings typed by the developer, accepted as they are entered.
|
|
201
232
|
def manual_findings
|
|
202
233
|
loop do
|
|
203
|
-
kind = ask("Add a finding from that answer? thing, action or
|
|
234
|
+
kind = ask("Add a finding from that answer? thing, action, rule, field or transition (enter to skip): ")
|
|
204
235
|
kind = kind.to_s.strip.downcase
|
|
205
236
|
break if kind.empty?
|
|
206
237
|
|
|
207
|
-
typed = typed_finding(kind) or next say(" I only know thing, action and
|
|
238
|
+
typed = typed_finding(kind) or next say(" I only know thing, action, rule, field and transition.")
|
|
208
239
|
config = KINDS["SME::Interview.Propose#{kind.capitalize}"]
|
|
209
240
|
number = propose(config, typed) or next
|
|
210
241
|
decide(config, number, true)
|
|
@@ -216,6 +247,11 @@ module Hecks
|
|
|
216
247
|
when "thing" then named("Name" => "name", "Identified by" => "identifier")
|
|
217
248
|
when "action" then typed_action
|
|
218
249
|
when "rule" then named("The rule, in the expert's words" => "statement")
|
|
250
|
+
when "field" then named({ "On which thing" => "thing", "Name" => "name",
|
|
251
|
+
"Values it may take, comma separated (enter for any)" => "values" }, %w[values])
|
|
252
|
+
when "transition" then named({ "On which thing" => "thing", "Caused by which action" => "action",
|
|
253
|
+
"Leaves it in the state" => "to",
|
|
254
|
+
"From the state (enter if it starts there)" => "from" }, %w[from])
|
|
219
255
|
end
|
|
220
256
|
end
|
|
221
257
|
|
|
@@ -224,9 +260,11 @@ module Hecks
|
|
|
224
260
|
fields.merge("creates" => TRUE_WORDS.include?(ask(" Does it create the thing? [y/N] ").to_s.strip.downcase))
|
|
225
261
|
end
|
|
226
262
|
|
|
227
|
-
def named(prompts)
|
|
263
|
+
def named(prompts, optional = [])
|
|
228
264
|
fields = prompts.to_h { |label, key| [key, ask(" #{label}: ").to_s.strip] }
|
|
229
|
-
fields.
|
|
265
|
+
return nil unless fields.all? { |key, value| optional.include?(key) || !value.empty? }
|
|
266
|
+
|
|
267
|
+
fields.reject { |key, value| optional.include?(key) && value.empty? }
|
|
230
268
|
end
|
|
231
269
|
|
|
232
270
|
def suggest_enough
|
|
@@ -271,9 +309,11 @@ module Hecks
|
|
|
271
309
|
end
|
|
272
310
|
|
|
273
311
|
def accepted_state(plain)
|
|
274
|
-
{ things:
|
|
275
|
-
actions:
|
|
276
|
-
|
|
312
|
+
{ things: InterviewDraft.accepted(plain, :things).map { |f| f.slice(:name, :identifier) },
|
|
313
|
+
actions: InterviewDraft.accepted(plain, :actions).map { |f| f.slice(:name, :thing, :event, :creates, :takes, :by) },
|
|
314
|
+
fields: InterviewDraft.accepted(plain, :fields).map { |f| f.slice(:thing, :name, :values) },
|
|
315
|
+
transitions: InterviewDraft.accepted(plain, :transitions).map { |f| f.slice(:thing, :action, :to, :from) },
|
|
316
|
+
rules: InterviewDraft.accepted(plain, :rules).map { |f| f.slice(:statement) } }
|
|
277
317
|
end
|
|
278
318
|
|
|
279
319
|
# What the interview has not yet pinned down, in words a model can act on.
|
|
@@ -283,7 +323,18 @@ module Hecks
|
|
|
283
323
|
gaps = things.reject { |t| actions.any? { |a| a[:thing] == t } }.map { |t| "nothing is yet said to happen to #{t}" }
|
|
284
324
|
gaps += things.reject { |t| actions.any? { |a| a[:thing] == t && a[:creates] } }.map { |t| "nothing yet creates #{t}" }
|
|
285
325
|
unplaced = actions.reject { |a| things.include?(a[:thing].to_s) }
|
|
286
|
-
gaps + unplaced.map { |a| "#{a[:name]} names #{a[:thing]}, not yet a thing" }
|
|
326
|
+
gaps + unplaced.map { |a| "#{a[:name]} names #{a[:thing]}, not yet a thing" } + shape_gaps(plain, things, actions)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# What a thing is not yet said to have, and how it moves from one state to the next.
|
|
330
|
+
def shape_gaps(plain, things, actions)
|
|
331
|
+
fields = InterviewDraft.accepted(plain, :fields)
|
|
332
|
+
steps = InterviewDraft.accepted(plain, :transitions)
|
|
333
|
+
bare = things.reject { |t| fields.any? { |f| f[:thing] == t } }
|
|
334
|
+
gaps = bare.map { |t| "nothing is yet said #{t} has, beyond its identifier" }
|
|
335
|
+
changing = things.select { |t| actions.count { |a| a[:thing] == t && !a[:creates] } > 1 }
|
|
336
|
+
unmoved = changing.reject { |t| steps.any? { |s| s[:thing] == t } }
|
|
337
|
+
gaps + unmoved.map { |t| "nothing yet says how #{t} moves from one state to the next" }
|
|
287
338
|
end
|
|
288
339
|
|
|
289
340
|
def reason(error) = error.message.sub(/\A[A-Z]\w* refused\s+[—-]\s+/, "").strip
|
|
@@ -0,0 +1,198 @@
|
|
|
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
|
+
# One action for each name: an expert who refines an answer gets the same action accepted more
|
|
125
|
+
# than once, and a command can be declared only once. The first one's event and place in the
|
|
126
|
+
# interview stay; what each takes is joined, who does it is joined, and it creates if any did.
|
|
127
|
+
#
|
|
128
|
+
# @param actions [Array<Hash>] a thing's accepted actions
|
|
129
|
+
# @return [Array<Hash>] the actions, with each name once and in the order first accepted
|
|
130
|
+
def merged_actions(actions)
|
|
131
|
+
actions.group_by { |action| InterviewDraft.word(action[:name]) }.values.map do |group|
|
|
132
|
+
group.first.merge(creates: group.any? { |a| a[:creates] }, takes: joined(group, :takes, ", "),
|
|
133
|
+
by: joined(group, :by, " or "))
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @api private
|
|
138
|
+
def joined(group, key, separator)
|
|
139
|
+
parts = group.flat_map { |a| a[key].to_s.split(separator == ", " ? /\s*,\s*/ : /\s+or\s+/) }
|
|
140
|
+
parts.map(&:strip).reject(&:empty?).uniq.join(separator)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# @api private
|
|
144
|
+
def field_row(finding, taken, identifier, lifecycle)
|
|
145
|
+
field = Naming.snake(InterviewDraft.word(finding[:name]))
|
|
146
|
+
return if field == identifier || (lifecycle && field == "status")
|
|
147
|
+
|
|
148
|
+
type = Naming.pascal(field)
|
|
149
|
+
type = "#{type}Value" while taken.include?(type)
|
|
150
|
+
taken << type
|
|
151
|
+
{ name: field, type: type, values: finding[:values].to_s.split(/\s*,\s*|\s+or\s+/).map(&:strip).reject(&:empty?).uniq }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# One step for each action and state it leads to: the states it starts from are joined, and a
|
|
155
|
+
# step with no state to start from means it may be taken from any.
|
|
156
|
+
# @api private
|
|
157
|
+
def merged(steps)
|
|
158
|
+
steps.group_by { |step| [InterviewDraft.word(step[:action]), state_word(step[:to])] }.values.map do |group|
|
|
159
|
+
froms = group.map { |step| step[:from].to_s.strip }
|
|
160
|
+
group.first.merge(from: froms.any?(&:empty?) ? "" : froms.join(","))
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The state a thing starts in: where its creating action's step leads, else the first state a
|
|
165
|
+
# change is said to start from.
|
|
166
|
+
# @api private
|
|
167
|
+
def beginning(starts, placed)
|
|
168
|
+
from = placed.map { |step| step[:from].to_s.split(",").first }.find { |text| !text.to_s.strip.empty? }
|
|
169
|
+
state_word(starts.first&.fetch(:to) || from)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# @api private
|
|
173
|
+
def transition_line(step)
|
|
174
|
+
" transition #{InterviewDraft.word(step[:action]).inspect} => #{state_word(step[:to]).inspect}" \
|
|
175
|
+
"#{from_clause(step)}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @api private
|
|
179
|
+
def loose_line(step)
|
|
180
|
+
" # UNPLACED transition: #{InterviewDraft.word(step[:action])} to #{state_word(step[:to])}; " \
|
|
181
|
+
"no accepted action has that name."
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The states a step may start from: none, one, or several.
|
|
185
|
+
# @api private
|
|
186
|
+
def from_clause(step)
|
|
187
|
+
states = step[:from].to_s.split(/\s*,\s*/).map { |text| state_word(text) }.reject(&:empty?)
|
|
188
|
+
return "" if states.empty?
|
|
189
|
+
|
|
190
|
+
states.one? ? ", from: #{states.first.inspect}" : ", from: %w[#{states.join(' ')}]"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# A state as a lowercase word: `Not Yet Triaged` is `not_yet_triaged`.
|
|
194
|
+
# @api private
|
|
195
|
+
def state_word(text) = text.to_s.strip.downcase.gsub(/[^a-z0-9]+/, "_").gsub(/\A_|_\z/, "")
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
data/lib/hecks/corpus.rb
CHANGED
|
@@ -241,8 +241,7 @@ module Hecks
|
|
|
241
241
|
"of rust/src/generated, and there is no directory to fuzz")
|
|
242
242
|
}.freeze
|
|
243
243
|
|
|
244
|
-
# Every place a Rust-facing domain's own `attaches`
|
|
245
|
-
# `uses_embryonaut_bluebook`) attachment could be declared.
|
|
244
|
+
# Every place a Rust-facing domain's own `attaches` attachment could be declared.
|
|
246
245
|
#
|
|
247
246
|
# @param root [String] repository root to search under
|
|
248
247
|
# @return [String] every reachable hecksagon file's own text, joined by newlines
|
|
@@ -310,11 +309,10 @@ module Hecks
|
|
|
310
309
|
|
|
311
310
|
# The stamp hecks project_rust writes into metadata.rs, e.g.
|
|
312
311
|
# `examples/pizzas` or `the self-hosted language (...)`, with any
|
|
313
|
-
# ` (attaches "X")`/` (attaches "x", from: :vendor)` suffix stripped
|
|
314
|
-
# `uses_framework` / `uses_embryonaut_bluebook` spellings too).
|
|
312
|
+
# ` (attaches "X")`/` (attaches "x", from: :vendor)` suffix stripped.
|
|
315
313
|
SOURCE_STAMP = /
|
|
316
314
|
GENERATED\ by\ hecks\ project_rust\ —\ (.+?)
|
|
317
|
-
(?:\ \(
|
|
315
|
+
(?:\ \(attaches\ "\w+"(?:,\ from:\ :vendor)?\))?
|
|
318
316
|
's\ own\ canonical\ IR,
|
|
319
317
|
/x
|
|
320
318
|
|
|
@@ -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
|
@@ -1,23 +1,6 @@
|
|
|
1
1
|
module Hecks
|
|
2
2
|
# The class-free public surface, installed per boot by `Runtime::Loader.bind_runtime`.
|
|
3
3
|
module Doors
|
|
4
|
-
# The removal release of the `Facade` and `install_facade:` spellings.
|
|
5
|
-
REMOVAL = "3.2.0".freeze
|
|
6
|
-
|
|
7
|
-
module_function
|
|
8
|
-
|
|
9
|
-
# Resolves the boot switch from the current keyword and its deprecated spelling.
|
|
10
|
-
#
|
|
11
|
-
# @param install_doors [Boolean] the `install_doors:` keyword
|
|
12
|
-
# @param install_facade [Boolean, nil] the deprecated `install_facade:` keyword; nil when
|
|
13
|
-
# the caller did not pass it
|
|
14
|
-
# @return [Boolean] whether to install the Ruby door constants
|
|
15
|
-
def install?(install_doors, install_facade = nil)
|
|
16
|
-
return install_doors if install_facade.nil?
|
|
17
|
-
|
|
18
|
-
warn "[hecks] `install_facade:` is deprecated and is removed in #{REMOVAL}; use `install_doors:`"
|
|
19
|
-
install_facade
|
|
20
|
-
end
|
|
21
4
|
end
|
|
22
5
|
|
|
23
6
|
# Installs facade constants at top level, replacing only what it installed itself.
|
|
@@ -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
|