hecks 3.0.4 → 3.1.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 (159) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +25 -7
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda.rb +1 -1
  5. data/lib/hecks/adapters/driven/tenant_provisioner.rb +2 -2
  6. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +59 -32
  7. data/lib/hecks/bluebook/hexagon.rb +45 -25
  8. data/lib/hecks/bluebook/model_check.rb +7 -7
  9. data/lib/hecks/chapters.rb +31 -3
  10. data/lib/hecks/cli/check_era.rb +1 -1
  11. data/lib/hecks/cli/console.rb +24 -8
  12. data/lib/hecks/cli/deploy_template_diff.rb +1 -1
  13. data/lib/hecks/cli/doc_coverage.rb +1 -1
  14. data/lib/hecks/cli/domain_stub.rb +128 -0
  15. data/lib/hecks/cli/interview_draft.rb +246 -0
  16. data/lib/hecks/cli/model_check.rb +1 -1
  17. data/lib/hecks/cli/project_cli.rb +69 -18
  18. data/lib/hecks/cli/reference.rb +2 -1
  19. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +1 -1
  20. data/lib/hecks/cli/seed_semantics_corpus.rb +38 -9
  21. data/lib/hecks/cli/spec_example.rb +1 -1
  22. data/lib/hecks/cli/stress_concurrency_specs.rb +14 -5
  23. data/lib/hecks/corpus.rb +11 -8
  24. data/lib/hecks/deploy/bluebook/deploy.bluebook +85 -12
  25. data/lib/hecks/deploy/oidc.json +5 -0
  26. data/lib/hecks/doc/reference.rb +4 -3
  27. data/lib/hecks/doors/cli_door.rb +13 -13
  28. data/lib/hecks/doors/cli_runner.rb +52 -32
  29. data/lib/hecks/doors/launcher_options.rb +23 -5
  30. data/lib/hecks/doors/mcp_door.rb +40 -18
  31. data/lib/hecks/doors/mcp_door_scope.rb +244 -24
  32. data/lib/hecks/doors.rb +1 -1
  33. data/lib/hecks/embryonaut_bluebook.rb +2 -2
  34. data/lib/hecks/forms/banking_presentation.rb +1 -1
  35. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -1
  36. data/lib/hecks/framework/bluebook/console_settings.bluebook +1 -1
  37. data/lib/hecks/framework/bluebook/framework.hecksagon +4 -4
  38. data/lib/hecks/framework.rb +1 -1
  39. data/lib/hecks/fuzzing/combination_miner.rb +1 -1
  40. data/lib/hecks/fuzzing/concurrent_dispatch.rb +1 -1
  41. data/lib/hecks/fuzzing/differential.rb +4 -2
  42. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  43. data/lib/hecks/fuzzing/form_census.rb +2 -2
  44. data/lib/hecks/fuzzing/generated_domain_check.rb +1 -1
  45. data/lib/hecks/fuzzing/isolated_boot.rb +10 -3
  46. data/lib/hecks/fuzzing/persistence_parity.rb +2 -2
  47. data/lib/hecks/fuzzing/target_capabilities.rb +3 -3
  48. data/lib/hecks/gate/stages.yml +8 -4
  49. data/lib/hecks/grammar/grammar.hecksagon +2 -2
  50. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +4 -4
  51. data/lib/hecks/hecks/adapters/host_http.rb +4 -3
  52. data/lib/hecks/hecks/adapters/local_files.rb +59 -0
  53. data/lib/hecks/hecks/adapters/site_toolchain.adapter +7 -0
  54. data/lib/hecks/hecks/adapters/site_toolchain.rb +54 -0
  55. data/lib/hecks/hecks/codebase.bluebook +4 -3
  56. data/lib/hecks/hecks/context_map.hecksagon +2 -2
  57. data/lib/hecks/hecks/custodian.bluebook +45 -3
  58. data/lib/hecks/hecks/hecks.hecksagon +10 -4
  59. data/lib/hecks/hecks/hecks.world +7 -4
  60. data/lib/hecks/hecks/site.behaviors +17 -0
  61. data/lib/hecks/hecks/site.world +10 -0
  62. data/lib/hecks/language/bluebook/bluebook.hecksagon +1 -1
  63. data/lib/hecks/language/hecksagon/hecksagon.bluebook +24 -47
  64. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +4 -4
  65. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +2 -1
  66. data/lib/hecks/projections/deploy/box/settings.rb +248 -0
  67. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +267 -0
  68. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +70 -0
  69. data/lib/hecks/projections/deploy/box/templates/fetch-secrets.sh +21 -0
  70. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +165 -0
  71. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +43 -0
  72. data/lib/hecks/projections/deploy/box.rb +387 -0
  73. data/lib/hecks/projections/deploy/fargate/cdn.rb +13 -1
  74. data/lib/hecks/projections/deploy/fargate.rb +1 -1
  75. data/lib/hecks/projections/deploy/lambda.rb +11 -10
  76. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +1 -1
  77. data/lib/hecks/projections/diagrams.rb +2 -2
  78. data/lib/hecks/projections/site/edge/behaviours.rb +107 -0
  79. data/lib/hecks/projections/site/edge/checks.rb +137 -0
  80. data/lib/hecks/projections/site/edge/pattern.rb +50 -0
  81. data/lib/hecks/projections/site/edge/rules.rb +128 -0
  82. data/lib/hecks/projections/site/edge.rb +136 -0
  83. data/lib/hecks/projections/site/regions.rb +44 -0
  84. data/lib/hecks/projections/site/routes_ts.rb +284 -0
  85. data/lib/hecks/projections/site/site_cdn.rb +78 -0
  86. data/lib/hecks/projections/site/table/checks.rb +124 -0
  87. data/lib/hecks/projections/site/table/row_builder.rb +155 -0
  88. data/lib/hecks/projections/site/table.rb +98 -0
  89. data/lib/hecks/projections.rb +2 -0
  90. data/lib/hecks/projector/cli_projector.rb +172 -95
  91. data/lib/hecks/quality_control/adapters/agent.adapter +1 -1
  92. data/lib/hecks/quality_control/adapters/agent.rb +2 -2
  93. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +3 -3
  94. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +8 -8
  95. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +3 -3
  96. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +4 -4
  97. data/lib/hecks/quality_control/cli/qa_log_bug.rb +2 -2
  98. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +6 -5
  99. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +2 -2
  100. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +5 -5
  101. data/lib/hecks/quality_control/cli/qa_pr_check.rb +3 -3
  102. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +3 -3
  103. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +2 -2
  104. data/lib/hecks/quality_control/cli/qa_sweep.rb +10 -9
  105. data/lib/hecks/quality_control/cli/qa_tick.rb +3 -3
  106. data/lib/hecks/quality_control/quality_control.bluebook +18 -18
  107. data/lib/hecks/quality_control/quality_control.hecksagon +1 -1
  108. data/lib/hecks/runtime/command_interpreter.rb +2 -0
  109. data/lib/hecks/runtime/interpreting.rb +21 -0
  110. data/lib/hecks/runtime/invocation.rb +5 -3
  111. data/lib/hecks/runtime/registry/verification.rb +15 -29
  112. data/lib/hecks/runtime/registry.rb +14 -20
  113. data/lib/hecks/rust_build/append_optionals.rb +79 -0
  114. data/lib/hecks/rust_build/conformance_fuzz.rb +2 -1
  115. data/lib/hecks/rust_build/domain_name.rb +26 -0
  116. data/lib/hecks/rust_build/kernel_input.rb +58 -0
  117. data/lib/hecks/rust_build/project_rust/codegen_run.rb +143 -0
  118. data/lib/hecks/rust_build/project_rust/root_mod.rb +1 -1
  119. data/lib/hecks/rust_build/project_rust.rb +57 -24
  120. data/lib/hecks/rust_build/rust_literal.rb +33 -0
  121. data/lib/hecks/rust_build/write_if_changed.rb +86 -0
  122. data/lib/hecks/site/bluebook/site.bluebook +205 -0
  123. data/lib/hecks/site/bluebook/site.hecksagon +5 -0
  124. data/lib/hecks/site/bluebook/site.ports.hecksagon +15 -0
  125. data/lib/hecks/sme/bluebook/sme.bluebook +336 -0
  126. data/lib/hecks/sme/bluebook/sme.hecksagon +5 -0
  127. data/lib/hecks/storehouse.rb +37 -7
  128. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +1 -1
  129. data/lib/hecks/three_zero/forms.yml +88 -88
  130. data/lib/hecks/tools/ci_gates.rb +1 -1
  131. data/lib/hecks/tools/deploy_recipe.rb +14 -6
  132. data/lib/hecks/tools/oidc_manifests.rb +2 -1
  133. data/lib/hecks/tools/sequence_script.rb +2 -2
  134. data/lib/hecks/tools/site_routes.rb +152 -0
  135. data/lib/hecks/tools/tenant_provisioning.rb +1 -1
  136. data/lib/hecks/tools.rb +1 -0
  137. data/lib/hecks/version.rb +1 -1
  138. data/rust/build/src/cargo_sync.rs +1 -1
  139. data/rust/build/src/pipeline.rs +23 -16
  140. data/rust/build/src/resolve.rs +39 -6
  141. data/rust/host/HECKS_RELEASE +1 -1
  142. data/rust/host/src/api.rs +1 -1
  143. data/rust/host/src/dispatch.rs +87 -7
  144. data/rust/host/src/needs.rs +111 -0
  145. data/rust/host/src/web/newsletter/template.rs +69 -0
  146. data/rust/host/src/web/newsletter/tests.rs +43 -0
  147. data/rust/host/src/web/newsletter.rs +6 -1
  148. data/rust/host/src/web/newsletter_send.rs +1 -1
  149. data/rust/parser/src/keywords.rs +7 -6
  150. data/rust/parser/src/main.rs +4 -3
  151. data/rust/parser/src/parse/chapter.rs +1 -1
  152. data/rust/parser/src/parse/hecksagon.rs +25 -10
  153. data/rust/project/mutations.rb +4 -30
  154. data/rust/project/naming.rb +8 -28
  155. data/rust/project/write_if_changed.rb +4 -80
  156. data/rust/project_rust_pipeline.rb +16 -12
  157. data/rust/src/kernel/cli.rs +2 -0
  158. data/rust/src/kernel/needs.rs +74 -1
  159. metadata +39 -2
