hecks 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/postgres.rb +1 -1
- data/lib/hecks/adapters/driven/sql_query_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/policy_builder.rb +5 -3
- data/lib/hecks/bluebook/dsl/rule_reference.rb +6 -3
- data/lib/hecks/bluebook/expression/ast_json.rb +43 -0
- data/lib/hecks/bluebook/meta_validator/shapes.rb +17 -1
- data/lib/hecks/corpus.rb +24 -94
- data/lib/hecks/fuzzing/isolated_boot.rb +44 -2
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +3 -0
- data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +138 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -0
- data/lib/hecks/fuzzing/sequence_generator.rb +20 -7
- data/lib/hecks/fuzzing/target_capabilities.rb +29 -1
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +1 -1
- data/lib/hecks/projections/glossary/html.rb +2 -1
- data/lib/hecks/projections/glossary/markdown.rb +14 -2
- data/lib/hecks/projections/glossary/sensitivity.rb +67 -0
- data/lib/hecks/projections/glossary/sentences.rb +18 -9
- data/lib/hecks/projections/glossary.rb +24 -10
- data/lib/hecks/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 97edfb74e99fd94bc3ffa0d9b4b9d9a6b1fafb257431fbe1bb128fdecb4bbf33
|
|
4
|
+
data.tar.gz: 2e4741a23695049fd2639d484f9ca6476c6a89195f0c1be0c8cb5e2434a50f6f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c016c201f4217462adc98d860308cce1ed47485abae6012d3a7220e4604bc83533ad8e2c302d94aeff9b476da59eb0f9bce431a9ca36ad9be259fe6a753690c
|
|
7
|
+
data.tar.gz: a85c4aac72e327792fd65b7027fe0e07a8440ac952031351caece519952b25bd25d256722c415a3552e41869eed4eb807e9827d4a28e78de1112bb63b39d4af1
|
|
@@ -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
|
|
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
|
|
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? &&
|
|
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
|
|
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
|
|
|
@@ -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.
|
|
151
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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"
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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
|
|
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
|
-
|
|
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),
|
|
34
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
@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
|
-
|
|
261
|
-
@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
|
|
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
|
#
|
|
@@ -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
|
|
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
|
-
|
|
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 +=
|
|
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
|
-
# @
|
|
119
|
-
|
|
120
|
-
|
|
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]
|
|
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,
|
|
256
|
+
section: aggregate.hecks_name,
|
|
257
|
+
facts: { value_object: value_object, sensitive: sensitive })
|
|
244
258
|
end
|
|
245
259
|
end
|
|
246
260
|
end
|
data/lib/hecks/version.rb
CHANGED
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.
|
|
4
|
+
version: 2.4.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-
|
|
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
|