hecks 3.2.1 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/assembly/contracts.rb +2 -1
  3. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +1 -0
  4. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -39
  5. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +1 -1
  6. data/lib/hecks/bluebook/dsl/need_word.rb +50 -0
  7. data/lib/hecks/bluebook/dsl/query_builder.rb +6 -1
  8. data/lib/hecks/bluebook/query.rb +8 -5
  9. data/lib/hecks/cli/interview_draft.rb +86 -31
  10. data/lib/hecks/cli/interview_session.rb +77 -26
  11. data/lib/hecks/cli/interview_shape.rb +179 -0
  12. data/lib/hecks/doors/cli_runner.rb +14 -1
  13. data/lib/hecks/doors/launcher_options.rb +13 -3
  14. data/lib/hecks/doors/mcp_door_scope.rb +2 -1
  15. data/lib/hecks/doors.rb +1 -1
  16. data/lib/hecks/embryonaut_bluebook/manifest.rb +117 -0
  17. data/lib/hecks/embryonaut_bluebook/registry.rb +209 -0
  18. data/lib/hecks/embryonaut_bluebook/vendor.rb +1 -1
  19. data/lib/hecks/embryonaut_bluebook.rb +2 -0
  20. data/lib/hecks/fuzzing/qa_settings.rb +15 -34
  21. data/lib/hecks/gate/stages.yml +113 -8
  22. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +10 -1
  23. data/lib/hecks/hecks/adapters/first_admin.rb +108 -0
  24. data/lib/hecks/hecks/adapters/git.rb +59 -0
  25. data/lib/hecks/hecks/adapters/in_process_access.rb +25 -0
  26. data/lib/hecks/hecks/adapters/in_process_boot.rb +2 -0
  27. data/lib/hecks/hecks/adapters/rust_toolchain.rb +18 -3
  28. data/lib/hecks/hecks/adapters/site_toolchain.rb +67 -0
  29. data/lib/hecks/hecks/codebase.behaviors +6 -0
  30. data/lib/hecks/hecks/codebase.bluebook +21 -0
  31. data/lib/hecks/hecks/custodian.behaviors +6 -0
  32. data/lib/hecks/hecks/custodian.bluebook +217 -0
  33. data/lib/hecks/hecks/hecks.hecksagon +24 -0
  34. data/lib/hecks/hecks/hecks.world +8 -3
  35. data/lib/hecks/hecks/site.world +5 -2
  36. data/lib/hecks/language/bluebook/query.bluebook +25 -0
  37. data/lib/hecks/language/bluebook/vocabulary.bluebook +213 -0
  38. data/lib/hecks/language/hecksagon/hecksagon.bluebook +1 -1
  39. data/lib/hecks/language/oidc.json +5 -0
  40. data/lib/hecks/projections/deploy/box/hosting.rb +170 -0
  41. data/lib/hecks/projections/deploy/box/hosting_settings.rb +123 -0
  42. data/lib/hecks/projections/deploy/box/settings.rb +17 -4
  43. data/lib/hecks/projections/deploy/box/templates/deploy-service.services-roll.part +33 -0
  44. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +79 -0
  45. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef-roll.part +77 -0
  46. data/lib/hecks/projections/deploy/box/templates/deploy-service.taskdef.part +12 -0
  47. data/lib/hecks/projections/deploy/box/templates/hosting.mk.tmpl +24 -0
  48. data/lib/hecks/projections/deploy/box/templates/smoke-after-deploy.sh.tmpl +271 -0
  49. data/lib/hecks/projections/deploy/box.rb +15 -2
  50. data/lib/hecks/projections/deploy/fargate.rb +3 -3
  51. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  52. data/lib/hecks/projections/model/deviations.rb +1 -1
  53. data/lib/hecks/projections/site/live_edge.rb +162 -0
  54. data/lib/hecks/quality_control/adapters/git_pr.rb +0 -14
  55. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +1 -1
  56. data/lib/hecks/quality_control/cli/qa_open_pr.rb +32 -16
  57. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +2 -2
  58. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +1 -2
  59. data/lib/hecks/quality_control/quality_control.bluebook +76 -124
  60. data/lib/hecks/runtime/interpreting.rb +3 -22
  61. data/lib/hecks/runtime/needs.rb +48 -0
  62. data/lib/hecks/runtime/query_interpreter.rb +3 -0
  63. data/lib/hecks/runtime/registry/verification.rb +3 -3
  64. data/lib/hecks/rust_build/host.rb +147 -0
  65. data/lib/hecks/rust_build/kernel_input.rb +40 -18
  66. data/lib/hecks/rust_build/project_rust/codegen_run.rb +0 -18
  67. data/lib/hecks/rust_build/project_rust.rb +3 -10
  68. data/lib/hecks/rust_build.rb +1 -0
  69. data/lib/hecks/site/bluebook/site.bluebook +53 -1
  70. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  71. data/lib/hecks/sme/bluebook/sme.bluebook +128 -1
  72. data/lib/hecks/tools/gate.rb +1 -1
  73. data/lib/hecks/tools/rust_comment_style.rb +1 -3
  74. data/lib/hecks/tools/tools_doc.rb +204 -0
  75. data/lib/hecks/tools.rb +1 -0
  76. data/lib/hecks/version.rb +1 -1
  77. data/lib/hecks/vocabulary.rb +151 -0
  78. data/qa/settings.yml +1 -2
  79. data/rust/build/src/lineage_pass.rs +30 -15
  80. data/rust/build/src/main.rs +4 -2
  81. data/rust/build/src/pipeline.rs +8 -19
  82. data/rust/codegen/src/bridging.rs +7 -7
  83. data/rust/codegen/src/json.rs +214 -23
  84. data/rust/codegen/src/literal.rs +6 -3
  85. data/rust/codegen/src/main.rs +20 -9
  86. data/rust/codegen/src/naming.rs +4 -2
  87. data/rust/codegen/src/optional_pass.rs +226 -0
  88. data/rust/{build → codegen}/src/sidecars.rs +9 -5
  89. data/rust/host/HECKS_RELEASE +1 -1
  90. data/rust/host/src/api.rs +1 -1
  91. data/rust/host/src/dispatch.rs +5 -0
  92. data/rust/host/src/needs.rs +99 -2
  93. data/rust/host/src/resend.rs +4 -4
  94. data/rust/host/src/ui_schema.rs +1 -1
  95. data/rust/parser/src/emit.rs +12 -0
  96. data/rust/parser/src/ir.rs +2 -0
  97. data/rust/parser/src/keywords.rs +2 -0
  98. data/rust/parser/src/main.rs +1 -0
  99. data/rust/parser/src/parse/command.rs +2 -44
  100. data/rust/parser/src/parse/mod.rs +1 -0
  101. data/rust/parser/src/parse/needs.rs +60 -0
  102. data/rust/parser/src/parse/query.rs +3 -0
  103. data/rust/src/kernel/cli.rs +16 -6
  104. data/rust/src/kernel/needs.rs +104 -9
  105. metadata +22 -8
  106. data/lib/hecks/rust_build/append_optionals.rb +0 -79
  107. data/lib/hecks/rust_build/rust_literal.rb +0 -33
  108. data/lib/hecks/three_zero/forms.yml +0 -127
  109. data/lib/hecks/three_zero.rb +0 -12
  110. data/rust/build/src/json.rs +0 -421
  111. data/rust/build/src/optional_pass.rs +0 -150
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9b963870a34a1483bc0156042a2967a72f8fc1b8e907ba5d8cac5a5382d4999d
4
- data.tar.gz: 26697af02778cda282a44c1ca099e36a9866bf2d204ab5abb23df8ac2a86283a
3
+ metadata.gz: 288ec2962d96e6d9772c6fd6155d15461f69259cd7f69e107e308a2710fcb668
4
+ data.tar.gz: e71d1ec056df32604dc6ef8de8cab5069ae998faa74d89acd377d79b217820ed
5
5
  SHA512:
