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
@@ -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