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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/heki/journal.rb +57 -0
  3. data/lib/hecks/adapters/driven/postgres_era.adapter +5 -0
  4. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  5. data/lib/hecks/behaviors/expectations.rb +32 -4
  6. data/lib/hecks/bluebook/behaviour/domain_port.rb +24 -0
  7. data/lib/hecks/bluebook/meta_validator/judge.rb +25 -3
  8. data/lib/hecks/bluebook/model_check.rb +148 -17
  9. data/lib/hecks/forms/field_shape.rb +5 -3
  10. data/lib/hecks/fuzzing/concurrent_dispatch.rb +266 -0
  11. data/lib/hecks/fuzzing/era_boundary.rb +105 -0
  12. data/lib/hecks/fuzzing/form_census.rb +184 -0
  13. data/lib/hecks/fuzzing/isolated_boot.rb +198 -10
  14. data/lib/hecks/fuzzing/persistence_parity.rb +163 -0
  15. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  16. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +188 -13
  17. data/lib/hecks/fuzzing/properties/guards.rb +103 -0
  18. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +34 -1
  19. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  20. data/lib/hecks/fuzzing/properties.rb +23 -2
  21. data/lib/hecks/fuzzing/replay.rb +130 -20
  22. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  23. data/lib/hecks/fuzzing/self_consistency.rb +647 -0
  24. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  25. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +90 -26
  26. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +50 -4
  27. data/lib/hecks/fuzzing/sequence_generator/picker.rb +11 -0
  28. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +73 -9
  29. data/lib/hecks/fuzzing/sequence_generator.rb +47 -14
  30. data/lib/hecks/fuzzing/structural_skips.rb +146 -0
  31. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  32. data/lib/hecks/fuzzing/target_capabilities.rb +149 -0
  33. data/lib/hecks/fuzzing/value_generator.rb +55 -3
  34. data/lib/hecks/fuzzing.rb +6 -0
  35. data/lib/hecks/language/bluebook/vocabulary.bluebook +17 -2
  36. data/lib/hecks/naming.rb +70 -2
  37. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +50 -0
  38. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -0
  39. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +31 -0
  40. data/lib/hecks/ports/persistence/repository_factory.rb +8 -5
  41. data/lib/hecks/projections/glossary/html.rb +250 -0
  42. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  43. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  44. data/lib/hecks/projections/glossary/page.css +271 -0
  45. data/lib/hecks/projections/glossary/page.js +72 -0
  46. data/lib/hecks/projections/glossary/sections.rb +17 -0
  47. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  48. data/lib/hecks/projections/glossary.rb +214 -286
  49. data/lib/hecks/projector/narrate_projector.rb +4 -11
  50. data/lib/hecks/query_specification/common/comparison.rb +27 -1
  51. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +26 -8
  52. data/lib/hecks/runtime/command_rules/references.rb +75 -0
  53. data/lib/hecks/runtime/entity_element.rb +168 -16
  54. data/lib/hecks/runtime/entity_interpreter.rb +68 -3
  55. data/lib/hecks/runtime/query_interpreter.rb +66 -2
  56. data/lib/hecks/runtime/reaction_invocation.rb +70 -3
  57. data/lib/hecks/runtime/refusal_wording.rb +5 -2
  58. data/lib/hecks/runtime/registry.rb +12 -0
  59. data/lib/hecks/runtime/routing.rb +67 -2
  60. data/lib/hecks/runtime/saga_interpreter.rb +38 -1
  61. data/lib/hecks/runtime/value/coercion.rb +77 -115
  62. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  63. data/lib/hecks/runtime/value.rb +7 -2
  64. data/lib/hecks/version.rb +1 -1
  65. data/lib/hecks/vocabulary.rb +2 -1
  66. 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. Postgres/PostgresEra are deliberately NOT
48
- # added here: a real Postgres run needs a live server and shared
49
- # connection settings this in-process, no-adapter-config path has no
50
- # place to source safely — that stays `io: true`-gated, direct-adapter
51
- # coverage (`spec/adapters/driven/postgres_*_spec.rb`), not this.
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
- def call(domain_path, adapter: :memory)
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 then rebind_to_memory!(copy)
75
- when :sqlite then rebind_to_sqlite!(copy)
76
- when :postgres then rebind_to_postgres!(copy)
77
- else raise ArgumentError, "unknown fuzz adapter #{adapter.inspect} — :memory, :sqlite, or :postgres"
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