@@ -0,0 +1,128 @@
1
+ require_relative "../naming"
2
+
3
+ module Hecks
4
+ module CLI
5
+ # The files `hecks init` writes: a stub domain that boots as it stands and that the author
6
+ # then edits into a real one (ADR 0087).
7
+ #
8
+ # Pure: it answers the text of each file and writes none, so the adapter that does the writing
9
+ # can check every target before it writes the first, and a spec can boot the result.
10
+ module DomainStub
11
+ # A domain's name: a capital, then letters and digits, as its bluebook spells it.
12
+ NAME = /\A[A-Z][A-Za-z0-9]*\z/
13
+
14
+ SQLITE_URL = 'default_database "data/%<snake>s.db"'.freeze
15
+ POSTGRES_URL = 'default_database "postgres://localhost/%<snake>s"'.freeze
16
+ private_constant :SQLITE_URL, :POSTGRES_URL
17
+
18
+ # Persistence adapters a stub can be bound to, each with the world lines that bind it, the
19
+ # data it keeps under `data/` (so a `.gitignore` is written), and whether it needs a server
20
+ # (so a memory overlay is written and the first run needs no database).
21
+ ADAPTERS = {
22
+ "Memory" => { world: ['default_adapter "Memory"'], local_data: false, server: false },
23
+ "SqlitePersistence" => { world: ['default_adapter "SqlitePersistence"', SQLITE_URL],
24
+ local_data: true, server: false },
25
+ "Heki" => { world: ['default_adapter "Heki"', 'persisted_by("Heki") { dir "data" }'],
26
+ local_data: true, server: false },
27
+ "Postgres" => { world: ['default_adapter "Postgres"', POSTGRES_URL],
28
+ local_data: false, server: true },
29
+ "PostgresEra" => { world: ['default_adapter "PostgresEra"', POSTGRES_URL],
30
+ local_data: false, server: true }
31
+ }.freeze
32
+
33
+ # What `init` binds a stub to when no adapter is named: it needs no server and keeps its data.
34
+ DEFAULT_ADAPTER = "SqlitePersistence".freeze
35
+
36
+ module_function
37
+
38
+ # @return [Array<String>] the adapter names `init` accepts
39
+ def adapters = ADAPTERS.keys
40
+
41
+ # @param name [String] the domain's name, as its bluebook spells it (`Lending`)
42
+ # @param adapter [String, nil] an adapter named in `ADAPTERS`; the default when nil
43
+ # @return [Hash{String => String}] each file's path under the domain directory, and its text
44
+ # @raise [ArgumentError] when the name is not a capitalised word or the adapter is unknown
45
+ def files(name:, adapter: nil)
46
+ adapter ||= DEFAULT_ADAPTER
47
+ check!(name, adapter)
48
+ { "bluebook/#{Naming.snake(name)}.bluebook" => bluebook(name) }.merge(support_files(name: name, adapter: adapter))
49
+ end
50
+
51
+ # The files around a domain's bluebook: its world bound to the adapter, the memory overlay for
52
+ # an adapter that needs a server, and a `.gitignore` for one that keeps data. `hecks init`
53
+ # writes them beside a stub, and an interview draft beside the bluebook it renders (ADR 0088).
54
+ #
55
+ # @param name [String] the domain's name
56
+ # @param adapter [String, nil] an adapter named in `ADAPTERS`; the default when nil
57
+ # @return [Hash{String => String}] each file's path under the domain directory, and its text
58
+ # @raise [ArgumentError] when the name is not a capitalised word or the adapter is unknown
59
+ def support_files(name:, adapter: nil)
60
+ adapter ||= DEFAULT_ADAPTER
61
+ check!(name, adapter)
62
+ snake = Naming.snake(name)
63
+ files = { "bluebook/#{snake}.world" => world(name, snake, adapter) }
64
+ files["bluebook/environments/memory.world"] = overlay(name) if ADAPTERS.fetch(adapter).fetch(:server)
65
+ files[".gitignore"] = "data/\n" if ADAPTERS.fetch(adapter).fetch(:local_data)
66
+ files
67
+ end
68
+
69
+ # @param name [String] a domain name
70
+ # @return [String] the directory `init` writes into when none is named
71
+ def directory(name) = Naming.snake(name)
72
+
73
+ # @api private
74
+ def check!(name, adapter)
75
+ unless name.to_s.match?(NAME)
76
+ raise ArgumentError, "#{name.inspect} is not a domain name: a capital, then letters and digits (Lending)"
77
+ end
78
+ return if ADAPTERS.key?(adapter)
79
+
80
+ raise ArgumentError, "unknown adapter #{adapter.inspect}; choose one of #{adapters.join(', ')}"
81
+ end
82
+
83
+ # @api private
84
+ def bluebook(name)
85
+ <<~BLUEBOOK
86
+ Hecks.bluebook "#{name}" do
87
+ vision "TODO: say in one sentence what #{name} is for."
88
+ supporting
89
+
90
+ # TODO: rename Example to the main thing #{name} keeps track of.
91
+ aggregate "Example" do
92
+ description "TODO: describe what an Example is."
93
+
94
+ identified_by :name
95
+
96
+ attribute :name, ExampleName
97
+
98
+ value_object "ExampleName" do
99
+ attribute :value, String, pattern: '[^ \\t\\n\\r]'
100
+ end
101
+
102
+ # TODO: replace Create with the first thing that happens to an Example.
103
+ command "Create" do
104
+ goal "TODO: say what Create does"
105
+
106
+ attribute :name, ExampleName
107
+
108
+ emits "ExampleCreated"
109
+ end
110
+ end
111
+ end
112
+ BLUEBOOK
113
+ end
114
+
115
+ # @api private
116
+ def world(name, snake, adapter)
117
+ lines = ADAPTERS.fetch(adapter).fetch(:world).map { |line| " #{format(line, snake: snake)}" }
118
+ note = adapter == "PostgresEra" ? " # PostgresEra refuses a superuser role; use an ordinary one in the URL.\n" : ""
119
+ "Hecks.world \"#{name}\" do\n realm \"#{name}\"\n#{note}#{lines.join("\n")}\nend\n"
120
+ end
121
+
122
+ # @api private
123
+ def overlay(name)
124
+ "Hecks.world \"#{name}\" do\n default_adapter \"Memory\"\nend\n"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,246 @@
1
+ require_relative "../naming"
2
+ require_relative "domain_stub"
3
+
4
+ module Hecks
5
+ module CLI
6
+ # Turns an interview's accepted findings into the files of a first domain (ADR 0088).
7
+ #
8
+ # Pure: it answers the text of each file and writes none, so the command that runs an interview
9
+ # can check every target before it writes the first, and a spec can boot the result. It shares
10
+ # the files around a bluebook with `hecks init` (`DomainStub.support_files`) and renders the
11
+ # bluebook itself, from what the developer accepted, where `init` writes a fixed stub.
12
+ #
13
+ # Only `accepted` findings reach the bluebook; every finding reaches the record. A rule is never
14
+ # turned into code from prose: it is a comment citing its source, to become a `given` by hand.
15
+ module InterviewDraft
16
+ ACCEPTED = "accepted".freeze
17
+
18
+ # The pattern every generated string field carries: it refuses a blank value.
19
+ PATTERN = %q('[^ \t\n\r]').freeze
20
+
21
+ # What stands in for a creating command nothing in the interview named.
22
+ STUB_NOTE = " # TODO: no action that creates was accepted; replace Create with the real one.".freeze
23
+ private_constant :PATTERN, :STUB_NOTE
24
+
25
+ module_function
26
+
27
+ # The files a first interview writes: the bluebook, the files around it, and the record.
28
+ #
29
+ # @param interview [Hash] the interview, with at least one accepted thing
30
+ # @param adapter [String, nil] an adapter in `DomainStub::ADAPTERS`; its default when nil
31
+ # @return [Hash{String => String}] each file's path under the domain directory, and its text
32
+ # @raise [ArgumentError] when nothing was accepted, or the subject or adapter is refused
33
+ def files(interview, adapter: nil)
34
+ name = interview.fetch(:subject)
35
+ support = DomainStub.support_files(name: name, adapter: adapter)
36
+ { "bluebook/#{Naming.snake(name)}.bluebook" => bluebook(interview) }
37
+ .merge(support)
38
+ .merge(record_file(interview))
39
+ end
40
+
41
+ # What a later interview writes: its record, ending in the aggregates and commands it would
42
+ # add, for a developer to merge. An existing domain is never touched (ADR 0088).
43
+ #
44
+ # @param interview [Hash] the interview
45
+ # @return [Hash{String => String}] the one file, `interviews/<reference>.md`
46
+ def additions(interview)
47
+ record_file(interview, "\n## Proposed additions\n\nMerge by hand into the domain's bluebook.\n\n" \
48
+ "```ruby\n#{fragment(interview)}```\n")
49
+ end
50
+
51
+ # The interview's record: every exchange in order, and every finding with how it was decided.
52
+ #
53
+ # @param interview [Hash] the interview
54
+ # @return [String] Markdown
55
+ def record(interview)
56
+ lines = ["# Interview #{interview.fetch(:reference)}: #{interview.fetch(:subject)}", "",
57
+ "Expert: #{interview.fetch(:expert)}", "", "## Exchanges", ""]
58
+ interview.fetch(:exchanges).each_with_index { |ex, i| lines.concat(exchange_lines(ex, i + 1)) }
59
+ lines << "" << "## Findings" << ""
60
+ lines.concat(finding_lines(interview))
61
+ "#{lines.join("\n")}\n"
62
+ end
63
+
64
+ # Reads the plain interview out of an `Interview` record booted from the SME chapter.
65
+ #
66
+ # @param interview [Object] the booted record
67
+ # @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
70
+ def from_record(interview)
71
+ { reference: plain(interview.reference), subject: plain(interview.subject), expert: plain(interview.expert),
72
+ exchanges: interview.exchanges.map { |ex| slice(ex, :question, :answer, :topic) },
73
+ 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) } }
76
+ end
77
+
78
+ # @api private
79
+ def record_file(interview, tail = "")
80
+ { "interviews/#{file_stem(interview.fetch(:reference))}.md" => record(interview) + tail }
81
+ end
82
+
83
+ # @api private
84
+ def file_stem(reference) = reference.to_s.gsub(/[^A-Za-z0-9._-]/, "_")
85
+
86
+ # @api private
87
+ def bluebook(interview)
88
+ name = interview.fetch(:subject)
89
+ things = accepted(interview, :things)
90
+ raise ArgumentError, "nothing was accepted: a draft needs at least one thing" if things.empty?
91
+
92
+ out = ["Hecks.bluebook #{name.inspect} do"]
93
+ out.concat(header(interview))
94
+ out << %( vision "TODO: say in one sentence what #{name} is for.")
95
+ out << " supporting" << ""
96
+ out.concat(rule_lines(interview))
97
+ out << fragment(interview).chomp << "end"
98
+ "#{out.join("\n")}\n"
99
+ end
100
+
101
+ # The aggregates, with their commands: the part a later interview offers to merge.
102
+ # @api private
103
+ def fragment(interview)
104
+ things = accepted(interview, :things)
105
+ actions = accepted(interview, :actions)
106
+ placed = things.map { |t| word(t[:name]) }
107
+ blocks = things.map { |thing| aggregate(interview, thing, actions) }
108
+ loose = actions.reject { |a| placed.include?(word(a[:thing])) }
109
+ blocks << unplaced(interview, loose) unless loose.empty?
110
+ "#{blocks.join("\n\n")}\n"
111
+ end
112
+
113
+ # @api private
114
+ def header(interview)
115
+ [" # Drafted from interview #{interview[:reference]} with #{interview[:expert]}. Each name below cites the",
116
+ " # exchange it came from; edit freely."]
117
+ end
118
+
119
+ # @api private
120
+ def rule_lines(interview)
121
+ rules = accepted(interview, :rules)
122
+ return [] if rules.empty?
123
+
124
+ [" # RULES the expert gave, for you to turn into `given` clauses:"] +
125
+ rules.map { |r| " # - #{cite(interview, r)} #{one_line(r[:statement])}" } + [""]
126
+ end
127
+
128
+ # @api private
129
+ def aggregate(interview, thing, actions)
130
+ name = word(thing[:name])
131
+ field = Naming.snake(word(thing[:identifier]))
132
+ field_type = identifier_type(name, field)
133
+ mine = actions.select { |a| word(a[:thing]) == name }
134
+ 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")
148
+ end
149
+
150
+ # The creating command nothing in the interview named: a stub, marked for the developer.
151
+ # @api private
152
+ def stub_create(name) = { name: "Create", event: "#{name}Created", stub: true }
153
+
154
+ # @api private
155
+ def creating_command(interview, action, field, field_type)
156
+ verb = word(action[:name])
157
+ 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"]
160
+ end
161
+
162
+ # @api private
163
+ def mutating_command(interview, action, name)
164
+ verb = word(action[:name])
165
+ ["", " # #{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"]
168
+ end
169
+
170
+ # An action whose thing nobody accepted has nowhere to go; it is kept as a comment, not lost.
171
+ # @api private
172
+ def unplaced(interview, actions)
173
+ lines = [" # UNPLACED: these accepted actions name a thing that was not accepted. Place each, or drop it."]
174
+ actions.each do |action|
175
+ lines << " # - #{cite(interview, action)} #{word(action[:name])} on #{action[:thing]}, announcing #{action[:event]}"
176
+ end
177
+ lines.join("\n")
178
+ end
179
+
180
+ # @api private
181
+ def identifier_type(name, field)
182
+ type = Naming.pascal(field)
183
+ type == name ? "#{type}Id" : type
184
+ end
185
+
186
+ # @api private
187
+ def exchange_lines(exchange, number)
188
+ lines = ["#{number}. **Q:** #{one_line(exchange[:question])}", " **A:** #{one_line(exchange[:answer])}"]
189
+ topic = exchange[:topic].to_s
190
+ lines << " _Topic: #{one_line(topic)}_" unless topic.empty?
191
+ lines
192
+ end
193
+
194
+ # @api private
195
+ def finding_lines(interview)
196
+ lines = interview.fetch(:things).map do |f|
197
+ "- Thing #{f[:number]}, #{f[:status]}: **#{f[:name]}**, identified by `#{f[:identifier]}` (exchange #{f[:source]})"
198
+ 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]})"
203
+ end
204
+ lines + interview.fetch(:rules).map do |f|
205
+ "- Rule #{f[:number]}, #{f[:status]}: #{one_line(f[:statement])} (exchange #{f[:source]})"
206
+ end
207
+ end
208
+
209
+ # @api private
210
+ def accepted(interview, kind) = interview.fetch(kind).select { |finding| finding[:status] == ACCEPTED }
211
+
212
+ # A free-text name as a PascalCase word safe to write into Ruby: `lend a book` is `LendABook`.
213
+ # @api private
214
+ def word(text) = text.to_s.split(/[^A-Za-z0-9]+/).reject(&:empty?).map { |part| part.sub(/\A./, &:upcase) }.join
215
+
216
+ # @api private
217
+ def one_line(text) = text.to_s.gsub(/\s+/, " ").strip
218
+
219
+ # @api private
220
+ def cite(interview, finding) = "#{interview[:reference]} ##{finding[:source]}:"
221
+
222
+ # Where a name came from: the interview, the exchange, and a short quotation of the answer.
223
+ # @api private
224
+ def source_note(interview, finding)
225
+ exchange = interview.fetch(:exchanges)[finding[:source].to_i - 1]
226
+ note = "from #{cite(interview, finding)}"
227
+ return note unless exchange
228
+
229
+ text = one_line(exchange[:answer])
230
+ text = "#{text[0, 77]}..." if text.length > 80
231
+ "#{note} #{text.inspect}"
232
+ end
233
+
234
+ # @api private
235
+ def plain(value)
236
+ return nil if value.nil?
237
+
238
+ found = value.respond_to?(:to_h) && !value.is_a?(String) ? value.to_h : value
239
+ found.is_a?(Hash) && found.key?(:value) ? found[:value] : found
240
+ end
241
+
242
+ # @api private
243
+ def slice(record, *keys) = keys.to_h { |key| [key, plain(record[key])] }
244
+ end
245
+ end
246
+ end
@@ -88,7 +88,7 @@ module Hecks
88
88
  end
