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
|
@@ -1,17 +1,19 @@
|
|
|
1
|
+
require "hecks/vocabulary"
|
|
2
|
+
|
|
1
3
|
module Hecks
|
|
2
4
|
module Bluebook
|
|
3
5
|
# Lightweight formal methods over the IR — the same family as TLA+/
|
|
4
6
|
# Alloy/P: every lifecycle is a declared finite state machine and
|
|
5
|
-
# every process manager a declared protocol, and
|
|
6
|
-
# data, not code, so they can be
|
|
7
|
+
# every process manager a declared protocol, and both are already
|
|
8
|
+
# data, not code, so they can be model-checked rather than merely
|
|
7
9
|
# executed. Static analysis only — no bluebook boots twice, no
|
|
8
10
|
# runtime is touched — over what meta_validator/judge.rb and the
|
|
9
11
|
# builders' own `validate!` methods leave uncovered (an undeclared
|
|
10
12
|
# transition target, a dispatch to nowhere, a compensation nothing
|
|
11
13
|
# can ever reach).
|
|
12
14
|
#
|
|
13
|
-
#
|
|
14
|
-
# A checker over TLA+ verifies a
|
|
15
|
+
# **The rare property this rests on**: the model is the implementation.
|
|
16
|
+
# A checker over TLA+ verifies a spec a human keeps in sync with
|
|
15
17
|
# code by hand ; this verifies the same IR the runtime dispatches
|
|
16
18
|
# against, so there is no second copy to drift.
|
|
17
19
|
module ModelCheck
|
|
@@ -19,8 +21,8 @@ module Hecks
|
|
|
19
21
|
def to_s = "#{severity.to_s.upcase.ljust(7)} #{kind.to_s.ljust(20)} #{subject} — #{message}"
|
|
20
22
|
end
|
|
21
23
|
|
|
22
|
-
# A
|
|
23
|
-
# allowlists enforced
|
|
24
|
+
# **A finding shipped, not silenced** — the coverage-gate idiom, empty
|
|
25
|
+
# allowlists enforced both directions (spec/model_check_spec.rb holds
|
|
24
26
|
# this exact table: an error the checker reports and this does not
|
|
25
27
|
# name is a regression, an entry the checker no longer reports is
|
|
26
28
|
# stale and must be deleted). bin/model_check reads this same
|
|
@@ -29,45 +31,42 @@ module Hecks
|
|
|
29
31
|
# "banking"/ExternalSettlement — found on the first real run:
|
|
30
32
|
# ExternalSettlement declares `ends_on "ExternalTransferSent"`, and
|
|
31
33
|
# ExternalTransfer.Send genuinely emits it — the event is real, and
|
|
32
|
-
# the
|
|
33
|
-
# the
|
|
34
|
+
# the aggregate reaches "sent" (its own, separate lifecycle) — but
|
|
35
|
+
# the saga's protocol has no `on "ExternalTransferSent"` handler, so
|
|
34
36
|
# its own `state "sent"` is unreachable through the chain the
|
|
35
37
|
# checker walks, and the saga's own bookkeeping (saga_log, ends_on)
|
|
36
38
|
# never closes it. Real domain activity is unaffected; the saga's
|
|
37
|
-
#
|
|
39
|
+
# own tracking of it is not. Left named rather than redesigning a
|
|
38
40
|
# corpus fixture that is not this checker's to redesign.
|
|
39
|
-
# S7, ADR 0025 —
|
|
40
|
-
# allowlist is
|
|
41
|
-
# `state "x"` line never named by any handler's own from:/to:, a
|
|
42
|
-
# pure declaration-drift artifact. States are
|
|
41
|
+
# S7, ADR 0025 — an ExternalSettlement finding once lived here as an
|
|
42
|
+
# allowlist entry and is gone now, not just quieted: its "sent" state
|
|
43
|
+
# was a `state "x"` line never named by any handler's own from:/to:, a
|
|
44
|
+
# pure declaration-drift artifact. States are derived from the
|
|
43
45
|
# transitions that name them now (ProcessManagerBuilder#derived_
|
|
44
46
|
# states), so a state nothing ever transitions into or out of no
|
|
45
|
-
# longer exists to be unreachable — the finding
|
|
47
|
+
# longer exists to be unreachable — the finding that entry allowlisted
|
|
46
48
|
# cannot occur any more, by construction.
|
|
47
49
|
#
|
|
48
|
-
# "banking"/NotifyOnClosure, FlagKeyReturn —
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
50
|
+
# "banking"/NotifyOnClosure, FlagKeyReturn — gone from here, moved
|
|
51
|
+
# to banking. `across "Notifications"` names a domain that does not
|
|
52
|
+
# exist anywhere in this repo, deliberately (it exercises the
|
|
53
|
+
# undelivered-reaction runtime path — `spec/runtime/policy_spec.rb`,
|
|
54
|
+
# "records a reaction it cannot deliver rather than swallowing it").
|
|
55
|
+
# That expectation is now declared on the two policies themselves —
|
|
56
|
+
# `across "Notifications", expect_undelivered: true` — and
|
|
57
|
+
# `expected_undelivered_findings` below holds it in both directions:
|
|
58
|
+
# the unknown-target and unacknowledged-relationship findings are
|
|
59
|
+
# expected, and a declaration whose target turns out reachable is a
|
|
60
|
+
# `stale_undelivered_expectation` error. A domain's own allowance
|
|
61
|
+
# lives in its own source, never in a core table keyed by its name.
|
|
62
|
+
#
|
|
63
|
+
# Pinned empty (spec/model_check_spec.rb), the way `bin/fuzz`'s
|
|
64
|
+
# `KNOWN_FUZZ_FINDINGS` is: a finding a domain means to keep belongs
|
|
65
|
+
# in that domain's own declaration.
|
|
61
66
|
ALLOWED_FINDINGS = {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
[:unacknowledged_relationship, "FlagKeyReturn"],
|
|
66
|
-
[:unknown_target_domain, "FlagKeyReturn"]
|
|
67
|
-
]
|
|
68
|
-
# QualityControl WAS the first domain in this corpus to trigger an
|
|
69
|
-
# `asks`/`tells` PORT OPERATION from a `policy`, and used to carry
|
|
70
|
-
# two entries here for it — both GONE now, not just quieted:
|
|
67
|
+
# QualityControl was the first domain in this corpus to trigger an
|
|
68
|
+
# `asks`/`tells` port operation from a `policy`, and once carried
|
|
69
|
+
# two entries here for it — both gone now, not just quieted:
|
|
71
70
|
#
|
|
72
71
|
# `deaf_policy` (ClearOnPass, RefuseOnFail, RecordTheIssue,
|
|
73
72
|
# RecordTheRefusal) went first: `emitted_events` below now reads an
|
|
@@ -80,19 +79,19 @@ module Hecks
|
|
|
80
79
|
#
|
|
81
80
|
# `unknown_trigger` (FileWhenSubmitted, AskOnceMore) — BUG#23 — was
|
|
82
81
|
# never actually a `Naming`/`PolicyBuilder` defect, confirmed by
|
|
83
|
-
# tracing the real dispatch path rather than
|
|
84
|
-
# that
|
|
85
|
-
# rewrite
|
|
82
|
+
# tracing the real dispatch path rather than trusting a stale
|
|
83
|
+
# comment that once sat here: `Naming.command_ref`'s bare-constant
|
|
84
|
+
# rewrite does leave `trigger Ticket::IssueTracker::File` (aggregate,
|
|
86
85
|
# port, operation) as "Ticket::IssueTracker.File", a leftover `::`
|
|
87
86
|
# past the aggregate — but `PolicyInterpreter#deliver` re-qualifies
|
|
88
87
|
# every trigger with this domain's own name before dispatch
|
|
89
88
|
# ("QualityControl::Ticket::IssueTracker.File"), and `Naming.
|
|
90
89
|
# split_verb` already folds that reintroduced `::` into the
|
|
91
|
-
# dot-joined tail correctly (
|
|
92
|
-
# resolve_target
|
|
93
|
-
#
|
|
90
|
+
# dot-joined tail correctly (the same fix `ReactionInvocation#
|
|
91
|
+
# resolve_target` already carries) — confirmed live: a real
|
|
92
|
+
# dispatch through `Ticket.Submit` fires
|
|
94
93
|
# `IssueFiled`/`TicketFiled` exactly as declared. The actual gap was
|
|
95
|
-
# entirely in
|
|
94
|
+
# entirely in this checker: `verbs_of` never enumerated a port
|
|
96
95
|
# operation as a triggerable verb at all, and `policy_findings`
|
|
97
96
|
# compared raw strings instead of `Naming.split_verb` triples the
|
|
98
97
|
# way `handler_findings`'s own `unknown_dispatch` check already does
|
|
@@ -103,31 +102,156 @@ module Hecks
|
|
|
103
102
|
|
|
104
103
|
module_function
|
|
105
104
|
|
|
105
|
+
# Runs every static model check over `bluebook` and returns what it
|
|
106
|
+
# finds.
|
|
107
|
+
#
|
|
106
108
|
# `hecksagon:`/`known_domains:` — both optional, both `nil`-safe
|
|
107
109
|
# (every existing caller with no sibling hecksagon, or checking one
|
|
108
110
|
# domain in isolation, behaves exactly as before). `hecksagon` is
|
|
109
|
-
#
|
|
111
|
+
# this bluebook's own sibling wiring file, if the caller loaded one
|
|
110
112
|
# (see `emitted_events`'s own comment on why a caller that didn't
|
|
111
113
|
# simply finds none, correctly). `known_domains` is the caller's
|
|
112
|
-
#
|
|
114
|
+
# own corpus-wide view — every bluebook/hecksagon name it has
|
|
113
115
|
# booted anywhere, across every domain it has looked at, not just
|
|
114
116
|
# this one — used only to catch a typo'd `across`/`uses_framework`
|
|
115
117
|
# target; see `cross_domain_policy_findings`'s own comment for why
|
|
116
118
|
# this can only ever be a corpus-scoped heuristic, never a general
|
|
117
119
|
# correctness guarantee.
|
|
118
|
-
|
|
120
|
+
#
|
|
121
|
+
# `rust_target:`/`strict:` — both default false, both only change the
|
|
122
|
+
# severity of `rust_reserved_name` findings (see
|
|
123
|
+
# `rust_reserved_name_findings`); every other finding is unaffected.
|
|
124
|
+
#
|
|
125
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter to check
|
|
126
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
|
|
127
|
+
# sibling wiring file, if the caller loaded one
|
|
128
|
+
# @param known_domains [Set<String>, nil] every bluebook/hecksagon
|
|
129
|
+
# name the caller has booted anywhere in this corpus scan
|
|
130
|
+
# @param global_emitted_events [Set<String>, nil] every bare event
|
|
131
|
+
# name any domain the caller has booted this run emits — lets a
|
|
132
|
+
# same-domain-target policy (e.g. one built by `translates`)
|
|
133
|
+
# reacting to a genuinely foreign event avoid a false `deaf_policy`
|
|
134
|
+
# finding; `nil` (every pre-existing call site) keeps today's
|
|
135
|
+
# single-domain-only check
|
|
136
|
+
# @param rust_target [Boolean] whether this domain has a real Rust
|
|
137
|
+
# target, raising `rust_reserved_name` findings to error
|
|
138
|
+
# @param strict [Boolean] whether to raise every `rust_reserved_name`
|
|
139
|
+
# finding to error regardless of `rust_target`
|
|
140
|
+
# @return [Array<Finding>] every finding this bluebook triggers,
|
|
141
|
+
# across its lifecycles, sagas, policies, and Rust-reserved names
|
|
142
|
+
def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false)
|
|
119
143
|
findings = []
|
|
120
144
|
bluebook.aggregates.each do |aggregate|
|
|
121
145
|
findings.concat(lifecycle_findings(aggregate, aggregate))
|
|
122
146
|
aggregate.entities.each { |entity| findings.concat(lifecycle_findings(aggregate, entity)) }
|
|
123
147
|
end
|
|
124
148
|
bluebook.process_managers.each { |process_manager| findings.concat(saga_findings(bluebook, process_manager)) }
|
|
125
|
-
bluebook.policies.each
|
|
149
|
+
bluebook.policies.each do |policy|
|
|
150
|
+
findings.concat(policy_findings(bluebook, policy, hecksagon, known_domains, global_emitted_events))
|
|
151
|
+
end
|
|
152
|
+
findings.concat(rust_reserved_name_findings(domain_name: bluebook.name,
|
|
153
|
+
aggregate_names: bluebook.aggregates.map(&:hecks_name),
|
|
154
|
+
rust_target: rust_target, strict: strict))
|
|
155
|
+
findings
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# ── Rust reserved names ───────────────────────────────────────────
|
|
159
|
+
#
|
|
160
|
+
# A name that becomes a bare Rust module identifier with no `r#`
|
|
161
|
+
# escape hatch: an aggregate (`pub mod <name.downcase>;` plus its
|
|
162
|
+
# `<name.downcase>.rs` file) and a domain (`pub mod <name>;` and a
|
|
163
|
+
# Cargo `[features]` key). Field names are not checked — both
|
|
164
|
+
# generators already raw-escape those (`rust_ident_field`).
|
|
165
|
+
#
|
|
166
|
+
# The words come from the `RustReservedWord`/`CargoReservedName`
|
|
167
|
+
# vocabularies, the same tables `rust/project/naming.rb` and
|
|
168
|
+
# hecks-codegen's generated `reserved_names.rs` read. Both Rust
|
|
169
|
+
# generators refuse through this check (`Projector.
|
|
170
|
+
# reserved_name_refusal`, and its hecks-codegen port in `naming.rs`).
|
|
171
|
+
#
|
|
172
|
+
# Severity: a domain that only ever runs in Ruby is fine with an
|
|
173
|
+
# aggregate named `Match`, so this warns by default. It is an error
|
|
174
|
+
# when the caller says the domain has a Rust target (`rust_target:` —
|
|
175
|
+
# `bin/model_check` reads it off the domain's Cargo feature, the
|
|
176
|
+
# generators always pass it) or asks for strictness (`strict:`,
|
|
177
|
+
# `bin/model_check --strict`).
|
|
178
|
+
#
|
|
179
|
+
# The module-name transform is `downcase`, the one both generators
|
|
180
|
+
# apply to an aggregate name and to an attached chapter's name.
|
|
181
|
+
#
|
|
182
|
+
# @param domain_name [String, Symbol, nil] the domain's own name, or
|
|
183
|
+
# `nil` to skip the domain-level check
|
|
184
|
+
# @param aggregate_names [Array<String, Symbol>] every aggregate name
|
|
185
|
+
# to check
|
|
186
|
+
# @param rust_target [Boolean] whether this domain has a real Rust
|
|
187
|
+
# target, raising severity to error
|
|
188
|
+
# @param strict [Boolean] whether to raise severity to error
|
|
189
|
+
# regardless of `rust_target`
|
|
190
|
+
# @return [Array<Finding>] one `:rust_reserved_name` finding per
|
|
191
|
+
# aggregate or domain name that collides with a Rust or Cargo
|
|
192
|
+
# reserved word
|
|
193
|
+
def rust_reserved_name_findings(domain_name: nil, aggregate_names: [], rust_target: false, strict: false)
|
|
194
|
+
severity = rust_target || strict ? :error : :warning
|
|
195
|
+
keywords = Hecks::Vocabulary.fetch("RustReservedWord")
|
|
196
|
+
|
|
197
|
+
findings = aggregate_names.filter_map do |name|
|
|
198
|
+
module_name = rust_module_name(name)
|
|
199
|
+
next unless keywords.include?(module_name)
|
|
200
|
+
|
|
201
|
+
Finding.new(kind: :rust_reserved_name, severity: severity, subject: name.to_s,
|
|
202
|
+
message: "the aggregate's Rust module `#{module_name}` is a Rust keyword (RustReservedWord) — " \
|
|
203
|
+
"`pub mod #{module_name};` has no raw-identifier escape; rename the aggregate")
|
|
204
|
+
end
|
|
205
|
+
findings.concat(domain_reserved_name_findings(domain_name, keywords, severity)) if domain_name
|
|
126
206
|
findings
|
|
127
207
|
end
|
|
128
208
|
|
|
129
|
-
#
|
|
209
|
+
# Checks the domain's own module/Cargo-feature name, one level up
|
|
210
|
+
# from `rust_reserved_name_findings`'s own per-aggregate check.
|
|
211
|
+
#
|
|
212
|
+
# @param domain_name [String, Symbol] the domain's own name
|
|
213
|
+
# @param keywords [Array<String>] the admitted `RustReservedWord`
|
|
214
|
+
# vocabulary rows
|
|
215
|
+
# @param severity [Symbol] the finding severity to use, `:error` or
|
|
216
|
+
# `:warning`
|
|
217
|
+
# @return [Array<Finding>] a single-element array holding a
|
|
218
|
+
# `:rust_reserved_name` finding, or `[]` if `domain_name` collides
|
|
219
|
+
# with no reserved word or Cargo key
|
|
220
|
+
def domain_reserved_name_findings(domain_name, keywords, severity)
|
|
221
|
+
module_name = rust_module_name(domain_name)
|
|
222
|
+
table = if keywords.include?(module_name)
|
|
223
|
+
"a Rust keyword (RustReservedWord)"
|
|
224
|
+
elsif Hecks::Vocabulary.fetch("CargoReservedName").include?(module_name)
|
|
225
|
+
"a reserved Cargo.toml key (CargoReservedName)"
|
|
226
|
+
end
|
|
227
|
+
return [] unless table
|
|
228
|
+
|
|
229
|
+
[Finding.new(kind: :rust_reserved_name, severity: severity, subject: domain_name.to_s,
|
|
230
|
+
message: "the domain's Rust module and Cargo feature `#{module_name}` is #{table} — " \
|
|
231
|
+
"rename the domain")]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Derives the Rust module identifier both generators derive from a
|
|
235
|
+
# name.
|
|
236
|
+
#
|
|
237
|
+
# @param name [String, Symbol] an aggregate or domain name
|
|
238
|
+
# @return [String] the Rust module identifier both generators derive
|
|
239
|
+
# from `name`
|
|
240
|
+
def rust_module_name(name) = name.to_s.downcase
|
|
241
|
+
|
|
242
|
+
# ── lifecycles (aggregate and entity — a piece may declare one too) ──
|
|
130
243
|
|
|
244
|
+
# Runs every lifecycle-shaped model check for one declaring
|
|
245
|
+
# construct's own lifecycle, if it has one.
|
|
246
|
+
#
|
|
247
|
+
# @param aggregate [Bluebook::Aggregate] the root aggregate `declaring`
|
|
248
|
+
# belongs to
|
|
249
|
+
# @param declaring [Bluebook::Aggregate, Bluebook::Entity] the
|
|
250
|
+
# construct whose own lifecycle to check — `aggregate` itself, or
|
|
251
|
+
# one of its entities
|
|
252
|
+
# @return [Array<Finding>] `[]` if `declaring` has no lifecycle;
|
|
253
|
+
# otherwise every `unknown_command`/`unreachable_state`/
|
|
254
|
+
# `dead_transition`/`stuck_state` finding its lifecycle triggers
|
|
131
255
|
def lifecycle_findings(aggregate, declaring)
|
|
132
256
|
lifecycle = declaring.lifecycle
|
|
133
257
|
return [] unless lifecycle
|
|
@@ -146,6 +270,15 @@ module Hecks
|
|
|
146
270
|
findings
|
|
147
271
|
end
|
|
148
272
|
|
|
273
|
+
# Finds every declared state a lifecycle's own reachability walk
|
|
274
|
+
# never reaches.
|
|
275
|
+
#
|
|
276
|
+
# @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
|
|
277
|
+
# @param full [Array<String>] every state `full_states` declares
|
|
278
|
+
# @param reached [Set<String>] every state `reachable_states` reaches
|
|
279
|
+
# @param subject [String] the finding's own subject label
|
|
280
|
+
# @return [Array<Finding>] one `:unreachable_state` finding per
|
|
281
|
+
# declared state that `reached` does not cover
|
|
149
282
|
def unreachable_state_findings(lifecycle, full, reached, subject)
|
|
150
283
|
(full - reached.to_a).map do |state|
|
|
151
284
|
Finding.new(kind: :unreachable_state, severity: :error, subject: subject,
|
|
@@ -154,6 +287,13 @@ module Hecks
|
|
|
154
287
|
end
|
|
155
288
|
end
|
|
156
289
|
|
|
290
|
+
# Finds every constrained transition that can never fire.
|
|
291
|
+
#
|
|
292
|
+
# @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
|
|
293
|
+
# @param reached [Set<String>] every state `reachable_states` reaches
|
|
294
|
+
# @param subject [String] the finding's own subject label
|
|
295
|
+
# @return [Array<Finding>] one `:dead_transition` finding per
|
|
296
|
+
# constrained transition whose `from:` states are all unreached
|
|
157
297
|
def dead_transition_findings(lifecycle, reached, subject)
|
|
158
298
|
lifecycle.transitions.filter_map do |command, transition|
|
|
159
299
|
next unless transition.constrained?
|
|
@@ -165,6 +305,14 @@ module Hecks
|
|
|
165
305
|
end
|
|
166
306
|
end
|
|
167
307
|
|
|
308
|
+
# Finds every reached state with no transition ever leaving it.
|
|
309
|
+
#
|
|
310
|
+
# @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
|
|
311
|
+
# @param reached [Set<String>] every state `reachable_states` reaches
|
|
312
|
+
# @param subject [String] the finding's own subject label
|
|
313
|
+
# @return [Array<Finding>] one `:stuck_state` finding per reached,
|
|
314
|
+
# non-exempt state with no transition ever leaving it (skipped
|
|
315
|
+
# entirely when any transition is unconstrained)
|
|
168
316
|
def stuck_state_findings(lifecycle, reached, subject)
|
|
169
317
|
any_unconstrained = lifecycle.transitions.any? { |_, t| !t.constrained? }
|
|
170
318
|
(reached - terminal_exempt(lifecycle)).filter_map do |state|
|
|
@@ -177,6 +325,15 @@ module Hecks
|
|
|
177
325
|
end
|
|
178
326
|
end
|
|
179
327
|
|
|
328
|
+
# Finds every transition named after a command this construct does
|
|
329
|
+
# not declare.
|
|
330
|
+
#
|
|
331
|
+
# @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
|
|
332
|
+
# @param commands [Array<String>] every command name the declaring
|
|
333
|
+
# construct actually declares
|
|
334
|
+
# @param subject [String] the finding's own subject label
|
|
335
|
+
# @return [Array<Finding>] one `:unknown_command` finding per
|
|
336
|
+
# transition named after a command `commands` does not list
|
|
180
337
|
def unknown_transition_commands(lifecycle, commands, subject)
|
|
181
338
|
lifecycle.transitions.filter_map do |command, _transition|
|
|
182
339
|
next if commands.include?(command)
|
|
@@ -190,6 +347,10 @@ module Hecks
|
|
|
190
347
|
# from-only state (declared nowhere as a target) is real and is
|
|
191
348
|
# exactly the hole `Lifecycle#states` leaves: it answers default
|
|
192
349
|
# plus targets only.
|
|
350
|
+
#
|
|
351
|
+
# @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
|
|
352
|
+
# @return [Array<String>] every state `lifecycle` declares, in any
|
|
353
|
+
# role (default, target, or from), unique
|
|
193
354
|
def full_states(lifecycle)
|
|
194
355
|
(
|
|
195
356
|
[lifecycle.default] +
|
|
@@ -198,9 +359,13 @@ module Hecks
|
|
|
198
359
|
).uniq
|
|
199
360
|
end
|
|
200
361
|
|
|
201
|
-
# Least fixpoint from the default state: an
|
|
362
|
+
# Least fixpoint from the default state: an unconstrained
|
|
202
363
|
# transition always fires, from wherever the machine is ; a
|
|
203
364
|
# constrained one fires once any of its named sources is reached.
|
|
365
|
+
#
|
|
366
|
+
# @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
|
|
367
|
+
# @return [Set<String>] every state reachable from `lifecycle`'s own
|
|
368
|
+
# default state
|
|
204
369
|
def reachable_states(lifecycle)
|
|
205
370
|
reached = Set.new([lifecycle.default])
|
|
206
371
|
loop do
|
|
@@ -222,8 +387,8 @@ module Hecks
|
|
|
222
387
|
reached
|
|
223
388
|
end
|
|
224
389
|
|
|
225
|
-
# A state with no
|
|
226
|
-
# stuck-state
|
|
390
|
+
# A state with no outgoing declared path at all is exempt from the
|
|
391
|
+
# stuck-state warning for a different reason than "it fires an
|
|
227
392
|
# unconstrained transition" — the default state of a lifecycle
|
|
228
393
|
# with only constrained transitions is legitimately allowed to sit
|
|
229
394
|
# forever, since nothing about *entering* it via default implies
|
|
@@ -231,13 +396,17 @@ module Hecks
|
|
|
231
396
|
# explicitly delivered somewhere.
|
|
232
397
|
#
|
|
233
398
|
# Scoped to `default` alone, and only when the lifecycle actually
|
|
234
|
-
# declares real transitions elsewhere: an
|
|
399
|
+
# declares real transitions elsewhere: an empty lifecycle (no
|
|
235
400
|
# transitions at all) doesn't get this exemption — that's not "a
|
|
236
401
|
# machine whose entry point deliberately awaits external action,"
|
|
237
402
|
# it's much more likely a lifecycle nobody finished wiring, and
|
|
238
403
|
# should still warn (see spec/fixtures/model_check/lifecycle_
|
|
239
404
|
# findings.bluebook's own Widget::Part, which stays warned on
|
|
240
405
|
# purpose).
|
|
406
|
+
#
|
|
407
|
+
# @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
|
|
408
|
+
# @return [Array<String>] `[lifecycle.default]` when it is exempt from
|
|
409
|
+
# the stuck-state warning, `[]` otherwise
|
|
241
410
|
def terminal_exempt(lifecycle)
|
|
242
411
|
return [] if lifecycle.transitions.empty?
|
|
243
412
|
|
|
@@ -247,9 +416,18 @@ module Hecks
|
|
|
247
416
|
|
|
248
417
|
# ── process managers / sagas ──────────────────────────────────────
|
|
249
418
|
|
|
419
|
+
# Runs every saga-shaped model check for one process manager.
|
|
420
|
+
#
|
|
421
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter
|
|
422
|
+
# `process_manager` belongs to
|
|
423
|
+
# @param process_manager [Bluebook::ProcessManager] the process
|
|
424
|
+
# manager to check
|
|
425
|
+
# @return [Array<Finding>] every `deaf_trigger`/`unreachable_pm_state`/
|
|
426
|
+
# `deaf_handler`/`unknown_dispatch`/`unarmed_compensation`/
|
|
427
|
+
# `dead_compensation` finding this process manager triggers
|
|
250
428
|
def saga_findings(bluebook, process_manager)
|
|
251
429
|
emitted = emitted_events(bluebook)
|
|
252
|
-
# (domain, aggregate, command)
|
|
430
|
+
# (domain, aggregate, command) triples, not raw strings — see
|
|
253
431
|
# `handler_findings`'s own comment on the dispatch side for why:
|
|
254
432
|
# `Naming.split_verb` is what makes an entity verb's two legitimate
|
|
255
433
|
# spellings (`Naming.command_ref`'s own `::`-then-`.` rewrite vs.
|
|
@@ -267,6 +445,15 @@ module Hecks
|
|
|
267
445
|
findings
|
|
268
446
|
end
|
|
269
447
|
|
|
448
|
+
# Finds every declared `starts_on`/`ends_on` event this domain never
|
|
449
|
+
# emits.
|
|
450
|
+
#
|
|
451
|
+
# @param process_manager [Bluebook::ProcessManager] the process
|
|
452
|
+
# manager being checked
|
|
453
|
+
# @param emitted [Array<String>] every bare event name this domain
|
|
454
|
+
# actually emits
|
|
455
|
+
# @return [Array<Finding>] one `:deaf_trigger` finding per declared
|
|
456
|
+
# `starts_on`/`ends_on` event this domain never emits
|
|
270
457
|
def deaf_trigger_findings(process_manager, emitted)
|
|
271
458
|
[process_manager.starts_on, process_manager.ends_on].compact.filter_map do |event|
|
|
272
459
|
next if emitted.include?(bare(event))
|
|
@@ -277,6 +464,14 @@ module Hecks
|
|
|
277
464
|
end
|
|
278
465
|
end
|
|
279
466
|
|
|
467
|
+
# Finds every declared state no handler chain ever reaches.
|
|
468
|
+
#
|
|
469
|
+
# @param process_manager [Bluebook::ProcessManager] the process
|
|
470
|
+
# manager being checked
|
|
471
|
+
# @param reached [Set<String>] every state `pm_reachable_states`
|
|
472
|
+
# reaches
|
|
473
|
+
# @return [Array<Finding>] one `:unreachable_pm_state` finding per
|
|
474
|
+
# declared state that `reached` does not cover
|
|
280
475
|
def unreachable_pm_state_findings(process_manager, reached)
|
|
281
476
|
(Array(process_manager.states) - reached.to_a).map do |state|
|
|
282
477
|
Finding.new(kind: :unreachable_pm_state, severity: :error, subject: process_manager.name,
|
|
@@ -285,17 +480,31 @@ module Hecks
|
|
|
285
480
|
end
|
|
286
481
|
end
|
|
287
482
|
|
|
288
|
-
#
|
|
483
|
+
# One handler's own findings — deaf_handler, unknown_dispatch (one
|
|
289
484
|
# per dispatch), and unarmed_compensation (one per compensating
|
|
290
485
|
# dispatch), pulled out of saga_findings' own handler loop; each
|
|
291
486
|
# check reads only this handler plus the domain-wide emitted/verbs
|
|
292
|
-
# sets saga_findings already resolved once, no state shared
|
|
487
|
+
# sets saga_findings already resolved once, no state shared between
|
|
293
488
|
# handlers.
|
|
489
|
+
#
|
|
490
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter
|
|
491
|
+
# `process_manager` belongs to
|
|
492
|
+
# @param process_manager [Bluebook::ProcessManager] the process
|
|
493
|
+
# manager `handler` belongs to
|
|
494
|
+
# @param emitted [Array<String>] every bare event name this domain
|
|
495
|
+
# actually emits
|
|
496
|
+
# @param verbs [Array<Array(String, String, String)>] every command
|
|
497
|
+
# this domain declares, parsed as `Naming.split_verb`
|
|
498
|
+
# `[domain, aggregate, command]` triples
|
|
499
|
+
# @param handler [Bluebook::ProcessManagerHandler] the handler to
|
|
500
|
+
# check
|
|
501
|
+
# @return [Array<Finding>] every `deaf_handler`/`unknown_dispatch`/
|
|
502
|
+
# `unarmed_compensation` finding this handler triggers
|
|
294
503
|
def handler_findings(bluebook, process_manager, emitted, verbs, handler)
|
|
295
504
|
findings = []
|
|
296
505
|
|
|
297
|
-
# The compensating leg answers REFUSED
|
|
298
|
-
# command ever emits by name (ProcessManager::REFUSED) — not
|
|
506
|
+
# The compensating leg answers `REFUSED`, a synthetic trigger no
|
|
507
|
+
# command ever emits by name (`ProcessManager::REFUSED`) — not
|
|
299
508
|
# a deaf handler, the one handler this domain's own events can
|
|
300
509
|
# never satisfy on purpose.
|
|
301
510
|
if handler.event_type != ProcessManager::REFUSED && !emitted.include?(bare(handler.event_type))
|
|
@@ -305,22 +514,22 @@ module Hecks
|
|
|
305
514
|
end
|
|
306
515
|
|
|
307
516
|
handler.dispatches.each do |dispatch|
|
|
308
|
-
#
|
|
309
|
-
# #qualified` (BUG#6).
|
|
310
|
-
# `command_name` still
|
|
311
|
-
# command_ref`'s own rewrite
|
|
312
|
-
#
|
|
517
|
+
# **Always this domain** — same fix, same reason, as `SagaInterpreter
|
|
518
|
+
# #qualified` (BUG#6). Guessing instead — reading a dispatch whose
|
|
519
|
+
# own `command_name` still carries a leftover `::` after `Naming.
|
|
520
|
+
# command_ref`'s own rewrite as "already qualified" and leaving it
|
|
521
|
+
# alone — would hit the exact same string-shape ambiguity that
|
|
313
522
|
# `SagaInterpreter#qualified`'s own comment explains at length
|
|
314
523
|
# (a same-domain entity command reference and a genuinely
|
|
315
524
|
# cross-domain one are textually indistinguishable after that
|
|
316
525
|
# rewrite). Confirmed against the entire corpus, same as that
|
|
317
526
|
# fix: no saga anywhere ever dispatches genuinely cross-domain,
|
|
318
|
-
# so this checker
|
|
527
|
+
# so this checker qualifies exactly the way the runtime
|
|
319
528
|
# actually dispatches — unconditionally against `bluebook.name`
|
|
320
529
|
# — instead of maintaining its own, independently-wrong copy of
|
|
321
530
|
# the same guess.
|
|
322
531
|
#
|
|
323
|
-
#
|
|
532
|
+
# Compared as a triple, not a string — `Naming.command_ref`'s
|
|
324
533
|
# own rewrite of an entity reference (`Manifest::Slot::Fill`)
|
|
325
534
|
# collapses to "Manifest::Slot.Fill" (`::` between aggregate and
|
|
326
535
|
# entity, `.` before the command); `verbs_of`'s own entity
|
|
@@ -345,11 +554,11 @@ module Hecks
|
|
|
345
554
|
"checker's scope, same as CommandRules#resolve_references")
|
|
346
555
|
end
|
|
347
556
|
|
|
348
|
-
# A `compensates`
|
|
557
|
+
# A `compensates` declared with nowhere to ever fire — the exact
|
|
349
558
|
# shape of the real bug this whole feature closes ("the
|
|
350
559
|
# reversal was written and never armed"), caught at build/
|
|
351
560
|
# model-check time instead of discovered in production. No
|
|
352
|
-
# handler anywhere answers REFUSED (`process_manager.saga?` false) means
|
|
561
|
+
# handler anywhere answers `REFUSED` (`process_manager.saga?` false) means
|
|
353
562
|
# `SagaInterpreter#unwind` never runs for this process
|
|
354
563
|
# manager at all, so a declared `compensates` is structurally
|
|
355
564
|
# unreachable — not a warning about style, a dead declaration.
|
|
@@ -365,6 +574,16 @@ module Hecks
|
|
|
365
574
|
findings
|
|
366
575
|
end
|
|
367
576
|
|
|
577
|
+
# Checks whether a saga's own compensation leaves an unreachable
|
|
578
|
+
# state.
|
|
579
|
+
#
|
|
580
|
+
# @param process_manager [Bluebook::ProcessManager] the process
|
|
581
|
+
# manager being checked
|
|
582
|
+
# @param reached [Set<String>] every state `pm_reachable_states`
|
|
583
|
+
# reaches
|
|
584
|
+
# @return [Array<Finding>] a single-element array holding a
|
|
585
|
+
# `:dead_compensation` finding, or `[]` if `process_manager` has no
|
|
586
|
+
# saga or its own compensation leaves a reached state
|
|
368
587
|
def dead_compensation_findings(process_manager, reached)
|
|
369
588
|
return [] unless process_manager.saga? && !reached.include?(process_manager.saga.from_state)
|
|
370
589
|
|
|
@@ -374,13 +593,21 @@ module Hecks
|
|
|
374
593
|
end
|
|
375
594
|
|
|
376
595
|
# A handler edge is only usable in the closure if it can actually
|
|
377
|
-
#
|
|
596
|
+
# fire — `REFUSED` always can (it is a compensation trigger, not an
|
|
378
597
|
# event), and any other handler needs its event genuinely emitted.
|
|
379
598
|
# Without this, a deaf handler's declared from_state -> to_state
|
|
380
599
|
# pair reads as connected even though nothing can ever traverse
|
|
381
600
|
# it, which would hide exactly the states this walk exists to
|
|
382
601
|
# catch (a state only "reachable" through a handler that itself
|
|
383
602
|
# never fires).
|
|
603
|
+
#
|
|
604
|
+
# @param process_manager [Bluebook::ProcessManager] the process
|
|
605
|
+
# manager being checked
|
|
606
|
+
# @param emitted [Array<String>] every bare event name this domain
|
|
607
|
+
# actually emits
|
|
608
|
+
# @return [Set<String>] every state reachable from
|
|
609
|
+
# `process_manager`'s own first declared state, through handlers
|
|
610
|
+
# that can actually fire
|
|
384
611
|
def pm_reachable_states(process_manager, emitted)
|
|
385
612
|
return Set.new if Array(process_manager.states).empty?
|
|
386
613
|
|
|
@@ -402,14 +629,28 @@ module Hecks
|
|
|
402
629
|
|
|
403
630
|
# ── policies ───────────────────────────────────────────────────────
|
|
404
631
|
|
|
405
|
-
|
|
632
|
+
# Runs every same-domain policy check, or defers to
|
|
633
|
+
# `cross_domain_policy_findings` for a policy that targets another
|
|
634
|
+
# domain.
|
|
635
|
+
#
|
|
636
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter `policy`
|
|
637
|
+
# belongs to
|
|
638
|
+
# @param policy [Bluebook::Policy] the policy to check
|
|
639
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
|
|
640
|
+
# sibling wiring file, if the caller loaded one
|
|
641
|
+
# @param known_domains [Set<String>, nil] every bluebook/hecksagon
|
|
642
|
+
# name the caller has booted anywhere in this corpus scan
|
|
643
|
+
# @return [Array<Finding>] every `deaf_policy`/`unknown_trigger`
|
|
644
|
+
# finding this policy triggers, or `cross_domain_policy_findings`'s
|
|
645
|
+
# own return for a cross-domain policy
|
|
646
|
+
def policy_findings(bluebook, policy, hecksagon, known_domains, global_emitted_events = nil)
|
|
406
647
|
return cross_domain_policy_findings(policy, hecksagon, known_domains) if policy.target_domain
|
|
407
648
|
|
|
408
649
|
emitted = emitted_events(bluebook)
|
|
409
650
|
findings = []
|
|
410
651
|
|
|
411
|
-
# `policy.event_name` (Naming.unqualified) —
|
|
412
|
-
# strips a "::" domain qualifier. An
|
|
652
|
+
# `policy.event_name` (Naming.unqualified) — not `bare`, which only
|
|
653
|
+
# strips a "::" domain qualifier. An aggregate-scoped policy's
|
|
413
654
|
# `on_event` carries a "." aggregate qualifier instead (PolicyBuilder
|
|
414
655
|
# stores whatever was typed, verbatim — see `on "Account.
|
|
415
656
|
# AccountFrozen"`), and `bare` left it untouched, silently comparing
|
|
@@ -419,7 +660,18 @@ module Hecks
|
|
|
419
660
|
# prior aggregate-scoped policy (ReviewOnFreeze) is also cross-domain
|
|
420
661
|
# (`across "Compliance"`), which exits this method one line above
|
|
421
662
|
# before the mismatch is ever reached.
|
|
422
|
-
|
|
663
|
+
# `global_emitted_events` — a `translates` (hecksagon-level)
|
|
664
|
+
# reaction is BUILT as the exact same same-domain-target `Policy`
|
|
665
|
+
# an ordinary `policy` block is (no `target_domain`, so it never
|
|
666
|
+
# reaches `cross_domain_policy_findings` above), but its whole
|
|
667
|
+
# point is to react to a FOREIGN domain's own event — this
|
|
668
|
+
# domain's own `emitted_events(bluebook)` was never going to
|
|
669
|
+
# contain it. Checked only as a FALLBACK, after the local check
|
|
670
|
+
# already failed, so a real same-domain typo still gets flagged
|
|
671
|
+
# exactly as before whenever the caller has no cross-domain set
|
|
672
|
+
# to offer (nil — every existing call site, unchanged) or the
|
|
673
|
+
# event genuinely isn't emitted anywhere this run examined.
|
|
674
|
+
unless emitted.include?(policy.event_name) || global_emitted_events&.include?(policy.event_name)
|
|
423
675
|
findings << Finding.new(kind: :deaf_policy, severity: :error, subject: policy.name,
|
|
424
676
|
message: "on #{policy.on_event.inspect}, which no command in this domain emits")
|
|
425
677
|
end
|
|
@@ -429,13 +681,13 @@ module Hecks
|
|
|
429
681
|
# "#{domain}::#{trigger_command}" — the same join `verbs_of` builds
|
|
430
682
|
# independently, so the two spellings have to be compared as FQNs.
|
|
431
683
|
#
|
|
432
|
-
#
|
|
684
|
+
# Compared as a triple, not a string — `handler_findings`'s own
|
|
433
685
|
# `unknown_dispatch` check (BUG#6) already applies this fix for a
|
|
434
686
|
# saga's dispatch; a policy's `trigger` needed the identical one. A
|
|
435
|
-
# policy triggering an `asks`/`tells`
|
|
687
|
+
# policy triggering an `asks`/`tells` port operation (`Aggregate::
|
|
436
688
|
# Port::Operation`, three colon-joined segments — `Naming.command_ref`'s
|
|
437
689
|
# bare-constant rewrite turns this into "Aggregate::Port.Operation",
|
|
438
|
-
# a leftover `::` past the aggregate) is a real,
|
|
690
|
+
# a leftover `::` past the aggregate) is a real, working dispatch —
|
|
439
691
|
# `PolicyInterpreter#deliver` qualifies it with this domain's own
|
|
440
692
|
# name before `Naming.split_verb` ever sees it, and `split_verb`
|
|
441
693
|
# already folds that leftover `::` into the dot-joined tail
|
|
@@ -458,32 +710,44 @@ module Hecks
|
|
|
458
710
|
|
|
459
711
|
# ── cross-domain policies (Context Mapping) ───────────────────────
|
|
460
712
|
#
|
|
461
|
-
# `uses_framework "X"` already
|
|
462
|
-
# merges X's own bluebook into
|
|
463
|
-
# domain `policy ... across: "X"` already
|
|
713
|
+
# `uses_framework "X"` already is a Shared Kernel relationship — it
|
|
714
|
+
# merges X's own bluebook into this registry, no boundary. A cross-
|
|
715
|
+
# domain `policy ... across: "X"` already is a Customer/Supplier
|
|
464
716
|
# relationship — it dispatches into X over real cross-Lambda RPC in
|
|
465
717
|
# the Rust host (`rust/host/src/lambda_client.rs`). Neither is a new
|
|
466
|
-
# word; this makes the
|
|
718
|
+
# word; this makes the choice between them checked instead of a
|
|
467
719
|
# prose comment nobody enforces (`examples/banking/bluebook/
|
|
468
720
|
# banking.hecksagon`'s own hand-written note explaining why
|
|
469
721
|
# Compliance is reached via `across`, never `uses_framework`).
|
|
470
722
|
#
|
|
471
|
-
#
|
|
723
|
+
# No new keyword anywhere — ADR 0025 principle 1 ("one idea, one
|
|
472
724
|
# spelling") refuses a `relationship:`/`as:` argument that would
|
|
473
725
|
# just restate, as a string, the fact the chosen keyword (
|
|
474
726
|
# `uses_framework` vs `across`) already states completely. The
|
|
475
727
|
# DDD vocabulary (Shared Kernel, Customer/Supplier) lives here, in
|
|
476
728
|
# the finding's own name and this comment, and in prose docs — not
|
|
477
729
|
# in the grammar.
|
|
730
|
+
#
|
|
731
|
+
# @param policy [Bluebook::Policy] the cross-domain policy to check
|
|
732
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
|
|
733
|
+
# sibling wiring file, if the caller loaded one
|
|
734
|
+
# @param known_domains [Set<String>, nil] every bluebook/hecksagon
|
|
735
|
+
# name the caller has booted anywhere in this corpus scan
|
|
736
|
+
# @return [Array<Finding>] `expected_undelivered_findings`'s own
|
|
737
|
+
# return for a policy declared `expect_undelivered`; otherwise every
|
|
738
|
+
# `contradictory_relationship`/`unacknowledged_relationship`/
|
|
739
|
+
# `unknown_target_domain` finding this policy triggers
|
|
478
740
|
def cross_domain_policy_findings(policy, hecksagon, known_domains)
|
|
479
|
-
return
|
|
741
|
+
return expected_undelivered_findings(policy, hecksagon, known_domains) if policy.expect_undelivered
|
|
742
|
+
# No sibling hecksagon loaded — nothing to check a relationship against.
|
|
743
|
+
return [] unless hecksagon
|
|
480
744
|
|
|
481
745
|
target = policy.target_domain
|
|
482
746
|
findings = []
|
|
483
747
|
|
|
484
748
|
if hecksagon.framework_members.include?(target)
|
|
485
|
-
#
|
|
486
|
-
#
|
|
749
|
+
# Shared kernel and customer/supplier are mutually exclusive
|
|
750
|
+
# claims about the same target — `uses_framework` means "X is
|
|
487
751
|
# loaded in-process, right here"; `across` means "X is a
|
|
488
752
|
# separate deployment, reached only by RPC." Declaring both is
|
|
489
753
|
# either a pointless RPC to a domain already local, or a
|
|
@@ -495,7 +759,7 @@ module Hecks
|
|
|
495
759
|
"relationship declarations contradict each other for the same " \
|
|
496
760
|
"target domain")
|
|
497
761
|
elsif hecksagon.subscriptions.none? { |subscribed| Naming.qualifier(subscribed) == target }
|
|
498
|
-
#
|
|
762
|
+
# This is what finally gives `subscribe` real teeth — checked
|
|
499
763
|
# here, at model-check time, still never routed at runtime
|
|
500
764
|
# (nothing dispatches off a `subscribe` line; see hecksagon.md's
|
|
501
765
|
# own "checked, not routed" section). ADR 0025 names `subscribe`
|
|
@@ -508,43 +772,77 @@ module Hecks
|
|
|
508
772
|
"attach it in-process instead")
|
|
509
773
|
end
|
|
510
774
|
|
|
511
|
-
#
|
|
512
|
-
# ever be a
|
|
775
|
+
# **Typo detection, deliberately weaker** — `known_domains` can only
|
|
776
|
+
# ever be a monorepo-scoped heuristic: a real external hecks
|
|
513
777
|
# consumer's own domain (this repo's own embryonaut/lifeadelics-
|
|
514
778
|
# shaped case) lives in a genuinely separate repository this
|
|
515
779
|
# corpus scan can never see, so a target this check cannot find
|
|
516
|
-
# is "unknown to THIS corpus," never proof of a typo.
|
|
517
|
-
#
|
|
518
|
-
# undefined by design (the corpus's own "Notifications," used
|
|
780
|
+
# is "unknown to THIS corpus," never proof of a typo. A target
|
|
781
|
+
# undefined by design (the corpus's own "Notifications", used
|
|
519
782
|
# deliberately to exercise the undelivered-reaction runtime path)
|
|
520
|
-
#
|
|
521
|
-
# `
|
|
522
|
-
#
|
|
523
|
-
# keyword invented to declare "this one's fine."
|
|
783
|
+
# declares so on its own policy — `expect_undelivered: true`,
|
|
784
|
+
# checked by `expected_undelivered_findings` — rather than being
|
|
785
|
+
# named in a core allowlist.
|
|
524
786
|
if known_domains && !known_domains.include?(target)
|
|
525
787
|
findings << Finding.new(kind: :unknown_target_domain, severity: :error, subject: policy.name,
|
|
526
788
|
message: "across #{target.inspect} names a domain nowhere in the corpus " \
|
|
527
|
-
"this check has booted — a typo, or a
|
|
528
|
-
"
|
|
529
|
-
"
|
|
530
|
-
"explained, not silently assumed correct")
|
|
789
|
+
"this check has booted — a typo, or a target intentionally never " \
|
|
790
|
+
"reached, which the policy itself declares with " \
|
|
791
|
+
"across #{target.inspect}, expect_undelivered: true")
|
|
531
792
|
end
|
|
532
793
|
|
|
533
794
|
findings
|
|
534
795
|
end
|
|
535
796
|
|
|
797
|
+
# A declared undelivered target, held to its declaration. The two
|
|
798
|
+
# findings an unreachable `across` target raises (unknown target,
|
|
799
|
+
# unacknowledged relationship) are what the policy declared it
|
|
800
|
+
# expects, so they are not raised. What is raised is the declaration
|
|
801
|
+
# going stale: the target is a domain this corpus actually booted,
|
|
802
|
+
# or the sibling hecksagon attaches or subscribes to it — either way
|
|
803
|
+
# the reaction can be delivered, and the declaration is now a lie.
|
|
804
|
+
# `known_domains` is nil for a single-target run, which can then only
|
|
805
|
+
# check the hecksagon half.
|
|
806
|
+
#
|
|
807
|
+
# @param policy [Bluebook::Policy] the `expect_undelivered` policy to
|
|
808
|
+
# check
|
|
809
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
|
|
810
|
+
# sibling wiring file, if the caller loaded one
|
|
811
|
+
# @param known_domains [Set<String>, nil] every bluebook/hecksagon
|
|
812
|
+
# name the caller has booted anywhere in this corpus scan
|
|
813
|
+
# @return [Array<Finding>] a single-element array holding a
|
|
814
|
+
# `:stale_undelivered_expectation` finding, or `[]` if the target
|
|
815
|
+
# remains genuinely unreachable
|
|
816
|
+
def expected_undelivered_findings(policy, hecksagon, known_domains)
|
|
817
|
+
target = policy.target_domain
|
|
818
|
+
reached = []
|
|
819
|
+
reached << "#{target} is a domain this corpus boots" if known_domains&.include?(target)
|
|
820
|
+
if hecksagon
|
|
821
|
+
reached << "this hecksagon uses_framework #{target.inspect}" if hecksagon.framework_members.include?(target)
|
|
822
|
+
if hecksagon.subscriptions.any? { |subscribed| Naming.qualifier(subscribed) == target }
|
|
823
|
+
reached << "this hecksagon subscribes to #{target}"
|
|
824
|
+
end
|
|
825
|
+
end
|
|
826
|
+
return [] if reached.empty?
|
|
827
|
+
|
|
828
|
+
[Finding.new(kind: :stale_undelivered_expectation, severity: :error, subject: policy.name,
|
|
829
|
+
message: "across #{target.inspect}, expect_undelivered: true — but #{reached.join(' and ')}, " \
|
|
830
|
+
"so the reaction can be delivered after all; drop expect_undelivered: or remove " \
|
|
831
|
+
"what reaches #{target}")]
|
|
832
|
+
end
|
|
833
|
+
|
|
536
834
|
# ── shared enumeration ────────────────────────────────────────────
|
|
537
835
|
|
|
538
|
-
# A
|
|
836
|
+
# A port operation emits too — the primary/driving port an adapter
|
|
539
837
|
# outside the bluebook calls through (see hecksagon_builder.rb) is a
|
|
540
838
|
# second, real source of events, alongside a command's own `emits`.
|
|
541
|
-
# Ports attach to the aggregate/bluebook from the
|
|
839
|
+
# Ports attach to the aggregate/bluebook from the sibling `.hecksagon`
|
|
542
840
|
# file, not this one — a caller that boots only the `.bluebook` (as
|
|
543
841
|
# the fixtures under spec/fixtures/model_check/ do, having no
|
|
544
842
|
# hecksagon at all) simply finds none, which is correct : nothing
|
|
545
843
|
# can be deaf to an event that isn't even wired up yet.
|
|
546
844
|
#
|
|
547
|
-
#
|
|
845
|
+
# An outbound operation (`asks`) emits through a different door — it
|
|
548
846
|
# declares no `.emits` at all (`PortOperationBuilder#refuse_wrong_
|
|
549
847
|
# words!` refuses one that tries), naming its two real endings
|
|
550
848
|
# `.answers`/`.refuses` instead (`PortOperation#initialize`). Reading
|
|
@@ -552,9 +850,14 @@ module Hecks
|
|
|
552
850
|
# method — real, live events a policy genuinely reacts to
|
|
553
851
|
# (`Clearance.SuitePassed`/`SuiteFailed`, `Ticket.IssueFiled`/
|
|
554
852
|
# `IssueFilingRefused`), reported as `deaf_policy` findings until this
|
|
555
|
-
# read both. `.compact` because an
|
|
853
|
+
# read both. `.compact` because an inbound operation's `.answers`/
|
|
556
854
|
# `.refuses` are always nil (there is no channel back to tell), which
|
|
557
855
|
# would otherwise seed every emitted-events set with a stray nil.
|
|
856
|
+
#
|
|
857
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter to
|
|
858
|
+
# enumerate
|
|
859
|
+
# @return [Array<String>] every bare event name this domain's
|
|
860
|
+
# commands and port operations emit, answer, or refuse, unique
|
|
558
861
|
def emitted_events(bluebook)
|
|
559
862
|
aggregate_emits = bluebook.aggregates.flat_map do |aggregate|
|
|
560
863
|
aggregate.commands.map(&:emits) +
|
|
@@ -566,13 +869,18 @@ module Hecks
|
|
|
566
869
|
(aggregate_emits + chapter_emits).flatten.compact.uniq
|
|
567
870
|
end
|
|
568
871
|
|
|
569
|
-
#
|
|
872
|
+
# One operation, either of its own sources of events — an inbound
|
|
570
873
|
# `tells` names its own via `.emits`; an outbound `asks` has none
|
|
571
874
|
# (`PortOperationBuilder#refuse_wrong_words!` refuses one that
|
|
572
875
|
# tries) and names its two real endings `.answers`/`.refuses`
|
|
573
876
|
# instead. Pulled out of `emitted_events` above purely to keep that
|
|
574
877
|
# method's own branching low enough to read at a glance — every
|
|
575
878
|
# port, aggregate-owned or chapter-level, asks this the same way.
|
|
879
|
+
#
|
|
880
|
+
# @param ports [Array<Bluebook::DomainPort>] the ports to enumerate
|
|
881
|
+
# @return [Array<String, nil>] every event name each operation emits,
|
|
882
|
+
# answers, or refuses; `nil` entries included (compacted by the
|
|
883
|
+
# caller)
|
|
576
884
|
def port_operation_events(ports)
|
|
577
885
|
ports.flat_map { |port| port.operations.flat_map { |op| [*op.emits, op.answers, op.refuses] } }
|
|
578
886
|
end
|
|
@@ -581,6 +889,12 @@ module Hecks
|
|
|
581
889
|
# carries and fuzzing/sequence_generator/catalog.rb builds
|
|
582
890
|
# independently for the same reason: a saga dispatch and a fuzzer
|
|
583
891
|
# step both have to name a verb the same way the door does.
|
|
892
|
+
#
|
|
893
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter to
|
|
894
|
+
# enumerate
|
|
895
|
+
# @return [Array<String>] every command's own fully-qualified verb,
|
|
896
|
+
# `"Domain::Aggregate.Command"` or
|
|
897
|
+
# `"Domain::Aggregate.Entity.Command"`
|
|
584
898
|
def verbs_of(bluebook)
|
|
585
899
|
bluebook.aggregates.flat_map do |aggregate|
|
|
586
900
|
verbs = aggregate.commands.map { |command| "#{bluebook.name}::#{aggregate.hecks_name}.#{command.hecks_name}" }
|
|
@@ -592,15 +906,20 @@ module Hecks
|
|
|
592
906
|
end
|
|
593
907
|
end
|
|
594
908
|
|
|
595
|
-
#
|
|
909
|
+
# An aggregate-owned port operation is a triggerable verb too —
|
|
596
910
|
# `ReactionInvocation#resolve_target`'s own port-operation branch
|
|
597
911
|
# resolves one by the exact same two-segment tail shape ("Aggregate::
|
|
598
912
|
# Port.Operation", the aggregate then the port then the operation,
|
|
599
913
|
# dot-joined past the domain) an entity command uses, checked first,
|
|
600
914
|
# same order `Dispatcher#dispatch` already resolves a live verb in.
|
|
601
|
-
# Only an aggregate's
|
|
915
|
+
# Only an aggregate's own ports (`aggregate.ports`) are in scope here
|
|
602
916
|
# — a policy's `trigger` always names one aggregate, never a chapter-
|
|
603
917
|
# level port with no owner to address through.
|
|
918
|
+
#
|
|
919
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter to
|
|
920
|
+
# enumerate
|
|
921
|
+
# @return [Array<String>] every aggregate-owned port operation's own
|
|
922
|
+
# fully-qualified verb, `"Domain::Aggregate.Port.Operation"`
|
|
604
923
|
def port_verbs_of(bluebook)
|
|
605
924
|
bluebook.aggregates.flat_map do |aggregate|
|
|
606
925
|
aggregate.ports.flat_map do |port|
|
|
@@ -611,16 +930,27 @@ module Hecks
|
|
|
611
930
|
end
|
|
612
931
|
end
|
|
613
932
|
|
|
614
|
-
#
|
|
933
|
+
# Every triggerable verb, as a triple — `verbs_of` (ordinary/entity
|
|
615
934
|
# commands) plus `port_verbs_of` (port operations), each parsed
|
|
616
935
|
# through `Naming.split_verb` so a caller never has to compare two
|
|
617
936
|
# spellings of the same verb as strings (see `policy_findings`'s own
|
|
618
937
|
# `unknown_trigger` check for why that comparison has to happen this
|
|
619
938
|
# way, not as `include?` on a raw string).
|
|
939
|
+
#
|
|
940
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter to
|
|
941
|
+
# enumerate
|
|
942
|
+
# @return [Set<Array(String, String, String)>] every triggerable
|
|
943
|
+
# verb, parsed as `Naming.split_verb` `[domain, aggregate,
|
|
944
|
+
# command]` triples
|
|
620
945
|
def triggerable_verbs(bluebook)
|
|
621
946
|
(verbs_of(bluebook) + port_verbs_of(bluebook)).to_set { |verb| Naming.split_verb(verb) }
|
|
622
947
|
end
|
|
623
948
|
|
|
949
|
+
# Strips a domain qualifier off an event name.
|
|
950
|
+
#
|
|
951
|
+
# @param event [String, Symbol] an event name, qualified
|
|
952
|
+
# (`"Domain::Event"`) or bare
|
|
953
|
+
# @return [String] `event`'s own bare name, past the last `::`
|
|
624
954
|
def bare(event) = event.to_s.split("::").last
|
|
625
955
|
end
|
|
626
956
|
end
|