hecks 3.1.0 → 3.1.2

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +1 -1
  3. data/lib/hecks/chapters.rb +4 -3
  4. data/lib/hecks/cli/domain_writer.rb +39 -0
  5. data/lib/hecks/cli/interview_agent.rb +59 -0
  6. data/lib/hecks/cli/interview_run.rb +87 -0
  7. data/lib/hecks/cli/interview_session.rb +299 -0
  8. data/lib/hecks/corpus.rb +1 -0
  9. data/lib/hecks/doors/cli_runner.rb +1 -1
  10. data/lib/hecks/doors/usage_cache.rb +123 -0
  11. data/lib/hecks/hecks/adapters/finding_github.adapter +7 -0
  12. data/lib/hecks/hecks/adapters/finding_github.rb +114 -0
  13. data/lib/hecks/hecks/adapters/terminal.rb +15 -0
  14. data/lib/hecks/hecks/context_map.hecksagon +1 -1
  15. data/lib/hecks/hecks/custodian.bluebook +36 -0
  16. data/lib/hecks/hecks/hecks.bluebook +18 -0
  17. data/lib/hecks/hecks/hecks.hecksagon +8 -2
  18. data/lib/hecks/hecks/hecks.world +3 -2
  19. data/lib/hecks/projections/deploy/box/settings.rb +87 -4
  20. data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +2 -0
  21. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +18 -5
  22. data/lib/hecks/projections/deploy/box/templates/rds.yaml.tmpl +17 -1
  23. data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +49 -0
  24. data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +1 -1
  25. data/lib/hecks/projections/deploy/box/templates/restore-to-rds.sh.tmpl +91 -0
  26. data/lib/hecks/projections/deploy/box/templates/verify-copy.sh.tmpl +58 -0
  27. data/lib/hecks/projections/deploy/box.rb +216 -20
  28. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +20 -4
  29. data/lib/hecks/projections/deploy/scripts/settings.rb +6 -0
  30. data/lib/hecks/projector/cli_projector.rb +38 -14
  31. data/lib/hecks/runtime/loader.rb +31 -8
  32. data/lib/hecks/tickets/bluebook/tickets.bluebook +331 -0
  33. data/lib/hecks/tickets/bluebook/tickets.hecksagon +11 -0
  34. data/lib/hecks/tickets/bluebook/tickets.ports.hecksagon +26 -0
  35. data/lib/hecks/version.rb +1 -1
  36. data/lib/hecks.rb +1 -0
  37. data/rust/codegen/src/commands.rs +4 -32
  38. data/rust/codegen/src/json_codec.rs +3 -0
  39. data/rust/codegen/src/mutations.rs +74 -5
  40. data/rust/codegen/src/naming.rs +4 -0
  41. data/rust/codegen/src/types.rs +45 -0
  42. data/rust/host/HECKS_RELEASE +1 -1
  43. data/rust/host/src/web/registration_receipt/tests.rs +39 -0
  44. data/rust/host/src/web/registration_receipt.rs +122 -0
  45. data/rust/host/src/web/registrations.rs +14 -2
  46. data/rust/host/src/web.rs +1 -0
  47. metadata +17 -2
@@ -400,16 +400,15 @@ module Hecks
400
400
  return command_help(program, spec[:short], spec, ask: options[:ask]) if spec
401
401
  end
402
402
 
403
- width = (commands.values + questions.values).map { |spec| label(spec).length }.max.to_i
404
403
  out = ["#{bluebook.name} — #{bluebook.vision}", "",
405
404
  " #{program} <command>! [name=value …] do something",
406
405
  " #{program} query <query> [name=value …] read something", ""]
407
406
 
408
407
  out << "commands:"
409
- out.concat(listing(commands, width) { |spec| spec[:summary] })
408
+ out.concat(listing(commands) { |spec| spec[:summary] })
410
409
  out << ""
411
410
  out << "queries (nothing here changes anything):"
412
- out.concat(listing(questions, width) { |spec| first_sentence(spec[:summary]) })
411
+ out.concat(listing(questions) { |spec| first_sentence(spec[:summary]) })
413
412
  out << ""
414
413
  out << " #{program} <command> --help what one command wants, and every way it refuses"
415
414
  out << " a command is called with its aggregate — #{example_qualified(commands)}"
