hecks 3.2.1 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +21 -23
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/interview_draft.rb +88 -33
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +198 -0
  12. data/lib/hecks/corpus.rb +3 -5
  13. data/lib/hecks/doors/cli_runner.rb +14 -1
  14. data/lib/hecks/doors/launcher_options.rb +13 -3
  15. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  16. data/lib/hecks/doors.rb +0 -17
  17. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  18. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  19. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  20. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  21. data/lib/hecks/fuzzing/isolated_boot.rb +1 -4
  22. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  23. data/lib/hecks/fuzzing/target_capabilities.rb +1 -1
  24. data/lib/hecks/gate/stages.yml +113 -8
  25. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  26. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  27. data/lib/hecks/hecks/adapters/git.rb +59 -0
  28. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  29. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  30. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  31. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  32. data/lib/hecks/hecks/codebase.behaviors +6 -0
  33. data/lib/hecks/hecks/codebase.bluebook +21 -0
  34. data/lib/hecks/hecks/custodian.behaviors +6 -0
  35. data/lib/hecks/hecks/custodian.bluebook +217 -0
  36. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  37. data/lib/hecks/hecks/hecks.world +8 -3
  38. data/lib/hecks/hecks/site.world +5 -2
  39. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  40. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  41. data/lib/hecks/language/hecksagon/hecksagon.bluebook +0 -5
  42. data/lib/hecks/language/oidc.json +5 -0
  43. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  44. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  45. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  47. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  48. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  49. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  50. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  51. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  52. data/lib/hecks/projections/deploy/box.rb +15 -2
  53. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  54. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  55. data/lib/hecks/projections/model/deviations.rb +1 -1
  56. data/lib/hecks/projections/site/live_edge.rb +162 -0
  57. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  58. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  59. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  60. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  61. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  62. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  63. data/lib/hecks/runtime/interpreting.rb +3 -22
  64. data/lib/hecks/runtime/loader.rb +6 -7
  65. data/lib/hecks/runtime/needs.rb +48 -0
  66. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  67. data/lib/hecks/runtime/registry/verification.rb +3 -3
  68. data/lib/hecks/runtime.rb +6 -11
  69. data/lib/hecks/rust_build/host.rb +147 -0
  70. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  71. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  72. data/lib/hecks/rust_build/project_rust.rb +3 -10
  73. data/lib/hecks/rust_build.rb +1 -0
  74. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  75. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  76. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  77. data/lib/hecks/tools/gate.rb +1 -1
  78. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  79. data/lib/hecks/tools/tools_doc.rb +204 -0
  80. data/lib/hecks/tools.rb +1 -0
  81. data/lib/hecks/version.rb +1 -1
  82. data/lib/hecks/vocabulary.rb +151 -0
  83. data/lib/hecks.rb +6 -12
  84. data/qa/settings.yml +1 -2
  85. data/rust/build/src/lineage_pass.rs +30 -15
  86. data/rust/build/src/main.rs +4 -2
  87. data/rust/build/src/pipeline.rs +8 -19
  88. data/rust/codegen/src/bridging.rs +7 -7
  89. data/rust/codegen/src/json.rs +214 -23
  90. data/rust/codegen/src/literal.rs +6 -3
  91. data/rust/codegen/src/main.rs +20 -9
  92. data/rust/codegen/src/naming.rs +4 -2
  93. data/rust/codegen/src/optional_pass.rs +226 -0
  94. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  95. data/rust/host/HECKS_RELEASE +1 -1
  96. data/rust/host/src/api.rs +1 -1
  97. data/rust/host/src/dispatch.rs +5 -0
  98. data/rust/host/src/needs.rs +99 -2
  99. data/rust/host/src/resend.rs +4 -4
  100. data/rust/host/src/ui_schema.rs +1 -1
  101. data/rust/parser/src/emit.rs +12 -0
  102. data/rust/parser/src/ir.rs +2 -0
  103. data/rust/parser/src/keywords.rs +2 -4
  104. data/rust/parser/src/lex.rs +1 -1
  105. data/rust/parser/src/main.rs +1 -0
  106. data/rust/parser/src/parse/command.rs +2 -44
  107. data/rust/parser/src/parse/hecksagon.rs +15 -24
  108. data/rust/parser/src/parse/mod.rs +1 -0
  109. data/rust/parser/src/parse/needs.rs +60 -0
  110. data/rust/parser/src/parse/query.rs +3 -0
  111. data/rust/src/kernel/cli.rs +16 -6
  112. data/rust/src/kernel/needs.rs +104 -9
  113. metadata +22 -9
  114. data/lib/hecks/facade.rb +0 -31
  115. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  116. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  117. data/lib/hecks/three_zero/forms.yml +0 -127
  118. data/lib/hecks/three_zero.rb +0 -12
  119. data/rust/build/src/json.rs +0 -421
  120. data/rust/build/src/optional_pass.rs +0 -150
@@ -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 happens to it, and what must " \
28
- "never happen. Use the expert's own words. Do not assume what kind of business it is, or an " \
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
- "arguments" => %w[name thing event creates]
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.ProposeRule" => {
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, and the SME commands that carry it out and decide it.
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" => { kind: "thing", fields: %w[name identifier], propose: :propose_thing!,
51
- entity: "ThingFinding", accept: "AcceptThing", reject: "RejectThing" },
52
- "SME::Interview.ProposeAction" => { kind: "action", fields: %w[name thing event creates],
53
- propose: :propose_action!, entity: "ActionFinding",
54
- accept: "AcceptAction", reject: "RejectAction" },
55
- "SME::Interview.ProposeRule" => { kind: "rule", fields: %w[statement], propose: :propose_rule!,
56
- entity: "RuleFinding", accept: "AcceptRule", reject: "RejectRule" }
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 == "creates" || fields[f].to_s.strip != "" }
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 rule (enter to skip): ")
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 rule.")
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.values.all? { |v| !v.empty? } ? fields : nil
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: InterviewDraft.accepted(plain, :things).map { |f| f.slice(:name, :identifier) },
275
- actions: InterviewDraft.accepted(plain, :actions).map { |f| f.slice(:name, :thing, :event, :creates) },
276
- rules: InterviewDraft.accepted(plain, :rules).map { |f| f.slice(:statement) } }
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` (or the deprecated `uses_framework` /
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 (the deprecated
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
- (?:\ \((?:attaches|uses_framework|uses_embryonaut_bluebook)\ "\w+"(?:,\ from:\ :vendor)?\))?
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 += ["#{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
@@ -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