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
|
@@ -15,11 +15,12 @@ module Hecks
|
|
|
15
15
|
parent_scalar = identity_scalar_of(aggregate, args)
|
|
16
16
|
|
|
17
17
|
@known_ids[aggregate.hecks_name] << parent_scalar if entry[:entity].nil? && entry[:command].creates?
|
|
18
|
+
record_grant(args) if entry[:verb] == Adversary::GRANT_VERB
|
|
18
19
|
|
|
19
|
-
populator = catalog
|
|
20
|
+
populator = populator_for_entry(catalog, entry)
|
|
20
21
|
return unless populator
|
|
21
22
|
|
|
22
|
-
key =
|
|
23
|
+
key = append_pool_key(populator, args)
|
|
23
24
|
|
|
24
25
|
# The auto-minted case (CommandInterpreter#entity_element): the
|
|
25
26
|
# element just landed at count-so-far + 1. The explicit case: this
|
|
@@ -33,6 +34,51 @@ module Hecks
|
|
|
33
34
|
(@entity_known_ids[key].size + 1).to_s
|
|
34
35
|
end
|
|
35
36
|
@entity_known_ids[key] << new_id
|
|
37
|
+
|
|
38
|
+
# THE CALLER-SUPPLIED IDENTITY TUPLE, kept whole — every mapped
|
|
39
|
+
# identity argument and the exact value this step offered for it
|
|
40
|
+
# — so the adversarial duplicate-identity mutation (BUG#13's own
|
|
41
|
+
# shape, `Folder.AddSlip` twice under the same `reference`) can
|
|
42
|
+
# offer it AGAIN later against the same parent, composite
|
|
43
|
+
# identities included (BUG#13's fix explicitly did NOT cover
|
|
44
|
+
# those; this is how a sequence gets to ask).
|
|
45
|
+
return if populator[:identity_arguments].empty?
|
|
46
|
+
|
|
47
|
+
@appended_identities[key] << populator[:identity_arguments].to_h { |name| [name.to_s, args[name.to_s]] }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A GRANT THIS SEQUENCE MADE FOR REAL — `Governance::RoleAssignment.
|
|
51
|
+
# Assign` succeeded with these exact args, so `actor_id` now holds
|
|
52
|
+
# `role_name` in this boot's own store, on both replay sides. The
|
|
53
|
+
# `actor_known` caller shape (adversary.rb) replays that identity
|
|
54
|
+
# against a command gated on the same role: the one way a
|
|
55
|
+
# generated step reaches `holds_role?`'s AUTHORIZED branch rather
|
|
56
|
+
# than only its refusing one.
|
|
57
|
+
def record_grant(args)
|
|
58
|
+
role = ValueGenerator.scalar_of(args["role_name"]).to_s
|
|
59
|
+
actor = ValueGenerator.scalar_of(args["actor_id"]).to_s
|
|
60
|
+
@granted[role] << actor unless role.empty? || actor.empty?
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# THE POOL AN APPENDED ELEMENT LANDS IN — the aggregate's own
|
|
64
|
+
# identity, then one scalar per owning hop (an aggregate-level
|
|
65
|
+
# append has none; `Board.AddCard` has `Board`'s own `number`,
|
|
66
|
+
# read straight back off the args this step addressed it by).
|
|
67
|
+
def append_pool_key(populator, args)
|
|
68
|
+
parent_scalar = identity_scalar_of(populator[:aggregate], args)
|
|
69
|
+
owner_scalars = populator[:owner_chain].map do |piece|
|
|
70
|
+
ValueGenerator.scalar_of(args[(piece.identified_by || :id).to_s])
|
|
71
|
+
end
|
|
72
|
+
entity_pool_key(populator[:aggregate].hecks_name,
|
|
73
|
+
populator[:owner_chain].map(&:hecks_name) + [populator[:entity].hecks_name],
|
|
74
|
+
[parent_scalar] + owner_scalars)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `"Agg.Board#w1"` for a depth-1 pool — byte-identical to the key
|
|
78
|
+
# this always used, so nothing a pinned seed draws from moves —
|
|
79
|
+
# and `"Agg.Board.Card#w1/1"` one hop deeper.
|
|
80
|
+
def entity_pool_key(aggregate_name, chain_names, scalars)
|
|
81
|
+
"#{aggregate_name}.#{chain_names.join('.')}##{scalars.join('/')}"
|
|
36
82
|
end
|
|
37
83
|
|
|
38
84
|
# THE SCALAR THIS STEP'S OWN AGGREGATE IDENTITY RESOLVES TO, from the
|
|
@@ -68,8 +114,8 @@ module Hecks
|
|
|
68
114
|
pool.sample(random: @random)
|
|
69
115
|
end
|
|
70
116
|
|
|
71
|
-
def pick_entity_known(
|
|
72
|
-
pool = @entity_known_ids[
|
|
117
|
+
def pick_entity_known(key)
|
|
118
|
+
pool = @entity_known_ids[key]
|
|
73
119
|
return ValueGenerator.random_id(@random) if pool.empty? || @random.rand < ValueGenerator::INVALID_REFERENCE_PROBABILITY
|
|
74
120
|
|
|
75
121
|
pool.sample(random: @random)
|
|
@@ -16,10 +16,21 @@ module Hecks
|
|
|
16
16
|
|
|
17
17
|
makers = catalog[:creating].select { |entry| satisfiable?(catalog, entry) }
|
|
18
18
|
pool = rest + makers.flat_map { |entry| [entry] * creating_weight(rest.size) }
|
|
19
|
+
pool += deep_entity_bias(rest) if adversarial?
|
|
19
20
|
|
|
20
21
|
steer(pool).sample(random: @random)
|
|
21
22
|
end
|
|
22
23
|
|
|
24
|
+
# BUG#11's own preference (adversary.rb) — an entity command two
|
|
25
|
+
# or more hops deep is weighted up the same way an unexercised
|
|
26
|
+
# verb is, ONLY in adversarial mode: the eligibility rules above
|
|
27
|
+
# still decide what is possible, and a default-mode pool is
|
|
28
|
+
# exactly the pool it always was.
|
|
29
|
+
def deep_entity_bias(rest)
|
|
30
|
+
deep = rest.select { |entry| (entry[:chain] || []).size >= Adversary::DEEP_ENTITY_DEPTH }
|
|
31
|
+
deep * Adversary::DEEP_ENTITY_WEIGHT
|
|
32
|
+
end
|
|
33
|
+
|
|
23
34
|
# WHILE THERE IS NOTHING TO FIND, MAKING SOMETHING IS THE ONLY USEFUL MOVE.
|
|
24
35
|
#
|
|
25
36
|
# A flat weight is right once the domain has records in it, and badly
|
|
@@ -40,16 +40,66 @@ module Hecks
|
|
|
40
40
|
{ "query" => entry[:verb], "args" => args }
|
|
41
41
|
end
|
|
42
42
|
|
|
43
|
+
# THE ADVERSARIAL LAYER SITS HERE, AND ONLY HERE — after the step's
|
|
44
|
+
# arguments and identity are fully built, BEFORE the one real
|
|
45
|
+
# dispatch this generator makes to learn what the step did, and
|
|
46
|
+
# before the step is returned as corpus data. That ordering is
|
|
47
|
+
# the whole contract: the mutated `args` are what this generator's
|
|
48
|
+
# own inline dispatch sees (so `known_ids` tracking reflects what
|
|
49
|
+
# actually happened), AND they are the bytes `Fuzzing::Replay`
|
|
50
|
+
# later hands Ruby's runtime and the bytes `JSON.generate({steps:
|
|
51
|
+
# ...})` hands the compiled Rust binary — one step, one payload,
|
|
52
|
+
# both engines. Nothing downstream of this method can tell a
|
|
53
|
+
# mutated step from an ordinary one except by reading the
|
|
54
|
+
# `"adversarial"` metadata it carries (see adversary.rb).
|
|
55
|
+
#
|
|
56
|
+
# TWO MORE DRAWS SIT RIGHT AFTER THE MUTATION, IN A FIXED ORDER —
|
|
57
|
+
# a caller (`caller_draw!`, adversary.rb: `role:`/`actor_id:` on
|
|
58
|
+
# the step, bound around this one dispatch exactly the way
|
|
59
|
+
# `Fuzzing::Replay` and `kernel/cli.rs` will later bind it) and
|
|
60
|
+
# then the dry-run coin (`{"dry_run": verb}` instead of
|
|
61
|
+
# `{"verb": verb}` — `Dispatcher#dry_run?` here, the same door on
|
|
62
|
+
# both replay sides). Order matters for the seed contract: every
|
|
63
|
+
# draw comes from the one `Random.new(seed)`, so the sequence of
|
|
64
|
+
# draws per step is what makes a seed reproducible. Both are
|
|
65
|
+
# off by default and draw nothing when off.
|
|
43
66
|
def build_command_step(runtime, catalog, entry)
|
|
44
67
|
args = args_for(entry[:command].attributes, entry[:aggregate])
|
|
45
68
|
add_identity!(args, entry)
|
|
69
|
+
steer_grant!(args, entry, catalog)
|
|
70
|
+
mutations = adversarial_mutations!(args, entry, catalog)
|
|
71
|
+
caller, caller_note = caller_draw!(entry, catalog)
|
|
72
|
+
mutations << caller_note if caller_note
|
|
46
73
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
74
|
+
step =
|
|
75
|
+
if dry_run_draw?
|
|
76
|
+
safe_call { as_caller(caller) { runtime.dry_run?(entry[:verb], **symbolize(args)) } }
|
|
77
|
+
{ "dry_run" => entry[:verb], "args" => args }
|
|
78
|
+
else
|
|
79
|
+
outcome = safe_call { as_caller(caller) { runtime.dispatch(entry[:verb], **symbolize(args)) } }
|
|
80
|
+
if outcome
|
|
81
|
+
record_outcome(catalog, entry, args)
|
|
82
|
+
@event_count += outcome.events.length
|
|
83
|
+
end
|
|
84
|
+
{ "verb" => entry[:verb], "args" => args }
|
|
85
|
+
end
|
|
86
|
+
step.merge!(caller) if caller
|
|
87
|
+
step["adversarial"] = mutations unless mutations.empty?
|
|
88
|
+
step
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# No RNG draw at all when the fraction is zero — the same "off is
|
|
92
|
+
# byte-identical" contract `adversarial?` keeps.
|
|
93
|
+
def dry_run_draw? = @dry_run.positive? && @random.rand < @dry_run
|
|
94
|
+
|
|
95
|
+
# `Hecks.as_caller` for exactly this block, or a bare yield — the
|
|
96
|
+
# SAME binding `Fuzzing::Replay` makes from the step's own keys
|
|
97
|
+
# later, so the generator's inline dispatch and both replays see
|
|
98
|
+
# one caller.
|
|
99
|
+
def as_caller(caller, &)
|
|
100
|
+
return yield unless caller
|
|
101
|
+
|
|
102
|
+
Hecks.as_caller(role: caller["role"], actor_id: caller["actor_id"], &)
|
|
53
103
|
end
|
|
54
104
|
|
|
55
105
|
def args_for(attributes, aggregate)
|
|
@@ -154,9 +204,23 @@ module Hecks
|
|
|
154
204
|
if entry[:entity]
|
|
155
205
|
parent_scalar = pick_known(aggregate.hecks_name)
|
|
156
206
|
args[parent_key] = identity_shaped(aggregate, aggregate.identified_by, parent_scalar, aggregate)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
207
|
+
# ONE IDENTITY PER HOP, each drawn from the pool its OWN
|
|
208
|
+
# parent-plus-hops landed elements in (`entity_pool_key`) —
|
|
209
|
+
# a depth-1 chain draws exactly what it always did; a
|
|
210
|
+
# `Board.Card` chain draws a Board under this Workspace, then
|
|
211
|
+
# a Card under THAT Board. Flat args, one head per hop, is
|
|
212
|
+
# the legacy addressing `EntityElement#locate_chain` reads
|
|
213
|
+
# (`args[head]` per identity path); the routed `to: {
|
|
214
|
+
# aggregate:, entities: [...] }` spelling is an adversarial
|
|
215
|
+
# shape layered on top (adversary.rb), never the default.
|
|
216
|
+
scalars = [parent_scalar]
|
|
217
|
+
names = []
|
|
218
|
+
entry[:chain].each do |piece|
|
|
219
|
+
names << piece.hecks_name
|
|
220
|
+
scalar = pick_entity_known(entity_pool_key(aggregate.hecks_name, names, scalars))
|
|
221
|
+
args[(piece.identified_by || :id).to_s] = identity_shaped(piece, piece.identified_by, scalar, aggregate)
|
|
222
|
+
scalars << scalar
|
|
223
|
+
end
|
|
160
224
|
elsif entry[:command].creates?
|
|
161
225
|
# A COMPOSITE IDENTITY (`identified_by` answering nil with MORE
|
|
162
226
|
# THAN ONE declared path — Behaviour::Identified's own "a
|
|
@@ -7,6 +7,7 @@ require_relative "sequence_generator/catalog"
|
|
|
7
7
|
require_relative "sequence_generator/picker"
|
|
8
8
|
require_relative "sequence_generator/step_builder"
|
|
9
9
|
require_relative "sequence_generator/outcome_tracker"
|
|
10
|
+
require_relative "sequence_generator/adversary"
|
|
10
11
|
|
|
11
12
|
module Hecks
|
|
12
13
|
module Fuzzing
|
|
@@ -28,13 +29,16 @@ module Hecks
|
|
|
28
29
|
#
|
|
29
30
|
# One concern per file beside this one: what the domain offers
|
|
30
31
|
# (sequence_generator/catalog.rb), which step to try next (picker.rb),
|
|
31
|
-
# how a step is built and dispatched (step_builder.rb),
|
|
32
|
-
# success taught us (outcome_tracker.rb)
|
|
32
|
+
# how a step is built and dispatched (step_builder.rb), what a
|
|
33
|
+
# success taught us (outcome_tracker.rb), and — opt-in, `adversarial:`
|
|
34
|
+
# — the argument shapes real Ruby/Rust divergences were found through
|
|
35
|
+
# (adversary.rb).
|
|
33
36
|
class SequenceGenerator
|
|
34
37
|
include Catalog
|
|
35
38
|
include Picker
|
|
36
39
|
include StepBuilder
|
|
37
40
|
include OutcomeTracker
|
|
41
|
+
include Adversary
|
|
38
42
|
|
|
39
43
|
# Creating commands are always eligible ; weighting them heavier (not
|
|
40
44
|
# exclusively — a domain with only one or two aggregates would starve
|
|
@@ -72,8 +76,22 @@ module Hecks
|
|
|
72
76
|
# the generating end rather than the scoring end.
|
|
73
77
|
UNEXERCISED_WEIGHT = 4
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
# `adversarial:` — the fraction of generated COMMAND steps (0.0..1.0)
|
|
80
|
+
# that get one deliberately adversarial argument mutation
|
|
81
|
+
# (adversary.rb — the shapes BUG#7–#16 were found through). `0.0`,
|
|
82
|
+
# the default, draws nothing extra from the seeded RNG, so a seed's
|
|
83
|
+
# output is byte-for-byte what it was before the option existed;
|
|
84
|
+
# any positive value is just as deterministic per seed, since every
|
|
85
|
+
# choice the mutation makes comes from the same `Random.new(seed)`.
|
|
86
|
+
#
|
|
87
|
+
# `role_draw:` / `dry_run:` — two more opt-in fractions with the
|
|
88
|
+
# identical contract (adversary.rb's `caller_draw!`, step_builder.rb's
|
|
89
|
+
# `dry_run_draw?`): `0.0` draws nothing, so every pinned seed is
|
|
90
|
+
# byte-for-byte what it was; `bin/qa_sweep` reads them from
|
|
91
|
+
# `QualityControlDials::ROLE_DRAW_PROBABILITY`/`DRY_RUN_FRACTION`.
|
|
92
|
+
def self.generate(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0)
|
|
93
|
+
new(domain_path, seed: seed, steps: steps, adapter: adapter, adversarial: adversarial,
|
|
94
|
+
role_draw: role_draw, dry_run: dry_run).call
|
|
77
95
|
end
|
|
78
96
|
|
|
79
97
|
# How many EVENTS the generated sequence actually produced — not
|
|
@@ -86,16 +104,31 @@ module Hecks
|
|
|
86
104
|
# replay one.
|
|
87
105
|
attr_reader :event_count
|
|
88
106
|
|
|
89
|
-
def initialize(domain_path, seed:, steps:, adapter: :memory)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
@
|
|
97
|
-
@
|
|
98
|
-
@
|
|
107
|
+
def initialize(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0)
|
|
108
|
+
{ adversarial: adversarial, role_draw: role_draw, dry_run: dry_run }.each do |name, fraction|
|
|
109
|
+
next if fraction.is_a?(Numeric) && fraction.between?(0, 1)
|
|
110
|
+
|
|
111
|
+
raise ArgumentError, "#{name}: must be a fraction between 0.0 and 1.0, got #{fraction.inspect}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
@domain_path = domain_path
|
|
115
|
+
@seed = seed
|
|
116
|
+
@step_count = steps
|
|
117
|
+
@adapter = adapter
|
|
118
|
+
@adversarial = adversarial.to_f
|
|
119
|
+
@role_draw = role_draw.to_f
|
|
120
|
+
@dry_run = dry_run.to_f
|
|
121
|
+
@random = Random.new(seed)
|
|
122
|
+
@known_ids = Hash.new { |h, k| h[k] = [] }
|
|
123
|
+
@entity_known_ids = Hash.new { |h, k| h[k] = [] }
|
|
124
|
+
@appended_identities = Hash.new { |h, k| h[k] = [] }
|
|
125
|
+
# ROLE => [actor ids] this sequence's own successful
|
|
126
|
+
# `Governance::RoleAssignment.Assign` steps granted — what the
|
|
127
|
+
# `actor_known` caller shape draws from (adversary.rb).
|
|
128
|
+
@granted = Hash.new { |h, k| h[k] = [] }
|
|
129
|
+
@precedence_caller = nil
|
|
130
|
+
@exercised = Set.new
|
|
131
|
+
@event_count = 0
|
|
99
132
|
end
|
|
100
133
|
|
|
101
134
|
def call
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
# WHAT A "NOT GENERATED FOR THIS DOMAIN" REFUSAL IS HIDING, PER VERB.
|
|
4
|
+
#
|
|
5
|
+
# `RustConformanceHelpers#structurally_refused_verbs` drops every named
|
|
6
|
+
# query/read model the compiled Rust kernel answers "is not generated
|
|
7
|
+
# for this domain" from BOTH sides of the differential comparison —
|
|
8
|
+
# correct (the refusal is codegen's own honest "I cannot execute this
|
|
9
|
+
# construct at all", `rust/project/queries.rb`'s and `read_models.rb`'s
|
|
10
|
+
# documented boundary), but SILENT: a sweep over a domain whose every
|
|
11
|
+
# `offset`/`cursor`/`group_by`/`count`/`median` ask was dropped reads
|
|
12
|
+
# as "agreed across all steps" with nothing on the record saying how
|
|
13
|
+
# much of the domain was never compared at all. Worse, the drop keys
|
|
14
|
+
# on Rust's wording alone, so a codegen regression that started
|
|
15
|
+
# refusing a plain wheres-only query the same way would ALSO vanish
|
|
16
|
+
# into "agreed" — the exact quiet divergence the practice hunts.
|
|
17
|
+
#
|
|
18
|
+
# This module attributes each dropped verb to the constructs its OWN
|
|
19
|
+
# Ruby declaration carries, in the vocabulary `queries.rb`/
|
|
20
|
+
# `read_models.rb`'s skip reasons already use, so `bin/qa_sweep` can
|
|
21
|
+
# log one Check per sweep saying exactly which verbs were skipped and
|
|
22
|
+
# WHY — and mark it Surprised whenever a skipped verb declares nothing
|
|
23
|
+
# the dial `QualityControlDials::STRUCTURAL_REFUSAL_BOUNDARY` admits as
|
|
24
|
+
# a reason to skip (a wheres-only query on plain fields, a rooted
|
|
25
|
+
# read model with nothing but heads, a verb Ruby doesn't even declare).
|
|
26
|
+
#
|
|
27
|
+
# COARSER THAN CODEGEN'S OWN PREDICATE, ON PURPOSE. `query_where_skip_
|
|
28
|
+
# reason` decides per where clause from the field's resolved KIND
|
|
29
|
+
# (number/string/multi-member value object) — reproducing that here
|
|
30
|
+
# would be a second copy of codegen's typing rules, drifting on its
|
|
31
|
+
# own. Instead a literal-valued where is reported as `where_literal`,
|
|
32
|
+
# a family the dial admits, and only shapes with NO admitted family at
|
|
33
|
+
# all surprise. That trades some sensitivity for zero false surprises
|
|
34
|
+
# on today's corpus; the Check's observation still names every skipped
|
|
35
|
+
# verb with its constructs, so a human reading the sweep sees what was
|
|
36
|
+
# accepted and can narrow the dial when codegen grows.
|
|
37
|
+
module StructuralSkips
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# `Query#to_h`/`ReadModel#to_h` keys that, when present and
|
|
41
|
+
# non-empty, are constructs codegen documents as (conditionally or
|
|
42
|
+
# wholly) ungenerated — the same names as the dial's own vocabulary.
|
|
43
|
+
QUERY_OPTION_KEYS = %i[order_by limit offset cursor consistency freshness authorization inspection
|
|
44
|
+
index_hints scope_to].freeze
|
|
45
|
+
READ_MODEL_OPTION_KEYS = %i[wheres order_by limit offset cursor consistency freshness authorization inspection
|
|
46
|
+
index_hints group_by count median_field].freeze
|
|
47
|
+
|
|
48
|
+
# One entry per skipped verb: `{ verb:, constructs: [...] }`, the
|
|
49
|
+
# constructs sorted so the printed observation is stable across
|
|
50
|
+
# seeds and runs.
|
|
51
|
+
def attribute(bluebooks, verbs)
|
|
52
|
+
verbs.sort.map { |verb| { verb: verb, constructs: constructs_of(bluebooks, verb) } }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Every skipped verb whose constructs are NOT all inside `boundary`
|
|
56
|
+
# — including a verb with no constructs at all (nothing explains the
|
|
57
|
+
# skip) and a verb Ruby never declared (`unknown`).
|
|
58
|
+
def outside_boundary(attributed, boundary)
|
|
59
|
+
admitted = boundary.map(&:to_s)
|
|
60
|
+
attributed.select { |entry| entry[:constructs].empty? || (entry[:constructs] - admitted).any? }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def constructs_of(bluebooks, verb)
|
|
64
|
+
if verb.include?("::")
|
|
65
|
+
query = find_query(bluebooks, verb)
|
|
66
|
+
return %w[unknown] unless query
|
|
67
|
+
|
|
68
|
+
query_constructs(query)
|
|
69
|
+
else
|
|
70
|
+
model = find_read_model(bluebooks, verb)
|
|
71
|
+
return %w[unknown] unless model
|
|
72
|
+
|
|
73
|
+
read_model_constructs(model)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# "Domain::Aggregate.Query" or "Domain::Aggregate.Entity.Query" — the
|
|
78
|
+
# two named-query spellings `Fuzzing::SequenceGenerator`'s catalog
|
|
79
|
+
# generates (entity queries stay one hop deep there).
|
|
80
|
+
def find_query(bluebooks, verb)
|
|
81
|
+
domain, rest = verb.split("::", 2)
|
|
82
|
+
aggregate_name, *path = rest.to_s.split(".")
|
|
83
|
+
aggregate = bluebooks[domain]&.aggregate(aggregate_name)
|
|
84
|
+
return nil unless aggregate && path.any?
|
|
85
|
+
|
|
86
|
+
owner = aggregate
|
|
87
|
+
path[0...-1].each do |entity_name|
|
|
88
|
+
owner = owner.entities.find { |entity| entity.hecks_name == entity_name }
|
|
89
|
+
return nil unless owner
|
|
90
|
+
end
|
|
91
|
+
owner.query(path.last)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# "Domain.report_name" — the bare read-model form `Dispatcher#query`
|
|
95
|
+
# routes by the absence of "::".
|
|
96
|
+
def find_read_model(bluebooks, verb)
|
|
97
|
+
domain, name = verb.split(".", 2)
|
|
98
|
+
bluebooks[domain]&.read_models&.find { |model| model.query_name.to_s == name.to_s }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def query_constructs(query)
|
|
102
|
+
spec = query.to_h
|
|
103
|
+
constructs = QUERY_OPTION_KEYS.select { |key| present?(spec[key]) }.map(&:to_s)
|
|
104
|
+
constructs << "null_semantics" if spec[:null_semantics] && spec[:null_semantics] != { mode: "native" }
|
|
105
|
+
constructs << "no_wheres" if Array(spec[:wheres]).empty?
|
|
106
|
+
constructs.concat(where_constructs(Array(spec[:wheres])))
|
|
107
|
+
constructs.uniq.sort
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def read_model_constructs(model)
|
|
111
|
+
spec = model.to_h
|
|
112
|
+
constructs = READ_MODEL_OPTION_KEYS.select { |key| present?(spec[key]) }.map(&:to_s)
|
|
113
|
+
constructs << "null_semantics" if spec[:null_semantics] && spec[:null_semantics] != { mode: "native" }
|
|
114
|
+
constructs << "rootless" if model.reference_target.nil?
|
|
115
|
+
constructs.concat(where_constructs(Array(spec[:wheres])))
|
|
116
|
+
constructs.uniq.sort
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The where-clause families `query_where_skip_reason` refuses by:
|
|
120
|
+
# a `/` hop through a reference, a `.` walk into a nested field
|
|
121
|
+
# (an entity-scoped or value-object path codegen may not resolve),
|
|
122
|
+
# `none_in_state` (admitted by the vocabulary, deliberately not
|
|
123
|
+
# generated), and any literal (non-Symbol) value, whose true wire
|
|
124
|
+
# type codegen may not recover from the IR.
|
|
125
|
+
def where_constructs(wheres)
|
|
126
|
+
wheres.flat_map do |where|
|
|
127
|
+
field = where[:field].to_s
|
|
128
|
+
value = where[:value].to_s
|
|
129
|
+
families = []
|
|
130
|
+
families << "reference_hop_where" if field.include?("/")
|
|
131
|
+
families << "where_nested_field" if field.include?(".")
|
|
132
|
+
families << "where_none_in_state" if where[:op].to_s == "none_in_state"
|
|
133
|
+
families << "where_literal" unless value.start_with?(":")
|
|
134
|
+
families
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def present?(value)
|
|
139
|
+
return false if value.nil? || value == false
|
|
140
|
+
return value.any? if value.respond_to?(:empty?)
|
|
141
|
+
|
|
142
|
+
true
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
# HOW HARD ONE SWEEP FUZZES, AS A FUNCTION OF `Target.clean_streak`
|
|
4
|
+
# (qa/bluebook/quality_control.bluebook — that attribute's own comment
|
|
5
|
+
# says what the streak means). The sibling of `RotationPriority`: the
|
|
6
|
+
# ledger holds the number, `QualityControlDials::WIDENING_TIERS` holds
|
|
7
|
+
# the policy, and this module is where the two meet — a pure function
|
|
8
|
+
# of both, nothing else read.
|
|
9
|
+
#
|
|
10
|
+
# WHY THIS IS NOT IN `bin/qa_sweep` ANY MORE. It was — `WIDENING_TIERS`
|
|
11
|
+
# and `widen_for_streak` lived at the top of that script, which meant
|
|
12
|
+
# policy data lived in a script (unlike every other dial, which lives
|
|
13
|
+
# in the bluebook a human edits and reviews) and duplicated itself as
|
|
14
|
+
# prose in SKILL.md. Now the table is a dial, this is the one reader,
|
|
15
|
+
# and `bin/qa_sweep` calls it the way it already calls
|
|
16
|
+
# `RotationPriority.pick`.
|
|
17
|
+
#
|
|
18
|
+
# PURE, DELIBERATELY — the same discipline `RotationPriority` keeps:
|
|
19
|
+
# same streak in, same `[seeds, steps]` out, every time, which is what
|
|
20
|
+
# lets a human predict what a given sweep is about to do before it
|
|
21
|
+
# runs one. `tiers:` defaults to the dial but is a plain argument, so
|
|
22
|
+
# a unit spec can pass its own table with no ledger boot at all and a
|
|
23
|
+
# `bin/qa_sweep` run against a ledger that declares no dials (an
|
|
24
|
+
# isolated spec's own fixture) can fall back the same way it already
|
|
25
|
+
# does for `ADVERSARIAL_FRACTION`.
|
|
26
|
+
module SweepDepth
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# THE FALLBACK TABLE, for a boot with no `QualityControlDials` at all
|
|
30
|
+
# — the same shape and the same three rows the dial ships with, so a
|
|
31
|
+
# dial-less ledger fuzzes exactly as a dialled one does by default.
|
|
32
|
+
# Never read when the dial exists; `bin/qa_sweep` passes the dial in.
|
|
33
|
+
DEFAULT_TIERS = [
|
|
34
|
+
{ upto: 4, seeds: 10, steps: 25 },
|
|
35
|
+
{ upto: 19, seeds: 25, steps: 50 },
|
|
36
|
+
{ upto: Float::INFINITY, seeds: 50, steps: 100 }
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# `[seeds, steps]` for one streak. The first row whose `upto` the
|
|
40
|
+
# streak does not exceed wins — rows are read in order, so the table
|
|
41
|
+
# must be ascending, and the last row's `Float::INFINITY` is what
|
|
42
|
+
# makes it the ceiling rather than a gap.
|
|
43
|
+
def for_streak(streak, tiers: DEFAULT_TIERS)
|
|
44
|
+
raise ArgumentError, "streak must not be negative" if streak.negative?
|
|
45
|
+
|
|
46
|
+
tier = tiers.find { |row| streak <= row[:upto] }
|
|
47
|
+
raise ArgumentError, "no tier covers a streak of #{streak} — the last row must be upto: Float::INFINITY" unless tier
|
|
48
|
+
|
|
49
|
+
[tier[:seeds], tier[:steps]]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Fuzzing
|
|
3
|
+
# WHAT A SWEEP TARGET CAN ACTUALLY BE CHECKED FOR, read off the
|
|
4
|
+
# filesystem — never off a stored list.
|
|
5
|
+
#
|
|
6
|
+
# `bin/qa_sweep` used to decide its ONE comparison mode inline: "is
|
|
7
|
+
# there a Cargo feature named after this directory? then
|
|
8
|
+
# `:differential`, else `:ruby_only`", and a separate hand-typed abort
|
|
9
|
+
# for `--persistence-parity` ("does any .hecksagon bind PostgresEra?").
|
|
10
|
+
# Every further mode the practice adds (era boundary, concurrency, a
|
|
11
|
+
# WASM front) would have grown one more inline `if`, each one a
|
|
12
|
+
# policy decision hiding in a script. This module is those decisions
|
|
13
|
+
# as DATA: `MODE_REQUIREMENTS` says which capabilities each mode
|
|
14
|
+
# needs, `infer` says which capabilities a target's own directory
|
|
15
|
+
# actually has, and `resolve` is the one rule that joins them —
|
|
16
|
+
# `modes_to_run = enabled ∩ eligible`.
|
|
17
|
+
#
|
|
18
|
+
# INFERENCE DECIDES; A STORED LIST ONLY RECORDS. `Target.capabilities`
|
|
19
|
+
# (qa/bluebook/quality_control.bluebook, once PR-1's era lands) is
|
|
20
|
+
# written by the runner from exactly this inference at release time
|
|
21
|
+
# so `Target.EligibleFor(mode)` can AUDIT the rotation from the
|
|
22
|
+
# ledger alone — but the runner re-infers every sweep, because a
|
|
23
|
+
# stored list that lags yesterday's Cargo feature is precisely the
|
|
24
|
+
# "quiet divergence" (the chapter's own opening comment) this whole
|
|
25
|
+
# practice exists to hunt. Nothing here ever reads the ledger.
|
|
26
|
+
#
|
|
27
|
+
# EVERY REGEX IS ONE THE HARNESS ALREADY OWNED, moved here rather than
|
|
28
|
+
# re-derived, and each one's provenance is named beside it so a
|
|
29
|
+
# future edit to the original site is a visible drift, not a silent
|
|
30
|
+
# one: the Cargo feature line (`RustConformanceHelpers#build_rust_for`),
|
|
31
|
+
# the PostgresEra binding (`bin/qa_sweep`'s old `POSTGRES_ERA_BINDING`),
|
|
32
|
+
# the translations glob (`IsolatedBoot#strip_translations!`).
|
|
33
|
+
module TargetCapabilities
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# `RustConformanceHelpers#build_rust_for`'s own test, scoped to the
|
|
37
|
+
# `[features]` table the way `bin/project_rust`'s Cargo sync scopes
|
|
38
|
+
# its own lookup (a `[package] name = "rust"` line must never read
|
|
39
|
+
# as a feature named `rust`).
|
|
40
|
+
FEATURES_TABLE = /^\[features\](?:\n(?!\[).*)*$/
|
|
41
|
+
|
|
42
|
+
# `bin/qa_sweep`'s former `POSTGRES_ERA_BINDING` — both spellings
|
|
43
|
+
# `IsolatedBoot#rewrite_bindings!` has to catch: aggregate-scoped
|
|
44
|
+
# (`Directory::Member.persisted_by("PostgresEra")`) and the bare
|
|
45
|
+
# domain-level default (`persisted_by "PostgresEra"`).
|
|
46
|
+
POSTGRES_ERA_BINDING = /persisted_by\s*\(?\s*"PostgresEra"/
|
|
47
|
+
|
|
48
|
+
# `HecksagonBuilder#uses_framework` — the one line that attaches
|
|
49
|
+
# Governance's own `RoleAssignment` lookup to a domain's `role` checks
|
|
50
|
+
# (`CommandRules::Authorization#governance_attached?`).
|
|
51
|
+
GOVERNANCE_ATTACHED = /uses_framework\s*\(?\s*"Governance"/
|
|
52
|
+
|
|
53
|
+
# A command-level `role "..."` — the only construct
|
|
54
|
+
# `refuse_role_mismatch` ever has anything to check a caller against.
|
|
55
|
+
ROLE_GATED = /^\s*role\s+"/
|
|
56
|
+
|
|
57
|
+
# `authorize :vault_access, tenant: :branch_code`
|
|
58
|
+
# (examples/banking/bluebook/safe_deposit_boxes.bluebook) — the one
|
|
59
|
+
# `tenant:` spelling the corpus has.
|
|
60
|
+
TENANT_SCOPED = /\btenant:/
|
|
61
|
+
|
|
62
|
+
PROCESS_MANAGER = /^\s*process_manager\s+"/
|
|
63
|
+
|
|
64
|
+
# WHICH CAPABILITIES EACH MODE NEEDS BEFORE IT CAN SAY ANYTHING TRUE
|
|
65
|
+
# ABOUT A TARGET. An empty list means "any target at all" — every
|
|
66
|
+
# domain boots under Memory, so Ruby-only properties and the
|
|
67
|
+
# self-consistency pass are always answerable. `ruby_only` is listed
|
|
68
|
+
# requirement-free on purpose and then EXCLUDED by `resolve` whenever
|
|
69
|
+
# `differential` resolved too: they are the same seat, and a compiled
|
|
70
|
+
# Rust binary is strictly the better occupant (item 1 of the
|
|
71
|
+
# detection plan folded the Ruby-only property battery INTO the
|
|
72
|
+
# differential seat, so nothing is lost by the exclusion).
|
|
73
|
+
#
|
|
74
|
+
# The four `false`-by-default modes in `QualityControlDials::MODES`
|
|
75
|
+
# (`adapter_parity_postgres`, `era_boundary`, `concurrency`,
|
|
76
|
+
# `wasm_front`) are named here with their requirements even though
|
|
77
|
+
# nothing runs them yet — so `resolved modes:` can already say, per
|
|
78
|
+
# target, which of them WOULD be eligible the day a human flips the
|
|
79
|
+
# dial, and so flipping it is a one-line data change rather than a
|
|
80
|
+
# code change plus a data change.
|
|
81
|
+
MODE_REQUIREMENTS = {
|
|
82
|
+
differential: %w[rust],
|
|
83
|
+
ruby_only: [],
|
|
84
|
+
self_consistency: [],
|
|
85
|
+
properties_in_differential: %w[rust],
|
|
86
|
+
structural_skip_report: %w[rust],
|
|
87
|
+
adapter_parity_sqlite: %w[sqlite],
|
|
88
|
+
persistence_parity: %w[postgres_era],
|
|
89
|
+
adapter_parity_postgres: %w[postgres_era],
|
|
90
|
+
era_boundary: %w[translations postgres_era],
|
|
91
|
+
concurrency: %w[postgres_era],
|
|
92
|
+
wasm_front: %w[rust]
|
|
93
|
+
}.freeze
|
|
94
|
+
|
|
95
|
+
# MODES THAT NAME A SEPARATE, EXPENSIVE PASS OF THEIR OWN rather than
|
|
96
|
+
# an extra check folded into the ordinary per-seed loop —
|
|
97
|
+
# `bin/qa_sweep` runs these only when asked by name (`--modes
|
|
98
|
+
# persistence_parity`, or its older alias `--persistence-parity`) or
|
|
99
|
+
# as `--all`'s own second wave, never silently inside a plain
|
|
100
|
+
# single-target sweep (the seed cap `PERSISTENCE_PARITY_SEED_CAP`
|
|
101
|
+
# exists because that pass pays for real Postgres I/O per dispatch).
|
|
102
|
+
DEFERRED_MODES = %i[persistence_parity adapter_parity_postgres era_boundary concurrency].freeze
|
|
103
|
+
|
|
104
|
+
# Sorted, plain strings — comma-joined by the runner into the
|
|
105
|
+
# `Target.Release(capabilities:)` value object and printed verbatim
|
|
106
|
+
# on the `resolved modes:` line, so the same spelling is what a
|
|
107
|
+
# human reads, what `--all` parses back, and what the ledger stores.
|
|
108
|
+
def infer(domain_path, rust_dir: File.expand_path("../../../rust", __dir__))
|
|
109
|
+
capabilities = %w[sqlite]
|
|
110
|
+
capabilities << "rust" if rust_feature?(domain_path, rust_dir)
|
|
111
|
+
capabilities << "postgres_era" if any_file?(domain_path, "*.hecksagon", POSTGRES_ERA_BINDING)
|
|
112
|
+
capabilities << "translations" if Dir.glob(File.join(domain_path, "**", "translations", "*.bluebook")).any?
|
|
113
|
+
capabilities << "governance" if any_file?(domain_path, "*.hecksagon", GOVERNANCE_ATTACHED)
|
|
114
|
+
capabilities << "role_gated" if any_file?(domain_path, "*.bluebook", ROLE_GATED)
|
|
115
|
+
capabilities << "tenant" if any_file?(domain_path, "*.bluebook", TENANT_SCOPED)
|
|
116
|
+
capabilities << "sagas" if any_file?(domain_path, "*.bluebook", PROCESS_MANAGER)
|
|
117
|
+
capabilities.sort
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def eligible?(mode, capabilities)
|
|
121
|
+
required = MODE_REQUIREMENTS.fetch(mode.to_sym) { raise ArgumentError, "unknown sweep mode #{mode.inspect}" }
|
|
122
|
+
(required - capabilities).empty?
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# THE ONE RULE. `enabled` is whatever the dial (or `--modes`) turned
|
|
126
|
+
# on, in the dial's own declaration order — that order is preserved
|
|
127
|
+
# so the printed line reads the same way the dial does. Then the
|
|
128
|
+
# single exclusion named on `MODE_REQUIREMENTS`.
|
|
129
|
+
def resolve(enabled, capabilities)
|
|
130
|
+
resolved = enabled.map(&:to_sym).select { |mode| eligible?(mode, capabilities) }
|
|
131
|
+
resolved.delete(:ruby_only) if resolved.include?(:differential)
|
|
132
|
+
resolved
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def rust_feature?(domain_path, rust_dir)
|
|
136
|
+
cargo_toml = File.join(rust_dir, "Cargo.toml")
|
|
137
|
+
return false unless File.file?(cargo_toml)
|
|
138
|
+
|
|
139
|
+
feature = File.basename(domain_path).downcase
|
|
140
|
+
features = File.read(cargo_toml)[FEATURES_TABLE] || ""
|
|
141
|
+
features.match?(/^#{Regexp.escape(feature)}\s*=\s*\[\]/)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def any_file?(domain_path, glob, pattern)
|
|
145
|
+
Dir.glob(File.join(domain_path, "**", glob)).any? { |path| File.read(path).match?(pattern) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|