@@ -417,34 +416,59 @@ module Hecks
417
416
  end
418
417
 
419
418
  # The command or question lines of the help. A domain with more than one aggregate is listed
420
- # under a heading per aggregate, so related commands sit together; the bookkeeping a run
419
+ # under a heading per aggregate, so related commands sit together; the heading is the prefix
420
+ # every call to them carries, so the lines under it leave it out. The bookkeeping a run
421
421
  # records about itself (`internal`: system-role commands and port operations) is set apart as
422
- # names only, since a person never types them. A single-aggregate domain keeps the plain list.
423
- def listing(specs, width)
422
+ # names only, since a person never types them. A single-aggregate domain keeps the plain
423
+ # list, each name in full.
424
+ def listing(specs)
424
425
  shown, internal = specs.values.partition { |spec| !spec[:internal] }
425
426
  groups = shown.group_by { |spec| spec[:group] }
427
+ grouped = groups.length > 1
428
+ named = shown.to_h { |spec| [spec, entry_name(spec, grouped)] }
429
+ width = named.values.map(&:length).max.to_i
426
430
  lines = []
427
- if groups.length > 1
431
+ if grouped
428
432
  groups.each do |group, members|
429
433
  lines << " #{heading(group)}" if group
430
- members.each { |spec| lines << " #{label(spec).ljust(width)} #{yield(spec)}" }
434
+ members.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
431
435
  end
432
436
  else
433
- shown.each { |spec| lines << " #{label(spec).ljust(width)} #{yield(spec)}" }
437
+ shown.each { |spec| lines << " #{named[spec].ljust(width)} #{yield(spec)}#{alias_note(spec)}" }
434
438
  end
435
439
  lines.concat(internal_lines(internal)) unless internal.empty?
436
440
  lines
437
441
  end
438
442
 
439
- # "LanguageRun" reads "Language": the Run suffix names the journaled-command shape all of
440
- # these aggregates share, so it adds nothing under a heading ("Test suite", "Model check").
443
+ # The aggregate's own name as a heading: the prefix of every call to the lines under it.
441
444
  def heading(group)
442
- words = Naming.snake(group.sub(/Run\z/, "")).tr("_", " ")
443
- "#{words.capitalize}:"
445
+ "#{Naming.snake(group)}:"
446
+ end
447
+
448
+ # A spec's name as listed: under its aggregate's heading the aggregate prefix is left out.
449
+ # A command the chapter gives a short name (`mcp`) is listed by its real name, so the
450
+ # heading and the line still spell a call; `alias_note` says the short name.
451
+ def entry_name(spec, grouped)
452
+ name = label(spec, real: true)
453
+ return name unless grouped && spec[:group]
454
+
455
+ name.delete_prefix("#{Naming.snake(spec[:group])}.")
456
+ end
457
+
458
+ # " (also: mcp!)" for a spec the chapter gave a short name, else nothing. A short name that
459
+ # is only the command's own name (`init` for `door.init`) is already in the line.
460
+ def alias_note(spec)
461
+ return "" if spec[:short_was].nil? || spec[:short_was].split(".").last == spec[:short]
462
+
463
+ " (also: #{label(spec)})"
444
464
  end
445
465
 
446
466
  # A command is written with the `!` that marks it; a query without one.
447
- def label(spec) = spec[:kind] == :command ? "#{spec[:short]}!" : spec[:short]
467
+ # With `real:`, a short name the chapter gave it gives way to the name it was given for.
468
+ def label(spec, real: false)
469
+ name = real && spec.key?(:short_was) ? spec[:short_was] : spec[:short]
470
+ spec[:kind] == :command ? "#{name}!" : name
471
+ end
448
472
 
449
473
  # The internal commands as bare names, wrapped, under one line saying what they are.
450
474
  def internal_lines(specs)
@@ -50,7 +50,26 @@ module Hecks
50
50
  end
51
51
 
52
52
  # What `describe` answers: the loaded declarations and nothing bound to run them.