6
- metadata.gz: a81b0ffb4b0e04024518169e3c6dd51a3f55b5da39659a852c2a0c9528453fe0eb9facf07d7b18bd35d7924c5a08331b2f1ad82a7e5c4c43c61fae55490d46ed
7
- data.tar.gz: f6780cb0c58069a5049081c7a319424787c9fea1fd94a5085153d7b99533c0e1974c23dfeb3515b66f3b2d8d3c7ca83a08dcd5f024df02da2b2fc9609c3ba6b7
6
+ metadata.gz: a2586bb29d327f576976c7573236abfde1f60deba2eec7529b13ea34fc1b4ea917abe996fd44076cb76690835fb7089f4d18f38d0693d0aa14da2a106334a1a7
7
+ data.tar.gz: 1101c11307086a6df1833e6cd7b4fb5fdaa5b0d265b0a4569fa9d4df5d85f27039975f46135dd90619ad69e50d553172eb82d203633177595503f9bbfb67998a
@@ -101,6 +101,7 @@ module Hecks
101
101
  order_by: [:order_by, :order_by],
102
102
  limit: [:limit, :limit],
103
103
  returns: [:returns, :plain],
104
+ needs: [:needs, [:each, :need]],
104
105
  # Held by the language as an open map, so every one of these reads the
