hecks 1.2.0 → 1.3.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/heki/journal.rb +57 -0
- data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
- data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
- data/lib/hecks/behaviors/expectations.rb +32 -4
- data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
- data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
- data/lib/hecks/bluebook/model_check.rb +148 -17
- data/lib/hecks/forms/field_shape.rb +5 -3
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
- data/lib/hecks/fuzzing/era_boundary.rb +105 -0
- data/lib/hecks/fuzzing/form_census.rb +184 -0
- data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
- data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
- data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
- data/lib/hecks/fuzzing/properties/guards.rb +103 -0
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
- data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
- data/lib/hecks/fuzzing/properties.rb +23 -2
- data/lib/hecks/fuzzing/replay.rb +130 -20
- data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
- data/lib/hecks/fuzzing/self_consistency.rb +647 -0
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
- data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
- data/lib/hecks/fuzzing/structural_skips.rb +146 -0
- data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
- data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
- data/lib/hecks/fuzzing/value_generator.rb +55 -3
- data/lib/hecks/fuzzing.rb +6 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
- data/lib/hecks/naming.rb +70 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
- data/lib/hecks/projections/glossary/html.rb +250 -0
- data/lib/hecks/projections/glossary/markdown.rb +105 -0
- data/lib/hecks/projections/glossary/mermaid.rb +110 -0
- data/lib/hecks/projections/glossary/page.css +271 -0
- data/lib/hecks/projections/glossary/page.js +72 -0
- data/lib/hecks/projections/glossary/sections.rb +17 -0
- data/lib/hecks/projections/glossary/sentences.rb +205 -0
- data/lib/hecks/projections/glossary.rb +214 -286
- data/lib/hecks/projector/narrate_projector.rb +4 -11
- data/lib/hecks/query_specification/common/comparison.rb +27 -1
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
- data/lib/hecks/runtime/command_rules/references.rb +75 -0
- data/lib/hecks/runtime/entity_element.rb +168 -16
- data/lib/hecks/runtime/entity_interpreter.rb +68 -3
- data/lib/hecks/runtime/query_interpreter.rb +66 -2
- data/lib/hecks/runtime/reaction_invocation.rb +70 -3
- data/lib/hecks/runtime/refusal_wording.rb +5 -2
- data/lib/hecks/runtime/registry.rb +12 -0
- data/lib/hecks/runtime/routing.rb +67 -2
- data/lib/hecks/runtime/saga_interpreter.rb +38 -1
- data/lib/hecks/runtime/value/coercion.rb +77 -115
- data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
- data/lib/hecks/runtime/value.rb +7 -2
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +2 -1
- metadata +23 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b85a8382bb201f9ab54417b3535d8fe1ede57c842f627e32a2ff75d7f2dd6618
|
|
4
|
+
data.tar.gz: e4852fd6b22145bec8a215e4c46fc6347527f7bd05811ff7da780e74dc24fe1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a538ddbe90fed2ccfa8c066f00777699b0eadade3c6523e6fbf5f62c19385ab7ace8e2c8de6fabafffce46abbefd36642401f6e5f23696127445378e95d98aad
|
|
7
|
+
data.tar.gz: e22574943d4246998ba9121c0c84d7b39ec944416066ad881f144f166a09deb5c1a1fbb8b6c8378084b89856ed9a4e5bfd9c66a3cd6d07fa9a9a21eee77d63fc
|
|
@@ -17,8 +17,65 @@ module Hecks
|
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# An EXPLICIT, opt-in maintenance operation — never run
|
|
21
|
+
# automatically after an ordinary save/delete. Heki's journal is
|
|
22
|
+
# not a disposable write-ahead log: it is this adapter's own
|
|
23
|
+
# answer to `entries`, and `entries` is a real port contract
|
|
24
|
+
# (`Ports::Persistence::AppendOnly`'s own required-methods list)
|
|
25
|
+
# read in full, forever, by `Ports::Projection::Worker#catch_up!`
|
|
26
|
+
# and `Registry#projection_current?` to catch a projection up to
|
|
27
|
+
# its authoritative source, and by `bin/history` to show "every
|
|
28
|
+
# journal entry a domain's append-only adapters hold" — the same
|
|
29
|
+
# contract Postgres/Sqlite/D1 uphold by way of a journal TABLE
|
|
30
|
+
# that is never pruned. A real example (`examples/banking`,
|
|
31
|
+
# `persisted_by("Heki")` + `projected_by("SqliteProjection")`)
|
|
32
|
+
# depends on this today. Compacting throws that full history away
|
|
33
|
+
# for whatever happened before the call — correct only for an
|
|
34
|
+
# aggregate nothing ever projects from; callers (`bin/
|
|
35
|
+
# heki_compact`) are responsible for confirming that first.
|
|
36
|
+
#
|
|
37
|
+
# Crash-safety ordering: `write` below is the exact same
|
|
38
|
+
# temp-file, fsync, atomic-rename sequence `save`/`delete`
|
|
39
|
+
# already use — the snapshot it produces is confirmed durably on
|
|
40
|
+
# disk before this method ever touches the journal. Only once
|
|
41
|
+
# that succeeds does `truncate_journal!` run. A crash between the
|
|
42
|
+
# two leaves old (now fully redundant) journal lines in place;
|
|
43
|
+
# replaying them again over the fresh snapshot on the next boot
|
|
44
|
+
# is idempotent — the same value gets set again, never a wrong
|
|
45
|
+
# one — so nothing is lost, just a little wasted replay work
|
|
46
|
+
# once. A crash before `write` completes leaves the journal
|
|
47
|
+
# fully intact and the prior snapshot untouched, exactly today's
|
|
48
|
+
# existing crash-recovery guarantee.
|
|
49
|
+
def compact!
|
|
50
|
+
with_lock do
|
|
51
|
+
current = replay_journal(read_snapshot)
|
|
52
|
+
write(current)
|
|
53
|
+
truncate_journal!
|
|
54
|
+
@store = current
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
20
58
|
private
|
|
21
59
|
|
|
60
|
+
def truncate_journal!
|
|
61
|
+
return unless File.exist?(@journal_path)
|
|
62
|
+
|
|
63
|
+
# A single `truncate(0)` syscall on an already-open file
|
|
64
|
+
# descriptor — the file's length changes atomically at the
|
|
65
|
+
# filesystem level, so there is no "half truncated" state to
|
|
66
|
+
# observe even under a crash mid-call. `fsync` below makes
|
|
67
|
+
# that change durable before this method returns; without it
|
|
68
|
+
# a crash could still leave the old (harmless-to-replay)
|
|
69
|
+
# content on disk after a normal return, which is fine per
|
|
70
|
+
# the crash-safety note above, but the durable case is the
|
|
71
|
+
# one actually worth returning success for.
|
|
72
|
+
File.open(@journal_path, "r+b") do |file|
|
|
73
|
+
file.truncate(0)
|
|
74
|
+
file.flush
|
|
75
|
+
file.fsync
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
22
79
|
def replay_journal(records)
|
|
23
80
|
return records unless File.exist?(@journal_path)
|
|
24
81
|
|
|
@@ -14,4 +14,9 @@ Hecks.adapter "PostgresEra" do
|
|
|
14
14
|
field :database
|
|
15
15
|
field :role
|
|
16
16
|
field :schema
|
|
17
|
+
# `allow_superuser true` — the explicit, on-the-record opt-in to boot
|
|
18
|
+
# over a connection whose role is a Postgres superuser or carries
|
|
19
|
+
# BYPASSRLS, which the era write-fence (row-level security) cannot
|
|
20
|
+
# bite. Refused by default; see `Lineage#check_fence_applies!` (BUG#24).
|
|
21
|
+
field :allow_superuser
|
|
17
22
|
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "json"
|
|
3
|
+
require "rack"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Adapters
|
|
7
|
+
# THE DRIVING SIDE — code an OUTSIDE caller reaches IN through,
|
|
8
|
+
# rather than code the domain reaches OUT through. Every existing
|
|
9
|
+
# file under `adapters/driven/` is the latter: a store or reader a
|
|
10
|
+
# `persisted_by`/`port` binding resolves TO, called BY this
|
|
11
|
+
# framework's own runtime. Nothing under this repository has ever
|
|
12
|
+
# been the mirror image before — code that receives a request FROM
|
|
13
|
+
# the outside world and turns it into a dispatch — so this is the
|
|
14
|
+
# first entry, and the directory itself is new.
|
|
15
|
+
module Driving
|
|
16
|
+
# A GITHUB WEBHOOK RECEIVER, TRANSPORT ONLY — the same split
|
|
17
|
+
# `Hecks::Adapters::GithubChecks` (qa/adapters/github_checks.rb,
|
|
18
|
+
# this class's own PULL-side sibling) already draws for itself:
|
|
19
|
+
# THIS file owns proving a request really came from GitHub and
|
|
20
|
+
# unwrapping GitHub's own webhook envelope (`X-GitHub-Event`, the
|
|
21
|
+
# JSON body, GitHub's own automatic `ping` check) — never which
|
|
22
|
+
# commands to dispatch about what it finds inside. That is exactly
|
|
23
|
+
# as domain-specific as `GithubChecks#run` turning `check-runs`
|
|
24
|
+
# JSON into green-or-raise, and lives exactly where that class's
|
|
25
|
+
# own header explains such logic belongs: outside this library,
|
|
26
|
+
# in `qa/adapters/github_ci_webhook.rb`, the subclass of this file
|
|
27
|
+
# that actually knows what a `QualityControl::Clearance` is.
|
|
28
|
+
#
|
|
29
|
+
# A PLAIN RACK APP (`#call(env)`) — no Sinatra, no Rails — the same
|
|
30
|
+
# shape `Hecks::Forms::App` (lib/hecks/forms/app.rb) already
|
|
31
|
+
# established for the one other HTTP-facing surface this library
|
|
32
|
+
# ships. `rack` is a LAZY Gemfile dependency for exactly the reason
|
|
33
|
+
# that file's own header gives: this file is never required by
|
|
34
|
+
# `require "hecks"` (nothing under `adapters.rb`'s own eager
|
|
35
|
+
# `adapters/driven` load names it — see that file's own header),
|
|
36
|
+
# so a project that never mounts a driving adapter never needs
|
|
37
|
+
# `rack` installed, the same "opt in by requiring the file at all"
|
|
38
|
+
# contract `hecks/forms.rb` already has for `Forms::App`.
|
|
39
|
+
#
|
|
40
|
+
# SUBCLASS RESPONSIBILITY: implement `#handle_event(event, action,
|
|
41
|
+
# payload)`, returning `[http_status, response_body_hash]`. Called
|
|
42
|
+
# ONLY after the signature has verified and the body has parsed as
|
|
43
|
+
# JSON — a subclass never has to re-check either. `event` is
|
|
44
|
+
# GitHub's own `X-GitHub-Event` header value ("check_suite",
|
|
45
|
+
# "check_run", "pull_request", ...); `action` is the payload's own
|
|
46
|
+
# top-level `"action"` field when it has one (GitHub's webhooks
|
|
47
|
+
# nearly all carry one — "completed", "requested", "opened", ...)
|
|
48
|
+
# and nil when it does not. `ping` — GitHub's own automatic
|
|
49
|
+
# connectivity check, sent once when a webhook is first saved in
|
|
50
|
+
# repository settings — is answered here and never reaches a
|
|
51
|
+
# subclass at all; there is nothing domain-specific to decide
|
|
52
|
+
# about it.
|
|
53
|
+
class GithubWebhook
|
|
54
|
+
# REFUSED, LOUDLY — the same shape a domain refusal already takes
|
|
55
|
+
# everywhere else in this codebase (`Runtime::DOMAIN_REFUSALS`,
|
|
56
|
+
# `Forms::App`'s own `{error:, message:}` JSON body for a bad
|
|
57
|
+
# command). A request that cannot prove it came from GitHub gets
|
|
58
|
+
# a real 401 and a named reason, never a silent 200 that would
|
|
59
|
+
# let a forged "CI passed" payload regress nothing while looking
|
|
60
|
+
# exactly like success in a log nobody re-reads.
|
|
61
|
+
class InvalidSignature < StandardError; end
|
|
62
|
+
|
|
63
|
+
# THE BODY DID NOT EVEN PARSE — distinct from a signature refusal:
|
|
64
|
+
# this body genuinely came from whoever signed it (checked
|
|
65
|
+
# FIRST, before parsing ever runs — see `#call`), and simply
|
|
66
|
+
# is not JSON. Still refused, never guessed at.
|
|
67
|
+
class MalformedPayload < StandardError; end
|
|
68
|
+
|
|
69
|
+
SIGNATURE_HEADER = "HTTP_X_HUB_SIGNATURE_256".freeze
|
|
70
|
+
EVENT_HEADER = "HTTP_X_GITHUB_EVENT".freeze
|
|
71
|
+
|
|
72
|
+
# `secret:` HAS NO DEFAULT, ON PURPOSE — the same rule
|
|
73
|
+
# `GoogleAuthentication`'s own header states for its own
|
|
74
|
+
# `ENV.fetch`, restated here because the consequence is worse for
|
|
75
|
+
# a webhook: an unverified signature check is not "half
|
|
76
|
+
# configured", it is NO verification at all, silently accepting
|
|
77
|
+
# anything claiming to be GitHub. A caller passes the real
|
|
78
|
+
# secret explicitly — from `ENV.fetch("GITHUB_WEBHOOK_SECRET")`
|
|
79
|
+
# or wherever it keeps one — rather than this class reaching into
|
|
80
|
+
# the environment itself and hiding that requirement inside a
|
|
81
|
+
# default.
|
|
82
|
+
def initialize(secret:)
|
|
83
|
+
raise ArgumentError, "no webhook secret configured" if secret.to_s.empty?
|
|
84
|
+
|
|
85
|
+
@secret = secret
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def call(env)
|
|
89
|
+
request = Rack::Request.new(env)
|
|
90
|
+
return respond(405, error: "MethodNotAllowed", message: "POST only") unless request.post?
|
|
91
|
+
|
|
92
|
+
body = request.body.read
|
|
93
|
+
verify_signature!(request, body)
|
|
94
|
+
|
|
95
|
+
event = request.get_header(EVENT_HEADER)
|
|
96
|
+
return respond(200, ok: true, event: "ping") if event == "ping"
|
|
97
|
+
return respond(400, error: "MissingEvent", message: "no #{EVENT_HEADER} header") if event.to_s.empty?
|
|
98
|
+
|
|
99
|
+
payload = parse_json(body)
|
|
100
|
+
status, result = handle_event(event, payload["action"], payload)
|
|
101
|
+
respond(status, result)
|
|
102
|
+
rescue InvalidSignature => e
|
|
103
|
+
respond(401, error: "InvalidSignature", message: e.message)
|
|
104
|
+
rescue MalformedPayload => e
|
|
105
|
+
respond(400, error: "MalformedPayload", message: e.message)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
# CONSTANT-TIME COMPARE, NOT `==`. A byte-by-byte `==` returns
|
|
111
|
+
# the moment it finds the first mismatching byte, so how LONG
|
|
112
|
+
# that took leaks how many leading bytes of a forged signature
|
|
113
|
+
# were already right to anyone timing the response — GitHub's
|
|
114
|
+
# own webhook documentation calls this out by name and recommends
|
|
115
|
+
# exactly the constant-time compare `Rack::Utils.secure_compare`
|
|
116
|
+
# already gives for free, reused rather than hand-rolled.
|
|
117
|
+
def verify_signature!(request, body)
|
|
118
|
+
header = request.get_header(SIGNATURE_HEADER)
|
|
119
|
+
raise InvalidSignature, "missing #{SIGNATURE_HEADER.sub('HTTP_', '').tr('_', '-')} header" if header.to_s.empty?
|
|
120
|
+
|
|
121
|
+
digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new("sha256"), @secret, body)
|
|
122
|
+
expected = "sha256=#{digest}"
|
|
123
|
+
return if Rack::Utils.secure_compare(expected, header)
|
|
124
|
+
|
|
125
|
+
raise InvalidSignature,
|
|
126
|
+
"signature does not match — refusing a payload that cannot be proven to be GitHub's own"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def parse_json(body)
|
|
130
|
+
JSON.parse(body)
|
|
131
|
+
rescue JSON::ParserError => e
|
|
132
|
+
raise MalformedPayload, e.message
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def handle_event(event, action, payload)
|
|
136
|
+
raise NotImplementedError, "#{self.class} must implement #handle_event(event, action, payload)"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def respond(status, body)
|
|
140
|
+
[status, { "content-type" => "application/json" }, [JSON.generate(body)]]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -194,11 +194,24 @@ module Hecks
|
|
|
194
194
|
# mixed facts into the strict envelope — identities lifted into
|
|
195
195
|
# `to:`, declared facts into `with:` — so a behaviors dispatch now
|
|
196
196
|
# goes through the exact same separation a policy's projection
|
|
197
|
-
# does. A verb that
|
|
198
|
-
# "Pizzas::Order.PaymentGateway.Receive") keeps the loose
|
|
199
|
-
# passthrough: its own input already spells the port
|
|
200
|
-
# `to:`/`with:`, which the dispatcher's port branch reads
|
|
197
|
+
# does. A verb that names a port operation (checked explicitly,
|
|
198
|
+
# below — "Pizzas::Order.PaymentGateway.Receive") keeps the loose
|
|
199
|
+
# passthrough instead: its own input already spells the port
|
|
200
|
+
# form's `to:`/`with:`, which the dispatcher's port branch reads
|
|
201
|
+
# directly, and `ReactionInvocation.build`'s explicit envelope
|
|
202
|
+
# expects a command's own declared attributes at the top level,
|
|
203
|
+
# not a port operation's already-wrapped `to:`/`with:` shape.
|
|
204
|
+
#
|
|
205
|
+
# THIS USED TO RELY ON `resolve_target` RAISING `UnknownVerb` for
|
|
206
|
+
# any port-operation verb — true only so long as nothing else ever
|
|
207
|
+
# asked it to resolve one. Now that a `policy` can legitimately
|
|
208
|
+
# `trigger` a port operation (`ReactionInvocation#resolve_target`'s
|
|
209
|
+
# own port-operation branch), that raise is gone, so this checks
|
|
210
|
+
# for a port operation directly instead of leaning on a refusal
|
|
211
|
+
# that no longer happens.
|
|
201
212
|
def dispatch_command(runtime, verb, args)
|
|
213
|
+
return runtime.dispatch(verb, **args) if port_operation?(runtime, verb)
|
|
214
|
+
|
|
202
215
|
invocation = begin
|
|
203
216
|
Runtime::ReactionInvocation.build(registry: runtime.registry, verb: verb,
|
|
204
217
|
projected: args, explicit: true)
|
|
@@ -214,6 +227,21 @@ module Hecks
|
|
|
214
227
|
end
|
|
215
228
|
end
|
|
216
229
|
|
|
230
|
+
# THE SAME "Head.Rest" SHAPE `Dispatcher#dispatch` AND
|
|
231
|
+
# `ReactionInvocation#resolve_target` BOTH ALREADY CHECK — a bare
|
|
232
|
+
# domain/aggregate lookup plus a port-name lookup, no command
|
|
233
|
+
# resolution needed since all this asks is whether one exists.
|
|
234
|
+
def port_operation?(runtime, verb)
|
|
235
|
+
domain, aggregate_name, command_path = Naming.split_verb(verb)
|
|
236
|
+
return false unless command_path
|
|
237
|
+
|
|
238
|
+
aggregate = runtime.registry.bluebook(domain)&.aggregate(aggregate_name)
|
|
239
|
+
return false unless aggregate
|
|
240
|
+
|
|
241
|
+
head, rest = command_path.split(".", 2)
|
|
242
|
+
rest && !!aggregate.port(head)
|
|
243
|
+
end
|
|
244
|
+
|
|
217
245
|
def run_query(test, runtime, verb)
|
|
218
246
|
rows = runtime.query(verb, **test.input)
|
|
219
247
|
|
|
@@ -11,9 +11,33 @@ module Hecks
|
|
|
11
11
|
# what it acts for, and `identity_attribute` is how that is found.
|
|
12
12
|
def references = nil
|
|
13
13
|
|
|
14
|
+
# NEVER a creating command — a port operation always acts on an
|
|
15
|
+
# aggregate that already exists (`operation.to`/`identity_attribute`
|
|
16
|
+
# both name where its RECEIVER comes from, never a birth). Answered
|
|
17
|
+
# explicitly, not derived from `references` the way `Command
|
|
18
|
+
# #creates?` is (`references.nil?` would read every operation as
|
|
19
|
+
# creating, since `references` above is unconditionally nil) —
|
|
20
|
+
# needed so `ReactionInvocation#source_receiver_for` can call
|
|
21
|
+
# `target.command.creates?` on a port operation the same way it
|
|
22
|
+
# already does on an ordinary command, and correctly lift a
|
|
23
|
+
# same-aggregate policy's own Event.id as the operation's receiver.
|
|
24
|
+
def creates? = false
|
|
25
|
+
|
|
14
26
|
def identity_attribute(owner_name)
|
|
15
27
|
@attributes.find { |attribute| attribute.reference? && attribute.type.target_name == owner_name.to_s }
|
|
16
28
|
end
|
|
29
|
+
|
|
30
|
+
# THE SAME READING `Command#addressing_key_for` gives, minus its
|
|
31
|
+
# self-addressing branch — a port operation's `references` is
|
|
32
|
+
# unconditionally nil (above), so it never means "this verb is
|
|
33
|
+
# declared ON the very aggregate it acts on" the way a command's
|
|
34
|
+
# does; a port operation's only path back to its owner is a real,
|
|
35
|
+
# declared reference-typed attribute, which is exactly what
|
|
36
|
+
# `identity_attribute` already finds. Needed for the identical
|
|
37
|
+
# reason `Command#addressing_key_for` is: `ReactionInvocation
|
|
38
|
+
# #aggregate_aliases` calls it on whatever `target.command` holds,
|
|
39
|
+
# a `PortOperation` now included since a policy can trigger one.
|
|
40
|
+
def addressing_key_for(aggregate_name) = identity_attribute(aggregate_name)&.name
|
|
17
41
|
end
|
|
18
42
|
|
|
19
43
|
# WHAT A PORT DOES — one finder over its declared operations.
|
|
@@ -117,6 +117,28 @@ module Hecks
|
|
|
117
117
|
offer(label) { @runtime.dispatch(verb, to: to, with: args(payload)) }
|
|
118
118
|
end
|
|
119
119
|
|
|
120
|
+
# THE RECEIVER, SPELLED THE WAY A REAL CALLER ADDRESSES IT.
|
|
121
|
+
#
|
|
122
|
+
# `receiver` is an internal accumulator — it tracks the aggregate this
|
|
123
|
+
# walk is inside AND, once it crosses into a real entity-owned category
|
|
124
|
+
# (Member/Handler/Dispatch — S17, ADR 0026), every entity hop on top of
|
|
125
|
+
# it. But `Routing.envelope`/`parse_envelope_hash` do not want that
|
|
126
|
+
# shape restated when there is no entity to route to: a plain aggregate
|
|
127
|
+
# command is addressed by its bare id (`Facade::Handle#dispatch` — "to:
|
|
128
|
+
# @id" — and `CommandRequest`'s own header: "aggregate command: { to:
|
|
129
|
+
# "record-id", ... }"), never `{aggregate:, entities: []}` — that hash
|
|
130
|
+
# is the ENTITY route's own shape, degenerate with nothing in it.
|
|
131
|
+
#
|
|
132
|
+
# So THIS is the one place `receiver` turns into a `to:` value: bare
|
|
133
|
+
# when there is no entity hop (the overwhelming common case — every
|
|
134
|
+
# category but those three), the full envelope only when there
|
|
135
|
+
# genuinely is one.
|
|
136
|
+
def address(receiver)
|
|
137
|
+
return receiver[:aggregate] if receiver[:entities].empty?
|
|
138
|
+
|
|
139
|
+
receiver
|
|
140
|
+
end
|
|
141
|
+
|
|
120
142
|
def judge!
|
|
121
143
|
declare_node("Bluebook", @bluebook, nil, 0)
|
|
122
144
|
detail_node("Bluebook", @bluebook, nil, 0)
|
|
@@ -384,7 +406,7 @@ module Hecks
|
|
|
384
406
|
end
|
|
385
407
|
next if payload.values.all?(&:nil?)
|
|
386
408
|
|
|
387
|
-
send_to("Bluebook::#{verb_for(plan, setter.verb)}", receiver[:aggregate], to: receiver, **payload)
|
|
409
|
+
send_to("Bluebook::#{verb_for(plan, setter.verb)}", receiver[:aggregate], to: address(receiver), **payload)
|
|
388
410
|
end
|
|
389
411
|
end
|
|
390
412
|
|
|
@@ -409,7 +431,7 @@ module Hecks
|
|
|
409
431
|
end
|
|
410
432
|
|
|
411
433
|
send_to("Bluebook::#{verb_for(plan, chosen.verb)}", "#{id}##{list_name}[#{index}]",
|
|
412
|
-
to: receiver, **payload)
|
|
434
|
+
to: address(receiver), **payload)
|
|
413
435
|
end
|
|
414
436
|
end
|
|
415
437
|
end
|
|
@@ -430,7 +452,7 @@ module Hecks
|
|
|
430
452
|
|
|
431
453
|
def sealers(plan, _category, receiver)
|
|
432
454
|
id = receiver[:entities].last || receiver[:aggregate]
|
|
433
|
-
plan.sealers.each { |verb| send_to("Bluebook::#{verb_for(plan, verb)}", id, to: receiver) }
|
|
455
|
+
plan.sealers.each { |verb| send_to("Bluebook::#{verb_for(plan, verb)}", id, to: address(receiver)) }
|
|
434
456
|
end
|
|
435
457
|
|
|
436
458
|
# An aggregate's or an entity's attribute names its value object by TYPE,
|
|
@@ -65,6 +65,40 @@ module Hecks
|
|
|
65
65
|
[:unacknowledged_relationship, "FlagKeyReturn"],
|
|
66
66
|
[:unknown_target_domain, "FlagKeyReturn"]
|
|
67
67
|
]
|
|
68
|
+
# QualityControl WAS the first domain in this corpus to trigger an
|
|
69
|
+
# `asks`/`tells` PORT OPERATION from a `policy`, and used to carry
|
|
70
|
+
# two entries here for it — both GONE now, not just quieted:
|
|
71
|
+
#
|
|
72
|
+
# `deaf_policy` (ClearOnPass, RefuseOnFail, RecordTheIssue,
|
|
73
|
+
# RecordTheRefusal) went first: `emitted_events` below now reads an
|
|
74
|
+
# outbound operation's `.answers`/`.refuses` the same way it already
|
|
75
|
+
# read a command's `.emits`, so `Clearance.SuitePassed`/`SuiteFailed`
|
|
76
|
+
# and `Ticket.IssueFiled`/`IssueFilingRefused` enter the known-emits
|
|
77
|
+
# set for real — the same fix `bin/qa_pr_check`'s own move to
|
|
78
|
+
# dispatching through the CI port (rather than `Clearance::Passed`/
|
|
79
|
+
# `Failed` directly) needed to make these two policies actually fire.
|
|
80
|
+
#
|
|
81
|
+
# `unknown_trigger` (FileWhenSubmitted, AskOnceMore) — BUG#23 — was
|
|
82
|
+
# never actually a `Naming`/`PolicyBuilder` defect, confirmed by
|
|
83
|
+
# tracing the real dispatch path rather than assuming the comment
|
|
84
|
+
# that used to sit here: `Naming.command_ref`'s bare-constant
|
|
85
|
+
# rewrite DOES leave `trigger Ticket::IssueTracker::File` (aggregate,
|
|
86
|
+
# port, operation) as "Ticket::IssueTracker.File", a leftover `::`
|
|
87
|
+
# past the aggregate — but `PolicyInterpreter#deliver` re-qualifies
|
|
88
|
+
# every trigger with this domain's own name before dispatch
|
|
89
|
+
# ("QualityControl::Ticket::IssueTracker.File"), and `Naming.
|
|
90
|
+
# split_verb` already folds that reintroduced `::` into the
|
|
91
|
+
# dot-joined tail correctly (fixed for `ReactionInvocation#
|
|
92
|
+
# resolve_target`, PR #520, predating this entry's own removal) —
|
|
93
|
+
# confirmed live: a real dispatch through `Ticket.Submit` fires
|
|
94
|
+
# `IssueFiled`/`TicketFiled` exactly as declared. The actual gap was
|
|
95
|
+
# entirely in THIS checker: `verbs_of` never enumerated a port
|
|
96
|
+
# operation as a triggerable verb at all, and `policy_findings`
|
|
97
|
+
# compared raw strings instead of `Naming.split_verb` triples the
|
|
98
|
+
# way `handler_findings`'s own `unknown_dispatch` check already does
|
|
99
|
+
# (BUG#6). Fixed with `port_verbs_of`/`triggerable_verbs`, scoped
|
|
100
|
+
# entirely to this file — no change to `Naming` or `PolicyBuilder`
|
|
101
|
+
# was needed or made.
|
|
68
102
|
}.freeze
|
|
69
103
|
|
|
70
104
|
module_function
|
|
@@ -215,7 +249,12 @@ module Hecks
|
|
|
215
249
|
|
|
216
250
|
def saga_findings(bluebook, process_manager)
|
|
217
251
|
emitted = emitted_events(bluebook)
|
|
218
|
-
|
|
252
|
+
# (domain, aggregate, command) TRIPLES, not raw strings — see
|
|
253
|
+
# `handler_findings`'s own comment on the dispatch side for why:
|
|
254
|
+
# `Naming.split_verb` is what makes an entity verb's two legitimate
|
|
255
|
+
# spellings (`Naming.command_ref`'s own `::`-then-`.` rewrite vs.
|
|
256
|
+
# `verbs_of`'s own all-`.` one) compare equal.
|
|
257
|
+
verbs = verbs_of(bluebook).map { |verb| Naming.split_verb(verb) }
|
|
219
258
|
reached = pm_reachable_states(process_manager, emitted)
|
|
220
259
|
|
|
221
260
|
findings = []
|
|
@@ -266,16 +305,38 @@ module Hecks
|
|
|
266
305
|
end
|
|
267
306
|
|
|
268
307
|
handler.dispatches.each do |dispatch|
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
qualified
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
308
|
+
# ALWAYS THIS DOMAIN — same fix, same reason, as `SagaInterpreter
|
|
309
|
+
# #qualified` (BUG#6). This used to guess: a dispatch whose own
|
|
310
|
+
# `command_name` still carried a leftover `::` after `Naming.
|
|
311
|
+
# command_ref`'s own rewrite was read as "already qualified" and
|
|
312
|
+
# left alone — the exact same string-shape ambiguity that
|
|
313
|
+
# `SagaInterpreter#qualified`'s own comment explains at length
|
|
314
|
+
# (a same-domain entity command reference and a genuinely
|
|
315
|
+
# cross-domain one are textually indistinguishable after that
|
|
316
|
+
# rewrite). Confirmed against the entire corpus, same as that
|
|
317
|
+
# fix: no saga anywhere ever dispatches genuinely cross-domain,
|
|
318
|
+
# so this checker now qualifies exactly the way the runtime
|
|
319
|
+
# actually dispatches — unconditionally against `bluebook.name`
|
|
320
|
+
# — instead of maintaining its own, independently-wrong copy of
|
|
321
|
+
# the same guess.
|
|
322
|
+
#
|
|
323
|
+
# COMPARED AS A TRIPLE, NOT A STRING — `Naming.command_ref`'s
|
|
324
|
+
# own rewrite of an entity reference (`Manifest::Slot::Fill`)
|
|
325
|
+
# collapses to "Manifest::Slot.Fill" (`::` between aggregate and
|
|
326
|
+
# entity, `.` before the command); `verbs_of`'s own entity
|
|
327
|
+
# spelling, below, joins aggregate/entity/command all with `.`
|
|
328
|
+
# instead (matching `fuzzing/sequence_generator/catalog.rb`'s own
|
|
329
|
+
# independent convention, its comment's own "the same spelling"
|
|
330
|
+
# claim). Both are legitimate, and `Naming.split_verb` already
|
|
331
|
+
# parses either to the identical (domain, aggregate, command)
|
|
332
|
+
# triple (its own comment: "past the already-resolved domain
|
|
333
|
+
# boundary, any leftover `::` is unambiguous... folding it into
|
|
334
|
+
# the dot-joined tail") — the same reading `ReactionInvocation.
|
|
335
|
+
# resolve_target` relies on at runtime. A bare string `include?`
|
|
336
|
+
# would falsely flag every entity dispatch as unknown_dispatch
|
|
337
|
+
# even once correctly domain-qualified, comparing two spellings
|
|
338
|
+
# of the same verb as though they were different ones.
|
|
339
|
+
qualified = Naming.split_verb("#{bluebook.name}::#{dispatch.command_name}")
|
|
279
340
|
next if verbs.include?(qualified)
|
|
280
341
|
|
|
281
342
|
findings << Finding.new(kind: :unknown_dispatch, severity: :error, subject: process_manager.name,
|
|
@@ -366,9 +427,27 @@ module Hecks
|
|
|
366
427
|
# `trigger` is spelled "Aggregate.Command" (or "Entity.Command" one
|
|
367
428
|
# level down), completed to an FQN by PolicyInterpreter#deliver as
|
|
368
429
|
# "#{domain}::#{trigger_command}" — the same join `verbs_of` builds
|
|
369
|
-
# independently, so the two spellings have to be compared as FQNs
|
|
370
|
-
#
|
|
371
|
-
|
|
430
|
+
# independently, so the two spellings have to be compared as FQNs.
|
|
431
|
+
#
|
|
432
|
+
# COMPARED AS A TRIPLE, NOT A STRING — `handler_findings`'s own
|
|
433
|
+
# `unknown_dispatch` check (BUG#6) already applies this fix for a
|
|
434
|
+
# saga's dispatch; a policy's `trigger` needed the identical one. A
|
|
435
|
+
# policy triggering an `asks`/`tells` PORT OPERATION (`Aggregate::
|
|
436
|
+
# Port::Operation`, three colon-joined segments — `Naming.command_ref`'s
|
|
437
|
+
# bare-constant rewrite turns this into "Aggregate::Port.Operation",
|
|
438
|
+
# a leftover `::` past the aggregate) is a real, WORKING dispatch —
|
|
439
|
+
# `PolicyInterpreter#deliver` qualifies it with this domain's own
|
|
440
|
+
# name before `Naming.split_verb` ever sees it, and `split_verb`
|
|
441
|
+
# already folds that leftover `::` into the dot-joined tail
|
|
442
|
+
# correctly (fixed for `ReactionInvocation#resolve_target`, PR
|
|
443
|
+
# #520) — but this check compared raw strings against `verbs_of`,
|
|
444
|
+
# which never enumerated port operations at all, so it reported
|
|
445
|
+
# every port-operation trigger as unknown regardless. `triggerable_
|
|
446
|
+
# verbs` now includes both, and both sides are parsed through
|
|
447
|
+
# `Naming.split_verb` before comparing, the same reading
|
|
448
|
+
# `resolve_target` relies on at runtime.
|
|
449
|
+
qualified = Naming.split_verb("#{bluebook.name}::#{policy.trigger_command}")
|
|
450
|
+
unless qualified && triggerable_verbs(bluebook).include?(qualified)
|
|
372
451
|
findings << Finding.new(kind: :unknown_trigger, severity: :error, subject: policy.name,
|
|
373
452
|
message: "trigger #{policy.trigger_command.inspect} resolves to no command " \
|
|
374
453
|
"this domain declares")
|
|
@@ -464,15 +543,38 @@ module Hecks
|
|
|
464
543
|
# the fixtures under spec/fixtures/model_check/ do, having no
|
|
465
544
|
# hecksagon at all) simply finds none, which is correct : nothing
|
|
466
545
|
# can be deaf to an event that isn't even wired up yet.
|
|
546
|
+
#
|
|
547
|
+
# AN OUTBOUND OPERATION (`asks`) EMITS THROUGH A DIFFERENT DOOR — it
|
|
548
|
+
# declares no `.emits` at all (`PortOperationBuilder#refuse_wrong_
|
|
549
|
+
# words!` refuses one that tries), naming its two real endings
|
|
550
|
+
# `.answers`/`.refuses` instead (`PortOperation#initialize`). Reading
|
|
551
|
+
# only `.emits` left every `asks`'s own two events invisible to this
|
|
552
|
+
# method — real, live events a policy genuinely reacts to
|
|
553
|
+
# (`Clearance.SuitePassed`/`SuiteFailed`, `Ticket.IssueFiled`/
|
|
554
|
+
# `IssueFilingRefused`), reported as `deaf_policy` findings until this
|
|
555
|
+
# read both. `.compact` because an INBOUND operation's `.answers`/
|
|
556
|
+
# `.refuses` are always nil (there is no channel back to tell), which
|
|
557
|
+
# would otherwise seed every emitted-events set with a stray nil.
|
|
467
558
|
def emitted_events(bluebook)
|
|
468
559
|
aggregate_emits = bluebook.aggregates.flat_map do |aggregate|
|
|
469
560
|
aggregate.commands.map(&:emits) +
|
|
470
561
|
aggregate.entities.flat_map { |entity| entity.commands.map(&:emits) } +
|
|
471
|
-
aggregate.ports
|
|
562
|
+
port_operation_events(aggregate.ports)
|
|
472
563
|
end
|
|
473
|
-
chapter_emits = bluebook.ports
|
|
564
|
+
chapter_emits = port_operation_events(bluebook.ports)
|
|
565
|
+
|
|
566
|
+
(aggregate_emits + chapter_emits).flatten.compact.uniq
|
|
567
|
+
end
|
|
474
568
|
|
|
475
|
-
|
|
569
|
+
# ONE OPERATION, EITHER OF ITS OWN SOURCES OF EVENTS — an inbound
|
|
570
|
+
# `tells` names its own via `.emits`; an outbound `asks` has none
|
|
571
|
+
# (`PortOperationBuilder#refuse_wrong_words!` refuses one that
|
|
572
|
+
# tries) and names its two real endings `.answers`/`.refuses`
|
|
573
|
+
# instead. Pulled out of `emitted_events` above purely to keep that
|
|
574
|
+
# method's own branching low enough to read at a glance — every
|
|
575
|
+
# port, aggregate-owned or chapter-level, asks this the same way.
|
|
576
|
+
def port_operation_events(ports)
|
|
577
|
+
ports.flat_map { |port| port.operations.flat_map { |op| [*op.emits, op.answers, op.refuses] } }
|
|
476
578
|
end
|
|
477
579
|
|
|
478
580
|
# Fully-qualified, the same spelling DispatchSpec#command_name
|
|
@@ -490,6 +592,35 @@ module Hecks
|
|
|
490
592
|
end
|
|
491
593
|
end
|
|
492
594
|
|
|
595
|
+
# AN AGGREGATE-OWNED PORT OPERATION IS A TRIGGERABLE VERB TOO —
|
|
596
|
+
# `ReactionInvocation#resolve_target`'s own port-operation branch
|
|
597
|
+
# resolves one by the exact same two-segment tail shape ("Aggregate::
|
|
598
|
+
# Port.Operation", the aggregate then the port then the operation,
|
|
599
|
+
# dot-joined past the domain) an entity command uses, checked first,
|
|
600
|
+
# same order `Dispatcher#dispatch` already resolves a live verb in.
|
|
601
|
+
# Only an aggregate's OWN ports (`aggregate.ports`) are in scope here
|
|
602
|
+
# — a policy's `trigger` always names one aggregate, never a chapter-
|
|
603
|
+
# level port with no owner to address through.
|
|
604
|
+
def port_verbs_of(bluebook)
|
|
605
|
+
bluebook.aggregates.flat_map do |aggregate|
|
|
606
|
+
aggregate.ports.flat_map do |port|
|
|
607
|
+
port.operations.map do |operation|
|
|
608
|
+
"#{bluebook.name}::#{aggregate.hecks_name}.#{port.name}.#{operation.hecks_name}"
|
|
609
|
+
end
|
|
610
|
+
end
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# EVERY TRIGGERABLE VERB, AS A TRIPLE — `verbs_of` (ordinary/entity
|
|
615
|
+
# commands) plus `port_verbs_of` (port operations), each parsed
|
|
616
|
+
# through `Naming.split_verb` so a caller never has to compare two
|
|
617
|
+
# spellings of the same verb as strings (see `policy_findings`'s own
|
|
618
|
+
# `unknown_trigger` check for why that comparison has to happen this
|
|
619
|
+
# way, not as `include?` on a raw string).
|
|
620
|
+
def triggerable_verbs(bluebook)
|
|
621
|
+
(verbs_of(bluebook) + port_verbs_of(bluebook)).to_set { |verb| Naming.split_verb(verb) }
|
|
622
|
+
end
|
|
623
|
+
|
|
493
624
|
def bare(event) = event.to_s.split("::").last
|
|
494
625
|
end
|
|
495
626
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require_relative "../bluebook/attribute"
|
|
2
|
+
require_relative "../naming"
|
|
2
3
|
require_relative "value_object_shape"
|
|
3
4
|
|
|
4
5
|
module Hecks
|
|
@@ -36,10 +37,11 @@ module Hecks
|
|
|
36
37
|
# genuinely dotted path ("amount.cents") drops everything before
|
|
37
38
|
# the last ".".
|
|
38
39
|
segment = text.to_s.split(".").last.to_s
|
|
39
|
-
|
|
40
|
-
return segment if words.empty?
|
|
40
|
+
return segment if segment.empty?
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
# The word split itself is `Naming.words`' now — the one
|
|
43
|
+
# humanizer, shared with the glossary projection.
|
|
44
|
+
Naming.words(segment)
|
|
43
45
|
end
|
|
44
46
|
|
|
45
47
|
# The full dotted path, each segment humanized and joined with
|