53
- Described = Struct.new(:registry, :directory)
53
+ #
54
+ # The declarations load the first time `registry` is asked for, not when `describe` returns,
55
+ # so a caller that can answer from `directory` alone (a launcher with its help already
56
+ # remembered) never pays for the load. The directory is checked at once.
57
+ class Described
58
+ # @return [String] the domain directory that was described
59
+ attr_reader :directory
60
+
61
+ # @param directory [String] the domain directory
62
+ # @yield loads the declarations; answers the registry
63
+ def initialize(directory, &load)
64
+ @directory = directory
65
+ @load = load
66
+ end
67
+
68
+ # @return [Registry] the declarations, loaded on first use
69
+ def registry
70
+ @registry ||= @load.call
71
+ end
72
+ end
54
73
 
55
74
  # Loads `path`'s declarations into a fresh Registry and stops: no boot gate runs, no
56
75
  # persistence adapter is resolved or bound, nothing connects to a database.
@@ -66,15 +85,19 @@ module Hecks
66
85
  def self.describe(path, shared: nil, environment: FROM_ENV)
67
86
  loading = Ports::Loading.bootstrap
68
87
  directory = loading.bluebook_directory(path)
69
- root = loading.shared_root(shared, directory)
70
- registry = Registry.new(root: File.dirname(directory))
88
+ overlay = selected_environment(environment)
71
89
 
72
- Hecks.with_registry(registry) do
73
- loading.load_library
74
- loading.load_project(root)
75
- loading.load_domain(directory, environment: selected_environment(environment))
90
+ Described.new(directory) do
91
+ root = loading.shared_root(shared, directory)
92
+ registry = Registry.new(root: File.dirname(directory))
93
+
94
+ Hecks.with_registry(registry) do
95
+ loading.load_library
96
+ loading.load_project(root)
97
+ loading.load_domain(directory, environment: overlay)
98
+ end
99
+ registry
76
100
  end
77
- Described.new(registry, directory)
78
101
  end
79
102
 
80
103
  # The overlay a boot loads: the caller's own choice (nil meaning none), else the