89
89
 
90
90
  # `root:` is the source's parent, so a member declaring
91
- # `uses_embryonaut_bluebook` can vendor from it.
91
+ # `attaches ... from: :vendor` can vendor from it.
92
92
  def boot(source)
93
93
  root = File.directory?(source) ? File.dirname(source) : nil
94
94
  registry = Runtime::Registry.new(root: root)
@@ -17,7 +17,7 @@ module Hecks
17
17
  # Build output and other trees under a root that hold no domain a caller means.
18
18
  IGNORED = %r{\A(rust|deploy|tmp|coverage)/}
19
19
 
20
- # What a chapter name, a domain path and a launcher verb may be made of.
20
+ # What a chapter name, a domain path and a launcher command may be made of.
21
21
  NAME = /\A[A-Za-z][A-Za-z0-9_ ]*\z/
22
22
  PATH = %r{\A[\w./-]+\z}
23
23
  VERB = /\A[A-Za-z_][A-Za-z0-9_]*\z/
@@ -40,9 +40,28 @@ module Hecks
40
40
  status.zero? ? puts(text) : abort(text)
41
41
  LAUNCHER_TAIL
42
42
 
43
+ # What a Memory command does when refused: say why, from the record's `refusal`.
44
+ MEMORY_REFUSAL = <<~'LAUNCHER_REFUSAL'.gsub(/^/, " ").freeze
45
+ if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))
46
+ why = begin
47
+ require "json"
48
+ JSON.parse(text).dig("state", "refusal", "value")
49
+ rescue JSON::ParserError
50
+ nil
51
+ end
52
+ warn(why ? why.sub(/\A(\w+::)+\w+: /, "") : reason)
53
+ else
54
+ puts text
55
+ warn reason
56
+ end
57
+ LAUNCHER_REFUSAL
58
+
43
59
  # The generator's name as an executable launcher's header states it, whoever ran it.
