hecks 2.6.0 → 2.8.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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -0,0 +1,59 @@
1
+ require_relative "../../hecks"
2
+ require_relative "../bluebook/smoke_test"
3
+
4
+ module Hecks
5
+ module CLI
6
+ # The command behind `bin/smoke_test` and `hecks smoke_test`: boots a real domain
7
+ # and dispatches one synthesized call per declared command and report
8
+ # (`Bluebook::SmokeTest`), printing every failure rather than the first.
9
+ #
10
+ # It needs a real `.hecksagon`: a bare `.bluebook` with nothing bound to an
11
+ # adapter has nothing to run commands against. A failure may be a domain bug or
12
+ # a value the synthesizer's naive choices (0, an empty list, "smoke-test") cannot
13
+ # satisfy; the message underneath says which. Smoke-testing proves nothing
14
+ # crashes, not that an answer is right.
15
+ module SmokeTest
16
+ module_function
17
+
18
+ # Smoke-tests the domain `argv` names, or every wired domain under
19
+ # `root/examples/`, and exits with the verdict.
20
+ #
21
+ # @param argv [Array<String>] an optional domain directory
22
+ # @param root [String] the directory whose `examples/*` are tested when `argv`
23
+ # names no domain
24
+ # @return [void]
25
+ # @raise [SystemExit] always: 0 when clean, 1 with failures or no domain found
26
+ def call(argv, root:)
27
+ dir_arg = argv.first
28
+ targets = dir_arg ? [dir_arg] : Dir.glob("#{root}/examples/*/").map { |d| d.chomp("/") }.select { |d| wired?(d) }
29
+ abort "no wired example domains found (none of examples/* has a .hecksagon)" if targets.empty?
30
+
31
+ ok = targets.reduce(true) { |all_ok, dir| clean?(dir) && all_ok }
32
+
33
+ puts
34
+ puts ok ? "Every declared command and report dispatched cleanly." : "SMOKE TEST FOUND PROBLEMS."
35
+ exit(ok ? 0 : 1)
36
+ end
37
+
38
+ # Says whether `dir`, or something nested under it, is a bindable domain.
39
+ #
40
+ # @param dir [String] a directory to check
41
+ # @return [Boolean] true if `dir` contains at least one `.hecksagon` file
42
+ def wired?(dir) = !Dir.glob(File.join(dir, "**/*.hecksagon")).empty?
43
+
44
+ # @api private
45
+ def clean?(dir)
46
+ puts "── #{File.basename(dir)}"
47
+ failures = Bluebook::SmokeTest.call(dir)
48
+ if failures.empty?
49
+ puts " clean — every declared command and report dispatched cleanly"
50
+ return true
51
+ end
52
+
53
+ puts " #{failures.size} failure(s)"
54
+ failures.each { |f| puts " #{f}" }
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,54 @@
1
+ require "json"
2
+ require_relative "../../hecks"
3
+
4
+ module Hecks
5
+ module CLI
6
+ # The command behind `bin/stores` and `hecks stores`: every aggregate's current
7
+ # records as JSON, the head rather than the journal (`bin/history` prints the
8
+ # full write history).
9
+ module Stores
10
+ module_function
11
+
12
+ # Boots the domain `argv` names and prints its stores as one JSON document.
13
+ #
14
+ # @param argv [Array<String>] the domain directory, first
15
+ # @param program [String] the name the error message calls this command by
16
+ # @return [void]
17
+ # @raise [IndexError] when `argv` is empty
18
+ # @raise [SystemExit] when the domain directory does not exist
19
+ def call(argv, program:)
20
+ domain = argv.fetch(0)
21
+ unless Dir.exist?(domain)
22
+ warn "#{program}: no such domain #{domain.inspect}"
23
+ exit 1
24
+ end
25
+
26
+ registry = Hecks.boot(domain).registry
27
+ stores = registry.bluebooks.each_with_object({}) do |(name, bluebook), all|
28
+ bluebook.aggregates.each do |aggregate|
29
+ all[aggregate.storage_name] = data_for(registry.repository(name, aggregate))
30
+ end
31
+ end
32
+
33
+ puts JSON.generate(stores)
34
+ end
35
+
36
+ # Reads one aggregate's current records as plain JSON-ready hashes, ordered by id.
37
+ #
38
+ # @param repository [Ports::Persistence::AppendOnly] the aggregate's repository
39
+ # @return [Array<Hash{String => Object}>] one hash per stored `Runtime::Instance`,
40
+ # sorted by id; `[]` when the aggregate has no records
41
+ def records(repository)
42
+ repository.all.sort_by(&:id).map { |instance| JSON.parse(JSON.generate(instance.to_h)) }
43
+ end
44
+
45
+ # Shapes one aggregate's dump for the top-level stores document.
46
+ #
47
+ # @param repository [Ports::Persistence::AppendOnly] the aggregate's repository
48
+ # @return [Hash{String => Object}] `"authoritative"` mapped to `records(repository)`
49
+ def data_for(repository)
50
+ { "authoritative" => records(repository) }
51
+ end
52
+ end
53
+ end
54
+ end
data/lib/hecks/cli.rb ADDED
@@ -0,0 +1,166 @@
1
+ module Hecks
2
+ # The `hecks` command an installed gem puts on the path: one router over the
3
+ # domain-operator tools, each of which also runs from a checkout as `bin/<name>`.
4
+ #
5
+ # ## A router, not a second copy
6
+ #
7
+ # Every subcommand's logic lives in `lib/hecks/cli/`, and the matching `bin/`
8
+ # script is a thin wrapper over the same entry point, so `hecks run` and
9
+ # `bin/run` cannot drift apart. The domain-scoped verbs go through
10
+ # `Facade::CliRunner`, the same way `bin/run` does.
11
+ #
12
+ # ## What a checkout has and an install does not
13
+ #
14
+ # The `bin/` wrappers pass the repository root, which is where they look for
15
+ # `examples/`, `rust/` and `docs/generated/`. Here the root is the working
16
+ # directory, so a command that lists or writes files does it where the caller
17
+ # stands. `model_check` with no domain sweeps the repository's corpus, which an
18
+ # install does not carry, so here it needs at least one domain path.
19
+ #
20
+ # ## Loading
21
+ #
22
+ # Nothing here requires the framework. A subcommand loads it when it runs, and
23
+ # `mcp` checks its transport before anything else loads, as
24
+ # `McpStdioGuard.enforce_stdio!` requires.
25
+ #
26
+ # See `docs/decisions/0066-the-gem-ships-a-hecks-executable-and-dev-tooling-stays-in-the-repo.md`.
27
+ module CLI
28
+ # One subcommand's help: a one-line summary and its usage line.
29
+ Command = Struct.new(:summary, :usage)
30
+
31
+ # Every subcommand, in the order the help lists them.
32
+ COMMANDS = {
33
+ "run" => Command.new(
34
+ "Dispatch a verb, or run a JSON step list, against a domain.",
35
+ "hecks run [domain] <verb [name=value …] | script.json | - | '{\"steps\":[…]}'>"
36
+ ),
37
+ "docs" => Command.new(
38
+ "Print a domain's usage document, projected from its bluebook.",
39
+ "hecks docs [domain-path] [aggregate]"
40
+ ),
41
+ "narrate" => Command.new(
42
+ "Print a domain read back in English, projected from its bluebook.",
43
+ "hecks narrate [domain-path] [aggregate]"
44
+ ),
45
+ "ir" => Command.new(
46
+ "Print a booted domain's IR as JSON.",
47
+ "hecks ir <domain> [--translations] | hecks ir --meta"
48
+ ),
49
+ "stores" => Command.new(
50
+ "Print every aggregate's current records as JSON.",
51
+ "hecks stores <domain>"
52
+ ),
53
+ "model_check" => Command.new(
54
+ "Statically check a domain's IR for dead states and unreachable steps.",
55
+ "hecks model_check [--strict] [--profile client] <domain> [<domain> …]"
56
+ ),
57
+ "smoke_test" => Command.new(
58
+ "Boot a domain and dispatch every declared command and report once.",
59
+ "hecks smoke_test [domain]"
60
+ ),
61
+ "project_diagrams" => Command.new(
62
+ "Write a domain's Mermaid diagrams under ./docs/generated/diagrams/.",
63
+ "hecks project_diagrams <domain-path> <ChapterName>"
64
+ ),
65
+ "project_cli" => Command.new(
66
+ "Write a command-line launcher beside each domain, named after its bluebook.",
67
+ "hecks project_cli [domain-path …]"
68
+ ),
69
+ "mcp" => Command.new(
70
+ "Serve the MCP door over stdio (no authentication; stdio only).",
71
+ "hecks mcp [--stdio]"
72
+ )
73
+ }.freeze
74
+
75
+ HELP_FLAGS = ["--help", "-h"].freeze
76
+
77
+ # Exit status for a missing or unknown subcommand.
78
+ USAGE_STATUS = 2
79
+
80
+ module_function
81
+
82
+ # Routes `argv` to its subcommand and runs it.
83
+ #
84
+ # A subcommand followed by only `--help` or `-h` prints that subcommand's usage
85
+ # without running it; any other arguments pass through untouched, so
86
+ # `hecks run order.place --help` still reaches the verb's own help.
87
+ #
88
+ # @param argv [Array<String>] the command line, subcommand first
89
+ # @param out [#puts] where help goes
90
+ # @param err [#puts] where usage errors go
91
+ # @return [Integer] the process exit status for a subcommand that returns; a
92
+ # subcommand that fails exits the process itself
93
+ def start(argv, out: $stdout, err: $stderr)
94
+ name, *rest = argv
95
+ if name.nil? || !COMMANDS.key?(name)
96
+ asked = HELP_FLAGS.include?(name) || name == "help"
97
+ err.puts "hecks: unknown command #{name.inspect}" unless asked || name.nil?
98
+ overview(asked ? out : err)
99
+ return asked ? 0 : USAGE_STATUS
100
+ end
101
+
102
+ if rest.length == 1 && HELP_FLAGS.include?(rest.first)
103
+ help(COMMANDS.fetch(name), out)
104
+ else
105
+ dispatch(name, rest)
106
+ end
107
+ 0
108
+ end
109
+
110
+ # Runs one known subcommand with the working directory as its root.
111
+ #
112
+ # @param name [String] a key of `COMMANDS`
113
+ # @param argv [Array<String>] the arguments after the subcommand
114
+ # @return [void]
115
+ # @raise [SystemExit] when the subcommand exits the process itself
116
+ def dispatch(name, argv)
117
+ program = "hecks #{name}"
118
+ case name
119
+ when "run"
120
+ require_relative "cli/run"
121
+ Run.call(argv, program: program)
122
+ when "docs", "narrate"
123
+ require_relative "cli/document"
124
+ Document.call(argv, projection: name.to_sym, program: program, root: Dir.pwd)
125
+ when "ir"
126
+ require_relative "cli/ir"
127
+ Ir.call(argv, program: program)
128
+ when "stores"
129
+ require_relative "cli/stores"
130
+ Stores.call(argv, program: program)
131
+ when "model_check"
132
+ require_relative "cli/model_check"
133
+ ModelCheck.call(argv, program: program)
134
+ when "smoke_test"
135
+ require_relative "cli/smoke_test"
136
+ SmokeTest.call(argv, root: Dir.pwd)
137
+ when "project_diagrams"
138
+ require_relative "cli/project_diagrams"
139
+ ProjectDiagrams.call(argv, program: program, root: Dir.pwd)
140
+ when "project_cli"
141
+ require_relative "cli/project_cli"
142
+ ProjectCli.call(argv, program: program, root: Dir.pwd, remove_stale_bin: false)
143
+ when "mcp"
144
+ require_relative "cli/mcp"
145
+ Mcp.call(argv)
146
+ end
147
+ end
148
+
149
+ # @api private
150
+ def overview(io)
151
+ io.puts "usage: hecks <command> [arguments]"
152
+ io.puts ""
153
+ width = COMMANDS.keys.map(&:length).max
154
+ COMMANDS.each { |name, command| io.puts " #{name.ljust(width)} #{command.summary}" }
155
+ io.puts ""
156
+ io.puts "Run `hecks <command> --help` for one command's usage."
157
+ end
158
+
159
+ # @api private
160
+ def help(command, io)
161
+ io.puts "usage: #{command.usage}"
162
+ io.puts ""
163
+ io.puts command.summary
164
+ end
165
+ end
166
+ end
@@ -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
@@ -0,0 +1,88 @@
1
+ require "optparse"
2
+ require_relative "vendor"
3
+
4
+ module Hecks
5
+ module EmbryonautBluebook
6
+ # The command line behind `bin/vendor_bluebook`.
7
+ #
8
+ # It lives in the gem, not in a script, so a consuming project reaches it
9
+ # through its own bundle without a copy of the script:
10
+ #
11
+ # bundle exec ruby -rhecks \
12
+ # -e 'exit Hecks::EmbryonautBluebook::VendorCli.run(ARGV)' payments@1.2.0
13
+ #
14
+ # ## Usage
15
+ #
16
+ # vendor_bluebook <package>[@<version-or-commit>] [--from <path>] [--root <path>]
17
+ #
18
+ # `--from` defaults to `$EMBRYONAUT_BLUEBOOKS_SRC` and `--root` to the current
19
+ # directory. `ALLOW_DOWNGRADE=1` in the environment permits a release older
20
+ # than the vendored one.
21
+ module VendorCli
22
+ USAGE = "usage: vendor_bluebook <package>[@<version-or-commit>] [--from PATH] [--root PATH]".freeze
23
+
24
+ # Runs the command and reports on the given streams.
25
+ #
26
+ # @param argv [Array<String>] the command-line arguments
27
+ # @param env [Hash{String => String}] the environment
28
+ # @param out [IO] where the report goes
29
+ # @param err [IO] where a refusal goes
30
+ # @param root [String] the default project root
31
+ # @return [Integer] the exit status, 0 when the package was vendored
32
+ def self.run(argv, env: ENV, out: $stdout, err: $stderr, root: Dir.pwd)
33
+ options = { from: env["EMBRYONAUT_BLUEBOOKS_SRC"], root: root }
34
+ spec = parse(argv, options)
35
+ return usage(err) unless spec && options[:from]
36
+
37
+ name, _, ref = spec.partition("@")
38
+ result = Vendor.new(name, from: options[:from], root: options[:root], ref: ref.empty? ? nil : ref,
39
+ allow_downgrade: env["ALLOW_DOWNGRADE"] == "1").call
40
+ report(result, options[:from], out)
41
+ 0
42
+ rescue Vendoring::Error, OptionParser::ParseError => e
43
+ err.puts(e.message)
44
+ 1
45
+ end
46
+
47
+ # Reads the options into a hash and picks out the package argument.
48
+ #
49
+ # @param argv [Array<String>] the command-line arguments
50
+ # @param options [Hash{Symbol => String}] receives `:from` and `:root` when given
51
+ # @return [String, nil] the one positional argument, nil unless exactly one was given
52
+ def self.parse(argv, options)
53
+ parser = OptionParser.new
54
+ parser.on("--from PATH") { |path| options[:from] = path }
55
+ parser.on("--root PATH") { |path| options[:root] = path }
56
+ rest = parser.parse(argv)
57
+ rest.first if rest.length == 1
58
+ end
59
+
60
+ def self.usage(err)
61
+ err.puts(USAGE)
62
+ 2
63
+ end
64
+
65
+ def self.report(result, from, out)
66
+ label = result.release? ? "#{result.version} (#{result.tag}, #{result.commit[0, 7]})" : result.commit[0, 12]
67
+ out.puts("Vendored embryonaut_bluebooks/#{result.package} #{label} from #{from} into #{result.dir}")
68
+ out.puts(shape_line(result))
69
+ end
70
+
71
+ # The shape label tells a re-vendor that only moved the pin apart from one
72
+ # that will mint a new era on the next deploy.
73
+ def self.shape_line(result)
74
+ now = result.shape.join(" ")
75
+ if result.previous_shape.nil?
76
+ "Shape: #{now} (no earlier vendored copy to compare against)"
77
+ elsif result.shape_changed?
78
+ "SHAPE CHANGED: the next deploy mints a new era; add its translation edge first.\n " \
79
+ "before: #{result.previous_shape.join(' ')}\n after: #{now}"
80
+ else
81
+ "Shape unchanged: #{now}. No new era on deploy."
82
+ end
83
+ end
84
+
85
+ private_class_method :parse, :usage, :report, :shape_line
86
+ end
87
+ end
88
+ end