105
106
  # same way and a ninth option needs no new field on either side.
106
107
  offset: [:offset, [:option, :offset]],
@@ -111,7 +112,7 @@ module Hecks
111
112
  },
112
113
  rows: { wheres: :where_rows, options: :option_rows },
113
114
  reads: { attributes: [:each, :shape_field], wheres: [:each, :where_clause],
114
- order_by: [:call, :order_by], limit: [:call, :limit] },
115
+ needs: [:each, :need], order_by: [:call, :order_by], limit: [:call, :limit] },
115
116
  derived: {
116
117
  position: :walk,
117
118
  order_field: [:folded, :order_by, :field],
@@ -75,6 +75,7 @@ module Hecks
75
75
  ["Query", "where"] => :where_impl,
76
76
  ["Query", "order_by"] => :order_by_impl,
77
77
  ["Query", "authorize"] => :authorize_impl,
78
+ ["Query", "needs"] => :needs_impl,
78
79
  ["ValueObject", "attribute"] => :attribute_impl,
79
80
  ["ValueObject", "one_of"] => :one_of_impl,
80
81
  ["ValueObject", "invariant"] => :invariant_impl,
@@ -1,4 +1,5 @@
1
1
  require_relative "word_gate"
2
+ require_relative "need_word"
2
3
  module Hecks
3
4
  module Bluebook
4
5
  module DSL
@@ -10,6 +11,7 @@ module Hecks
10
11
  include AttributeCollector
11
12
  include RuleReference
12
13
  include WordGate
14
+ include NeedWord
13
15
 
14
16
  # Sentinel for "this keyword was never passed" — distinct from Ruby's own
15
17
  # nil/false, so `to: false` doesn't get treated as absent.
@@ -67,31 +69,6 @@ module Hecks
67
69
  # @return [String] the description as stored
68
70
  def goal(value) = @goal = value
69
71
 
70
- # The outside facts a command may declare it needs, each the name of the attribute the
71
- # runtime fills when the caller leaves it out. `now` is the clock port's answer, in epoch
72
- # seconds.
73
- NEEDABLE_FACTS = %i[now].freeze
74
-
75
- # Declares an outside fact the runtime supplies before any given runs (ADR 0081):
76
- # `needs :now` fills the command's own `now` attribute from the clock port when the caller
77
- # names none.
78
- #
79
- # @param fact [Symbol] one of `NEEDABLE_FACTS`
80
- # @return [Array<Symbol>] the facts declared so far
81
- # @raise [Bluebook::DSL::Malformed] if the fact is not one the runtime can supply, or is
82
- # declared twice
83
- def needs_impl(fact)
84
- fact = fact.to_sym
85
- unless NEEDABLE_FACTS.include?(fact)
86
- raise Malformed,
87
- "#{@name} needs :#{fact}, which the runtime cannot supply — it supplies " \
88
- "#{NEEDABLE_FACTS.map { |known| ":#{known}" }.join(', ')}"
89
- end
90
- raise Malformed, "#{@name} declares needs :#{fact} twice" if @needs.include?(fact)
91
-
92
- @needs << fact
93
- end
94
-
95
72
  # Names where a concept adopted from a canonical source came from.
96
73
  #
97
74
  # @param from [Object] the canonical source, captured exactly as written
@@ -344,20 +321,6 @@ module Hecks
344
321
  )