@@ -0,0 +1,331 @@
1
+ # The Tickets chapter: what a maintainer observed about Hecks that needs a decision or a fix, the
2
+ # and the decisions that settle them.
3
+ #
4
+ # QualityControl has its own Ticket, the issue raised for a Bug in an outside tracker, and the
5
+ # Hecks domain has its own Release, the record of a gem version. A Finding sits before both: it is
6
+ # any observation, not yet a proven bug, and it needs no sweep or failing test to be recorded.
7
+ #
8
+ # A Finding is the record. A gap, a parity divergence, a QA finding and an idea are kinds of it, not
9
+ # separate aggregates. Hecks owns the finding's state; GitHub is driven from it through the
10
+ # GitHubIssues port, which a hecksagon binds (nothing here reads GitHub back). A finding exists whether
11
+ # or not GitHub answers: each lifecycle event asks the port, and a refused ask is recorded on the
12
+ # finding as a sync failure that a later run retries.
13
+ #
14
+ # An Adr is a decision record with its own lifecycle, linked to the finding it settles.
15
+ Hecks.bluebook "Tickets" do
16
+ vision "Track what Hecks needs, decide it, ship it, and drive GitHub from the record."
17
+
18
+ aggregate "Finding" do
19
+ description "One thing observed about Hecks that needs a decision or a fix: reported, triaged, then resolved or dismissed."
20
+
21
+ attribute :finding, FindingKey
22
+ attribute :title, Title
23
+ attribute :body, Body, optional: true
24
+ attribute :kind, Kind, optional: true
25
+ attribute :severity, Severity, optional: true
26
+ attribute :source, Source
27
+ attribute :fingerprint, Fingerprint, optional: true
28
+ attribute :issue_number, Number, optional: true
29
+ attribute :fix_pr_number, Number, optional: true
30
+ attribute :dismissal, Body, optional: true
31
+ attribute :sync_failure, Body, optional: true
32
+ identified_by :finding
33
+
34
+ value_object "FindingKey" do
35
+ attribute :value, String, pattern: '[^ \t\n\r]'
36
+
37
+ invariant("a finding is named") { !value.to_s.empty? }
38
+ end
39
+
40
+ value_object "Title" do
41
+ attribute :value, String
42
+
43
+ invariant("a finding has a title") { !value.to_s.empty? }
44
+ end
45
+
46
+ value_object "Body" do
47
+ attribute :value, String
48
+ end
49
+
50
+ # What the finding is: a `gap` Hecks cannot yet express, a `parity` divergence between the Ruby
51
+ # and Rust runtimes, a `qa` finding from the QA loop, or an `idea`.
52
+ value_object "Kind" do
53
+ attribute :value, String, one_of: ["gap", "parity", "qa", "idea"]
54
+ end
55
+
56
+ value_object "Severity" do
57
+ attribute :value, String, one_of: ["low", "medium", "high"]
58
+ end
59
+
60
+ # Where it came from: the QA loop, work for a client, a parity check, or a maintainer by hand.
61
+ value_object "Source" do
62
+ attribute :value, String, one_of: ["qa_loop", "client_work", "parity_check", "maintainer"]
63
+ end
64
+
65
+ # The same finding refiled by the QA loop carries the same fingerprint, so a report of it while an
66
+ # open finding holds it changes nothing.
67
+ value_object "Fingerprint" do
68
+ attribute :value, String, pattern: '[^ \t\n\r]'
69
+ end
70
+
71
+ value_object "Number" do
72
+ attribute :value, Integer
73
+
74
+ invariant("a number is positive") { value.positive? }
75
+ end
76
+
77
+ lifecycle :status, default: "reported" do
78
+ transition "Triage" => "triaged", from: %w[reported triaged]
79
+ transition "Resolve" => "resolved", from: "triaged"
80
+ transition "Dismiss" => "dismissed", from: %w[reported triaged]
81
+ transition "Reopen" => "triaged", from: %w[resolved dismissed]
82
+ end
83
+
84
+ command "Report" do
85
+ goal "Record something observed about Hecks as a finding"
86
+
87
+ attribute :finding, FindingKey
88
+ attribute :title, Title
89
+ attribute :body, Body, optional: true
90
+ attribute :source, Source
91
+ attribute :fingerprint, Fingerprint, optional: true
92
+
93
+ sets :finding
94
+ sets :title
95
+ sets :body
96
+ sets :source
97
+ sets :fingerprint
98
+
99
+ emits "FindingReported"
100
+ end
101
+
102
+ # A failed Codebase run reports itself: the run's key names the finding and doubles as its
103
+ # fingerprint, so the same run failing again while its finding is open changes nothing.
104
+ command "ReportFailedRun" do
105
+ goal "Record a failed conformance or gate run as a finding"
106
+
107
+ attribute :finding, FindingKey
108
+ attribute :body, Body
109
+
110
+ sets :finding
111
+ sets :body
112
+ sets :fingerprint, to: "run"
113
+ sets :title, to: "A Codebase run failed"
114
+ sets :source, to: "qa_loop"
115
+
116
+ emits "RunFindingReported"
117
+ end
118
+
119
+ command "Triage" do
120
+ role "Maintainer"
121
+ goal "Give a finding its kind and severity"
122
+
123
+ reference_to Finding
124
+ attribute :kind, Kind
125
+ attribute :severity, Severity
126
+
127
+ sets :kind
128
+ sets :severity
129
+
130
+ emits "FindingTriaged"
131
+ end
132
+
133
+ command "LinkFix" do
134
+ role "Maintainer"
135
+ goal "Record the pull request that fixes a triaged finding"
136
+
137
+ reference_to Finding
138
+ attribute :fix_pr_number, Number
139
+
140
+ sets :fix_pr_number
141
+
142
+ emits "FixLinked"
143
+ end
144
+
145
+ command "Resolve" do
146
+ role "Maintainer"
147
+ goal "Close a triaged finding as resolved"
148
+
149
+ reference_to Finding
150
+
151
+ emits "FindingResolved"
152
+ end
153
+
154
+ command "Dismiss" do
155
+ role "Maintainer"
156
+ goal "Close a finding without a fix, with the reason"
157
+
158
+ reference_to Finding
159
+ attribute :dismissal, Body
160
+
161
+ sets :dismissal
162
+
163
+ emits "FindingDismissed"
164
+ end
165
+
166
+ command "Reopen" do
167
+ role "Maintainer"
168
+ goal "Take a resolved or dismissed finding back to triaged, as when a fix regresses"
169
+
170
+ reference_to Finding
171
+
172
+ emits "FindingReopened"
173
+ end
174
+
175
+ command "RecordIssue" do
176
+ goal "Record the GitHub issue number once the issue is opened"
177
+
178
+ reference_to Finding
179
+ attribute :issue_number, Number
180
+
181
+ sets :issue_number
182
+
183
+ emits "IssueRecorded"
184
+ end
185
+
186
+ command "RecordSyncFailure" do
187
+ goal "Record that GitHub could not be driven, so a later run retries"
188
+
189
+ reference_to Finding
190
+ attribute :sync_failure, Body
191
+
192
+ sets :sync_failure
193
+
194
+ emits "SyncFailed"
195
+ end
196
+
197
+ query "Open" do
198
+ description "Every finding not yet resolved or dismissed."
199
+ where(status: { in: "reported,triaged" })
200
+ order_by :finding
201
+ end
202
+ end
203
+
204
+ aggregate "Adr" do
205
+ description "A decision record: drafted, accepted, and possibly superseded, linked to the tickets it settles."
206
+
207
+ attribute :adr, AdrNumber
208
+ attribute :title, AdrTitle
209
+ attribute :supersedes, AdrNumber, optional: true
210
+ attribute :settles, FindingKey, optional: true
211
+ identified_by :adr
212
+
213
+ value_object "AdrNumber" do
214
+ attribute :value, String, pattern: '\A[0-9]{4}\z'
215
+ end
216
+
217
+ value_object "AdrTitle" do
218
+ attribute :value, String
219
+
220
+ invariant("a decision has a title") { !value.to_s.empty? }
221
+ end
222
+
223
+ value_object "FindingKey" do
224
+ attribute :value, String, pattern: '[^ \t\n\r]'
225
+ end
226
+
227
+ lifecycle :status, default: "draft" do
228
+ transition "Accept" => "accepted", from: "draft"
229
+ transition "Supersede" => "superseded", from: "accepted"
230
+ end
231
+
232
+ command "Draft" do
233
+ role "Maintainer"
234
+ goal "Open a decision record, optionally for the finding it settles"
235
+
236
+ attribute :adr, AdrNumber
237
+ attribute :title, AdrTitle
238
+ attribute :settles, FindingKey, optional: true
239
+
240
+ sets :adr
241
+ sets :title
242
+ sets :settles
243
+
244
+ emits "AdrDrafted"
245
+ end
246
+
247
+ command "Accept" do
248
+ role "Maintainer"
249
+ goal "Record that the decision was accepted"
250
+
251
+ reference_to Adr
252
+
253
+ emits "AdrAccepted"
254
+ end
255
+
256
+ command "Supersede" do
257
+ role "Maintainer"
258
+ goal "Record that a later decision replaces this one"
259
+
260
+ reference_to Adr
261
+ attribute :supersedes, AdrNumber
262
+
263
+ sets :supersedes
264
+
265
+ emits "AdrSuperseded"
266
+ end
267
+
268
+ query "Standing" do
269
+ description "Every decision that is accepted and not superseded."
270
+ where(status: "accepted")
271
+ order_by :adr
272
+ end
273
+ end
274
+
275
+ # Each lifecycle event drives GitHub. A finding's own state never waits on the answer.
276
+ policy "OpenIssueWhenReported" do
277
+ on "Finding.FindingReported"
278
+ trigger Finding::GitHubIssues::OpenIssue, with: { finding: :finding }
279
+ end
280
+
281
+ policy "OpenIssueWhenRunReported" do
282
+ on "Finding.RunFindingReported"
283
+ trigger Finding::GitHubIssues::OpenIssue, with: { finding: :finding }
284
+ end
285
+
286
+ policy "LabelIssueWhenTriaged" do
287
+ on "Finding.FindingTriaged"
288
+ trigger Finding::GitHubIssues::LabelIssue, with: { finding: :finding }
289
+ end
290
+
291
+ policy "CommentWhenFixLinked" do
292
+ on "Finding.FixLinked"
293
+ trigger Finding::GitHubIssues::CommentOnIssue, with: { finding: :finding }
294
+ end
295
+
296
+ policy "CloseIssueWhenResolved" do
297
+ on "Finding.FindingResolved"
298
+ trigger Finding::GitHubIssues::CloseIssue, with: { finding: :finding }
299
+ end
300
+
301
+ policy "CloseIssueWhenDismissed" do
302
+ on "Finding.FindingDismissed"
303
+ trigger Finding::GitHubIssues::CloseIssue, with: { finding: :finding }
304
+ end
305
+
306
+ policy "ReopenIssueWhenReopened" do
307
+ on "Finding.FindingReopened"
308
+ trigger Finding::GitHubIssues::ReopenIssue, with: { finding: :finding }
309
+ end
310
+
311
+ # A conformance or gate run that faults files its own finding.
312
+ policy "ReportWhenConformanceFaulted" do
313
+ on "ConformanceRun.ConformanceFaulted"
314
+ trigger Finding::ReportFailedRun, with: { finding: :run, body: :refusal }
315
+ end
316
+
317
+ policy "ReportWhenGateFaulted" do
318
+ on "GateRun.GateFaulted"
319
+ trigger Finding::ReportFailedRun, with: { finding: :run, body: :refusal }
320
+ end
321
+
322
+ policy "RecordTheIssue" do
323
+ on "Finding.IssueOpened"
324
+ trigger Finding::RecordIssue, with: { issue_number: :issue_number }
325
+ end
326
+
327
+ policy "RecordTheSyncFailure" do
328
+ on "Finding.GitHubRefused"
329
+ trigger Finding::RecordSyncFailure, with: { sync_failure: :refusal }
330
+ end
331
+ end
@@ -0,0 +1,11 @@
1
+ # The Tickets domain, bound so it boots standing alone (a fuzz or model_check boot of this
2
+ # directory). The GitHubIssues port every attaching hecksagon shares is in tickets.ports.hecksagon.
3
+ Hecks.hecksagon "Tickets" do
4
+ attaches "Governance"
5
+ persisted_by "Memory"
6
+ end
7
+
8
+ # The anti-corruption layer for Governance, which the chapter's roles are checked against.
9
+ Hecks.hecksagon "Governance" do
10
+ persisted_by "Memory"
11
+ end
@@ -0,0 +1,26 @@
1
+ # The port Tickets declares, for every hecksagon that attaches the chapter (`Chapters` loads this
2
+ # file with it). Each lifecycle event of a finding asks the GitHubIssues port, which the Hecks domain
3
+ # binds (lib/hecks/hecks/adapters/finding_github.rb): the outside a finding reaches. The ask is
4
+ # answered with the record's own arguments or refused with a reason, and a refusal is recorded on the
5
+ # finding and never blocks it. The port has to be declared even where no adapter binds it, so the
6
+ # policies have a target and model_check's deaf-policy check can see it.
7
+ #
8
+ # Only OpenIssue answers with the issue number, so only its answer is recorded on the finding; the
9
+ # other asks answer with a line saying what was done.
10
+ Hecks.hecksagon "Tickets" do
11
+ Tickets::Finding.port "GitHubIssues" do
12
+ asks "OpenIssue", to: Finding do
13
+ attribute :finding, FindingKey
14
+ answers "IssueOpened"
15
+ refuses "GitHubRefused"
16
+ end
17
+
18
+ %w[LabelIssue CommentOnIssue CloseIssue ReopenIssue].each do |ask|
19
+ asks ask, to: Finding do
20
+ attribute :finding, FindingKey
21
+ answers "GitHubAnswered"
22
+ refuses "GitHubRefused"
23
+ end
24
+ end
25
+ end
26
+ end
data/lib/hecks/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  module Hecks
2
2
  # Read directly by the gemspec, so this file must never require the
