hecks 2.5.1 → 2.7.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/google_authentication.rb +1 -1
- data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
- data/lib/hecks/adapters/driven/prism.rb +1 -1
- data/lib/hecks/bench/cli.rb +74 -0
- data/lib/hecks/bench/environment.rb +100 -0
- data/lib/hecks/bench/postgres_probe.rb +41 -0
- data/lib/hecks/bench/report.rb +116 -0
- data/lib/hecks/bench/ruby_runner.rb +151 -0
- data/lib/hecks/bench/run.rb +55 -0
- data/lib/hecks/bench/rust_runner.rb +165 -0
- data/lib/hecks/bench/stats.rb +75 -0
- data/lib/hecks/bench/suite.rb +172 -0
- data/lib/hecks/bench/workload.rb +137 -0
- data/lib/hecks/bench.rb +28 -0
- data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
- data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
- data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
- data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
- data/lib/hecks/bluebook/hexagon.rb +15 -7
- data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
- data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
- data/lib/hecks/bluebook/model_check.rb +21 -2
- data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
- data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
- data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
- data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
- data/lib/hecks/embryonaut_bluebook.rb +29 -3
- data/lib/hecks/forms/value_object_shape.rb +1 -1
- data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
- data/lib/hecks/fuzzing/era_boundary.rb +1 -1
- data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
- data/lib/hecks/language/world/world.bluebook +32 -2
- data/lib/hecks/mcp_stdio_guard.rb +160 -0
- data/lib/hecks/ports/key_vault.rb +1 -1
- data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
- data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
- data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
- data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
- data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
- data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
- data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
- data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
- data/lib/hecks/ports/persistence.rb +4 -0
- data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
- data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
- data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
- data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
- data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
- data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
- data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
- data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
- data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
- data/lib/hecks/projections/deploy/fargate.rb +187 -138
- data/lib/hecks/projections/deploy/lambda.rb +5 -5
- data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
- data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
- data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
- data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
- data/lib/hecks/projections/deploy/preview/script.rb +40 -0
- data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
- data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
- data/lib/hecks/projections/deploy/preview/template.rb +247 -0
- data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
- data/lib/hecks/projections/deploy/preview.rb +103 -0
- data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
- data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
- data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
- data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
- data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
- data/lib/hecks/projections/deploy/scripts.rb +171 -0
- data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
- data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
- data/lib/hecks/projections/deploy/smoke.rb +159 -0
- data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
- data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
- data/lib/hecks/projections/deploy/template_diff.rb +173 -0
- data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
- data/lib/hecks/projections.rb +2 -0
- data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
- data/lib/hecks/release/gem_pin.rb +112 -0
- data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
- data/lib/hecks/runtime/loader.rb +26 -2
- data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
- data/lib/hecks/runtime/registry/verification.rb +4 -2
- data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
- data/lib/hecks/runtime/registry.rb +14 -9
- data/lib/hecks/runtime/tenant_check.rb +1 -1
- data/lib/hecks/vendoring/git_environment.rb +35 -0
- data/lib/hecks/vendoring/git_source.rb +102 -0
- data/lib/hecks/vendoring.rb +102 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks.rb +3 -1
- metadata +59 -2
|
@@ -11,8 +11,17 @@ module Hecks
|
|
|
11
11
|
# Starts with no settings recorded.
|
|
12
12
|
def initialize = @values = {}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
# A call with a block records the block's own settings as a nested Hash, so a bind can
|
|
15
|
+
# group settings (`preview do ... end`); a call without one records its argument.
|
|
16
|
+
def method_missing(key, *args, &block)
|
|
17
|
+
@values[key.to_sym] =
|
|
18
|
+
if block
|
|
19
|
+
nested = SettingsCollector.new
|
|
20
|
+
nested.instance_eval(&block)
|
|
21
|
+
nested.to_h
|
|
22
|
+
else
|
|
23
|
+
args.size == 1 ? args.first : args
|
|
24
|
+
end
|
|
16
25
|
end
|
|
17
26
|
|
|
18
27
|
def respond_to_missing?(_name, _include_private = false) = true
|
|
@@ -96,6 +105,35 @@ module Hecks
|
|
|
96
105
|
@latest = required(value, "latest version")
|
|
97
106
|
end
|
|
98
107
|
|
|
108
|
+
# Names the connection every chapter's database-taking persistence adapter uses
|
|
109
|
+
# unless that chapter's own world settings name one.
|
|
110
|
+
#
|
|
111
|
+
# Answers the `default_database` word through the table's `calls:` column, the
|
|
112
|
+
# same way `realm_impl` does. A chapter's own
|
|
113
|
+
# `persisted_by("Adapter") { database ... }` still wins for that adapter.
|
|
114
|
+
#
|
|
115
|
+
# @param value [String] the connection, such as a database URL; blankness is
|
|
116
|
+
# judged by the world language at `build`, not here
|
|
117
|
+
# @return [String] the connection as stored
|
|
118
|
+
def default_database_impl(value)
|
|
119
|
+
@default_database = required(value, "default database")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Names the persistence adapter every aggregate binds to unless its own chapter's
|
|
123
|
+
# hecksagon binds it.
|
|
124
|
+
#
|
|
125
|
+
# Answers the `default_adapter` word through the table's `calls:` column, the same
|
|
126
|
+
# way `realm_impl` does. Sits between an explicit bind and the framework's
|
|
127
|
+
# in-memory fallback; whether the adapter really is a persistence adapter is
|
|
128
|
+
# judged at boot, where adapters are known.
|
|
129
|
+
#
|
|
130
|
+
# @param value [String, Symbol] the adapter's declared name, such as `"PostgresEra"`;
|
|
131
|
+
# blankness is judged by the world language at `build`, not here
|
|
132
|
+
# @return [String] the adapter name as stored
|
|
133
|
+
def default_adapter_impl(value)
|
|
134
|
+
@default_adapter = required(value, "default adapter")
|
|
135
|
+
end
|
|
136
|
+
|
|
99
137
|
def method_missing(verb, *args, **kwargs, &block)
|
|
100
138
|
result = word_gate_dispatch(verb, args, kwargs, block)
|
|
101
139
|
return result unless result.equal?(WordGate::NOT_ADMITTED)
|
|
@@ -134,7 +172,8 @@ module Hecks
|
|
|
134
172
|
# as a blank `realm`
|
|
135
173
|
def build
|
|
136
174
|
MetaValidator.call_world(
|
|
137
|
-
World.new(domain: @domain, realm: @realm, latest: @latest, settings: @settings
|
|
175
|
+
World.new(domain: @domain, realm: @realm, latest: @latest, settings: @settings,
|
|
176
|
+
default_database: @default_database, default_adapter: @default_adapter)
|
|
138
177
|
)
|
|
139
178
|
end
|
|
140
179
|
|
|
@@ -97,9 +97,10 @@ module Hecks
|
|
|
97
97
|
include Hecks::IR
|
|
98
98
|
include Behaviour::World
|
|
99
99
|
|
|
100
|
-
emits_ir(domain: :domain, realm: :realm, latest: :latest, settings: :settings
|
|
100
|
+
emits_ir(domain: :domain, realm: :realm, latest: :latest, settings: :settings,
|
|
101
|
+
default_database: :default_database, default_adapter: :default_adapter)
|
|
101
102
|
|
|
102
|
-
attr_reader :domain, :realm, :latest, :settings
|
|
103
|
+
attr_reader :domain, :realm, :latest, :settings, :default_database, :default_adapter
|
|
103
104
|
|
|
104
105
|
# @param domain [String, Symbol] the domain this world configures
|
|
105
106
|
# @param realm [String, Symbol, nil] the declared realm/version marker, or `nil`
|
|
@@ -108,11 +109,18 @@ module Hecks
|
|
|
108
109
|
# if none is declared
|
|
109
110
|
# @param settings [Hash] the declared adapter bind settings, keyed by verb and,
|
|
110
111
|
# for a qualified entry, `"verb:adapter"`
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
112
|
+
# @param default_database [String, nil] the connection every chapter's
|
|
113
|
+
# database-taking persistence adapter uses unless its own settings name one,
|
|
114
|
+
# or `nil` if none is declared
|
|
115
|
+
# @param default_adapter [String, nil] the persistence adapter every aggregate
|
|
116
|
+
# binds to unless its chapter's hecksagon binds it, or `nil` if none is declared
|
|
117
|
+
def initialize(domain:, realm: nil, latest: nil, settings: {}, default_database: nil, default_adapter: nil)
|
|
118
|
+
@domain = domain.to_s
|
|
119
|
+
@realm = realm&.to_s
|
|
120
|
+
@latest = latest&.to_s
|
|
121
|
+
@settings = settings
|
|
122
|
+
@default_database = default_database&.to_s
|
|
123
|
+
@default_adapter = default_adapter&.to_s
|
|
116
124
|
end
|
|
117
125
|
end
|
|
118
126
|
end
|
|
@@ -40,7 +40,9 @@ module Hecks
|
|
|
40
40
|
def judge!
|
|
41
41
|
domain = @world.domain
|
|
42
42
|
send_to("World::World.Declare", domain, domain: v(domain),
|
|
43
|
-
realm: v(@world.realm), latest: v(@world.latest)
|
|
43
|
+
realm: v(@world.realm), latest: v(@world.latest),
|
|
44
|
+
default_database: v(@world.default_database),
|
|
45
|
+
default_adapter: v(@world.default_adapter))
|
|
44
46
|
|
|
45
47
|
Hash(@world.settings).each do |verb, values|
|
|
46
48
|
# the DSL records each binding twice — once by verb, once by
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
module Hecks
|
|
2
|
+
module Bluebook
|
|
3
|
+
module ModelCheck
|
|
4
|
+
# The opt-in `client` profile: refuses the bluebook constructs this
|
|
5
|
+
# repository already knows produce a silently wrong answer, so a client
|
|
6
|
+
# domain cannot reach one without being told.
|
|
7
|
+
#
|
|
8
|
+
# ## What it is not
|
|
9
|
+
#
|
|
10
|
+
# It fixes nothing. Each rule below names a bug that is still open and
|
|
11
|
+
# stops a domain reaching it unnoticed; when the bug is fixed the rule
|
|
12
|
+
# goes with it. `spec/model_check_client_profile_spec.rb` holds a probe
|
|
13
|
+
# per rule that runs the buggy code and fails the moment the bug stops
|
|
14
|
+
# reproducing, naming the rule to delete.
|
|
15
|
+
#
|
|
16
|
+
# ## The rules
|
|
17
|
+
#
|
|
18
|
+
# - `:client_group_by_row_drop` — a `group_by` whose fields do not include
|
|
19
|
+
# the grouped aggregate's whole identity. Rows sharing a key path are
|
|
20
|
+
# collapsed to the first, on every adapter (docs/decisions/0061).
|
|
21
|
+
# - `:client_native_read_model` — a read model the SQLite projection
|
|
22
|
+
# answers natively, where every other path answers it in process
|
|
23
|
+
# (docs/1.0-readiness.md, "Known gaps at 1.0", item 2).
|
|
24
|
+
# - `:client_dotted_compute_source` — an era translation `compute` whose
|
|
25
|
+
# source is a dotted path, which never fires
|
|
26
|
+
# (`Translation::RuleCompiler.compile_compute`).
|
|
27
|
+
module ClientProfile
|
|
28
|
+
# Adapters whose Ruby class implements `query_read_model`, which is what
|
|
29
|
+
# makes `ReadModelInterpreter#project` take the native path. The spec
|
|
30
|
+
# checks this list against the adapter classes themselves.
|
|
31
|
+
NATIVE_READ_MODEL_ADAPTERS = %w[SqliteProjection].freeze
|
|
32
|
+
|
|
33
|
+
GROUP_BY_TRACKER = "docs/decisions/0061-query-dsl-aggregation-count-sum-group-by.md".freeze
|
|
34
|
+
NATIVE_READ_MODEL_TRACKER = "docs/1.0-readiness.md (Known gaps at 1.0, item 2)".freeze
|
|
35
|
+
DOTTED_COMPUTE_TRACKER = "Translation::RuleCompiler.compile_compute, and the pending example " \
|
|
36
|
+
"\"applies the SQL of a compute whose source is a dotted member\" in " \
|
|
37
|
+
"spec/adapters/driven/postgres_era/migration_data_safety_spec.rb".freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# Runs every client-profile rule over one chapter and its translations.
|
|
42
|
+
#
|
|
43
|
+
# @param bluebook [Bluebook::Chapter] the assembled chapter to check
|
|
44
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] the chapter's sibling wiring file; without
|
|
45
|
+
# one no aggregate is projected, so the native-read-model rule has nothing to find
|
|
46
|
+
# @param translations [Array<Bluebook::Translation>] the data translations declared for
|
|
47
|
+
# this chapter's domain
|
|
48
|
+
# @return [Array<ModelCheck::Finding>] one error-severity finding per construct refused
|
|
49
|
+
def call(bluebook, hecksagon: nil, translations: [])
|
|
50
|
+
bluebook.read_models.flat_map do |model|
|
|
51
|
+
group_by_findings(bluebook, model) + native_read_model_findings(model, hecksagon)
|
|
52
|
+
end + dotted_compute_findings(translations)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Refuses a `group_by` that cannot prove each of its key paths names one row.
|
|
56
|
+
#
|
|
57
|
+
# The interpreter's `nest` keeps `stripped.first` per key path, whichever adapter
|
|
58
|
+
# supplied the rows, because a `group_by` model never reaches the native path. Grouping
|
|
59
|
+
# by the aggregate's whole identity is the one shape that cannot collide.
|
|
60
|
+
#
|
|
61
|
+
# @param bluebook [Bluebook::Chapter] the chapter the read model is declared in
|
|
62
|
+
# @param model [Bluebook::ReadModel] the read model to inspect
|
|
63
|
+
# @return [Array<ModelCheck::Finding>] one finding, or `[]` when `model` declares no
|
|
64
|
+
# `group_by` or groups by its aggregate's whole identity
|
|
65
|
+
def group_by_findings(bluebook, model)
|
|
66
|
+
return [] if model.group_by.empty? || groups_by_identity?(bluebook, model)
|
|
67
|
+
|
|
68
|
+
[finding(:client_group_by_row_drop, model.name,
|
|
69
|
+
"group_by #{model.group_by_fields.map(&:inspect).join(', ')} does not cover the grouped " \
|
|
70
|
+
"aggregate's whole identity, so rows sharing a key path are silently reduced to the " \
|
|
71
|
+
"first. Tracked in #{GROUP_BY_TRACKER}.")]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Refuses a read model that the SQLite projection would answer natively.
|
|
75
|
+
#
|
|
76
|
+
# Mirrors the fork in `ReadModelInterpreter#project`: a rooted model with no `group_by`,
|
|
77
|
+
# `count` or `median` reads through `read_repository`, which is a projection repository
|
|
78
|
+
# only for an aggregate with a `projected_by` bind. Whether that projection is current
|
|
79
|
+
# is only known at run time (`Registry#projection_current?`), so a model eligible here
|
|
80
|
+
# can also silently run in process.
|
|
81
|
+
#
|
|
82
|
+
# @param model [Bluebook::ReadModel] the read model to inspect
|
|
83
|
+
# @param hecksagon [Bluebook::Hecksagon, nil] the wiring holding the `projected_by` binds
|
|
84
|
+
# @return [Array<ModelCheck::Finding>] one finding, or `[]` when the model cannot be
|
|
85
|
+
# pushed down
|
|
86
|
+
def native_read_model_findings(model, hecksagon)
|
|
87
|
+
return [] unless hecksagon && native_eligible?(model)
|
|
88
|
+
|
|
89
|
+
bind = hecksagon.binds_for(model.reference_target, Ports::Projection::VERB).first
|
|
90
|
+
return [] unless bind && NATIVE_READ_MODEL_ADAPTERS.include?(bind.adapter.to_s)
|
|
91
|
+
|
|
92
|
+
[finding(:client_native_read_model, model.name,
|
|
93
|
+
"#{model.reference_target} is projected_by #{bind.adapter.inspect}, so this read model " \
|
|
94
|
+
"is answered by SQL when the projection is current and by the in-process loop when it " \
|
|
95
|
+
"is not, and nothing checks the two agree. Tracked in #{NATIVE_READ_MODEL_TRACKER}.")]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Refuses a translation `compute` whose source names a member of a value object.
|
|
99
|
+
#
|
|
100
|
+
# `compile_compute` guards the compute with `__s ? '<from>'`, a top-level key test, so a
|
|
101
|
+
# dotted source never matches, the mint succeeds and the record keeps its old value.
|
|
102
|
+
#
|
|
103
|
+
# @param translations [Array<Bluebook::Translation>] the translations to inspect
|
|
104
|
+
# @return [Array<ModelCheck::Finding>] one finding per dotted-source compute
|
|
105
|
+
def dotted_compute_findings(translations)
|
|
106
|
+
translations.flat_map do |translation|
|
|
107
|
+
translation.aggregates.flat_map do |aggregate|
|
|
108
|
+
aggregate.computes.select { |compute| compute.from.to_s.include?(".") }.map do |compute|
|
|
109
|
+
finding(:client_dotted_compute_source, "#{translation.domain}::#{aggregate.name}",
|
|
110
|
+
"compute #{compute.from.to_s.inspect} has a dotted source, which the compiled SQL " \
|
|
111
|
+
"never matches, so the mint succeeds and leaves the old value in place. " \
|
|
112
|
+
"Tracked in #{DOTTED_COMPUTE_TRACKER}.")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Reports whether a model's shape sends it down the native path in `#project`.
|
|
119
|
+
#
|
|
120
|
+
# @param model [Bluebook::ReadModel] the read model to inspect
|
|
121
|
+
# @return [Boolean] true for a rooted model declaring no `group_by`, `count` or `median`
|
|
122
|
+
def native_eligible?(model)
|
|
123
|
+
!model.reference_target.nil? && model.group_by.empty? && !model.count? && !model.median_field
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Reports whether a `group_by` names every identity path of the aggregate it groups.
|
|
127
|
+
#
|
|
128
|
+
# @param bluebook [Bluebook::Chapter] the chapter the read model is declared in
|
|
129
|
+
# @param model [Bluebook::ReadModel] a read model that declares a `group_by`
|
|
130
|
+
# @return [Boolean] false when the many-side aggregate is not a top-level aggregate or
|
|
131
|
+
# declares no identity, since uniqueness cannot then be shown
|
|
132
|
+
def groups_by_identity?(bluebook, model)
|
|
133
|
+
target = model.aggregate_heads.find { |head| head[:many] }
|
|
134
|
+
aggregate = target && bluebook.aggregate(target[:aggregate])
|
|
135
|
+
return false unless aggregate
|
|
136
|
+
|
|
137
|
+
identity = Array(aggregate.identity_heads).map(&:to_sym)
|
|
138
|
+
!identity.empty? && (identity - model.group_by_fields.map(&:to_sym)).empty?
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Builds one error-severity finding of this profile.
|
|
142
|
+
#
|
|
143
|
+
# @param kind [Symbol] the finding's kind, one of the `:client_*` rules above
|
|
144
|
+
# @param subject [String] the read model or `Domain::Aggregate` the finding is about
|
|
145
|
+
# @param message [String] what was found, why it is refused, and where it is tracked
|
|
146
|
+
# @return [ModelCheck::Finding] an `:error` finding
|
|
147
|
+
def finding(kind, subject, message)
|
|
148
|
+
ModelCheck::Finding.new(kind: kind, severity: :error, subject: subject, message: message)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "hecks/vocabulary"
|
|
2
|
+
require_relative "model_check/client_profile"
|
|
2
3
|
|
|
3
4
|
module Hecks
|
|
4
5
|
module Bluebook
|
|
@@ -100,6 +101,9 @@ module Hecks
|
|
|
100
101
|
# was needed or made.
|
|
101
102
|
}.freeze
|
|
102
103
|
|
|
104
|
+
# The profiles `call` accepts; `nil` is the default, unprofiled run.
|
|
105
|
+
PROFILES = %i[client].freeze
|
|
106
|
+
|
|
103
107
|
module_function
|
|
104
108
|
|
|
105
109
|
# Runs every static model check over `bluebook` and returns what it
|
|
@@ -137,9 +141,23 @@ module Hecks
|
|
|
137
141
|
# target, raising `rust_reserved_name` findings to error
|
|
138
142
|
# @param strict [Boolean] whether to raise every `rust_reserved_name`
|
|
139
143
|
# finding to error regardless of `rust_target`
|
|
144
|
+
# @param profile [Symbol, nil] `:client` adds `ClientProfile`'s error
|
|
145
|
+
# findings for constructs known to answer wrongly without refusing;
|
|
146
|
+
# `nil` (the default) adds nothing and leaves every other finding as
|
|
147
|
+
# it was
|
|
148
|
+
# @param translations [Array<Bluebook::Translation>] the data
|
|
149
|
+
# translations declared for this chapter's domain, read only by the
|
|
150
|
+
# `:client` profile — they live on the registry, not the chapter, so
|
|
151
|
+
# the caller supplies them the way it supplies `hecksagon`
|
|
140
152
|
# @return [Array<Finding>] every finding this bluebook triggers,
|
|
141
153
|
# across its lifecycles, sagas, policies, and Rust-reserved names
|
|
142
|
-
|
|
154
|
+
# @raise [ArgumentError] if `profile` is neither `nil` nor `:client`
|
|
155
|
+
def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false,
|
|
156
|
+
profile: nil, translations: [])
|
|
157
|
+
unless profile.nil? || PROFILES.include?(profile)
|
|
158
|
+
raise ArgumentError, "unknown profile #{profile.inspect} (known: #{PROFILES.inspect})"
|
|
159
|
+
end
|
|
160
|
+
|
|
143
161
|
findings = []
|
|
144
162
|
bluebook.aggregates.each do |aggregate|
|
|
145
163
|
findings.concat(lifecycle_findings(aggregate, aggregate))
|
|
@@ -152,6 +170,7 @@ module Hecks
|
|
|
152
170
|
findings.concat(rust_reserved_name_findings(domain_name: bluebook.name,
|
|
153
171
|
aggregate_names: bluebook.aggregates.map(&:hecks_name),
|
|
154
172
|
rust_target: rust_target, strict: strict))
|
|
173
|
+
findings.concat(ClientProfile.call(bluebook, hecksagon: hecksagon, translations: translations)) if profile == :client
|
|
155
174
|
findings
|
|
156
175
|
end
|
|
157
176
|
|
|
@@ -774,7 +793,7 @@ module Hecks
|
|
|
774
793
|
|
|
775
794
|
# **Typo detection, deliberately weaker** — `known_domains` can only
|
|
776
795
|
# ever be a monorepo-scoped heuristic: a real external hecks
|
|
777
|
-
# consumer's own domain (this repo's own embryonaut
|
|
796
|
+
# consumer's own domain (this repo's own embryonaut client-
|
|
778
797
|
# shaped case) lives in a genuinely separate repository this
|
|
779
798
|
# corpus scan can never see, so a target this check cannot find
|
|
780
799
|
# is "unknown to THIS corpus," never proof of a typo. A target
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module EmbryonautBluebook
|
|
5
|
+
# What a vendored package records about the release it came from:
|
|
6
|
+
# `vendor/embryonaut_bluebooks/<name>/bluebook.lock`.
|
|
7
|
+
#
|
|
8
|
+
# ## File format
|
|
9
|
+
#
|
|
10
|
+
# One `key: value` line each for `package`, `version`, `tag`, `commit`
|
|
11
|
+
# and `digest`, then one `shape: <Domain> <label>` line per domain:
|
|
12
|
+
#
|
|
13
|
+
# package: payments
|
|
14
|
+
# version: 1.2.0
|
|
15
|
+
# tag: payments-v1.2.0
|
|
16
|
+
# commit: <full commit id the tag points at>
|
|
17
|
+
# digest: <sha256 of the vendored *.bluebook files>
|
|
18
|
+
# shape: Payments d33c23
|
|
19
|
+
#
|
|
20
|
+
# The digest is the sha256 of the `<sha256> <name>` lines of every
|
|
21
|
+
# top-level `*.bluebook` file, sorted by name. The source repository's own
|
|
22
|
+
# `bin/bluebook_digest` prints the same number for the tagged release, which
|
|
23
|
+
# is how a vendored copy is proven to be exactly what it claims to be.
|
|
24
|
+
Lock = Struct.new(:package, :version, :tag, :commit, :digest, :shape, keyword_init: true) do
|
|
25
|
+
# Computes the content digest of a directory's `*.bluebook` files.
|
|
26
|
+
#
|
|
27
|
+
# @param dir [String] the directory to measure
|
|
28
|
+
# @return [String, nil] 64 lowercase hex characters, nil when the directory holds
|
|
29
|
+
# no bluebook file
|
|
30
|
+
def self.digest_of(dir)
|
|
31
|
+
names = Dir.children(dir).select { |name| name.end_with?(".bluebook") }.sort
|
|
32
|
+
return nil if names.empty?
|
|
33
|
+
|
|
34
|
+
lines = names.map { |name| "#{Digest::SHA256.file(File.join(dir, name)).hexdigest} #{name}\n" }
|
|
35
|
+
Digest::SHA256.hexdigest(lines.join)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Reads a lock file.
|
|
39
|
+
#
|
|
40
|
+
# @param path [String] the `bluebook.lock` path
|
|
41
|
+
# @return [Lock, nil] the parsed lock, nil when the file does not exist
|
|
42
|
+
def self.read(path)
|
|
43
|
+
return nil unless File.file?(path)
|
|
44
|
+
|
|
45
|
+
parse(File.read(path))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Parses a lock's text.
|
|
49
|
+
#
|
|
50
|
+
# @param text [String] the file's content
|
|
51
|
+
# @return [Lock] the lock, with `shape` as an Array of lines (empty when none)
|
|
52
|
+
def self.parse(text)
|
|
53
|
+
fields = { shape: [] }
|
|
54
|
+
text.each_line do |line|
|
|
55
|
+
key, _, value = line.chomp.partition(": ")
|
|
56
|
+
next if key.empty?
|
|
57
|
+
|
|
58
|
+
key == "shape" ? fields[:shape] << value : fields[key.to_sym] = value
|
|
59
|
+
end
|
|
60
|
+
new(**fields.slice(*members))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Renders the lock as the text written to `bluebook.lock`.
|
|
64
|
+
#
|
|
65
|
+
# @return [String] the lines described above, newline terminated
|
|
66
|
+
def to_s
|
|
67
|
+
header = %i[package version tag commit digest].map { |key| "#{key}: #{self[key]}\n" }
|
|
68
|
+
(header + Array(shape).map { |line| "shape: #{line}\n" }).join
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require_relative "../ports/persistence/plugins/era/storage_shape"
|
|
2
|
+
|
|
3
|
+
module Hecks
|
|
4
|
+
module EmbryonautBluebook
|
|
5
|
+
# The storage-shape labels of a directory of `*.bluebook` files.
|
|
6
|
+
#
|
|
7
|
+
# A label is what `PostgresEra` mints an era from, so a vendored package
|
|
8
|
+
# whose label changed is one whose next deploy mints a new era. This reads
|
|
9
|
+
# the files the way the era guard reads a held era's text: parse only, no
|
|
10
|
+
# boot, no wiring, no database, each file loaded into a scratch registry.
|
|
11
|
+
module Shape
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Measures every domain a directory's bluebook files declare.
|
|
15
|
+
#
|
|
16
|
+
# @param dir [String] a directory holding `*.bluebook` files
|
|
17
|
+
# @return [Array<String>] one `"<Domain> <label>"` line per domain, sorted by domain name
|
|
18
|
+
# @raise [Vendoring::Error] if the directory holds no bluebook file, or the files do
|
|
19
|
+
# not load, the refusal a boot would raise
|
|
20
|
+
def labels(dir)
|
|
21
|
+
files = Dir[File.join(dir, "*.bluebook")]
|
|
22
|
+
raise Vendoring::Error, "no *.bluebook files in #{dir}" if files.empty?
|
|
23
|
+
|
|
24
|
+
load_all(files).bluebooks.sort_by { |name, _| name.to_s }.map do |name, bluebook|
|
|
25
|
+
"#{name} #{Runtime::StorageShape.mint_label(bluebook)}"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Loads bluebook files into a registry of their own.
|
|
30
|
+
#
|
|
31
|
+
# @param files [Array<String>] absolute bluebook file paths, in load order
|
|
32
|
+
# @return [Runtime::Registry] the scratch registry holding what the files declared
|
|
33
|
+
# @raise [Vendoring::Error] if any file fails to load
|
|
34
|
+
def load_all(files)
|
|
35
|
+
scratch = Runtime::Registry.new
|
|
36
|
+
library = Ports::Loading.bootstrap
|
|
37
|
+
Hecks.with_registry(scratch) do
|
|
38
|
+
library.load_library
|
|
39
|
+
files.each { |file| Kernel.load(file) }
|
|
40
|
+
end
|
|
41
|
+
scratch
|
|
42
|
+
rescue StandardError, ScriptError => e
|
|
43
|
+
raise Vendoring::Error, "the bluebook files do not load: #{e.class}: #{e.message.lines.first.to_s.strip}"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
require_relative "../vendoring"
|
|
3
|
+
require_relative "lock"
|
|
4
|
+
require_relative "shape"
|
|
5
|
+
|
|
6
|
+
module Hecks
|
|
7
|
+
module EmbryonautBluebook
|
|
8
|
+
# Vendors one package of the bluebook registry into a consuming project.
|
|
9
|
+
#
|
|
10
|
+
# ## What it does
|
|
11
|
+
#
|
|
12
|
+
# Pins the package's top-level `bluebook/*.bluebook` files from one commit
|
|
13
|
+
# of a local source repository into
|
|
14
|
+
# `<root>/vendor/embryonaut_bluebooks/<name>/bluebook/`, where `load!`
|
|
15
|
+
# resolves them. Only bluebook files are taken: a `.hecksagon`, `.port` or
|
|
16
|
+
# `.adapter` is a wiring decision for whoever deploys, never part of the
|
|
17
|
+
# vendored package.
|
|
18
|
+
#
|
|
19
|
+
# ## Releases and bare commits
|
|
20
|
+
#
|
|
21
|
+
# The source repository tags each release `<name>-v<X.Y.Z>` on a commit
|
|
22
|
+
# whose `<name>/bluebook.yml` says that version. A release pin (`ref` nil for
|
|
23
|
+
# the newest tag, `"1.2.0"`, or the tag name) also writes `bluebook.lock`,
|
|
24
|
+
# which records version, tag, commit, content digest and storage shape, and
|
|
25
|
+
# holds two refusals, because a production project is bound to `PostgresEra`:
|
|
26
|
+
#
|
|
27
|
+
# - a version lower than the vendored one, unless `allow_downgrade`;
|
|
28
|
+
# - a storage-shape change that does not raise the version by at least a
|
|
29
|
+
# minor, so a new era shows in the version number and not only in a hash.
|
|
30
|
+
#
|
|
31
|
+
# Any other commit-ish pins that commit and writes only the marker, with no
|
|
32
|
+
# lock and no version checks. Either way nothing on disk changes when a
|
|
33
|
+
# pin is refused or its files do not load.
|
|
34
|
+
class Vendor
|
|
35
|
+
# A release version, `X.Y.Z`.
|
|
36
|
+
VERSION = /\A\d+\.\d+\.\d+\z/
|
|
37
|
+
|
|
38
|
+
# A package name is a directory name and a bluebook name stem.
|
|
39
|
+
NAME = /\A[a-z][a-z0-9_]*\z/
|
|
40
|
+
|
|
41
|
+
# What a vendoring changed.
|
|
42
|
+
#
|
|
43
|
+
# @!attribute [r] previous_version
|
|
44
|
+
# @return [String, nil] the version recorded before, nil when there was none
|
|
45
|
+
# @!attribute [r] previous_shape
|
|
46
|
+
# @return [Array<String>, nil] the shape lines measured before, nil when there was
|
|
47
|
+
# no earlier copy
|
|
48
|
+
Result = Struct.new(:package, :commit, :version, :tag, :digest, :shape,
|
|
49
|
+
:previous_version, :previous_shape, :dir, keyword_init: true) do
|
|
50
|
+
# Says whether this was a release pin, which writes a lock.
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean] true for a release, false for a bare commit
|
|
53
|
+
def release? = !tag.nil?
|
|
54
|
+
|
|
55
|
+
# Says whether the storage shape moved since the earlier copy.
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean] true when an earlier copy existed and its shape differs
|
|
58
|
+
def shape_changed? = !previous_shape.nil? && previous_shape != shape
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @param name [String, Symbol] the package's directory name, such as `"payments"`
|
|
62
|
+
# @param from [String] path of the local source repository
|
|
63
|
+
# @param ref [String, nil] `X.Y.Z`, a release tag name, any commit-ish, or nil for the
|
|
64
|
+
# newest release
|
|
65
|
+
# @param root [String] the consuming project's root
|
|
66
|
+
# @param allow_downgrade [Boolean] whether a release older than the vendored one is allowed
|
|
67
|
+
# @raise [Vendoring::Error] if the name is not a plain package name
|
|
68
|
+
def initialize(name, from:, root:, ref: nil, allow_downgrade: false)
|
|
69
|
+
@name = name.to_s
|
|
70
|
+
raise Vendoring::Error, "#{name.inspect} is not a package name" unless @name.match?(NAME)
|
|
71
|
+
|
|
72
|
+
@source = Vendoring::GitSource.new(from)
|
|
73
|
+
@ref = ref&.to_s
|
|
74
|
+
@root = root
|
|
75
|
+
@allow_downgrade = allow_downgrade
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Pins the package and reports what changed.
|
|
79
|
+
#
|
|
80
|
+
# @return [Result] the commit, version and shape now vendored
|
|
81
|
+
# @raise [Vendoring::Error] if the release or commit is not found, the tagged commit
|
|
82
|
+
# disagrees with its own version, the files do not load, or a refusal above applies
|
|
83
|
+
def call
|
|
84
|
+
tag, version = release
|
|
85
|
+
previous_version, previous_shape = existing
|
|
86
|
+
shape = nil
|
|
87
|
+
pin = Vendoring.pin(from: @source.path, ref: tag || @ref, subtree: "#{@name}/bluebook",
|
|
88
|
+
into: package_dir, glob: "*.bluebook") do |staged, commit|
|
|
89
|
+
shape = Shape.labels(staged)
|
|
90
|
+
next nil unless tag
|
|
91
|
+
|
|
92
|
+
refuse_unless_acceptable!(version, previous_version, previous_shape, shape)
|
|
93
|
+
{ "bluebook.lock" => lock(version, tag, commit, staged, shape).to_s }
|
|
94
|
+
end
|
|
95
|
+
build_result(pin, tag, version, shape, previous_version, previous_shape)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def package_dir = File.join(@root, "vendor", "embryonaut_bluebooks", @name)
|
|
101
|
+
|
|
102
|
+
# Resolves what `ref` asks for into a release tag and its version.
|
|
103
|
+
#
|
|
104
|
+
# @return [Array(String, String), nil] tag and version for a release, nil for a bare commit
|
|
105
|
+
def release
|
|
106
|
+
tag = release_tag
|
|
107
|
+
return nil unless tag
|
|
108
|
+
|
|
109
|
+
version = tag.delete_prefix("#{@name}-v")
|
|
110
|
+
tagged = @source.read(tag, "#{@name}/bluebook.yml")&.[](/^version: *(\S+)/, 1)
|
|
111
|
+
unless tagged == version
|
|
112
|
+
raise Vendoring::Error, "#{tag} points at a commit whose bluebook.yml says version #{tagged.inspect}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[tag, version]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def release_tag
|
|
119
|
+
return newest_release_tag if @ref.nil?
|
|
120
|
+
return existing_tag("#{@name}-v#{@ref}") if @ref.match?(VERSION)
|
|
121
|
+
|
|
122
|
+
@ref.match?(/\A#{Regexp.escape(@name)}-v\d+\.\d+\.\d+\z/) ? existing_tag(@ref) : nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def newest_release_tag
|
|
126
|
+
tags = release_tags
|
|
127
|
+
raise Vendoring::Error, "no #{@name}-v* release tag in #{@source.path}" if tags.empty?
|
|
128
|
+
|
|
129
|
+
"#{@name}-v#{tags.max_by { |version| Gem::Version.new(version) }}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def existing_tag(tag)
|
|
133
|
+
return tag if release_tags.include?(tag.delete_prefix("#{@name}-v"))
|
|
134
|
+
|
|
135
|
+
raise Vendoring::Error, "no release #{tag} in #{@source.path} (releases: #{release_tags.sort_by do |v|
|
|
136
|
+
Gem::Version.new(v)
|
|
137
|
+
end.join(' ')})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def release_tags
|
|
141
|
+
@source.tags("#{@name}-v*").map { |tag| tag.delete_prefix("#{@name}-v") }.grep(VERSION)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The version and storage shape of what is vendored now, measured from
|
|
145
|
+
# its files as they stand rather than read back, so a copy vendored before
|
|
146
|
+
# locks existed still gets a real "before".
|
|
147
|
+
def existing
|
|
148
|
+
version = Lock.read(File.join(package_dir, "bluebook.lock"))&.version
|
|
149
|
+
dir = File.join(package_dir, "bluebook")
|
|
150
|
+
shape = File.directory?(dir) ? Shape.labels(dir) : nil
|
|
151
|
+
[version, shape]
|
|
152
|
+
rescue Vendoring::Error
|
|
153
|
+
[version, nil]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def refuse_unless_acceptable!(version, previous_version, previous_shape, shape)
|
|
157
|
+
return unless previous_version
|
|
158
|
+
|
|
159
|
+
refuse_downgrade!(version, previous_version)
|
|
160
|
+
return if previous_shape.nil? || previous_shape == shape
|
|
161
|
+
return if minor_or_more?(previous_version, version)
|
|
162
|
+
|
|
163
|
+
raise Vendoring::Error,
|
|
164
|
+
"#{@name} #{previous_version} -> #{version} changes the storage shape but is only a patch bump " \
|
|
165
|
+
"(before: #{previous_shape.join(' ')}; after: #{shape.join(' ')}). Raise the package's version " \
|
|
166
|
+
"by at least a minor in the source repository and release again. Nothing was changed."
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def refuse_downgrade!(version, previous_version)
|
|
170
|
+
return if @allow_downgrade || Gem::Version.new(version) >= Gem::Version.new(previous_version)
|
|
171
|
+
|
|
172
|
+
raise Vendoring::Error, "#{@name} #{previous_version} is vendored; #{version} is older. " \
|
|
173
|
+
"Pass allow_downgrade to do it anyway."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def minor_or_more?(old, new)
|
|
177
|
+
old_major, old_minor = old.split(".").map(&:to_i)
|
|
178
|
+
new_major, new_minor = new.split(".").map(&:to_i)
|
|
179
|
+
new_major > old_major || (new_major == old_major && new_minor > old_minor)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def lock(version, tag, commit, staged, shape)
|
|
183
|
+
Lock.new(package: @name, version: version, tag: tag, commit: commit,
|
|
184
|
+
digest: Lock.digest_of(staged), shape: shape)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def build_result(pin, tag, version, shape, previous_version, previous_shape)
|
|
188
|
+
Result.new(package: @name, commit: pin.commit, version: version, tag: tag,
|
|
189
|
+
digest: Lock.digest_of(pin.dir), shape: shape, dir: pin.dir,
|
|
190
|
+
previous_version: previous_version, previous_shape: previous_shape)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|