44
60
  GENERATOR = "hecks project_cli".freeze
45
61
 
62
+ # The launcher's test for a Memory command; a trailing `!` is not part of the name.
63
+ RUNS_ON_MEMORY = 'MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))'.freeze
64
+
46
65
  module_function
47
66
 
48
67
  # Writes a launcher for each named domain, or for every domain under `root`.
@@ -103,7 +122,7 @@ module Hecks
103
122
  label = executable || "#{path}/#{snake}"
104
123
  file = File.join(root, label)
105
124
  text = launcher(path, name, program, executable: executable, legacy: setting[:legacy],
106
- opted: !setting.empty?)
125
+ memory_commands: setting[:memory_commands], opted: !setting.empty?)
107
126
  return skip(path, "#{label} is a directory") if File.directory?(file)
108
127
 
109
128
  return :drifted if check && !(File.file?(file) && File.read(file) == text)
@@ -149,11 +168,12 @@ module Hecks
149
168
  # @param program [String] the generator's invocation, named in the header
150
169
  # @param executable [String, nil] the file's path under the root when it is not beside the
151
170
  # domain; its program name is then that file's basename, and its header names the generator
152
- # @param legacy [Array<String>, nil] verbs an executable hands to `Hecks::CLI` first
171
+ # @param legacy [Array<String>, nil] commands an executable hands to `Hecks::CLI` first
172
+ # @param memory_commands [Array<String>, nil] commands that default to Memory
153
173
  # @param opted [Boolean] whether the chapter's world declares a `launcher` setting
