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
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "isolated_boot"
|
|
6
|
+
|
|
7
|
+
module Hecks
|
|
8
|
+
module Fuzzing
|
|
9
|
+
# A GENERATED SEQUENCE, RACED FOR REAL — `spec/adapters/driven/
|
|
10
|
+
# postgres_era_concurrent_dispatch_spec.rb` proves ADR 0036's own fix
|
|
11
|
+
# (a real `pg_advisory_xact_lock` serializes a PostgresEra-bound
|
|
12
|
+
# dispatch across separate OS processes) against ONE hand-authored
|
|
13
|
+
# fixture and ONE hand-picked conflicting pair (two `$6,000` Debits
|
|
14
|
+
# against a `$10,000` account). This module asks the SAME question —
|
|
15
|
+
# does the cross-process write lock actually serialize concurrent
|
|
16
|
+
# writers? — of an ARBITRARY generated sequence against a REAL target
|
|
17
|
+
# domain, so the practice's own adversarial generator gets to pick the
|
|
18
|
+
# conflict instead of a human picking it once and never again.
|
|
19
|
+
#
|
|
20
|
+
# THE MECHANISM, GENERALIZED FROM THAT SPEC RATHER THAN REDERIVED: a
|
|
21
|
+
# command step partway through a generated sequence is chosen as the
|
|
22
|
+
# RACE STEP; every step before it is SETUP (replayed once, sequentially,
|
|
23
|
+
# to bring a fresh disposable schema to the state the race step expects
|
|
24
|
+
# to act against); the race step is then dispatched TWICE — once from
|
|
25
|
+
# each of two real, separate, forked OS processes racing against that
|
|
26
|
+
# SAME schema, no artificial gating, whichever the scheduler favors.
|
|
27
|
+
#
|
|
28
|
+
# THE ORACLE IS THE SAME PAIR, DISPATCHED SEQUENTIALLY, NOT A
|
|
29
|
+
# HARDCODED EXPECTATION — unlike the hand-authored spec (which can
|
|
30
|
+
# assert `%w[refused succeeded]` because it knows its own fixture's
|
|
31
|
+
# business rule), this module has no idea whether an arbitrary
|
|
32
|
+
# generated race step conflicts with itself at all. So it asks the
|
|
33
|
+
# DOMAIN: replay the identical setup on a SEPARATE fresh schema, then
|
|
34
|
+
# dispatch the SAME race step twice, one after the other, in one
|
|
35
|
+
# process, with no contention at all — genuinely correct by
|
|
36
|
+
# construction, since nothing else can touch that schema while it
|
|
37
|
+
# runs. Two conflicting writes settle as {"succeeded", "refused"};
|
|
38
|
+
# two independent ones settle as {"succeeded", "succeeded"}; either
|
|
39
|
+
# way, THAT is the multiset the concurrent pair must also produce if
|
|
40
|
+
# the write lock actually serializes them — order does not matter
|
|
41
|
+
# (which real racer wins a genuine race is never controlled), the SET
|
|
42
|
+
# of outcomes does.
|
|
43
|
+
#
|
|
44
|
+
# WHAT A BROKEN LOCK LOOKS LIKE HERE: the concurrent pair settling as
|
|
45
|
+
# {"succeeded", "succeeded"} where the sequential oracle says
|
|
46
|
+
# {"succeeded", "refused"} — two processes each hydrated the
|
|
47
|
+
# pre-write state, neither saw the other's write, and the second
|
|
48
|
+
# commit landed as a silent lost update instead of failing its own
|
|
49
|
+
# `given`. That is the EXACT corruption class ADR 0036 fixed for
|
|
50
|
+
# PostgresEra and `postgres_concurrent_dispatch_spec.rb` still
|
|
51
|
+
# documents, unfixed, for plain Postgres.
|
|
52
|
+
#
|
|
53
|
+
# NOT A REPLACEMENT for the hand-authored spec — that spec proves the
|
|
54
|
+
# mechanism once, precisely, with controlled gating so the assertion
|
|
55
|
+
# is deterministic; this module proves the SAME mechanism holds for
|
|
56
|
+
# whatever a real domain's own generated sequences throw at it, with
|
|
57
|
+
# no gating (a genuine, ungated race), on every sweep this mode runs.
|
|
58
|
+
module ConcurrentDispatch
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
COMMAND_STEP = ->(step) { step["verb"] && !step["query"] && !step["dry_run"] }
|
|
62
|
+
|
|
63
|
+
# ONE DIVERGENCE LIST, THE SAME SHAPE EVERY OTHER MODE IN THIS
|
|
64
|
+
# PRACTICE PRODUCES — `[]` when nothing was found (including the
|
|
65
|
+
# legitimate "this seed's generated sequence has no command step to
|
|
66
|
+
# race at all" case: a sequence of pure queries/dry-runs has nothing
|
|
67
|
+
# to concurrently dispatch, and that is not a finding).
|
|
68
|
+
#
|
|
69
|
+
# `database:` is the shared, never-dropped scratch database
|
|
70
|
+
# (`bin/qa_sweep`'s own `persistence_parity_database`, reused here
|
|
71
|
+
# for the identical reason: a container, not the thing that's
|
|
72
|
+
# unique per run). `race_schema:`/`reference_schema:` are two
|
|
73
|
+
# DIFFERENT disposable schema names this ONE call owns for its own
|
|
74
|
+
# duration — the caller creates neither ahead of time (both are
|
|
75
|
+
# wiped fresh by the boots below) and drops both afterward, the
|
|
76
|
+
# same lifecycle `persistence_parity_schema` already has.
|
|
77
|
+
def check(domain_path, steps, database:, race_schema:, reference_schema:)
|
|
78
|
+
normalized = steps.map { |step| step.transform_keys(&:to_s) }
|
|
79
|
+
race_index = pick_race_index(normalized)
|
|
80
|
+
return [] unless race_index
|
|
81
|
+
|
|
82
|
+
setup_steps = normalized[0...race_index]
|
|
83
|
+
race_step = normalized[race_index]
|
|
84
|
+
|
|
85
|
+
reference = reference_outcomes(domain_path, setup_steps, race_step, database: database, schema: reference_schema)
|
|
86
|
+
concurrent = concurrent_outcomes(domain_path, setup_steps, race_step, database: database, schema: race_schema)
|
|
87
|
+
|
|
88
|
+
divergences_for(race_step, reference, concurrent)
|
|
89
|
+
rescue StandardError => e
|
|
90
|
+
[{ field: "process", detail: "#{e.class}: #{e.message}" }]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# THE COMMAND STEP CLOSEST TO THE MIDDLE OF THE SEQUENCE — not the
|
|
94
|
+
# first (racing a bare identity-creation with no setup at all is a
|
|
95
|
+
# legitimate, useful case, so index 0 is not excluded) and not
|
|
96
|
+
# chosen for any domain-specific reason: a mid-sequence step has, on
|
|
97
|
+
# average, the deepest state to act against and the best odds of
|
|
98
|
+
# actually conflicting with itself. `nil` when the generated
|
|
99
|
+
# sequence has no command step at all (every step a query or a dry
|
|
100
|
+
# run) — nothing here for this seed to race.
|
|
101
|
+
def pick_race_index(steps)
|
|
102
|
+
command_indices = steps.each_index.select { |i| COMMAND_STEP.call(steps[i]) }
|
|
103
|
+
return nil if command_indices.empty?
|
|
104
|
+
|
|
105
|
+
command_indices[command_indices.size / 2]
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def divergences_for(race_step, reference, concurrent)
|
|
109
|
+
crashes = (reference + concurrent).select { |outcome| outcome.start_with?("crashed:") }.uniq
|
|
110
|
+
return crashes.map { |c| { field: "concurrency_crash", verb: race_step["verb"], detail: c } } if crashes.any?
|
|
111
|
+
|
|
112
|
+
return [] if reference.sort == concurrent.sort
|
|
113
|
+
|
|
114
|
+
[{ field: "concurrency_race", verb: race_step["verb"], reference: reference, concurrent: concurrent,
|
|
115
|
+
detail: "two concurrent cross-process dispatches of #{race_step['verb']} settled as #{concurrent.sort} " \
|
|
116
|
+
"where the identical pair, dispatched sequentially with no contention, settled as " \
|
|
117
|
+
"#{reference.sort} — the cross-process write lock did not correctly serialize this write" }]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# THE ORACLE — one boot, one process, the setup then the race step
|
|
121
|
+
# TWICE in immediate succession. Nothing else ever touches this
|
|
122
|
+
# schema while this runs, so whatever the domain itself settles on
|
|
123
|
+
# is correct by construction, not asserted.
|
|
124
|
+
def reference_outcomes(domain_path, setup_steps, race_step, database:, schema:)
|
|
125
|
+
outcomes = []
|
|
126
|
+
IsolatedBoot.call(domain_path, adapter: :postgres_era, database: database, schema: schema) do |copy|
|
|
127
|
+
runtime = Hecks.boot(copy)
|
|
128
|
+
dispatch_all!(runtime, setup_steps)
|
|
129
|
+
outcomes << dispatch_one(runtime, race_step)
|
|
130
|
+
outcomes << dispatch_one(runtime, race_step)
|
|
131
|
+
end
|
|
132
|
+
outcomes
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# THE RACE ITSELF — setup runs ONCE, sequentially, in THIS process
|
|
136
|
+
# (the same `IsolatedBoot.call` wipe-then-boot every other mode
|
|
137
|
+
# here already uses), and only THEN do the two real racers run.
|
|
138
|
+
# Each racer boots its OWN fresh copy of the domain against the
|
|
139
|
+
# SAME now-populated schema — `boot_preserving_schema`, below,
|
|
140
|
+
# deliberately skips the wipe `IsolatedBoot.call` always does, or
|
|
141
|
+
# the setup this line just wrote would be gone before either racer
|
|
142
|
+
# ever dispatched anything.
|
|
143
|
+
#
|
|
144
|
+
# REAL, SEPARATE OS PROCESSES, NOT `Thread.new` — `postgres_era_
|
|
145
|
+
# concurrent_dispatch_spec.rb`'s own header explains why:
|
|
146
|
+
# `Runtime::AggregateLock`'s in-process registry would fully (and
|
|
147
|
+
# misleadingly) serialize two THREADS sharing one process even with
|
|
148
|
+
# the cross-process lock fix reverted. Only two genuinely separate
|
|
149
|
+
# OS processes exercise the gap this check exists to catch.
|
|
150
|
+
#
|
|
151
|
+
# `Process.spawn`, NOT `Process.fork` — `bin/qa_sweep`'s own
|
|
152
|
+
# top-of-file comment on `--all` names the identical hazard this
|
|
153
|
+
# sidesteps: by the time a `concurrency` seed runs, THIS process
|
|
154
|
+
# already holds the QualityControl ledger's own live PostgresEra
|
|
155
|
+
# connection (this module's own caller, `bin/qa_sweep`, booted it
|
|
156
|
+
# long before any seed ran). `Process.fork` duplicates every open
|
|
157
|
+
# file descriptor, SSL session state included — confirmed live
|
|
158
|
+
# while wiring this mode up: forking directly from here corrupted
|
|
159
|
+
# the LEDGER's own connection the moment either racer child exited,
|
|
160
|
+
# surfacing on the NEXT unrelated ledger write, nowhere near this
|
|
161
|
+
# method's own code. `bin/qa_concurrency_racer` is this method's own
|
|
162
|
+
# worker, one real `ruby` process per racer — read that script's own
|
|
163
|
+
# header for the rest of this reasoning.
|
|
164
|
+
def concurrent_outcomes(domain_path, setup_steps, race_step, database:, schema:)
|
|
165
|
+
IsolatedBoot.call(domain_path, adapter: :postgres_era, database: database, schema: schema) do |copy|
|
|
166
|
+
dispatch_all!(Hecks.boot(copy), setup_steps)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
root = File.expand_path("../../..", __dir__)
|
|
170
|
+
racer = File.join(root, "bin/qa_concurrency_racer")
|
|
171
|
+
args_json = JSON.generate(race_step["args"] || {})
|
|
172
|
+
logs = Array.new(2) { Tempfile.new(["qa-concurrency-racer-", ".log"]) }
|
|
173
|
+
logs.each(&:unlink)
|
|
174
|
+
|
|
175
|
+
pids = logs.map do |log|
|
|
176
|
+
Process.spawn("bundle", "exec", "ruby", racer, domain_path, database, schema, race_step["verb"], args_json,
|
|
177
|
+
out: log, err: log, chdir: root)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
pids.each { |pid| Process.wait(pid) }
|
|
181
|
+
logs.map do |log|
|
|
182
|
+
log.rewind
|
|
183
|
+
output = log.read
|
|
184
|
+
log.close
|
|
185
|
+
output.strip.empty? ? "crashed:no output from bin/qa_concurrency_racer" : output.lines.last.chomp
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# ONE STEP, ONE OUTCOME — never raises: a declared domain refusal is
|
|
190
|
+
# "refused" (the expected, ordinary answer a `given`/invariant can
|
|
191
|
+
# give), anything else escaping is "crashed:<class>: <message>", a
|
|
192
|
+
# genuine finding this module's own caller surfaces rather than lets
|
|
193
|
+
# kill a forked racer silently.
|
|
194
|
+
def dispatch_one(runtime, step)
|
|
195
|
+
args = (step["args"] || {}).transform_keys(&:to_sym)
|
|
196
|
+
runtime.dispatch(step["verb"], **args)
|
|
197
|
+
"succeeded"
|
|
198
|
+
rescue *Hecks::Runtime::DOMAIN_REFUSALS, Hecks::Bluebook::Expression::EvaluationError
|
|
199
|
+
"refused"
|
|
200
|
+
rescue StandardError => e
|
|
201
|
+
"crashed:#{e.class}: #{e.message}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# SETUP TOLERATES AN ORDINARY REFUSAL (a generated sequence's own
|
|
205
|
+
# earlier step can legitimately refuse — every other mode in this
|
|
206
|
+
# practice already replays a prefix that way) but never a crash: an
|
|
207
|
+
# unexpected exception during setup means the schema this race is
|
|
208
|
+
# about to run against is in an unknown state, which is itself
|
|
209
|
+
# worth surfacing, not silently racing anyway.
|
|
210
|
+
def dispatch_all!(runtime, steps)
|
|
211
|
+
steps.each do |step|
|
|
212
|
+
outcome = dispatch_one(runtime, step)
|
|
213
|
+
raise "setup step #{step['verb']} #{outcome}" if outcome.start_with?("crashed:")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# THE SAME COPY-AND-REBIND `IsolatedBoot.call(..., adapter:
|
|
218
|
+
# :postgres_era, ...)` DOES, MINUS THE SCHEMA WIPE — deliberately
|
|
219
|
+
# NOT reusing `IsolatedBoot.rebind_to_postgres_era!` itself, which
|
|
220
|
+
# bundles `ensure_postgres_era_schema!`'s own `DROP SCHEMA` into the
|
|
221
|
+
# same call with no way to opt out (see that method's own header:
|
|
222
|
+
# the wipe is the "zero-history guarantee every other adapter mode
|
|
223
|
+
# already gives," exactly the guarantee THIS caller must NOT have —
|
|
224
|
+
# the whole point of a race is booting against what setup already
|
|
225
|
+
# wrote). `copy_dereferencing`/`rewrite_bindings!` are the same two
|
|
226
|
+
# public steps that method itself calls first; only the `.world`
|
|
227
|
+
# this writes is duplicated from it, not re-derived, because the
|
|
228
|
+
# shape a `PostgresEra`-bound copy's `.world` needs is exactly that
|
|
229
|
+
# method's own, one step short.
|
|
230
|
+
def boot_preserving_schema(domain_path, database:, schema:)
|
|
231
|
+
Dir.mktmpdir("hecks-concurrency") do |tmp|
|
|
232
|
+
copy = File.join(tmp, File.basename(domain_path))
|
|
233
|
+
IsolatedBoot.copy_dereferencing(domain_path, copy)
|
|
234
|
+
FileUtils.rm_rf(File.join(copy, "data"))
|
|
235
|
+
IsolatedBoot.rewrite_bindings!(copy, "PostgresEra")
|
|
236
|
+
write_postgres_era_world!(copy, database: database, schema: schema)
|
|
237
|
+
yield copy
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def write_postgres_era_world!(copy, database:, schema:)
|
|
242
|
+
Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
|
|
243
|
+
names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
|
|
244
|
+
next if names.empty?
|
|
245
|
+
|
|
246
|
+
world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres_era.world")
|
|
247
|
+
File.write(world_path, names.map do |name|
|
|
248
|
+
<<~WORLD
|
|
249
|
+
Hecks.world "#{name}" do
|
|
250
|
+
persisted_by("PostgresEra") do
|
|
251
|
+
database "#{database}"
|
|
252
|
+
schema "#{schema}"
|
|
253
|
+
allow_superuser true
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
WORLD
|
|
257
|
+
end.join("\n"))
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
Dir.glob(File.join(copy, "**", "*.world")).each do |path|
|
|
261
|
+
File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres_era.world"
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
require "hecks/ports/persistence/plugins/era"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Fuzzing
|
|
5
|
+
# THE FORK-LOSS CLASS, AS A CHECK — not a fuzz. Every other mode in
|
|
6
|
+
# this file generates a sequence and compares two answers to the SAME
|
|
7
|
+
# question; this one asks a single, unconditional question of a
|
|
8
|
+
# target's own REAL, already-configured `PostgresEra` ledger:
|
|
9
|
+
# does any ancestor era still hold writes nobody has merged forward?
|
|
10
|
+
#
|
|
11
|
+
# THE BUG THIS TARGETS, NAMED EXACTLY. Minting a new era (an attribute
|
|
12
|
+
# or aggregate addition — `StorageShape.project`, lib/hecks/ports/
|
|
13
|
+
# persistence/plugins/era/storage_shape.rb) advances the readable head
|
|
14
|
+
# to a NEW partition; an OLD checkout, or a process that boots slower
|
|
15
|
+
# than the mint, can keep writing into the era it still believes is
|
|
16
|
+
# current. Those writes are not lost — `Lineage#diverged_count`
|
|
17
|
+
# (postgres_era/lineage/tail_merge.rb) can always find them — but
|
|
18
|
+
# nothing EVER asked it automatically. The practice's own ledger found
|
|
19
|
+
# exactly this live, twice: once as an operational gap this session's
|
|
20
|
+
# Step 0 recovered by hand (`bin/merge_tail`, three conflicting
|
|
21
|
+
# records, three eras deep), and once as BUG#24 (a superuser
|
|
22
|
+
# connection walking straight through the era write-fence — fixed by
|
|
23
|
+
# refusing that connection outright, `Lineage#check_fence_applies!`).
|
|
24
|
+
# Both are "a fork happened and nothing said so" — this module is the
|
|
25
|
+
# automatic version of the question `bin/merge_tail`'s own diagnostic
|
|
26
|
+
# line already answers by hand, run as an ordinary sweep Check instead
|
|
27
|
+
# of only when a human remembers to ask.
|
|
28
|
+
#
|
|
29
|
+
# THE TARGET'S REAL DATABASE, READ-ONLY, NEVER A DISPOSABLE ONE — every
|
|
30
|
+
# other Postgres-touching mode here (`persistence_parity`,
|
|
31
|
+
# `adapter_parity_sqlite`) owns a throwaway schema for the exact
|
|
32
|
+
# reason it must never look at what a real deployment actually holds;
|
|
33
|
+
# this check exists FOR what a real deployment actually holds, so it
|
|
34
|
+
# connects the same way `bin/merge_tail` itself does — the target's
|
|
35
|
+
# own `.world` binding — and never writes anything: `eras`/
|
|
36
|
+
# `diverged_count` are both plain `SELECT`s, and `ensure_base!` only
|
|
37
|
+
# ever provisions (`CREATE TABLE IF NOT EXISTS`) when THIS connection
|
|
38
|
+
# is the database's own provisioning owner, the identical idempotent
|
|
39
|
+
# call every ordinary boot already makes. A target with no real,
|
|
40
|
+
# reachable database configured (nothing provisioned locally, say) is
|
|
41
|
+
# reported `checked: false` with the reason — an operational note this
|
|
42
|
+
# mode's own caller surfaces once, never a crash and never silently
|
|
43
|
+
# "clean".
|
|
44
|
+
module EraBoundary
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# `{ checked: true, diverged_total:, breakdown: [{ordinal:, diverged:}] }`
|
|
48
|
+
# or `{ checked: false, reason: "..." }` — the caller decides what a
|
|
49
|
+
# `false` means (an operational note, not a finding); a `true` with
|
|
50
|
+
# `diverged_total.positive?` is the finding this module exists to
|
|
51
|
+
# surface: real post-cut writes an ancestor era is still holding.
|
|
52
|
+
def diverged_ancestor_writes(domain_path)
|
|
53
|
+
registry, directory = load_registry(domain_path)
|
|
54
|
+
bluebook = registry.bluebooks.values.first
|
|
55
|
+
return unchecked("no bluebook in #{directory}") unless bluebook
|
|
56
|
+
|
|
57
|
+
first = bluebook.aggregates.first
|
|
58
|
+
return unchecked("#{bluebook.name} declares no aggregates") unless first
|
|
59
|
+
|
|
60
|
+
adapter_name = Hecks::Ports::Persistence::BindingPolicy.resolve(registry, bluebook.name, first).adapter
|
|
61
|
+
return unchecked("#{bluebook.name} is bound to #{adapter_name}, not PostgresEra") unless adapter_name == "PostgresEra"
|
|
62
|
+
|
|
63
|
+
settings = registry.world(bluebook.name)&.for_binding(Hecks::Ports::Persistence::VERB, adapter_name) || {}
|
|
64
|
+
db = Hecks::Adapters::PostgresEra.connect_for(bluebook.name, settings)
|
|
65
|
+
begin
|
|
66
|
+
lineage = Hecks::Adapters::PostgresEra::Lineage.new(db, bluebook.name)
|
|
67
|
+
lineage.ensure_base!
|
|
68
|
+
eras = lineage.eras
|
|
69
|
+
# `bin/merge_tail`'s own arithmetic, restated read-only: every
|
|
70
|
+
# ancestor era (every ordinal strictly before the head's own) may
|
|
71
|
+
# still hold post-cut writes the head has never interleaved.
|
|
72
|
+
breakdown = if eras.size > 1
|
|
73
|
+
(1...eras.last[:ordinal]).map do |ordinal|
|
|
74
|
+
{ ordinal: ordinal, diverged: lineage.diverged_count(ordinal) }
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
[]
|
|
78
|
+
end
|
|
79
|
+
{ checked: true, era_count: eras.size, breakdown: breakdown, diverged_total: breakdown.sum { |b| b[:diverged] } }
|
|
80
|
+
ensure
|
|
81
|
+
db.close
|
|
82
|
+
end
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
unchecked("#{e.class}: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def unchecked(reason) = { checked: false, reason: reason }
|
|
88
|
+
|
|
89
|
+
# THE SAME LOAD `bin/merge_tail` ITSELF PERFORMS (that script's own
|
|
90
|
+
# top half) — a fresh `Registry`, never the ledger's own (this asks
|
|
91
|
+
# about the SWEPT TARGET's lineage, not QualityControl's own).
|
|
92
|
+
def load_registry(domain_path)
|
|
93
|
+
loading = Hecks::Ports::Loading.bootstrap
|
|
94
|
+
directory = loading.bluebook_directory(domain_path)
|
|
95
|
+
registry = Hecks::Runtime::Registry.new(root: File.dirname(directory))
|
|
96
|
+
Hecks.with_registry(registry) do
|
|
97
|
+
loading.load_library
|
|
98
|
+
loading.load_project(loading.shared_root(nil, directory))
|
|
99
|
+
loading.load_domain(directory)
|
|
100
|
+
end
|
|
101
|
+
[registry, directory]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module Fuzzing
|
|
5
|
+
# WHAT AN AGGREGATE CAN EXHIBIT, AND WHICH PAIRS IT PUTS TOGETHER.
|
|
6
|
+
#
|
|
7
|
+
# Extracted from `spec/combination_coverage_spec.rb`'s own pairwise
|
|
8
|
+
# table so it has exactly two consumers that can never drift: that
|
|
9
|
+
# spec (the golden corpus, held to every pair) and `bin/qa_domain_
|
|
10
|
+
# novelty` (a CANDIDATE stress domain, measured against every path
|
|
11
|
+
# the QA ledger already sweeps — see that script's own header for why
|
|
12
|
+
# a new domain has to name the pair no existing target meets before
|
|
13
|
+
# it earns a place in the rotation).
|
|
14
|
+
#
|
|
15
|
+
# THE UNIT IS ONE AGGREGATE. Two forms in the same chapter but
|
|
16
|
+
# different heads never meet at dispatch; two forms on one head do
|
|
17
|
+
# — that spec's own header has the four defects that argument came
|
|
18
|
+
# from. Each entry below is a form the language declares and a
|
|
19
|
+
# runtime has to handle, chosen because it has produced a defect or
|
|
20
|
+
# sits one step from one; adding one here is how a new form joins
|
|
21
|
+
# BOTH gates at once, and it will name its own uncovered pairs on the
|
|
22
|
+
# first run of each.
|
|
23
|
+
#
|
|
24
|
+
# ONE FLAT TABLE, ON PURPOSE — each entry is an independent boolean
|
|
25
|
+
# check against the same string-keyed aggregate IR hash (the shape
|
|
26
|
+
# `spec/golden/ir/*.json` carries and `Projector::Exporter.call`
|
|
27
|
+
# round-trips to through JSON), laid out so every declared form can
|
|
28
|
+
# be read, and added to, at a glance.
|
|
29
|
+
module FormCensus
|
|
30
|
+
# A `given`'s own lookup path crossing at least two references —
|
|
31
|
+
# `member.sponsor.standing`, `source.customer.status`: the
|
|
32
|
+
# `CommandRules::References#dereference` recursion, walked only
|
|
33
|
+
# on a fresh command argument (S12, ADR 0025) and hydrated one
|
|
34
|
+
# repository lookup per hop.
|
|
35
|
+
TWO_HOP_GIVEN_PATH_LENGTH = 3
|
|
36
|
+
|
|
37
|
+
FORMS = {
|
|
38
|
+
"composite_id" => ->(a) { (a["identified_by"] || []).size >= 2 },
|
|
39
|
+
"has_entity" => ->(a) { entities(a).any? },
|
|
40
|
+
"two_entities" => ->(a) { entities(a).size >= 2 },
|
|
41
|
+
"composite_piece" => ->(a) { entities(a).any? { |piece| (piece["identified_by"] || []).size >= 2 } },
|
|
42
|
+
"multi_emit" => ->(a) { commands(a).any? { |verb| (verb["emits"] || []).size >= 2 } },
|
|
43
|
+
"lifecycle" => ->(a) { !a["lifecycle"].nil? },
|
|
44
|
+
"piece_lifecycle" => ->(a) { entities(a).any? { |piece| !piece["lifecycle"].nil? } },
|
|
45
|
+
"has_query" => ->(a) { queries(a).any? },
|
|
46
|
+
"list_attr" => ->(a) { attributes(a).any? { |held| held["list"] } },
|
|
47
|
+
"reference_attr" => ->(a) { attributes(a).any? { |held| reference?(held) } },
|
|
48
|
+
"closed_set" => ->(a) { (a["value_objects"] || []).any? { |shape| shape["closed_set"] } },
|
|
49
|
+
"has_default" => ->(a) { attributes(a).any? { |held| !held["default"].nil? } },
|
|
50
|
+
"has_optional" => ->(a) { commands(a).any? { |verb| (verb["attributes"] || []).any? { |held| held["optional"] } } },
|
|
51
|
+
# THE REFERENCE-HOP FAMILY (ANGLE-2, qa/bluebook ledger) — the
|
|
52
|
+
# forms `qa/stress_domains/referral_chain` exists for, absent
|
|
53
|
+
# from the census until that domain named them. Each is one
|
|
54
|
+
# step from a catalogued gap: `two_hop_given` is `dereference`'s
|
|
55
|
+
# own recursion (`DEREFERENCE_DEPTH`); `multi_hop_where` is a
|
|
56
|
+
# `/`-chain `HopPath` walks for real and `rust/project/queries.
|
|
57
|
+
# rb` structurally refuses (D2 of the equivalence-gap plan);
|
|
58
|
+
# `revalued_reference` is ADR 0037 Finding 5's exact trigger — a
|
|
59
|
+
# command redeclaring the aggregate's own reference field under
|
|
60
|
+
# a non-reference type, so only `resolve_state_references` (never
|
|
61
|
+
# ported) can catch a dangling id.
|
|
62
|
+
"two_hop_given" => ->(a) { two_hop_given?(a) },
|
|
63
|
+
"multi_hop_where" => ->(a) { multi_hop_where?(a) },
|
|
64
|
+
"revalued_reference" => ->(a) { revalued_reference?(a) }
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
67
|
+
module_function
|
|
68
|
+
|
|
69
|
+
def entities(aggregate) = aggregate["entities"] || []
|
|
70
|
+
def commands(aggregate) = aggregate["commands"] || []
|
|
71
|
+
def attributes(aggregate) = aggregate["attributes"] || []
|
|
72
|
+
def queries(aggregate) = aggregate["queries"] || []
|
|
73
|
+
def reference?(attribute) = attribute["type"].to_s.start_with?("Reference<")
|
|
74
|
+
|
|
75
|
+
def two_hop_given?(aggregate)
|
|
76
|
+
commands(aggregate).any? { |verb| (verb["givens"] || []).any? { |given| deep_lookup?(given["ast"]) } }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A `where` whose field crosses two `/` — `member/sponsor/standing`.
|
|
80
|
+
def multi_hop_where?(aggregate)
|
|
81
|
+
queries(aggregate).any? { |query| (query["wheres"] || []).any? { |where| where["field"].to_s.count("/") >= 2 } }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# A command attribute sharing a name with one of the aggregate's
|
|
85
|
+
# own reference-typed attributes while carrying a DIFFERENT, non-
|
|
86
|
+
# reference type — `attribute :member, Handle` against
|
|
87
|
+
# `reference_to Member`.
|
|
88
|
+
def revalued_reference?(aggregate)
|
|
89
|
+
references = attributes(aggregate).select { |held| reference?(held) }.to_set { |held| held["name"].to_s }
|
|
90
|
+
commands(aggregate).any? do |verb|
|
|
91
|
+
(verb["attributes"] || []).any? { |held| references.include?(held["name"].to_s) && !reference?(held) }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Walks a given's own exported AST for any `lookup` whose path is
|
|
96
|
+
# long enough to have crossed two references.
|
|
97
|
+
def deep_lookup?(node)
|
|
98
|
+
case node
|
|
99
|
+
when Hash
|
|
100
|
+
return true if node["op"] == "lookup" && Array(node["path"]).size >= TWO_HOP_GIVEN_PATH_LENGTH
|
|
101
|
+
|
|
102
|
+
node.each_value.any? { |child| deep_lookup?(child) }
|
|
103
|
+
when Array
|
|
104
|
+
node.any? { |child| deep_lookup?(child) }
|
|
105
|
+
else
|
|
106
|
+
false
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Every form, answered for one aggregate — the table above, applied.
|
|
111
|
+
def properties(aggregate)
|
|
112
|
+
FORMS.transform_values { |form| form.call(aggregate) }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Every unordered pair of forms, each rendered "left + right" in
|
|
116
|
+
# alphabetical order — the key both gates' excuse tables use.
|
|
117
|
+
def pairs
|
|
118
|
+
FORMS.keys.combination(2).map { |pair| pair_key(*pair) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def pair_key(left, right) = [left, right].sort.join(" + ")
|
|
122
|
+
|
|
123
|
+
# `held` is `[[aggregate_name, properties], ...]`. Answers which
|
|
124
|
+
# pairs are met on ONE aggregate, and by which — a Hash from pair
|
|
125
|
+
# key to the names carrying it, so a caller can say who.
|
|
126
|
+
def covered_pairs(held)
|
|
127
|
+
held.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(name, shows), covered|
|
|
128
|
+
shows.select { |_, present| present }.keys.combination(2).each do |left, right|
|
|
129
|
+
covered[pair_key(left, right)] << name
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# `[[\"Chapter::Aggregate\", properties], ...]` for every aggregate
|
|
135
|
+
# a string-keyed chapter IR declares — the same walk the golden
|
|
136
|
+
# spec makes over `spec/golden/ir/*.json`.
|
|
137
|
+
def aggregates_in(chapter_ir)
|
|
138
|
+
(chapter_ir["aggregates"] || []).map do |aggregate|
|
|
139
|
+
["#{chapter_ir['name']}::#{aggregate['name']}", properties(aggregate)]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# WHERE A DOMAIN PATH KEEPS ITS BLUEBOOKS — `<domain>/bluebook/*.
|
|
144
|
+
# bluebook` (every example and stress domain), or the directory
|
|
145
|
+
# itself (`qa/bluebook`, the ledger's own `Target.path`): the same
|
|
146
|
+
# two shapes `bin/model_check`'s `bluebook_in` reads. `nil` when
|
|
147
|
+
# neither holds a bluebook.
|
|
148
|
+
def bluebook_files(domain_path)
|
|
149
|
+
[File.join(domain_path, "bluebook"), domain_path].each do |dir|
|
|
150
|
+
files = Dir[File.join(dir, "*.bluebook")]
|
|
151
|
+
return files unless files.empty?
|
|
152
|
+
end
|
|
153
|
+
nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# THE SAME CENSUS OVER A DOMAIN ON DISK, booted the lightweight
|
|
157
|
+
# way `bin/model_check` and `Hecks::Codemod.load_bluebook` already
|
|
158
|
+
# do (ports and the two in-process adapters, no `Hecks.boot`, no
|
|
159
|
+
# live database, no `.hecksagon`: the census reads declared SHAPE,
|
|
160
|
+
# and a framework chapter a `.hecksagon` would attach is not this
|
|
161
|
+
# domain's own). Only the domain's own chapter is measured — the
|
|
162
|
+
# first bluebook loaded, the same "target chapter is always first"
|
|
163
|
+
# fact `bin/project_rust` relies on.
|
|
164
|
+
def census(domain_path)
|
|
165
|
+
root = File.expand_path("../../..", __dir__)
|
|
166
|
+
files = bluebook_files(domain_path)
|
|
167
|
+
raise ArgumentError, "#{domain_path} has no bluebook/*.bluebook (or *.bluebook) to measure" if files.nil?
|
|
168
|
+
|
|
169
|
+
registry = Hecks::Runtime::Registry.new(root: File.expand_path(domain_path))
|
|
170
|
+
Hecks.with_registry(registry) do
|
|
171
|
+
Kernel.load(File.join(root, "lib/hecks/ports/persistence.port"))
|
|
172
|
+
Kernel.load(File.join(root, "lib/hecks/ports/extraction.port"))
|
|
173
|
+
Kernel.load(File.join(root, "lib/hecks/adapters/driven/memory.adapter"))
|
|
174
|
+
Kernel.load(File.join(root, "lib/hecks/adapters/driven/prism.adapter"))
|
|
175
|
+
files.each { |file| Kernel.load(file) }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
chapter_name = registry.bluebooks.keys.first
|
|
179
|
+
exported = JSON.parse(JSON.generate(Hecks::Projector::Exporter.call(registry).fetch(chapter_name)))
|
|
180
|
+
aggregates_in(exported)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|