hecks 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 07b56eefa1dd5cd2cc6308fa35aab4b8aba8af59da9418283d98a1235268e3c4
4
- data.tar.gz: cc996a57a43dc3652a062c6af748545ec5846d50c08a43d372224e402c60fbf7
3
+ metadata.gz: 9df85914ab1de8512f099777f6129d4ed2c75fa26cb29de9162fb82ddc66b6ee
4
+ data.tar.gz: 6e171cfe202477bded39de3591ffa009b5738136e28444d042d408db1888d99a
5
5
  SHA512:
6
- metadata.gz: aab93180168cb073c8f65b05697cfe8c94f68d43a0516e2f8540d0c457d2ff238173fc223006f72e1681b870dcdd86b1ae65d1883d7d5375f62207b2b4a08b6d
7
- data.tar.gz: 941f1214a2f1f7a144c57e8c0fc780d5444133973170afbc1d5dd6e646c0456feaaf73ac58da9382fc0b229af37a929e15eb8b77dd0dc36216187fd2341d8e04
6
+ metadata.gz: 8cd2ea1bd846b0064d56e6309e8cd99e2a0b8c7d2dfe368a021e2af7c5b8a377068ade46e6367d63e844a5eb735f4067d12fc29ce3a63f10dcf1c03545a4388c
7
+ data.tar.gz: b0dea44c43a300f81ea80c1917852e90b12093c9ee2e651f032bf0b06bc89bca88c1165ddb72f2c69653cd89e2e74006779325dd478fa28c44d9b130b714fbbb
@@ -580,7 +580,7 @@ module Hecks
580
580
  def numeric_field?(field)
581
581
  name, *path = field.to_s.split(".")
582
582
  QuerySpecification::FieldPath.numeric?(@aggregate.attribute(name), path) do |type|
583
- @aggregate.value_object(type)
583
+ Runtime::Value.value_object_for(@aggregate, type)
584
584
  end
585
585
  end
586
586
 
@@ -187,7 +187,7 @@ module Hecks
187
187
  numeric ? numeric.first : nil
188
188
  end
189
189
  member ||= if path.empty? && attribute && value_object?(attribute)
190
- object = @aggregate.value_object(attribute.type)
190
+ object = Runtime::Value.value_object_for(@aggregate, attribute.type)
191
191
  # A sole attribute is the fallback when no member is
192
192
  # numeric — a single-attribute value object is its one
193
193
  # field whatever that field is named (`Behaviour::
@@ -230,7 +230,7 @@ module Hecks
230
230
  end
231
231
 
232
232
  def value_object?(attr)
233
- !attr.list? && !@aggregate.value_object(attr.type).nil?
233
+ !attr.list? && !Runtime::Value.value_object_for(@aggregate, attr.type).nil?
234
234
  end
235
235
 
236
236
  # `contains` on a `list_of` field means real element membership, not
@@ -250,7 +250,7 @@ module Hecks
250
250
  attribute = @aggregate.attribute(field.to_s)
251
251
  return nil unless attribute&.list?
252
252
 
253
- object = @aggregate.value_object(attribute.type)
253
+ object = Runtime::Value.value_object_for(@aggregate, attribute.type)
254
254
  return "" unless object
255
255
  return object.sole_attribute.name.to_s if object.sole_attribute
256
256
 
@@ -48,8 +48,22 @@ module Hecks
48
48
  # vocabulary), not commands.
49
49
  PAYMENTS = "payments".freeze
50
50
 