154
174
  # @return [String] the Ruby source
155
- # @raise [ArgumentError] if a name, path, executable or legacy verb is not plain
156
- def launcher(path, name, program, executable: nil, legacy: nil, opted: !executable.nil?)
175
+ # @raise [ArgumentError] if a name, path, executable or legacy command is not plain
176
+ def launcher(path, name, program, executable: nil, legacy: nil, memory_commands: nil, opted: !executable.nil?)
157
177
  plain!("chapter name", name, NAME)
158
178
  plain!("domain path", path, PATH)
159
179
  snake = Naming.snake(name)
@@ -174,45 +194,48 @@ module Hecks
174
194
  where = shown = "#{path}/#{snake}"
175
195
  end
176
196
 
177
- handoff = legacy_handoff(Array(legacy)) if executable
197
+ handoff = legacy_handoff(Array(legacy)) + memory_default(Array(memory_commands)) if executable
178
198
  encoding = opted ? ENCODING : ""
179
199
  ending = opted ? OPTED_ENDING : PLAIN_ENDING
200
+ ending = quiet_ending(ending) if executable && !Array(memory_commands).empty?
180
201
 
181
202
  <<~RUBY
182
203
  #!/usr/bin/env ruby
183
204
 
184
205
  # #{name}'s command line — GENERATED by #{program}. Do not edit.
