hecks 1.3.0 → 1.5.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/claude_code.rb +72 -7
- data/lib/hecks/adapters/driven/d1.rb +187 -23
- data/lib/hecks/adapters/driven/folder.rb +83 -10
- data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
- data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
- data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
- data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
- data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
- data/lib/hecks/adapters/driven/heki.rb +106 -9
- data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
- data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
- data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
- data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
- data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
- data/lib/hecks/adapters/driven/lambda.rb +82 -35
- data/lib/hecks/adapters/driven/local_storage.rb +83 -10
- data/lib/hecks/adapters/driven/memory.rb +205 -9
- data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
- data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
- data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
- data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
- data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
- data/lib/hecks/adapters/driven/postgres.rb +175 -28
- data/lib/hecks/adapters/driven/prism.rb +50 -11
- data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
- data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
- data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
- data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
- data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
- data/lib/hecks/adapters/driven/sqlite.rb +181 -21
- data/lib/hecks/adapters/driven/system_clock.rb +3 -0
- data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
- data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
- data/lib/hecks/adapters/driven.rb +6 -4
- data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
- data/lib/hecks/behaviors/dsl.rb +60 -2
- data/lib/hecks/behaviors/expectations.rb +190 -29
- data/lib/hecks/behaviors/ir.rb +12 -1
- data/lib/hecks/behaviors/rspec.rb +9 -1
- data/lib/hecks/behaviors/runner.rb +21 -2
- data/lib/hecks/behaviors.rb +9 -1
- data/lib/hecks/bluebook/aggregate.rb +43 -13
- data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
- data/lib/hecks/bluebook/assembly/build.rb +26 -5
- data/lib/hecks/bluebook/assembly/contract.rb +98 -23
- data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
- data/lib/hecks/bluebook/assembly/marks.rb +159 -30
- data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
- data/lib/hecks/bluebook/assembly.rb +32 -14
- data/lib/hecks/bluebook/attribute.rb +26 -12
- data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
- data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
- data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
- data/lib/hecks/bluebook/behaviour/command.rb +55 -25
- data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
- data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
- data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
- data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
- data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
- data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
- data/lib/hecks/bluebook/behaviour/query.rb +6 -1
- data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
- data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
- data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
- data/lib/hecks/bluebook/capabilities.rb +27 -0
- data/lib/hecks/bluebook/chapter.rb +51 -9
- data/lib/hecks/bluebook/command.rb +62 -17
- data/lib/hecks/bluebook/domain_port.rb +34 -9
- data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
- data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
- data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
- data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
- data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
- data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
- data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
- data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
- data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
- data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
- data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
- data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
- data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
- data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
- data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
- data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
- data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
- data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
- data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
- data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
- data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
- data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
- data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
- data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
- data/lib/hecks/bluebook/entity.rb +40 -11
- data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
- data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
- data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
- data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
- data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
- data/lib/hecks/bluebook/expression/resolver.rb +369 -128
- data/lib/hecks/bluebook/hexagon.rb +35 -1
- data/lib/hecks/bluebook/lifecycle.rb +12 -1
- data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
- data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
- data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
- data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
- data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
- data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
- data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
- data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
- data/lib/hecks/bluebook/meta_validator.rb +235 -139
- data/lib/hecks/bluebook/model_check.rb +434 -104
- data/lib/hecks/bluebook/pattern_subset.rb +32 -10
- data/lib/hecks/bluebook/policy.rb +15 -13
- data/lib/hecks/bluebook/process_manager.rb +27 -14
- data/lib/hecks/bluebook/project_discovery.rb +5 -0
- data/lib/hecks/bluebook/project_loader.rb +40 -0
- data/lib/hecks/bluebook/project_register.rb +50 -6
- data/lib/hecks/bluebook/query.rb +31 -4
- data/lib/hecks/bluebook/read_model.rb +35 -15
- data/lib/hecks/bluebook/reference.rb +26 -13
- data/lib/hecks/bluebook/smoke_test.rb +46 -23
- data/lib/hecks/bluebook/synthesizer.rb +46 -12
- data/lib/hecks/bluebook/translation.rb +34 -5
- data/lib/hecks/bluebook/value_object.rb +29 -11
- data/lib/hecks/bluebook.rb +5 -6
- data/lib/hecks/codemod.rb +138 -50
- data/lib/hecks/construct.rb +21 -7
- data/lib/hecks/corpus.rb +438 -0
- data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
- data/lib/hecks/doc/reference.rb +200 -31
- data/lib/hecks/embryonaut_bluebook.rb +38 -15
- data/lib/hecks/facade/cli_door.rb +69 -10
- data/lib/hecks/facade/cli_runner.rb +105 -24
- data/lib/hecks/facade/command_request.rb +23 -0
- data/lib/hecks/facade/handle.rb +155 -35
- data/lib/hecks/facade/json_door.rb +106 -25
- data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
- data/lib/hecks/facade/surface/chapter.rb +26 -17
- data/lib/hecks/facade/surface.rb +16 -3
- data/lib/hecks/facade.rb +15 -4
- data/lib/hecks/forms/app.rb +46 -30
- data/lib/hecks/forms/command_form_renderer.rb +70 -9
- data/lib/hecks/forms/field_renderer.rb +142 -6
- data/lib/hecks/forms/field_shape.rb +183 -19
- data/lib/hecks/forms/html.rb +51 -7
- data/lib/hecks/forms/index_renderer.rb +14 -2
- data/lib/hecks/forms/page.rb +14 -0
- data/lib/hecks/forms/params.rb +120 -23
- data/lib/hecks/forms/port_argument.rb +14 -2
- data/lib/hecks/forms/query_form_renderer.rb +65 -2
- data/lib/hecks/forms/record_renderer.rb +60 -2
- data/lib/hecks/forms/record_table.rb +28 -1
- data/lib/hecks/forms/reference_options.rb +24 -0
- data/lib/hecks/forms/value_object_shape.rb +13 -3
- data/lib/hecks/forms.rb +24 -4
- data/lib/hecks/fqn.rb +59 -1
- data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
- data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
- data/lib/hecks/framework/oidc.json +15 -0
- data/lib/hecks/framework.rb +79 -25
- data/lib/hecks/freezer.rb +27 -11
- data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
- data/lib/hecks/fuzzing/combination_miner.rb +178 -0
- data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
- data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
- data/lib/hecks/fuzzing/differential.rb +192 -0
- data/lib/hecks/fuzzing/domain_generator.rb +871 -0
- data/lib/hecks/fuzzing/era_boundary.rb +68 -19
- data/lib/hecks/fuzzing/form_census.rb +121 -20
- data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
- data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
- data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
- data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
- data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
- data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
- data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
- data/lib/hecks/fuzzing/properties/guards.rb +86 -42
- data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
- data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
- data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
- data/lib/hecks/fuzzing/properties/querying.rb +82 -28
- data/lib/hecks/fuzzing/properties.rb +84 -51
- data/lib/hecks/fuzzing/qa_settings.rb +164 -0
- data/lib/hecks/fuzzing/replay.rb +241 -133
- data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
- data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
- data/lib/hecks/fuzzing/self_consistency.rb +315 -137
- data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
- data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
- data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
- data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
- data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
- data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
- data/lib/hecks/fuzzing/shrinker.rb +309 -0
- data/lib/hecks/fuzzing/structural_skips.rb +37 -130
- data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
- data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
- data/lib/hecks/fuzzing/value_generator.rb +110 -22
- data/lib/hecks/fuzzing.rb +5 -0
- data/lib/hecks/grammar/evolve.rb +188 -12
- data/lib/hecks/grammar.rb +53 -7
- data/lib/hecks/ir.rb +51 -20
- data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
- data/lib/hecks/language/bluebook/policy.bluebook +11 -1
- data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
- data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
- data/lib/hecks/language/oidc.json +5 -0
- data/lib/hecks/literal.rb +41 -9
- data/lib/hecks/naming.rb +112 -31
- data/lib/hecks/ports/access_control.rb +53 -2
- data/lib/hecks/ports/agent/answers.rb +83 -6
- data/lib/hecks/ports/agent.rb +119 -35
- data/lib/hecks/ports/authentication.rb +44 -4
- data/lib/hecks/ports/authorization.rb +50 -11
- data/lib/hecks/ports/clock.rb +42 -23
- data/lib/hecks/ports/extraction.rb +16 -0
- data/lib/hecks/ports/identity_assignment.rb +23 -2
- data/lib/hecks/ports/identity_generation.rb +17 -3
- data/lib/hecks/ports/identity_resolution.rb +17 -1
- data/lib/hecks/ports/key_vault.port +6 -0
- data/lib/hecks/ports/key_vault.rb +58 -0
- data/lib/hecks/ports/loading.rb +4 -0
- data/lib/hecks/ports/persistence/append_only.rb +172 -8
- data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
- data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
- data/lib/hecks/ports/persistence/execution.rb +4 -0
- data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
- data/lib/hecks/ports/persistence/plugin.rb +42 -4
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
- data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
- data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
- data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
- data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
- data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
- data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
- data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
- data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
- data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
- data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
- data/lib/hecks/ports/persistence/state_codec.rb +319 -0
- data/lib/hecks/ports/persistence.rb +36 -1
- data/lib/hecks/ports/projection.rb +61 -7
- data/lib/hecks/ports/query/in_memory.rb +41 -8
- data/lib/hecks/ports/query/ordering.rb +21 -6
- data/lib/hecks/ports/query.rb +35 -0
- data/lib/hecks/ports.rb +1 -0
- data/lib/hecks/projections/bootstrap_table.rb +147 -0
- data/lib/hecks/projections/diagrams.rb +315 -79
- data/lib/hecks/projections/glossary/html.rb +91 -3
- data/lib/hecks/projections/glossary/markdown.rb +50 -3
- data/lib/hecks/projections/glossary/mermaid.rb +49 -1
- data/lib/hecks/projections/glossary/sections.rb +1 -1
- data/lib/hecks/projections/glossary/sentences.rb +110 -5
- data/lib/hecks/projections/glossary.rb +173 -31
- data/lib/hecks/projections/ir.rb +1 -1
- data/lib/hecks/projections/model/deviations.rb +62 -17
- data/lib/hecks/projections/model.rb +75 -21
- data/lib/hecks/projections/oidc.rb +45 -8
- data/lib/hecks/projections/parser_table.rb +53 -12
- data/lib/hecks/projections/reference.rb +15 -3
- data/lib/hecks/projections/rust_vocabulary.rb +646 -0
- data/lib/hecks/projections/shape.rb +10 -2
- data/lib/hecks/projections/statements.rb +69 -22
- data/lib/hecks/projections/vocabulary.rb +26 -9
- data/lib/hecks/projections.rb +5 -3
- data/lib/hecks/projector/cli_projector.rb +245 -37
- data/lib/hecks/projector/docs_projector.rb +154 -28
- data/lib/hecks/projector/exporter.rb +104 -29
- data/lib/hecks/projector/ir_projector.rb +7 -1
- data/lib/hecks/projector/narrate_projector.rb +144 -23
- data/lib/hecks/projector/target.rb +42 -18
- data/lib/hecks/projector.rb +86 -18
- data/lib/hecks/query_ir.rb +94 -47
- data/lib/hecks/query_specification/common/comparators.rb +19 -3
- data/lib/hecks/query_specification/common/comparison.rb +109 -27
- data/lib/hecks/query_specification/common/dsl.rb +65 -9
- data/lib/hecks/query_specification/common/null_policy.rb +61 -15
- data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
- data/lib/hecks/query_specification/common/options.rb +25 -0
- data/lib/hecks/query_specification/field_path.rb +69 -15
- data/lib/hecks/query_specification/hop_path.rb +57 -20
- data/lib/hecks/query_specification/read_model/specification.rb +4 -0
- data/lib/hecks/rendering.rb +9 -3
- data/lib/hecks/router/namespace_installer.rb +16 -3
- data/lib/hecks/router.rb +56 -1
- data/lib/hecks/runtime/aggregate_lock.rb +20 -11
- data/lib/hecks/runtime/boot_gates.rb +21 -3
- data/lib/hecks/runtime/caller.rb +40 -8
- data/lib/hecks/runtime/capability_graph.rb +13 -2
- data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
- data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
- data/lib/hecks/runtime/command_interpreter.rb +195 -113
- data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
- data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
- data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
- data/lib/hecks/runtime/command_rules/emission.rb +18 -1
- data/lib/hecks/runtime/command_rules/references.rb +145 -55
- data/lib/hecks/runtime/command_rules.rb +3 -0
- data/lib/hecks/runtime/dependency_planning.rb +56 -11
- data/lib/hecks/runtime/dispatcher.rb +263 -92
- data/lib/hecks/runtime/entity_element.rb +304 -67
- data/lib/hecks/runtime/entity_interpreter.rb +149 -88
- data/lib/hecks/runtime/errors.rb +37 -23
- data/lib/hecks/runtime/event.rb +15 -10
- data/lib/hecks/runtime/identity.rb +91 -23
- data/lib/hecks/runtime/instance.rb +103 -18
- data/lib/hecks/runtime/interpreting.rb +24 -16
- data/lib/hecks/runtime/invocation.rb +358 -0
- data/lib/hecks/runtime/loader.rb +105 -19
- data/lib/hecks/runtime/outbox.rb +164 -26
- data/lib/hecks/runtime/policy_interpreter.rb +73 -60
- data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
- data/lib/hecks/runtime/query_interpreter.rb +93 -74
- data/lib/hecks/runtime/reaction_invocation.rb +73 -28
- data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
- data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
- data/lib/hecks/runtime/reference_hop.rb +48 -6
- data/lib/hecks/runtime/refusal_wording.rb +142 -115
- data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
- data/lib/hecks/runtime/registry/verification.rb +153 -28
- data/lib/hecks/runtime/registry.rb +202 -32
- data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
- data/lib/hecks/runtime/routing.rb +36 -154
- data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
- data/lib/hecks/runtime/saga_interpreter.rb +90 -76
- data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
- data/lib/hecks/runtime/tenant_check.rb +33 -13
- data/lib/hecks/runtime/tenant_scope.rb +23 -5
- data/lib/hecks/runtime/value/admission.rb +75 -30
- data/lib/hecks/runtime/value/coercion.rb +555 -142
- data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
- data/lib/hecks/runtime/value.rb +71 -21
- data/lib/hecks/runtime.rb +39 -7
- data/lib/hecks/storehouse.rb +368 -72
- data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
- data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
- data/lib/hecks/version.rb +3 -3
- data/lib/hecks/vocabulary.rb +205 -4
- data/lib/hecks.rb +91 -11
- data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
- data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
- data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
- metadata +28 -2
|
@@ -0,0 +1,871 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require "json"
|
|
3
|
+
require_relative "form_census"
|
|
4
|
+
|
|
5
|
+
module Hecks
|
|
6
|
+
module Fuzzing
|
|
7
|
+
# A small, valid bluebook, written from a seed — so the QA loop can test
|
|
8
|
+
# construct combinations nobody has hand-authored yet.
|
|
9
|
+
#
|
|
10
|
+
# ## Why this exists
|
|
11
|
+
#
|
|
12
|
+
# The ledger's most productive moments were a new stress domain's first
|
|
13
|
+
# sweep: `corrections` found four bugs, `referral_chain` four,
|
|
14
|
+
# `tenant_ledger` two. Every one of those domains was written by hand,
|
|
15
|
+
# one angle at a time, and `bin/qa_domain_novelty` exists precisely
|
|
16
|
+
# because the bugs live where two declared forms meet on one aggregate
|
|
17
|
+
# for the first time (`spec/combination_coverage_spec.rb`'s header).
|
|
18
|
+
# This writes those meetings mechanically: a seed picks two
|
|
19
|
+
# `FormCensus::FORMS` to force onto one aggregate, adds whatever other
|
|
20
|
+
# aggregates those forms need (a reference target, a two-hop chain),
|
|
21
|
+
# sprinkles extras (a lifecycle, a closed set, an entity, a query, a
|
|
22
|
+
# policy, a role), and renders the whole thing as ordinary bluebook
|
|
23
|
+
# source any runtime boots.
|
|
24
|
+
#
|
|
25
|
+
# ## Blueprint, not source, is the unit
|
|
26
|
+
#
|
|
27
|
+
# `generate` answers a plain, JSON-shaped Hash (string keys) — a small
|
|
28
|
+
# IR of its own — and `render` turns it into source. That split is what
|
|
29
|
+
# makes a finding shrinkable at the domain level: `shrink_candidates`
|
|
30
|
+
# removes one element at a time (a query, a command, an entity, an
|
|
31
|
+
# aggregate, a given…) and `prune` drops whatever that removal left
|
|
32
|
+
# dangling, using the explicit `requires` tokens every dependent
|
|
33
|
+
# element carries. Nothing here parses Ruby back.
|
|
34
|
+
#
|
|
35
|
+
# ## One fixed domain name
|
|
36
|
+
#
|
|
37
|
+
# Every generated domain is the same domain name — `QaGenerated`, in a
|
|
38
|
+
# `qa_generated/bluebook/qa_generated.bluebook` directory — because the
|
|
39
|
+
# directory basename doubles as a Rust module and Cargo feature name
|
|
40
|
+
# (`bin/project_rust`'s own landmine guard), and one fixed feature is
|
|
41
|
+
# what lets `bin/qa_generated_domains --rust` rebuild incrementally.
|
|
42
|
+
# Each domain is checked in its own child process for the same reason:
|
|
43
|
+
# two different shapes under one constant name never share a process.
|
|
44
|
+
module DomainGenerator
|
|
45
|
+
DOMAIN_NAME = "QaGenerated".freeze
|
|
46
|
+
DIRECTORY = "qa_generated".freeze
|
|
47
|
+
# `FORMS` — what this generator can build, defined at the bottom of
|
|
48
|
+
# this module, beside the `Builder::FORM_STEPS` table it reads.
|
|
49
|
+
|
|
50
|
+
# **No Rust-reserved snake names**. `Crate` was here first and every
|
|
51
|
+
# domain holding it failed to compile under `--rust`: its snake form
|
|
52
|
+
# is the keyword `crate`, which Rust cannot escape even as a raw
|
|
53
|
+
# identifier, and the projection emits it as a module and field name
|
|
54
|
+
# unguarded (`bin/project_rust` only guards the domain name). That is
|
|
55
|
+
# a real finding, reported rather than generated into every run.
|
|
56
|
+
AGGREGATE_NAMES = %w[Ticket Desk Parcel Venue Kiosk Hangar].freeze
|
|
57
|
+
ENTITY_NAMES = %w[Line Stamp].freeze
|
|
58
|
+
CLOSED_SETS = [%w[low high], %w[red amber green], %w[draft final]].freeze
|
|
59
|
+
ROLES = %w[Clerk Manager].freeze
|
|
60
|
+
|
|
61
|
+
# Forms that need another aggregate to reference, and the ones that
|
|
62
|
+
# need a two-hop chain ending in a lifecycle.
|
|
63
|
+
REFERENCE_FORMS = %w[reference_attr revalued_reference].freeze
|
|
64
|
+
CHAIN_FORMS = %w[two_hop_given multi_hop_where].freeze
|
|
65
|
+
|
|
66
|
+
module_function
|
|
67
|
+
|
|
68
|
+
# Builds a random blueprint from a seed and prunes it to a fixpoint,
|
|
69
|
+
# ready for `write` to render into a fresh `QaGenerated` domain.
|
|
70
|
+
#
|
|
71
|
+
# @param seed [Integer] the random seed; the same seed and `forms` always
|
|
72
|
+
# produce the same blueprint
|
|
73
|
+
# @param forms [Array<String, Symbol>, nil] the `FormCensus::FORMS` names to
|
|
74
|
+
# force onto the primary aggregate; two random forms from `FORMS` when nil
|
|
75
|
+
# @return [Hash{String => Object}] the pruned blueprint, with `"seed"` and
|
|
76
|
+
# `"forms"` merged in
|
|
77
|
+
# @raise [ArgumentError] if `forms` names anything outside `FORMS`
|
|
78
|
+
def generate(seed:, forms: nil)
|
|
79
|
+
random = Random.new(seed)
|
|
80
|
+
forms = Array(forms || FORMS.sample(2, random: random)).map(&:to_s)
|
|
81
|
+
unknown = forms - FORMS
|
|
82
|
+
raise ArgumentError, "unknown form(s) #{unknown.join(', ')} — FormCensus::FORMS names #{FORMS.join(', ')}" if unknown.any?
|
|
83
|
+
|
|
84
|
+
blueprint = Builder.new(random, forms).build
|
|
85
|
+
prune(blueprint.merge("seed" => seed, "forms" => forms))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# `<root>/qa_generated/bluebook/qa_generated.bluebook`, plus the
|
|
89
|
+
# blueprint beside the domain directory (never inside it — a stray
|
|
90
|
+
# file under `bluebook/` would be loaded as a chapter).
|
|
91
|
+
#
|
|
92
|
+
# A blueprint carrying `"source"` is bluebook text someone else wrote
|
|
93
|
+
# (`bin/qa_mine_combinations`' agent): it is adopted, not rendered,
|
|
94
|
+
# and its empty `aggregates`/`policies` leave nothing to shrink.
|
|
95
|
+
#
|
|
96
|
+
# @param blueprint [Hash{String => Object}] a blueprint as `generate` answers,
|
|
97
|
+
# or one carrying a `"source"` key instead of `"aggregates"`/`"policies"`
|
|
98
|
+
# @param root [String] the directory to write the domain and blueprint under
|
|
99
|
+
# @return [String] the written domain's directory (`<root>/qa_generated`)
|
|
100
|
+
def write(blueprint, root)
|
|
101
|
+
domain = File.join(root, DIRECTORY)
|
|
102
|
+
FileUtils.rm_rf(domain)
|
|
103
|
+
FileUtils.mkdir_p(File.join(domain, "bluebook"))
|
|
104
|
+
text = blueprint["source"] ? adopt(blueprint["source"]) : render(blueprint)
|
|
105
|
+
File.write(File.join(domain, "bluebook", "#{DIRECTORY}.bluebook"), text)
|
|
106
|
+
File.write(File.join(root, "blueprint.json"), JSON.pretty_generate(blueprint))
|
|
107
|
+
domain
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Renames an outside bluebook's own chapter to `QaGenerated`, the one
|
|
111
|
+
# name the scratch crate and child processes are built around.
|
|
112
|
+
#
|
|
113
|
+
# @param source [String] bluebook source text declaring one chapter
|
|
114
|
+
# @return [String] `source` with its `Hecks.bluebook "..."` chapter name
|
|
115
|
+
# replaced by `"QaGenerated"`
|
|
116
|
+
def adopt(source)
|
|
117
|
+
source.sub(/Hecks\.bluebook\s*\(?\s*(["'])[^"']+\1/) { "Hecks.bluebook #{DOMAIN_NAME.inspect}" }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# ── rendering ───────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
# Renders a whole blueprint into one bluebook source file.
|
|
123
|
+
#
|
|
124
|
+
# @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
|
|
125
|
+
# @return [String] the complete `Hecks.bluebook "QaGenerated" do ... end` source,
|
|
126
|
+
# ending in a newline
|
|
127
|
+
def render(blueprint)
|
|
128
|
+
out = []
|
|
129
|
+
out << "# GENERATED by Hecks::Fuzzing::DomainGenerator — seed #{blueprint['seed']}, forms " \
|
|
130
|
+
"#{blueprint['forms'].join(', ')}. Not hand-written; see bin/qa_generated_domains."
|
|
131
|
+
out << "Hecks.bluebook #{DOMAIN_NAME.inspect} do"
|
|
132
|
+
out << " vision #{"A generated domain that puts #{blueprint['forms'].join(' and ')} together.".inspect}"
|
|
133
|
+
out << ""
|
|
134
|
+
out << " core"
|
|
135
|
+
blueprint["aggregates"].each { |aggregate| out.concat(render_aggregate(aggregate)) }
|
|
136
|
+
blueprint["policies"].each { |policy| out.concat(render_policy(policy)) }
|
|
137
|
+
out << "end"
|
|
138
|
+
"#{out.join("\n")}\n"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Renders one aggregate's whole `aggregate ... end` block, including
|
|
142
|
+
# every value object, entity, command and query it declares.
|
|
143
|
+
#
|
|
144
|
+
# @param aggregate [Hash{String => Object}] one aggregate's blueprint entry
|
|
145
|
+
# @return [Array<String>] source lines, unindented at the block's own `aggregate`/`end`
|
|
146
|
+
def render_aggregate(aggregate)
|
|
147
|
+
out = aggregate_header(aggregate)
|
|
148
|
+
aggregate["attributes"].each { |attribute| out << " #{render_attribute(attribute)}" }
|
|
149
|
+
aggregate["references"].each { |target| out << " reference_to #{target}" }
|
|
150
|
+
aggregate["vos"].each { |name, vo| out.concat(render_value_object(name, vo, " ")) }
|
|
151
|
+
out.concat(render_lifecycle(aggregate["lifecycle"], " ")) if aggregate["lifecycle"]
|
|
152
|
+
aggregate["invariants"].each { |inv| out << " invariant(#{inv['label'].inspect}) { #{inv['expr']} }" }
|
|
153
|
+
aggregate["entities"].each { |entity| out.concat(render_entity(entity)) }
|
|
154
|
+
aggregate["commands"].each { |command| out.concat(render_command(command, aggregate["name"], " ")) }
|
|
155
|
+
aggregate["queries"].each { |query| out.concat(render_query(query)) }
|
|
156
|
+
out << " end"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Renders an aggregate block's opening lines, up through `identified_by`.
|
|
160
|
+
#
|
|
161
|
+
# @param aggregate [Hash{String => Object}] one aggregate's blueprint entry
|
|
162
|
+
# @return [Array<String>] the `aggregate ... do`, `description` and
|
|
163
|
+
# `identified_by` lines
|
|
164
|
+
def aggregate_header(aggregate)
|
|
165
|
+
["", " aggregate #{aggregate['name'].inspect} do",
|
|
166
|
+
" description #{"A generated #{aggregate['name'].downcase}.".inspect}", "",
|
|
167
|
+
" identified_by #{aggregate['identity'].map { |part| ":#{part}" }.join(', ')}"]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Renders one entity's whole `entity ... end` block.
|
|
171
|
+
#
|
|
172
|
+
# @param entity [Hash{String => Object}] one entity's blueprint entry, as built
|
|
173
|
+
# by `Builder#entity`
|
|
174
|
+
# @return [Array<String>] source lines, unindented at the block's own `entity`/`end`
|
|
175
|
+
def render_entity(entity)
|
|
176
|
+
out = ["", " entity #{entity['name'].inspect} do",
|
|
177
|
+
" description #{"A generated #{entity['name'].downcase}.".inspect}"]
|
|
178
|
+
out << " identified_by #{entity['identity'].map { |part| ":#{part}" }.join(', ')}"
|
|
179
|
+
entity["attributes"].each { |attribute| out << " #{render_attribute(attribute)}" }
|
|
180
|
+
out.concat(render_lifecycle(entity["lifecycle"], " ")) if entity["lifecycle"]
|
|
181
|
+
entity["commands"].each { |command| out.concat(render_command(command, nil, " ")) }
|
|
182
|
+
out << " end"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Renders one `attribute` declaration line.
|
|
186
|
+
#
|
|
187
|
+
# @param attribute [Hash{String => Object}] `"name"`, `"type"`, and optional
|
|
188
|
+
# `"list"`, `"optional"`, `"default"` keys
|
|
189
|
+
# @return [String] one `attribute :name, Type` line, with `list_of`, `optional:`
|
|
190
|
+
# and `default:` added as the Hash calls for
|
|
191
|
+
def render_attribute(attribute)
|
|
192
|
+
type = attribute["list"] ? "list_of(#{attribute['type']})" : attribute["type"]
|
|
193
|
+
line = "attribute :#{attribute['name']}, #{type}"
|
|
194
|
+
line += ", optional: true" if attribute["optional"]
|
|
195
|
+
line += ", default: { value: #{attribute['default'].inspect} }" if attribute.key?("default")
|
|
196
|
+
line
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Renders one `value_object ... end` block.
|
|
200
|
+
#
|
|
201
|
+
# @param name [String] the value object's declared name
|
|
202
|
+
# @param value_object [Hash{String => Object}] `"kind"` (`"string"`, `"positive"`,
|
|
203
|
+
# `"integer"` or `"closed"`) and, for `"closed"`, a `"members"` array
|
|
204
|
+
# @param indent [String] leading whitespace for every rendered line
|
|
205
|
+
# @return [Array<String>] source lines, indented by `indent`
|
|
206
|
+
def render_value_object(name, value_object, indent)
|
|
207
|
+
body =
|
|
208
|
+
case value_object["kind"]
|
|
209
|
+
when "string"
|
|
210
|
+
["attribute :value, String, pattern: '[^ \\t\\n\\r]'",
|
|
211
|
+
"invariant(#{"a #{name} is not blank".inspect}) { !value.to_s.empty? }"]
|
|
212
|
+
when "positive"
|
|
213
|
+
["attribute :value, Integer", "invariant(#{"a #{name} is positive".inspect}) { value.positive? }"]
|
|
214
|
+
when "integer"
|
|
215
|
+
["attribute :value, Integer"]
|
|
216
|
+
when "closed"
|
|
217
|
+
["attribute :value, String, one_of: #{value_object['members'].inspect}"]
|
|
218
|
+
end
|
|
219
|
+
["", "#{indent}value_object #{name.inspect} do", *body.map { |line| "#{indent} #{line}" }, "#{indent}end"]
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Renders one `lifecycle ... end` block.
|
|
223
|
+
#
|
|
224
|
+
# @param lifecycle [Hash{String => Object}] `"field"`, `"default"` and
|
|
225
|
+
# `"transitions"` (each `"command"`, `"to"`, `"from"`)
|
|
226
|
+
# @param indent [String] leading whitespace for every rendered line
|
|
227
|
+
# @return [Array<String>] source lines, indented by `indent`
|
|
228
|
+
def render_lifecycle(lifecycle, indent)
|
|
229
|
+
out = ["", "#{indent}lifecycle :#{lifecycle['field']}, default: #{lifecycle['default'].inspect} do"]
|
|
230
|
+
lifecycle["transitions"].each do |transition|
|
|
231
|
+
from = transition["from"].size == 1 ? transition["from"].first.inspect : transition["from"].inspect
|
|
232
|
+
out << "#{indent} transition #{transition['command'].inspect} => #{transition['to'].inspect}, from: #{from}"
|
|
233
|
+
end
|
|
234
|
+
out << "#{indent}end"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Renders one `command ... end` block.
|
|
238
|
+
#
|
|
239
|
+
# @param command [Hash{String => Object}] `"name"`, `"creates"`, `"role"`,
|
|
240
|
+
# `"references"`, `"args"`, `"givens"`, `"sets"` and `"emits"`
|
|
241
|
+
# @param self_name [String, nil] the owning aggregate's name, added as a
|
|
242
|
+
# `reference_to` on a non-creating entity command; nil for an
|
|
243
|
+
# aggregate-level command, which needs no reference to itself
|
|
244
|
+
# @param indent [String] leading whitespace for every rendered line
|
|
245
|
+
# @return [Array<String>] source lines, indented by `indent`
|
|
246
|
+
def render_command(command, self_name, indent)
|
|
247
|
+
out = ["", "#{indent}command #{command['name'].inspect} do"]
|
|
248
|
+
out << "#{indent} role #{command['role'].inspect}" if command["role"]
|
|
249
|
+
out << "#{indent} goal #{"#{command['name']} it".inspect}"
|
|
250
|
+
out << "#{indent} reference_to #{self_name}" if self_name && !command["creates"]
|
|
251
|
+
command["references"].each { |target| out << "#{indent} reference_to #{target}" }
|
|
252
|
+
command["args"].each { |arg| out << "#{indent} #{render_attribute(arg)}" }
|
|
253
|
+
command["givens"].each { |given| out << "#{indent} given(#{given['label'].inspect}) { #{given['expr']} }" }
|
|
254
|
+
command["sets"].each { |set| out << "#{indent} #{render_set(set)}" }
|
|
255
|
+
command["emits"].each { |event| out << "#{indent} emits #{event.inspect}" }
|
|
256
|
+
out << "#{indent}end"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Renders one `sets` line inside a command block.
|
|
260
|
+
#
|
|
261
|
+
# @param set [Hash{String => Object}] `"target"`, plus either `"to"` (rename) or
|
|
262
|
+
# `"append"` (a Hash of field => value token to append with); neither means a
|
|
263
|
+
# bare positional set
|
|
264
|
+
# @return [String] one `sets :target[, to: :value | append: { ... }]` line
|
|
265
|
+
def render_set(set)
|
|
266
|
+
return "sets :#{set['target']}, to: :#{set['to']}" if set["to"]
|
|
267
|
+
return "sets :#{set['target']}, append: { #{set['append'].map { |k, v| "#{k}: :#{v}" }.join(', ')} }" if set["append"]
|
|
268
|
+
|
|
269
|
+
"sets :#{set['target']}"
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Renders one `query ... end` block.
|
|
273
|
+
#
|
|
274
|
+
# @param query [Hash{String => Object}] `"name"`, `"wheres"` (each `"field"`,
|
|
275
|
+
# `"value"`) and `"order_by"`
|
|
276
|
+
# @return [Array<String>] source lines, unindented at the block's own `query`/`end`
|
|
277
|
+
def render_query(query)
|
|
278
|
+
out = ["", " query #{query['name'].inspect} do", " description #{"Generated #{query['name']}.".inspect}"]
|
|
279
|
+
query["wheres"].each do |where|
|
|
280
|
+
out << " where(:#{where['field'].inspect} => #{where['value'].inspect})"
|
|
281
|
+
end
|
|
282
|
+
out << " order_by :#{query['order_by']}"
|
|
283
|
+
out << " end"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Renders one `policy ... end` block.
|
|
287
|
+
#
|
|
288
|
+
# @param policy [Hash{String => Object}] `"name"`, `"on"` and `"trigger"`
|
|
289
|
+
# @return [Array<String>] source lines, unindented at the block's own `policy`/`end`
|
|
290
|
+
def render_policy(policy)
|
|
291
|
+
["", " policy #{policy['name'].inspect} do", " on #{policy['on']}", " trigger #{policy['trigger']}", " end"]
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# ── domain-level shrinking ──────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
# Every blueprint one removal smaller, each already pruned. Identity
|
|
297
|
+
# attributes and creating commands are never offered: without them
|
|
298
|
+
# there is no domain left to dispatch against.
|
|
299
|
+
#
|
|
300
|
+
# @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
|
|
301
|
+
# @return [Array<Hash{String => Object}>] one pruned blueprint per candidate removal,
|
|
302
|
+
# deduplicated and never including `blueprint` itself
|
|
303
|
+
def shrink_candidates(blueprint)
|
|
304
|
+
removals(blueprint).map { |path| prune(remove_at(blueprint, path)) }.uniq.reject { |candidate| candidate == blueprint }
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Every path into `blueprint` naming one element a shrink could remove.
|
|
308
|
+
#
|
|
309
|
+
# @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
|
|
310
|
+
# @return [Array<Array<String, Integer>>] one path per removable element, each
|
|
311
|
+
# a sequence of Hash keys and Array indices `Hash#dig` can follow
|
|
312
|
+
def removals(blueprint)
|
|
313
|
+
paths = blueprint["policies"].each_index.map { |i| ["policies", i] }
|
|
314
|
+
blueprint["aggregates"].each_with_index do |aggregate, a|
|
|
315
|
+
at = ["aggregates", a]
|
|
316
|
+
paths << at if a.positive?
|
|
317
|
+
%w[queries invariants entities references].each { |key| aggregate[key].each_index { |i| paths << [*at, key, i] } }
|
|
318
|
+
paths << [*at, "lifecycle"] if aggregate["lifecycle"]
|
|
319
|
+
aggregate["attributes"].each_with_index do |attribute, i|
|
|
320
|
+
paths << [*at, "attributes", i] unless aggregate["identity"].include?(attribute["name"])
|
|
321
|
+
end
|
|
322
|
+
aggregate["commands"].each_with_index { |command, i| paths.concat(command_removals(command, [*at, "commands", i])) }
|
|
323
|
+
paths.concat(entity_removals(aggregate, at))
|
|
324
|
+
end
|
|
325
|
+
paths
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# `removals`'s own entity half — one path per entity's lifecycle (if any) and
|
|
329
|
+
# per entity command.
|
|
330
|
+
#
|
|
331
|
+
# @param aggregate [Hash{String => Object}] one aggregate's blueprint entry
|
|
332
|
+
# @param at [Array<String, Integer>] the path prefix to `aggregate` itself
|
|
333
|
+
# @return [Array<Array<String, Integer>>] one path per removable entity element
|
|
334
|
+
def entity_removals(aggregate, at)
|
|
335
|
+
aggregate["entities"].each_with_index.flat_map do |entity, e|
|
|
336
|
+
paths = entity["lifecycle"] ? [[*at, "entities", e, "lifecycle"]] : []
|
|
337
|
+
paths + entity["commands"].each_index.map { |i| [*at, "entities", e, "commands", i] }
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# `removals`'s own command half — one path for the whole command (unless it
|
|
342
|
+
# creates the aggregate), one per given, and one for its role or its last
|
|
343
|
+
# extra emit.
|
|
344
|
+
#
|
|
345
|
+
# @param command [Hash{String => Object}] one command's blueprint entry
|
|
346
|
+
# @param at [Array<String, Integer>] the path prefix to `command` itself
|
|
347
|
+
# @return [Array<Array<String, Integer>>] one path per removable command element
|
|
348
|
+
def command_removals(command, at)
|
|
349
|
+
paths = command["creates"] ? [] : [at]
|
|
350
|
+
command["givens"].each_index { |g| paths << [*at, "givens", g] }
|
|
351
|
+
paths << [*at, "role"] if command["role"]
|
|
352
|
+
paths << [*at, "emits", command["emits"].size - 1] if command["emits"].size > 1
|
|
353
|
+
paths
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Deep-copies `blueprint` and deletes whatever `path` names.
|
|
357
|
+
#
|
|
358
|
+
# @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
|
|
359
|
+
# @param path [Array<String, Integer>] a path as `removals` returns, its last
|
|
360
|
+
# element the Hash key or Array index to delete
|
|
361
|
+
# @return [Hash{String => Object}] a deep copy of `blueprint` with that one
|
|
362
|
+
# element removed
|
|
363
|
+
def remove_at(blueprint, path)
|
|
364
|
+
copy = JSON.parse(JSON.generate(blueprint))
|
|
365
|
+
*parents, last = path
|
|
366
|
+
holder = parents.empty? ? copy : copy.dig(*parents)
|
|
367
|
+
holder.is_a?(Array) ? holder.delete_at(last) : holder.delete(last)
|
|
368
|
+
copy
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Drop whatever a removal left dangling, to a fixpoint. Every element
|
|
372
|
+
# that depends on another carries `requires` — tokens naming exactly
|
|
373
|
+
# what must still exist (`attribute:Ticket.score`, `lifecycle:Desk`,
|
|
374
|
+
# `reference:Ticket->Desk`, `command:Ticket.Close`, …) — so this is
|
|
375
|
+
# set arithmetic over those tokens, never a reading of the source.
|
|
376
|
+
#
|
|
377
|
+
# @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
|
|
378
|
+
# @return [Hash{String => Object}] a deep copy of `blueprint` with every element
|
|
379
|
+
# whose `requires` tokens are not all still present removed, repeated until
|
|
380
|
+
# nothing more can be dropped
|
|
381
|
+
def prune(blueprint)
|
|
382
|
+
current = JSON.parse(JSON.generate(blueprint))
|
|
383
|
+
loop do
|
|
384
|
+
available = tokens(current)
|
|
385
|
+
before = JSON.generate(current)
|
|
386
|
+
prune_once!(current, available)
|
|
387
|
+
break if JSON.generate(current) == before
|
|
388
|
+
end
|
|
389
|
+
current
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# One pass of `prune` — drops every policy, then every aggregate element,
|
|
393
|
+
# whose `requires` tokens are not all in `available`.
|
|
394
|
+
#
|
|
395
|
+
# @param blueprint [Hash{String => Object}] the blueprint being pruned, mutated
|
|
396
|
+
# in place
|
|
397
|
+
# @param available [Set<String>] every token `tokens` finds still declared
|
|
398
|
+
# @return [void]
|
|
399
|
+
def prune_once!(blueprint, available)
|
|
400
|
+
keep = ->(item) { Array(item["requires"]).all? { |token| available.include?(token) } }
|
|
401
|
+
blueprint["policies"].select!(&keep)
|
|
402
|
+
blueprint["aggregates"].each { |aggregate| prune_aggregate!(aggregate, available, keep) }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# `prune_once!`'s own per-aggregate pass — drops attributes, invariants,
|
|
406
|
+
# entities, unsatisfied references and empty queries, then recurses into
|
|
407
|
+
# each remaining lifecycle and command.
|
|
408
|
+
#
|
|
409
|
+
# @param aggregate [Hash{String => Object}] one aggregate's blueprint entry,
|
|
410
|
+
# mutated in place
|
|
411
|
+
# @param available [Set<String>] every token `tokens` finds still declared
|
|
412
|
+
# @param keep [Proc] the `requires`-satisfied predicate `prune_once!` built
|
|
413
|
+
# @return [void]
|
|
414
|
+
def prune_aggregate!(aggregate, available, keep)
|
|
415
|
+
aggregate["references"].select! { |target| available.include?("aggregate:#{target}") }
|
|
416
|
+
%w[attributes invariants entities].each { |key| aggregate[key].select!(&keep) }
|
|
417
|
+
aggregate["queries"].each { |query| query["wheres"].select!(&keep) }
|
|
418
|
+
aggregate["queries"].select! { |query| query["wheres"].any? }
|
|
419
|
+
[aggregate, *aggregate["entities"]].each do |owner|
|
|
420
|
+
prune_lifecycle!(owner, available)
|
|
421
|
+
owner["commands"].each { |command| prune_command!(command, available) }
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Drops transitions whose own command no longer exists, then whatever that
|
|
426
|
+
# leaves unreachable from the default state; clears `"lifecycle"` outright
|
|
427
|
+
# once no transition survives.
|
|
428
|
+
#
|
|
429
|
+
# @param owner [Hash{String => Object}] an aggregate or entity blueprint entry,
|
|
430
|
+
# mutated in place
|
|
431
|
+
# @param available [Set<String>] every token `tokens` finds still declared
|
|
432
|
+
# @return [void]
|
|
433
|
+
def prune_lifecycle!(owner, available)
|
|
434
|
+
return unless owner["lifecycle"]
|
|
435
|
+
|
|
436
|
+
transitions = owner["lifecycle"]["transitions"]
|
|
437
|
+
transitions.select! { |t| available.include?(t["requires"].first) }
|
|
438
|
+
reachable = reachable_states(owner["lifecycle"]["default"], transitions)
|
|
439
|
+
transitions.select! { |t| t["from"].any? { |state| reachable.include?(state) } }
|
|
440
|
+
owner["lifecycle"] = nil if transitions.empty?
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Every state a path from the default reaches. A transition out of a
|
|
444
|
+
# state nothing reaches can never fire, so a removal that orphans one
|
|
445
|
+
# goes too — without this, shrinking away `Close` leaves `Reopen from
|
|
446
|
+
# closed` behind, the shape `qa/stress_domains/generated_revalued_shape`
|
|
447
|
+
# was promoted with, which `bin/model_check` reports as a dead transition.
|
|
448
|
+
#
|
|
449
|
+
# @param default [String] the lifecycle's default state name
|
|
450
|
+
# @param transitions [Array<Hash{String => Object}>] the lifecycle's surviving
|
|
451
|
+
# transitions, each with `"from"` (an Array of state names) and `"to"`
|
|
452
|
+
# @return [Array<String>] every state reachable from `default`, `default` included
|
|
453
|
+
def reachable_states(default, transitions)
|
|
454
|
+
reachable = [default]
|
|
455
|
+
loop do
|
|
456
|
+
reached = transitions.select { |t| t["from"].any? { |state| reachable.include?(state) } }.map { |t| t["to"] }
|
|
457
|
+
return reachable if (reached - reachable).empty?
|
|
458
|
+
|
|
459
|
+
reachable |= reached
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Drops a command's own unsatisfied references, args, givens and sets.
|
|
464
|
+
#
|
|
465
|
+
# @param command [Hash{String => Object}] one command's blueprint entry,
|
|
466
|
+
# mutated in place
|
|
467
|
+
# @param available [Set<String>] every token `tokens` finds still declared
|
|
468
|
+
# @return [void]
|
|
469
|
+
def prune_command!(command, available)
|
|
470
|
+
keep = ->(item) { Array(item["requires"]).all? { |token| available.include?(token) } }
|
|
471
|
+
command["references"].select! { |target| available.include?("aggregate:#{target}") }
|
|
472
|
+
%w[args givens sets].each { |key| command[key].select!(&keep) }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Every declaration token `blueprint` currently makes available — what a
|
|
476
|
+
# `requires` entry elsewhere in the blueprint can point at.
|
|
477
|
+
#
|
|
478
|
+
# @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
|
|
479
|
+
# @return [Set<String>] tokens such as `"aggregate:Ticket"`,
|
|
480
|
+
# `"attribute:Ticket.score"`, `"lifecycle:Desk"`, `"reference:Ticket->Desk"`,
|
|
481
|
+
# `"command:Ticket.Close"` and `"event:Ticket.TicketOpened"`
|
|
482
|
+
def tokens(blueprint)
|
|
483
|
+
blueprint["aggregates"].each_with_object(Set.new) do |aggregate, set|
|
|
484
|
+
name = aggregate["name"]
|
|
485
|
+
set << "aggregate:#{name}"
|
|
486
|
+
set << "lifecycle:#{name}" if aggregate["lifecycle"]
|
|
487
|
+
aggregate["attributes"].each { |attribute| set << "attribute:#{name}.#{attribute['name']}" }
|
|
488
|
+
aggregate["references"].each { |target| set << "reference:#{name}->#{target}" }
|
|
489
|
+
aggregate["commands"].each do |command|
|
|
490
|
+
set << "command:#{name}.#{command['name']}"
|
|
491
|
+
command["references"].each { |target| set << "command_reference:#{name}.#{command['name']}->#{target}" }
|
|
492
|
+
command["emits"].each { |event| set << "event:#{name}.#{event}" }
|
|
493
|
+
end
|
|
494
|
+
aggregate["entities"].each do |entity|
|
|
495
|
+
set << "entity:#{name}.#{entity['name']}"
|
|
496
|
+
set << "lifecycle:#{name}.#{entity['name']}" if entity["lifecycle"]
|
|
497
|
+
entity["commands"].each { |command| set << "command:#{name}.#{entity['name']}.#{command['name']}" }
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Converts a rendered `CamelCase` aggregate/entity name to the `snake_case`
|
|
503
|
+
# form its own `reference_to`/`sets` field uses.
|
|
504
|
+
#
|
|
505
|
+
# @param name [String] a `CamelCase` name
|
|
506
|
+
# @return [String] `name` in `snake_case`
|
|
507
|
+
def snake(name) = name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
508
|
+
|
|
509
|
+
# ── building ────────────────────────────────────────────────────
|
|
510
|
+
|
|
511
|
+
# One seeded pass: the aggregates the forms need, the forms forced
|
|
512
|
+
# onto the first ("primary") aggregate, then extras everywhere.
|
|
513
|
+
class Builder
|
|
514
|
+
# @param random [Random] the seeded generator every random choice this
|
|
515
|
+
# builder makes draws from
|
|
516
|
+
# @param forms [Array<String>] the `FormCensus::FORMS` names to force onto
|
|
517
|
+
# the primary aggregate
|
|
518
|
+
def initialize(random, forms)
|
|
519
|
+
@random = random
|
|
520
|
+
@forms = forms
|
|
521
|
+
@names = AGGREGATE_NAMES.shuffle(random: random)
|
|
522
|
+
@policies = []
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# Builds the aggregates the forced forms need, applies each forced form to
|
|
526
|
+
# the primary aggregate, then sprinkles extras onto every aggregate.
|
|
527
|
+
#
|
|
528
|
+
# @return [Hash{String => Object}] `"aggregates"` and `"policies"`, the two
|
|
529
|
+
# keys `DomainGenerator.generate` merges `"seed"`/`"forms"` into
|
|
530
|
+
def build
|
|
531
|
+
chain = @forms.intersect?(CHAIN_FORMS)
|
|
532
|
+
count = if chain then 3
|
|
533
|
+
elsif @forms.intersect?(REFERENCE_FORMS) then 2
|
|
534
|
+
else 1
|
|
535
|
+
end
|
|
536
|
+
count += 1 if count < 3 && @random.rand < 0.3
|
|
537
|
+
aggregates = @names.first(count).map { |name| base(name) }
|
|
538
|
+
primary = aggregates.first
|
|
539
|
+
|
|
540
|
+
link_chain(aggregates.first(3)) if chain
|
|
541
|
+
@forms.each { |form| apply_form(form, primary, aggregates) }
|
|
542
|
+
aggregates.each { |aggregate| extras(aggregate, aggregates) }
|
|
543
|
+
{ "aggregates" => aggregates, "policies" => @policies }
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
# One step per `FormCensus::FORMS` entry, each run against the
|
|
547
|
+
# builder with the primary aggregate and every aggregate in play.
|
|
548
|
+
FORM_STEPS = {
|
|
549
|
+
"composite_id" => ->(primary, _) { composite_id(primary) },
|
|
550
|
+
"has_entity" => ->(primary, _) { entity(primary) },
|
|
551
|
+
"two_entities" => ->(primary, _) { 2.times { entity(primary) } },
|
|
552
|
+
"composite_piece" => ->(primary, _) { entity(primary, composite: true) },
|
|
553
|
+
"multi_emit" => ->(primary, _) { creating(primary)["emits"] << "#{primary['name']}Logged" },
|
|
554
|
+
"lifecycle" => ->(primary, _) { lifecycle(primary) },
|
|
555
|
+
"piece_lifecycle" => ->(primary, _) { entity(primary, lifecycle: true) },
|
|
556
|
+
"has_query" => ->(primary, _) { query(primary) },
|
|
557
|
+
"list_attr" => ->(primary, _) { list_attr(primary) },
|
|
558
|
+
"reference_attr" => ->(primary, all) { reference_attr(primary, all[1]) },
|
|
559
|
+
"closed_set" => ->(primary, _) { closed_set(primary) },
|
|
560
|
+
"has_default" => ->(primary, _) { default_attr(primary) },
|
|
561
|
+
"has_optional" => ->(primary, _) { optional_arg(primary) },
|
|
562
|
+
"two_hop_given" => ->(primary, all) { two_hop_given(primary, all[1], all[2]) },
|
|
563
|
+
"multi_hop_where" => ->(primary, all) { multi_hop_where(primary, all[1], all[2]) },
|
|
564
|
+
"revalued_reference" => ->(primary, all) { revalued_reference(primary, all[1]) }
|
|
565
|
+
}.freeze
|
|
566
|
+
|
|
567
|
+
private
|
|
568
|
+
|
|
569
|
+
def chance?(probability) = @random.rand < probability
|
|
570
|
+
|
|
571
|
+
def base(name)
|
|
572
|
+
aggregate = { "name" => name, "identity" => ["code"], "vos" => {}, "attributes" => [], "references" => [],
|
|
573
|
+
"lifecycle" => nil, "invariants" => [], "entities" => [], "commands" => [], "queries" => [] }
|
|
574
|
+
vo(aggregate, "#{name}Code", "string")
|
|
575
|
+
aggregate["attributes"] << { "name" => "code", "type" => "#{name}Code" }
|
|
576
|
+
aggregate["commands"] << { "name" => "Open", "creates" => true, "references" => [],
|
|
577
|
+
"args" => [{ "name" => "code", "type" => "#{name}Code" }],
|
|
578
|
+
"givens" => [], "sets" => [{ "target" => "code" }], "emits" => ["#{name}Opened"] }
|
|
579
|
+
aggregate
|
|
580
|
+
end
|
|
581
|
+
|
|
582
|
+
def vo(aggregate, name, kind, members: nil)
|
|
583
|
+
aggregate["vos"][name] ||= { "kind" => kind }.merge(members ? { "members" => members } : {})
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
def creating(aggregate) = aggregate["commands"].find { |command| command["creates"] }
|
|
587
|
+
|
|
588
|
+
def command(aggregate, name, args: [], sets: [], givens: [], emits: nil)
|
|
589
|
+
existing = aggregate["commands"].find { |candidate| candidate["name"] == name }
|
|
590
|
+
return existing if existing
|
|
591
|
+
|
|
592
|
+
entry = { "name" => name, "creates" => false, "references" => [], "args" => args, "givens" => givens,
|
|
593
|
+
"sets" => sets, "emits" => emits || ["#{aggregate['name']}#{past(name)}"] }
|
|
594
|
+
aggregate["commands"] << entry
|
|
595
|
+
entry
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def past(verb) = verb.end_with?("e") ? "#{verb}d" : "#{verb}ed"
|
|
599
|
+
|
|
600
|
+
# ── the forms ────────────────────────────────────────────────
|
|
601
|
+
|
|
602
|
+
def apply_form(form, primary, aggregates) = instance_exec(primary, aggregates, &FORM_STEPS.fetch(form))
|
|
603
|
+
|
|
604
|
+
def composite_id(aggregate)
|
|
605
|
+
return if aggregate["identity"].size > 1
|
|
606
|
+
|
|
607
|
+
name = aggregate["name"]
|
|
608
|
+
vo(aggregate, "#{name}Branch", "string")
|
|
609
|
+
vo(aggregate, "#{name}Number", "positive")
|
|
610
|
+
aggregate["identity"] = %w[branch number]
|
|
611
|
+
aggregate["attributes"].reject! { |attribute| attribute["name"] == "code" }
|
|
612
|
+
aggregate["attributes"].unshift({ "name" => "branch", "type" => "#{name}Branch" },
|
|
613
|
+
{ "name" => "number", "type" => "#{name}Number" })
|
|
614
|
+
open = creating(aggregate)
|
|
615
|
+
open["args"] = [{ "name" => "branch", "type" => "#{name}Branch" }, { "name" => "number", "type" => "#{name}Number" }]
|
|
616
|
+
open["sets"] = [{ "target" => "branch" }, { "target" => "number" }]
|
|
617
|
+
aggregate["vos"].delete("#{name}Code")
|
|
618
|
+
end
|
|
619
|
+
|
|
620
|
+
def entity(aggregate, composite: false, lifecycle: false)
|
|
621
|
+
taken = aggregate["entities"].map { |entity| entity["name"] }
|
|
622
|
+
name = (ENTITY_NAMES - taken).first
|
|
623
|
+
return unless name
|
|
624
|
+
|
|
625
|
+
owner = aggregate["name"]
|
|
626
|
+
list = "#{name.downcase}s"
|
|
627
|
+
parts = composite ? %w[batch sequence] : ["sequence"]
|
|
628
|
+
vo(aggregate, "#{name}Sequence", "positive")
|
|
629
|
+
vo(aggregate, "#{name}Batch", "string") if composite
|
|
630
|
+
vo(aggregate, "#{name}Label", "string")
|
|
631
|
+
types = { "sequence" => "#{name}Sequence", "batch" => "#{name}Batch" }
|
|
632
|
+
|
|
633
|
+
aggregate["attributes"] << { "name" => list, "type" => name, "list" => true, "requires" => ["entity:#{owner}.#{name}"] }
|
|
634
|
+
# Event names are qualified by the owner aggregate, not just the
|
|
635
|
+
# entity type — `ENTITY_NAMES` is a small pool (`Line`, `Stamp`)
|
|
636
|
+
# shared across every aggregate in a domain, and `extras` can pick
|
|
637
|
+
# the same entity name on a different aggregate than a form forced
|
|
638
|
+
# it onto (composite there, plain here, or vice versa). Aggregate
|
|
639
|
+
# names are always unique within one generated domain, so this is
|
|
640
|
+
# the same qualification ordinary commands already get by default
|
|
641
|
+
# (`"#{aggregate['name']}#{past(name)}"`) — without it, two
|
|
642
|
+
# aggregates can both emit a bare "LineAdded" with different
|
|
643
|
+
# shapes, which `validate_event_shapes!` correctly refuses.
|
|
644
|
+
piece = { "name" => name, "identity" => parts, "requires" => [],
|
|
645
|
+
"attributes" => parts.map { |part| { "name" => part, "type" => types[part] } } +
|
|
646
|
+
[{ "name" => "label", "type" => "#{name}Label", "optional" => true }],
|
|
647
|
+
"lifecycle" => nil,
|
|
648
|
+
"commands" => [{ "name" => "Label", "creates" => false, "references" => [],
|
|
649
|
+
"args" => [{ "name" => "label", "type" => "#{name}Label" }], "givens" => [],
|
|
650
|
+
"sets" => [{ "target" => "label" }], "emits" => ["#{owner}#{name}Labeled"] }] }
|
|
651
|
+
if lifecycle || chance?(0.2)
|
|
652
|
+
piece["commands"] << { "name" => "Settle", "creates" => false, "references" => [], "args" => [], "givens" => [],
|
|
653
|
+
"sets" => [], "emits" => ["#{owner}#{name}Settled"] }
|
|
654
|
+
piece["lifecycle"] = { "field" => "state", "default" => "pending",
|
|
655
|
+
"transitions" => [{ "command" => "Settle", "to" => "settled", "from" => ["pending"],
|
|
656
|
+
"requires" => ["command:#{owner}.#{name}.Settle"] }] }
|
|
657
|
+
end
|
|
658
|
+
aggregate["entities"] << piece
|
|
659
|
+
command(aggregate, "Add#{name}",
|
|
660
|
+
args: parts.map { |part| { "name" => part, "type" => types[part] } },
|
|
661
|
+
sets: [{ "target" => list, "append" => parts.to_h { |part| [part, part] },
|
|
662
|
+
"requires" => ["attribute:#{owner}.#{list}", "entity:#{owner}.#{name}"] }],
|
|
663
|
+
emits: ["#{owner}#{name}Added"])
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def lifecycle(aggregate)
|
|
667
|
+
return if aggregate["lifecycle"]
|
|
668
|
+
|
|
669
|
+
name = aggregate["name"]
|
|
670
|
+
command(aggregate, "Close")
|
|
671
|
+
command(aggregate, "Reopen")
|
|
672
|
+
aggregate["lifecycle"] = { "field" => "status", "default" => "open", "transitions" => [
|
|
673
|
+
{ "command" => "Close", "to" => "closed", "from" => ["open"], "requires" => ["command:#{name}.Close"] },
|
|
674
|
+
{ "command" => "Reopen", "to" => "open", "from" => ["closed"], "requires" => ["command:#{name}.Reopen"] }
|
|
675
|
+
] }
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
def query(aggregate)
|
|
679
|
+
return if aggregate["queries"].any? { |query| query["name"] == "Listed" }
|
|
680
|
+
|
|
681
|
+
name = aggregate["name"]
|
|
682
|
+
where = if aggregate["lifecycle"] || chance?(0.5)
|
|
683
|
+
lifecycle(aggregate)
|
|
684
|
+
{ "field" => "status", "value" => "open", "requires" => ["lifecycle:#{name}"] }
|
|
685
|
+
else
|
|
686
|
+
members = closed_set(aggregate)
|
|
687
|
+
{ "field" => "priority.value", "value" => members.first, "requires" => ["attribute:#{name}.priority"] }
|
|
688
|
+
end
|
|
689
|
+
aggregate["queries"] << { "name" => "Listed", "wheres" => [where], "order_by" => aggregate["identity"].first }
|
|
690
|
+
end
|
|
691
|
+
|
|
692
|
+
def list_attr(aggregate)
|
|
693
|
+
name = aggregate["name"]
|
|
694
|
+
return if aggregate["attributes"].any? { |attribute| attribute["name"] == "tags" }
|
|
695
|
+
|
|
696
|
+
vo(aggregate, "#{name}Tag", "string")
|
|
697
|
+
aggregate["attributes"] << { "name" => "tags", "type" => "#{name}Tag", "list" => true }
|
|
698
|
+
command(aggregate, "Retag", args: [{ "name" => "tags", "type" => "#{name}Tag", "list" => true }],
|
|
699
|
+
sets: [{ "target" => "tags", "requires" => ["attribute:#{name}.tags"] }])
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Answers the closed set's members, so a query can filter on one.
|
|
703
|
+
def closed_set(aggregate)
|
|
704
|
+
name = aggregate["name"]
|
|
705
|
+
existing = aggregate["vos"]["#{name}Priority"]
|
|
706
|
+
return existing["members"] if existing
|
|
707
|
+
|
|
708
|
+
members = CLOSED_SETS.sample(random: @random)
|
|
709
|
+
vo(aggregate, "#{name}Priority", "closed", members: members)
|
|
710
|
+
aggregate["attributes"] << { "name" => "priority", "type" => "#{name}Priority", "optional" => true }
|
|
711
|
+
command(aggregate, "Prioritize", args: [{ "name" => "priority", "type" => "#{name}Priority" }],
|
|
712
|
+
sets: [{ "target" => "priority", "requires" => ["attribute:#{name}.priority"] }])
|
|
713
|
+
members
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
def default_attr(aggregate)
|
|
717
|
+
name = aggregate["name"]
|
|
718
|
+
return if aggregate["attributes"].any? { |attribute| attribute["name"] == "score" }
|
|
719
|
+
|
|
720
|
+
vo(aggregate, "#{name}Score", "integer")
|
|
721
|
+
aggregate["attributes"] << { "name" => "score", "type" => "#{name}Score", "default" => 0 }
|
|
722
|
+
givens = []
|
|
723
|
+
if chance?(0.5)
|
|
724
|
+
givens << { "label" => "a score never drops", "expr" => "amount.value >= score.value",
|
|
725
|
+
"requires" => ["attribute:#{name}.score"] }
|
|
726
|
+
end
|
|
727
|
+
command(aggregate, "Rescore", args: [{ "name" => "amount", "type" => "#{name}Score" }], givens: givens,
|
|
728
|
+
sets: [{ "target" => "score", "to" => "amount",
|
|
729
|
+
"requires" => ["attribute:#{name}.score"] }])
|
|
730
|
+
return unless chance?(0.5)
|
|
731
|
+
|
|
732
|
+
aggregate["invariants"] << { "label" => "a score is never negative", "expr" => "score.value >= 0",
|
|
733
|
+
"requires" => ["attribute:#{name}.score"] }
|
|
734
|
+
end
|
|
735
|
+
|
|
736
|
+
def optional_arg(aggregate)
|
|
737
|
+
name = aggregate["name"]
|
|
738
|
+
return if aggregate["attributes"].any? { |attribute| attribute["name"] == "note" }
|
|
739
|
+
|
|
740
|
+
vo(aggregate, "#{name}Note", "string")
|
|
741
|
+
aggregate["attributes"] << { "name" => "note", "type" => "#{name}Note", "optional" => true }
|
|
742
|
+
command(aggregate, "Annotate", args: [{ "name" => "note", "type" => "#{name}Note", "optional" => true }],
|
|
743
|
+
sets: [{ "target" => "note", "requires" => ["attribute:#{name}.note"] }])
|
|
744
|
+
end
|
|
745
|
+
|
|
746
|
+
# An aggregate-level `reference_to`, set by the owner's own creating
|
|
747
|
+
# command (`Member.Join`'s shape, qa/stress_domains/referral_chain).
|
|
748
|
+
def reference_attr(owner, target)
|
|
749
|
+
return unless target
|
|
750
|
+
return if owner["references"].include?(target["name"])
|
|
751
|
+
|
|
752
|
+
owner["references"] << target["name"]
|
|
753
|
+
open = creating(owner)
|
|
754
|
+
open["references"] << target["name"]
|
|
755
|
+
open["sets"] << { "target" => snake(target["name"]),
|
|
756
|
+
"requires" => ["reference:#{owner['name']}->#{target['name']}"] }
|
|
757
|
+
return unless target["lifecycle"] && chance?(0.5)
|
|
758
|
+
|
|
759
|
+
open["givens"] << { "label" => "the #{snake(target['name'])} is open",
|
|
760
|
+
"expr" => "#{snake(target['name'])}.status == \"open\"",
|
|
761
|
+
"requires" => ["lifecycle:#{target['name']}",
|
|
762
|
+
"command_reference:#{owner['name']}.Open->#{target['name']}"] }
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# primary -> middle -> root, root with a lifecycle.
|
|
766
|
+
def link_chain(chain)
|
|
767
|
+
primary, middle, root = chain
|
|
768
|
+
lifecycle(root)
|
|
769
|
+
reference_attr(middle, root)
|
|
770
|
+
reference_attr(primary, middle)
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
def two_hop_given(primary, middle, root)
|
|
774
|
+
return unless middle && root
|
|
775
|
+
|
|
776
|
+
path = "#{snake(middle['name'])}.#{snake(root['name'])}.status"
|
|
777
|
+
creating(primary)["givens"] << {
|
|
778
|
+
"label" => "the #{snake(middle['name'])}'s #{snake(root['name'])} is open", "expr" => "#{path} == \"open\"",
|
|
779
|
+
"requires" => ["lifecycle:#{root['name']}", "reference:#{middle['name']}->#{root['name']}",
|
|
780
|
+
"command_reference:#{primary['name']}.Open->#{middle['name']}"]
|
|
781
|
+
}
|
|
782
|
+
end
|
|
783
|
+
|
|
784
|
+
def multi_hop_where(primary, middle, root)
|
|
785
|
+
return unless middle && root
|
|
786
|
+
|
|
787
|
+
field = "#{snake(middle['name'])}/#{snake(root['name'])}/status"
|
|
788
|
+
primary["queries"] << {
|
|
789
|
+
"name" => "ThroughOpen#{root['name']}", "order_by" => primary["identity"].first,
|
|
790
|
+
"wheres" => [{ "field" => field, "value" => "open",
|
|
791
|
+
"requires" => ["lifecycle:#{root['name']}", "reference:#{middle['name']}->#{root['name']}",
|
|
792
|
+
"reference:#{primary['name']}->#{middle['name']}"] }]
|
|
793
|
+
}
|
|
794
|
+
end
|
|
795
|
+
|
|
796
|
+
# A command redeclaring the aggregate's own reference field under a
|
|
797
|
+
# plain value object — `Referral.Reassign`'s shape (ADR 0037 F5).
|
|
798
|
+
def revalued_reference(owner, target)
|
|
799
|
+
return unless target
|
|
800
|
+
|
|
801
|
+
reference_attr(owner, target)
|
|
802
|
+
field = snake(target["name"])
|
|
803
|
+
vo(owner, "#{target['name']}Handle", "string")
|
|
804
|
+
requires = ["reference:#{owner['name']}->#{target['name']}"]
|
|
805
|
+
command(owner, "Repoint", args: [{ "name" => field, "type" => "#{target['name']}Handle" }],
|
|
806
|
+
sets: [{ "target" => field, "requires" => requires }])
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
# ── extras, on every aggregate ───────────────────────────────
|
|
810
|
+
|
|
811
|
+
def extras(aggregate, aggregates)
|
|
812
|
+
extra_shape(aggregate)
|
|
813
|
+
extra_wiring(aggregate, aggregates)
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def extra_shape(aggregate)
|
|
817
|
+
lifecycle(aggregate) if chance?(0.4)
|
|
818
|
+
closed_set(aggregate) if chance?(0.25)
|
|
819
|
+
default_attr(aggregate) if chance?(0.25)
|
|
820
|
+
optional_arg(aggregate) if chance?(0.25)
|
|
821
|
+
entity(aggregate) if chance?(0.2)
|
|
822
|
+
query(aggregate) if chance?(0.3)
|
|
823
|
+
emits = creating(aggregate)["emits"]
|
|
824
|
+
emits << "#{aggregate['name']}Logged" if chance?(0.15) && emits.size == 1
|
|
825
|
+
end
|
|
826
|
+
|
|
827
|
+
def extra_wiring(aggregate, aggregates)
|
|
828
|
+
other = (aggregates - [aggregate]).sample(random: @random)
|
|
829
|
+
reference_attr(aggregate, other) if other && chance?(0.15) && !creates_cycle?(aggregate, other, aggregates)
|
|
830
|
+
aggregate["commands"].each { |command| command["role"] = ROLES.sample(random: @random) if chance?(0.3) }
|
|
831
|
+
policy(aggregate) if aggregate["lifecycle"] && chance?(0.25)
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def creates_cycle?(from, to, aggregates)
|
|
835
|
+
seen = Set.new
|
|
836
|
+
stack = [to["name"]]
|
|
837
|
+
until stack.empty?
|
|
838
|
+
name = stack.pop
|
|
839
|
+
return true if name == from["name"]
|
|
840
|
+
next unless seen.add?(name)
|
|
841
|
+
|
|
842
|
+
stack.concat(aggregates.find { |aggregate| aggregate["name"] == name }["references"])
|
|
843
|
+
end
|
|
844
|
+
false
|
|
845
|
+
end
|
|
846
|
+
|
|
847
|
+
def policy(aggregate)
|
|
848
|
+
name = aggregate["name"]
|
|
849
|
+
event = creating(aggregate)["emits"].first
|
|
850
|
+
@policies << { "name" => "On#{event}Close", "on" => "#{name}::#{event}", "trigger" => "#{name}::Close",
|
|
851
|
+
"requires" => ["event:#{name}.#{event}", "command:#{name}.Close", "lifecycle:#{name}"] }
|
|
852
|
+
end
|
|
853
|
+
|
|
854
|
+
def snake(name) = DomainGenerator.snake(name)
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
# What this generator can build, not everything the census names.
|
|
858
|
+
# This read `FormCensus::FORMS.keys`, which quietly assumed the two
|
|
859
|
+
# tables would always agree — and they stopped agreeing the moment
|
|
860
|
+
# the census learned a form (`corrects`, `role_gated`) that
|
|
861
|
+
# `Builder::FORM_STEPS` has no recipe for: `generate` raised
|
|
862
|
+
# `KeyError` for any seed that happened to draw one. The census
|
|
863
|
+
# measures what a domain has; this names what a generator can
|
|
864
|
+
# write, and a form in the first without the second simply is not
|
|
865
|
+
# generated — the rotation still meets it, and
|
|
866
|
+
# `spec/combination_coverage_spec.rb`'s own `HELD_OUTSIDE_THE_GOLDENS`
|
|
867
|
+
# names where. Kept honest by `spec/fuzzing/domain_generator_spec.rb`.
|
|
868
|
+
FORMS = Builder::FORM_STEPS.keys.freeze
|
|
869
|
+
end
|
|
870
|
+
end
|
|
871
|
+
end
|