3
3
  # rest of the gem (its dependencies aren't resolved yet at that point).
4
- VERSION = "3.1.0".freeze
4
+ VERSION = "3.1.2".freeze
5
5
  end
data/lib/hecks.rb CHANGED
@@ -29,6 +29,7 @@ require_relative "hecks/projector"
29
29
  require_relative "hecks/projections"
30
30
  # After `Projector`, which dispatches against the `:cli` projection.
31
31
  require_relative "hecks/doors/cli_door"
32
+ require_relative "hecks/doors/usage_cache"
32
33
  require_relative "hecks/doors/cli_runner"
33
34
  require_relative "hecks/storehouse"
34
35
  require_relative "hecks/mcp_stdio_guard"
@@ -444,14 +444,7 @@ pub fn emit_command(exemplar: &Exemplar, command: &Json, aggregate: &Json, domai
444
444
  .collect();
445
445
 
446
446
  let transition = mutations::lifecycle_transition_for(command, aggregate);
447
- let transition_arg = match &transition {
448
- Some(t) => format!(
449
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
450
- naming::ruby_inspect_string(&t.field),
451
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
452
- ),
453
- None => "None".to_string(),
454
- };
447
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
455
448
 
456
449
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
457
450
  // A `delegate` mutation is rendered by `delegation_of`, which overwrites `mutation_lines`;
@@ -772,14 +765,7 @@ fn delegation_of(exemplar: &Exemplar, command: &Json, aggregate: &Json, value_ob
772
765
  })