185
206
  #
186
- # A launcher, not a CLI: the verbs, their arguments and their refusals are
207
+ # A launcher, not a CLI: the commands, their arguments and their refusals are
187
208
  # projected from the bluebook beside it every time this runs, so a change
188
209
  # to the chapter shows up here without re-minting anything.
189
210
  #
190
- # #{where} every verb, and every question
191
- # #{where} <verb> --help what it wants, and how it refuses
211
+ # #{where} every command, and every question
212
+ # #{where} <command> --help what it wants, and how it refuses
192
213
 
193
214
  #{encoding}$LOAD_PATH.unshift File.expand_path("#{up}lib", __dir__)
194
215
  #{handoff}
195
216
  require "hecks"
196
217
 
197
- #{entry(name, boot, shown, opted)}
218
+ #{entry(name, boot, shown, opted, quiet: !Array(memory_commands).empty?)}
198
219
  #{ending}
199
220
  RUBY
200
221
  end
201
222
 
202
223
  # @api private
203
224
  # @return [String] the launcher's middle: for an opted-in launcher, usage answered from the
204
- # projection and a boot only for a line that runs a verb; for any other, the boot and
225
+ # projection and a boot only for a line that runs a command; for any other, the boot and
205
226
  # dispatch every launcher has always had, byte for byte