345
322
  end
346
323
 
347
- # A fact is filled into the argument of the same name, so the command declares one.
348
- #
349
- # @raise [Bluebook::DSL::Malformed] if a needed fact has no attribute to fill
350
- def refuse_undeclared_needs!
351
- declared = attributes.map { |attribute| attribute.name.to_s }
352
- missing = @needs.reject { |fact| declared.include?(fact.to_s) }
353
- return if missing.empty?
354
-
355
- raise Malformed,
356
- "#{@name} needs :#{missing.first} but declares no attribute :#{missing.first} " \
357
- "for the runtime to fill — add `attribute :#{missing.first}, <type>`"
358
- end
359
- private :refuse_undeclared_needs!
360
-
361
324
  # Evaluates a `command` block against a fresh builder and returns what it built.
362
325
  #
363
326
  # @param name [String] the command's name
@@ -59,7 +59,7 @@ module Hecks
59
59
 
60
60
  # The release that drops the deprecated spellings of `attaches`; independent of
61
61
  # `Hecks::Doors::REMOVAL`, which covers the older `Facade` aliases.
62
- REMOVAL = "3.3.0".freeze
62
+ REMOVAL = "3.4.0".freeze
63
63
 
64
64
  attr_reader :binds, :subscriptions, :attachments
65
65
 
@@ -0,0 +1,50 @@
1
+ module Hecks
2
+ module Bluebook
3
+ module DSL
4
+ # The `needs` word, shared by the builders of the two declarations that may use it: a
5
+ # command and a query (ADR 0081). The including builder holds `@name` and `@needs` (an
6
+ # empty list) and answers `attributes`.
7
+ module NeedWord
8
+ # The outside facts a declaration may need, each the name of the attribute the runtime
9
+ # fills when the caller leaves it out. `now` is the clock port's reading in epoch seconds;
10
+ # `today` is the day it falls in, whole days since the epoch in UTC.
11
+ NEEDABLE_FACTS = %i[now today].freeze
12
+
13
+ # Declares an outside fact the runtime supplies before any given or filter runs:
14
+ # `needs :now` fills the declaration's own `now` attribute from the clock port when the
15
+ # caller names none.
16
+ #
17
+ # @param fact [Symbol] one of `NEEDABLE_FACTS`
18
+ # @return [Array<Symbol>] the facts declared so far
19
+ # @raise [Bluebook::DSL::Malformed] if the fact is not one the runtime can supply, or is
20
+ # declared twice
21
+ def needs_impl(fact)
22
+ fact = fact.to_sym
23
+ unless NEEDABLE_FACTS.include?(fact)
24
+ raise Malformed,
25
+ "#{@name} needs :#{fact}, which the runtime cannot supply — it supplies " \
26
+ "#{NEEDABLE_FACTS.map { |known| ":#{known}" }.join(', ')}"
27
+ end
28
+ raise Malformed, "#{@name} declares needs :#{fact} twice" if @needs.include?(fact)
29
+
30
+ @needs << fact
31
+ end
32
+
33
+ private
34
+
35
+ # A fact is filled into the argument of the same name, so the declaration has one.
36
+ #
37
+ # @raise [Bluebook::DSL::Malformed] if a needed fact has no attribute to fill
38
+ def refuse_undeclared_needs!
39
+ declared = attributes.map { |attribute| attribute.name.to_s }
40
+ missing = @needs.reject { |fact| declared.include?(fact.to_s) }
41
+ return if missing.empty?
42
+
43
+ raise Malformed,
44
+ "#{@name} needs :#{missing.first} but declares no attribute :#{missing.first} " \
45
+ "for the runtime to fill — add `attribute :#{missing.first}, <type>`"
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -1,4 +1,5 @@
1
1
  require_relative "word_gate"
