hecks 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +9 -1
- data/lib/hecks/adapters/driven/sql_query_builder.rb +13 -2
- data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +30 -1
- data/lib/hecks/bluebook/dsl/command_builder.rb +53 -0
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
- data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
- data/lib/hecks/bluebook/expression/resolver.rb +25 -0
- data/lib/hecks/bluebook/expression.rb +1 -0
- data/lib/hecks/bluebook/model_check.rb +15 -1
- data/lib/hecks/bluebook/project_register.rb +25 -0
- data/lib/hecks/bluebook/value_object.rb +10 -1
- data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
- data/lib/hecks/fuzzing/properties.rb +49 -4
- data/lib/hecks/language/bluebook/aggregate.bluebook +17 -0
- data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
- data/lib/hecks/runtime/command_interpreter.rb +70 -23
- data/lib/hecks/runtime/command_rules/admissibility.rb +42 -7
- data/lib/hecks/runtime/command_rules/arithmetic.rb +33 -3
- data/lib/hecks/runtime/dependency_planning.rb +52 -5
- data/lib/hecks/runtime/entity_interpreter.rb +8 -1
- data/lib/hecks/runtime/read_model_interpreter.rb +13 -4
- data/lib/hecks/runtime/value.rb +54 -4
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +21 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5d13701a926b6be040b496d5e582a9d4fd99ad878372f5345636ef074aa6e4e8
|
|
4
|
+
data.tar.gz: 95fc7593502393bd0d698aeaac1b9419dc6e00b78e12f11ad6740a2008a0df0e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9b65062cd42566cb0f2e4d5c45b12b1990133850964e117a6827b917d5b293245855a292ccd698c12d982de268a1554029a7a079a03c2cd7e99148eba1f7af47
|
|
7
|
+
data.tar.gz: 9b98481d47cfcbd09e72e5d53226287fbed298d2d1ae77ebbd48b133c7fb9100be1531daca0be92728976ef11eb87979e5847306b4eed628308dfebfdf83014f
|
|
@@ -43,7 +43,15 @@ module Hecks
|
|
|
43
43
|
vo = aggregate.value_object(attribute.type)
|
|
44
44
|
return field.to_s unless vo
|
|
45
45
|
|
|
46
|
-
member
|
|
46
|
+
# Numeric member first, then the SOLE attribute whatever it is
|
|
47
|
+
# named (single-attribute value objects strictly answer `.value`
|
|
48
|
+
# — the same generalization `SqlQueryBuilder#query_expression`
|
|
49
|
+
# makes for the column side, kept in lockstep so Memory and SQL
|
|
50
|
+
# order the identical rows identically), and only then the bare
|
|
51
|
+
# `value` convention — now purely a backstop for the multi-field
|
|
52
|
+
# non-numeric shape neither rule can honestly pick a field for.
|
|
53
|
+
member = vo.attributes.find { |a| %w[Integer Float].include?(a.type) }&.name ||
|
|
54
|
+
vo.sole_attribute&.name || "value"
|
|
47
55
|
"#{name}.#{member}"
|
|
48
56
|
end
|
|
49
57
|
end
|
|
@@ -151,7 +151,18 @@ module Hecks
|
|
|
151
151
|
end
|
|
152
152
|
member ||= if path.empty? && attribute && value_object?(attribute)
|
|
153
153
|
object = @aggregate.value_object(attribute.type)
|
|
154
|
-
|
|
154
|
+
# A SOLE attribute is the fallback when no member is
|
|
155
|
+
# numeric — a single-attribute value object IS its one
|
|
156
|
+
# field whatever that field is named (`Behaviour::
|
|
157
|
+
# ValueObject#sole_attribute`, the same strict rule
|
|
158
|
+
# `Runtime::Value`'s own `.value` alias enforces), so
|
|
159
|
+
# `EmailAddress{address}` compiles to `$.address`
|
|
160
|
+
# rather than falling through to the dialects' own
|
|
161
|
+
# `member || "value"` convention and silently matching
|
|
162
|
+
# nothing. Multi-field non-numeric shapes still fall
|
|
163
|
+
# through to that convention, unchanged — there is no
|
|
164
|
+
# single field to honestly pick for them here either.
|
|
165
|
+
(Forms::ValueObjectShape.numeric_member(object) || object.sole_attribute)&.name
|
|
155
166
|
end
|
|
156
167
|
nested_expression(name, path, member)
|
|
157
168
|
end
|
|
@@ -204,7 +215,7 @@ module Hecks
|
|
|
204
215
|
|
|
205
216
|
object = @aggregate.value_object(attribute.type)
|
|
206
217
|
return "" unless object
|
|
207
|
-
return object.
|
|
218
|
+
return object.sole_attribute.name.to_s if object.sole_attribute
|
|
208
219
|
|
|
209
220
|
raise ArgumentError,
|
|
210
221
|
"#{dialect_name} query adapter cannot compile contains on #{field} — " \
|
|
@@ -17,13 +17,19 @@ module Hecks
|
|
|
17
17
|
|
|
18
18
|
# A single-attribute value object (EmailAddress{address},
|
|
19
19
|
# CustomerNumber{value}) is a NAME for a scalar, not a genuine
|
|
20
|
-
# group — [[feedback_name_the_scalar_field]].
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
24
|
-
# `
|
|
25
|
-
# (
|
|
26
|
-
#
|
|
20
|
+
# group — [[feedback_name_the_scalar_field]]. `adapters/driven/
|
|
21
|
+
# sql_query_builder.rb` and `fuzzing/invalid_value_generator.rb`
|
|
22
|
+
# now read through this rather than inlining the check.
|
|
23
|
+
#
|
|
24
|
+
# `forms/field_shape.rb`'s own two `attributes.first.name` sites
|
|
25
|
+
# (`closed_set_options`/`closed_set_field`) look identical but are
|
|
26
|
+
# NOT the same question — they pick a closed set's DISCRIMINANT
|
|
27
|
+
# column, and a closed set can be genuinely multi-attribute
|
|
28
|
+
# (`Runtime::Value::Admission#member_matches?`'s own comment
|
|
29
|
+
# names a real one: `StatementFrequency`'s `cadence`/
|
|
30
|
+
# `retention_months`/`paper_fee_cents`). `sole_attribute` would
|
|
31
|
+
# return `nil` for that shape and break the discriminant lookup —
|
|
32
|
+
# left as `.first` on purpose, not a missed migration.
|
|
27
33
|
def sole_attribute
|
|
28
34
|
attributes.first if attributes.size == 1
|
|
29
35
|
end
|
|
@@ -242,8 +242,37 @@ module Hecks
|
|
|
242
242
|
# into THIS aggregate's own `@value_objects`, the identical move
|
|
243
243
|
# `@value_objects + closed_sets` already makes for the aggregate's
|
|
244
244
|
# own direct attributes (see this file's other 5 call sites).
|
|
245
|
-
|
|
245
|
+
# `type` — THE BARE SHORTHAND (single-attribute value objects):
|
|
246
|
+
# `value_object :Price, Integer` declares a value object with
|
|
247
|
+
# exactly one attribute, NAMED `value`, of that type — pure sugar
|
|
248
|
+
# for `value_object("Price") { attribute :value, Integer }`,
|
|
249
|
+
# routed through the SAME `attribute_impl` the block form's own
|
|
250
|
+
# `attribute` line reaches (so the quoted-text-type refusal,
|
|
251
|
+
# `one_of(...)`/`list_of(...)` synthesis, everything an attribute
|
|
252
|
+
# line already does, applies unchanged rather than being
|
|
253
|
+
# re-derived here). The name `value` is not arbitrary: a
|
|
254
|
+
# single-attribute value object is a NAME for a scalar, not a
|
|
255
|
+
# genuine group ([[feedback_name_the_scalar_field]], `Behaviour::
|
|
256
|
+
# ValueObject#sole_attribute`), and `value` is what the language
|
|
257
|
+
# guarantees EVERY sole field answers to at runtime regardless of
|
|
258
|
+
# its declared name (`Runtime::Value#method_missing`'s alias) —
|
|
259
|
+
# so the shorthand simply declares it under the canonical name
|
|
260
|
+
# directly. Type AND block together are refused: the block exists
|
|
261
|
+
# to say what the fields are, and the type just said it — two
|
|
262
|
+
# answers to one question is an authoring error, never a merge.
|
|
263
|
+
# NEITHER type NOR block keeps its historical behavior untouched
|
|
264
|
+
# (an empty attribute list — judged, or not, by the language
|
|
265
|
+
# downstream, the same as before this parameter existed).
|
|
266
|
+
def value_object(name, type = nil, &block)
|
|
267
|
+
if type && block
|
|
268
|
+
raise Malformed,
|
|
269
|
+
"#{name} declares both a type (#{type.inspect}) and a block — " \
|
|
270
|
+
"value_object #{name.inspect}, Type is sugar for a block declaring " \
|
|
271
|
+
"exactly one attribute named :value; write one form or the other, never both"
|
|
272
|
+
end
|
|
273
|
+
|
|
246
274
|
builder = ValueObjectBuilder.new(name, owner_value_objects: @value_objects + closed_sets)
|
|
275
|
+
builder.attribute_impl(:value, type) if type
|
|
247
276
|
builder.instance_eval(&block) if block
|
|
248
277
|
@value_objects << builder.build
|
|
249
278
|
@value_objects.concat(builder.closed_sets)
|
|
@@ -554,6 +554,59 @@ module Hecks
|
|
|
554
554
|
end
|
|
555
555
|
refuse_unknown_state_sources!(mutation)
|
|
556
556
|
end
|
|
557
|
+
# A SECOND, SEPARATE pass — not folded into the loop above — so
|
|
558
|
+
# every mutation's own self-referential import (resolve_bare_set!)
|
|
559
|
+
# has already landed before any mutation's SOURCE is checked
|
|
560
|
+
# against the final `attributes` list. `sets :a, to: :b` declared
|
|
561
|
+
# before `sets :b` (bare, importing :b from the owner) is real
|
|
562
|
+
# and legal; checking inline, mutation by mutation, would refuse
|
|
563
|
+
# it for an ordering accident this language has never required
|
|
564
|
+
# authors to avoid. rubocop's Style/CombinableLoops can't see
|
|
565
|
+
# that dependency — it only sees two `@mutations.each` calls
|
|
566
|
+
# back to back — so it's disabled here, not satisfied.
|
|
567
|
+
# rubocop:disable-next Style/CombinableLoops
|
|
568
|
+
@mutations.each { |mutation| refuse_unknown_argument_sources!(mutation) }
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
# THE ONES `resolve_source` (CommandRules::Arithmetic) ONLY EVER
|
|
572
|
+
# READS FROM `args` — never a fallback to the record's own current
|
|
573
|
+
# state the way `append`'s own per-field resolution legitimately
|
|
574
|
+
# can (`MutationApplier#resolve_append_source`'s own `instance
|
|
575
|
+
# [source]` fallback, a real, intentional second meaning this
|
|
576
|
+
# check must not foreclose). For exactly these five ops, a bare
|
|
577
|
+
# Symbol source has exactly one legitimate reading — the name of
|
|
578
|
+
# a declared argument — so anything else is unreachable at
|
|
579
|
+
# runtime, not merely optional: no caller can ever supply a value
|
|
580
|
+
# under a name the command never declared (ArgumentGate's own
|
|
581
|
+
# `refuse_unknown_arguments` already refuses that), so the source
|
|
582
|
+
# resolves to nil FOREVER, indistinguishable from a legitimately
|
|
583
|
+
# absent OPTIONAL argument until this check existed to tell them
|
|
584
|
+
# apart. Mirrors `AggregateBuilder#seal_query_argument`'s
|
|
585
|
+
# identical shape for a query's own where-clause argument —
|
|
586
|
+
# same mistake, one construct over.
|
|
587
|
+
CHECKED_SYMBOL_SOURCE_OPS = %i[set increment decrement multiply remove].freeze
|
|
588
|
+
private_constant :CHECKED_SYMBOL_SOURCE_OPS
|
|
589
|
+
|
|
590
|
+
def refuse_unknown_argument_sources!(mutation)
|
|
591
|
+
return unless CHECKED_SYMBOL_SOURCE_OPS.include?(mutation.op)
|
|
592
|
+
return unless mutation.source.is_a?(Symbol)
|
|
593
|
+
# The bare self-referential shape (`sets :field` alone, `source
|
|
594
|
+
# == target`) is `resolve_bare_set!`'s own territory, not this
|
|
595
|
+
# check's — when neither the command nor the owner declares
|
|
596
|
+
# that name, the MORE SPECIFIC, pre-existing refusal one level
|
|
597
|
+
# up (`AggregateBuilder#seal_mutation_targets`, checking the
|
|
598
|
+
# mutation's TARGET against the aggregate's own fields) is the
|
|
599
|
+
# one that should fire, naming the field as a target problem,
|
|
600
|
+
# not — confusingly — as a source problem this check would
|
|
601
|
+
# otherwise misreport it as.
|
|
602
|
+
return if mutation.source.to_s == mutation.target.to_s
|
|
603
|
+
return if attributes.any? { |attr| attr.name == mutation.source }
|
|
604
|
+
|
|
605
|
+
raise Malformed,
|
|
606
|
+
"#{@name}'s sets :#{mutation.target} resolves :#{mutation.source} from its " \
|
|
607
|
+
"arguments, but #{@name} declares no #{mutation.source} attribute — an " \
|
|
608
|
+
"argument that does not exist resolves to nil, always, never what the " \
|
|
609
|
+
"caller actually sent"
|
|
557
610
|
end
|
|
558
611
|
|
|
559
612
|
# `state(:name)` names one of the OWNER'S OWN fields — a snapshot
|
|
@@ -7,10 +7,28 @@ module Hecks
|
|
|
7
7
|
|
|
8
8
|
include WordGate
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
# `legacy_bare_port:` — ONLY `Hecks.port`'s own top-level method
|
|
11
|
+
# (lib/hecks.rb) passes `true`. `PortBuilder#build` never refused
|
|
12
|
+
# an empty build (no verb, no signal, nothing) — `Port.new(verb:
|
|
13
|
+
# nil, signal: :reply)` is a real, allowed shape dsl_spec.rb's own
|
|
14
|
+
# "a port" tests rely on (`signal`-only, no `verb` at all). The
|
|
15
|
+
# AGGREGATE-scoped (`BindingProxy#port`) and hecksagon-ROOT
|
|
16
|
+
# (`HecksagonBuilder#port_impl`) callers both reach this SAME
|
|
17
|
+
# class with `owner: nil` too when they're building the bare-verb
|
|
18
|
+
# shape (`port_impl`'s own root-level port can be EITHER shape,
|
|
19
|
+
# decided only after `build` returns) — so `owner.nil?` cannot be
|
|
20
|
+
# the discriminator between "old Hecks.port semantics" and "real
|
|
21
|
+
# DomainPort semantics"; those two callers correctly want the
|
|
22
|
+
# stricter "declares no verb and no operations" refusal `build`
|
|
23
|
+
# already raises below, unchanged. Only the literal top-level
|
|
24
|
+
# `.port` file caller wants the older, looser rule.
|
|
25
|
+
def initialize(name, owner: nil, legacy_bare_port: false)
|
|
26
|
+
@name = name
|
|
27
|
+
@owner = owner
|
|
28
|
+
@operations = []
|
|
29
|
+
@signal = :reply
|
|
30
|
+
@answers = []
|
|
31
|
+
@legacy_bare_port = legacy_bare_port
|
|
14
32
|
end
|
|
15
33
|
|
|
16
34
|
# WHAT THE OUTSIDE TELLS US — an external fact arriving, translated
|
|
@@ -58,18 +76,42 @@ module Hecks
|
|
|
58
76
|
# coerce-and-assign with nothing else, now executed by
|
|
59
77
|
# `GenericDispatch`.
|
|
60
78
|
|
|
79
|
+
# `Hecks.port "x" do verb "y"; signal :effect end`'s own two words,
|
|
80
|
+
# reachable here too — a bare-verb `DomainPortBuilder.build` falls
|
|
81
|
+
# back to the SAME `Port` object `PortBuilder` produces (`build`,
|
|
82
|
+
# below), so any `.port` file can migrate to being parsed by this
|
|
83
|
+
# builder with zero change to its own text, or to any caller that
|
|
84
|
+
# reads `.verb`/`.signal` off the `Port` it gets back. Ordinary
|
|
85
|
+
# `def`s, exactly like `PortBuilder`'s own — `WordGate`'s own
|
|
86
|
+
# header is explicit that a word answered this way never reaches
|
|
87
|
+
# its `method_missing`, so no new self-hosted grammar row is
|
|
88
|
+
# needed for either word under this context.
|
|
89
|
+
def verb(value) = @verb = value.to_s
|
|
90
|
+
def signal(value) = @signal = value.to_sym
|
|
91
|
+
|
|
92
|
+
# THE METHOD CONTRACT — `PortBuilder#answers`'s own twin, added
|
|
93
|
+
# here after the fact: a `.port` file migrated to parse through
|
|
94
|
+
# this builder (the repoint `lib/hecks.rb#port`'s own comment
|
|
95
|
+
# describes) can still declare one (`extraction.port`'s own
|
|
96
|
+
# `answers :canonical`, real, live corpus text) — this builder's
|
|
97
|
+
# bare-verb fallback needs to carry it through to the same `Port`
|
|
98
|
+
# object `PortBuilder` itself would have built, or the migration
|
|
99
|
+
# would silently drop a method-contract check for any `.port`
|
|
100
|
+
# file that uses this word.
|
|
101
|
+
def answers(name) = @answers << name.to_sym
|
|
102
|
+
|
|
61
103
|
def build
|
|
62
104
|
raise Malformed, "#{@name} declares both a verb and operations — a port is one or the other, not both" if @verb && !@operations.empty?
|
|
63
105
|
|
|
64
|
-
return Port.new(name: @name, verb: @verb, signal: :
|
|
106
|
+
return MetaValidator.call_port(Port.new(name: @name, verb: @verb, signal: @signal, answers: @answers)) if @verb || (@legacy_bare_port && @operations.empty?)
|
|
65
107
|
|
|
66
108
|
raise Malformed, "#{@name} declares no verb and no operations" if @operations.empty?
|
|
67
109
|
|
|
68
110
|
DomainPort.new(name: @name, operations: @operations)
|
|
69
111
|
end
|
|
70
112
|
|
|
71
|
-
def self.build(name, owner: nil, &block)
|
|
72
|
-
builder = new(name, owner: owner)
|
|
113
|
+
def self.build(name, owner: nil, legacy_bare_port: false, &block)
|
|
114
|
+
builder = new(name, owner: owner, legacy_bare_port: legacy_bare_port)
|
|
73
115
|
builder.instance_eval(&block) if block
|
|
74
116
|
builder.build
|
|
75
117
|
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
require_relative "evaluator"
|
|
2
|
+
require_relative "resolver"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module Bluebook
|
|
6
|
+
module Expression
|
|
7
|
+
# ── AST → JSON — walks the REAL Evaluator/Resolver AST (the same
|
|
8
|
+
# objects a live dispatch parses `given`/`ensures`/invariant text
|
|
9
|
+
# into — see docs/implemented/guides/running-a-runtime.md's "The
|
|
10
|
+
# expression grammar") and emits plain, JSON-serializable Ruby
|
|
11
|
+
# Hashes, tagged by `"op"`.
|
|
12
|
+
#
|
|
13
|
+
# THE SAME TWO-METHOD WALK `rust/project/expr_emitter.rb`'s own
|
|
14
|
+
# `emit_bool`/`emit_resolver` already do, over the SAME AST — that
|
|
15
|
+
# file's own methods build RUST SOURCE-CODE STRINGS for `rust/
|
|
16
|
+
# project`'s codegen (`rust/src/kernel::expr::Expr` literals, baked
|
|
17
|
+
# into a generated domain's own compiled binary); this builds DATA
|
|
18
|
+
# instead, for a genuinely different consumer with a genuinely
|
|
19
|
+
# different constraint: `rust/host` can never link the `rust`
|
|
20
|
+
# (kernel) crate at all (a real, load-bearing build constraint —
|
|
21
|
+
# `reference_validate.rs`'s own header has the full reasoning: one
|
|
22
|
+
# path dependency would statically bake every domain's generated
|
|
23
|
+
# dispatch code into every Lambda binary), so a value object's own
|
|
24
|
+
# `invariant` predicate has to travel as something `rust/host` can
|
|
25
|
+
# deserialize and interpret itself, at RUNTIME, from `ir.json` — the
|
|
26
|
+
# exact same relationship `rust/project`'s own `Expr` literals
|
|
27
|
+
# already have to the compiled kernel, one layer further out.
|
|
28
|
+
#
|
|
29
|
+
# LIVES IN CORE `lib/hecks`, NOT `rust/project/` — `rust/project.rb`
|
|
30
|
+
# is a separate, downstream toolchain
|
|
31
|
+
# (`lib/hecks/projector.rb`'s own header: "a whole separate Ruby
|
|
32
|
+
# program"), never `require`d by core `lib/hecks/bluebook/*.rb`
|
|
33
|
+
# files (confirmed: no core file does). `value_object.rb`'s own
|
|
34
|
+
# `invariants:` IR emission needs this for EVERY domain's ordinary
|
|
35
|
+
# `to_h`/`ir.json` export — golden fixtures, `hecks-parse`'s parity
|
|
36
|
+
# comparisons, and any deploy artifact, not only a `bin/project_rust`
|
|
37
|
+
# run — so it belongs beside `Evaluator`/`Resolver` themselves, not
|
|
38
|
+
# bolted onto a tool that only sometimes runs.
|
|
39
|
+
#
|
|
40
|
+
# COMPLETE, not corpus-scoped: every node this grammar admits gets
|
|
41
|
+
# a real arm, the identical "raise, don't silently drop" discipline
|
|
42
|
+
# `expr_emitter.rb`'s own `emit_bool`/`emit_resolver` already hold
|
|
43
|
+
# to — even though, as of this writing, no real corpus VALUE OBJECT
|
|
44
|
+
# invariant exercises `Include`/`Modulo`/`BlockPredicate`/`Find`/
|
|
45
|
+
# `Array`/`MatchesRegex`/`Presence`/`Split`/`StartsWith`/`EndsWith`/
|
|
46
|
+
# `First`/`Last` (only `given`/`ensures` clauses do, elsewhere in
|
|
47
|
+
# the corpus — a different consumer of this same grammar).
|
|
48
|
+
# `rust/host/src/expr_json.rs`'s own header names exactly which of
|
|
49
|
+
# these its interpreter evaluates for real today versus refuses
|
|
50
|
+
# cleanly — a narrower, deliberate, documented boundary on the
|
|
51
|
+
# INTERPRETING side, not on this EMITTING side: an author is free
|
|
52
|
+
# to write ANY real expression in a value object's own `invariant`,
|
|
53
|
+
# and this always emits it faithfully; whether `rust/host` can yet
|
|
54
|
+
# CHECK it at mint time is that file's own question to answer, not
|
|
55
|
+
# this one's to pre-empt by refusing to even try.
|
|
56
|
+
module AstJson
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
def emit_predicate(canonical)
|
|
60
|
+
emit_bool(Evaluator.parse(canonical))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def emit_bool(node)
|
|
64
|
+
case node
|
|
65
|
+
when Evaluator::Or then { "op" => "or", "left" => emit_bool(node.left), "right" => emit_bool(node.right) }
|
|
66
|
+
when Evaluator::And then { "op" => "and", "left" => emit_bool(node.left), "right" => emit_bool(node.right) }
|
|
67
|
+
when Evaluator::Not then { "op" => "not", "expr" => emit_bool(node.node) }
|
|
68
|
+
when Evaluator::Compare
|
|
69
|
+
{ "op" => "compare", "cmp" => emit_comparison(node.operator),
|
|
70
|
+
"left" => emit_resolver(node.left), "right" => emit_resolver(node.right) }
|
|
71
|
+
when Evaluator::Include
|
|
72
|
+
emit_include(node)
|
|
73
|
+
when Evaluator::Resolve
|
|
74
|
+
emit_resolver(node.expr)
|
|
75
|
+
else
|
|
76
|
+
raise "unhandled evaluator node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_bool)"
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def emit_comparison(op)
|
|
81
|
+
{ "less_than" => op.compares_less_than, "equal" => op.compares_equal, "negated" => op.negated }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The JSON-target sibling of `expr_emitter.rb`'s own
|
|
85
|
+
# `emit_include` — see that method's own comment for the full
|
|
86
|
+
# reasoning (a LITERAL array haystack has no `Expr::Include`-
|
|
87
|
+
# representable shape on EITHER target, kernel or host, so both
|
|
88
|
+
# rewrite it identically into an OR-of-equalities at emission
|
|
89
|
+
# time rather than carrying a shape neither interpreter could
|
|
90
|
+
# evaluate). A non-literal haystack still emits `include`
|
|
91
|
+
# unchanged.
|
|
92
|
+
EQ = Evaluator::OPERATORS.find { |op| op.symbol == "==" }
|
|
93
|
+
private_constant :EQ
|
|
94
|
+
|
|
95
|
+
def emit_include(node)
|
|
96
|
+
return { "op" => "include", "haystack" => emit_resolver(node.haystack), "needle" => emit_resolver(node.needle) } \
|
|
97
|
+
unless node.haystack.is_a?(Resolver::ArrayLiteral)
|
|
98
|
+
|
|
99
|
+
return { "op" => "bool", "value" => false } if node.haystack.elements.empty?
|
|
100
|
+
|
|
101
|
+
equalities = node.haystack.elements.map do |element|
|
|
102
|
+
{ "op" => "compare", "cmp" => emit_comparison(EQ), "left" => emit_resolver(node.needle), "right" => emit_resolver(element) }
|
|
103
|
+
end
|
|
104
|
+
equalities.reduce { |left, right| { "op" => "or", "left" => left, "right" => right } }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def emit_resolver(node)
|
|
108
|
+
case node
|
|
109
|
+
when Resolver::IntegerLiteral then { "op" => "int", "value" => node.value }
|
|
110
|
+
when Resolver::FloatLiteral then { "op" => "float", "value" => node.value }
|
|
111
|
+
when Resolver::StringLiteral then { "op" => "str", "value" => node.value }
|
|
112
|
+
when Resolver::BoolLiteral then { "op" => "bool", "value" => node.value }
|
|
113
|
+
when Resolver::NilLiteral then { "op" => "nil" }
|
|
114
|
+
when Resolver::Lookup then { "op" => "lookup", "path" => node.path }
|
|
115
|
+
when Resolver::Addition then { "op" => "add", "left" => emit_resolver(node.left), "right" => emit_resolver(node.right) }
|
|
116
|
+
when Resolver::SignTest
|
|
117
|
+
{ "op" => "sign_test", "cmp" => emit_comparison(node.operator), "receiver" => emit_resolver(node.receiver) }
|
|
118
|
+
when Resolver::Empty then { "op" => "empty", "receiver" => emit_resolver(node.receiver) }
|
|
119
|
+
when Resolver::ToS then { "op" => "to_s", "receiver" => emit_resolver(node.receiver) }
|
|
120
|
+
when Resolver::Modulo then { "op" => "modulo", "receiver" => emit_resolver(node.receiver), "divisor" => emit_resolver(node.divisor) }
|
|
121
|
+
when Resolver::Size then { "op" => "size", "receiver" => emit_resolver(node.receiver) }
|
|
122
|
+
when Resolver::BlockPredicate
|
|
123
|
+
{ "op" => "block_predicate", "mode" => node.mode.to_s, "receiver" => emit_resolver(node.receiver),
|
|
124
|
+
"param" => node.param.to_s, "predicate" => emit_bool(node.predicate) }
|
|
125
|
+
when Resolver::Find
|
|
126
|
+
{ "op" => "find", "receiver" => emit_resolver(node.receiver), "param" => node.param.to_s,
|
|
127
|
+
"predicate" => emit_bool(node.predicate), "path" => node.path.map(&:to_s) }
|
|
128
|
+
when Resolver::ArrayLiteral
|
|
129
|
+
{ "op" => "array", "elements" => node.elements.map { |element| emit_resolver(element) } }
|
|
130
|
+
when Resolver::MatchesRegex
|
|
131
|
+
{ "op" => "matches_regex", "receiver" => emit_resolver(node.receiver), "pattern" => node.pattern, "flags" => node.flags }
|
|
132
|
+
when Resolver::Presence
|
|
133
|
+
{ "op" => "presence", "receiver" => emit_resolver(node.receiver), "negated" => node.negated }
|
|
134
|
+
when Resolver::Split
|
|
135
|
+
{ "op" => "split", "receiver" => emit_resolver(node.receiver), "separator" => node.separator }
|
|
136
|
+
when Resolver::StartsWith
|
|
137
|
+
{ "op" => "starts_with", "receiver" => emit_resolver(node.receiver), "substring" => node.substring }
|
|
138
|
+
when Resolver::EndsWith
|
|
139
|
+
{ "op" => "ends_with", "receiver" => emit_resolver(node.receiver), "substring" => node.substring }
|
|
140
|
+
when Resolver::First then { "op" => "first", "receiver" => emit_resolver(node.receiver) }
|
|
141
|
+
when Resolver::Last then { "op" => "last", "receiver" => emit_resolver(node.receiver) }
|
|
142
|
+
else
|
|
143
|
+
raise "unhandled resolver node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_resolver)"
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -744,9 +744,34 @@ module Hecks
|
|
|
744
744
|
# `#to_h`), so this is safe for the existing `field.value`-
|
|
745
745
|
# shaped dotted lookups too -- they already returned a raw
|
|
746
746
|
# scalar and are unaffected.
|
|
747
|
+
# UPDATE (single-element value objects strictly answer `.value`):
|
|
748
|
+
# the unwrap used to gate on the sole key being literally NAMED
|
|
749
|
+
# `:value` — correct for the shorthand/closed-set shapes that
|
|
750
|
+
# motivated it, but a lie of omission for `Money{amount}` and
|
|
751
|
+
# every other single-field value object whose author picked a
|
|
752
|
+
# domain name for the field: the SAME "this VO IS its scalar"
|
|
753
|
+
# reading ([[feedback_name_the_scalar_field]], `Behaviour::
|
|
754
|
+
# ValueObject#sole_attribute`) applies regardless of what the
|
|
755
|
+
# sole field happens to be called, and the name gate made a bare
|
|
756
|
+
# `balance > 0` work for a `Balance{value}` while silently
|
|
757
|
+
# comparing a whole VO for a `Balance{amount}`. Now the COUNT is
|
|
758
|
+
# the gate, never the name. A declared `Runtime::Value` reads
|
|
759
|
+
# its own `sole_attribute` (the declaration's answer, not the
|
|
760
|
+
# stored hash's); any OTHER to_h-able (a Struct, a bespoke
|
|
761
|
+
# wrapper with no declaration to consult) keeps the original
|
|
762
|
+
# `{value: X}`-only unwrap, so nothing that never was a value
|
|
763
|
+
# object gains a surprise unwrapping. `rust/src/kernel/json.rs`'s
|
|
764
|
+
# `impl Fielded for Json` mirrors the count-only reading on the
|
|
765
|
+
# Rust side — change them in lockstep or rust_conformance
|
|
766
|
+
# diverges.
|
|
747
767
|
def unwrap_scalar(value)
|
|
748
768
|
return value unless value.respond_to?(:to_h) && !value.is_a?(Hash) && !value.is_a?(Array)
|
|
749
769
|
|
|
770
|
+
if value.respond_to?(:value_object)
|
|
771
|
+
sole = value.value_object.sole_attribute
|
|
772
|
+
return sole ? value[sole.name] : value
|
|
773
|
+
end
|
|
774
|
+
|
|
750
775
|
hash = value.to_h
|
|
751
776
|
hash.size == 1 && hash.key?(:value) ? hash[:value] : value
|
|
752
777
|
end
|
|
@@ -184,7 +184,21 @@ module Hecks
|
|
|
184
184
|
# forever, since nothing about *entering* it via default implies
|
|
185
185
|
# anything must eventually move it, unlike a state a transition
|
|
186
186
|
# explicitly delivered somewhere.
|
|
187
|
-
|
|
187
|
+
#
|
|
188
|
+
# Scoped to `default` alone, and only when the lifecycle actually
|
|
189
|
+
# declares real transitions elsewhere: an EMPTY lifecycle (no
|
|
190
|
+
# transitions at all) doesn't get this exemption — that's not "a
|
|
191
|
+
# machine whose entry point deliberately awaits external action,"
|
|
192
|
+
# it's much more likely a lifecycle nobody finished wiring, and
|
|
193
|
+
# should still warn (see spec/fixtures/model_check/lifecycle_
|
|
194
|
+
# findings.bluebook's own Widget::Part, which stays warned on
|
|
195
|
+
# purpose).
|
|
196
|
+
def terminal_exempt(lifecycle)
|
|
197
|
+
return [] if lifecycle.transitions.empty?
|
|
198
|
+
|
|
199
|
+
outgoing_sources = lifecycle.transitions.flat_map { |_, t| Array(t.from) }.to_set
|
|
200
|
+
outgoing_sources.include?(lifecycle.default) ? [] : [lifecycle.default]
|
|
201
|
+
end
|
|
188
202
|
|
|
189
203
|
# ── process managers / sagas ──────────────────────────────────────
|
|
190
204
|
|
|
@@ -16,6 +16,7 @@ module Hecks
|
|
|
16
16
|
|
|
17
17
|
def initialize
|
|
18
18
|
@entries = {}
|
|
19
|
+
@tenant_directories = Hash.new { |hash, key| hash[key] = [] }
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def fetch(address) = entries.fetch(address.to_s)
|
|
@@ -25,6 +26,7 @@ module Hecks
|
|
|
25
26
|
|
|
26
27
|
def register(bluebooks, registry, dispatcher, directory)
|
|
27
28
|
bluebooks.each do |bluebook|
|
|
29
|
+
refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
|
|
28
30
|
world = registry.world(bluebook.name)
|
|
29
31
|
realm = world&.realm
|
|
30
32
|
raise MissingRealm, "#{bluebook.name} in #{directory} has no world realm" if realm.to_s.empty?
|
|
@@ -62,6 +64,29 @@ module Hecks
|
|
|
62
64
|
|
|
63
65
|
private
|
|
64
66
|
|
|
67
|
+
# THE ACTUAL "more than one tenant" MOMENT — Runtime::TenantCheck's
|
|
68
|
+
# own header names this table as the one place real multitenancy
|
|
69
|
+
# happens: the SAME on-disk directory (one domain, one
|
|
70
|
+
# `persisted_by` binding) registering a SECOND time, under a
|
|
71
|
+
# different realm, into this SAME shared route table. A directory's
|
|
72
|
+
# FIRST registration is never refused here — nothing shares its
|
|
73
|
+
# data yet, so a plain single-tenant deployment on an ordinary
|
|
74
|
+
# adapter (Postgres, no schema story) still boots exactly as
|
|
75
|
+
# before. Only the SECOND (and any later) registration of that
|
|
76
|
+
# same directory is refused, and refused before this call adds its
|
|
77
|
+
# routes to the table — so a leaking tenant's requests never
|
|
78
|
+
# become reachable through `Router#resolve` in the first place.
|
|
79
|
+
#
|
|
80
|
+
# Keyed on `directory` + `bluebook.name` only, not realm or
|
|
81
|
+
# `dispatcher` — two tenants share the identical on-disk domain,
|
|
82
|
+
# differing only by which realm/environment overlay booted it.
|
|
83
|
+
def refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
|
|
84
|
+
key = [directory, bluebook.name]
|
|
85
|
+
seen_before = @tenant_directories.key?(key) && @tenant_directories[key].any?
|
|
86
|
+
Runtime::TenantCheck.refuse_unless_tenant_capable!(registry, bluebook.name) if seen_before
|
|
87
|
+
@tenant_directories[key] << registry
|
|
88
|
+
end
|
|
89
|
+
|
|
65
90
|
def current?(bluebook, world)
|
|
66
91
|
bluebook.version.nil? || world.latest == bluebook.version
|
|
67
92
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "behaviour/value_object"
|
|
2
|
+
require_relative "expression/ast_json"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Bluebook
|
|
@@ -28,7 +29,15 @@ module Hecks
|
|
|
28
29
|
emits_ir(
|
|
29
30
|
name: :hecks_name,
|
|
30
31
|
attributes: many(:attributes),
|
|
31
|
-
|
|
32
|
+
# `ast:` — a JSON-serializable rendering of the SAME predicate
|
|
33
|
+
# `canonical` already spells as text, alongside it rather than
|
|
34
|
+
# replacing it (`canonical` stays the human-facing/doctest-facing
|
|
35
|
+
# form; parsing it back would just re-derive what `ast` already
|
|
36
|
+
# states directly). Ground truth and the full reasoning:
|
|
37
|
+
# `Expression::AstJson`'s own header — built for `rust/host`'s
|
|
38
|
+
# own mint-time invariant check (`reference_validate.rs`), which
|
|
39
|
+
# has no kernel crate to parse `canonical` with.
|
|
40
|
+
invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical, ast: Expression::AstJson.emit_predicate(rule.canonical) } } },
|
|
32
41
|
closed_set: :closed_set?,
|
|
33
42
|
# THE FIELD NAME IS STRINGIFIED, NEVER THE VALUE. A `member` row can
|
|
34
43
|
# hold any of the scalar types an attribute declares — `Integer 84`
|
|
@@ -67,11 +67,12 @@ module Hecks
|
|
|
67
67
|
# scalar cannot stand for anything and the refusal has to say so.
|
|
68
68
|
def scalar_for(attribute, aggregate, random:)
|
|
69
69
|
value_object = aggregate.value_object(attribute.type.to_s)
|
|
70
|
-
|
|
70
|
+
sole = value_object&.sole_attribute
|
|
71
|
+
return "a bare scalar" unless sole
|
|
71
72
|
|
|
72
73
|
# One field, so a scalar is legal — corrupt the FIELD's own type instead,
|
|
73
74
|
# which is still a shape the attribute cannot accept.
|
|
74
|
-
{
|
|
75
|
+
{ sole.name.to_s => ["nested", "array"] }
|
|
75
76
|
end
|
|
76
77
|
|
|
77
78
|
# An attribute the command never declared. `refuse_unknown_arguments` is a
|