206
- def entry(name, boot, shown, opted)
207
- opted ? described_entry(name, boot, shown) : plain_entry(name, boot, shown)
227
+ def entry(name, boot, shown, opted, quiet: false)
228
+ opted ? described_entry(name, boot, shown, quiet: quiet) : plain_entry(name, boot, shown)
208
229
  end
209
230
 
210
231
  # @api private
211
232
  # @return [String] the opted-in launcher's middle
212
- def described_entry(name, boot, shown)
233
+ def described_entry(name, boot, shown, quiet: false)
234
+ started = "Hecks.boot_described(described, install_doors: false)"
235
+ started = "Hecks::Doors::LauncherOptions.quietly(hold: #{RUNS_ON_MEMORY}) { #{started} }" if quiet
213
236
  <<~RUBY.chomp
214
237
  # Usage is answered from the projected chapter alone: no adapter is bound and no
215
- # database is opened. Only a line that runs a verb boots the domain,
238
+ # database is opened. Only a line that runs a command boots the domain,
216
239
  # and it boots from what the usage check already loaded.
217
240
  domain = #{boot}
218
241
  program = "#{shown}"
@@ -227,7 +250,7 @@ module Hecks
227
250
  )
228
251
  unless text
229
252
  runtime = begin
230
- Hecks.boot_described(described, install_doors: false)
253
+ #{started}
231
254
  rescue StandardError => e
232
255
  abort "cannot open #{name}: \#{e.message.lines.first.strip}"
233
256
  end
@@ -265,11 +288,39 @@ module Hecks
265
288
  end
266
289
 
267
290
  # @api private