773
766
  .collect();
774
767
  let transition = mutations::lifecycle_transition_for(target, entity);
775
- let transition_arg = match &transition {
776
- Some(t) => format!(
777
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
778
- naming::ruby_inspect_string(&t.field),
779
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
780
- ),
781
- None => "None".to_string(),
782
- };
768
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
783
769
  let mut mutation_lines: Vec<String> = target
784
770
  .get("mutations")
785
771
  .map(Json::each)
@@ -896,14 +882,7 @@ pub fn emit_entity_command(
896
882
  .collect();
897
883
 
898
884
  let transition = mutations::lifecycle_transition_for(command, entity);
899
- let transition_arg = match &transition {
900
- Some(t) => format!(
901
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
902
- naming::ruby_inspect_string(&t.field),
903
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
904
- ),
905
- None => "None".to_string(),
906
- };
885
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
907
886
 
908
887
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
909
888
  let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, entity, command, value_objects_by_name, false)).collect();
@@ -1063,14 +1042,7 @@ pub fn emit_nested_entity_command(
1063
1042
 
1064
1043
  // The nested entity's lifecycle, as in `emit_entity_command`.
1065
1044
  let transition = mutations::lifecycle_transition_for(command, nested);
1066
- let transition_arg = match &transition {
1067
- Some(t) => format!(
1068
- "Some(crate::kernel::TransitionCheck {{ field: {}, from_states: &[{}] }})",
1069
- naming::ruby_inspect_string(&t.field),
1070
- t.from_states.iter().map(|s| naming::ruby_inspect_string(s)).collect::<Vec<_>>().join(", ")
1071
- ),
1072
- None => "None".to_string(),
1073
- };
1045
+ let transition_arg = mutations::transition_check_arg(transition.as_ref());
1074
1046
 
1075
1047
  let mutations_list = command.get("mutations").map(Json::each).unwrap_or(&[]);
1076
1048
  let mut mutation_lines: Vec<String> = mutations_list.iter().map(|m| mutations::emit_mutation_line(exemplar, m, nested, command, value_objects_by_name, false)).collect();
@@ -44,6 +44,7 @@ pub fn scalar_json_accessor(scalar_type: &str) -> &'static str {
44
44
  "String" => "as_str",
45
45
  "Integer" => "as_i64",
46
46
  "Float" => "as_f64",
47
+ "TrueClass" | "FalseClass" => "as_bool",
47
48
  other => panic!("no JSON accessor for scalar type {other:?}"),
48
49
  }
