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.
Files changed (95) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  3. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  4. data/lib/hecks/adapters/driven/prism.rb +1 -1
  5. data/lib/hecks/bench/cli.rb +74 -0
  6. data/lib/hecks/bench/environment.rb +100 -0
  7. data/lib/hecks/bench/postgres_probe.rb +41 -0
  8. data/lib/hecks/bench/report.rb +116 -0
  9. data/lib/hecks/bench/ruby_runner.rb +151 -0
  10. data/lib/hecks/bench/run.rb +55 -0
  11. data/lib/hecks/bench/rust_runner.rb +165 -0
  12. data/lib/hecks/bench/stats.rb +75 -0
  13. data/lib/hecks/bench/suite.rb +172 -0
  14. data/lib/hecks/bench/workload.rb +137 -0
  15. data/lib/hecks/bench.rb +28 -0
  16. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  17. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  18. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/world_builder.rb +42 -3
  20. data/lib/hecks/bluebook/hexagon.rb +15 -7
  21. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  22. data/lib/hecks/bluebook/model_check/client_profile.rb +153 -0
  23. data/lib/hecks/bluebook/model_check.rb +21 -2
  24. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  25. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  26. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  27. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  28. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  29. data/lib/hecks/forms/value_object_shape.rb +1 -1
  30. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  31. data/lib/hecks/fuzzing/era_boundary.rb +1 -1
  32. data/lib/hecks/fuzzing/isolated_boot.rb +136 -69
  33. data/lib/hecks/language/world/world.bluebook +32 -2
  34. data/lib/hecks/mcp_stdio_guard.rb +160 -0
  35. data/lib/hecks/ports/key_vault.rb +1 -1
  36. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  37. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +2 -1
  38. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  39. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  40. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +1 -1
  41. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  42. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  43. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  44. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  45. data/lib/hecks/ports/persistence.rb +4 -0
  46. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  47. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  48. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  49. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  50. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  51. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  52. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  53. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  54. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  55. data/lib/hecks/projections/deploy/fargate.rb +187 -138
  56. data/lib/hecks/projections/deploy/lambda.rb +5 -5
  57. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  58. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  59. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  60. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  61. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  62. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  63. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  64. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  65. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  66. data/lib/hecks/projections/deploy/preview.rb +103 -0
  67. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  68. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  69. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  70. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  71. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  72. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  73. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  74. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  75. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  76. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  77. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  78. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  79. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  80. data/lib/hecks/projections.rb +2 -0
  81. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  82. data/lib/hecks/release/gem_pin.rb +112 -0
  83. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  84. data/lib/hecks/runtime/loader.rb +26 -2
  85. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  86. data/lib/hecks/runtime/registry/verification.rb +4 -2
  87. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  88. data/lib/hecks/runtime/registry.rb +14 -9
  89. data/lib/hecks/runtime/tenant_check.rb +1 -1
  90. data/lib/hecks/vendoring/git_environment.rb +35 -0
  91. data/lib/hecks/vendoring/git_source.rb +102 -0
  92. data/lib/hecks/vendoring.rb +102 -0
  93. data/lib/hecks/version.rb +1 -1
  94. data/lib/hecks.rb +3 -1
  95. metadata +59 -2
@@ -11,8 +11,17 @@ module Hecks
11
11
  # Starts with no settings recorded.
12
12
  def initialize = @values = {}
13
13
 
14
- def method_missing(key, *args, &)
15
- @values[key.to_sym] = args.size == 1 ? args.first : args
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
- def initialize(domain:, realm: nil, latest: nil, settings: {})
112
- @domain = domain.to_s
113
- @realm = realm&.to_s
114
- @latest = latest&.to_s
115
- @settings = settings
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
- def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false)
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/lifeadelics-
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