268
- # @return [String] the lines that hand the legacy verbs to `Hecks::CLI`, or nothing
291
+ # @param ending [String] the launcher's closing lines
292
+ # @return [String] the same lines, except that a memory command prints no settled record, only
293
+ # the reason it was refused: a person is at it, so the record is noise
294
+ def quiet_ending(ending)
295
+ ending.sub(" puts text\n warn reason\n", MEMORY_REFUSAL)
296
+ .sub("status.zero? ? puts(text)",
297
+ "exit 0 if status.zero? && MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp(\"!\"))\nstatus.zero? ? puts(text)")
298
+ end
299
+
300
+ # @api private
301
+ # @return [String] the lines that default the listed commands to Memory, or nothing
302
+ def memory_default(commands)
303
+ return "" if commands.empty?
304
+
305
+ commands.each { |command| plain!("memory command", command, VERB) }
306
+
307
+ <<~RUBY
308
+
309
+ # These commands keep nothing worth a database, so they run on Memory unless told otherwise.
310
+ # A person is at them: they wait for their result, say why when they are refused, and print
311
+ # no record when they end well.
312
+ MEMORY_COMMANDS = %w[#{commands.join(' ')}].freeze
313
+ ENV["HECKS_ENVIRONMENT"] ||= "memory" if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!"))
314
+ ARGV << "--wait" if MEMORY_COMMANDS.include?(ARGV.first.to_s.chomp("!")) && !ARGV.include?("--wait")
315
+ RUBY
316
+ end
317
+
318
+ # @api private
319
+ # @return [String] the lines that hand the legacy commands to `Hecks::CLI`, or nothing
269
320
  def legacy_handoff(legacy)
270
321
  return "" if legacy.empty?
271
322
 
272
- legacy.each { |verb| plain!("legacy verb", verb, VERB) }
323
+ legacy.each { |command| plain!("legacy command", command, VERB) }
273
324
 
274
325
  <<~RUBY
275
326
 
@@ -5,7 +5,8 @@ require_relative "../projection_files"
5
5
 
6
6
  module Hecks
7
7
  module CLI
8
- # The command behind `hecks project_reference`: regenerates `docs/implemented/reference/` from
8
+ # The command behind `hecks language_run.project_reference`: regenerates
9
+ # `docs/implemented/reference/` from
9
10
  # the language's Syntax chapter. Tables come from the declaration; prose is kept from the
10
11
  # committed pages.
11
12
  module Reference
@@ -39,7 +39,7 @@ module Hecks
39
39
  if argv.first == "--from-run"
40
40
  from_run(root, argv.fetch(1) { abort USAGE }, out)
41
41
  else
42
- from_local(root, argv.fetch(0, "6"), out)
42
+ from_local(root, argv.fetch(0) { abort USAGE }, out)
43
43
  end
44
44
  0
45
45
  end
@@ -6,18 +6,30 @@ require_relative "../fuzzing"
6
6
 
7
7
  module Hecks
8
8
  module CLI
9
- # The command behind `hecks seed_semantics_corpus`: seeds (or deliberately re-seeds) the
10
- # `expect` block of semantics-corpus fixtures from the Ruby runtime. Review the result
9
+ # The command behind `hecks test_suite_run.seed_semantics_corpus`: seeds (or re-seeds) the
10
+ # `expect` block of corpus fixtures from the Ruby runtime. Review the result
11
11
  # against `docs/semantics/bluebook-semantics.md` before committing.
12
12
  #
13
- # hecks seed_semantics_corpus # fill fixtures missing an expect
14
- # SEED=refusal_kind_lifecycle hecks seed_semantics_corpus # re-seed one, deliberately
13
+ # Seeds `spec/corpus/semantics/` and the conformance corpus ({FULL_SCRIPTS} plus
14
+ # `spec/corpus/rust_conformance/`, whose `expect` also freezes more).
15
+ #
16
+ # hecks test_suite_run.seed_semantics_corpus # fill what lacks an expect
17
+ # SEED=refusal_kind_lifecycle hecks test_suite_run.seed_semantics_corpus # re-seed one
15
18
  #
16
19
  # The seeded expectation is the definition, not a recording: both runtimes are compared to it.
17
20
  # `occurred_at` is stripped from events (C7.3/C9.1); refusals keep verb, error and kind (C8.2).
18
21
  module SeedSemanticsCorpus
19
22
  module_function
20
23
 
24
+ # Full corpus scripts held to the conformance bar, keyed by file, valued by domain.
25
+ FULL_SCRIPTS = {
26
+ "spec/corpus/banking.json" => "examples/banking",
27
+ "spec/corpus/chess.json" => "examples/chess"
28
+ }.freeze
29
+
30
+ # The `expect` keys a conformance fixture freezes beyond the semantics three.
31
+ CONFORMANCE_KEYS = %w[queries sagas dry_runs reactions].freeze
32
+
21
33
  # Seeds the fixtures that need it and reports which.
22
34
  #
23
35
  # @param root [String] the checkout whose `spec/corpus/semantics/` is seeded
@@ -26,12 +38,13 @@ module Hecks
26
38
  # @return [Integer] the exit status, always 0
27
39
  def call(root:, env: ENV, out: $stdout)
28
40
  only = env.fetch("SEED", nil)
29
- seeded = Dir.glob(File.join(root, "spec/corpus/semantics", "*.json")).filter_map do |path|
41
+ seeded = corpus_paths(root).filter_map do |path, domain, full|
30
42
  fixture = JSON.parse(File.read(path))
31
43
  name = File.basename(path, ".json")
32
44
  next if only ? only != name : fixture.key?("expect")
33
45
 
34
- fixture["expect"] = expectation_for(root, fixture)
46
+ fixture["domain"] ||= domain if domain
47
+ fixture["expect"] = expectation_for(root, fixture, full: full)
35
48
  File.write(path, "#{JSON.pretty_generate(fixture)}\n")
36
49
  name
37
50
  end
@@ -39,19 +52,35 @@ module Hecks
39
52
  0
40
53
  end
41
54
 
55
+ # @param root [String] the checkout
56
+ # @return [Array<Array>] `[path, domain_to_add_or_nil, full_expect?]` for every seedable file
57
+ def corpus_paths(root)
58
+ semantics = Dir.glob(File.join(root, "spec/corpus/semantics", "*.json")).map { |p| [p, nil, false] }
59
+ conformance = Dir.glob(File.join(root, "spec/corpus/rust_conformance", "*.json")).map { |p| [p, nil, true] }
60
+ scripts = FULL_SCRIPTS.map { |file, domain| [File.join(root, file), domain, true] }
61
+ semantics + conformance + scripts
62
+ end
63
+
42
64
  # Replays the fixture's script against the Ruby runtime. Each refusal's `"kind"` is reduced
43
65
  # to its class name; `occurred_at` is stripped from events.
44
66
  #
45
67
  # @param root [String] the checkout the fixture's domain path is relative to
46
68
  # @param fixture [Hash] the parsed fixture: `domain` and `steps`
47
- # @return [Hash{String => Array}] its `"expect"` block: refusals, instances, events
48
- def expectation_for(root, fixture)
69
+ # @param full [Boolean] also freeze queries, sagas, dry runs, reactions (conformance)
70
+ # @return [Hash{String => Array}] its `"expect"` block: refusals, instances, events, and
71
+ # with `full` the {CONFORMANCE_KEYS}
72
+ def expectation_for(root, fixture, full: false)
49
73
  result = Hecks::Fuzzing::Replay.call(File.join(root, fixture["domain"]), fixture["steps"])
50
- {
74
+ expect = {
51
75
  "refusals" => JSON.parse(JSON.generate(result[:refusals])).each { |r| r["kind"] = r["kind"]&.split("::")&.last },
52
76
  "instances" => JSON.parse(JSON.generate(result[:instances])),
53
77
  "events" => JSON.parse(JSON.generate(result[:events])).each { |e| e.delete("occurred_at") }
54
78
  }
79
+ return expect unless full
80
+
81
+ # instances_at is Replay's per-query snapshot for the property harness, not an outcome.
82
+ expect["queries"] = JSON.parse(JSON.generate(result[:queries].map { |q| q.except(:instances_at) }))
83
+ expect.merge!(CONFORMANCE_KEYS.drop(1).to_h { |k| [k, JSON.parse(JSON.generate(result[k.to_sym]))] })
55
84
  end
56
85
 
57
86
  # @param out [IO] where the report goes