49
50
  }
@@ -77,6 +78,7 @@ pub fn scalar_to_json_expr(scalar_type: &str, rust_expr: &str) -> String {
77
78
  "String" => format!("crate::kernel::Json::Str({rust_expr}.clone())"),
78
79
  "Integer" => format!("crate::kernel::Json::int({rust_expr})"),
79
80
  "Float" => format!("crate::kernel::Json::Float({rust_expr})"),
81
+ "TrueClass" | "FalseClass" => format!("crate::kernel::Json::Bool({rust_expr})"),
80
82
  other => panic!("no to_json expr for scalar type {other:?}"),
81
83
  }
82
84
  }
@@ -565,6 +567,7 @@ pub fn emit_closed_set_table_codec(exemplar: &Exemplar, vo: &Json) -> String {
565
567
  Some("String") => format!("crate::kernel::Json::Str(self.{ident}.to_string())"),
566
568
  Some("Integer") => format!("crate::kernel::Json::int(self.{ident})"),
567
569
  Some("Float") => format!("crate::kernel::Json::Float(self.{ident})"),
570
+ Some("TrueClass" | "FalseClass") => format!("crate::kernel::Json::Bool(self.{ident})"),
568
571
  _ => String::new(),
569
572
  };
570
573
  exemplar.render("to_json_field", &[("\"tmpl_field_name\"", naming::ruby_inspect_string(&key)), ("tmpl_json_value_placeholder()", value_expr)])