2
+ require_relative "need_word"
2
3
  module Hecks
3
4
  module Bluebook
4
5
  module DSL
@@ -14,11 +15,13 @@ module Hecks
14
15
  include AttributeCollector
15
16
  include QuerySpecification::Common::DSL
16
17
  include WordGate
18
+ include NeedWord
17
19
 
18
20
  # @param name [String] the query's name, as written after `query`
19
21
  def initialize(name)
20
22
  @name = name
21
23
  @wheres = []
24
+ @needs = []
22
25
  end
23
26
 
24
27
  # Sets the human-readable description shown for this query.
@@ -75,6 +78,7 @@ module Hecks
75
78
  # implements
76
79
  def build
77
80
  seal_cursor
81
+ refuse_undeclared_needs!
78
82
  Query.new(
79
83
  name: @name,
80
84
  description: @description,
@@ -87,7 +91,8 @@ module Hecks
87
91
  authorization: @authorization,
88
92
  null_semantics: @null_semantics,
89
93
  inspection: @inspection,
90
- returns: @returns
94
+ returns: @returns,
95
+ needs: @needs
91
96
  )
92
97
  end
93
98
 
@@ -25,14 +25,14 @@ module Hecks
25
25
  limit: one(:limit)
26
26
  )
27
27
 
28
- attr_reader :name, :description, :attributes, :returns
28
+ attr_reader :name, :description, :attributes, :returns, :needs
29
29
 
30
30
  # @param null_semantics [QuerySpecification::Common::NullSemantics, nil] defaults to
31
31
  # `NullSemantics.default`
32
32
  def initialize(name:, description: nil, attributes: [], wheres: [],
33
33
  order_by: nil, limit: nil, offset: nil, cursor: nil,
34
34
  authorization: nil, null_semantics: nil,
35
- inspection: nil, returns: nil)
35
+ inspection: nil, returns: nil, needs: [])
36
36
  null_semantics ||= QuerySpecification::Common::NullSemantics.default