51
+ # Scheduling sessions and taking registrations for them — recognised
52
+ # by declaration, not the literal aggregate names "Event" and
53
+ # "Registration". rust/host's event and registration routes dispatch
54
+ # these verbs; the aggregates they name are the event and the
55
+ # registration.
56
+ REGISTRATIONS = "registrations".freeze
57
+
58
+ # A business's connection to its payment processor: connect it, drop
59
+ # it, pause it, and switch taking payments on or off — recognised by
60
+ # declaration, not the literal name "PaymentConnection". rust/host's
61
+ # payments routes dispatch these verbs; the connection aggregate is
62
+ # the one `connect` names.
63
+ PAYMENT_CONNECTION = "payment_connection".freeze
64
+
51
65
  CONTRACTS = {
52
- AUTHORIZATION => {
66
+ AUTHORIZATION => {
53
67
  # every assignment an actor holds, current or historical
54
68
  assignments: :query,
55
69
  # the command that grants an actor a role
@@ -57,7 +71,7 @@ module Hecks
57
71
  # every grant of one role acting as another
58
72
  transitions: :query
59
73
  }.freeze,
60
- MEMBERSHIP => {
74
+ MEMBERSHIP => {
61
75
  # recognize a person who may eventually sign in
62
76
  admit: :command,
63
77
  # grant an admitted person a role (the access-grant half)
@@ -65,7 +79,7 @@ module Hecks
65
79
  # every admitted person, for the admin listing
66
80
  people: :query
67
81
  }.freeze,
68
- IDENTITY => {
82
+ IDENTITY => {
69
83
  # mint a stable identity, independent of how it authenticated
70
84
  register: :command,
71
85
  # associate an (issuer, subject) pair with that identity
@@ -73,7 +87,7 @@ module Hecks
73
87
  # look up the identity an authenticated pair resolves to
74
88
  resolve: :query
75
89
  }.freeze,
76
- NEWSLETTER => {
90
+ NEWSLETTER => {
77
91
  # a guest signs up; the aggregate it names is the subscriber
78
92
  subscribe: :command,
79
93
  # attach a display name to an existing subscriber
@@ -83,20 +97,42 @@ module Hecks
83
97
  # a subscriber leaves from the emailed link
84
98
  unsubscribe: :command
85
99
  }.freeze,
86
- NEWSLETTER_ISSUES => {
100
+ NEWSLETTER_ISSUES => {
87
101
  # mark an issue sent; the issue aggregate is the one it names
88
102
  send_issue: :command,
89
103
  # record one email handed to the mail provider; the delivery
90
104
  # aggregate is the one it names
91
105
  record_delivery: :command
92
106
  }.freeze,
93
- PAYMENTS => {
107
+ PAYMENTS => {
94
108
  # start a payment; the aggregate it names is the payment
95
109
  initiate: :command,
96
110
  # the processor reports the money arrived
97
111
  succeeded: :port_operation,
98
112
  # the processor reports the payment failed or expired
99
113
  failed: :port_operation
114
+ }.freeze,
115
+ REGISTRATIONS => {
116
+ # schedule a session; the aggregate it names is the event
117
+ schedule: :command,
118
+ # a guest asks for a place; the aggregate it names is the registration
119
+ request: :command
120
+ }.freeze,
121
+ PAYMENT_CONNECTION => {
122
+ # link the processor account; the aggregate it names is the connection
123
+ connect: :command,
124
+ # link a different processor account after a disconnect
125
+ reconnect: :command,
126
+ # drop the link
127
+ disconnect: :command,
128
+ # pause the connection without dropping it
129
+ suspend: :command,
130
+ # lift a pause
131
+ resume: :command,
132
+ # switch taking payments on
133
+ enable: :command,
134
+ # switch taking payments off
135
+ disable: :command
100
136
  }.freeze
101
137
  }.freeze
102
138
  end
@@ -136,7 +136,8 @@ module Hecks
136
136
  # `Runtime::PolicyInterpreter` against the triggering event's payload; never called here
137
137
  # @return [void]
138
138
  # @raise [Bluebook::DSL::Malformed] if the block's source could not be extracted, or if it
139
- # references a pattern `Expression::AstJson.refuse_unshared_patterns!` does not share
139
+ # references a pattern `Expression::AstJson.refuse_unshared_patterns!` does not share, or
140
+ # calls a method the expression language does not have
140
141
  def where(&predicate)
141
142
  canonical = Ports::Extraction.canonical(predicate)
142
143
 
@@ -147,8 +148,9 @@ module Hecks
147
148
  end
148
149
 
149
150
  # C3.6 — the same PatternSubset check every rule site gets.
150
- Expression::AstJson.refuse_unshared_patterns!(Expression::AstJson.emit_predicate(canonical),
151
- owner: @name, word: "where")
151
+ ast = Expression::AstJson.emit_predicate(canonical)
152
+ Expression::AstJson.refuse_unshared_patterns!(ast, owner: @name, word: "where")
153
+ Expression::AstJson.refuse_unresolvable_lookups!(ast, owner: @name, word: "where")
152
154
  @where = canonical
153
155
  end
154
156
 
@@ -71,7 +71,8 @@ module Hecks
71
71
  # @return [Bluebook::Given, Bluebook::Invariant] the built rule struct, an instance of
72
72
  # `struct_class`
73
73
  # @raise [Bluebook::DSL::Malformed] if `predicate`'s source could not be extracted, or
74
- # it matches against a pattern construct `Expression::AstJson::PatternSubset` refuses
74
+ # it matches against a pattern construct `Expression::AstJson::PatternSubset` refuses, or
75
+ # it calls a method the expression language does not have
75
76
  def build_rule(struct_class, description, predicate, owner_name:, word:, extraction_failure:)
76
77
  canonical = Ports::Extraction.canonical(predicate)
77
78
 
@@ -81,8 +82,10 @@ module Hecks
81
82
  "extraction — #{extraction_failure}"
82
83
  end
83
84
 
84
- ast = Expression::AstJson.refuse_unshared_patterns!(Expression::AstJson.emit_predicate(canonical),
85
- owner: owner_name, word: "#{word} #{description.inspect}")
85
+ rule_word = "#{word} #{description.inspect}"
86
+ ast = Expression::AstJson.emit_predicate(canonical)
87
+ Expression::AstJson.refuse_unshared_patterns!(ast, owner: owner_name, word: rule_word)
88
+ Expression::AstJson.refuse_unresolvable_lookups!(ast, owner: owner_name, word: rule_word)
86
89
  struct_class.new(description: description, canonical: canonical, predicate: predicate, ast: ast)
87
90
  end
88
91
 
@@ -131,6 +131,49 @@ module Hecks
131
131
  ast
132
132
  end
133
133
 
134
+ # What a `lookup` path can never contain: a call's parentheses, or an argument list's
135
+ # commas and spaces. Attribute names carry none of these, so a path that does is a method
136
+ # call with arguments that the grammar has no node for.
137
+ #
138
+ # A bare `?` is deliberately not in the set. `x.nil?` also parses as a lookup, but it loads
139
+ # and, on a String, Boolean or `nil` value, evaluates without raising; real bluebooks
140
+ # declare it, so refusing it would stop them loading.
141
+ UNRESOLVABLE_PATH = /[(),\s]/
142
+ private_constant :UNRESOLVABLE_PATH
143
+
144
+ # Refuses a rule that calls a method the expression grammar does not have.
145
+ #
146
+ # An unrecognised call such as `value.between?(100, 599)` parses as a `lookup` whose path
147
+ # names an attribute that can never exist, so it loads and then fails on the first
148
+ # dispatch. Refusing it here moves the failure to build time, where the author is looking.
149
+ # The supported spellings (comparisons joined with `&&`/`||`, `.positive?`, `.empty?`,
150
+ # `.include?`, ...) are the rows of `projection.json`.
151
+ #
152
+ # @param ast [Hash] a JSON AST node, as emitted by `emit_predicate` or `emit_bool`
153
+ # @param owner [String] the declaring bluebook's name, for the refusal message
154
+ # @param word [String] the rule's own kind and description (such as
155
+ # `"invariant \"a status code\""`), for the refusal message
156
+ # @return [Hash] `ast`, unchanged
157
+ # @raise [Bluebook::DSL::Malformed] if any `lookup` node's path contains a parenthesis,
158
+ # comma or whitespace
159
+ def refuse_unresolvable_lookups!(ast, owner:, word:)
160
+ return ast if Hecks::Bluebook::MetaValidator.shadow_parsing? # frozen era text is history
161
+
162
+ each_node(ast) do |node|
163
+ next unless node["op"] == "lookup" && node["path"].is_a?(::Array)
164
+
165
+ expression = node["path"].join(".")
166
+ next unless expression.match?(UNRESOLVABLE_PATH)
167
+
168
+ raise DSL::Malformed,
169
+ "#{owner}'s #{word} uses #{expression.inspect}, which the expression language " \
170
+ "cannot evaluate — it is not an attribute, and it is not a method the language " \
171
+ "supports. Spell the test with comparisons and `&&`/`||` instead, such as " \
172
+ "`value >= 100 && value <= 599` in place of `value.between?(100, 599)`"
173
+ end
174
+ ast
175
+ end
176
+
134
177
  # Every name a rule resolves at its root — the first segment of
135
178
  # each `lookup` path, unique, in first-seen order.
136
179
  #
@@ -27,7 +27,7 @@ module Hecks
27
27
 
28
28
  {
29
29
  name: text(field[:name])&.to_sym,
30
- type: owned_type(type, aggregate_id) || reference_type(type),
30
+ type: owned_type(type, aggregate_id) || sibling_type(type) || reference_type(type),
31
31
  list: text(field[:list]).to_s == "true",
32
32
  default: decode_literal(text(field[:default])),
33
33
  # Read back the same way `list` is — both are booleans about the
@@ -60,6 +60,22 @@ module Hecks
60
60
  type.delete_prefix(prefix)
61
61
  end
62
62
 
63
+ # A type another aggregate of the same chapter declares, read back as its name.
64
+ #
65
+ # An attribute typed with a sibling aggregate's value object or entity is offered
66
+ # as that declaration's id (chapter, owning aggregate, name), three segments. A
67
+ # `Reference<X>` is offered as the target head's id, two segments. The extra segment
68
+ # is what tells a type name from a reference, so a three-segment id is the bare
69
+ # declaration name and only a two-segment id is a reference.
70
+ #
71
+ # @param type [String] the attribute's own dispatched `type` cell
72
+ # @return [String, nil] the declaration's bare name when `type` is a three-segment id;
73
+ # `nil` for a head's two-segment id
74
+ def sibling_type(type)
75
+ segments = type.split(Naming::IDENTITY_JOIN)
76
+ segments.last if segments.size == 3
77
+ end
78
+
63
79
  # One part of an identity, read back as the path it went in as. The inverse
64
80
  # of `Marks#identity_path`, and named the same so the two directions read
65
81
  # as one table.
data/lib/hecks/corpus.rb CHANGED
@@ -260,117 +260,31 @@ module Hecks
260
260
  RustDomain = Struct.new(:feature, :dir, :kind)
261
261
 
262
262
  # `check: :named_in` — `destination` must name `names`.
263
- # `check: :external` — no in-repo domain directory may carry the name;
264
- # the day one does, it belongs in rust_domains.
265
263
  Elsewhere = Struct.new(:check, :destination, :names, :why)
266
264
 
267
265
  RUST_ELSEWHERE = {
268
- "meta" => Elsewhere.new(:named_in, "spec/codegen_parity_spec.rb", "bluebook_language",
269
- "the self-hosted grammar (lib/hecks/language), not a domain directory — every " \
270
- "bin/project_rust run rewrites it (so the drift check diffs it), codegen parity " \
271
- "checks it as bluebook_language, and there is no directory to fuzz"),
272
- "embryonaut" => Elsewhere.new(:external, "~/Projects/embryonautfoundersapp", "embryonaut",
273
- "an external product's domain — its bluebook, regeneration and parity are owed " \
274
- "by its own repo; here bin/rust_coverage checks only the committed snapshot"),
275
- # Lifeadelics — same external-product shape as "embryonaut" above,
276
- # first generated 2026-09-19 fixing a live era-shape-drift outage.
277
- # Its directory used to be named "domain" (a generic, collision-
278
- # prone Cargo feature/module name — the chapter name is, and
279
- # always was, "Lifeadelics"), so the generated module and Cargo
280
- # feature were "domain" too, read off `metadata.rs`'s own stamp
281
- # the same way `generated_source` reads any other module's. Fixed
282
- # 2026-09-20 by renaming the directory itself
283
- # (~/Projects/lifeadelics/domain -> ~/Projects/lifeadelics/
284
- # lifeadelics) — the generated module and Cargo feature are
285
- # "lifeadelics" now, matching every other domain's own convention.
286
- #
287
- # Lifeadelics also attaches `newsletter`/`payments`/`membership`
288
- # (its own vendored embryonaut_bluebooks packages, see
289
- # RUST_EXTERNAL_VENDORED_CHAPTERS below) and `Privacy` (an in-repo
290
- # framework chapter, lib/hecks/framework/bluebook/privacy.bluebook,
291
- # generated into Rust for the first time by any domain here).
292
- # `rust_side_chapters` only ever looks for the attaching domain
293
- # among `rust_domains` — an in-repo directory — so it can attribute
294
- # neither module to lifeadelics on its own; `rust_attachment_
295
- # hecksagon_text`, below, reads every `:external` domain's own
296
- # hecksagon files too, the same way it reads an in-repo domain's,
297
- # so `Privacy`'s own bucket membership (already correct — it is a
298
- # real in-repo framework member with no merged.rs) can still be
299
- # proven attached.
300
- "lifeadelics" => Elsewhere.new(:external, "~/Projects/lifeadelics", "lifeadelics",
301
- "an external product's domain — its bluebook, regeneration and parity are owed " \
302
- "by its own repo; here bin/rust_coverage checks only the committed snapshot")
266
+ "meta" => Elsewhere.new(:named_in, "spec/codegen_parity_spec.rb", "bluebook_language",
267
+ "the self-hosted grammar (lib/hecks/language), not a domain directory — every " \
268
+ "bin/project_rust run rewrites it (so the drift check diffs it), codegen parity " \
269
+ "checks it as bluebook_language, and there is no directory to fuzz")
303
270
  }.freeze
304
271
 
305
- # Vendored embryonaut_bluebooks packages attached only by an external
306
- # RUST_ELSEWHERE domain — `members(:vendored)` can't find them
307
- # itself; that glob only reaches `examples/*/vendor/
308
- # embryonaut_bluebooks/*`, never an external checkout. Declared once,
309
- # by hand, the same manual-commit contract "embryonaut" itself
310
- # already carries: stem => the RUST_ELSEWHERE feature that attaches
311
- # it. spec/corpus_rust_spec.rb checks each is really attached, the
312
- # same as an in-repo vendored chapter (see
313
- # `rust_external_vendored_domain_dir`, below).
314
- RUST_EXTERNAL_VENDORED_CHAPTERS = {
315
- "newsletter" => "lifeadelics",
316
- "payments" => "lifeadelics",
317
- "membership" => "lifeadelics"
318
- }.freeze
319
-
320
- # Every external vendored chapter's own stem.
321
- #
322
- # @return [Array<String>] stems declared in RUST_EXTERNAL_VENDORED_CHAPTERS
323
- def rust_external_vendored_chapters
324
- RUST_EXTERNAL_VENDORED_CHAPTERS.keys
325
- end
326
-
327
- # Where an external vendored chapter's own bluebook lives — somewhere
328
- # under `<destination>/**/vendor/embryonaut_bluebooks/<stem>`, the
329
- # same layout `EmbryonautBluebook.load!` resolves for an in-repo
330
- # vendored member, one level further out. Globbed rather than joined
331
- # directly: `RUST_ELSEWHERE`'s own `destination` names the external
332
- # product's checkout root, not necessarily the exact directory its
333
- # own bluebook lives under (confirmed live against lifeadelics's own
334
- # checkout: `~/Projects/lifeadelics/lifeadelics/vendor/
335
- # embryonaut_bluebooks/accounts/bluebook`, one level below
336
- # `~/Projects/lifeadelics` itself).
337
- #
338
- # @param stem [String] an external vendored chapter's stem
339
- # @return [String, nil] the vendored member's own directory, or nil when none is found
340
- def rust_external_vendored_domain_dir(stem)
341
- feature = RUST_EXTERNAL_VENDORED_CHAPTERS.fetch(stem)
342
- destination = File.expand_path(RUST_ELSEWHERE.fetch(feature).destination)
343
- Dir.glob(File.join(destination, "**", "vendor", "embryonaut_bluebooks", stem)).first
344
- end
345
-
346
272
  # Every place a Rust-facing domain's own `uses_framework`/
347
273
  # `uses_embryonaut_bluebook` attachment could be declared — every
348
- # in-repo Rust domain's own hecksagon files, plus each `:external`
349
- # RUST_ELSEWHERE domain's own (its `destination`, expanded, is a
350
- # real checkout on this machine, read the same way an in-repo
351
- # domain's own hecksagon files already are).
274
+ # in-repo Rust domain's own hecksagon files.
352
275
  #
353
276
  # @param root [String] repository root to search under
354
277
  # @return [String] every reachable hecksagon file's own text, joined by newlines
355
278
  def rust_attachment_hecksagon_text(root: ROOT)
356
- in_repo = rust_domains(root: root).flat_map { |domain| Dir.glob(File.join(domain.dir, "**", "*.hecksagon")) }
357
- external = RUST_ELSEWHERE.values.select { |route| route.check == :external }
358
- .flat_map { |route| Dir.glob(File.join(File.expand_path(route.destination), "**", "*.hecksagon")) }
359
- (in_repo + external).map { |path| File.read(path) }.join("\n")
279
+ rust_domains(root: root).flat_map { |domain| Dir.glob(File.join(domain.dir, "**", "*.hecksagon")) }
280
+ .map { |path| File.read(path) }.join("\n")
360
281
  end
361
282
 
362
283
  # **Shrink-only**. A generated module `bin/rust_coverage` still reports a
363
284
  # gap for. `bin/corpus --rust-coverage` requires each of these to
364
285
  # still fail, so an entry that starts passing breaks the build until
365
286
  # it is deleted here.
366
- RUST_COVERAGE_PENDING = {
367
- "newsletter" => "query Listing declares no where clause at all, on all 3 of its own Listing queries " \
368
- "(Delivery/Issue/Subscriber) — rust/project/queries.rb's own no_wheres skip refuses " \
369
- "to generate an unfiltered per_instance Listing (\"nothing for filter_entries to " \
370
- "bake in\"); a structural Rust codegen limitation, not a bug in this vendored package",
371
- "lifeadelics" => "same no_wheres gap as newsletter, on Registration.Listing — see newsletter's entry above",
372
- "membership" => "same no_wheres gap as newsletter, on Person.All — see newsletter's entry above"
373
- }.freeze
287
+ RUST_COVERAGE_PENDING = {}.freeze
374
288
 
375
289
  # The Cargo `[features]` table's raw text.
376
290
  #
@@ -518,6 +432,22 @@ module Hecks
518
432
  rust_side_chapters(:vendored, root: root)
519
433
  end
520
434
 
435
+ # Chapters an in-repo Rust domain carries beside its own bluebook (a
436
+ # sibling `.bluebook` in the domain's directory) that `bin/project_rust`
437
+ # writes a module for as a side effect — a module, no merged.rs of its
438
+ # own. Today: `payments`, beside spec/fixtures/rust_host/checkout_fixture.
439
+ #
440
+ # @param root [String] repository root to search under
441
+ # @return [Array<String>] generated module names with no merged.rs of their own
442
+ def rust_sibling_chapters(root: ROOT)
443
+ modules = generated_modules(root: root)
444
+ rust_domains(root: root)
445
+ .flat_map { |domain| Dir.glob(File.join(domain.dir, "bluebook", "*.bluebook")) }
446
+ .map { |path| Naming.pascal(File.basename(path, ".bluebook")).downcase }
447
+ .uniq
448
+ .select { |name| modules.include?(name) && !generated?(name, root: root) }
449
+ end
450
+
521
451
  # THE REGENERATION ORDER the drift check runs. Sorted by path, so
522
452
  # which domain runs last — and so wins Cargo's `default`, mod.rs's cfg
523
453
  # comments and the shared framework modules' attribution stamp — is a
@@ -130,10 +130,23 @@ module Hecks
130
130
  # rebind! rewrites files in it and must not reach back through a
131
131
  # link into the real tree.
132
132
  #
133
+ # The vendored bluebook packages the domain's hecksagons name with
134
+ # `uses_embryonaut_bluebook` are carried too — see `carry_vendored_bluebooks!`.
135
+ #
133
136
  # @param source [String] the real domain directory to copy
134
137
  # @param destination [String] the tmpdir path to copy it into; created if missing
135
138
  # @return [void]
136
139
  def copy_dereferencing(source, destination)
140
+ copy_files(source, destination)
141
+ carry_vendored_bluebooks!(source, destination)
142
+ end
143
+
144
+ # Copies every file under `source` into `destination`, following symlinks.
145
+ #
146
+ # @param source [String] the directory to copy
147
+ # @param destination [String] the path to copy it into; created if missing
148
+ # @return [void]
149
+ def copy_files(source, destination)
137
150
  FileUtils.mkdir_p(destination)
138
151
  Dir.glob(File.join(source, "**", "*"), File::FNM_DOTMATCH).each do |path|
139
152
  next if [".", ".."].include?(File.basename(path))
@@ -156,6 +169,33 @@ module Hecks
156
169
  end
157
170
  end
158
171
 
172
+ # Copies the vendored bluebook packages the copy's hecksagons name into
173
+ # the place a boot of the copy looks for them.
174
+ #
175
+ # `Hecks.boot` roots a registry at the parent of the directory it boots,
176
+ # and `uses_embryonaut_bluebook "<name>"` loads from
177
+ # `<root>/vendor/embryonaut_bluebooks/<name>/bluebook` (see
178
+ # `EmbryonautBluebook.load!`). The copy holds only the domain directory,
179
+ # so without this a target that vendors a package crashed the boot with
180
+ # "no vendored embryonaut bluebook named ...". Only the packages a
181
+ # hecksagon names are copied, never the rest of `vendor/`. A package the
182
+ # source root does not have is skipped, so the boot's own error stands.
183
+ #
184
+ # @param source [String] the real domain directory
185
+ # @param destination [String] the copy's directory; its parent is the copy's root
186
+ # @return [void]
187
+ def carry_vendored_bluebooks!(source, destination)
188
+ names = Dir.glob(File.join(destination, "**", "*.hecksagon")).flat_map do |path|
189
+ File.read(path).scan(/uses_embryonaut_bluebook\s*\(?\s*"([\w-]+)"/).flatten
190
+ end
191
+ names.uniq.each do |name|
192
+ package = File.join(File.dirname(source), "vendor", "embryonaut_bluebooks", name)
193
+ next unless File.directory?(package)
194
+
195
+ copy_files(package, File.join(File.dirname(destination), "vendor", "embryonaut_bluebooks", name))
196
+ end
197
+ end
198
+
159
199
  # Rewrites every `.hecksagon` in the copy to bind through Memory and drops its
160
200
  # `.world` files, so the boot needs no settings at all.
161
201
  #
@@ -531,7 +571,8 @@ module Hecks
531
571
  end
532
572
 
533
573
  # Rewrites every `persisted_by` bind — aggregate-scoped or bare at the
534
- # hecksagon's own root — in the copy's `.hecksagon` files to name `adapter_name`,
574
+ # hecksagon's own root, naming its adapter as a literal or through a local
575
+ # variable — in the copy's `.hecksagon` files to name `adapter_name`,
535
576
  # and drops every `projected_by` bind outright.
536
577
  #
537
578
  # @param copy [String] the isolated copy's root directory
@@ -541,7 +582,8 @@ module Hecks
541
582
  def rewrite_bindings!(copy, adapter_name)
542
583
  Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |path|
543
584
  lines = File.readlines(path).grep_v(/\bprojected_by\s*\(?\s*"/)
544
- File.write(path, lines.join.gsub(/persisted_by\s*\(?\s*"[^"]+"\s*\)?/, "persisted_by(\"#{adapter_name}\")"))
585
+ bind = /persisted_by\s*\(?\s*(?:"[^"]+"|[a-z_]\w*)\s*\)?/
586
+ File.write(path, lines.join.gsub(bind, "persisted_by(\"#{adapter_name}\")"))
545
587
  end
546
588
  end
547
589
  end
@@ -47,6 +47,9 @@ module Hecks
47
47
  { creating: creating, instance: instance, entity_commands: entity_commands,
48
48
  queries: queries, entity_queries: entity_queries, read_models: read_models,
49
49
  populators: populators(runtime),
50
+ # The aggregates a query filters on, and the fields it compares
51
+ # (query_binding.rb).
52
+ query_bindings: build_query_bindings(runtime),
50
53
  # Every `role "..."` any command in the boot declares — the
51
54
  # "wrong hat" pool the caller draw's `mismatched` shape picks
52
55
  # from (adversary.rb `other_role`).
@@ -0,0 +1,138 @@
1
+ require_relative "../../query_specification/field_path"
2
+ require_relative "../../runtime/value"
3
+
4
+ module Hecks
5
+ module Fuzzing
6
+ class SequenceGenerator
7
+ # Query arguments drawn from what the sequence has already written.
8
+ #
9
+ # A query is only a real test of an adapter when its argument names a row
10
+ # that exists. A `where(site_reference: :site_reference)` asked with a
11
+ # freshly generated word matches nothing on every adapter, so an adapter
12
+ # that compiles the comparison wrongly (and so also matches nothing) is
13
+ # indistinguishable from a correct one. Every argument `args_for` draws
14
+ # is independent of the store, and the chance that one lands on a stored
15
+ # value is small enough that a whole persistence-parity sweep can pass
16
+ # without a single query returning a row.
17
+ #
18
+ # ## What is written, and what is bound
19
+ #
20
+ # After each command that took effect, every aggregate a query filters
21
+ # on is read back from the repository, and the values of exactly the
22
+ # fields those queries compare (`bound_fields`) are remembered as one
23
+ # row per record. A row is what the store holds, not what a command was
24
+ # given, so a value a `sets` mapping renamed, a policy wrote in another
25
+ # aggregate, or a lifecycle transition assigned is covered too. A later
26
+ # query step usually takes its arguments from one remembered row, so
27
+ # several parameters filter the same record. The rest of the time it
28
+ # keeps the generated argument, which is what reaches the empty answer.
29
+ #
30
+ # ## Why a separate random stream
31
+ #
32
+ # The choice draws from its own `Random`, seeded from the sequence's
33
+ # seed and restarted wherever `@random` is (`restart_random`). Drawing
34
+ # from `@random` would shift every later step of every seed. This way
35
+ # each seed generates the same commands, in the same order, with the
36
+ # same arguments as before, and only the arguments of a query step
37
+ # change. A query changes no state, so nothing downstream moves.
38
+ module QueryBinding
39
+ # How often a query step whose aggregate has written rows takes its
40
+ # arguments from one of them. High, because a query that misses a
41
+ # stored row tests nothing, and the miss is still drawn the other
42
+ # quarter of the time.
43
+ BOUND_QUERY_PROBABILITY = 0.75
44
+
45
+ # Comparators whose argument is one scalar or value, so a stored value
46
+ # is a legitimate operand. `in` takes a list and `none_in_state` an
47
+ # `"Aggregate:state"` string; a stored value is neither.
48
+ BINDABLE_OPERATORS = %i[eq ne lt lte gt gte].freeze
49
+
50
+ # Keeps the binding stream apart from `@random`'s, which is seeded
51
+ # with the bare seed.
52
+ BINDING_SEED_OFFSET = 0x9e3779b1
53
+
54
+ private
55
+
56
+ # `{"Domain::Aggregate" => {domain:, aggregate:, fields: [String]}}`
57
+ # for every aggregate some query filters on, where `fields` is the
58
+ # union of the stored fields those queries compare.
59
+ def build_query_bindings(runtime)
60
+ runtime.registry.bluebooks.each_with_object({}) do |(domain_name, bluebook), bindings|
61
+ bluebook.aggregates.each do |aggregate|
62
+ fields = aggregate.queries.flat_map { |query| bound_fields(query).values }.uniq
63
+ next if fields.empty?
64
+
65
+ bindings["#{domain_name}::#{aggregate.hecks_name}"] =
66
+ { domain: domain_name, aggregate: aggregate, fields: fields }
67
+ end
68
+ end
69
+ end
70
+
71
+ # `{parameter name => stored field}` for each `where` clause whose
72
+ # operand is one of the query's own parameters. A clause on a literal,
73
+ # or aimed at another aggregate's field (`target`), binds nothing.
74
+ def bound_fields(query)
75
+ query.wheres.each_with_object({}) do |clause, fields|
76
+ next unless clause.value.is_a?(Symbol) && clause.target.nil?
77
+ next unless BINDABLE_OPERATORS.include?(clause.op.to_sym)
78
+
79
+ fields[clause.value.to_s] ||= clause.field.to_s
80
+ end
81
+ end
82
+
83
+ # Remembers the compared fields of every stored record of every
84
+ # aggregate a query filters on, as one row per distinct record shape.
85
+ def harvest_written_rows(runtime, catalog)
86
+ catalog[:query_bindings].each do |key, binding|
87
+ repository = runtime.registry.repository(binding[:domain], binding[:aggregate])
88
+ repository.all.each do |record|
89
+ row = binding[:fields].to_h { |field| [field, written_value(field_of(record, field))] }.compact
90
+ @written_rows[key] << row unless row.empty? || @written_rows[key].include?(row)
91
+ end
92
+ end
93
+ end
94
+
95
+ def field_of(record, field) = QuerySpecification::FieldPath.dig(record.state, field)
96
+
97
+ # A stored value as the JSON-shaped argument a step carries: string
98
+ # keys, nested values opened. `nil` for anything a query argument
99
+ # cannot be (a missing value, a list, a timestamp object).
100
+ def written_value(value)
101
+ case value
102
+ when Runtime::Value then written_value(value.to_h)
103
+ when Hash then value.to_h { |key, inner| [key.to_s, written_value(inner)] }
104
+ when String, Integer, Float, true, false then value
105
+ end
106
+ end
107
+
108
+ # Replaces the generated arguments of a query step with a remembered
109
+ # row's, most of the time. Only a parameter the step already carries
110
+ # is replaced, so an optional argument left out, or a required one a
111
+ # malformation dropped, stays that way.
112
+ def bind_to_written_row!(args, entry)
113
+ return if entry[:entity]
114
+
115
+ rows = @written_rows[entry[:verb].split(".").first]
116
+ params = bound_fields(entry[:query]).select { |param, _| args.key?(param) }
117
+ return if rows.empty? || params.empty?
118
+ return unless @binding_random.rand < BOUND_QUERY_PROBABILITY
119
+
120
+ row = rows.select { |candidate| params.values.any? { |field| candidate.key?(field) } }
121
+ .sample(random: @binding_random)
122
+ params.each do |param, field|
123
+ args[param] = shaped_like(args[param], row[field]) if row&.key?(field)
124
+ end
125
+ end
126
+
127
+ # A single-field value object is written `{"value" => x}` but may be
128
+ # asked with the bare `x`; keeps whichever spelling the generator
129
+ # already chose for this argument.
130
+ def shaped_like(current, written)
131
+ return written unless written.is_a?(Hash) && written.size == 1 && !current.is_a?(Hash)
132
+
133
+ written.values.first
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -16,6 +16,7 @@ module Hecks
16
16
 
17
17
  def build_query_step(runtime, entry)
18
18
  args = args_for(entry[:query].attributes, entry[:aggregate])
19
+ bind_to_written_row!(args, entry)
19
20
  safe_call { runtime.query(entry[:verb], **symbolize(args)) }
20
21
  { "query" => entry[:verb], "args" => args }
21
22
  end
@@ -80,6 +81,7 @@ module Hecks
80
81
  outcome = safe_call { as_caller(caller) { runtime.dispatch_flat(entry[:verb], symbolize(args)) } }
81
82
  if outcome
82
83
  record_outcome(catalog, entry, args)
84
+ harvest_written_rows(runtime, catalog)
83
85
  @event_count += outcome.events.length
84
86
  end
85
87
  { "verb" => entry[:verb], "args" => args }
@@ -7,6 +7,7 @@ require_relative "sequence_generator/catalog"
7
7
  require_relative "sequence_generator/picker"
8
8
  require_relative "sequence_generator/step_builder"
9
9
  require_relative "sequence_generator/outcome_tracker"
10
+ require_relative "sequence_generator/query_binding"
10
11
  require_relative "sequence_generator/adversary"
11
12
 
12
13
  module Hecks
@@ -30,14 +31,16 @@ module Hecks
30
31
  # One concern per file beside this one: what the domain offers
31
32
  # (sequence_generator/catalog.rb), which step to try next (picker.rb),
32
33
  # how a step is built and dispatched (step_builder.rb), what a
33
- # success taught us (outcome_tracker.rb), and — opt-in, `adversarial:`
34
- # — the argument shapes real Ruby/Rust divergences were found through
34
+ # success taught us (outcome_tracker.rb), which stored values a query is
35
+ # asked about (query_binding.rb), and — opt-in, `adversarial:` — the
36
+ # argument shapes real Ruby/Rust divergences were found through
35
37
  # (adversary.rb).
36
38
  class SequenceGenerator
37
39
  include Catalog
38
40
  include Picker
39
41
  include StepBuilder
40
42
  include OutcomeTracker
43
+ include QueryBinding
41
44
  include Adversary
42
45
 
43
46
  # Creating commands are always eligible ; weighting them heavier (not
@@ -187,10 +190,13 @@ module Hecks
187
190
  @adversarial = adversarial.to_f
188
191
  @role_draw = role_draw.to_f
189
192
  @dry_run = dry_run.to_f
190
- @random = Random.new(seed)
193
+ restart_random(seed)
191
194
  @known_ids = Hash.new { |h, k| h[k] = [] }
192
195
  @entity_known_ids = Hash.new { |h, k| h[k] = [] }
193
196
  @appended_identities = Hash.new { |h, k| h[k] = [] }
197
+ # "Domain::Aggregate" => the rows this sequence has stored that a
198
+ # query filters on (query_binding.rb).
199
+ @written_rows = Hash.new { |h, k| h[k] = [] }
194
200
  # Role => [actor ids] this sequence's own successful
195
201
  # `Governance::RoleAssignment.Assign` steps granted — what the
196
202
  # `actor_known` caller shape draws from (adversary.rb).
@@ -228,8 +234,8 @@ module Hecks
228
234
  steps = []
229
235
  if @prefix
230
236
  realize_prefix(runtime, catalog, @prefix, prefix_limit(@prefix), steps)
231
- @random = Random.new(@seed)
232
- @favor = @own_favor
237
+ restart_random(@seed)
238
+ @favor = @own_favor
233
239
  end
234
240
  @step_count.times { steps << attempt_step(runtime, catalog) }
235
241
  steps.compact
@@ -257,12 +263,19 @@ module Hecks
257
263
 
258
264
  inner = spec["prefix"]
259
265
  used = inner ? realize_prefix(runtime, catalog, inner, [prefix_limit(inner), limit].min, steps) : 0
260
- @random = Random.new(Integer(spec.fetch("seed")))
261
- @favor = Array(spec["favor"])
266
+ restart_random(Integer(spec.fetch("seed")))
267
+ @favor = Array(spec["favor"])
262
268
  (limit - used).times { steps << attempt_step(runtime, catalog) }
263
269
  limit
264
270
  end
265
271
 
272
+ # Both random streams start over together, so a prefix replay draws the
273
+ # same query bindings the prefix seed's own generation did.
274
+ def restart_random(seed)
275
+ @random = Random.new(seed)
276
+ @binding_random = Random.new(seed + QueryBinding::BINDING_SEED_OFFSET)
277
+ end
278
+
266
279
  def prefix_limit(spec) = Integer(spec.fetch("steps")).clamp(0, @step_count)
267
280
 
268
281
  def attempt_step(runtime, catalog)
@@ -49,6 +49,11 @@ module Hecks
49
49
  # domain-level default (`persisted_by "PostgresEra"`).
50
50
  POSTGRES_ERA_BINDING = /persisted_by\s*\(?\s*"PostgresEra"/
51
51
 
52
+ # A local variable assigned the bare literal `"PostgresEra"` on a line of
53
+ # its own (`adapter = "PostgresEra"`). Only the whole-line literal counts:
54
+ # a ternary or any other right-hand side is not a PostgresEra binding.
55
+ POSTGRES_ERA_VARIABLE = /^\s*([a-z_]\w*)\s*=\s*"PostgresEra"\s*(?:#.*)?$/
56
+
52
57
  # `HecksagonBuilder#uses_framework "X"` — captures which member a
53
58
  # hecksagon attaches, whatever its name. Whether that member answers
54
59
  # a role check is then read off its own declaration (`provides
@@ -141,7 +146,7 @@ module Hecks
141
146
  def infer(domain_path, rust_dir: File.expand_path("../../../rust", __dir__))
142
147
  capabilities = %w[sqlite]
143
148
  capabilities << "rust" if rust_feature?(domain_path, rust_dir)
144
- capabilities << "postgres_era" if any_file?(domain_path, "*.hecksagon", POSTGRES_ERA_BINDING)
149
+ capabilities << "postgres_era" if postgres_era_bound?(domain_path)
145
150
  capabilities << "translations" if Dir.glob(File.join(domain_path, "**", "translations", "*.bluebook")).any?
146
151
  capabilities << "governance" if authorization_attached?(domain_path)
147
152
  capabilities << "role_gated" if any_file?(domain_path, "*.bluebook", ROLE_GATED)
@@ -194,6 +199,29 @@ module Hecks
194
199
  features.match?(/^#{Regexp.escape(feature)}\s*=\s*\[\]/)
195
200
  end
196
201
 
202
+ # Answers whether any `.hecksagon` under `domain_path` binds PostgresEra,
203
+ # either with the literal name or through a local variable that the same
204
+ # file assigned `"PostgresEra"` and then passed to `persisted_by`.
205
+ #
206
+ # @param domain_path [String] filesystem path to the target domain's directory
207
+ # @return [Boolean] whether a PostgresEra `persisted_by` binding is found
208
+ def postgres_era_bound?(domain_path)
209
+ Dir.glob(File.join(domain_path, "**", "*.hecksagon")).any? do |path|
210
+ text = File.read(path)
211
+ text.match?(POSTGRES_ERA_BINDING) || postgres_era_variable_bound?(text)
212
+ end
213
+ end
214
+
215
+ # Answers whether `text` passes a variable assigned `"PostgresEra"` to `persisted_by`.
216
+ #
217
+ # @param text [String] the contents of one `.hecksagon` file
218
+ # @return [Boolean] whether some `persisted_by` call names such a variable
219
+ def postgres_era_variable_bound?(text)
220
+ text.scan(POSTGRES_ERA_VARIABLE).flatten.any? do |name|
221
+ text.match?(/persisted_by\s*\(?\s*#{Regexp.escape(name)}(?!\w)/)
222
+ end
223
+ end
224
+
197
225
  # Answers whether any `.hecksagon` under `domain_path` attaches a member
198
226
  # that provides authorization.
199
227
  #
@@ -305,6 +305,15 @@ Hecks.bluebook "Bluebook", version: "1" do
305
305
  member keyword: "provides", context: "Bluebook", at: "", named: "failed", kind: "text", required: "false", fills: "provides"
306
306
  member keyword: "provides", context: "Bluebook", at: "", named: "send_issue", kind: "text", required: "false", fills: "provides"
307
307
  member keyword: "provides", context: "Bluebook", at: "", named: "record_delivery", kind: "text", required: "false", fills: "provides"
308
+ member keyword: "provides", context: "Bluebook", at: "", named: "schedule", kind: "text", required: "false", fills: "provides"
309
+ member keyword: "provides", context: "Bluebook", at: "", named: "request", kind: "text", required: "false", fills: "provides"
310
+ member keyword: "provides", context: "Bluebook", at: "", named: "connect", kind: "text", required: "false", fills: "provides"
311
+ member keyword: "provides", context: "Bluebook", at: "", named: "reconnect", kind: "text", required: "false", fills: "provides"
312
+ member keyword: "provides", context: "Bluebook", at: "", named: "disconnect", kind: "text", required: "false", fills: "provides"
313
+ member keyword: "provides", context: "Bluebook", at: "", named: "suspend", kind: "text", required: "false", fills: "provides"
314
+ member keyword: "provides", context: "Bluebook", at: "", named: "resume", kind: "text", required: "false", fills: "provides"
315
+ member keyword: "provides", context: "Bluebook", at: "", named: "enable", kind: "text", required: "false", fills: "provides"
316
+ member keyword: "provides", context: "Bluebook", at: "", named: "disable", kind: "text", required: "false", fills: "provides"
308
317
  member keyword: "aggregate", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
309
318
  member keyword: "read_model", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
310
319
  member keyword: "policy", context: "Bluebook", at: "1", named: "", kind: "text", required: "true", fills: "name"
@@ -900,7 +900,7 @@ module Hecks
900
900
  def numeric_field?(field)
901
901
  name, *path = field.to_s.split(".")
902
902
  QuerySpecification::FieldPath.numeric?(@aggregate.attribute(name), path) do |type|
903
- @aggregate.value_object(type)
903
+ Runtime::Value.value_object_for(@aggregate, type)
904
904
  end
905
905
  end
906
906
 
@@ -269,7 +269,8 @@ module Hecks
269
269
  when :mermaid
270
270
  parts << figure(block, state.delete(:caption))
271
271
  when :list
272
- parts << "<p class=\"caption\">#{escape(state[:caption])}</p>" if state.delete(:caption)
272
+ caption = state.delete(:caption)
273
+ parts << "<p class=\"caption\">#{escape(caption)}</p>" if caption
273
274
  parts << "<ul class=\"rules\">#{block.items.map { |item| "<li>#{inline(item)}</li>" }.join}</ul>"
274
275
  when :quote
275
276
  parts << "<p class=\"about\">#{inline(block.text)}</p>"
@@ -1,4 +1,5 @@
1
1
  require_relative "sections"
2
+ require_relative "sensitivity"
2
3
  require_relative "sentences"
3
4
  require_relative "mermaid"
4
5
  require_relative "../statements"
@@ -77,7 +78,12 @@ module Hecks
77
78
  # @return [String] the section's Markdown source
78
79
  def section_text(section, document)
79
80
  parts = ["## #{section.title}"]
80
- parts += section.aggregate ? opening(section.aggregate, document.bluebook) : ["> #{STANDING[section.name]}"]
81
+ parts += if section.aggregate
82
+ marked = Sensitivity.for_aggregate(document.markings, document.bluebook.name, section.aggregate)
83
+ opening(section.aggregate, document.bluebook, marked)
84
+ else
85
+ ["> #{STANDING[section.name]}"]
86
+ end
81
87
  parts += section.terms.map { |entry| term_text(entry, document.index) }
82
88
  parts.join("\n\n")
83
89
  end
@@ -88,9 +94,11 @@ module Hecks
88
94
  # @param aggregate [Bluebook::Aggregate] the aggregate to render an opening for
89
95
  # @param bluebook [Bluebook::Behaviour::Chapter] the chapter `aggregate` belongs
90
96
  # to, for drawing its context diagram
97
+ # @param marked [Array<Hash{Symbol => String}>] the markings that flag
98
+ # `aggregate`'s own fields sensitive, listed after its rules
91
99
  # @return [Array<String>] the opening's Markdown blocks, one per paragraph or
92
100
  # diagram, not yet joined
93
- def opening(aggregate, bluebook)
101
+ def opening(aggregate, bluebook, marked = [])
94
102
  parts = []
95
103
  parts << "> #{aggregate.description}" if aggregate.description
96
104
  parts << Sentences.lifecycle_sentence(aggregate.lifecycle) if aggregate.lifecycle
@@ -98,6 +106,10 @@ module Hecks
98
106
  parts << "**How it moves**" << fence(Mermaid.lifecycle(aggregate)) if aggregate.lifecycle
99
107
  statements = always_true(aggregate, bluebook)
100
108
  parts << "**Always true**" << statements.map { |statement| "- #{statement}" }.join("\n") unless statements.empty?
109
+ unless marked.empty?
110
+ lines = marked.map { |marking| "- #{Sensitivity.sentence(marking)}" }
111
+ parts << "**Handled as sensitive**" << lines.join("\n")
112
+ end
101
113
  parts
102
114
  end
103
115
 
@@ -0,0 +1,67 @@
1
+ require_relative "../../naming"
2
+
3
+ module Hecks
4
+ module Projections
5
+ module Glossary
6
+ # **The sensitive fields a glossary flags** — read from the markings a
7
+ # deployment declares apart from the domain (`Privacy::Marking`, or a
8
+ # `has_phi(readable_by:)` chain in a `.hecksagon`) and handed in as
9
+ # `options[:markings]`. A marking names an aggregate by its qualified
10
+ # name (`"Lifeadelics::Registration"`) and a field by its dotted path
11
+ # (`"attendee.medications"`). Its category is spoken as the acronym it
12
+ # is, uppercased and never reworded, because the category vocabulary is
13
+ # the deployment's own and open-ended.
14
+ module Sensitivity
15
+ module_function
16
+
17
+ # The markings that name one aggregate of the chapter.
18
+ #
19
+ # @param markings [Array<Hash{Symbol => String}>] every marking handed in, each
20
+ # with `:domain`, `:attribute_path`, `:category` and `:readable_by`
21
+ # @param chapter_name [String] the chapter's declared name
22
+ # @param aggregate [Bluebook::Aggregate] the aggregate to find markings for
23
+ # @return [Array<Hash{Symbol => String}>] the markings whose `:domain` is
24
+ # `aggregate`'s qualified name, in the order they were declared
25
+ def for_aggregate(markings, chapter_name, aggregate)
26
+ qualified = "#{chapter_name}::#{aggregate.hecks_name}"
27
+ markings.select { |marking| marking[:domain].to_s == qualified }
28
+ end
29
+
30
+ # The marked fields inside one value object, reached through whichever of
31
+ # the aggregate's attributes holds that value object.
32
+ #
33
+ # @param marked [Array<Hash{Symbol => String}>] the aggregate's own markings
34
+ # @param aggregate [Bluebook::Aggregate] the aggregate holding the value object
35
+ # @param value_object [Bluebook::ValueObject] the value object to look inside
36
+ # @return [Hash{String => Hash{Symbol => String}}] each marked field's name,
37
+ # mapped to its marking; empty when no marking reaches one of its fields
38
+ def for_value_object(marked, aggregate, value_object)
39
+ holders = aggregate.attributes.select { |attribute| attribute.type.to_s == value_object.hecks_name }
40
+ holder_names = holders.map { |attribute| attribute.name.to_s }
41
+ marked.each_with_object({}) do |marking, tagged|
42
+ head, field, *deeper = marking[:attribute_path].to_s.split(".")
43
+ tagged[field] = marking if field && deeper.empty? && holder_names.include?(head)
44
+ end
45
+ end
46
+
47
+ # The short tag a marked field carries beside its type.
48
+ #
49
+ # @param marking [Hash{Symbol => String}] one marking
50
+ # @return [String] the marking's category, uppercased
51
+ def tag(marking) = marking[:category].to_s.upcase
52
+
53
+ # One line of an aggregate's "Handled as sensitive" list: which field, what
54
+ # kind of sensitive, and who may read it unredacted.
55
+ #
56
+ # @param marking [Hash{Symbol => String}] one marking
57
+ # @return [String] the field's spoken path, its uppercased category, and the
58
+ # role that reads it unredacted, as one sentence
59
+ def sentence(marking)
60
+ path = marking[:attribute_path].to_s.split(".").map { |part| Naming.words(part).downcase }.join(" ")
61
+ reader = Naming.words(marking[:readable_by]).downcase
62
+ "#{path.sub(/\A[[:lower:]]/, &:upcase)}: #{tag(marking)}, read unredacted only by the #{reader}."
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -1,5 +1,6 @@
1
1
  require_relative "../../naming"
2
2
  require_relative "../statements"
3
+ require_relative "sensitivity"
3
4
 
4
5
  module Hecks
5
6
  module Projections
@@ -39,7 +40,7 @@ module Hecks
39
40
  facts = entry.facts
40
41
  case entry.kind
41
42
  when :entity then holder_paragraphs(facts[:entity])
42
- when :value_object then value_object_paragraphs(facts[:value_object], index, entry.within)
43
+ when :value_object then value_object_paragraphs(facts[:value_object], index, entry.within, facts.fetch(:sensitive, {}))
43
44
  when :command then [command_sentence(facts[:command])]
44
45
  when :query then [facts[:query].description]
45
46
  when :event then [event_sentence(facts, index)]
@@ -82,11 +83,13 @@ module Hecks
82
83
  # @param index [Glossary::Index] the document's link index
83
84
  # @param within [String, nil] the value object's own holder's name, for
84
85
  # resolving a nested value object's own type link
86
+ # @param sensitive [Hash{String => Hash{Symbol => String}}] each marked field's
87
+ # name, mapped to its marking (as `Sensitivity.for_value_object` builds)
85
88
  # @return [Array<String>] the value object's definition sentence, then its
86
89
  # "Always true: …" rules line; the rules line is absent when it has none
87
- def value_object_paragraphs(value_object, index, within)
90
+ def value_object_paragraphs(value_object, index, within, sensitive = {})
88
91
  rules = value_object.invariants.map { |invariant| Statements.invariant_statement(invariant) }
89
- [value_object_sentence(value_object, index, within), rules_line(rules)].compact
92
+ [value_object_sentence(value_object, index, within, sensitive), rules_line(rules)].compact
90
93
  end
91
94
 
92
95
  # A one-field object whose field is just "value" is its type —
@@ -95,8 +98,10 @@ module Hecks
95
98
  # @param value_object [Bluebook::ValueObject] the value object to describe
96
99
  # @param index [Glossary::Index] the document's link index
97
100
  # @param within [String, nil] the value object's own holder's name
101
+ # @param sensitive [Hash{String => Hash{Symbol => String}}] each marked field's
102
+ # name, mapped to its marking
98
103
  # @return [String] the value object's definition sentence
99
- def value_object_sentence(value_object, index, within)
104
+ def value_object_sentence(value_object, index, within, sensitive = {})
100
105
  return closed_set_sentence(value_object.members) if value_object.closed_set?
101
106
  return "A marker with no details of its own." if value_object.attributes.empty?
102
107
 
@@ -105,19 +110,23 @@ module Hecks
105
110
  return "#{upper_first(type_words(only, index, within))}."
106
111
  end
107
112
 
108
- fields = value_object.attributes.map { |field| field_phrase(field, index, within) }
113
+ fields = value_object.attributes.map { |field| field_phrase(field, index, within, sensitive[field.name.to_s]) }
109
114
  "Made up of #{Naming.to_sentence_list(fields)}."
110
115
  end
111
116
 
112
117
  # "amount (a whole number)" — the field as the author named it,
113
- # then what kind of thing goes in it.
118
+ # then what kind of thing goes in it, then the tag of any marking
119
+ # that flags it sensitive: "medications (text, PHI)".
114
120
  #
115
121
  # @param field [Bluebook::Attribute] the field to describe
116
122
  # @param index [Glossary::Index] the document's link index
117
123
  # @param within [String, nil] the field's own holder's name
118
- # @return [String] the field's name, spoken, with its type in parentheses
119
- def field_phrase(field, index, within)
120
- "#{Naming.words(field.name).downcase} (#{type_words(field, index, within)})"
124
+ # @param marking [Hash{Symbol => String}, nil] the marking that flags `field`
125
+ # sensitive, if any
126
+ # @return [String] the field's name, spoken, with its type (and tag) in parentheses
127
+ def field_phrase(field, index, within, marking = nil)
128
+ detail = [type_words(field, index, within), (Sensitivity.tag(marking) if marking)].compact.join(", ")
129
+ "#{Naming.words(field.name).downcase} (#{detail})"
121
130
  end
122
131
 
123
132
  # Describes one field's type in plain words.
@@ -2,6 +2,7 @@ require_relative "../projector"
2
2
  require_relative "../naming"
3
3
  require_relative "statements"
4
4
  require_relative "glossary/sections"
5
+ require_relative "glossary/sensitivity"
5
6
  require_relative "glossary/sentences"
6
7
  require_relative "glossary/mermaid"
7
8
  require_relative "glossary/markdown"
@@ -59,7 +60,7 @@ module Hecks
59
60
  # the lede and diagrams) or one of the three trailing groups.
60
61
  Section = Struct.new(:name, :title, :aggregate, :terms, :slug, keyword_init: true)
61
62
 
62
- Document = Struct.new(:bluebook, :sections, :index, keyword_init: true)
63
+ Document = Struct.new(:bluebook, :sections, :index, :markings, keyword_init: true)
63
64
 
64
65
  module_function
65
66
 
@@ -68,11 +69,15 @@ module Hecks
68
69
  #
69
70
  # @param bluebook [Bluebook::Behaviour::Chapter] the chapter to render a
70
71
  # glossary for
71
- # @param options [Hash] unused; accepted to satisfy the registry's call shape
72
+ # @param options [Hash] the registry's call shape
73
+ # @option options [Array<Hash{Symbol => String}>] :markings the sensitive fields a
74
+ # deployment declares (`registry.pending_privacy_markings`' own shape); each
75
+ # marked field is tagged with its category, and every marking is listed under
76
+ # its aggregate. Absent, the glossary says nothing about sensitivity.
72
77
  # @return [Hash{String => String}] `"glossary.md"` and `"html/index.html"`,
73
78
  # each mapped to its rendered content
74
79
  def call(bluebook:, options: {})
75
- markdown = Markdown.render(document(bluebook))
80
+ markdown = Markdown.render(document(bluebook, Array(options[:markings])))
76
81
  { "glossary.md" => markdown, "html/index.html" => Html.render(markdown) }
77
82
  end
78
83
 
@@ -82,11 +87,13 @@ module Hecks
82
87
  # slugs, and builds the index links resolve through.
83
88
  #
84
89
  # @param bluebook [Bluebook::Behaviour::Chapter] the chapter to render
90
+ # @param markings [Array<Hash{Symbol => String}>] the sensitive fields declared for
91
+ # the chapter's aggregates; empty when none are
85
92
  # @return [Document] the fully assembled document, ready for `Markdown.render`
86
- def document(bluebook)
87
- sections = sections(bluebook, entries(bluebook))
93
+ def document(bluebook, markings = [])
94
+ sections = sections(bluebook, entries(bluebook, markings))
88
95
  Slugs.assign!(bluebook, sections)
89
- Document.new(bluebook: bluebook, sections: sections, index: Index.new(sections))
96
+ Document.new(bluebook: bluebook, sections: sections, index: Index.new(sections), markings: markings)
90
97
  end
91
98
 
92
99
  # Aggregates first, a to Z by their spoken name ("Account" before
@@ -197,16 +204,18 @@ module Hecks
197
204
  # Gathers every term the glossary carries, ungrouped and unordered.
198
205
  #
199
206
  # @param bluebook [Bluebook::Behaviour::Chapter] the chapter to gather terms from
207
+ # @param markings [Array<Hash{Symbol => String}>] the sensitive fields declared for
208
+ # the chapter's aggregates
200
209
  # @return [Array<Entry>] one entry per entity, value object, command, query,
201
210
  # event, policy, saga, role, and read model
202
- def entries(bluebook)
211
+ def entries(bluebook, markings = [])
203
212
  homes = holder_aggregate(bluebook)
204
213
  raisers = event_raisers(bluebook)
205
214
  home_of = ->(event) { raisers.key?(event) ? homes[raisers[event].first.first.hecks_name] : nil }
206
215
 
207
216
  entries = []
208
217
  entries += entity_entries(bluebook)
209
- entries += value_object_entries(bluebook)
218
+ entries += value_object_entries(bluebook, markings)
210
219
  entries += verb_entries(bluebook, homes)
211
220
  entries += event_entries(bluebook, raisers, home_of)
212
221
  entries += policy_entries(bluebook, home_of)
@@ -234,13 +243,18 @@ module Hecks
234
243
  #
235
244
  # @param bluebook [Bluebook::Behaviour::Chapter] the chapter to gather value
236
245
  # objects from
246
+ # @param markings [Array<Hash{Symbol => String}>] the sensitive fields declared for
247
+ # the chapter's aggregates, each carried onto the value object it reaches into
237
248
  # @return [Array<Entry>] one `:value_object`-kind entry per aggregate-declared
238
249
  # value object
239
- def value_object_entries(bluebook)
250
+ def value_object_entries(bluebook, markings = [])
240
251
  bluebook.aggregates.flat_map do |aggregate|
252
+ marked = Sensitivity.for_aggregate(markings, bluebook.name, aggregate)
241
253
  aggregate.value_objects.map do |value_object|
254
+ sensitive = Sensitivity.for_value_object(marked, aggregate, value_object)
242
255
  Entry.new(name: value_object.hecks_name, kind: :value_object, within: aggregate.hecks_name,
243
- section: aggregate.hecks_name, facts: { value_object: value_object })
256
+ section: aggregate.hecks_name,
257
+ facts: { value_object: value_object, sensitive: sensitive })
244
258
  end
245
259
  end
246
260
  end
@@ -230,6 +230,52 @@ module Hecks
230
230
  }
231
231
  end
232
232
 
233
+ # Same seam as `newsletter` — which chapter answers scheduling sessions
234
+ # and taking registrations (`Registry#registrations_provider_for`), with
235
+ # its declared verbs qualified and the event and registration aggregates
236
+ # named off them. `{}` when nothing this domain attaches provides
237
+ # registrations.
238
+ # @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
239
+ # @param domain_name [String] the domain to export the registrations binding for
240
+ # @return [Hash{Symbol => String, nil}] `:provider` (name), `:schedule`, `:request`
241
+ # (qualified verbs), `:event_aggregate` and `:registration_aggregate` (each verb's own
242
+ # leading qualified aggregate name); `{}` if nothing this domain attaches provides
243
+ # registrations
244
+ def registrations(registry, domain_name)
245
+ provider = registry.registrations_provider_for(domain_name)
246
+ return {} unless provider
247
+
248
+ capability = Bluebook::Capabilities::REGISTRATIONS
249
+ schedule = provider.provided_verb(capability, :schedule)
250
+ request = provider.provided_verb(capability, :request)
251
+ {
252
+ provider: provider.name,
253
+ schedule: schedule,
254
+ request: request,
255
+ event_aggregate: schedule&.split(".")&.first,
256
+ registration_aggregate: request&.split(".")&.first
257
+ }
258
+ end
259
+
260
+ # Which chapter owns the payment-processor connection
261
+ # (`Registry#payment_connection_provider_for`), with its declared verbs
262
+ # qualified and the connection aggregate named off `connect`. `{}` when
263
+ # nothing this domain attaches provides payment_connection.
264
+ # @param registry [Runtime::Registry] the booted registry `domain_name` is loaded in
265
+ # @param domain_name [String] the domain to export the payment-connection binding for
266
+ # @return [Hash{Symbol => String, nil}] `:provider` (name), one qualified verb per contract key
267
+ # (`:connect`, `:reconnect`, `:disconnect`, `:suspend`, `:resume`, `:enable`, `:disable`) and
268
+ # `:aggregate` (`:connect`'s own leading qualified aggregate name); `{}` if nothing this
269
+ # domain attaches provides payment_connection
270
+ def payment_connection(registry, domain_name)
271
+ provider = registry.payment_connection_provider_for(domain_name)
272
+ return {} unless provider
273
+
274
+ capability = Bluebook::Capabilities::PAYMENT_CONNECTION
275
+ verbs = Bluebook::Capabilities::CONTRACTS.fetch(capability).keys.to_h { |key| [key, provider.provided_verb(capability, key)] }
276
+ { provider: provider.name, **verbs, aggregate: verbs[:connect]&.split(".")&.first }
277
+ end
278
+
233
279
  # Same seam as `newsletter` — which chapter takes payments
234
280
  # (`Registry#payments_provider_for`), with its declared verbs
235
281
  # qualified and the paying aggregate named off `initiate`. rust/host
@@ -403,6 +403,28 @@ module Hecks
403
403
  vendored_provider_for(domain, Bluebook::Capabilities::NEWSLETTER_ISSUES)
404
404
  end
405
405
 
406
+ # The chapter that answers scheduling sessions and taking registrations
407
+ # for `domain` — resolved the same way as `payments_provider_for`, by what
408
+ # it declares (`provides "registrations"`). Nil when none does.
409
+ #
410
+ # @param domain [String, Symbol] the domain whose registrations chapter is being resolved
411
+ # @return [Bluebook::Chapter, nil] the chapter that answers `domain`'s registrations,
412
+ # or nil if none does
413
+ def registrations_provider_for(domain)
414
+ vendored_provider_for(domain, Bluebook::Capabilities::REGISTRATIONS)
415
+ end
416
+
417
+ # The chapter that owns the business's payment-processor connection for
418
+ # `domain` — resolved by what it declares (`provides "payment_connection"`).
419
+ # Nil when none does.
420
+ #
421
+ # @param domain [String, Symbol] the domain whose payment-connection chapter is being resolved
422
+ # @return [Bluebook::Chapter, nil] the chapter that owns `domain`'s payment connection,
423
+ # or nil if none does
424
+ def payment_connection_provider_for(domain)
425
+ vendored_provider_for(domain, Bluebook::Capabilities::PAYMENT_CONNECTION)
426
+ end
427
+
406
428
  # The chapter that takes payments for `domain` — the domain's own
407
429
  # chapter, a framework member, or a vendored embryonaut bluebook it
408
430
  # attaches, that declares `provides "payments"`. Nil when none does.
data/lib/hecks/version.rb CHANGED
@@ -12,5 +12,5 @@ module Hecks
12
12
  # it there, or even in the consuming Gemfile, never closed the gap,
13
13
  # because gemspec evaluation happens before anything Bundler
14
14
  # resolves is actually loadable yet.
15
- VERSION = "2.3.0".freeze
15
+ VERSION = "2.5.0".freeze
16
16
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.0
4
+ version: 2.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-25 00:00:00.000000000 Z
11
+ date: 2026-09-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -262,6 +262,7 @@ files:
262
262
  - lib/hecks/fuzzing/sequence_generator/catalog.rb
263
263
  - lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb
264
264
  - lib/hecks/fuzzing/sequence_generator/picker.rb
265
+ - lib/hecks/fuzzing/sequence_generator/query_binding.rb
265
266
  - lib/hecks/fuzzing/sequence_generator/step_builder.rb
266
267
  - lib/hecks/fuzzing/shrinker.rb
267
268
  - lib/hecks/fuzzing/structural_skips.rb
@@ -389,6 +390,7 @@ files:
389
390
  - lib/hecks/projections/glossary/page.css
390
391
  - lib/hecks/projections/glossary/page.js
391
392
  - lib/hecks/projections/glossary/sections.rb
393
+ - lib/hecks/projections/glossary/sensitivity.rb
392
394
  - lib/hecks/projections/glossary/sentences.rb
393
395
  - lib/hecks/projections/ir.rb
394
396
  - lib/hecks/projections/model.rb