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
|
@@ -44,11 +44,21 @@ module Hecks
|
|
|
44
44
|
# `root:`, which `Hecks.boot(copy)` passes as this ephemeral copy's
|
|
45
45
|
# own directory — a fresh, empty `data/` per run, exactly like
|
|
46
46
|
# Memory's own zero-history guarantee, just backed by a real SQLite
|
|
47
|
-
# file instead of a Hash.
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
47
|
+
# file instead of a Hash. STALE AS OF THIS PARAGRAPH'S ORIGINAL
|
|
48
|
+
# WRITING — both `Postgres` (PRD 02, docs/prds/02-fuzzer-real-
|
|
49
|
+
# adapters.md) and `PostgresEra` (this mode's own header, below,
|
|
50
|
+
# `rebind_to_postgres_era!`) since gained real `adapter:` modes here.
|
|
51
|
+
# Each writes its own fresh `.world` per `.hecksagon` rather than
|
|
52
|
+
# relying on the zero-config default Sqlite/Memory get, and each
|
|
53
|
+
# needs a real, reachable Postgres server — but "no place to source a
|
|
54
|
+
# connection safely" turned out not to be true: `:postgres` sources
|
|
55
|
+
# one shared, permanent scratch database/schema this module itself
|
|
56
|
+
# owns (see `FUZZ_POSTGRES_DATABASE`'s own header); `:postgres_era`
|
|
57
|
+
# instead requires the CALLER to supply (and own the lifecycle of) its
|
|
58
|
+
# own throwaway `database:`/`schema:`, since its only caller
|
|
59
|
+
# (`bin/qa_sweep --persistence-parity`) already has to manage a
|
|
60
|
+
# disposable database of its own, never a shared one this module could
|
|
61
|
+
# safely default to.
|
|
52
62
|
module IsolatedBoot
|
|
53
63
|
module_function
|
|
54
64
|
|
|
@@ -65,16 +75,26 @@ module Hecks
|
|
|
65
75
|
# so `bin/fuzz --adapter postgres` is meant to run with smaller
|
|
66
76
|
# seed/step counts than the Memory default, not as a like-for-like
|
|
67
77
|
# swap; see that flag's own comment.
|
|
68
|
-
|
|
78
|
+
# `database:`/`schema:` are ONLY meaningful for `adapter: :postgres_era`
|
|
79
|
+
# — see `rebind_to_postgres_era!`'s own header for why that mode takes
|
|
80
|
+
# caller-supplied connection identity instead of a hardcoded shared
|
|
81
|
+
# constant the way `:postgres` does. Every other adapter ignores both;
|
|
82
|
+
# accepting them unconditionally here (rather than a separate method
|
|
83
|
+
# signature per adapter) keeps `SequenceGenerator`/`Replay`'s own
|
|
84
|
+
# single passthrough (`adapter:`, now joined by these two) uniform
|
|
85
|
+
# across all four modes.
|
|
86
|
+
def call(domain_path, adapter: :memory, database: nil, schema: nil)
|
|
69
87
|
Dir.mktmpdir("hecks-fuzz") do |tmp|
|
|
70
88
|
copy = File.join(tmp, File.basename(domain_path))
|
|
71
89
|
copy_dereferencing(domain_path, copy)
|
|
72
90
|
FileUtils.rm_rf(File.join(copy, "data"))
|
|
73
91
|
case adapter
|
|
74
|
-
when :memory
|
|
75
|
-
when :sqlite
|
|
76
|
-
when :postgres
|
|
77
|
-
|
|
92
|
+
when :memory then rebind_to_memory!(copy)
|
|
93
|
+
when :sqlite then rebind_to_sqlite!(copy)
|
|
94
|
+
when :postgres then rebind_to_postgres!(copy)
|
|
95
|
+
when :postgres_era then rebind_to_postgres_era!(copy, database: database, schema: schema)
|
|
96
|
+
else raise ArgumentError,
|
|
97
|
+
"unknown fuzz adapter #{adapter.inspect} — :memory, :sqlite, :postgres, or :postgres_era"
|
|
78
98
|
end
|
|
79
99
|
yield copy
|
|
80
100
|
end
|
|
@@ -118,6 +138,7 @@ module Hecks
|
|
|
118
138
|
|
|
119
139
|
def rebind_to_memory!(copy)
|
|
120
140
|
rewrite_bindings!(copy, "Memory")
|
|
141
|
+
strip_translations!(copy)
|
|
121
142
|
|
|
122
143
|
# THE SETTINGS, NOT JUST THE BIND — `WorldBuilder#method_missing`
|
|
123
144
|
# stores a settings block under BOTH "verb:adapter" and the bare
|
|
@@ -143,6 +164,7 @@ module Hecks
|
|
|
143
164
|
# nothing here creates it up front.
|
|
144
165
|
def rebind_to_sqlite!(copy)
|
|
145
166
|
rewrite_bindings!(copy, "SqlitePersistence")
|
|
167
|
+
strip_translations!(copy)
|
|
146
168
|
Dir.glob(File.join(copy, "**", "*.world")).each { |path| File.delete(path) }
|
|
147
169
|
end
|
|
148
170
|
|
|
@@ -174,6 +196,7 @@ module Hecks
|
|
|
174
196
|
def rebind_to_postgres!(copy)
|
|
175
197
|
require "pg"
|
|
176
198
|
rewrite_bindings!(copy, "Postgres")
|
|
199
|
+
strip_translations!(copy)
|
|
177
200
|
ensure_fuzz_schema!
|
|
178
201
|
|
|
179
202
|
# ONE `.world` PER DIRECTORY A `.hecksagon` ACTUALLY LIVES IN, not
|
|
@@ -269,6 +292,131 @@ module Hecks
|
|
|
269
292
|
db.close
|
|
270
293
|
end
|
|
271
294
|
|
|
295
|
+
# THE ADAPTER `:postgres` NEVER TOUCHES — `Postgres` and `PostgresEra`
|
|
296
|
+
# are SIBLING, NOT interchangeable, adapters (see postgres_era.rb's
|
|
297
|
+
# own header: "the only one that declares the LINEAGE capability").
|
|
298
|
+
# PRD 02 (docs/prds/02-fuzzer-real-adapters.md) shipped `:postgres`
|
|
299
|
+
# and explicitly scoped `PostgresEra` OUT: "nothing here touches
|
|
300
|
+
# era/lineage machinery." That gap is real, not cosmetic —
|
|
301
|
+
# `examples/directory` (a `compute`/`rekey` translation edge, the
|
|
302
|
+
# one domain in this corpus that actually exercises PostgresEra-
|
|
303
|
+
# bound SQL compilation) had to be shelved out of `hecks_qa`'s own
|
|
304
|
+
# rotation for exactly this reason: every existing fuzz/replay path,
|
|
305
|
+
# `:memory` included, structurally cannot reach it. This mode closes
|
|
306
|
+
# that — `bin/qa_sweep --persistence-parity` is its first caller.
|
|
307
|
+
#
|
|
308
|
+
# NO SHARED CONSTANT DATABASE, UNLIKE `:postgres` ABOVE — deliberate.
|
|
309
|
+
# `rebind_to_postgres!`'s own `FUZZ_POSTGRES_DATABASE`/`_SCHEMA` are
|
|
310
|
+
# module-level constants because `bin/fuzz --adapter postgres` is a
|
|
311
|
+
# general-purpose, run-it-anytime tool with no caller-tracked
|
|
312
|
+
# lifecycle of its own. This mode's only caller
|
|
313
|
+
# (`bin/qa_sweep --persistence-parity`) is different: it dispatches
|
|
314
|
+
# through `QualityControl::Target.claim!`'s own cross-process lock
|
|
315
|
+
# first (see that script's own header), so at most ONE sweep is ever
|
|
316
|
+
# touching a given target's own disposable database at a time — but
|
|
317
|
+
# the caller, not this module, is what OWNS that database's name and
|
|
318
|
+
# lifecycle (created, and genuinely dropped, by the caller itself),
|
|
319
|
+
# exactly the discipline `spec/qa_sweep_all_spec.rb`'s own header
|
|
320
|
+
# describes and this repository's persistence-parity work is
|
|
321
|
+
# required to follow. Accepting `database:`/`schema:` as REQUIRED
|
|
322
|
+
# keyword arguments (never a fallback constant) is what keeps that
|
|
323
|
+
# ownership from silently drifting back onto this module the way
|
|
324
|
+
# `:postgres`'s own `FUZZ_POSTGRES_DATABASE` already has.
|
|
325
|
+
def rebind_to_postgres_era!(copy, database:, schema:)
|
|
326
|
+
require "pg"
|
|
327
|
+
if database.to_s.empty? || schema.to_s.empty?
|
|
328
|
+
raise ArgumentError,
|
|
329
|
+
"adapter: :postgres_era requires both database: and schema: — a throwaway database/schema " \
|
|
330
|
+
"THIS CALLER creates and drops itself (see rebind_to_postgres_era!'s own header). " \
|
|
331
|
+
"There is no shared default, unlike :postgres, so the caller cannot forget to own the lifecycle."
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
rewrite_bindings!(copy, "PostgresEra")
|
|
335
|
+
ensure_postgres_era_schema!(database: database, schema: schema)
|
|
336
|
+
|
|
337
|
+
# SAME ONE-`.world`-PER-`.hecksagon`-DIRECTORY SHAPE `rebind_to_
|
|
338
|
+
# postgres!` already uses, for the identical reason (`Folder#
|
|
339
|
+
# load_domain` globs `*.world` non-recursively) — see that
|
|
340
|
+
# method's own comment on `world_path` above. `PostgresEra`
|
|
341
|
+
# additionally takes `schema:` (postgres_era.rb's own "SHARED-
|
|
342
|
+
# INSTANCE ISOLATION" comment): the caller-supplied throwaway
|
|
343
|
+
# schema is what actually isolates this one ephemeral boot from
|
|
344
|
+
# the next, the same job `FUZZ_POSTGRES_SCHEMA` does for `:postgres`
|
|
345
|
+
# — `connect_for` itself idempotently `CREATE SCHEMA IF NOT
|
|
346
|
+
# EXISTS`s it, so this method only ever needs to DROP it first
|
|
347
|
+
# (in `ensure_postgres_era_schema!`, below) for the zero-history
|
|
348
|
+
# guarantee every other adapter mode already gives.
|
|
349
|
+
#
|
|
350
|
+
# `allow_superuser true` — ON THE RECORD, ON PURPOSE. A bare
|
|
351
|
+
# `database` connects as the ambient Postgres user, and
|
|
352
|
+
# PostgresEra refuses to boot at all when that user is a
|
|
353
|
+
# superuser (its era write-fence is row-level security, which a
|
|
354
|
+
# superuser walks through — `Lineage#check_fence_applies!`,
|
|
355
|
+
# BUG#24). That refusal protects a REAL ledger from an old
|
|
356
|
+
# checkout's stale writes; nothing here is one. This is an
|
|
357
|
+
# ephemeral boot into a throwaway schema the caller itself
|
|
358
|
+
# creates and drops, whose data no second checkout ever shares,
|
|
359
|
+
# and what it compares is Memory's answers against PostgresEra's
|
|
360
|
+
# own SQL — the era fence is not under test and cannot be
|
|
361
|
+
# crossed. So opt in explicitly rather than make every
|
|
362
|
+
# persistence-parity run first provision a fenced role for a
|
|
363
|
+
# database it is about to throw away; the one-line warning
|
|
364
|
+
# PostgresEra prints per boot under the opt-in is the honest
|
|
365
|
+
# price. Inert on a machine whose ambient user is ordinary.
|
|
366
|
+
Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
|
|
367
|
+
names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
|
|
368
|
+
next if names.empty?
|
|
369
|
+
|
|
370
|
+
world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres_era.world")
|
|
371
|
+
File.write(world_path, names.map do |name|
|
|
372
|
+
<<~WORLD
|
|
373
|
+
Hecks.world "#{name}" do
|
|
374
|
+
persisted_by("PostgresEra") do
|
|
375
|
+
database "#{database}"
|
|
376
|
+
schema "#{schema}"
|
|
377
|
+
allow_superuser true
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
WORLD
|
|
381
|
+
end.join("\n"))
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
Dir.glob(File.join(copy, "**", "*.world")).each do |path|
|
|
385
|
+
File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres_era.world"
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# THE ZERO-HISTORY GUARANTEE FOR THIS MODE — `DROP SCHEMA ... CASCADE`
|
|
390
|
+
# before every ephemeral boot, mirroring `ensure_fuzz_schema!` above
|
|
391
|
+
# (same `GC.start`-before-connecting fix for the identical
|
|
392
|
+
# `max_connections` exhaustion that method's own comment documents —
|
|
393
|
+
# `PostgresEra` opens real `PG::Connection`s exactly like `Postgres`
|
|
394
|
+
# does, same unclosed-until-GC'd lifetime). The DATABASE itself is
|
|
395
|
+
# created here too, idempotently (`CREATE DATABASE IF NOT EXISTS`
|
|
396
|
+
# has no Postgres spelling, hence the existence check) — but never
|
|
397
|
+
# DROPPED here: this module creates it once per process because
|
|
398
|
+
# `Hecks.boot` needs it to exist before `PostgresEra.connect_for`'s
|
|
399
|
+
# own `PG.connect(dbname: ...)` can succeed at all, but dropping it
|
|
400
|
+
# again is the CALLER's own job (its name and lifecycle belong to
|
|
401
|
+
# the caller — see `rebind_to_postgres_era!`'s own header), not
|
|
402
|
+
# something this per-ephemeral-boot helper should ever do mid-sweep.
|
|
403
|
+
def ensure_postgres_era_schema!(database:, schema:)
|
|
404
|
+
GC.start
|
|
405
|
+
|
|
406
|
+
admin = PG.connect(dbname: "postgres")
|
|
407
|
+
exists = admin.exec_params(
|
|
408
|
+
"SELECT 1 FROM pg_database WHERE datname = $1", [database]
|
|
409
|
+
).ntuples.positive?
|
|
410
|
+
admin.exec(%(CREATE DATABASE "#{database}")) unless exists
|
|
411
|
+
admin.close
|
|
412
|
+
|
|
413
|
+
db = PG.connect(dbname: database)
|
|
414
|
+
db.exec("SET client_min_messages = warning")
|
|
415
|
+
quoted = db.quote_ident(schema)
|
|
416
|
+
db.exec("DROP SCHEMA IF EXISTS #{quoted} CASCADE")
|
|
417
|
+
db.close
|
|
418
|
+
end
|
|
419
|
+
|
|
272
420
|
# THE SHARED REWRITE — factored out of `rebind_to_memory!` when
|
|
273
421
|
# Sqlite/Postgres modes needed the identical `.hecksagon` surgery
|
|
274
422
|
# with only the target adapter name differing. `persisted_by`/
|
|
@@ -283,6 +431,46 @@ module Hecks
|
|
|
283
431
|
# #read_repository` already falls back to the authoritative
|
|
284
432
|
# repository when none exists, so a read model this ephemeral copy
|
|
285
433
|
# never wires is simply unread, not broken.
|
|
434
|
+
# A `compute`/`rekey` TRANSLATION EDGE REFUSES TO BOOT AT ALL UNDER
|
|
435
|
+
# ANY NON-LINEAGE-CAPABLE ADAPTER — found live, wiring this very
|
|
436
|
+
# mode up against `examples/directory`: `Runtime::EraCheck
|
|
437
|
+
# .check_compute_rules!` (era_check.rb) runs UNCONDITIONALLY for
|
|
438
|
+
# every loaded bluebook once the era plugin is loaded at all
|
|
439
|
+
# (`bin/qa_sweep`'s own top-of-file `require "hecks/ports/
|
|
440
|
+
# persistence/plugins/era"`, needed for the ledger's own
|
|
441
|
+
# PostgresEra-bound aggregates), and refuses outright — "compute
|
|
442
|
+
# rules require the Postgres adapter" — for ANY aggregate whose
|
|
443
|
+
# lineage carries a `compute` rule and whose BOUND adapter is not
|
|
444
|
+
# lineage-capable. `PostgresEra` is the only adapter that answers
|
|
445
|
+
# `lineage_capable? == true` (postgres_era.rb's own `self.
|
|
446
|
+
# lineage_capable? = true`) — plain `Postgres` does NOT, so this
|
|
447
|
+
# refusal was already real for `:postgres`/`:sqlite`/`:memory`
|
|
448
|
+
# alike, for any domain with a translation edge, before this
|
|
449
|
+
# mode's own `:postgres_era` ever existed. This is very likely the
|
|
450
|
+
# MECHANICAL reason `examples/directory` had to be shelved out of
|
|
451
|
+
# `hecks_qa`'s own rotation in the first place — not merely "less
|
|
452
|
+
# interesting to fuzz on Memory," but "cannot BOOT on Memory at
|
|
453
|
+
# all" once the era plugin is loaded, which every real
|
|
454
|
+
# `bin/qa_sweep` invocation already does.
|
|
455
|
+
#
|
|
456
|
+
# THE FIX IS TO DROP THE EDGE, NOT TO CHASE THE REFUSAL — an
|
|
457
|
+
# ephemeral, zero-history replay boot (every mode `IsolatedBoot`
|
|
458
|
+
# offers) never has a pre-existing era-1 row to translate in the
|
|
459
|
+
# first place, so the translation edge is IRRELEVANT to anything a
|
|
460
|
+
# fuzz/replay run actually exercises (ordinary command dispatch
|
|
461
|
+
# against a fresh boot) — it only ever matters at MINT time,
|
|
462
|
+
# against a real, pre-existing database
|
|
463
|
+
# (`PostgresEra::LineageManager.check!`, a wholly separate,
|
|
464
|
+
# human-approved path this harness was never meant to reach).
|
|
465
|
+
# Dropping it here is exactly the same move `rebind_to_memory!`
|
|
466
|
+
# already makes for `.world` (irrelevant/conflicting settings for
|
|
467
|
+
# an ephemeral boot, deleted outright) — never called for
|
|
468
|
+
# `:postgres_era` itself, where the bound adapter genuinely IS
|
|
469
|
+
# lineage-capable and the edge causes no refusal to begin with.
|
|
470
|
+
def strip_translations!(copy)
|
|
471
|
+
Dir.glob(File.join(copy, "**", "translations", "*.bluebook")).each { |path| File.delete(path) }
|
|
472
|
+
end
|
|
473
|
+
|
|
286
474
|
def rewrite_bindings!(copy, adapter_name)
|
|
287
475
|
Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |path|
|
|
288
476
|
lines = File.readlines(path).grep_v(/\bprojected_by\s*\(?\s*"/)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "replay"
|
|
3
|
+
|
|
4
|
+
module Hecks
|
|
5
|
+
module Fuzzing
|
|
6
|
+
# THE SECOND DIFFERENTIAL AXIS — bin/qa_sweep's own `diff_ruby_vs_rust`
|
|
7
|
+
# compares two DIFFERENT ENGINES (the Ruby interpreter vs the compiled
|
|
8
|
+
# Rust kernel) against the SAME persistence (Memory, always — see
|
|
9
|
+
# `SequenceGenerator`'s own header: sequence GENERATION stays
|
|
10
|
+
# Memory-only, and `IsolatedBoot`'s own header explains why every
|
|
11
|
+
# existing fuzz/replay path structurally cannot reach a real Postgres-
|
|
12
|
+
# bound domain's own SQL compilation). This module compares the
|
|
13
|
+
# opposite pairing: the SAME ONE Ruby engine, against two DIFFERENT
|
|
14
|
+
# persistences — Memory (the reference, exactly as fast and as
|
|
15
|
+
# deterministic as every other fuzz check) and a real, disposable
|
|
16
|
+
# `PostgresEra` database (the actual SQL compute/rekey/query-pushdown
|
|
17
|
+
# path a Postgres-bound domain's users really hit).
|
|
18
|
+
#
|
|
19
|
+
# `examples/directory` is why this exists at all: the one domain in
|
|
20
|
+
# this corpus with a real `compute`/`rekey` translation edge
|
|
21
|
+
# (docs/prds/02-fuzzer-real-adapters.md's own "What shipped" section
|
|
22
|
+
# names it directly), which no existing fuzz/replay/property check has
|
|
23
|
+
# ever been able to exercise against real PostgresEra SQL — every one
|
|
24
|
+
# of them boots through `IsolatedBoot`, and until `adapter:
|
|
25
|
+
# :postgres_era` existed (isolated_boot.rb's own header), that meant
|
|
26
|
+
# Memory, unconditionally, no matter what `directory.world` itself
|
|
27
|
+
# declares.
|
|
28
|
+
#
|
|
29
|
+
# NOT a replacement for `bin/qa_sweep`'s own Ruby-vs-Rust differential
|
|
30
|
+
# mode — a genuinely separate axis, opt-in (`--persistence-parity`),
|
|
31
|
+
# because this one pays for a real `PG.connect` and real SQL per
|
|
32
|
+
# dispatch where Memory-vs-Rust pays for neither. See `bin/qa_sweep`'s
|
|
33
|
+
# own `--persistence-parity` handling for the seed-count dial that
|
|
34
|
+
# keeps that cost bounded.
|
|
35
|
+
#
|
|
36
|
+
# GENERALIZED TO `left:`/`right:` — originally hardcoded to Memory vs
|
|
37
|
+
# PostgresEra (the only pairing that existed), now any two of
|
|
38
|
+
# `IsolatedBoot`'s own adapter symbols (`:memory`, `:sqlite`,
|
|
39
|
+
# `:postgres`, `:postgres_era`). Defaults preserve the original
|
|
40
|
+
# pairing exactly, so every existing caller (this file's own spec,
|
|
41
|
+
# `bin/qa_sweep`'s `--persistence-parity`) is unchanged. The second
|
|
42
|
+
# pairing this generalization exists FOR is Memory vs SQLite
|
|
43
|
+
# (`QualityControlDials::ADAPTER_PARITY_PAIRS`, `bin/qa_sweep`'s own
|
|
44
|
+
# `adapter_parity_sqlite` mode) — `:sqlite` is nearly as cheap as
|
|
45
|
+
# Memory itself (`IsolatedBoot#rebind_to_sqlite!`'s own header: an
|
|
46
|
+
# on-disk file, no server, no disposable database/schema lifecycle to
|
|
47
|
+
# own), so that pairing folds straight into the ordinary per-seed
|
|
48
|
+
# loop instead of needing a deferred wave of its own the way
|
|
49
|
+
# PostgresEra does.
|
|
50
|
+
#
|
|
51
|
+
# `database:`/`schema:` — REQUIRED only when `:postgres_era` is one of
|
|
52
|
+
# the two adapters (the caller — today, only `bin/qa_sweep` — owns the
|
|
53
|
+
# disposable database's whole lifecycle: created before the sweep,
|
|
54
|
+
# dropped after — see that script's own comment, and the discipline
|
|
55
|
+
# `IsolatedBoot#rebind_to_postgres_era!` itself refuses to let a
|
|
56
|
+
# caller skip); every other adapter ignores both, same as `Replay.
|
|
57
|
+
# call`/`IsolatedBoot.call` already do for their own `adapter:`.
|
|
58
|
+
module PersistenceParity
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# THE SAME SIX FIELDS `bin/qa_sweep`'s own `diff_ruby_vs_rust`
|
|
62
|
+
# compares (its own comment: "instances, events, refusals, queries,
|
|
63
|
+
# sagas, reactions") — deliberately the identical set, so a report
|
|
64
|
+
# this mode produces reads exactly like the sibling mode's own,
|
|
65
|
+
# differing only in which two things were compared, not in what
|
|
66
|
+
# "found something" means.
|
|
67
|
+
#
|
|
68
|
+
# SIMPLER NORMALIZATION THAN `diff_ruby_vs_rust`, on purpose — that
|
|
69
|
+
# method reduces Rust's OWN JSON-over-stdout output to "wire
|
|
70
|
+
# precision" and filters known Ruby/Rust structural gaps, because
|
|
71
|
+
# it is comparing two genuinely different engines that are allowed
|
|
72
|
+
# to differ in already-catalogued, understood ways. Both sides here
|
|
73
|
+
# are the SAME Ruby engine (`Replay.call`, called twice, adapter
|
|
74
|
+
# only) — there is no second engine's own known-gap catalogue to
|
|
75
|
+
# filter against, so any real difference IS the finding. Both
|
|
76
|
+
# results still round-trip through `JSON.generate`/`JSON.parse`
|
|
77
|
+
# before comparing, matching `diff_ruby_vs_rust`'s own discipline —
|
|
78
|
+
# not because either side needs a wire-format reduction, but so
|
|
79
|
+
# `Hash#==`/`Array#==` compares plain, JSON-shaped data on both
|
|
80
|
+
# sides identically (a `Runtime::Value`, a `Symbol` key, a `Time`
|
|
81
|
+
# nobody asked for — none of that survives an accidental leak into
|
|
82
|
+
# this comparison unnoticed).
|
|
83
|
+
def diff(domain_path, steps, left: :memory, right: :postgres_era, database: nil, schema: nil)
|
|
84
|
+
left_result = Replay.call(domain_path, steps, adapter: left, database: database, schema: schema)
|
|
85
|
+
right_result = Replay.call(domain_path, steps, adapter: right, database: database, schema: schema)
|
|
86
|
+
|
|
87
|
+
divergences = []
|
|
88
|
+
divergences.concat(diff_instances(left_result, right_result, left, right))
|
|
89
|
+
divergences.concat(diff_events(left_result, right_result, left, right))
|
|
90
|
+
divergences.concat(diff_refusals(left_result, right_result, left, right))
|
|
91
|
+
divergences.concat(diff_queries(left_result, right_result, left, right))
|
|
92
|
+
divergences.concat(diff_sagas(left_result, right_result, left, right))
|
|
93
|
+
divergences.concat(diff_reactions(left_result, right_result, left, right))
|
|
94
|
+
divergences
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def as_json(value) = JSON.parse(JSON.generate(value))
|
|
98
|
+
|
|
99
|
+
def diff_instances(left_result, right_result, left, right)
|
|
100
|
+
l = as_json(left_result[:instances])
|
|
101
|
+
r = as_json(right_result[:instances])
|
|
102
|
+
return [] if l == r
|
|
103
|
+
|
|
104
|
+
[{ field: "instances", left => l, right => r }]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def diff_events(left_result, right_result, left, right)
|
|
108
|
+
l = as_json(left_result[:events])
|
|
109
|
+
r = as_json(right_result[:events])
|
|
110
|
+
return [] if l == r
|
|
111
|
+
|
|
112
|
+
[{ field: "events", left => l, right => r }]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# `verb:`/`kind:` normalized to plain strings the same way
|
|
116
|
+
# `diff_ruby_vs_rust`'s own `ruby_refusals` mapping does — both
|
|
117
|
+
# sides here already answer strings (`Replay#refusal_kind` always
|
|
118
|
+
# returns one), so this is belt-and-suspenders consistency with the
|
|
119
|
+
# sibling mode's own shape, not a real coercion.
|
|
120
|
+
def diff_refusals(left_result, right_result, left, right)
|
|
121
|
+
normalize = lambda do |refusals|
|
|
122
|
+
refusals.map { |r| { "verb" => r[:verb].to_s, "kind" => r[:kind].to_s, "error" => r[:error] } }
|
|
123
|
+
end
|
|
124
|
+
l = normalize.call(left_result[:refusals])
|
|
125
|
+
r = normalize.call(right_result[:refusals])
|
|
126
|
+
return [] if l == r
|
|
127
|
+
|
|
128
|
+
[{ field: "refusals", left => l, right => r }]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# `instances_at:` dropped from every entry — the same reason
|
|
132
|
+
# `diff_ruby_vs_rust` excludes it (`row.except(:instances_at)`):
|
|
133
|
+
# it is a full state snapshot taken for the QUERY oracle's own use,
|
|
134
|
+
# already covered by `diff_instances` above, and would make every
|
|
135
|
+
# query-step entry re-litigate the SAME instances divergence a
|
|
136
|
+
# second time under a different field name.
|
|
137
|
+
def diff_queries(left_result, right_result, left, right)
|
|
138
|
+
strip = ->(rows) { rows.map { |row| row.except(:instances_at) } }
|
|
139
|
+
l = as_json(strip.call(left_result[:queries]))
|
|
140
|
+
r = as_json(strip.call(right_result[:queries]))
|
|
141
|
+
return [] if l == r
|
|
142
|
+
|
|
143
|
+
[{ field: "queries", left => l, right => r }]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def diff_sagas(left_result, right_result, left, right)
|
|
147
|
+
l = as_json(left_result[:sagas])
|
|
148
|
+
r = as_json(right_result[:sagas])
|
|
149
|
+
return [] if l == r
|
|
150
|
+
|
|
151
|
+
[{ field: "sagas", left => l, right => r }]
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def diff_reactions(left_result, right_result, left, right)
|
|
155
|
+
l = as_json(left_result[:reactions])
|
|
156
|
+
r = as_json(right_result[:reactions])
|
|
157
|
+
return [] if l == r
|
|
158
|
+
|
|
159
|
+
[{ field: "reactions", left => l, right => r }]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
module Properties
|
|
4
|
+
# ANGLE-9 — `corrects` (retroactive correction) had exactly one
|
|
5
|
+
# declaration anywhere in the corpus (`examples/banking/bluebook/
|
|
6
|
+
# deposit_accounts.bluebook:353`, aggregate-level) and no property
|
|
7
|
+
# anywhere in this file ever checked it, and no `FEATURE_COVERAGE`
|
|
8
|
+
# claim either — confirmed absent before this file existed. `qa/
|
|
9
|
+
# stress_domains/corrections` gives it its first real coverage; see
|
|
10
|
+
# that domain's own NOTES.md for what it found (an entity-level
|
|
11
|
+
# `corrects` crashes Ruby at dispatch outright, and Rust's own
|
|
12
|
+
# generated code has NO admissibility check for it AT ALL — neither
|
|
13
|
+
# engine can be compared on the untested combination this property
|
|
14
|
+
# was written to watch, which is itself the headline finding).
|
|
15
|
+
module Corrections
|
|
16
|
+
# Every event named by a `corrects` mutation, on any command a
|
|
17
|
+
# booted history's own bluebooks declare — aggregate-level, or
|
|
18
|
+
# nested inside an entity at any depth (`each_command_including_
|
|
19
|
+
# entities`, below, is the same recursive walk `SequenceGenerator::
|
|
20
|
+
# Catalog#each_entity_chain` already uses, for the identical reason:
|
|
21
|
+
# `Aggregate#entities`/`Entity#entities` nest, ADR 0026, S17). For
|
|
22
|
+
# every such command, every event THIS history actually recorded
|
|
23
|
+
# under one of the command's own `emits` names must have an event
|
|
24
|
+
# named by the command's own `corrects` target — same aggregate-
|
|
25
|
+
# qualified name, same id — appearing STRICTLY EARLIER in the same
|
|
26
|
+
# history.
|
|
27
|
+
#
|
|
28
|
+
# `history[:events]` is already in occurrence order (`Replay.call`'s
|
|
29
|
+
# own `runtime.events`, appended as each step dispatches) — "earlier"
|
|
30
|
+
# is therefore "earlier in this array," no timestamp comparison
|
|
31
|
+
# needed, and no per-step attribution back to which command produced
|
|
32
|
+
# which event is needed either: an event's own declared NAME already
|
|
33
|
+
# identifies the one command in its aggregate that can produce it
|
|
34
|
+
# (`AggregateBuilder::Sealing#seal_correction_targets`'s own
|
|
35
|
+
# `emitted_by` hash reads the identical fact, one level shallower).
|
|
36
|
+
#
|
|
37
|
+
# WHY THIS CANNOT BE `GUARANTEED_BY_CONSTRUCTION` THE WAY THE
|
|
38
|
+
# AGGREGATE-LEVEL CASE ALMOST IS: `CommandRules::Admissibility#
|
|
39
|
+
# enforce_correction_target` (the dispatch-time check) and
|
|
40
|
+
# `AggregateBuilder::Sealing#seal_correction_targets` (the build-time
|
|
41
|
+
# check) both exist ONLY for an aggregate-level `corrects` —
|
|
42
|
+
# `EntityInterpreter#step_enforce_givens` never calls the former at
|
|
43
|
+
# all, and the latter walks only `@commands` (the aggregate's own
|
|
44
|
+
# top-level list), never `@entities`. An entity-level `corrects`
|
|
45
|
+
# mutation is invisible to BOTH doors today — this property is the
|
|
46
|
+
# only thing anywhere, on either engine, that would ever catch one
|
|
47
|
+
# going wrong.
|
|
48
|
+
def corrections_reference_an_emitted_event(history)
|
|
49
|
+
violations = []
|
|
50
|
+
|
|
51
|
+
(history[:bluebooks] || {}).each do |domain, bluebook|
|
|
52
|
+
bluebook.aggregates.each do |aggregate|
|
|
53
|
+
aggregate_key = "#{domain}::#{aggregate.hecks_name}"
|
|
54
|
+
|
|
55
|
+
each_command_including_entities(aggregate) do |command|
|
|
56
|
+
corrects_mutations = command.mutations.select { |mutation| mutation.op == :corrects }
|
|
57
|
+
next if corrects_mutations.empty?
|
|
58
|
+
|
|
59
|
+
corrects_mutations.each do |mutation|
|
|
60
|
+
corrected_event = mutation.target.to_s
|
|
61
|
+
|
|
62
|
+
command.emits.each do |produced_event_name|
|
|
63
|
+
violations.concat(unmatched_corrections(history[:events], aggregate_key,
|
|
64
|
+
produced_event_name.to_s, corrected_event,
|
|
65
|
+
command.hecks_name))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
violations.empty? || violations.uniq.join("; ")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def unmatched_corrections(events, aggregate_key, produced_event_name, corrected_event, command_name)
|
|
76
|
+
own_events = events.each_with_index.select do |event, _index|
|
|
77
|
+
event[:name] == produced_event_name && event[:aggregate] == aggregate_key
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
own_events.filter_map do |event, index|
|
|
81
|
+
preceding = events.first(index)
|
|
82
|
+
next if preceding.any? do |earlier|
|
|
83
|
+
earlier[:name] == corrected_event && earlier[:aggregate] == aggregate_key &&
|
|
84
|
+
earlier[:id].to_s == event[:id].to_s
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
"#{command_name} (#{aggregate_key}##{event[:id]}) emitted #{produced_event_name}, claiming to " \
|
|
88
|
+
"correct #{corrected_event}, but no #{corrected_event} for the same aggregate/id appears " \
|
|
89
|
+
"earlier in this history"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def each_command_including_entities(owner, &block)
|
|
94
|
+
owner.commands.each(&block)
|
|
95
|
+
owner.entities.each { |entity| each_command_including_entities(entity, &block) }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|