37
37
  super(wheres: wheres, order_by: order_by, limit: limit, offset: offset, cursor: cursor,
38
38
  authorization: authorization,
@@ -42,6 +42,7 @@ module Hecks
42
42
  @description = description
43
43
  @attributes = attributes
44
44
  @returns = returns&.to_s
45
+ @needs = needs
45
46
  end
46
47
 
47
48
  # The value object a `returns` names, without the `list_of(...)` wrapper.
@@ -55,13 +56,15 @@ module Hecks
55
56
  def returns_list? = !@returns.nil? && @returns.start_with?("list_of(")
56
57
 
57
58
  # `extra_options_to_h` (count, median, group_by, scope_to) stays dynamic; `super` covers the
58
- # declared emission. `returns` follows, only when the query declares one, so a query that
59
- # returns nothing keeps its wire shape.
59
+ # declared emission. `returns` and `needs` follow, only when the query declares them, so a
60
+ # query that returns nothing and needs nothing keeps its wire shape.
60
61
  #
61
62
  # @return [Hash] the declared emission merged with `extra_options_to_h`, then `returns`
63
+ # and `needs`
62
64
  def to_h
63
65
  shape = super.merge(extra_options_to_h)
64
- @returns ? shape.merge(returns: @returns) : shape
66
+ shape = shape.merge(returns: @returns) if @returns
67
+ @needs.empty? ? shape : shape.merge(needs: @needs.map { |fact| { fact: fact.to_s } })
65
68
  end
66
69
  end
67
70
  end
@@ -1,5 +1,6 @@
1
1
  require_relative "../naming"
2
2
  require_relative "domain_stub"
3
+ require_relative "interview_shape"
3
4
 
4
5
  module Hecks
5
6
  module CLI
@@ -20,7 +21,11 @@ module Hecks
20
21
 
21
22
  # What stands in for a creating command nothing in the interview named.
22
23
  STUB_NOTE = " # TODO: no action that creates was accepted; replace Create with the real one.".freeze
23
- private_constant :PATTERN, :STUB_NOTE
24
+
25
+ # One accepted thing: what it is called and identified by, its fields, its transitions, the
26
+ # actions that create it and the others.
27
+ Shape = Struct.new(:name, :identifier, :type, :fields, :steps, :creating, :rest, keyword_init: true)
28
+ private_constant :PATTERN, :STUB_NOTE, :Shape
24
29
 
25
30
  module_function
26
31
 
@@ -65,14 +70,21 @@ module Hecks
65
70
  #
66
71
  # @param interview [Object] the booted record
67
72
  # @return [Hash] the plain interview this module takes: `reference`, `subject`, `expert`,
68
- # `exchanges` (`question`, `answer`, `topic`), and `things`, `actions` and `rules`, each a
69
- # finding with its `number`, `source` and `status` and the fields of its kind
73
+ # `exchanges` (`question`, `answer`, `topic`), and `things`, `actions`, `rules`,
74
+ # `fields` and `transitions`, each a finding with its `number`, `source` and `status`
75
+ # and the fields of its kind
70
76
  def from_record(interview)
71
77
  { reference: plain(interview.reference), subject: plain(interview.subject), expert: plain(interview.expert),
72
78
  exchanges: interview.exchanges.map { |ex| slice(ex, :question, :answer, :topic) },
73
79
  things: interview.thing_findings.map { |f| slice(f, :number, :name, :identifier, :source, :status) },
74
- actions: interview.action_findings.map { |f| slice(f, :number, :name, :thing, :event, :creates, :source, :status) },
75
- rules: interview.rule_findings.map { |f| slice(f, :number, :statement, :source, :status) } }
80
+ actions: interview.action_findings.map do |f|
81
+ slice(f, :number, :name, :thing, :event, :creates, :takes, :by, :source, :status)
82
+ end,
83
+ rules: interview.rule_findings.map { |f| slice(f, :number, :statement, :source, :status) },
84
+ fields: interview.field_findings.map { |f| slice(f, :number, :thing, :name, :values, :source, :status) },
85
+ transitions: interview.transition_findings.map do |f|
86
+ slice(f, :number, :thing, :action, :to, :from, :source, :status)
87
+ end }
76
88
  end
77
89
 
78
90
  # @api private
@@ -127,24 +139,48 @@ module Hecks
127
139
 
128
140
  # @api private
129
141
  def aggregate(interview, thing, actions)
142
+ shape = shape_of(interview, thing, actions)
143
+ lines = head_lines(interview, thing, shape)
144
+ lines.concat(command_lines(interview, shape))
145
+ lines.concat(InterviewShape.lifecycle(shape.steps, shape.creating, shape.rest))
146
+ (lines << " end").join("\n")
147
+ end
148
+
149
+ # What one accepted thing is made of: its fields, its actions, and its steps.
150
+ # @api private
151
+ def shape_of(interview, thing, actions)
130
152
  name = word(thing[:name])
