hecks 1.0.0 → 1.0.1
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/sql_query_builder.rb +1 -1
- data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
- 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.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/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/version.rb +1 -1
- data/lib/hecks.rb +21 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 82b36c04d3c930c3f3193aeb23d208c170ab95507dfbac4842b8dae59168a732
|
|
4
|
+
data.tar.gz: 1cac5931a30cf658980cc65cf8c31a90b36deae5460a9ed6846902d51faa4630
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a90f3c1372ac1a9d3755fe3fbc83a9036cea933a9b63f9517bd52e1010c397c6e2dacd21c6a5030b1fdebbca4bb339c1cc651923b20c1bd9aa1160161ccd7d23
|
|
7
|
+
data.tar.gz: 6d1069bd54229fddc5befad9ff9afb727fba894816895acc59fcfec20000c765e0444cdd0fd23dc9a37a5a3478c209a0005c836f5351483fbadbfbebb7742566
|
|
@@ -204,7 +204,7 @@ module Hecks
|
|
|
204
204
|
|
|
205
205
|
object = @aggregate.value_object(attribute.type)
|
|
206
206
|
return "" unless object
|
|
207
|
-
return object.
|
|
207
|
+
return object.sole_attribute.name.to_s if object.sole_attribute
|
|
208
208
|
|
|
209
209
|
raise ArgumentError,
|
|
210
210
|
"#{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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -4,7 +4,9 @@ Hecks.bluebook "Hecksagon" do
|
|
|
4
4
|
|
|
5
5
|
identified_by PortName, as: :name
|
|
6
6
|
|
|
7
|
-
attribute :verb,
|
|
7
|
+
attribute :verb, PortVerb, optional: true
|
|
8
|
+
attribute :signal, PortSignal, optional: true
|
|
9
|
+
attribute :answers, list_of(PortAnswer)
|
|
8
10
|
|
|
9
11
|
value_object "PortName" do
|
|
10
12
|
attribute :value, String
|
|
@@ -16,6 +18,24 @@ Hecks.bluebook "Hecksagon" do
|
|
|
16
18
|
invariant("a verb is named") { !value.to_s.empty? }
|
|
17
19
|
end
|
|
18
20
|
|
|
21
|
+
# SAME CLOSED SET port.bluebook's own "Port" aggregate declares —
|
|
22
|
+
# kept as its own local value object rather than shared cross-
|
|
23
|
+
# chapter, the same way PortVerb already is here.
|
|
24
|
+
value_object "PortSignal" do
|
|
25
|
+
attribute :value, String, one_of: ["reply", "effect"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `answers` — DomainPortBuilder's own twin of PortBuilder#answers
|
|
29
|
+
# (port.bluebook's "Port" aggregate, same shape), added once a real
|
|
30
|
+
# `.port` file migrated to parse through this builder (the repoint
|
|
31
|
+
# `lib/hecks.rb#port`'s own comment describes) used the word
|
|
32
|
+
# (`extraction.port`'s own `answers :canonical`). Local, not shared
|
|
33
|
+
# cross-chapter, same reasoning as PortSignal just above.
|
|
34
|
+
value_object "PortAnswer" do
|
|
35
|
+
attribute :value, String
|
|
36
|
+
invariant("an answer names a real method") { !value.to_s.empty? }
|
|
37
|
+
end
|
|
38
|
+
|
|
19
39
|
# HOW DOMAINPORT IS SPELLED. These rows live with the concept
|
|
20
40
|
# they describe; SyntaxBoot discovers every aggregate-local seed table.
|
|
21
41
|
# Contexts represented here: DomainPort.
|
|
@@ -35,6 +55,8 @@ Hecks.bluebook "Hecksagon" do
|
|
|
35
55
|
member word: "tells", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "tells_impl"
|
|
36
56
|
member word: "asks", context: "DomainPort", body: "keywords", inner: "PortOperation", opens: "PortOperation", fills: "", calls: "asks_impl"
|
|
37
57
|
member word: "verb", context: "DomainPort", body: "none", inner: "", opens: "", fills: "verb"
|
|
58
|
+
member word: "signal", context: "DomainPort", body: "none", inner: "", opens: "", fills: "signal"
|
|
59
|
+
member word: "answers", context: "DomainPort", body: "none", inner: "", opens: "", fills: "answers"
|
|
38
60
|
|
|
39
61
|
end
|
|
40
62
|
|
|
@@ -59,6 +81,8 @@ Hecks.bluebook "Hecksagon" do
|
|
|
59
81
|
member keyword: "tells", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
60
82
|
member keyword: "asks", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "name"
|
|
61
83
|
member keyword: "verb", context: "DomainPort", at: "1", named: "", kind: "text", required: "true", fills: "verb"
|
|
84
|
+
member keyword: "signal", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "signal"
|
|
85
|
+
member keyword: "answers", context: "DomainPort", at: "1", named: "", kind: "symbol", required: "true", fills: "answers"
|
|
62
86
|
|
|
63
87
|
# THE RECEIVING AGGREGATE — PortOperationBuilder's own `reference_to`
|
|
64
88
|
# (inside the block) is refused (#335); this is the sanctioned
|
|
@@ -48,7 +48,7 @@ module Hecks
|
|
|
48
48
|
# that sets them runs, same as they were unset locals before that point.
|
|
49
49
|
Context = Struct.new(:domain, :aggregate, :command, :args, :repository, :instance, :transition, :old_state,
|
|
50
50
|
:result, :correlation, :route, :plan, :strategy, :persistence_outcome, :delegated_events,
|
|
51
|
-
:dry_run)
|
|
51
|
+
:dry_run, :correction_bindings)
|
|
52
52
|
|
|
53
53
|
def initialize(registry, rules:)
|
|
54
54
|
@registry = registry
|
|
@@ -135,10 +135,13 @@ module Hecks
|
|
|
135
135
|
# NotFound/AlreadyExists already get at hydration: "does the
|
|
136
136
|
# fact this command's corrects names even exist" is not a
|
|
137
137
|
# domain rule an author wrote, it is a precondition for the
|
|
138
|
-
# domain rules to mean anything at all.
|
|
139
|
-
|
|
138
|
+
# domain rules to mean anything at all. Also locates the
|
|
139
|
+
# correction target itself, if `as:` named one — carried on
|
|
140
|
+
# `ctx` so `step_enforce_ensures` (the settled-record half)
|
|
141
|
+
# can bind the SAME name too, not just this pre-mutation half.
|
|
142
|
+
ctx.correction_bindings = @rules.enforce_correction_target(ctx.instance, ctx.aggregate, ctx.command, domain: ctx.domain)
|
|
140
143
|
@rules.enforce_givens(ctx.instance, ctx.command, ctx.args, domain: ctx.domain,
|
|
141
|
-
declaring: ctx.aggregate, parent: ctx.instance)
|
|
144
|
+
declaring: ctx.aggregate, parent: ctx.instance, correction: ctx.correction_bindings)
|
|
142
145
|
}
|
|
143
146
|
end
|
|
144
147
|
|
|
@@ -239,7 +242,7 @@ module Hecks
|
|
|
239
242
|
def step_enforce_ensures(ctx)
|
|
240
243
|
step(:enforce_ensures) {
|
|
241
244
|
@rules.enforce_ensures(ctx.instance, ctx.command, ctx.args, old: ctx.old_state,
|
|
242
|
-
domain: ctx.domain, parent: ctx.instance)
|
|
245
|
+
domain: ctx.domain, parent: ctx.instance, correction: ctx.correction_bindings || {})
|
|
243
246
|
}
|
|
244
247
|
end
|
|
245
248
|
|
|
@@ -260,7 +263,7 @@ module Hecks
|
|
|
260
263
|
seed_projected_fields(ctx)
|
|
261
264
|
ctx.persistence_outcome = if ctx.strategy == DependencyPlanning::ATOMIC_PUT
|
|
262
265
|
# A SECOND CREATION IS NOT A FRESH ONE — see
|
|
263
|
-
#
|
|
266
|
+
# hydrate_complete_state's own comment; the
|
|
264
267
|
# same refusal, on the same terms, for the
|
|
265
268
|
# complete-state path. `insert_only:` asks the
|
|
266
269
|
# ADAPTER to decide and refuse ATOMICALLY
|
|
@@ -376,19 +379,61 @@ module Hecks
|
|
|
376
379
|
found.dup
|
|
377
380
|
end
|
|
378
381
|
|
|
379
|
-
#
|
|
380
|
-
#
|
|
381
|
-
#
|
|
382
|
-
#
|
|
383
|
-
#
|
|
382
|
+
# WAVE 8 (equivalence-gap plan, item 1.6) — NOT done, and this
|
|
383
|
+
# comment now says precisely how far it got, corrected from an
|
|
384
|
+
# EARLIER version of itself that briefly (same PR, never released)
|
|
385
|
+
# claimed the inventory was empty and deleted this method outright.
|
|
386
|
+
# A full corpus audit (`DependencyPlanning::Analyzer.call` against
|
|
387
|
+
# every `creates?`-true command in all 5 EXAMPLE domains — banking,
|
|
388
|
+
# pizzas, chess, compliance, roster) found and fixed 4 real
|
|
389
|
+
# authoring bugs (`Statement.Generate`, `CreatePizza`, `Chess::
|
|
390
|
+
# Game.Start`, `Roster.Open` — each declared an attribute and
|
|
391
|
+
# never `sets` it, so the field silently stayed nil on every
|
|
392
|
+
# created record regardless of what a caller sent), plus a real
|
|
393
|
+
# `DependencyPlanning::Analyzer` bug for ENTITY-owned commands
|
|
394
|
+
# (`root_aggregate:`, that class's own header) that surfaced one
|
|
395
|
+
# more live corpus bug of its own (`payment_cards.bluebook`'s own
|
|
396
|
+
# `Withdrawal.Dispute`, which never actually checked whether the
|
|
397
|
+
# card was retired) — all real, all kept.
|
|
384
398
|
#
|
|
385
|
-
#
|
|
386
|
-
#
|
|
387
|
-
#
|
|
388
|
-
#
|
|
389
|
-
#
|
|
390
|
-
#
|
|
391
|
-
#
|
|
399
|
+
# DELETING THIS METHOD ON THAT BASIS TURNED OUT TO BE WRONG,
|
|
400
|
+
# caught by running the full suite rather than trusting the
|
|
401
|
+
# audit's own scope: the 5 example domains are nowhere near the
|
|
402
|
+
# WHOLE inventory of `creates?`-true commands this fallback
|
|
403
|
+
# actually carries. Dozens of separate, purpose-built spec
|
|
404
|
+
# fixtures across the suite (`spec/fixtures/*.bluebook`, and
|
|
405
|
+
# inline `Hecks.bluebook` blocks declared directly inside
|
|
406
|
+
# individual spec files — governance, mutation ops, ports,
|
|
407
|
+
# routing, tenant isolation, sagas, and more) declare their OWN
|
|
408
|
+
# small `creates?`-true commands the same incomplete way, and
|
|
409
|
+
# rely on this exact fallback to create anything at all. Deleting
|
|
410
|
+
# it produced 218 failures across specs with nothing to do with
|
|
411
|
+
# Wave 8's own subject — confirmed, not guessed, by actually
|
|
412
|
+
# running `bundle exec rspec` after the deletion, not merely by
|
|
413
|
+
# extrapolating from the one audited inventory. `spec/fixtures/
|
|
414
|
+
# till.bluebook`'s own `Till.OpenTill` (missing `sets :number`,
|
|
415
|
+
# the identical bug shape as the 4 fixed above) was fixed
|
|
416
|
+
# alongside the four real corpus ones as one instance of this — but
|
|
417
|
+
# it was one of many, not the last one, and finding the rest is
|
|
418
|
+
# real, separate, much larger follow-up work this pass does not
|
|
419
|
+
# attempt: a suite-wide audit of every declared bluebook fixture,
|
|
420
|
+
# not just the 5 example domains the plan's own text named.
|
|
421
|
+
#
|
|
422
|
+
# Transitional compatibility for live source that has not yet
|
|
423
|
+
# acquired explicit effects, same as it always was — deliberately
|
|
424
|
+
# isolated from the normal routing and planning path so
|
|
425
|
+
# `reference_to` no longer chooses how a migrated command hydrates
|
|
426
|
+
# or persists. A real, future Wave 8 removes this once THAT wider
|
|
427
|
+
# inventory is empty, not before.
|
|
428
|
+
#
|
|
429
|
+
# `ctx.plan.complete_state?` already claimed every command whose
|
|
430
|
+
# plan is fully resolved in the two branches above `step_hydrate`
|
|
431
|
+
# tries first, so reaching this check already means the plan is
|
|
432
|
+
# incomplete — an un-migrated command still routes here on
|
|
433
|
+
# `creates?` alone, the same as the old `hydrate` did, regardless
|
|
434
|
+
# of whether it happens to have any mutations (`write_set`).
|
|
435
|
+
# Requiring an EMPTY write_set here refused every un-migrated
|
|
436
|
+
# creating command that sets even one field.
|
|
392
437
|
def legacy_implicit_creation?(ctx)
|
|
393
438
|
ctx.route.nil? && ctx.command.creates?
|
|
394
439
|
end
|
|
@@ -421,9 +466,11 @@ module Hecks
|
|
|
421
466
|
aggregate: aggregate.hecks_name,
|
|
422
467
|
identity: identity_reading(aggregate)))
|
|
423
468
|
|
|
424
|
-
# A SECOND CREATION IS NOT A FRESH ONE —
|
|
425
|
-
#
|
|
426
|
-
#
|
|
469
|
+
# A SECOND CREATION IS NOT A FRESH ONE — `creates?` on an identity
|
|
470
|
+
# a record already exists under refuses (`AlreadyExists`) rather
|
|
471
|
+
# than silently overwriting it, the same refusal `hydrate_prior_
|
|
472
|
+
# or_initial`'s own body gives for its own complete-but-state-
|
|
473
|
+
# dependent case, below. ONLY when `strategy` will NOT be ATOMIC_PUT:
|
|
427
474
|
# an atomic-put-capable adapter enforces this itself, atomically,
|
|
428
475
|
# via `insert_only:` in step_save (no read here, no race with the
|
|
429
476
|
# write) — but `strategy_for` already fell back to a plain `save`
|
|
@@ -461,8 +508,8 @@ module Hecks
|
|
|
461
508
|
identity: identity_reading(aggregate)))
|
|
462
509
|
found = repository.find(id)
|
|
463
510
|
|
|
464
|
-
# A SECOND CREATION IS NOT A FRESH ONE — see
|
|
465
|
-
#
|
|
511
|
+
# A SECOND CREATION IS NOT A FRESH ONE — see hydrate_complete_
|
|
512
|
+
# state's own comment; the same refusal, on the same terms, for
|
|
466
513
|
# a complete-but-state-dependent command (one with a `given`
|
|
467
514
|
# reading its own prior state, which is what routes here instead
|
|
468
515
|
# of hydrate_complete_state). Gated on `command.creates?`: a
|
|
@@ -116,7 +116,13 @@ module Hecks
|
|
|
116
116
|
# parent aggregate reaching ITS OWN customer) resolves the same
|
|
117
117
|
# way `account.customer.status` does for a command-level reference.
|
|
118
118
|
# nil for an aggregate command — CommandInterpreter never passes it.
|
|
119
|
-
|
|
119
|
+
# `correction:` — the `{as_name => payload}` bindings
|
|
120
|
+
# `enforce_correction_target` (above) already located, merged in
|
|
121
|
+
# LAST so an `as:` name wins the same way `old:` always wins in
|
|
122
|
+
# `enforce_ensures`, below — it is a fresh local binding a
|
|
123
|
+
# `corrects` command introduces, not a real argument/state field
|
|
124
|
+
# a caller could collide with by accident.
|
|
125
|
+
def enforce_givens(subject, command, args, domain:, declaring: nil, parent: nil, correction: {})
|
|
120
126
|
state = GuardState.new(subject)
|
|
121
127
|
# A RULE MAY ONLY READ WITHIN ITS OWN AGGREGATE BOUNDARY (S12,
|
|
122
128
|
# ADR 0025) — `subject`'s own STORED references are no longer
|
|
@@ -135,6 +141,7 @@ module Hecks
|
|
|
135
141
|
# forbids.
|
|
136
142
|
attrs = args.merge(dereference(domain, command, args))
|
|
137
143
|
attrs = attrs.merge(parent: parent.state) if parent
|
|
144
|
+
attrs = attrs.merge(correction) unless correction.empty?
|
|
138
145
|
command.givens.each do |given|
|
|
139
146
|
next if Bluebook::Expression::Evaluator.call(given.canonical, state, attrs)
|
|
140
147
|
|
|
@@ -154,20 +161,43 @@ module Hecks
|
|
|
154
161
|
# event at all — is `AggregateBuilder#seal_correction_targets`;
|
|
155
162
|
# this is the dispatch-time half — has THIS record actually done
|
|
156
163
|
# so yet.
|
|
164
|
+
#
|
|
165
|
+
# ALSO LOCATES the matched event now, not just its existence, and
|
|
166
|
+
# returns a `{as_name => payload}` bindings hash — one entry per
|
|
167
|
+
# `:corrects` mutation that named an `as:` — so `given`/`ensures`
|
|
168
|
+
# on a corrects-bearing command can reference the located OLD
|
|
169
|
+
# event by that name, the same shape `enforce_ensures`'s own
|
|
170
|
+
# `old:` binding already has (CommandBuilder#corrects_impl's own
|
|
171
|
+
# comment: `as:` was stored, from the start, specifically to be
|
|
172
|
+
# wired into the evaluator once a real runtime consumer existed —
|
|
173
|
+
# this is that consumer). `.reverse.find` — the MOST RECENT
|
|
174
|
+
# matching event, if this record has somehow emitted the same
|
|
175
|
+
# correction target more than once; the prior existence-only
|
|
176
|
+
# check never had to make this choice, so it's a genuinely new
|
|
177
|
+
# one, made deliberately: `as:` reads as "the instance being
|
|
178
|
+
# corrected," which is naturally the latest fact on record, not
|
|
179
|
+
# an arbitrary one.
|
|
157
180
|
def enforce_correction_target(instance, aggregate, command, domain:)
|
|
181
|
+
bindings = {}
|
|
158
182
|
command.mutations.each do |mutation|
|
|
159
183
|
next unless mutation.op == :corrects
|
|
160
184
|
|
|
161
185
|
event_key = "#{domain}::#{aggregate.hecks_name}"
|
|
162
186
|
event_name = mutation.target.to_s
|
|
163
|
-
|
|
187
|
+
corrected = @registry.event_log.reverse.find do |event|
|
|
164
188
|
event.name == event_name && event.aggregate == event_key && event.id == instance.id
|
|
165
189
|
end
|
|
166
190
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
191
|
+
unless corrected
|
|
192
|
+
raise NothingToCorrect,
|
|
193
|
+
"#{command.hecks_name} refused — corrects #{event_name}, but " \
|
|
194
|
+
"#{event_key} ##{instance.id} has never emitted it"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
as = mutation.source[:as]
|
|
198
|
+
bindings[as.to_sym] = corrected.payload if as && !as.to_s.empty?
|
|
170
199
|
end
|
|
200
|
+
bindings
|
|
171
201
|
end
|
|
172
202
|
|
|
173
203
|
# LIFECYCLE STATE AS A COMMAND GUARD (S10, ADR 0025) — `command
|
|
@@ -215,16 +245,21 @@ module Hecks
|
|
|
215
245
|
# Not new to ensures — `given` lives under the same rule — but an
|
|
216
246
|
# ensures is more likely to collide, since it typically re-reads a
|
|
217
247
|
# field the command just took in to mutate it.
|
|
218
|
-
def enforce_ensures(subject, command, args, old:, domain:, parent: nil)
|
|
248
|
+
def enforce_ensures(subject, command, args, old:, domain:, parent: nil, correction: {})
|
|
219
249
|
state = GuardState.new(subject)
|
|
220
250
|
# S12, ADR 0025 — same boundary reasoning as enforce_givens
|
|
221
251
|
# above: `subject`'s own stored references are no longer
|
|
222
252
|
# dereferenced here; a `projects`-maintained field is already
|
|
223
253
|
# part of `state`. `command`/`args` still dereferences — a
|
|
224
254
|
# fresh reference-typed ARGUMENT stays in bounds.
|
|
225
|
-
# `old` still wins over everything, unchanged.
|
|
255
|
+
# `old` still wins over everything, unchanged. `correction`
|
|
256
|
+
# (an `as:`-bound corrected event, if this command declares
|
|
257
|
+
# one) wins right alongside it — a settled-record ensures can
|
|
258
|
+
# reference the correction target exactly as freely as a
|
|
259
|
+
# pre-mutation given already can.
|
|
226
260
|
attrs = args.merge(dereference(domain, command, args))
|
|
227
261
|
attrs = attrs.merge(parent: parent.state) if parent
|
|
262
|
+
attrs = attrs.merge(correction) unless correction.empty?
|
|
228
263
|
attrs = attrs.merge(old: old)
|
|
229
264
|
command.ensures.each do |rule|
|
|
230
265
|
next if Bluebook::Expression::Evaluator.call(rule.canonical, state, attrs)
|
|
@@ -68,9 +68,39 @@ module Hecks
|
|
|
68
68
|
# made. The real mistake, an absent argument, was never the one refused,
|
|
69
69
|
# which is what fuzz surfaced.
|
|
70
70
|
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
71
|
+
# STALE (as of the equivalence-gap plan's own audit): this used to
|
|
72
|
+
# say "the language cannot yet say which arguments are optional" —
|
|
73
|
+
# it already can, and always could once `attribute ..., optional:
|
|
74
|
+
# true` existed (`CommandBuilder#attribute_impl`,
|
|
75
|
+
# `attribute_collector.rb`): `sets` already sources correctly from
|
|
76
|
+
# an optional attribute, resolving absent to nil exactly as this
|
|
77
|
+
# method does, and REFUSING it here would be wrong, not merely
|
|
78
|
+
# undone work — `TillRoom::Till.TakeIn`'s own `note` (spec/
|
|
79
|
+
# fixtures/till.bluebook) and Banking's `CardPayment.Authorize`'s
|
|
80
|
+
# `tags` (payment_cards.bluebook) are real, live commands whose
|
|
81
|
+
# `sets` mutation is deliberately sourced from an optional
|
|
82
|
+
# attribute the caller may omit — `spec/runtime/command_rules_spec
|
|
83
|
+
# .rb`'s own "says an absent OPTIONAL argument is nil, not the
|
|
84
|
+
# name of the argument" pins exactly this as correct, not pending.
|
|
85
|
+
# The meta-domain's own self-hosted commands (Command.Declare's
|
|
86
|
+
# `role`/`goal`/`provenance`/`from`/`position`, and ~35 more sites
|
|
87
|
+
# across the language) all lean on the identical pattern — nil is
|
|
88
|
+
# the RIGHT answer for a `sets` sourced from a declared-optional
|
|
89
|
+
# attribute the caller left out, every time.
|
|
90
|
+
#
|
|
91
|
+
# The one thing that WAS still a real, narrow gap — a `sets`
|
|
92
|
+
# source Symbol naming NOTHING the command declares at all (a
|
|
93
|
+
# typo, not an optional argument) — silently resolved to nil
|
|
94
|
+
# forever the same way, indistinguishable at either build or run
|
|
95
|
+
# time from a legitimate optional absence. Closed at BUILD time
|
|
96
|
+
# instead of here: `CommandBuilder#refuse_unknown_argument_sources!`
|
|
97
|
+
# refuses it the moment the `.bluebook` file loads, mirroring
|
|
98
|
+
# `AggregateBuilder#seal_query_argument`'s identical check for a
|
|
99
|
+
# query's own where-clause argument. This function stays exactly
|
|
100
|
+
# what it always was — a pure, unconditional lookup — because by
|
|
101
|
+
# the time ANY mutation reaches it, the source has already been
|
|
102
|
+
# proven to name either a real, possibly-optional argument, or a
|
|
103
|
+
# StateRef/literal; there is nothing left here to refuse.
|
|
74
104
|
def resolve_source(source, args)
|
|
75
105
|
return args[source] if source.is_a?(Symbol)
|
|
76
106
|
|
|
@@ -62,13 +62,29 @@ module Hecks
|
|
|
62
62
|
class Analyzer
|
|
63
63
|
STATEFUL_MUTATIONS = %i[append increment decrement multiply clamp remove].freeze
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
# `root_aggregate:` — Wave 8's own audit surfaced a real bug here,
|
|
66
|
+
# not merely a missing feature: for an ENTITY-owned command,
|
|
67
|
+
# `EntityInterpreter` calls this with `aggregate:` set to the
|
|
68
|
+
# ENTITY itself (`element_interpreter.rb`'s own `Analyzer.call
|
|
69
|
+
# (aggregate: entity, command:)`), so `owner_fields` was always
|
|
70
|
+
# the entity's own attribute set. A `given`/`ensures` reading
|
|
71
|
+
# `parent.X` legitimately means the ROOT aggregate's own field —
|
|
72
|
+
# a genuinely different owner — but `classify_path`'s `:parent`
|
|
73
|
+
# branch checked that read against `owner_fields` (the entity's),
|
|
74
|
+
# which can never contain a root-level field, so every entity
|
|
75
|
+
# command with a real, legitimate `parent.*` read was refused as
|
|
76
|
+
# unresolved regardless of correctness. Defaults to `aggregate`
|
|
77
|
+
# (a no-op) for the plain-aggregate case — `CommandInterpreter`'s
|
|
78
|
+
# own call site never needed to change.
|
|
79
|
+
def self.call(aggregate:, command:, root_aggregate: aggregate) = new(aggregate, command, root_aggregate).call
|
|
80
|
+
|
|
81
|
+
def initialize(aggregate, command, root_aggregate = aggregate)
|
|
68
82
|
@aggregate = aggregate
|
|
69
83
|
@command = command
|
|
70
84
|
@owner_fields = aggregate.attributes.to_set(&:name)
|
|
71
85
|
@owner_fields << aggregate.lifecycle.field.to_sym if aggregate.lifecycle
|
|
86
|
+
@root_owner_fields = root_aggregate.attributes.to_set(&:name)
|
|
87
|
+
@root_owner_fields << root_aggregate.lifecycle.field.to_sym if root_aggregate.lifecycle
|
|
72
88
|
# `projects` FIELDS (S12, ADR 0025) ARE OWNER STATE TOO — a
|
|
73
89
|
# `given`/`ensures` reading one (e.g. `customer_status ==
|
|
74
90
|
# "active"`) is reading this record's own stored field, same
|
|
@@ -80,9 +96,17 @@ module Hecks
|
|
|
80
96
|
# a partial mutation, which is exactly right — a projected
|
|
81
97
|
# field's freshness comes from the interpreter reseeding it on
|
|
82
98
|
# save, not from anything a caller-supplied write set carries.
|
|
99
|
+
# Applies to BOTH `owner_fields` and `root_owner_fields` — an
|
|
100
|
+
# entity's own `parent.*` read can name the root aggregate's
|
|
101
|
+
# projected field just as easily as one of its real attributes
|
|
102
|
+
# (`Banking::Withdrawal.Dispute`'s own `parent.account_customer_
|
|
103
|
+
# status`, ATMCard's projected field, is a real, live example).
|
|
83
104
|
if aggregate.respond_to?(:projected_fields)
|
|
84
105
|
aggregate.projected_fields.each { |field| @owner_fields << field.name }
|
|
85
106
|
end
|
|
107
|
+
if root_aggregate.respond_to?(:projected_fields)
|
|
108
|
+
root_aggregate.projected_fields.each { |field| @root_owner_fields << field.name }
|
|
109
|
+
end
|
|
86
110
|
@payload_fields = command.attributes.to_set(&:name)
|
|
87
111
|
@state_reads = Set.new
|
|
88
112
|
@payload_reads = Set.new
|
|
@@ -115,7 +139,7 @@ module Hecks
|
|
|
115
139
|
|
|
116
140
|
private
|
|
117
141
|
|
|
118
|
-
attr_reader :aggregate, :command, :owner_fields, :payload_fields,
|
|
142
|
+
attr_reader :aggregate, :command, :owner_fields, :root_owner_fields, :payload_fields,
|
|
119
143
|
:state_reads, :payload_reads, :writes, :known_writes, :unresolved
|
|
120
144
|
|
|
121
145
|
# A fresh Instance supplies these values without reading a stored
|
|
@@ -204,13 +228,36 @@ module Hecks
|
|
|
204
228
|
end
|
|
205
229
|
end
|
|
206
230
|
|
|
231
|
+
# KNOWN, HARMLESS GAP: `corrects ..., as: :name`'s bound name
|
|
232
|
+
# (admissibility.rb's `enforce_correction_target`/`enforce_givens`/
|
|
233
|
+
# `enforce_ensures`) isn't special-cased here the way `:old`/
|
|
234
|
+
# `:parent` are — a given/ensures referencing it falls through to
|
|
235
|
+
# `unresolved` below (its own field lookup finds no owner/payload
|
|
236
|
+
# match), same net effect as any other not-yet-optimized command:
|
|
237
|
+
# `complete_state?` comes back false, so dispatch takes the safe
|
|
238
|
+
# `hydrate_existing` path instead of the `ATOMIC_PUT` fast path.
|
|
239
|
+
# Not a correctness bug — `as:`'s runtime binding (a plain `attrs`
|
|
240
|
+
# merge, exactly like `old:`'s) resolves and evaluates correctly
|
|
241
|
+
# regardless of what this STATIC analysis concludes — just a real,
|
|
242
|
+
# deliberately-left optimization gap: closing it would mean
|
|
243
|
+
# threading "which names this command declares as correction
|
|
244
|
+
# bindings" into the Analyzer, which doesn't have that per-command
|
|
245
|
+
# context today. Worth doing alongside `:old`/`:parent`'s own
|
|
246
|
+
# handling someday, not attempted here.
|
|
207
247
|
def classify_path(path, phase)
|
|
208
248
|
head, nested = path.split(".", 2)
|
|
209
249
|
name = head.to_sym
|
|
210
250
|
|
|
211
251
|
if name == :parent
|
|
212
252
|
parent_field = nested.to_s.split(".", 2).first
|
|
213
|
-
|
|
253
|
+
# `root_owner_fields` — NOT `owner_fields`. For an entity-owned
|
|
254
|
+
# command `owner_fields` is the ENTITY's own attribute set;
|
|
255
|
+
# `parent.X` always means the ROOT aggregate's own field, a
|
|
256
|
+
# genuinely different owner (`root_aggregate:`'s own header,
|
|
257
|
+
# above, has the full bug this fixes). Identical for a plain
|
|
258
|
+
# aggregate command, where root_aggregate defaults to aggregate
|
|
259
|
+
# itself and the two sets are the same set.
|
|
260
|
+
if parent_field.empty? || !root_owner_fields.include?(parent_field.to_sym)
|
|
214
261
|
unresolved << "#{path} does not name parent aggregate state"
|
|
215
262
|
else
|
|
216
263
|
state_reads << parent_field.to_sym
|
|
@@ -98,7 +98,14 @@ module Hecks
|
|
|
98
98
|
ctx.chain = chain
|
|
99
99
|
ctx.route = route
|
|
100
100
|
ctx.dry_run = dry_run
|
|
101
|
-
|
|
101
|
+
# `root_aggregate:` — `entity` is the immediate owner (what
|
|
102
|
+
# `owner_fields` inside the Analyzer means), but a `parent.X`
|
|
103
|
+
# read inside this command's own given/ensures means the ROOT
|
|
104
|
+
# aggregate's own field, not the entity's — `aggregate` here IS
|
|
105
|
+
# that root (this method's own first parameter, never the
|
|
106
|
+
# entity). See DependencyPlanning::Analyzer.call's own header
|
|
107
|
+
# for the bug this closes.
|
|
108
|
+
ctx.plan = DependencyPlanning::Analyzer.call(aggregate: entity, command: command, root_aggregate: aggregate)
|
|
102
109
|
# RESOLVED HERE, ONCE — see CommandInterpreter#call's own comment;
|
|
103
110
|
# `step_hydrate_parent` reads `ctx.repository` without re-fetching.
|
|
104
111
|
ctx.repository = @registry.repository(domain, aggregate)
|
|
@@ -73,10 +73,19 @@ module Hecks
|
|
|
73
73
|
[fetch(bluebook, domain, head[:aggregate], reference_id)]
|
|
74
74
|
elsif rootless
|
|
75
75
|
# No root to FK-match against — a rootless model reads
|
|
76
|
-
# each of its own heads WHOLE, independently.
|
|
77
|
-
# heads on one rootless model
|
|
78
|
-
# against each other
|
|
79
|
-
#
|
|
76
|
+
# each of its own heads WHOLE, independently. Multiple
|
|
77
|
+
# heads on one rootless model are NEVER cross-joined
|
|
78
|
+
# against each other, and there is no DSL to declare
|
|
79
|
+
# one if you wanted to — `ReadModelBuilder#include_impl`
|
|
80
|
+
# takes only `type`/`as:` (checked directly, not
|
|
81
|
+
# assumed), and `group_by` groups this bulk read's own
|
|
82
|
+
# output, it names no predicate between two heads.
|
|
83
|
+
# Building a cross-join here would mean CHOOSING a join
|
|
84
|
+
# semantics (equality on which fields?) nobody has
|
|
85
|
+
# declared — a real, deliberate scope limit pending a
|
|
86
|
+
# future `include ..., joins: ...`-shaped grammar
|
|
87
|
+
# addition (with its own Rust mirror), not a gap this
|
|
88
|
+
# interpreter can quietly grow into on its own.
|
|
80
89
|
records(bluebook, domain, head[:aggregate])
|
|
81
90
|
else
|
|
82
91
|
matching(records(bluebook, domain, head[:aggregate])) do |record|
|
data/lib/hecks/version.rb
CHANGED
data/lib/hecks.rb
CHANGED
|
@@ -100,7 +100,27 @@ module Hecks
|
|
|
100
100
|
end
|
|
101
101
|
|
|
102
102
|
def hecksagon(name, &block) = collect(:add_hecksagon, Bluebook::DSL::HecksagonBuilder.build(name, &block))
|
|
103
|
-
|
|
103
|
+
# REPOINTED TO DomainPortBuilder — the migration DomainPort's own
|
|
104
|
+
# class comment names as its goal, now landed for the top-level
|
|
105
|
+
# `.port` file callers too (the aggregate-scoped `Thing.port(...)`,
|
|
106
|
+
# binding_proxy.rb, already went through this builder). Every real
|
|
107
|
+
# `.port` file only ever spells `verb`/`signal` (no `.port` file
|
|
108
|
+
# declares operations — that's DomainPort's own newer shape), and
|
|
109
|
+
# DomainPortBuilder's own bare-verb branch produces the exact same
|
|
110
|
+
# `Port` object PortBuilder itself did (dsl_spec.rb's own byte-
|
|
111
|
+
# identity check) — a pure repoint, no behavior change for any
|
|
112
|
+
# existing caller reading `.verb`/`.signal` off what comes back.
|
|
113
|
+
#
|
|
114
|
+
# `legacy_bare_port: true` — the ONE real semantic gap this repoint
|
|
115
|
+
# would otherwise open: `PortBuilder#build` never refused a
|
|
116
|
+
# completely empty build (no verb, no signal even), a real shape
|
|
117
|
+
# dsl_spec.rb's own "a port" tests exercise (`signal`-only, no
|
|
118
|
+
# `verb`). `DomainPortBuilder`'s own "declares no verb and no
|
|
119
|
+
# operations" refusal is real and correct for its OTHER two callers
|
|
120
|
+
# (`BindingProxy#port`, `HecksagonBuilder#port_impl`) — only this,
|
|
121
|
+
# the literal top-level `.port` file entry point, keeps the older,
|
|
122
|
+
# looser rule (see `DomainPortBuilder#initialize`'s own comment).
|
|
123
|
+
def port(name, &block) = collect(:add_port, Bluebook::DSL::DomainPortBuilder.build(name, legacy_bare_port: true, &block))
|
|
104
124
|
def adapter(name, &block) = collect(:add_adapter, Bluebook::DSL::AdapterBuilder.build(name, &block))
|
|
105
125
|
def world(name, &block) = collect(:add_world, Bluebook::DSL::WorldBuilder.build(name, &block))
|
|
106
126
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: hecks
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.0.
|
|
4
|
+
version: 1.0.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Chris Young
|
|
@@ -140,6 +140,7 @@ files:
|
|
|
140
140
|
- lib/hecks/bluebook/dsl/world_builder.rb
|
|
141
141
|
- lib/hecks/bluebook/entity.rb
|
|
142
142
|
- lib/hecks/bluebook/expression.rb
|
|
143
|
+
- lib/hecks/bluebook/expression/ast_json.rb
|
|
143
144
|
- lib/hecks/bluebook/expression/canonical_form.rb
|
|
144
145
|
- lib/hecks/bluebook/expression/evaluator.rb
|
|
145
146
|
- lib/hecks/bluebook/expression/projection.json
|