131
- field = Naming.snake(word(thing[:identifier]))
132
- field_type = identifier_type(name, field)
153
+ identifier = Naming.snake(word(thing[:identifier]))
133
154
  mine = actions.select { |a| word(a[:thing]) == name }
155
+ steps = accepted(interview, :transitions).select { |s| word(s[:thing]) == name }
156
+ found = accepted(interview, :fields).select { |f| word(f[:thing]) == name }
157
+ type = identifier_type(name, identifier)
158
+ fields = InterviewShape.fields(found, name, identifier, type, lifecycle: !steps.empty?)
134
159
  creating = mine.select { |a| a[:creates] }
135
- rest = mine - creating
136
- lines = [" # #{source_note(interview, thing)}".rstrip,
137
- " aggregate #{name.inspect} do",
138
- " description #{"TODO: describe what a #{name} is.".inspect}", "",
139
- " identified_by :#{field}", "",
140
- " attribute :#{field}, #{field_type}", "",
141
- " value_object #{field_type.inspect} do",
142
- " attribute :value, String, pattern: #{PATTERN}",
143
- " end"]
144
- creating = [stub_create(name)] if creating.empty?
145
- creating.each { |action| lines.concat(creating_command(interview, action, field, field_type)) }
146
- rest.each { |action| lines.concat(mutating_command(interview, action, name)) }
147
- (lines << " end").join("\n")
160
+ Shape.new(name: name, identifier: identifier, type: type, fields: fields, steps: steps,
161
+ creating: creating, rest: mine - creating)
162
+ end
163
+
164
+ # The aggregate's opening: its identity, its fields, and the value object of each.
165
+ # @api private
166
+ def head_lines(interview, thing, shape)
167
+ required = shape.creating.flat_map { |action| InterviewShape.takes(action) }
168
+ [" # #{source_note(interview, thing)}".rstrip,
169
+ " aggregate #{shape.name.inspect} do",
170
+ " description #{"TODO: describe what a #{shape.name} is.".inspect}", "",
171
+ " identified_by :#{shape.identifier}", "",
172
+ " attribute :#{shape.identifier}, #{shape.type}",
173
+ *InterviewShape.attributes(shape.fields, required), "",
174
+ " value_object #{shape.type.inspect} do",
175
+ " attribute :value, String, pattern: #{PATTERN}", " end",
176
+ *InterviewShape.value_objects(shape.fields)]
177
+ end
178
+
179
+ # @api private
180
+ def command_lines(interview, shape)
181
+ creating = shape.creating.empty? ? [stub_create(shape.name)] : shape.creating
182
+ creating.flat_map { |action| creating_command(interview, action, shape) } +
183
+ shape.rest.flat_map { |action| mutating_command(interview, action, shape) }
148
184
  end
149
185
 
150
186
  # The creating command nothing in the interview named: a stub, marked for the developer.
@@ -152,19 +188,24 @@ module Hecks
152
188
  def stub_create(name) = { name: "Create", event: "#{name}Created", stub: true }
153
189
 
154
190
  # @api private
155
- def creating_command(interview, action, field, field_type)
191
+ def creating_command(interview, action, shape)
156
192
  verb = word(action[:name])
157
193
  note = action[:stub] ? STUB_NOTE : " # #{source_note(interview, action)}"
158
- ["", note, " command #{verb.inspect} do", " goal #{"TODO: say what #{verb} does".inspect}", "",
159
- " attribute :#{field}, #{field_type}", "", " emits #{word(action[:event]).inspect}", " end"]
194
+ given, unknown = InterviewShape.inputs(action, shape.fields, shape.identifier)
195
+ ["", note, " command #{verb.inspect} do", " goal #{"TODO: say what #{verb} does".inspect}",
196
+ *InterviewShape.who_lines(action), "", " attribute :#{shape.identifier}, #{shape.type}",
197
+ *given.map { |f| " attribute :#{f[:name]}, #{f[:type]}" }, *InterviewShape.unknown_lines(unknown), "",
198
+ " emits #{word(action[:event]).inspect}", " end"]
160
199
  end
161
200
 
162
201
  # @api private
163
- def mutating_command(interview, action, name)
202
+ def mutating_command(interview, action, shape)
164
203
  verb = word(action[:name])
204
+ given, unknown = InterviewShape.inputs(action, shape.fields, shape.identifier)
165
205
  ["", " # #{source_note(interview, action)}", " command #{verb.inspect} do",
166
- " goal #{"TODO: say what #{verb} does".inspect}", "", " reference_to #{name}", "",
167
- " emits #{word(action[:event]).inspect}", " end"]
206
+ " goal #{"TODO: say what #{verb} does".inspect}", *InterviewShape.who_lines(action), "",
207
+ " reference_to #{shape.name}", *InterviewShape.input_lines(given), *InterviewShape.unknown_lines(unknown),
208
+ *InterviewShape.assignment_lines(given), "", " emits #{word(action[:event]).inspect}", " end"]
168
209
  end
169
210
 
170
211
  # An action whose thing nobody accepted has nowhere to go; it is kept as a comment, not lost.
@@ -196,10 +237,15 @@ module Hecks
196
237
  lines = interview.fetch(:things).map do |f|
197
238
  "- Thing #{f[:number]}, #{f[:status]}: **#{f[:name]}**, identified by `#{f[:identifier]}` (exchange #{f[:source]})"
198
239
  end
199
- lines += interview.fetch(:actions).map do |f|
200
- creates = f[:creates] ? ", creates it" : ""
201
- "- Action #{f[:number]}, #{f[:status]}: **#{f[:name]}** on #{f[:thing]}, " \
202
- "announcing `#{f[:event]}`#{creates} (exchange #{f[:source]})"
240
+ lines += interview.fetch(:actions).map { |f| action_line(f) }
241
+ lines += interview.fetch(:fields, []).map do |f|
242
+ values = f[:values].to_s.strip.empty? ? "" : ", one of #{f[:values]}"
243
+ "- Field #{f[:number]}, #{f[:status]}: **#{f[:name]}** on #{f[:thing]}#{values} (exchange #{f[:source]})"
244
+ end
245
+ lines += interview.fetch(:transitions, []).map do |f|
246
+ from = f[:from].to_s.strip.empty? ? "" : " from #{f[:from]}"
247
+ "- Transition #{f[:number]}, #{f[:status]}: **#{f[:action]}** on #{f[:thing]} to #{f[:to]}#{from} " \
248
+ "(exchange #{f[:source]})"
203
249
  end
204
250
  lines + interview.fetch(:rules).map do |f|
205
251
  "- Rule #{f[:number]}, #{f[:status]}: #{one_line(f[:statement])} (exchange #{f[:source]})"
@@ -207,7 +253,16 @@ module Hecks
207
253
  end
208
254
 
209
255
  # @api private
210
- def accepted(interview, kind) = interview.fetch(kind).select { |finding| finding[:status] == ACCEPTED }
256
+ def action_line(finding)
257
+ creates = finding[:creates] ? ", creates it" : ""
258
+ takes = finding[:takes].to_s.strip.empty? ? "" : ", takes #{finding[:takes]}"
259
+ by = finding[:by].to_s.strip.empty? ? "" : ", by #{finding[:by]}"
260
+ "- Action #{finding[:number]}, #{finding[:status]}: **#{finding[:name]}** on #{finding[:thing]}, " \
261
+ "announcing `#{finding[:event]}`#{creates}#{takes}#{by} (exchange #{finding[:source]})"
262
+ end
263
+
264
+ # @api private
265
+ def accepted(interview, kind) = interview.fetch(kind, []).select { |finding| finding[:status] == ACCEPTED }
211
266
 
212
267
  # A free-text name as a PascalCase word safe to write into Ruby: `lend a book` is `LendABook`.
213
268
  # @api private
@@ -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